diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index 359a78ada..2661cffd6 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -94,8 +94,10 @@ goes to the nearest one before the moment and replays only from there, the app p rather than cut to its keyframe and reloaded. Fizzy's snapshot holds the demo's files as saved, each open document's state from its owner (`captureDocumentState`: contents, caret, scroll), which is active, the explorer and its open folders, every plugin's settings, and focus; restoring -one closes what was opened since and puts the rest back where it sits. It is restored only into -the scene it came from with its documents still open — otherwise the player tries the snapshot +one closes what was opened since and puts the rest back where it sits. It does not hold the +layout, so it is restored only into the scene it came from, with its documents still open and the +window arranged as it was then (`Demo.layoutPrint`: the places, shut or not and what each shows, +the panes, the floats) — otherwise the player tries the snapshot before it, and a seek forward simply carries on — and taken only while nothing is loading and the palette is shut. **The scrubber seeks as it is dragged**, not only when let go: with a snapshot every few seconds, each move replays a second or two of demo and lands in the frame it was made. @@ -265,6 +267,18 @@ A keyframe starts with the explorer put away (`.layout = .focused`), so the edit a demo opens it from the rail when it is about to use it and puts it away after — `catalog.openFile(s, "README.md")` does all three. +**One keyframe, then carry on.** A keyframe is a cut, and a cut mid-demo snaps the window back to +a fresh layout in front of the viewer. Chapters continue in the window the last one left; one that +needs the window otherwise gets there the way a person would (the rail, a corner button, a close), +animated. A seek back across a split or a float replays from the keyframe, as it must. +`tests/demos.zig` fails a chapter that opens on another layout than the last one left. + +**A carried glide goes where its target settled.** With a button held, what is under the pointer +moves for what it carries (a tab strip makes room for the tab), so once the target has been seen +in one place on two frames the pointer stops following it; with nothing held it follows it all +the way, as a place still easing open moves it. A seek's replay waits for a glide's target to stop +moving before it starts, and plays a carried glide a frame per step, as it was drawn live. + Things that bite: - **The editor pairs brackets and quotes.** Type code the way a person would — `{` then Enter diff --git a/plugins/workbench/src/Workspace.zig b/plugins/workbench/src/Workspace.zig index 254dc20f6..169a392e5 100644 --- a/plugins/workbench/src/Workspace.zig +++ b/plugins/workbench/src/Workspace.zig @@ -552,6 +552,10 @@ pub fn setTabs(self: *Workspace, ids: []const []const u8, focus: ?[]const u8) vo if (focus) |id| { runtime.host().selectInRegion(region_name, id); runtime.workbench().open_workspace_grouping = self.grouping; + // In front now, not when the pane next draws (`draw` sets it from the region's selection): + // a pane a drop just opened has not drawn, and until it had there was no active document. + const path = sdk.document.pathOfSurfaceId(id); + self.active = if (path) |p| runtime.host().docFromPath(p) else null; } } diff --git a/sdk/replay/Player.zig b/sdk/replay/Player.zig index 03379a0fc..c89205734 100644 --- a/sdk/replay/Player.zig +++ b/sdk/replay/Player.zig @@ -116,6 +116,11 @@ restore_forward: bool = false, cut_waits: bool = false, /// Put back from a snapshot, the app is still settling and no op lands yet (`restoreSnapshot`). settling: ?Settling = null, +/// A seek's glide held until its target stops moving (`Sequencer.Sink.VTable.still`): the op, and +/// where its target was the run before. +/// This seek let go of a drag the tape held (`letGo`): forward, it cannot carry on from there. +let_go: bool = false, +aiming: ?struct { op: usize, last: Sequencer.Point, runs: u8 = 0 } = null, /// The op a snapshot's fingerprint was last compared at, so a moment is checked once a pass. checked_cursor: ?usize = null, /// The keyframe op the app's scene began with: set as each keyframe is cut to, and by a restore; @@ -427,6 +432,8 @@ fn seekTo(self: *Player, t_in: f64, after: After) void { // cutting to it beats replaying everything up to it. Anything else goes back. const forward = !self.diverged and t >= self.seq.now and kf < self.seq.cursor; self.restore_pending = null; + self.aiming = null; + self.let_go = false; // Back to the nearest snapshot before the moment, in its scene — or, forward, to one further // on than here. Put back at the start of the next frame (`restore_pending`). const snap = self.nearestSnapshot(t, kf); @@ -470,6 +477,7 @@ fn arrive(self: *Player) void { fn rewind(self: *Player, kf: usize) void { if (self.letGo()) self.cut_waits = true; self.settling = null; + self.aiming = null; self.seq.rewind(kf); self.scene = null; self.last_press = null; @@ -485,6 +493,9 @@ fn rewind(self: *Player, kf: usize) void { fn letGo(self: *Player) bool { if (self.input.held.count() == 0) return false; self.input.releaseHeld(); + // A drag the tape had under way is dropped where it is: the app is not where the tape is + // until a cut puts it somewhere the tape has been (`goBack`). + self.let_go = true; return true; } @@ -494,6 +505,7 @@ fn restoreSnapshot(self: *Player, i: usize) bool { const s = self.snapshots.items[i]; if (!self.stage.restore(s.state)) return false; self.seq.restoreTo(s.cursor, s.at, s.pointer); + self.aiming = null; self.scene = s.scene; // The pointer is kept in pixels, and they are only where the tape left it in the layout the // snapshot was taken in. The app put back takes a few runs to get there — a sidebar the @@ -557,7 +569,9 @@ fn goBack(self: *Player, first: usize) bool { if (self.restore_forward and s.at <= self.seq.now) break; if (self.restoreSnapshot(i)) return true; } - if (!self.restore_forward) { + // Forward, it carries on from where the tape is, unless the app has left it: let go of mid-drag + // ahead of a restore that then could not be made, say. + if (!self.restore_forward or self.let_go) { self.rewind(self.tape().?.keyframeBefore(self.seek_target)); self.seek_from = self.seq.now; } @@ -705,11 +719,13 @@ pub fn frame(self: *Player) void { switch (self.state) { .playing => { Input.holdPointer(self.seq.pointer); + self.seq.replaying = false; _ = self.seq.advance(self.seq.now + wall_ms * self.rate, wall_ms, self.sink()); if (self.seq.done() and self.seq.now >= @as(f64, @floatFromInt(self.duration()))) self.state = .ended; }, .seeking => { Input.holdPointer(self.seq.pointer); + self.seq.replaying = true; if (!cut and self.seq.advance(self.seek_target, wall_ms, self.sink()) == .reached) self.arrive(); }, .paused, .ended, .idle => return, @@ -785,7 +801,10 @@ pub fn frames(self: *Player, win: *dvui.Window, frame_fn: *const fn () anyerror! /// When the next run of a displayed frame begins on the app's clock: at the demo moment of what it /// is about to do while a seek is in flight (see the file comment), else just after the last. fn runAt(self: *const Player, win: *dvui.Window, clock: ?*i128, base_ns: i128, base_ms: f64) i128 { - const soonest = win.frame_time_ns + std.time.ns_per_us; + // Waiting on a glide's target to stop moving (`targetStill`), a run is a frame of motion where + // the backend's clock can be moved on: on a microsecond's clock, a place sliding open would + // never move between runs, and look still. + const soonest = win.frame_time_ns + if (self.aiming != null and clock != null) msToNs(Sequencer.path_step_ms) else std.time.ns_per_us; if (clock == null or self.state != .seeking) return soonest; return @max(soonest, base_ns + msToNs(self.nextMoment() - base_ms)); } @@ -978,8 +997,33 @@ const sink_vtable: Sequencer.Sink.VTable = .{ .holds = holds, .timedOut = timedOut, .step = step, + .still = targetStill, }; +/// Live, a glide follows its target as it goes; a seek's replay finds it once, so it waits for the +/// target to stop moving first — two runs seeing it in the same place, or `Settling.max_runs`. +fn targetStill(ctx: *anyopaque, target: Tape.Target) bool { + const self = from(ctx); + // Untagged, it is the window's own coordinates, which go nowhere. + if (self.state != .seeking or target.tag.len == 0) return true; + const pt = Input.targetPoint(target) orelse { + self.aiming = null; + return true; + }; + const op = self.seq.cursor; + if (self.aiming) |*a| if (a.op == op) { + a.runs += 1; + if ((a.last.x == pt.x and a.last.y == pt.y) or a.runs >= Settling.max_runs) { + self.aiming = null; + return true; + } + a.last = pt; + return false; + }; + self.aiming = .{ .op = op, .last = pt }; + return false; +} + fn from(ctx: *anyopaque) *Player { return @ptrCast(@alignCast(ctx)); } diff --git a/sdk/tape/Sequencer.zig b/sdk/tape/Sequencer.zig index 48ef0dc54..b207ae892 100644 --- a/sdk/tape/Sequencer.zig +++ b/sdk/tape/Sequencer.zig @@ -43,6 +43,11 @@ typing: ?Typing = null, /// The op at `cursor` is a wait that has not held yet; `held_ms` of wall time spent on it. holding: bool = false, held_ms: f64 = 0, +/// Buttons the tape holds down: a glide while one is carries something (`Glide.carried`). +pressed: u8 = 0, +/// Set by whoever plays the tape while it replays to a moment rather than plays live (a seek): +/// a carried glide then takes a frame per step of its path (`path_step_ms`). +replaying: bool = false, pub const Point = struct { x: f32 = 0, @@ -56,6 +61,14 @@ const Glide = struct { to: ?Point = null, /// Demo time the pointer was last put down along the path. at: f64, + /// Begun with a button held: carrying something, the pointer goes where the target was seen + /// to settle, and does not follow it after (`locked`). What it is carried over moves for it — a tab strip + /// makes room for the tab, a drop opens under it — and chasing that moved the room it made: + /// the tab carried at a tab went back and forth between two places every other frame. A person + /// aims where they saw it, too. + carried: bool = false, + /// Carried, and the target seen in the same place on two frames: `to` is where it goes. + locked: bool = false, }; /// Demo time between the points of a glide that one call covers more of than a frame (a replay, @@ -93,6 +106,12 @@ pub const Sink = struct { /// Apply a step to its file, shown being made over `ms` (the op's). Null for a sink that /// plays only live tapes, which have none (`Tape.validate`). step: ?*const fn (ctx: *anyopaque, step: Tape.Step, ms: u32) void = null, + /// Whether `target` has stopped moving, asked as a glide onto it is about to begin: false + /// holds the glide for a frame, and it is asked again. A replay covers a glide in one + /// call and finds its target once, where live play follows it for the glide's whole + /// length, so a place still sliding open when the glide began sends a replay's pointer + /// where the place was. Null, or always true: glides begin at once (live play). + still: ?*const fn (ctx: *anyopaque, target: Tape.Target) bool = null, }; }; @@ -108,7 +127,11 @@ pub fn nextAt(self: Sequencer) f64 { const ops = self.tape.ops; var t: f64 = if (self.cursor < ops.len) @floatFromInt(ops[self.cursor].at) else std.math.inf(f64); if (self.holding) t = self.now; - if (self.glide) |g| t = @min(t, @as(f64, @floatFromInt(ops[g.op].end()))); + if (self.glide) |g| { + t = @min(t, @as(f64, @floatFromInt(ops[g.op].end()))); + // Replayed a step a frame (`advanceSpans`), each frame begins at its own step. + if (self.replaying and g.carried and ops[g.op].do == .move) t = @min(t, g.at + path_step_ms); + } if (self.typing) |ty| t = @min(t, @as(f64, @floatFromInt(ops[ty.op].end()))); return @max(t, self.now); } @@ -129,6 +152,8 @@ pub fn restoreTo(self: *Sequencer, cursor: usize, now: f64, pointer: Point) void self.typing = null; self.holding = false; self.held_ms = 0; + // A moment is calm with no button held (`Player.keepSnapshot`). + self.pressed = 0; } /// Every op has been applied and nothing is in flight. @@ -145,6 +170,7 @@ pub fn rewind(self: *Sequencer, op_index: usize) void { self.typing = null; self.holding = false; self.held_ms = 0; + self.pressed = 0; } pub const Progress = enum { @@ -199,8 +225,11 @@ pub fn advance(self: *Sequencer, until: f64, wall_ms: f64, sink: Sink) Progress } self.cursor += 1; }, - .move, .trace => { - self.glide = .{ .op = self.cursor, .from = self.pointer, .at = self.now }; + .move, .trace => |target| { + // A move only: a stroke's points are on a canvas that is not going anywhere, and a + // drawing has hundreds of them. + if (op.do == .move) if (sink.vtable.still) |still| if (!still(sink.ctx, target)) return .holding; + self.glide = .{ .op = self.cursor, .from = self.pointer, .at = self.now, .carried = self.pressed > 0 }; self.cursor += 1; }, .type => { @@ -218,11 +247,13 @@ pub fn advance(self: *Sequencer, until: f64, wall_ms: f64, sink: Sink) Progress }, .press => |b| { self.cursor += 1; + self.pressed +|= 1; sink.vtable.button(sink.ctx, b, true); return .yielded; }, .release => |b| { self.cursor += 1; + self.pressed -|= 1; sink.vtable.button(sink.ctx, b, false); return .yielded; }, @@ -251,7 +282,12 @@ fn advanceSpans(self: *Sequencer, to: f64, sink: Sink) ?Progress { if (self.glide) |*g| { const op = self.tape.ops[g.op]; const f = fraction(op, to); - if (sink.vtable.locate(sink.ctx, op.do.target().?)) |pt| g.to = pt; + if (!g.locked) if (sink.vtable.locate(sink.ctx, op.do.target().?)) |pt| { + // Carried, it locks on once the target is seen in the same place twice: still + // easing in, it is followed there. + if (g.carried and g.to != null and g.to.?.x == pt.x and g.to.?.y == pt.y) g.locked = true; + g.to = pt; + }; // A target never seen leaves the pointer where it is rather than flying to the corner. const dest = g.to orelse g.from; // The frames live play would have drawn between, each a point along the way. @@ -262,6 +298,14 @@ fn advanceSpans(self: *Sequencer, to: f64, sink: Sink) ?Progress { if (pt.x == self.pointer.x and pt.y == self.pointer.y) continue; self.pointer = pt; sink.vtable.moveTo(sink.ctx, pt); + // Replaying, a step of a carried glide is a frame of its own, as it was live: what + // is carried moves with the frames it is drawn in (a float by its header), and the + // rest of a glide landed in one frame was lost to it. Not a trace: a stroke's points + // are the canvas's to take as they come, and a drawing has hundreds of them. + if (g.carried and op.do == .move and self.replaying) { + g.at = t; + return .yielded; + } } g.at = @max(g.at, to); self.pointer = pathPoint(op, g.from, dest, f); @@ -290,7 +334,8 @@ fn advanceSpans(self: *Sequencer, to: f64, sink: Sink) ?Progress { /// Land whatever is still in flight, as if its time had come. fn finishSpans(self: *Sequencer, sink: Sink) void { if (self.glide) |g| { - if (sink.vtable.locate(sink.ctx, self.tape.ops[g.op].do.target().?)) |pt| { + const to = if (g.locked) g.to else sink.vtable.locate(sink.ctx, self.tape.ops[g.op].do.target().?); + if (to) |pt| { self.pointer = pt; sink.vtable.moveTo(sink.ctx, pt); } @@ -788,6 +833,53 @@ test "rewinding to a keyframe and replaying fast lands where live play did" { try testing.expectEqual(@as(f32, 50), seq.pointer.x); } +test "carrying something, the pointer goes where the target settled; with nothing held, it follows" { + const ops = [_]Tape.Op{ + .{ .at = 0, .do = .{ .keyframe = 0 } }, + .{ .at = 0, .ms = 300, .do = .{ .move = .{ .tag = "slot" } } }, + .{ .at = 300, .do = .{ .press = .left } }, + .{ .at = 320, .ms = 300, .do = .{ .move = .{ .tag = "slot" } } }, + .{ .at = 620, .do = .{ .release = .left } }, + }; + const tape: Tape = .{ .name = "t", .ops = &ops, .keyframes = &test_keyframes }; + var log: Log = .{}; + defer log.deinit(); + try log.tags.put(testing.allocator, "slot", .{ .x = 100, .y = 10 }); + var seq: Sequencer = .init(&tape); + // Free, the glide follows the slot where it moves to. + playTo(&seq, 100, 16, log.sink()); + try log.tags.put(testing.allocator, "slot", .{ .x = 400, .y = 10 }); + playTo(&seq, 310, 16, log.sink()); + try testing.expectEqual(@as(f32, 400), seq.pointer.x); + // Carried, the slot moving out from under the pointer (room made for what it carries) leaves + // the glide going where it first saw it. + try log.tags.put(testing.allocator, "slot", .{ .x = 700, .y = 10 }); + playTo(&seq, 400, 16, log.sink()); + try log.tags.put(testing.allocator, "slot", .{ .x = 100, .y = 10 }); + playTo(&seq, 700, 16, log.sink()); + try testing.expectEqual(@as(f32, 700), seq.pointer.x); + try testing.expectEqual(@as(u8, 0), seq.pressed); +} + +test "replaying, a carried glide takes a frame per step of its path" { + const ops = [_]Tape.Op{ + .{ .at = 0, .do = .{ .keyframe = 0 } }, + .{ .at = 0, .do = .{ .press = .left } }, + .{ .at = 0, .ms = 160, .do = .{ .move = .{ .x = 1, .y = 0 } } }, + .{ .at = 160, .do = .{ .release = .left } }, + }; + const tape: Tape = .{ .name = "t", .ops = &ops, .keyframes = &test_keyframes }; + var log: Log = .{}; + defer log.deinit(); + var seq: Sequencer = .init(&tape); + seq.replaying = true; + var frames: usize = 0; + while (seq.advance(1000, 16, log.sink()) != .reached) frames += 1; + // The keyframe, the press, a frame per 16 ms step of the glide, its end, the release. + try testing.expect(frames >= 160 / 16); + try testing.expectEqual(@as(f32, 1000), seq.pointer.x); +} + test "a seek into the middle of a span leaves it part done, to finish live" { const ops = [_]Tape.Op{ .{ .at = 0, .do = .{ .keyframe = 0 } }, diff --git a/src/editor/Demo.zig b/src/editor/Demo.zig index 68c241b17..1da72f93d 100644 --- a/src/editor/Demo.zig +++ b/src/editor/Demo.zig @@ -26,7 +26,8 @@ //! settings, and keyboard focus. Restoring one puts them back in place — documents opened since //! are closed, the rest restored where they sit — so a seek back within a scene costs no reload. //! One is taken only while nothing is loading and the palette is shut, and restored only into the -//! scene it came from with its documents still open; otherwise the seek cuts to the keyframe. +//! scene it came from with its documents still open and the window arranged as it was +//! (`layoutPrint`); otherwise the seek cuts to the keyframe. //! //! Demos start from the command palette ("Demo: …"), the Help menu, `FIZZY_DEMO=` natively, //! or `?demo=` / `?demo=` on the web — see `docs/AUTOMATION.md`. @@ -843,6 +844,9 @@ const Snapshot = struct { material: core.material.Kind, focus: dvui.Id, focus_subwindow: dvui.Id, + /// How the window was arranged (`layoutPrint`): what a restore cannot put back, so it puts + /// back only into the arrangement it was taken in. + layout: u64, const Node = struct { path: []const u8, kind: @FieldType(core.vfs.Mem.Node, "kind"), bytes: []const u8 }; const Doc = struct { path: []const u8, grouping: u64, state: []const u8 }; @@ -870,6 +874,7 @@ fn capture(ctx: *anyopaque) ?*anyopaque { .material = editor.app.settings.window_material, .focus = dvui.focusedWidgetId() orelse .zero, .focus_subwindow = dvui.focusedSubwindowId(), + .layout = layoutPrint(editor), }; const ok = fill(self, snap, root, mem) catch false; if (!ok) { @@ -929,6 +934,10 @@ fn restore(ctx: *anyopaque, raw: *anyopaque) bool { const root = self.mount orelse return false; const mem = self.mem orelse return false; if (!std.mem.eql(u8, root, snap.root)) return false; + // The window arranged as it was: a demo keeps one arrangement from chapter to chapter rather + // than cutting to a fresh one at each, and a split, a float or a place opened later is not + // something a restore takes away. Arranged otherwise, the seek replays from further back. + if (layoutPrint(editor) != snap.layout) return false; // Every document it held still open, in its pane. for (snap.docs) |d| { const doc = openDoc(editor, d.path) orelse return false; @@ -1015,6 +1024,30 @@ fn fingerprint(ctx: *anyopaque) u64 { return h.final(); } +/// How the window is arranged, as far as a restore cannot put it back: each of the app's places by +/// name, shut or not and what it shows, how many panes the documents have, and the floats. The explorer's own place opening and +/// shutting is left out: a restore opens or shuts the explorer (`Snapshot.explorer_closed`). +/// Places are taken in any order: the same arrangement drawn in another order is the same. +pub fn layoutPrint(editor: *Editor) u64 { + const state = &editor.app.layout; + const explorer = if (editor.regionFor(sdk.keywords.ide.sidebar)) |r| r.name else ""; + var print: u64 = 0; + for (state.regions.items) |r| { + // A plugin's own places — the documents' panes — are its to arrange, and named afresh + // each time one opens: counted below, and their documents' panes checked on restore. + if (r.by_name) continue; + var h = std.hash.Wyhash.init(0x1a70); + h.update(r.name); + if (!std.mem.eql(u8, r.name, explorer)) h.update(std.mem.asBytes(&r.isClosed())); + h.update(editor.app.host.selectionForKey(r.selectionKey()) orelse ""); + print +%= h.final(); + } + var h = std.hash.Wyhash.init(print); + h.update(std.mem.asBytes(&editor.workbench.workspaces.count())); + h.update(std.mem.asBytes(&state.floats.items.items.len)); + return h.final(); +} + fn openDoc(editor: *Editor, path: []const u8) ?sdk.DocHandle { const i = openIndex(editor, path) orelse return null; return editor.app.open_files.values()[i]; diff --git a/src/editor/demos/tour.zig b/src/editor/demos/tour.zig index 2816f230b..8179dce93 100644 --- a/src/editor/demos/tour.zig +++ b/src/editor/demos/tour.zig @@ -2,9 +2,10 @@ //! the window's materials, the plugins everything is made of, and the history kept of every change //! — about a minute, in chapters a viewer can jump between (the guides view lists them). //! -//! Each chapter that rearranges the window opens on a keyframe of its own: a snapshot holds the -//! documents and settings but not the layout, so a seek back across a split or a float lands on a -//! cut rather than on a window still arranged the later way. +//! One keyframe, at the start: each chapter carries on in the window the last one left, a split, a +//! float and all, rather than cutting it back to a fresh one. A snapshot puts back only into the +//! arrangement it was taken in (`Demo.layoutPrint`), so a seek back across a split or a float +//! replays from further back rather than landing on a window still arranged the later way. const Tape = @import("tape").Tape; const Script = @import("tape").Script; const catalog = @import("catalog.zig"); @@ -40,13 +41,12 @@ const files = [_]Tape.File{ .{ .path = "notes/ideas.md", .text = ideas }, }; -/// The tour's folder, cut to: its three files open as tabs, README in front. -fn scene(s: *Script, layout: Tape.Keyframe.Layout) !void { +/// The tour's folder, cut to: its three files open as tabs, README in front, nothing else showing. +fn scene(s: *Script) !void { try s.keyframe(.{ .root = "demo://tour", .files = &files, .open = &.{ "src/main.zig", "notes/ideas.md", "README.md" }, - .layout = layout, // A preview beside the markdown rather than over it. .settings = &.{.{ .owner = "markdown", .key = "default_md_view", .value = ".split" }}, }); @@ -54,7 +54,7 @@ fn scene(s: *Script, layout: Tape.Keyframe.Layout) !void { } pub fn build(s: *Script) !void { - try scene(s, .focused); + try scene(s); try s.chapter("Welcome"); try s.caption("This is fizzy itself, played by a tape. Click anywhere to take over, and press play to carry on.", .{ @@ -73,7 +73,10 @@ pub fn build(s: *Script) !void { s.pause(900); try s.chapter("Places"); - try scene(s, .reset); + try s.caption("The rail opens the explorer, in a place of its own beside the documents.", .{ .near = catalog.files_icon }); + try s.click(.{ .tag = catalog.files_icon }, .{}); + try s.waitFor(try catalog.file(s, "README.md"), .{}); + s.pause(900); try s.caption("Every place has a corner. Its button chooses what the place shows…", .{}); try catalog.cornerButton(s, "Sidebar"); try s.click(try catalog.corner(s, "Sidebar"), .{ .move_ms = 0 }); @@ -85,7 +88,6 @@ pub fn build(s: *Script) !void { s.pause(1200); try s.chapter("Floats"); - try scene(s, .reset); try s.caption("Carry a view from its corner and drop it on the middle of its own place: it floats.", .{}); try catalog.cornerButton(s, "Sidebar"); try s.drag(try catalog.corner(s, "Sidebar"), .{ .tag = try catalog.place(s, "Sidebar") }, .{ .ms = 900 }); @@ -121,7 +123,9 @@ pub fn build(s: *Script) !void { s.pause(2600); try s.chapter("History"); - try scene(s, .focused); + // The store left open beside it: shutting it would slide the page over just as the pointer + // went for it. + try s.click(.{ .tag = try catalog.tab(s, "README.md") }, .{}); try s.caption("Every change is kept, a step at a time.", try catalog.aboutWriting(s, "README.md")); try s.click(.{ .tag = try catalog.end(s, "README.md") }, .{}); try s.typeText("\n## History\n\nEvery keystroke is a step you can go back to.\n", .{ .cps = 20 }); diff --git a/tests/demos.zig b/tests/demos.zig index 0ef026bac..bf95daf2e 100644 --- a/tests/demos.zig +++ b/tests/demos.zig @@ -95,11 +95,18 @@ test "demo: every bundled demo plays to the end through fizzy's stage, a seek la // Straight through, on the testing backend's clock (100 ms a step), keeping every moment // a seek could be asked to reproduce. moments.clearRetainingCapacity(); + var steady: Steady = .{}; + defer steady.deinit(gpa); var frames: usize = 0; while (p.state != .ended and frames < 20_000) : (frames += 1) { _ = try dvui.testing.step(headlessFrame); + try steady.frame(gpa, app.editor, e.name); if (p.state != .playing or p.seq.cursor == 0) continue; if (p.seq.nextAt() <= p.seq.now or !p.stage.idle()) continue; + // Not mid-drag: a seek there lands with the button held, and one on from it lets go + // first and replays from the keyframe (`Player.letGo`), which replays a drag cut short + // no better than it did — a moment the player takes no snapshot at either. + if (p.input.held.count() > 0) continue; try moments.append(gpa, .{ .now = p.seq.now, .cursor = p.seq.cursor, .print = p.stage.fingerprint().? }); } errdefer std.debug.print("demo '{s}': {t} after {d} frames at {d:.0} of {d} ms\n", .{ e.name, p.state, frames, p.seq.now, p.duration() }); @@ -108,6 +115,7 @@ test "demo: every bundled demo plays to the end through fizzy's stage, a seek la try std.testing.expectEqual(@as(u32, 0), p.timeouts); try std.testing.expectEqual(@as(u32, 0), p.mismatches); try std.testing.expect(moments.items.len > bundled_random_seeks); + try steady.landed(e.name); // Seeks to random moments, and the first of each chapter, in an order that goes back and // forth: from snapshots, from the keyframe, and forward on the tape's own state. @@ -358,6 +366,102 @@ test "demo: every bundled demo plays to the end through fizzy's stage, a seek la try std.testing.expectEqualStrings(active_before, activePath(app.editor)); } +/// What straight play of a demo must keep steady, watched a frame at a time: +/// +/// - **The layout carries over from chapter to chapter.** A chapter starts in the window the last +/// one left; no cut puts it back to a fresh arrangement (`Demo.layoutPrint`). +/// - **Carrying something, the pointer goes straight to where it is going.** Its distance to where +/// the glide lands never grows: chasing a target that moves for what it carries (a tab strip +/// making room) sent it back and forth every other frame (`Sequencer.Glide.carried`). +/// - **A drop's lit bubble does not flicker.** Over one drag, a bubble once left is not lit again. +const Steady = struct { + chapter: ?usize = null, + print: u64 = 0, + /// The carried glide in flight, by op, and the pointer at each of its frames. + glide: ?usize = null, + path: std.ArrayList(dvui.Point.Physical) = .empty, + /// The bubbles lit over the drag in flight, each once, and those lit the frame before. + lit: std.ArrayList(u64) = .empty, + was_lit: std.ArrayList(u64) = .empty, + dragging: bool = false, + + fn deinit(self: *Steady, gpa: std.mem.Allocator) void { + self.path.deinit(gpa); + self.lit.deinit(gpa); + self.was_lit.deinit(gpa); + } + + fn frame(self: *Steady, gpa: std.mem.Allocator, editor: *fizzy.Editor, name: []const u8) !void { + const p = &editor.demo.player; + if (p.state != .playing) return; + const tape = p.tape().?; + + const print = fizzy.Editor.Demo.layoutPrint(editor); + const chapter = tape.chapterAt(p.seq.now); + if (self.chapter != null and chapter != self.chapter and print != self.print) { + std.debug.print("demo '{s}': chapter '{s}' opens on another layout than '{s}' left\n", .{ name, tape.chapters[chapter.?].title, tape.chapters[self.chapter.?].title }); + return error.LayoutCut; + } + self.chapter = chapter; + self.print = print; + + const g: ?usize = if (p.seq.glide) |g| (if (g.carried) g.op else null) else null; + if (g != self.glide) try self.landed(name); + self.glide = g; + if (g != null) try self.path.append(gpa, dvui.currentWindow().mouse_pt); + + const d = &editor.app.layout.view_drag; + if (!d.active()) { + self.dragging = false; + return; + } + if (!self.dragging) { + self.lit.clearRetainingCapacity(); + self.was_lit.clearRetainingCapacity(); + } + self.dragging = true; + var now_lit: std.ArrayList(u64) = .empty; + defer now_lit.deinit(gpa); + for (d.pending[0..d.pending_count]) |q| { + if (!q.look.target) continue; + const zone = q.look.hovered orelse continue; + var h = std.hash.Wyhash.init(@backingInt(q.key)); + h.update(std.mem.asBytes(&zone)); + try now_lit.append(gpa, h.final()); + } + // Lit now, not lit the frame before, and lit at some time before that: it went out and + // came back. + for (now_lit.items) |lit| { + const before = std.mem.indexOfScalar(u64, self.was_lit.items, lit) != null; + const ever = std.mem.indexOfScalar(u64, self.lit.items, lit) != null; + if (ever and !before) { + std.debug.print("demo '{s}': a drop's bubble lit again at {d:.0} ms after another was\n", .{ name, p.seq.now }); + return error.DropFlickers; + } + if (!ever) try self.lit.append(gpa, lit); + } + self.was_lit.clearRetainingCapacity(); + try self.was_lit.appendSlice(gpa, now_lit.items); + } + + /// The carried glide in flight has ended: its pointer never got further from where it landed. + fn landed(self: *Steady, name: []const u8) !void { + defer self.path.clearRetainingCapacity(); + const pts = self.path.items; + if (pts.len < 2) return; + const end = pts[pts.len - 1]; + var was = std.math.floatMax(f32); + for (pts) |pt| { + const dist = @sqrt((pt.x - end.x) * (pt.x - end.x) + (pt.y - end.y) * (pt.y - end.y)); + if (dist > was + 2) { + std.debug.print("demo '{s}': a carried pointer turned back, {d:.0} px from where it landed after {d:.0}\n", .{ name, dist, was }); + return error.PointerTurnedBack; + } + was = @min(was, dist); + } + } +}; + fn activePath(editor: *fizzy.Editor) []const u8 { const doc = editor.activeDoc() orelse return ""; return doc.owner.documentPath(doc);