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
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ The open PR list is the board of who is working on what.
yes without opening the PR.
4. **Merging is the maintainer's.** On a yes, merge with `gh pr merge --squash`. Never push to
`main`, and never merge a PR you opened unless asked to.
5. **Auto-merge comes after review and a yes.** It merges, so it is the maintainer's, or whoever
merges on their yes: on only once the PR's code review is done and the maintainer said yes,
never by the session that opened the PR. On 2026-10-19 #623 and #624 merged before their
review landed, and the findings became follow-ups #639 and #640.

## jj

Expand Down
13 changes: 13 additions & 0 deletions app/layout/ViewDrag.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1315,6 +1315,19 @@ pub fn drawZones(l: *Layout, name: []const u8, key: dvui.Id) void {
else if (joins(l, d.name, name))
.join
else if (regionNamed(l.state, name)) |r| (if (r.shows == .many) .add else .replace) else .replace;
// The drop a release would land in, each bubble an anchor (`drop:<zone>`): one place at a time
// is the target, so a demo aims at the bubble rather than an offset from the place's middle.
// Marked only while a tape asks for anchors, and ahead of the offer queue's limit (which has
// nothing to do with the bubble being there).
if (target and !covered and core.anchor.wanted()) for (DropZones.all) |z| {
if (z == .remove and !zones.remove) continue;
const b = zones.bubble(z);
const r: dvui.Rect.Physical = .{ .x = b.c.x - b.r, .y = b.c.y - b.r, .w = 2 * b.r, .h = 2 * b.r };
switch (z) {
.edge => |side| core.anchor.markRect(key, r, true, "drop:{t}", .{side}),
else => core.anchor.markRect(key, r, true, "drop:{t}", .{z}),
}
};
// Queued, not drawn: over everything, once every place has drawn (`drawOverlay`).
const now = dvui.currentWindow().frame_time_ns;
if (d.pending_frame != now) {
Expand Down
9 changes: 9 additions & 0 deletions docs/AUTOMATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,14 @@ which costs nothing unless someone asked for anchors this frame (`core.anchor.wa
while a demo is loaded, the live driver while a live tape plays). dvui keeps tags on the shared
window, so a plugin dylib's anchors are visible to the app.

**A button lands on an anchor a widget publishes** (`region:<name>`, `fizzy.*`), never on the
window or an offset from an anchor: the `move` or `trace` before a `press` or `release` names a tag
and nudges nothing (`dx`, `dy` stay 0), because a nudge is a coordinate that goes stale when the
layout shifts. The offset is for a pointer that only points, or glides past. If no anchor names
what you need, publish one (`core.anchor.mark`). `Tape.firstButtonOffAnchor` finds a button aimed
any other way: `tests/demos.zig` fails a bundled demo that has one, and the soak tape's test fails
it too.

**The overlay is a function of time.** The synthetic pointer and its click ripple (none until the
tape first uses it after a keyframe; it goes away while the tape types, as a desktop's does, and
comes back when it moves), the keys pressed (each with its chord from the user's own keymap),
Expand Down Expand Up @@ -171,6 +179,7 @@ user's motion settings, timed in demo time.
| `fizzy.window:dialog:<title>`, `fizzy.window:float:<title>`, `fizzy.window:menu` | an OS window of the app's own: a dialog's, a popped-out view's, a menu's (a second of the same name gets `:2`) |
| `region:<name>` | a layout region (`Main`, `Panel`, …) |
| `region.arrange:<name>` | a region's arrange button, where it grows in the region's corner (move to the corner first to bring it up) |
| `drop:center`, `drop:left`, `:right`, `:top`, `:bottom`, `:remove` | a bubble of the drop a carried view would land in, while it is carried over a place: only once the drag is under way, so `drag` carries it there `via` the place first |
| `float-header:<place>`, `float-close:<place>` | a float's header, to carry it by, and its close button: `float-close:Float 1` |
| `picker:<view id>` | a view's card in a place's picker |
| `picker.mode:one`, `picker.mode:many` | the picker's Single / Multiple |
Expand Down
4 changes: 4 additions & 0 deletions sdk/tape/Script.zig
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,9 @@ pub const DragOptions = struct {
button: Tape.Button = .left,
/// The carry from `from` to `to`; null is half again `Pace.move_ms`, a drag being slower.
ms: ?u32 = null,
/// Carried through here first: for a `to` that shows only once the drag is under way (a
/// place's drop, `drop:<zone>`), which a drag that has not moved yet would wait on forever.
via: ?Tape.Target = null,
};

/// Press on one target, carry to another, release.
Expand All @@ -260,6 +263,7 @@ pub fn drag(self: *Script, from: Tape.Target, to: Tape.Target, opts: DragOptions
self.t += self.pace.hover_ms;
try self.push(.{ .at = self.t, .do = .{ .press = opts.button } });
self.t += self.pace.hover_ms;
if (opts.via) |v| try self.moveTo(v, .{});
try self.moveTo(to, .{ .ms = opts.ms orelse self.pace.move_ms * 3 / 2 });
self.t += self.pace.hover_ms;
try self.push(.{ .at = self.t, .do = .{ .release = opts.button } });
Expand Down
54 changes: 54 additions & 0 deletions sdk/tape/Tape.zig
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ pub const Target = struct {
x: f32 = 0.5,
y: f32 = 0.5,
/// Then nudged by this many natural pixels — "just inside the left edge" is `x = 0, dx = 8`.
/// Not where a button goes down or up: that lands on the anchor (`firstButtonOffAnchor`).
dx: f32 = 0,
dy: f32 = 0,
};
Expand Down Expand Up @@ -280,6 +281,25 @@ pub fn keyframeBefore(self: Tape, t: f64) usize {
return found;
}

/// The first press or release that is not aimed at an anchor, by index into `ops`, or null. A
/// button lands where the pointer is, so the `move` or `trace` before it is its aim: that must name
/// a tag a widget publishes and nudge nothing off it (`Target.dx`, `dy`), since the window itself
/// (an empty tag) or a nudge is a coordinate that goes stale when the layout shifts. A keyframe
/// is a fresh start: a button after it with no aim of its own is not aimed at anything.
pub fn firstButtonOffAnchor(self: Tape) ?usize {
var aim: ?Target = null;
for (self.ops, 0..) |op, i| switch (op.do) {
.keyframe => aim = null,
.move, .trace => |t| aim = t,
.press, .release => {
const t = aim orelse return i;
if (t.tag.len == 0 or t.dx != 0 or t.dy != 0) return i;
},
else => {},
};
return null;
}

/// The chapter playing at `t`, by index, or null before the first.
pub fn chapterAt(self: Tape, t: f64) ?usize {
var found: ?usize = null;
Expand Down Expand Up @@ -687,6 +707,40 @@ pub const test_check: Check = .{ .key = struct {
}
}.ok };

test "a button is aimed at an anchor, with nothing nudged off it" {
const anchored = [_]Op{
.{ .at = 0, .do = .{ .keyframe = 0 } },
.{ .at = 0, .do = .{ .move = .{ .tag = "fizzy.rail:files", .x = 1, .y = 0 } } },
.{ .at = 10, .do = .{ .press = .left } },
.{ .at = 20, .do = .{ .move = .{ .tag = "region:Sidebar" } } },
.{ .at = 30, .do = .{ .release = .left } },
};
try std.testing.expectEqual(@as(?usize, null), (Tape{ .name = "t", .ops = &anchored }).firstButtonOffAnchor());

const nudged = [_]Op{
.{ .at = 0, .do = .{ .move = .{ .tag = "region:Sidebar", .x = 1, .y = 0, .dx = -26, .dy = 26 } } },
.{ .at = 10, .do = .{ .press = .left } },
};
try std.testing.expectEqual(@as(?usize, 1), (Tape{ .name = "t", .ops = &nudged }).firstButtonOffAnchor());

// A trace aims as a move does, and a raw drop counts as much as a raw press.
const traced = [_]Op{
.{ .at = 0, .do = .{ .move = .{ .tag = "fizzy.rail:files" } } },
.{ .at = 10, .do = .{ .press = .left } },
.{ .at = 20, .do = .{ .trace = .{ .x = 0.3, .y = 0.4 } } },
.{ .at = 30, .do = .{ .release = .left } },
};
try std.testing.expectEqual(@as(?usize, 3), (Tape{ .name = "t", .ops = &traced }).firstButtonOffAnchor());

// A cut forgets the aim before it.
const cut = [_]Op{
.{ .at = 0, .do = .{ .move = .{ .tag = "fizzy.rail:files" } } },
.{ .at = 10, .do = .{ .keyframe = 0 } },
.{ .at = 20, .do = .{ .press = .left } },
};
try std.testing.expectEqual(@as(?usize, 2), (Tape{ .name = "t", .ops = &cut }).firstButtonOffAnchor());
}

test "a tape round-trips through ZON" {
const tape = sample();
var out: std.Io.Writer.Allocating = .init(testing.allocator);
Expand Down
2 changes: 1 addition & 1 deletion src/editor/demos/tour.zig
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ pub fn build(s: *Script) !void {
try s.caption("Tabs go where you drag them.", .{ .near = try catalog.tab(s, "README.md") });
try s.drag(.{ .tag = try catalog.tab(s, "README.md") }, .{ .tag = try catalog.tab(s, "src/main.zig"), .x = 0.2 }, .{});
try s.caption("Drop one on the editor's edge, and it opens beside the others.", .{});
try s.drag(.{ .tag = try catalog.tab(s, "src/main.zig") }, .{ .tag = catalog.documents, .dx = 132 }, .{});
try s.drag(.{ .tag = try catalog.tab(s, "src/main.zig") }, .{ .tag = "drop:right" }, .{ .via = .{ .tag = catalog.documents } });
try s.waitFor(try catalog.editor(s, "src/main.zig"), .{});
s.pause(900);

Expand Down
5 changes: 5 additions & 0 deletions tests/demos.zig
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ test "demo: every bundled demo plays to the end through fizzy's stage, a seek la
// 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.
const tape = p.tape().?;
// A button aimed at an anchor, never a coordinate (docs/AUTOMATION.md).
if (tape.firstButtonOffAnchor()) |i| {
std.log.err("demo '{s}': op {d} presses or releases off an anchor; aim at one (docs/AUTOMATION.md)", .{ tape.name, i });
return error.ButtonOffAnchor;
}
var picks: std.ArrayList(usize) = .empty;
defer picks.deinit(gpa);
for (0..bundled_random_seeks) |_| try picks.append(gpa, random.uintLessThan(usize, moments.items.len));
Expand Down
2 changes: 2 additions & 0 deletions tests/integration.zig
Original file line number Diff line number Diff line change
Expand Up @@ -5966,6 +5966,8 @@ test "verdict: the soak tape is a demo, and its expectations read" {
try std.testing.expectEqualStrings("soak", owned.tape.name);
// It ends on a wait, so what closed last has time to close before the windows are counted.
try std.testing.expect(owned.tape.ops[owned.tape.ops.len - 1].do == .wait);
// Every button on an anchor (docs/AUTOMATION.md), as every bundled demo's is (`tests/demos.zig`).
try std.testing.expectEqual(@as(?usize, null), owned.tape.firstButtonOffAnchor());

var expect_arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer expect_arena.deinit();
Expand Down
Loading