diff options
| author | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:00:03 +0200 |
|---|---|---|
| committer | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:00:03 +0200 |
| commit | 2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8 (patch) | |
| tree | 7cd6274303ba1da1f8bd57371aec336dd687fb58 /vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig | |
| download | slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.tar.gz slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.tar.xz slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.zip | |
sl0ptty: M0 — libghostty-vt vendored, one pane, compositor, diff renderer
Design contract in DESIGN.md. libghostty-vt vendored fresh from ghostty
main@bedb6f2 (zig 0.16), built to a 14MB static lib.
M0: pty spawn (no libutil), a lib-vt terminal per pane, a screen cell buffer,
a diff emitter that repaints only what changed, raw mode, SIGWINCH, and a
~120Hz frame cap that coalesces pty reads.
Also the seed of the headless harness: --headless dumps the composited screen
without a tty, so composition is testable in milliseconds.
tests/live_m0.py covers what headless cannot: raw mode, the diff emitter,
resize propagation, and the quit path. 6/6 green.
Diffstat (limited to 'vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig | 1516 |
1 files changed, 1516 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig b/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig new file mode 100644 index 0000000..ca74371 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig @@ -0,0 +1,1516 @@ +const std = @import("std"); +const assert = @import("../../../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const adw = @import("adw"); +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const configpkg = @import("../../../config.zig"); +const apprt = @import("../../../apprt.zig"); +const ext = @import("../ext.zig"); +const gresource = @import("../build/gresource.zig"); +const Common = @import("../class.zig").Common; +const WeakRef = @import("../weak_ref.zig").WeakRef; +const Application = @import("application.zig").Application; +const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; +const Surface = @import("surface.zig").Surface; +const SurfaceScrolledWindow = @import("surface_scrolled_window.zig").SurfaceScrolledWindow; + +const log = std.log.scoped(.gtk_ghostty_split_tree); + +pub const SplitTree = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = gtk.Box; + 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 { + /// The active surface is the surface that should be receiving all + /// surface-targeted actions. This is usually the focused surface, + /// but may also not be focused if the user has selected a non-surface + /// widget. + pub const @"active-surface" = struct { + pub const name = "active-surface"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface, + .{ + .accessor = gobject.ext.typedAccessor( + Self, + ?*Surface, + .{ + .getter = getActiveSurface, + }, + ), + }, + ); + }; + + pub const @"has-surfaces" = struct { + pub const name = "has-surfaces"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ + .getter = getHasSurfaces, + }, + ), + }, + ); + }; + + pub const @"is-zoomed" = struct { + pub const name = "is-zoomed"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ + .getter = getIsZoomed, + }, + ), + }, + ); + }; + + pub const tree = struct { + pub const name = "tree"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface.Tree, + .{ + .accessor = .{ + .getter = getTreeValue, + .setter = setTreeValue, + }, + }, + ); + }; + + pub const @"is-split" = struct { + pub const name = "is-split"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ + .getter = getIsSplit, + }, + ), + }, + ); + }; + }; + + pub const signals = struct { + /// 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 }, + void, + ); + }; + }; + + const Private = struct { + /// The tree datastructure containing all of our surface views. + tree: ?*Surface.Tree, + + // Template bindings + tree_bin: *adw.Bin, + + /// Last focused surface in the tree. We need this to handle various + /// tree change states. + last_focused: WeakRef(Surface) = .empty, + + /// The source that we use to rebuild the tree. This is also + /// used to debounce updates. + rebuild_source: ?c_uint = null, + + /// The source that we use to restore focus. With enough nested + /// splits, some surfaces might initially be allocated a width or + /// height of 0 which causes them to get unmapped and lose focus. + /// We can reliably restore focus to the last focused surface only + /// once it is mapped again. + restore_focus_source: ?c_uint = null, + + /// Used to store state about a pending surface close for the + /// close dialog. + pending_close: ?Surface.Tree.Node.Handle, + + pub var offset: c_int = 0; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + // Initialize our actions + self.initActionMap(); + + // Initialize some basic state + const priv = self.private(); + priv.pending_close = null; + } + + fn initActionMap(self: *Self) void { + const s_variant_type = glib.ext.VariantType.newFor([:0]const u8); + defer s_variant_type.free(); + + const actions = [_]ext.actions.Action(Self){ + // All of these will eventually take a target surface parameter. + // For now all our targets originate from the focused surface. + .init("new-split", actionNewSplit, s_variant_type), + .init("equalize", actionEqualize, null), + .init("zoom", actionZoom, null), + .init("close-split", actionCloseSplit, null), + }; + + _ = ext.actions.addAsGroup(Self, self, "split-tree", &actions); + } + + /// Create a new split in the given direction from the currently + /// active surface. + /// + /// If the tree is empty this will create a new tree with a new surface + /// and ignore the direction. + /// + /// The parent will be used as the parent of the surface regardless of + /// if that parent is in this split tree or not. This allows inheriting + /// surface properties from anywhere. + pub fn newSplit( + self: *Self, + direction: Surface.Tree.Split.Direction, + parent_: ?*Surface, + overrides: struct { + command: ?configpkg.Command = null, + shell_integration: ?configpkg.Config.ShellIntegration = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }, + ) Allocator.Error!void { + const alloc = Application.default().allocator(); + + // Create our new surface. + const surface: *Surface = .new(.{ + .command = overrides.command, + .shell_integration = overrides.shell_integration, + .working_directory = overrides.working_directory, + .title = overrides.title, + }); + defer surface.unref(); + _ = surface.refSink(); + + // Inherit properly if we were asked to. + if (parent_) |p| { + if (p.core()) |core| { + surface.setParent(core, .split); + } + } + + // Bind is-split property for new surface + surface.bindIsSplit(self); + + // Create our tree + var single_tree = try Surface.Tree.init(alloc, surface); + defer single_tree.deinit(); + + // We want to move our focus to the new surface no matter what. + // But we need to be careful to restore state if we fail. + const old_last_focused = self.private().last_focused.get(); + defer if (old_last_focused) |v| v.unref(); // unref strong ref from get + self.private().last_focused.set(surface); + errdefer self.private().last_focused.set(old_last_focused); + + // If we have no tree yet, then this becomes our tree and we're done. + const old_tree = self.getTree() orelse { + self.setTree(&single_tree); + return; + }; + + // The handle we create the split relative to. Today this is the active + // surface but this might be the handle of the given parent if we want. + const handle = self.getActiveSurfaceHandle() orelse .root; + + // Create our split! + var new_tree = try old_tree.split( + alloc, + handle, + direction, + 0.5, // Always split equally for new splits + &single_tree, + ); + defer new_tree.deinit(); + log.debug( + "new split at={} direction={} old_tree={f} new_tree={f}", + .{ handle, direction, old_tree, &new_tree }, + ); + + // Replace our tree + self.setTree(&new_tree); + } + + pub fn resize( + self: *Self, + direction: Surface.Tree.Split.Direction, + amount: u16, + ) Allocator.Error!bool { + // Avoid useless work + if (amount == 0) return false; + + const old_tree = self.getTree() orelse return false; + const active = self.getActiveSurfaceHandle() orelse return false; + + // Get all our dimensions we're going to need to turn our + // amount into a percentage. + const priv = self.private(); + const width = priv.tree_bin.as(gtk.Widget).getWidth(); + const height = priv.tree_bin.as(gtk.Widget).getHeight(); + if (width == 0 or height == 0) return false; + const width_f64: f64 = @floatFromInt(width); + const height_f64: f64 = @floatFromInt(height); + const amount_f64: f64 = @floatFromInt(amount); + + // Get our ratio and use positive/neg for directions. + const ratio: f64 = switch (direction) { + .right => amount_f64 / width_f64, + .left => -(amount_f64 / width_f64), + .down => amount_f64 / height_f64, + .up => -(amount_f64 / height_f64), + }; + + const layout: Surface.Tree.Split.Layout = switch (direction) { + .left, .right => .horizontal, + .up, .down => .vertical, + }; + + var new_tree = try old_tree.resize( + Application.default().allocator(), + active, + layout, + @floatCast(ratio), + ); + defer new_tree.deinit(); + self.setTree(&new_tree); + return true; + } + + /// Move focus from the currently focused surface to the given + /// direction. Returns true if focus switched to a new surface. + pub fn goto(self: *Self, to: Surface.Tree.Goto) bool { + const tree = self.getTree() orelse return false; + const active = self.getActiveSurfaceHandle() orelse return false; + const target = if (tree.goto( + Application.default().allocator(), + active, + to, + )) |handle_| + handle_ orelse return false + else |err| switch (err) { + // Nothing we can do in this scenario. This is highly unlikely + // since split trees don't use that much memory. The application + // is probably about to crash in other ways. + error.OutOfMemory => return false, + }; + + // If we aren't changing targets then we did nothing. + if (active == target) return false; + + // Get the surface at the target location and grab focus. + const surface = tree.nodes[target.idx()].leaf; + surface.grabFocus(); + + // We also need to setup our last_focused to this because if we + // trigger a tree change like below, the grab focus above never + // actually triggers in time to set this and this ensures we + // grab focus to the right thing. + const old_last_focused = self.private().last_focused.get(); + defer if (old_last_focused) |v| v.unref(); // unref strong ref from get + self.private().last_focused.set(surface); + errdefer self.private().last_focused.set(old_last_focused); + + if (tree.zoomed != null) { + const app = Application.default(); + const config_obj = app.getConfig(); + defer config_obj.unref(); + const config = config_obj.get(); + + if (!config.@"split-preserve-zoom".navigation) { + tree.zoomed = null; + } else { + tree.zoom(target); + } + + // When the zoom state changes our tree state changes and + // we need to send the proper notifications to trigger + // relayout. + const object = self.as(gobject.Object); + object.notifyByPspec(properties.tree.impl.param_spec); + object.notifyByPspec(properties.@"is-zoomed".impl.param_spec); + } + + return true; + } + + /// Move the source split onto the target split in a given direction. + /// The target split must be located within this tree. + pub fn moveSplit( + self: *Self, + source: *Surface, + target: *Surface, + dir: Surface.Tree.Split.Direction, + ) Allocator.Error!void { + const alloc = Application.default().allocator(); + const target_tree = self.getTree() orelse return; + + // This really shouldn't fail, but just in case + const target_handle = target_tree.locate(target) orelse { + log.warn("target is not placed in a split tree", .{}); + return; + }; + + // Try to find the source within the current tree. + // If it exists, then it's a local move; otherwise the logic gets more + // complicated + const source_handle = target_tree.locate(source); + + // First add the source into this tree. + // We have to do this no matter what + var branch = try Surface.Tree.init(alloc, source); + defer branch.deinit(); + + var after_split = try target_tree.split( + alloc, + target_handle, + dir, + 0.5, + &branch, + ); + defer after_split.deinit(); + + if (source_handle) |handle| { + // Happy path: source and target are located within the same tree. + // In that case, we just remove the existing source from this tree + + var after_remove = try after_split.remove(alloc, handle); + defer after_remove.deinit(); + + self.setTree(&after_remove); + } else { + // :( Cross-tree moves are a bit more complicated. + + // TODO: Find a better way to access the split tree from here + const source_tree_widget = ext.getAncestor( + SplitTree, + source.as(gtk.Widget), + ) orelse { + log.warn("source is not placed in a split tree", .{}); + return; + }; + const source_tree = source_tree_widget.getTree() orelse return; + + // Remove the source from its own tree + const handle = source_tree.locate(source) orelse return; + var new_source_tree = try source_tree.remove(alloc, handle); + defer new_source_tree.deinit(); + + // Finally, set the final tree structures for both tree widgets + source_tree_widget.setTree(&new_source_tree); + self.setTree(&after_split); + + // Re-bind vital properties like `is-split` + source.bindIsSplit(self); + } + } + + fn disconnectSurfaceHandlers(self: *Self) void { + const tree = self.getTree() orelse return; + 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, + ); + } + } + + fn connectSurfaceHandlers(self: *Self) void { + const tree = self.getTree() orelse return; + 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" }, + ); + _ = gobject.Object.signals.notify.connect( + surface, + *Self, + propSurfaceMapped, + self, + .{ .detail = "mapped" }, + ); + } + } + + //--------------------------------------------------------------- + // Properties + + /// Returns true if this split tree needs confirmation before quitting based + /// on the various Ghostty configurations. + pub fn getNeedsConfirmQuit(self: *Self) bool { + const tree = self.getTree() orelse return false; + var it = tree.iterator(); + while (it.next()) |entry| { + if (entry.view.core()) |core| { + if (core.needsConfirmQuit()) { + return true; + } + } + } + + return false; + } + + /// Get the currently active surface. See the "active-surface" property. + /// This does not ref the value. + pub fn getActiveSurface(self: *Self) ?*Surface { + const tree = self.getTree() orelse return null; + const handle = self.getActiveSurfaceHandle() orelse return null; + return tree.nodes[handle.idx()].leaf; + } + + fn getActiveSurfaceHandle(self: *Self) ?Surface.Tree.Node.Handle { + const tree = self.getTree() orelse return null; + var it = tree.iterator(); + while (it.next()) |entry| { + if (entry.view.getFocused()) return entry.handle; + } + + // If none are currently focused, the most previously focused + // surface (if it exists) is our active surface. This lets things + // like apprt actions and bell ringing continue to work in the + // background. + if (self.private().last_focused.get()) |v| { + defer v.unref(); + + // We need to find the handle of the last focused surface. + it = tree.iterator(); + while (it.next()) |entry| { + if (entry.view == v) return entry.handle; + } + } + + return null; + } + + /// Returns the last focused surface in the tree. + pub fn getLastFocusedSurface(self: *Self) ?*Surface { + const surface = self.private().last_focused.get() orelse return null; + // We unref because get() refs the surface. We don't use the weakref + // in a multi-threaded context so this is safe. + surface.unref(); + return surface; + } + + pub fn getHasSurfaces(self: *Self) bool { + const tree: *const Surface.Tree = self.private().tree orelse &.empty; + return !tree.isEmpty(); + } + + pub fn getIsZoomed(self: *Self) bool { + const tree: *const Surface.Tree = self.private().tree orelse &.empty; + return tree.zoomed != null; + } + + /// 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(); + + // We always normalize our tree parameter so that empty trees + // become null so that we don't have to deal with callers being + // confused about that. + const tree: ?*const Surface.Tree = tree: { + const tree = tree_ orelse break :tree null; + if (tree.isEmpty()) break :tree null; + break :tree tree; + }; + + // Emit the signal so that handlers can witness both the before and + // after values of the tree. + signals.changed.impl.emit( + self, + null, + .{ priv.tree, tree }, + null, + ); + + if (priv.tree) |old_tree| { + self.disconnectSurfaceHandlers(); + ext.boxedFree(Surface.Tree, old_tree); + priv.tree = null; + } + + if (tree) |new_tree| { + assert(priv.tree == null); + assert(!new_tree.isEmpty()); + priv.tree = ext.boxedCopy(Surface.Tree, new_tree); + self.connectSurfaceHandlers(); + } + + 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, + )); + } + + pub fn getIsSplit(self: *Self) bool { + const tree: *const Surface.Tree = self.private().tree orelse &.empty; + if (tree.isEmpty()) return false; + + const root_handle: Surface.Tree.Node.Handle = .root; + const root = tree.nodes[root_handle.idx()]; + return switch (root) { + .leaf => false, + .split => true, + }; + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + priv.last_focused.deinit(); + if (priv.rebuild_source) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove rebuild source", .{}); + } + priv.rebuild_source = null; + } + if (priv.restore_focus_source) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove restore_focus source", .{}); + } + priv.restore_focus_source = null; + } + + 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(); + if (priv.tree) |tree| { + ext.boxedFree(Surface.Tree, tree); + priv.tree = null; + } + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + //--------------------------------------------------------------- + // Signal handlers + + pub fn actionNewSplit( + _: *gio.SimpleAction, + args_: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const args = args_ orelse { + log.warn("split-tree.new-split called without a parameter", .{}); + return; + }; + + var dir: ?[*:0]const u8 = null; + args.get("&s", &dir); + + const direction = std.meta.stringToEnum( + Surface.Tree.Split.Direction, + std.mem.span(dir) orelse return, + ) orelse { + // Need to be defensive here since actions can be triggered externally. + log.warn("invalid split direction for split-tree.new-split: {s}", .{dir.?}); + return; + }; + + self.newSplit( + direction, + self.getActiveSurface(), + .none, + ) catch |err| { + log.warn("new split failed error={}", .{err}); + }; + } + + pub fn actionEqualize( + _: *gio.SimpleAction, + parameter_: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + _ = parameter_; + + const old_tree = self.getTree() orelse return; + var new_tree = old_tree.equalize(Application.default().allocator()) catch |err| { + log.warn("unable to equalize tree: {}", .{err}); + return; + }; + defer new_tree.deinit(); + self.setTree(&new_tree); + } + + pub fn actionZoom( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const tree = self.getTree() orelse return; + if (tree.zoomed != null) { + tree.zoomed = null; + } else { + const active = self.getActiveSurfaceHandle() orelse return; + if (tree.zoomed == active) return; + tree.zoom(active); + } + + self.as(gobject.Object).notifyByPspec(properties.tree.impl.param_spec); + } + + pub fn actionCloseSplit( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const surface = self.getActiveSurface() orelse return; + surface.close(); + } + + fn surfaceCloseRequest( + surface: *Surface, + self: *Self, + ) callconv(.c) void { + const core = surface.core() orelse return; + + // Reset our pending close state + const priv = self.private(); + priv.pending_close = null; + + // Find the surface in the tree to verify this is valid and + // set our pending close handle. + const tree = self.getTree() orelse return; + priv.pending_close = tree.locate(surface) orelse return; + + // If we don't need to confirm then just close immediately. + if (!core.needsConfirmQuit()) { + closeConfirmationClose( + null, + self, + ); + return; + } + + // Show a confirmation dialog + const dialog: *CloseConfirmationDialog = .new(.surface); + _ = CloseConfirmationDialog.signals.@"close-request".connect( + dialog, + *Self, + closeConfirmationClose, + self, + .{}, + ); + dialog.present(self.as(gtk.Widget)); + } + + fn closeConfirmationClose( + _: ?*CloseConfirmationDialog, + self: *Self, + ) callconv(.c) void { + // Get the handle we're closing + const priv = self.private(); + const handle = priv.pending_close orelse return; + priv.pending_close = null; + + // Figure out our next focus target. The next focus target is + // always the "previous" surface unless we're the leftmost then + // its the next. + const old_tree = self.getTree() orelse return; + const next_focus: ?*Surface = next_focus: { + const alloc = Application.default().allocator(); + const next_handle: Surface.Tree.Node.Handle = + (old_tree.goto(alloc, handle, .previous) catch null) orelse + (old_tree.goto(alloc, handle, .next) catch null) orelse + break :next_focus null; + if (next_handle == handle) break :next_focus null; + + // Note: we don't need to ref this or anything because its + // guaranteed to remain in the new tree since its not part + // of the handle we're removing. + break :next_focus old_tree.nodes[next_handle.idx()].leaf; + }; + + // Remove it from the tree. + var new_tree = old_tree.remove( + Application.default().allocator(), + handle, + ) catch |err| { + log.warn("unable to remove surface from tree: {}", .{err}); + return; + }; + defer new_tree.deinit(); + self.setTree(&new_tree); + + // Grab focus. We have to set this on the "last focused" because our + // focus will be set when the tree is redrawn. + if (next_focus) |v| priv.last_focused.set(v); + } + + fn propSurfaceFocused( + surface: *Surface, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + // We never CLEAR our last_focused because the property is specifically + // the last focused surface. We let the weakref clear itself when + // the surface is destroyed. + if (!surface.getFocused()) return; + self.private().last_focused.set(surface); + + // Our active surface probably changed + self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); + } + + fn propSurfaceMapped( + surface: *Surface, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + if (!surface.getMapped()) return; + + // We could add the idle callback only if this is actually the last + // focused surface. But we can avoid that check because usually all + // the surfaces get mapped at once, so the idle callback will run + // only once anyway. + const priv = self.private(); + if (priv.restore_focus_source == null) priv.restore_focus_source = glib.idleAdd( + onRestoreFocus, + self, + ); + } + + fn propTree( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + + // No matter what we notify + self.as(gobject.Object).freezeNotify(); + defer self.as(gobject.Object).thawNotify(); + self.as(gobject.Object).notifyByPspec(properties.@"has-surfaces".impl.param_spec); + self.as(gobject.Object).notifyByPspec(properties.@"is-zoomed".impl.param_spec); + + // If we were planning a rebuild or focus restore, always remove + // that so we can start from a clean slate. + if (priv.rebuild_source) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove rebuild source", .{}); + } + priv.rebuild_source = null; + } + if (priv.restore_focus_source) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove restore_focus source", .{}); + } + priv.restore_focus_source = null; + } + + // If we transitioned to an empty tree, clear immediately instead of + // waiting for an idle callback. Delaying teardown can keep the last + // surface alive during shutdown if the main loop exits first. + if (priv.tree == null) { + priv.tree_bin.setChild(null); + return; + } + + // Build on an idle callback so rapid tree changes are debounced. + // We keep the existing tree attached until the rebuild runs, + // which avoids transient empty frames. + assert(priv.rebuild_source == null); + priv.rebuild_source = glib.idleAdd( + onRebuild, + self, + ); + } + + fn onRebuild(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + + // Always mark our rebuild source as null since we're done. + const priv = self.private(); + priv.rebuild_source = null; + + // Rebuild our tree + const tree: *const Surface.Tree = self.private().tree orelse &.empty; + if (tree.isEmpty()) { + priv.tree_bin.setChild(null); + } else { + const built = self.buildTree( + tree, + tree.zoomed orelse .root, + ); + defer built.deinit(); + priv.tree_bin.setChild(built.widget); + } + + // Replacing our tree widget hierarchy can reset focus state. + // If we have a last-focused surface, restore focus to it. + if (priv.last_focused.get()) |v| { + defer v.unref(); + v.grabFocus(); + } + + // Our split status may have changed + self.as(gobject.Object).notifyByPspec(properties.@"is-split".impl.param_spec); + + // Our active surface may have changed + self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); + + return 0; + } + + fn onRestoreFocus(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + + // Always mark our source as null since we're done. + const priv = self.private(); + priv.restore_focus_source = null; + + // If we have a last-focused surface and it is mapped, restore focus + // to it. Depending on the available size, the surface might already + // have focus because it never got unmapped. In that case grabbing + // focus will have no effect. + if (priv.last_focused.get()) |v| { + defer v.unref(); + if (v.getMapped()) { + v.grabFocus(); + } + } + return 0; + } + + /// Builds the widget tree associated with a surface split tree. + /// + /// Returned widgets are expected to be attached to a parent by the caller. + /// + /// If `release_ref` is true then `widget` has an extra temporary + /// reference that must be released once it is parented in the rebuilt + /// tree. + const BuildTreeResult = struct { + widget: *gtk.Widget, + release_ref: bool, + + pub fn initNew(widget: *gtk.Widget) BuildTreeResult { + return .{ .widget = widget, .release_ref = false }; + } + + pub fn initReused(widget: *gtk.Widget) BuildTreeResult { + // We add a temporary ref to the widget to ensure it doesn't + // get destroyed while we're rebuilding the tree and detaching + // it from its old parent. The caller is expected to release + // this ref once the widget is attached to its new parent. + _ = widget.as(gobject.Object).ref(); + + // Detach after we ref it so that this doesn't mark the + // widget for destruction. + detachWidget(widget); + + return .{ .widget = widget, .release_ref = true }; + } + + pub fn deinit(self: BuildTreeResult) void { + // If we have to release a ref, do it. + if (self.release_ref) self.widget.as(gobject.Object).unref(); + } + }; + + fn buildTree( + self: *Self, + tree: *const Surface.Tree, + current: Surface.Tree.Node.Handle, + ) BuildTreeResult { + return switch (tree.nodes[current.idx()]) { + .leaf => |v| leaf: { + const window = ext.getAncestor( + SurfaceScrolledWindow, + v.as(gtk.Widget), + ) orelse { + // The surface isn't in a window already so we don't + // have to worry about reuse. + break :leaf .initNew(gobject.ext.newInstance( + SurfaceScrolledWindow, + .{ .surface = v }, + ).as(gtk.Widget)); + }; + + // Keep this widget alive while we detach it from the + // old tree and adopt it into the new one. + break :leaf .initReused(window.as(gtk.Widget)); + }, + .split => |s| split: { + const left = self.buildTree(tree, s.left); + defer left.deinit(); + const right = self.buildTree(tree, s.right); + defer right.deinit(); + + break :split .initNew(SplitTreeSplit.new( + current, + &s, + left.widget, + right.widget, + ).as(gtk.Widget)); + }, + }; + } + + /// Detach a split widget from its current parent. + /// + /// We intentionally use parent-specific child APIs when possible + /// (`GtkPaned.setStartChild/setEndChild`, `AdwBin.setChild`) instead of + /// calling `gtk.Widget.unparent` directly. Container implementations track + /// child pointers/properties internally, and those setters are the path + /// that keeps container state and notifications in sync. + fn detachWidget(widget: *gtk.Widget) void { + const parent = widget.getParent() orelse return; + + // Surface will be in a paned when it is split. + if (gobject.ext.cast(gtk.Paned, parent)) |paned| { + if (paned.getStartChild()) |child| { + if (child == widget) { + paned.setStartChild(null); + return; + } + } + + if (paned.getEndChild()) |child| { + if (child == widget) { + paned.setEndChild(null); + return; + } + } + } + + // Surface will be in a bin when it is not split. + if (gobject.ext.cast(adw.Bin, parent)) |bin| { + if (bin.getChild()) |child| { + if (child == widget) { + bin.setChild(null); + return; + } + } + } + + // Fallback for unexpected parents where we don't have a typed + // container API available. + widget.unparent(); + } + + //--------------------------------------------------------------- + // 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.@"active-surface".impl, + properties.@"has-surfaces".impl, + properties.@"is-zoomed".impl, + properties.tree.impl, + properties.@"is-split".impl, + }); + + // Bindings + class.bindTemplateChildPrivate("tree_bin", .{}); + + // Template Callbacks + class.bindTemplateCallback("notify_tree", &propTree); + + // Signals + signals.changed.impl.register(.{}); + + // 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; + }; +}; + +/// This is an internal-only widget that represents a split in the +/// split tree. This is a wrapper around gtk.Paned that allows us to handle +/// ratio (0 to 1) based positioning of the split, and also allows us to +/// write back the updated ratio to the split tree when the user manually +/// adjusts the split position. +/// +/// Since this is internal, it expects to be nested within a SplitTree and +/// will use `getAncestor` to find the SplitTree it belongs to. +/// +/// This is an _immutable_ widget. It isn't meant to be updated after +/// creation. As such, there are no properties or APIs to change the split, +/// access the paned, etc. +const SplitTreeSplit = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttySplitTreeSplit", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + const Private = struct { + /// The handle of the node in the tree that this split represents. + /// Assumed to be correct. + handle: Surface.Tree.Node.Handle, + + /// Cache the split ratio. Looking it up is relatively expensive + /// because we have to first walk the widget tree to find the + /// SplitTree widget. + ratio: f64, + + /// Source to handle repositioning the split when properties change. + idle: ?c_uint = null, + + /// Whether the max-position/position property of the gtk.Paned widget + /// changed. We use these to distinguish between a resize and the user + /// manually moving the split divider. See the "onIdle" function. + max_changed: bool = false, + pos_changed: bool = false, + + // Template bindings + paned: *gtk.Paned, + + pub var offset: c_int = 0; + }; + + /// Create a new split. + /// + /// The reason we don't use GObject properties here is because this is + /// an immutable widget and we don't want to deal with the overhead of + /// all the boilerplate for properties, signals, bindings, etc. + pub fn new( + handle: Surface.Tree.Node.Handle, + split: *const Surface.Tree.Split, + start_child: *gtk.Widget, + end_child: *gtk.Widget, + ) *Self { + const self = gobject.ext.newInstance(Self, .{}); + const priv = self.private(); + priv.handle = handle; + priv.ratio = split.ratio; + + // Setup our paned fields + const paned = priv.paned; + paned.setStartChild(start_child); + paned.setEndChild(end_child); + paned.as(gtk.Orientable).setOrientation(switch (split.layout) { + .horizontal => .horizontal, + .vertical => .vertical, + }); + + // Request min width/height 1 for surfaces to prevent them from + // becoming temporarily invisible in nested layouts. Otherwise + // gtk.Paned might mark a surface as not visible and unmap it + // for a single frame. See the comments below in propMaxPosition. + if (gobject.ext.isA(start_child, SurfaceScrolledWindow)) { + start_child.setSizeRequest(1, 1); + } + if (gobject.ext.isA(end_child, SurfaceScrolledWindow)) { + end_child.setSizeRequest(1, 1); + } + + // Signals and so on are setup in the template. + + return self; + } + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + + const SyncDirection = enum { + // Update gtk.Paned widget to match ratio from split tree node. + tree_to_widget, + // Update split tree node to match ratio from gtk.Paned widget. + widget_to_tree, + }; + + // Sync split ratio between the gtk.Paned widget and the split tree. + fn syncSplitRatio(self: *Self, direction: SyncDirection) void { + const priv = self.private(); + const paned = priv.paned; + + // Current, min, and max positions as pixels. + const pos = paned.getPosition(); + const min = min: { + var val = gobject.ext.Value.new(c_int); + defer val.unset(); + gobject.Object.getProperty( + paned.as(gobject.Object), + "min-position", + &val, + ); + break :min gobject.ext.Value.get(&val, c_int); + }; + const max = max: { + var val = gobject.ext.Value.new(c_int); + defer val.unset(); + gobject.Object.getProperty( + paned.as(gobject.Object), + "max-position", + &val, + ); + break :max gobject.ext.Value.get(&val, c_int); + }; + + // We don't actually use min, but we don't expect this to ever + // be non-zero, so let's add an assert to ensure that. + assert(min == 0); + + // If our max is zero then we can't do any math. I don't know + // if this is possible but I suspect it can be if you make a nested + // split completely minimized. + if (max == 0) return; + + // Determine our current ratio. + const current_ratio: f64 = ratio: { + const pos_f64: f64 = @floatFromInt(pos); + const max_f64: f64 = @floatFromInt(max); + break :ratio pos_f64 / max_f64; + }; + const desired_ratio: f64 = priv.ratio; + + // If our ratio is close enough to our desired ratio, then + // we ignore the update. This is to avoid constant split updates + // for lossy floating point math. + if (std.math.approxEqAbs( + f64, + current_ratio, + desired_ratio, + 0.001, + )) { + return; + } + + switch (direction) { + .tree_to_widget => { + // Update position in gtk.Paned to match desired ratio. Note that if + // max-position is small, it might not be possible to accurately set + // the desired ratio. E.g. with max-position=2 you can only have + // ratios 0, 0.5 and 1. + const desired_pos: c_int = desired_pos: { + const max_f64: f64 = @floatFromInt(max); + break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio)); + }; + paned.setPosition(desired_pos); + }, + .widget_to_tree => { + // Update ratio of the split tree node. This is the most dangerous + // part of this widget. We assume that this widget is always a child + // of a SplitTree, we assume that our handle is valid, and we assume + // the handle is always a split node. + const split_tree = ext.getAncestor( + SplitTree, + self.as(gtk.Widget), + ) orelse { + log.warn("SplitTree ancestor not found", .{}); + return; + }; + const tree = split_tree.getTree() orelse { + log.warn("no tree data model set", .{}); + return; + }; + assert(priv.handle.idx() < tree.nodes.len); + assert(tree.nodes[priv.handle.idx()] == .split); + tree.resizeInPlace(priv.handle, @floatCast(current_ratio)); + priv.ratio = current_ratio; + }, + } + } + + // We need to keep the split ratios from the tree datastructure and + // widget tree in sync. Using the max-position and position properties + // of the gtk.Paned widget, we can distinguish a resize from a manual + // update (e.g. the user dragging the divider). If max-position changes, + // we always have a widget resize and we sync the ratio directly from + // the propMaxPosition function. Usually position will change as well + // but it might not if the size change is small enough. If only position + // changes, we have a manual human update and we sync the ratio here. + // + // This is a hack, it relies on the timing of property notifcations. + // From looking at the GTK source code, it should not be possible that + // we interpret a position change from a resize as a manual update. + // When a gtk.Paned is resized, internally the gtk_paned_calc_position + // function will change both max-position and position and synchronously + // call our propMaxPosition and propPosition functions. I.e. when the + // widget is resized, it should not be possible for onIdle to run before + // we have been notified of both property changes. + fn onIdle(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + const priv = self.private(); + + // Clear source and fields. + defer { + priv.idle = null; + priv.max_changed = false; + priv.pos_changed = false; + } + + if (priv.max_changed) { + // Nothing to do, syncSplitRatio was already called + // directly from propMaxPosition. + return 0; + } + if (!priv.pos_changed) { + return 0; + } + + // If only position changed, this is a manual human update and + // we need to write our update back to the tree. + self.syncSplitRatio(.widget_to_tree); + return 0; + } + + //--------------------------------------------------------------- + // Signal handlers + + fn propMaxPosition( + _: *gtk.Paned, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + priv.max_changed = true; + + // Widget got resized, update position to match desired ratio. + // + // Syncing the split ratio directly here ensures that every surface + // gets the correct size allocated immediately. This works because + // this callback runs during size allocation, so that we update the + // divider position right after the gtk.Paned widget computes it and + // right before it will use the position to compute the sizes of its + // children. See the size_allocate and calc_position functions of the + // gtk.Paned widget. + // + // If we synced the ratio in an idle callback, there might be some + // flickering as surfaces are shown with the wrong size for a few + // frames until our idle callback runs. Depending on the split layout + // it might take multiple rounds of size allocation and idle callbacks + // for every widget to get the correct size. + // + // Note that gtk.Paned calls Widget.setChildVisible(true/false) for + // both children based on the initial divider position it computes. + // If we then update the position here, we might sometimes have to + // call setChildVisible again. This would be necessary if we updated + // the position from initially 0 (max), where start (end) child is + // invisible, to a value > 0 (< max). However, we can skip this because + // set_position already queues another round of size allocation, during + // which gtk.Paned will keep the correct position we set manually and + // then call setChildVisible again based on that. The only problematic + // situation is when we update the position from 0 to a value > 0, + // because setChildVisible(false) will unmap the start child widget. + // It will not be visible for a single frame which introduces some + // flickering. To prevent this from happening we set a min size request + // of 1 for all surfaces (see the "new" function above). + self.syncSplitRatio(.tree_to_widget); + + // We still need the idle callback to clear the max_changed field + // later, because propPosition might or might not still get called. + if (priv.idle == null) priv.idle = glib.idleAdd( + onIdle, + self, + ); + } + + fn propPosition( + _: *gtk.Paned, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + priv.pos_changed = true; + if (priv.idle == null) priv.idle = glib.idleAdd( + onIdle, + self, + ); + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + if (priv.idle) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove idle source", .{}); + } + priv.idle = null; + } + + 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 { + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + 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 { + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "split-tree-split", + }), + ); + + // Bindings + class.bindTemplateChildPrivate("paned", .{}); + + // Template Callbacks + class.bindTemplateCallback("notify_max_position", &propMaxPosition); + class.bindTemplateCallback("notify_position", &propPosition); + + // 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; + }; +}; |
