From 58502db4e3d77d095b1cfa8bdd31b5cfc39d5ef0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:13:14 +0000 Subject: [PATCH 1/4] docs: auto-merge after review; tapes press anchors Two process rules, and a test that fails a bundled tape pressing by coordinate. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01YMFwhPVgTZ3HuKf89J7hnh --- CONTRIBUTING.md | 3 +++ docs/AUTOMATION.md | 7 +++++++ tests/demos.zig | 44 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 91c001869..8ba34e987 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,6 +45,9 @@ 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.** Turn it on only once the PR's code review is + done and the maintainer said yes; a worker never turns it on itself. On 2026-10-19 #623 and + #624 merged before their review landed, and the findings became follow-ups #639 and #640. ## jj diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index af9ae6c23..928da208f 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -137,6 +137,13 @@ 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 tape presses an anchor a widget publishes** (`region.*`, `fizzy.*`), never a screen coordinate +or an offset from one: the `move` before a `press` names a tag and nudges nothing, because a +nudge is a coordinate that goes stale when the layout shifts. fizzyedit/fizzy#634's soak tape +pressed a fixed offset from the sidebar's corner and broke when the arrange button moved; it now +presses `region.arrange:`. If no anchor names what you need, publish one +(`core.anchor.mark`). `tests/demos.zig` fails a bundled tape whose press is aimed any other way. + **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), diff --git a/tests/demos.zig b/tests/demos.zig index a77adc470..3a5d0fd79 100644 --- a/tests/demos.zig +++ b/tests/demos.zig @@ -65,6 +65,46 @@ const DemoMoment = struct { now: f64, cursor: usize, print: u64 }; const bundled_seek_seed: u64 = 0xf122_de40; const bundled_random_seeks = 16; +/// A press lands where the pointer is, so the pointer's last `move` is what a press aims at. That +/// move must name an anchor a widget publishes (docs/AUTOMATION.md, "Ops aim at anchors"), with no +/// nudge off it, never the window (an empty tag, which is a raw coordinate) nor an offset from a +/// corner. Returns the index of the first press that doesn't, or null. +fn firstRawPress(tape: automation.Tape) ?usize { + var aim: ?automation.Tape.Target = null; + for (tape.ops, 0..) |op, i| switch (op.do) { + .move => |t| aim = t, + .press => { + const t = aim orelse return i; + if (t.tag.len == 0 or t.dx != 0 or t.dy != 0) return i; + }, + else => {}, + }; + return null; +} + +test "tapes: a press aims at an anchor, not a coordinate" { + // Bundled demos are checked in the test below, as they play. Drawing strokes on a canvas would + // be the legitimate raw coordinate; none of these tapes draw, so nothing is exempt on that + // ground. `soak.zon` presses an offset from the sidebar's corner until fizzyedit/fizzy#634 + // moves it onto `region.arrange:`; delete its entry then. + const exempt = [_][]const u8{"soak"}; + const tapes = [_]struct { name: []const u8, source: [:0]const u8 }{ + .{ .name = "soak", .source = @embedFile("tapes/soak.zon") }, + }; + for (tapes) |t| { + var owned = try automation.Tape.parse(std.testing.allocator, t.source, automation.Input.check); + defer owned.deinit(); + const raw = firstRawPress(owned.tape); + const is_exempt = for (exempt) |e| { + if (std.mem.eql(u8, e, t.name)) break true; + } else false; + if (raw != null and !is_exempt) { + std.log.err("tape '{s}': op {d} presses a coordinate; aim at an anchor (docs/AUTOMATION.md)", .{ t.name, raw.? }); + return error.PressByCoordinate; + } + } +} + test "demo: the rail's Files anchor the scripts spell out is the one fizzy publishes" { try std.testing.expectEqualStrings("fizzy.rail:" ++ workbench.view_files, fizzy.Editor.Demo.catalog.files_icon); } @@ -112,6 +152,10 @@ 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().?; + if (firstRawPress(tape.*)) |i| { + std.log.err("demo '{s}': op {d} presses a coordinate; aim at an anchor (docs/AUTOMATION.md)", .{ tape.name, i }); + return error.PressByCoordinate; + } 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)); From 0f7c7c1da630509c3ddf86e7771816f5f70e7f9b Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 19 Oct 2026 12:18:00 -0500 Subject: [PATCH 2/4] tape: a button aimed off an anchor is found by the tape itself `Tape.firstButtonOffAnchor` replaces the test-local check: a `trace` aims as a `move` does, a keyframe forgets the aim, and a `release` (a drop) is held to the rule as a press is. The soak tape's check moves to its verdict test, where it asserts soak still presses off an anchor, so the exemption cannot outlive #634 unnoticed (the old test listed only soak, and exempted it). AUTOMATION.md names the anchors that exist (`region:`), says soak is not fixed yet, and squares the rule with the offset the paragraph above describes; CONTRIBUTING says who turns auto-merge on. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 7 +++--- docs/AUTOMATION.md | 14 ++++++----- sdk/tape/Tape.zig | 54 +++++++++++++++++++++++++++++++++++++++++++ tests/demos.zig | 47 ++++--------------------------------- tests/integration.zig | 4 ++++ 5 files changed, 74 insertions(+), 52 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8ba34e987..86745e9e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,9 +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.** Turn it on only once the PR's code review is - done and the maintainer said yes; a worker never turns it on itself. On 2026-10-19 #623 and - #624 merged before their review landed, and the findings became follow-ups #639 and #640. +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 diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index 928da208f..c4f7af503 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -137,12 +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 tape presses an anchor a widget publishes** (`region.*`, `fizzy.*`), never a screen coordinate -or an offset from one: the `move` before a `press` names a tag and nudges nothing, because a -nudge is a coordinate that goes stale when the layout shifts. fizzyedit/fizzy#634's soak tape -pressed a fixed offset from the sidebar's corner and broke when the arrange button moved; it now -presses `region.arrange:`. If no anchor names what you need, publish one -(`core.anchor.mark`). `tests/demos.zig` fails a bundled tape whose press is aimed any other way. +**A button lands on an anchor a widget publishes** (`region:`, `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, and `tests/demos.zig` fails a bundled demo that has one. The soak tape still presses +an offset from the sidebar's corner, where the arrange button was; fizzyedit/fizzy#634 aims it at +the button's own anchor (`region.arrange:`). **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 diff --git a/sdk/tape/Tape.zig b/sdk/tape/Tape.zig index 634b96c2f..cf5d64f58 100644 --- a/sdk/tape/Tape.zig +++ b/sdk/tape/Tape.zig @@ -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, }; @@ -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; @@ -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); diff --git a/tests/demos.zig b/tests/demos.zig index 3a5d0fd79..28f30358a 100644 --- a/tests/demos.zig +++ b/tests/demos.zig @@ -65,46 +65,6 @@ const DemoMoment = struct { now: f64, cursor: usize, print: u64 }; const bundled_seek_seed: u64 = 0xf122_de40; const bundled_random_seeks = 16; -/// A press lands where the pointer is, so the pointer's last `move` is what a press aims at. That -/// move must name an anchor a widget publishes (docs/AUTOMATION.md, "Ops aim at anchors"), with no -/// nudge off it, never the window (an empty tag, which is a raw coordinate) nor an offset from a -/// corner. Returns the index of the first press that doesn't, or null. -fn firstRawPress(tape: automation.Tape) ?usize { - var aim: ?automation.Tape.Target = null; - for (tape.ops, 0..) |op, i| switch (op.do) { - .move => |t| aim = t, - .press => { - const t = aim orelse return i; - if (t.tag.len == 0 or t.dx != 0 or t.dy != 0) return i; - }, - else => {}, - }; - return null; -} - -test "tapes: a press aims at an anchor, not a coordinate" { - // Bundled demos are checked in the test below, as they play. Drawing strokes on a canvas would - // be the legitimate raw coordinate; none of these tapes draw, so nothing is exempt on that - // ground. `soak.zon` presses an offset from the sidebar's corner until fizzyedit/fizzy#634 - // moves it onto `region.arrange:`; delete its entry then. - const exempt = [_][]const u8{"soak"}; - const tapes = [_]struct { name: []const u8, source: [:0]const u8 }{ - .{ .name = "soak", .source = @embedFile("tapes/soak.zon") }, - }; - for (tapes) |t| { - var owned = try automation.Tape.parse(std.testing.allocator, t.source, automation.Input.check); - defer owned.deinit(); - const raw = firstRawPress(owned.tape); - const is_exempt = for (exempt) |e| { - if (std.mem.eql(u8, e, t.name)) break true; - } else false; - if (raw != null and !is_exempt) { - std.log.err("tape '{s}': op {d} presses a coordinate; aim at an anchor (docs/AUTOMATION.md)", .{ t.name, raw.? }); - return error.PressByCoordinate; - } - } -} - test "demo: the rail's Files anchor the scripts spell out is the one fizzy publishes" { try std.testing.expectEqualStrings("fizzy.rail:" ++ workbench.view_files, fizzy.Editor.Demo.catalog.files_icon); } @@ -152,9 +112,10 @@ 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().?; - if (firstRawPress(tape.*)) |i| { - std.log.err("demo '{s}': op {d} presses a coordinate; aim at an anchor (docs/AUTOMATION.md)", .{ tape.name, i }); - return error.PressByCoordinate; + // 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); diff --git a/tests/integration.zig b/tests/integration.zig index 19cd93464..c3690a22e 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -5966,6 +5966,10 @@ 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); + // The one tape still pressing off an anchor (docs/AUTOMATION.md): an offset from the sidebar's + // corner, where the arrange button was. fizzyedit/fizzy#634 aims it at `region.arrange:Sidebar` + // and turns this into `== null`, as every bundled demo's is (`tests/demos.zig`). + try std.testing.expect(owned.tape.firstButtonOffAnchor() != null); var expect_arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer expect_arena.deinit(); From c1035caf98e34965b281f29b16120ac3a7decf92 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 19 Oct 2026 13:04:44 -0500 Subject: [PATCH 3/4] demos: the tour drops on the drop's own anchors; soak presses anchors --- app/layout/ViewDrag.zig | 11 +++++++++++ docs/AUTOMATION.md | 6 +++--- sdk/tape/Script.zig | 4 ++++ src/editor/demos/tour.zig | 2 +- tests/integration.zig | 6 ++---- 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/app/layout/ViewDrag.zig b/app/layout/ViewDrag.zig index 4a1ae72a1..ee15645d9 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -1322,6 +1322,17 @@ pub fn drawZones(l: *Layout, name: []const u8, key: dvui.Id) void { d.pending_frame = now; } if (d.pending_count == max_offers) return; + // The drop a release would land in, each bubble an anchor (`drop:`): one place at a time + // is the target, so a demo aims at the bubble rather than an offset from the place's middle. + if (target and !covered) 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}), + } + }; d.pending[d.pending_count] = .{ .key = key, .wheel = zones, diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index c4f7af503..82119a89e 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -142,9 +142,8 @@ window or an offset from an anchor: the `move` or `trace` before a `press` or `r 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, and `tests/demos.zig` fails a bundled demo that has one. The soak tape still presses -an offset from the sidebar's corner, where the arrange button was; fizzyedit/fizzy#634 aims it at -the button's own anchor (`region.arrange:`). +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 @@ -180,6 +179,7 @@ user's motion settings, timed in demo time. | `fizzy.window:dialog:`, `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 | diff --git a/sdk/tape/Script.zig b/sdk/tape/Script.zig index 114ffdd16..301a48bea 100644 --- a/sdk/tape/Script.zig +++ b/sdk/tape/Script.zig @@ -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. @@ -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 } }); diff --git a/src/editor/demos/tour.zig b/src/editor/demos/tour.zig index 078a38132..2816f230b 100644 --- a/src/editor/demos/tour.zig +++ b/src/editor/demos/tour.zig @@ -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); diff --git a/tests/integration.zig b/tests/integration.zig index c3690a22e..074cbdee7 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -5966,10 +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); - // The one tape still pressing off an anchor (docs/AUTOMATION.md): an offset from the sidebar's - // corner, where the arrange button was. fizzyedit/fizzy#634 aims it at `region.arrange:Sidebar` - // and turns this into `== null`, as every bundled demo's is (`tests/demos.zig`). - try std.testing.expect(owned.tape.firstButtonOffAnchor() != null); + // 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(); From 67945c06152c906b09a3cb7b6398bd4a25a9972e Mon Sep 17 00:00:00 2001 From: foxnne <foxnne@gmail.com> Date: Mon, 19 Oct 2026 13:26:52 -0500 Subject: [PATCH 4/4] layout: the drop anchors are marked only when asked for, ahead of the offer queue Marking costs nothing without a tape listening: the bubble rects are no longer computed unless anchors are wanted this frame, and the marks no longer depend on the offer queue's capacity. --- app/layout/ViewDrag.zig | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/app/layout/ViewDrag.zig b/app/layout/ViewDrag.zig index ee15645d9..664e544f1 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -1315,16 +1315,11 @@ 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; - // Queued, not drawn: over everything, once every place has drawn (`drawOverlay`). - const now = dvui.currentWindow().frame_time_ns; - if (d.pending_frame != now) { - d.pending_count = 0; - d.pending_frame = now; - } - if (d.pending_count == max_offers) return; // 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. - if (target and !covered) for (DropZones.all) |z| { + // 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 }; @@ -1333,6 +1328,13 @@ pub fn drawZones(l: *Layout, name: []const u8, key: dvui.Id) void { 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) { + d.pending_count = 0; + d.pending_frame = now; + } + if (d.pending_count == max_offers) return; d.pending[d.pending_count] = .{ .key = key, .wheel = zones,