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
122 changes: 103 additions & 19 deletions app/HistoryFrames.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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.
Expand All @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -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| {
Expand All @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
}
14 changes: 12 additions & 2 deletions src/editor/Editor.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading