diff --git a/app/layout/Layout.zig b/app/layout/Layout.zig index 08edabec1..358ba8fec 100644 --- a/app/layout/Layout.zig +++ b/app/layout/Layout.zig @@ -1089,6 +1089,10 @@ pub const splitNamed = Region.splitNamed; pub const splitOn = Region.splitOn; /// Mark the split that minted a place as fizzy's own, so it follows its space's shape (`core.placement`). pub const markAdaptive = Region.markAdaptive; +/// Divide a named place the way its space's shape asks, the split adaptive — the menu's Split. +pub const splitAdaptive = Region.splitAdaptive; +/// Make the split a place is one half of follow its space's shape, or stop. +pub const setAdaptive = Region.setAdaptive; /// Move the visible surface from one place to another — a view-drag drop. pub const placeVisible = ViewDrag.place; diff --git a/app/layout/Picker.zig b/app/layout/Picker.zig index 88e8d90b1..743443d90 100644 --- a/app/layout/Picker.zig +++ b/app/layout/Picker.zig @@ -25,7 +25,8 @@ //! Each card carries a **snapshot** of the surface, not the live surface. //! //! 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** +//! down, split the way the room asks, the split following its room or not (lit +//! while it does), 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`. @@ -71,6 +72,8 @@ list_ns: i128 = 0, pub const Action = enum { split_right, split_down, + split, + adaptive, clear, reset, delete, @@ -122,7 +125,8 @@ fn applies(f: *Layout, region: *const Layout.Region, action: Action) bool { pub fn allows(state: *State, name: []const u8, action: Action) bool { const created = state.isMinted(name); return switch (action) { - .split_right, .split_down => true, + .split_right, .split_down, .split => true, + .adaptive => state.adaptiveOf(name) != null, .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 @@ -156,6 +160,11 @@ fn applyRequest(self: *Picker, f: *Layout, req: *const Request) void { .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 }); }, + .split => if (f.splitAdaptive(name) == null) { + return dvui.log.warn("{s}: '{s}' is too small to split", .{ place_commands.title(req.action), name }); + }, + // `applies` checked it is a split the person may turn. + .adaptive => f.setAdaptive(name, !f.state.adaptiveOf(name).?), .clear => clearRegion(f, name), .reset => { f.state.unassign(f.gpa, name); @@ -166,11 +175,11 @@ fn applyRequest(self: *Picker, f: *Layout, req: *const Request) void { } // 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)) { + .split_right, .split_down, .split, .delete => if (self.is_open and std.mem.eql(u8, self.region, name)) { self.close(f.gpa); f.state.discardSnapshots(f.gpa); }, - .clear, .reset => {}, + .adaptive, .clear, .reset => {}, } } @@ -274,7 +283,8 @@ pub fn draw(self: *Picker, f: *Layout) void { defer actions.deinit(); inline for (place_commands.all, 0..) |c, i| { const enabled = applies(f, ®ion, c.action); - if (controlButton(@src(), c, region.name, enabled, i)) { + const lit = c.action == .adaptive and (f.state.adaptiveOf(region.name) orelse false); + if (controlButton(@src(), c, region.name, enabled, lit, i)) { f.host.runCommand(c.id) catch |err| dvui.log.err("{s}: {t}", .{ c.id, err }); dvui.refresh(null, @src(), null); } @@ -772,13 +782,14 @@ fn drawLifted(self: *Picker, f: *Layout) void { /// 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.lang.SourceLocation, c: place_commands.Command, place: []const u8, enabled: bool, id_extra: usize) bool { +/// Delete wears the theme's error colour, a toggle that is on (`lit`) the highlight's. Disabled, +/// it keeps its place (dimmed, no hover). +fn controlButton(src: std.lang.SourceLocation, c: place_commands.Command, place: []const u8, enabled: bool, lit: bool, id_extra: usize) bool { const theme = dvui.themeGet(); const destructive = c.action == .delete; // Destructive controls wear the error fill as it is (lightening it in dark darkened it); // `.err`'s text colour is not red in every theme. - const tint = if (destructive) theme.color(.err, .fill) else theme.color(.control, .text); + const tint = if (destructive) theme.color(.err, .fill) else if (lit) theme.color(.highlight, .fill) else theme.color(.control, .text); // Disabled keeps the place's slot but dimmer; delete stays red enough to read as delete. const toward_fill: f32 = if (destructive) 0.4 else 0.65; const fill = theme.color(.control, .fill); diff --git a/app/layout/Region.zig b/app/layout/Region.zig index 01c54b483..58ba03cb3 100644 --- a/app/layout/Region.zig +++ b/app/layout/Region.zig @@ -1328,7 +1328,7 @@ fn initTree( if (init_opts.resize) self.drawPendingSplit(id) else self.drawPendingSplit(null); - const branch = orientBranch(self, node.kind.branch, id); + const branch = orientBranch(self, &node.kind.branch, id); var box_opts = opts; var default_extent: f32 = 0; // Forgotten this frame, the tree `branch` points into may be freed: its children wait for @@ -1440,7 +1440,9 @@ fn initTree( /// changed shape), and follows a drag while turned. That drag is shown and nothing more: turning /// back takes the share from the saved one again (`_seen` is unread while turned, so dvui drops it /// and the first frame back reads the saved share anew). -fn orientBranch(self: *Layout, saved: SplitTree.Branch, id: dvui.Id) SplitTree.Branch { +fn orientBranch(self: *Layout, branch: *SplitTree.Branch, id: dvui.Id) SplitTree.Branch { + if (branch.settle) settleTurn(self, branch, id); + const saved = branch.*; if (!saved.adaptive) return saved; const dims = dvui.dataGet(null, id, "_dims", dvui.Size) orelse return saved; const leaf = idOf(self.state, saved.created); @@ -1511,6 +1513,19 @@ fn orientBranch(self: *Layout, saved: SplitTree.Branch, id: dvui.Id) SplitTree.B return drawn; } +/// Drop what a turn left in the frame's data (`orientBranch`) for `branch`, whose box is `id`. Its +/// places read `_fold` and `_turned` every frame, adaptive or not, so a split that stopped being +/// adaptive mid-turn would stay shut, or never save a drag again. What the turn showed is already +/// in the branch (`setAdaptive`), or is not wanted (a split rebuilt from a saved arrangement). +fn settleTurn(self: *Layout, branch: *SplitTree.Branch, id: dvui.Id) void { + branch.settle = false; + for ([_][]const u8{ "_turned", "_fold", "_share", "_set", "_seen" }) |key| dvui.dataRemove(null, id, key); + if (idOf(self.state, branch.created)) |leaf| { + dvui.dataRemove(null, leaf, "_turned"); + dvui.dataRemove(null, leaf, "_fold"); + } +} + /// The share an adaptive split's new half has now: drawn through the turn (`_share`), and saved /// with the split. fn setShare(self: *Layout, created: []const u8, id: dvui.Id, share: f32) void { @@ -1700,7 +1715,7 @@ fn drawTreeNode( }, .branch => |*branch| { if (self.depth >= Layout.max_nesting) return; - const b = orientBranch(self, branch.*, dvui.parentGet().extendId(src, nameExtra(branch.origin))); + const b = orientBranch(self, branch, dvui.parentGet().extendId(src, nameExtra(branch.origin))); const parent_dir = if (self.depth > 0) self.containers[self.depth - 1].dir else .horizontal; // Same extra as the leaf this branch replaced, so the parent sash // keeps the widget it was already dragging. @@ -1890,6 +1905,55 @@ pub fn markAdaptive(self: *Layout, new: []const u8) void { if (self.state.splits.markAdaptive(new)) self.state.markDirty(); } +/// Divide `name` the way its space's shape asks (`core.placement.orient`), the new place on the +/// trailing side, and the split adaptive from then on: the menu's Split (`layout.place.split`), +/// two groups where the person asked for two and named no side. Returns the minted leaf, or null +/// as `splitOn` does. No size floor past the layout's own: the person asked. +pub fn splitAdaptive(self: *Layout, name: []const u8) ?[]const u8 { + const size = ViewDrag.placeSize(self.state, name) orelse return null; + const side: SplitTree.Side = switch (core.placement.orient(null, size.w, size.h)) { + .side_by_side => .right, + .stacked => .bottom, + }; + const new = splitOn(self, name, side) orelse return null; + markAdaptive(self, new); + return new; +} + +/// Make the split place `name` is one half of follow its room, or stop (`layout.place.adaptive`), +/// where `State.adaptiveOf` allows it. Nothing moves under the person either way. On, the split +/// keeps the size it is drawn at as its share (the seed tree's sizes by `ratio` already; the +/// forest's takes the share from the extent, `orientBranch`). Off, it keeps the direction it is +/// drawn in now, and a turn under way settles where it is (`settleTurn`). +pub fn setAdaptive(self: *Layout, name: []const u8, on: bool) void { + if (self.state.adaptiveOf(name) == null) return; + if (self.state.floatRoot(name) == null) if (self.state.dock) |*dock| { + const parent = dock.findParent(dock.findPanel(name).?).?; + const split = &dock.nodes.items[parent.idx].split; + if (!on) split.dir = split.axis(); + split.adaptive = on; + split.drawn = null; + self.state.markDirty(); + dvui.refresh(null, @src(), null); + return; + }; + const branch = self.state.splits.halfOf(name).?; + if (!on) if (idOf(self.state, branch.created)) |leaf| { + if (dvui.dataGet(null, leaf, "_turned", bool) orelse false) { + SplitTree.turn(branch); + if (dvui.dataGet(null, leaf, "_size", f32)) |extent| { + if (self.state.setExtent(self.gpa, branch.created, extent)) self.extents_changed = true; + } + } + }; + branch.adaptive = on; + // Taken anew from the extent it has now: a share from when it was last adaptive would jump. + branch.share = null; + if (!on) branch.settle = true; + self.state.markDirty(); + dvui.refresh(null, @src(), null); +} + fn splitDock( self: *Layout, dock: *core.widgets.DockLayout, diff --git a/app/layout/SplitTree.zig b/app/layout/SplitTree.zig index 78fa33e27..76c0955f3 100644 --- a/app/layout/SplitTree.zig +++ b/app/layout/SplitTree.zig @@ -60,6 +60,10 @@ pub const Branch = struct { /// the half's pixel extent is along the axis it was last chosen on, which a box of another /// shape (a restart in portrait) no longer has. Null until the person has sized it. share: ?f32 = null, + /// Transient: what a turn left in the frame's data for this split is to go on its next draw + /// (`Region.settleTurn`). Set when it stops being adaptive, and when it is rebuilt under the + /// name of one that was. Never saved. + settle: bool = false, a: *Node, b: *Node, }; @@ -224,6 +228,25 @@ pub const Forest = struct { return false; } + /// The split `name` is one half of, as a single place; null when it is no split's half. + pub fn halfOf(self: *const Forest, name: []const u8) ?*Branch { + var it = self.roots.valueIterator(); + while (it.next()) |n| if (halfIn(n.*, name)) |b| return b; + return null; + } + + fn halfIn(node: *Node, name: []const u8) ?*Branch { + switch (node.kind) { + .leaf => return null, + .branch => |*b| { + for ([_]*Node{ b.a, b.b }) |half| { + if (leafName(half.*)) |n| if (std.mem.eql(u8, n, name)) return b; + } + return halfIn(b.a, name) orelse halfIn(b.b, name); + }, + } + } + /// Record the share of the adaptive split that minted `created`. True when it changed. pub fn setShare(self: *Forest, created: []const u8, share: f32) bool { var it = self.roots.valueIterator(); @@ -737,3 +760,28 @@ test "an adaptive split turns a quarter, keeps its half and its name, and is sav turn(b); try std.testing.expectEqual(Side.right, b.side); } + +test "a place's own split is found from either half, and is saved as the toggle leaves it" { + const gpa = std.testing.allocator; + var f: Forest = .{}; + defer f.deinit(gpa); + _ = f.split(gpa, internLiteral, "Main", .right, 80, "Main/r1").?; + _ = f.split(gpa, internLiteral, "Main/r1", .bottom, 40, "Main/r1/b1").?; + try std.testing.expect(f.halfOf("Nowhere") == null); + + const outer = f.halfOf("Main").?; + try std.testing.expectEqualStrings("Main/r1", outer.created); + // The half split again is a half of its own split, not of the one that made it. + try std.testing.expectEqualStrings("Main/r1/b1", f.halfOf("Main/r1").?.created); + try std.testing.expectEqual(f.halfOf("Main/r1"), f.halfOf("Main/r1/b1")); + + outer.adaptive = true; + turn(outer); + outer.adaptive = false; + const links = f.collectLinks(gpa); + defer gpa.free(links); + for (links) |l| { + try std.testing.expect(!l.adaptive); + if (std.mem.eql(u8, l.name, "Main/r1")) try std.testing.expectEqual(Side.bottom, l.side); + } +} diff --git a/app/layout/State.zig b/app/layout/State.zig index 243029465..b593019d3 100644 --- a/app/layout/State.zig +++ b/app/layout/State.zig @@ -426,6 +426,55 @@ pub fn isMinted(self: *const State, name: []const u8) bool { return self.splits.canForget(name); } +/// Whether the split place `name` is one half of follows its room (`core.placement`), or null +/// when that split is not the person's to change so: `name` is no split's half, the shape declared +/// the split (a shape's own splits stay as it wrote them), or the split is sized to its content +/// or in points (`fit`, `fixed`), which a turn would not keep. +pub fn adaptiveOf(self: *const State, name: []const u8) ?bool { + if (self.floatRoot(name) == null) { + if (self.dock) |*d| { + const idx = d.findPanel(name) orelse return null; + const parent = d.findParent(idx) orelse return null; + const split = switch (d.nodes.items[parent.idx]) { + .split => |sp| sp, + else => return null, + }; + if (split.fit != null or split.fixed != null) return null; + const made = mintedFrom(d, split.first, split.second) or mintedFrom(d, split.second, split.first); + return if (made) split.adaptive else null; + } + } + const branch = self.splits.halfOf(name) orelse return null; + return branch.adaptive; +} + +/// Whether a place under `at` was minted from one under `origin`: so a split made at runtime +/// (`Region.splitDock`) has its halves, where a split the seed declared has places of its own. +fn mintedFrom(d: *const core.widgets.DockLayout, at: core.widgets.DockLayout.NodeIndex, origin: core.widgets.DockLayout.NodeIndex) bool { + switch (d.nodes.items[at]) { + .split => |sp| return mintedFrom(d, sp.first, origin) or mintedFrom(d, sp.second, origin), + .leaf => |l| { + if (l.pinned) return false; + for (l.tabs.items) |panel| { + const slash = std.mem.findScalarLast(u8, panel, '/') orelse continue; + if (holds(d, origin, panel[0..slash])) return true; + } + return false; + }, + .free => return false, + } +} + +fn holds(d: *const core.widgets.DockLayout, at: core.widgets.DockLayout.NodeIndex, name: []const u8) bool { + return switch (d.nodes.items[at]) { + .split => |sp| holds(d, sp.first, name) or holds(d, sp.second, name), + .leaf => |l| for (l.tabs.items) |panel| { + if (std.mem.eql(u8, panel, name)) break true; + } else false, + .free => false, + }; +} + /// The other half of the split place `name` is one half of, when that half is a single place: /// the place it would merge with. Null when `name` is no split's half, or its other half is /// split again. @@ -1106,3 +1155,41 @@ test "a split nested in a split rounds only the outer corners" { try std.testing.expectEqual([4]bool{ false, false, false, false }, testCorners(&s, bl)); try std.testing.expectEqual([4]bool{ false, false, true, false }, testCorners(&s, br)); } + +test "only a split made at runtime may follow its room, never one the seed declared" { + const gpa = std.testing.allocator; + const side: Seed.Tree = .{ .leaf = .{ .name = "Sidebar", .keywords = &.{"sidebar"}, .pinned = true } }; + const main: Seed.Tree = .{ .leaf = .{ .name = "Main", .keywords = &.{"main"}, .pinned = true } }; + const panel: Seed.Tree = .{ .leaf = .{ .name = "Panel", .keywords = &.{"panel"}, .pinned = true } }; + const column: Seed.Tree = .{ .split = .{ .dir = .vertical, .first = &main, .second = &panel } }; + const seed: Seed.Tree = .{ .split = .{ + .dir = .horizontal, + .fixed = .{ .child = .first, .points = 260 }, + .first = &side, + .second = &column, + } }; + var s: State = .{}; + s.dock = try seed.toDockLayout(gpa); + defer s.dock.?.deinit(); + const dock = &s.dock.?; + + // The seed's own: one in points, one plain. Neither is the person's to turn. + try std.testing.expectEqual(@as(?bool, null), s.adaptiveOf("Sidebar")); + try std.testing.expectEqual(@as(?bool, null), s.adaptiveOf("Main")); + try std.testing.expectEqual(@as(?bool, null), s.adaptiveOf("Nowhere")); + + // Main split at runtime (`Region.splitDock`), then its new half split again. + try dock.splitLeaf(dock.findPanel("Main").?, .right, try gpa.dupe(u8, "Main/r1")); + try std.testing.expectEqual(@as(?bool, false), s.adaptiveOf("Main")); + try std.testing.expectEqual(@as(?bool, false), s.adaptiveOf("Main/r1")); + try dock.splitLeaf(dock.findPanel("Main/r1").?, .bottom, try gpa.dupe(u8, "Main/r1/b1")); + try std.testing.expectEqual(@as(?bool, false), s.adaptiveOf("Main/r1/b1")); + // Main's split holds the other split whole: still the runtime one. + try std.testing.expectEqual(@as(?bool, false), s.adaptiveOf("Main")); + // The column the seed declared, Main's split now inside it, stays the seed's. + try std.testing.expectEqual(@as(?bool, null), s.adaptiveOf("Panel")); + + const parent = dock.findParent(dock.findPanel("Main/r1/b1").?).?; + dock.nodes.items[parent.idx].split.adaptive = true; + try std.testing.expectEqual(@as(?bool, true), s.adaptiveOf("Main/r1")); +} diff --git a/app/layout/place_commands.zig b/app/layout/place_commands.zig index 3fcb8231d..f51fc1611 100644 --- a/app/layout/place_commands.zig +++ b/app/layout/place_commands.zig @@ -32,6 +32,8 @@ pub const Command = struct { 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.split", .title = "Place: Split", .action = .split, .icon = icons.tvg.lucide.@"square-split-horizontal" }, + .{ .id = "layout.place.adaptive", .title = "Place: Split Follows the Room", .action = .adaptive, .icon = icons.tvg.lucide.@"rotate-cw-square" }, .{ .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" }, diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index 472454fb8..52ea26adf 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -200,7 +200,7 @@ 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 | -| `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 | +| `region.menu.split-right:`, `.split-down:`, `.split:`, `.adaptive:`, `.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`, `split`, `adaptive`, `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 | diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 83046ce1a..63bcb4d09 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -5287,6 +5287,9 @@ fn loadRuntimeSplits(state: *Layout.State, gpa: std.mem.Allocator, saved: []cons const parent = r.parent orelse continue; const side = Layout.State.SplitTree.parseSide(r.from orelse continue) orelse continue; const made = state.splits.split(gpa, intern.go, parent, side, r.extent orelse 0, r.name); + // Rebuilt under the name of a split that may have been turning (a demo putting the + // person's arrangement back): that turn is not this one's (`Region.settleTurn`). + if (made != null) state.splits.halfOf(r.name).?.settle = true; if (made != null and (r.adaptive orelse false)) { _ = state.splits.markAdaptive(r.name); if (r.share) |share| _ = state.splits.setShare(r.name, share); diff --git a/src/editor/layout_mobile.zig b/src/editor/layout_mobile.zig index cd0af8f8f..60dc37439 100644 --- a/src/editor/layout_mobile.zig +++ b/src/editor/layout_mobile.zig @@ -138,6 +138,14 @@ fn topStrip(editor: *fizzy.Editor) void { }); _ = dvui.spacer(@src(), .{ .expand = .horizontal }); + // Two groups, the way the room asks: the command, so a tape, an agent or the palette does + // the same thing, and the button can move with the strip. + // TODO(seam): plans#60's swipe on the group's strip and plans#109 D11's split header call + // `layout.place.split` as this does, and this button goes when they land. + if (iconButton(@src(), icons.tvg.lucide.@"square-split-horizontal", "fizzy.mobile.split")) { + const out = editor.app.host.callCommand("layout.place.split", ".{ .place = \"Main\" }", dvui.currentWindow().arena()); + if (out != .ok) dvui.log.warn("layout.place.split: {s}", .{@tagName(out)}); + } if (iconButton(@src(), icons.tvg.lucide.search, "fizzy.mobile.commands")) editor.command_palette.show(.commands); } diff --git a/tests/integration.zig b/tests/integration.zig index ee771f165..7c0c2b5c6 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -4128,6 +4128,256 @@ test "a drag while an adaptive split is turned is not kept when it turns back" { try std.testing.expect(!editor.app.layout.dirty); } +// -- the place commands that follow the room -------------------------------------------------------- +// `layout.place.split` and `layout.place.adaptive` (design note "layout placement", Who asks it, +// item 2), on the forest of a carded Main as a phone or a tablet has it. The picker is drawn after +// the shape, as fizzy's frame does: what a place command asks is carried out there. + +const PlaceCase = struct { + ctx: shim.Ctx, + + fn init(w: f32, h: f32) !PlaceCase { + var ctx = try shim.init(std.testing.allocator); + errdefer ctx.deinit(std.testing.allocator); + const editor = ctx.editor; + editor.app.gpa = std.testing.allocator; + ColumnCardFrame.editor = editor; + try fizzy.Editor.Layout.place_commands.register(&editor.app.host, &editor.app.layout); + resize(&ctx, w, h); + _ = try dvui.testing.step(frame); + try dvui.testing.settle(frame); + return .{ .ctx = ctx }; + } + + fn deinit(self: *PlaceCase) void { + const editor = self.ctx.editor; + editor.app.layout.picker.close(editor.app.gpa); + editor.app.layout.regions.deinit(editor.app.gpa); + editor.app.layout.regions_building.deinit(editor.app.gpa); + editor.app.layout.deinitExtents(editor.app.gpa); + editor.app.layout.deinitAssignments(editor.app.gpa); + editor.app.layout.deinitQualified(editor.app.gpa); + ColumnCardFrame.editor = null; + self.ctx.deinit(std.testing.allocator); + } + + fn frame() !dvui.App.Result { + fizzy.core.anchor.want(); + const result = try ColumnCardFrame.frame(); + const e = ColumnCardFrame.editor.?; + var layout = fizzy.Editor.Layout.init(&e.app.host, &e.app.layout, e.app.gpa, dvui.currentWindow().arena()); + e.app.layout.picker.draw(&layout); + return result; + } + + fn palette() !dvui.App.Result { + fizzy.core.anchor.want(); + const e = ColumnCardFrame.editor.?; + e.command_palette.draw(e); + return .ok; + } + + fn resize(ctx: *shim.Ctx, w: f32, h: f32) void { + ctx.t.backend.size = .{ .w = w, .h = h }; + ctx.t.backend.size_pixels = .{ .w = w, .h = h }; + } + + /// Run place command `id` on `place`, as the palette, a tape or an agent does, and draw what + /// it asked. + fn run(self: *PlaceCase, id: []const u8, place: []const u8) !void { + var results: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer results.deinit(); + var buf: [96]u8 = undefined; + const args = try std.mem.print(&buf, ".{{ .place = \"{s}\" }}", .{place}); + const out = self.ctx.editor.app.host.callCommand(id, args, results.allocator()); + if (out != .ok) return error.TestUnexpectedResult; + try dvui.testing.settle(frame); + } + + fn link(self: *PlaceCase) !fizzy.Editor.Layout.State.SplitTree.Forest.SavedLink { + const links = self.ctx.editor.app.layout.splits.collectLinks(std.testing.allocator); + defer std.testing.allocator.free(links); + try std.testing.expectEqual(@as(usize, 1), links.len); + return links[0]; + } + + fn leafId(self: *PlaceCase, name: []const u8) !dvui.Id { + for (self.ctx.editor.app.layout.regions.items) |r| { + if (std.mem.eql(u8, r.name, name)) return r.id; + } + return error.TestExpectedEqual; + } +}; + +fn splitsByRoom(w: f32, h: f32, made: []const u8, side: fizzy.Editor.Layout.SplitTree.Side) !void { + var case = try PlaceCase.init(w, h); + defer case.deinit(); + try case.run("layout.place.split", "Main"); + const l = try case.link(); + try std.testing.expectEqualStrings(made, l.name); + try std.testing.expectEqual(side, l.side); + try std.testing.expect(l.adaptive); + const old = try ColumnCardFrame.sizeOf(case.ctx.editor, "Main"); + const new = try ColumnCardFrame.sizeOf(case.ctx.editor, made); + try std.testing.expect(new.w > 40 and new.h > 40); + switch (side) { + .bottom => try std.testing.expectApproxEqAbs(old.w, new.w, 1), + else => try std.testing.expectApproxEqAbs(old.h, new.h, 1), + } +} + +test "layout.place.split stacks a tall place, as the room asks, and the split follows its room" { + try splitsByRoom(820, 1180, "Main/b1", .bottom); +} + +test "layout.place.split puts a wide place's halves side by side, as the room asks" { + try splitsByRoom(1180, 820, "Main/r1", .right); +} + +// A person's split is a point extent. Marked adaptive it keeps the size it is drawn at as its +// share; unmarked while turned it keeps the turn, and is the person's again: a drag is saved. +test "layout.place.adaptive turns a person's split to follow its room and back, nothing moving" { + var case = try PlaceCase.init(1180, 820); + defer case.deinit(); + const editor = case.ctx.editor; + { + var layout = fizzy.Editor.Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, dvui.currentWindow().arena()); + try std.testing.expectEqualStrings("Main/r1", layout.splitOn("Main", .right) orelse return error.TestExpectedEqual); + } + try dvui.testing.settle(PlaceCase.frame); + try std.testing.expect(!(try case.link()).adaptive); + // Sized by the person, not halved. + const leaf = try case.leafId("Main/r1"); + dvui.dataSet(null, leaf, "_size", @as(f32, 300)); + dvui.dataSet(null, leaf, "_shown", @as(f32, 300)); + try dvui.testing.settle(PlaceCase.frame); + const fixed = try ColumnCardFrame.sizeOf(editor, "Main/r1"); + + try case.run("layout.place.adaptive", "Main/r1"); + for (0..4) |_| _ = try dvui.testing.step(PlaceCase.frame); + try std.testing.expect((try case.link()).adaptive); + try std.testing.expect((try case.link()).share != null); + try std.testing.expectApproxEqAbs(fixed.w, (try ColumnCardFrame.sizeOf(editor, "Main/r1")).w, 1); + + // Upright: it turns, with the share it had. + PlaceCase.resize(&case.ctx, 820, 1180); + for (0..40) |_| _ = try dvui.testing.step(PlaceCase.frame); + const top = try ColumnCardFrame.sizeOf(editor, "Main"); + const bottom = try ColumnCardFrame.sizeOf(editor, "Main/r1"); + try std.testing.expectApproxEqAbs(top.w, bottom.w, 1); + + // Off while turned: it stays as it is drawn, and that is saved. + try case.run("layout.place.adaptive", "Main/r1"); + const l = try case.link(); + try std.testing.expect(!l.adaptive); + try std.testing.expectEqual(fizzy.Editor.Layout.SplitTree.Side.bottom, l.side); + const still = try ColumnCardFrame.sizeOf(editor, "Main/r1"); + try std.testing.expectApproxEqAbs(bottom.w, still.w, 1); + try std.testing.expectApproxEqAbs(bottom.h, still.h, 1); + + // The person's again: a drag of it is kept. + const room = top.h + bottom.h; + dvui.dataSet(null, leaf, "_size", room / 3); + dvui.dataSet(null, leaf, "_shown", room / 3); + for (0..4) |_| _ = try dvui.testing.step(PlaceCase.frame); + try std.testing.expectApproxEqAbs(room / 3, editor.app.layout.extent("Main/r1", 0), 1); +} + +test "layout.place.adaptive off while the split slides shut to turn opens it back where it was" { + var case = try PlaceCase.init(1180, 820); + defer case.deinit(); + const editor = case.ctx.editor; + try case.run("layout.place.split", "Main"); + try std.testing.expectEqualStrings("Main/r1", (try case.link()).name); + const before = try ColumnCardFrame.sizeOf(editor, "Main/r1"); + + PlaceCase.resize(&case.ctx, 820, 1180); + const leaf = try case.leafId("Main/r1"); + for (0..10) |_| { + _ = try dvui.testing.step(PlaceCase.frame); + if (dvui.dataGet(null, leaf, "_fold", bool) orelse false) break; + } else return error.TestExpectedEqual; + + try case.run("layout.place.adaptive", "Main/r1"); + const l = try case.link(); + try std.testing.expect(!l.adaptive); + try std.testing.expectEqual(fizzy.Editor.Layout.SplitTree.Side.right, l.side); + const main = try ColumnCardFrame.sizeOf(editor, "Main"); + const after = try ColumnCardFrame.sizeOf(editor, "Main/r1"); + try std.testing.expectApproxEqAbs(before.w, after.w, 2); + try std.testing.expectApproxEqAbs(main.h, after.h, 1); +} + +test "layout.place.adaptive is refused where the shape declared the split, and offered on a split made since" { + var case = try PlaceCase.init(1180, 820); + defer case.deinit(); + const editor = case.ctx.editor; + var results: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer results.deinit(); + try std.testing.expect(editor.app.host.callCommand("layout.place.adaptive", ".{ .place = \"Main\" }", results.allocator()) == .failed); + + // The menu: Split offered, the toggle not until there is a split of the person's to turn. + editor.app.layout.picker.open(editor.app.gpa, "Main", null); + try dvui.testing.settle(PlaceCase.frame); + try std.testing.expect(dvui.tagGet("region.menu.split:Main") != null); + try std.testing.expect(dvui.tagGet("region.menu.adaptive:Main") == null); + editor.app.layout.picker.close(editor.app.gpa); + + try case.run("layout.place.splitRight", "Main"); + editor.app.layout.picker.open(editor.app.gpa, "Main", null); + try dvui.testing.settle(PlaceCase.frame); + try dvui.testing.moveTo("region.menu.adaptive:Main"); + try dvui.testing.click(.left); + try dvui.testing.settle(PlaceCase.frame); + try std.testing.expect((try case.link()).adaptive); + // A toggle: the menu stays open on the place it changed. + try std.testing.expect(editor.app.layout.picker.is_open); +} + +test "the palette lists the place commands that follow the room" { + var case = try PlaceCase.init(1180, 820); + defer case.deinit(); + const editor = case.ctx.editor; + editor.command_palette.show(.commands); + try dvui.testing.settle(PlaceCase.palette); + try dvui.testing.writeText("Place: Split"); + try dvui.testing.settle(PlaceCase.palette); + try std.testing.expect(dvui.tagGet("fizzy.palette.row:layout.place.split") != null); + try std.testing.expect(dvui.tagGet("fizzy.palette.row:layout.place.adaptive") != null); +} + +fn mobilePlaceFrame() !dvui.App.Result { + const result = try MobileFrame.frame(); + const e = MobileFrame.editor.?; + var layout = fizzy.Editor.Layout.init(&e.app.host, &e.app.layout, e.app.gpa, dvui.currentWindow().arena()); + e.app.layout.picker.draw(&layout); + return result; +} + +test "the phone's split button makes two groups, stacked in portrait" { + var ctx = try shim.init(std.testing.allocator); + defer ctx.deinit(std.testing.allocator); + const editor = ctx.editor; + try mobileCase(&ctx); + defer fizzy.core.platform.cached_form_factor = .desktop; + defer MobileFrame.editor = null; + 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.deinitQualified(editor.app.gpa); + defer editor.app.layout.deinitAssignments(editor.app.gpa); + try fizzy.Editor.Layout.place_commands.register(&editor.app.host, &editor.app.layout); + PlaceCase.resize(&ctx, 390, 844); + try dvui.testing.settle(mobilePlaceFrame); + + try tapAt((dvui.tagGet("fizzy.mobile.split") orelse return error.TestExpectedEqual).rect, mobilePlaceFrame); + const links = editor.app.layout.splits.collectLinks(std.testing.allocator); + defer std.testing.allocator.free(links); + try std.testing.expectEqual(@as(usize, 1), links.len); + try std.testing.expectEqualStrings("Main/b1", links[0].name); + try std.testing.expect(links[0].adaptive); +} + test "an adaptive split saved without a share loads with the ratio absent" { var ctx = try shim.init(std.testing.allocator); defer ctx.deinit(std.testing.allocator);