fix: restore split terminal resize after Cmd-D

This commit is contained in:
austinpower1258 2026-03-10 02:01:57 -07:00
parent 07412f39fd
commit d089f6df18
2 changed files with 125 additions and 3 deletions

View file

@ -3351,9 +3351,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
private var eventMonitor: Any?
private var trackingArea: NSTrackingArea?
private var windowObserver: NSObjectProtocol?
private var lastScrollEventTime: CFTimeInterval = 0
private var lastScrollEventTime: CFTimeInterval = 0
private var visibleInUI: Bool = true
private var pendingSurfaceSize: CGSize?
private var deferredSurfaceSizeRetryQueued = false
private var lastDrawableSize: CGSize = .zero
private var isFindEscapeSuppressionArmed = false
#if DEBUG
@ -3651,11 +3652,39 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
return currentBounds
}
private static func hasActiveTabDragPasteboard() -> Bool {
private static func hasTabDragPasteboardTypes() -> Bool {
let types = NSPasteboard(name: .drag).types ?? []
return types.contains(tabTransferPasteboardType) || types.contains(sidebarTabReorderPasteboardType)
}
private static func isDragResizeEvent(_ eventType: NSEvent.EventType?) -> Bool {
switch eventType {
case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
return true
default:
return false
}
}
private static func shouldDeferSurfaceResizeForActiveDrag() -> Bool {
// The drag pasteboard can retain tab-transfer UTIs briefly after a split command
// or other layout churn. Only defer terminal resizes while an actual drag event
// is in flight; otherwise pre-existing panes can stay stuck at their old size.
guard hasTabDragPasteboardTypes() else { return false }
return isDragResizeEvent(NSApp.currentEvent?.type)
}
private func scheduleDeferredSurfaceSizeRetryIfNeeded() {
guard window != nil else { return }
guard !deferredSurfaceSizeRetryQueued else { return }
deferredSurfaceSizeRetryQueued = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.deferredSurfaceSizeRetryQueued = false
_ = self.updateSurfaceSize()
}
}
@discardableResult
private func updateSurfaceSize(size: CGSize? = nil) -> Bool {
guard let terminalSurface = terminalSurface else { return false }
@ -3675,7 +3704,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
return false
}
pendingSurfaceSize = size
guard !Self.hasActiveTabDragPasteboard() else {
guard !Self.shouldDeferSurfaceResizeForActiveDrag() else {
scheduleDeferredSurfaceSizeRetryIfNeeded()
#if DEBUG
let signature = "tabDrag-\(Int(size.width.rounded()))x\(Int(size.height.rounded()))"
if lastSizeSkipSignature != signature {
@ -4584,6 +4614,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
// Use accumulated text from insertText (for IME), or compute text for key
let accumulatedText = keyTextAccumulator ?? []
var shouldRefreshAfterTextInput = false
if !accumulatedText.isEmpty {
// Accumulated text comes from insertText (IME composition result).
// These never have "composing" set to true because these are the
@ -4591,6 +4622,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
keyEvent.composing = false
for text in accumulatedText {
if shouldSendText(text) {
shouldRefreshAfterTextInput = true
text.withCString { ptr in
keyEvent.text = ptr
_ = ghostty_surface_key(surface, keyEvent)
@ -4611,6 +4643,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
)
if let text = textForKeyEvent(translationEvent) {
if shouldSendText(text), !suppressShiftSpaceFallbackText {
shouldRefreshAfterTextInput = true
text.withCString { ptr in
keyEvent.text = ptr
_ = ghostty_surface_key(surface, keyEvent)
@ -4625,6 +4658,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}
}
if shouldRefreshAfterTextInput {
terminalSurface?.forceRefresh(reason: "keyDown.textInput")
}
// Rendering is driven by Ghostty's wakeups/renderer.
}
@ -5391,6 +5428,7 @@ final class GhosttySurfaceScrollView: NSView {
private var isLiveScrolling = false
private var lastSentRow: Int?
private var isActive = true
private var lastFocusRefreshAt: CFTimeInterval = 0
private var activeDropZone: DropZone?
private var pendingDropZone: DropZone?
private var dropZoneOverlayAnimationGeneration: UInt64 = 0
@ -6609,6 +6647,7 @@ final class GhosttySurfaceScrollView: NSView {
if let fr = window.firstResponder as? NSView,
fr === surfaceView || fr.isDescendant(of: surfaceView) {
reassertTerminalSurfaceFocus(reason: "ensureFocus.alreadyFirstResponder")
return
}
@ -6619,9 +6658,27 @@ final class GhosttySurfaceScrollView: NSView {
if !isSurfaceViewFirstResponder() {
retry()
} else {
reassertTerminalSurfaceFocus(reason: "ensureFocus.afterMakeFirstResponder")
}
}
private func matchesCurrentTerminalFocusTarget(tabId: UUID, surfaceId: UUID) -> Bool {
guard let delegate = AppDelegate.shared,
let tabManager = delegate.tabManagerFor(tabId: tabId) ?? delegate.tabManager,
tabManager.selectedTabId == tabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
let tabIdForSurface = tab.surfaceIdFromPanelId(surfaceId),
let paneId = tab.bonsplitController.allPaneIds.first(where: { paneId in
tab.bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabIdForSurface })
}) else {
return false
}
return tab.bonsplitController.selectedTab(inPane: paneId)?.id == tabIdForSurface &&
tab.bonsplitController.focusedPaneId == paneId
}
/// Suppress the surface view's onFocus callback and ghostty_surface_set_focus during
/// SwiftUI reparenting (programmatic splits). Call clearSuppressReparentFocus() after layout settles.
func suppressReparentFocus() {
@ -6640,6 +6697,33 @@ final class GhosttySurfaceScrollView: NSView {
return fr === surfaceView || fr.isDescendant(of: surfaceView)
}
private func reassertTerminalSurfaceFocus(reason: String) {
guard let terminalSurface = surfaceView.terminalSurface else { return }
#if DEBUG
dlog("focus.surface.reassert surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)")
#endif
terminalSurface.setFocus(true)
refreshSurfaceAfterFocusIfNeeded(reason: reason)
}
private func refreshSurfaceAfterFocusIfNeeded(reason: String) {
guard let terminalSurface = surfaceView.terminalSurface,
isActive,
let window,
window.isKeyWindow,
surfaceView.isVisibleInUI else { return }
let now = CACurrentMediaTime()
if now - lastFocusRefreshAt < 0.05 {
return
}
lastFocusRefreshAt = now
#if DEBUG
dlog("focus.surface.refresh surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)")
#endif
terminalSurface.forceRefresh(reason: "focus.surface.\(reason)")
}
private func applyFirstResponderIfNeeded() {
let hasUsablePortalGeometry: Bool = {
let size = bounds.size
@ -6660,6 +6744,14 @@ final class GhosttySurfaceScrollView: NSView {
return
}
guard let window, window.isKeyWindow else { return }
guard let tabId = surfaceView.tabId,
let panelId = surfaceView.terminalSurface?.id,
matchesCurrentTerminalFocusTarget(tabId: tabId, surfaceId: panelId) else {
#if DEBUG
dlog("focus.apply.skip surface=\(surfaceShort) reason=stale_target")
#endif
return
}
if surfaceView.terminalSurface?.searchState != nil {
// Find bar is open. Restore focus based on what the user last intended.
restoreSearchFocus(window: window)
@ -6667,6 +6759,7 @@ final class GhosttySurfaceScrollView: NSView {
}
if let fr = window.firstResponder as? NSView,
fr === surfaceView || fr.isDescendant(of: surfaceView) {
reassertTerminalSurfaceFocus(reason: "applyFirstResponder.alreadyFirstResponder")
return
}
// Don't steal focus from a search overlay on another surface in this window.
@ -6680,6 +6773,9 @@ final class GhosttySurfaceScrollView: NSView {
dlog("find.applyFirstResponder APPLY surface=\(surfaceShort) prevFirstResponder=\(String(describing: window.firstResponder))")
#endif
window.makeFirstResponder(surfaceView)
if isSurfaceViewFirstResponder() {
reassertTerminalSurfaceFocus(reason: "applyFirstResponder.afterMakeFirstResponder")
}
}
/// Restore focus when window becomes key and the find bar is open.

View file

@ -3109,6 +3109,32 @@ final class Workspace: Identifiable, ObservableObject {
maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger)
}
}
if trigger == .terminalFirstResponder,
panels[panelId] is TerminalPanel {
scheduleTerminalFirstResponderReassert(panelId: panelId)
}
}
/// A terminal click can arrive while AppKit and bonsplit already look converged, which takes
/// the re-entrant focus path and skips the normal explicit `ensureFocus` call. Re-assert focus
/// on the next couple of turns so stale callbacks from split churn can't leave keyboard input
/// attached to the wrong surface (#1147).
private func scheduleTerminalFirstResponderReassert(panelId: UUID, remainingPasses: Int = 2) {
guard remainingPasses > 0 else { return }
DispatchQueue.main.async { [weak self] in
guard let self,
self.focusedPanelId == panelId,
let terminalPanel = self.terminalPanel(for: panelId) else {
return
}
terminalPanel.hostedView.ensureFocus(for: self.id, surfaceId: panelId)
self.scheduleTerminalFirstResponderReassert(
panelId: panelId,
remainingPasses: remainingPasses - 1
)
}
}
private func maybeAutoFocusBrowserAddressBarOnPanelFocus(