aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/datastruct/split_tree.zig
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-04 19:22:04 +0300
committerCan Celik <ogulcancelik@gmail.com>2026-04-05 01:23:06 +0300
commit41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch)
tree3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/datastruct/split_tree.zig
parent55c138263e190a1e79d4b997bfb9397306c0ec1f (diff)
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/datastruct/split_tree.zig')
-rw-r--r--vendor/libghostty-vt/src/datastruct/split_tree.zig2367
1 files changed, 2367 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/datastruct/split_tree.zig b/vendor/libghostty-vt/src/datastruct/split_tree.zig
new file mode 100644
index 00000000..c7da8ad9
--- /dev/null
+++ b/vendor/libghostty-vt/src/datastruct/split_tree.zig
@@ -0,0 +1,2367 @@
+const std = @import("std");
+const assert = @import("../quirks.zig").inlineAssert;
+const build_config = @import("../build_config.zig");
+const ArenaAllocator = std.heap.ArenaAllocator;
+const Allocator = std.mem.Allocator;
+
+/// SplitTree represents a tree of view types that can be divided.
+///
+/// Concretely for Ghostty, it represents a tree of terminal views. In
+/// its basic state, there are no splits and it is a single full-sized
+/// terminal. However, it can be split arbitrarily many times among two
+/// axes (horizontal and vertical) to create a tree of terminal views.
+///
+/// This is an immutable tree structure, meaning all operations on it
+/// will return a new tree with the operation applied. This allows us to
+/// store versions of the tree in a history for easy undo/redo. To facilitate
+/// this, the stored View type must implement reference counting; this is left
+/// as an implementation detail of the View type.
+///
+/// The View type will be stored as a pointer within the tree and must
+/// implement a number of functions to work properly:
+///
+/// - `fn ref(*View, Allocator) Allocator.Error!*View` - Increase a
+/// reference count of the view. The Allocator will be the allocator provided
+/// to the tree operation. This is allowed to copy the value if it wants to;
+/// the returned value is expected to be a new reference (but that may
+/// just be a copy).
+///
+/// - `fn unref(*View, Allocator) void` - Decrease the reference count of a
+/// view. The Allocator will be the allocator provided to the tree
+/// operation.
+///
+/// - `fn eql(*const View, *const View) bool` - Check if two views are equal.
+///
+/// Optionally the following functions can also be implemented:
+///
+/// - `fn splitTreeLabel(*const View) []const u8` - Return a label that is used
+/// for the debug view. If this isn't specified then the node handle
+/// will be used.
+///
+/// Note: for both the ref and unref functions, the allocator is optional.
+/// If the functions take less arguments, then the allocator will not be
+/// passed.
+pub fn SplitTree(comptime V: type) type {
+ return struct {
+ const Self = @This();
+
+ /// The view that this tree contains.
+ pub const View = V;
+
+ /// The arena allocator used for all allocations in the tree.
+ /// Since the tree is an immutable structure, this lets us
+ /// cleanly free all memory when the tree is deinitialized.
+ arena: ArenaAllocator,
+
+ /// All the nodes in the tree. Node at index 0 is always the root.
+ nodes: []const Node,
+
+ /// The handle of the zoomed node. A "zoomed" node is one that is
+ /// expected to be made the full size of the split tree. Various
+ /// operations may unzoom (e.g. resize).
+ zoomed: ?Node.Handle,
+
+ /// An empty tree.
+ pub const empty: Self = .{
+ // Arena can be undefined because we have zero allocated nodes.
+ // If our nodes are empty our deinit function doesn't touch the
+ // arena.
+ .arena = undefined,
+ .nodes = &.{},
+ .zoomed = null,
+ };
+
+ pub const Node = union(enum) {
+ leaf: *View,
+ split: Split,
+
+ /// A handle into the nodes array. This lets us keep track of
+ /// nodes with 16-bit handles rather than full pointer-width
+ /// values.
+ pub const Handle = enum(Backing) {
+ root = 0,
+ _,
+
+ pub const Backing = u16;
+
+ pub inline fn idx(self: Handle) usize {
+ return @intFromEnum(self);
+ }
+
+ /// Offset the handle by a given amount.
+ pub fn offset(self: Handle, v: usize) Handle {
+ const self_usize: usize = @intCast(@intFromEnum(self));
+ const final = self_usize + v;
+ assert(final < std.math.maxInt(Backing));
+ return @enumFromInt(final);
+ }
+ };
+ };
+
+ pub const Split = struct {
+ layout: Layout,
+ ratio: f16,
+ left: Node.Handle,
+ right: Node.Handle,
+
+ pub const Layout = enum { horizontal, vertical };
+ pub const Direction = enum { left, right, down, up };
+ };
+
+ /// Initialize a new tree with a single view.
+ pub fn init(gpa: Allocator, view: *View) Allocator.Error!Self {
+ var arena = ArenaAllocator.init(gpa);
+ errdefer arena.deinit();
+ const alloc = arena.allocator();
+
+ const nodes = try alloc.alloc(Node, 1);
+ nodes[0] = .{ .leaf = try viewRef(view, gpa) };
+ errdefer viewUnref(view, gpa);
+
+ return .{
+ .arena = arena,
+ .nodes = nodes,
+ .zoomed = null,
+ };
+ }
+
+ pub fn deinit(self: *Self) void {
+ // Important: only free memory if we have memory to free,
+ // because we use an undefined arena for empty trees.
+ if (self.nodes.len > 0) {
+ // Unref all our views
+ const gpa: Allocator = self.arena.child_allocator;
+ for (self.nodes) |node| switch (node) {
+ .leaf => |view| viewUnref(view, gpa),
+ .split => {},
+ };
+ self.arena.deinit();
+ }
+
+ self.* = undefined;
+ }
+
+ /// Clone this tree, returning a new tree with the same nodes.
+ pub fn clone(self: *const Self, gpa: Allocator) Allocator.Error!Self {
+ // If we're empty then return an empty tree.
+ if (self.isEmpty()) return .empty;
+
+ // Create a new arena allocator for the clone.
+ var arena = ArenaAllocator.init(gpa);
+ errdefer arena.deinit();
+ const alloc = arena.allocator();
+
+ // Allocate a new nodes array and copy the existing nodes into it.
+ const nodes = try alloc.dupe(Node, self.nodes);
+
+ // Increase the reference count of all the views in the nodes.
+ try refNodes(gpa, nodes);
+
+ return .{
+ .arena = arena,
+ .nodes = nodes,
+ .zoomed = self.zoomed,
+ };
+ }
+
+ /// Returns true if this is an empty tree.
+ pub fn isEmpty(self: *const Self) bool {
+ // An empty tree has no nodes.
+ return self.nodes.len == 0;
+ }
+
+ /// Returns true if this tree has more than one split (i.e., the root
+ /// is a split node). This is useful for determining if actions like
+ /// resize_split or toggle_split_zoom are performable.
+ pub fn isSplit(self: *const Self) bool {
+ // An empty tree is not split.
+ if (self.isEmpty()) return false;
+ // The root node is at index 0. If it's a split, we have multiple splits.
+ return switch (self.nodes[0]) {
+ .split => true,
+ .leaf => false,
+ };
+ }
+
+ /// An iterator over all the views in the tree.
+ pub fn iterator(
+ self: *const Self,
+ ) Iterator {
+ return .{ .nodes = self.nodes };
+ }
+
+ pub const ViewEntry = struct {
+ handle: Node.Handle,
+ view: *View,
+ };
+
+ pub const Iterator = struct {
+ i: Node.Handle = .root,
+ nodes: []const Node,
+
+ pub fn next(self: *Iterator) ?ViewEntry {
+ // If we have no nodes, return null.
+ if (@intFromEnum(self.i) >= self.nodes.len) return null;
+
+ // Get the current node and increment the index.
+ const handle = self.i;
+ self.i = @enumFromInt(handle.idx() + 1);
+ const node = self.nodes[handle.idx()];
+
+ return switch (node) {
+ .leaf => |v| .{ .handle = handle, .view = v },
+ .split => self.next(),
+ };
+ }
+ };
+
+ /// Change the zoomed state to the given node. Assumes the handle
+ /// is valid.
+ pub fn zoom(self: *Self, handle: ?Node.Handle) void {
+ if (handle) |v| {
+ assert(@intFromEnum(v) >= 0);
+ assert(@intFromEnum(v) < self.nodes.len);
+ }
+ self.zoomed = handle;
+ }
+
+ pub const Goto = union(enum) {
+ /// Previous view, null if we're the first view.
+ previous,
+
+ /// Next view, null if we're the last view.
+ next,
+
+ /// Previous view, but wrapped around to the last view. May
+ /// return the same view if this is the first view.
+ previous_wrapped,
+
+ /// Next view, but wrapped around to the first view. May return
+ /// the same view if this is the last view.
+ next_wrapped,
+
+ /// A spatial direction. "Spatial" means that the direction is
+ /// based on the nearest surface in the given direction visually
+ /// as the surfaces are laid out on a 2D grid.
+ spatial: Spatial.Direction,
+ };
+
+ /// Goto a view from a certain point in the split tree. Returns null
+ /// if the direction results in no visitable view.
+ ///
+ /// Allocator is only used for temporary state for spatial navigation.
+ pub fn goto(
+ self: *const Self,
+ alloc: Allocator,
+ from: Node.Handle,
+ to: Goto,
+ ) Allocator.Error!?Node.Handle {
+ return switch (to) {
+ .previous => self.previous(from),
+ .next => self.next(from),
+ .previous_wrapped => self.previous(from) orelse self.deepest(.right, .root),
+ .next_wrapped => self.next(from) orelse self.deepest(.left, .root),
+ .spatial => |d| spatial: {
+ // Get our spatial representation.
+ var sp = try self.spatial(alloc);
+ defer sp.deinit(alloc);
+ break :spatial self.nearestWrapped(sp, from, d);
+ },
+ };
+ }
+
+ pub const Side = enum { left, right };
+
+ /// Returns the deepest view in the tree in the given direction.
+ /// This can be used to find the leftmost/rightmost surface within
+ /// a given split structure.
+ pub fn deepest(
+ self: *const Self,
+ side: Side,
+ from: Node.Handle,
+ ) Node.Handle {
+ var current: Node.Handle = from;
+ while (true) {
+ switch (self.nodes[current.idx()]) {
+ .leaf => return current,
+ .split => |s| current = switch (side) {
+ .left => s.left,
+ .right => s.right,
+ },
+ }
+ }
+ }
+
+ /// Returns the previous view from the given node handle (which itself
+ /// doesn't need to be a view). If there is no previous (this is the
+ /// most previous view) then this will return null.
+ ///
+ /// "Previous" is defined as the previous node in an in-order
+ /// traversal of the tree. This isn't a perfect definition and we
+ /// may want to change this to something that better matches a
+ /// spatial view of the tree later.
+ fn previous(self: *const Self, from: Node.Handle) ?Node.Handle {
+ return switch (self.previousBacktrack(from, .root)) {
+ .result => |v| v,
+ .backtrack, .deadend => null,
+ };
+ }
+
+ /// Same as `previous`, but returns the next view instead.
+ fn next(self: *const Self, from: Node.Handle) ?Node.Handle {
+ return switch (self.nextBacktrack(from, .root)) {
+ .result => |v| v,
+ .backtrack, .deadend => null,
+ };
+ }
+
+ // Design note: we use a recursive backtracking search because
+ // split trees are never that deep, so we can abuse the stack as
+ // a safe allocator (stack overflow unlikely unless the kernel is
+ // tuned in some really weird way).
+ const Backtrack = union(enum) {
+ deadend,
+ backtrack,
+ result: Node.Handle,
+ };
+
+ fn previousBacktrack(
+ self: *const Self,
+ from: Node.Handle,
+ current: Node.Handle,
+ ) Backtrack {
+ // If we reached the point that we're trying to find the previous
+ // value of, then we need to backtrack from here.
+ if (from == current) return .backtrack;
+
+ return switch (self.nodes[current.idx()]) {
+ // If we hit a leaf that isn't our target, then deadend.
+ .leaf => .deadend,
+
+ .split => |s| switch (self.previousBacktrack(from, s.left)) {
+ .result => |v| .{ .result = v },
+
+ // Backtrack from the left means we have to continue
+ // backtracking because we can't see what's before the left.
+ .backtrack => .backtrack,
+
+ // If we hit a deadend on the left then let's move right.
+ .deadend => switch (self.previousBacktrack(from, s.right)) {
+ .result => |v| .{ .result = v },
+
+ // Deadend means its not in this split at all since
+ // we already tracked the left.
+ .deadend => .deadend,
+
+ // Backtrack means that its in our left view because
+ // we can see the immediate previous and there MUST
+ // be leaves (we can't have split-only leaves).
+ .backtrack => .{ .result = self.deepest(.right, s.left) },
+ },
+ },
+ };
+ }
+
+ // See previousBacktrack for detailed comments. This is a mirror
+ // of that.
+ fn nextBacktrack(
+ self: *const Self,
+ from: Node.Handle,
+ current: Node.Handle,
+ ) Backtrack {
+ if (from == current) return .backtrack;
+ return switch (self.nodes[current.idx()]) {
+ .leaf => .deadend,
+ .split => |s| switch (self.nextBacktrack(from, s.right)) {
+ .result => |v| .{ .result = v },
+ .backtrack => .backtrack,
+ .deadend => switch (self.nextBacktrack(from, s.left)) {
+ .result => |v| .{ .result = v },
+ .deadend => .deadend,
+ .backtrack => .{ .result = self.deepest(.left, s.right) },
+ },
+ },
+ };
+ }
+
+ /// Returns the nearest leaf node (view) in the given direction.
+ /// This does not handle wrapping and will return null if there
+ /// is no node in that direction.
+ fn nearest(
+ self: *const Self,
+ sp: Spatial,
+ from: Node.Handle,
+ direction: Spatial.Direction,
+ target: Spatial.Slot,
+ ) ?Node.Handle {
+ var result: ?struct {
+ handle: Node.Handle,
+ distance: f16,
+ } = null;
+ for (sp.slots, 0..) |slot, handle| {
+ // Never match ourself
+ if (handle == from.idx()) continue;
+
+ // Only match leaves
+ switch (self.nodes[handle]) {
+ .leaf => {},
+ .split => continue,
+ }
+
+ // Ensure it is in the proper direction
+ if (!switch (direction) {
+ .left => slot.maxX() <= target.x,
+ .right => slot.x >= target.maxX(),
+ .up => slot.maxY() <= target.y,
+ .down => slot.y >= target.maxY(),
+ }) continue;
+
+ // Track our distance
+ const dx = slot.x - target.x;
+ const dy = slot.y - target.y;
+ const distance = @sqrt(dx * dx + dy * dy);
+
+ // If we have a nearest it must be closer.
+ if (result) |n| {
+ if (distance >= n.distance) continue;
+ }
+ result = .{
+ .handle = @enumFromInt(handle),
+ .distance = distance,
+ };
+ }
+
+ return if (result) |n| n.handle else null;
+ }
+
+ /// Same as nearest but supports wrapping.
+ fn nearestWrapped(
+ self: *const Self,
+ sp: Spatial,
+ from: Node.Handle,
+ direction: Spatial.Direction,
+ ) ?Node.Handle {
+ // If we can find a nearest value without wrapping, then
+ // use that.
+ var target = sp.slots[from.idx()];
+ if (self.nearest(
+ sp,
+ from,
+ direction,
+ target,
+ )) |v| return v;
+
+ // The spatial grid is normalized to 1x1, so wrapping is modeled
+ // by shifting the target slot by one full grid in the opposite
+ // direction and reusing the same nearest distance logic.
+ // We don't actually modify the grid or spatial representation,
+ // this just fakes it.
+ assert(target.x >= 0 and target.y >= 0);
+ assert(target.maxX() <= 1 and target.maxY() <= 1);
+ switch (direction) {
+ .left => target.x += 1,
+ .right => target.x -= 1,
+ .up => target.y += 1,
+ .down => target.y -= 1,
+ }
+
+ return self.nearest(
+ sp,
+ from,
+ direction,
+ target,
+ );
+ }
+
+ /// Resize the given node in place. The node MUST be a split (asserted).
+ ///
+ /// In general, this is an immutable data structure so this is
+ /// heavily discouraged. However, this is provided for convenience
+ /// and performance reasons where its very important for GUIs to
+ /// update the ratio during a live resize than to redraw the entire
+ /// widget tree.
+ pub fn resizeInPlace(
+ self: *Self,
+ at: Node.Handle,
+ ratio: f16,
+ ) void {
+ // Let's talk about this constCast. Our member are const but
+ // we actually always own their memory. We don't want consumers
+ // who directly access the nodes to be able to modify them
+ // (without nasty stuff like this), but given this is internal
+ // usage its perfectly fine to modify the node in-place.
+ const s: *Split = @constCast(&self.nodes[at.idx()].split);
+ s.ratio = ratio;
+ }
+
+ /// Insert another tree into this tree at the given node in the
+ /// specified direction. The other tree will be inserted in the
+ /// new direction. For example, if the direction is "right" then
+ /// `insert` is inserted right of the existing node.
+ ///
+ /// The allocator will be used for the newly created tree.
+ /// The previous trees will not be freed, but reference counts
+ /// for the views will be increased accordingly for the new tree.
+ pub fn split(
+ self: *const Self,
+ gpa: Allocator,
+ at: Node.Handle,
+ direction: Split.Direction,
+ ratio: f16,
+ insert: *const Self,
+ ) Allocator.Error!Self {
+ // The new arena for our new tree.
+ var arena = ArenaAllocator.init(gpa);
+ errdefer arena.deinit();
+ const alloc = arena.allocator();
+
+ // We know we're going to need the sum total of the nodes
+ // between the two trees plus one for the new split node.
+ const nodes = try alloc.alloc(Node, self.nodes.len + insert.nodes.len + 1);
+ if (nodes.len > std.math.maxInt(Node.Handle.Backing)) return error.OutOfMemory;
+
+ // We can copy our nodes exactly as they are, since they're
+ // mostly not changing (only `at` is changing).
+ @memcpy(nodes[0..self.nodes.len], self.nodes);
+
+ // We can copy the destination nodes as well directly next to
+ // the source nodes. We just have to go through and offset
+ // all the handles in the destination tree to account for
+ // the shift.
+ const nodes_inserted = nodes[self.nodes.len..][0..insert.nodes.len];
+ @memcpy(nodes_inserted, insert.nodes);
+ for (nodes_inserted) |*node| switch (node.*) {
+ .leaf => {},
+ .split => |*s| {
+ // We need to offset the handles in the split
+ s.left = s.left.offset(self.nodes.len);
+ s.right = s.right.offset(self.nodes.len);
+ },
+ };
+
+ // Determine our split layout and if we're on the left
+ const layout: Split.Layout, const left: bool = switch (direction) {
+ .left => .{ .horizontal, true },
+ .right => .{ .horizontal, false },
+ .up => .{ .vertical, true },
+ .down => .{ .vertical, false },
+ };
+
+ // Copy our previous value to the end of the nodes list and
+ // create our new split node.
+ nodes[nodes.len - 1] = nodes[at.idx()];
+ nodes[at.idx()] = .{ .split = .{
+ .layout = layout,
+ .ratio = ratio,
+ .left = @enumFromInt(if (left) self.nodes.len else nodes.len - 1),
+ .right = @enumFromInt(if (left) nodes.len - 1 else self.nodes.len),
+ } };
+
+ // We need to increase the reference count of all the nodes.
+ try refNodes(gpa, nodes);
+
+ return .{
+ .arena = arena,
+ .nodes = nodes,
+ // Splitting always resets zoom state.
+ .zoomed = null,
+ };
+ }
+
+ /// Remove a node from the tree.
+ pub fn remove(
+ self: *Self,
+ gpa: Allocator,
+ at: Node.Handle,
+ ) Allocator.Error!Self {
+ assert(at.idx() < self.nodes.len);
+
+ // If we're removing node zero then we're clearing the tree.
+ if (at == .root) return .empty;
+
+ // The new arena for our new tree.
+ var arena = ArenaAllocator.init(gpa);
+ errdefer arena.deinit();
+ const alloc = arena.allocator();
+
+ // Allocate our new nodes list with the number of nodes we'll
+ // need after the removal.
+ const nodes = try alloc.alloc(Node, self.countAfterRemoval(
+ .root,
+ at,
+ 0,
+ ));
+
+ var result: Self = .{
+ .arena = arena,
+ .nodes = nodes,
+ .zoomed = null,
+ };
+
+ // Traverse the tree and copy all our nodes into place.
+ assert(self.removeNode(
+ &result,
+ 0,
+ .root,
+ at,
+ ) != 0);
+
+ // Increase the reference count of all the nodes.
+ try refNodes(gpa, nodes);
+
+ return result;
+ }
+
+ fn removeNode(
+ old: *Self,
+ new: *Self,
+ new_offset: usize,
+ current: Node.Handle,
+ target: Node.Handle,
+ ) usize {
+ assert(current != target);
+
+ // If we have a zoomed node and this is it then we migrate it.
+ if (old.zoomed) |v| {
+ if (v == current) {
+ assert(new.zoomed == null);
+ new.zoomed = @enumFromInt(new_offset);
+ }
+ }
+
+ // Let's talk about this constCast. Our member are const but
+ // we actually always own their memory. We don't want consumers
+ // who directly access the nodes to be able to modify them
+ // (without nasty stuff like this), but given this is internal
+ // usage its perfectly fine to modify the node in-place.
+ const new_nodes: []Node = @constCast(new.nodes);
+
+ switch (old.nodes[current.idx()]) {
+ // Leaf is simple, just copy it over. We don't ref anything
+ // yet because it'd make undo (errdefer) harder. We do that
+ // all at once later.
+ .leaf => |view| {
+ new_nodes[new_offset] = .{ .leaf = view };
+ return 1;
+ },
+
+ .split => |s| {
+ // If we're removing one of the split node sides then
+ // we remove the split node itself as well and only add
+ // the other (non-removed) side.
+ if (s.left == target) return old.removeNode(
+ new,
+ new_offset,
+ s.right,
+ target,
+ );
+ if (s.right == target) return old.removeNode(
+ new,
+ new_offset,
+ s.left,
+ target,
+ );
+
+ // Neither side is being directly removed, so we traverse.
+ const left = old.removeNode(
+ new,
+ new_offset + 1,
+ s.left,
+ target,
+ );
+ assert(left != 0);
+ const right = old.removeNode(
+ new,
+ new_offset + left + 1,
+ s.right,
+ target,
+ );
+ assert(right != 0);
+ new_nodes[new_offset] = .{ .split = .{
+ .layout = s.layout,
+ .ratio = s.ratio,
+ .left = @enumFromInt(new_offset + 1),
+ .right = @enumFromInt(new_offset + 1 + left),
+ } };
+
+ return left + right + 1;
+ },
+ }
+ }
+
+ /// Returns the number of nodes that would be needed to store
+ /// the tree if the target node is removed.
+ fn countAfterRemoval(
+ self: *Self,
+ current: Node.Handle,
+ target: Node.Handle,
+ acc: usize,
+ ) usize {
+ assert(current != target);
+
+ return switch (self.nodes[current.idx()]) {
+ // Leaf is simple, always takes one node.
+ .leaf => acc + 1,
+
+ // Split is slightly more complicated. If either side is the
+ // target to remove, then we remove the split node as well
+ // so our count is just the count of the other side.
+ //
+ // If neither side is the target, then we count both sides
+ // and add one to account for the split node itself.
+ .split => |s| if (s.left == target) self.countAfterRemoval(
+ s.right,
+ target,
+ acc,
+ ) else if (s.right == target) self.countAfterRemoval(
+ s.left,
+ target,
+ acc,
+ ) else self.countAfterRemoval(
+ s.left,
+ target,
+ acc,
+ ) + self.countAfterRemoval(
+ s.right,
+ target,
+ acc,
+ ) + 1,
+ };
+ }
+
+ /// Reference all the nodes in the given slice, handling unref if
+ /// any fail. This should be called LAST so you don't have to undo
+ /// the refs at any further point after this.
+ fn refNodes(gpa: Allocator, nodes: []Node) Allocator.Error!void {
+ // We need to increase the reference count of all the nodes.
+ // Careful accounting here so that we properly unref on error
+ // only the nodes we referenced.
+ var reffed: usize = 0;
+ errdefer for (0..reffed) |i| {
+ switch (nodes[i]) {
+ .split => {},
+ .leaf => |view| viewUnref(view, gpa),
+ }
+ };
+ for (0..nodes.len) |i| {
+ switch (nodes[i]) {
+ .split => {},
+ .leaf => |view| nodes[i] = .{ .leaf = try viewRef(view, gpa) },
+ }
+ reffed = i;
+ }
+ assert(reffed == nodes.len - 1);
+ }
+
+ /// Equalize this node and all its children, returning a new node with splits
+ /// adjusted so that each split's ratio is based on the relative weight
+ /// (number of leaves) of its children.
+ pub fn equalize(
+ self: *const Self,
+ gpa: Allocator,
+ ) Allocator.Error!Self {
+ if (self.isEmpty()) return .empty;
+
+ // Create a new arena allocator for the clone.
+ var arena = ArenaAllocator.init(gpa);
+ errdefer arena.deinit();
+ const alloc = arena.allocator();
+
+ // Allocate a new nodes array and copy the existing nodes into it.
+ const nodes = try alloc.dupe(Node, self.nodes);
+
+ // Go through and equalize our ratios based on weights.
+ for (nodes) |*node| switch (node.*) {
+ .leaf => {},
+ .split => |*s| {
+ const weight_left = self.weight(s.left, s.layout, 0);
+ const weight_right = self.weight(s.right, s.layout, 0);
+ assert(weight_left > 0);
+ assert(weight_right > 0);
+ const total_f16: f16 = @floatFromInt(weight_left + weight_right);
+ const weight_left_f16: f16 = @floatFromInt(weight_left);
+ s.ratio = weight_left_f16 / total_f16;
+ },
+ };
+
+ // Increase the reference count of all the views in the nodes.
+ try refNodes(gpa, nodes);
+
+ return .{
+ .arena = arena,
+ .nodes = nodes,
+ .zoomed = self.zoomed,
+ };
+ }
+
+ fn weight(
+ self: *const Self,
+ from: Node.Handle,
+ layout: Split.Layout,
+ acc: usize,
+ ) usize {
+ return switch (self.nodes[from.idx()]) {
+ .leaf => acc + 1,
+ .split => |s| if (s.layout == layout)
+ self.weight(s.left, layout, acc) +
+ self.weight(s.right, layout, acc)
+ else
+ 1,
+ };
+ }
+
+ /// Resize the nearest split matching the layout by the given ratio.
+ /// Positive is right and down.
+ ///
+ /// The ratio is a signed delta representing the percentage to move
+ /// the divider. The percentage is of the entire grid size, not just
+ /// the specific split size.
+ /// We use the entire grid size because that's what Ghostty's
+ /// `resize_split` keybind does, because it maps to a general human
+ /// understanding of moving a split relative to the entire window
+ /// (generally).
+ ///
+ /// For example, a ratio of 0.1 and a layout of `vertical` will find
+ /// the nearest vertical split and move the divider down by 10% of
+ /// the total grid height.
+ ///
+ /// If no matching split is found, this does nothing, but will always
+ /// still return a cloned tree.
+ pub fn resize(
+ self: *const Self,
+ gpa: Allocator,
+ from: Node.Handle,
+ layout: Split.Layout,
+ ratio: f16,
+ ) Allocator.Error!Self {
+ assert(ratio >= -1 and ratio <= 1);
+ assert(!std.math.isNan(ratio));
+ assert(!std.math.isInf(ratio));
+
+ // Fast path empty trees.
+ if (self.isEmpty()) return .empty;
+
+ // From this point forward worst case we return a clone.
+ var result = try self.clone(gpa);
+ errdefer result.deinit();
+
+ // Find our nearest parent split node matching the layout.
+ const parent_handle = switch (self.findParentSplit(
+ layout,
+ from,
+ .root,
+ )) {
+ .deadend, .backtrack => return result,
+ .result => |v| v,
+ };
+
+ // Get our spatial layout, because we need the dimensions of this
+ // split with regards to the entire grid.
+ var sp = try result.spatial(gpa);
+ defer sp.deinit(gpa);
+
+ // Get the ratio of the split relative to the full grid.
+ const full_ratio = full_ratio: {
+ // Our scale is the amount we need to multiply our individual
+ // ratio by to get the full ratio. Its actually a ratio on its
+ // own but I'm trying to avoid that word: its the ratio of
+ // our spatial width/height to the total.
+ const scale = switch (layout) {
+ .horizontal => sp.slots[parent_handle.idx()].width / sp.slots[0].width,
+ .vertical => sp.slots[parent_handle.idx()].height / sp.slots[0].height,
+ };
+
+ const current = result.nodes[parent_handle.idx()].split.ratio;
+ break :full_ratio current * scale;
+ };
+
+ // Set the final new ratio, clamping it to [0, 1]
+ result.resizeInPlace(
+ parent_handle,
+ @min(@max(full_ratio + ratio, 0), 1),
+ );
+ return result;
+ }
+
+ fn findParentSplit(
+ self: *const Self,
+ layout: Split.Layout,
+ from: Node.Handle,
+ current: Node.Handle,
+ ) Backtrack {
+ if (from == current) return .backtrack;
+ return switch (self.nodes[current.idx()]) {
+ .leaf => .deadend,
+ .split => |s| switch (self.findParentSplit(
+ layout,
+ from,
+ s.left,
+ )) {
+ .result => |v| .{ .result = v },
+ .backtrack => if (s.layout == layout)
+ .{ .result = current }
+ else
+ .backtrack,
+ .deadend => switch (self.findParentSplit(
+ layout,
+ from,
+ s.right,
+ )) {
+ .deadend => .deadend,
+ .result => |v| .{ .result = v },
+ .backtrack => if (s.layout == layout)
+ .{ .result = current }
+ else
+ .backtrack,
+ },
+ },
+ };
+ }
+
+ /// Spatial representation of the split tree. See spatial.
+ pub const Spatial = struct {
+ /// The slots of the spatial representation in the same order
+ /// as the tree it was created from.
+ slots: []const Slot,
+
+ pub const empty: Spatial = .{ .slots = &.{} };
+
+ pub const Direction = enum { left, right, down, up };
+
+ const Slot = struct {
+ x: f16,
+ y: f16,
+ width: f16,
+ height: f16,
+
+ fn maxX(self: *const Slot) f16 {
+ return self.x + self.width;
+ }
+
+ fn maxY(self: *const Slot) f16 {
+ return self.y + self.height;
+ }
+ };
+
+ pub fn deinit(self: *Spatial, alloc: Allocator) void {
+ alloc.free(self.slots);
+ self.* = undefined;
+ }
+ };
+
+ /// Spatial representation of the split tree. This can be used to
+ /// better understand the layout of the tree in a 2D space.
+ ///
+ /// The bounds of the representation are always based on the total
+ /// 2D space being 1x1. The x/y coordinates and width/height dimensions
+ /// of each individual split and leaf are relative to this.
+ /// This means that the spatial representation is a normalized
+ /// representation of the actual space.
+ ///
+ /// The top-left corner of the tree is always (0, 0).
+ ///
+ /// We use a normalized form because we can calculate it without
+ /// accessing to the actual rendered view sizes. These actual sizes
+ /// may not be available at various times because GUI toolkits often
+ /// only make them available once they're part of a widget tree and
+ /// a SplitTree can represent views that aren't currently visible.
+ pub fn spatial(
+ self: *const Self,
+ alloc: Allocator,
+ ) Allocator.Error!Spatial {
+ // No nodes, empty spatial representation.
+ if (self.nodes.len == 0) return .empty;
+
+ // Get our total dimensions.
+ const dim = self.dimensions(.root);
+
+ // Create our slots which will match our nodes exactly.
+ const slots = try alloc.alloc(Spatial.Slot, self.nodes.len);
+ errdefer alloc.free(slots);
+ slots[0] = .{
+ .x = 0,
+ .y = 0,
+ .width = @floatFromInt(dim.width),
+ .height = @floatFromInt(dim.height),
+ };
+ self.fillSpatialSlots(slots, .root);
+
+ // Normalize the dimensions to 1x1 grid.
+ for (slots) |*slot| {
+ slot.x /= @floatFromInt(dim.width);
+ slot.y /= @floatFromInt(dim.height);
+ slot.width /= @floatFromInt(dim.width);
+ slot.height /= @floatFromInt(dim.height);
+ }
+
+ return .{ .slots = slots };
+ }
+
+ fn fillSpatialSlots(
+ self: *const Self,
+ slots: []Spatial.Slot,
+ current_: Node.Handle,
+ ) void {
+ const current = current_.idx();
+ assert(slots[current].width >= 0 and slots[current].height >= 0);
+ switch (self.nodes[current]) {
+ // Leaf node, current slot is already filled by caller.
+ .leaf => {},
+
+ .split => |s| {
+ switch (s.layout) {
+ .horizontal => {
+ slots[s.left.idx()] = .{
+ .x = slots[current].x,
+ .y = slots[current].y,
+ .width = slots[current].width * s.ratio,
+ .height = slots[current].height,
+ };
+ slots[s.right.idx()] = .{
+ .x = slots[current].x + slots[current].width * s.ratio,
+ .y = slots[current].y,
+ .width = slots[current].width * (1 - s.ratio),
+ .height = slots[current].height,
+ };
+ },
+
+ .vertical => {
+ slots[s.left.idx()] = .{
+ .x = slots[current].x,
+ .y = slots[current].y,
+ .width = slots[current].width,
+ .height = slots[current].height * s.ratio,
+ };
+ slots[s.right.idx()] = .{
+ .x = slots[current].x,
+ .y = slots[current].y + slots[current].height * s.ratio,
+ .width = slots[current].width,
+ .height = slots[current].height * (1 - s.ratio),
+ };
+ },
+ }
+
+ self.fillSpatialSlots(slots, s.left);
+ self.fillSpatialSlots(slots, s.right);
+ },
+ }
+ }
+
+ /// Get the dimensions of the tree starting from the given node.
+ ///
+ /// This creates relative dimensions (see Spatial) by assuming each
+ /// leaf is exactly 1x1 unit in size.
+ fn dimensions(self: *const Self, current: Node.Handle) struct {
+ width: u16,
+ height: u16,
+ } {
+ return switch (self.nodes[current.idx()]) {
+ .leaf => .{ .width = 1, .height = 1 },
+ .split => |s| split: {
+ const left = self.dimensions(s.left);
+ const right = self.dimensions(s.right);
+ break :split switch (s.layout) {
+ .horizontal => .{
+ .width = left.width + right.width,
+ .height = @max(left.height, right.height),
+ },
+
+ .vertical => .{
+ .width = @max(left.width, right.width),
+ .height = left.height + right.height,
+ },
+ };
+ },
+ };
+ }
+
+ /// Format the tree in a human-readable format. By default this will
+ /// output a diagram followed by a textual representation.
+ pub fn format(
+ self: *const Self,
+ writer: *std.Io.Writer,
+ ) !void {
+ if (self.nodes.len == 0) {
+ try writer.writeAll("empty");
+ return;
+ }
+ self.formatDiagram(writer) catch {};
+ try self.formatText(writer);
+ }
+
+ pub fn formatText(self: Self, writer: *std.Io.Writer) std.Io.Writer.Error!void {
+ if (self.nodes.len == 0) {
+ try writer.writeAll("empty");
+ return;
+ }
+ try self.formatTextInner(writer, .root, 0);
+ }
+
+ fn formatTextInner(
+ self: Self,
+ writer: *std.Io.Writer,
+ current: Node.Handle,
+ depth: usize,
+ ) std.Io.Writer.Error!void {
+ for (0..depth) |_| try writer.writeAll(" ");
+
+ if (self.zoomed) |zoomed| if (zoomed == current) {
+ try writer.writeAll("(zoomed) ");
+ };
+
+ switch (self.nodes[current.idx()]) {
+ .leaf => |v| if (@hasDecl(View, "splitTreeLabel"))
+ try writer.print("leaf: {s}\n", .{v.splitTreeLabel()})
+ else
+ try writer.print("leaf: {d}\n", .{current}),
+
+ .split => |s| {
+ try writer.print("split (layout: {t}, ratio: {d:.2})\n", .{
+ s.layout,
+ s.ratio,
+ });
+ try self.formatTextInner(writer, s.left, depth + 1);
+ try self.formatTextInner(writer, s.right, depth + 1);
+ },
+ }
+ }
+
+ pub fn formatDiagram(
+ self: Self,
+ writer: *std.Io.Writer,
+ ) std.Io.Writer.Error!void {
+ if (self.nodes.len == 0) {
+ try writer.writeAll("empty");
+ return;
+ }
+
+ // Use our arena's GPA to allocate some intermediate memory.
+ // Requiring allocation for formatting is nasty but this is really
+ // only used for debugging and testing and shouldn't hit OOM
+ // scenarios.
+ var arena: ArenaAllocator = .init(self.arena.child_allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ // Get our spatial representation.
+ const sp = spatial: {
+ const sp = self.spatial(alloc) catch return error.WriteFailed;
+
+ // Scale our spatial representation to have minimum width/height 1.
+ var min_w: f16 = 1;
+ var min_h: f16 = 1;
+ for (sp.slots) |slot| {
+ if (slot.width > 0) min_w = @min(min_w, slot.width);
+ if (slot.height > 0) min_h = @min(min_h, slot.height);
+ }
+
+ const ratio_w: f16 = 1 / min_w;
+ const ratio_h: f16 = 1 / min_h;
+ const slots = alloc.dupe(Spatial.Slot, sp.slots) catch return error.WriteFailed;
+ for (slots) |*slot| {
+ slot.x *= ratio_w;
+ slot.y *= ratio_h;
+ slot.width *= ratio_w;
+ slot.height *= ratio_h;
+ }
+
+ break :spatial .{ .slots = slots };
+ };
+
+ // The width we need for the largest label.
+ const max_label_width: usize = max_label_width: {
+ if (!@hasDecl(View, "splitTreeLabel")) {
+ break :max_label_width std.math.log10(sp.slots.len) + 1;
+ }
+
+ var max: usize = 0;
+ for (self.nodes) |node| switch (node) {
+ .split => {},
+ .leaf => |view| {
+ const label = view.splitTreeLabel();
+ max = @max(max, label.len);
+ },
+ };
+
+ break :max_label_width max;
+ };
+
+ // We need space for whitespace and ASCII art so add that.
+ // We need to accommodate the leaf handle, whitespace, and
+ // then the border.
+ const cell_width = cell_width: {
+ // Border + whitespace + label + whitespace + border.
+ break :cell_width 2 + max_label_width + 2;
+ };
+ const cell_height = cell_height: {
+ // Border + label + border. No whitespace needed on the
+ // vertical axis.
+ break :cell_height 1 + 1 + 1;
+ };
+
+ // Make a grid that can fit our entire ASCII diagram. We know
+ // the width/height based on node 0.
+ const grid = grid: {
+ // Get our initial width/height. Each leaf is 1x1 in this.
+ // We round up for this because partial widths/heights should
+ // take up an extra cell.
+ var width: usize = @intFromFloat(@ceil(sp.slots[0].width));
+ var height: usize = @intFromFloat(@ceil(sp.slots[0].height));
+
+ // We need space for whitespace and ASCII art so add that.
+ // We need to accommodate the leaf handle, whitespace, and
+ // then the border.
+ width *= cell_width;
+ height *= cell_height;
+
+ const rows = alloc.alloc([]u8, height) catch return error.WriteFailed;
+ for (0..rows.len) |y| {
+ rows[y] = alloc.alloc(u8, width + 1) catch return error.WriteFailed;
+ @memset(rows[y], ' ');
+ rows[y][width] = '\n';
+ }
+ break :grid rows;
+ };
+
+ // Draw each node
+ for (sp.slots, 0..) |slot, handle| {
+ // We only draw leaf nodes. Splits are only used for layout.
+ const node = self.nodes[handle];
+ switch (node) {
+ .leaf => {},
+ .split => continue,
+ }
+
+ // If our width/height is zero then we skip this.
+ if (slot.width == 0 or slot.height == 0) continue;
+
+ var x: usize = @intFromFloat(@floor(slot.x));
+ var y: usize = @intFromFloat(@floor(slot.y));
+ var width: usize = @intFromFloat(@max(@floor(slot.width), 1));
+ var height: usize = @intFromFloat(@max(@floor(slot.height), 1));
+ x *= cell_width;
+ y *= cell_height;
+ width *= cell_width;
+ height *= cell_height;
+
+ // Top border
+ {
+ const top = grid[y][x..][0..width];
+ top[0] = '+';
+ for (1..width - 1) |i| top[i] = '-';
+ top[width - 1] = '+';
+ }
+
+ // Bottom border
+ {
+ const bottom = grid[y + height - 1][x..][0..width];
+ bottom[0] = '+';
+ for (1..width - 1) |i| bottom[i] = '-';
+ bottom[width - 1] = '+';
+ }
+
+ // Left border
+ for (y + 1..y + height - 1) |y_cur| grid[y_cur][x] = '|';
+ for (y + 1..y + height - 1) |y_cur| grid[y_cur][x + width - 1] = '|';
+
+ // Get our label text
+ var buf: [10]u8 = undefined;
+ const label: []const u8 = if (@hasDecl(View, "splitTreeLabel"))
+ node.leaf.splitTreeLabel()
+ else
+ std.fmt.bufPrint(&buf, "{d}", .{handle}) catch return error.WriteFailed;
+
+ // Draw the handle in the center
+ const x_mid = width / 2 + x;
+ const y_mid = height / 2 + y;
+ const label_width = label.len;
+ const label_start = x_mid - label_width / 2;
+ const row = grid[y_mid][label_start..];
+ _ = std.fmt.bufPrint(row, "{s}", .{label}) catch return error.WriteFailed;
+ }
+
+ // Output every row
+ for (grid) |row| {
+ // We currently have a bug in our height calculation that
+ // results in trailing blank lines. Ignore those. We should
+ // really fix our height calculation instead. If someone wants
+ // to do that just remove this line and see the tests that fail
+ // and go from there.
+ if (row[0] == ' ') break;
+ try writer.writeAll(row);
+ }
+ }
+
+ fn viewRef(view: *View, gpa: Allocator) Allocator.Error!*View {
+ const func = @typeInfo(@TypeOf(View.ref)).@"fn";
+ return switch (func.params.len) {
+ 1 => view.ref(),
+ 2 => try view.ref(gpa),
+ else => @compileError("invalid view ref function"),
+ };
+ }
+
+ fn viewUnref(view: *View, gpa: Allocator) void {
+ const func = @typeInfo(@TypeOf(View.unref)).@"fn";
+ switch (func.params.len) {
+ 1 => view.unref(),
+ 2 => view.unref(gpa),
+ else => @compileError("invalid view unref function"),
+ }
+ }
+
+ /// Make this a valid gobject if we're in a GTK environment.
+ pub const getGObjectType = switch (build_config.app_runtime) {
+ .gtk => @import("gobject").ext.defineBoxed(
+ Self,
+ .{
+ // To get the type name we get the non-qualified type name
+ // of the view and append that to `GhosttySplitTree`.
+ .name = name: {
+ const type_name = @typeName(View);
+ const last = if (std.mem.lastIndexOfScalar(
+ u8,
+ type_name,
+ '.',
+ )) |idx|
+ type_name[idx + 1 ..]
+ else
+ type_name;
+ assert(last.len > 0);
+ break :name "GhosttySplitTree" ++ last;
+ },
+
+ .funcs = .{
+ .copy = &struct {
+ fn copy(self: *Self) callconv(.c) *Self {
+ const ptr = @import("glib").ext.create(Self);
+ ptr.* = if (self.nodes.len == 0)
+ .empty
+ else
+ self.clone(self.arena.child_allocator) catch @panic("oom");
+ return ptr;
+ }
+ }.copy,
+ .free = &struct {
+ fn free(self: *Self) callconv(.c) void {
+ self.deinit();
+ @import("glib").ext.destroy(self);
+ }
+ }.free,
+ },
+ },
+ ),
+
+ .none => void,
+ };
+ };
+}
+
+const TestTree = SplitTree(TestView);
+
+const TestView = struct {
+ const Self = @This();
+
+ label: []const u8,
+
+ pub fn ref(self: *Self, alloc: Allocator) Allocator.Error!*Self {
+ const ptr = try alloc.create(Self);
+ ptr.* = self.*;
+ return ptr;
+ }
+
+ pub fn unref(self: *Self, alloc: Allocator) void {
+ alloc.destroy(self);
+ }
+
+ pub fn splitTreeLabel(self: *const Self) []const u8 {
+ return self.label;
+ }
+};
+
+test "SplitTree: isSplit" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // Empty tree should not be split
+ var empty: TestTree = .empty;
+ defer empty.deinit();
+ try testing.expect(!empty.isSplit());
+
+ // Single node tree should not be split
+ var v1: TestView = .{ .label = "A" };
+ var single: TestTree = try TestTree.init(alloc, &v1);
+ defer single.deinit();
+ try testing.expect(!single.isSplit());
+
+ // Split tree should be split
+ var v2: TestView = .{ .label = "B" };
+ var tree2: TestTree = try TestTree.init(alloc, &v2);
+ defer tree2.deinit();
+ var split = try single.split(
+ alloc,
+ .root,
+ .right,
+ 0.5,
+ &tree2,
+ );
+ defer split.deinit();
+ try testing.expect(split.isSplit());
+}
+
+test "SplitTree: empty tree" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ var t: TestTree = .empty;
+ defer t.deinit();
+
+ const str = try std.fmt.allocPrint(alloc, "{f}", .{t});
+ defer alloc.free(str);
+ try testing.expectEqualStrings(str,
+ \\empty
+ );
+}
+
+test "SplitTree: single node" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ var v: TestTree.View = .{ .label = "A" };
+ var t: TestTree = try .init(alloc, &v);
+ defer t.deinit();
+
+ const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(t, .formatDiagram)});
+ defer alloc.free(str);
+ try testing.expectEqualStrings(str,
+ \\+---+
+ \\| A |
+ \\+---+
+ \\
+ );
+}
+
+test "SplitTree: split horizontal" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var v1: TestTree.View = .{ .label = "A" };
+ var t1: TestTree = try .init(alloc, &v1);
+ defer t1.deinit();
+ var v2: TestTree.View = .{ .label = "B" };
+ var t2: TestTree = try .init(alloc, &v2);
+ defer t2.deinit();
+ var t3 = try t1.split(
+ alloc,
+ .root, // at root
+ .right, // split right
+ 0.5,
+ &t2, // insert t2
+ );
+ defer t3.deinit();
+
+ {
+ const str = try std.fmt.allocPrint(alloc, "{f}", .{t3});
+ defer alloc.free(str);
+ try testing.expectEqualStrings(str,
+ \\+---++---+
+ \\| A || B |
+ \\+---++---+
+ \\split (layout: horizontal, ratio: 0.50)
+ \\ leaf: A
+ \\ leaf: B
+ \\
+ );
+ }
+
+ // Split right at B
+ var vC: TestTree.View = .{ .label = "C" };
+ var tC: TestTree = try .init(alloc, &vC);
+ defer tC.deinit();
+ var it = t3.iterator();
+ var t4 = try t3.split(
+ alloc,
+ while (it.next()) |entry| {
+ if (std.mem.eql(u8, entry.view.label, "B")) {
+ break entry.handle;
+ }
+ } else return error.NotFound,
+ .right,
+ 0.5,
+ &tC,
+ );
+ defer t4.deinit();
+
+ {
+ const str = try std.fmt.allocPrint(alloc, "{f}", .{t4});
+ defer alloc.free(str);
+ try testing.expectEqualStrings(str,
+ \\+--------++---++---+
+ \\| A || B || C |
+ \\+--------++---++---+
+ \\split (layout: horizontal, ratio: 0.50)
+ \\ leaf: A
+ \\ split (layout: horizontal, ratio: 0.50)
+ \\ leaf: B
+ \\ leaf: C
+ \\
+ );
+ }
+
+ // Split right at C
+ var vD: TestTree.View = .{ .label = "D" };
+ var tD: TestTree = try .init(alloc, &vD);
+ defer tD.deinit();
+ it = t4.iterator();
+ var t5 = try t4.split(
+ alloc,
+ while (it.next()) |entry| {
+ if (std.mem.eql(u8, entry.view.label, "C")) {
+ break entry.handle;
+ }
+ } else return error.NotFound,
+ .right,
+ 0.5,
+ &tD,
+ );
+ defer t5.deinit();
+
+ {
+ const str = try std.fmt.allocPrint(alloc, "{f}", .{t5});
+ defer alloc.free(str);
+ try testing.expectEqualStrings(
+ \\+------------------++--------++---++---+
+ \\| A || B || C || D |
+ \\+------------------++--------++---++---+
+ \\split (layout: horizontal, ratio: 0.50)
+ \\ leaf: A
+ \\ split (layout: horizontal, ratio: 0.50)
+ \\ leaf: B
+ \\ split (layout: horizontal, ratio: 0.50)
+ \\ leaf: C
+ \\ leaf: D
+ \\
+ , str);
+ }
+
+ // Find "previous" from D back.
+ {
+ var current: u8 = 'D';
+ while (current != 'A') : (current -= 1) {
+ it = t5.iterator();
+ const handle = t5.previous(
+ while (it.next()) |entry| {
+ if (std.mem.eql(u8, entry.view.label, &.{current})) {
+ break entry.handle;
+ }
+ } else return error.NotFound,
+ ).?;
+
+ const entry = t5.nodes[handle.idx()].leaf;
+ try testing.expectEqualStrings(
+ entry.label,
+ &.{current - 1},
+ );
+ }
+
+ it = t5.iterator();
+ try testing.expect(t5.previous(
+ while (it.next()) |entry| {
+ if (std.mem.eql(u8, entry.view.label, &.{current})) {
+ break entry.handle;
+ }
+ } else return error.NotFound,
+ ) == null);
+ }
+
+ // Find "next" from A forward.
+ {
+ var current: u8 = 'A';
+ while (current != 'D') : (current += 1) {
+ it = t5.iterator();
+ const handle = t5.next(
+ while (it.next()) |entry| {
+ if (std.mem.eql(u8, entry.view.label, &.{current})) {
+ break entry.handle;
+ }
+ } else return error.NotFound,
+ ).?;
+
+ const entry = t5.nodes[handle.idx()].leaf;
+ try testing.expectEqualStrings(
+ entry.label,
+ &.{current + 1},
+ );
+ }
+
+ it = t5.iterator();
+ try testing.expect(t5.next(
+ while (it.next()) |entry| {
+ if (std.mem.eql(u8, entry.view.label, &.{current})) {
+ break entry.handle;
+ }
+ } else return error.NotFound,
+ ) == null);
+ }
+}
+
+test "SplitTree: split vertical" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var v1: TestTree.View = .{ .label = "A" };
+ var t1: TestTree = try .init(alloc, &v1);
+ defer t1.deinit();
+ var v2: TestTree.View = .{ .label = "B" };
+ var t2: TestTree = try .init(alloc, &v2);
+ defer t2.deinit();
+
+ var t3 = try t1.split(
+ alloc,
+ .root, // at root
+ .down, // split down
+ 0.5,
+ &t2, // insert t2
+ );
+ defer t3.deinit();
+
+ const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(t3, .formatDiagram)});
+ defer alloc.free(str);
+ try testing.expectEqualStrings(str,
+ \\+---+
+ \\| A |
+ \\+---+
+ \\+---+
+ \\| B |
+ \\+---+
+ \\
+ );
+}
+
+test "SplitTree: split horizontal with zero ratio" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var v1: TestTree.View = .{ .label = "A" };
+ var t1: TestTree = try .init(alloc, &v1);
+ defer t1.deinit();
+ var v2: TestTree.View = .{ .label = "B" };
+ var t2: TestTree = try .init(alloc, &v2);
+ defer t2.deinit();
+
+ // A | B horizontal
+ var splitAB = try t1.split(
+ alloc,
+ .root, // at root
+ .right, // split right
+ 0,
+ &t2, // insert t2
+ );
+ defer splitAB.deinit();
+ const split = splitAB;
+
+ {
+ const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(split, .formatDiagram)});
+ defer alloc.free(str);
+ try testing.expectEqualStrings(str,
+ \\+---+
+ \\| B |
+ \\+---+
+ \\
+ );
+ }
+}
+
+test "SplitTree: split vertical with zero ratio" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var v1: TestTree.View = .{ .label = "A" };
+ var t1: TestTree = try .init(alloc, &v1);
+ defer t1.deinit();
+ var v2: TestTree.View = .{ .label = "B" };
+ var t2: TestTree = try .init(alloc, &v2);
+ defer t2.deinit();
+
+ // A | B horizontal
+ var splitAB = try t1.split(
+ alloc,
+ .root, // at root
+ .down, // split right
+ 0,
+ &t2, // insert t2
+ );
+ defer splitAB.deinit();
+ const split = splitAB;
+
+ {
+ const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(split, .formatDiagram)});
+ defer alloc.free(str);
+ try testing.expectEqualStrings(str,
+ \\+---+
+ \\| B |
+ \\+---+
+ \\
+ );
+ }
+}
+
+test "SplitTree: split horizontal with full width" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var v1: TestTree.View = .{ .label = "A" };
+ var t1: TestTree = try .init(alloc, &v1);
+ defer t1.deinit();
+ var v2: TestTree.View = .{ .label = "B" };
+ var t2: TestTree = try .init(alloc, &v2);
+ defer t2.deinit();
+
+ // A | B horizontal
+ var splitAB = try t1.split(
+ alloc,
+ .root, // at root
+ .right, // split right
+ 1,
+ &t2, // insert t2
+ );
+ defer splitAB.deinit();
+ const split = splitAB;
+
+ {
+ const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(split, .formatDiagram)});
+ defer alloc.free(str);
+ try testing.expectEqualStrings(str,
+ \\+---+
+ \\| A |
+ \\+---+
+ \\
+ );
+ }
+}
+
+test "SplitTree: split vertical with full width" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var v1: TestTree.View = .{ .label = "A" };
+ var t1: TestTree = try .init(alloc, &v1);
+ defer t1.deinit();
+ var v2: TestTree.View = .{ .label = "B" };
+ var t2: TestTree = try .init(alloc, &v2);
+ defer t2.deinit();
+
+ // A | B horizontal
+ var splitAB = try t1.split(
+ alloc,
+ .root, // at root
+ .down, // split right
+ 1,
+ &t2, // insert t2
+ );
+ defer splitAB.deinit();
+ const split = splitAB;
+
+ {
+ const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(split, .formatDiagram)});
+ defer alloc.free(str);
+ try testing.expectEqualStrings(str,
+ \\+---+
+ \\| A |
+ \\+---+
+ \\
+ );
+ }
+}
+
+test "SplitTree: remove leaf" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var v1: TestTree.View = .{ .label = "A" };
+ var t1: TestTree = try .init(alloc, &v1);
+ defer t1.deinit();
+ var v2: TestTree.View = .{ .label = "B" };
+ var t2: TestTree = try .init(alloc, &v2);
+ defer t2.deinit();
+ var t3 = try t1.split(
+ alloc,
+ .root, // at root
+ .right, // split right
+ 0.5,
+ &t2, // insert t2
+ );
+ defer t3.deinit();
+
+ // Remove "A"
+ var it = t3.iterator();
+ var t4 = try t3.remove(
+ alloc,
+ while (it.next()) |entry| {
+ if (std.mem.eql(u8, entry.view.label, "A")) {
+ break entry.handle;
+ }
+ } else return error.NotFound,
+ );
+ defer t4.deinit();
+
+ const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(t4, .formatDiagram)});
+ defer alloc.free(str);
+ try testing.expectEqualStrings(str,
+ \\+---+
+ \\| B |
+ \\+---+
+ \\
+ );
+}
+
+test "SplitTree: split twice, remove intermediary" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var v1: TestTree.View = .{ .label = "A" };
+ var t1: TestTree = try .init(alloc, &v1);
+ defer t1.deinit();
+ var v2: TestTree.View = .{ .label = "B" };
+ var t2: TestTree = try .init(alloc, &v2);
+ defer t2.deinit();
+ var v3: TestTree.View = .{ .label = "C" };
+ var t3: TestTree = try .init(alloc, &v3);
+ defer t3.deinit();
+
+ // A | B horizontal.
+ var split1 = try t1.split(
+ alloc,
+ .root, // at root
+ .right, // split right
+ 0.5,
+ &t2, // insert t2
+ );
+ defer split1.deinit();
+
+ // Insert C below that.
+ var split2 = try split1.split(
+ alloc,
+ .root, // at root
+ .down, // split down
+ 0.5,
+ &t3, // insert t3
+ );
+ defer split2.deinit();
+
+ {
+ const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(split2, .formatDiagram)});
+ defer alloc.free(str);
+ try testing.expectEqualStrings(str,
+ \\+---++---+
+ \\| A || B |
+ \\+---++---+
+ \\+--------+
+ \\| C |
+ \\+--------+
+ \\
+ );
+ }
+
+ // Remove "B"
+ var it = split2.iterator();
+ var split3 = try split2.remove(
+ alloc,
+ while (it.next()) |entry| {
+ if (std.mem.eql(u8, entry.view.label, "B")) {
+ break entry.handle;
+ }
+ } else return error.NotFound,
+ );
+ defer split3.deinit();
+
+ {
+ const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(split3, .formatDiagram)});
+ defer alloc.free(str);
+ try testing.expectEqualStrings(str,
+ \\+---+
+ \\| A |
+ \\+---+
+ \\+---+
+ \\| C |
+ \\+---+
+ \\
+ );
+ }
+
+ // Remove every node from split2 (our most complex one), which should
+ // never crash. We don't test the result is correct, this just verifies
+ // we don't hit any assertion failures.
+ for (0..split2.nodes.len) |i| {
+ var t = try split2.remove(alloc, @enumFromInt(i));
+ t.deinit();
+ }
+}
+
+test "SplitTree: spatial goto" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var v1: TestTree.View = .{ .label = "A" };
+ var t1: TestTree = try .init(alloc, &v1);
+ defer t1.deinit();
+ var v2: TestTree.View = .{ .label = "B" };
+ var t2: TestTree = try .init(alloc, &v2);
+ defer t2.deinit();
+ var v3: TestTree.View = .{ .label = "C" };
+ var t3: TestTree = try .init(alloc, &v3);
+ defer t3.deinit();
+ var v4: TestTree.View = .{ .label = "D" };
+ var t4: TestTree = try .init(alloc, &v4);
+ defer t4.deinit();
+
+ // A | B horizontal
+ var splitAB = try t1.split(
+ alloc,
+ .root, // at root
+ .right, // split right
+ 0.5,
+ &t2, // insert t2
+ );
+ defer splitAB.deinit();
+
+ // A | C vertical
+ var splitAC = try splitAB.split(
+ alloc,
+ at: {
+ var it = splitAB.iterator();
+ break :at while (it.next()) |entry| {
+ if (std.mem.eql(u8, entry.view.label, "A")) {
+ break entry.handle;
+ }
+ } else return error.NotFound;
+ },
+ .down, // split down
+ 0.8,
+ &t3, // insert t3
+ );
+ defer splitAC.deinit();
+
+ // B | D vertical
+ var splitBD = try splitAC.split(
+ alloc,
+ at: {
+ var it = splitAB.iterator();
+ break :at while (it.next()) |entry| {
+ if (std.mem.eql(u8, entry.view.label, "B")) {
+ break entry.handle;
+ }
+ } else return error.NotFound;
+ },
+ .down, // split down
+ 0.3,
+ &t4, // insert t4
+ );
+ defer splitBD.deinit();
+ const split = splitBD;
+
+ {
+ const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(split, .formatDiagram)});
+ defer alloc.free(str);
+ try testing.expectEqualStrings(str,
+ \\+---++---+
+ \\| || B |
+ \\| |+---+
+ \\| |+---+
+ \\| A || |
+ \\| || |
+ \\| || |
+ \\| || D |
+ \\+---+| |
+ \\+---+| |
+ \\| C || |
+ \\+---++---+
+ \\
+ );
+ }
+
+ // Spatial C => right
+ {
+ const target = (try split.goto(
+ alloc,
+ from: {
+ var it = split.iterator();
+ break :from while (it.next()) |entry| {
+ if (std.mem.eql(u8, entry.view.label, "C")) {
+ break entry.handle;
+ }
+ } else return error.NotFound;
+ },
+ .{ .spatial = .right },
+ )).?;
+ const view = split.nodes[target.idx()].leaf;
+ try testing.expectEqualStrings(view.label, "D");
+ }
+
+ // Spatial D => left
+ {
+ const target = (try split.goto(
+ alloc,
+ from: {
+ var it = split.iterator();
+ break :from while (it.next()) |entry| {
+ if (std.mem.eql(u8, entry.view.label, "D")) {
+ break entry.handle;
+ }
+ } else return error.NotFound;
+ },
+ .{ .spatial = .left },
+ )).?;
+ const view = split.nodes[target.idx()].leaf;
+ try testing.expectEqualStrings("A", view.label);
+ }
+
+ // Spatial A => left (wrapped)
+ {
+ const target = (try split.goto(
+ alloc,
+ from: {
+ var it = split.iterator();
+ break :from while (it.next()) |entry| {
+ if (std.mem.eql(u8, entry.view.label, "A")) {
+ break entry.handle;
+ }
+ } else return error.NotFound;
+ },
+ .{ .spatial = .left },
+ )).?;
+ const view = split.nodes[target.idx()].leaf;
+ try testing.expectEqualStrings("B", view.label);
+ }
+
+ // Spatial B => right (wrapped)
+ {
+ const target = (try split.goto(
+ alloc,
+ from: {
+ var it = split.iterator();
+ break :from while (it.next()) |entry| {
+ if (std.mem.eql(u8, entry.view.label, "B")) {
+ break entry.handle;
+ }
+ } else return error.NotFound;
+ },
+ .{ .spatial = .right },
+ )).?;
+ const view = split.nodes[target.idx()].leaf;
+ try testing.expectEqualStrings("A", view.label);
+ }
+
+ // Spatial C => down (wrapped)
+ {
+ const target = (try split.goto(
+ alloc,
+ from: {
+ var it = split.iterator();
+ break :from while (it.next()) |entry| {
+ if (std.mem.eql(u8, entry.view.label, "C")) {
+ break entry.handle;
+ }
+ } else return error.NotFound;
+ },
+ .{ .spatial = .down },
+ )).?;
+ const view = split.nodes[target.idx()].leaf;
+ try testing.expectEqualStrings("A", view.label);
+ }
+
+ // Equalize
+ var equal = try split.equalize(alloc);
+ defer equal.deinit();
+
+ {
+ const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(equal, .formatDiagram)});
+ defer alloc.free(str);
+ try testing.expectEqualStrings(str,
+ \\+---++---+
+ \\| A || B |
+ \\+---++---+
+ \\+---++---+
+ \\| C || D |
+ \\+---++---+
+ \\
+ );
+ }
+}
+
+test "SplitTree: resize" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var v1: TestTree.View = .{ .label = "A" };
+ var t1: TestTree = try .init(alloc, &v1);
+ defer t1.deinit();
+ var v2: TestTree.View = .{ .label = "B" };
+ var t2: TestTree = try .init(alloc, &v2);
+ defer t2.deinit();
+
+ // A | B horizontal
+ var split = try t1.split(
+ alloc,
+ .root, // at root
+ .right, // split right
+ 0.5,
+ &t2, // insert t2
+ );
+ defer split.deinit();
+
+ {
+ const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(split, .formatDiagram)});
+ defer alloc.free(str);
+ try testing.expectEqualStrings(str,
+ \\+---++---+
+ \\| A || B |
+ \\+---++---+
+ \\
+ );
+ }
+
+ // Resize
+ {
+ var resized = try split.resize(
+ alloc,
+ at: {
+ var it = split.iterator();
+ break :at while (it.next()) |entry| {
+ if (std.mem.eql(u8, entry.view.label, "B")) {
+ break entry.handle;
+ }
+ } else return error.NotFound;
+ },
+ .horizontal, // resize right
+ 0.25,
+ );
+ defer resized.deinit();
+ const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(resized, .formatDiagram)});
+ defer alloc.free(str);
+ try testing.expectEqualStrings(str,
+ \\+-------------++---+
+ \\| A || B |
+ \\+-------------++---+
+ \\
+ );
+ }
+
+ // Resize the other direction (negative ratio)
+ {
+ var resized = try split.resize(
+ alloc,
+ at: {
+ var it = split.iterator();
+ break :at while (it.next()) |entry| {
+ if (std.mem.eql(u8, entry.view.label, "B")) {
+ break entry.handle;
+ }
+ } else return error.NotFound;
+ },
+ .horizontal, // resize left
+ -0.25,
+ );
+ defer resized.deinit();
+ const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(resized, .formatDiagram)});
+ defer alloc.free(str);
+ try testing.expectEqualStrings(str,
+ \\+---++-------------+
+ \\| A || B |
+ \\+---++-------------+
+ \\
+ );
+ }
+}
+
+test "SplitTree: clone empty tree" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ var t: TestTree = .empty;
+ defer t.deinit();
+
+ var t2 = try t.clone(alloc);
+ defer t2.deinit();
+
+ {
+ const str = try std.fmt.allocPrint(alloc, "{f}", .{t2});
+ defer alloc.free(str);
+ try testing.expectEqualStrings(str,
+ \\empty
+ );
+ }
+}
+
+test "SplitTree: zoom" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var v1: TestTree.View = .{ .label = "A" };
+ var t1: TestTree = try .init(alloc, &v1);
+ defer t1.deinit();
+ var v2: TestTree.View = .{ .label = "B" };
+ var t2: TestTree = try .init(alloc, &v2);
+ defer t2.deinit();
+
+ // A | B horizontal
+ var split = try t1.split(
+ alloc,
+ .root, // at root
+ .right, // split right
+ 0.5,
+ &t2, // insert t2
+ );
+ defer split.deinit();
+ split.zoom(at: {
+ var it = split.iterator();
+ break :at while (it.next()) |entry| {
+ if (std.mem.eql(u8, entry.view.label, "B")) {
+ break entry.handle;
+ }
+ } else return error.NotFound;
+ });
+
+ {
+ const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(split, .formatText)});
+ defer alloc.free(str);
+ try testing.expectEqualStrings(str,
+ \\split (layout: horizontal, ratio: 0.50)
+ \\ leaf: A
+ \\ (zoomed) leaf: B
+ \\
+ );
+ }
+
+ // Clone preserves zoom
+ var clone = try split.clone(alloc);
+ defer clone.deinit();
+
+ {
+ const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(clone, .formatText)});
+ defer alloc.free(str);
+ try testing.expectEqualStrings(str,
+ \\split (layout: horizontal, ratio: 0.50)
+ \\ leaf: A
+ \\ (zoomed) leaf: B
+ \\
+ );
+ }
+}
+
+test "SplitTree: split resets zoom" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var v1: TestTree.View = .{ .label = "A" };
+ var t1: TestTree = try .init(alloc, &v1);
+ defer t1.deinit();
+ var v2: TestTree.View = .{ .label = "B" };
+ var t2: TestTree = try .init(alloc, &v2);
+ defer t2.deinit();
+
+ // Zoom A
+ t1.zoom(at: {
+ var it = t1.iterator();
+ break :at while (it.next()) |entry| {
+ if (std.mem.eql(u8, entry.view.label, "A")) {
+ break entry.handle;
+ }
+ } else return error.NotFound;
+ });
+
+ // A | B horizontal
+ var split = try t1.split(
+ alloc,
+ .root, // at root
+ .right, // split right
+ 0.5,
+ &t2, // insert t2
+ );
+ defer split.deinit();
+
+ {
+ const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(split, .formatText)});
+ defer alloc.free(str);
+ try testing.expectEqualStrings(str,
+ \\split (layout: horizontal, ratio: 0.50)
+ \\ leaf: A
+ \\ leaf: B
+ \\
+ );
+ }
+}
+
+test "SplitTree: remove and zoom" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var v1: TestTree.View = .{ .label = "A" };
+ var t1: TestTree = try .init(alloc, &v1);
+ defer t1.deinit();
+ var v2: TestTree.View = .{ .label = "B" };
+ var t2: TestTree = try .init(alloc, &v2);
+ defer t2.deinit();
+
+ // A | B horizontal
+ var split = try t1.split(
+ alloc,
+ .root, // at root
+ .right, // split right
+ 0.5,
+ &t2, // insert t2
+ );
+ defer split.deinit();
+ split.zoom(at: {
+ var it = split.iterator();
+ break :at while (it.next()) |entry| {
+ if (std.mem.eql(u8, entry.view.label, "A")) {
+ break entry.handle;
+ }
+ } else return error.NotFound;
+ });
+
+ // Remove A, should unzoom
+ {
+ var removed = try split.remove(
+ alloc,
+ at: {
+ var it = split.iterator();
+ break :at while (it.next()) |entry| {
+ if (std.mem.eql(u8, entry.view.label, "A")) {
+ break entry.handle;
+ }
+ } else return error.NotFound;
+ },
+ );
+ defer removed.deinit();
+ try testing.expect(removed.zoomed == null);
+
+ const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(removed, .formatText)});
+ defer alloc.free(str);
+ try testing.expectEqualStrings(str,
+ \\leaf: B
+ \\
+ );
+ }
+
+ // Remove B, should keep zoom
+ {
+ var removed = try split.remove(
+ alloc,
+ at: {
+ var it = split.iterator();
+ break :at while (it.next()) |entry| {
+ if (std.mem.eql(u8, entry.view.label, "B")) {
+ break entry.handle;
+ }
+ } else return error.NotFound;
+ },
+ );
+ defer removed.deinit();
+
+ const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(removed, .formatText)});
+ defer alloc.free(str);
+ try testing.expectEqualStrings(str,
+ \\(zoomed) leaf: A
+ \\
+ );
+ }
+}