Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 98 additions & 8 deletions Sources/Pesty/AppController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions Sources/Pesty/Store/ClipboardStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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() } }
}
Expand Down
141 changes: 141 additions & 0 deletions Sources/Pesty/UI/BarSearchField.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
62 changes: 48 additions & 14 deletions Sources/Pesty/UI/BarView.swift
Original file line number Diff line number Diff line change
@@ -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<String> {
Binding(
get: { store.searchText },
set: { AppController.shared.updateBarSearchText($0) }
)
}

var body: some View {
ZStack {
panelBackground
Expand Down Expand Up @@ -64,29 +76,51 @@ 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)
}
.buttonStyle(.plain)
.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 {
Expand Down
Loading
Loading