diff --git a/README.md b/README.md index 40588ff..b6463c8 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca - `Shift + Command + N` creates a new collection - `Space` previews the selected card when the focused search field is empty - Clipboard history for text, URLs with local preview thumbnails when available, images, audio, RTF/HTML rich text, PDFs, and file references -- Keyboard-focusable cards and collection chips with Return-to-paste/select, Space-to-preview for text, links, files, and media, vertical wheel/trackpad panning and overflow edge fades in horizontal rails, visible focus chrome, and VoiceOver action hints +- Keyboard-focusable cards and collection chips with type-to-search, Return-to-paste/select, Space-to-preview for text, links, files, and media, vertical wheel/trackpad panning and overflow edge fades in horizontal rails, visible focus chrome, and VoiceOver action hints - Shelf navigation keys for focused cards: Left/Right, Page Up/Page Down, Home, and End - Shelf navigation keys for focused collection chips: Left/Right, Home, and End - SQLite persistence with bounded history, pinned-item retention, and encrypted app-managed payloads diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index f0a3baf..a028df7 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -39,20 +39,21 @@ Use this checklist before a release or after changes to panel, pasteboard, setti 6. Tab to collection chips and press `Space` or `Return`; confirm the focused chip is selected and the visible focus state is clear. Use Left/Right, Home, and End to move through the chip rail, including custom collections and Stack when present. 7. Tab to cards; confirm the focused card gets a clear focus border, `Return` pastes or copies it, and `Space` opens Quick Look for text, links, files, and media. 8. With a card focused, use Left/Right, Page Up/Page Down, Home, and End; confirm selection and focus move together across the shelf. -9. Use a mouse wheel or two-finger vertical scroll over the card shelf and a crowded collection rail; confirm each pans horizontally, clamps at both ends, and shows subtle edge fades only where more content is hidden. -10. Right-click a filtered result and choose Show in Clipboard, or press `Command + G`, and confirm search clears while the same card stays selected in Most Recent. -11. Press `Esc` once with a non-empty search field and confirm search clears. -12. Press `Esc` again and confirm the panel closes. -13. Reopen the panel, change sort segments, and confirm each segment updates results. -14. Press `Shift + Command + N` or the collection rail `+`, enter `Client Work`, choose a color, and confirm a Client Work chip appears with 0 clips and an empty collection view. -15. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases. -16. Select the Client Work chip and confirm the rail filters to assigned items, cards use the Client Work name/color in their headers, and the collection/color/assignment persists after quitting and reopening ClipBored. -17. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update. -18. Right-click a media, file, link, PDF, audio, or text card, choose Rename..., give it a title, and confirm the card title and search results use the custom title while paste/copy still uses the original payload. -19. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry. -20. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped. -21. Drag an unassigned card onto the renamed collection chip and confirm the chip count increases and the card appears when that collection is selected. -22. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly. +9. With a card or collection chip focused, type a normal character and confirm focus returns to search with that character inserted and results filtered. +10. Use a mouse wheel or two-finger vertical scroll over the card shelf and a crowded collection rail; confirm each pans horizontally, clamps at both ends, and shows subtle edge fades only where more content is hidden. +11. Right-click a filtered result and choose Show in Clipboard, or press `Command + G`, and confirm search clears while the same card stays selected in Most Recent. +12. Press `Esc` once with a non-empty search field and confirm search clears. +13. Press `Esc` again and confirm the panel closes. +14. Reopen the panel, change sort segments, and confirm each segment updates results. +15. Press `Shift + Command + N` or the collection rail `+`, enter `Client Work`, choose a color, and confirm a Client Work chip appears with 0 clips and an empty collection view. +16. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases. +17. Select the Client Work chip and confirm the rail filters to assigned items, cards use the Client Work name/color in their headers, and the collection/color/assignment persists after quitting and reopening ClipBored. +18. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update. +19. Right-click a media, file, link, PDF, audio, or text card, choose Rename..., give it a title, and confirm the card title and search results use the custom title while paste/copy still uses the original payload. +20. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry. +21. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped. +22. Drag an unassigned card onto the renamed collection chip and confirm the chip count increases and the card appears when that collection is selected. +23. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly. ## Copy And Paste diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index 5c96cd9..5508321 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -592,6 +592,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } private func configureCollectionKeyboardNavigation(for chip: CollectionChipView) { + chip.onStartSearch = { [weak self] text in + self?.startSearchFromShelf(text) + } chip.onMoveFocus = { [weak self, weak chip] delta in self?.moveCollectionFocus(from: chip, delta: delta) } @@ -744,6 +747,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { card.onSelect = { [weak self] selected in self?.viewModel.selectItem(at: selected) } + card.onStartSearch = { [weak self] text in + self?.startSearchFromShelf(text) + } card.onMoveSelection = { [weak self] delta in self?.moveSelectionFromFocusedCard(delta) } @@ -1225,6 +1231,13 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { window?.makeFirstResponder(searchField) } + private func startSearchFromShelf(_ text: String) { + guard !text.isEmpty else { return } + focusSearchField() + searchField.stringValue += text + updateSearchText() + } + func setBottomSafeInset(_ inset: CGFloat) { bottomSafeInset = max(Metrics.minimumBottomInset, inset) mainStack?.edgeInsets = contentInsets() @@ -1478,6 +1491,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { debugPressFocusedResponder(characters: "", keyCode: keyCode) } + func debugTypeFocusedResponder(_ characters: String, keyCode: UInt16) { + debugPressFocusedResponder(characters: characters, keyCode: keyCode) + } + private func debugPressFocusedResponder(characters: String, keyCode: UInt16) { guard let window, let event = NSEvent.keyEvent( @@ -1813,6 +1830,14 @@ private enum ClipboardItemDragPasteboard { ] } +private func shelfSearchText(from event: NSEvent) -> String? { + let blockedModifiers: NSEvent.ModifierFlags = [.command, .control, .option, .function] + guard event.modifierFlags.intersection(blockedModifiers).isEmpty else { return nil } + guard let characters = event.characters, !characters.isEmpty else { return nil } + guard characters.rangeOfCharacter(from: .controlCharacters) == nil else { return nil } + return characters +} + private enum ClipboardCardDragContext { static var itemID: UUID? } @@ -1960,6 +1985,7 @@ private final class CollectionChipView: NSView { private var isKeyboardFocused = false private var isDropTargeted = false var onPress: () -> Void = {} + var onStartSearch: (String) -> Void = { _ in } var onMoveFocus: (Int) -> Void = { _ in } var onSelectFirst: () -> Void = {} var onSelectLast: () -> Void = {} @@ -2126,7 +2152,11 @@ private final class CollectionChipView: NSView { case 124: onMoveFocus(1) default: - super.keyDown(with: event) + if let text = shelfSearchText(from: event) { + onStartSearch(text) + } else { + super.keyDown(with: event) + } } } @@ -2313,6 +2343,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { var onIgnoreSourceApp: (Int) -> Void = { _ in } var onIgnoreKind: (Int) -> Void = { _ in } var onDelete: (Int) -> Void = { _ in } + var onStartSearch: (String) -> Void = { _ in } private let index: Int private let itemID: UUID @@ -2447,7 +2478,11 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { case 124: onMoveSelection(1) default: - super.keyDown(with: event) + if let text = shelfSearchText(from: event) { + onStartSearch(text) + } else { + super.keyDown(with: event) + } } } diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index f39e4e2..b8b8fad 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -246,6 +246,23 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Clipboard"]) } + func testTypingFromFocusedCollectionChipStartsSearch() { + let fixture = makePanelFixture() + fixture.store.upsert(makeTextItem("Alpha note", store: fixture.store)) + fixture.store.upsert(makeTextItem("Quantum reference", store: fixture.store)) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertTrue(fixture.view.debugFocusCollectionChip(.links)) + fixture.view.debugTypeFocusedResponder("q", keyCode: 12) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertTrue(fixture.view.isSearchFieldEditing) + XCTAssertEqual(fixture.view.debugSearchFieldText, "q") + XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Quantum reference"]) + } + func testCollectionRailAddButtonCreatesEmptyCollection() { let fixture = makePanelFixture() @@ -380,6 +397,23 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.viewModel.statusMessage, "") } + func testTypingFromFocusedCardStartsSearch() { + let fixture = makePanelFixture() + fixture.store.upsert(makeTextItem("Alpha note", store: fixture.store)) + fixture.store.upsert(makeTextItem("Quantum card", store: fixture.store)) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) + fixture.view.debugTypeFocusedResponder("q", keyCode: 12) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertTrue(fixture.view.isSearchFieldEditing) + XCTAssertEqual(fixture.view.debugSearchFieldText, "q") + XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Quantum card"]) + } + func testFocusedPreviewableCardSpaceOpensQuickLook() { let fixture = makePanelFixture() fixture.store.upsert(makeItem(kind: .url, text: "https://example.com/read", store: fixture.store))