diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 89a3ddd7d..5dbb6bea6 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -505,13 +505,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) @@ -1244,16 +1244,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, @@ -1262,9 +1267,11 @@ extension Ghostty { Notification.ResizeSplitAmountKey: resize.amount, ] ) + return true default: assertionFailure() + return false } } @@ -1292,23 +1299,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 } } diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index bc83c09a4..0a336fd79 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -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.isSplit()) 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.isSplit()) return false; + return surface.as(gtk.Widget).activateAction("split-tree.zoom", null) != 0; }, } diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index be24187f6..93daa77e9 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -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, @@ -1326,6 +1339,34 @@ 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 split = try single.split( + alloc, + .root, + .right, + 0.5, + &v2, + ); + defer split.deinit(); + try testing.expect(split.isSplit()); +} + test "SplitTree: empty tree" { const testing = std.testing; const alloc = testing.allocator;