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,