Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 215 additions & 0 deletions app/HistoryTransport.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
//! A document's transport: a bar of glass at the bottom of its pane, its history as a line with a
//! knob where the document is, and undo and redo either side (`plans/RECORDING_PLAN.md`, "Undo is
//! a seek"). Undo and redo show it (`stir`); it stays while the pointer is on it and goes a few
//! seconds after (`history_transport.Shown`). A press or a drag on the line goes to the step under
//! it, one `historyGoTo` a frame at most, so a drag across a thousand steps costs a jump a frame.
//!
//! Over any owner's history (`Plugin.historyLine`, `historyGoTo`); an owner without them has no
//! transport. Nothing runs for a bar that is not showing: no history is asked for, no timer is
//! set. One per document pane, kept by the app beside the pane.
const HistoryTransport = @This();

const std = @import("std");
const dvui = @import("dvui");
const core = @import("core");
const sdk = @import("fizzy_sdk");
const icons = @import("icons");
const rules = @import("history_transport.zig");

shown: rules.Shown = .{},
repeat: rules.Repeat = .{},
/// The stop the pointer holds the knob at while it drags the line, until the drag ends. Only
/// while the bar holds the mouse: a drag whose capture went (the bar stopped drawing) is over.
drag: ?usize = null,

const bar_h: f32 = 40;
const button_d: f32 = 34;
const gap: f32 = 10;
const max_w: f32 = 460;

/// Undo or redo was pressed at `now_ns`, or something else asked for the bar: it shows.
pub fn stir(self: *HistoryTransport, now_ns: i128) void {
self.shown.stir(now_ns);
dvui.refresh(null, @src(), null);
}

/// A press of undo or redo (`forward`) at `now_ns` on this document: the bar shows, and the
/// press moves this many steps (`history_transport.Repeat`: more the longer the key is held).
pub fn press(self: *HistoryTransport, now_ns: i128, forward: bool) usize {
self.stir(now_ns);
return self.repeat.press(now_ns, forward);
}

/// Where the bar is over `area` (a document's pane, physical) while it shows, as far as the
/// pointer keeps it: what else drawn at the bottom of the pane (the activity feed) stays above.
/// Null when it is not showing at `now_ns`, or the pane is too narrow for it.
pub fn place(self: *const HistoryTransport, area: dvui.Rect.Physical, now_ns: i128) ?dvui.Rect.Physical {
if (self.drag == null and self.shown.openness(now_ns) <= 0) return null;
const whole = geometry(area.toNatural()) orelse return null;
return dvui.windowRectScale().rectToPhysical(whole.outsetAll(4));
}

/// The bar's rect over a pane's natural rect, or null when the pane is too narrow for it.
fn geometry(nat: dvui.Rect.Natural) ?dvui.Rect {
const w = @min(max_w, nat.w - 24);
if (w < 3 * button_d) return null;
const center: dvui.Point = .{ .x = nat.x + nat.w / 2, .y = nat.y + nat.h - 14 - bar_h / 2 };
return .{ .x = center.x - w / 2, .y = center.y - bar_h / 2, .w = w, .h = bar_h };
}

/// The id `historyGoTo` takes for stop `i`: 0 before the first step, else the step's.
fn stopId(line: sdk.Plugin.HistoryLine, i: usize) u64 {
return if (i == 0) 0 else line.entries[i - 1].id;
}

/// Draw the bar over `area` (the document's pane, physical) when it shows, and take its input.
/// `id_extra` keeps one pane's bar apart from another's.
pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physical, id_extra: usize) void {
const now = dvui.frameTimeNS();
// The pointer on it, or a drag in it, keeps it.
if (self.drag != null) self.shown.stir(now) else if (self.place(area, now)) |b| if (b.contains(dvui.currentWindow().mouse_pt)) self.shown.stir(now);
const p = self.shown.openness(now);
if (p <= 0) return;
const arena = dvui.currentWindow().arena();
const line = doc.owner.historyLine(doc, arena) orelse return;
const n = line.entries.len;

const wrs = dvui.windowRectScale();
const s = wrs.s;
const whole = geometry(area.toNatural()) orelse return;
const w = whole.w;
const center = whole.center();
const back: dvui.Rect = .{ .x = whole.x, .y = center.y - button_d / 2, .w = button_d, .h = button_d };
const fwd: dvui.Rect = .{ .x = whole.x + w - button_d, .y = back.y, .w = button_d, .h = button_d };
const track: dvui.Rect = .{ .x = back.x + button_d + gap, .y = whole.y, .w = w - 2 * (button_d + gap), .h = bar_h };
const hit = wrs.rectToPhysical(whole.outsetAll(4));

var float: dvui.FloatingWidget = undefined;
float.init(@src(), .{}, .{ .rect = whole.outsetAll(16), .id_extra = id_extra });
defer float.deinit();
const bounds = float.data().rect;
const fid = float.data().id;
// A drag lives as long as the bar holds the mouse: drawn again after it was not (a claim came
// and went), it holds nothing, and the knob is the document's again.
if (self.drag != null and !dvui.captured(fid)) self.drag = null;

const phys = [3]dvui.Rect.Physical{ wrs.rectToPhysical(back), wrs.rectToPhysical(track), wrs.rectToPhysical(fwd) };
const pad = 14;
const x0 = phys[1].x + pad * s;
const x1 = phys[1].x + phys[1].w - pad * s;
// Where a press goes to a step: the line's band, under the title. A press on the title or
// the capsule's ends is the bar's, and does nothing.
const line_hit: dvui.Rect.Physical = .{ .x = x0 - 8 * s, .y = phys[1].y + phys[1].h * 0.45, .w = x1 - x0 + 16 * s, .h = phys[1].h * 0.55 };
// Named for a tape or a test to aim at: the line from its first stop to its last, so a point
// a fraction along it is that fraction of the history.
const path = doc.owner.documentPath(doc);
core.anchor.markRect(fid, phys[0], true, "history.back:{s}", .{path});
core.anchor.markRect(fid, .{ .x = x0, .y = phys[1].y, .w = x1 - x0, .h = phys[1].h }, true, "history.line:{s}", .{path});
core.anchor.markRect(fid, phys[2], true, "history.forward:{s}", .{path});

// Input: the buttons step, the line goes where it is pressed and follows a drag.
var go: ?usize = null;
for (dvui.events()) |*e| {
if (!dvui.eventMatch(e, .{ .id = fid, .r = hit })) continue;
const me = switch (e.evt) {
.mouse => |m| m,
else => continue,
};
switch (me.action) {
.press => if (me.button.pointer()) {
e.handle(@src(), float.data());
if (phys[0].contains(me.p)) {
go = line.at -| 1;
} else if (phys[2].contains(me.p)) {
go = @min(n, line.at + 1);
} else if (line_hit.contains(me.p)) {
dvui.captureMouse(float.data(), e.num);
self.drag = rules.stopAt(me.p.x, x0, x1, n);
}
},
.motion => if (self.drag != null and dvui.captured(fid)) {
e.handle(@src(), float.data());
self.drag = rules.stopAt(me.p.x, x0, x1, n);
},
.release => if (me.button.pointer() and dvui.captured(fid)) {
e.handle(@src(), float.data());
dvui.captureMouse(null, e.num);
self.drag = null;
},
else => {},
}
}
if (self.drag) |d| go = d;
var at = line.at;
if (go) |to| if (to != at) {
if (doc.owner.historyGoTo(doc, stopId(line, to))) at = to;
dvui.refresh(null, @src(), fid);
};

const prev_alpha = dvui.alpha(p);
defer dvui.alphaSet(prev_alpha);
const pt = dvui.currentWindow().mouse_pt;
var bubbles: [3]core.dialogs.Bubble = undefined;
for (phys, &bubbles, 0..) |r, *b, i| b.* = .{
.rect = r,
.light = if (i != 1 and r.contains(pt) and (if (i == 0) at > 0 else at < n)) core.dialogs.bubble_hover else 0,
};
core.dialogs.glassBubbles(fid, &bubbles, s, .{ .merge = 14, .shadow = 1 });

const ink = dvui.themeGet().color(.window, .text);
button(bounds, back, "history_back", icons.tvg.lucide.undo, ink.opacity(if (at > 0) 0.85 else 0.3));
button(bounds, fwd, "history_forward", icons.tvg.lucide.redo, ink.opacity(if (at < n) 0.85 else 0.3));

// What the document is at, over the line: the step's title, and how far along.
const font = dvui.Font.theme(.body).larger(-2);
var buf: [96]u8 = undefined;
const title = if (at == 0) "Before any change" else line.entries[at - 1].title;
const label = std.fmt.bufPrint(&buf, "{s} {d} / {d}", .{ title, at, n }) catch title;
dvui.labelNoFmt(@src(), label, .{}, .{
.rect = .{ .x = track.x + pad - bounds.x, .y = track.y + 4 - bounds.y, .w = track.w - 2 * pad, .h = font.lineHeight() },
.padding = .{},
.margin = .{},
.font = font,
.color_text = .{ .color = ink.opacity(0.85) },
});
paintLine(.{ .x = x0, .y = phys[1].y + phys[1].h - 16 * s, .w = @max(1, x1 - x0), .h = 10 * s }, at, n, ink, s);

// Comes in, goes: a frame each while it fades, and one wake for when it next changes on its
// own (it starts to go, it is gone).
if (p < 1) dvui.refresh(null, @src(), fid);
if (self.shown.nextChange(now)) |t| dvui.timer(fid, @intCast(@min(@divTrunc(t - now, 1000) + 1, std.math.maxInt(i32))));
}

/// A step button's icon, in the middle of its bubble `r` (window-natural; `bounds` the layer's).
fn button(bounds: dvui.Rect, r: dvui.Rect, name: []const u8, tvg: []const u8, color: dvui.Color) void {
const d = r.w * 0.44;
core.icon.icon(@src(), name, tvg, .{
.stroke_color = .{ .color = color },
.fill_color = .{ .color = color },
}, .{
.id_extra = @intFromPtr(name.ptr),
.rect = .{ .x = r.x + (r.w - d) / 2 - bounds.x, .y = r.y + (r.h - d) / 2 - bounds.y, .w = d, .h = d },
});
}

/// The line `r` (physical): the history to `at` lit, a tick per step while they fit, the knob.
fn paintLine(r: dvui.Rect.Physical, at: usize, n: usize, ink: dvui.Color, s: f32) void {
const h = 4 * s;
const line: dvui.Rect.Physical = .{ .x = r.x, .y = r.y + (r.h - h) / 2, .w = r.w, .h = h };
line.fill(.all(h / 2), .{ .color = .{ .color = ink.opacity(0.15) } });
const accent = dvui.themeGet().color(.highlight, .fill);
const knob_x = rules.stopX(at, r.x, r.x + r.w, n);
var done = line;
done.w = knob_x - line.x;
if (done.w > 0) done.fill(.all(h / 2), .{ .color = .{ .color = accent } });
// A tick per step while they stand 6 points apart; past that the line is its own scale.
if (n > 0 and r.w / @as(f32, @floatFromInt(n)) >= 6 * s) for (1..n) |i| {
const x = rules.stopX(i, r.x, r.x + r.w, n);
const tick: dvui.Rect.Physical = .{ .x = x - 0.5 * s, .y = r.y + 1 * s, .w = 1 * s, .h = r.h - 2 * s };
tick.fill(.{}, .{ .color = .{ .color = ink.opacity(0.25) } });
};
var knob: dvui.Path.Builder = .init(dvui.currentWindow().lifo());
defer knob.deinit();
knob.addArc(.{ .x = knob_x, .y = line.y + h / 2 }, 6 * s, std.math.tau, 0, true);
knob.build().fillConvex(.{ .color = .{ .color = accent }, .fade = 1 });
}
146 changes: 146 additions & 0 deletions app/history_transport.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
//! The rules of a document's transport (`HistoryTransport`): when its bar shows and for how long,
//! how far a held undo or redo goes each press, and which step a point on its line is.
//!
//! Pure decisions over times and sizes, std-only, so they test without a window
//! (`fizzy-history-transport-tests`).
const std = @import("std");

/// How long the bar stays once nothing touches it (`plans/RECORDING_PLAN.md`, "Undo is a seek").
pub const linger_ns: i128 = 3 * std.time.ns_per_s;
/// How long it takes to come in, and to go.
pub const fade_ns: i128 = 160 * std.time.ns_per_ms;

/// When the bar was last asked for: an undo, a redo, the pointer on it.
pub const Shown = struct {
at_ns: ?i128 = null,

/// Asked for at `now_ns`. Still coming in, it carries on; showing, it stays in and its linger
/// starts over; gone, it comes in from nothing.
pub fn stir(self: *Shown, now_ns: i128) void {
if (self.at_ns) |at| if (now_ns - at < fade_ns) return;
self.at_ns = if (self.openness(now_ns) > 0) now_ns - fade_ns else now_ns;
}

/// How much of the bar shows at `now_ns`, 0 gone to 1 in: in over `fade_ns`, held, and out over
/// `fade_ns` once it has lingered.
pub fn openness(self: Shown, now_ns: i128) f32 {
const at = self.at_ns orelse return 0;
const since = now_ns - at;
if (since < 0) return 1;
if (since < fade_ns) return frac(since, fade_ns);
const left = linger_ns + fade_ns - since;
if (left <= 0) return 0;
return if (left < fade_ns) frac(left, fade_ns) else 1;
}

/// When the bar next changes on its own (comes fully in, starts to go, is gone): what its timer
/// waits for. Null when it is gone, so a bar not showing wakes nothing.
pub fn nextChange(self: Shown, now_ns: i128) ?i128 {
const at = self.at_ns orelse return null;
for ([_]i128{ at + fade_ns, at + linger_ns, at + linger_ns + fade_ns }) |t| if (t > now_ns) return t;
return null;
}

fn frac(n: i128, d: i128) f32 {
return @floatCast(@as(f64, @floatFromInt(n)) / @as(f64, @floatFromInt(d)));
}
};

/// Presses closer together than this are a key held down, its repeats.
pub const repeat_gap_ns: i128 = 150 * std.time.ns_per_ms;

/// How far each press of a held undo or redo goes: one step at a time for the first few, then
/// further, so holding the key scrubs a long history in a second or two.
pub const Repeat = struct {
last_ns: ?i128 = null,
/// Which way the run goes: a press the other way starts a new one.
forward: bool = false,
/// Presses in this run, the first one included.
run: u32 = 0,

/// A press at `now_ns`, of redo (`forward`) or undo: how many steps it moves.
pub fn press(self: *Repeat, now_ns: i128, forward: bool) usize {
const held = if (self.last_ns) |l| now_ns - l < repeat_gap_ns and forward == self.forward else false;
self.run = if (held) self.run + 1 else 1;
self.last_ns = now_ns;
self.forward = forward;
return stepsAt(self.run);
}

/// The step a run of `run` presses takes: 1 for the first six (a deliberate few undos stay one
/// at a time), then doubling every four presses, up to 32.
pub fn stepsAt(run: u32) usize {
if (run <= 6) return 1;
const doublings: u5 = @intCast(@min(5, (run - 7) / 4 + 1));
return @as(usize, 1) << doublings;
}
};

/// The step a point `x` on a line from `x0` to `x1` means, in a history of `n` steps: how many
/// of them are applied, 0 (before the first) to `n`. The line is split into `n + 1` equal stops,
/// the point taking the nearest.
pub fn stopAt(x: f32, x0: f32, x1: f32, n: usize) usize {
if (n == 0 or x1 <= x0) return 0;
const f = std.math.clamp((x - x0) / (x1 - x0), 0, 1);
return @intFromFloat(@round(f * @as(f32, @floatFromInt(n))));
}

/// Where stop `i` of `n` sits on the line from `x0` to `x1`.
pub fn stopX(i: usize, x0: f32, x1: f32, n: usize) f32 {
if (n == 0) return x0;
return x0 + (x1 - x0) * @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(n));
}

// ---- tests ---------------------------------------------------------------------------------

const testing = std.testing;
const ms = std.time.ns_per_ms;

test "the bar comes in, lingers, goes, and wakes nothing once gone" {
var s: Shown = .{};
try testing.expectEqual(@as(f32, 0), s.openness(0));
try testing.expect(s.nextChange(0) == null);
s.stir(1000 * ms);
try testing.expectEqual(@as(f32, 0.5), s.openness(1080 * ms));
try testing.expectEqual(@as(f32, 1), s.openness(2000 * ms));
try testing.expectEqual(@as(?i128, 1000 * ms + linger_ns), s.nextChange(2000 * ms));
try testing.expect(s.openness(1000 * ms + linger_ns + fade_ns / 2) < 1);
try testing.expectEqual(@as(f32, 0), s.openness(1000 * ms + linger_ns + fade_ns));
try testing.expect(s.nextChange(1000 * ms + linger_ns + fade_ns) == null);
}

test "asked for again while it shows, it stays in rather than fading in again" {
var s: Shown = .{};
s.stir(0);
try testing.expectEqual(@as(f32, 1), s.openness(1000 * ms));
s.stir(2500 * ms);
try testing.expectEqual(@as(f32, 1), s.openness(2500 * ms));
// Its linger starts again from the stir.
try testing.expectEqual(@as(f32, 1), s.openness(2500 * ms + linger_ns - fade_ns));
try testing.expectEqual(@as(f32, 0), s.openness(2500 * ms + linger_ns + 2 * fade_ns));
// Gone, a stir brings it in from nothing.
s.stir(60_000 * ms);
try testing.expect(s.openness(60_000 * ms) < 0.01);
}

test "a held key goes one step at a time, then further" {
var r: Repeat = .{};
var got: [16]usize = undefined;
for (&got, 0..) |*g, i| g.* = r.press(@as(i128, @intCast(i)) * 50 * ms, false);
try testing.expectEqualSlices(usize, &.{ 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 8, 8 }, &got);
try testing.expectEqual(@as(usize, 32), Repeat.stepsAt(1000));
// The other way, at once, is a first press: overshooting and coming back is one step.
try testing.expectEqual(@as(usize, 1), r.press(16 * 50 * ms, true));
// A pause, and the next press is a first one again.
try testing.expectEqual(@as(usize, 1), r.press(10_000 * ms, true));
}

test "a point on the line is the nearest step" {
try testing.expectEqual(@as(usize, 0), stopAt(0, 0, 100, 10));
try testing.expectEqual(@as(usize, 10), stopAt(100, 0, 100, 10));
try testing.expectEqual(@as(usize, 5), stopAt(52, 0, 100, 10));
try testing.expectEqual(@as(usize, 0), stopAt(-40, 0, 100, 10));
try testing.expectEqual(@as(usize, 10), stopAt(140, 0, 100, 10));
try testing.expectEqual(@as(usize, 0), stopAt(50, 0, 100, 0));
for (0..11) |i| try testing.expectEqual(i, stopAt(stopX(i, 20, 220, 10), 20, 220, 10));
}
3 changes: 3 additions & 0 deletions app/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ pub const Recents = @import("Recents.zig");
pub const ActivityFeed = @import("ActivityFeed.zig");
/// A track per open document: its history with the time put back in.
pub const Tracks = @import("Tracks.zig");
/// A document's transport: its history as a line at the bottom of its pane, undo and redo as seeks.
pub const HistoryTransport = @import("HistoryTransport.zig");

/// Key chords → command ids: the table, its ZON form, chord matching, and the dvui adapter.
pub const keymap = struct {
Expand Down Expand Up @@ -168,4 +170,5 @@ test {
_ = watch.ExecutableWatcher;
_ = store.Loader;
_ = Tracks;
_ = HistoryTransport;
}
4 changes: 4 additions & 0 deletions build/app.zig
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,10 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.
// A document state as the bytes that differ from another: how a track's checkpoints
// stay small. std-only (see state_delta.zig); `Tracks` keeps its checkpoints with it.
.{ "fizzy-state-delta-tests", "app/state_delta.zig" },
// When a document's transport shows and for how long, how far a held undo goes, which
// step a point on its line is. std-only (see history_transport.zig); `HistoryTransport`
// draws it.
.{ "fizzy-history-transport-tests", "app/history_transport.zig" },
// One material on two sliders: what every glass is at the window's opacity and roughness,
// the OS's form and the app's. std-only (see glass_look.zig); `LiquidField` and `Popout`
// apply it.
Expand Down
Loading
Loading