apprt/gtk-ng: split tree foundations (#8165)

This begins to bring back splits to `gtk-ng`. As of this PR, **splits
still don't work**, but the architectural underpinnings for them are all
present. Namely, our tab view now embeds a `GhosttySplitTree` widget
which has a full split tree present, and all the signals and active
surface properties and so on are hooked up to the tree.

In theory, once we hook up split creation, close, etc. everything should
_just work_.

But, this PR is already very large and I don't want to make it even
larger, so I'm opening this PR to add the foundations for this while
keeping `gtk-ng` in the state it more or less is on main right now.

The meat of this is in a pure Zig data structure `SplitTree` added to
`src/datastruct`. This is a Ziggified port of our macOS implementation
(but, much better if I do say so myself). Being in pure Zig lets us
write unit tests easily, control allocations tightly, get our safety
checks, etc. There is coverage in this PR.

## Other Bugs Fixed

- Boxed accessors use the proper `g_boxed_copy/free` functions. Didn't
really cause any issues because this is the first PR where we actually
use custom implementations for that.

- `Surface` properly emits a notify event for focus change
This commit is contained in:
Mitchell Hashimoto
2025-08-07 08:41:18 -07:00
committed by GitHub
10 changed files with 1616 additions and 101 deletions

View File

@@ -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" },

View File

@@ -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));

View File

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

View File

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

View File

@@ -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(.{});

View File

@@ -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,

View File

@@ -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.";
}
}

View File

@@ -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();
}
}

View File

@@ -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());

View File

@@ -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();
}
}