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.