//! The split tree behind a view. //! //! A view's panes form a binary tree: every interior node is a split with an //! orientation and two children, and every leaf is a pane. That is what lets //! a view hold layouts a flat list can't express — three panes in a row where //! one is then dragged to the bottom becomes a vertical split whose top half //! is the remaining row, so the moved pane spans the full width beneath them. //! //! Each split node owns a `GtkPaned`, which gives draggable dividers for //! free. Split nodes hold a strong reference to their paned and panes hold one //! to their own widget, so detaching during a rearrangement never destroys //! anything — widgets are only finalized when their node is. const std = @import("std"); const gobject = @import("gobject"); const gtk = @import("gtk"); const Pane = @import("Pane.zig"); const Layout = @This(); /// Which side of a target a pane is placed on. The side also determines the /// orientation of the split created to hold them. pub const Side = enum { left, right, top, bottom, pub fn orientation(self: Side) gtk.Orientation { return switch (self) { .left, .right => .horizontal, .top, .bottom => .vertical, }; } /// True if the placed pane comes first in the new split. pub fn isBefore(self: Side) bool { return self == .left or self == .top; } }; pub const Node = struct { parent: ?*Node = null, kind: union(enum) { leaf: *Pane, split: Split, }, pub fn widget(self: *Node) *gtk.Widget { return switch (self.kind) { .leaf => |pane| pane.widget(), .split => |s| s.paned.as(gtk.Widget), }; } }; pub const Split = struct { paned: *gtk.Paned, a: *Node, b: *Node, /// Divider position as a fraction of the available space. Kept as a ratio /// rather than pixels so it survives window resizes and rebuilds. ratio: f64 = 0.5, /// Set while we are pushing the ratio into the paned, so the resulting /// position change isn't mistaken for the user dragging the divider. applying: bool = false, }; alloc: std.mem.Allocator, root: ?*Node = null, pub fn deinit(self: *Layout) void { if (self.root) |root| self.destroyTree(root); self.root = null; } /// Free a subtree's nodes. Panes are owned by the view, not by the tree, so /// leaves are dropped without touching their pane. fn destroyTree(self: *Layout, node: *Node) void { switch (node.kind) { .leaf => {}, .split => |s| { self.destroyTree(s.a); self.destroyTree(s.b); s.paned.setStartChild(null); s.paned.setEndChild(null); s.paned.as(gobject.Object).unref(); }, } self.alloc.destroy(node); } pub fn newLeaf(self: *Layout, pane: *Pane) !*Node { const node = try self.alloc.create(Node); node.* = .{ .kind = .{ .leaf = pane } }; return node; } /// Find the leaf holding `pane`. pub fn find(self: *Layout, pane: *Pane) ?*Node { return findIn(self.root orelse return null, pane); } fn findIn(node: *Node, pane: *Pane) ?*Node { return switch (node.kind) { .leaf => |p| if (p == pane) node else null, .split => |s| findIn(s.a, pane) orelse findIn(s.b, pane), }; } /// Place `node` beside `target`, on the given side, by replacing `target` /// with a new split holding both. pub fn insert(self: *Layout, node: *Node, target: *Node, side: Side, ratio: f64) !void { // Captured before the new split claims target as a child. const grandparent = target.parent; const first = if (side.isBefore()) node else target; const second = if (side.isBefore()) target else node; const split = try self.makeSplit(side.orientation(), first, second, ratio); split.parent = grandparent; if (grandparent) |g| { const s = &g.kind.split; if (s.a == target) s.a = split else s.b = split; } else { self.root = split; } } /// Detach `node` from the tree. Its parent split collapses, so the sibling /// takes the parent's place and the layout never keeps a split with one child. pub fn remove(self: *Layout, node: *Node) void { const parent = node.parent orelse { // Removing the only pane empties the tree. self.root = null; return; }; const s = &parent.kind.split; const sibling = if (s.a == node) s.b else s.a; sibling.parent = parent.parent; if (parent.parent) |g| { const gs = &g.kind.split; if (gs.a == parent) gs.a = sibling else gs.b = sibling; } else { self.root = sibling; } node.parent = null; s.paned.setStartChild(null); s.paned.setEndChild(null); s.paned.as(gobject.Object).unref(); self.alloc.destroy(parent); } /// Build a split holding two existing subtrees. /// /// `insert` grows a tree one pane at a time, which is the right shape for /// interactive splitting but can't express an arbitrary arrangement with /// per-split ratios. Opening a saved layout needs exactly that, so it builds /// the tree bottom-up through this instead. pub fn newSplit( self: *Layout, orientation: gtk.Orientation, a: *Node, b: *Node, ratio: f64, ) !*Node { return self.makeSplit(orientation, a, b, ratio); } fn makeSplit( self: *Layout, orientation: gtk.Orientation, a: *Node, b: *Node, ratio: f64, ) !*Node { const node = try self.alloc.create(Node); errdefer self.alloc.destroy(node); const paned = gtk.Paned.new(orientation); // Take ownership of the floating reference so the paned survives being // unparented while the tree is reassembled. _ = paned.as(gobject.Object).refSink(); // Both sides share newly available space, and neither may be squeezed to // nothing, so a terminal can't be collapsed out of existence by a resize. paned.setResizeStartChild(1); paned.setResizeEndChild(1); paned.setShrinkStartChild(0); paned.setShrinkEndChild(0); paned.setWideHandle(1); node.* = .{ .kind = .{ .split = .{ .paned = paned, .a = a, .b = b, .ratio = ratio, } } }; a.parent = node; b.parent = node; _ = gobject.Object.signals.notify.connect( paned, *Node, &onMaxPosition, node, .{ .detail = "max-position" }, ); _ = gobject.Object.signals.notify.connect( paned, *Node, &onPosition, node, .{ .detail = "position" }, ); return node; } /// The space available to the paned changed, so re-derive the divider /// position from the ratio. Doing this on every change (rather than only the /// first) is what keeps a split's proportions across window resizes. fn onMaxPosition(paned: *gtk.Paned, _: *gobject.ParamSpec, node: *Node) callconv(.c) void { const s = &node.kind.split; const max = maxPosition(paned); if (max <= 0) return; s.applying = true; defer s.applying = false; paned.setPosition(@intFromFloat(@round(s.ratio * @as(f64, @floatFromInt(max))))); } /// Remember where the user dragged the divider to, as a fraction. The ratio /// is the source of truth, so only user-driven changes update it. fn onPosition(paned: *gtk.Paned, _: *gobject.ParamSpec, node: *Node) callconv(.c) void { const s = &node.kind.split; if (s.applying) return; const max = maxPosition(paned); if (max <= 0) return; s.ratio = @as(f64, @floatFromInt(paned.getPosition())) / @as(f64, @floatFromInt(max)); } fn maxPosition(paned: *gtk.Paned) c_int { var value = gobject.ext.Value.new(c_int); defer value.unset(); paned.as(gobject.Object).getProperty("max-position", &value); return gobject.ext.Value.get(&value, c_int); } /// Rebuild the widget hierarchy from the tree and mount it in `box`. /// /// Everything is detached first so that reattaching never hits a widget that /// still has a parent. This is cheap: a view holds a handful of panes, and /// the terminals themselves are untouched by the reparenting. pub fn materialize(self: *Layout, box: *gtk.Box) void { if (box.as(gtk.Widget).getFirstChild()) |child| box.remove(child); const root = self.root orelse return; detach(root); assemble(root); box.append(root.widget()); } fn detach(node: *Node) void { switch (node.kind) { .leaf => {}, .split => |s| { detach(s.a); detach(s.b); s.paned.setStartChild(null); s.paned.setEndChild(null); }, } } fn assemble(node: *Node) void { switch (node.kind) { .leaf => {}, .split => |s| { assemble(s.a); assemble(s.b); s.paned.setStartChild(s.a.widget()); s.paned.setEndChild(s.b.widget()); }, } } /// The side `node` sits on within its parent split, and the parent's ratio. /// Used to put a pane back where it started when a drag is cancelled. pub fn positionOf(node: *Node) ?struct { sibling: *Node, side: Side, ratio: f64 } { const parent = node.parent orelse return null; const s = parent.kind.split; const first = s.a == node; const vertical = s.paned.as(gtk.Orientable).getOrientation() == .vertical; return .{ .sibling = if (first) s.b else s.a, .side = if (vertical) (if (first) .top else .bottom) else (if (first) .left else .right), .ratio = s.ratio, }; }