Files
playpen/src/Layout.zig
T

313 lines
9.3 KiB
Zig

//! 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,
};
}