Split tree layouts, resizable dividers, and live drag preview

Replaces the flat pane list with a binary split tree, so a view can hold
layouts a single shared orientation could not express. Three panes in a row
with the rightmost dragged to the bottom now gives two on top and one
spanning the full width beneath them.

Each split node owns a GtkPaned, which brings draggable dividers. Positions
are stored as ratios and re-applied whenever the available space changes, so
proportions survive window resizes while user drags still update them.

Drop placement depends on proximity to the view's own border: near an edge
the pane is placed against the whole layout and spans it, anywhere else it
splits only the pane under the pointer.

Drags now rearrange live rather than on release, so the layout under the
cursor is always the result. Cancelling restores the original arrangement,
which works because only the dragged pane moves: the rest of the tree keeps
its shape, so re-inserting beside the original sibling is enough.

Fixes a latent ownership bug the tree exposed: panes did not hold a
reference to their own widget, so detaching one during a rebuild dropped the
last reference and finalized it, producing GTK_IS_WIDGET assertion failures.
Panes and split nodes now each own a reference to their widget.
This commit is contained in:
Greyson Parrelli
2026-08-11 11:25:44 -04:00
parent 5b00c98e03
commit 918ccec6e1
6 changed files with 656 additions and 212 deletions
+296
View File
@@ -0,0 +1,296 @@
//! 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);
}
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,
};
}