diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-04 19:22:04 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 01:23:06 +0300 |
| commit | 41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch) | |
| tree | 3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/datastruct/split_tree.zig | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (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.zig | 2367 |
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 + \\ + ); + } +} |
