diff --git a/src/apprt/gtk-ng/build/gresource.zig b/src/apprt/gtk-ng/build/gresource.zig index f5b91ce48..0f7237331 100644 --- a/src/apprt/gtk-ng/build/gresource.zig +++ b/src/apprt/gtk-ng/build/gresource.zig @@ -40,6 +40,7 @@ pub const blueprints: []const Blueprint = &.{ .{ .major = 1, .minor = 2, .name = "debug-warning" }, .{ .major = 1, .minor = 3, .name = "debug-warning" }, .{ .major = 1, .minor = 2, .name = "resize-overlay" }, + .{ .major = 1, .minor = 5, .name = "split-tree" }, .{ .major = 1, .minor = 2, .name = "surface" }, .{ .major = 1, .minor = 3, .name = "surface-child-exited" }, .{ .major = 1, .minor = 5, .name = "tab" }, diff --git a/src/apprt/gtk-ng/class.zig b/src/apprt/gtk-ng/class.zig index 170df1acb..a22b8771b 100644 --- a/src/apprt/gtk-ng/class.zig +++ b/src/apprt/gtk-ng/class.zig @@ -5,6 +5,7 @@ const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); +const ext = @import("ext.zig"); pub const Application = @import("class/application.zig").Application; pub const Window = @import("class/window.zig").Window; pub const Config = @import("class/config.zig").Config; @@ -79,7 +80,10 @@ pub fn Common( fn set(self: *Self, value: *const gobject.Value) void { const priv = private(self); if (@field(priv, name)) |v| { - glib.ext.destroy(v); + ext.boxedFree( + @typeInfo(@TypeOf(v)).pointer.child, + v, + ); } const T = @TypeOf(@field(priv, name)); diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig new file mode 100644 index 000000000..750ba670e --- /dev/null +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -0,0 +1,288 @@ +const std = @import("std"); +const build_config = @import("../../../build_config.zig"); +const assert = std.debug.assert; +const adw = @import("adw"); +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const i18n = @import("../../../os/main.zig").i18n; +const apprt = @import("../../../apprt.zig"); +const input = @import("../../../input.zig"); +const CoreSurface = @import("../../../Surface.zig"); +const gtk_version = @import("../gtk_version.zig"); +const adw_version = @import("../adw_version.zig"); +const ext = @import("../ext.zig"); +const gresource = @import("../build/gresource.zig"); +const Common = @import("../class.zig").Common; +const Config = @import("config.zig").Config; +const Application = @import("application.zig").Application; +const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; +const Surface = @import("surface.zig").Surface; + +const log = std.log.scoped(.gtk_ghostty_split_tree); + +pub const SplitTree = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = 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 { + pub const @"has-surfaces" = struct { + pub const name = "has-surfaces"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .nick = "Has Surfaces", + .blurb = "Tree has surfaces.", + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ + .getter = getHasSurfaces, + }, + ), + }, + ); + }; + + pub const tree = struct { + pub const name = "tree"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface.Tree, + .{ + .nick = "Tree Model", + .blurb = "Underlying data model for the tree.", + .accessor = .{ + .getter = getTreeValue, + .setter = setTreeValue, + }, + }, + ); + }; + }; + + 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, + + pub var offset: c_int = 0; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + + //--------------------------------------------------------------- + // Properties + + pub fn getHasSurfaces(self: *Self) bool { + const tree: *const Surface.Tree = self.private().tree orelse &.empty; + return !tree.isEmpty(); + } + + /// Get the tree data model that we're showing in this widget. This + /// does not clone the tree. + pub fn getTree(self: *Self) ?*Surface.Tree { + return self.private().tree; + } + + /// Set the tree data model that we're showing in this widget. This + /// will clone the given tree. + pub fn setTree(self: *Self, tree: ?*const Surface.Tree) void { + const priv = self.private(); + + // Emit the signal so that handlers can witness both the before and + // after values of the tree. + signals.changed.impl.emit( + self, + null, + .{ priv.tree, tree }, + null, + ); + + if (priv.tree) |old_tree| { + ext.boxedFree(Surface.Tree, old_tree); + priv.tree = null; + } + + if (tree) |new_tree| { + priv.tree = ext.boxedCopy(Surface.Tree, new_tree); + } + + self.as(gobject.Object).notifyByPspec(properties.tree.impl.param_spec); + } + + fn getTreeValue(self: *Self, value: *gobject.Value) void { + gobject.ext.Value.set( + value, + self.private().tree, + ); + } + + fn setTreeValue(self: *Self, value: *const gobject.Value) void { + self.setTree(gobject.ext.Value.get( + value, + ?*Surface.Tree, + )); + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.c) void { + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + fn finalize(self: *Self) callconv(.c) void { + const priv = self.private(); + 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 + + fn propTree( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + const tree: *const Surface.Tree = self.private().tree orelse &.empty; + + // Reset our widget tree. + priv.tree_bin.setChild(null); + if (!tree.isEmpty()) { + priv.tree_bin.setChild(buildTree(tree, 0)); + } + + // Dependent properties + self.as(gobject.Object).notifyByPspec(properties.@"has-surfaces".impl.param_spec); + } + + /// Builds the widget tree associated with a surface split tree. + /// + /// The final returned widget is expected to be a floating reference, + /// ready to be attached to a parent widget. + fn buildTree( + tree: *const Surface.Tree, + current: Surface.Tree.Node.Handle, + ) *gtk.Widget { + switch (tree.nodes[current]) { + .leaf => |v| { + // We have to setup our signal handlers. + return v.as(gtk.Widget); + }, + + .split => |s| return gobject.ext.newInstance( + gtk.Paned, + .{ + .orientation = @as(gtk.Orientation, switch (s.layout) { + .horizontal => .horizontal, + .vertical => .vertical, + }), + .@"start-child" = buildTree(tree, s.left), + .@"end-child" = buildTree(tree, s.right), + // TODO: position/ratio + }, + ).as(gtk.Widget), + } + } + + //--------------------------------------------------------------- + // Class + + 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.@"has-surfaces".impl, + properties.tree.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; + }; +}; diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 4251b56a8..383c3b084 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -9,6 +9,7 @@ const gobject = @import("gobject"); const gtk = @import("gtk"); const apprt = @import("../../../apprt.zig"); +const datastruct = @import("../../../datastruct/main.zig"); const font = @import("../../../font/main.zig"); const input = @import("../../../input.zig"); const internal_os = @import("../../../os/main.zig"); @@ -42,6 +43,9 @@ pub const Surface = extern struct { .private = .{ .Type = Private, .offset = &Private.offset }, }); + /// A SplitTree implementation that stores surfaces. + pub const Tree = datastruct.SplitTree(Self); + pub const properties = struct { pub const config = struct { pub const name = "config"; @@ -1314,6 +1318,11 @@ pub const Surface = extern struct { return self.private().pwd; } + /// Returns the focus state of this surface. + pub fn getFocused(self: *Self) bool { + return self.private().focused; + } + /// Change the configuration for this surface. pub fn setConfig(self: *Self, config: *Config) void { const priv = self.private(); @@ -1650,6 +1659,7 @@ pub const Surface = extern struct { priv.focused = true; priv.im_context.as(gtk.IMContext).focusIn(); _ = glib.idleAddOnce(idleFocus, self.ref()); + self.as(gobject.Object).notifyByPspec(properties.focused.impl.param_spec); } fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { @@ -1657,6 +1667,7 @@ pub const Surface = extern struct { priv.focused = false; priv.im_context.as(gtk.IMContext).focusOut(); _ = glib.idleAddOnce(idleFocus, self.ref()); + self.as(gobject.Object).notifyByPspec(properties.focused.impl.param_spec); } /// The focus callback must be triggered on an idle loop source because @@ -2298,6 +2309,7 @@ pub const Surface = extern struct { const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; + pub const refSink = C.refSink; pub const unref = C.unref; const private = C.private; diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index 3aa41c5ff..5de4839ec 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -18,6 +18,7 @@ const Common = @import("../class.zig").Common; const Config = @import("config.zig").Config; const Application = @import("application.zig").Application; const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; +const SplitTree = @import("split_tree.zig").SplitTree; const Surface = @import("surface.zig").Surface; const log = std.log.scoped(.gtk_ghostty_window); @@ -73,6 +74,26 @@ pub const Tab = extern struct { ); }; + pub const @"surface-tree" = struct { + pub const name = "surface-tree"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface.Tree, + .{ + .nick = "Surface Tree", + .blurb = "The surface tree that is contained in this tab.", + .accessor = gobject.ext.typedAccessor( + Self, + ?*Surface.Tree, + .{ + .getter = getSurfaceTree, + }, + ), + }, + ); + }; + pub const title = struct { pub const name = "title"; pub const get = impl.get; @@ -117,7 +138,7 @@ pub const Tab = extern struct { surface_bindings: *gobject.BindingGroup, // Template bindings - surface: *Surface, + split_tree: *SplitTree, pub var offset: c_int = 0; }; @@ -125,12 +146,10 @@ pub const Tab = extern struct { /// Set the parent of this tab page. This only affects the first surface /// ever created for a tab. If a surface was already created this does /// nothing. - pub fn setParent( - self: *Self, - parent: *CoreSurface, - ) void { - const priv = self.private(); - priv.surface.setParent(parent); + pub fn setParent(self: *Self, parent: *CoreSurface) void { + if (self.getActiveSurface()) |surface| { + surface.setParent(parent); + } } fn init(self: *Self, _: *Class) callconv(.c) void { @@ -153,13 +172,66 @@ pub const Tab = extern struct { .{}, ); - // TODO: Eventually this should be set dynamically based on the - // current active surface. - priv.surface_bindings.setSource(priv.surface.as(gobject.Object)); + // A tab always starts with a single surface. + const surface: *Surface = .new(); + defer surface.unref(); + _ = surface.refSink(); + const alloc = Application.default().allocator(); + if (Surface.Tree.init(alloc, surface)) |tree| { + priv.split_tree.setTree(&tree); - // We need to do this so that the title initializes properly, - // I think because its a dynamic getter. - self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); + // Hacky because we need a non-const result. + var mut = tree; + mut.deinit(); + } else |_| { + // TODO: We should make our "no surfaces" state more aesthetically + // pleasing and show something like an "Oops, something went wrong" + // message. For now, this is incredibly unlikely. + @panic("oom"); + } + } + + fn connectSurfaceHandlers( + self: *Self, + tree: *const Surface.Tree, + ) void { + var it = tree.iterator(); + while (it.next()) |entry| { + const surface = entry.view; + _ = Surface.signals.@"close-request".connect( + surface, + *Self, + surfaceCloseRequest, + self, + .{}, + ); + _ = gobject.Object.signals.notify.connect( + surface, + *Self, + propSurfaceFocused, + self, + .{ .detail = "focused" }, + ); + } + } + + fn disconnectSurfaceHandlers( + self: *Self, + tree: *const Surface.Tree, + ) void { + var it = tree.iterator(); + while (it.next()) |entry| { + const surface = entry.view; + _ = gobject.signalHandlersDisconnectMatched( + surface.as(gobject.Object), + .{ .data = true }, + 0, + 0, + null, + null, + self, + ); + } } //--------------------------------------------------------------- @@ -167,15 +239,32 @@ pub const Tab = extern struct { /// Get the currently active surface. See the "active-surface" property. /// This does not ref the value. - pub fn getActiveSurface(self: *Self) *Surface { + pub fn getActiveSurface(self: *Self) ?*Surface { + const tree = self.getSurfaceTree() orelse return null; + var it = tree.iterator(); + while (it.next()) |entry| { + if (entry.view.getFocused()) return entry.view; + } + + return null; + } + + /// Get the surface tree of this tab. + pub fn getSurfaceTree(self: *Self) ?*Surface.Tree { const priv = self.private(); - return priv.surface; + return priv.split_tree.getTree(); + } + + /// Get the split tree widget that is in this tab. + pub fn getSplitTree(self: *Self) *SplitTree { + const priv = self.private(); + return priv.split_tree; } /// Returns true if this tab needs confirmation before quitting based /// on the various Ghostty configurations. pub fn getNeedsConfirmQuit(self: *Self) bool { - const surface = self.getActiveSurface(); + const surface = self.getActiveSurface() orelse return false; const core_surface = surface.core() orelse return false; return core_surface.needsConfirmQuit(); } @@ -239,6 +328,50 @@ pub const Tab = extern struct { } } + fn splitTreeChanged( + _: *SplitTree, + old_tree: ?*const Surface.Tree, + new_tree: ?*const Surface.Tree, + self: *Self, + ) callconv(.c) void { + if (old_tree) |tree| { + self.disconnectSurfaceHandlers(tree); + } + + if (new_tree) |tree| { + self.connectSurfaceHandlers(tree); + } + } + + fn propSplitTree( + _: *SplitTree, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + self.as(gobject.Object).notifyByPspec(properties.@"surface-tree".impl.param_spec); + } + + fn propActiveSurface( + _: *Self, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + priv.surface_bindings.setSource(null); + if (self.getActiveSurface()) |surface| { + priv.surface_bindings.setSource(surface.as(gobject.Object)); + } + } + + fn propSurfaceFocused( + surface: *Surface, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + if (!surface.getFocused()) return; + self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -251,6 +384,7 @@ pub const Tab = extern struct { pub const Instance = Self; fn init(class: *Class) callconv(.c) void { + gobject.ext.ensureType(SplitTree); gobject.ext.ensureType(Surface); gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), @@ -265,14 +399,17 @@ pub const Tab = extern struct { gobject.ext.registerProperties(class, &.{ properties.@"active-surface".impl, properties.config.impl, + properties.@"surface-tree".impl, properties.title.impl, }); // Bindings - class.bindTemplateChildPrivate("surface", .{}); + class.bindTemplateChildPrivate("split_tree", .{}); // Template Callbacks - class.bindTemplateCallback("surface_close_request", &surfaceCloseRequest); + class.bindTemplateCallback("tree_changed", &splitTreeChanged); + class.bindTemplateCallback("notify_active_surface", &propActiveSurface); + class.bindTemplateCallback("notify_tree", &propSplitTree); // Signals signals.@"close-request".impl.register(.{}); diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index 6881ee052..bffa43bb1 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -22,6 +22,7 @@ const Common = @import("../class.zig").Common; const Config = @import("config.zig").Config; const Application = @import("application.zig").Application; const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; +const SplitTree = @import("split_tree.zig").SplitTree; const Surface = @import("surface.zig").Surface; const Tab = @import("tab.zig").Tab; const DebugWarning = @import("debug_warning.zig").DebugWarning; @@ -408,6 +409,25 @@ pub const Window = extern struct { .{ .sync_create = true }, ); + // Bind signals + const split_tree = tab.getSplitTree(); + _ = SplitTree.signals.changed.connect( + split_tree, + *Self, + tabSplitTreeChanged, + self, + .{}, + ); + + // Run an initial notification for the surface tree so we can setup + // initial state. + tabSplitTreeChanged( + split_tree, + null, + split_tree.getTree(), + self, + ); + return page; } @@ -637,6 +657,102 @@ pub const Window = extern struct { self.private().toast_overlay.addToast(toast); } + fn connectSurfaceHandlers( + self: *Self, + tree: *const Surface.Tree, + ) void { + const priv = self.private(); + var it = tree.iterator(); + while (it.next()) |entry| { + const surface = entry.view; + _ = Surface.signals.@"close-request".connect( + surface, + *Self, + surfaceCloseRequest, + self, + .{}, + ); + _ = Surface.signals.@"present-request".connect( + surface, + *Self, + surfacePresentRequest, + self, + .{}, + ); + _ = Surface.signals.@"clipboard-write".connect( + surface, + *Self, + surfaceClipboardWrite, + self, + .{}, + ); + _ = Surface.signals.menu.connect( + surface, + *Self, + surfaceMenu, + self, + .{}, + ); + _ = Surface.signals.@"toggle-fullscreen".connect( + surface, + *Self, + surfaceToggleFullscreen, + self, + .{}, + ); + _ = Surface.signals.@"toggle-maximize".connect( + surface, + *Self, + surfaceToggleMaximize, + self, + .{}, + ); + _ = Surface.signals.@"toggle-command-palette".connect( + surface, + *Self, + surfaceToggleCommandPalette, + self, + .{}, + ); + + // If we've never had a surface initialize yet, then we register + // this signal. Its theoretically possible to launch multiple surfaces + // before init so we could register this on multiple and that is not + // a problem because we'll check the flag again in each handler. + if (!priv.surface_init) { + _ = Surface.signals.init.connect( + surface, + *Self, + surfaceInit, + self, + .{}, + ); + } + } + } + + /// Disconnect all the surface handlers for the given tree. This should + /// be called whenever a tree is no longer present in the window, e.g. + /// when a tab is detached or the tree changes. + fn disconnectSurfaceHandlers( + self: *Self, + tree: *const Surface.Tree, + ) void { + var it = tree.iterator(); + while (it.next()) |entry| { + const surface = entry.view; + _ = gobject.signalHandlersDisconnectMatched( + surface.as(gobject.Object), + .{ .data = true }, + 0, + 0, + null, + null, + self, + ); + } + } + //--------------------------------------------------------------- // Properties @@ -1134,8 +1250,6 @@ pub const Window = extern struct { _: c_int, self: *Self, ) callconv(.c) void { - const priv = self.private(); - // Get the attached page which must be a Tab object. const child = page.getChild(); const tab = gobject.ext.cast(Tab, child) orelse return; @@ -1168,71 +1282,8 @@ pub const Window = extern struct { // behavior is consistent with macOS and the previous GTK apprt, // but that behavior was all implicit and not documented, so here // I am. - // - // TODO: When we have a split tree we'll want to attach to that. - const surface = tab.getActiveSurface(); - _ = Surface.signals.@"close-request".connect( - surface, - *Self, - surfaceCloseRequest, - self, - .{}, - ); - _ = Surface.signals.@"present-request".connect( - surface, - *Self, - surfacePresentRequest, - self, - .{}, - ); - _ = Surface.signals.@"clipboard-write".connect( - surface, - *Self, - surfaceClipboardWrite, - self, - .{}, - ); - _ = Surface.signals.menu.connect( - surface, - *Self, - surfaceMenu, - self, - .{}, - ); - _ = Surface.signals.@"toggle-fullscreen".connect( - surface, - *Self, - surfaceToggleFullscreen, - self, - .{}, - ); - _ = Surface.signals.@"toggle-maximize".connect( - surface, - *Self, - surfaceToggleMaximize, - self, - .{}, - ); - _ = Surface.signals.@"toggle-command-palette".connect( - surface, - *Self, - surfaceToggleCommandPalette, - self, - .{}, - ); - - // If we've never had a surface initialize yet, then we register - // this signal. Its theoretically possible to launch multiple surfaces - // before init so we could register this on multiple and that is not - // a problem because we'll check the flag again in each handler. - if (!priv.surface_init) { - _ = Surface.signals.init.connect( - surface, - *Self, - surfaceInit, - self, - .{}, - ); + if (tab.getSurfaceTree()) |tree| { + self.connectSurfaceHandlers(tree); } } @@ -1255,17 +1306,10 @@ pub const Window = extern struct { self, ); - // Remove all the signals that have this window as the userdata. - const surface = tab.getActiveSurface(); - _ = gobject.signalHandlersDisconnectMatched( - surface.as(gobject.Object), - .{ .data = true }, - 0, - 0, - null, - null, - self, - ); + // Remove the tree handlers + if (tab.getSurfaceTree()) |tree| { + self.disconnectSurfaceHandlers(tree); + } } fn tabViewCreateWindow( @@ -1464,6 +1508,21 @@ pub const Window = extern struct { } } + fn tabSplitTreeChanged( + _: *SplitTree, + old_tree: ?*const Surface.Tree, + new_tree: ?*const Surface.Tree, + self: *Self, + ) callconv(.c) void { + if (old_tree) |tree| { + self.disconnectSurfaceHandlers(tree); + } + + if (new_tree) |tree| { + self.connectSurfaceHandlers(tree); + } + } + fn actionAbout( _: *gio.SimpleAction, _: ?*glib.Variant, diff --git a/src/apprt/gtk-ng/ui/1.5/split-tree.blp b/src/apprt/gtk-ng/ui/1.5/split-tree.blp new file mode 100644 index 000000000..e8c53b607 --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.5/split-tree.blp @@ -0,0 +1,25 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttySplitTree: Box { + notify::tree => $notify_tree(); + orientation: vertical; + + Adw.Bin tree_bin { + visible: bind template.has-surfaces; + hexpand: true; + vexpand: true; + } + + // This could be a lot more visually pleasing but in practice this doesn't + // ever happen at the time of writing this comment. A surface-less split + // tree always closes its parent. + Label { + visible: bind template.has-surfaces inverted; + // Purposely not localized currently because this shouldn't really + // ever appear. When we have a situation it does appear, we may want + // to change the styling and text so I don't want to burden localizers + // to handle this yet. + label: "No surfaces."; + } +} diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk-ng/ui/1.5/tab.blp index 476244576..61f106ce1 100644 --- a/src/apprt/gtk-ng/ui/1.5/tab.blp +++ b/src/apprt/gtk-ng/ui/1.5/tab.blp @@ -5,11 +5,13 @@ template $GhosttyTab: Box { "tab", ] + notify::active-surface => $notify_active_surface(); + orientation: vertical; hexpand: true; vexpand: true; - // A tab currently just contains a surface directly. When we introduce - // splits we probably want to replace this with the split widget type. - $GhosttySurface surface { - close-request => $surface_close_request(); + + $GhosttySplitTree split_tree { + notify::tree => $notify_tree(); + changed => $tree_changed(); } } diff --git a/src/datastruct/main.zig b/src/datastruct/main.zig index 4f45f9483..14ee0e504 100644 --- a/src/datastruct/main.zig +++ b/src/datastruct/main.zig @@ -6,6 +6,7 @@ const cache_table = @import("cache_table.zig"); const circ_buf = @import("circ_buf.zig"); const intrusive_linked_list = @import("intrusive_linked_list.zig"); const segmented_pool = @import("segmented_pool.zig"); +const split_tree = @import("split_tree.zig"); pub const lru = @import("lru.zig"); pub const BlockingQueue = blocking_queue.BlockingQueue; @@ -13,6 +14,7 @@ pub const CacheTable = cache_table.CacheTable; pub const CircBuf = circ_buf.CircBuf; pub const IntrusiveDoublyLinkedList = intrusive_linked_list.DoublyLinkedList; pub const SegmentedPool = segmented_pool.SegmentedPool; +pub const SplitTree = split_tree.SplitTree; test { @import("std").testing.refAllDecls(@This()); diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig new file mode 100644 index 000000000..68a7c09e7 --- /dev/null +++ b/src/datastruct/split_tree.zig @@ -0,0 +1,985 @@ +const std = @import("std"); +const assert = std.debug.assert; +const build_config = @import("../build_config.zig"); +const ArenaAllocator = std.heap.ArenaAllocator; +const Allocator = std.mem.Allocator; + +/// SplitTree represents a tree of view types that can be divided. +/// +/// Concretely for Ghostty, it represents a tree of terminal views. In +/// its basic state, there are no splits and it is a single full-sized +/// terminal. However, it can be split arbitrarily many times among two +/// axes (horizontal and vertical) to create a tree of terminal views. +/// +/// This is an immutable tree structure, meaning all operations on it +/// will return a new tree with the operation applied. This allows us to +/// store versions of the tree in a history for easy undo/redo. To facilitate +/// this, the stored View type must implement reference counting; this is left +/// as an implementation detail of the View type. +/// +/// The View type will be stored as a pointer within the tree and must +/// implement a number of functions to work properly: +/// +/// - `fn ref(*View, Allocator) Allocator.Error!*View` - Increase a +/// reference count of the view. The Allocator will be the allocator provided +/// to the tree operation. This is allowed to copy the value if it wants to; +/// the returned value is expected to be a new reference (but that may +/// just be a copy). +/// +/// - `fn unref(*View, Allocator) void` - Decrease the reference count of a +/// view. The Allocator will be the allocator provided to the tree +/// operation. +/// +/// - `fn eql(*const View, *const View) bool` - Check if two views are equal. +/// +/// Optionally the following functions can also be implemented: +/// +/// - `fn splitTreeLabel(*const View) []const u8` - Return a label that is used +/// for the debug view. If this isn't specified then the node handle +/// will be used. +/// +/// Note: for both the ref and unref functions, the allocator is optional. +/// If the functions take less arguments, then the allocator will not be +/// passed. +pub fn SplitTree(comptime V: type) type { + return struct { + const Self = @This(); + + /// The view that this tree contains. + pub const View = V; + + /// The arena allocator used for all allocations in the tree. + /// Since the tree is an immutable structure, this lets us + /// cleanly free all memory when the tree is deinitialized. + arena: ArenaAllocator, + + /// All the nodes in the tree. Node at index 0 is always the root. + nodes: []const Node, + + /// An empty tree. + pub const empty: Self = .{ + // Arena can be undefined because we have zero allocated nodes. + // If our nodes are empty our deinit function doesn't touch the + // arena. + .arena = undefined, + .nodes = &.{}, + }; + + pub const Node = union(enum) { + leaf: *View, + split: Split, + + /// A handle into the nodes array. This lets us keep track of + /// nodes with 16-bit handles rather than full pointer-width + /// values. + pub const Handle = u16; + }; + + pub const Split = struct { + layout: Layout, + ratio: f16, + left: Node.Handle, + right: Node.Handle, + + pub const Layout = enum { horizontal, vertical }; + pub const Direction = enum { left, right, down, up }; + }; + + /// Initialize a new tree with a single view. + pub fn init(gpa: Allocator, view: *View) Allocator.Error!Self { + var arena = ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + const nodes = try alloc.alloc(Node, 1); + nodes[0] = .{ .leaf = try viewRef(view, gpa) }; + errdefer viewUnref(view, gpa); + + return .{ + .arena = arena, + .nodes = nodes, + }; + } + + pub fn deinit(self: *Self) void { + // Important: only free memory if we have memory to free, + // because we use an undefined arena for empty trees. + if (self.nodes.len > 0) { + // Unref all our views + const gpa: Allocator = self.arena.child_allocator; + for (self.nodes) |node| switch (node) { + .leaf => |view| viewUnref(view, gpa), + .split => {}, + }; + self.arena.deinit(); + } + + self.* = undefined; + } + + /// Clone this tree, returning a new tree with the same nodes. + pub fn clone(self: *const Self, gpa: Allocator) Allocator.Error!Self { + // Create a new arena allocator for the clone. + var arena = ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // Allocate a new nodes array and copy the existing nodes into it. + const nodes = try alloc.dupe(Node, self.nodes); + + // Increase the reference count of all the views in the nodes. + try refNodes(gpa, nodes); + + return .{ + .arena = arena, + .nodes = nodes, + }; + } + + /// Returns true if this is an empty tree. + pub fn isEmpty(self: *const Self) bool { + // An empty tree has no nodes. + return self.nodes.len == 0; + } + + /// An iterator over all the views in the tree. + pub fn iterator( + self: *const Self, + ) Iterator { + return .{ .nodes = self.nodes }; + } + + pub const Iterator = struct { + i: Node.Handle = 0, + nodes: []const Node, + + pub const Entry = struct { + handle: Node.Handle, + view: *View, + }; + + pub fn next(self: *Iterator) ?Entry { + // If we have no nodes, return null. + if (self.i >= self.nodes.len) return null; + + // Get the current node and increment the index. + const handle = self.i; + self.i += 1; + const node = self.nodes[handle]; + + return switch (node) { + .leaf => |v| .{ .handle = handle, .view = v }, + .split => self.next(), + }; + } + }; + + /// Insert another tree into this tree at the given node in the + /// specified direction. The other tree will be inserted in the + /// new direction. For example, if the direction is "right" then + /// `insert` is inserted right of the existing node. + /// + /// The allocator will be used for the newly created tree. + /// The previous trees will not be freed, but reference counts + /// for the views will be increased accordingly for the new tree. + pub fn split( + self: *const Self, + gpa: Allocator, + at: Node.Handle, + direction: Split.Direction, + insert: *const Self, + ) Allocator.Error!Self { + // The new arena for our new tree. + var arena = ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // We know we're going to need the sum total of the nodes + // between the two trees plus one for the new split node. + const nodes = try alloc.alloc(Node, self.nodes.len + insert.nodes.len + 1); + if (nodes.len > std.math.maxInt(Node.Handle)) return error.OutOfMemory; + + // We can copy our nodes exactly as they are, since they're + // mostly not changing (only `at` is changing). + @memcpy(nodes[0..self.nodes.len], self.nodes); + + // We can copy the destination nodes as well directly next to + // the source nodes. We just have to go through and offset + // all the handles in the destination tree to account for + // the shift. + const nodes_inserted = nodes[self.nodes.len..][0..insert.nodes.len]; + @memcpy(nodes_inserted, insert.nodes); + for (nodes_inserted) |*node| switch (node.*) { + .leaf => {}, + .split => |*s| { + // We need to offset the handles in the split + s.left += @intCast(self.nodes.len); + s.right += @intCast(self.nodes.len); + }, + }; + + // Determine our split layout and if we're on the left + const layout: Split.Layout, const left: bool = switch (direction) { + .left => .{ .horizontal, true }, + .right => .{ .horizontal, false }, + .up => .{ .vertical, true }, + .down => .{ .vertical, false }, + }; + + // Copy our previous value to the end of the nodes list and + // create our new split node. + nodes[nodes.len - 1] = nodes[at]; + nodes[at] = .{ .split = .{ + .layout = layout, + .ratio = 0.5, + .left = @intCast(if (left) self.nodes.len else nodes.len - 1), + .right = @intCast(if (left) nodes.len - 1 else self.nodes.len), + } }; + + // We need to increase the reference count of all the nodes. + try refNodes(gpa, nodes); + + return .{ .arena = arena, .nodes = nodes }; + } + + /// Remove a node from the tree. + pub fn remove( + self: *Self, + gpa: Allocator, + at: Node.Handle, + ) Allocator.Error!Self { + assert(at < self.nodes.len); + + // If we're removing node zero then we're clearing the tree. + if (at == 0) return .empty; + + // The new arena for our new tree. + var arena = ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // Allocate our new nodes list with the number of nodes we'll + // need after the removal. + const nodes = try alloc.alloc(Node, self.countAfterRemoval( + 0, + at, + 0, + )); + + // Traverse the tree and copy all our nodes into place. + assert(self.removeNode( + nodes, + 0, + 0, + at, + ) > 0); + + // Increase the reference count of all the nodes. + try refNodes(gpa, nodes); + + return .{ + .arena = arena, + .nodes = nodes, + }; + } + + fn removeNode( + self: *Self, + nodes: []Node, + new_offset: Node.Handle, + current: Node.Handle, + target: Node.Handle, + ) Node.Handle { + assert(current != target); + + switch (self.nodes[current]) { + // Leaf is simple, just copy it over. We don't ref anything + // yet because it'd make undo (errdefer) harder. We do that + // all at once later. + .leaf => |view| { + nodes[new_offset] = .{ .leaf = view }; + return 1; + }, + + .split => |s| { + // If we're removing one of the split node sides then + // we remove the split node itself as well and only add + // the other (non-removed) side. + if (s.left == target) return self.removeNode( + nodes, + new_offset, + s.right, + target, + ); + if (s.right == target) return self.removeNode( + nodes, + new_offset, + s.left, + target, + ); + + // Neither side is being directly removed, so we traverse. + const left = self.removeNode( + nodes, + new_offset + 1, + s.left, + target, + ); + assert(left > 0); + const right = self.removeNode( + nodes, + new_offset + 1 + left, + s.right, + target, + ); + assert(right > 0); + nodes[new_offset] = .{ .split = .{ + .layout = s.layout, + .ratio = s.ratio, + .left = new_offset + 1, + .right = new_offset + 1 + left, + } }; + + return left + right + 1; + }, + } + } + + /// Returns the number of nodes that would be needed to store + /// the tree if the target node is removed. + fn countAfterRemoval( + self: *Self, + current: Node.Handle, + target: Node.Handle, + acc: usize, + ) usize { + assert(current != target); + + return switch (self.nodes[current]) { + // Leaf is simple, always takes one node. + .leaf => acc + 1, + + // Split is slightly more complicated. If either side is the + // target to remove, then we remove the split node as well + // so our count is just the count of the other side. + // + // If neither side is the target, then we count both sides + // and add one to account for the split node itself. + .split => |s| if (s.left == target) self.countAfterRemoval( + s.right, + target, + acc, + ) else if (s.right == target) self.countAfterRemoval( + s.left, + target, + acc, + ) else self.countAfterRemoval( + s.left, + target, + acc, + ) + self.countAfterRemoval( + s.right, + target, + acc, + ) + 1, + }; + } + + /// Reference all the nodes in the given slice, handling unref if + /// any fail. This should be called LAST so you don't have to undo + /// the refs at any further point after this. + fn refNodes(gpa: Allocator, nodes: []Node) Allocator.Error!void { + // We need to increase the reference count of all the nodes. + // Careful accounting here so that we properly unref on error + // only the nodes we referenced. + var reffed: usize = 0; + errdefer for (0..reffed) |i| { + switch (nodes[i]) { + .split => {}, + .leaf => |view| viewUnref(view, gpa), + } + }; + for (0..nodes.len) |i| { + switch (nodes[i]) { + .split => {}, + .leaf => |view| nodes[i] = .{ .leaf = try viewRef(view, gpa) }, + } + reffed = i; + } + assert(reffed == nodes.len - 1); + } + + /// Spatial representation of the split tree. This can be used to + /// better understand the layout of the tree in a 2D space. + /// + /// The bounds of the representation are always based on each split + /// being exactly 1 unit wide and high. The x and y coordinates + /// are offsets into that space. This means that the spatial + /// representation is a normalized representation of the actual + /// space. + /// + /// The top-left corner of the tree is always (0, 0). + /// + /// We use a normalized form because we can calculate it without + /// accessing to the actual rendered view sizes. These actual sizes + /// may not be available at various times because GUI toolkits often + /// only make them available once they're part of a widget tree and + /// a SplitTree can represent views that aren't currently visible. + pub const Spatial = struct { + /// The slots of the spatial representation in the same order + /// as the tree it was created from. + slots: []const Slot, + + pub const empty: Spatial = .{ .slots = &.{} }; + + const Slot = struct { + x: f16, + y: f16, + width: f16, + height: f16, + }; + + pub fn deinit(self: *const Spatial, alloc: Allocator) void { + alloc.free(self.slots); + self.* = undefined; + } + }; + + /// Returns the spatial representation of this tree. See Spatial + /// for more details. + pub fn spatial( + self: *const Self, + alloc: Allocator, + ) Allocator.Error!Spatial { + // No nodes, empty spatial representation. + if (self.nodes.len == 0) return .empty; + + // Get our total dimensions. + const dim = self.dimensions(0); + + // Create our slots which will match our nodes exactly. + const slots = try alloc.alloc(Spatial.Slot, self.nodes.len); + errdefer alloc.free(slots); + slots[0] = .{ + .x = 0, + .y = 0, + .width = @floatFromInt(dim.width), + .height = @floatFromInt(dim.height), + }; + self.fillSpatialSlots(slots, 0); + + return .{ .slots = slots }; + } + + fn fillSpatialSlots( + self: *const Self, + slots: []Spatial.Slot, + current: Node.Handle, + ) void { + assert(slots[current].width > 0 and slots[current].height > 0); + + switch (self.nodes[current]) { + // Leaf node, current slot is already filled by caller. + .leaf => {}, + + .split => |s| { + switch (s.layout) { + .horizontal => { + slots[s.left] = .{ + .x = slots[current].x, + .y = slots[current].y, + .width = slots[current].width * s.ratio, + .height = slots[current].height, + }; + slots[s.right] = .{ + .x = slots[current].x + slots[current].width * s.ratio, + .y = slots[current].y, + .width = slots[current].width * (1 - s.ratio), + .height = slots[current].height, + }; + }, + + .vertical => { + slots[s.left] = .{ + .x = slots[current].x, + .y = slots[current].y, + .width = slots[current].width, + .height = slots[current].height * s.ratio, + }; + slots[s.right] = .{ + .x = slots[current].x, + .y = slots[current].y + slots[current].height * s.ratio, + .width = slots[current].width, + .height = slots[current].height * (1 - s.ratio), + }; + }, + } + + self.fillSpatialSlots(slots, s.left); + self.fillSpatialSlots(slots, s.right); + }, + } + } + + /// Get the dimensions of the tree starting from the given node. + /// + /// This creates relative dimensions (see Spatial) by assuming each + /// leaf is exactly 1x1 unit in size. + fn dimensions(self: *const Self, current: Node.Handle) struct { + width: u16, + height: u16, + } { + return switch (self.nodes[current]) { + .leaf => .{ .width = 1, .height = 1 }, + .split => |s| split: { + const left = self.dimensions(s.left); + const right = self.dimensions(s.right); + break :split switch (s.layout) { + .horizontal => .{ + .width = left.width + right.width, + .height = @max(left.height, right.height), + }, + + .vertical => .{ + .width = @max(left.width, right.width), + .height = left.height + right.height, + }, + }; + }, + }; + } + + /// Format the tree in a human-readable format. + pub fn format( + self: *const Self, + comptime fmt: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = fmt; + _ = options; + + if (self.nodes.len == 0) { + try writer.writeAll("empty"); + return; + } + + // Use our arena's GPA to allocate some intermediate memory. + // Requiring allocation for formatting is nasty but this is really + // only used for debugging and testing and shouldn't hit OOM + // scenarios. + var arena: ArenaAllocator = .init(self.arena.child_allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // Get our spatial representation. + const sp = try self.spatial(alloc); + + // The width we need for the largest label. + const max_label_width: usize = max_label_width: { + if (!@hasDecl(View, "splitTreeLabel")) { + break :max_label_width std.math.log10(sp.slots.len) + 1; + } + + var max: usize = 0; + for (self.nodes) |node| switch (node) { + .split => {}, + .leaf => |view| { + const label = view.splitTreeLabel(); + max = @max(max, label.len); + }, + }; + + break :max_label_width max; + }; + + // We need space for whitespace and ASCII art so add that. + // We need to accommodate the leaf handle, whitespace, and + // then the border. + const cell_width = cell_width: { + // Border + whitespace + label + whitespace + border. + break :cell_width 2 + max_label_width + 2; + }; + const cell_height = cell_height: { + // Border + label + border. No whitespace needed on the + // vertical axis. + break :cell_height 1 + 1 + 1; + }; + + // Make a grid that can fit our entire ASCII diagram. We know + // the width/height based on node 0. + const grid = grid: { + // Get our initial width/height. Each leaf is 1x1 in this. + var width: usize = @intFromFloat(@ceil(sp.slots[0].width)); + var height: usize = @intFromFloat(@ceil(sp.slots[0].height)); + + // We need space for whitespace and ASCII art so add that. + // We need to accommodate the leaf handle, whitespace, and + // then the border. + width *= cell_width; + height *= cell_height; + + const rows = try alloc.alloc([]u8, height); + for (0..rows.len) |y| { + rows[y] = try alloc.alloc(u8, width + 1); + @memset(rows[y], ' '); + rows[y][width] = '\n'; + } + break :grid rows; + }; + + // Draw each node + for (sp.slots, 0..) |slot, handle| { + // We only draw leaf nodes. Splits are only used for layout. + const node = self.nodes[handle]; + switch (node) { + .leaf => {}, + .split => continue, + } + + var x: usize = @intFromFloat(@ceil(slot.x)); + var y: usize = @intFromFloat(@ceil(slot.y)); + var width: usize = @intFromFloat(@ceil(slot.width)); + var height: usize = @intFromFloat(@ceil(slot.height)); + x *= cell_width; + y *= cell_height; + width *= cell_width; + height *= cell_height; + + // Top border + { + const top = grid[y][x..][0..width]; + top[0] = '+'; + for (1..width - 1) |i| top[i] = '-'; + top[width - 1] = '+'; + } + + // Bottom border + { + const bottom = grid[y + height - 1][x..][0..width]; + bottom[0] = '+'; + for (1..width - 1) |i| bottom[i] = '-'; + bottom[width - 1] = '+'; + } + + // Left border + for (y + 1..y + height - 1) |y_cur| grid[y_cur][x] = '|'; + for (y + 1..y + height - 1) |y_cur| grid[y_cur][x + width - 1] = '|'; + + // Get our label text + var buf: [10]u8 = undefined; + const label: []const u8 = if (@hasDecl(View, "splitTreeLabel")) + node.leaf.splitTreeLabel() + else + try std.fmt.bufPrint(&buf, "{d}", .{handle}); + + // Draw the handle in the center + const x_mid = width / 2 + x; + const y_mid = height / 2 + y; + const label_width = label.len; + const label_start = x_mid - label_width / 2; + const row = grid[y_mid][label_start..]; + _ = try std.fmt.bufPrint(row, "{s}", .{label}); + } + + // Output every row + for (grid) |row| { + try writer.writeAll(row); + } + } + + fn viewRef(view: *View, gpa: Allocator) Allocator.Error!*View { + const func = @typeInfo(@TypeOf(View.ref)).@"fn"; + return switch (func.params.len) { + 1 => view.ref(), + 2 => try view.ref(gpa), + else => @compileError("invalid view ref function"), + }; + } + + fn viewUnref(view: *View, gpa: Allocator) void { + const func = @typeInfo(@TypeOf(View.unref)).@"fn"; + switch (func.params.len) { + 1 => view.unref(), + 2 => view.unref(gpa), + else => @compileError("invalid view unref function"), + } + } + + /// Make this a valid gobject if we're in a GTK environment. + pub const getGObjectType = switch (build_config.app_runtime) { + .gtk, .@"gtk-ng" => @import("gobject").ext.defineBoxed( + Self, + .{ + // To get the type name we get the non-qualified type name + // of the view and append that to `GhosttySplitTree`. + .name = name: { + const type_name = @typeName(View); + const last = if (std.mem.lastIndexOfScalar( + u8, + type_name, + '.', + )) |idx| + type_name[idx + 1 ..] + else + type_name; + assert(last.len > 0); + break :name "GhosttySplitTree" ++ last; + }, + + .funcs = .{ + .copy = &struct { + fn copy(self: *Self) callconv(.c) *Self { + const ptr = @import("glib").ext.create(Self); + const alloc = self.arena.child_allocator; + ptr.* = self.clone(alloc) catch @panic("oom"); + return ptr; + } + }.copy, + .free = &struct { + fn free(self: *Self) callconv(.c) void { + self.deinit(); + @import("glib").ext.destroy(self); + } + }.free, + }, + }, + ), + + .none => void, + }; + }; +} + +const TestTree = SplitTree(TestView); + +const TestView = struct { + const Self = @This(); + + label: []const u8, + + pub fn ref(self: *Self, alloc: Allocator) Allocator.Error!*Self { + const ptr = try alloc.create(Self); + ptr.* = self.*; + return ptr; + } + + pub fn unref(self: *Self, alloc: Allocator) void { + alloc.destroy(self); + } + + pub fn splitTreeLabel(self: *const Self) []const u8 { + return self.label; + } +}; + +test "SplitTree: empty tree" { + const testing = std.testing; + const alloc = testing.allocator; + var t: TestTree = .empty; + defer t.deinit(); + + const str = try std.fmt.allocPrint(alloc, "{}", .{t}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\empty + ); +} + +test "SplitTree: single node" { + const testing = std.testing; + const alloc = testing.allocator; + var v: TestTree.View = .{ .label = "A" }; + var t: TestTree = try .init(alloc, &v); + defer t.deinit(); + + const str = try std.fmt.allocPrint(alloc, "{}", .{t}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---+ + \\| A | + \\+---+ + \\ + ); +} + +test "SplitTree: split horizontal" { + const testing = std.testing; + const alloc = testing.allocator; + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); + defer t1.deinit(); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); + defer t2.deinit(); + + var t3 = try t1.split( + alloc, + 0, // at root + .right, // split right + &t2, // insert t2 + ); + defer t3.deinit(); + + const str = try std.fmt.allocPrint(alloc, "{}", .{t3}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---++---+ + \\| A || B | + \\+---++---+ + \\ + ); +} + +test "SplitTree: split vertical" { + const testing = std.testing; + const alloc = testing.allocator; + + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); + defer t1.deinit(); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); + defer t2.deinit(); + + var t3 = try t1.split( + alloc, + 0, // at root + .down, // split down + &t2, // insert t2 + ); + defer t3.deinit(); + + const str = try std.fmt.allocPrint(alloc, "{}", .{t3}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---+ + \\| A | + \\+---+ + \\+---+ + \\| B | + \\+---+ + \\ + ); +} + +test "SplitTree: remove leaf" { + const testing = std.testing; + const alloc = testing.allocator; + + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); + defer t1.deinit(); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); + defer t2.deinit(); + var t3 = try t1.split( + alloc, + 0, // at root + .right, // split right + &t2, // insert t2 + ); + defer t3.deinit(); + + // Remove "A" + var it = t3.iterator(); + var t4 = try t3.remove( + alloc, + while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "A")) { + break entry.handle; + } + } else return error.NotFound, + ); + defer t4.deinit(); + + const str = try std.fmt.allocPrint(alloc, "{}", .{t4}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---+ + \\| B | + \\+---+ + \\ + ); +} + +test "SplitTree: split twice, remove intermediary" { + const testing = std.testing; + const alloc = testing.allocator; + + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); + defer t1.deinit(); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); + defer t2.deinit(); + var v3: TestTree.View = .{ .label = "C" }; + var t3: TestTree = try .init(alloc, &v3); + defer t3.deinit(); + + // A | B horizontal. + var split1 = try t1.split( + alloc, + 0, // at root + .right, // split right + &t2, // insert t2 + ); + defer split1.deinit(); + + // Insert C below that. + var split2 = try split1.split( + alloc, + 0, // at root + .down, // split down + &t3, // insert t3 + ); + defer split2.deinit(); + + { + const str = try std.fmt.allocPrint(alloc, "{}", .{split2}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---++---+ + \\| A || B | + \\+---++---+ + \\+--------+ + \\| C | + \\+--------+ + \\ + ); + } + + // Remove "B" + var it = split2.iterator(); + var split3 = try split2.remove( + alloc, + while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "B")) { + break entry.handle; + } + } else return error.NotFound, + ); + defer split3.deinit(); + + { + const str = try std.fmt.allocPrint(alloc, "{}", .{split3}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---+ + \\| A | + \\+---+ + \\+---+ + \\| C | + \\+---+ + \\ + ); + } + + // Remove every node from split2 (our most complex one), which should + // never crash. We don't test the result is correct, this just verifies + // we don't hit any assertion failures. + for (0..split2.nodes.len) |i| { + var t = try split2.remove(alloc, @intCast(i)); + t.deinit(); + } +}