apprt/gtk-ng: create/close split functionality (#8202)
This adds on to our existing foundations from #8165 and adds the ability to create and close splits. We're still missing split navigation, resizing via keybindings, etc. And there are a number of known issues (listed below). But this is a strict improvement from where we're at and includes a number of important bug fixes to our split tree. The only nasty thing in this PR is that I learned that GTK _did not like_ rebuilding our split widget tree on every data model change. I don't know enough about how all the re-parenting plus size allocation interactions work together. As a compromise, this PR adds a listener, waits for our surface tree to "settle" by having all surfaces have no parents, then schedules a single rebuild after that. This works well, but results in some noticeable flashing for a frame or so. I think we can improve this later, it works completely well enough. Importantly, all of this is Valgrind clean. I long suspected our splits on legacy are NOT free of leaks, but never proved it, so this makes me happy. ## Demo https://github.com/user-attachments/assets/e231d89f-581e-486b-ade0-1d7e6795262e ## Known Issues I may fix this in this PR, I may follow up. - [ ] Focus doesn't go to the right place after closing a split - [x] Divider with a transparent background is transparent - [x] Close split doesn't show any close confirmation dialog - Missing features: * [ ] Equalize splits * [ ] Resize splits keybind (manual mouse action works fine) * [ ] Go to split keybindpull/8209/head
commit
a9a41aec83
|
|
@ -41,6 +41,7 @@ pub const blueprints: []const Blueprint = &.{
|
||||||
.{ .major = 1, .minor = 3, .name = "debug-warning" },
|
.{ .major = 1, .minor = 3, .name = "debug-warning" },
|
||||||
.{ .major = 1, .minor = 2, .name = "resize-overlay" },
|
.{ .major = 1, .minor = 2, .name = "resize-overlay" },
|
||||||
.{ .major = 1, .minor = 5, .name = "split-tree" },
|
.{ .major = 1, .minor = 5, .name = "split-tree" },
|
||||||
|
.{ .major = 1, .minor = 5, .name = "split-tree-split" },
|
||||||
.{ .major = 1, .minor = 2, .name = "surface" },
|
.{ .major = 1, .minor = 2, .name = "surface" },
|
||||||
.{ .major = 1, .minor = 3, .name = "surface-child-exited" },
|
.{ .major = 1, .minor = 3, .name = "surface-child-exited" },
|
||||||
.{ .major = 1, .minor = 5, .name = "tab" },
|
.{ .major = 1, .minor = 5, .name = "tab" },
|
||||||
|
|
|
||||||
|
|
@ -562,6 +562,8 @@ pub const Application = extern struct {
|
||||||
|
|
||||||
.move_tab => return Action.moveTab(target, value),
|
.move_tab => return Action.moveTab(target, value),
|
||||||
|
|
||||||
|
.new_split => return Action.newSplit(target, value),
|
||||||
|
|
||||||
.new_tab => return Action.newTab(target),
|
.new_tab => return Action.newTab(target),
|
||||||
|
|
||||||
.new_window => try Action.newWindow(
|
.new_window => try Action.newWindow(
|
||||||
|
|
@ -611,7 +613,6 @@ pub const Application = extern struct {
|
||||||
.prompt_title,
|
.prompt_title,
|
||||||
.inspector,
|
.inspector,
|
||||||
// TODO: splits
|
// TODO: splits
|
||||||
.new_split,
|
|
||||||
.resize_split,
|
.resize_split,
|
||||||
.equalize_splits,
|
.equalize_splits,
|
||||||
.goto_split,
|
.goto_split,
|
||||||
|
|
@ -881,6 +882,10 @@ pub const Application = extern struct {
|
||||||
self.syncActionAccelerator("win.reset", .{ .reset = {} });
|
self.syncActionAccelerator("win.reset", .{ .reset = {} });
|
||||||
self.syncActionAccelerator("win.clear", .{ .clear_screen = {} });
|
self.syncActionAccelerator("win.clear", .{ .clear_screen = {} });
|
||||||
self.syncActionAccelerator("win.prompt-title", .{ .prompt_surface_title = {} });
|
self.syncActionAccelerator("win.prompt-title", .{ .prompt_surface_title = {} });
|
||||||
|
self.syncActionAccelerator("split-tree.new-left", .{ .new_split = .left });
|
||||||
|
self.syncActionAccelerator("split-tree.new-right", .{ .new_split = .right });
|
||||||
|
self.syncActionAccelerator("split-tree.new-up", .{ .new_split = .up });
|
||||||
|
self.syncActionAccelerator("split-tree.new-down", .{ .new_split = .down });
|
||||||
}
|
}
|
||||||
|
|
||||||
fn syncActionAccelerator(
|
fn syncActionAccelerator(
|
||||||
|
|
@ -1257,6 +1262,7 @@ pub const Application = extern struct {
|
||||||
diag.close();
|
diag.close();
|
||||||
diag.unref(); // strong ref from get()
|
diag.unref(); // strong ref from get()
|
||||||
}
|
}
|
||||||
|
priv.config_errors_dialog.set(null);
|
||||||
if (priv.signal_source) |v| {
|
if (priv.signal_source) |v| {
|
||||||
if (glib.Source.remove(v) == 0) {
|
if (glib.Source.remove(v) == 0) {
|
||||||
log.warn("unable to remove signal source", .{});
|
log.warn("unable to remove signal source", .{});
|
||||||
|
|
@ -1746,6 +1752,28 @@ const Action = struct {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn newSplit(
|
||||||
|
target: apprt.Target,
|
||||||
|
direction: apprt.action.SplitDirection,
|
||||||
|
) bool {
|
||||||
|
switch (target) {
|
||||||
|
.app => {
|
||||||
|
log.warn("new split to app is unexpected", .{});
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
.surface => |core| {
|
||||||
|
const surface = core.rt_surface.surface;
|
||||||
|
return surface.as(gtk.Widget).activateAction(switch (direction) {
|
||||||
|
.right => "split-tree.new-right",
|
||||||
|
.left => "split-tree.new-left",
|
||||||
|
.down => "split-tree.new-down",
|
||||||
|
.up => "split-tree.new-up",
|
||||||
|
}, null) != 0;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn newTab(target: apprt.Target) bool {
|
pub fn newTab(target: apprt.Target) bool {
|
||||||
switch (target) {
|
switch (target) {
|
||||||
.app => {
|
.app => {
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,7 @@ pub const CloseConfirmationDialog = extern struct {
|
||||||
const C = Common(Self, Private);
|
const C = Common(Self, Private);
|
||||||
pub const as = C.as;
|
pub const as = C.as;
|
||||||
pub const ref = C.ref;
|
pub const ref = C.ref;
|
||||||
|
pub const refSink = C.refSink;
|
||||||
pub const unref = C.unref;
|
pub const unref = C.unref;
|
||||||
const private = C.private;
|
const private = C.private;
|
||||||
|
|
||||||
|
|
@ -179,12 +180,14 @@ pub const Target = enum(c_int) {
|
||||||
app,
|
app,
|
||||||
tab,
|
tab,
|
||||||
window,
|
window,
|
||||||
|
surface,
|
||||||
|
|
||||||
pub fn title(self: Target) [*:0]const u8 {
|
pub fn title(self: Target) [*:0]const u8 {
|
||||||
return switch (self) {
|
return switch (self) {
|
||||||
.app => i18n._("Quit Ghostty?"),
|
.app => i18n._("Quit Ghostty?"),
|
||||||
.tab => i18n._("Close Tab?"),
|
.tab => i18n._("Close Tab?"),
|
||||||
.window => i18n._("Close Window?"),
|
.window => i18n._("Close Window?"),
|
||||||
|
.surface => i18n._("Close Split?"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,6 +196,7 @@ pub const Target = enum(c_int) {
|
||||||
.app => i18n._("All terminal sessions will be terminated."),
|
.app => i18n._("All terminal sessions will be terminated."),
|
||||||
.tab => i18n._("All terminal sessions in this tab will be terminated."),
|
.tab => i18n._("All terminal sessions in this tab will be terminated."),
|
||||||
.window => i18n._("All terminal sessions in this window will be terminated."),
|
.window => i18n._("All terminal sessions in this window will be terminated."),
|
||||||
|
.surface => i18n._("The currently running process in this split will be terminated."),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const build_config = @import("../../../build_config.zig");
|
const build_config = @import("../../../build_config.zig");
|
||||||
const assert = std.debug.assert;
|
const assert = std.debug.assert;
|
||||||
|
const Allocator = std.mem.Allocator;
|
||||||
const adw = @import("adw");
|
const adw = @import("adw");
|
||||||
const gio = @import("gio");
|
const gio = @import("gio");
|
||||||
const glib = @import("glib");
|
const glib = @import("glib");
|
||||||
|
|
@ -16,6 +17,7 @@ const adw_version = @import("../adw_version.zig");
|
||||||
const ext = @import("../ext.zig");
|
const ext = @import("../ext.zig");
|
||||||
const gresource = @import("../build/gresource.zig");
|
const gresource = @import("../build/gresource.zig");
|
||||||
const Common = @import("../class.zig").Common;
|
const Common = @import("../class.zig").Common;
|
||||||
|
const WeakRef = @import("../weak_ref.zig").WeakRef;
|
||||||
const Config = @import("config.zig").Config;
|
const Config = @import("config.zig").Config;
|
||||||
const Application = @import("application.zig").Application;
|
const Application = @import("application.zig").Application;
|
||||||
const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog;
|
const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog;
|
||||||
|
|
@ -36,6 +38,28 @@ pub const SplitTree = extern struct {
|
||||||
});
|
});
|
||||||
|
|
||||||
pub const properties = struct {
|
pub const properties = struct {
|
||||||
|
/// The active surface is the surface that should be receiving all
|
||||||
|
/// surface-targeted actions. This is usually the focused surface,
|
||||||
|
/// but may also not be focused if the user has selected a non-surface
|
||||||
|
/// widget.
|
||||||
|
pub const @"active-surface" = struct {
|
||||||
|
pub const name = "active-surface";
|
||||||
|
const impl = gobject.ext.defineProperty(
|
||||||
|
name,
|
||||||
|
Self,
|
||||||
|
?*Surface,
|
||||||
|
.{
|
||||||
|
.accessor = gobject.ext.typedAccessor(
|
||||||
|
Self,
|
||||||
|
?*Surface,
|
||||||
|
.{
|
||||||
|
.getter = getActiveSurface,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
pub const @"has-surfaces" = struct {
|
pub const @"has-surfaces" = struct {
|
||||||
pub const name = "has-surfaces";
|
pub const name = "has-surfaces";
|
||||||
const impl = gobject.ext.defineProperty(
|
const impl = gobject.ext.defineProperty(
|
||||||
|
|
@ -93,16 +117,237 @@ pub const SplitTree = extern struct {
|
||||||
// Template bindings
|
// Template bindings
|
||||||
tree_bin: *adw.Bin,
|
tree_bin: *adw.Bin,
|
||||||
|
|
||||||
|
/// Last focused surface in the tree. We need this to handle various
|
||||||
|
/// tree change states.
|
||||||
|
last_focused: WeakRef(Surface) = .{},
|
||||||
|
|
||||||
|
/// The source that we use to rebuild the tree. This is also
|
||||||
|
/// used to debounce updates.
|
||||||
|
rebuild_source: ?c_uint = null,
|
||||||
|
|
||||||
|
/// Tracks whether we want a rebuild to happen at the next tick
|
||||||
|
/// that our surface tree has no surfaces with parents. See the
|
||||||
|
/// propTree function for a lot more details.
|
||||||
|
rebuild_pending: bool,
|
||||||
|
|
||||||
|
/// Used to store state about a pending surface close for the
|
||||||
|
/// close dialog.
|
||||||
|
pending_close: ?Surface.Tree.Node.Handle,
|
||||||
|
|
||||||
pub var offset: c_int = 0;
|
pub var offset: c_int = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
fn init(self: *Self, _: *Class) callconv(.c) void {
|
fn init(self: *Self, _: *Class) callconv(.c) void {
|
||||||
gtk.Widget.initTemplate(self.as(gtk.Widget));
|
gtk.Widget.initTemplate(self.as(gtk.Widget));
|
||||||
|
|
||||||
|
// Initialize our actions
|
||||||
|
self.initActions();
|
||||||
|
|
||||||
|
// Initialize some basic state
|
||||||
|
const priv = self.private();
|
||||||
|
priv.pending_close = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initActions(self: *Self) void {
|
||||||
|
// The set of actions. Each action has (in order):
|
||||||
|
// [0] The action name
|
||||||
|
// [1] The callback function
|
||||||
|
// [2] The glib.VariantType of the parameter
|
||||||
|
//
|
||||||
|
// For action names:
|
||||||
|
// https://docs.gtk.org/gio/type_func.Action.name_is_valid.html
|
||||||
|
const actions = .{
|
||||||
|
// All of these will eventually take a target surface parameter.
|
||||||
|
// For now all our targets originate from the focused surface.
|
||||||
|
.{ "new-left", actionNewLeft, null },
|
||||||
|
.{ "new-right", actionNewRight, null },
|
||||||
|
.{ "new-up", actionNewUp, null },
|
||||||
|
.{ "new-down", actionNewDown, null },
|
||||||
|
};
|
||||||
|
|
||||||
|
// We need to collect our actions into a group since we're just
|
||||||
|
// a plain widget that doesn't implement ActionGroup directly.
|
||||||
|
const group = gio.SimpleActionGroup.new();
|
||||||
|
errdefer group.unref();
|
||||||
|
const map = group.as(gio.ActionMap);
|
||||||
|
inline for (actions) |entry| {
|
||||||
|
const action = gio.SimpleAction.new(
|
||||||
|
entry[0],
|
||||||
|
entry[2],
|
||||||
|
);
|
||||||
|
defer action.unref();
|
||||||
|
_ = gio.SimpleAction.signals.activate.connect(
|
||||||
|
action,
|
||||||
|
*Self,
|
||||||
|
entry[1],
|
||||||
|
self,
|
||||||
|
.{},
|
||||||
|
);
|
||||||
|
map.addAction(action.as(gio.Action));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.as(gtk.Widget).insertActionGroup(
|
||||||
|
"split-tree",
|
||||||
|
group.as(gio.ActionGroup),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new split in the given direction from the currently
|
||||||
|
/// active surface.
|
||||||
|
///
|
||||||
|
/// If the tree is empty this will create a new tree with a new surface
|
||||||
|
/// and ignore the direction.
|
||||||
|
///
|
||||||
|
/// The parent will be used as the parent of the surface regardless of
|
||||||
|
/// if that parent is in this split tree or not. This allows inheriting
|
||||||
|
/// surface properties from anywhere.
|
||||||
|
pub fn newSplit(
|
||||||
|
self: *Self,
|
||||||
|
direction: Surface.Tree.Split.Direction,
|
||||||
|
parent_: ?*Surface,
|
||||||
|
) Allocator.Error!void {
|
||||||
|
const alloc = Application.default().allocator();
|
||||||
|
|
||||||
|
// Create our new surface.
|
||||||
|
const surface: *Surface = .new();
|
||||||
|
defer surface.unref();
|
||||||
|
_ = surface.refSink();
|
||||||
|
|
||||||
|
// Inherit properly if we were asked to.
|
||||||
|
if (parent_) |p| {
|
||||||
|
if (p.core()) |core| {
|
||||||
|
surface.setParent(core);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create our tree
|
||||||
|
var single_tree = try Surface.Tree.init(alloc, surface);
|
||||||
|
defer single_tree.deinit();
|
||||||
|
|
||||||
|
// We want to move our focus to the new surface no matter what.
|
||||||
|
// But we need to be careful to restore state if we fail.
|
||||||
|
const old_last_focused = self.private().last_focused.get();
|
||||||
|
defer if (old_last_focused) |v| v.unref(); // unref strong ref from get
|
||||||
|
self.private().last_focused.set(surface);
|
||||||
|
errdefer self.private().last_focused.set(old_last_focused);
|
||||||
|
|
||||||
|
// If we have no tree yet, then this becomes our tree and we're done.
|
||||||
|
const old_tree = self.getTree() orelse {
|
||||||
|
self.setTree(&single_tree);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The handle we create the split relative to. Today this is the active
|
||||||
|
// surface but this might be the handle of the given parent if we want.
|
||||||
|
const handle = self.getActiveSurfaceHandle() orelse 0;
|
||||||
|
|
||||||
|
// Create our split!
|
||||||
|
var new_tree = try old_tree.split(
|
||||||
|
alloc,
|
||||||
|
handle,
|
||||||
|
direction,
|
||||||
|
&single_tree,
|
||||||
|
);
|
||||||
|
defer new_tree.deinit();
|
||||||
|
log.debug(
|
||||||
|
"new split at={} direction={} old_tree={} new_tree={}",
|
||||||
|
.{ handle, direction, old_tree, &new_tree },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Replace our tree
|
||||||
|
self.setTree(&new_tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disconnectSurfaceHandlers(self: *Self) void {
|
||||||
|
const tree = self.getTree() orelse return;
|
||||||
|
var it = tree.iterator();
|
||||||
|
while (it.next()) |entry| {
|
||||||
|
const surface = entry.view;
|
||||||
|
_ = gobject.signalHandlersDisconnectMatched(
|
||||||
|
surface.as(gobject.Object),
|
||||||
|
.{ .data = true },
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
self,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connectSurfaceHandlers(self: *Self) void {
|
||||||
|
const tree = self.getTree() orelse return;
|
||||||
|
var it = tree.iterator();
|
||||||
|
while (it.next()) |entry| {
|
||||||
|
const surface = entry.view;
|
||||||
|
_ = Surface.signals.@"close-request".connect(
|
||||||
|
surface,
|
||||||
|
*Self,
|
||||||
|
surfaceCloseRequest,
|
||||||
|
self,
|
||||||
|
.{},
|
||||||
|
);
|
||||||
|
_ = gobject.Object.signals.notify.connect(
|
||||||
|
surface,
|
||||||
|
*Self,
|
||||||
|
propSurfaceFocused,
|
||||||
|
self,
|
||||||
|
.{ .detail = "focused" },
|
||||||
|
);
|
||||||
|
_ = gobject.Object.signals.notify.connect(
|
||||||
|
surface.as(gtk.Widget),
|
||||||
|
*Self,
|
||||||
|
propSurfaceParent,
|
||||||
|
self,
|
||||||
|
.{ .detail = "parent" },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//---------------------------------------------------------------
|
//---------------------------------------------------------------
|
||||||
// Properties
|
// Properties
|
||||||
|
|
||||||
|
/// Get the currently active surface. See the "active-surface" property.
|
||||||
|
/// This does not ref the value.
|
||||||
|
pub fn getActiveSurface(self: *Self) ?*Surface {
|
||||||
|
const tree = self.getTree() orelse return null;
|
||||||
|
const handle = self.getActiveSurfaceHandle() orelse return null;
|
||||||
|
return tree.nodes[handle].leaf;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn getActiveSurfaceHandle(self: *Self) ?Surface.Tree.Node.Handle {
|
||||||
|
const tree = self.getTree() orelse return null;
|
||||||
|
var it = tree.iterator();
|
||||||
|
while (it.next()) |entry| {
|
||||||
|
if (entry.view.getFocused()) return entry.handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the last focused surface in the tree.
|
||||||
|
pub fn getLastFocusedSurface(self: *Self) ?*Surface {
|
||||||
|
const surface = self.private().last_focused.get() orelse return null;
|
||||||
|
// We unref because get() refs the surface. We don't use the weakref
|
||||||
|
// in a multi-threaded context so this is safe.
|
||||||
|
surface.unref();
|
||||||
|
return surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether any of the surfaces in the tree have a parent.
|
||||||
|
/// This is important because we can only rebuild the widget tree
|
||||||
|
/// when every surface has no parent.
|
||||||
|
fn getTreeHasParents(self: *Self) bool {
|
||||||
|
const tree: *const Surface.Tree = self.getTree() orelse &.empty;
|
||||||
|
var it = tree.iterator();
|
||||||
|
while (it.next()) |entry| {
|
||||||
|
const surface = entry.view;
|
||||||
|
if (surface.as(gtk.Widget).getParent() != null) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn getHasSurfaces(self: *Self) bool {
|
pub fn getHasSurfaces(self: *Self) bool {
|
||||||
const tree: *const Surface.Tree = self.private().tree orelse &.empty;
|
const tree: *const Surface.Tree = self.private().tree orelse &.empty;
|
||||||
return !tree.isEmpty();
|
return !tree.isEmpty();
|
||||||
|
|
@ -116,9 +361,18 @@ pub const SplitTree = extern struct {
|
||||||
|
|
||||||
/// Set the tree data model that we're showing in this widget. This
|
/// Set the tree data model that we're showing in this widget. This
|
||||||
/// will clone the given tree.
|
/// will clone the given tree.
|
||||||
pub fn setTree(self: *Self, tree: ?*const Surface.Tree) void {
|
pub fn setTree(self: *Self, tree_: ?*const Surface.Tree) void {
|
||||||
const priv = self.private();
|
const priv = self.private();
|
||||||
|
|
||||||
|
// We always normalize our tree parameter so that empty trees
|
||||||
|
// become null so that we don't have to deal with callers being
|
||||||
|
// confused about that.
|
||||||
|
const tree: ?*const Surface.Tree = tree: {
|
||||||
|
const tree = tree_ orelse break :tree null;
|
||||||
|
if (tree.isEmpty()) break :tree null;
|
||||||
|
break :tree tree;
|
||||||
|
};
|
||||||
|
|
||||||
// Emit the signal so that handlers can witness both the before and
|
// Emit the signal so that handlers can witness both the before and
|
||||||
// after values of the tree.
|
// after values of the tree.
|
||||||
signals.changed.impl.emit(
|
signals.changed.impl.emit(
|
||||||
|
|
@ -129,12 +383,16 @@ pub const SplitTree = extern struct {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (priv.tree) |old_tree| {
|
if (priv.tree) |old_tree| {
|
||||||
|
self.disconnectSurfaceHandlers();
|
||||||
ext.boxedFree(Surface.Tree, old_tree);
|
ext.boxedFree(Surface.Tree, old_tree);
|
||||||
priv.tree = null;
|
priv.tree = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tree) |new_tree| {
|
if (tree) |new_tree| {
|
||||||
|
assert(priv.tree == null);
|
||||||
|
assert(!new_tree.isEmpty());
|
||||||
priv.tree = ext.boxedCopy(Surface.Tree, new_tree);
|
priv.tree = ext.boxedCopy(Surface.Tree, new_tree);
|
||||||
|
self.connectSurfaceHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
self.as(gobject.Object).notifyByPspec(properties.tree.impl.param_spec);
|
self.as(gobject.Object).notifyByPspec(properties.tree.impl.param_spec);
|
||||||
|
|
@ -158,6 +416,15 @@ pub const SplitTree = extern struct {
|
||||||
// Virtual methods
|
// Virtual methods
|
||||||
|
|
||||||
fn dispose(self: *Self) callconv(.c) void {
|
fn dispose(self: *Self) callconv(.c) void {
|
||||||
|
const priv = self.private();
|
||||||
|
priv.last_focused.set(null);
|
||||||
|
if (priv.rebuild_source) |v| {
|
||||||
|
if (glib.Source.remove(v) == 0) {
|
||||||
|
log.warn("unable to remove rebuild source", .{});
|
||||||
|
}
|
||||||
|
priv.rebuild_source = null;
|
||||||
|
}
|
||||||
|
|
||||||
gtk.Widget.disposeTemplate(
|
gtk.Widget.disposeTemplate(
|
||||||
self.as(gtk.Widget),
|
self.as(gtk.Widget),
|
||||||
getGObjectType(),
|
getGObjectType(),
|
||||||
|
|
@ -185,51 +452,273 @@ pub const SplitTree = extern struct {
|
||||||
//---------------------------------------------------------------
|
//---------------------------------------------------------------
|
||||||
// Signal handlers
|
// Signal handlers
|
||||||
|
|
||||||
|
pub fn actionNewLeft(
|
||||||
|
_: *gio.SimpleAction,
|
||||||
|
parameter_: ?*glib.Variant,
|
||||||
|
self: *Self,
|
||||||
|
) callconv(.c) void {
|
||||||
|
_ = parameter_;
|
||||||
|
self.newSplit(
|
||||||
|
.left,
|
||||||
|
self.getActiveSurface(),
|
||||||
|
) catch |err| {
|
||||||
|
log.warn("new split failed error={}", .{err});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn actionNewRight(
|
||||||
|
_: *gio.SimpleAction,
|
||||||
|
parameter_: ?*glib.Variant,
|
||||||
|
self: *Self,
|
||||||
|
) callconv(.c) void {
|
||||||
|
_ = parameter_;
|
||||||
|
self.newSplit(
|
||||||
|
.right,
|
||||||
|
self.getActiveSurface(),
|
||||||
|
) catch |err| {
|
||||||
|
log.warn("new split failed error={}", .{err});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn actionNewUp(
|
||||||
|
_: *gio.SimpleAction,
|
||||||
|
parameter_: ?*glib.Variant,
|
||||||
|
self: *Self,
|
||||||
|
) callconv(.c) void {
|
||||||
|
_ = parameter_;
|
||||||
|
self.newSplit(
|
||||||
|
.up,
|
||||||
|
self.getActiveSurface(),
|
||||||
|
) catch |err| {
|
||||||
|
log.warn("new split failed error={}", .{err});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn actionNewDown(
|
||||||
|
_: *gio.SimpleAction,
|
||||||
|
parameter_: ?*glib.Variant,
|
||||||
|
self: *Self,
|
||||||
|
) callconv(.c) void {
|
||||||
|
_ = parameter_;
|
||||||
|
self.newSplit(
|
||||||
|
.down,
|
||||||
|
self.getActiveSurface(),
|
||||||
|
) catch |err| {
|
||||||
|
log.warn("new split failed error={}", .{err});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn surfaceCloseRequest(
|
||||||
|
surface: *Surface,
|
||||||
|
scope: *const Surface.CloseScope,
|
||||||
|
self: *Self,
|
||||||
|
) callconv(.c) void {
|
||||||
|
switch (scope.*) {
|
||||||
|
// Handled upstream... this will probably go away for widget
|
||||||
|
// actions eventually.
|
||||||
|
.window, .tab => return,
|
||||||
|
|
||||||
|
// Remove the surface from the tree.
|
||||||
|
.surface => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
const core = surface.core() orelse return;
|
||||||
|
|
||||||
|
// Reset our pending close state
|
||||||
|
const priv = self.private();
|
||||||
|
priv.pending_close = null;
|
||||||
|
|
||||||
|
// Find the surface in the tree to verify this is valid and
|
||||||
|
// set our pending close handle.
|
||||||
|
priv.pending_close = handle: {
|
||||||
|
const tree = self.getTree() orelse return;
|
||||||
|
var it = tree.iterator();
|
||||||
|
while (it.next()) |entry| {
|
||||||
|
if (entry.view == surface) {
|
||||||
|
break :handle entry.handle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// If we don't need to confirm then just close immediately.
|
||||||
|
if (!core.needsConfirmQuit()) {
|
||||||
|
closeConfirmationClose(
|
||||||
|
null,
|
||||||
|
self,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show a confirmation dialog
|
||||||
|
const dialog: *CloseConfirmationDialog = .new(.surface);
|
||||||
|
_ = CloseConfirmationDialog.signals.@"close-request".connect(
|
||||||
|
dialog,
|
||||||
|
*Self,
|
||||||
|
closeConfirmationClose,
|
||||||
|
self,
|
||||||
|
.{},
|
||||||
|
);
|
||||||
|
dialog.present(self.as(gtk.Widget));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn closeConfirmationClose(
|
||||||
|
_: ?*CloseConfirmationDialog,
|
||||||
|
self: *Self,
|
||||||
|
) callconv(.c) void {
|
||||||
|
// Get the handle we're closing
|
||||||
|
const priv = self.private();
|
||||||
|
const handle = priv.pending_close orelse return;
|
||||||
|
priv.pending_close = null;
|
||||||
|
|
||||||
|
// Remove it from the tree.
|
||||||
|
const old_tree = self.getTree() orelse return;
|
||||||
|
var new_tree = old_tree.remove(
|
||||||
|
Application.default().allocator(),
|
||||||
|
handle,
|
||||||
|
) catch |err| {
|
||||||
|
log.warn("unable to remove surface from tree: {}", .{err});
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
defer new_tree.deinit();
|
||||||
|
self.setTree(&new_tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn propSurfaceFocused(
|
||||||
|
surface: *Surface,
|
||||||
|
_: *gobject.ParamSpec,
|
||||||
|
self: *Self,
|
||||||
|
) callconv(.c) void {
|
||||||
|
// We never CLEAR our last_focused because the property is specifically
|
||||||
|
// the last focused surface. We let the weakref clear itself when
|
||||||
|
// the surface is destroyed.
|
||||||
|
if (!surface.getFocused()) return;
|
||||||
|
self.private().last_focused.set(surface);
|
||||||
|
|
||||||
|
// Our active surface probably changed
|
||||||
|
self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn propSurfaceParent(
|
||||||
|
_: *gtk.Widget,
|
||||||
|
_: *gobject.ParamSpec,
|
||||||
|
self: *Self,
|
||||||
|
) callconv(.c) void {
|
||||||
|
const priv = self.private();
|
||||||
|
|
||||||
|
// If we're not waiting to rebuild then ignore this.
|
||||||
|
if (!priv.rebuild_pending) return;
|
||||||
|
|
||||||
|
// If any parents still exist in our tree then don't do anything.
|
||||||
|
if (self.getTreeHasParents()) return;
|
||||||
|
|
||||||
|
// Schedule the rebuild. Note, I tried to do this immediately (not
|
||||||
|
// on an idle tick) and it didn't work and had obvious rendering
|
||||||
|
// glitches. Something to look into in the future.
|
||||||
|
assert(priv.rebuild_source == null);
|
||||||
|
priv.rebuild_pending = false;
|
||||||
|
priv.rebuild_source = glib.idleAdd(onRebuild, self);
|
||||||
|
}
|
||||||
|
|
||||||
fn propTree(
|
fn propTree(
|
||||||
self: *Self,
|
self: *Self,
|
||||||
_: *gobject.ParamSpec,
|
_: *gobject.ParamSpec,
|
||||||
_: ?*anyopaque,
|
_: ?*anyopaque,
|
||||||
) callconv(.c) void {
|
) callconv(.c) void {
|
||||||
const priv = self.private();
|
const priv = self.private();
|
||||||
const tree: *const Surface.Tree = self.private().tree orelse &.empty;
|
|
||||||
|
|
||||||
// Reset our widget tree.
|
// If we were planning a rebuild, always remove that so we can
|
||||||
|
// start from a clean slate.
|
||||||
|
if (priv.rebuild_source) |v| {
|
||||||
|
if (glib.Source.remove(v) == 0) {
|
||||||
|
log.warn("unable to remove rebuild source", .{});
|
||||||
|
}
|
||||||
|
priv.rebuild_source = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need to wait for all our previous surfaces to lose their
|
||||||
|
// parent before adding them to a new one. I'm not sure if its a GTK
|
||||||
|
// bug, but manually forcing an unparent of all prior surfaces AND
|
||||||
|
// adding them to a new parent in the same tick causes the GLArea
|
||||||
|
// to break (it seems). I didn't investigate too deeply.
|
||||||
|
//
|
||||||
|
// Note, we also can't just defer to an idle tick (via idleAdd) because
|
||||||
|
// sometimes it takes more than one tick for all our surfaces to
|
||||||
|
// lose their parent.
|
||||||
|
//
|
||||||
|
// To work around this issue, if we have any surfaces that have
|
||||||
|
// a parent, we set the build pending flag and wait for the tree
|
||||||
|
// to be fully parent-free before building.
|
||||||
|
priv.rebuild_pending = self.getTreeHasParents();
|
||||||
|
|
||||||
|
// Reset our prior bin. This will force all prior surfaces to
|
||||||
|
// unparent... eventually.
|
||||||
priv.tree_bin.setChild(null);
|
priv.tree_bin.setChild(null);
|
||||||
if (!tree.isEmpty()) {
|
|
||||||
priv.tree_bin.setChild(buildTree(tree, 0));
|
// If none of the surfaces we plan on drawing require an unparent
|
||||||
|
// then we can setup our tree immediately. Otherwise, it'll happen
|
||||||
|
// via the `propSurfaceParent` callback.
|
||||||
|
if (!priv.rebuild_pending and priv.rebuild_source == null) {
|
||||||
|
priv.rebuild_source = glib.idleAdd(
|
||||||
|
onRebuild,
|
||||||
|
self,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dependent properties
|
// Dependent properties
|
||||||
self.as(gobject.Object).notifyByPspec(properties.@"has-surfaces".impl.param_spec);
|
self.as(gobject.Object).notifyByPspec(properties.@"has-surfaces".impl.param_spec);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn onRebuild(ud: ?*anyopaque) callconv(.c) c_int {
|
||||||
|
const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
|
||||||
|
|
||||||
|
// Always mark our rebuild source as null since we're done.
|
||||||
|
const priv = self.private();
|
||||||
|
priv.rebuild_source = null;
|
||||||
|
|
||||||
|
// Prior to rebuilding the tree, our surface tree must be
|
||||||
|
// comprised of fully orphaned surfaces.
|
||||||
|
assert(!self.getTreeHasParents());
|
||||||
|
|
||||||
|
// Rebuild our tree
|
||||||
|
const tree: *const Surface.Tree = self.private().tree orelse &.empty;
|
||||||
|
if (!tree.isEmpty()) {
|
||||||
|
priv.tree_bin.setChild(self.buildTree(tree, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a last focused surface, we need to refocus it, because
|
||||||
|
// during the frame between setting the bin to null and rebuilding,
|
||||||
|
// GTK will reset our focus state (as it should!)
|
||||||
|
if (priv.last_focused.get()) |v| {
|
||||||
|
defer v.unref();
|
||||||
|
v.grabFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Our active surface may have changed
|
||||||
|
self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// Builds the widget tree associated with a surface split tree.
|
/// Builds the widget tree associated with a surface split tree.
|
||||||
///
|
///
|
||||||
/// The final returned widget is expected to be a floating reference,
|
/// The final returned widget is expected to be a floating reference,
|
||||||
/// ready to be attached to a parent widget.
|
/// ready to be attached to a parent widget.
|
||||||
fn buildTree(
|
fn buildTree(
|
||||||
|
self: *Self,
|
||||||
tree: *const Surface.Tree,
|
tree: *const Surface.Tree,
|
||||||
current: Surface.Tree.Node.Handle,
|
current: Surface.Tree.Node.Handle,
|
||||||
) *gtk.Widget {
|
) *gtk.Widget {
|
||||||
switch (tree.nodes[current]) {
|
return switch (tree.nodes[current]) {
|
||||||
.leaf => |v| {
|
.leaf => |v| v.as(gtk.Widget),
|
||||||
// We have to setup our signal handlers.
|
.split => |s| SplitTreeSplit.new(
|
||||||
return v.as(gtk.Widget);
|
current,
|
||||||
},
|
&s,
|
||||||
|
self.buildTree(tree, s.left),
|
||||||
.split => |s| return gobject.ext.newInstance(
|
self.buildTree(tree, s.right),
|
||||||
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),
|
).as(gtk.Widget),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
//---------------------------------------------------------------
|
//---------------------------------------------------------------
|
||||||
|
|
@ -259,6 +748,7 @@ pub const SplitTree = extern struct {
|
||||||
|
|
||||||
// Properties
|
// Properties
|
||||||
gobject.ext.registerProperties(class, &.{
|
gobject.ext.registerProperties(class, &.{
|
||||||
|
properties.@"active-surface".impl,
|
||||||
properties.@"has-surfaces".impl,
|
properties.@"has-surfaces".impl,
|
||||||
properties.tree.impl,
|
properties.tree.impl,
|
||||||
});
|
});
|
||||||
|
|
@ -282,3 +772,280 @@ pub const SplitTree = extern struct {
|
||||||
pub const bindTemplateCallback = C.Class.bindTemplateCallback;
|
pub const bindTemplateCallback = C.Class.bindTemplateCallback;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// This is an internal-only widget that represents a split in the
|
||||||
|
/// split tree. This is a wrapper around gtk.Paned that allows us to handle
|
||||||
|
/// ratio (0 to 1) based positioning of the split, and also allows us to
|
||||||
|
/// write back the updated ratio to the split tree when the user manually
|
||||||
|
/// adjusts the split position.
|
||||||
|
///
|
||||||
|
/// Since this is internal, it expects to be nested within a SplitTree and
|
||||||
|
/// will use `getAncestor` to find the SplitTree it belongs to.
|
||||||
|
///
|
||||||
|
/// This is an _immutable_ widget. It isn't meant to be updated after
|
||||||
|
/// creation. As such, there are no properties or APIs to change the split,
|
||||||
|
/// access the paned, etc.
|
||||||
|
const SplitTreeSplit = extern struct {
|
||||||
|
const Self = @This();
|
||||||
|
parent_instance: Parent,
|
||||||
|
pub const Parent = adw.Bin;
|
||||||
|
pub const getGObjectType = gobject.ext.defineClass(Self, .{
|
||||||
|
.name = "GhosttySplitTreeSplit",
|
||||||
|
.instanceInit = &init,
|
||||||
|
.classInit = &Class.init,
|
||||||
|
.parent_class = &Class.parent,
|
||||||
|
.private = .{ .Type = Private, .offset = &Private.offset },
|
||||||
|
});
|
||||||
|
|
||||||
|
const Private = struct {
|
||||||
|
/// The handle of the node in the tree that this split represents.
|
||||||
|
/// Assumed to be correct.
|
||||||
|
handle: Surface.Tree.Node.Handle,
|
||||||
|
|
||||||
|
/// Source to handle repositioning the split when properties change.
|
||||||
|
idle: ?c_uint = null,
|
||||||
|
|
||||||
|
// Template bindings
|
||||||
|
paned: *gtk.Paned,
|
||||||
|
|
||||||
|
pub var offset: c_int = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Create a new split.
|
||||||
|
///
|
||||||
|
/// The reason we don't use GObject properties here is because this is
|
||||||
|
/// an immutable widget and we don't want to deal with the overhead of
|
||||||
|
/// all the boilerplate for properties, signals, bindings, etc.
|
||||||
|
pub fn new(
|
||||||
|
handle: Surface.Tree.Node.Handle,
|
||||||
|
split: *const Surface.Tree.Split,
|
||||||
|
start_child: *gtk.Widget,
|
||||||
|
end_child: *gtk.Widget,
|
||||||
|
) *Self {
|
||||||
|
const self = gobject.ext.newInstance(Self, .{});
|
||||||
|
const priv = self.private();
|
||||||
|
priv.handle = handle;
|
||||||
|
|
||||||
|
// Setup our paned fields
|
||||||
|
const paned = priv.paned;
|
||||||
|
paned.setStartChild(start_child);
|
||||||
|
paned.setEndChild(end_child);
|
||||||
|
paned.as(gtk.Orientable).setOrientation(switch (split.layout) {
|
||||||
|
.horizontal => .horizontal,
|
||||||
|
.vertical => .vertical,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Signals and so on are setup in the template.
|
||||||
|
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(self: *Self, _: *Class) callconv(.c) void {
|
||||||
|
gtk.Widget.initTemplate(self.as(gtk.Widget));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh(self: *Self) void {
|
||||||
|
const priv = self.private();
|
||||||
|
if (priv.idle == null) priv.idle = glib.idleAdd(
|
||||||
|
onIdle,
|
||||||
|
self,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn onIdle(ud: ?*anyopaque) callconv(.c) c_int {
|
||||||
|
const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
|
||||||
|
const priv = self.private();
|
||||||
|
const paned = priv.paned;
|
||||||
|
|
||||||
|
// Our idle source is always over
|
||||||
|
priv.idle = null;
|
||||||
|
|
||||||
|
// Get our split. This is the most dangerous part of this entire
|
||||||
|
// widget. We assume that this widget is always a child of a
|
||||||
|
// SplitTree, we assume that our handle is valid, and we assume
|
||||||
|
// the handle is always a split node.
|
||||||
|
const split_tree = ext.getAncestor(
|
||||||
|
SplitTree,
|
||||||
|
self.as(gtk.Widget),
|
||||||
|
) orelse return 0;
|
||||||
|
const tree = split_tree.getTree() orelse return 0;
|
||||||
|
const split: *const Surface.Tree.Split = &tree.nodes[priv.handle].split;
|
||||||
|
|
||||||
|
// Current, min, and max positions as pixels.
|
||||||
|
const pos = paned.getPosition();
|
||||||
|
const min = min: {
|
||||||
|
var val = gobject.ext.Value.new(c_int);
|
||||||
|
defer val.unset();
|
||||||
|
gobject.Object.getProperty(
|
||||||
|
paned.as(gobject.Object),
|
||||||
|
"min-position",
|
||||||
|
&val,
|
||||||
|
);
|
||||||
|
break :min gobject.ext.Value.get(&val, c_int);
|
||||||
|
};
|
||||||
|
const max = max: {
|
||||||
|
var val = gobject.ext.Value.new(c_int);
|
||||||
|
defer val.unset();
|
||||||
|
gobject.Object.getProperty(
|
||||||
|
paned.as(gobject.Object),
|
||||||
|
"max-position",
|
||||||
|
&val,
|
||||||
|
);
|
||||||
|
break :max gobject.ext.Value.get(&val, c_int);
|
||||||
|
};
|
||||||
|
const pos_set: bool = max: {
|
||||||
|
var val = gobject.ext.Value.new(c_int);
|
||||||
|
defer val.unset();
|
||||||
|
gobject.Object.getProperty(
|
||||||
|
paned.as(gobject.Object),
|
||||||
|
"position-set",
|
||||||
|
&val,
|
||||||
|
);
|
||||||
|
break :max gobject.ext.Value.get(&val, c_int) != 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// We don't actually use min, but we don't expect this to ever
|
||||||
|
// be non-zero, so let's add an assert to ensure that.
|
||||||
|
assert(min == 0);
|
||||||
|
|
||||||
|
// If our max is zero then we can't do any math. I don't know
|
||||||
|
// if this is possible but I suspect it can be if you make a nested
|
||||||
|
// split completely minimized.
|
||||||
|
if (max == 0) return 0;
|
||||||
|
|
||||||
|
// Determine our current ratio.
|
||||||
|
const current_ratio: f64 = ratio: {
|
||||||
|
const pos_f64: f64 = @floatFromInt(pos);
|
||||||
|
const max_f64: f64 = @floatFromInt(max);
|
||||||
|
break :ratio pos_f64 / max_f64;
|
||||||
|
};
|
||||||
|
const desired_ratio: f64 = @floatCast(split.ratio);
|
||||||
|
|
||||||
|
// If our ratio is close enough to our desired ratio, then
|
||||||
|
// we ignore the update. This is to avoid constant split updates
|
||||||
|
// for lossy floating point math.
|
||||||
|
if (std.math.approxEqAbs(
|
||||||
|
f64,
|
||||||
|
current_ratio,
|
||||||
|
desired_ratio,
|
||||||
|
0.001,
|
||||||
|
)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're out of bounds, then we need to either set the position
|
||||||
|
// to what we expect OR update our expected ratio.
|
||||||
|
|
||||||
|
// If we've never set the position, then we set it to the desired.
|
||||||
|
if (!pos_set) {
|
||||||
|
const desired_pos: c_int = desired_pos: {
|
||||||
|
const max_f64: f64 = @floatFromInt(max);
|
||||||
|
break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio));
|
||||||
|
};
|
||||||
|
paned.setPosition(desired_pos);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we've set the position, then this is a manual human update
|
||||||
|
// and we need to write our update back to the tree.
|
||||||
|
tree.resizeInPlace(priv.handle, @floatCast(current_ratio));
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
//---------------------------------------------------------------
|
||||||
|
// Signal handlers
|
||||||
|
|
||||||
|
fn propPosition(
|
||||||
|
_: *gtk.Paned,
|
||||||
|
_: *gobject.ParamSpec,
|
||||||
|
self: *Self,
|
||||||
|
) callconv(.c) void {
|
||||||
|
self.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn propMaxPosition(
|
||||||
|
_: *gtk.Paned,
|
||||||
|
_: *gobject.ParamSpec,
|
||||||
|
self: *Self,
|
||||||
|
) callconv(.c) void {
|
||||||
|
self.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn propMinPosition(
|
||||||
|
_: *gtk.Paned,
|
||||||
|
_: *gobject.ParamSpec,
|
||||||
|
self: *Self,
|
||||||
|
) callconv(.c) void {
|
||||||
|
self.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
//---------------------------------------------------------------
|
||||||
|
// Virtual methods
|
||||||
|
|
||||||
|
fn dispose(self: *Self) callconv(.c) void {
|
||||||
|
const priv = self.private();
|
||||||
|
if (priv.idle) |v| {
|
||||||
|
if (glib.Source.remove(v) == 0) {
|
||||||
|
log.warn("unable to remove idle source", .{});
|
||||||
|
}
|
||||||
|
priv.idle = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
gtk.Widget.disposeTemplate(
|
||||||
|
self.as(gtk.Widget),
|
||||||
|
getGObjectType(),
|
||||||
|
);
|
||||||
|
|
||||||
|
gobject.Object.virtual_methods.dispose.call(
|
||||||
|
Class.parent,
|
||||||
|
self.as(Parent),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finalize(self: *Self) callconv(.c) void {
|
||||||
|
gobject.Object.virtual_methods.finalize.call(
|
||||||
|
Class.parent,
|
||||||
|
self.as(Parent),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const C = Common(Self, Private);
|
||||||
|
pub const as = C.as;
|
||||||
|
pub const ref = C.ref;
|
||||||
|
pub const unref = C.unref;
|
||||||
|
const private = C.private;
|
||||||
|
|
||||||
|
pub const Class = extern struct {
|
||||||
|
parent_class: Parent.Class,
|
||||||
|
var parent: *Parent.Class = undefined;
|
||||||
|
pub const Instance = Self;
|
||||||
|
|
||||||
|
fn init(class: *Class) callconv(.c) void {
|
||||||
|
gtk.Widget.Class.setTemplateFromResource(
|
||||||
|
class.as(gtk.Widget.Class),
|
||||||
|
comptime gresource.blueprint(.{
|
||||||
|
.major = 1,
|
||||||
|
.minor = 5,
|
||||||
|
.name = "split-tree-split",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bindings
|
||||||
|
class.bindTemplateChildPrivate("paned", .{});
|
||||||
|
|
||||||
|
// Template Callbacks
|
||||||
|
class.bindTemplateCallback("notify_max_position", &propMaxPosition);
|
||||||
|
class.bindTemplateCallback("notify_min_position", &propMinPosition);
|
||||||
|
class.bindTemplateCallback("notify_position", &propPosition);
|
||||||
|
|
||||||
|
// Virtual methods
|
||||||
|
gobject.Object.virtual_methods.dispose.implement(class, &dispose);
|
||||||
|
gobject.Object.virtual_methods.finalize.implement(class, &finalize);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const as = C.Class.as;
|
||||||
|
pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
|
||||||
|
pub const bindTemplateCallback = C.Class.bindTemplateCallback;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ pub const Tab = extern struct {
|
||||||
});
|
});
|
||||||
|
|
||||||
pub const properties = struct {
|
pub const properties = struct {
|
||||||
/// The active surface is the focus that should be receiving all
|
/// The active surface is the surface that should be receiving all
|
||||||
/// surface-targeted actions. This is usually the focused surface,
|
/// surface-targeted actions. This is usually the focused surface,
|
||||||
/// but may also not be focused if the user has selected a non-surface
|
/// but may also not be focused if the user has selected a non-surface
|
||||||
/// widget.
|
/// widget.
|
||||||
|
|
@ -164,66 +164,15 @@ pub const Tab = extern struct {
|
||||||
.{},
|
.{},
|
||||||
);
|
);
|
||||||
|
|
||||||
// A tab always starts with a single surface.
|
// Create our initial surface in the split tree.
|
||||||
const surface: *Surface = .new();
|
priv.split_tree.newSplit(.right, null) catch |err| switch (err) {
|
||||||
defer surface.unref();
|
error.OutOfMemory => {
|
||||||
_ = surface.refSink();
|
// TODO: We should make our "no surfaces" state more aesthetically
|
||||||
const alloc = Application.default().allocator();
|
// pleasing and show something like an "Oops, something went wrong"
|
||||||
if (Surface.Tree.init(alloc, surface)) |tree| {
|
// message. For now, this is incredibly unlikely.
|
||||||
priv.split_tree.setTree(&tree);
|
@panic("oom");
|
||||||
|
},
|
||||||
// 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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//---------------------------------------------------------------
|
//---------------------------------------------------------------
|
||||||
|
|
@ -232,13 +181,7 @@ pub const Tab = extern struct {
|
||||||
/// Get the currently active surface. See the "active-surface" property.
|
/// Get the currently active surface. See the "active-surface" property.
|
||||||
/// This does not ref the value.
|
/// 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;
|
return self.getSplitTree().getActiveSurface();
|
||||||
var it = tree.iterator();
|
|
||||||
while (it.next()) |entry| {
|
|
||||||
if (entry.view.getFocused()) return entry.view;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the surface tree of this tab.
|
/// Get the surface tree of this tab.
|
||||||
|
|
@ -299,52 +242,28 @@ pub const Tab = extern struct {
|
||||||
//---------------------------------------------------------------
|
//---------------------------------------------------------------
|
||||||
// Signal handlers
|
// Signal handlers
|
||||||
|
|
||||||
fn surfaceCloseRequest(
|
|
||||||
_: *Surface,
|
|
||||||
scope: *const Surface.CloseScope,
|
|
||||||
self: *Self,
|
|
||||||
) callconv(.c) void {
|
|
||||||
switch (scope.*) {
|
|
||||||
// Handled upstream... we don't control our window close.
|
|
||||||
.window => return,
|
|
||||||
|
|
||||||
// Presently both the same, results in the tab closing.
|
|
||||||
.surface, .tab => {
|
|
||||||
signals.@"close-request".impl.emit(
|
|
||||||
self,
|
|
||||||
null,
|
|
||||||
.{},
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
fn propSplitTree(
|
||||||
_: *SplitTree,
|
_: *SplitTree,
|
||||||
_: *gobject.ParamSpec,
|
_: *gobject.ParamSpec,
|
||||||
self: *Self,
|
self: *Self,
|
||||||
) callconv(.c) void {
|
) callconv(.c) void {
|
||||||
self.as(gobject.Object).notifyByPspec(properties.@"surface-tree".impl.param_spec);
|
self.as(gobject.Object).notifyByPspec(properties.@"surface-tree".impl.param_spec);
|
||||||
|
|
||||||
|
// If our tree is empty we close the tab.
|
||||||
|
const tree: *const Surface.Tree = self.getSurfaceTree() orelse &.empty;
|
||||||
|
if (tree.isEmpty()) {
|
||||||
|
signals.@"close-request".impl.emit(
|
||||||
|
self,
|
||||||
|
null,
|
||||||
|
.{},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn propActiveSurface(
|
fn propActiveSurface(
|
||||||
_: *Self,
|
_: *SplitTree,
|
||||||
_: *gobject.ParamSpec,
|
_: *gobject.ParamSpec,
|
||||||
self: *Self,
|
self: *Self,
|
||||||
) callconv(.c) void {
|
) callconv(.c) void {
|
||||||
|
|
@ -353,14 +272,7 @@ pub const Tab = extern struct {
|
||||||
if (self.getActiveSurface()) |surface| {
|
if (self.getActiveSurface()) |surface| {
|
||||||
priv.surface_bindings.setSource(surface.as(gobject.Object));
|
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);
|
self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -399,7 +311,6 @@ pub const Tab = extern struct {
|
||||||
class.bindTemplateChildPrivate("split_tree", .{});
|
class.bindTemplateChildPrivate("split_tree", .{});
|
||||||
|
|
||||||
// Template Callbacks
|
// Template Callbacks
|
||||||
class.bindTemplateCallback("tree_changed", &splitTreeChanged);
|
|
||||||
class.bindTemplateCallback("notify_active_surface", &propActiveSurface);
|
class.bindTemplateCallback("notify_active_surface", &propActiveSurface);
|
||||||
class.bindTemplateCallback("notify_tree", &propSplitTree);
|
class.bindTemplateCallback("notify_tree", &propSplitTree);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -335,6 +335,10 @@ pub const Window = extern struct {
|
||||||
.{ "close-tab", actionCloseTab, null },
|
.{ "close-tab", actionCloseTab, null },
|
||||||
.{ "new-tab", actionNewTab, null },
|
.{ "new-tab", actionNewTab, null },
|
||||||
.{ "new-window", actionNewWindow, null },
|
.{ "new-window", actionNewWindow, null },
|
||||||
|
.{ "split-right", actionSplitRight, null },
|
||||||
|
.{ "split-left", actionSplitLeft, null },
|
||||||
|
.{ "split-up", actionSplitUp, null },
|
||||||
|
.{ "split-down", actionSplitDown, null },
|
||||||
.{ "copy", actionCopy, null },
|
.{ "copy", actionCopy, null },
|
||||||
.{ "paste", actionPaste, null },
|
.{ "paste", actionPaste, null },
|
||||||
.{ "reset", actionReset, null },
|
.{ "reset", actionReset, null },
|
||||||
|
|
@ -1650,6 +1654,38 @@ pub const Window = extern struct {
|
||||||
self.performBindingAction(.new_tab);
|
self.performBindingAction(.new_tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn actionSplitRight(
|
||||||
|
_: *gio.SimpleAction,
|
||||||
|
_: ?*glib.Variant,
|
||||||
|
self: *Window,
|
||||||
|
) callconv(.c) void {
|
||||||
|
self.performBindingAction(.{ .new_split = .right });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn actionSplitLeft(
|
||||||
|
_: *gio.SimpleAction,
|
||||||
|
_: ?*glib.Variant,
|
||||||
|
self: *Window,
|
||||||
|
) callconv(.c) void {
|
||||||
|
self.performBindingAction(.{ .new_split = .left });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn actionSplitUp(
|
||||||
|
_: *gio.SimpleAction,
|
||||||
|
_: ?*glib.Variant,
|
||||||
|
self: *Window,
|
||||||
|
) callconv(.c) void {
|
||||||
|
self.performBindingAction(.{ .new_split = .up });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn actionSplitDown(
|
||||||
|
_: *gio.SimpleAction,
|
||||||
|
_: ?*glib.Variant,
|
||||||
|
self: *Window,
|
||||||
|
) callconv(.c) void {
|
||||||
|
self.performBindingAction(.{ .new_split = .down });
|
||||||
|
}
|
||||||
|
|
||||||
fn actionCopy(
|
fn actionCopy(
|
||||||
_: *gio.SimpleAction,
|
_: *gio.SimpleAction,
|
||||||
_: ?*glib.Variant,
|
_: ?*glib.Variant,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,8 @@
|
||||||
.transparent {
|
.transparent {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.window .split paned > separator {
|
||||||
|
background-color: rgba(36, 36, 36, 1);
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,3 +114,26 @@ label.resize-overlay {
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Splits
|
||||||
|
*/
|
||||||
|
|
||||||
|
.window .split paned > separator {
|
||||||
|
background-color: rgba(250, 250, 250, 1);
|
||||||
|
background-clip: content-box;
|
||||||
|
|
||||||
|
/* This works around the oversized drag area for the right side of GtkPaned.
|
||||||
|
*
|
||||||
|
* Upstream Gtk issue:
|
||||||
|
* https://gitlab.gnome.org/GNOME/gtk/-/issues/4484#note_2362002
|
||||||
|
*
|
||||||
|
* Ghostty issue:
|
||||||
|
* https://github.com/ghostty-org/ghostty/issues/3020
|
||||||
|
*
|
||||||
|
* Without this, it's not possible to select the first character on the
|
||||||
|
* right-hand side of a split.
|
||||||
|
*/
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -172,22 +172,22 @@ menu context_menu_model {
|
||||||
|
|
||||||
item {
|
item {
|
||||||
label: _("Split Up");
|
label: _("Split Up");
|
||||||
action: "win.split-up";
|
action: "split-tree.new-up";
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
label: _("Split Down");
|
label: _("Split Down");
|
||||||
action: "win.split-down";
|
action: "split-tree.new-down";
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
label: _("Split Left");
|
label: _("Split Left");
|
||||||
action: "win.split-left";
|
action: "split-tree.new-left";
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
label: _("Split Right");
|
label: _("Split Right");
|
||||||
action: "win.split-right";
|
action: "split-tree.new-right";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
using Gtk 4.0;
|
||||||
|
using Adw 1;
|
||||||
|
|
||||||
|
template $GhosttySplitTreeSplit: Adw.Bin {
|
||||||
|
styles [
|
||||||
|
"split",
|
||||||
|
]
|
||||||
|
|
||||||
|
// The double-nesting is required due to a GTK bug where you can't
|
||||||
|
// bind the first child of a builder layout. If you do, you get a double
|
||||||
|
// dispose. Easiest way to see that is simply remove this and see the
|
||||||
|
// GTK critical errors (and sometimes crashes).
|
||||||
|
Adw.Bin {
|
||||||
|
Paned paned {
|
||||||
|
notify::max-position => $notify_max_position();
|
||||||
|
notify::min-position => $notify_min_position();
|
||||||
|
notify::position => $notify_position();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,13 +5,12 @@ template $GhosttyTab: Box {
|
||||||
"tab",
|
"tab",
|
||||||
]
|
]
|
||||||
|
|
||||||
notify::active-surface => $notify_active_surface();
|
|
||||||
orientation: vertical;
|
orientation: vertical;
|
||||||
hexpand: true;
|
hexpand: true;
|
||||||
vexpand: true;
|
vexpand: true;
|
||||||
|
|
||||||
$GhosttySplitTree split_tree {
|
$GhosttySplitTree split_tree {
|
||||||
|
notify::active-surface => $notify_active_surface();
|
||||||
notify::tree => $notify_tree();
|
notify::tree => $notify_tree();
|
||||||
changed => $tree_changed();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,9 @@ pub fn SplitTree(comptime V: type) type {
|
||||||
|
|
||||||
/// Clone this tree, returning a new tree with the same nodes.
|
/// Clone this tree, returning a new tree with the same nodes.
|
||||||
pub fn clone(self: *const Self, gpa: Allocator) Allocator.Error!Self {
|
pub fn clone(self: *const Self, gpa: Allocator) Allocator.Error!Self {
|
||||||
|
// If we're empty then return an empty tree.
|
||||||
|
if (self.isEmpty()) return .empty;
|
||||||
|
|
||||||
// Create a new arena allocator for the clone.
|
// Create a new arena allocator for the clone.
|
||||||
var arena = ArenaAllocator.init(gpa);
|
var arena = ArenaAllocator.init(gpa);
|
||||||
errdefer arena.deinit();
|
errdefer arena.deinit();
|
||||||
|
|
@ -174,6 +177,27 @@ pub fn SplitTree(comptime V: type) type {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Resize the given node in place. The node MUST be a split (asserted).
|
||||||
|
///
|
||||||
|
/// In general, this is an immutable data structure so this is
|
||||||
|
/// heavily discouraged. However, this is provided for convenience
|
||||||
|
/// and performance reasons where its very important for GUIs to
|
||||||
|
/// update the ratio during a live resize than to redraw the entire
|
||||||
|
/// widget tree.
|
||||||
|
pub fn resizeInPlace(
|
||||||
|
self: *Self,
|
||||||
|
at: Node.Handle,
|
||||||
|
ratio: f16,
|
||||||
|
) void {
|
||||||
|
// Let's talk about this constCast. Our member are const but
|
||||||
|
// we actually always own their memory. We don't want consumers
|
||||||
|
// who directly access the nodes to be able to modify them
|
||||||
|
// (without nasty stuff like this), but given this is internal
|
||||||
|
// usage its perfectly fine to modify the node in-place.
|
||||||
|
const s: *Split = @constCast(&self.nodes[at].split);
|
||||||
|
s.ratio = ratio;
|
||||||
|
}
|
||||||
|
|
||||||
/// Insert another tree into this tree at the given node in the
|
/// Insert another tree into this tree at the given node in the
|
||||||
/// specified direction. The other tree will be inserted in the
|
/// specified direction. The other tree will be inserted in the
|
||||||
/// new direction. For example, if the direction is "right" then
|
/// new direction. For example, if the direction is "right" then
|
||||||
|
|
@ -409,22 +433,7 @@ pub fn SplitTree(comptime V: type) type {
|
||||||
assert(reffed == nodes.len - 1);
|
assert(reffed == nodes.len - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spatial representation of the split tree. This can be used to
|
/// Spatial representation of the split tree. See spatial.
|
||||||
/// 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 {
|
pub const Spatial = struct {
|
||||||
/// The slots of the spatial representation in the same order
|
/// The slots of the spatial representation in the same order
|
||||||
/// as the tree it was created from.
|
/// as the tree it was created from.
|
||||||
|
|
@ -445,8 +454,22 @@ pub fn SplitTree(comptime V: type) type {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Returns the spatial representation of this tree. See Spatial
|
/// Spatial representation of the split tree. This can be used to
|
||||||
/// for more details.
|
/// 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 fn spatial(
|
pub fn spatial(
|
||||||
self: *const Self,
|
self: *const Self,
|
||||||
alloc: Allocator,
|
alloc: Allocator,
|
||||||
|
|
@ -549,14 +572,20 @@ pub fn SplitTree(comptime V: type) type {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format the tree in a human-readable format.
|
/// Format the tree in a human-readable format. By default this will
|
||||||
|
/// output a diagram followed by a textual representation. This can
|
||||||
|
/// be controlled via the formatting string:
|
||||||
|
///
|
||||||
|
/// - `diagram` - Output a diagram of the split tree only.
|
||||||
|
/// - `text` - Output a textual representation of the split tree only.
|
||||||
|
/// - Empty - Output both a diagram and a textual representation.
|
||||||
|
///
|
||||||
pub fn format(
|
pub fn format(
|
||||||
self: *const Self,
|
self: *const Self,
|
||||||
comptime fmt: []const u8,
|
comptime fmt: []const u8,
|
||||||
options: std.fmt.FormatOptions,
|
options: std.fmt.FormatOptions,
|
||||||
writer: anytype,
|
writer: anytype,
|
||||||
) !void {
|
) !void {
|
||||||
_ = fmt;
|
|
||||||
_ = options;
|
_ = options;
|
||||||
|
|
||||||
if (self.nodes.len == 0) {
|
if (self.nodes.len == 0) {
|
||||||
|
|
@ -564,6 +593,48 @@ pub fn SplitTree(comptime V: type) type {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (std.mem.eql(u8, fmt, "diagram")) {
|
||||||
|
self.formatDiagram(writer) catch
|
||||||
|
try writer.writeAll("failed to draw split tree diagram");
|
||||||
|
} else if (std.mem.eql(u8, fmt, "text")) {
|
||||||
|
try self.formatText(writer, 0, 0);
|
||||||
|
} else if (fmt.len == 0) {
|
||||||
|
self.formatDiagram(writer) catch {};
|
||||||
|
try self.formatText(writer, 0, 0);
|
||||||
|
} else {
|
||||||
|
return error.InvalidFormat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn formatText(
|
||||||
|
self: *const Self,
|
||||||
|
writer: anytype,
|
||||||
|
current: Node.Handle,
|
||||||
|
depth: usize,
|
||||||
|
) !void {
|
||||||
|
for (0..depth) |_| try writer.writeAll(" ");
|
||||||
|
|
||||||
|
switch (self.nodes[current]) {
|
||||||
|
.leaf => |v| if (@hasDecl(View, "splitTreeLabel"))
|
||||||
|
try writer.print("leaf: {s}\n", .{v.splitTreeLabel()})
|
||||||
|
else
|
||||||
|
try writer.print("leaf: {d}\n", .{current}),
|
||||||
|
|
||||||
|
.split => |s| {
|
||||||
|
try writer.print("split (layout: {s}, ratio: {d:.2})\n", .{
|
||||||
|
@tagName(s.layout),
|
||||||
|
s.ratio,
|
||||||
|
});
|
||||||
|
try self.formatText(writer, s.left, depth + 1);
|
||||||
|
try self.formatText(writer, s.right, depth + 1);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn formatDiagram(
|
||||||
|
self: *const Self,
|
||||||
|
writer: anytype,
|
||||||
|
) !void {
|
||||||
// Use our arena's GPA to allocate some intermediate memory.
|
// Use our arena's GPA to allocate some intermediate memory.
|
||||||
// Requiring allocation for formatting is nasty but this is really
|
// Requiring allocation for formatting is nasty but this is really
|
||||||
// only used for debugging and testing and shouldn't hit OOM
|
// only used for debugging and testing and shouldn't hit OOM
|
||||||
|
|
@ -573,7 +644,29 @@ pub fn SplitTree(comptime V: type) type {
|
||||||
const alloc = arena.allocator();
|
const alloc = arena.allocator();
|
||||||
|
|
||||||
// Get our spatial representation.
|
// Get our spatial representation.
|
||||||
const sp = try self.spatial(alloc);
|
const sp = spatial: {
|
||||||
|
const sp = try self.spatial(alloc);
|
||||||
|
|
||||||
|
// Scale our spatial representation to have minimum width/height 1.
|
||||||
|
var min_w: f16 = 1;
|
||||||
|
var min_h: f16 = 1;
|
||||||
|
for (sp.slots) |slot| {
|
||||||
|
min_w = @min(min_w, slot.width);
|
||||||
|
min_h = @min(min_h, slot.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratio_w: f16 = 1 / min_w;
|
||||||
|
const ratio_h: f16 = 1 / min_h;
|
||||||
|
const slots = try alloc.dupe(Spatial.Slot, sp.slots);
|
||||||
|
for (slots) |*slot| {
|
||||||
|
slot.x *= ratio_w;
|
||||||
|
slot.y *= ratio_h;
|
||||||
|
slot.width *= ratio_w;
|
||||||
|
slot.height *= ratio_h;
|
||||||
|
}
|
||||||
|
|
||||||
|
break :spatial .{ .slots = slots };
|
||||||
|
};
|
||||||
|
|
||||||
// The width we need for the largest label.
|
// The width we need for the largest label.
|
||||||
const max_label_width: usize = max_label_width: {
|
const max_label_width: usize = max_label_width: {
|
||||||
|
|
@ -610,6 +703,8 @@ pub fn SplitTree(comptime V: type) type {
|
||||||
// the width/height based on node 0.
|
// the width/height based on node 0.
|
||||||
const grid = grid: {
|
const grid = grid: {
|
||||||
// Get our initial width/height. Each leaf is 1x1 in this.
|
// Get our initial width/height. Each leaf is 1x1 in this.
|
||||||
|
// We round up for this because partial widths/heights should
|
||||||
|
// take up an extra cell.
|
||||||
var width: usize = @intFromFloat(@ceil(sp.slots[0].width));
|
var width: usize = @intFromFloat(@ceil(sp.slots[0].width));
|
||||||
var height: usize = @intFromFloat(@ceil(sp.slots[0].height));
|
var height: usize = @intFromFloat(@ceil(sp.slots[0].height));
|
||||||
|
|
||||||
|
|
@ -637,10 +732,10 @@ pub fn SplitTree(comptime V: type) type {
|
||||||
.split => continue,
|
.split => continue,
|
||||||
}
|
}
|
||||||
|
|
||||||
var x: usize = @intFromFloat(@ceil(slot.x));
|
var x: usize = @intFromFloat(@floor(slot.x));
|
||||||
var y: usize = @intFromFloat(@ceil(slot.y));
|
var y: usize = @intFromFloat(@floor(slot.y));
|
||||||
var width: usize = @intFromFloat(@ceil(slot.width));
|
var width: usize = @intFromFloat(@max(@floor(slot.width), 1));
|
||||||
var height: usize = @intFromFloat(@ceil(slot.height));
|
var height: usize = @intFromFloat(@max(@floor(slot.height), 1));
|
||||||
x *= cell_width;
|
x *= cell_width;
|
||||||
y *= cell_height;
|
y *= cell_height;
|
||||||
width *= cell_width;
|
width *= cell_width;
|
||||||
|
|
@ -731,8 +826,10 @@ pub fn SplitTree(comptime V: type) type {
|
||||||
.copy = &struct {
|
.copy = &struct {
|
||||||
fn copy(self: *Self) callconv(.c) *Self {
|
fn copy(self: *Self) callconv(.c) *Self {
|
||||||
const ptr = @import("glib").ext.create(Self);
|
const ptr = @import("glib").ext.create(Self);
|
||||||
const alloc = self.arena.child_allocator;
|
ptr.* = if (self.nodes.len == 0)
|
||||||
ptr.* = self.clone(alloc) catch @panic("oom");
|
.empty
|
||||||
|
else
|
||||||
|
self.clone(self.arena.child_allocator) catch @panic("oom");
|
||||||
return ptr;
|
return ptr;
|
||||||
}
|
}
|
||||||
}.copy,
|
}.copy,
|
||||||
|
|
@ -793,7 +890,7 @@ test "SplitTree: single node" {
|
||||||
var t: TestTree = try .init(alloc, &v);
|
var t: TestTree = try .init(alloc, &v);
|
||||||
defer t.deinit();
|
defer t.deinit();
|
||||||
|
|
||||||
const str = try std.fmt.allocPrint(alloc, "{}", .{t});
|
const str = try std.fmt.allocPrint(alloc, "{diagram}", .{t});
|
||||||
defer alloc.free(str);
|
defer alloc.free(str);
|
||||||
try testing.expectEqualStrings(str,
|
try testing.expectEqualStrings(str,
|
||||||
\\+---+
|
\\+---+
|
||||||
|
|
@ -806,13 +903,13 @@ test "SplitTree: single node" {
|
||||||
test "SplitTree: split horizontal" {
|
test "SplitTree: split horizontal" {
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
const alloc = testing.allocator;
|
const alloc = testing.allocator;
|
||||||
|
|
||||||
var v1: TestTree.View = .{ .label = "A" };
|
var v1: TestTree.View = .{ .label = "A" };
|
||||||
var t1: TestTree = try .init(alloc, &v1);
|
var t1: TestTree = try .init(alloc, &v1);
|
||||||
defer t1.deinit();
|
defer t1.deinit();
|
||||||
var v2: TestTree.View = .{ .label = "B" };
|
var v2: TestTree.View = .{ .label = "B" };
|
||||||
var t2: TestTree = try .init(alloc, &v2);
|
var t2: TestTree = try .init(alloc, &v2);
|
||||||
defer t2.deinit();
|
defer t2.deinit();
|
||||||
|
|
||||||
var t3 = try t1.split(
|
var t3 = try t1.split(
|
||||||
alloc,
|
alloc,
|
||||||
0, // at root
|
0, // at root
|
||||||
|
|
@ -821,14 +918,87 @@ test "SplitTree: split horizontal" {
|
||||||
);
|
);
|
||||||
defer t3.deinit();
|
defer t3.deinit();
|
||||||
|
|
||||||
const str = try std.fmt.allocPrint(alloc, "{}", .{t3});
|
{
|
||||||
defer alloc.free(str);
|
const str = try std.fmt.allocPrint(alloc, "{}", .{t3});
|
||||||
try testing.expectEqualStrings(str,
|
defer alloc.free(str);
|
||||||
\\+---++---+
|
try testing.expectEqualStrings(str,
|
||||||
\\| A || B |
|
\\+---++---+
|
||||||
\\+---++---+
|
\\| A || B |
|
||||||
\\
|
\\+---++---+
|
||||||
|
\\split (layout: horizontal, ratio: 0.50)
|
||||||
|
\\ leaf: A
|
||||||
|
\\ leaf: B
|
||||||
|
\\
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split right at B
|
||||||
|
var vC: TestTree.View = .{ .label = "C" };
|
||||||
|
var tC: TestTree = try .init(alloc, &vC);
|
||||||
|
defer tC.deinit();
|
||||||
|
var it = t3.iterator();
|
||||||
|
var t4 = try t3.split(
|
||||||
|
alloc,
|
||||||
|
while (it.next()) |entry| {
|
||||||
|
if (std.mem.eql(u8, entry.view.label, "B")) {
|
||||||
|
break entry.handle;
|
||||||
|
}
|
||||||
|
} else return error.NotFound,
|
||||||
|
.right,
|
||||||
|
&tC,
|
||||||
);
|
);
|
||||||
|
defer t4.deinit();
|
||||||
|
|
||||||
|
{
|
||||||
|
const str = try std.fmt.allocPrint(alloc, "{}", .{t4});
|
||||||
|
defer alloc.free(str);
|
||||||
|
try testing.expectEqualStrings(str,
|
||||||
|
\\+--------++---++---+
|
||||||
|
\\| A || B || C |
|
||||||
|
\\+--------++---++---+
|
||||||
|
\\split (layout: horizontal, ratio: 0.50)
|
||||||
|
\\ leaf: A
|
||||||
|
\\ split (layout: horizontal, ratio: 0.50)
|
||||||
|
\\ leaf: B
|
||||||
|
\\ leaf: C
|
||||||
|
\\
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split right at C
|
||||||
|
var vD: TestTree.View = .{ .label = "D" };
|
||||||
|
var tD: TestTree = try .init(alloc, &vD);
|
||||||
|
defer tD.deinit();
|
||||||
|
it = t4.iterator();
|
||||||
|
var t5 = try t4.split(
|
||||||
|
alloc,
|
||||||
|
while (it.next()) |entry| {
|
||||||
|
if (std.mem.eql(u8, entry.view.label, "C")) {
|
||||||
|
break entry.handle;
|
||||||
|
}
|
||||||
|
} else return error.NotFound,
|
||||||
|
.right,
|
||||||
|
&tD,
|
||||||
|
);
|
||||||
|
defer t5.deinit();
|
||||||
|
|
||||||
|
{
|
||||||
|
const str = try std.fmt.allocPrint(alloc, "{}", .{t5});
|
||||||
|
defer alloc.free(str);
|
||||||
|
try testing.expectEqualStrings(
|
||||||
|
\\+------------------++--------++---++---+
|
||||||
|
\\| A || B || C || D |
|
||||||
|
\\+------------------++--------++---++---+
|
||||||
|
\\split (layout: horizontal, ratio: 0.50)
|
||||||
|
\\ leaf: A
|
||||||
|
\\ split (layout: horizontal, ratio: 0.50)
|
||||||
|
\\ leaf: B
|
||||||
|
\\ split (layout: horizontal, ratio: 0.50)
|
||||||
|
\\ leaf: C
|
||||||
|
\\ leaf: D
|
||||||
|
\\
|
||||||
|
, str);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test "SplitTree: split vertical" {
|
test "SplitTree: split vertical" {
|
||||||
|
|
@ -850,7 +1020,7 @@ test "SplitTree: split vertical" {
|
||||||
);
|
);
|
||||||
defer t3.deinit();
|
defer t3.deinit();
|
||||||
|
|
||||||
const str = try std.fmt.allocPrint(alloc, "{}", .{t3});
|
const str = try std.fmt.allocPrint(alloc, "{diagram}", .{t3});
|
||||||
defer alloc.free(str);
|
defer alloc.free(str);
|
||||||
try testing.expectEqualStrings(str,
|
try testing.expectEqualStrings(str,
|
||||||
\\+---+
|
\\+---+
|
||||||
|
|
@ -893,7 +1063,7 @@ test "SplitTree: remove leaf" {
|
||||||
);
|
);
|
||||||
defer t4.deinit();
|
defer t4.deinit();
|
||||||
|
|
||||||
const str = try std.fmt.allocPrint(alloc, "{}", .{t4});
|
const str = try std.fmt.allocPrint(alloc, "{diagram}", .{t4});
|
||||||
defer alloc.free(str);
|
defer alloc.free(str);
|
||||||
try testing.expectEqualStrings(str,
|
try testing.expectEqualStrings(str,
|
||||||
\\+---+
|
\\+---+
|
||||||
|
|
@ -936,7 +1106,7 @@ test "SplitTree: split twice, remove intermediary" {
|
||||||
defer split2.deinit();
|
defer split2.deinit();
|
||||||
|
|
||||||
{
|
{
|
||||||
const str = try std.fmt.allocPrint(alloc, "{}", .{split2});
|
const str = try std.fmt.allocPrint(alloc, "{diagram}", .{split2});
|
||||||
defer alloc.free(str);
|
defer alloc.free(str);
|
||||||
try testing.expectEqualStrings(str,
|
try testing.expectEqualStrings(str,
|
||||||
\\+---++---+
|
\\+---++---+
|
||||||
|
|
@ -962,7 +1132,7 @@ test "SplitTree: split twice, remove intermediary" {
|
||||||
defer split3.deinit();
|
defer split3.deinit();
|
||||||
|
|
||||||
{
|
{
|
||||||
const str = try std.fmt.allocPrint(alloc, "{}", .{split3});
|
const str = try std.fmt.allocPrint(alloc, "{diagram}", .{split3});
|
||||||
defer alloc.free(str);
|
defer alloc.free(str);
|
||||||
try testing.expectEqualStrings(str,
|
try testing.expectEqualStrings(str,
|
||||||
\\+---+
|
\\+---+
|
||||||
|
|
@ -983,3 +1153,21 @@ test "SplitTree: split twice, remove intermediary" {
|
||||||
t.deinit();
|
t.deinit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "SplitTree: clone empty tree" {
|
||||||
|
const testing = std.testing;
|
||||||
|
const alloc = testing.allocator;
|
||||||
|
var t: TestTree = .empty;
|
||||||
|
defer t.deinit();
|
||||||
|
|
||||||
|
var t2 = try t.clone(alloc);
|
||||||
|
defer t2.deinit();
|
||||||
|
|
||||||
|
{
|
||||||
|
const str = try std.fmt.allocPrint(alloc, "{}", .{t2});
|
||||||
|
defer alloc.free(str);
|
||||||
|
try testing.expectEqualStrings(str,
|
||||||
|
\\empty
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,34 @@
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Reproduction:
|
||||||
|
#
|
||||||
|
# 1. Launch Ghostty
|
||||||
|
# 2. Split Right
|
||||||
|
# 3. Hit "X" to close
|
||||||
|
{
|
||||||
|
GTK CSS Node State
|
||||||
|
Memcheck:Leak
|
||||||
|
match-leak-kinds: possible
|
||||||
|
fun:malloc
|
||||||
|
fun:g_malloc
|
||||||
|
fun:g_memdup2
|
||||||
|
fun:gtk_css_node_declaration_set_state
|
||||||
|
fun:gtk_css_node_set_state
|
||||||
|
fun:gtk_widget_propagate_state
|
||||||
|
fun:gtk_widget_update_state_flags
|
||||||
|
fun:gtk_main_do_event
|
||||||
|
fun:surface_event
|
||||||
|
fun:_gdk_marshal_BOOLEAN__POINTERv
|
||||||
|
fun:gdk_surface_event_marshallerv
|
||||||
|
fun:_g_closure_invoke_va
|
||||||
|
fun:signal_emit_valist_unlocked
|
||||||
|
fun:g_signal_emit_valist
|
||||||
|
fun:g_signal_emit
|
||||||
|
fun:gdk_surface_handle_event
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
GTK CSS Provider Leak
|
GTK CSS Provider Leak
|
||||||
Memcheck:Leak
|
Memcheck:Leak
|
||||||
|
|
@ -516,9 +544,7 @@
|
||||||
pango font map
|
pango font map
|
||||||
Memcheck:Leak
|
Memcheck:Leak
|
||||||
match-leak-kinds: possible
|
match-leak-kinds: possible
|
||||||
fun:calloc
|
...
|
||||||
fun:g_malloc0
|
|
||||||
fun:g_rc_box_alloc_full
|
|
||||||
fun:pango_fc_font_map_load_fontset
|
fun:pango_fc_font_map_load_fontset
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue