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
|
- `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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user