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 - `Shift + Command + N` creates a new collection
- `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 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 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 - 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

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. 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. 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. 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. 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. 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. 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 cardViews: [ClipboardItemCardView] = []
private var collectionButtons: [ClipboardSortMode: CollectionChipView] = [:] private var collectionButtons: [ClipboardSortMode: CollectionChipView] = [:]
private var customCollectionButtons: [String: CollectionChipView] = [:] private var customCollectionButtons: [String: CollectionChipView] = [:]
private var collectionChipOrder: [CollectionChipView] = []
private var lastScrollContentWidth: CGFloat = 0 private var lastScrollContentWidth: CGFloat = 0
private var lastCollectionViewportWidth: CGFloat = 0 private var lastCollectionViewportWidth: CGFloat = 0
private var defersVisualReloads = false private var defersVisualReloads = false
@@ -533,7 +534,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
private func configureCollectionButtons() { private func configureCollectionButtons() {
collectionButtons.removeAll() collectionButtons.removeAll()
customCollectionButtons.removeAll() customCollectionButtons.removeAll()
collectionChipOrder.removeAll()
for view in collectionStack.arrangedSubviews { for view in collectionStack.arrangedSubviews {
(view as? CollectionChipView)?.clearKeyboardFocus()
collectionStack.removeArrangedSubview(view) collectionStack.removeArrangedSubview(view)
view.removeFromSuperview() view.removeFromSuperview()
} }
@@ -544,7 +547,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
chip.onPress = { [weak self] in chip.onPress = { [weak self] in
self?.viewModel.sortMode = mode self?.viewModel.sortMode = mode
} }
configureCollectionKeyboardNavigation(for: chip)
collectionButtons[mode] = chip collectionButtons[mode] = chip
collectionChipOrder.append(chip)
collectionStack.addArrangedSubview(chip) collectionStack.addArrangedSubview(chip)
} }
@@ -563,7 +568,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
chip.onDelete = { [weak self] in chip.onDelete = { [weak self] in
self?.deleteCollection(named: collectionName) self?.deleteCollection(named: collectionName)
} }
configureCollectionKeyboardNavigation(for: chip)
customCollectionButtons[collectionName] = chip customCollectionButtons[collectionName] = chip
collectionChipOrder.append(chip)
collectionStack.addArrangedSubview(chip) collectionStack.addArrangedSubview(chip)
} }
configureStackChip() configureStackChip()
@@ -577,11 +584,26 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
stackChip.onPress = { [weak self] in stackChip.onPress = { [weak self] in
self?.viewModel.selectStack() self?.viewModel.selectStack()
} }
configureCollectionKeyboardNavigation(for: stackChip)
if viewModel.stackCount > 0 { if viewModel.stackCount > 0 {
collectionChipOrder.append(stackChip)
collectionStack.addArrangedSubview(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() { private func configureAddCollectionButton() {
let image = NSImage(systemSymbolName: "plus", accessibilityDescription: "New collection") let image = NSImage(systemSymbolName: "plus", accessibilityDescription: "New collection")
image?.isTemplate = true image?.isTemplate = true
@@ -891,6 +913,46 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
scrollView.reflectScrolledClipView(scrollView.contentView) 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) { private func updateStatus(_ message: String) {
let text: String let text: String
if !message.isEmpty { if !message.isEmpty {
@@ -1390,6 +1452,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
ClipboardSortMode.allCases.compactMap { collectionButtons[$0]?.acceptsFirstResponder } ClipboardSortMode.allCases.compactMap { collectionButtons[$0]?.acceptsFirstResponder }
} }
var debugKeyboardFocusedCollectionTitles: [String] {
collectionChipOrder.compactMap { $0.debugIsKeyboardFocused ? $0.titleText : nil }
}
func debugFocusCollectionChip(_ mode: ClipboardSortMode) -> Bool { func debugFocusCollectionChip(_ mode: ClipboardSortMode) -> Bool {
guard let chip = collectionButtons[mode] else { return false } guard let chip = collectionButtons[mode] else { return false }
return window?.makeFirstResponder(chip) ?? false return window?.makeFirstResponder(chip) ?? false
@@ -1894,6 +1960,9 @@ private final class CollectionChipView: NSView {
private var isKeyboardFocused = false private var isKeyboardFocused = false
private var isDropTargeted = false private var isDropTargeted = false
var onPress: () -> Void = {} var onPress: () -> Void = {}
var onMoveFocus: (Int) -> Void = { _ in }
var onSelectFirst: () -> Void = {}
var onSelectLast: () -> Void = {}
var onDropItem: ((UUID) -> Void)? var onDropItem: ((UUID) -> Void)?
var onEdit: (() -> Void)? var onEdit: (() -> Void)?
var onDelete: (() -> Void)? var onDelete: (() -> Void)?
@@ -1918,7 +1987,7 @@ private final class CollectionChipView: NSView {
layer?.borderColor = NSColor.clear.cgColor layer?.borderColor = NSColor.clear.cgColor
setAccessibilityElement(true) setAccessibilityElement(true)
setAccessibilityRole(.button) 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 heightAnchor.constraint(equalToConstant: 26).isActive = true
registerForDraggedTypes(ClipboardItemDragPasteboard.acceptedTypes) registerForDraggedTypes(ClipboardItemDragPasteboard.acceptedTypes)
@@ -2010,7 +2079,7 @@ private final class CollectionChipView: NSView {
let selectedText = isSelected ? "selected, " : "" let selectedText = isSelected ? "selected, " : ""
setAccessibilityLabel("\(titleText), \(selectedText)\(count) \(noun)") setAccessibilityLabel("\(titleText), \(selectedText)\(count) \(noun)")
setAccessibilityValue("\(count)") 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)" toolTip = "\(titleText), \(selectedText)\(count) \(noun)"
} }
@@ -2030,6 +2099,12 @@ private final class CollectionChipView: NSView {
return true return true
} }
func clearKeyboardFocus() {
guard isKeyboardFocused else { return }
isKeyboardFocused = false
updateChrome()
}
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
true true
} }
@@ -2042,6 +2117,14 @@ private final class CollectionChipView: NSView {
switch event.keyCode { switch event.keyCode {
case 36, 49: case 36, 49:
onPress() onPress()
case 115:
onSelectFirst()
case 119:
onSelectLast()
case 123:
onMoveFocus(-1)
case 124:
onMoveFocus(1)
default: default:
super.keyDown(with: event) super.keyDown(with: event)
} }
@@ -2133,6 +2216,10 @@ private final class CollectionChipView: NSView {
onDropItem != nil onDropItem != nil
} }
var debugIsKeyboardFocused: Bool {
isKeyboardFocused
}
func debugDropItem(_ itemID: UUID) { func debugDropItem(_ itemID: UUID) {
onDropItem?(itemID) onDropItem?(itemID)
} }

View File

@@ -206,6 +206,46 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertTrue(fixture.view.debugCollectionChipAccessibilityLabels.contains("Links, selected, 0 clips")) 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() { func testCollectionRailAddButtonCreatesEmptyCollection() {
let fixture = makePanelFixture() let fixture = makePanelFixture()