From eb9ac2380511abec4099fa2ba60f44cb50319fb5 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 19 Oct 2026 11:38:19 -0500 Subject: [PATCH] app: each step's history slice shows where it changed The history strip gave each step the stretch of its picture under its own knob, so the strip read as one picture building up left to right. Now each step's slice stands round the columns its picture changed from the step before's, so the strip reads as the actions themselves, sliced. A step that changed nothing visible keeps the old slice. Co-Authored-By: Claude Opus 5.5 --- app/HistoryFrames.zig | 130 ++++++++++++++++++++++++++++++++------ app/HistoryTransport.zig | 14 ++-- app/history_transport.zig | 47 ++++++++++++++ src/editor/Editor.zig | 2 +- 4 files changed, 167 insertions(+), 26 deletions(-) diff --git a/app/HistoryFrames.zig b/app/HistoryFrames.zig index 69149c2f2..c0afc37a1 100644 --- a/app/HistoryFrames.zig +++ b/app/HistoryFrames.zig @@ -8,6 +8,10 @@ //! 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. //! +//! Each picture also knows where its step changed the document (`Picture.change`): compared with +//! the picture of the step before, column by column (`columnPrints`), once, as it lands. The strip +//! shows each step's slice there, so it reads as the steps themselves. +//! //! Costs nothing on a frame the history did not move; on one it did, a copy of the pane into a //! texture the strip's size. At most `cap` pictures a document, the oldest taken going first. const HistoryFrames = @This(); @@ -20,14 +24,29 @@ const rules = @import("history_transport.zig"); /// Each picture, by the history entry on top when it was taken; oldest taken first. frames: std.AutoArrayHashMapUnmanaged(u64, Picture) = .empty, /// The entry on top the frame just drawn shows, to be taken from it at the next (`take`). -due: ?u64 = null, +due: ?Step = null, /// A picture taken last frame, still the target it was drawn into: read the frame after, once /// its copy has landed, for where its content is (`land`). -unread: ?struct { top: u64, target: dvui.TextureTarget } = null, +unread: ?struct { step: Step, target: dvui.TextureTarget, source: [2]u32 } = null, + +/// 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 }; /// A picture of the document, and where in it the content is (`content`): what differs from its /// background, as fractions of it; null where nothing does (an empty document). -pub const Picture = struct { tex: dvui.Texture, content: ?rules.Frac }; +pub const Picture = struct { + tex: dvui.Texture, + content: ?rules.Frac, + /// Each column's print (`columnPrints`), owned; empty when it could not be read. + prints: []u32 = &.{}, + /// The size of the pane's part it was taken from, in physical pixels: two pictures are + /// compared only when taken from the same, or the scaling alone would differ everywhere. + source: [2]u32 = .{ 0, 0 }, + /// The middle of what this step changed from the one before, as a fraction across; null when + /// nothing visibly did, or the step before has no picture to compare with. + change: ?f32 = null, +}; /// At most this many pictures a document: 48 of 384 by 192 is about 14 MB. pub const cap = 48; @@ -36,13 +55,14 @@ pub const cap = 48; pub const height: u32 = 192; pub const max_w: u32 = 384; -/// The document's history moved and now has `top` (its entry on top, 0 for none) on top, and the -/// frame the app is drawing shows it: the next frame takes the picture from it. The app asks after +/// 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 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) void { - self.due = top; +pub fn moved(self: *HistoryFrames, top: u64, below: ?u64) void { + self.due = .{ .top = top, .below = below }; // 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(); @@ -55,7 +75,7 @@ pub fn moved(self: *HistoryFrames, top: u64) void { /// the strip itself. pub fn take(self: *HistoryFrames, gpa: std.mem.Allocator, pane: dvui.Rect.Physical, bar_h: f32) void { self.land(gpa); - const top = self.due orelse return; + const step = self.due orelse return; self.due = null; var r = pane; r.h -= bar_h; @@ -65,37 +85,75 @@ pub fn take(self: *HistoryFrames, gpa: std.mem.Allocator, pane: dvui.Rect.Physic const h: f32 = @floatFromInt(height); const w: u32 = @intFromFloat(@min(@as(f32, @floatFromInt(max_w)), @round(h * r.w / r.h))); const target = core.FrameTarget.snapshotTarget(r, .{ @max(w, 1), height }) orelse return; - self.unread = .{ .top = top, .target = target }; + self.unread = .{ .step = step, .target = target, .source = .{ @intFromFloat(@round(r.w)), @intFromFloat(@round(r.h)) } }; // The frame that reads it back. dvui.refresh(null, @src(), null); } /// The picture taken last frame (`unread`), its copy landed: read back for where its content is -/// (`contentOf`), then kept as a texture. A read of one small picture, once per move. +/// (`contentOf`) and where it changed from the step before (`columnPrints`), then kept as a +/// texture. A read of one small picture, once per move. fn land(self: *HistoryFrames, gpa: std.mem.Allocator) void { const u = self.unread orelse return; self.unread = null; const prof = core.profile.begin("fizzy", "history.frame.content"); defer prof.end(); - const content = if (dvui.Texture.readTarget(dvui.currentWindow().arena(), u.target)) |px| contentOf(px, u.target.width, u.target.height) else |_| null; - const tex = dvui.Texture.fromTarget(u.target) catch return; - const pic: Picture = .{ .tex = tex, .content = content }; - const gop = self.frames.getOrPut(gpa, u.top) catch { - dvui.textureDestroyLater(tex); + const w = u.target.width; + const h = u.target.height; + var pic: Picture = .{ .tex = undefined, .content = null, .source = u.source }; + // 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.target)) |px| { + pic.content = contentOf(px, w, h); + if (gpa.alloc(u32, w)) |prints| { + columnPrints(px, w, h, prints); + pic.prints = prints; + if (u.step.below) |b| if (self.frames.get(b)) |before| if (before.prints.len == prints.len and prints.len > 0 and std.mem.eql(u32, &before.source, &pic.source)) { + compared = true; + if (rules.changedSpan(before.prints, prints)) |c| pic.change = (c.lo + c.hi) / 2; + }; + } else |_| {} + } else |_| {} + pic.tex = dvui.Texture.fromTarget(u.target) catch { + gpa.free(pic.prints); return; }; - if (gop.found_existing) { + const top = u.step.top; + if (self.frames.fetchOrderedRemove(top)) |kv| { // Taken again: the newest, so the last to go. - dvui.textureDestroyLater(gop.value_ptr.tex); - _ = self.frames.orderedRemove(u.top); - self.frames.put(gpa, u.top, pic) catch dvui.textureDestroyLater(tex); - } else gop.value_ptr.* = pic; + // Kept only from the same view of it: from another, it is somewhere else now. + if (!compared and std.mem.eql(u32, &kv.value.source, &pic.source)) pic.change = kv.value.change; + release(gpa, kv.value); + } + self.frames.put(gpa, top, pic) catch release(gpa, pic); while (self.frames.count() > cap) { - dvui.textureDestroyLater(self.frames.values()[0].tex); + release(gpa, self.frames.values()[0]); self.frames.orderedRemoveAt(0); } } +fn release(gpa: std.mem.Allocator, pic: Picture) void { + dvui.textureDestroyLater(pic.tex); + gpa.free(pic.prints); +} + +/// A print of each column of a picture of `w` by `h` pixels into `out` (`w` long): a hash of its +/// pixels, so two pictures of the same size differ in a column exactly where their prints do +/// (`history_transport.changedSpan`). The bands along the bottom and right edges are left out, as +/// for the content (`contentOf`): a scrollbar moving is not the step's change. +pub fn columnPrints(px: []const dvui.Color.PMA, w: u32, h: u32, out: []u32) void { + @memset(out, 2166136261); + if (w == 0 or h == 0 or px.len < @as(usize, w) * h or out.len < w) return; + const scan_w = w -| @as(u32, @intFromFloat(@ceil(@as(f32, @floatFromInt(w)) * edge_band))); + const scan_h = h -| @as(u32, @intFromFloat(@ceil(@as(f32, @floatFromInt(h)) * edge_band))); + // Row by row, each column's print taking its pixel in turn: in memory order. + for (0..scan_h) |y| { + const row = px[y * w ..][0..scan_w]; + for (row, out[0..scan_w]) |p, *o| o.* = (o.* ^ (@as(u32, p.r) | @as(u32, p.g) << 8 | @as(u32, p.b) << 16 | @as(u32, p.a) << 24)) *% 16777619; + } +} + /// Where in a picture of `w` by `h` pixels its content is: the box round every pixel that differs /// from its background (its bottom-right pixel, where a document least often has anything), as /// fractions of it. Null where none does. The bands along its bottom and right edges are left @@ -145,6 +203,7 @@ pub fn deinit(self: *HistoryFrames, gpa: std.mem.Allocator) void { for (self.frames.values()) |p| dvui.textureDestroyLater(p.tex); if (self.unread) |u| u.target.destroyLater(); } + for (self.frames.values()) |p| gpa.free(p.prints); self.frames.deinit(gpa); } @@ -164,3 +223,32 @@ test "a picture's content is the box round what differs from its background" { px[3 * 8 + 0] = .{ .r = 40, .g = 36, .b = 34, .a = 255 }; try std.testing.expectEqual(c.x, contentOf(&px, 8, 4).?.x); } + +test "a step's change is the span of columns where its picture differs from the one before" { + const bg: dvui.Color.PMA = .{ .r = 30, .g = 30, .b = 34, .a = 255 }; + const ink: dvui.Color.PMA = .{ .r = 220, .g = 180, .b = 120, .a = 255 }; + const w = 40; + const h = 20; + var before: [w * h]dvui.Color.PMA = @splat(bg); + // A mark already there, at the left. + for (2..6) |y| before[y * w + 3] = ink; + var after = before; + // The step draws a stroke across columns 20 to 25, on two rows. + for (20..26) |x| for (8..10) |y| { + after[y * w + x] = ink; + }; + var pa: [w]u32 = undefined; + var pb: [w]u32 = undefined; + columnPrints(&before, w, h, &pa); + columnPrints(&after, w, h, &pb); + const span = rules.changedSpan(&pa, &pb).?; + try std.testing.expectEqual(@as(f32, 20.0 / 40.0), span.lo); + try std.testing.expectEqual(@as(f32, 26.0 / 40.0), span.hi); + // Nothing visible changed: no span, and the slice stays where it was. + try std.testing.expectEqual(@as(?rules.Span, null), rules.changedSpan(&pa, &pa)); + // A change only in the right-hand band (a scrollbar) is not the step's. + var bar = before; + for (0..h) |y| bar[y * w + w - 1] = ink; + columnPrints(&bar, w, h, &pb); + try std.testing.expectEqual(@as(?rules.Span, null), rules.changedSpan(&pa, &pb)); +} diff --git a/app/HistoryTransport.zig b/app/HistoryTransport.zig index 6a41fc69b..7e3fe58b4 100644 --- a/app/HistoryTransport.zig +++ b/app/HistoryTransport.zig @@ -611,9 +611,10 @@ fn paintWindow(vessel: dvui.Rect.Physical, radius: f32, x0: f32, x1: f32, win: r } /// The strip `r` (physical), its ends where the line's are: one picture of the history, each stop -/// a slice of it (`history_transport.stripColumns`). A stop takes the stretch round where its knob -/// sits and shows that stretch of the document as it was then, so across the strip the document is -/// read building up from left to right, and a slice is under the knob that goes to it. The strip +/// a slice of it (`history_transport.stripColumns`), under the knob that goes to it. A stop shows +/// its slice's width of the document as it was then, round where that step changed it +/// (`history_transport.sliceAt`), so across the strip the steps read as the actions themselves; a +/// step that changed nothing visible shows the stretch round where its knob sits. The strip /// frames the document's content, as a recorder's strip frames what changed: the part of the /// pictures round everything any of them holds (`HistoryFrames.Picture.content`), at the strip's /// shape (`history_transport.frameContent`), so a few words at the top of a wide pane fill it @@ -660,7 +661,12 @@ fn paintStrip(r: dvui.Rect.Physical, radius: f32, frames: *const HistoryFrames, const cover = rules.frameContent(content, ta, want, content_pad, content_closest); const fa = (cx0 - r.x) / r.w; const fb = (cx1 - r.x) / r.w; - const uv: dvui.Rect = .{ .x = cover.x + fa * cover.w, .y = cover.y, .w = (fb - fa) * cover.w, .h = cover.h }; + // Where the step changed something, when it did (`HistoryFrames.Picture.change`): the + // strip reads as the steps themselves, each slice its own action. + const sw = (fb - fa) * cover.w; + // Only its own picture's change: a stop borrowing a neighbour's shows its stretch of it. + const change = if (frames.get(stopId(line, c.stop))) |_| pic.change else null; + const uv: dvui.Rect = .{ .x = rules.sliceAt(cover.x + fa * cover.w, sw, change), .y = cover.y, .w = sw, .h = cover.h }; dvui.renderTexture(tex, .{ .r = col, .s = s }, .{ .uv = uv, .corners = corners.scale(1 / s, dvui.CornerRect), .colormod = dvui.Color.white.opacity(grown) }) catch {}; } // The playhead, standing a little past the strip's top and bottom, with a dark edge so it reads diff --git a/app/history_transport.zig b/app/history_transport.zig index 1d33dbaec..bb4a5f22a 100644 --- a/app/history_transport.zig +++ b/app/history_transport.zig @@ -223,6 +223,34 @@ pub fn stripColumns(n: usize, x0: f32, x1: f32, min_w: f32, out: []Column) []Col return out[0..count]; } +/// A stretch across a picture, as fractions of its width: from `lo` to `hi`. +pub const Span = struct { lo: f32, hi: f32 }; + +/// Where two pictures of the same width differ, given each column's print (`HistoryFrames.columnPrints`): +/// from the first column whose prints differ to past the last. Null when none do, or they are not +/// the same width. +pub fn changedSpan(before: []const u32, after: []const u32) ?Span { + if (before.len != after.len or after.len == 0) return null; + var lo: usize = after.len; + var hi: usize = 0; + for (before, after, 0..) |b, a, i| if (a != b) { + lo = @min(lo, i); + hi = i + 1; + }; + if (hi <= lo) return null; + const n: f32 = @floatFromInt(after.len); + return .{ .lo = @as(f32, @floatFromInt(lo)) / n, .hi = @as(f32, @floatFromInt(hi)) / n }; +} + +/// Where a stop's slice of its picture starts, as a fraction across it, the slice `w` of it wide: +/// at `x`, its place in the strip, for a step with no `change` (`HistoryFrames.Picture.change`); +/// else round the middle of what the step changed, so the strip shows each step's own action. +/// Kept inside the picture either way. +pub fn sliceAt(x: f32, w: f32, change: ?f32) f32 { + const c = change orelse return x; + return std.math.clamp(c - w / 2, 0, @max(0, 1 - w)); +} + /// A part of a picture, as fractions of it: 0 to 1 across and down. pub const Frac = struct { x: f32 = 0, y: f32 = 0, w: f32 = 1, h: f32 = 1 }; @@ -424,3 +452,22 @@ test "the strip's content leaves out a picture of something passing over the pan try testing.expectApproxEqAbs(@as(f32, 0.9), contentUnion(&big).?.w, 1e-5); try testing.expectEqual(@as(?Frac, null), contentUnion(&.{})); } + +test "a step's slice stands round what it changed, inside the picture" { + // No change: where the strip puts it. + try testing.expectEqual(@as(f32, 0.3), sliceAt(0.3, 0.1, null)); + // Round the change's middle. + try testing.expectApproxEqAbs(@as(f32, 0.65), sliceAt(0.3, 0.1, 0.7), 1e-6); + // Near an edge: held inside the picture. + try testing.expectEqual(@as(f32, 0), sliceAt(0.3, 0.1, 0.02)); + try testing.expectApproxEqAbs(@as(f32, 0.9), sliceAt(0.3, 0.1, 0.99), 1e-6); + // A change wider than a slice: its middle (from `changedSpan`). + const before = [_]u32{ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 }; + const after = [_]u32{ 1, 2, 2, 2, 2, 2, 2, 1, 1, 1 }; + const span = changedSpan(&before, &after).?; + try testing.expectApproxEqAbs(@as(f32, 0.1), span.lo, 1e-6); + try testing.expectApproxEqAbs(@as(f32, 0.7), span.hi, 1e-6); + try testing.expectApproxEqAbs(@as(f32, 0.35), sliceAt(0.8, 0.1, (span.lo + span.hi) / 2), 1e-6); + // Pictures of different widths are not compared. + try testing.expectEqual(@as(?Span, null), changedSpan(&before, after[0..9])); +} diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 55d4100ea..142a54f50 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -3160,7 +3160,7 @@ fn drawDocSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result { if (recorded) |tr| if (@intFromPtr(tr) != ds.frames_track or tr.steps.items.len != ds.frames_steps) { 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 (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); }; // The commands run on it, as they run; none while a tape seeks, which replays them unseen.