From 95bfac8f6b2d1c5e643dbf494b6a96dba2c84c0b Mon Sep 17 00:00:00 2001 From: Jiulong Wang Date: Wed, 26 Nov 2025 14:31:37 -0800 Subject: [PATCH 1/2] goto_split focus most recently focused split --- include/ghostty.h | 1 + .../Terminal/BaseTerminalController.swift | 117 +++++++++++++----- macos/Sources/Ghostty/Ghostty.App.swift | 12 +- macos/Sources/Ghostty/Package.swift | 11 +- src/apprt/action.zig | 1 + src/apprt/gtk/class/application.zig | 9 +- src/apprt/gtk/class/split_tree.zig | 84 ++++++++++++- src/input/Binding.zig | 2 + src/input/command.zig | 5 + 9 files changed, 191 insertions(+), 51 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 6cafe8773..ea56ae368 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -510,6 +510,7 @@ typedef enum { GHOSTTY_GOTO_SPLIT_LEFT, GHOSTTY_GOTO_SPLIT_DOWN, GHOSTTY_GOTO_SPLIT_RIGHT, + GHOSTTY_GOTO_SPLIT_RECENT, } ghostty_action_goto_split_e; // apprt.action.ResizeSplit.Direction diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 9104e61ff..a1f128867 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -48,7 +48,7 @@ class BaseTerminalController: NSWindowController, /// This can be set to show/hide the command palette. @Published var commandPaletteIsShowing: Bool = false - + /// Set if the terminal view should show the update overlay. @Published var updateOverlayIsVisible: Bool = false @@ -78,6 +78,9 @@ class BaseTerminalController: NSWindowController, /// The cancellables related to our focused surface. private var focusedSurfaceCancellables: Set = [] + /// The most recently focused surface used for toggling focus. + private var recentFocusedSurface: Ghostty.SurfaceView? = nil + /// The time that undo/redo operations that contain running ptys are valid for. var undoExpiration: Duration { ghostty.config.undoTimeout @@ -259,6 +262,8 @@ class BaseTerminalController: NSWindowController, if (to.isEmpty) { focusedSurface = nil } + + pruneRecentFocusedSurface(tree: to) } /// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about @@ -364,11 +369,61 @@ class BaseTerminalController: NSWindowController, // MARK: Split Tree Management + private func containsInSurfaceTree( + _ surface: Ghostty.SurfaceView?, + tree: SplitTree? = nil + ) -> Bool { + guard let surface else { return false } + let activeTree = tree ?? surfaceTree + return activeTree.contains(surface) + } + + private func pruneRecentFocusedSurface(tree: SplitTree? = nil) { + guard recentFocusedSurface != nil else { return } + + if !containsInSurfaceTree(recentFocusedSurface, tree: tree) { + recentFocusedSurface = nil + } + } + + private func updateRecentFocusedSurface( + previous: Ghostty.SurfaceView?, + current: Ghostty.SurfaceView? + ) { + guard let previous, let current else { return } + guard previous !== current else { return } + guard containsInSurfaceTree(previous), containsInSurfaceTree(current) else { return } + + recentFocusedSurface = previous + } + + func splitFocusTarget( + for direction: Ghostty.SplitFocusDirection, + from target: Ghostty.SurfaceView + ) -> Ghostty.SurfaceView? { + // Find the node for the target surface + guard let targetNode = surfaceTree.root?.node(view: target) else { return nil } + + switch direction { + case .recent: + pruneRecentFocusedSurface() + + guard let recentSurface = recentFocusedSurface, recentSurface !== target else { + return nil + } + + return containsInSurfaceTree(recentSurface) ? recentSurface : nil + + default: + return surfaceTree.focusTarget(for: direction.toSplitTreeFocusDirection(), from: targetNode) + } + } + /// Find the next surface to focus when a node is being closed. /// Goes to previous split unless we're the leftmost leaf, then goes to next. private func findNextFocusTargetAfterClosing(node: SplitTree.Node) -> Ghostty.SurfaceView? { guard let root = surfaceTree.root else { return nil } - + // If we're the leftmost, then we move to the next surface after closing. // Otherwise, we move to the previous. if root.leftmostLeaf() == node.leftmostLeaf() { @@ -377,7 +432,7 @@ class BaseTerminalController: NSWindowController, return surfaceTree.focusTarget(for: .previous, from: node) } } - + /// Remove a node from the surface tree and move focus appropriately. /// /// This also updates the undo manager to support restoring this node. @@ -553,14 +608,14 @@ class BaseTerminalController: NSWindowController, @objc private func ghosttyDidEqualizeSplits(_ notification: Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } - + // Check if target surface is in current controller's tree guard surfaceTree.contains(target) else { return } - + // Equalize the splits surfaceTree = surfaceTree.equalize() } - + @objc private func ghosttyDidFocusSplit(_ notification: Notification) { // The target must be within our tree guard let target = notification.object as? Ghostty.SurfaceView else { return } @@ -570,11 +625,8 @@ class BaseTerminalController: NSWindowController, guard let directionAny = notification.userInfo?[Ghostty.Notification.SplitDirectionKey] else { return } guard let direction = directionAny as? Ghostty.SplitFocusDirection else { return } - // Find the node for the target surface - guard let targetNode = surfaceTree.root?.node(view: target) else { return } - // Find the next surface to focus - guard let nextSurface = surfaceTree.focusTarget(for: direction.toSplitTreeFocusDirection(), from: targetNode) else { + guard let nextSurface = splitFocusTarget(for: direction, from: target) else { return } @@ -588,7 +640,7 @@ class BaseTerminalController: NSWindowController, Ghostty.moveFocus(to: nextSurface, from: target) } } - + @objc private func ghosttyDidToggleSplitZoom(_ notification: Notification) { // The target must be within our tree guard let target = notification.object as? Ghostty.SurfaceView else { return } @@ -616,19 +668,19 @@ class BaseTerminalController: NSWindowController, Ghostty.moveFocus(to: target) } } - + @objc private func ghosttyDidResizeSplit(_ notification: Notification) { // The target must be within our tree guard let target = notification.object as? Ghostty.SurfaceView else { return } guard let targetNode = surfaceTree.root?.node(view: target) else { return } - + // Extract direction and amount from notification guard let directionAny = notification.userInfo?[Ghostty.Notification.ResizeSplitDirectionKey] else { return } guard let direction = directionAny as? Ghostty.SplitResizeDirection else { return } - + guard let amountAny = notification.userInfo?[Ghostty.Notification.ResizeSplitAmountKey] else { return } guard let amount = amountAny as? UInt16 else { return } - + // Convert Ghostty.SplitResizeDirection to SplitTree.Spatial.Direction let spatialDirection: SplitTree.Spatial.Direction switch direction { @@ -637,10 +689,10 @@ class BaseTerminalController: NSWindowController, case .left: spatialDirection = .left case .right: spatialDirection = .right } - + // Use viewBounds for the spatial calculation bounds let bounds = CGRect(origin: .zero, size: surfaceTree.viewBounds()) - + // Perform the resize using the new SplitTree resize method do { surfaceTree = try surfaceTree.resize(node: targetNode, by: amount, in: spatialDirection, with: bounds) @@ -670,7 +722,7 @@ class BaseTerminalController: NSWindowController, if NSApp.mainWindow == window { surfaces = surfaces.filter { $0 != focusedSurface } } - + for surface in surfaces { surface.flagsChanged(with: event) } @@ -684,6 +736,9 @@ class BaseTerminalController: NSWindowController, let lastFocusedSurface = focusedSurface focusedSurface = to + updateRecentFocusedSurface(previous: lastFocusedSurface, current: focusedSurface) + pruneRecentFocusedSurface() + // Important to cancel any prior subscriptions focusedSurfaceCancellables = [] @@ -704,7 +759,7 @@ class BaseTerminalController: NSWindowController, titleDidChange(to: "👻") } } - + private func computeTitle(title: String, bell: Bool) -> String { var result = title if (bell && ghostty.config.bellFeatures.contains(.title)) { @@ -716,11 +771,11 @@ class BaseTerminalController: NSWindowController, private func titleDidChange(to: String) { guard let window else { return } - + // Set the main window title window.title = to } - + func pwdDidChange(to: URL?) { guard let window else { return } @@ -812,7 +867,7 @@ class BaseTerminalController: NSWindowController, func fullscreenDidChange() { guard let fullscreenStyle else { return } - + // When we enter fullscreen, we want to show the update overlay so that it // is easily visible. For native fullscreen this is visible by showing the // menubar but we don't want to rely on that. @@ -903,26 +958,26 @@ class BaseTerminalController: NSWindowController, fullscreenStyle = NativeFullscreen(window) fullscreenStyle?.delegate = self } - + // Set our update overlay state updateOverlayIsVisible = defaultUpdateOverlayVisibility() } - + func defaultUpdateOverlayVisibility() -> Bool { guard let window else { return true } - + // No titlebar we always show the update overlay because it can't support // updates in the titlebar guard window.styleMask.contains(.titled) else { return true } - + // If it's a non terminal window we can't trust it has an update accessory, // so we always want to show the overlay. guard let window = window as? TerminalWindow else { return true } - + // Show the overlay if the window isn't. return !window.supportsUpdateAccessory } @@ -1112,19 +1167,19 @@ class BaseTerminalController: NSWindowController, @IBAction func toggleCommandPalette(_ sender: Any?) { commandPaletteIsShowing.toggle() } - + @IBAction func find(_ sender: Any) { focusedSurface?.find(sender) } - + @IBAction func findNext(_ sender: Any) { focusedSurface?.findNext(sender) } - + @IBAction func findPrevious(_ sender: Any) { focusedSurface?.findNext(sender) } - + @IBAction func findHide(_ sender: Any) { focusedSurface?.findHide(sender) } diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 39ebbb51f..134e617bc 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -1086,16 +1086,8 @@ extension Ghostty { // Convert the C API direction to our Swift type guard let splitDirection = SplitFocusDirection.from(direction: direction) else { return false } - // Find the current node in the tree - guard let targetNode = controller.surfaceTree.root?.node(view: surfaceView) else { return false } - - // Check if a split actually exists in the target direction before - // returning true. This ensures performable keybinds only consume - // the key event when we actually perform navigation. - let focusDirection: SplitTree.FocusDirection = splitDirection.toSplitTreeFocusDirection() - guard controller.surfaceTree.focusTarget(for: focusDirection, from: targetNode) != nil else { - return false - } + // Validate there is a target split before claiming the action is performable. + guard controller.splitFocusTarget(for: splitDirection, from: surfaceView) != nil else { return false } // We have a valid target, post the notification to perform the navigation NotificationCenter.default.post( diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 7ee815caa..850d5fe3e 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -140,7 +140,7 @@ extension Ghostty { /// An enum that is used for the directions that a split focus event can change. enum SplitFocusDirection { - case previous, next, up, down, left, right + case previous, next, up, down, left, right, recent /// Initialize from a Ghostty API enum. static func from(direction: ghostty_action_goto_split_e) -> Self? { @@ -151,6 +151,9 @@ extension Ghostty { case GHOSTTY_GOTO_SPLIT_NEXT: return .next + case GHOSTTY_GOTO_SPLIT_RECENT: + return .recent + case GHOSTTY_GOTO_SPLIT_UP: return .up @@ -176,6 +179,9 @@ extension Ghostty { case .next: return GHOSTTY_GOTO_SPLIT_NEXT + case .recent: + return GHOSTTY_GOTO_SPLIT_RECENT + case .up: return GHOSTTY_GOTO_SPLIT_UP @@ -249,6 +255,9 @@ extension Ghostty.SplitFocusDirection { case .right: return .spatial(.right) + + case .recent: + fatalError("recent is not a valid SplitTree FocusDirection") } } } diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 00bf8685a..419293f6a 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -467,6 +467,7 @@ pub const GotoSplit = enum(c_int) { left, down, right, + recent, }; /// The amount to resize the split by and the direction to resize it in. diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 9c22782c7..3313c363a 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -1956,14 +1956,7 @@ const Action = struct { return false; }; - return tree.goto(switch (to) { - .previous => .previous_wrapped, - .next => .next_wrapped, - .up => .{ .spatial = .up }, - .down => .{ .spatial = .down }, - .left => .{ .spatial = .left }, - .right => .{ .spatial = .right }, - }); + return tree.gotoSplit(to); }, } } diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 4fbf7a0c2..2d9cffbd3 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -160,6 +160,9 @@ pub const SplitTree = extern struct { /// tree change states. last_focused: WeakRef(Surface) = .empty, + /// Previously focused surface, used for recent navigation. + recent_focused: WeakRef(Surface) = .empty, + /// The source that we use to rebuild the tree. This is also /// used to debounce updates. rebuild_source: ?c_uint = null, @@ -350,6 +353,37 @@ pub const SplitTree = extern struct { return true; } + /// Move focus to the most recently focused surface (if any). + fn gotoRecent(self: *Self) bool { + const tree = self.getTree() orelse return false; + const active = self.getActiveSurfaceHandle() orelse return false; + + const recent_surface = self.private().recent_focused.get() orelse return false; + defer recent_surface.unref(); + + const recent_handle = handleForSurface(tree, recent_surface) orelse return false; + + // Don't do anything if we're already on the recent surface. + if (recent_handle == active) return false; + + const surface = tree.nodes[recent_handle.idx()].leaf; + surface.grabFocus(); + return true; + } + + /// Move focus according to an apprt GotoSplit direction. + pub fn gotoSplit(self: *Self, to: apprt.action.GotoSplit) bool { + return switch (to) { + .recent => self.gotoRecent(), + .previous => self.goto(.previous_wrapped), + .next => self.goto(.next_wrapped), + .up => self.goto(.{ .spatial = .up }), + .down => self.goto(.{ .spatial = .down }), + .left => self.goto(.{ .spatial = .left }), + .right => self.goto(.{ .spatial = .right }), + }; + } + fn disconnectSurfaceHandlers(self: *Self) void { const tree = self.getTree() orelse return; var it = tree.iterator(); @@ -456,6 +490,41 @@ pub const SplitTree = extern struct { return surface; } + fn handleForSurface( + tree: *const Surface.Tree, + surface: *Surface, + ) ?Surface.Tree.Node.Handle { + var it = tree.iterator(); + while (it.next()) |entry| { + if (entry.view == surface) return entry.handle; + } + + return null; + } + + fn pruneCachedFocus(self: *Self, tree: ?*Surface.Tree) void { + const priv = self.private(); + const active_tree = tree orelse { + priv.last_focused.set(null); + priv.recent_focused.set(null); + return; + }; + + if (priv.last_focused.get()) |v| { + defer v.unref(); + if (handleForSurface(active_tree, v) == null) { + priv.last_focused.set(null); + } + } + + if (priv.recent_focused.get()) |v| { + defer v.unref(); + if (handleForSurface(active_tree, v) == null) { + priv.recent_focused.set(null); + } + } + } + /// 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. @@ -522,6 +591,7 @@ pub const SplitTree = extern struct { self.connectSurfaceHandlers(); } + self.pruneCachedFocus(priv.tree); self.as(gobject.Object).notifyByPspec(properties.tree.impl.param_spec); } @@ -557,6 +627,7 @@ pub const SplitTree = extern struct { fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); priv.last_focused.set(null); + priv.recent_focused.set(null); if (priv.rebuild_source) |v| { if (glib.Source.remove(v) == 0) { log.warn("unable to remove rebuild source", .{}); @@ -747,11 +818,22 @@ pub const SplitTree = extern struct { _: *gobject.ParamSpec, self: *Self, ) callconv(.c) void { + const priv = self.private(); + // 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); + const last_focused = priv.last_focused.get(); + defer if (last_focused) |v| v.unref(); + + if (last_focused) |v| { + if (v != surface) { + priv.recent_focused.set(v); + } + } + + priv.last_focused.set(surface); // Our active surface probably changed self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 1e7db3592..f1a606d65 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -876,6 +876,7 @@ pub const Action = union(enum) { left, down, right, + recent, pub fn parse(input: []const u8) !SplitFocusDirection { return std.meta.stringToEnum(SplitFocusDirection, input) orelse { @@ -901,6 +902,7 @@ pub const Action = union(enum) { try testing.expectEqual(.left, try SplitFocusDirection.parse("left")); try testing.expectEqual(.down, try SplitFocusDirection.parse("down")); try testing.expectEqual(.right, try SplitFocusDirection.parse("right")); + try testing.expectEqual(.recent, try SplitFocusDirection.parse("recent")); try testing.expectEqual(.up, try SplitFocusDirection.parse("top")); try testing.expectEqual(.down, try SplitFocusDirection.parse("bottom")); diff --git a/src/input/command.zig b/src/input/command.zig index 7cbff405a..8669f50d9 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -458,6 +458,11 @@ fn actionCommands(action: Action.Key) []const Command { .title = "Focus Split: Next", .description = "Focus the next split, if any.", }, + .{ + .action = .{ .goto_split = .recent }, + .title = "Focus Split: Recent", + .description = "Focus the most recently used split, if any.", + }, .{ .action = .{ .goto_split = .left }, .title = "Focus Split: Left", From ffbe5e46ca0c92b7b93404ccebc6253a796d1eb0 Mon Sep 17 00:00:00 2001 From: Jiulong Wang Date: Thu, 27 Nov 2025 13:47:43 -0800 Subject: [PATCH 2/2] doc: update goto_split with the recent option --- src/input/Binding.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index f1a606d65..369cfa600 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -537,7 +537,7 @@ pub const Action = union(enum) { /// Focus on a split either in the specified direction (`right`, `down`, /// `left` and `up`), or in the adjacent split in the order of creation - /// (`previous` and `next`). + /// (`previous` and `next`), or the most recently used split (`recent`). goto_split: SplitFocusDirection, /// Zoom in or out of the current split.