WIP: type to search from shelf focus

This commit is contained in:
Akshay Kolli
2026-06-30 09:40:04 -07:00
parent f541992dea
commit 83e347d507
4 changed files with 87 additions and 17 deletions

View File

@@ -16,7 +16,7 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca
- `Shift + Command + N` creates a new collection - `Shift + Command + N` creates a new collection
- `Space` previews the selected card when the focused search field is empty - `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 - 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 cards: Left/Right, Page Up/Page Down, Home, and End
- Shelf navigation keys for focused collection chips: Left/Right, 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 - SQLite persistence with bounded history, pinned-item retention, and encrypted app-managed payloads

View File

@@ -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. 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. 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. 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. 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. 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. 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. Press `Esc` once with a non-empty search field and confirm search clears. 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` again and confirm the panel closes. 12. Press `Esc` once with a non-empty search field and confirm search clears.
13. Reopen the panel, change sort segments, and confirm each segment updates results. 13. Press `Esc` again and confirm the panel closes.
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. 14. Reopen the panel, change sort segments, and confirm each segment updates results.
15. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases. 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. 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. 16. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases.
17. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update. 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 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. 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. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry. 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. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped. 20. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
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. 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. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly. 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 ## Copy And Paste

View File

@@ -592,6 +592,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
} }
private func configureCollectionKeyboardNavigation(for chip: CollectionChipView) { private func configureCollectionKeyboardNavigation(for chip: CollectionChipView) {
chip.onStartSearch = { [weak self] text in
self?.startSearchFromShelf(text)
}
chip.onMoveFocus = { [weak self, weak chip] delta in chip.onMoveFocus = { [weak self, weak chip] delta in
self?.moveCollectionFocus(from: chip, delta: delta) self?.moveCollectionFocus(from: chip, delta: delta)
} }
@@ -744,6 +747,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
card.onSelect = { [weak self] selected in card.onSelect = { [weak self] selected in
self?.viewModel.selectItem(at: selected) self?.viewModel.selectItem(at: selected)
} }
card.onStartSearch = { [weak self] text in
self?.startSearchFromShelf(text)
}
card.onMoveSelection = { [weak self] delta in card.onMoveSelection = { [weak self] delta in
self?.moveSelectionFromFocusedCard(delta) self?.moveSelectionFromFocusedCard(delta)
} }
@@ -1225,6 +1231,13 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
window?.makeFirstResponder(searchField) window?.makeFirstResponder(searchField)
} }
private func startSearchFromShelf(_ text: String) {
guard !text.isEmpty else { return }
focusSearchField()
searchField.stringValue += text
updateSearchText()
}
func setBottomSafeInset(_ inset: CGFloat) { func setBottomSafeInset(_ inset: CGFloat) {
bottomSafeInset = max(Metrics.minimumBottomInset, inset) bottomSafeInset = max(Metrics.minimumBottomInset, inset)
mainStack?.edgeInsets = contentInsets() mainStack?.edgeInsets = contentInsets()
@@ -1478,6 +1491,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
debugPressFocusedResponder(characters: "", keyCode: keyCode) debugPressFocusedResponder(characters: "", keyCode: keyCode)
} }
func debugTypeFocusedResponder(_ characters: String, keyCode: UInt16) {
debugPressFocusedResponder(characters: characters, keyCode: keyCode)
}
private func debugPressFocusedResponder(characters: String, keyCode: UInt16) { private func debugPressFocusedResponder(characters: String, keyCode: UInt16) {
guard let window, guard let window,
let event = NSEvent.keyEvent( 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 { private enum ClipboardCardDragContext {
static var itemID: UUID? static var itemID: UUID?
} }
@@ -1960,6 +1985,7 @@ private final class CollectionChipView: NSView {
private var isKeyboardFocused = false private var isKeyboardFocused = false
private var isDropTargeted = false private var isDropTargeted = false
var onPress: () -> Void = {} var onPress: () -> Void = {}
var onStartSearch: (String) -> Void = { _ in }
var onMoveFocus: (Int) -> Void = { _ in } var onMoveFocus: (Int) -> Void = { _ in }
var onSelectFirst: () -> Void = {} var onSelectFirst: () -> Void = {}
var onSelectLast: () -> Void = {} var onSelectLast: () -> Void = {}
@@ -2126,7 +2152,11 @@ private final class CollectionChipView: NSView {
case 124: case 124:
onMoveFocus(1) onMoveFocus(1)
default: 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 onIgnoreSourceApp: (Int) -> Void = { _ in }
var onIgnoreKind: (Int) -> Void = { _ in } var onIgnoreKind: (Int) -> Void = { _ in }
var onDelete: (Int) -> Void = { _ in } var onDelete: (Int) -> Void = { _ in }
var onStartSearch: (String) -> Void = { _ in }
private let index: Int private let index: Int
private let itemID: UUID private let itemID: UUID
@@ -2447,7 +2478,11 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
case 124: case 124:
onMoveSelection(1) onMoveSelection(1)
default: default:
super.keyDown(with: event) if let text = shelfSearchText(from: event) {
onStartSearch(text)
} else {
super.keyDown(with: event)
}
} }
} }

View File

@@ -246,6 +246,23 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Clipboard"]) 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() { func testCollectionRailAddButtonCreatesEmptyCollection() {
let fixture = makePanelFixture() let fixture = makePanelFixture()
@@ -380,6 +397,23 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(fixture.viewModel.statusMessage, "") 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() { func testFocusedPreviewableCardSpaceOpensQuickLook() {
let fixture = makePanelFixture() let fixture = makePanelFixture()
fixture.store.upsert(makeItem(kind: .url, text: "https://example.com/read", store: fixture.store)) fixture.store.upsert(makeItem(kind: .url, text: "https://example.com/read", store: fixture.store))