mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-09-05 19:08:17 +00:00
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 keybind
This commit is contained in:
@@ -41,6 +41,7 @@ pub const blueprints: []const Blueprint = &.{
|
||||
.{ .major = 1, .minor = 3, .name = "debug-warning" },
|
||||
.{ .major = 1, .minor = 2, .name = "resize-overlay" },
|
||||
.{ .major = 1, .minor = 5, .name = "split-tree" },
|
||||
.{ .major = 1, .minor = 5, .name = "split-tree-split" },
|
||||
.{ .major = 1, .minor = 2, .name = "surface" },
|
||||
.{ .major = 1, .minor = 3, .name = "surface-child-exited" },
|
||||
.{ .major = 1, .minor = 5, .name = "tab" },
|
||||
|
@@ -562,6 +562,8 @@ pub const Application = extern struct {
|
||||
|
||||
.move_tab => return Action.moveTab(target, value),
|
||||
|
||||
.new_split => return Action.newSplit(target, value),
|
||||
|
||||
.new_tab => return Action.newTab(target),
|
||||
|
||||
.new_window => try Action.newWindow(
|
||||
@@ -611,7 +613,6 @@ pub const Application = extern struct {
|
||||
.prompt_title,
|
||||
.inspector,
|
||||
// TODO: splits
|
||||
.new_split,
|
||||
.resize_split,
|
||||
.equalize_splits,
|
||||
.goto_split,
|
||||
@@ -881,6 +882,10 @@ pub const Application = extern struct {
|
||||
self.syncActionAccelerator("win.reset", .{ .reset = {} });
|
||||
self.syncActionAccelerator("win.clear", .{ .clear_screen = {} });
|
||||
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(
|
||||
@@ -1257,6 +1262,7 @@ pub const Application = extern struct {
|
||||
diag.close();
|
||||
diag.unref(); // strong ref from get()
|
||||
}
|
||||
priv.config_errors_dialog.set(null);
|
||||
if (priv.signal_source) |v| {
|
||||
if (glib.Source.remove(v) == 0) {
|
||||
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 {
|
||||
switch (target) {
|
||||
.app => {
|
||||
|
@@ -133,6 +133,7 @@ pub const CloseConfirmationDialog = 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;
|
||||
|
||||
@@ -179,12 +180,14 @@ pub const Target = enum(c_int) {
|
||||
app,
|
||||
tab,
|
||||
window,
|
||||
surface,
|
||||
|
||||
pub fn title(self: Target) [*:0]const u8 {
|
||||
return switch (self) {
|
||||
.app => i18n._("Quit Ghostty?"),
|
||||
.tab => i18n._("Close Tab?"),
|
||||
.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."),
|
||||
.tab => i18n._("All terminal sessions in this tab 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 build_config = @import("../../../build_config.zig");
|
||||
const assert = std.debug.assert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const adw = @import("adw");
|
||||
const gio = @import("gio");
|
||||
const glib = @import("glib");
|
||||
@@ -16,6 +17,7 @@ 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 WeakRef = @import("../weak_ref.zig").WeakRef;
|
||||
const Config = @import("config.zig").Config;
|
||||
const Application = @import("application.zig").Application;
|
||||
const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog;
|
||||
@@ -36,6 +38,28 @@ pub const SplitTree = extern 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 name = "has-surfaces";
|
||||
const impl = gobject.ext.defineProperty(
|
||||
@@ -93,16 +117,237 @@ pub const SplitTree = extern struct {
|
||||
// Template bindings
|
||||
tree_bin: *adw.Bin,
|
||||
|
||||
/// Last focused surface in the tree. We need this to handle various
|
||||
/// tree change states.
|
||||
last_focused: WeakRef(Surface) = .{},
|
||||
|
||||
/// 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;
|
||||
};
|
||||
|
||||
fn init(self: *Self, _: *Class) callconv(.c) void {
|
||||
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
|
||||
|
||||
/// 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 {
|
||||
const tree: *const Surface.Tree = self.private().tree orelse &.empty;
|
||||
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
|
||||
/// 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();
|
||||
|
||||
// We always normalize our tree parameter so that empty trees
|
||||
// become null so that we don't have to deal with callers being
|
||||
// confused about that.
|
||||
const tree: ?*const Surface.Tree = tree: {
|
||||
const tree = tree_ orelse break :tree null;
|
||||
if (tree.isEmpty()) break :tree null;
|
||||
break :tree tree;
|
||||
};
|
||||
|
||||
// Emit the signal so that handlers can witness both the before and
|
||||
// after values of the tree.
|
||||
signals.changed.impl.emit(
|
||||
@@ -129,12 +383,16 @@ pub const SplitTree = extern struct {
|
||||
);
|
||||
|
||||
if (priv.tree) |old_tree| {
|
||||
self.disconnectSurfaceHandlers();
|
||||
ext.boxedFree(Surface.Tree, old_tree);
|
||||
priv.tree = null;
|
||||
}
|
||||
|
||||
if (tree) |new_tree| {
|
||||
assert(priv.tree == null);
|
||||
assert(!new_tree.isEmpty());
|
||||
priv.tree = ext.boxedCopy(Surface.Tree, new_tree);
|
||||
self.connectSurfaceHandlers();
|
||||
}
|
||||
|
||||
self.as(gobject.Object).notifyByPspec(properties.tree.impl.param_spec);
|
||||
@@ -158,6 +416,15 @@ pub const SplitTree = extern struct {
|
||||
// Virtual methods
|
||||
|
||||
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(
|
||||
self.as(gtk.Widget),
|
||||
getGObjectType(),
|
||||
@@ -185,51 +452,273 @@ pub const SplitTree = extern struct {
|
||||
//---------------------------------------------------------------
|
||||
// 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(
|
||||
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.
|
||||
// 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);
|
||||
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
|
||||
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.
|
||||
///
|
||||
/// The final returned widget is expected to be a floating reference,
|
||||
/// ready to be attached to a parent widget.
|
||||
fn buildTree(
|
||||
self: *Self,
|
||||
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
|
||||
},
|
||||
return switch (tree.nodes[current]) {
|
||||
.leaf => |v| v.as(gtk.Widget),
|
||||
.split => |s| SplitTreeSplit.new(
|
||||
current,
|
||||
&s,
|
||||
self.buildTree(tree, s.left),
|
||||
self.buildTree(tree, s.right),
|
||||
).as(gtk.Widget),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------
|
||||
@@ -259,6 +748,7 @@ pub const SplitTree = extern struct {
|
||||
|
||||
// Properties
|
||||
gobject.ext.registerProperties(class, &.{
|
||||
properties.@"active-surface".impl,
|
||||
properties.@"has-surfaces".impl,
|
||||
properties.tree.impl,
|
||||
});
|
||||
@@ -282,3 +772,280 @@ pub const SplitTree = extern struct {
|
||||
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 {
|
||||
/// 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,
|
||||
/// but may also not be focused if the user has selected a non-surface
|
||||
/// widget.
|
||||
@@ -164,66 +164,15 @@ pub const Tab = extern struct {
|
||||
.{},
|
||||
);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
// Create our initial surface in the split tree.
|
||||
priv.split_tree.newSplit(.right, null) catch |err| switch (err) {
|
||||
error.OutOfMemory => {
|
||||
// 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");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------
|
||||
@@ -232,13 +181,7 @@ 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 {
|
||||
const tree = self.getSurfaceTree() orelse return null;
|
||||
var it = tree.iterator();
|
||||
while (it.next()) |entry| {
|
||||
if (entry.view.getFocused()) return entry.view;
|
||||
}
|
||||
|
||||
return null;
|
||||
return self.getSplitTree().getActiveSurface();
|
||||
}
|
||||
|
||||
/// Get the surface tree of this tab.
|
||||
@@ -299,52 +242,28 @@ pub const Tab = extern struct {
|
||||
//---------------------------------------------------------------
|
||||
// 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(
|
||||
_: *SplitTree,
|
||||
_: *gobject.ParamSpec,
|
||||
self: *Self,
|
||||
) callconv(.c) void {
|
||||
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(
|
||||
_: *Self,
|
||||
_: *SplitTree,
|
||||
_: *gobject.ParamSpec,
|
||||
self: *Self,
|
||||
) callconv(.c) void {
|
||||
@@ -353,14 +272,7 @@ pub const Tab = extern struct {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -399,7 +311,6 @@ pub const Tab = extern struct {
|
||||
class.bindTemplateChildPrivate("split_tree", .{});
|
||||
|
||||
// Template Callbacks
|
||||
class.bindTemplateCallback("tree_changed", &splitTreeChanged);
|
||||
class.bindTemplateCallback("notify_active_surface", &propActiveSurface);
|
||||
class.bindTemplateCallback("notify_tree", &propSplitTree);
|
||||
|
||||
|
@@ -335,6 +335,10 @@ pub const Window = extern struct {
|
||||
.{ "close-tab", actionCloseTab, null },
|
||||
.{ "new-tab", actionNewTab, null },
|
||||
.{ "new-window", actionNewWindow, null },
|
||||
.{ "split-right", actionSplitRight, null },
|
||||
.{ "split-left", actionSplitLeft, null },
|
||||
.{ "split-up", actionSplitUp, null },
|
||||
.{ "split-down", actionSplitDown, null },
|
||||
.{ "copy", actionCopy, null },
|
||||
.{ "paste", actionPaste, null },
|
||||
.{ "reset", actionReset, null },
|
||||
@@ -1650,6 +1654,38 @@ pub const Window = extern struct {
|
||||
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(
|
||||
_: *gio.SimpleAction,
|
||||
_: ?*glib.Variant,
|
||||
|
@@ -1,3 +1,8 @@
|
||||
.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-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 {
|
||||
label: _("Split Up");
|
||||
action: "win.split-up";
|
||||
action: "split-tree.new-up";
|
||||
}
|
||||
|
||||
item {
|
||||
label: _("Split Down");
|
||||
action: "win.split-down";
|
||||
action: "split-tree.new-down";
|
||||
}
|
||||
|
||||
item {
|
||||
label: _("Split Left");
|
||||
action: "win.split-left";
|
||||
action: "split-tree.new-left";
|
||||
}
|
||||
|
||||
item {
|
||||
label: _("Split Right");
|
||||
action: "win.split-right";
|
||||
action: "split-tree.new-right";
|
||||
}
|
||||
}
|
||||
|
||||
|
20
src/apprt/gtk-ng/ui/1.5/split-tree-split.blp
Normal file
20
src/apprt/gtk-ng/ui/1.5/split-tree-split.blp
Normal file
@@ -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",
|
||||
]
|
||||
|
||||
notify::active-surface => $notify_active_surface();
|
||||
orientation: vertical;
|
||||
hexpand: true;
|
||||
vexpand: true;
|
||||
|
||||
$GhosttySplitTree split_tree {
|
||||
notify::active-surface => $notify_active_surface();
|
||||
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.
|
||||
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.
|
||||
var arena = ArenaAllocator.init(gpa);
|
||||
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
|
||||
/// specified direction. The other tree will be inserted in the
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Spatial representation of the split tree. See spatial.
|
||||
pub const Spatial = struct {
|
||||
/// The slots of the spatial representation in the same order
|
||||
/// 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
|
||||
/// for more details.
|
||||
/// 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 fn spatial(
|
||||
self: *const Self,
|
||||
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(
|
||||
self: *const Self,
|
||||
comptime fmt: []const u8,
|
||||
options: std.fmt.FormatOptions,
|
||||
writer: anytype,
|
||||
) !void {
|
||||
_ = fmt;
|
||||
_ = options;
|
||||
|
||||
if (self.nodes.len == 0) {
|
||||
@@ -564,6 +593,48 @@ pub fn SplitTree(comptime V: type) type {
|
||||
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.
|
||||
// Requiring allocation for formatting is nasty but this is really
|
||||
// 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();
|
||||
|
||||
// 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.
|
||||
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.
|
||||
const grid = grid: {
|
||||
// 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 height: usize = @intFromFloat(@ceil(sp.slots[0].height));
|
||||
|
||||
@@ -637,10 +732,10 @@ pub fn SplitTree(comptime V: type) type {
|
||||
.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));
|
||||
var x: usize = @intFromFloat(@floor(slot.x));
|
||||
var y: usize = @intFromFloat(@floor(slot.y));
|
||||
var width: usize = @intFromFloat(@max(@floor(slot.width), 1));
|
||||
var height: usize = @intFromFloat(@max(@floor(slot.height), 1));
|
||||
x *= cell_width;
|
||||
y *= cell_height;
|
||||
width *= cell_width;
|
||||
@@ -731,8 +826,10 @@ pub fn SplitTree(comptime V: type) type {
|
||||
.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");
|
||||
ptr.* = if (self.nodes.len == 0)
|
||||
.empty
|
||||
else
|
||||
self.clone(self.arena.child_allocator) catch @panic("oom");
|
||||
return ptr;
|
||||
}
|
||||
}.copy,
|
||||
@@ -793,7 +890,7 @@ test "SplitTree: single node" {
|
||||
var t: TestTree = try .init(alloc, &v);
|
||||
defer t.deinit();
|
||||
|
||||
const str = try std.fmt.allocPrint(alloc, "{}", .{t});
|
||||
const str = try std.fmt.allocPrint(alloc, "{diagram}", .{t});
|
||||
defer alloc.free(str);
|
||||
try testing.expectEqualStrings(str,
|
||||
\\+---+
|
||||
@@ -806,13 +903,13 @@ test "SplitTree: single node" {
|
||||
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
|
||||
@@ -821,14 +918,87 @@ test "SplitTree: split horizontal" {
|
||||
);
|
||||
defer t3.deinit();
|
||||
|
||||
const str = try std.fmt.allocPrint(alloc, "{}", .{t3});
|
||||
defer alloc.free(str);
|
||||
try testing.expectEqualStrings(str,
|
||||
\\+---++---+
|
||||
\\| A || B |
|
||||
\\+---++---+
|
||||
\\
|
||||
{
|
||||
const str = try std.fmt.allocPrint(alloc, "{}", .{t3});
|
||||
defer alloc.free(str);
|
||||
try testing.expectEqualStrings(str,
|
||||
\\+---++---+
|
||||
\\| 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" {
|
||||
@@ -850,7 +1020,7 @@ test "SplitTree: split vertical" {
|
||||
);
|
||||
defer t3.deinit();
|
||||
|
||||
const str = try std.fmt.allocPrint(alloc, "{}", .{t3});
|
||||
const str = try std.fmt.allocPrint(alloc, "{diagram}", .{t3});
|
||||
defer alloc.free(str);
|
||||
try testing.expectEqualStrings(str,
|
||||
\\+---+
|
||||
@@ -893,7 +1063,7 @@ test "SplitTree: remove leaf" {
|
||||
);
|
||||
defer t4.deinit();
|
||||
|
||||
const str = try std.fmt.allocPrint(alloc, "{}", .{t4});
|
||||
const str = try std.fmt.allocPrint(alloc, "{diagram}", .{t4});
|
||||
defer alloc.free(str);
|
||||
try testing.expectEqualStrings(str,
|
||||
\\+---+
|
||||
@@ -936,7 +1106,7 @@ test "SplitTree: split twice, remove intermediary" {
|
||||
defer split2.deinit();
|
||||
|
||||
{
|
||||
const str = try std.fmt.allocPrint(alloc, "{}", .{split2});
|
||||
const str = try std.fmt.allocPrint(alloc, "{diagram}", .{split2});
|
||||
defer alloc.free(str);
|
||||
try testing.expectEqualStrings(str,
|
||||
\\+---++---+
|
||||
@@ -962,7 +1132,7 @@ test "SplitTree: split twice, remove intermediary" {
|
||||
defer split3.deinit();
|
||||
|
||||
{
|
||||
const str = try std.fmt.allocPrint(alloc, "{}", .{split3});
|
||||
const str = try std.fmt.allocPrint(alloc, "{diagram}", .{split3});
|
||||
defer alloc.free(str);
|
||||
try testing.expectEqualStrings(str,
|
||||
\\+---+
|
||||
@@ -983,3 +1153,21 @@ test "SplitTree: split twice, remove intermediary" {
|
||||
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
|
||||
Memcheck:Leak
|
||||
@@ -516,9 +544,7 @@
|
||||
pango font map
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: possible
|
||||
fun:calloc
|
||||
fun:g_malloc0
|
||||
fun:g_rc_box_alloc_full
|
||||
...
|
||||
fun:pango_fc_font_map_load_fontset
|
||||
...
|
||||
}
|
||||
|
Reference in New Issue
Block a user