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
2 changes: 2 additions & 0 deletions app/layout/Layout.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
304 changes: 159 additions & 145 deletions app/layout/Picker.zig

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/layout/Region.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions app/layout/SPLITS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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. |

Expand Down
76 changes: 76 additions & 0 deletions app/layout/place_commands.zig
Original file line number Diff line number Diff line change
@@ -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", .{});
}
5 changes: 2 additions & 3 deletions docs/AUTOMATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<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 |
| `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:<name>`, `.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:<name>` | 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.
Expand Down
1 change: 1 addition & 0 deletions src/editor/Editor.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions src/editor/demos/tour.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
56 changes: 52 additions & 4 deletions tests/integration.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
Loading