* Add regressions for issue #2347 terminal focus loss * Fix issue #2347 terminal focus and surface recovery * Add regressions for missing-surface recovery review cases * Fix missing-surface recovery lifecycle and focus replay
This commit is contained in:
parent
29c0f525db
commit
ae59e571a8
3 changed files with 337 additions and 2 deletions
|
|
@ -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 }
|
||||
|
|
@ -3467,6 +3471,10 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
(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,6 +4169,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
return
|
||||
}
|
||||
|
||||
guard allowsRuntimeSurfaceCreation() else { return }
|
||||
guard surface == nil, attachedView != nil else { return }
|
||||
guard !backgroundSurfaceStartQueued else { return }
|
||||
backgroundSurfaceStartQueued = true
|
||||
|
|
@ -4147,6 +4177,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
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
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -8892,6 +8934,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"
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
/// Returns true if the terminal's actual Ghostty surface view is (or contains) the window first responder.
|
||||
|
|
@ -8918,6 +8987,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
|
||||
|
|
|
|||
|
|
@ -1301,6 +1301,10 @@ final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase {
|
|||
|
||||
@MainActor
|
||||
final class TerminalNotificationDirectInteractionTests: XCTestCase {
|
||||
private final class FocusProbeView: NSView {
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
}
|
||||
|
||||
private func makeWindow() -> NSWindow {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
|
||||
|
|
@ -1497,6 +1501,207 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase {
|
|||
XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id))
|
||||
XCTAssertEqual(GhosttySurfaceScrollView.flashCount(for: terminalPanel.id), 1)
|
||||
}
|
||||
|
||||
func testKeyDownRecoversReleasedSurfaceWhileHostedViewIsDetached() throws {
|
||||
#if DEBUG
|
||||
let window = makeWindow()
|
||||
defer { window.orderOut(nil) }
|
||||
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
|
||||
let surface = TerminalSurface(
|
||||
tabId: UUID(),
|
||||
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
|
||||
configTemplate: nil,
|
||||
workingDirectory: nil
|
||||
)
|
||||
let hostedView = surface.hostedView
|
||||
hostedView.frame = contentView.bounds
|
||||
hostedView.autoresizingMask = [.width, .height]
|
||||
contentView.addSubview(hostedView)
|
||||
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
window.displayIfNeeded()
|
||||
contentView.layoutSubtreeIfNeeded()
|
||||
hostedView.layoutSubtreeIfNeeded()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
|
||||
guard let surfaceView = surfaceView(in: hostedView) as? GhosttyNSView else {
|
||||
XCTFail("Expected terminal surface view")
|
||||
return
|
||||
}
|
||||
XCTAssertNotNil(surface.surface, "Expected runtime surface before simulating the detach race")
|
||||
|
||||
surface.releaseSurfaceForTesting()
|
||||
XCTAssertNil(surface.surface, "Expected runtime surface to be released for the regression setup")
|
||||
|
||||
hostedView.removeFromSuperview()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
XCTAssertNil(surfaceView.window, "Expected hosted terminal view to be detached from any window")
|
||||
|
||||
let event = makeKeyEvent(characters: "a", keyCode: 0, window: window)
|
||||
surfaceView.keyDown(with: event)
|
||||
|
||||
let recovered = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in
|
||||
surface.surface != nil
|
||||
},
|
||||
object: NSObject()
|
||||
)
|
||||
wait(for: [recovered], timeout: 1.0)
|
||||
|
||||
XCTAssertNotNil(
|
||||
surface.surface,
|
||||
"Missing-surface keyDown should request background surface recreation instead of leaving terminal input dead"
|
||||
)
|
||||
#else
|
||||
throw XCTSkip("Debug-only regression test")
|
||||
#endif
|
||||
}
|
||||
|
||||
func testKeyDownRecoveryDoesNotReplayFocusAfterResponderMovesAway() throws {
|
||||
#if DEBUG
|
||||
let window = makeWindow()
|
||||
defer { window.orderOut(nil) }
|
||||
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
|
||||
let surface = TerminalSurface(
|
||||
tabId: UUID(),
|
||||
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
|
||||
configTemplate: nil,
|
||||
workingDirectory: nil
|
||||
)
|
||||
let hostedView = surface.hostedView
|
||||
hostedView.frame = contentView.bounds
|
||||
hostedView.autoresizingMask = [.width, .height]
|
||||
contentView.addSubview(hostedView)
|
||||
|
||||
let otherResponder = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40))
|
||||
contentView.addSubview(otherResponder)
|
||||
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
window.displayIfNeeded()
|
||||
contentView.layoutSubtreeIfNeeded()
|
||||
hostedView.layoutSubtreeIfNeeded()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
|
||||
guard let surfaceView = surfaceView(in: hostedView) as? GhosttyNSView else {
|
||||
XCTFail("Expected terminal surface view")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertTrue(window.makeFirstResponder(surfaceView))
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
XCTAssertTrue(surface.debugDesiredFocusState(), "Focused terminal should start with desired Ghostty focus")
|
||||
|
||||
surface.releaseSurfaceForTesting()
|
||||
XCTAssertNil(surface.surface, "Expected runtime surface to be released for the regression setup")
|
||||
|
||||
hostedView.removeFromSuperview()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
XCTAssertNil(surfaceView.window, "Expected hosted terminal view to be detached from any window")
|
||||
XCTAssertTrue(
|
||||
(window.firstResponder as? NSView) === surfaceView,
|
||||
"Expected the detached Ghostty view to remain the stale first responder during the regression setup"
|
||||
)
|
||||
|
||||
let event = makeKeyEvent(characters: "a", keyCode: 0, window: window)
|
||||
surfaceView.keyDown(with: event)
|
||||
|
||||
XCTAssertTrue(window.makeFirstResponder(otherResponder))
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
|
||||
XCTAssertTrue(
|
||||
(window.firstResponder as? NSView) === otherResponder,
|
||||
"Expected focus to move to the replacement responder"
|
||||
)
|
||||
XCTAssertFalse(
|
||||
surface.debugDesiredFocusState(),
|
||||
"Responder loss after a missing-surface keyDown should clear desired Ghostty focus before recovery completes"
|
||||
)
|
||||
|
||||
let recovered = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in
|
||||
surface.surface != nil
|
||||
},
|
||||
object: NSObject()
|
||||
)
|
||||
wait(for: [recovered], timeout: 1.0)
|
||||
|
||||
XCTAssertNotNil(surface.surface, "Expected missing-surface recovery to still recreate the runtime surface")
|
||||
XCTAssertFalse(
|
||||
surface.debugDesiredFocusState(),
|
||||
"Recovered runtime surface should not restore focus after the pane already lost first responder"
|
||||
)
|
||||
#else
|
||||
throw XCTSkip("Debug-only regression test")
|
||||
#endif
|
||||
}
|
||||
|
||||
func testKeyDownRecoveryDoesNotRecreateClosedSurface() throws {
|
||||
#if DEBUG
|
||||
let window = makeWindow()
|
||||
defer { window.orderOut(nil) }
|
||||
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
|
||||
let surface = TerminalSurface(
|
||||
tabId: UUID(),
|
||||
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
|
||||
configTemplate: nil,
|
||||
workingDirectory: nil
|
||||
)
|
||||
let hostedView = surface.hostedView
|
||||
hostedView.frame = contentView.bounds
|
||||
hostedView.autoresizingMask = [.width, .height]
|
||||
contentView.addSubview(hostedView)
|
||||
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
window.displayIfNeeded()
|
||||
contentView.layoutSubtreeIfNeeded()
|
||||
hostedView.layoutSubtreeIfNeeded()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
|
||||
guard let surfaceView = surfaceView(in: hostedView) as? GhosttyNSView else {
|
||||
XCTFail("Expected terminal surface view")
|
||||
return
|
||||
}
|
||||
XCTAssertNotNil(surface.surface, "Expected runtime surface before simulating close lifecycle teardown")
|
||||
|
||||
surface.beginPortalCloseLifecycle(reason: "test.close")
|
||||
surface.teardownSurface()
|
||||
XCTAssertNil(surface.surface, "Teardown should release the runtime surface")
|
||||
XCTAssertEqual(surface.portalBindingStateLabel(), "closed")
|
||||
|
||||
hostedView.removeFromSuperview()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
XCTAssertNil(surfaceView.window, "Expected hosted terminal view to be detached from any window")
|
||||
|
||||
let event = makeKeyEvent(characters: "a", keyCode: 0, window: window)
|
||||
surfaceView.keyDown(with: event)
|
||||
|
||||
let drained = expectation(description: "background recovery drained")
|
||||
DispatchQueue.main.async { drained.fulfill() }
|
||||
wait(for: [drained], timeout: 1.0)
|
||||
|
||||
XCTAssertNil(
|
||||
surface.surface,
|
||||
"Missing-surface keyDown should not recreate a Ghostty runtime surface after close lifecycle teardown"
|
||||
)
|
||||
#else
|
||||
throw XCTSkip("Debug-only regression test")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1351,6 +1351,64 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase {
|
|||
"Expected the clicked split pane to become first responder"
|
||||
)
|
||||
}
|
||||
|
||||
func testClearSuppressReparentFocusReassertsGhosttyFocusForCurrentFirstResponder() throws {
|
||||
#if DEBUG
|
||||
let workspace = Workspace()
|
||||
guard let leftPanelId = workspace.focusedPanelId,
|
||||
let leftPanel = workspace.terminalPanel(for: leftPanelId),
|
||||
let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
|
||||
XCTFail("Expected split terminal panels")
|
||||
return
|
||||
}
|
||||
|
||||
let window = makeWindow()
|
||||
defer { window.orderOut(nil) }
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
|
||||
leftPanel.hostedView.frame = NSRect(x: 0, y: 0, width: 180, height: 220)
|
||||
rightPanel.hostedView.frame = NSRect(x: 180, y: 0, width: 180, height: 220)
|
||||
contentView.addSubview(leftPanel.hostedView)
|
||||
contentView.addSubview(rightPanel.hostedView)
|
||||
|
||||
leftPanel.hostedView.setVisibleInUI(true)
|
||||
rightPanel.hostedView.setVisibleInUI(true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
window.displayIfNeeded()
|
||||
contentView.layoutSubtreeIfNeeded()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
|
||||
guard let leftSurfaceView = surfaceView(in: leftPanel.hostedView) else {
|
||||
XCTFail("Expected left terminal surface view")
|
||||
return
|
||||
}
|
||||
|
||||
leftPanel.surface.setFocus(false)
|
||||
rightPanel.surface.setFocus(true)
|
||||
leftPanel.hostedView.suppressReparentFocus()
|
||||
|
||||
XCTAssertTrue(window.makeFirstResponder(leftSurfaceView))
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
|
||||
XCTAssertFalse(
|
||||
leftPanel.surface.debugDesiredFocusState(),
|
||||
"Suppressed reparent focus should not immediately flip the Ghostty focus bit"
|
||||
)
|
||||
|
||||
leftPanel.hostedView.clearSuppressReparentFocus()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
|
||||
XCTAssertTrue(
|
||||
leftPanel.surface.debugDesiredFocusState(),
|
||||
"Clearing reparent-focus suppression should reassert Ghostty focus when the surface still owns first responder"
|
||||
)
|
||||
#else
|
||||
throw XCTSkip("Debug-only regression test")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue