diff --git a/README.md b/README.md index 6108a90..f8d7d2e 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,9 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca - `Shift + Command + N` creates a new collection - `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 +- Keyboard-focusable cards and collection chips with Return-to-paste/select, 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 +- 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 - 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 3b075cb..4de1d10 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -36,7 +36,7 @@ Use this checklist before a release or after changes to panel, pasteboard, setti 3. Type a structured query such as `pinboard:"Client Work","Read Later" type:image,pdf` and confirm only clips from those collections and content types remain. 4. Clear the search field, press `Space`, and confirm the selected previewable clip opens in Quick Look instead of inserting a blank query. 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. 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 previewable clips. 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. diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index 5f16364..8981809 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -239,6 +239,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { private var cardViews: [ClipboardItemCardView] = [] private var collectionButtons: [ClipboardSortMode: CollectionChipView] = [:] private var customCollectionButtons: [String: CollectionChipView] = [:] + private var collectionChipOrder: [CollectionChipView] = [] private var lastScrollContentWidth: CGFloat = 0 private var lastCollectionViewportWidth: CGFloat = 0 private var defersVisualReloads = false @@ -533,7 +534,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { private func configureCollectionButtons() { collectionButtons.removeAll() customCollectionButtons.removeAll() + collectionChipOrder.removeAll() for view in collectionStack.arrangedSubviews { + (view as? CollectionChipView)?.clearKeyboardFocus() collectionStack.removeArrangedSubview(view) view.removeFromSuperview() } @@ -544,7 +547,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { chip.onPress = { [weak self] in self?.viewModel.sortMode = mode } + configureCollectionKeyboardNavigation(for: chip) collectionButtons[mode] = chip + collectionChipOrder.append(chip) collectionStack.addArrangedSubview(chip) } @@ -563,7 +568,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { chip.onDelete = { [weak self] in self?.deleteCollection(named: collectionName) } + configureCollectionKeyboardNavigation(for: chip) customCollectionButtons[collectionName] = chip + collectionChipOrder.append(chip) collectionStack.addArrangedSubview(chip) } configureStackChip() @@ -577,11 +584,26 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { stackChip.onPress = { [weak self] in self?.viewModel.selectStack() } + configureCollectionKeyboardNavigation(for: stackChip) if viewModel.stackCount > 0 { + collectionChipOrder.append(stackChip) collectionStack.addArrangedSubview(stackChip) } } + private func configureCollectionKeyboardNavigation(for chip: CollectionChipView) { + chip.onMoveFocus = { [weak self, weak chip] delta in + self?.moveCollectionFocus(from: chip, delta: delta) + } + chip.onSelectFirst = { [weak self] in + self?.selectCollectionChip(at: 0) + } + chip.onSelectLast = { [weak self] in + guard let self else { return } + self.selectCollectionChip(at: self.collectionChipOrder.count - 1) + } + } + private func configureAddCollectionButton() { let image = NSImage(systemSymbolName: "plus", accessibilityDescription: "New collection") image?.isTemplate = true @@ -891,6 +913,46 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { scrollView.reflectScrolledClipView(scrollView.contentView) } + private func moveCollectionFocus(from chip: CollectionChipView?, delta: Int) { + guard let chip, + let currentIndex = collectionChipOrder.firstIndex(where: { $0 === chip }) else { + return + } + selectCollectionChip(at: currentIndex + delta) + } + + private func selectCollectionChip(at index: Int) { + guard !collectionChipOrder.isEmpty else { return } + let targetIndex = max(0, min(collectionChipOrder.count - 1, index)) + let title = collectionChipOrder[targetIndex].titleText + collectionChipOrder[targetIndex].onPress() + + let focusedChip: CollectionChipView? + if let rebuiltChip = collectionChipOrder.first(where: { $0.titleText == title }) { + focusedChip = rebuiltChip + } else if collectionChipOrder.indices.contains(targetIndex) { + focusedChip = collectionChipOrder[targetIndex] + } else { + focusedChip = nil + } + guard let focusedChip else { return } + collectionChipOrder.forEach { $0.clearKeyboardFocus() } + window?.makeFirstResponder(focusedChip) + scrollCollectionChipIntoView(focusedChip) + } + + private func scrollCollectionChipIntoView(_ chip: NSView) { + guard collectionScrollView.documentView === collectionStack else { return } + guard chip.window != nil else { return } + collectionScrollView.layoutSubtreeIfNeeded() + collectionStack.layoutSubtreeIfNeeded() + + let frame = chip.convert(chip.bounds, to: collectionStack) + let paddedFrame = frame.insetBy(dx: -10, dy: 0) + collectionStack.scrollToVisible(paddedFrame) + collectionScrollView.reflectScrolledClipView(collectionScrollView.contentView) + } + private func updateStatus(_ message: String) { let text: String if !message.isEmpty { @@ -1390,6 +1452,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { ClipboardSortMode.allCases.compactMap { collectionButtons[$0]?.acceptsFirstResponder } } + var debugKeyboardFocusedCollectionTitles: [String] { + collectionChipOrder.compactMap { $0.debugIsKeyboardFocused ? $0.titleText : nil } + } + func debugFocusCollectionChip(_ mode: ClipboardSortMode) -> Bool { guard let chip = collectionButtons[mode] else { return false } return window?.makeFirstResponder(chip) ?? false @@ -1894,6 +1960,9 @@ private final class CollectionChipView: NSView { private var isKeyboardFocused = false private var isDropTargeted = false var onPress: () -> Void = {} + var onMoveFocus: (Int) -> Void = { _ in } + var onSelectFirst: () -> Void = {} + var onSelectLast: () -> Void = {} var onDropItem: ((UUID) -> Void)? var onEdit: (() -> Void)? var onDelete: (() -> Void)? @@ -1918,7 +1987,7 @@ private final class CollectionChipView: NSView { layer?.borderColor = NSColor.clear.cgColor setAccessibilityElement(true) setAccessibilityRole(.button) - setAccessibilityHelp("Press Return or Space to show \(titleText)") + setAccessibilityHelp("Press Return or Space to show \(titleText). Use Left and Right to move between collections.") heightAnchor.constraint(equalToConstant: 26).isActive = true registerForDraggedTypes(ClipboardItemDragPasteboard.acceptedTypes) @@ -2010,7 +2079,7 @@ private final class CollectionChipView: NSView { let selectedText = isSelected ? "selected, " : "" setAccessibilityLabel("\(titleText), \(selectedText)\(count) \(noun)") setAccessibilityValue("\(count)") - setAccessibilityHelp("Press Return or Space to show \(titleText)") + setAccessibilityHelp("Press Return or Space to show \(titleText). Use Left and Right to move between collections.") toolTip = "\(titleText), \(selectedText)\(count) \(noun)" } @@ -2030,6 +2099,12 @@ private final class CollectionChipView: NSView { return true } + func clearKeyboardFocus() { + guard isKeyboardFocused else { return } + isKeyboardFocused = false + updateChrome() + } + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } @@ -2042,6 +2117,14 @@ private final class CollectionChipView: NSView { switch event.keyCode { case 36, 49: onPress() + case 115: + onSelectFirst() + case 119: + onSelectLast() + case 123: + onMoveFocus(-1) + case 124: + onMoveFocus(1) default: super.keyDown(with: event) } @@ -2133,6 +2216,10 @@ private final class CollectionChipView: NSView { onDropItem != nil } + var debugIsKeyboardFocused: Bool { + isKeyboardFocused + } + func debugDropItem(_ itemID: UUID) { onDropItem?(itemID) } diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index 098178e..3eed796 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -206,6 +206,46 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertTrue(fixture.view.debugCollectionChipAccessibilityLabels.contains("Links, selected, 0 clips")) } + func testCollectionRailChipsSupportShelfNavigationKeys() { + let fixture = makePanelFixture() + var clientItem = makeTextItem("Client collection item", store: fixture.store) + clientItem.collectionName = "Client Work" + fixture.store.upsert(clientItem) + fixture.store.upsert(makeTextItem("Stack queue item", store: fixture.store)) + drainMainQueue() + + fixture.viewModel.selectItem(at: 0) + fixture.viewModel.toggleSelectedStackMembership() + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertTrue(fixture.view.debugFocusCollectionChip(.links)) + fixture.view.debugPressFocusedResponderKeyCode(124) + drainMainQueue() + XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Images") + XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Images"]) + + fixture.view.debugPressFocusedResponderKeyCode(123) + drainMainQueue() + XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Links") + XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Links"]) + + fixture.view.debugPressFocusedResponderKeyCode(119) + drainMainQueue() + XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Stack") + XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Stack"]) + + fixture.view.debugPressFocusedResponderKeyCode(123) + drainMainQueue() + XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Client Work") + XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Client Work"]) + + fixture.view.debugPressFocusedResponderKeyCode(115) + drainMainQueue() + XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") + XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Clipboard"]) + } + func testCollectionRailAddButtonCreatesEmptyCollection() { let fixture = makePanelFixture()