aboutsummaryrefslogtreecommitdiffstats
path: root/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig
diff options
context:
space:
mode:
authorsl0p <sl0p@sl0p.foo>2026-08-13 22:00:03 +0200
committersl0p <sl0p@sl0p.foo>2026-08-13 22:00:03 +0200
commit2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8 (patch)
tree7cd6274303ba1da1f8bd57371aec336dd687fb58 /vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig
downloadslosh-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.zig1516
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;
+ };
+};