diff --git a/app/HistoryTransport.zig b/app/HistoryTransport.zig new file mode 100644 index 000000000..f00163b02 --- /dev/null +++ b/app/HistoryTransport.zig @@ -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 }); +} diff --git a/app/history_transport.zig b/app/history_transport.zig new file mode 100644 index 000000000..7aae14312 --- /dev/null +++ b/app/history_transport.zig @@ -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)); +} diff --git a/app/root.zig b/app/root.zig index d0fb148b5..e87fbf614 100644 --- a/app/root.zig +++ b/app/root.zig @@ -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 { @@ -168,4 +170,5 @@ test { _ = watch.ExecutableWatcher; _ = store.Loader; _ = Tracks; + _ = HistoryTransport; } diff --git a/build/app.zig b/build/app.zig index 81d0e95e5..8a213a70c 100644 --- a/build/app.zig +++ b/build/app.zig @@ -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. diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index 0472208a7..d19ca5ec0 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -149,6 +149,8 @@ user's motion settings, timed in demo time. | `fizzy.command:` | a menu row that runs a command | | `fizzy.dialog.close` | the close button in a dialog's header | | `fizzy.dialog::<label>` | a button in a dialog: its footer's, or one drawn with `core.dialogs.bodyButton` (`fizzy.dialog:About Fizzy:Close`) | +| `history.line:<abs path>` | a document's transport line, from its first stop to its last, while the transport shows: `x` is that fraction of its history (`x = 0` before any change) | +| `history.back:<abs path>`, `history.forward:<abs path>` | its undo and redo buttons | | `fizzy.window:main` | the main window | | `fizzy.window:dialog:<title>`, `fizzy.window:float:<title>`, `fizzy.window:menu` | an OS window of the app's own: a dialog's, a popped-out view's, a menu's (a second of the same name gets `:2`) | | `region:<name>` | a layout region (`Main`, `Panel`, …) | diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index e5fba8cdd..58e265248 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -2731,6 +2731,8 @@ const DocSurface = struct { focus_inside: bool = false, /// The commands just run on it, shown on its pane. feed: @import("app").ActivityFeed = .{}, + /// Its history as a line at the bottom of the pane: undo and redo show it. + transport: @import("app").HistoryTransport = .{}, }; pub fn insertOpenDoc(editor: *Editor, doc_buf: *anyopaque, owner: *sdk.Plugin, id: u64) !void { @@ -2947,9 +2949,12 @@ fn drawDocSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result { if (editor.app.host.activity_muted) ds.feed.clear() else { const now = dvui.frameTimeNS(); ds.feed.take(&editor.app.host, doc.id, now); - // Above the pill, while someone holds the document. + // Above the pill while someone holds the document, and above the transport while it shows. var area = canvas.data().borderRectScale().r; if (claim_pill) |pill| area.h = pill.physical.y - area.y + 6 * dvui.windowRectScale().s; + if (claim_pill == null) if (ds.transport.place(area, now)) |bar| { + area.h = @min(area.h, bar.y - area.y + 6 * dvui.windowRectScale().s); + }; ds.feed.draw(area, @truncate(ds.doc_id), now); } @@ -2973,7 +2978,8 @@ fn drawDocSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result { }, false); } } - if (claim) |c| drawClaim(editor, c, claim_pill.?, doc.owner.documentPath(doc)); + // Its history, while undo or redo or the pointer keep it: not while someone else holds it. + if (claim) |c| drawClaim(editor, c, claim_pill.?, doc.owner.documentPath(doc)) else ds.transport.draw(doc, canvas.data().borderRectScale().r, @truncate(ds.doc_id)); return .ok; } @@ -6061,11 +6067,26 @@ pub fn processPendingSaveAs(editor: *Editor) void { } pub fn undo(editor: *Editor) !void { - try editor.runActiveDocCommand("undo"); + try editor.historyStep(.undo); } pub fn redo(editor: *Editor) !void { - try editor.runActiveDocCommand("redo"); + try editor.historyStep(.redo); +} + +/// An undo or a redo of the active document, through its owner's command: its transport shows, +/// and a key held down goes further each press (`HistoryTransport.press`), the command run that +/// many times. Nothing to undo, or a claimed document: the command as ever, and no bar. +fn historyStep(editor: *Editor, action: enum { undo, redo }) !void { + const doc = editor.activeDoc() orelse return; + const name = @tagName(action); + const shows = editor.app.host.documentClaim(doc.id) == null and editor.activeDocCommandEnabled(name); + const ds = if (shows) editor.doc_surfaces.get(doc.id) else null; + const by = if (ds) |d| d.transport.press(dvui.frameTimeNS(), action == .redo) else 1; + for (0..by) |_| { + if (!editor.activeDocCommandEnabled(name)) break; + try editor.runActiveDocCommand(name); + } } pub fn openInFileBrowser(editor: *Editor, path: []const u8) !void { diff --git a/tests/integration.zig b/tests/integration.zig index 6389726de..c8f9d0d5b 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7476,6 +7476,96 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try dvui.testing.settle(headlessFrame); } + // A document's transport (`app.HistoryTransport`): undo shows it over the document's pane; a + // press on its line goes to that step, its buttons step, a drag along it follows; and an undo + // held down goes further each press. + { + const host = &editor.app.host; + const was = editor.activeDoc() orelse return error.TestUnexpectedResult; + try host.runCommand("fizzy.newFile"); + for (0..200) |_| { + _ = try dvui.testing.step(headlessFrame); + const d = editor.activeDoc() orelse continue; + if (d.id != was.id and std.mem.startsWith(u8, std.fs.path.basename(d.owner.documentPath(d)), "untitled")) break; + } + const doc = editor.activeDoc() orelse return error.TestUnexpectedResult; + try std.testing.expect(doc.id != was.id); + const path = doc.owner.documentPath(doc); + for (0..40) |_| try std.testing.expect(host.callCommand("text.insert", ".{ .text = \"x\", .line = 1 }", host.arena()) == .ok); + try dvui.testing.settle(headlessFrame); + const at = struct { + fn at(h: *sdk.Host, d: sdk.DocHandle) !usize { + return (d.owner.historyLine(d, h.arena()) orelse return error.TestUnexpectedResult).at; + } + }.at; + const ds = editor.doc_surfaces.get(doc.id) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(f32, 0), ds.transport.shown.openness(dvui.frameTimeNS())); + + try editor.undo(); + for (0..4) |_| _ = try dvui.testing.step(headlessFrame); + try std.testing.expect(ds.transport.shown.openness(dvui.frameTimeNS()) > 0); + try std.testing.expectEqual(@as(usize, 39), try at(host, doc)); + + const play = struct { + fn play(ed: *fizzy.Editor, script: *automation.Script) !void { + try ed.demo.live.play(try script.finish()); + for (0..300) |_| { + if (!ed.demo.live.playing()) break; + _ = try dvui.testing.step(headlessFrame); + } + try std.testing.expect(ed.demo.live.outcome.? == .finished); + } + }.play; + const line_tag = try std.fmt.allocPrint(gpa, "history.line:{s}", .{path}); + defer gpa.free(line_tag); + const forward_tag = try std.fmt.allocPrint(gpa, "history.forward:{s}", .{path}); + defer gpa.free(forward_tag); + { + // A press on the title, over the line's start, is the bar's and goes nowhere. + var s: automation.Script = .init(gpa, "transport-title", ""); + s.check = automation.Input.check; + s.check.live = true; + try s.click(.{ .tag = line_tag, .x = 0, .y = 0.15 }, .{}); + try play(editor, &s); + } + try std.testing.expectEqual(@as(usize, 39), try at(host, doc)); + { + // The line's start: before any change. + var s: automation.Script = .init(gpa, "transport-start", ""); + s.check = automation.Input.check; + s.check.live = true; + try s.click(.{ .tag = line_tag, .x = 0 }, .{}); + try play(editor, &s); + } + try std.testing.expectEqual(@as(usize, 0), try at(host, doc)); + { + // Redo: one step on. + var s: automation.Script = .init(gpa, "transport-forward", ""); + s.check = automation.Input.check; + s.check.live = true; + try s.click(.{ .tag = forward_tag }, .{}); + try play(editor, &s); + } + try std.testing.expectEqual(@as(usize, 1), try at(host, doc)); + { + // A drag from the start to the end follows the pointer there. + var s: automation.Script = .init(gpa, "transport-drag", ""); + s.check = automation.Input.check; + s.check.live = true; + try s.drag(.{ .tag = line_tag, .x = 0 }, .{ .tag = line_tag, .x = 1 }, .{}); + try play(editor, &s); + } + try std.testing.expectEqual(@as(usize, 40), try at(host, doc)); + + // Held down, ten presses in a frame: one step each for six, then two each. + for (0..10) |_| try editor.undo(); + try std.testing.expectEqual(@as(usize, 40 - 14), try at(host, doc)); + + try editor.rawCloseFileID(doc.id); + editor.workbench.setActiveDocIndex(editor.app.open_files.getIndex(was.id) orelse return error.TestUnexpectedResult); + try dvui.testing.settle(headlessFrame); + } + // Revealing a view (`Host.revealSurface`): with the bottom panel shut, revealing Output opens // the panel with Output selected, and a view no region shows is not revealed. {