diff --git a/app/layout/Layout.zig b/app/layout/Layout.zig index 73e836992..7eb18fca2 100644 --- a/app/layout/Layout.zig +++ b/app/layout/Layout.zig @@ -1056,6 +1056,8 @@ pub const SplitTree = @import("SplitTree.zig"); pub const Drop = @import("Drop.zig"); /// Carrying a view from one place to another — the gesture `Drop` decides for. pub const ViewDrag = @import("ViewDrag.zig"); +/// What a place's menu does to a place, as commands (`Picker`'s header). +pub const place_commands = @import("place_commands.zig"); /// The views floating over the window, each in a place of its own — see `Floats.zig`. pub const Floats = @import("Floats.zig"); /// The rules a float follows, as values (std-only). diff --git a/app/layout/Picker.zig b/app/layout/Picker.zig index a5f69b5ed..e08be74ce 100644 --- a/app/layout/Picker.zig +++ b/app/layout/Picker.zig @@ -9,7 +9,7 @@ //! //! Cards assign a surface here. That is how a sidebar view stays in the //! sidebar, and how a plugin readme lands in the center — keywords are the -//! default guess, an assignment is the answer. "Back to defaults" undoes it. +//! default guess, an assignment is the answer. Reset undoes it. //! The cards are listed under the place holding them, or else the place their //! keywords are for — this place first, then each other place by its name, then //! "Other" — so a view made for the panel reads as one, wherever the picker was @@ -23,10 +23,12 @@ //! card that started it went away with the popup. //! //! Each card carries a **snapshot** of the surface, not the live surface. -//! A **pinned** place (Sidebar, Main, Panel, Center) can be emptied (**Clear**) -//! or returned to keywords; it cannot be deleted. A **created** place -//! (`Main/r1`) is **Remove**. Split offers Vertical or Horizontal, and keeps -//! this place's view beside the new empty one — see `SPLITS.md`. +//! +//! The header is icons only, each a command (`place_commands`): split right or +//! down, clear, reset to the layout's default, delete (in red). A **pinned** +//! place (Sidebar, Main, Panel, Center) can be cleared or reset to its keywords; +//! it cannot be deleted. A **created** place (`Main/r1`) can. A split keeps this +//! place's view beside the new empty one — see `SPLITS.md`. const std = @import("std"); const dvui = @import("dvui"); const core = @import("core"); @@ -36,6 +38,7 @@ const Split = core.widgets.Split; const Layout = @import("Layout.zig"); const State = @import("State.zig"); const ViewDrag = @import("ViewDrag.zig"); +const place_commands = @import("place_commands.zig"); const Picker = @This(); @@ -52,11 +55,125 @@ grow_from: ?dvui.Rect.Natural = null, /// `ViewDrag`. The popup is closed; `draw` drives the drag instead until the /// button comes up. lifted: bool = false, +/// What place commands asked of places (`place_commands`), in order, carried out by the next +/// `draw`, where the live `Layout` is. +requests: [max_requests]Request = undefined, +request_n: usize = 0, +/// The place commands' owner (`place_commands.register`): a pseudo-plugin whose state is the +/// layout state this picker is part of, so each layout's commands reach their own. +commands_owner: sdk.Plugin = .{ .state = undefined, .vtable = &place_commands.vtable, .id = "layout", .display_name = "Layout" }, /// When the list last laid its cards out (`dvui.frameTimeNS`): a view being drawn for its picture /// whose card was not in view then has left the list (`Layout.captureUnplaced`). A draw that /// returned before the list leaves it alone. list_ns: i128 = 0, +/// What the menu's header can do to a place, each a command (`place_commands`). +pub const Action = enum { + split_right, + split_down, + clear, + reset, + delete, + + /// The anchor name a control publishes says it this way: `region.menu.split-right:Main`. + pub fn format(self: Action, w: *std.Io.Writer) std.Io.Writer.Error!void { + for (@tagName(self)) |ch| try w.writeByte(if (ch == '_') '-' else ch); + } +}; + +pub const Request = struct { + action: Action, + buf: [128]u8 = undefined, + len: usize = 0, + + pub fn place(self: *const Request) []const u8 { + return self.buf[0..self.len]; + } +}; + +/// How many place commands can wait for the next draw. +const max_requests = 8; + +/// Ask for `action` on place `name` in the next draw. False when the name does not fit, or too +/// many are waiting. +pub fn ask(self: *Picker, action: Action, name: []const u8) bool { + if (self.request_n == max_requests) return false; + var r: Request = .{ .action = action }; + if (name.len > r.buf.len) return false; + @memcpy(r.buf[0..name.len], name); + r.len = name.len; + self.requests[self.request_n] = r; + self.request_n += 1; + dvui.refresh(null, @src(), null); + return true; +} + +/// Whether `action` applies to place `region`: `allows`, and Clear only while it shows something — +/// which takes the live layout to know. The one rule the menu's buttons and the requests both +/// follow. +fn applies(f: *Layout, region: *const Layout.Region, action: Action) bool { + if (!allows(f.state, region.name, action)) return false; + if (action != .clear) return true; + return if (f.state.assignment(region.name)) |ids| ids.len > 0 else f.selectedIn(region) != null; +} + +/// Whether `action` applies to place `name`, as far as the layout state says (`applies` adds what +/// only the layout knows). +pub fn allows(state: *State, name: []const u8, action: Action) bool { + const created = state.isMinted(name); + return switch (action) { + .split_right, .split_down => true, + .clear => !created, + .reset => !created and state.assignment(name) != null, + // On the tree, any leaf with a sibling can go — a declared place's pin moves to the + // sibling. Off it, only a minted leaf can. A float can always go: it closes, and what it + // holds goes home. A minted half split again goes by merging its other half into it + // (`ViewDrag.closeEmptied`); with that half split too there is nothing to merge, and + // deleting would only clear it. + .delete => blk: { + const merges = !state.splits.isSplit(name) or state.siblingLeaf(name) != null; + break :blk (created and merges) or state.canRemove(name) or state.floats.find(name) != null; + }, + }; +} + +/// Carry out what place commands asked, in order, each on the place it named. +fn applyRequests(self: *Picker, f: *Layout) void { + const n = self.request_n; + self.request_n = 0; + for (self.requests[0..n]) |*req| self.applyRequest(f, req); +} + +fn applyRequest(self: *Picker, f: *Layout, req: *const Request) void { + const name = req.place(); + const region = for (f.state.regions.items) |r| { + if (std.mem.eql(u8, r.name, name)) break r; + } else return dvui.log.warn("{s}: no place named '{s}' now", .{ place_commands.title(req.action), name }); + if (!applies(f, ®ion, req.action)) return dvui.log.warn("{s}: does not apply to '{s}' now", .{ place_commands.title(req.action), name }); + switch (req.action) { + // Vertical / horizontal name the divider: a vertical bar is side by side (the layout axis + // is horizontal). + .split_right, .split_down => if (f.splitOn(name, if (req.action == .split_right) .right else .bottom) == null) { + return dvui.log.warn("{s}: '{s}' is too small to split", .{ place_commands.title(req.action), name }); + }, + .clear => clearRegion(f, name), + .reset => { + f.state.unassign(f.gpa, name); + f.state.markDirty(); + dvui.refresh(null, @src(), null); + }, + .delete => removeRegion(f, ®ion), + } + // A split or a deleted place leaves the menu with nothing that is still true to show. + switch (req.action) { + .split_right, .split_down, .delete => if (self.is_open and std.mem.eql(u8, self.region, name)) { + self.close(f.gpa); + f.state.discardSnapshots(f.gpa); + }, + .clear, .reset => {}, + } +} + /// Card geometry, in points. The preview keeps the snapshot's aspect inside this box. const preview: dvui.Size = .{ .w = 150, .h = 100 }; const columns: usize = 2; @@ -82,6 +199,7 @@ pub fn close(self: *Picker, gpa: std.mem.Allocator) void { /// Draw the popup if it is open. Call once per frame after the shape has run. Closing — /// by the user or because the region vanished — also discards the snapshots. pub fn draw(self: *Picker, f: *Layout) void { + self.applyRequests(f); if (self.lifted) return self.drawLifted(f); if (!self.is_open) return; const prof = core.profile.begin("fizzy", "picker"); @@ -139,121 +257,28 @@ pub fn draw(self: *Picker, f: *Layout) void { defer box.deinit(); { - var chrome = dvui.box(@src(), .{ .dir = .vertical }, .{ + // The place's name, and on the same row what the menu can do to the place: icons + // only, each a command (`place_commands`), each running its command. Those that do + // not apply keep their places, dimmed: coming and going they shoved the row around + // under the pointer. + var title = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .padding = .{ .h = 6 }, }); - defer chrome.deinit(); - - { - var title = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); - defer title.deinit(); - dvui.labelNoFmt(@src(), region.name, .{}, .{ - .font = dvui.Font.theme(.heading), - .gravity_y = 0.5, - }); - } - - const created = isCreated(state, region.name); - // On the tree, any leaf with a sibling can go — a declared place's pin moves to the - // sibling. Off it, only a minted leaf can. A float can always go: it closes, and what - // it holds goes home. - // A minted half split again goes by merging its other half into it (`ViewDrag.closeEmptied`); - // with that half split too there is nothing to merge, and Remove would only clear it. - const merges = !state.splits.isSplit(region.name) or state.siblingLeaf(region.name) != null; - const removable = (created and merges) or state.canRemove(region.name) or state.floats.find(region.name) != null; - const assigned = state.assignment(region.name); - const showing = if (assigned) |ids| ids.len > 0 else f.selectedIn(®ion) != null; - - // Every control on one row, what to do with the place on the right. Clear and - // Defaults keep their places, dimmed when they do not apply — each turns the other on - // as often as not (clearing leaves an assignment to go back from), and coming and - // going they shoved the row around under the pointer. - var controls = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .horizontal, - .padding = .{ .y = 4 }, + defer title.deinit(); + dvui.labelNoFmt(@src(), region.name, .{}, .{ + .font = dvui.Font.theme(.heading), + .gravity_y = 0.5, }); - defer controls.deinit(); - var actions = dvui.box(@src(), .{ .dir = .horizontal }, .{ .gravity_x = 1.0, .gravity_y = 0.5 }); defer actions.deinit(); - { - // Vertical / Horizontal name the divider: a vertical bar is side by - // side (the layout axis is horizontal). The old "split horizontally" - // label was that axis and read as the opposite split. - // - // On `core.widgets`' menu chain, not a `dvui.DropdownWidget`: this popup is a core - // floating menu, and dvui's dropdown opens its list on dvui's own chain, which this - // popup cannot see. The popup took the list taking focus for focus moving elsewhere, - // and closed — so Split shut the picker instead of offering the two choices. - const split = core.widgets.menuItem(@src(), .{ .submenu = true }, .{ - .font = actionFont(), - .padding = .{ .x = 6, .y = 2, .w = 6, .h = 2 }, - .margin = .{ .w = 4 }, - .gravity_y = 0.5, - .background = true, - .border = dvui.ButtonWidget.defaults.border, - .corners = dvui.ButtonWidget.defaults.corners, - .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(); - dvui.labelNoFmt(@src(), "Split", .{}, .{ - .font = actionFont(), - .padding = .{}, - .margin = .{}, - .gravity_y = 0.5, - }); - core.icon.icon(@src(), "split_choice", dvui.entypo.triangle_down, .{}, .{ - .padding = .{ .x = 4 }, - .gravity_y = 0.5, - }); - } - split.deinit(); - if (split_from) |r| { - const choices = core.widgets.floatingMenu(@src(), .{ - .from = r, - .avoid = .vertical, - .frost = core.widgets.menuFrost(), - }, core.widgets.menuSurfaceOptions()); - defer choices.deinit(); - if (core.widgets.menuRow(@src(), "Vertical", .{ .icon = icons.tvg.lucide.@"columns-2", .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", .{ .icon = icons.tvg.lucide.@"rows-2", .id_extra = 1, .anchor = "picker.split:horizontal" }) != null) { - choices.close(); - f.splitNamed(region.name, .vertical); - self.close(gpa); - state.discardSnapshots(gpa); - return; - } - } - } - if (!created) { - if (actionButton(@src(), "Clear", "picker.action:clear", showing, 2)) { - clearRegion(f, region.name); - } - if (actionButton(@src(), "Defaults", "picker.action:defaults", assigned != null, 4)) { - state.unassign(gpa, region.name); - state.markDirty(); + inline for (place_commands.all, 0..) |c, i| { + const enabled = applies(f, ®ion, c.action); + if (controlButton(@src(), c, region.name, enabled, i)) { + f.host.runCommand(c.id) catch |err| dvui.log.err("{s}: {t}", .{ c.id, err }); dvui.refresh(null, @src(), null); } } - if (removable) { - if (actionButton(@src(), "Remove", "picker.action:remove", true, 3)) { - removeRegion(f, ®ion); - self.close(gpa); - state.discardSnapshots(gpa); - return; - } - } } // The same filter box as the file tree's, the settings' and the store's. It matches what a @@ -723,44 +748,38 @@ fn drawLifted(self: *Picker, f: *Layout) void { ViewDrag.tick(f); } -fn actionFont() dvui.Font { - return dvui.Font.theme(.body).larger(-1); -} - -/// 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, name: []const u8, enabled: bool, id_extra: usize) bool { +/// One of the header's controls: an icon, a tooltip naming it, and its command run when pressed. +/// Published as `region.menu.:` 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. +/// Delete wears the theme's error colour. Disabled, it keeps its place (dimmed, no hover). +fn controlButton(src: std.builtin.SourceLocation, c: place_commands.Command, place: []const u8, enabled: bool, id_extra: usize) bool { const theme = dvui.themeGet(); - // 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 }, - .gravity_y = 0.5, + const tint = if (c.action == .delete) theme.color(.err, .text) else theme.color(.control, .text); + var bw: dvui.ButtonWidget = undefined; + bw.init(src, .{}, .{ .id_extra = id_extra, - .color_text = if (enabled) null else .{ .color = theme.color(.control, .text).opacity(0.35) }, + .padding = dvui.Rect.all(5), + .margin = .{ .x = 2 }, + .gravity_y = 0.5, .color_fill_hover = if (enabled) null else .{ .color = theme.color(.control, .fill) }, .color_fill_press = if (enabled) null else .{ .color = theme.color(.control, .fill) }, }); - 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}); + if (enabled) core.anchor.mark(bw.data(), "region.menu.{f}:{s}", .{ c.action, place }); 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 })); + core.icon.icon(@src(), c.id, c.icon, .{ + .stroke_color = .{ .color = if (enabled) tint else tint.opacity(0.35) }, + }, .{ + .min_size_content = .{ .w = 14, .h = 14 }, + .gravity_x = 0.5, + .gravity_y = 0.5, + }); bw.drawFocus(); - return clicked; + // Its title less the "Place: " every command of the palette's carries. + const title = if (std.mem.indexOf(u8, c.title, ": ")) |at| c.title[at + 2 ..] else c.title; + core.widgets.tooltip(@src(), .{ .active_rect = bw.data().borderRectScale().r }, "{s}", .{title}, .{}); + return enabled and bw.clicked(); } fn contains(list: []const *sdk.Surface, id: []const u8) bool { @@ -768,11 +787,6 @@ fn contains(list: []const *sdk.Surface, id: []const u8) bool { return false; } -/// A minted split leaf, not a name the shape declared. -fn isCreated(state: *State, name: []const u8) bool { - return state.isMinted(name); -} - /// Empty the place: nothing draws, and keywords no longer attract a replacement. fn clearRegion(f: *Layout, name: []const u8) void { f.state.assign(f.gpa, name, &.{}) catch |err| { diff --git a/app/layout/Region.zig b/app/layout/Region.zig index 70756aedf..098e3ccdf 100644 --- a/app/layout/Region.zig +++ b/app/layout/Region.zig @@ -1685,7 +1685,7 @@ pub fn fillInBox(self: *Layout, init_opts: InitOptions, box: *dvui.BoxWidget) !v /// Divide `name` on `axis`, keeping its view and opening an empty place beside /// it on the trailing side. `axis` is the layout direction — `.horizontal` is a -/// vertical divider (side by side), which is what the picker's Split menu names. +/// vertical divider (side by side): the menu's Split Right (`layout.place.splitRight`). pub fn splitNamed(self: *Layout, name: []const u8, axis: dvui.enums.Direction) void { const side: SplitTree.Side = switch (axis) { .horizontal => .right, diff --git a/app/layout/SPLITS.md b/app/layout/SPLITS.md index 85920bbdc..ebb715bc2 100644 --- a/app/layout/SPLITS.md +++ b/app/layout/SPLITS.md @@ -200,7 +200,7 @@ by again, the drop zones, splits, the picker. A place of several views keeps the 160×96. A press anywhere in it brings it to the front, as on an OS window. - **Back again.** Dragged out by its corner button, a view lands like any other, and a float its last view leaves closes behind it. Closed from its header, its window's close button (or the - picker's Remove), a float moves everything it holds back into the main window and loses + menu's Delete), a float moves everything it holds back into the main window and loses nothing (`float_rules.goHome`): each view into the list of the place it floated out of when the user had arranged that place; let go where its keywords show it, which never freezes a place its keywords fill; and a view they would show nowhere — one merged in from elsewhere, a @@ -234,7 +234,7 @@ its file's icon in the drop — and goes only to a document's slot, whose own dr ## Removing a split Carry the last view of one half onto the middle of the other (a join, above), carry it anywhere -else out of a minted half, or pick Remove. A minted leaf can be emptied and removed; a +else out of a minted half, or press Delete in its menu (`layout.place.delete`). A minted leaf can be emptied and removed; a shape-declared place can only be emptied. Removing the last leaf collapses the branch and the origin becomes a whole place again, with its extent and assignment untouched throughout — a split and its undo are symmetric. @@ -254,7 +254,7 @@ the branch, never the leaf that kept the name). | `ViewDrag.zig` | The gesture: drag state, hit-testing, the card, the drop zones over every place, and the assignment that lands. | | `SplitTree.zig` | The tree: split, collapse, persistence links. Geometry-free. | | `Region.zig` | Declaring a place and walking the tree to draw its leaves. | -| `Picker.zig` | The menu on a place: assign, clear, remove, split. | +| `Picker.zig` | The menu on a place: assign, and its header's split, clear, reset and delete (`place_commands.zig`). | | `Floats.zig` | The floats: their windows, landing, closing, stacking. | | `float_rules.zig` | Where a float opens, what keeps it reachable, its name, where a closed float's views go. std-only, unit-tested. | diff --git a/app/layout/place_commands.zig b/app/layout/place_commands.zig new file mode 100644 index 000000000..3fcb8231d --- /dev/null +++ b/app/layout/place_commands.zig @@ -0,0 +1,76 @@ +//! What a place's menu does to the place (`Picker`'s header): split it, clear it, put it back +//! to the layout's default, delete it. Each is a command, so the palette, a keymap, a tape or an +//! agent reaches it as the menu's button does — the button runs the command. +//! +//! A command only asks: it writes `Picker.request`, and the picker carries the request out in +//! its next draw, where the live `Layout` is. A command may run outside a frame (a control +//! server's call), when there is no `Layout` to split a place with. +const std = @import("std"); +const sdk = @import("fizzy_sdk"); +const icons = @import("icons"); + +const State = @import("State.zig"); +const Picker = @import("Picker.zig"); + +/// The commands' owner is a pseudo-plugin, as fizzy's own commands have one (never in +/// `host.plugins`, so no hook fires on it): `Picker.commands_owner`, its state the layout state it +/// is part of. +pub const vtable: sdk.Plugin.VTable = .{}; + +const PlaceArg = sdk.Command.Params(struct { + place: sdk.Command.Arg([]const u8, .{ .description = "The place, by name (\"Panel\", \"Main/r1\"). Empty: the place whose menu is open." }) = .init(""), +}); + +pub const Command = struct { + id: []const u8, + title: []const u8, + action: Picker.Action, + icon: []const u8, +}; + +/// Every place command, in the order the menu draws its buttons. +pub const all = [_]Command{ + .{ .id = "layout.place.splitRight", .title = "Place: Split Right", .action = .split_right, .icon = icons.tvg.lucide.@"columns-2" }, + .{ .id = "layout.place.splitDown", .title = "Place: Split Down", .action = .split_down, .icon = icons.tvg.lucide.@"rows-2" }, + .{ .id = "layout.place.clear", .title = "Place: Clear", .action = .clear, .icon = icons.tvg.lucide.eraser }, + .{ .id = "layout.place.reset", .title = "Place: Reset to the Layout's Default", .action = .reset, .icon = icons.tvg.lucide.@"rotate-ccw" }, + .{ .id = "layout.place.delete", .title = "Place: Delete", .action = .delete, .icon = icons.tvg.lucide.@"trash-2" }, +}; + +/// What `action` is called where a person reads it: its command's title. +pub fn title(action: Picker.Action) []const u8 { + inline for (all) |c| if (c.action == action) return c.title; + unreachable; +} + +/// Register the place commands against `state`, which must outlive them: call it once the +/// application's layout state is at its final address. +pub fn register(host: *sdk.Host, state: *State) !void { + const owner = &state.picker.commands_owner; + owner.state = state; + inline for (all) |c| { + try host.registerCommand(.{ + .id = c.id, + .owner = owner, + .title = c.title, + .icon = c.icon, + .params = PlaceArg.params, + .runWith = PlaceArg.bind(struct { + fn run(s: *anyopaque, args: PlaceArg.Args, call: *sdk.Command.Call) anyerror!void { + return ask(@ptrCast(@alignCast(s)), c.action, args.place, call); + } + }.run), + }); + } +} + +fn ask(state: *State, action: Picker.Action, given: []const u8, call: *sdk.Command.Call) !void { + const picker = &state.picker; + const name = if (given.len > 0) given else if (picker.is_open) picker.region else return call.fail("no place's menu is open: name the place", .{}); + const known = for (state.regions.items) |r| { + if (std.mem.eql(u8, r.name, name)) break true; + } else false; + if (!known) return call.fail("no place named '{s}'", .{name}); + if (!Picker.allows(state, name, action)) return call.fail("{s} does not apply to '{s}'", .{ title(action), name }); + if (!picker.ask(action, name)) return call.fail("the place name is too long, or too many place commands are waiting", .{}); +} diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index 2661cffd6..2bd12bfe4 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -189,10 +189,9 @@ user's motion settings, timed in demo time. | `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:`, `float-close:` | a float's header, to carry it by, and its close button: `float-close:Float 1` | | `picker:` | 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) | +| `region.menu.split-right:`, `.split-down:`, `.clear:`, `.reset:`, `.delete:` | the controls in the header of a place's menu, each only while it can be pressed (a dimmed Clear has no name); each runs its command (`layout.place.splitRight`, `splitDown`, `clear`, `reset`, `delete`), which a tape can call instead | +| `tabs:` | a place's tab strip, drawn while it holds more than one view | | `picker.empty` | the header of a place with nothing in it | -| `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/src/editor/Editor.zig b/src/editor/Editor.zig index 3709e19f4..1fdd01dd3 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -2200,6 +2200,7 @@ pub fn postInit(editor: *Editor) !void { // Fizzy commands must be registered against the Editor's *final* address — `init` returns // an Editor by value, so a pointer taken there would dangle the moment it's moved. try Keybinds.registerCommands(editor); + try Layout.place_commands.register(&editor.app.host, &editor.app.layout); // Install fizzy's read/utility surface so plugins reach shared fizzy state // (per-frame arena, project folder, content opacity, settings dirty-mark) through diff --git a/src/editor/demos/tour.zig b/src/editor/demos/tour.zig index 8179dce93..50766cf11 100644 --- a/src/editor/demos/tour.zig +++ b/src/editor/demos/tour.zig @@ -80,11 +80,10 @@ pub fn build(s: *Script) !void { try s.caption("Every place has a corner. Its button chooses what the place shows…", .{}); try catalog.cornerButton(s, "Sidebar"); try s.click(try catalog.corner(s, "Sidebar"), .{ .move_ms = 0 }); - try s.waitFor("picker.action:split", .{}); + try s.waitFor("region.menu.split-down:Sidebar", .{}); s.pause(1200); - try s.caption("…and splits it, for two views side by side.", .{ .near = "picker.action:split" }); - try s.click(.{ .tag = "picker.action:split" }, .{}); - try s.click(.{ .tag = "picker.split:horizontal" }, .{}); + try s.caption("…and splits it, for two views one above the other.", .{ .near = "region.menu.split-down:Sidebar" }); + try s.click(.{ .tag = "region.menu.split-down:Sidebar" }, .{}); s.pause(1200); try s.chapter("Floats"); diff --git a/tests/integration.zig b/tests/integration.zig index 722c7d953..43f351f6c 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -3140,20 +3140,68 @@ test "picker: its controls are named for a tape or an agent to aim at, while the PickerFrame.editor = editor; defer PickerFrame.editor = null; + try fizzy.Editor.Layout.place_commands.register(&editor.app.host, &editor.app.layout); 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.action:split"}) |name| { + for ([_][]const u8{ "region.menu.split-right:Main", "region.menu.split-down:Main" }) |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); + // Main shows nothing, so Clear is dimmed and has no name; nothing was put there, so there is + // no default to go back to; and a shape's place cannot be deleted. + try std.testing.expect(dvui.tagGet("region.menu.clear:Main") == null); + try std.testing.expect(dvui.tagGet("region.menu.reset:Main") == null); + try std.testing.expect(dvui.tagGet("region.menu.delete:Main") == null); +} + +test "picker: each header control runs its command, the button and the palette alike" { + 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.splits.deinit(editor.app.gpa); + defer editor.app.layout.picker.close(editor.app.gpa); + + PickerFrame.editor = editor; + defer PickerFrame.editor = null; + const state = &editor.app.layout; + try fizzy.Editor.Layout.place_commands.register(&editor.app.host, state); + try editor.app.host.registerSurface(.{ .id = "test.out", .title = "Out", .keywords = sdk.keywords.ide.panel, .draw = PickerFrame.draw }); + try dvui.testing.settle(PickerFrame.frame); + + // Run from the palette, with no place named: the place whose menu is open. + state.picker.open(editor.app.gpa, "Panel", null); + try editor.app.host.runCommand("layout.place.clear"); + try dvui.testing.settle(PickerFrame.frame); + try std.testing.expectEqual(@as(usize, 0), (state.assignment("Panel") orelse return error.TestExpectedEqual).len); + try std.testing.expect(state.picker.is_open); + + // The button: pressed, it runs `layout.place.reset`, and Panel goes back to its keywords. + try dvui.testing.settle(PickerFrame.frame); + try dvui.testing.moveTo("region.menu.reset:Panel"); + try dvui.testing.click(.left); + try dvui.testing.settle(PickerFrame.frame); + try std.testing.expect(state.assignment("Panel") == null); + + // Split, named: the place divides, and its menu, no longer true, shuts. + var results: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer results.deinit(); + const out = editor.app.host.callCommand("layout.place.splitRight", ".{ .place = \"Panel\" }", results.allocator()); + try std.testing.expect(out == .ok); + try dvui.testing.settle(PickerFrame.frame); + try std.testing.expect(state.splits.root("Panel") != null); + try std.testing.expect(!state.picker.is_open); } test "a fizzy Main split is a removable slot, not main.slot" {