macos: more robust surface focus state detection

Fixes #1500

This overhauls how we do focus management for surfaces to make it more
robust. This DID somehow all work before but was always brittle and was
a sketchy play with SwiftUI/AppKit behavior across macOS versions.

The new approach uses our window controller and terminal delegate
system to disseminate focus information whenever any surface changes
focus. This ensures that only ONE surface ever has focus in libghostty
because the controller ensures it is widely distributed.
This commit is contained in:
Mitchell Hashimoto
2024-02-11 09:16:41 -08:00
parent aa27190baf
commit 118b51157a
4 changed files with 55 additions and 18 deletions

View File

@ -14,7 +14,11 @@ class TerminalController: NSWindowController, NSWindowDelegate,
let ghostty: Ghostty.App let ghostty: Ghostty.App
/// The currently focused surface. /// The currently focused surface.
var focusedSurface: Ghostty.SurfaceView? = nil var focusedSurface: Ghostty.SurfaceView? = nil {
didSet {
syncFocusToSurfaceTree()
}
}
/// The surface tree for this window. /// The surface tree for this window.
@Published var surfaceTree: Ghostty.SplitNode? = nil { @Published var surfaceTree: Ghostty.SplitNode? = nil {
@ -23,6 +27,7 @@ class TerminalController: NSWindowController, NSWindowDelegate,
// in the old tree have closed and then close the window. // in the old tree have closed and then close the window.
if (surfaceTree == nil) { if (surfaceTree == nil) {
oldValue?.close() oldValue?.close()
focusedSurface = nil
lastSurfaceDidClose() lastSurfaceDidClose()
} }
} }
@ -181,6 +186,21 @@ class TerminalController: NSWindowController, NSWindowDelegate,
} }
} }
/// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about
/// what surface is focused. This must be called whenever a surface OR window changes focus.
private func syncFocusToSurfaceTree() {
guard let tree = self.surfaceTree else { return }
for leaf in tree {
// Our focus state requires that this window is key and our currently
// focused surface is the surface in this leaf.
let focused: Bool = (window?.isKeyWindow ?? false) &&
focusedSurface != nil &&
leaf.surface == focusedSurface!
leaf.surface.focusDidChange(focused)
}
}
//MARK: - NSWindowController //MARK: - NSWindowController
override func windowWillLoad() { override func windowWillLoad() {
@ -348,6 +368,16 @@ class TerminalController: NSWindowController, NSWindowDelegate,
func windowDidBecomeKey(_ notification: Notification) { func windowDidBecomeKey(_ notification: Notification) {
self.relabelTabs() self.relabelTabs()
self.fixTabBar() self.fixTabBar()
// Becoming/losing key means we have to notify our surface(s) that we have focus
// so things like cursors blink, pty events are sent, etc.
self.syncFocusToSurfaceTree()
}
func windowDidResignKey(_ notification: Notification) {
// Becoming/losing key means we have to notify our surface(s) that we have focus
// so things like cursors blink, pty events are sent, etc.
self.syncFocusToSurfaceTree()
} }
func windowDidMove(_ notification: Notification) { func windowDidMove(_ notification: Notification) {

View File

@ -11,7 +11,7 @@ extension Ghostty {
/// "container" which has a recursive top/left SplitNode and bottom/right SplitNode. These /// "container" which has a recursive top/left SplitNode and bottom/right SplitNode. These
/// values can further be split infinitely. /// values can further be split infinitely.
/// ///
enum SplitNode: Equatable, Hashable, Codable { enum SplitNode: Equatable, Hashable, Codable, Sequence {
case leaf(Leaf) case leaf(Leaf)
case split(Container) case split(Container)
@ -136,6 +136,24 @@ extension Ghostty {
} }
} }
// MARK: - Sequence
func makeIterator() -> IndexingIterator<[Leaf]> {
return leaves().makeIterator()
}
/// Return all the leaves in this split node. This isn't very efficient but our split trees are never super
/// deep so its not an issue.
private func leaves() -> [Leaf] {
switch (self) {
case .leaf(let leaf):
return [leaf]
case .split(let container):
return container.topLeft.leaves() + container.bottomRight.leaves()
}
}
// MARK: - Equatable // MARK: - Equatable
static func == (lhs: SplitNode, rhs: SplitNode) -> Bool { static func == (lhs: SplitNode, rhs: SplitNode) -> Bool {

View File

@ -51,10 +51,6 @@ extension Ghostty {
@EnvironmentObject private var ghostty: Ghostty.App @EnvironmentObject private var ghostty: Ghostty.App
// This is true if the terminal is considered "focused". The terminal is focused if
// it is both individually focused and the containing window is key.
private var hasFocus: Bool { surfaceFocus && windowFocus }
var body: some View { var body: some View {
let center = NotificationCenter.default let center = NotificationCenter.default
@ -72,7 +68,7 @@ extension Ghostty {
let pubResign = center.publisher(for: NSWindow.didResignKeyNotification) let pubResign = center.publisher(for: NSWindow.didResignKeyNotification)
#endif #endif
Surface(view: surfaceView, hasFocus: hasFocus, size: geo.size) Surface(view: surfaceView, size: geo.size)
.focused($surfaceFocus) .focused($surfaceFocus)
.focusedValue(\.ghosttySurfaceTitle, surfaceView.title) .focusedValue(\.ghosttySurfaceTitle, surfaceView.title)
.focusedValue(\.ghosttySurfaceView, surfaceView) .focusedValue(\.ghosttySurfaceView, surfaceView)
@ -230,11 +226,6 @@ extension Ghostty {
/// The view to render for the terminal surface. /// The view to render for the terminal surface.
let view: SurfaceView let view: SurfaceView
/// This should be set to true when the surface has focus. This is up to the parent because
/// focus is also defined by window focus. It is important this is set correctly since if it is
/// false then the surface will idle at almost 0% CPU.
let hasFocus: Bool
/// The size of the frame containing this view. We use this to update the the underlying /// The size of the frame containing this view. We use this to update the the underlying
/// surface. This does not actually SET the size of our frame, this only sets the size /// surface. This does not actually SET the size of our frame, this only sets the size
/// of our Metal surface for drawing. /// of our Metal surface for drawing.
@ -253,7 +244,6 @@ extension Ghostty {
} }
func updateOSView(_ view: SurfaceView, context: Context) { func updateOSView(_ view: SurfaceView, context: Context) {
view.focusDidChange(hasFocus)
view.sizeDidChange(size) view.sizeDidChange(size)
} }
} }

View File

@ -188,6 +188,8 @@ extension Ghostty {
func focusDidChange(_ focused: Bool) { func focusDidChange(_ focused: Bool) {
guard let surface = self.surface else { return } guard let surface = self.surface else { return }
guard self.focused != focused else { return }
self.focused = focused
ghostty_surface_set_focus(surface, focused) ghostty_surface_set_focus(surface, focused)
} }
@ -358,7 +360,7 @@ extension Ghostty {
override func becomeFirstResponder() -> Bool { override func becomeFirstResponder() -> Bool {
let result = super.becomeFirstResponder() let result = super.becomeFirstResponder()
if (result) { focused = true } if (result) { focusDidChange(true) }
return result return result
} }
@ -367,10 +369,7 @@ extension Ghostty {
// We sometimes call this manually (see SplitView) as a way to force us to // We sometimes call this manually (see SplitView) as a way to force us to
// yield our focus state. // yield our focus state.
if (result) { if (result) { focusDidChange(false) }
focusDidChange(false)
focused = false
}
return result return result
} }