WIP: add shelf range navigation keys

This commit is contained in:
Akshay Kolli
2026-06-30 09:19:18 -07:00
parent eedccf8422
commit 14b1d3323c
8 changed files with 222 additions and 20 deletions

View File

@@ -17,6 +17,7 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca
- `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 with Return-to-paste, Space-to-preview for Quick Look capable clips, vertical wheel/trackpad panning and overflow edge fades in horizontal rails, visible focus chrome, and VoiceOver action hints - Keyboard-focusable cards with Return-to-paste, Space-to-preview for Quick Look capable clips, 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
- 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
- Search with independent token matching, structured filters such as `app:Safari`, `type:image,pdf`, `pinboard:"Client Work","Read Later"`, `date:2026-06-30`, result jump-back to full history, and optional local OCR for copied images - Search with independent token matching, structured filters such as `app:Safari`, `type:image,pdf`, `pinboard:"Client Work","Read Later"`, `date:2026-06-30`, result jump-back to full history, and optional local OCR for copied images
- Sort modes for recent, most used, images, links, text, files, audio, and pinned items - Sort modes for recent, most used, images, links, text, files, audio, and pinned items

View File

@@ -38,20 +38,21 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
5. Use arrow keys to move selection while the search field is focused. 5. Use arrow keys to move selection while the search field is focused.
6. Tab to collection chips and press `Space` or `Return`; confirm the focused chip is selected and the visible focus state is clear. 6. Tab to collection chips and press `Space` or `Return`; confirm the focused chip is selected and the visible focus state is clear.
7. Tab to cards; confirm the focused card gets a clear focus border, `Return` pastes or copies it, and `Space` opens Quick Look for previewable clips. 7. Tab to cards; confirm the focused card gets a clear focus border, `Return` pastes or copies it, and `Space` opens Quick Look for previewable clips.
8. 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. 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. 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. 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. Press `Esc` once with a non-empty search field and confirm search clears. 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` again and confirm the panel closes. 11. Press `Esc` once with a non-empty search field and confirm search clears.
12. Reopen the panel, change sort segments, and confirm each segment updates results. 12. Press `Esc` again and confirm the panel closes.
13. 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. 13. Reopen the panel, change sort segments, and confirm each segment updates results.
14. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases. 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. 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. 15. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases.
16. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update. 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 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. 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. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry. 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. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped. 19. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
20. Drag an unassigned card onto the renamed collection chip and confirm the chip count increases and the card appears when that collection is selected. 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. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly. 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.
## Copy And Paste ## Copy And Paste

View File

@@ -26,6 +26,15 @@ enum ClipboardPanelShortcutAction: Equatable {
case toggleStack case toggleStack
} }
enum ClipboardPanelNavigationAction: Equatable {
case first
case last
case next
case pageNext
case pagePrevious
case previous
}
final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanelDataSource, QLPreviewPanelDelegate { final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanelDataSource, QLPreviewPanelDelegate {
private enum Animation { private enum Animation {
static let showDuration: TimeInterval = 0.16 static let showDuration: TimeInterval = 0.16
@@ -375,6 +384,10 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
return nil return nil
} }
guard self.shouldHandlePanelKeyEvent(event) else { return event } guard self.shouldHandlePanelKeyEvent(event) else { return event }
if let action = Self.navigationShortcutAction(forKeyCode: event.keyCode, modifiers: event.modifierFlags) {
self.performNavigationAction(action)
return nil
}
switch event.keyCode { switch event.keyCode {
case 53: case 53:
self.hide() self.hide()
@@ -388,12 +401,6 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
case 51, 117: case 51, 117:
self.viewModel.deleteSelected() self.viewModel.deleteSelected()
return nil return nil
case 123:
self.viewModel.moveSelection(-1)
return nil
case 124:
self.viewModel.moveSelection(1)
return nil
case 35: case 35:
self.viewModel.togglePinSelected() self.viewModel.togglePinSelected()
return nil return nil
@@ -403,6 +410,24 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
} }
} }
private func performNavigationAction(_ action: ClipboardPanelNavigationAction) {
switch action {
case .first:
viewModel.selectFirstItem()
case .last:
viewModel.selectLastItem()
case .next:
viewModel.moveSelection(1)
case .pageNext:
viewModel.moveSelection(panelView.visibleCardPageStep)
case .pagePrevious:
viewModel.moveSelection(-panelView.visibleCardPageStep)
case .previous:
viewModel.moveSelection(-1)
}
panelView.focusSelectedCardForKeyboardNavigation()
}
private func performShortcutAction(_ action: ClipboardPanelShortcutAction) { private func performShortcutAction(_ action: ClipboardPanelShortcutAction) {
switch action { switch action {
case .copy: case .copy:
@@ -467,6 +492,20 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
return quickPasteKeyCodes[keyCode] return quickPasteKeyCodes[keyCode]
} }
static func navigationShortcutAction(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> ClipboardPanelNavigationAction? {
let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask)
guard relevantModifiers.isEmpty else { return nil }
switch keyCode {
case 115: return .first
case 119: return .last
case 124: return .next
case 121: return .pageNext
case 116: return .pagePrevious
case 123: return .previous
default: return nil
}
}
static func quickPastePlainTextIndex(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Int? { static func quickPastePlainTextIndex(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Int? {
let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask) let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask)
guard relevantModifiers == [.command, .shift] else { return nil } guard relevantModifiers == [.command, .shift] else { return nil }

View File

@@ -722,6 +722,19 @@ 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.onMoveSelection = { [weak self] delta in
self?.moveSelectionFromFocusedCard(delta)
}
card.onPageSelection = { [weak self] direction in
guard let self else { return }
self.moveSelectionFromFocusedCard(direction * self.visibleCardPageStep)
}
card.onSelectFirst = { [weak self] in
self?.selectFirstCardFromFocusedCard()
}
card.onSelectLast = { [weak self] in
self?.selectLastCardFromFocusedCard()
}
card.onPaste = { [weak self] selected in card.onPaste = { [weak self] selected in
self?.viewModel.selectItem(at: selected) self?.viewModel.selectItem(at: selected)
self?.viewModel.pasteSelected() self?.viewModel.pasteSelected()
@@ -1156,6 +1169,16 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
needsLayout = true needsLayout = true
} }
var visibleCardPageStep: Int {
let span = cardDensity.layout.width + cardDensity.cardSpacing
guard span > 0 else { return 1 }
return max(1, Int(floor(scrollView.contentView.bounds.width / span)))
}
func focusSelectedCardForKeyboardNavigation() {
focusSelectedCard()
}
func prepareForShow() { func prepareForShow() {
if !searchField.stringValue.isEmpty { if !searchField.stringValue.isEmpty {
searchField.stringValue = "" searchField.stringValue = ""
@@ -1283,6 +1306,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
scrollView.documentView?.frame.width ?? 0 scrollView.documentView?.frame.width ?? 0
} }
var debugVisibleCardPageStep: Int {
visibleCardPageStep
}
var debugCardRailOverflowFadeVisibility: [Bool] { var debugCardRailOverflowFadeVisibility: [Bool] {
scrollView.overflowFadeVisibility scrollView.overflowFadeVisibility
} }
@@ -1381,6 +1408,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
debugPressFocusedResponder(characters: " ", keyCode: 49) debugPressFocusedResponder(characters: " ", keyCode: 49)
} }
func debugPressFocusedResponderKeyCode(_ keyCode: UInt16) {
debugPressFocusedResponder(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(
@@ -1547,6 +1578,29 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
viewModel.searchText = searchField.stringValue viewModel.searchText = searchField.stringValue
} }
private func moveSelectionFromFocusedCard(_ delta: Int) {
viewModel.moveSelection(delta)
focusSelectedCard()
}
private func selectFirstCardFromFocusedCard() {
viewModel.selectFirstItem()
focusSelectedCard()
}
private func selectLastCardFromFocusedCard() {
viewModel.selectLastItem()
focusSelectedCard()
}
private func focusSelectedCard() {
guard viewModel.selectedIndex >= 0,
viewModel.selectedIndex < cardViews.count else {
return
}
window?.makeFirstResponder(cardViews[viewModel.selectedIndex])
}
@objc private func closePanel() { @objc private func closePanel() {
onClose() onClose()
} }
@@ -2148,6 +2202,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
} }
var onSelect: (Int) -> Void = { _ in } var onSelect: (Int) -> Void = { _ in }
var onMoveSelection: (Int) -> Void = { _ in }
var onPageSelection: (Int) -> Void = { _ in }
var onSelectFirst: () -> Void = {}
var onSelectLast: () -> Void = {}
var onPaste: (Int) -> Void = { _ in } var onPaste: (Int) -> Void = { _ in }
var onCopy: (Int) -> Void = { _ in } var onCopy: (Int) -> Void = { _ in }
var onPastePlainText: (Int) -> Void = { _ in } var onPastePlainText: (Int) -> Void = { _ in }
@@ -2289,6 +2347,18 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
} else { } else {
onPaste(index) onPaste(index)
} }
case 115:
onSelectFirst()
case 116:
onPageSelection(-1)
case 119:
onSelectLast()
case 121:
onPageSelection(1)
case 123:
onMoveSelection(-1)
case 124:
onMoveSelection(1)
default: default:
super.keyDown(with: event) super.keyDown(with: event)
} }

View File

@@ -211,6 +211,17 @@ final class ClipboardPanelViewModel {
} }
} }
func selectLastItem() {
guard !visibleItems.isEmpty else { return }
selectedItemID = nil
let lastIndex = visibleItems.count - 1
if selectedIndex == lastIndex {
notifyMain { self.onSelectedIndexChanged?(self.selectedIndex) }
} else {
selectedIndex = lastIndex
}
}
func moveSelection(_ delta: Int) { func moveSelection(_ delta: Int) {
let count = visibleItems.count let count = visibleItems.count
guard count > 0 else { return } guard count > 0 else { return }

View File

@@ -172,6 +172,21 @@ final class ClipboardPanelControllerTests: XCTestCase {
XCTAssertFalse(ClipboardPanelController.searchFieldPreviewShortcut(forKeyCode: 36, modifiers: [], searchText: "")) XCTAssertFalse(ClipboardPanelController.searchFieldPreviewShortcut(forKeyCode: 36, modifiers: [], searchText: ""))
} }
func testNavigationShortcutsMapToShelfMovement() {
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 115, modifiers: []), .first)
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 119, modifiers: []), .last)
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 124, modifiers: []), .next)
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 121, modifiers: []), .pageNext)
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 116, modifiers: []), .pagePrevious)
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 123, modifiers: []), .previous)
}
func testNavigationShortcutsRequireNoModifiers() {
XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 124, modifiers: .command))
XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 121, modifiers: .shift))
XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 35, modifiers: []))
}
func testCommandActionShortcutsMapToSelectedClipActions() { func testCommandActionShortcutsMapToSelectedClipActions() {
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 8, modifiers: .command), .copy) XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 8, modifiers: .command), .copy)
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 5, modifiers: .command), .showInClipboard) XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 5, modifiers: .command), .showInClipboard)

View File

@@ -475,6 +475,23 @@ final class ClipboardPanelViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.selectedItem?.payload, "newer") XCTAssertEqual(viewModel.selectedItem?.payload, "newer")
} }
func testSelectLastItemSelectsLastVisibleItem() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("older", createdAt: Date(timeIntervalSince1970: 100)))
store.upsert(makeTextItem("newer", createdAt: Date(timeIntervalSince1970: 200)))
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
viewModel.selectFirstItem()
viewModel.selectLastItem()
XCTAssertEqual(viewModel.selectedItem?.payload, "older")
}
func testSelectFirstItemPrefersLatestOnSubsequentUpdates() { func testSelectFirstItemPrefersLatestOnSubsequentUpdates() {
let settings = makeSettings() let settings = makeSettings()
let cacheService = makeCacheService() let cacheService = makeCacheService()

View File

@@ -340,6 +340,54 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "https://example.com/read") XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "https://example.com/read")
} }
func testFocusedCardsSupportShelfNavigationKeys() {
let fixture = makePanelFixture()
fixture.window.setFrame(NSRect(x: 0, y: 0, width: 620, height: 520), display: true)
for index in 0..<8 {
fixture.store.upsert(makeTextItem("Keyboard navigation item \(index)", store: fixture.store))
drainMainQueue()
}
fixture.window.contentView?.layoutSubtreeIfNeeded()
fixture.viewModel.selectFirstItem()
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
let pageStep = fixture.view.debugVisibleCardPageStep
XCTAssertGreaterThan(pageStep, 1)
XCTAssertTrue(fixture.view.debugFocusCard(at: 0))
fixture.view.debugPressFocusedResponderKeyCode(124)
drainMainQueue()
XCTAssertEqual(fixture.viewModel.selectedIndex, 1)
XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [1])
fixture.view.debugPressFocusedResponderKeyCode(121)
drainMainQueue()
XCTAssertEqual(fixture.viewModel.selectedIndex, min(7, 1 + pageStep))
XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [fixture.viewModel.selectedIndex])
fixture.view.debugPressFocusedResponderKeyCode(119)
drainMainQueue()
XCTAssertEqual(fixture.viewModel.selectedIndex, 7)
XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [7])
fixture.view.debugPressFocusedResponderKeyCode(116)
drainMainQueue()
XCTAssertEqual(fixture.viewModel.selectedIndex, max(0, 7 - pageStep))
XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [fixture.viewModel.selectedIndex])
fixture.view.debugPressFocusedResponderKeyCode(115)
drainMainQueue()
XCTAssertEqual(fixture.viewModel.selectedIndex, 0)
XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [0])
fixture.view.debugPressFocusedResponderKeyCode(123)
drainMainQueue()
XCTAssertEqual(fixture.viewModel.selectedIndex, 0)
XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [0])
}
func testCardHeaderUsesKindSymbolBadgeWhenSourceIconIsUnavailable() { func testCardHeaderUsesKindSymbolBadgeWhenSourceIconIsUnavailable() {
let fixture = makePanelFixture() let fixture = makePanelFixture()
fixture.store.upsert(makeItem(kind: .url, text: "https://example.com", store: fixture.store)) fixture.store.upsert(makeItem(kind: .url, text: "https://example.com", store: fixture.store))