WIP: add collection rail keyboard navigation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user