WIP: add shelf range navigation keys
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user