Merge branch 'main' into issue-151-ssh-remote-port-proxying
This commit is contained in:
commit
d67090994e
61 changed files with 11220 additions and 614 deletions
|
|
@ -708,6 +708,7 @@ class GhosttyApp {
|
|||
private let backgroundLogLock = NSLock()
|
||||
private var backgroundLogSequence: UInt64 = 0
|
||||
private var appObservers: [NSObjectProtocol] = []
|
||||
private var bellAudioSound: NSSound?
|
||||
private var backgroundEventCounter: UInt64 = 0
|
||||
private var defaultBackgroundUpdateScope: GhosttyDefaultBackgroundUpdateScope = .unscoped
|
||||
private var defaultBackgroundScopeSource: String = "initialize"
|
||||
|
|
@ -1524,6 +1525,75 @@ class GhosttyApp {
|
|||
return found && enabled
|
||||
}
|
||||
|
||||
func appleScriptAutomationEnabled() -> Bool {
|
||||
guard let config else { return false }
|
||||
var enabled = false
|
||||
let key = "macos-applescript"
|
||||
_ = ghostty_config_get(config, &enabled, key, UInt(key.lengthOfBytes(using: .utf8)))
|
||||
return enabled
|
||||
}
|
||||
|
||||
fileprivate func shellIntegrationMode() -> String {
|
||||
guard let config else { return "detect" }
|
||||
var value: UnsafePointer<Int8>?
|
||||
let key = "shell-integration"
|
||||
guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))),
|
||||
let value else {
|
||||
return "detect"
|
||||
}
|
||||
return String(cString: value)
|
||||
}
|
||||
|
||||
private func bellFeatures() -> CUnsignedInt {
|
||||
guard let config else { return 0 }
|
||||
var features: CUnsignedInt = 0
|
||||
let key = "bell-features"
|
||||
_ = ghostty_config_get(config, &features, key, UInt(key.lengthOfBytes(using: .utf8)))
|
||||
return features
|
||||
}
|
||||
|
||||
private func bellAudioPath() -> String? {
|
||||
guard let config else { return nil }
|
||||
var value = ghostty_config_path_s()
|
||||
let key = "bell-audio-path"
|
||||
guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))),
|
||||
let rawPath = value.path else {
|
||||
return nil
|
||||
}
|
||||
let path = String(cString: rawPath)
|
||||
return path.isEmpty ? nil : path
|
||||
}
|
||||
|
||||
private func bellAudioVolume() -> Float {
|
||||
guard let config else { return 0.5 }
|
||||
var value: Double = 0.5
|
||||
let key = "bell-audio-volume"
|
||||
_ = ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8)))
|
||||
return Float(min(1.0, max(0.0, value)))
|
||||
}
|
||||
|
||||
private func ringBell() {
|
||||
let features = bellFeatures()
|
||||
|
||||
if (features & (1 << 0)) != 0 {
|
||||
NSSound.beep()
|
||||
}
|
||||
|
||||
if (features & (1 << 1)) != 0,
|
||||
let path = bellAudioPath(),
|
||||
let sound = NSSound(contentsOfFile: path, byReference: false) {
|
||||
sound.volume = bellAudioVolume()
|
||||
bellAudioSound = sound
|
||||
if !sound.play() {
|
||||
bellAudioSound = nil
|
||||
}
|
||||
}
|
||||
|
||||
if (features & (1 << 2)) != 0 {
|
||||
NSApp.requestUserAttention(.informationalRequest)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyDefaultBackground(
|
||||
color: NSColor,
|
||||
opacity: Double,
|
||||
|
|
@ -1690,6 +1760,13 @@ class GhosttyApp {
|
|||
}
|
||||
}
|
||||
|
||||
if action.tag == GHOSTTY_ACTION_RING_BELL {
|
||||
performOnMain {
|
||||
self.ringBell()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG {
|
||||
let soft = action.action.reload_config.soft
|
||||
logThemeAction("reload request target=app soft=\(soft)")
|
||||
|
|
@ -1797,6 +1874,11 @@ class GhosttyApp {
|
|||
guard let tabManager = AppDelegate.shared?.tabManager else { return false }
|
||||
return tabManager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil
|
||||
}
|
||||
case GHOSTTY_ACTION_RING_BELL:
|
||||
performOnMain {
|
||||
self.ringBell()
|
||||
}
|
||||
return true
|
||||
case GHOSTTY_ACTION_GOTO_SPLIT:
|
||||
guard let tabId = surfaceView.tabId,
|
||||
let surfaceId = surfaceView.terminalSurface?.id,
|
||||
|
|
@ -2766,6 +2848,9 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
?? "/bin/zsh"
|
||||
let shellName = URL(fileURLWithPath: shell).lastPathComponent
|
||||
if shellName == "zsh" {
|
||||
if GhosttyApp.shared.shellIntegrationMode() != "none" {
|
||||
env["CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION"] = "1"
|
||||
}
|
||||
let candidateZdotdir = (env["ZDOTDIR"]?.isEmpty == false ? env["ZDOTDIR"] : nil)
|
||||
?? getenv("ZDOTDIR").map { String(cString: $0) }
|
||||
?? (ProcessInfo.processInfo.environment["ZDOTDIR"]?.isEmpty == false ? ProcessInfo.processInfo.environment["ZDOTDIR"] : nil)
|
||||
|
|
@ -3310,9 +3395,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
|
||||
|
|
@ -3610,11 +3696,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 }
|
||||
|
|
@ -3634,7 +3748,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 {
|
||||
|
|
@ -4376,6 +4491,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
super.keyDown(with: event)
|
||||
return
|
||||
}
|
||||
if let terminalSurface {
|
||||
AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction(
|
||||
tabId: terminalSurface.tabId,
|
||||
surfaceId: terminalSurface.id
|
||||
)
|
||||
}
|
||||
if event.keyCode != 53 {
|
||||
endFindEscapeSuppression()
|
||||
}
|
||||
|
|
@ -4537,6 +4658,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
|
||||
|
|
@ -4544,6 +4666,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)
|
||||
|
|
@ -4564,6 +4687,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)
|
||||
|
|
@ -4578,6 +4702,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
}
|
||||
}
|
||||
|
||||
if shouldRefreshAfterTextInput {
|
||||
terminalSurface?.forceRefresh(reason: "keyDown.textInput")
|
||||
}
|
||||
|
||||
// Rendering is driven by Ghostty's wakeups/renderer.
|
||||
}
|
||||
|
||||
|
|
@ -4817,11 +4945,22 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
}
|
||||
#endif
|
||||
|
||||
private func requestPointerFocusRecovery() {
|
||||
#if DEBUG
|
||||
dlog("focus.pointerDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
|
||||
#endif
|
||||
onFocus?()
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
#if DEBUG
|
||||
let debugPoint = convert(event.locationInWindow, from: nil)
|
||||
dlog("terminal.mouseDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") mods=[\(debugModifierString(event.modifierFlags))] clickCount=\(event.clickCount) point=(\(String(format: "%.0f", debugPoint.x)),\(String(format: "%.0f", debugPoint.y)))")
|
||||
#endif
|
||||
// Split reparent/layout churn can suppress the later `becomeFirstResponder -> onFocus`
|
||||
// callback. Treat pointer-down as explicit focus intent so clicking a ghost pane still
|
||||
// repairs workspace/pane active state before key routing runs.
|
||||
requestPointerFocusRecovery()
|
||||
window?.makeFirstResponder(self)
|
||||
if let terminalSurface {
|
||||
AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction(
|
||||
|
|
@ -4846,10 +4985,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
override func rightMouseDown(with event: NSEvent) {
|
||||
guard let surface = surface else { return }
|
||||
if !ghostty_surface_mouse_captured(surface) {
|
||||
requestPointerFocusRecovery()
|
||||
super.rightMouseDown(with: event)
|
||||
return
|
||||
}
|
||||
|
||||
requestPointerFocusRecovery()
|
||||
window?.makeFirstResponder(self)
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
|
||||
|
|
@ -4871,6 +5012,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
super.otherMouseDown(with: event)
|
||||
return
|
||||
}
|
||||
requestPointerFocusRecovery()
|
||||
window?.makeFirstResponder(self)
|
||||
guard let surface = surface else { return }
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
|
|
@ -5330,6 +5472,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
|
||||
|
|
@ -6286,6 +6429,15 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
}
|
||||
}
|
||||
|
||||
var debugPortalVisibleInUI: Bool {
|
||||
surfaceView.isVisibleInUI
|
||||
}
|
||||
|
||||
var debugPortalFrameInWindow: CGRect {
|
||||
guard window != nil else { return .zero }
|
||||
return convert(bounds, to: nil)
|
||||
}
|
||||
|
||||
func setActive(_ active: Bool) {
|
||||
let wasActive = isActive
|
||||
isActive = active
|
||||
|
|
@ -6301,10 +6453,8 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
#endif
|
||||
if active {
|
||||
scheduleAutomaticFirstResponderApply(reason: "setActive")
|
||||
} else if let window,
|
||||
let fr = window.firstResponder as? NSView,
|
||||
fr === surfaceView || fr.isDescendant(of: surfaceView) {
|
||||
window.makeFirstResponder(nil)
|
||||
} else {
|
||||
resignOwnedFirstResponderIfNeeded(reason: "setActive(false)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -6354,15 +6504,29 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
#if DEBUG
|
||||
let surfaceShort = self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
|
||||
let searchActive = self.surfaceView.terminalSurface?.searchState != nil
|
||||
dlog("find.moveFocus to=\(surfaceShort) searchState=\(searchActive ? "active" : "nil")")
|
||||
dlog(
|
||||
"find.moveFocus to=\(surfaceShort) " +
|
||||
"from=\(previous?.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"searchState=\(searchActive ? "active" : "nil") " +
|
||||
"delayMs=\(Int((delay ?? 0) * 1000))"
|
||||
)
|
||||
#endif
|
||||
let work = { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let window = self.window else { return }
|
||||
#if DEBUG
|
||||
let before = String(describing: window.firstResponder)
|
||||
#endif
|
||||
if let previous, previous !== self {
|
||||
_ = previous.surfaceView.resignFirstResponder()
|
||||
}
|
||||
window.makeFirstResponder(self.surfaceView)
|
||||
let result = window.makeFirstResponder(self.surfaceView)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"find.moveFocus.apply to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"result=\(result ? 1 : 0) before=\(before) after=\(String(describing: window.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
if let delay, delay > 0 {
|
||||
|
|
@ -6506,6 +6670,12 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
guard isActive else { return }
|
||||
guard let window else { return }
|
||||
guard surfaceView.isVisibleInUI else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"reason=not_visible attempts=\(attemptsRemaining)"
|
||||
)
|
||||
#endif
|
||||
retry()
|
||||
return
|
||||
}
|
||||
|
|
@ -6545,25 +6715,59 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
|
||||
// Search focus restoration — only after confirming this is the active tab/pane.
|
||||
if surfaceView.terminalSurface?.searchState != nil {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"focus.ensure.search surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " +
|
||||
"attempts=\(attemptsRemaining) firstResponder=\(String(describing: window.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
restoreSearchFocus(window: window)
|
||||
return
|
||||
}
|
||||
|
||||
if let fr = window.firstResponder as? NSView,
|
||||
fr === surfaceView || fr.isDescendant(of: surfaceView) {
|
||||
reassertTerminalSurfaceFocus(reason: "ensureFocus.alreadyFirstResponder")
|
||||
return
|
||||
}
|
||||
|
||||
if !window.isKeyWindow {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
_ = window.makeFirstResponder(surfaceView)
|
||||
let result = window.makeFirstResponder(surfaceView)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"focus.ensure.apply surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " +
|
||||
"result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder)) " +
|
||||
"attempts=\(attemptsRemaining)"
|
||||
)
|
||||
#endif
|
||||
|
||||
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() {
|
||||
|
|
@ -6596,6 +6800,33 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -6616,6 +6847,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)
|
||||
|
|
@ -6623,6 +6862,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.
|
||||
|
|
@ -6636,6 +6876,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.
|
||||
|
|
@ -6644,6 +6887,18 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
|
||||
switch searchFocusTarget {
|
||||
case .searchField:
|
||||
if let firstResponder = window.firstResponder,
|
||||
isSearchOverlayOrDescendant(firstResponder),
|
||||
!isCurrentSurfaceSearchResponder(firstResponder) {
|
||||
surfaceView.terminalSurface?.setFocus(false)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " +
|
||||
"reason=foreignSearchResponder firstResponder=\(String(describing: firstResponder))"
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
// 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)
|
||||
|
|
@ -6653,16 +6908,152 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface)
|
||||
}
|
||||
#if DEBUG
|
||||
dlog("find.restoreSearchFocus surface=\(surfaceShort) target=searchField via=notification")
|
||||
dlog(
|
||||
"find.restoreSearchFocus surface=\(surfaceShort) target=searchField " +
|
||||
"via=notification firstResponder=\(String(describing: window.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
case .terminal:
|
||||
window.makeFirstResponder(surfaceView)
|
||||
let result = window.makeFirstResponder(surfaceView)
|
||||
#if DEBUG
|
||||
dlog("find.restoreSearchFocus surface=\(surfaceShort) target=terminal")
|
||||
dlog(
|
||||
"find.restoreSearchFocus surface=\(surfaceShort) target=terminal " +
|
||||
"result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func capturePanelFocusIntent(in window: NSWindow?) -> TerminalPanelFocusIntent {
|
||||
if surfaceView.terminalSurface?.searchState != nil {
|
||||
if let firstResponder = window?.firstResponder as? NSView,
|
||||
(firstResponder === surfaceView || firstResponder.isDescendant(of: surfaceView)) {
|
||||
return .surface
|
||||
}
|
||||
if let firstResponder = window?.firstResponder,
|
||||
isCurrentSurfaceSearchResponder(firstResponder) {
|
||||
return .findField
|
||||
}
|
||||
if searchFocusTarget == .searchField {
|
||||
return .findField
|
||||
}
|
||||
}
|
||||
return .surface
|
||||
}
|
||||
|
||||
func preferredPanelFocusIntentForActivation() -> TerminalPanelFocusIntent {
|
||||
if surfaceView.terminalSurface?.searchState != nil, searchFocusTarget == .searchField {
|
||||
return .findField
|
||||
}
|
||||
return .surface
|
||||
}
|
||||
|
||||
func preparePanelFocusIntentForActivation(_ intent: TerminalPanelFocusIntent) {
|
||||
switch intent {
|
||||
case .surface:
|
||||
searchFocusTarget = .terminal
|
||||
case .findField:
|
||||
guard surfaceView.terminalSurface?.searchState != nil else { return }
|
||||
searchFocusTarget = .searchField
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"find.preparePanelFocusIntent surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"target=\(intent == .findField ? "searchField" : "terminal")"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func restorePanelFocusIntent(_ intent: TerminalPanelFocusIntent) -> Bool {
|
||||
switch intent {
|
||||
case .surface:
|
||||
searchFocusTarget = .terminal
|
||||
setActive(true)
|
||||
applyFirstResponderIfNeeded()
|
||||
return true
|
||||
case .findField:
|
||||
guard let terminalSurface = surfaceView.terminalSurface,
|
||||
terminalSurface.searchState != nil else {
|
||||
return false
|
||||
}
|
||||
searchFocusTarget = .searchField
|
||||
setActive(true)
|
||||
if let window {
|
||||
restoreSearchFocus(window: window)
|
||||
} else {
|
||||
terminalSurface.setFocus(false)
|
||||
NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface)
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"find.restorePanelFocusIntent surface=\(terminalSurface.id.uuidString.prefix(5)) " +
|
||||
"target=searchField firstResponder=\(String(describing: window?.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func ownedPanelFocusIntent(for responder: NSResponder) -> TerminalPanelFocusIntent? {
|
||||
if isCurrentSurfaceSearchResponder(responder) {
|
||||
return .findField
|
||||
}
|
||||
|
||||
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 nil }
|
||||
if view === surfaceView || view.isDescendant(of: surfaceView) {
|
||||
return .surface
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func yieldPanelFocusIntent(_ intent: TerminalPanelFocusIntent, in window: NSWindow) -> Bool {
|
||||
guard let firstResponder = window.firstResponder,
|
||||
ownedPanelFocusIntent(for: firstResponder) == intent else {
|
||||
return false
|
||||
}
|
||||
|
||||
surfaceView.terminalSurface?.setFocus(false)
|
||||
resignOwnedFirstResponderIfNeeded(reason: "yieldPanelFocusIntent")
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"focus.handoff.yield surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"target=\(intent == .findField ? "searchField" : "terminal")"
|
||||
)
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
private func resignOwnedFirstResponderIfNeeded(reason: String) {
|
||||
guard let window,
|
||||
let firstResponder = window.firstResponder else { return }
|
||||
|
||||
let ownsSurfaceResponder: Bool = {
|
||||
guard let view = firstResponder as? NSView else { return false }
|
||||
return view === surfaceView || view.isDescendant(of: surfaceView)
|
||||
}()
|
||||
|
||||
guard ownsSurfaceResponder || isCurrentSurfaceSearchResponder(firstResponder) else { return }
|
||||
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"focus.surface.resign surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"reason=\(reason) firstResponder=\(String(describing: firstResponder))"
|
||||
)
|
||||
#endif
|
||||
window.makeFirstResponder(nil)
|
||||
}
|
||||
|
||||
/// Check if a responder is inside a search overlay hosting view.
|
||||
/// Handles the AppKit field-editor case: when an NSTextField is being edited,
|
||||
/// window.firstResponder is the shared NSTextView field editor, not the text field.
|
||||
|
|
@ -6678,11 +7069,27 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
var current: NSView? = view
|
||||
while let v = current {
|
||||
if v is NSHostingView<SurfaceSearchOverlay> { return true }
|
||||
let typeName = String(describing: type(of: v))
|
||||
if typeName.contains("BrowserSearchOverlay") { return true }
|
||||
current = v.superview
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func isCurrentSurfaceSearchResponder(_ responder: NSResponder) -> Bool {
|
||||
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.isDescendant(of: self)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct DebugRenderStats {
|
||||
let drawCount: Int
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue