pull/9723/merge
Jiulong Wang 2026-05-20 23:44:06 -04:00 committed by GitHub
commit 2fd64b745c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 166 additions and 25 deletions

View File

@ -582,6 +582,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.GotoWindow

View File

@ -86,6 +86,9 @@ class BaseTerminalController: NSWindowController,
/// The cancellables related to our focused surface.
private var focusedSurfaceCancellables: Set<AnyCancellable> = []
/// The most recently focused surface used for toggling focus.
private var recentFocusedSurface: Ghostty.SurfaceView? = nil
/// Cancellable for aggregating bell state across all surfaces in this controller.
private var bellStateCancellable: AnyCancellable?
@ -292,6 +295,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
@ -427,6 +432,56 @@ class BaseTerminalController: NSWindowController,
// MARK: Split Tree Management
private func containsInSurfaceTree(
_ surface: Ghostty.SurfaceView?,
tree: SplitTree<Ghostty.SurfaceView>? = nil
) -> Bool {
guard let surface else { return false }
let activeTree = tree ?? surfaceTree
return activeTree.contains(surface)
}
private func pruneRecentFocusedSurface(tree: SplitTree<Ghostty.SurfaceView>? = 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<Ghostty.SurfaceView>.Node) -> Ghostty.SurfaceView? {
@ -633,11 +688,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
}
@ -805,6 +857,9 @@ class BaseTerminalController: NSWindowController,
let lastFocusedSurface = focusedSurface
focusedSurface = to
updateRecentFocusedSurface(previous: lastFocusedSurface, current: focusedSurface)
pruneRecentFocusedSurface()
// Important to cancel any prior subscriptions
focusedSurfaceCancellables = []
@ -853,6 +908,7 @@ class BaseTerminalController: NSWindowController,
window.title = lastComputedTitle
}
func pwdDidChange(to: URL?) {
guard let window else { return }

View File

@ -1178,16 +1178,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<Ghostty.SurfaceView>.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(

View File

@ -131,7 +131,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? {
@ -142,6 +142,9 @@ extension Ghostty {
case GHOSTTY_GOTO_SPLIT_NEXT:
return .next
case GHOSTTY_GOTO_SPLIT_RECENT:
return .recent
case GHOSTTY_GOTO_SPLIT_UP:
return .up
@ -167,6 +170,9 @@ extension Ghostty {
case .next:
return GHOSTTY_GOTO_SPLIT_NEXT
case .recent:
return GHOSTTY_GOTO_SPLIT_RECENT
case .up:
return GHOSTTY_GOTO_SPLIT_UP
@ -240,6 +246,9 @@ extension Ghostty.SplitFocusDirection {
case .right:
return .spatial(.right)
case .recent:
fatalError("recent is not a valid SplitTree FocusDirection")
}
}
}

View File

@ -511,6 +511,7 @@ pub const GotoSplit = enum(c_int) {
left,
down,
right,
recent,
test "ghostty.h GotoSplit" {
try lib.checkGhosttyHEnum(GotoSplit, "GHOSTTY_GOTO_SPLIT_");

View File

@ -2022,14 +2022,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);
},
}
}

View File

@ -154,6 +154,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,
@ -386,6 +389,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();
@ -492,6 +526,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);
}
}
}
pub fn getHasSurfaces(self: *Self) bool {
const tree: *const Surface.Tree = self.private().tree orelse &.empty;
return !tree.isEmpty();
@ -544,6 +613,7 @@ pub const SplitTree = extern struct {
self.connectSurfaceHandlers();
}
self.pruneCachedFocus(priv.tree);
self.as(gobject.Object).notifyByPspec(properties.tree.impl.param_spec);
}
@ -579,6 +649,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", .{});
@ -776,11 +847,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);

View File

@ -630,7 +630,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,
/// Focus on either the previous window or the next one ('previous', 'next')
@ -1047,6 +1047,7 @@ pub const Action = union(enum) {
left,
down,
right,
recent,
pub fn parse(input: []const u8) !SplitFocusDirection {
return std.meta.stringToEnum(SplitFocusDirection, input) orelse {
@ -1072,6 +1073,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"));

View File

@ -484,6 +484,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",