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
18 changes: 16 additions & 2 deletions docs/AUTOMATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions plugins/workbench/src/Workspace.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
48 changes: 46 additions & 2 deletions sdk/replay/Player.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}

Expand All @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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));
}
Expand Down
102 changes: 97 additions & 5 deletions sdk/tape/Sequencer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
};
};

Expand All @@ -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);
}
Expand All @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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 => {
Expand All @@ -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;
},
Expand Down Expand Up @@ -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.
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 } },
Expand Down
Loading
Loading