WIP: add collection rail keyboard navigation

This commit is contained in:
Akshay Kolli
2026-06-30 09:26:28 -07:00
parent 14b1d3323c
commit ac910726ac
4 changed files with 132 additions and 4 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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)
}

View File

@@ -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()