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; }; };