macos: focus split previous/next

This commit is contained in:
Mitchell Hashimoto
2025-06-03 19:41:21 -07:00
parent b84b715ddb
commit 0fb58298a7
5 changed files with 142 additions and 1 deletions

View File

@@ -61,6 +61,7 @@
A586167C2B7703CC009BDB1D /* fish in Resources */ = {isa = PBXBuildFile; fileRef = A586167B2B7703CC009BDB1D /* fish */; };
A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586365E2DEE6C2100E04A10 /* SplitTree.swift */; };
A58636662DEF964100E04A10 /* TerminalSplitTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */; };
A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366A2DF0A98900E04A10 /* Array+Extension.swift */; };
A5874D992DAD751B00E83852 /* CGS.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D982DAD751A00E83852 /* CGS.swift */; };
A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */; };
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; };
@@ -168,6 +169,7 @@
A586167B2B7703CC009BDB1D /* fish */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fish; path = "../zig-out/share/fish"; sourceTree = "<group>"; };
A586365E2DEE6C2100E04A10 /* SplitTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitTree.swift; sourceTree = "<group>"; };
A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSplitTreeView.swift; sourceTree = "<group>"; };
A586366A2DF0A98900E04A10 /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = "<group>"; };
A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = "<group>"; };
A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = "<group>"; };
A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
@@ -292,6 +294,7 @@
A534263D2A7DCBB000EBB7A2 /* Helpers */ = {
isa = PBXGroup;
children = (
A58636692DF0A98100E04A10 /* Extensions */,
A5874D9B2DAD781100E83852 /* Private */,
A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */,
A5A6F7292CC41B8700B232A5 /* Xcode.swift */,
@@ -442,6 +445,14 @@
path = Splits;
sourceTree = "<group>";
};
A58636692DF0A98100E04A10 /* Extensions */ = {
isa = PBXGroup;
children = (
A586366A2DF0A98900E04A10 /* Array+Extension.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
A5874D9B2DAD781100E83852 /* Private */ = {
isa = PBXGroup;
children = (
@@ -721,6 +732,7 @@
A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */,
A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */,
A5874D992DAD751B00E83852 /* CGS.swift in Sources */,
A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */,
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */,
A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */,
A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */,

View File

@@ -50,6 +50,20 @@ struct SplitTree<ViewType: NSView & Codable>: Codable {
case down
case up
}
/// The direction that focus can move from a node.
enum FocusDirection {
// Follow a consistent tree-like structure.
case previous
case next
// Geospatially-aware navigation targets. These take into account the
// dimensions of the view to find the correct node to go to.
case up
case down
case left
case right
}
}
// MARK: SplitTree
@@ -111,6 +125,44 @@ extension SplitTree {
return .init(root: newRoot, zoomed: newZoomed)
}
/// Find the next view to focus based on the current focused node and direction
func focusTarget(for direction: FocusDirection, from currentNode: Node) -> ViewType? {
guard let root else { return nil }
switch direction {
case .previous:
// For previous, we traverse in order and find the previous leaf from our leftmost
let allLeaves = root.leaves()
let currentView = currentNode.leftmostLeaf()
guard let currentIndex = allLeaves.firstIndex(where: { $0 === currentView }) else {
// Shouldn't be possible leftmostLeaf can't return something that doesn't exist!
return nil
}
let index = allLeaves.indexWrapping(before: currentIndex)
return allLeaves[index]
case .next:
// For previous, we traverse in order and find the next leaf from our rightmost
let allLeaves = root.leaves()
let currentView = currentNode.rightmostLeaf()
guard let currentIndex = allLeaves.firstIndex(where: { $0 === currentView }) else {
return nil
}
let index = allLeaves.indexWrapping(after: currentIndex)
return allLeaves[index]
case .up, .down, .left, .right:
// For directional movement, we need to traverse the tree structure
return directionalTarget(for: direction, from: currentNode)
}
}
/// Find focus target in a specific direction by traversing split boundaries
private func directionalTarget(for direction: FocusDirection, from currentNode: Node) -> ViewType? {
// TODO
return nil
}
}
// MARK: SplitTree.Node
@@ -331,6 +383,26 @@ extension SplitTree.Node {
))
}
}
/// Get the leftmost leaf in this subtree
func leftmostLeaf() -> ViewType {
switch self {
case .leaf(let view):
return view
case .split(let split):
return split.left.leftmostLeaf()
}
}
/// Get the rightmost leaf in this subtree
func rightmostLeaf() -> ViewType {
switch self {
case .leaf(let view):
return view
case .split(let split):
return split.right.rightmostLeaf()
}
}
}
// MARK: SplitTree.Node Protocols

View File

@@ -145,6 +145,11 @@ class BaseTerminalController: NSWindowController,
selector: #selector(ghosttyDidEqualizeSplits(_:)),
name: Ghostty.Notification.didEqualizeSplits,
object: nil)
center.addObserver(
self,
selector: #selector(ghosttyDidFocusSplit(_:)),
name: Ghostty.Notification.ghosttyFocusSplit,
object: nil)
// Listen for local events that we need to know of outside of
// single surface handlers.
@@ -386,6 +391,38 @@ class BaseTerminalController: NSWindowController,
_ = container.equalize()
}
}
@objc private func ghosttyDidFocusSplit(_ notification: Notification) {
// The target must be within our tree
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard surfaceTree2.root?.node(view: target) != nil else { return }
// Get the direction from the notification
guard let directionAny = notification.userInfo?[Ghostty.Notification.SplitDirectionKey] else { return }
guard let direction = directionAny as? Ghostty.SplitFocusDirection else { return }
// Convert Ghostty.SplitFocusDirection to our SplitTree.FocusDirection
let focusDirection: SplitTree<Ghostty.SurfaceView>.FocusDirection
switch direction {
case .previous: focusDirection = .previous
case .next: focusDirection = .next
case .up: focusDirection = .up
case .down: focusDirection = .down
case .left: focusDirection = .left
case .right: focusDirection = .right
}
// Find the node for the target surface
guard let targetNode = surfaceTree2.root?.node(view: target) else { return }
// Find the next surface to focus
guard let nextSurface = surfaceTree2.focusTarget(for: focusDirection, from: targetNode) else {
return
}
// Move focus to the next surface
Ghostty.moveFocus(to: nextSurface, from: target)
}
// MARK: Local Events

View File

@@ -921,7 +921,8 @@ extension Ghostty {
// we should only be returning true if we actually performed the action,
// but this handles the most common case of caring about goto_split performability
// which is the no-split case.
guard controller.surfaceTree?.isSplit ?? false else { return false }
// TODO: fix this
//guard controller.surfaceTree?.isSplit ?? false else { return false }
NotificationCenter.default.post(
name: Notification.ghosttyFocusSplit,

View File

@@ -0,0 +1,19 @@
extension Array {
/// Returns the index before i, with wraparound. Assumes i is a valid index.
func indexWrapping(before i: Int) -> Int {
if i == 0 {
return count - 1
}
return i - 1
}
/// Returns the index after i, with wraparound. Assumes i is a valid index.
func indexWrapping(after i: Int) -> Int {
if i == count - 1 {
return 0
}
return i + 1
}
}