Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4953,6 +4953,124 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return false
}

private func keyRoutingOwnerView(for responder: NSResponder?) -> NSView? {
guard let responder else { return nil }
if let editor = responder as? NSTextView,
editor.isFieldEditor,
let delegateView = editor.delegate as? NSView {
return delegateView
}
return responder as? NSView
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private func responderHasViableKeyRoutingOwner(
_ responder: NSResponder,
in window: NSWindow
) -> Bool {
if let ghosttyView = cmuxOwningGhosttyView(for: responder) {
if ghosttyView.window !== window {
return false
}
if ghosttyView.isHiddenOrHasHiddenAncestor {
return false
}
return ghosttyView === window.contentView || ghosttyView.superview != nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

guard let ownerView = keyRoutingOwnerView(for: responder) else {
return false
}

if ownerView.window !== window {
return false
}

if ownerView.isHiddenOrHasHiddenAncestor {
return false
}

if ownerView !== window.contentView, ownerView.superview == nil {
return false
}

return true
}

private func responderNeedsFocusedTerminalKeyRepair(
_ responder: NSResponder?,
in window: NSWindow,
hostedView: GhosttySurfaceScrollView
) -> Bool {
guard let responder else { return true }
if responder is NSWindow { return true }
guard responderHasViableKeyRoutingOwner(responder, in: window) else {
return true
}
if hostedView.responderMatchesPreferredKeyboardFocus(responder) {
return false
}
if cmuxOwningGhosttyView(for: responder) != nil {
return true
}
guard let ownerView = keyRoutingOwnerView(for: responder) else {
return true
}
if ownerView === window.contentView {
return true
}
if ownerView is NSControl || responder is NSTextView {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
return false
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return true
}

func repairFocusedTerminalKeyboardRoutingIfNeeded(
window: NSWindow,
event: NSEvent
) {
guard event.type == .keyDown else { return }
let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
guard !normalizedFlags.contains(.command) else { return }
guard isMainTerminalWindow(window) else { return }
guard window.attachedSheet == nil else { return }
guard !isCommandPaletteEffectivelyVisible(in: window) else { return }
guard let context = contextForMainWindow(window) ?? contextForMainTerminalWindow(window),
let workspace = context.tabManager.selectedWorkspace,
let panelId = workspace.focusedPanelId,
let terminalPanel = workspace.terminalPanel(for: panelId) else {
return
}
guard responderNeedsFocusedTerminalKeyRepair(
window.firstResponder,
in: window,
hostedView: terminalPanel.hostedView
) else { return }

#if DEBUG
let before = window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
let target = terminalPanel.hostedView.preferredPanelFocusIntentForActivation()
dlog(
"focus.keyRepair attempt window=\(ObjectIdentifier(window)) " +
"workspace=\(String(workspace.id.uuidString.prefix(5))) " +
"panel=\(String(panelId.uuidString.prefix(5))) " +
"target=\(target == .findField ? "searchField" : "surface") " +
"fr=\(before) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue)"
)
#endif

terminalPanel.hostedView.ensureFocus(for: workspace.id, surfaceId: panelId)

#if DEBUG
let after = window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
dlog(
"focus.keyRepair result window=\(ObjectIdentifier(window)) " +
"panel=\(String(panelId.uuidString.prefix(5))) " +
"isSurfaceResponder=\(terminalPanel.hostedView.isSurfaceViewFirstResponder() ? 1 : 0) " +
"fr=\(after)"
)
#endif
}

func locateSurface(surfaceId: UUID) -> (windowId: UUID, workspaceId: UUID, tabManager: TabManager)? {
for ctx in mainWindowContexts.values {
for ws in ctx.tabManager.tabs {
Expand Down Expand Up @@ -13137,6 +13255,7 @@ private extension NSWindow {
let typingTimingStart = event.type == .keyDown ? CmuxTypingTiming.start() : nil
let phaseTotalStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0
var contextSetupMs: Double = 0
var focusRepairMs: Double = 0
var folderGuardMs: Double = 0
var originalDispatchMs: Double = 0
let typingTimingExtra: String? = {
Expand Down Expand Up @@ -13170,6 +13289,7 @@ private extension NSWindow {
thresholdMs: 1.0,
parts: [
("contextSetupMs", contextSetupMs),
("focusRepairMs", focusRepairMs),
("folderGuardMs", folderGuardMs),
("originalDispatchMs", originalDispatchMs),
],
Expand All @@ -13195,6 +13315,18 @@ private extension NSWindow {
if event.type == .keyDown {
contextSetupMs = (ProcessInfo.processInfo.systemUptime - contextSetupStart) * 1000.0
}
let focusRepairStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0
#endif
if event.type == .keyDown {
AppDelegate.shared?.repairFocusedTerminalKeyboardRoutingIfNeeded(
window: self,
event: event
)
}
#if DEBUG
if event.type == .keyDown {
focusRepairMs = (ProcessInfo.processInfo.systemUptime - focusRepairStart) * 1000.0
}
let folderGuardStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0
#endif
defer {
Expand Down
111 changes: 102 additions & 9 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1455,21 +1455,48 @@ class GhosttyApp {
}
}

private func hasConfiguredBackgroundImage(_ config: ghostty_config_t) -> Bool {
var backgroundImage: UnsafePointer<Int8>?
let key = "background-image"
guard ghostty_config_get(config, &backgroundImage, key, UInt(key.lengthOfBytes(using: .utf8))),
let backgroundImage else {
return false
}

return !String(cString: backgroundImage).isEmpty
}

private func loadDefaultConfigFilesWithLegacyFallback(_ config: ghostty_config_t) {
ghostty_config_load_default_files(config)
loadLegacyGhosttyConfigIfNeeded(config)
ghostty_config_load_recursive_files(config)
loadCmuxAppSupportGhosttyConfigIfNeeded(config)
loadCJKFontFallbackIfNeeded(config)
// cmux provides the terminal background via backgroundView (CALayer)
// instead of the GPU full-screen bg pass, so the layer can provide
// instant coverage during sidebar toggle and other layout transitions.
loadInlineGhosttyConfig(
"macos-background-from-layer = true",
into: config,
prefix: "cmux-layer-bg",
logLabel: "layer background"
)
if hasConfiguredBackgroundImage(config) {
// Background images need Ghostty's fullscreen background pass. Force
// the layer-backed solid-color shortcut back off even if the user
// config enabled it manually.
loadInlineGhosttyConfig(
"macos-background-from-layer = false",
into: config,
prefix: "cmux-layer-bg-image-override",
logLabel: "layer background image override"
)
} else {
// cmux provides the terminal background via backgroundView (CALayer)
// instead of the GPU full-screen bg pass, so the layer can provide
// instant coverage during sidebar toggle and other layout transitions.
//
// Keep Ghostty's native background rendering when a background image
// is configured: the separate CALayer background only matches the
// solid-color path, not Ghostty's combined image compositing.
loadInlineGhosttyConfig(
"macos-background-from-layer = true",
into: config,
prefix: "cmux-layer-bg",
logLabel: "layer background"
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
ghostty_config_finalize(config)
}

Expand Down Expand Up @@ -9566,6 +9593,9 @@ final class GhosttySurfaceScrollView: NSView {
#endif
return
}
if focusMountedSearchFieldIfAvailable(window: window, surfaceShort: surfaceShort) {
return
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Explicitly unfocus the terminal so cursor stops blinking immediately.
// The notification observer also does this, but it runs async when posted from main.
surfaceView.terminalSurface?.setFocus(false)
Expand All @@ -9591,6 +9621,48 @@ final class GhosttySurfaceScrollView: NSView {
}
}

@discardableResult
private func focusMountedSearchFieldIfAvailable(
window: NSWindow,
surfaceShort: String
) -> Bool {
guard let overlay = searchOverlayHostingView,
overlay.superview === self,
let field = findEditableSearchField(in: overlay) else {
return false
}

let firstResponder = window.firstResponder
let alreadyFocused = firstResponder === field ||
field.currentEditor() != nil ||
((firstResponder as? NSTextView)?.delegate as? NSTextField) === field

surfaceView.terminalSurface?.setFocus(false)

#if DEBUG
if alreadyFocused {
dlog(
"find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " +
"reason=mountedFieldAlreadyFocused firstResponder=\(String(describing: firstResponder))"
)
}
#endif
guard !alreadyFocused else { return true }

let result = window.makeFirstResponder(field)
let ownsField = window.firstResponder === field ||
((window.firstResponder as? NSTextView)?.delegate as? NSTextField) === field

#if DEBUG
dlog(
"find.restoreSearchFocus surface=\(surfaceShort) target=searchField " +
"via=mountedField result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))"
)
#endif

return ownsField
}

func capturePanelFocusIntent(in window: NSWindow?) -> TerminalPanelFocusIntent {
if surfaceView.terminalSurface?.searchState != nil {
if let firstResponder = window?.firstResponder as? NSView,
Expand All @@ -9615,6 +9687,27 @@ final class GhosttySurfaceScrollView: NSView {
return .surface
}

func responderMatchesPreferredKeyboardFocus(_ responder: NSResponder) -> Bool {
switch preferredPanelFocusIntentForActivation() {
case .surface:
let resolvedResponder: NSResponder
if let editor = responder as? NSTextView,
editor.isFieldEditor,
let editedView = editor.delegate as? NSView {
resolvedResponder = editedView
} else {
resolvedResponder = responder
}

guard let view = resolvedResponder as? NSView else { return false }
return view === surfaceView || view.isDescendant(of: surfaceView)

case .findField:
return isCurrentSurfaceSearchResponder(responder) &&
isSearchOverlayOrDescendant(responder)
}
}

func preparePanelFocusIntentForActivation(_ intent: TerminalPanelFocusIntent) {
switch intent {
case .surface:
Expand Down
Loading
Loading