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
67 changes: 57 additions & 10 deletions app/ActivityFeed.zig
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
//! The commands just run on one document (`Host.activity`), shown small at the bottom right of
//! its pane as they run: each fades in, stays a moment and fades out, the older ones rising as
//! newer ones come. Drawn on a layer that takes no input, so it never covers the work, and on wall
//! time, so whatever runs the commands (a person, an agent, a tape) shows the same.
//! time, so whatever runs the commands (a person, an agent, a tape) shows the same. One that
//! offers an action ("Undid Claude's Draw" with its Redo) takes clicks on its button only, and
//! stays longer to be reached.
const ActivityFeed = @This();

const std = @import("std");
Expand All @@ -16,6 +18,8 @@ seen: u64 = 0,

const max = 5;
const life_ns: i128 = 2400 * std.time.ns_per_ms;
/// An item with an action stays this long.
const action_life_ns: i128 = 6000 * std.time.ns_per_ms;
const fade_ns: i128 = 250 * std.time.ns_per_ms;
const pad: f32 = 8;
const gap: f32 = 4;
Expand All @@ -24,6 +28,19 @@ const Item = struct {
title: [48]u8,
len: u8,
born: i128,
action: ?Action = null,

fn life(self: Item) i128 {
return if (self.action != null) action_life_ns else life_ns;
}
};

/// A button on an item: its label, and the caller's own tag for it, which `draw` answers when
/// it is pressed.
const Action = struct {
label: [16]u8,
len: u8,
tag: u64,
};

/// A feed showing only what runs from now on.
Expand Down Expand Up @@ -57,14 +74,26 @@ pub fn push(self: *ActivityFeed, title: []const u8, now: i128) void {
self.len += 1;
}

/// Draw the feed in the bottom right of `area` (physical), newest lowest.
pub fn draw(self: *ActivityFeed, area: dvui.Rect.Physical, id_extra: usize, now: i128) void {
// The oldest go first.
while (self.len > 0 and now - self.items[0].born > life_ns) {
std.mem.copyForwards(Item, self.items[0 .. self.len - 1], self.items[1..self.len]);
self.len -= 1;
/// `push`, with a button labelled `label` on it: `draw` answers `tag` when it is pressed.
pub fn pushAction(self: *ActivityFeed, title: []const u8, label: []const u8, tag: u64, now: i128) void {
self.push(title, now);
var action: Action = .{ .label = undefined, .len = @intCast(@min(label.len, 16)), .tag = tag };
@memcpy(action.label[0..action.len], label[0..action.len]);
self.items[self.len - 1].action = action;
}

/// Draw the feed in the bottom right of `area` (physical), newest lowest. Answers the tag of the
/// action pressed this frame, if one was.
pub fn draw(self: *ActivityFeed, area: dvui.Rect.Physical, id_extra: usize, now: i128) ?u64 {
// Those whose time is up go.
var kept: usize = 0;
for (self.items[0..self.len]) |item| {
if (now - item.born > item.life()) continue;
self.items[kept] = item;
kept += 1;
}
if (self.len == 0) return;
self.len = kept;
if (self.len == 0) return null;
dvui.refresh(null, @src(), null);

const r = area.toNatural();
Expand All @@ -89,9 +118,10 @@ pub fn draw(self: *ActivityFeed, area: dvui.Rect.Physical, id_extra: usize, now:
const age = now - item.born;
// In, then out, over `fade_ns` each; rising into place as it comes in.
const in = @min(1, @as(f32, @floatFromInt(age)) / @as(f32, @floatFromInt(fade_ns)));
const out = @min(1, @as(f32, @floatFromInt(life_ns - age)) / @as(f32, @floatFromInt(fade_ns)));
const out = @min(1, @as(f32, @floatFromInt(item.life() - age)) / @as(f32, @floatFromInt(fade_ns)));
const alpha = @max(0, @min(in, out));
const w = font.textSize(item.title[0..item.len]).w + pad * 2;
const action_w = if (item.action) |a| font.textSize(a.label[0..a.len]).w + pad * 2 else 0;
const w = font.textSize(item.title[0..item.len]).w + pad * 2 + action_w;
const rect: dvui.Rect = .{ .x = r.w - 12 - w, .y = bottom - h + (1 - in) * h * 0.5, .w = w, .h = h };
// Glass has no fade: it grows in from, and shrinks out to, a little smaller.
const k = 0.6 + 0.4 * alpha;
Expand All @@ -102,6 +132,7 @@ pub fn draw(self: *ActivityFeed, area: dvui.Rect.Physical, id_extra: usize, now:
bottom -= (h + gap) * in;
}
core.dialogs.glassBubbles(layer.data().id, bubbles[0..self.len], rs.s, .{});
var pressed: ?u64 = null;
for (self.items[0..self.len], rects[0..self.len], alphas[0..self.len], 0..) |item, rect, alpha, n| {
dvui.labelNoFmt(@src(), item.title[0..item.len], .{}, .{
.rect = rect,
Expand All @@ -111,5 +142,21 @@ pub fn draw(self: *ActivityFeed, area: dvui.Rect.Physical, id_extra: usize, now:
.margin = .{},
.color_text = .{ .color = ink.opacity(0.85 * alpha) },
});
const a = item.action orelse continue;
// The button alone takes input, on a layer of its own over the feed's.
const bw = font.textSize(a.label[0..a.len]).w + pad * 2;
var hit: dvui.FloatingWidget = undefined;
hit.init(@src(), .{}, .{ .rect = .{ .x = r.x + rect.x + rect.w - bw, .y = r.y + rect.y, .w = bw, .h = rect.h }, .id_extra = id_extra *% 8 +% n });
defer hit.deinit();
if (dvui.button(@src(), a.label[0..a.len], .{}, .{
.expand = .both,
.font = font,
.padding = .{ .x = pad, .y = pad / 2, .w = pad, .h = pad / 2 },
.margin = .{},
.background = false,
.border = .{},
.color_text = .{ .color = dvui.themeGet().color(.highlight, .fill).opacity(alpha) },
})) pressed = a.tag;
}
return pressed;
}
63 changes: 59 additions & 4 deletions src/editor/Editor.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2960,7 +2960,7 @@ fn drawDocSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result {
if (claim_pill == null) if (ds.transport.place(area, now)) |bar| {
area.h = @min(area.h, bar.y - area.y + 6 * dvui.windowRectScale().s);
};
ds.feed.draw(area, @truncate(ds.doc_id), now);
if (ds.feed.draw(area, @truncate(ds.doc_id), now)) |step| editor.redoFromFeed(doc, step);
}

// A menu inside the document only when its owner names one. The context is created *after*
Expand Down Expand Up @@ -6147,19 +6147,74 @@ pub fn redo(editor: *Editor) !void {

/// An undo or a redo of the active document, through its owner's command: its transport shows,
/// and a key held down goes further each press (`HistoryTransport.press`), the command run that
/// many times. Nothing to undo, or a claimed document: the command as ever, and no bar.
fn historyStep(editor: *Editor, action: enum { undo, redo }) !void {
/// many times. Nothing to undo, or a claimed document: the command as ever, and no bar. An undo of
/// a step someone else made says so on the document, with a Redo (`ActivityFeed.pushAction`), in
/// place of the plain "Undo" its run would show.
fn historyStep(editor: *Editor, action: HistoryAction) !void {
const doc = editor.activeDoc() orelse return;
const name = @tagName(action);
const shows = editor.app.host.documentClaim(doc.id) == null and editor.activeDocCommandEnabled(name);
const host = &editor.app.host;
const shows = host.documentClaim(doc.id) == null and editor.activeDocCommandEnabled(name);
const ds = if (shows) editor.doc_surfaces.get(doc.id) else null;
const by = if (ds) |d| d.transport.press(dvui.frameTimeNS(), action == .redo) else 1;
for (0..by) |_| {
if (!editor.activeDocCommandEnabled(name)) break;
// Who made a step is kept only once someone besides the person has acted on it.
const others: ?StepToTake = if (action == .undo and host.step_authors.contains(doc.id)) blk: {
const t = editor.stepToTake(doc, .undo, host.arena()) catch break :blk null;
break :blk if (t.by.kind == .person) null else t;
} else null;
const t = others orelse {
try editor.runActiveDocCommand(name);
continue;
};
// The run's own "Undo" goes unrecorded (as while a demo seeks), so the bubble below is
// the one it shows. The flag is put back before the feed draws.
const muted = host.activity_muted;
host.activity_muted = true;
defer host.activity_muted = muted;
try editor.runActiveDocCommand(name);
var buf: [96]u8 = undefined;
const said = std.fmt.bufPrint(&buf, "Undid {s}'s {s}", .{ authorName(t.by), t.step.title }) catch "Undid a change";
if (editor.doc_surfaces.get(doc.id)) |d| d.feed.pushAction(said, "Redo", t.step.id, dvui.frameTimeNS());
}
}

/// Redo `doc`'s step `id` from its bubble's Redo: only while it is still the next one to redo.
pub fn redoFromFeed(editor: *Editor, doc: sdk.DocHandle, id: u64) void {
const t = editor.stepToTake(doc, .redo, editor.app.host.arena()) catch return;
if (t.step.id == id) _ = doc.owner.historyGoTo(doc, t.to);
}

pub const HistoryAction = enum { undo, redo };

/// The step an undo or a redo of a document would take, the step it leaves on top (`to`, for
/// `historyGoTo`; 0 before the first), and who made it.
pub const StepToTake = struct { step: sdk.Plugin.HistoryLine.Entry, to: u64, by: sdk.Author };

/// What an undo or a redo of `doc` would take, from its owner's line (`historyLine`):
/// `error.NoLine` when the owner keeps none, `error.Nothing` when there is nothing to take.
pub fn stepToTake(editor: *Editor, doc: sdk.DocHandle, action: HistoryAction, arena: std.mem.Allocator) error{ NoLine, Nothing }!StepToTake {
const line = doc.owner.historyLine(doc, arena) orelse return error.NoLine;
const step: sdk.Plugin.HistoryLine.Entry, const to: u64 = switch (action) {
.undo => blk: {
if (line.at == 0) return error.Nothing;
break :blk .{ line.entries[line.at - 1], if (line.at >= 2) line.entries[line.at - 2].id else 0 };
},
.redo => blk: {
if (line.at >= line.entries.len) return error.Nothing;
break :blk .{ line.entries[line.at], line.entries[line.at].id };
},
};
return .{ .step = step, .to = to, .by = editor.app.host.stepAuthor(doc.id, step.id) };
}

/// Who made a step, as a person reads it: "the person", else the author's label ("Claude").
pub fn authorName(a: sdk.Author) []const u8 {
if (a.kind == .person) return "the person";
return if (a.label.len > 0) a.label else @tagName(a.kind);
}

pub fn openInFileBrowser(editor: *Editor, path: []const u8) !void {
// Darwin goes through `darwin_spawn` rather than `std.process.run`: the latter walks the
// `environ` array captured at startup, which any `unsetenv` elsewhere in the process (SDL,
Expand Down
41 changes: 16 additions & 25 deletions src/editor/Keybinds.zig
Original file line number Diff line number Diff line change
Expand Up @@ -442,40 +442,31 @@ fn cmdRedoWith(state: *anyopaque, args: HistoryStep.Args, call: *sdk.Command.Cal
/// `historyGoTo`), so it reaches a document that is not active. With `expect`, only when that
/// step is the one it would take, so a caller never takes back a step made since it looked
/// (`plans/SHARED_HISTORY_PLAN.md`, step 5). Answers the step and whose it was.
fn historyStepWith(editor: *Editor, args: HistoryStep.Args, call: *sdk.Command.Call, action: enum { undo, redo }) !void {
fn historyStepWith(editor: *Editor, args: HistoryStep.Args, call: *sdk.Command.Call, action: Editor.HistoryAction) !void {
const doc = if (args.path.len == 0)
editor.activeDoc() orelse return call.fail("no document is active", .{})
else
editor.docFromPath(args.path) orelse return call.fail("no open document at {s}", .{args.path});
const line = doc.owner.historyLine(doc, call.arena) orelse {
const t = editor.stepToTake(doc, action, call.arena) catch |err| switch (err) {
error.Nothing => return call.fail("nothing to {s}", .{@tagName(action)}),
// An owner with no line undoes the active document only, through its own command.
const active = editor.activeDoc();
if (args.expect != 0 or active == null or active.?.id != doc.id)
return call.fail("{s} keeps no history line, so it can only undo the active document, with no expect", .{doc.owner.id});
return switch (action) {
.undo => editor.undo(),
.redo => editor.redo(),
};
};
const host = &editor.app.host;
const step: sdk.Plugin.HistoryLine.Entry, const to: u64 = switch (action) {
.undo => blk: {
if (line.at == 0) return call.fail("nothing to undo", .{});
break :blk .{ line.entries[line.at - 1], if (line.at >= 2) line.entries[line.at - 2].id else 0 };
},
.redo => blk: {
if (line.at >= line.entries.len) return call.fail("nothing to redo", .{});
break :blk .{ line.entries[line.at], line.entries[line.at].id };
error.NoLine => {
const active = editor.activeDoc();
if (args.expect != 0 or active == null or active.?.id != doc.id)
return call.fail("{s} keeps no history line, so it can only {s} the active document, with no expect", .{ doc.owner.id, @tagName(action) });
return switch (action) {
.undo => editor.undo(),
.redo => editor.redo(),
};
},
};
const whose = host.stepAuthor(doc.id, step.id);
const by = if (whose.kind == .person) "the person" else if (whose.label.len > 0) whose.label else @tagName(whose.kind);
if (args.expect != 0 and args.expect != step.id) return call.fail(
const by = Editor.authorName(t.by);
if (args.expect != 0 and args.expect != t.step.id) return call.fail(
"step {d} is not the one to {s}: that is step {d}, {s}, by {s}. The document changed since; fix it forward",
.{ args.expect, @tagName(action), step.id, step.title, by },
.{ args.expect, @tagName(action), t.step.id, t.step.title, by },
);
if (!doc.owner.historyGoTo(doc, to)) return call.fail("{s} could not {s} step {d}", .{ doc.owner.id, @tagName(action), step.id });
try call.returns(.{ .step = step.id, .title = step.title, .by = by });
if (!doc.owner.historyGoTo(doc, t.to)) return call.fail("{s} could not {s} step {d}", .{ doc.owner.id, @tagName(action), t.step.id });
try call.returns(.{ .step = t.step.id, .title = t.step.title, .by = by });
}
/// Set while `tick` is running a command because of a live key event. That event is never
/// marked handled, so it goes on to reach the focused widget under its own steam — a clipboard
Expand Down
25 changes: 23 additions & 2 deletions src/editor/menu_model.zig
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,27 @@ fn canSaveAll(editor: *Editor) bool {
return false;
}

/// "Undo Claude's Draw" when the step it would take is someone else's; "Undo" for the person's
/// own, so working alone reads as it always has.
fn undoTitle(editor: *Editor) [:0]const u8 {
return historyTitle(editor, .undo, "Undo", &undo_title);
}
fn redoTitle(editor: *Editor) [:0]const u8 {
return historyTitle(editor, .redo, "Redo", &redo_title);
}

var undo_title: [96:0]u8 = undefined;
var redo_title: [96:0]u8 = undefined;

fn historyTitle(editor: *Editor, action: Editor.HistoryAction, plain: [:0]const u8, buf: *[96:0]u8) [:0]const u8 {
const doc = editor.activeDoc() orelse return plain;
// Who made a step is kept only once someone besides the person has acted on the document.
if (!editor.app.host.step_authors.contains(doc.id)) return plain;
const t = editor.stepToTake(doc, action, editor.app.host.arena()) catch return plain;
if (t.by.kind == .person) return plain;
return std.fmt.bufPrintZ(buf, "{s} {s}'s {s}", .{ plain, Editor.authorName(t.by), t.step.title }) catch plain;
}

fn canUndo(editor: *Editor) bool {
return editor.activeDocCommandEnabled("undo");
}
Expand Down Expand Up @@ -193,8 +214,8 @@ const edit_items = [_]Item{
.native_always_enabled = true,
} },
.separator,
.{ .command = .{ .id = "fizzy.undo", .title = .{ .static = "Undo" }, .sf_symbol = "arrow.uturn.backward", .enabled = canUndo } },
.{ .command = .{ .id = "fizzy.redo", .title = .{ .static = "Redo" }, .sf_symbol = "arrow.uturn.forward", .enabled = canRedo } },
.{ .command = .{ .id = "fizzy.undo", .title = .{ .dynamic = undoTitle }, .sf_symbol = "arrow.uturn.backward", .enabled = canUndo } },
.{ .command = .{ .id = "fizzy.redo", .title = .{ .dynamic = redoTitle }, .sf_symbol = "arrow.uturn.forward", .enabled = canRedo } },
// Transform / Grid Layout are pixel-art concepts, not fizzy's own — pixi parents its own
// section here rather than fizzy knowing about them.
.{ .plugin_section = "fizzy.menu.edit" },
Expand Down
26 changes: 26 additions & 0 deletions tests/integration.zig
Original file line number Diff line number Diff line change
Expand Up @@ -7454,6 +7454,32 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes
try std.testing.expectEqualStrings(before, (try read(host, notes_args)).text);
}

// The person's Cmd-Z taking a step someone else made: the menu names it, and the pane says
// so with a Redo that puts it back.
{
const front = editor.activeDoc() orelse return error.TestUnexpectedResult;
const before = (try read(host, "")).text;
const front_write = try std.fmt.allocPrint(gpa, ".{{ .path = \"{s}\", .text = \"// by Claude\\n\" }}", .{main_zig});
defer gpa.free(front_write);
const outer = host.act(.{ .kind = .agent, .plugin = "test", .session = 1, .label = "Claude" });
try std.testing.expect(host.callCommand("text.write", front_write, host.arena()) == .ok);
host.endAct(outer);
const taken = try editor.stepToTake(front, .undo, host.arena());
try std.testing.expectEqualStrings("Claude", fizzy.Editor.authorName(taken.by));

try editor.undo();
try std.testing.expectEqualStrings(before, (try read(host, "")).text);
const ds = editor.doc_surfaces.get(front.id) orelse return error.TestUnexpectedResult;
const said = ds.feed.items[ds.feed.len - 1];
try std.testing.expect(std.mem.startsWith(u8, said.title[0..said.len], "Undid Claude's "));
const redo = said.action orelse return error.TestUnexpectedResult;
try std.testing.expectEqual(taken.step.id, redo.tag);
editor.redoFromFeed(front, redo.tag);
try std.testing.expectEqualStrings("// by Claude\n", (try read(host, "")).text);
try editor.undo();
try std.testing.expectEqualStrings(before, (try read(host, "")).text);
}

// Save and Close, the unsaved-changes dialog's: the same write Save makes, then the tab
// closes once it lands.
try std.testing.expect(doc.owner.isDirty(doc));
Expand Down
Loading