From 6e799810996a5779cf9f4058d817ceba9b6c396e Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 19 Oct 2026 14:56:07 -0500 Subject: [PATCH] demos: the tour keeps its layout, and a carried tab stops flickering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs the maintainer saw in the tour (the "A Tour of Fizzy" tape on the web build), and what replaying the tour without its mid-tour cuts turned up. ## 1. "When dragging the document tabs, the drop zone flickers rapidly" **Cause.** A glide finds its target again every frame (`Sequencer.advanceSpans` → `locate`). While a button is held, the thing under the pointer moves *because* of what is carried. The tour's first drag carries README onto `src/main.zig`'s tab, and the strip makes room for it, so `main.zig`'s tab moves away. The glide then chased it, and the room closed again. Measured headless at 16 ms frames: for the last ~300 ms of that drag the pointer jumped between x≈156 and x≈402 every other frame, so the strip's insertion gap flickered. Frame pacing doesn't matter: it happens at any frame rate, the web's included. **Fix (`sdk/tape/Sequencer.zig`).** A glide that begins with a button held (`Glide.carried`) follows its target until it has seen it in the same place on two frames, then stops following (`locked`). A person aims where they saw the target, too. With nothing held, a glide still follows its target, as a place easing open needs. The `drop:right` drag (op 13–15) now lights centre, then right, and holds. ## 2. "The tape adjusts the layout and snaps it back" **Cause.** `tour.zig` cut to a fresh keyframe at **Places** (`.reset`: Main's split undone, explorer and panel opened), at **Floats** (`.reset`: the sidebar split undone) and at **History** (`.focused`: explorer and panel shut). That is three snaps. Those keyframes existed because a snapshot does not hold the layout: a seek back across a split had to land on a cut. **Fix.** - `tour.zig` has one keyframe. Each chapter carries on in the window the last one left: Places opens the explorer from the rail, the Floats chapter floats one half of the split sidebar, and History clicks README's tab with the store left open beside it. Headless layout timeline: `Pane 0` → `+Pane 1` (Tabs) → `+Sidebar` (Places) → `+Sidebar/b1` → `+Float 1` (Floats) → `−Float 1` (Materials, by its close button). Nothing reverts. - `Demo.layoutPrint`: the app's places (name, shut or not, what each shows), how many panes the documents have, and how many floats. A snapshot keeps it, and `restore` refuses to put a snapshot back into another arrangement. A seek back across a split replays from the keyframe instead of landing on a window arranged the later way. On main, a restore across a float or split put the documents back and left the window as it was, which nothing checked. ## What replaying through the whole tour turned up (each fixed here) With no mid-tour keyframes, every seek replays the tour's drags. Four replay bugs the cuts had been hiding: - **A drop's front document settled a frame late** (`Workspace.setTabs`). `active` was set only when the pane drew, so a pane a drop had just opened had no active document. Live play had drawn it by the next calm moment, but a seek arrived first, and the replay's fingerprint differed. `setTabs` now sets `active` along with the selection. - **A replayed glide aimed at a place still moving** (`Sequencer.Sink.VTable.still`, `Player.targetStill`). A replay covers a glide in one call and finds its target once. While seeking, a glide onto a tagged target is held until the target stops moving: two runs at the same point, at most `Settling.max_runs`, with the backend clock moved a frame per run so animations advance. - **A replayed drag lost most of its carry** (`Sequencer.replaying`). A float moved by its header follows the frames it is drawn in. With a whole glide in one frame it moved 2 px rather than across the window. While replaying, a carried `.move` takes a frame per 16 ms path step. Traces (strokes) don't, and live play is unchanged. - **A forward seek from mid-drag carried on after letting go** (`Player.letGo`, `goBack`). Letting go now marks the app diverged, so if no restore can then be made the seek replays from the keyframe. ## Tests - `tests/demos.zig` `Steady`, run on every bundled demo's straight play: - no chapter opens on another `layoutPrint` than the last one left; - a carried glide's pointer never moves further from where it lands (it fails on main's tab drag); - a drop's lit bubble, once left, is not lit again within the drag. - Seek moments are no longer taken mid-drag (button held), the same rule the player's snapshots follow. - `Sequencer` unit tests: a carried glide goes where its target settled and a free one follows it; a carried glide replays a frame per step. ## Seek cost, measured Headless, ReleaseFast, dvui's testing backend. Each row is a seek from the tour's end back to 200 ms into the chapter: the app runs it took (displayed + silent) and the CPU time to land. | chapter | main: runs / ms | this PR: runs / ms | |---|---|---| | Welcome | 4 / 2.1 | 4 / 2.1 | | Tabs | 4 / 1.9 | 6 / 2.2 | | Places | 4 / 2.1 | 217 / 23.9 | | Floats | 4 / 2.1 | 241 / 29.4 | | Materials | 4 / 0.7 | 376 / 59.5 | | Plugins | 9 / 1.5 | 387 / 64.5 | | History | 4 / 2.0 | 4 / 0.7 | | Your turn | 9 / 1.1 | 9 / 1.7 | A seek back into Places through Plugins is now a replay from the start. On main it was a cut to that chapter's own keyframe. The replay plays the tour's drags a frame per step and holds each glide until its target is still. Against the player's 8 ms budget that is up to about 8 displayed frames on this machine (about 65 ms of work), where main took one. It is correct, but slower. The way back to one-frame seeks is to put the layout itself (assignments, splits, floats) into the snapshot, so a restore can put it back rather than refuse. That is a follow-up: `Demo.Snapshot` is the seam, and `layoutPrint` is what it would replace. ## Review (code-review at medium, two passes on this PR) Fixed: - `paneUnsettled` could hold `idle` forever. It is replaced by the root fix: `Workspace.setTabs` sets `active`, null for a surface that is not a document. - `targetStill` saw no motion between runs on a microsecond clock. A held run now moves the backend clock 16 ms. - The hold is skipped for untagged targets and for `.trace`, so a drawing's strokes cost no extra runs. - `aiming` is now cleared on rewind and restore. - A duplicated comment is gone. - Steady's flicker check gave a false positive when several bubbles were lit at once. It now fires only on lit, then off, then lit again. - `letGo` marked the app diverged, which turned a later forward scrub into a seek back. It is now a per-seek `let_go`. - The clock during a step-per-frame replayed drag now advances a step per run (`Sequencer.nextAt`). Declined, or left as follow-ups: - Snapshots holding the layout (above). - A carried glide can lock on a target before its easing has begun. - Steady checks only the boundary frame of a chapter. - A `.via` drag that legitimately returns to a bubble would trip the flicker check (no bundled demo does that). ## Not done / follow-ups - A float dragged by its header still lands somewhere else after a seek than in straight play. Even a frame per step moves it less than live does. `layoutPrint` counts floats but has no float positions, so a restore puts a float back without moving it. That is a fidelity gap (fingerprints do not cover it) that main had too. - The glass aim offset (`dropCenter`, 0.55 R down-right of the pointer) applies only where `LiquidField.ready()`. Headless tests can't exercise it. The fix doesn't depend on it. Coordination: #665 (tape-own-stage) has merged. This PR is rebased on it with no conflicts. These bugs are separate from #665. ## SDK impact - [x] None (fingerprint unchanged; `test-sdk-version` passes) ## Verified macOS, headless: `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build test-integration` (all steps), `zig build check-web`, `zig build test-sdk-version`, `zig build check-tests-reached`. No fizzy launched. The flicker and the snaps were measured with a temporary 16 ms-frame probe of the tour: the pointer x per frame and the region list per chapter, quoted above. Co-Authored-By: Claude Opus 5.5 --- docs/AUTOMATION.md | 18 ++++- plugins/workbench/src/Workspace.zig | 4 ++ sdk/replay/Player.zig | 48 ++++++++++++- sdk/tape/Sequencer.zig | 102 +++++++++++++++++++++++++-- src/editor/Demo.zig | 35 +++++++++- src/editor/demos/tour.zig | 24 ++++--- tests/demos.zig | 104 ++++++++++++++++++++++++++++ 7 files changed, 315 insertions(+), 20 deletions(-) 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);