313 lines
9.3 KiB
Zig
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,
|
|
};
|
|
}
|