-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Fix #2347 terminal focus and surface recovery #2354
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7932ac7
87c1de7
803dff1
5686090
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3394,6 +3394,10 @@ final class TerminalSurface: Identifiable, ObservableObject { | |
| } | ||
| } | ||
|
|
||
| private func allowsRuntimeSurfaceCreation() -> Bool { | ||
| portalLifecycleState == .live | ||
| } | ||
|
|
||
| func beginPortalCloseLifecycle(reason: String) { | ||
| guard portalLifecycleState != .closed else { return } | ||
| guard portalLifecycleState != .closing else { return } | ||
|
|
@@ -3459,14 +3463,18 @@ final class TerminalSurface: Identifiable, ObservableObject { | |
| } | ||
| } | ||
|
|
||
| #if DEBUG | ||
| #if DEBUG | ||
| private static let surfaceLogPath = "/tmp/cmux-ghostty-surface.log" | ||
| private static let sizeLogPath = "/tmp/cmux-ghostty-size.log" | ||
|
|
||
| func debugCurrentPixelSize() -> (width: UInt32, height: UInt32) { | ||
| (lastPixelWidth, lastPixelHeight) | ||
| } | ||
|
|
||
| func debugDesiredFocusState() -> Bool { | ||
| desiredFocusState | ||
| } | ||
|
|
||
| private static func surfaceLog(_ message: String) { | ||
| let timestamp = ISO8601DateFormatter().string(from: Date()) | ||
| let line = "[\(timestamp)] \(message)\n" | ||
|
|
@@ -3564,6 +3572,15 @@ final class TerminalSurface: Identifiable, ObservableObject { | |
| // If surface doesn't exist yet, create it once the view is in a real window so | ||
| // content scale and pixel geometry are derived from the actual backing context. | ||
| if surface == nil { | ||
| guard allowsRuntimeSurfaceCreation() else { | ||
| #if DEBUG | ||
| dlog( | ||
| "surface.attach.skip surface=\(id.uuidString.prefix(5)) " + | ||
| "reason=lifecycle.\(portalLifecycleState.rawValue)" | ||
| ) | ||
| #endif | ||
| return | ||
| } | ||
| guard view.window != nil else { | ||
| #if DEBUG | ||
| dlog( | ||
|
|
@@ -3593,6 +3610,18 @@ final class TerminalSurface: Identifiable, ObservableObject { | |
| } | ||
|
|
||
| private func createSurface(for view: GhosttyNSView) { | ||
| guard allowsRuntimeSurfaceCreation() else { | ||
| #if DEBUG | ||
| dlog( | ||
| "surface.create.skip surface=\(id.uuidString.prefix(5)) " + | ||
| "reason=lifecycle.\(portalLifecycleState.rawValue)" | ||
| ) | ||
| Self.surfaceLog( | ||
| "createSurface SKIPPED surface=\(id.uuidString) tab=\(tabId.uuidString) lifecycle=\(portalLifecycleState.rawValue)" | ||
| ) | ||
| #endif | ||
| return | ||
| } | ||
| #if DEBUG | ||
| let resourcesDir = getenv("GHOSTTY_RESOURCES_DIR").flatMap { String(cString: $0) } ?? "(unset)" | ||
| let terminfo = getenv("TERMINFO").flatMap { String(cString: $0) } ?? "(unset)" | ||
|
|
@@ -4140,13 +4169,15 @@ final class TerminalSurface: Identifiable, ObservableObject { | |
| return | ||
| } | ||
|
|
||
| guard allowsRuntimeSurfaceCreation() else { return } | ||
| guard surface == nil, attachedView != nil else { return } | ||
| guard !backgroundSurfaceStartQueued else { return } | ||
| backgroundSurfaceStartQueued = true | ||
|
|
||
| DispatchQueue.main.async { [weak self] in | ||
| guard let self else { return } | ||
| self.backgroundSurfaceStartQueued = false | ||
| guard self.allowsRuntimeSurfaceCreation() else { return } | ||
| guard self.surface == nil, let view = self.attachedView else { return } | ||
| #if DEBUG | ||
| let startedAt = ProcessInfo.processInfo.systemUptime | ||
|
|
@@ -5030,6 +5061,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { | |
| return surface | ||
| } | ||
|
|
||
| private func requestInputRecoveryAfterSurfaceMiss(reason: String) { | ||
| terminalSurface?.requestBackgroundSurfaceStartIfNeeded() | ||
| #if DEBUG | ||
| dlog( | ||
| "focus.input_recovery surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + | ||
| "reason=\(reason) inWindow=\(window != nil ? 1 : 0)" | ||
| ) | ||
| #endif | ||
| } | ||
|
Comment on lines
+5064
to
+5072
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Propagate focus intent on the missing-surface key path. This helper only queues recreation, but 💡 Proposed fix private func requestInputRecoveryAfterSurfaceMiss(reason: String) {
+ desiredFocus = true
+ terminalSurface?.setFocus(true)
terminalSurface?.requestBackgroundSurfaceStartIfNeeded()
`#if` DEBUG
dlog(
"focus.input_recovery surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +Also applies to: 5732-5738 🤖 Prompt for AI Agents |
||
|
|
||
| func performBindingAction(_ action: String) -> Bool { | ||
| guard let surface = surface else { return false } | ||
| return action.withCString { cString in | ||
|
|
@@ -5460,12 +5501,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { | |
| let result = super.resignFirstResponder() | ||
| if result { | ||
| desiredFocus = false | ||
| terminalSurface?.recordExternalFocusState(false) | ||
| } | ||
| if result, let surface = surface { | ||
| let now = CACurrentMediaTime() | ||
| let deltaMs = (now - lastScrollEventTime) * 1000 | ||
| Self.focusLog("resignFirstResponder: surface=\(terminalSurface?.id.uuidString ?? "nil") deltaSinceScrollMs=\(String(format: "%.2f", deltaMs))") | ||
| terminalSurface?.recordExternalFocusState(false) | ||
| ghostty_surface_set_focus(surface, false) | ||
| } | ||
| return result | ||
|
|
@@ -5689,6 +5730,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { | |
| let ensureSurfaceStart = ProcessInfo.processInfo.systemUptime | ||
| #endif | ||
| guard let surface = ensureSurfaceReadyForInput() else { | ||
| requestInputRecoveryAfterSurfaceMiss(reason: "keyDown.missingSurface") | ||
| #if DEBUG | ||
| ensureSurfaceMs = (ProcessInfo.processInfo.systemUptime - ensureSurfaceStart) * 1000.0 | ||
| #endif | ||
|
|
@@ -8865,6 +8907,33 @@ final class GhosttySurfaceScrollView: NSView { | |
|
|
||
| func clearSuppressReparentFocus() { | ||
| surfaceView.suppressingReparentFocus = false | ||
| let hasUsablePortalGeometry: Bool = { | ||
| let size = bounds.size | ||
| return size.width > 1 && size.height > 1 | ||
| }() | ||
| let isHiddenForFocus = isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor | ||
| let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Consider wrapping the declaration: #if DEBUG
let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
#endif
|
||
|
|
||
| guard surfaceView.desiredFocus else { return } | ||
| guard isSurfaceViewFirstResponder() else { return } | ||
| guard isActive else { return } | ||
| guard surfaceView.isVisibleInUI else { return } | ||
| guard let window, window.isKeyWindow else { return } | ||
| guard !isHiddenForFocus, hasUsablePortalGeometry else { | ||
| #if DEBUG | ||
| dlog( | ||
| "focus.reparent.resume.defer surface=\(surfaceShort) " + | ||
| "reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) " + | ||
| "frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height))" | ||
| ) | ||
| #endif | ||
| scheduleAutomaticFirstResponderApply(reason: "clearSuppressReparentFocus.hiddenOrTiny") | ||
| return | ||
| } | ||
| #if DEBUG | ||
| dlog("focus.reparent.resume surface=\(surfaceShort) firstResponder=\(String(describing: window.firstResponder))") | ||
| #endif | ||
| reassertTerminalSurfaceFocus(reason: "clearSuppressReparentFocus") | ||
|
Comment on lines
8909
to
+8936
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This path restores Ghostty focus but not pane focus state. If suppression masked the original 💡 Suggested fix guard !isHiddenForFocus, hasUsablePortalGeometry else {
`#if` DEBUG
dlog(
"focus.reparent.resume.defer surface=\(surfaceShort) " +
"reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) " +
"frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height))"
)
`#endif`
scheduleAutomaticFirstResponderApply(reason: "clearSuppressReparentFocus.hiddenOrTiny")
return
}
+ surfaceView.onFocus?()
`#if` DEBUG
dlog("focus.reparent.resume surface=\(surfaceShort) firstResponder=\(String(describing: window.firstResponder))")
`#endif`
reassertTerminalSurfaceFocus(reason: "clearSuppressReparentFocus")🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| /// Returns true if the terminal's actual Ghostty surface view is (or contains) the window first responder. | ||
|
|
@@ -8891,6 +8960,9 @@ final class GhosttySurfaceScrollView: NSView { | |
|
|
||
| private func reassertTerminalSurfaceFocus(reason: String) { | ||
| guard let terminalSurface = surfaceView.terminalSurface else { return } | ||
| if terminalSurface.surface == nil { | ||
| terminalSurface.requestBackgroundSurfaceStartIfNeeded() | ||
| } | ||
| #if DEBUG | ||
| dlog("focus.surface.reassert surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)") | ||
| #endif | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
#if DEBUG/#endifindentationThe
#if DEBUGdirective was de-indented from 4 spaces to column 0, but its matching#endifon line 3499 (#endif) is still indented with 4 spaces. All other#if DEBUG/#endifpairs in this file use consistent column-0 placement. The compiler is fine with mixed indentation, but it's inconsistent with the rest of the file.