mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-06 07:38:21 +00:00
splits: make resize_split and toggle_split_zoom non-performable with single pane (#10376)
Refer to discussion #10000 When a tab contains only a single split, resize_split and toggle_split_zoom actions now return false (not performed). This allows keybindings marked with `performable: true` to pass the event through to the terminal program. The performable flag causes unperformed actions to be treated as if the binding didn't exist, so the key event is sent to the terminal instead of being consumed. - Add isSplit() helper to SplitTree to detect single-pane vs split state - Update GTK resizeSplit/toggleSplitZoom to return false when single pane - Update macOS resizeSplit/toggleSplitZoom to return Bool and check isSplit - Add unit test for isSplit method
This commit is contained in:
@@ -508,13 +508,13 @@ extension Ghostty {
|
||||
return gotoWindow(app, target: target, direction: action.action.goto_window)
|
||||
|
||||
case GHOSTTY_ACTION_RESIZE_SPLIT:
|
||||
resizeSplit(app, target: target, resize: action.action.resize_split)
|
||||
return resizeSplit(app, target: target, resize: action.action.resize_split)
|
||||
|
||||
case GHOSTTY_ACTION_EQUALIZE_SPLITS:
|
||||
equalizeSplits(app, target: target)
|
||||
|
||||
case GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM:
|
||||
toggleSplitZoom(app, target: target)
|
||||
return toggleSplitZoom(app, target: target)
|
||||
|
||||
case GHOSTTY_ACTION_INSPECTOR:
|
||||
controlInspector(app, target: target, mode: action.action.inspector)
|
||||
@@ -1247,16 +1247,21 @@ extension Ghostty {
|
||||
private static func resizeSplit(
|
||||
_ app: ghostty_app_t,
|
||||
target: ghostty_target_s,
|
||||
resize: ghostty_action_resize_split_s) {
|
||||
resize: ghostty_action_resize_split_s) -> Bool {
|
||||
switch (target.tag) {
|
||||
case GHOSTTY_TARGET_APP:
|
||||
Ghostty.logger.warning("resize split does nothing with an app target")
|
||||
return
|
||||
return false
|
||||
|
||||
case GHOSTTY_TARGET_SURFACE:
|
||||
guard let surface = target.target.surface else { return }
|
||||
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
||||
guard let resizeDirection = SplitResizeDirection.from(direction: resize.direction) else { return }
|
||||
guard let surface = target.target.surface else { return false }
|
||||
guard let surfaceView = self.surfaceView(from: surface) else { return false }
|
||||
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { return false }
|
||||
|
||||
// If the window has no splits, the action is not performable
|
||||
guard controller.surfaceTree.isSplit else { return false }
|
||||
|
||||
guard let resizeDirection = SplitResizeDirection.from(direction: resize.direction) else { return false }
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.didResizeSplit,
|
||||
object: surfaceView,
|
||||
@@ -1265,9 +1270,11 @@ extension Ghostty {
|
||||
Notification.ResizeSplitAmountKey: resize.amount,
|
||||
]
|
||||
)
|
||||
return true
|
||||
|
||||
default:
|
||||
assertionFailure()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1295,23 +1302,30 @@ extension Ghostty {
|
||||
|
||||
private static func toggleSplitZoom(
|
||||
_ app: ghostty_app_t,
|
||||
target: ghostty_target_s) {
|
||||
target: ghostty_target_s) -> Bool {
|
||||
switch (target.tag) {
|
||||
case GHOSTTY_TARGET_APP:
|
||||
Ghostty.logger.warning("toggle split zoom does nothing with an app target")
|
||||
return
|
||||
return false
|
||||
|
||||
case GHOSTTY_TARGET_SURFACE:
|
||||
guard let surface = target.target.surface else { return }
|
||||
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
||||
guard let surface = target.target.surface else { return false }
|
||||
guard let surfaceView = self.surfaceView(from: surface) else { return false }
|
||||
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { return false }
|
||||
|
||||
// If the window has no splits, the action is not performable
|
||||
guard controller.surfaceTree.isSplit else { return false }
|
||||
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.didToggleSplitZoom,
|
||||
object: surfaceView
|
||||
)
|
||||
return true
|
||||
|
||||
|
||||
default:
|
||||
assertionFailure()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2400,10 +2400,14 @@ const Action = struct {
|
||||
SplitTree,
|
||||
surface.as(gtk.Widget),
|
||||
) orelse {
|
||||
log.warn("surface is not in a split tree, ignoring goto_split", .{});
|
||||
log.warn("surface is not in a split tree, ignoring resize_split", .{});
|
||||
return false;
|
||||
};
|
||||
|
||||
// If the tree has no splits (only one leaf), this action is not performable.
|
||||
// This allows the key event to pass through to the terminal.
|
||||
if (!tree.getIsSplit()) return false;
|
||||
|
||||
return tree.resize(
|
||||
switch (value.direction) {
|
||||
.up => .up,
|
||||
@@ -2550,6 +2554,18 @@ const Action = struct {
|
||||
.surface => |core| {
|
||||
// TODO: pass surface ID when we have that
|
||||
const surface = core.rt_surface.surface;
|
||||
const tree = ext.getAncestor(
|
||||
SplitTree,
|
||||
surface.as(gtk.Widget),
|
||||
) orelse {
|
||||
log.warn("surface is not in a split tree, ignoring toggle_split_zoom", .{});
|
||||
return false;
|
||||
};
|
||||
|
||||
// If the tree has no splits (only one leaf), this action is not performable.
|
||||
// This allows the key event to pass through to the terminal.
|
||||
if (!tree.getIsSplit()) return false;
|
||||
|
||||
return surface.as(gtk.Widget).activateAction("split-tree.zoom", null) != 0;
|
||||
},
|
||||
}
|
||||
|
||||
@@ -561,7 +561,7 @@ pub const SplitTree = extern struct {
|
||||
));
|
||||
}
|
||||
|
||||
fn getIsSplit(self: *Self) bool {
|
||||
pub fn getIsSplit(self: *Self) bool {
|
||||
const tree: *const Surface.Tree = self.private().tree orelse &.empty;
|
||||
if (tree.isEmpty()) return false;
|
||||
|
||||
|
||||
@@ -170,6 +170,19 @@ pub fn SplitTree(comptime V: type) type {
|
||||
return self.nodes.len == 0;
|
||||
}
|
||||
|
||||
/// Returns true if this tree has more than one split (i.e., the root
|
||||
/// is a split node). This is useful for determining if actions like
|
||||
/// resize_split or toggle_split_zoom are performable.
|
||||
pub fn isSplit(self: *const Self) bool {
|
||||
// An empty tree is not split.
|
||||
if (self.isEmpty()) return false;
|
||||
// The root node is at index 0. If it's a split, we have multiple splits.
|
||||
return switch (self.nodes[0]) {
|
||||
.split => true,
|
||||
.leaf => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// An iterator over all the views in the tree.
|
||||
pub fn iterator(
|
||||
self: *const Self,
|
||||
@@ -760,9 +773,9 @@ pub fn SplitTree(comptime V: type) type {
|
||||
/// Resize the nearest split matching the layout by the given ratio.
|
||||
/// Positive is right and down.
|
||||
///
|
||||
/// The ratio is a value between 0 and 1 representing the percentage
|
||||
/// to move the divider in the given direction. The percentage is
|
||||
/// of the entire grid size, not just the specific split size.
|
||||
/// The ratio is a signed delta representing the percentage to move
|
||||
/// the divider. The percentage is of the entire grid size, not just
|
||||
/// the specific split size.
|
||||
/// We use the entire grid size because that's what Ghostty's
|
||||
/// `resize_split` keybind does, because it maps to a general human
|
||||
/// understanding of moving a split relative to the entire window
|
||||
@@ -781,7 +794,7 @@ pub fn SplitTree(comptime V: type) type {
|
||||
layout: Split.Layout,
|
||||
ratio: f16,
|
||||
) Allocator.Error!Self {
|
||||
assert(ratio >= 0 and ratio <= 1);
|
||||
assert(ratio >= -1 and ratio <= 1);
|
||||
assert(!std.math.isNan(ratio));
|
||||
assert(!std.math.isInf(ratio));
|
||||
|
||||
@@ -1326,6 +1339,36 @@ const TestView = struct {
|
||||
}
|
||||
};
|
||||
|
||||
test "SplitTree: isSplit" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
// Empty tree should not be split
|
||||
var empty: TestTree = .empty;
|
||||
defer empty.deinit();
|
||||
try testing.expect(!empty.isSplit());
|
||||
|
||||
// Single node tree should not be split
|
||||
var v1: TestView = .{ .label = "A" };
|
||||
var single: TestTree = try TestTree.init(alloc, &v1);
|
||||
defer single.deinit();
|
||||
try testing.expect(!single.isSplit());
|
||||
|
||||
// Split tree should be split
|
||||
var v2: TestView = .{ .label = "B" };
|
||||
var tree2: TestTree = try TestTree.init(alloc, &v2);
|
||||
defer tree2.deinit();
|
||||
var split = try single.split(
|
||||
alloc,
|
||||
.root,
|
||||
.right,
|
||||
0.5,
|
||||
&tree2,
|
||||
);
|
||||
defer split.deinit();
|
||||
try testing.expect(split.isSplit());
|
||||
}
|
||||
|
||||
test "SplitTree: empty tree" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
@@ -2007,6 +2050,32 @@ test "SplitTree: resize" {
|
||||
\\
|
||||
);
|
||||
}
|
||||
|
||||
// Resize the other direction (negative ratio)
|
||||
{
|
||||
var resized = try split.resize(
|
||||
alloc,
|
||||
at: {
|
||||
var it = split.iterator();
|
||||
break :at while (it.next()) |entry| {
|
||||
if (std.mem.eql(u8, entry.view.label, "B")) {
|
||||
break entry.handle;
|
||||
}
|
||||
} else return error.NotFound;
|
||||
},
|
||||
.horizontal, // resize left
|
||||
-0.25,
|
||||
);
|
||||
defer resized.deinit();
|
||||
const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(resized, .formatDiagram)});
|
||||
defer alloc.free(str);
|
||||
try testing.expectEqualStrings(str,
|
||||
\\+---++-------------+
|
||||
\\| A || B |
|
||||
\\+---++-------------+
|
||||
\\
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test "SplitTree: clone empty tree" {
|
||||
|
||||
Reference in New Issue
Block a user