From 47d5a70ba53153e1ef3283d45bee4a954b1d26f1 Mon Sep 17 00:00:00 2001 From: "fizzy-macos[bot]" <347071198+fizzy-macos[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:39:29 -0500 Subject: [PATCH 1/2] layout: placement follow-ups from #817 A drag while an adaptive split is turned no longer outlives the turn, besideGrouping sends a view only to a pane that is shown, and Workspaces.sync builds one Layout per pass. Agent-Team: macos Worker: claude Co-Authored-By: Claude Sonnet 5.5 --- app/layout/Region.zig | 5 +- plugins/workbench/src/Workbench.zig | 31 ++++++++---- src/editor/Workspaces.zig | 17 ++++--- tests/integration.zig | 78 +++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 18 deletions(-) diff --git a/app/layout/Region.zig b/app/layout/Region.zig index 3f17d5c57..c837aacdc 100644 --- a/app/layout/Region.zig +++ b/app/layout/Region.zig @@ -1437,7 +1437,8 @@ fn initTree( /// A turn is the new half sliding shut along the old axis (`_fold`, which its place reads as a /// target of zero), then easing open along the new one. Its share of the box is kept through the /// turn (`_share`, taken from the extent when that was last chosen, not from a box that has since -/// changed shape), and follows a drag while turned. +/// changed shape), and follows a drag while turned. That drag is shown and nothing more: turning +/// back takes the share from the saved extent again. fn orientBranch(self: *Layout, saved: SplitTree.Branch, id: dvui.Id) SplitTree.Branch { if (!saved.adaptive) return saved; const dims = dvui.dataGet(null, id, "_dims", dvui.Size) orelse return saved; @@ -1489,6 +1490,8 @@ fn orientBranch(self: *Layout, saved: SplitTree.Branch, id: dvui.Id) SplitTree.B turned = !turned; setFold(id, leaf, false); dvui.dataSet(null, id, "_turned", turned); + // Back to the saved side, the share is the saved extent's again, not a drag's while turned. + if (!turned) dvui.dataRemove(null, id, "_seen"); drawn = saved; if (turned) SplitTree.turn(&drawn); const room = alongSize(drawn.dir, dims); diff --git a/plugins/workbench/src/Workbench.zig b/plugins/workbench/src/Workbench.zig index ea3ce2a27..6a7bbd14b 100644 --- a/plugins/workbench/src/Workbench.zig +++ b/plugins/workbench/src/Workbench.zig @@ -231,21 +231,32 @@ pub fn sideBeside(self: *const Workbench, anchor: u64) Panes.Side { } /// What opening beside the person's pane asks of `core.placement.place`: the other pane that is -/// there, if any, and the answer over the person's room and that pane's. Null before the person's +/// shown, if any, and the answer over the person's room and that pane's. Null before the person's /// pane has drawn, with no room to judge. const Beside = struct { other: ?u64, answer: ?core.placement.Result }; +/// A pane other than the person's that is not sliding shut. +fn otherPane(self: *Workbench) ?u64 { + for (self.workspaces.values()) |ws| { + if (ws.grouping != self.open_workspace_grouping and !self.paneClosing(ws.grouping)) return ws.grouping; + } + return null; +} + fn besideAnswer(self: *Workbench) Beside { - const here = self.open_workspace_grouping; - const other = for (self.workspaces.values()) |ws| { - if (ws.grouping != here and !self.paneClosing(ws.grouping)) break ws.grouping; - } else null; - const room = self.paneSize(here) orelse return .{ .other = other, .answer = null }; + const room = self.paneSize(self.open_workspace_grouping) orelse return .{ .other = self.otherPane(), .answer = null }; + // `place` takes the first of these whatever its size, so a pane is a candidate only if the + // person can be sent to it: drawn, and in a place of the app's. var others: [1]core.placement.Size = undefined; - const places: []const core.placement.Size = if (other) |g| blk: { - others[0] = self.paneSize(g) orelse .{ .w = 0, .h = 0 }; - break :blk &others; - } else &.{}; + var other: ?u64 = null; + for (self.workspaces.values()) |ws| { + if (ws.grouping == self.open_workspace_grouping or self.paneClosing(ws.grouping)) continue; + if (self.places) |p| if (!p.placed(p.ctx, ws.grouping)) continue; + others[0] = self.paneSize(ws.grouping) orelse continue; + other = ws.grouping; + break; + } + const places: []const core.placement.Size = if (other != null) &others else &.{}; return .{ .other = other, .answer = core.placement.place(room, places) }; } diff --git a/src/editor/Workspaces.zig b/src/editor/Workspaces.zig index 1c1bfda8a..78ef43ca2 100644 --- a/src/editor/Workspaces.zig +++ b/src/editor/Workspaces.zig @@ -119,6 +119,7 @@ pub fn sync(self: *Workspaces) void { self.settle(); } var first: ?u64 = null; + var l = layoutOf(editor); for (wb.workspaces.keys()) |g| { if (self.assigned(g)) continue; if (first == null) { @@ -126,11 +127,10 @@ pub fn sync(self: *Workspaces) void { continue; } // The anchor's room asks the side, when it has one yet. - var l = layoutOf(editor); var abuf: [64]u8 = undefined; const room = if (Layout.ViewDrag.holderOf(&l, viewId(&abuf, first.?))) |holder| Layout.ViewDrag.placeSize(l.state, holder) else null; const side = if (room) |r| Workbench.sideFor(r.w, r.h) else .right; - _ = beside(self, g, first.?, side, true); + _ = besideIn(&l, g, first.?, side, true); } } @@ -291,11 +291,14 @@ fn layoutOf(editor: *Editor) Layout { fn beside(ctx: *anyopaque, grouping: u64, anchor: u64, side: Workbench.Panes.Side, adaptive: bool) bool { const self: *Workspaces = @ptrCast(@alignCast(ctx)); - const editor = self.editorOf(); - var l = layoutOf(editor); + var l = layoutOf(self.editorOf()); + return besideIn(&l, grouping, anchor, side, adaptive); +} + +fn besideIn(l: *Layout, grouping: u64, anchor: u64, side: Workbench.Panes.Side, adaptive: bool) bool { var abuf: [64]u8 = undefined; - const place = Layout.ViewDrag.holderOf(&l, viewId(&abuf, anchor)) orelse return false; - const new = Layout.splitOn(&l, place, switch (side) { + const place = Layout.ViewDrag.holderOf(l, viewId(&abuf, anchor)) orelse return false; + const new = Layout.splitOn(l, place, switch (side) { .left => .left, .right => .right, .top => .top, @@ -304,7 +307,7 @@ fn beside(ctx: *anyopaque, grouping: u64, anchor: u64, side: Workbench.Panes.Sid var buf: [64]u8 = undefined; const id = l.arena.dupe(u8, viewId(&buf, grouping)) catch return false; l.state.assign(l.gpa, new, &.{id}) catch return false; - if (adaptive) Layout.markAdaptive(&l, new); + if (adaptive) Layout.markAdaptive(l, new); l.state.markDirty(); return true; } diff --git a/tests/integration.zig b/tests/integration.zig index 9ae41d941..3c58aed3c 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -3836,6 +3836,74 @@ test "an adaptive split turns with the window, keeps its share and writes nothin try std.testing.expect(!editor.app.layout.dirty); } +// A sash dragged while an adaptive split is turned is shown and nothing more: the share the saved +// extent gave comes back with the turn back, and the next turn uses it, not the drag's. +test "a drag while an adaptive split is turned is not kept when it turns back" { + 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); + + ColumnCardFrame.editor = editor; + defer ColumnCardFrame.editor = null; + + ctx.t.backend.size = .{ .w = 1180, .h = 820 }; + ctx.t.backend.size_pixels = .{ .w = 1180, .h = 820 }; + _ = try dvui.testing.step(ColumnCardFrame.frame); + try dvui.testing.settle(ColumnCardFrame.frame); + { + 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); + layout.markAdaptive("Main/r1"); + } + try dvui.testing.settle(ColumnCardFrame.frame); + const old = try ColumnCardFrame.sizeOf(editor, "Main"); + const new = try ColumnCardFrame.sizeOf(editor, "Main/r1"); + const share = new.w / (old.w + new.w); + const saved_extent = editor.app.layout.extent("Main/r1", 0); + editor.app.layout.dirty = false; + + ctx.t.backend.size = .{ .w = 820, .h = 1180 }; + ctx.t.backend.size_pixels = .{ .w = 820, .h = 1180 }; + for (0..40) |_| _ = try dvui.testing.step(ColumnCardFrame.frame); + + // The sash dragged to a third of the room, as `Split` writes a drag. + const room = (try ColumnCardFrame.sizeOf(editor, "Main")).h + (try ColumnCardFrame.sizeOf(editor, "Main/r1")).h; + const leaf = for (editor.app.layout.regions.items) |r| { + if (std.mem.eql(u8, r.name, "Main/r1")) break r.id; + } else return error.TestExpectedEqual; + dvui.dataSet(null, leaf, "_size", room / 3); + dvui.dataSet(null, leaf, "_shown", room / 3); + for (0..4) |_| _ = try dvui.testing.step(ColumnCardFrame.frame); + const dragged = try ColumnCardFrame.sizeOf(editor, "Main/r1"); + try std.testing.expectApproxEqAbs(room / 3, dragged.h, room * 0.05); + try std.testing.expect(!editor.app.layout.dirty); + try std.testing.expectEqual(saved_extent, editor.app.layout.extent("Main/r1", 0)); + + // Back across: the saved share, not the drag's. + ctx.t.backend.size = .{ .w = 1180, .h = 820 }; + ctx.t.backend.size_pixels = .{ .w = 1180, .h = 820 }; + for (0..40) |_| _ = try dvui.testing.step(ColumnCardFrame.frame); + const left = try ColumnCardFrame.sizeOf(editor, "Main"); + const right = try ColumnCardFrame.sizeOf(editor, "Main/r1"); + try std.testing.expectApproxEqAbs(share, right.w / (left.w + right.w), 0.03); + + // And upright again: the share is still the saved one. + ctx.t.backend.size = .{ .w = 820, .h = 1180 }; + ctx.t.backend.size_pixels = .{ .w = 820, .h = 1180 }; + for (0..40) |_| _ = try dvui.testing.step(ColumnCardFrame.frame); + const top = try ColumnCardFrame.sizeOf(editor, "Main"); + const bottom = try ColumnCardFrame.sizeOf(editor, "Main/r1"); + try std.testing.expectApproxEqAbs(share, bottom.h / (top.h + bottom.h), 0.03); + try std.testing.expect(!editor.app.layout.dirty); +} + /// Main | (Main/r1 over Main/r1/b1) in fizzy's split forest, each holding one view. Main/r1 is two /// things at once: the half Main's split minted, and the half its own split kept. const NestedSplitCase = struct { @@ -10898,6 +10966,16 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes // With another pane there, it takes the view; without, the person's own does. const taken = wb.besideGrouping(); if (others > 0) try std.testing.expect(taken != here) else try std.testing.expectEqual(here, taken); + // A pane that has not drawn is no place to send the view: it joins the person's pane. + if (others > 0) { + const other = for (wb.workspaces.values()) |*o| { + if (o.grouping != here) break o; + } else return error.TestUnexpectedResult; + const other_rect = other.pane_rect_physical; + defer other.pane_rect_physical = other_rect; + other.pane_rect_physical = null; + try std.testing.expectEqual(here, wb.besideGrouping()); + } try std.testing.expectEqual(fizzy.core.widgets.DockLayout.Side.right, workbench.Workbench.sideFor(1200, 800)); try std.testing.expectEqual(fizzy.core.widgets.DockLayout.Side.bottom, workbench.Workbench.sideFor(820, 1180)); } From 4a8eb09d91e4197636eb5d33c985ff175b0e4393 Mon Sep 17 00:00:00 2001 From: "fizzy-macos[bot]" <347071198+fizzy-macos[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:06:53 -0500 Subject: [PATCH 2/2] layout: drop the redundant _seen reset on turning back Without the line the first frame back already reads _seen as null (dvui drops data a frame leaves unread), so the turned-drag test passes with or without it. Agent-Team: macos Worker: claude Co-Authored-By: Claude Sonnet 5.5 --- app/layout/Region.zig | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/layout/Region.zig b/app/layout/Region.zig index c837aacdc..53278a291 100644 --- a/app/layout/Region.zig +++ b/app/layout/Region.zig @@ -1438,7 +1438,8 @@ fn initTree( /// target of zero), then easing open along the new one. Its share of the box is kept through the /// turn (`_share`, taken from the extent when that was last chosen, not from a box that has since /// changed shape), and follows a drag while turned. That drag is shown and nothing more: turning -/// back takes the share from the saved extent again. +/// back takes the share from the saved extent again (`_seen` is unread while turned, so dvui drops +/// it and the first frame back sees the extent anew). fn orientBranch(self: *Layout, saved: SplitTree.Branch, id: dvui.Id) SplitTree.Branch { if (!saved.adaptive) return saved; const dims = dvui.dataGet(null, id, "_dims", dvui.Size) orelse return saved; @@ -1490,8 +1491,6 @@ fn orientBranch(self: *Layout, saved: SplitTree.Branch, id: dvui.Id) SplitTree.B turned = !turned; setFold(id, leaf, false); dvui.dataSet(null, id, "_turned", turned); - // Back to the saved side, the share is the saved extent's again, not a drag's while turned. - if (!turned) dvui.dataRemove(null, id, "_seen"); drawn = saved; if (turned) SplitTree.turn(&drawn); const room = alongSize(drawn.dir, dims);