diff --git a/app/HistoryFrames.zig b/app/HistoryFrames.zig index fe0fdac7b..fdc994e08 100644 --- a/app/HistoryFrames.zig +++ b/app/HistoryFrames.zig @@ -5,8 +5,11 @@ //! a texture (`core.FrameTarget.snapshot`), so any document has frames without its owner knowing. //! Taken only after the document's history moved (`moved`): the frame after an edit lands, an //! undo, a redo, a scrub's jump. Each is the pane above the bar, keyed by the history entry on top -//! then (0 before any). A step the document never stood at while open here has no picture: the -//! strip shows it plain until a scrub passes over it. +//! then (0 before any). A step's picture is taken once, as the step is made, and kept: going back, +//! forward or scrubbing over it only ever fills in one it lacks (a step the document never stood +//! at while open here), compared with the step truly before it, never the last one visited. Only +//! the step changing (a keystroke joining a run of typing, `Change.made`) takes it again, and a +//! history that started over (`Change.restarted`) lets go of them all. //! //! A picture is the strip's size, cut from the frame at its own pixels round where its step changed //! the document (`Picture.change`), so the strip draws it one pixel for one and it reads as @@ -23,6 +26,7 @@ const std = @import("std"); const dvui = @import("dvui"); const core = @import("core"); const rules = @import("history_transport.zig"); +const Tracks = @import("Tracks.zig"); /// Each picture, by the history entry on top when it was taken; oldest taken first. frames: std.AutoArrayHashMapUnmanaged(u64, Picture) = .empty, @@ -35,6 +39,10 @@ unread: ?Unread = null, /// while the history moves frame after frame (a held undo, a scrub), so each step is a copy into /// it rather than a new pane-sized target; let go of the first frame no picture is asked for. whole: ?dvui.TextureTarget = null, +/// The size of the pane's part a picture is taken from as the pane stands (`take`, each frame): a +/// step's picture from another size (the window resized since) is of another view of it, and a +/// visit takes it again. +pane_source: [2]u32 = .{ 0, 0 }, const Unread = struct { step: Step, @@ -47,8 +55,31 @@ const Unread = struct { }; /// A step's picture to take: the entry on top, and the one under it (null before any), whose -/// picture it is compared with for what the step changed. -const Step = struct { top: u64, below: ?u64 }; +/// picture it is compared with for what the step changed; `made` when the step itself was just +/// made or changed, so a picture it already has is out of date. +const Step = struct { top: u64, below: ?u64, made: bool = false }; + +/// What a move of the history did to its entry on top (`changeOf`). +pub const Change = enum { + /// Only moved to it (undone or redone to, gone to, scrubbed over): its picture, if it has one, + /// still shows it. + visited, + /// Made or changed (added, joined): a picture it has is of what it was. + made, + /// The history started over: no picture taken before is of this history. + restarted, +}; + +/// What the track's records since the last move (`recs`) did to the entry now on top (`top`): made +/// it, or only moved to it. A restart is never among them: it begins a new track (`Tracks.step`), +/// which the app tells by the track itself changing. +pub fn changeOf(recs: []const Tracks.Track.Rec, top: u64) Change { + for (recs) |r| switch (r.kind) { + .added, .joined => if (r.id == top) return .made, + .undone, .redone, .restarted => {}, + }; + return .visited; +} /// A crop of the document's pane at a step, the strip's size at its own pixels, and where the step /// changed it. @@ -75,12 +106,16 @@ pub const max_w: u32 = 384; /// The document's history moved and now has `top` (its entry on top, 0 for none) on top, `below` /// the one under it (0 for none, null when `top` is 0), and the frame the app is drawing shows it: -/// the next frame takes the picture from it. The app asks after +/// the next frame takes the picture from it, unless `top` already has one and `change` says it +/// still shows it (a step's picture is never retaken for a visit). The app asks after /// the document draws, and sees a move once its owner has reported it: an edit made in the /// document's own draw or before it is in the frame, and the transport's jumps (made after the /// document drew) are seen the frame after, which draws them. -pub fn moved(self: *HistoryFrames, top: u64, below: ?u64) void { - self.due = .{ .top = top, .below = below }; +pub fn moved(self: *HistoryFrames, gpa: std.mem.Allocator, top: u64, below: ?u64, change: Change) void { + if (change == .restarted) self.forget(gpa); + // Only visited, and it has its picture of the pane as it stands: kept as it was taken. + if (change == .visited) if (self.frames.get(top)) |pic| if (std.mem.eql(u32, &pic.source, &self.pane_source)) return; + self.due = .{ .top = top, .below = below, .made = change != .visited }; // Keep the frame in a texture to read from, on a backend that would draw it straight to the // window (the web). core.FrameTarget.want(); @@ -94,19 +129,20 @@ pub fn moved(self: *HistoryFrames, top: u64, below: ?u64) void { /// the crop's; null where the pane has no room for the bar, and no picture is taken. pub fn take(self: *HistoryFrames, gpa: std.mem.Allocator, pane: dvui.Rect.Physical, bar_h: f32, strip: ?[2]u32) void { self.land(gpa); + // On whole pixels, so the copy at its own size is the pane's pixels, not a blend of two. + var r: dvui.Rect.Physical = .{ .x = @round(pane.x), .y = @round(pane.y), .w = @round(pane.w), .h = @round(pane.h - bar_h) }; + r = r.intersect(dvui.windowRectPixels()); + self.pane_source = .{ @intFromFloat(@max(r.w, 0)), @intFromFloat(@max(r.h, 0)) }; const step = self.due orelse { self.dropWhole(); return; }; self.due = null; const crop = strip orelse return self.dropWhole(); - // On whole pixels, so the copy at its own size is the pane's pixels, not a blend of two. - var r: dvui.Rect.Physical = .{ .x = @round(pane.x), .y = @round(pane.y), .w = @round(pane.w), .h = @round(pane.h - bar_h) }; - r = r.intersect(dvui.windowRectPixels()); if (r.w < 8 or r.h < 8 or crop[0] == 0 or crop[1] == 0) return self.dropWhole(); const prof = core.profile.begin("fizzy", "history.frame"); defer prof.end(); - const source: [2]u32 = .{ @intFromFloat(r.w), @intFromFloat(r.h) }; + const source = self.pane_source; if (self.whole) |t| if (t.width != source[0] or t.height != source[1]) self.dropWhole(); const whole = self.whole orelse (dvui.textureCreateTarget(.{ .width = source[0], .height = source[1], .interpolation = .linear }) catch return); self.whole = whole; @@ -139,16 +175,12 @@ fn land(self: *HistoryFrames, gpa: std.mem.Allocator) void { const h = u.small.height; var pic: Picture = .{ .tex = undefined, .source = u.source }; var content: ?rules.Frac = null; - // Compared with: null when there is nothing to compare with, so a picture taken again keeps - // what it knew. - var compared = false; if (dvui.Texture.readTarget(dvui.currentWindow().arena(), u.small)) |px| { content = contentOf(px, w, h); if (gpa.alloc(u32, w + h)) |p| { prints(px, w, h, p); pic.prints = p; if (u.step.below) |b| if (self.frames.get(b)) |before| if (before.prints.len == p.len and std.mem.eql(u32, &before.source, &pic.source)) { - compared = true; const cols = rules.changedSpan(before.prints[0..w], p[0..w]); const rows = rules.changedSpan(before.prints[w..], p[w..]); if (cols) |c| if (rows) |r| { @@ -158,10 +190,11 @@ fn land(self: *HistoryFrames, gpa: std.mem.Allocator) void { } else |_| {} } else |_| {} const top = u.step.top; - // Taken again, not compared: what it knew, when taken from the same view of it (from another, - // it is somewhere else now). - if (self.frames.get(top)) |old| if (!compared and std.mem.eql(u32, &old.source, &pic.source)) { - pic.change = old.change; + // A visit's picture of a step that has one of this view by now (made since it was asked for) + // is not the step's: it keeps its own. + if (!u.step.made) if (self.frames.get(top)) |old| if (std.mem.eql(u32, &old.source, &pic.source)) { + gpa.free(pic.prints); + return; }; // Round what the step changed; one that changed nothing visible, round the document's content, // or the pane's middle. @@ -192,6 +225,12 @@ fn land(self: *HistoryFrames, gpa: std.mem.Allocator) void { } } +/// Let go of every picture: the history they were of started over. +fn forget(self: *HistoryFrames, gpa: std.mem.Allocator) void { + for (self.frames.values()) |p| release(gpa, p); + self.frames.clearRetainingCapacity(); +} + fn release(gpa: std.mem.Allocator, pic: Picture) void { dvui.textureDestroyLater(pic.tex); gpa.free(pic.prints); @@ -327,3 +366,48 @@ test "a step's change is the box of columns and rows where its picture differs f try std.testing.expectEqual(@as(?rules.Span, null), rules.changedSpan(pa[0..w], pb[0..w])); try std.testing.expectEqual(@as(?rules.Span, null), rules.changedSpan(pa[w..], pb[w..])); } + +test "scrubbing across the steps leaves each step's picture as it was taken" { + const gpa = std.testing.allocator; + var hf: HistoryFrames = .{ .pane_source = .{ 100, 80 } }; + defer hf.deinit(gpa); + // Five steps, each with the picture taken as it was made: its own prints and change. + for (0..5) |i| { + const p = try gpa.alloc(u32, 4); + @memset(p, @intCast(i)); + try hf.frames.put(gpa, i, .{ .tex = undefined, .prints = p, .source = .{ 100, 80 }, .change = .{ .x = @floatFromInt(i), .w = 1, .h = 1 } }); + } + // Back to the start and forward again, then to and fro: undo, redo, a scrub's jumps. + const stops = [_]u64{ 4, 3, 2, 1, 0, 1, 2, 3, 4, 0, 4, 2 }; + for (stops) |top| { + hf.moved(gpa, top, if (top == 0) null else top - 1, changeOf(&.{}, top)); + try std.testing.expectEqual(@as(?Step, null), hf.due); + } + try std.testing.expectEqual(@as(usize, 5), hf.frames.count()); + for (hf.frames.keys(), hf.frames.values()) |k, pic| { + try std.testing.expectEqual(@as(f32, @floatFromInt(k)), pic.change.?.x); + for (pic.prints) |v| try std.testing.expectEqual(@as(u32, @intCast(k)), v); + try std.testing.expectEqual([2]u32{ 100, 80 }, pic.source); + } +} + +test "only a step being made or joined takes its picture again" { + const R = Tracks.Track.Rec; + const at: R = .{ .kind = .added, .id = 0, .at = 0, .ms = 0, .effect = .{ .start = 0, .len = 0 }, .motion = .{ .start = 0, .len = 0 } }; + var recs = [_]R{ at, at, at }; + recs[0].kind = .undone; + recs[0].id = 7; + recs[1].kind = .redone; + recs[1].id = 7; + recs[2].kind = .redone; + recs[2].id = 8; + // Undone and redone over: only visited. + try std.testing.expectEqual(Change.visited, changeOf(&recs, 8)); + try std.testing.expectEqual(Change.visited, changeOf(&.{}, 8)); + // A keystroke joining the run on top changes it; one joining another entry does not. + recs[2].kind = .joined; + try std.testing.expectEqual(Change.made, changeOf(&recs, 8)); + try std.testing.expectEqual(Change.visited, changeOf(&recs, 9)); + recs[2].kind = .added; + try std.testing.expectEqual(Change.made, changeOf(&recs, 8)); +} diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 4c38da57b..008f8676a 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -3163,11 +3163,21 @@ fn drawDocSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result { ds.focus_inside = dvui.lastFocusedIdInFrameSince(focus_before) != null; // Its history moved (a step recorded, undone, redone, scrubbed to): a picture of it as it - // stands, a frame from now. The first frame it draws is one too. + // stands, a frame from now, for a step being made, or one with none yet; a step's picture is + // kept once taken (`HistoryFrames.changeOf`). The first frame it draws is a move too. if (recorded) |tr| if (@intFromPtr(tr) != ds.frames_track or tr.steps.items.len != ds.frames_steps) { + const new_track = ds.frames_track != 0 and @intFromPtr(tr) != ds.frames_track; + // The records since the last move; all of them where the track has fewer than then (it + // dropped steps taken before its base). + const since: []const @import("app").Tracks.Track.Rec = if (new_track) &.{} else tr.steps.items[if (tr.steps.items.len >= ds.frames_steps) ds.frames_steps else 0..]; ds.frames_track = @intFromPtr(tr); ds.frames_steps = tr.steps.items.len; - if (doc.owner.historyLine(doc, dvui.currentWindow().arena())) |line| ds.frames.moved(if (line.at == 0) 0 else line.entries[line.at - 1].id, if (line.at == 0) null else if (line.at == 1) 0 else line.entries[line.at - 2].id); + if (doc.owner.historyLine(doc, dvui.currentWindow().arena())) |line| { + const top = if (line.at == 0) 0 else line.entries[line.at - 1].id; + // A new track for a document that had one: its history started over (`Tracks.step`). + const change: @import("app").HistoryFrames.Change = if (new_track) .restarted else @import("app").HistoryFrames.changeOf(since, top); + ds.frames.moved(editor.app.gpa, top, if (line.at == 0) null else if (line.at == 1) 0 else line.entries[line.at - 2].id, change); + } }; // The commands run on it, as they run; none while a tape seeks, which replays them unseen.