diff --git a/plugin.zig b/plugin.zig index 11be610..71d22b1 100644 --- a/plugin.zig +++ b/plugin.zig @@ -75,6 +75,8 @@ const vtable: sdk.Plugin.VTable = .{ .applyStep = applyStep, .historyLine = historyLine, .historyGoTo = historyGoTo, + .restoreHistory = restoreHistory, + .documentFingerprint = documentFingerprint, .documentSnapshot = documentSnapshot, .encodeSnapshot = encodeSnapshot, .releaseSnapshot = releaseSnapshot, @@ -224,6 +226,15 @@ fn historyGoTo(_: *anyopaque, doc: DocHandle, id: u64) anyerror!void { try file.history.goTo(file, id); } +fn documentFingerprint(_: *anyopaque, doc: DocHandle) u64 { + return docFile(doc).fingerprint(); +} + +fn restoreHistory(_: *anyopaque, doc: DocHandle, history: sdk.Plugin.RestoredHistory) anyerror!void { + const file = docFile(doc); + try file.history.restore(file, history); +} + fn applyStep(_: *anyopaque, doc: DocHandle, effect: []const u8, motion: []const u8, ms: u32) anyerror!void { try docFile(doc).applyStep(effect, motion, ms > 0); } diff --git a/src/internal/File.zig b/src/internal/File.zig index 0717e12..f2285ae 100644 --- a/src/internal/File.zig +++ b/src/internal/File.zig @@ -1387,6 +1387,42 @@ pub fn reorderRows(file: *File, removed_row_index: usize, insert_before_row_inde } } +/// A hash of what the sprite is (`Plugin.VTable.documentFingerprint`): its grid, each layer by +/// position (its name, whether it shows and folds, and its pixels, a drawing still being shown +/// drawn as it will end), the sprites' origins, and each animation's name and frames. Not its ids, which a sprite loaded from its file does not keep, so the +/// same sprite closed and opened again hashes the same: how fizzy knows a kept track ends where it +/// stands (`restoreHistory`). +pub fn fingerprint(file: *const File) u64 { + var h: std.hash.Wyhash = .init(0); + h.update(std.mem.asBytes(&[_]u32{ file.column_width, file.row_height, file.columns, file.rows, @intCast(file.layers.len) })); + for (0..file.layers.len) |i| { + const layer = file.layers.get(i); + h.update(std.mem.asBytes(&@as(u64, layer.name.len))); + h.update(layer.name); + h.update(&.{ @intFromBool(layer.visible), @intFromBool(layer.collapse) }); + const px = layer.pixels(); + if (file.editor.reveal) |*r| if (r.layer_id == layer.id) { + // Whole, as `captureState` takes it; out of memory, as it stands. + const gpa = runtime.allocator(); + if (gpa.dupe([4]u8, px)) |whole| { + defer gpa.free(whole); + r.complete(whole); + h.update(std.mem.sliceAsBytes(whole)); + continue; + } else |_| {} + }; + h.update(std.mem.sliceAsBytes(px)); + } + h.update(std.mem.sliceAsBytes(file.sprites.items(.origin))); + h.update(std.mem.asBytes(&@as(u64, file.animations.len))); + for (file.animations.items(.name), file.animations.items(.frames)) |name, frames| { + h.update(std.mem.asBytes(&[_]u64{ name.len, frames.len })); + h.update(name); + for (frames) |f| h.update(std.mem.asBytes(&[_]u64{ f.sprite_index, f.ms })); + } + return h.final(); +} + /// The layers' pixels, by layer id, as a demo's snapshot holds them (`Plugin.captureDocumentState`), /// and whether the sprite was saved: a drawing still being shown drawn is in them whole. pub fn captureState(file: *File, allocator: std.mem.Allocator) ![]u8 { diff --git a/src/internal/History.zig b/src/internal/History.zig index 28eae8d..5ab1b33 100644 --- a/src/internal/History.zig +++ b/src/internal/History.zig @@ -292,8 +292,6 @@ showing_order: []const u32 = &.{}, showing_marks: []const pixi.internal.File.Reveal.Mark = &.{}, /// Moves whenever the line does: an entry added, undone or redone (`line`). version: u64 = 0, -/// Going to a step (`goTo`): each undo and redo on the way is not announced by a toast of its own. -quiet: bool = false, /// Who last went back (`undoRedo`'s undo): the steps an edit after it adds for the jump are theirs /// (`keepGoneBack`). went_back_by: sdk.Host.Acting = .{}, @@ -534,144 +532,6 @@ pub fn undoRedo(self: *History, file: *pixi.internal.File, action: Action) !void const entry_id = active_ids.pop() orelse 0; self.version += 1; - defer if (!self.quiet) { - // Microseconds-since-epoch (~1.7e15) overflows a 32-bit `usize` on wasm32, so a - // direct `@intCast` to `usize` crashes the safe-mode build with an "integer cast - // truncates value" panic every time the user undoes/redoes. `id_extra` only needs - // to be a salt that varies between toasts, so truncate via u128 → low bits of usize. - const ts_us: u128 = @intCast(@divTrunc(pixi.perf.nanoTimestamp(), 1000)); - const id_mutex = dvui.toastAdd(dvui.currentWindow(), @src(), @truncate(ts_us), file.editor.canvas.id, pixi.core.dialogs.toastDisplay, 2_000_000); - const id = id_mutex.id; - const action_text = switch (action) { - .undo => "Undo:", - .redo => "Redo:", - }; - var message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s}", .{action_text}) catch "Invalid change"; - - switch (change) { - .pixels => |*pixels| { - for (file.layers.items(.name), file.layers.items(.id)) |name, layer_id| { - if (layer_id == pixels.layer_id) { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Layer {s} pixels modified", .{ action_text, name }) catch "Invalid change"; - break; - } - } - }, - .origins => { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Sprite origins modified", .{action_text}) catch "Invalid change"; - }, - .animation_name => |*animation_name| { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Animation name {s} -> {s}", .{ - action_text, - animation_name.name, - file.animations.items(.name)[animation_name.index], - }) catch "Invalid change"; - }, - .animation_frames => |*animation_frames| { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Animation {s} frames modified", .{ - action_text, - file.animations.items(.name)[animation_frames.index], - }) catch "Invalid change"; - }, - .animation_settings => |*animation_settings| { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Animation {s} settings modified", .{ - action_text, - file.animations.items(.name)[animation_settings.index], - }) catch "Invalid change"; - }, - .animation_order => { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Animations order modified", .{action_text}) catch "Invalid change"; - }, - .animation_restore_delete => |*animation_restore_delete| { - switch (animation_restore_delete.action) { - .restore => { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Animation {s} deleted", .{ - action_text, - file.deleted_animations.items(.name)[file.deleted_animations.len - 1], - }) catch "Invalid change"; - }, - .delete => { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Animation {s} created", .{ - action_text, - file.animations.items(.name)[animation_restore_delete.index], - }) catch "Invalid change"; - }, - } - }, - .layers_order => { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Layers order modified", .{action_text}) catch "Invalid change"; - }, - .layer_restore_delete => |*layer_restore_delete| { - switch (layer_restore_delete.action) { - .restore => { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Layer {s} deleted", .{ - action_text, - file.deleted_layers.items(.name)[file.deleted_layers.len - 1], - }) catch "Invalid change"; - }, - .delete => { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Layer {s} created", .{ - action_text, - file.layers.items(.name)[layer_restore_delete.index], - }) catch "Invalid change"; - }, - } - }, - .layer_name => |*layer_name| { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Layer name {s} -> {s}", .{ - action_text, - layer_name.name, - file.layers.items(.name)[layer_name.index], - }) catch "Invalid change"; - }, - .layer_settings => |*layer_settings| { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Layer {s} settings modified", .{ - action_text, - file.layers.items(.name)[layer_settings.index], - }) catch "Invalid change"; - }, - .resize => { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} File resized to {d}x{d}", .{ - action_text, - file.width(), - file.height(), - }) catch "Invalid change"; - }, - .reorder_col_row => |*reorder| { - const removed = reorder.removed_index; - const insert_before = reorder.insert_before_index; - switch (reorder.mode) { - .columns => { - const removed_column_name = file.fmtColumn(dvui.currentWindow().arena(), removed) catch "Invalid change"; - const insert_before_column_name = file.fmtColumn(dvui.currentWindow().arena(), insert_before - 1) catch "Invalid change"; - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Column {s} moved to {s}", .{ action_text, insert_before_column_name, removed_column_name }) catch "Invalid change"; - }, - .rows => { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Row {d} moved to {d}", .{ action_text, insert_before, removed }) catch "Invalid change"; - }, - } - }, - .reorder_cell => { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Cells reordered", .{action_text}) catch "Invalid change"; - }, - .layer_merge => { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Layer merge", .{action_text}) catch "Invalid change"; - }, - .grid_layout => { - message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s} Grid layout {d}×{d} cells of {d}×{d}", .{ - action_text, - file.columns, - file.rows, - file.column_width, - file.row_height, - }) catch "Invalid change"; - }, - } - - dvui.dataSetSlice(dvui.currentWindow(), id, "_message", message); - id_mutex.mutex.unlock(dvui.io); - }; - try self.applyChange(file, &change, action); try other_ids.ensureUnusedCapacity(runtime.allocator(), 1); try other_stack.append(change); @@ -1035,8 +895,8 @@ pub fn stepsTo(self: *const History, id: u64) ?isize { return null; } -/// Undo or redo until step `id` is the last applied (0: before the first), quietly: one move along -/// the line, not a toast per step, and the person's layer and sidebar as they were rather than +/// Undo or redo until step `id` is the last applied (0: before the first), as one move along +/// the line: the person's layer and sidebar as they were rather than /// wherever each step on the way would put them. `error.NoSuchStep` when `id` is not on it, and /// `error.PersonBusy` while the person has a stroke or a transform open (undo under an open stroke /// would leave its record of what it drew over wrong); nothing moves for either. A step on the way @@ -1046,11 +906,7 @@ pub fn goTo(self: *History, file: *pixi.internal.File, id: u64) !void { if (n == 0) return; if (@import("../commands.zig").personBusy(file)) return error.PersonBusy; var held: Held = .take(file); - self.quiet = true; - defer { - self.quiet = false; - held.put(file); - } + defer held.put(file); const back: Action = if (n < 0) .redo else .undo; for (0..@abs(n)) |done| { self.undoRedo(file, if (n < 0) .undo else .redo) catch |err| { @@ -1109,12 +965,10 @@ fn keepGoneBack(self: *History, file: *pixi.internal.File, edit: *Change) void { const back_to = self.top(); var held: Held = .take(file); self.keeping = true; - self.quiet = true; const outer = host.actAgain(self.went_back_by); defer { host.endAct(outer); self.keeping = false; - self.quiet = false; held.put(file); } self.applyChange(file, edit, .undo) catch |err| { @@ -1263,9 +1117,9 @@ fn record(file: *pixi.internal.File, kind: sdk.Step.Kind, id: u64, change: *cons if (!host.recording(file.id)) return host.recordStep(file.id, .{ .kind = kind, .id = id, .top = on_top }); const pixels = switch (change.*) { .pixels => |p| p, - else => return host.recordStep(file.id, .{ .kind = .restarted, .id = 0 }), + else => return unencoded(file, kind, id, on_top), }; - const layer = file.getLayer(pixels.layer_id) orelse return host.recordStep(file.id, .{ .kind = .restarted, .id = 0 }); + const layer = file.getLayer(pixels.layer_id) orelse return unencoded(file, kind, id, on_top); // The frame's arena: the recorder copies what it keeps. const arena = host.arena(); const px = layer.pixels(); @@ -1287,7 +1141,19 @@ fn record(file: *pixi.internal.File, kind: sdk.Step.Kind, id: u64, change: *cons // A step not kept would leave the track behind the sprite, every later step written against // a sprite its replay never reaches: it starts again from the sprite instead. std.log.err("pixi: a step of {d} was not recorded (out of memory); its track starts again", .{file.id}); + unencoded(file, kind, id, on_top); +} + +/// A step `record` cannot encode (a kind of change it does not yet, or one that failed): the +/// recorder starts again from the sprite as it is after it (`restarted`), which also clears who +/// fizzy knows made each step before. A step added is then reported itself, with no effect, so +/// fizzy keeps who made it and the top (Undo's label, "Go back to before Claude's change"); the +/// recorder's new track drops it with whatever came before its start. An undo or redo is not: +/// fizzy would read the step it names as the person's, the authors being gone. +fn unencoded(file: *pixi.internal.File, kind: sdk.Step.Kind, id: u64, on_top: u64) void { + const host = runtime.state().host; host.recordStep(file.id, .{ .kind = .restarted, .id = 0 }); + if (kind == .added) host.recordStep(file.id, .{ .kind = kind, .id = id, .top = on_top }); } /// The history started over as far as a recorder can follow: `file` was put back in place to a @@ -1297,6 +1163,78 @@ pub fn restarted(file: *pixi.internal.File) void { host.recordStep(file.id, .{ .kind = .restarted, .id = 0 }); } +/// Put the history back as fizzy's track of the sprite recorded it +/// (`sdk.Plugin.VTable.restoreHistory`), in place of what it holds: a sprite opened again picks up +/// its undo. Each entry is one pixel step (`steps`; pixi never joins one to another), kept with +/// its id, the first `applied` of them applied. The sprite already stands where they leave it, so +/// nothing in it changes: they are checked against it first (`steps.fits`), and one that does not +/// fit is `error.StepDoesNotApply`, the history as it was. A saved sprite stays saved. An empty +/// history clears it ("Forget this document's history"). +pub fn restore(self: *History, file: *pixi.internal.File, history: sdk.Plugin.RestoredHistory) !void { + if (history.applied > history.entries.len) return error.BadStep; + // Put back where one is being made (fizzy's track starting again after a step pixi does not + // encode), it would take the steps made since: only a sprite with none takes one. + if (history.entries.len > 0 and (self.undo_stack.items.len > 0 or self.redo_stack.items.len > 0)) return error.HistoryInUse; + const gpa = runtime.allocator(); + { + // Ids name entries for good: never 0, never the unsaved mark (`saved`), never twice. + var ids: std.AutoHashMapUnmanaged(u64, void) = .empty; + defer ids.deinit(gpa); + for (history.entries) |e| { + if (e.id == 0 or e.id == std.math.maxInt(u64) or e.parts.len != 1) return error.BadStep; + if ((try ids.getOrPut(gpa, e.id)).found_existing) return error.BadStep; + } + } + file.finishReveal(); + const decoded = try gpa.alloc(steps.Pixels, history.entries.len); + var made: usize = 0; + defer { + for (decoded[0..made]) |d| d.deinit(gpa); + gpa.free(decoded); + } + for (history.entries, decoded) |e, *d| { + d.* = try steps.decode(gpa, e.parts[0]); + made += 1; + } + { + const layers = try gpa.alloc([]const [4]u8, file.layers.len); + defer gpa.free(layers); + for (layers, 0..) |*l, i| l.* = file.layers.get(i).pixels(); + try steps.fits(gpa, layers, decoded, history.applied); + } + const n_redo = decoded.len - history.applied; + try self.undo_stack.ensureTotalCapacity(history.applied); + try self.redo_stack.ensureTotalCapacity(n_redo); + try self.undo_ids.ensureTotalCapacity(gpa, history.applied); + try self.redo_ids.ensureTotalCapacity(gpa, n_redo); + + const was_saved = self.top() == self.saved; + self.clearRetainingCapacity(); + // Each entry as a change, holding what undoing (applied) or redoing it writes, its colours + // before or after: the decoded buffers move over, and the other side is freed. + const layer_ids = file.layers.items(.id); + for (decoded, history.entries, 0..) |*d, e, i| { + const applied = i < history.applied; + const change: Change = .{ .pixels = .{ .layer_id = layer_ids[@intCast(d.layer)], .indices = d.indices, .values = if (applied) d.before else d.after } }; + gpa.free(if (applied) d.after else d.before); + d.* = .{ .layer = d.layer, .indices = d.indices[0..0], .before = d.before[0..0], .after = d.after[0..0] }; + if (applied) { + self.undo_stack.appendAssumeCapacity(change); + self.undo_ids.appendAssumeCapacity(e.id); + } else { + // The redo stack's top is the next to redo: the first entry past `applied`, put in + // its place below. + self.redo_stack.appendAssumeCapacity(change); + self.redo_ids.appendAssumeCapacity(e.id); + } + self.next_id = @max(self.next_id, e.id + 1); + } + std.mem.reverse(Change, self.redo_stack.items); + std.mem.reverse(u64, self.redo_ids.items); + // Saved on the step now on top, or unsaved naming a step that is gone (ids are never reused). + self.saved = if (was_saved) self.top() else std.math.maxInt(u64); +} + /// The id of the step on top, the last one applied; 0 when none is. pub fn top(self: *const History) u64 { return self.undo_ids.getLastOrNull() orelse 0; diff --git a/src/internal/steps.zig b/src/internal/steps.zig index 33f599a..d671872 100644 --- a/src/internal/steps.zig +++ b/src/internal/steps.zig @@ -274,10 +274,78 @@ pub fn apply(px: [][4]u8, step: Pixels) error{StepDoesNotApply}!void { for (step.indices, step.after) |i, a| px[i] = a; } +/// Whether a history of steps fits a sprite whose layers are `layers` (by position): with the +/// first `applied` of `history` applied, undoing each of them, newest first, must find its "after" +/// colours and leave its "before", and redoing each of the rest, oldest first, must find its +/// "before" from where the sprite stands. `error.StepDoesNotApply` when one does not; `layers` are +/// not written. The walk is on a copy of each layer a step touches, made once. +pub fn fits(gpa: std.mem.Allocator, layers: []const []const [4]u8, history: []const Pixels, applied: usize) Error!void { + if (applied > history.len) return error.StepDoesNotApply; + const copies = try gpa.alloc(?[][4]u8, layers.len); + @memset(copies, null); + defer { + for (copies) |c| if (c) |px| gpa.free(px); + gpa.free(copies); + } + var i = applied; + while (i > 0) { + i -= 1; + try walk(gpa, copies, layers, history[i], history[i].after, history[i].before); + } + // Forward from where the sprite stands: the copies made so far, back as they are. + for (copies, 0..) |c, l| if (c) |px| @memcpy(px, layers[l]); + for (history[applied..]) |s| try walk(gpa, copies, layers, s, s.before, s.after); +} + +/// One step of `fits`'s walk: each pixel of `s` must hold `from` in its layer's copy (made from +/// `layers` the first time), and then holds `to`. +fn walk(gpa: std.mem.Allocator, copies: []?[][4]u8, layers: []const []const [4]u8, s: Pixels, from: []const [4]u8, to: []const [4]u8) Error!void { + if (s.layer >= layers.len) return error.StepDoesNotApply; + const l: usize = @intCast(s.layer); + const px = copies[l] orelse made: { + const c = try gpa.dupe([4]u8, layers[l]); + copies[l] = c; + break :made c; + }; + for (s.indices, from) |idx, f| { + if (idx >= px.len or !std.mem.eql(u8, &px[idx], &f)) return error.StepDoesNotApply; + } + for (s.indices, to) |idx, t| px[idx] = t; +} + // ---- tests --------------------------------------------------------------------------------- const testing = std.testing; +test "a history fits the sprite it leads to, and not another" { + const clear: [4]u8 = .{ 0, 0, 0, 0 }; + const red: [4]u8 = .{ 255, 0, 0, 255 }; + const blue: [4]u8 = .{ 0, 0, 255, 255 }; + var idx = [_]usize{ 2, 3 }; + var none = [_][4]u8{ clear, clear }; + var reds = [_][4]u8{ red, red }; + var blues = [_][4]u8{ blue, blue }; + // Red over clear at 2 and 3, then blue over red there, on the second layer; the blue undone. + const history = [_]Pixels{ + .{ .layer = 1, .indices = &idx, .before = &none, .after = &reds }, + .{ .layer = 1, .indices = &idx, .before = &reds, .after = &blues }, + }; + const bottom: [8][4]u8 = @splat(clear); + var top: [8][4]u8 = @splat(clear); + top[2] = red; + top[3] = red; + const layers = [_][]const [4]u8{ &bottom, &top }; + try fits(testing.allocator, &layers, &history, 1); + // Not with both applied: the sprite does not hold the blue. + try testing.expectError(error.StepDoesNotApply, fits(testing.allocator, &layers, &history, 2)); + // Nor with none: the red would be over the clear it found. + try testing.expectError(error.StepDoesNotApply, fits(testing.allocator, &layers, &history, 0)); + // Nor on a sprite with one layer. + try testing.expectError(error.StepDoesNotApply, fits(testing.allocator, layers[0..1], &history, 1)); + // An empty history fits anything. + try fits(testing.allocator, &layers, &.{}, 0); +} + fn bytesOf(layer: u64, indices: []const usize, before: []const [4]u8, after: []const [4]u8) ![]u8 { var out: std.Io.Writer.Allocating = .init(testing.allocator); defer out.deinit();