* Add debug logs for Cmd+F find bar focus/refocus state machine Traces the full lifecycle: menu action, startSearch, overlay mount/unmount, focus changes, window key/resign, applyFirstResponderIfNeeded guards, and moveFocus calls. Helps reproduce the bug where Cmd+F fails to reopen after switching away and back to the terminal window. * Fix Cmd+F find bar focus loss after window switch When the find bar is open and the user switches away and back, the window's first responder was left as the NSWindow itself because applyFirstResponderIfNeeded bailed on the searchState guard and nothing refocused the find bar. This caused a dead state where neither the search field nor the terminal accepted keyboard input. Add a SearchFocusTarget state machine (.searchField / .terminal) to GhosttySurfaceScrollView that tracks user intent. On window-become-key, restoreSearchFocus() makes the correct view first responder based on the target. Pressing Escape with a non-empty needle sets target to .terminal so window reactivation preserves that intent. Cmd+F and .ghosttySearchFocus notifications reset target to .searchField. * Fix multi-surface focus stealing and NSHostingView responder issue Two bugs found from debug logs: 1. Other surfaces in the same window (without search active) were calling applyFirstResponderIfNeeded and stealing focus from the find bar's surface. Added a check: if current first responder is inside a search overlay NSHostingView, don't steal it. 2. window.makeFirstResponder(overlay) on the NSHostingView was wrong. It made the hosting view itself the responder, which ate keystrokes as performKeyEquivalent instead of routing them to the SwiftUI TextField inside. Removed that call, now only posting the .ghosttySearchFocus notification to let SwiftUI handle internal focus via @FocusState. * Use AppKit NSTextField focus instead of SwiftUI @FocusState for search restore The notification-only approach fails because SwiftUI @FocusState can't propagate to AppKit when the first responder is the NSWindow itself (no view in the responder chain to anchor the change). And making the NSHostingView first responder eats keys as performKeyEquivalent. Now walks the hosting view's subview tree to find the actual editable NSTextField backing the SwiftUI TextField, and calls window.makeFirstResponder directly on it. Falls back to notification if the text field isn't found. * Two-phase focus restore: AppKit + SwiftUI sync, click-to-terminal fix restoreSearchFocus now does both: 1. AppKit: makeFirstResponder(nsTextField) so typing works immediately 2. SwiftUI: post .ghosttySearchFocus so @FocusState syncs and .onExitCommand (Escape) and .onKeyPress (Return) still work Also: clicking the terminal while find bar is open now sets searchFocusTarget to .terminal, so window reactivation correctly restores terminal focus instead of jumping back to the search field. * Replace SwiftUI TextField with NSViewRepresentable for find bar The core issue: SwiftUI @FocusState does not sync with AppKit's first responder after window resign/become-key cycles. This caused the find bar to lose all keyboard input after switching windows. Previous attempts to bridge SwiftUI and AppKit focus (notifications, makeFirstResponder on the backing NSTextField, belt-and-suspenders approaches) all failed because SwiftUI event handlers (.onExitCommand for Escape, .onKeyPress for Return) require @FocusState to be set. Fix: replace the SwiftUI TextField with an NSViewRepresentable-wrapped NSTextField (SearchTextFieldRepresentable), following the proven OmnibarNativeTextField pattern already in BrowserPanelView.swift. - Escape and Return handled via control(_:textView:doCommandBy:) at the AppKit delegate level, no @FocusState needed - Focus restored via .ghosttySearchFocus notification observed directly by the Coordinator, calling makeFirstResponder immediately - hasMarkedText() guard preserves CJK IME composition (issue #118) - isProgrammaticMutation guard prevents text binding cursor reset - Removes findTextField(in:) subview walk hack * Explicitly unfocus terminal surface when find bar takes focus The Ghostty cursor kept blinking even when the search field was focused because ghostty_surface_set_focus(false) was only called via surfaceView.resignFirstResponder. After window switching, the surface view may not have been the first responder, so resign was never called. Fix: call surface.setFocus(false) in both the .ghosttySearchFocus notification observer and directly in restoreSearchFocus. This ensures the cursor stops blinking regardless of previous first-responder state. * Address review findings: field-editor guard, NSLog→dlog, stale focus 1. isSearchOverlayOrDescendant now accepts NSResponder and follows the field-editor delegate chain back to the owning NSTextField. Previously, when the search field was being edited, the shared NSTextView field editor was the first responder (outside the overlay hierarchy), so the guard missed it and other surfaces could steal focus. 2. Converted all NSLog calls in TabManager (startSearch, hideFind, searchSelection), cmuxApp (Find menu), and GhosttyTerminalView (searchState didSet) to dlog() wrapped in #if DEBUG. Avoids leaking search needle text to system logs in release builds. 3. Added isFocused re-check inside the deferred focus block in SearchTextFieldRepresentable to prevent stale focus requests from stealing focus back after intent has changed. * Guard against re-focusing already-focused search field Every keystroke updated searchState.needle (@Published), which triggered a SwiftUI re-render → ensureFocus → restoreSearchFocus → posted .ghosttySearchFocus notification → Coordinator called makeFirstResponder unconditionally. makeFirstResponder on an already-editing NSTextField ends the editing session and restarts with all text selected, so the next typed character replaced the previous one ("hi" → "i"). Fix: check if the field is already first responder before calling makeFirstResponder in the notification handler. * Address review findings: stale focus target, IME guard, tab/pane gating - Add onFieldDidFocus callback so clicking back into the search field after Escape updates searchFocusTarget = .searchField, fixing stale focus restoration after window switches. - Guard updateNSView text sync with !editor.hasMarkedText() to prevent stomping active CJK IME composition. - Move ensureFocus search state check after tab/pane selection guards so search focus isn't restored on non-active tabs/panes. - Clear surfaceView.onFocus when setFocusHandler(nil) is called.
402 lines
15 KiB
Swift
402 lines
15 KiB
Swift
import AppKit
|
|
import Bonsplit
|
|
import SwiftUI
|
|
|
|
struct SurfaceSearchOverlay: View {
|
|
let tabId: UUID
|
|
let surfaceId: UUID
|
|
@ObservedObject var searchState: TerminalSurface.SearchState
|
|
let onMoveFocusToTerminal: () -> Void
|
|
let onNavigateSearch: (_ action: String) -> Void
|
|
let onFieldDidFocus: () -> Void
|
|
let onClose: () -> Void
|
|
@State private var corner: Corner = .topRight
|
|
@State private var dragOffset: CGSize = .zero
|
|
@State private var barSize: CGSize = .zero
|
|
@State private var isSearchFieldFocused: Bool = true
|
|
|
|
private let padding: CGFloat = 8
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
HStack(spacing: 4) {
|
|
SearchTextFieldRepresentable(
|
|
text: $searchState.needle,
|
|
isFocused: $isSearchFieldFocused,
|
|
surfaceId: surfaceId,
|
|
onFieldDidFocus: onFieldDidFocus,
|
|
onEscape: {
|
|
#if DEBUG
|
|
dlog("find.nativeField.escape surface=\(surfaceId.uuidString.prefix(5)) needleEmpty=\(searchState.needle.isEmpty)")
|
|
#endif
|
|
if searchState.needle.isEmpty {
|
|
onClose()
|
|
} else {
|
|
onMoveFocusToTerminal()
|
|
}
|
|
},
|
|
onReturn: { isShift in
|
|
let action = isShift
|
|
? "navigate_search:previous"
|
|
: "navigate_search:next"
|
|
onNavigateSearch(action)
|
|
}
|
|
)
|
|
.frame(width: 180)
|
|
.padding(.leading, 8)
|
|
.padding(.trailing, 50)
|
|
.padding(.vertical, 6)
|
|
.background(Color.primary.opacity(0.1))
|
|
.cornerRadius(6)
|
|
.overlay(alignment: .trailing) {
|
|
if let selected = searchState.selected {
|
|
let totalText = searchState.total.map { String($0) } ?? "?"
|
|
Text("\(selected + 1)/\(totalText)")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.monospacedDigit()
|
|
.padding(.trailing, 8)
|
|
} else if let total = searchState.total {
|
|
Text("-/\(total)")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.monospacedDigit()
|
|
.padding(.trailing, 8)
|
|
}
|
|
}
|
|
|
|
Button(action: {
|
|
#if DEBUG
|
|
dlog("findbar.next surface=\(surfaceId.uuidString.prefix(5))")
|
|
#endif
|
|
onNavigateSearch("navigate_search:next")
|
|
}) {
|
|
Image(systemName: "chevron.up")
|
|
}
|
|
.buttonStyle(SearchButtonStyle())
|
|
.help("Next match (Return)")
|
|
|
|
Button(action: {
|
|
#if DEBUG
|
|
dlog("findbar.prev surface=\(surfaceId.uuidString.prefix(5))")
|
|
#endif
|
|
onNavigateSearch("navigate_search:previous")
|
|
}) {
|
|
Image(systemName: "chevron.down")
|
|
}
|
|
.buttonStyle(SearchButtonStyle())
|
|
.help("Previous match (Shift+Return)")
|
|
|
|
Button(action: {
|
|
#if DEBUG
|
|
dlog("findbar.close surface=\(surfaceId.uuidString.prefix(5))")
|
|
#endif
|
|
onClose()
|
|
}) {
|
|
Image(systemName: "xmark")
|
|
}
|
|
.buttonStyle(SearchButtonStyle())
|
|
.help("Close (Esc)")
|
|
}
|
|
.padding(8)
|
|
.background(.background)
|
|
.clipShape(clipShape)
|
|
.shadow(radius: 4)
|
|
.onAppear {
|
|
#if DEBUG
|
|
dlog("find.overlay.appear tab=\(tabId.uuidString.prefix(5)) surface=\(surfaceId.uuidString.prefix(5))")
|
|
#endif
|
|
isSearchFieldFocused = true
|
|
}
|
|
.background(
|
|
GeometryReader { barGeo in
|
|
Color.clear.onAppear {
|
|
barSize = barGeo.size
|
|
}
|
|
}
|
|
)
|
|
.padding(padding)
|
|
.offset(dragOffset)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: corner.alignment)
|
|
.gesture(
|
|
DragGesture()
|
|
.onChanged { value in
|
|
dragOffset = value.translation
|
|
}
|
|
.onEnded { value in
|
|
let centerPos = centerPosition(for: corner, in: geo.size, barSize: barSize)
|
|
let newCenter = CGPoint(
|
|
x: centerPos.x + value.translation.width,
|
|
y: centerPos.y + value.translation.height
|
|
)
|
|
let newCorner = closestCorner(to: newCenter, in: geo.size)
|
|
withAnimation(.easeOut(duration: 0.2)) {
|
|
corner = newCorner
|
|
dragOffset = .zero
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
private var clipShape: some Shape {
|
|
RoundedRectangle(cornerRadius: 8)
|
|
}
|
|
|
|
enum Corner {
|
|
case topLeft
|
|
case topRight
|
|
case bottomLeft
|
|
case bottomRight
|
|
|
|
var alignment: Alignment {
|
|
switch self {
|
|
case .topLeft: return .topLeading
|
|
case .topRight: return .topTrailing
|
|
case .bottomLeft: return .bottomLeading
|
|
case .bottomRight: return .bottomTrailing
|
|
}
|
|
}
|
|
}
|
|
|
|
private func centerPosition(for corner: Corner, in containerSize: CGSize, barSize: CGSize) -> CGPoint {
|
|
let halfWidth = barSize.width / 2 + padding
|
|
let halfHeight = barSize.height / 2 + padding
|
|
|
|
switch corner {
|
|
case .topLeft:
|
|
return CGPoint(x: halfWidth, y: halfHeight)
|
|
case .topRight:
|
|
return CGPoint(x: containerSize.width - halfWidth, y: halfHeight)
|
|
case .bottomLeft:
|
|
return CGPoint(x: halfWidth, y: containerSize.height - halfHeight)
|
|
case .bottomRight:
|
|
return CGPoint(x: containerSize.width - halfWidth, y: containerSize.height - halfHeight)
|
|
}
|
|
}
|
|
|
|
private func closestCorner(to point: CGPoint, in containerSize: CGSize) -> Corner {
|
|
let midX = containerSize.width / 2
|
|
let midY = containerSize.height / 2
|
|
|
|
if point.x < midX {
|
|
return point.y < midY ? .topLeft : .bottomLeft
|
|
}
|
|
return point.y < midY ? .topRight : .bottomRight
|
|
}
|
|
}
|
|
|
|
// MARK: - Native Search Text Field (AppKit)
|
|
|
|
/// NSTextField subclass for the terminal find bar.
|
|
/// Strips visual chrome so SwiftUI handles the background/border appearance.
|
|
private final class SearchNativeTextField: NSTextField {
|
|
override init(frame frameRect: NSRect) {
|
|
super.init(frame: frameRect)
|
|
isBordered = false
|
|
isBezeled = false
|
|
drawsBackground = false
|
|
focusRingType = .none
|
|
usesSingleLineMode = true
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
}
|
|
|
|
/// NSViewRepresentable wrapping SearchNativeTextField.
|
|
/// Handles Escape and Return at the AppKit delegate level, eliminating the
|
|
/// SwiftUI @FocusState / AppKit first-responder mismatch that broke focus
|
|
/// after window switching.
|
|
private struct SearchTextFieldRepresentable: NSViewRepresentable {
|
|
@Binding var text: String
|
|
@Binding var isFocused: Bool
|
|
let surfaceId: UUID
|
|
let onFieldDidFocus: () -> Void
|
|
let onEscape: () -> Void
|
|
let onReturn: (_ isShift: Bool) -> Void
|
|
|
|
final class Coordinator: NSObject, NSTextFieldDelegate {
|
|
var parent: SearchTextFieldRepresentable
|
|
var isProgrammaticMutation = false
|
|
weak var parentField: SearchNativeTextField?
|
|
var pendingFocusRequest: Bool?
|
|
var searchFocusObserver: NSObjectProtocol?
|
|
|
|
init(parent: SearchTextFieldRepresentable) {
|
|
self.parent = parent
|
|
}
|
|
|
|
deinit {
|
|
if let searchFocusObserver {
|
|
NotificationCenter.default.removeObserver(searchFocusObserver)
|
|
}
|
|
}
|
|
|
|
func controlTextDidChange(_ obj: Notification) {
|
|
guard !isProgrammaticMutation else { return }
|
|
guard let field = obj.object as? NSTextField else { return }
|
|
parent.text = field.stringValue
|
|
}
|
|
|
|
func controlTextDidBeginEditing(_ obj: Notification) {
|
|
#if DEBUG
|
|
dlog("find.nativeField.beginEditing surface=\(parent.surfaceId.uuidString.prefix(5))")
|
|
#endif
|
|
parent.onFieldDidFocus()
|
|
if !parent.isFocused {
|
|
DispatchQueue.main.async {
|
|
self.parent.isFocused = true
|
|
}
|
|
}
|
|
}
|
|
|
|
func controlTextDidEndEditing(_ obj: Notification) {
|
|
#if DEBUG
|
|
dlog("find.nativeField.endEditing surface=\(parent.surfaceId.uuidString.prefix(5))")
|
|
#endif
|
|
if parent.isFocused {
|
|
DispatchQueue.main.async {
|
|
self.parent.isFocused = false
|
|
}
|
|
}
|
|
}
|
|
|
|
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
|
switch commandSelector {
|
|
case #selector(NSResponder.cancelOperation(_:)):
|
|
// Don't intercept Escape during CJK IME composition (issue #118)
|
|
if textView.hasMarkedText() { return false }
|
|
parent.onEscape()
|
|
return true
|
|
case #selector(NSResponder.insertNewline(_:)):
|
|
if textView.hasMarkedText() { return false }
|
|
let isShift = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false
|
|
parent.onReturn(isShift)
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(parent: self)
|
|
}
|
|
|
|
func makeNSView(context: Context) -> SearchNativeTextField {
|
|
let field = SearchNativeTextField(frame: .zero)
|
|
field.font = .systemFont(ofSize: NSFont.systemFontSize)
|
|
field.placeholderString = "Search"
|
|
field.delegate = context.coordinator
|
|
field.stringValue = text
|
|
context.coordinator.parentField = field
|
|
|
|
// Observe .ghosttySearchFocus to immediately focus from AppKit level.
|
|
// This is the primary mechanism for restoring focus after window switches.
|
|
context.coordinator.searchFocusObserver = NotificationCenter.default.addObserver(
|
|
forName: .ghosttySearchFocus,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak field, weak coordinator = context.coordinator] notification in
|
|
guard let field, let coordinator else { return }
|
|
guard let surface = notification.object as? TerminalSurface,
|
|
surface.id == coordinator.parent.surfaceId else { return }
|
|
guard let window = field.window else { return }
|
|
// Don't re-focus if already first responder. makeFirstResponder on an
|
|
// already-editing NSTextField ends the editing session and restarts it
|
|
// with all text selected, causing typed characters to replace each other.
|
|
let fr = window.firstResponder
|
|
let alreadyFocused = fr === field ||
|
|
field.currentEditor() != nil ||
|
|
((fr as? NSTextView)?.delegate as? NSTextField) === field
|
|
#if DEBUG
|
|
dlog("find.nativeField.searchFocusNotification surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) alreadyFocused=\(alreadyFocused)")
|
|
#endif
|
|
guard !alreadyFocused else { return }
|
|
window.makeFirstResponder(field)
|
|
}
|
|
|
|
return field
|
|
}
|
|
|
|
func updateNSView(_ nsView: SearchNativeTextField, context: Context) {
|
|
context.coordinator.parent = self
|
|
context.coordinator.parentField = nsView
|
|
|
|
// Sync text from binding to field (skip during active IME composition)
|
|
if let editor = nsView.currentEditor() as? NSTextView {
|
|
if editor.string != text, !editor.hasMarkedText() {
|
|
context.coordinator.isProgrammaticMutation = true
|
|
editor.string = text
|
|
nsView.stringValue = text
|
|
context.coordinator.isProgrammaticMutation = false
|
|
}
|
|
} else if nsView.stringValue != text {
|
|
nsView.stringValue = text
|
|
}
|
|
|
|
// Sync focus from binding to AppKit
|
|
if let window = nsView.window {
|
|
let fr = window.firstResponder
|
|
let isFirstResponder =
|
|
fr === nsView ||
|
|
nsView.currentEditor() != nil ||
|
|
((fr as? NSTextView)?.delegate as? NSTextField) === nsView
|
|
|
|
if isFocused, !isFirstResponder, context.coordinator.pendingFocusRequest != true {
|
|
context.coordinator.pendingFocusRequest = true
|
|
DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in
|
|
coordinator?.pendingFocusRequest = nil
|
|
guard let coordinator, coordinator.parent.isFocused else { return }
|
|
guard let nsView, let window = nsView.window else { return }
|
|
let fr = window.firstResponder
|
|
let alreadyFocused = fr === nsView ||
|
|
nsView.currentEditor() != nil ||
|
|
((fr as? NSTextView)?.delegate as? NSTextField) === nsView
|
|
guard !alreadyFocused else { return }
|
|
window.makeFirstResponder(nsView)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
static func dismantleNSView(_ nsView: SearchNativeTextField, coordinator: Coordinator) {
|
|
if let observer = coordinator.searchFocusObserver {
|
|
NotificationCenter.default.removeObserver(observer)
|
|
coordinator.searchFocusObserver = nil
|
|
}
|
|
nsView.delegate = nil
|
|
coordinator.parentField = nil
|
|
}
|
|
}
|
|
|
|
struct SearchButtonStyle: ButtonStyle {
|
|
@State private var isHovered = false
|
|
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.foregroundStyle(isHovered || configuration.isPressed ? .primary : .secondary)
|
|
.padding(.horizontal, 2)
|
|
.frame(height: 26)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(backgroundColor(isPressed: configuration.isPressed))
|
|
)
|
|
.onHover { hovering in
|
|
isHovered = hovering
|
|
}
|
|
.backport.pointerStyle(.link)
|
|
}
|
|
|
|
private func backgroundColor(isPressed: Bool) -> Color {
|
|
if isPressed {
|
|
return Color.primary.opacity(0.2)
|
|
}
|
|
if isHovered {
|
|
return Color.primary.opacity(0.1)
|
|
}
|
|
return Color.clear
|
|
}
|
|
}
|