From 48ec60946fc902ace11cf4244cfc7c571dd48f74 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 13 Mar 2026 18:16:57 -0700 Subject: [PATCH] Replace command palette SwiftUI TextField with native AppKit NSTextField The SwiftUI TextField lost arrow-key and backspace handlers when the query prefix (">") was deleted, because the scope transition tore down the .onKeyPress modifiers. Using an NSViewRepresentable with an AppKit field editor keeps navigation commands (up/down/enter/escape) on the native delegate, making them immune to SwiftUI scope changes. Fixes #1409 Co-Authored-By: Claude Opus 4.6 --- Sources/ContentView.swift | 190 +++++++++++++++++++++++++++++--------- 1 file changed, 147 insertions(+), 43 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index eb2bcf48..8a07eaaa 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -3071,35 +3071,18 @@ struct ContentView: View { let commandPaletteListHeight = min(commandPaletteListMaxHeight, commandPaletteListContentHeight) return VStack(spacing: 0) { HStack(spacing: 8) { - TextField(commandPaletteSearchPlaceholder, text: $commandPaletteQuery) - .textFieldStyle(.plain) - .font(.system(size: 13, weight: .regular)) - .tint(Color(nsColor: sidebarActiveForegroundNSColor(opacity: 1.0))) - .focused($isCommandPaletteSearchFocused) - .accessibilityIdentifier("CommandPaletteSearchField") - .onSubmit { - runSelectedCommandPaletteResult() - } - .backport.onKeyPress(.downArrow) { _ in - moveCommandPaletteSelection(by: 1) - return .handled - } - .backport.onKeyPress(.upArrow) { _ in - moveCommandPaletteSelection(by: -1) - return .handled - } - .backport.onKeyPress("n") { modifiers in - handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: 1) - } - .backport.onKeyPress("p") { modifiers in - handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: -1) - } - .backport.onKeyPress("j") { modifiers in - handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: 1) - } - .backport.onKeyPress("k") { modifiers in - handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: -1) - } + CommandPaletteSearchFieldRepresentable( + placeholder: commandPaletteSearchPlaceholder, + text: $commandPaletteQuery, + isFocused: Binding( + get: { isCommandPaletteSearchFocused }, + set: { isCommandPaletteSearchFocused = $0 } + ), + onSubmit: runSelectedCommandPaletteResult, + onEscape: { dismissCommandPalette() }, + onMoveSelection: moveCommandPaletteSelection(by:) + ) + .frame(maxWidth: .infinity) } .padding(.horizontal, 9) .padding(.vertical, 7) @@ -3345,6 +3328,141 @@ struct ContentView: View { } } + private final class CommandPaletteNativeTextField: 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") + } + } + + // Keep navigation on the AppKit field editor so deleting the ">" prefix + // cannot drop the palette's arrow-key handlers during the scope switch. + private struct CommandPaletteSearchFieldRepresentable: NSViewRepresentable { + let placeholder: String + @Binding var text: String + @Binding var isFocused: Bool + let onSubmit: () -> Void + let onEscape: () -> Void + let onMoveSelection: (Int) -> Void + + final class Coordinator: NSObject, NSTextFieldDelegate { + var parent: CommandPaletteSearchFieldRepresentable + var isProgrammaticMutation = false + weak var parentField: CommandPaletteNativeTextField? + var pendingFocusRequest: Bool? + + init(parent: CommandPaletteSearchFieldRepresentable) { + self.parent = parent + } + + 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 !parent.isFocused { + DispatchQueue.main.async { + self.parent.isFocused = true + } + } + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + switch commandSelector { + case #selector(NSResponder.moveDown(_:)): + parent.onMoveSelection(1) + return true + case #selector(NSResponder.moveUp(_:)): + parent.onMoveSelection(-1) + return true + case #selector(NSResponder.insertNewline(_:)): + guard !textView.hasMarkedText() else { return false } + parent.onSubmit() + return true + case #selector(NSResponder.cancelOperation(_:)): + guard !textView.hasMarkedText() else { return false } + parent.onEscape() + return true + default: + return false + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeNSView(context: Context) -> CommandPaletteNativeTextField { + let field = CommandPaletteNativeTextField(frame: .zero) + field.font = .systemFont(ofSize: 13) + field.placeholderString = placeholder + field.setAccessibilityIdentifier("CommandPaletteSearchField") + field.delegate = context.coordinator + field.stringValue = text + field.isEditable = true + field.isSelectable = true + field.isEnabled = true + context.coordinator.parentField = field + return field + } + + func updateNSView(_ nsView: CommandPaletteNativeTextField, context: Context) { + context.coordinator.parent = self + context.coordinator.parentField = nsView + nsView.placeholderString = placeholder + + 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 + } + + guard let window = nsView.window else { return } + let firstResponder = window.firstResponder + let isFirstResponder = + firstResponder === nsView || + nsView.currentEditor() != nil || + ((firstResponder 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 firstResponder = window.firstResponder + let alreadyFocused = + firstResponder === nsView || + nsView.currentEditor() != nil || + ((firstResponder as? NSTextView)?.delegate as? NSTextField) === nsView + guard !alreadyFocused else { return } + window.makeFirstResponder(nsView) + } + } + } + + static func dismantleNSView(_ nsView: CommandPaletteNativeTextField, coordinator: Coordinator) { + nsView.delegate = nil + coordinator.parentField = nil + } + } + private func renameInputHintText(target: CommandPaletteRenameTarget) -> String { switch target.kind { case .workspace: @@ -5670,20 +5788,6 @@ struct ContentView: View { syncCommandPaletteDebugStateForObservedWindow() } - private func handleCommandPaletteControlNavigationKey( - modifiers: EventModifiers, - delta: Int - ) -> BackportKeyPressResult { - guard modifiers.contains(.control), - !modifiers.contains(.command), - !modifiers.contains(.shift), - !modifiers.contains(.option) else { - return .ignored - } - moveCommandPaletteSelection(by: delta) - return .handled - } - static func commandPaletteShouldPopRenameInputOnDelete( renameDraft: String, modifiers: EventModifiers