diff --git a/src/apprt/gtk-ng.zig b/src/apprt/gtk-ng.zig index de9255fe9..8f0489b80 100644 --- a/src/apprt/gtk-ng.zig +++ b/src/apprt/gtk-ng.zig @@ -7,6 +7,7 @@ pub const resourcesDir = internal_os.resourcesDir; // The exported API, custom for the apprt. pub const class = @import("gtk-ng/class.zig"); +pub const SplitTree = @import("gtk-ng/split_tree.zig").SplitTree; pub const WeakRef = @import("gtk-ng/weak_ref.zig").WeakRef; test { diff --git a/src/apprt/gtk-ng/split_tree.zig b/src/apprt/gtk-ng/split_tree.zig new file mode 100644 index 000000000..6d86ed902 --- /dev/null +++ b/src/apprt/gtk-ng/split_tree.zig @@ -0,0 +1,310 @@ +const std = @import("std"); +const assert = std.debug.assert; +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. +/// +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, + + /// 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 = &.{}, + }; + + 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 = u16; + }; + + 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 view.ref(gpa) }; + errdefer view.unref(gpa); + + return .{ + .arena = arena, + .nodes = nodes, + }; + } + + 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| view.unref(gpa), + .split => {}, + }; + self.arena.deinit(); + } + + self.* = undefined; + } + + /// 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, + 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)) 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 += @intCast(self.nodes.len); + s.right += @intCast(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]; + nodes[at] = .{ .split = .{ + .layout = layout, + .ratio = 0.5, + .left = @intCast(if (left) self.nodes.len else nodes.len - 1), + .right = @intCast(if (left) nodes.len - 1 else self.nodes.len), + } }; + + // 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| view.unref(gpa), + } + }; + for (0..nodes.len) |i| { + switch (nodes[i]) { + .split => {}, + .leaf => |view| nodes[i] = .{ .leaf = try view.ref(gpa) }, + } + reffed = i; + } + assert(reffed == nodes.len - 1); + + return .{ .arena = arena, .nodes = nodes }; + } + + /// Format the tree in a human-readable format. + /// + /// NOTE: This is currently in node-order but we should change this + /// to spatial ASCII drawings once we have better support for that. + pub fn format( + self: *const Self, + comptime fmt: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = fmt; + _ = options; + + if (self.nodes.len == 0) { + try writer.writeAll("empty"); + } else { + try self.formatNode(writer, 0, 0); + } + } + + fn formatNode( + self: *const Self, + writer: anytype, + handle: Node.Handle, + depth: usize, + ) !void { + const node = self.nodes[handle]; + + // Write indentation + for (0..depth) |_| try writer.writeAll(" "); + + // Write node + switch (node) { + .leaf => try writer.print("leaf({d})", .{handle}), + .split => |s| { + try writer.print( + "split({s}, {d:.2})\n", + .{ @tagName(s.layout), s.ratio }, + ); + try self.formatNode(writer, s.left, depth + 1); + try writer.writeAll("\n"); + try self.formatNode(writer, s.right, depth + 1); + }, + } + } + }; +} + +const TestTree = SplitTree(TestView); + +const TestView = struct { + const Self = @This(); + + 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); + } +}; + +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, "{}", .{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 = .{}; + var t: TestTree = try .init(alloc, &v); + defer t.deinit(); + + const str = try std.fmt.allocPrint(alloc, "{}", .{t}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\leaf(0) + ); +} + +test "SplitTree: split" { + const testing = std.testing; + const alloc = testing.allocator; + var v: TestTree.View = .{}; + + var t1: TestTree = try .init(alloc, &v); + defer t1.deinit(); + var t2: TestTree = try .init(alloc, &v); + defer t2.deinit(); + + var t3 = try t1.split( + alloc, + 0, // at root + .right, // split right + &t2, // insert t2 + ); + defer t3.deinit(); + + const str = try std.fmt.allocPrint(alloc, "{}", .{t3}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\split(horizontal, 0.50) + \\ leaf(2) + \\ leaf(1) + ); +}