From 0fb58298a78979e75c67717d0587ffc3c94430e9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 3 Jun 2025 19:41:21 -0700 Subject: [PATCH] macos: focus split previous/next --- macos/Ghostty.xcodeproj/project.pbxproj | 12 ++++ macos/Sources/Features/Splits/SplitTree.swift | 72 +++++++++++++++++++ .../Terminal/BaseTerminalController.swift | 37 ++++++++++ macos/Sources/Ghostty/Ghostty.App.swift | 3 +- .../Helpers/Extensions/Array+Extension.swift | 19 +++++ 5 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 macos/Sources/Helpers/Extensions/Array+Extension.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 459b2b994..38e29a60e 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -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 = ""; }; A586365E2DEE6C2100E04A10 /* SplitTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitTree.swift; sourceTree = ""; }; A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSplitTreeView.swift; sourceTree = ""; }; + A586366A2DF0A98900E04A10 /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = ""; }; A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = ""; }; A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = ""; }; A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -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 = ""; }; + A58636692DF0A98100E04A10 /* Extensions */ = { + isa = PBXGroup; + children = ( + A586366A2DF0A98900E04A10 /* Array+Extension.swift */, + ); + path = Extensions; + sourceTree = ""; + }; 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 */, diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index a66d4abe7..a093934d8 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -50,6 +50,20 @@ struct SplitTree: 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 diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 1ffea9b4f..b6b745e82 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -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.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 diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index d8fdaa3ec..95e04fc1e 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -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, diff --git a/macos/Sources/Helpers/Extensions/Array+Extension.swift b/macos/Sources/Helpers/Extensions/Array+Extension.swift new file mode 100644 index 000000000..6f005a349 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/Array+Extension.swift @@ -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 + } +}