From 14b1d3323ca77edc6c212cd0fba393f2c6aed405 Mon Sep 17 00:00:00 2001 From: Akshay Kolli Date: Tue, 30 Jun 2026 09:19:18 -0700 Subject: [PATCH] WIP: add shelf range navigation keys --- README.md | 1 + docs/SMOKE_TEST.md | 29 ++++---- .../views/ClipboardPanelController.swift | 51 ++++++++++++-- .../clipbored/views/ClipboardPanelView.swift | 70 +++++++++++++++++++ .../views/ClipboardPanelViewModel.swift | 11 +++ .../ClipboardPanelControllerTests.swift | 15 ++++ .../ClipboardPanelViewModelTests.swift | 17 +++++ .../ClipboardPanelViewTests.swift | 48 +++++++++++++ 8 files changed, 222 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index fa509b2..6108a90 100644 --- a/README.md +++ b/README.md @@ -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 - 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 +- 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 - 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 diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index d085604..3b075cb 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -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. 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. -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. -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. -10. Press `Esc` once with a non-empty search field and confirm search clears. -11. Press `Esc` again and confirm the panel closes. -12. Reopen the panel, change sort segments, and confirm each segment updates results. -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. -14. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases. -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. -16. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update. -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. -18. 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 card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped. -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. -21. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly. +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. ## Copy And Paste diff --git a/sources/clipbored/views/ClipboardPanelController.swift b/sources/clipbored/views/ClipboardPanelController.swift index 1999069..8fb9fe7 100644 --- a/sources/clipbored/views/ClipboardPanelController.swift +++ b/sources/clipbored/views/ClipboardPanelController.swift @@ -26,6 +26,15 @@ enum ClipboardPanelShortcutAction: Equatable { case toggleStack } +enum ClipboardPanelNavigationAction: Equatable { + case first + case last + case next + case pageNext + case pagePrevious + case previous +} + final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanelDataSource, QLPreviewPanelDelegate { private enum Animation { static let showDuration: TimeInterval = 0.16 @@ -375,6 +384,10 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel return nil } 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 { case 53: self.hide() @@ -388,12 +401,6 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel case 51, 117: self.viewModel.deleteSelected() return nil - case 123: - self.viewModel.moveSelection(-1) - return nil - case 124: - self.viewModel.moveSelection(1) - return nil case 35: self.viewModel.togglePinSelected() 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) { switch action { case .copy: @@ -467,6 +492,20 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel 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? { let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask) guard relevantModifiers == [.command, .shift] else { return nil } diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index 39efef7..5f16364 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -722,6 +722,19 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { card.onSelect = { [weak self] selected in 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 self?.viewModel.selectItem(at: selected) self?.viewModel.pasteSelected() @@ -1156,6 +1169,16 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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() { if !searchField.stringValue.isEmpty { searchField.stringValue = "" @@ -1283,6 +1306,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { scrollView.documentView?.frame.width ?? 0 } + var debugVisibleCardPageStep: Int { + visibleCardPageStep + } + var debugCardRailOverflowFadeVisibility: [Bool] { scrollView.overflowFadeVisibility } @@ -1381,6 +1408,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { debugPressFocusedResponder(characters: " ", keyCode: 49) } + func debugPressFocusedResponderKeyCode(_ keyCode: UInt16) { + debugPressFocusedResponder(characters: "", keyCode: keyCode) + } + private func debugPressFocusedResponder(characters: String, keyCode: UInt16) { guard let window, let event = NSEvent.keyEvent( @@ -1547,6 +1578,29 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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() { onClose() } @@ -2148,6 +2202,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } 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 onCopy: (Int) -> Void = { _ in } var onPastePlainText: (Int) -> Void = { _ in } @@ -2289,6 +2347,18 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } else { 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: super.keyDown(with: event) } diff --git a/sources/clipbored/views/ClipboardPanelViewModel.swift b/sources/clipbored/views/ClipboardPanelViewModel.swift index 20f657e..211fc7c 100644 --- a/sources/clipbored/views/ClipboardPanelViewModel.swift +++ b/sources/clipbored/views/ClipboardPanelViewModel.swift @@ -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) { let count = visibleItems.count guard count > 0 else { return } diff --git a/tests/clipboredtests/ClipboardPanelControllerTests.swift b/tests/clipboredtests/ClipboardPanelControllerTests.swift index a84737d..2f1e8ae 100644 --- a/tests/clipboredtests/ClipboardPanelControllerTests.swift +++ b/tests/clipboredtests/ClipboardPanelControllerTests.swift @@ -172,6 +172,21 @@ final class ClipboardPanelControllerTests: XCTestCase { 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() { XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 8, modifiers: .command), .copy) XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 5, modifiers: .command), .showInClipboard) diff --git a/tests/clipboredtests/ClipboardPanelViewModelTests.swift b/tests/clipboredtests/ClipboardPanelViewModelTests.swift index 046e858..7a594a8 100644 --- a/tests/clipboredtests/ClipboardPanelViewModelTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewModelTests.swift @@ -475,6 +475,23 @@ final class ClipboardPanelViewModelTests: XCTestCase { 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() { let settings = makeSettings() let cacheService = makeCacheService() diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index 6f09a07..098178e 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -340,6 +340,54 @@ final class ClipboardPanelViewTests: XCTestCase { 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() { let fixture = makePanelFixture() fixture.store.upsert(makeItem(kind: .url, text: "https://example.com", store: fixture.store))