mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-08-02 14:57:31 +03:00
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:
@ -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) {
|
||||||
|
@ -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 {
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user