From 7811c04f9d75582f9ba12527a4919211ff8612b9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Aug 2025 10:18:09 -0700 Subject: [PATCH 01/12] apprt/gtk-ng: SplitTree data structure --- src/apprt/gtk-ng.zig | 1 + src/apprt/gtk-ng/split_tree.zig | 310 ++++++++++++++++++++++++++++++++ 2 files changed, 311 insertions(+) create mode 100644 src/apprt/gtk-ng/split_tree.zig 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) + ); +} From 5c30ac0e8e8dfb83a11a08e4a73a8a3904dc3053 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Aug 2025 15:59:42 -0700 Subject: [PATCH 02/12] apprt/gtk-ng: spatial tree --- src/apprt/gtk-ng/split_tree.zig | 140 ++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/src/apprt/gtk-ng/split_tree.zig b/src/apprt/gtk-ng/split_tree.zig index 6d86ed902..121a103eb 100644 --- a/src/apprt/gtk-ng/split_tree.zig +++ b/src/apprt/gtk-ng/split_tree.zig @@ -191,6 +191,146 @@ pub fn SplitTree(comptime V: type) type { return .{ .arena = arena, .nodes = nodes }; } + /// 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 each split + /// being exactly 1 unit wide and high. The x and y coordinates + /// are offsets into that space. 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 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 = &.{} }; + + const Slot = struct { + x: f16, + y: f16, + width: f16, + height: f16, + }; + + pub fn deinit(self: *const Spatial, alloc: Allocator) void { + alloc.free(self.slots); + self.* = undefined; + } + }; + + /// Returns the spatial representation of this tree. See Spatial + /// for more details. + 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(0); + + // 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 = dim.width, + .height = dim.height, + }; + self.fillSpatialSlots(slots, 0); + + return .{ .slots = slots }; + } + + fn fillSpatialSlots( + self: *const Self, + slots: []Spatial.Slot, + current: Node.Handle, + ) void { + 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] = .{ + .x = slots[current].x, + .y = slots[current].y, + .width = slots[current].width * s.ratio, + .height = slots[current].height, + }; + slots[s.right] = .{ + .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] = .{ + .x = slots[current].x, + .y = slots[current].y, + .width = slots[current].width, + .height = slots[current].height * s.ratio, + }; + slots[s.right] = .{ + .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]) { + .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. /// /// NOTE: This is currently in node-order but we should change this From 52e264948d1d413dd8cbcd5da7293ea29bb59a0c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Aug 2025 16:36:38 -0700 Subject: [PATCH 03/12] apprt/gtk-ng: ASCII output for SplitTree --- src/apprt/gtk-ng/split_tree.zig | 164 ++++++++++++++++++++++++++------ 1 file changed, 133 insertions(+), 31 deletions(-) diff --git a/src/apprt/gtk-ng/split_tree.zig b/src/apprt/gtk-ng/split_tree.zig index 121a103eb..275916e68 100644 --- a/src/apprt/gtk-ng/split_tree.zig +++ b/src/apprt/gtk-ng/split_tree.zig @@ -245,8 +245,8 @@ pub fn SplitTree(comptime V: type) type { slots[0] = .{ .x = 0, .y = 0, - .width = dim.width, - .height = dim.height, + .width = @floatFromInt(dim.width), + .height = @floatFromInt(dim.height), }; self.fillSpatialSlots(slots, 0); @@ -346,34 +346,101 @@ pub fn SplitTree(comptime V: type) type { if (self.nodes.len == 0) { try writer.writeAll("empty"); - } else { - try self.formatNode(writer, 0, 0); + return; } - } - fn formatNode( - self: *const Self, - writer: anytype, - handle: Node.Handle, - depth: usize, - ) !void { - const node = self.nodes[handle]; + // 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(); - // Write indentation - for (0..depth) |_| try writer.writeAll(" "); + // Get our spatial representation. + const sp = try self.spatial(alloc); - // 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); - }, + // 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: { + // The width we need for the largest label. + const max_label_width = std.math.log10(sp.slots.len) + 1; + + // 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. + 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 = try alloc.alloc([]u8, height); + for (0..rows.len) |y| { + rows[y] = try alloc.alloc(u8, width + 1); + @memset(rows[y], ' '); + rows[y][width] = '\n'; + } + break :grid rows; + }; + + // Draw each node + for (sp.slots, 0..) |slot, handle| { + var x: usize = @intFromFloat(@ceil(slot.x)); + var y: usize = @intFromFloat(@ceil(slot.y)); + var width: usize = @intFromFloat(@ceil(slot.width)); + var height: usize = @intFromFloat(@ceil(slot.height)); + 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] = '|'; + + // Draw the handle in the center + const x_mid = width / 2 + x; + const y_mid = height / 2 + y; + const label_width = std.math.log10(handle + 1) + 1; + const label_start = x_mid - label_width / 2; + const row = grid[y_mid][label_start..]; + _ = try std.fmt.bufPrint(row, "{d}", .{handle}); + } + + // Output every row + for (grid) |row| { + try writer.writeAll(row); } } }; @@ -418,11 +485,14 @@ test "SplitTree: single node" { const str = try std.fmt.allocPrint(alloc, "{}", .{t}); defer alloc.free(str); try testing.expectEqualStrings(str, - \\leaf(0) + \\+---+ + \\| 0 | + \\+---+ + \\ ); } -test "SplitTree: split" { +test "SplitTree: split horizontal" { const testing = std.testing; const alloc = testing.allocator; var v: TestTree.View = .{}; @@ -443,8 +513,40 @@ test "SplitTree: split" { const str = try std.fmt.allocPrint(alloc, "{}", .{t3}); defer alloc.free(str); try testing.expectEqualStrings(str, - \\split(horizontal, 0.50) - \\ leaf(2) - \\ leaf(1) + \\+---++---+ + \\| 2 || 1 | + \\+---++---+ + \\ + ); +} + +test "SplitTree: split vertical" { + 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 + .down, // split down + &t2, // insert t2 + ); + defer t3.deinit(); + + const str = try std.fmt.allocPrint(alloc, "{}", .{t3}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---+ + \\| 2 | + \\+---+ + \\+---+ + \\| 1 | + \\+---+ + \\ ); } From 3e767c166c6f2b87105d6e48b48eeeed9df94cdf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Aug 2025 07:29:20 -0700 Subject: [PATCH 04/12] datastruct: split tree node removal --- src/apprt/gtk-ng.zig | 1 - src/datastruct/main.zig | 2 + .../gtk-ng => datastruct}/split_tree.zig | 384 +++++++++++++++++- 3 files changed, 367 insertions(+), 20 deletions(-) rename src/{apprt/gtk-ng => datastruct}/split_tree.zig (63%) diff --git a/src/apprt/gtk-ng.zig b/src/apprt/gtk-ng.zig index 8f0489b80..de9255fe9 100644 --- a/src/apprt/gtk-ng.zig +++ b/src/apprt/gtk-ng.zig @@ -7,7 +7,6 @@ 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/datastruct/main.zig b/src/datastruct/main.zig index 4f45f9483..14ee0e504 100644 --- a/src/datastruct/main.zig +++ b/src/datastruct/main.zig @@ -6,6 +6,7 @@ const cache_table = @import("cache_table.zig"); const circ_buf = @import("circ_buf.zig"); const intrusive_linked_list = @import("intrusive_linked_list.zig"); const segmented_pool = @import("segmented_pool.zig"); +const split_tree = @import("split_tree.zig"); pub const lru = @import("lru.zig"); pub const BlockingQueue = blocking_queue.BlockingQueue; @@ -13,6 +14,7 @@ pub const CacheTable = cache_table.CacheTable; pub const CircBuf = circ_buf.CircBuf; pub const IntrusiveDoublyLinkedList = intrusive_linked_list.DoublyLinkedList; pub const SegmentedPool = segmented_pool.SegmentedPool; +pub const SplitTree = split_tree.SplitTree; test { @import("std").testing.refAllDecls(@This()); diff --git a/src/apprt/gtk-ng/split_tree.zig b/src/datastruct/split_tree.zig similarity index 63% rename from src/apprt/gtk-ng/split_tree.zig rename to src/datastruct/split_tree.zig index 275916e68..69cb5201a 100644 --- a/src/apprt/gtk-ng/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -31,6 +31,12 @@ const Allocator = std.mem.Allocator; /// /// - `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. +/// pub fn SplitTree(comptime V: type) type { return struct { const Self = @This(); @@ -107,6 +113,38 @@ pub fn SplitTree(comptime V: type) type { self.* = undefined; } + /// An iterator over all the views in the tree. + pub fn iterator( + self: *const Self, + ) Iterator { + return .{ .nodes = self.nodes }; + } + + pub const Iterator = struct { + i: Node.Handle = 0, + nodes: []const Node, + + pub const Entry = struct { + handle: Node.Handle, + view: *View, + }; + + pub fn next(self: *Iterator) ?Entry { + // If we have no nodes, return null. + if (self.i >= self.nodes.len) return null; + + // Get the current node and increment the index. + const handle = self.i; + self.i += 1; + const node = self.nodes[handle]; + + return switch (node) { + .leaf => |v| .{ .handle = handle, .view = v }, + .split => self.next(), + }; + } + }; + /// 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 @@ -169,6 +207,159 @@ pub fn SplitTree(comptime V: type) type { .right = @intCast(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 }; + } + + /// Remove a node from the tree. + pub fn remove( + self: *Self, + gpa: Allocator, + at: Node.Handle, + ) Allocator.Error!Self { + assert(at < self.nodes.len); + + // If we're removing node zero then we're clearing the tree. + if (at == 0) 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( + 0, + at, + 0, + )); + + // Traverse the tree and copy all our nodes into place. + assert(self.removeNode( + nodes, + 0, + 0, + at, + ) > 0); + + // Increase the reference count of all the nodes. + try refNodes(gpa, nodes); + + return .{ + .arena = arena, + .nodes = nodes, + }; + } + + fn removeNode( + self: *Self, + nodes: []Node, + new_offset: Node.Handle, + current: Node.Handle, + target: Node.Handle, + ) Node.Handle { + assert(current != target); + + switch (self.nodes[current]) { + // 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| { + 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 self.removeNode( + nodes, + new_offset, + s.right, + target, + ); + if (s.right == target) return self.removeNode( + nodes, + new_offset, + s.left, + target, + ); + + // Neither side is being directly removed, so we traverse. + const left = self.removeNode( + nodes, + new_offset + 1, + s.left, + target, + ); + assert(left > 0); + const right = self.removeNode( + nodes, + new_offset + 1 + left, + s.right, + target, + ); + assert(right > 0); + nodes[new_offset] = .{ .split = .{ + .layout = s.layout, + .ratio = s.ratio, + .left = new_offset + 1, + .right = 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]) { + // 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. @@ -187,8 +378,6 @@ pub fn SplitTree(comptime V: type) type { reffed = i; } assert(reffed == nodes.len - 1); - - return .{ .arena = arena, .nodes = nodes }; } /// Spatial representation of the split tree. This can be used to @@ -360,13 +549,28 @@ pub fn SplitTree(comptime V: type) type { // Get our spatial representation. const sp = try self.spatial(alloc); + // 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: { - // The width we need for the largest label. - const max_label_width = std.math.log10(sp.slots.len) + 1; - // Border + whitespace + label + whitespace + border. break :cell_width 2 + max_label_width + 2; }; @@ -400,6 +604,13 @@ pub fn SplitTree(comptime V: type) type { // 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, + } + var x: usize = @intFromFloat(@ceil(slot.x)); var y: usize = @intFromFloat(@ceil(slot.y)); var width: usize = @intFromFloat(@ceil(slot.width)); @@ -429,13 +640,20 @@ pub fn SplitTree(comptime V: type) type { 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 + try std.fmt.bufPrint(&buf, "{d}", .{handle}); + // Draw the handle in the center const x_mid = width / 2 + x; const y_mid = height / 2 + y; - const label_width = std.math.log10(handle + 1) + 1; + const label_width = label.len; const label_start = x_mid - label_width / 2; const row = grid[y_mid][label_start..]; - _ = try std.fmt.bufPrint(row, "{d}", .{handle}); + _ = try std.fmt.bufPrint(row, "{s}", .{label}); } // Output every row @@ -451,6 +669,8 @@ 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.*; @@ -460,6 +680,10 @@ const TestView = struct { pub fn unref(self: *Self, alloc: Allocator) void { alloc.destroy(self); } + + pub fn splitTreeLabel(self: *const Self) []const u8 { + return self.label; + } }; test "SplitTree: empty tree" { @@ -478,7 +702,7 @@ test "SplitTree: empty tree" { test "SplitTree: single node" { const testing = std.testing; const alloc = testing.allocator; - var v: TestTree.View = .{}; + var v: TestTree.View = .{ .label = "A" }; var t: TestTree = try .init(alloc, &v); defer t.deinit(); @@ -486,7 +710,7 @@ test "SplitTree: single node" { defer alloc.free(str); try testing.expectEqualStrings(str, \\+---+ - \\| 0 | + \\| A | \\+---+ \\ ); @@ -495,11 +719,11 @@ test "SplitTree: single node" { test "SplitTree: split horizontal" { const testing = std.testing; const alloc = testing.allocator; - var v: TestTree.View = .{}; - - var t1: TestTree = try .init(alloc, &v); + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); defer t1.deinit(); - var t2: TestTree = try .init(alloc, &v); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); defer t2.deinit(); var t3 = try t1.split( @@ -514,7 +738,7 @@ test "SplitTree: split horizontal" { defer alloc.free(str); try testing.expectEqualStrings(str, \\+---++---+ - \\| 2 || 1 | + \\| A || B | \\+---++---+ \\ ); @@ -523,11 +747,12 @@ test "SplitTree: split horizontal" { test "SplitTree: split vertical" { const testing = std.testing; const alloc = testing.allocator; - var v: TestTree.View = .{}; - var t1: TestTree = try .init(alloc, &v); + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); defer t1.deinit(); - var t2: TestTree = try .init(alloc, &v); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); defer t2.deinit(); var t3 = try t1.split( @@ -542,11 +767,132 @@ test "SplitTree: split vertical" { defer alloc.free(str); try testing.expectEqualStrings(str, \\+---+ - \\| 2 | + \\| A | \\+---+ \\+---+ - \\| 1 | + \\| B | \\+---+ \\ ); } + +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, + 0, // at root + .right, // split right + &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, "{}", .{t4}); + 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, + 0, // at root + .right, // split right + &t2, // insert t2 + ); + defer split1.deinit(); + + // Insert C below that. + var split2 = try split1.split( + alloc, + 0, // at root + .down, // split down + &t3, // insert t3 + ); + defer split2.deinit(); + + { + const str = try std.fmt.allocPrint(alloc, "{}", .{split2}); + 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, "{}", .{split3}); + 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, @intCast(i)); + t.deinit(); + } +} From ad1cfe8347144575e65f590cdd6ca83badc4dd21 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Aug 2025 09:19:32 -0700 Subject: [PATCH 05/12] remove outdated comment --- src/datastruct/split_tree.zig | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 69cb5201a..9cd929faa 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -521,9 +521,6 @@ pub fn SplitTree(comptime V: type) type { } /// 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, From fa08434b28580229c39082dc4f1de6731ed0ed3b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Aug 2025 09:52:41 -0700 Subject: [PATCH 06/12] apprt/gtk-ng: initial GhosttySplitTree widget --- src/apprt/gtk-ng/build/gresource.zig | 1 + src/apprt/gtk-ng/class/split_tree.zig | 157 +++++++++++++++++++++++++ src/apprt/gtk-ng/class/surface.zig | 4 + src/apprt/gtk-ng/class/tab.zig | 2 + src/apprt/gtk-ng/ui/1.5/split-tree.blp | 16 +++ src/apprt/gtk-ng/ui/1.5/tab.blp | 3 + src/datastruct/split_tree.zig | 37 +++++- 7 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 src/apprt/gtk-ng/class/split_tree.zig create mode 100644 src/apprt/gtk-ng/ui/1.5/split-tree.blp diff --git a/src/apprt/gtk-ng/build/gresource.zig b/src/apprt/gtk-ng/build/gresource.zig index f5b91ce48..0f7237331 100644 --- a/src/apprt/gtk-ng/build/gresource.zig +++ b/src/apprt/gtk-ng/build/gresource.zig @@ -40,6 +40,7 @@ pub const blueprints: []const Blueprint = &.{ .{ .major = 1, .minor = 2, .name = "debug-warning" }, .{ .major = 1, .minor = 3, .name = "debug-warning" }, .{ .major = 1, .minor = 2, .name = "resize-overlay" }, + .{ .major = 1, .minor = 5, .name = "split-tree" }, .{ .major = 1, .minor = 2, .name = "surface" }, .{ .major = 1, .minor = 3, .name = "surface-child-exited" }, .{ .major = 1, .minor = 5, .name = "tab" }, diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig new file mode 100644 index 000000000..968dbaa88 --- /dev/null +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -0,0 +1,157 @@ +const std = @import("std"); +const build_config = @import("../../../build_config.zig"); +const assert = std.debug.assert; +const adw = @import("adw"); +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const i18n = @import("../../../os/main.zig").i18n; +const apprt = @import("../../../apprt.zig"); +const input = @import("../../../input.zig"); +const CoreSurface = @import("../../../Surface.zig"); +const gtk_version = @import("../gtk_version.zig"); +const adw_version = @import("../adw_version.zig"); +const gresource = @import("../build/gresource.zig"); +const Common = @import("../class.zig").Common; +const Config = @import("config.zig").Config; +const Application = @import("application.zig").Application; +const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; +const Surface = @import("surface.zig").Surface; + +const log = std.log.scoped(.gtk_ghostty_split_tree); + +pub const SplitTree = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttySplitTree", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const @"is-empty" = struct { + pub const name = "is-empty"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .nick = "Tree Is Empty", + .blurb = "True when the tree has no surfaces.", + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ + .getter = getIsEmpty, + }, + ), + }, + ); + }; + }; + + const Private = struct { + /// The tree datastructure containing all of our surface views. + tree: Surface.Tree, + + pub var offset: c_int = 0; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + // Start with an empty split tree. + const priv = self.private(); + priv.tree = .empty; + } + + //--------------------------------------------------------------- + // Properties + + pub fn getIsEmpty(self: *Self) bool { + const priv = self.private(); + return priv.tree.isEmpty(); + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.c) void { + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + fn finalize(self: *Self) callconv(.c) void { + const priv = self.private(); + priv.tree.deinit(); + priv.tree = .empty; + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + //--------------------------------------------------------------- + // Signal handlers + + //--------------------------------------------------------------- + // Class + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.c) void { + gobject.ext.ensureType(Surface); + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "split-tree", + }), + ); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.@"is-empty".impl, + }); + + // Bindings + + // Template Callbacks + + // Signals + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 4251b56a8..f26783747 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -9,6 +9,7 @@ const gobject = @import("gobject"); const gtk = @import("gtk"); const apprt = @import("../../../apprt.zig"); +const datastruct = @import("../../../datastruct/main.zig"); const font = @import("../../../font/main.zig"); const input = @import("../../../input.zig"); const internal_os = @import("../../../os/main.zig"); @@ -42,6 +43,9 @@ pub const Surface = extern struct { .private = .{ .Type = Private, .offset = &Private.offset }, }); + /// A SplitTree implementation that stores surfaces. + pub const Tree = datastruct.SplitTree(Self); + pub const properties = struct { pub const config = struct { pub const name = "config"; diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index 3aa41c5ff..b343ba248 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -18,6 +18,7 @@ const Common = @import("../class.zig").Common; const Config = @import("config.zig").Config; const Application = @import("application.zig").Application; const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; +const SplitTree = @import("split_tree.zig").SplitTree; const Surface = @import("surface.zig").Surface; const log = std.log.scoped(.gtk_ghostty_window); @@ -251,6 +252,7 @@ pub const Tab = extern struct { pub const Instance = Self; fn init(class: *Class) callconv(.c) void { + gobject.ext.ensureType(SplitTree); gobject.ext.ensureType(Surface); gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), diff --git a/src/apprt/gtk-ng/ui/1.5/split-tree.blp b/src/apprt/gtk-ng/ui/1.5/split-tree.blp new file mode 100644 index 000000000..0eebff7a6 --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.5/split-tree.blp @@ -0,0 +1,16 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttySplitTree: Adw.Bin { + // This could be a lot more visually pleasing but in practice this doesn't + // ever happen at the time of writing this comment. A surface-less split + // tree always closes its parent. + Label { + visible: bind template.is-empty; + // Purposely not localized currently because this shouldn't really + // ever appear. When we have a situation it does appear, we may want + // to change the styling and text so I don't want to burden localizers + // to handle this yet. + label: "No surfaces."; + } +} diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk-ng/ui/1.5/tab.blp index 476244576..d1c7737f6 100644 --- a/src/apprt/gtk-ng/ui/1.5/tab.blp +++ b/src/apprt/gtk-ng/ui/1.5/tab.blp @@ -5,6 +5,7 @@ template $GhosttyTab: Box { "tab", ] + orientation: vertical; hexpand: true; vexpand: true; // A tab currently just contains a surface directly. When we introduce @@ -12,4 +13,6 @@ template $GhosttyTab: Box { $GhosttySurface surface { close-request => $surface_close_request(); } + + $GhosttySplitTree {} } diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 9cd929faa..759387073 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -37,6 +37,9 @@ const Allocator = std.mem.Allocator; /// 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(); @@ -88,8 +91,8 @@ pub fn SplitTree(comptime V: type) type { const alloc = arena.allocator(); const nodes = try alloc.alloc(Node, 1); - nodes[0] = .{ .leaf = try view.ref(gpa) }; - errdefer view.unref(gpa); + nodes[0] = .{ .leaf = try viewRef(view, gpa) }; + errdefer viewUnref(view, gpa); return .{ .arena = arena, @@ -104,7 +107,7 @@ pub fn SplitTree(comptime V: type) type { // Unref all our views const gpa: Allocator = self.arena.child_allocator; for (self.nodes) |node| switch (node) { - .leaf => |view| view.unref(gpa), + .leaf => |view| viewUnref(view, gpa), .split => {}, }; self.arena.deinit(); @@ -113,6 +116,12 @@ pub fn SplitTree(comptime V: type) type { self.* = undefined; } + /// 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; + } + /// An iterator over all the views in the tree. pub fn iterator( self: *const Self, @@ -367,13 +376,13 @@ pub fn SplitTree(comptime V: type) type { errdefer for (0..reffed) |i| { switch (nodes[i]) { .split => {}, - .leaf => |view| view.unref(gpa), + .leaf => |view| viewUnref(view, gpa), } }; for (0..nodes.len) |i| { switch (nodes[i]) { .split => {}, - .leaf => |view| nodes[i] = .{ .leaf = try view.ref(gpa) }, + .leaf => |view| nodes[i] = .{ .leaf = try viewRef(view, gpa) }, } reffed = i; } @@ -658,6 +667,24 @@ pub fn SplitTree(comptime V: type) type { 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"), + } + } }; } From 70b050ebb469ea21d8b1c37bd5ffe68fceb87c35 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Aug 2025 10:34:25 -0700 Subject: [PATCH 07/12] apprt/gtk-ng: setup split tree property --- src/apprt/gtk-ng/class.zig | 6 ++- src/apprt/gtk-ng/class/split_tree.zig | 41 ++++++++++++---- src/apprt/gtk-ng/ui/1.5/split-tree.blp | 33 +++++++++---- src/datastruct/split_tree.zig | 65 ++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 20 deletions(-) diff --git a/src/apprt/gtk-ng/class.zig b/src/apprt/gtk-ng/class.zig index 170df1acb..a22b8771b 100644 --- a/src/apprt/gtk-ng/class.zig +++ b/src/apprt/gtk-ng/class.zig @@ -5,6 +5,7 @@ const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); +const ext = @import("ext.zig"); pub const Application = @import("class/application.zig").Application; pub const Window = @import("class/window.zig").Window; pub const Config = @import("class/config.zig").Config; @@ -79,7 +80,10 @@ pub fn Common( fn set(self: *Self, value: *const gobject.Value) void { const priv = private(self); if (@field(priv, name)) |v| { - glib.ext.destroy(v); + ext.boxedFree( + @typeInfo(@TypeOf(v)).pointer.child, + v, + ); } const T = @TypeOf(@field(priv, name)); diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 968dbaa88..38f3d3536 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -13,6 +13,7 @@ const input = @import("../../../input.zig"); const CoreSurface = @import("../../../Surface.zig"); const gtk_version = @import("../gtk_version.zig"); const adw_version = @import("../adw_version.zig"); +const ext = @import("../ext.zig"); const gresource = @import("../build/gresource.zig"); const Common = @import("../class.zig").Common; const Config = @import("config.zig").Config; @@ -55,29 +56,39 @@ pub const SplitTree = extern struct { }, ); }; + + pub const tree = struct { + pub const name = "tree"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface.Tree, + .{ + .nick = "Tree Model", + .blurb = "Underlying data model for the tree.", + .accessor = C.privateBoxedFieldAccessor("tree"), + }, + ); + }; }; const Private = struct { /// The tree datastructure containing all of our surface views. - tree: Surface.Tree, + tree: ?*Surface.Tree, pub var offset: c_int = 0; }; fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); - - // Start with an empty split tree. - const priv = self.private(); - priv.tree = .empty; } //--------------------------------------------------------------- // Properties pub fn getIsEmpty(self: *Self) bool { - const priv = self.private(); - return priv.tree.isEmpty(); + const tree: *const Surface.Tree = self.private().tree orelse &.empty; + return tree.isEmpty(); } //--------------------------------------------------------------- @@ -97,8 +108,10 @@ pub const SplitTree = extern struct { fn finalize(self: *Self) callconv(.c) void { const priv = self.private(); - priv.tree.deinit(); - priv.tree = .empty; + if (priv.tree) |tree| { + ext.boxedFree(Surface.Tree, tree); + priv.tree = null; + } gobject.Object.virtual_methods.finalize.call( Class.parent, @@ -109,6 +122,14 @@ pub const SplitTree = extern struct { //--------------------------------------------------------------- // Signal handlers + fn propTree( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + self.as(gobject.Object).notifyByPspec(properties.@"is-empty".impl.param_spec); + } + //--------------------------------------------------------------- // Class @@ -137,11 +158,13 @@ pub const SplitTree = extern struct { // Properties gobject.ext.registerProperties(class, &.{ properties.@"is-empty".impl, + properties.tree.impl, }); // Bindings // Template Callbacks + class.bindTemplateCallback("notify_tree", &propTree); // Signals diff --git a/src/apprt/gtk-ng/ui/1.5/split-tree.blp b/src/apprt/gtk-ng/ui/1.5/split-tree.blp index 0eebff7a6..66053fd3d 100644 --- a/src/apprt/gtk-ng/ui/1.5/split-tree.blp +++ b/src/apprt/gtk-ng/ui/1.5/split-tree.blp @@ -2,15 +2,28 @@ using Gtk 4.0; using Adw 1; template $GhosttySplitTree: Adw.Bin { - // This could be a lot more visually pleasing but in practice this doesn't - // ever happen at the time of writing this comment. A surface-less split - // tree always closes its parent. - Label { - visible: bind template.is-empty; - // Purposely not localized currently because this shouldn't really - // ever appear. When we have a situation it does appear, we may want - // to change the styling and text so I don't want to burden localizers - // to handle this yet. - label: "No surfaces."; + notify::tree => $notify_tree(); + + Box { + orientation: vertical; + + Box surface_box { + visible: bind template.is-empty inverted; + orientation: vertical; + hexpand: true; + vexpand: true; + } + + // This could be a lot more visually pleasing but in practice this doesn't + // ever happen at the time of writing this comment. A surface-less split + // tree always closes its parent. + Label { + visible: bind template.is-empty; + // Purposely not localized currently because this shouldn't really + // ever appear. When we have a situation it does appear, we may want + // to change the styling and text so I don't want to burden localizers + // to handle this yet. + label: "No surfaces."; + } } } diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 759387073..23e9eae0c 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -1,5 +1,6 @@ const std = @import("std"); const assert = std.debug.assert; +const build_config = @import("../build_config.zig"); const ArenaAllocator = std.heap.ArenaAllocator; const Allocator = std.mem.Allocator; @@ -116,6 +117,25 @@ pub fn SplitTree(comptime V: type) type { self.* = undefined; } + /// Clone this tree, returning a new tree with the same nodes. + pub fn clone(self: *const Self, gpa: Allocator) Allocator.Error!Self { + // 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, + }; + } + /// Returns true if this is an empty tree. pub fn isEmpty(self: *const Self) bool { // An empty tree has no nodes. @@ -685,6 +705,51 @@ pub fn SplitTree(comptime V: type) type { 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, .@"gtk-ng" => @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 = .{ + // The @ptrCast below is to workaround this bug: + // https://github.com/ianprime0509/zig-gobject/issues/115 + .copy = @ptrCast(&struct { + fn copy(self: *Self) callconv(.c) *Self { + const ptr = @import("glib").ext.create(Self); + const alloc = self.arena.child_allocator; + ptr.* = self.clone(alloc) catch @panic("oom"); + return ptr; + } + }.copy), + .free = @ptrCast(&struct { + fn free(self: *Self) callconv(.c) void { + self.deinit(); + @import("glib").ext.destroy(self); + } + }.free), + }, + }, + ), + + .none => void, + }; }; } From a7865d79ea2e8df1a2fd4e3bba2839a61cee41c4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Aug 2025 10:53:18 -0700 Subject: [PATCH 08/12] apprt/gtk-ng: render a single artificial split --- src/apprt/gtk-ng/class/split_tree.zig | 112 ++++++++++++++++++++++++- src/apprt/gtk-ng/class/surface.zig | 1 + src/apprt/gtk-ng/class/tab.zig | 12 +++ src/apprt/gtk-ng/ui/1.5/split-tree.blp | 3 +- src/apprt/gtk-ng/ui/1.5/tab.blp | 2 +- 5 files changed, 126 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 38f3d3536..3cd03e810 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -66,16 +66,39 @@ pub const SplitTree = extern struct { .{ .nick = "Tree Model", .blurb = "Underlying data model for the tree.", - .accessor = C.privateBoxedFieldAccessor("tree"), + .accessor = .{ + .getter = getTreeValue, + .setter = setTreeValue, + }, }, ); }; }; + pub const signals = struct { + /// Emitted whenever the tree property is about to change. + /// + /// The new value is given as the signal parameter. The old value + /// can still be retrieved from the tree property. + pub const @"tree-will-change" = struct { + pub const name = "tree-change"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{?*const Surface.Tree}, + void, + ); + }; + }; + const Private = struct { /// The tree datastructure containing all of our surface views. tree: ?*Surface.Tree, + // Template bindings + tree_bin: *adw.Bin, + pub var offset: c_int = 0; }; @@ -91,6 +114,52 @@ pub const SplitTree = extern struct { return tree.isEmpty(); } + /// Get the tree data model that we're showing in this widget. This + /// does not clone the tree. + pub fn getTree(self: *Self) ?*Surface.Tree { + return self.private().tree; + } + + /// Set the tree data model that we're showing in this widget. This + /// will clone the given tree. + pub fn setTree(self: *Self, tree: ?*const Surface.Tree) void { + const priv = self.private(); + + // Emit the signal so that handlers can witness both the before and + // after values of the tree. + signals.@"tree-will-change".impl.emit( + self, + null, + .{tree}, + null, + ); + + if (priv.tree) |old_tree| { + ext.boxedFree(Surface.Tree, old_tree); + priv.tree = null; + } + + if (tree) |new_tree| { + priv.tree = ext.boxedCopy(Surface.Tree, new_tree); + } + + self.as(gobject.Object).notifyByPspec(properties.tree.impl.param_spec); + } + + fn getTreeValue(self: *Self, value: *gobject.Value) void { + gobject.ext.Value.set( + value, + self.private().tree, + ); + } + + fn setTreeValue(self: *Self, value: *const gobject.Value) void { + self.setTree(gobject.ext.Value.get( + value, + ?*Surface.Tree, + )); + } + //--------------------------------------------------------------- // Virtual methods @@ -127,9 +196,48 @@ pub const SplitTree = extern struct { _: *gobject.ParamSpec, _: ?*anyopaque, ) callconv(.c) void { + const priv = self.private(); + const tree: *const Surface.Tree = self.private().tree orelse &.empty; + + // Reset our widget tree. + priv.tree_bin.setChild(null); + if (!tree.isEmpty()) { + priv.tree_bin.setChild(buildTree(tree, 0)); + } + + // Dependent properties self.as(gobject.Object).notifyByPspec(properties.@"is-empty".impl.param_spec); } + /// Builds the widget tree associated with a surface split tree. + /// + /// The final returned widget is expected to be a floating reference, + /// ready to be attached to a parent widget. + fn buildTree( + tree: *const Surface.Tree, + current: Surface.Tree.Node.Handle, + ) *gtk.Widget { + switch (tree.nodes[current]) { + .leaf => |v| { + // We have to setup our signal handlers. + return v.as(gtk.Widget); + }, + + .split => |s| return gobject.ext.newInstance( + gtk.Paned, + .{ + .orientation = @as(gtk.Orientation, switch (s.layout) { + .horizontal => .horizontal, + .vertical => .vertical, + }), + .@"start-child" = buildTree(tree, s.left), + .@"end-child" = buildTree(tree, s.right), + // TODO: position/ratio + }, + ).as(gtk.Widget), + } + } + //--------------------------------------------------------------- // Class @@ -162,11 +270,13 @@ pub const SplitTree = extern struct { }); // Bindings + class.bindTemplateChildPrivate("tree_bin", .{}); // Template Callbacks class.bindTemplateCallback("notify_tree", &propTree); // Signals + signals.@"tree-will-change".impl.register(.{}); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index f26783747..35b4eaf88 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -2302,6 +2302,7 @@ pub const Surface = extern struct { const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; + pub const refSink = C.refSink; pub const unref = C.unref; const private = C.private; diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index b343ba248..0619b2de8 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -118,6 +118,7 @@ pub const Tab = extern struct { surface_bindings: *gobject.BindingGroup, // Template bindings + split_tree: *SplitTree, surface: *Surface, pub var offset: c_int = 0; @@ -161,6 +162,16 @@ pub const Tab = extern struct { // We need to do this so that the title initializes properly, // I think because its a dynamic getter. self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); + + // Setup our initial split tree. + // TODO: Probably make this a property + const surface: *Surface = .new(); + defer surface.unref(); + _ = surface.refSink(); + const alloc = Application.default().allocator(); + var tree = Surface.Tree.init(alloc, surface) catch unreachable; + defer tree.deinit(); + priv.split_tree.setTree(&tree); } //--------------------------------------------------------------- @@ -271,6 +282,7 @@ pub const Tab = extern struct { }); // Bindings + class.bindTemplateChildPrivate("split_tree", .{}); class.bindTemplateChildPrivate("surface", .{}); // Template Callbacks diff --git a/src/apprt/gtk-ng/ui/1.5/split-tree.blp b/src/apprt/gtk-ng/ui/1.5/split-tree.blp index 66053fd3d..2ce4b3f10 100644 --- a/src/apprt/gtk-ng/ui/1.5/split-tree.blp +++ b/src/apprt/gtk-ng/ui/1.5/split-tree.blp @@ -7,9 +7,8 @@ template $GhosttySplitTree: Adw.Bin { Box { orientation: vertical; - Box surface_box { + Adw.Bin tree_bin { visible: bind template.is-empty inverted; - orientation: vertical; hexpand: true; vexpand: true; } diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk-ng/ui/1.5/tab.blp index d1c7737f6..fc20962e4 100644 --- a/src/apprt/gtk-ng/ui/1.5/tab.blp +++ b/src/apprt/gtk-ng/ui/1.5/tab.blp @@ -14,5 +14,5 @@ template $GhosttyTab: Box { close-request => $surface_close_request(); } - $GhosttySplitTree {} + $GhosttySplitTree split_tree {} } From 3b4c33afe08024870dcf521532451b0cadb5d321 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Aug 2025 12:41:57 -0700 Subject: [PATCH 09/12] apprt/gtk-ng: connect surface signals --- src/apprt/gtk-ng/class/split_tree.zig | 2 +- src/apprt/gtk-ng/class/tab.zig | 42 +++++ src/apprt/gtk-ng/class/window.zig | 213 ++++++++++++++++---------- src/apprt/gtk-ng/ui/1.5/tab.blp | 4 +- 4 files changed, 181 insertions(+), 80 deletions(-) diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 3cd03e810..7e3f7d92b 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -81,7 +81,7 @@ pub const SplitTree = extern struct { /// The new value is given as the signal parameter. The old value /// can still be retrieved from the tree property. pub const @"tree-will-change" = struct { - pub const name = "tree-change"; + pub const name = "tree-will-change"; pub const connect = impl.connect; const impl = gobject.ext.defineSignal( name, diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index 0619b2de8..b8711873f 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -74,6 +74,26 @@ pub const Tab = extern struct { ); }; + pub const @"surface-tree" = struct { + pub const name = "surface-tree"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface.Tree, + .{ + .nick = "Surface Tree", + .blurb = "The surface tree that is contained in this tab.", + .accessor = gobject.ext.typedAccessor( + Self, + ?*Surface.Tree, + .{ + .getter = getSurfaceTree, + }, + ), + }, + ); + }; + pub const title = struct { pub const name = "title"; pub const get = impl.get; @@ -184,6 +204,18 @@ pub const Tab = extern struct { return priv.surface; } + /// Get the surface tree of this tab. + pub fn getSurfaceTree(self: *Self) ?*Surface.Tree { + const priv = self.private(); + return priv.split_tree.getTree(); + } + + /// Get the split tree widget that is in this tab. + pub fn getSplitTree(self: *Self) *SplitTree { + const priv = self.private(); + return priv.split_tree; + } + /// Returns true if this tab needs confirmation before quitting based /// on the various Ghostty configurations. pub fn getNeedsConfirmQuit(self: *Self) bool { @@ -251,6 +283,14 @@ pub const Tab = extern struct { } } + fn propSplitTree( + _: *SplitTree, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + self.as(gobject.Object).notifyByPspec(properties.@"surface-tree".impl.param_spec); + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -278,6 +318,7 @@ pub const Tab = extern struct { gobject.ext.registerProperties(class, &.{ properties.@"active-surface".impl, properties.config.impl, + properties.@"surface-tree".impl, properties.title.impl, }); @@ -287,6 +328,7 @@ pub const Tab = extern struct { // Template Callbacks class.bindTemplateCallback("surface_close_request", &surfaceCloseRequest); + class.bindTemplateCallback("notify_tree", &propSplitTree); // Signals signals.@"close-request".impl.register(.{}); diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index 6881ee052..3d7143953 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -22,6 +22,7 @@ const Common = @import("../class.zig").Common; const Config = @import("config.zig").Config; const Application = @import("application.zig").Application; const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; +const SplitTree = @import("split_tree.zig").SplitTree; const Surface = @import("surface.zig").Surface; const Tab = @import("tab.zig").Tab; const DebugWarning = @import("debug_warning.zig").DebugWarning; @@ -408,6 +409,24 @@ pub const Window = extern struct { .{ .sync_create = true }, ); + // Bind signals + const split_tree = tab.getSplitTree(); + _ = SplitTree.signals.@"tree-will-change".connect( + split_tree, + *Self, + tabSplitTreeWillChange, + self, + .{}, + ); + + // Run an initial notification for the surface tree so we can setup + // initial state. + tabSplitTreeWillChange( + split_tree, + split_tree.getTree(), + self, + ); + return page; } @@ -637,6 +656,102 @@ pub const Window = extern struct { self.private().toast_overlay.addToast(toast); } + fn connectSurfaceHandlers( + self: *Self, + tree: *const Surface.Tree, + ) void { + const priv = self.private(); + var it = tree.iterator(); + while (it.next()) |entry| { + const surface = entry.view; + _ = Surface.signals.@"close-request".connect( + surface, + *Self, + surfaceCloseRequest, + self, + .{}, + ); + _ = Surface.signals.@"present-request".connect( + surface, + *Self, + surfacePresentRequest, + self, + .{}, + ); + _ = Surface.signals.@"clipboard-write".connect( + surface, + *Self, + surfaceClipboardWrite, + self, + .{}, + ); + _ = Surface.signals.menu.connect( + surface, + *Self, + surfaceMenu, + self, + .{}, + ); + _ = Surface.signals.@"toggle-fullscreen".connect( + surface, + *Self, + surfaceToggleFullscreen, + self, + .{}, + ); + _ = Surface.signals.@"toggle-maximize".connect( + surface, + *Self, + surfaceToggleMaximize, + self, + .{}, + ); + _ = Surface.signals.@"toggle-command-palette".connect( + surface, + *Self, + surfaceToggleCommandPalette, + self, + .{}, + ); + + // If we've never had a surface initialize yet, then we register + // this signal. Its theoretically possible to launch multiple surfaces + // before init so we could register this on multiple and that is not + // a problem because we'll check the flag again in each handler. + if (!priv.surface_init) { + _ = Surface.signals.init.connect( + surface, + *Self, + surfaceInit, + self, + .{}, + ); + } + } + } + + /// Disconnect all the surface handlers for the given tree. This should + /// be called whenever a tree is no longer present in the window, e.g. + /// when a tab is detached or the tree changes. + fn disconnectSurfaceHandlers( + self: *Self, + tree: *const Surface.Tree, + ) void { + var it = tree.iterator(); + while (it.next()) |entry| { + const surface = entry.view; + _ = gobject.signalHandlersDisconnectMatched( + surface.as(gobject.Object), + .{ .data = true }, + 0, + 0, + null, + null, + self, + ); + } + } + //--------------------------------------------------------------- // Properties @@ -1134,8 +1249,6 @@ pub const Window = extern struct { _: c_int, self: *Self, ) callconv(.c) void { - const priv = self.private(); - // Get the attached page which must be a Tab object. const child = page.getChild(); const tab = gobject.ext.cast(Tab, child) orelse return; @@ -1168,71 +1281,8 @@ pub const Window = extern struct { // behavior is consistent with macOS and the previous GTK apprt, // but that behavior was all implicit and not documented, so here // I am. - // - // TODO: When we have a split tree we'll want to attach to that. - const surface = tab.getActiveSurface(); - _ = Surface.signals.@"close-request".connect( - surface, - *Self, - surfaceCloseRequest, - self, - .{}, - ); - _ = Surface.signals.@"present-request".connect( - surface, - *Self, - surfacePresentRequest, - self, - .{}, - ); - _ = Surface.signals.@"clipboard-write".connect( - surface, - *Self, - surfaceClipboardWrite, - self, - .{}, - ); - _ = Surface.signals.menu.connect( - surface, - *Self, - surfaceMenu, - self, - .{}, - ); - _ = Surface.signals.@"toggle-fullscreen".connect( - surface, - *Self, - surfaceToggleFullscreen, - self, - .{}, - ); - _ = Surface.signals.@"toggle-maximize".connect( - surface, - *Self, - surfaceToggleMaximize, - self, - .{}, - ); - _ = Surface.signals.@"toggle-command-palette".connect( - surface, - *Self, - surfaceToggleCommandPalette, - self, - .{}, - ); - - // If we've never had a surface initialize yet, then we register - // this signal. Its theoretically possible to launch multiple surfaces - // before init so we could register this on multiple and that is not - // a problem because we'll check the flag again in each handler. - if (!priv.surface_init) { - _ = Surface.signals.init.connect( - surface, - *Self, - surfaceInit, - self, - .{}, - ); + if (tab.getSurfaceTree()) |tree| { + self.connectSurfaceHandlers(tree); } } @@ -1255,17 +1305,10 @@ pub const Window = extern struct { self, ); - // Remove all the signals that have this window as the userdata. - const surface = tab.getActiveSurface(); - _ = gobject.signalHandlersDisconnectMatched( - surface.as(gobject.Object), - .{ .data = true }, - 0, - 0, - null, - null, - self, - ); + // Remove the tree handlers + if (tab.getSurfaceTree()) |tree| { + self.disconnectSurfaceHandlers(tree); + } } fn tabViewCreateWindow( @@ -1464,6 +1507,20 @@ pub const Window = extern struct { } } + fn tabSplitTreeWillChange( + split_tree: *SplitTree, + new_tree: ?*const Surface.Tree, + self: *Self, + ) callconv(.c) void { + if (split_tree.getTree()) |old_tree| { + self.disconnectSurfaceHandlers(old_tree); + } + + if (new_tree) |tree| { + self.connectSurfaceHandlers(tree); + } + } + fn actionAbout( _: *gio.SimpleAction, _: ?*glib.Variant, diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk-ng/ui/1.5/tab.blp index fc20962e4..8e6aee6cf 100644 --- a/src/apprt/gtk-ng/ui/1.5/tab.blp +++ b/src/apprt/gtk-ng/ui/1.5/tab.blp @@ -14,5 +14,7 @@ template $GhosttyTab: Box { close-request => $surface_close_request(); } - $GhosttySplitTree split_tree {} + $GhosttySplitTree split_tree { + notify::tree => $notify_tree(); + } } From bc731c0ff68170480e167c7ef6c36f62c1532f37 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Aug 2025 13:48:50 -0700 Subject: [PATCH 10/12] apprt/gtk-ng: hook up Tab signals to surface --- src/apprt/gtk-ng/class/surface.zig | 7 ++ src/apprt/gtk-ng/class/tab.zig | 110 ++++++++++++++++++++++++----- src/apprt/gtk-ng/ui/1.5/tab.blp | 7 +- 3 files changed, 102 insertions(+), 22 deletions(-) diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 35b4eaf88..383c3b084 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -1318,6 +1318,11 @@ pub const Surface = extern struct { return self.private().pwd; } + /// Returns the focus state of this surface. + pub fn getFocused(self: *Self) bool { + return self.private().focused; + } + /// Change the configuration for this surface. pub fn setConfig(self: *Self, config: *Config) void { const priv = self.private(); @@ -1654,6 +1659,7 @@ pub const Surface = extern struct { priv.focused = true; priv.im_context.as(gtk.IMContext).focusIn(); _ = glib.idleAddOnce(idleFocus, self.ref()); + self.as(gobject.Object).notifyByPspec(properties.focused.impl.param_spec); } fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { @@ -1661,6 +1667,7 @@ pub const Surface = extern struct { priv.focused = false; priv.im_context.as(gtk.IMContext).focusOut(); _ = glib.idleAddOnce(idleFocus, self.ref()); + self.as(gobject.Object).notifyByPspec(properties.focused.impl.param_spec); } /// The focus callback must be triggered on an idle loop source because diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index b8711873f..034dd25f6 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -139,7 +139,6 @@ pub const Tab = extern struct { // Template bindings split_tree: *SplitTree, - surface: *Surface, pub var offset: c_int = 0; }; @@ -147,12 +146,10 @@ pub const Tab = extern struct { /// Set the parent of this tab page. This only affects the first surface /// ever created for a tab. If a surface was already created this does /// nothing. - pub fn setParent( - self: *Self, - parent: *CoreSurface, - ) void { - const priv = self.private(); - priv.surface.setParent(parent); + pub fn setParent(self: *Self, parent: *CoreSurface) void { + if (self.getActiveSurface()) |surface| { + surface.setParent(parent); + } } fn init(self: *Self, _: *Class) callconv(.c) void { @@ -175,10 +172,6 @@ pub const Tab = extern struct { .{}, ); - // TODO: Eventually this should be set dynamically based on the - // current active surface. - priv.surface_bindings.setSource(priv.surface.as(gobject.Object)); - // We need to do this so that the title initializes properly, // I think because its a dynamic getter. self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); @@ -194,14 +187,62 @@ pub const Tab = extern struct { priv.split_tree.setTree(&tree); } + fn connectSurfaceHandlers( + self: *Self, + tree: *const Surface.Tree, + ) void { + var it = tree.iterator(); + while (it.next()) |entry| { + const surface = entry.view; + _ = Surface.signals.@"close-request".connect( + surface, + *Self, + surfaceCloseRequest, + self, + .{}, + ); + _ = gobject.Object.signals.notify.connect( + surface, + *Self, + propSurfaceFocused, + self, + .{ .detail = "focused" }, + ); + } + } + + fn disconnectSurfaceHandlers( + self: *Self, + tree: *const Surface.Tree, + ) void { + var it = tree.iterator(); + while (it.next()) |entry| { + const surface = entry.view; + _ = gobject.signalHandlersDisconnectMatched( + surface.as(gobject.Object), + .{ .data = true }, + 0, + 0, + null, + null, + self, + ); + } + } + //--------------------------------------------------------------- // Properties /// Get the currently active surface. See the "active-surface" property. /// This does not ref the value. - pub fn getActiveSurface(self: *Self) *Surface { - const priv = self.private(); - return priv.surface; + pub fn getActiveSurface(self: *Self) ?*Surface { + const tree = self.getSurfaceTree() orelse return null; + var it = tree.iterator(); + while (it.next()) |entry| { + if (entry.view.getFocused()) return entry.view; + } + + return null; } /// Get the surface tree of this tab. @@ -219,7 +260,7 @@ pub const Tab = extern struct { /// Returns true if this tab needs confirmation before quitting based /// on the various Ghostty configurations. pub fn getNeedsConfirmQuit(self: *Self) bool { - const surface = self.getActiveSurface(); + const surface = self.getActiveSurface() orelse return false; const core_surface = surface.core() orelse return false; return core_surface.needsConfirmQuit(); } @@ -283,6 +324,20 @@ pub const Tab = extern struct { } } + fn splitTreeWillChange( + split_tree: *SplitTree, + new_tree: ?*const Surface.Tree, + self: *Self, + ) callconv(.c) void { + if (split_tree.getTree()) |old_tree| { + self.disconnectSurfaceHandlers(old_tree); + } + + if (new_tree) |tree| { + self.connectSurfaceHandlers(tree); + } + } + fn propSplitTree( _: *SplitTree, _: *gobject.ParamSpec, @@ -291,6 +346,27 @@ pub const Tab = extern struct { self.as(gobject.Object).notifyByPspec(properties.@"surface-tree".impl.param_spec); } + fn propActiveSurface( + _: *Self, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + priv.surface_bindings.setSource(null); + if (self.getActiveSurface()) |surface| { + priv.surface_bindings.setSource(surface.as(gobject.Object)); + } + } + + fn propSurfaceFocused( + surface: *Surface, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + if (!surface.getFocused()) return; + self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -324,10 +400,10 @@ pub const Tab = extern struct { // Bindings class.bindTemplateChildPrivate("split_tree", .{}); - class.bindTemplateChildPrivate("surface", .{}); // Template Callbacks - class.bindTemplateCallback("surface_close_request", &surfaceCloseRequest); + class.bindTemplateCallback("tree_will_change", &splitTreeWillChange); + class.bindTemplateCallback("notify_active_surface", &propActiveSurface); class.bindTemplateCallback("notify_tree", &propSplitTree); // Signals diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk-ng/ui/1.5/tab.blp index 8e6aee6cf..f0d7f5f68 100644 --- a/src/apprt/gtk-ng/ui/1.5/tab.blp +++ b/src/apprt/gtk-ng/ui/1.5/tab.blp @@ -5,16 +5,13 @@ template $GhosttyTab: Box { "tab", ] + notify::active-surface => $notify_active_surface(); orientation: vertical; hexpand: true; vexpand: true; - // A tab currently just contains a surface directly. When we introduce - // splits we probably want to replace this with the split widget type. - $GhosttySurface surface { - close-request => $surface_close_request(); - } $GhosttySplitTree split_tree { notify::tree => $notify_tree(); + tree-will-change => $tree_will_change(); } } From 4a4577cf8a61bd7aa2e4fe89d61fd0b86a781dae Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Aug 2025 13:57:14 -0700 Subject: [PATCH 11/12] apprt/gtk-ng: address some TODOs --- src/apprt/gtk-ng/class/tab.zig | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index 034dd25f6..c60b01bfa 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -172,19 +172,23 @@ pub const Tab = extern struct { .{}, ); - // We need to do this so that the title initializes properly, - // I think because its a dynamic getter. - self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); - - // Setup our initial split tree. - // TODO: Probably make this a property + // A tab always starts with a single surface. const surface: *Surface = .new(); defer surface.unref(); _ = surface.refSink(); const alloc = Application.default().allocator(); - var tree = Surface.Tree.init(alloc, surface) catch unreachable; - defer tree.deinit(); - priv.split_tree.setTree(&tree); + if (Surface.Tree.init(alloc, surface)) |tree| { + priv.split_tree.setTree(&tree); + + // Hacky because we need a non-const result. + var mut = tree; + mut.deinit(); + } else |_| { + // TODO: We should make our "no surfaces" state more aesthetically + // pleasing and show something like an "Oops, something went wrong" + // message. For now, this is incredibly unlikely. + @panic("oom"); + } } fn connectSurfaceHandlers( From 326e55c8f8bd5bdf3d5883ecf19479668c65966a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 7 Aug 2025 08:35:18 -0700 Subject: [PATCH 12/12] apprt/gtk-ng: PR feedback --- src/apprt/gtk-ng/class/split_tree.zig | 38 ++++++++++++-------------- src/apprt/gtk-ng/class/tab.zig | 11 ++++---- src/apprt/gtk-ng/class/window.zig | 16 ++++++----- src/apprt/gtk-ng/ui/1.5/split-tree.blp | 37 ++++++++++++------------- src/apprt/gtk-ng/ui/1.5/tab.blp | 2 +- src/datastruct/split_tree.zig | 10 +++---- 6 files changed, 55 insertions(+), 59 deletions(-) diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 7e3f7d92b..750ba670e 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -26,7 +26,7 @@ const log = std.log.scoped(.gtk_ghostty_split_tree); pub const SplitTree = extern struct { const Self = @This(); parent_instance: Parent, - pub const Parent = adw.Bin; + pub const Parent = gtk.Box; pub const getGObjectType = gobject.ext.defineClass(Self, .{ .name = "GhosttySplitTree", .instanceInit = &init, @@ -36,21 +36,21 @@ pub const SplitTree = extern struct { }); pub const properties = struct { - pub const @"is-empty" = struct { - pub const name = "is-empty"; + pub const @"has-surfaces" = struct { + pub const name = "has-surfaces"; const impl = gobject.ext.defineProperty( name, Self, bool, .{ - .nick = "Tree Is Empty", - .blurb = "True when the tree has no surfaces.", + .nick = "Has Surfaces", + .blurb = "Tree has surfaces.", .default = false, .accessor = gobject.ext.typedAccessor( Self, bool, .{ - .getter = getIsEmpty, + .getter = getHasSurfaces, }, ), }, @@ -76,17 +76,15 @@ pub const SplitTree = extern struct { }; pub const signals = struct { - /// Emitted whenever the tree property is about to change. - /// - /// The new value is given as the signal parameter. The old value - /// can still be retrieved from the tree property. - pub const @"tree-will-change" = struct { - pub const name = "tree-will-change"; + /// Emitted whenever the tree property has changed, with access + /// to the previous and new values. + pub const changed = struct { + pub const name = "changed"; pub const connect = impl.connect; const impl = gobject.ext.defineSignal( name, Self, - &.{?*const Surface.Tree}, + &.{ ?*const Surface.Tree, ?*const Surface.Tree }, void, ); }; @@ -109,9 +107,9 @@ pub const SplitTree = extern struct { //--------------------------------------------------------------- // Properties - pub fn getIsEmpty(self: *Self) bool { + pub fn getHasSurfaces(self: *Self) bool { const tree: *const Surface.Tree = self.private().tree orelse &.empty; - return tree.isEmpty(); + return !tree.isEmpty(); } /// Get the tree data model that we're showing in this widget. This @@ -127,10 +125,10 @@ pub const SplitTree = extern struct { // Emit the signal so that handlers can witness both the before and // after values of the tree. - signals.@"tree-will-change".impl.emit( + signals.changed.impl.emit( self, null, - .{tree}, + .{ priv.tree, tree }, null, ); @@ -206,7 +204,7 @@ pub const SplitTree = extern struct { } // Dependent properties - self.as(gobject.Object).notifyByPspec(properties.@"is-empty".impl.param_spec); + self.as(gobject.Object).notifyByPspec(properties.@"has-surfaces".impl.param_spec); } /// Builds the widget tree associated with a surface split tree. @@ -265,7 +263,7 @@ pub const SplitTree = extern struct { // Properties gobject.ext.registerProperties(class, &.{ - properties.@"is-empty".impl, + properties.@"has-surfaces".impl, properties.tree.impl, }); @@ -276,7 +274,7 @@ pub const SplitTree = extern struct { class.bindTemplateCallback("notify_tree", &propTree); // Signals - signals.@"tree-will-change".impl.register(.{}); + signals.changed.impl.register(.{}); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index c60b01bfa..5de4839ec 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -328,13 +328,14 @@ pub const Tab = extern struct { } } - fn splitTreeWillChange( - split_tree: *SplitTree, + fn splitTreeChanged( + _: *SplitTree, + old_tree: ?*const Surface.Tree, new_tree: ?*const Surface.Tree, self: *Self, ) callconv(.c) void { - if (split_tree.getTree()) |old_tree| { - self.disconnectSurfaceHandlers(old_tree); + if (old_tree) |tree| { + self.disconnectSurfaceHandlers(tree); } if (new_tree) |tree| { @@ -406,7 +407,7 @@ pub const Tab = extern struct { class.bindTemplateChildPrivate("split_tree", .{}); // Template Callbacks - class.bindTemplateCallback("tree_will_change", &splitTreeWillChange); + class.bindTemplateCallback("tree_changed", &splitTreeChanged); class.bindTemplateCallback("notify_active_surface", &propActiveSurface); class.bindTemplateCallback("notify_tree", &propSplitTree); diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index 3d7143953..bffa43bb1 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -411,18 +411,19 @@ pub const Window = extern struct { // Bind signals const split_tree = tab.getSplitTree(); - _ = SplitTree.signals.@"tree-will-change".connect( + _ = SplitTree.signals.changed.connect( split_tree, *Self, - tabSplitTreeWillChange, + tabSplitTreeChanged, self, .{}, ); // Run an initial notification for the surface tree so we can setup // initial state. - tabSplitTreeWillChange( + tabSplitTreeChanged( split_tree, + null, split_tree.getTree(), self, ); @@ -1507,13 +1508,14 @@ pub const Window = extern struct { } } - fn tabSplitTreeWillChange( - split_tree: *SplitTree, + fn tabSplitTreeChanged( + _: *SplitTree, + old_tree: ?*const Surface.Tree, new_tree: ?*const Surface.Tree, self: *Self, ) callconv(.c) void { - if (split_tree.getTree()) |old_tree| { - self.disconnectSurfaceHandlers(old_tree); + if (old_tree) |tree| { + self.disconnectSurfaceHandlers(tree); } if (new_tree) |tree| { diff --git a/src/apprt/gtk-ng/ui/1.5/split-tree.blp b/src/apprt/gtk-ng/ui/1.5/split-tree.blp index 2ce4b3f10..e8c53b607 100644 --- a/src/apprt/gtk-ng/ui/1.5/split-tree.blp +++ b/src/apprt/gtk-ng/ui/1.5/split-tree.blp @@ -1,28 +1,25 @@ using Gtk 4.0; using Adw 1; -template $GhosttySplitTree: Adw.Bin { +template $GhosttySplitTree: Box { notify::tree => $notify_tree(); + orientation: vertical; - Box { - orientation: vertical; + Adw.Bin tree_bin { + visible: bind template.has-surfaces; + hexpand: true; + vexpand: true; + } - Adw.Bin tree_bin { - visible: bind template.is-empty inverted; - hexpand: true; - vexpand: true; - } - - // This could be a lot more visually pleasing but in practice this doesn't - // ever happen at the time of writing this comment. A surface-less split - // tree always closes its parent. - Label { - visible: bind template.is-empty; - // Purposely not localized currently because this shouldn't really - // ever appear. When we have a situation it does appear, we may want - // to change the styling and text so I don't want to burden localizers - // to handle this yet. - label: "No surfaces."; - } + // This could be a lot more visually pleasing but in practice this doesn't + // ever happen at the time of writing this comment. A surface-less split + // tree always closes its parent. + Label { + visible: bind template.has-surfaces inverted; + // Purposely not localized currently because this shouldn't really + // ever appear. When we have a situation it does appear, we may want + // to change the styling and text so I don't want to burden localizers + // to handle this yet. + label: "No surfaces."; } } diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk-ng/ui/1.5/tab.blp index f0d7f5f68..61f106ce1 100644 --- a/src/apprt/gtk-ng/ui/1.5/tab.blp +++ b/src/apprt/gtk-ng/ui/1.5/tab.blp @@ -12,6 +12,6 @@ template $GhosttyTab: Box { $GhosttySplitTree split_tree { notify::tree => $notify_tree(); - tree-will-change => $tree_will_change(); + changed => $tree_changed(); } } diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 23e9eae0c..68a7c09e7 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -728,22 +728,20 @@ pub fn SplitTree(comptime V: type) type { }, .funcs = .{ - // The @ptrCast below is to workaround this bug: - // https://github.com/ianprime0509/zig-gobject/issues/115 - .copy = @ptrCast(&struct { + .copy = &struct { fn copy(self: *Self) callconv(.c) *Self { const ptr = @import("glib").ext.create(Self); const alloc = self.arena.child_allocator; ptr.* = self.clone(alloc) catch @panic("oom"); return ptr; } - }.copy), - .free = @ptrCast(&struct { + }.copy, + .free = &struct { fn free(self: *Self) callconv(.c) void { self.deinit(); @import("glib").ext.destroy(self); } - }.free), + }.free, }, }, ),