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
130 changes: 109 additions & 21 deletions app/HistoryFrames.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
Expand All @@ -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();
Expand All @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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);
}

Expand All @@ -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));
}
14 changes: 10 additions & 4 deletions app/HistoryTransport.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions app/history_transport.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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]));
}
2 changes: 1 addition & 1 deletion src/editor/Editor.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading