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