From aad5563e1267c1161027c2f8bd1c591dd34a7bd6 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Sat, 15 Aug 2026 15:01:44 -0700 Subject: [PATCH] Replace the search indicator with a real editable field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous search UI was a synthetic pill built from captured keystrokes — appended-only, no cursor, no click-to-edit, no text selection or IME support. NativeBarSearchField is a real NSTextField edited through the normal AppKit responder chain (kept mounted even in its compact zero-width state, so the bar's key monitor can focus it synchronously and hand off the very first keystroke without losing it). BarInputMode tracks whether focus belongs to the field or the clip strip, so the monitor and the field agree on who owns a given key without probing the first-responder chain from every call site. --- Sources/Pesty/AppController.swift | 106 ++++++++++++++-- Sources/Pesty/Store/ClipboardStore.swift | 10 ++ Sources/Pesty/UI/BarSearchField.swift | 141 +++++++++++++++++++++ Sources/Pesty/UI/BarView.swift | 62 +++++++-- Sources/Pesty/UI/BarWindowController.swift | 16 ++- 5 files changed, 312 insertions(+), 23 deletions(-) create mode 100644 Sources/Pesty/UI/BarSearchField.swift diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index 6d3f395..964c9e6 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -297,6 +297,47 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { barController?.hide() } + func setBarSearchEditing(_ editing: Bool) { + let mode: BarInputMode = editing ? .search : .cards + guard store.barInputMode != mode else { return } + store.barInputMode = mode + } + + /// Routes every edit from the native search field through here instead + /// of writing `store.searchText` directly, so the selection follows the + /// filtered results as you type instead of staying on whatever was + /// selected before the query changed. + func updateBarSearchText(_ text: String) { + guard store.searchText != text else { return } + store.searchText = text + store.selectFirst() + } + + /// Return leaves the query intact and hands arrows/shortcuts back to the + /// clip strip. With no result there is nowhere to move, so search keeps + /// focus instead. + func submitBarSearch() { + guard store.barInputMode == .search, !store.visibleItems.isEmpty else { return } + barController?.resignSearch() + store.barInputMode = .cards + } + + func clearBarSearch() { + let hadQuery = !store.searchText.isEmpty + barController?.resignSearch() + store.searchText = "" + store.barInputMode = .cards + if hadQuery { store.selectFirst(); searchClearedAt = Date() } + } + + func cancelBarSearchOrHide() { + if !store.searchText.isEmpty { + clearBarSearch() + } else { + hideBar() + } + } + func pasteSelected() { guard let item = store.selectedItem else { return } pasteItem(item) @@ -494,13 +535,19 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { // monitor is only responsible for keys delivered to the panel itself. guard event.window === barController?.window else { return event } + // The native search field owns the entire event while it is editing: + // arrows, selection, clipboard commands, deletion, spaces, keyboard + // layouts, and composed text all need real AppKit text-editing + // behavior, not this monitor's clip-navigation shortcuts. + if barController?.searchOwnsFirstResponder == true { + return event + } + if handleBarCommandShortcut(event) { return nil } let code = Int(event.keyCode) let flags = event.modifierFlags let cmd = flags.contains(.command) - let ctrl = flags.contains(.control) - let opt = flags.contains(.option) if let digit = Self.quickPasteDigit(for: code), includes(Settings.shared.quickPasteModifier, in: flags) { @@ -530,7 +577,16 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { store.moveSelection(by: 1); return nil case kVK_Delete: if cmd { deleteEffectiveSelection(); return nil } + // Backspace edits the query before it can remove a filtered clip, + // even when focus has already moved back to the cards (e.g. + // after Return submitted the search). Refocusing the native + // field and returning the same event lets it handle the + // backspace itself, rather than manually mutating the string. if !store.searchText.isEmpty { + store.barInputMode = .search + if barController?.focusSearchAtEnd() == true { + return event + } store.searchText.removeLast(); store.selectFirst() if store.searchText.isEmpty { searchClearedAt = Date() } return nil @@ -554,17 +610,51 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { break } - if !cmd && !ctrl && !opt, - let chars = event.characters, chars.count == 1, - let scalar = chars.unicodeScalars.first, - scalar.value >= 32, scalar.value != 127 { - store.searchText.append(chars) - store.selectFirst() + if isPrintableTextIntent(event) { + store.barInputMode = .search + if barController?.focusSearchAtEnd() == true { + // The local monitor runs before responder dispatch. Returning + // the same event now sends its very first character directly + // to the newly focused native field editor — no character is + // lost transferring focus mid-keystroke. + return event + } + if let chars = fallbackSearchCharacters(from: event) { + store.searchText.append(chars) + store.selectFirst() + } return nil } return event } + private func isPrintableTextIntent(_ event: NSEvent) -> Bool { + let flags = event.modifierFlags + guard !flags.contains(.command), + !flags.contains(.control), + let chars = event.charactersIgnoringModifiers, + !chars.isEmpty else { return false } + return chars.unicodeScalars.contains { + $0.value >= 0x20 + && $0.value != 0x7F + && !(0xF700...0xF8FF).contains($0.value) + } + } + + private func fallbackSearchCharacters(from event: NSEvent) -> String? { + let flags = event.modifierFlags + guard !flags.contains(.command), + !flags.contains(.control), + let chars = event.characters, + !chars.isEmpty, + chars.unicodeScalars.allSatisfy({ + $0.value >= 0x20 + && $0.value != 0x7F + && !(0xF700...0xF8FF).contains($0.value) + }) else { return nil } + return chars + } + private static func quickPasteDigit(for keyCode: Int) -> Int? { switch keyCode { case kVK_ANSI_1, kVK_ANSI_Keypad1: return 1 diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index c0bcd40..061e9ff 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -10,6 +10,15 @@ enum BarSource: Equatable { case pinboard(UUID) } +/// Whether keyboard focus currently belongs to the native search field or +/// to the clip strip. Lets the key monitor and the search field agree on +/// who owns a given keystroke without probing AppKit's first-responder +/// chain from every call site. +enum BarInputMode: Equatable { + case cards + case search +} + @Observable @MainActor final class ClipboardStore { @@ -21,6 +30,7 @@ final class ClipboardStore { var source: BarSource = .history { didSet { if source != oldValue { clearMultiSelection() } } } + var barInputMode: BarInputMode = .cards var searchText: String = "" { didSet { if searchText != oldValue { clearMultiSelection() } } } diff --git a/Sources/Pesty/UI/BarSearchField.swift b/Sources/Pesty/UI/BarSearchField.swift new file mode 100644 index 0000000..c5f0abf --- /dev/null +++ b/Sources/Pesty/UI/BarSearchField.swift @@ -0,0 +1,141 @@ +import AppKit +import SwiftUI + +/// Keeps the Paste Bar's native search field reachable while SwiftUI renders +/// it at a compact width. The local key monitor can therefore transfer first +/// responder synchronously and let the triggering key reach AppKit normally. +/// +/// Ported from Pesty-Alvie's `BarSearchField.swift`, which replaced the +/// previous approach — a global keyDown monitor that appended characters +/// directly to `store.searchText` — with a real `NSTextField` edited through +/// the normal AppKit responder chain. The append-only approach had no actual +/// cursor: arrow keys always moved clip selection, never a caret inside the +/// query, and mid-string edits/selection/IME composition were impossible. +@MainActor +final class BarSearchFieldBridge { + weak var field: NSTextField? + + func install(_ field: NSTextField) { + self.field = field + } + + func uninstall(_ field: NSTextField) { + if self.field === field { self.field = nil } + } + + @discardableResult + func focusAtEnd() -> Bool { + guard let field, + let window = field.window, + window.makeFirstResponder(field) else { return false } + if let editor = field.currentEditor() as? NSTextView { + editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0)) + } + return true + } + + func resign() { + guard let field, + let window = field.window, + let editor = field.currentEditor(), + window.firstResponder === editor else { return } + window.makeFirstResponder(nil) + } + + func ownsFirstResponder(in window: NSWindow?) -> Bool { + guard let window, + let field, + let editor = field.currentEditor() else { return false } + return window.firstResponder === editor + } +} + +struct NativeBarSearchField: NSViewRepresentable { + @Binding var text: String + let bridge: BarSearchFieldBridge + let onBegin: () -> Void + let onEnd: () -> Void + let onSubmit: () -> Void + let onCancel: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeNSView(context: Context) -> NSTextField { + let field = NSTextField() + field.delegate = context.coordinator + field.isBordered = false + field.drawsBackground = false + field.focusRingType = .none + field.isEditable = true + field.isSelectable = true + field.usesSingleLineMode = true + field.lineBreakMode = .byTruncatingTail + field.placeholderString = "Search" + field.font = .systemFont(ofSize: 13, weight: .medium) + // The chrome bar is dark, unlike Pesty-Alvie's light card styling — + // matches Theme.chromeTextPrimary (Color.white.opacity(0.95)). + field.textColor = NSColor.white.withAlphaComponent(0.95) + let placeholderColor = NSColor.white.withAlphaComponent(0.55) + field.placeholderAttributedString = NSAttributedString( + string: "Search", + attributes: [.foregroundColor: placeholderColor] + ) + field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + bridge.install(field) + return field + } + + func updateNSView(_ field: NSTextField, context: Context) { + context.coordinator.parent = self + bridge.install(field) + // Reassigning an equal value resets the native caret and selection. + if field.stringValue != text { field.stringValue = text } + } + + static func dismantleNSView(_ field: NSTextField, coordinator: Coordinator) { + coordinator.parent.bridge.uninstall(field) + field.delegate = nil + } + + @MainActor + final class Coordinator: NSObject, NSTextFieldDelegate { + var parent: NativeBarSearchField + + init(parent: NativeBarSearchField) { + self.parent = parent + } + + func controlTextDidBeginEditing(_ notification: Notification) { + parent.onBegin() + } + + func controlTextDidEndEditing(_ notification: Notification) { + parent.onEnd() + } + + func controlTextDidChange(_ notification: Notification) { + guard let field = notification.object as? NSTextField, + parent.text != field.stringValue else { return } + parent.text = field.stringValue + } + + func control(_ control: NSControl, + textView: NSTextView, + doCommandBy commandSelector: Selector) -> Bool { + if textView.hasMarkedText() { return false } + + if commandSelector == #selector(NSResponder.insertNewline(_:)) + || commandSelector == #selector(NSResponder.insertNewlineIgnoringFieldEditor(_:)) { + parent.onSubmit() + return true + } + if commandSelector == #selector(NSResponder.cancelOperation(_:)) { + parent.onCancel() + return true + } + return false + } + } +} diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index 87dcd77..5bb88a8 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -1,9 +1,21 @@ import SwiftUI struct BarView: View { + let searchBridge: BarSearchFieldBridge @Bindable private var store = ClipboardStore.shared @Bindable private var settings = Settings.shared + private var searchIsActive: Bool { + store.barInputMode == .search || !store.searchText.isEmpty + } + + private var searchTextBinding: Binding { + Binding( + get: { store.searchText }, + set: { AppController.shared.updateBarSearchText($0) } + ) + } + var body: some View { ZStack { panelBackground @@ -64,17 +76,35 @@ struct BarView: View { } private var searchIndicator: some View { - HStack(spacing: 6) { + HStack(spacing: searchIsActive ? 6 : 0) { Image(systemName: "magnifyingglass") .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(store.searchText.isEmpty ? Theme.chromeTextSecondary : Theme.chromeTextPrimary) - if !store.searchText.isEmpty { - Text(store.searchText) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(Theme.chromeTextPrimary) - .lineLimit(1) - .truncationMode(.head) - Button { store.searchText = ""; store.selectFirst() } label: { + .foregroundStyle(searchIsActive ? Theme.chromeTextPrimary : Theme.chromeTextSecondary) + .accessibilityHidden(true) + + // Kept mounted even in the compact state: the key monitor can + // focus it synchronously and return the same first key event, so + // type-anywhere search never loses a character. + NativeBarSearchField( + text: searchTextBinding, + bridge: searchBridge, + onBegin: { AppController.shared.setBarSearchEditing(true) }, + onEnd: { AppController.shared.setBarSearchEditing(false) }, + onSubmit: { AppController.shared.submitBarSearch() }, + onCancel: { AppController.shared.cancelBarSearchOrHide() } + ) + .frame(minWidth: searchIsActive ? 120 : 0, + idealWidth: searchIsActive ? 180 : 0, + maxWidth: searchIsActive ? 260 : 0, + alignment: .leading) + .opacity(searchIsActive ? 1 : 0) + .allowsHitTesting(searchIsActive) + .accessibilityHidden(!searchIsActive) + + if searchIsActive { + Button { + AppController.shared.clearBarSearch() + } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 12)).foregroundStyle(Theme.chromeTextTertiary) } @@ -82,11 +112,15 @@ struct BarView: View { .accessibilityLabel("Clear search") } } - .padding(.horizontal, store.searchText.isEmpty ? 0 : 10) - .frame(minWidth: 22, maxWidth: 700, minHeight: 30, maxHeight: 30, alignment: .leading) - .fixedSize(horizontal: true, vertical: false) - .background(store.searchText.isEmpty ? Color.clear : Theme.fieldBG, in: Capsule()) - .animation(.easeOut(duration: 0.15), value: store.searchText.isEmpty) + .padding(.horizontal, searchIsActive ? 10 : 0) + .frame(height: 30) + .background(searchIsActive ? Theme.fieldBG : Color.clear, in: Capsule()) + // Without this, the search field competes for space with the + // Pinboard tabs' ScrollView in the same HStack and can get squeezed + // below its intended width — an active query needs to keep its + // requested width over the (infinitely flexible) tab strip. + .layoutPriority(searchIsActive ? 2 : 0) + .animation(.easeOut(duration: 0.15), value: searchIsActive) } private var bulkDeleteButton: some View { diff --git a/Sources/Pesty/UI/BarWindowController.swift b/Sources/Pesty/UI/BarWindowController.swift index 663be06..957378c 100644 --- a/Sources/Pesty/UI/BarWindowController.swift +++ b/Sources/Pesty/UI/BarWindowController.swift @@ -33,6 +33,7 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { private var phase: Phase = .hidden private var epoch = 0 + private let searchBridge = BarSearchFieldBridge() /// True while the bar is up or on its way up. `AppController.toggleBar` asks this /// instead of `window.isVisible`. @@ -68,7 +69,7 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { // Without this a stray close() would deallocate the panel and leave `window` // nil, which is another way to never show the bar again. panel.isReleasedWhenClosed = false - let content = NSHostingView(rootView: BarView()) + let content = NSHostingView(rootView: BarView(searchBridge: searchBridge)) if #available(macOS 26.0, *) { let glassContent = NSView() content.translatesAutoresizingMaskIntoConstraints = false @@ -223,6 +224,19 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { window?.orderOut(nil) } + var searchOwnsFirstResponder: Bool { + searchBridge.ownsFirstResponder(in: window) + } + + @discardableResult + func focusSearchAtEnd() -> Bool { + searchBridge.focusAtEnd() + } + + func resignSearch() { + searchBridge.resign() + } + func windowDidResignKey(_ notification: Notification) { guard Settings.shared.hideOnClickOutside, phase == .shown,