cmux/Sources/Find/SurfaceSearchOverlay.swift
Lawrence Chen 8a3ab6b3f0
Fix command palette focus after terminal find (#2089)
* test: cover command palette focus guard

* fix: block terminal find from stealing palette focus

* test: cover text view focus-stealer fallback

* Add regression for hidden DevTools sync republish loop

* Avoid redundant DevTools visibility publishes

* test: cover browser find focus after workspace round-trip

* fix: restore browser find focus after workspace round-trip

* fix: keep browser find caret on workspace return

* Add workspace round-trip split find regressions

* Keep inactive find overlays from stealing focus

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
2026-03-25 17:27:54 -07:00

436 lines
17 KiB
Swift

import AppKit
import Bonsplit
import SwiftUI
private extension NSView {
func cmuxAncestor<T: NSView>(of type: T.Type) -> T? {
var current: NSView? = self
while let view = current {
if let target = view as? T {
return target
}
current = view.superview
}
return nil
}
}
struct SurfaceSearchOverlay: View {
let tabId: UUID
let surfaceId: UUID
@ObservedObject var searchState: TerminalSurface.SearchState
let canApplyFocusRequest: () -> Bool
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,
canApplyFocusRequest: canApplyFocusRequest,
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)
}
)
.accessibilityIdentifier("TerminalFindSearchTextField")
.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())
.safeHelp(String(localized: "search.nextMatch.help", defaultValue: "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())
.safeHelp(String(localized: "search.previousMatch.help", defaultValue: "Previous match (Shift+Return)"))
Button(action: {
#if DEBUG
dlog("findbar.close surface=\(surfaceId.uuidString.prefix(5))")
#endif
onClose()
}) {
Image(systemName: "xmark")
}
.buttonStyle(SearchButtonStyle())
.safeHelp(String(localized: "search.close.help", defaultValue: "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 canApplyFocusRequest: () -> Bool
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 }
control.cmuxAncestor(of: GhosttySurfaceScrollView.self)?.beginFindEscapeSuppression()
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 = String(localized: "search.placeholder", defaultValue: "Search")
field.setAccessibilityIdentifier("TerminalFindSearchTextField")
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 coordinator.parent.canApplyFocusRequest() 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) firstResponder=\(String(describing: fr))"
)
#endif
guard !alreadyFocused else { return }
let result = window.makeFirstResponder(field)
#if DEBUG
dlog(
"find.nativeField.searchFocusApply surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) " +
"result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))"
)
#endif
}
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,
canApplyFocusRequest(),
!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,
coordinator.parent.canApplyFocusRequest() 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
}
}