diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 91c001869..86745e9e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/app/layout/ViewDrag.zig b/app/layout/ViewDrag.zig index 4a1ae72a1..664e544f1 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -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:`): 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) { diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index af9ae6c23..82119a89e 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -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:`, `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), @@ -171,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/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/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/demos.zig b/tests/demos.zig index a77adc470..28f30358a 100644 --- a/tests/demos.zig +++ b/tests/demos.zig @@ -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)); diff --git a/tests/integration.zig b/tests/integration.zig index 19cd93464..074cbdee7 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -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();