From d8dc931222fc49459f6b450a1fc5fc257affe690 Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 16 Oct 2026 15:46:22 -0500 Subject: [PATCH] layout: the picker's buttons are named for tapes and agents The picker's Single, Multiple, Split, Clear, Defaults and Remove, and the Split menu's Vertical and Horizontal, now publish `picker.*` anchors. The row's buttons come and go with the place (Remove sits where Split sat on a place with nothing to Clear), so a click aimed by position removed places while a split was being driven; a name cannot land on the wrong action. `core.widgets.menuRow` takes an `anchor` for a row that runs no command. Co-Authored-By: Claude Opus 5.5 --- app/layout/Picker.zig | 42 ++++++++++++++++++++++++++---------- core/widgets.zig | 4 ++++ docs/AUTOMATION.md | 4 ++++ tests/integration.zig | 50 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 11 deletions(-) diff --git a/app/layout/Picker.zig b/app/layout/Picker.zig index efa7b7040..e6d0edb34 100644 --- a/app/layout/Picker.zig +++ b/app/layout/Picker.zig @@ -156,10 +156,10 @@ pub fn draw(self: *Picker, f: *Layout) void { .padding = .{ .y = 4 }, }); defer controls.deinit(); - if (modeButton(@src(), "Single", region.shows == .one, 1)) { + if (modeButton(@src(), "Single", "picker.mode:one", region.shows == .one, 1)) { setShows(f, ®ion, .one); } - if (modeButton(@src(), "Multiple", region.shows == .many, 2)) { + if (modeButton(@src(), "Multiple", "picker.mode:many", region.shows == .many, 2)) { setShows(f, ®ion, .many); } @@ -185,6 +185,7 @@ pub fn draw(self: *Picker, f: *Layout) void { .style = .control, }); const split_from = split.activeRect(); + core.anchor.mark(split.data(), "picker.action:split", .{}); { var label = dvui.box(@src(), .{ .dir = .horizontal }, .{}); defer label.deinit(); @@ -207,14 +208,14 @@ pub fn draw(self: *Picker, f: *Layout) void { .frost = core.widgets.menuFrost(), }, core.widgets.menuSurfaceOptions()); defer choices.deinit(); - if (core.widgets.menuRow(@src(), "Vertical", .{}) != null) { + if (core.widgets.menuRow(@src(), "Vertical", .{ .anchor = "picker.split:vertical" }) != null) { choices.close(); f.splitNamed(region.name, .horizontal); self.close(gpa); state.discardSnapshots(gpa); return; } - if (core.widgets.menuRow(@src(), "Horizontal", .{ .id_extra = 1 }) != null) { + if (core.widgets.menuRow(@src(), "Horizontal", .{ .id_extra = 1, .anchor = "picker.split:horizontal" }) != null) { choices.close(); f.splitNamed(region.name, .vertical); self.close(gpa); @@ -224,17 +225,17 @@ pub fn draw(self: *Picker, f: *Layout) void { } } if (!created) { - if (actionButton(@src(), "Clear", showing, 2)) { + if (actionButton(@src(), "Clear", "picker.action:clear", showing, 2)) { clearRegion(f, region.name); } - if (actionButton(@src(), "Defaults", assigned != null, 4)) { + if (actionButton(@src(), "Defaults", "picker.action:defaults", assigned != null, 4)) { state.unassign(gpa, region.name); state.markDirty(); dvui.refresh(null, @src(), null); } } if (removable) { - if (actionButton(@src(), "Remove", true, 3)) { + if (actionButton(@src(), "Remove", "picker.action:remove", true, 3)) { removeRegion(f, ®ion); self.close(gpa); state.discardSnapshots(gpa); @@ -606,9 +607,9 @@ fn setShows(f: *Layout, region: *const Layout.Region, shows: Layout.Region.Shows dvui.refresh(null, @src(), null); } -fn modeButton(src: std.builtin.SourceLocation, label: []const u8, on: bool, id_extra: usize) bool { +fn modeButton(src: std.builtin.SourceLocation, label: []const u8, name: []const u8, on: bool, id_extra: usize) bool { const theme = dvui.themeGet(); - return dvui.button(src, label, .{}, .{ + return namedButton(src, label, name, .{ .font = actionFont(), .padding = .{ .x = 6, .y = 2, .w = 6, .h = 2 }, .margin = .{ .x = 4 }, @@ -625,9 +626,11 @@ fn actionFont() dvui.Font { /// One of the picker's actions. Disabled, it keeps its place (dimmed, no hover, never clicked) /// rather than leaving a gap the row closes up over. -fn actionButton(src: std.builtin.SourceLocation, label: []const u8, enabled: bool, id_extra: usize) bool { +fn actionButton(src: std.builtin.SourceLocation, label: []const u8, name: []const u8, enabled: bool, id_extra: usize) bool { const theme = dvui.themeGet(); - const clicked = dvui.button(src, label, .{}, .{ + // Named only while it can be pressed: a dimmed one swallows the click, and a driver waiting + // for the name should wait for the action, not for the button. + const clicked = namedButton(src, label, if (enabled) name else null, .{ .font = actionFont(), .padding = .{ .x = 6, .y = 2, .w = 6, .h = 2 }, .margin = .{ .w = 4 }, @@ -640,6 +643,23 @@ fn actionButton(src: std.builtin.SourceLocation, label: []const u8, enabled: boo return enabled and clicked; } +/// `dvui.button`, named `name` (when there is one) for a demo or an agent to aim at (`core.anchor`, `picker.*` in +/// docs/AUTOMATION.md). The row's buttons come and go with the place — Remove sits where Split +/// sat on a place with nothing to Clear — so a point read off one place's picker can land on +/// another action in the next; a name cannot. +fn namedButton(src: std.builtin.SourceLocation, label: []const u8, name: ?[]const u8, opts: dvui.Options) bool { + var bw: dvui.ButtonWidget = undefined; + bw.init(src, .{}, opts); + defer bw.deinit(); + if (name) |n| core.anchor.mark(bw.data(), "{s}", .{n}); + bw.processEvents(); + bw.drawBackground(); + const clicked = bw.clicked(); + dvui.labelNoFmt(@src(), label, .{ .align_x = 0.5, .align_y = 0.5 }, opts.strip().override(bw.style()).override(.{ .gravity_x = 0.5, .gravity_y = 0.5 })); + bw.drawFocus(); + return clicked; +} + fn contains(list: []const *sdk.Surface, id: []const u8) bool { for (list) |s| if (std.mem.eql(u8, s.id, id)) return true; return false; diff --git a/core/widgets.zig b/core/widgets.zig index ac6f1c45d..189beffde 100644 --- a/core/widgets.zig +++ b/core/widgets.zig @@ -141,6 +141,9 @@ pub const MenuRowOptions = struct { /// The command the row runs, if it names one: the row is then a demo's to point at, as /// `fizzy.command:` (`core.anchor`). command: ?[]const u8 = null, + /// A name of its own for a row that runs no command, for a demo or an agent to aim at + /// (`core.anchor`). + anchor: ?[]const u8 = null, }; /// One menu row, the same everywhere: an icon column, the label straight after it, and the @@ -153,6 +156,7 @@ pub fn menuRow(src: std.builtin.SourceLocation, label: []const u8, opts: MenuRow var mi = menuItem(src, .{ .submenu = opts.submenu }, menuRowOptions(.{ .expand = .horizontal, .id_extra = opts.id_extra })); const ret: ?dvui.Rect.Natural = if (opts.enabled) mi.activeRect() else null; if (opts.command) |id| anchor.mark(mi.data(), "fizzy.command:{s}", .{id}); + if (opts.anchor) |name| anchor.mark(mi.data(), "{s}", .{name}); // Closed before `mi`: it is `mi`'s child, and dvui's widget stack is strictly LIFO. var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .id_extra = opts.id_extra }); diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index 0472208a7..1367a1f0e 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -152,6 +152,10 @@ user's motion settings, timed in demo time. | `fizzy.window:main` | the main window | | `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`, …) | +| `picker:<view id>` | a view's card in a place's picker | +| `picker.mode:one`, `picker.mode:many` | the picker's Single / Multiple | +| `picker.action:split`, `:clear`, `:defaults`, `:remove` | the picker's actions, each only while it can be pressed (a dimmed Clear has no name) | +| `picker.split:vertical`, `picker.split:horizontal` | the rows of the picker's Split menu | A demo's files live under its keyframe's `root`, so a path is `demo://tour/src/main.zig`. `src/editor/demos/catalog.zig` has helpers (`catalog.file(s, "src/main.zig")`) that build these. diff --git a/tests/integration.zig b/tests/integration.zig index 07eeb2ee0..7571d2119 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -2713,6 +2713,56 @@ const FizzySplitFrame = struct { } }; +/// `FizzySplitFrame` with anchors asked for and the picker drawn over it, as fizzy does. +const PickerFrame = struct { + var editor: ?*fizzy.Editor = null; + + fn frame() anyerror!dvui.App.Result { + const e = editor.?; + fizzy.core.anchor.want(); + var layout = fizzy.Editor.Layout.init(&e.app.host, &e.app.layout, e.app.gpa, dvui.currentWindow().arena()); + { + var main = try layout.region(@src(), .{ .name = "Main", .keywords = fizzy.sdk.keywords.ide.main }, .{ .expand = .both }); + defer main.deinit(); + } + e.app.layout.publishRegions(); + e.app.layout.picker.draw(&layout); + return .ok; + } +}; + +test "picker: its controls are named for a tape or an agent to aim at, while they can be pressed" { + var ctx = try shim.init(std.testing.allocator); + defer ctx.deinit(std.testing.allocator); + + const editor = ctx.editor; + editor.app.gpa = std.testing.allocator; + defer editor.app.layout.regions.deinit(editor.app.gpa); + defer editor.app.layout.regions_building.deinit(editor.app.gpa); + defer editor.app.layout.deinitExtents(editor.app.gpa); + defer editor.app.layout.deinitAssignments(editor.app.gpa); + defer editor.app.layout.deinitQualified(editor.app.gpa); + defer editor.app.layout.picker.close(editor.app.gpa); + + PickerFrame.editor = editor; + defer PickerFrame.editor = null; + + try dvui.testing.settle(PickerFrame.frame); + editor.app.layout.picker.open(editor.app.gpa, "Main", null); + _ = try dvui.testing.step(PickerFrame.frame); + _ = try dvui.testing.step(PickerFrame.frame); + + for ([_][]const u8{ "picker.mode:one", "picker.mode:many", "picker.action:split" }) |name| { + if (dvui.tagGet(name) == null) { + std.debug.print("no anchor {s}\n", .{name}); + return error.TestExpectedEqual; + } + } + // Main shows nothing, so Clear is dimmed and has no name; a shape's place has no Remove. + try std.testing.expect(dvui.tagGet("picker.action:clear") == null); + try std.testing.expect(dvui.tagGet("picker.action:remove") == null); +} + test "a fizzy Main split is a removable slot, not main.slot" { var ctx = try shim.init(std.testing.allocator); defer ctx.deinit(std.testing.allocator);