WIP: type to search from shelf focus
This commit is contained in:
@@ -16,7 +16,7 @@ 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 and collection chips with Return-to-paste/select, Space-to-preview for text, links, files, and media, 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 type-to-search, Return-to-paste/select, Space-to-preview for text, links, files, and media, 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
|
- 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
|
||||||
|
|||||||
@@ -39,20 +39,21 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
|
|||||||
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.
|
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 text, links, files, and media.
|
7. Tab to cards; confirm the focused card gets a clear focus border, `Return` pastes or copies it, and `Space` opens Quick Look for text, links, files, and media.
|
||||||
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. With a card or collection chip focused, type a normal character and confirm focus returns to search with that character inserted and results filtered.
|
||||||
10. Right-click a filtered result and choose Show in Clipboard, or press `Command + G`, and confirm search clears while the same card stays selected in Most Recent.
|
10. 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.
|
||||||
11. Press `Esc` once with a non-empty search field and confirm search clears.
|
11. Right-click a filtered result and choose Show in Clipboard, or press `Command + G`, and confirm search clears while the same card stays selected in Most Recent.
|
||||||
12. Press `Esc` again and confirm the panel closes.
|
12. Press `Esc` once with a non-empty search field and confirm search clears.
|
||||||
13. Reopen the panel, change sort segments, and confirm each segment updates results.
|
13. Press `Esc` again and confirm the panel closes.
|
||||||
14. Press `Shift + Command + N` or the collection rail `+`, enter `Client Work`, choose a color, and confirm a Client Work chip appears with 0 clips and an empty collection view.
|
14. Reopen the panel, change sort segments, and confirm each segment updates results.
|
||||||
15. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases.
|
15. Press `Shift + Command + N` or the collection rail `+`, enter `Client Work`, choose a color, and confirm a Client Work chip appears with 0 clips and an empty collection view.
|
||||||
16. Select the Client Work chip and confirm the rail filters to assigned items, cards use the Client Work name/color in their headers, and the collection/color/assignment persists after quitting and reopening ClipBored.
|
16. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases.
|
||||||
17. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update.
|
17. Select the Client Work chip and confirm the rail filters to assigned items, cards use the Client Work name/color in their headers, and the collection/color/assignment persists after quitting and reopening ClipBored.
|
||||||
18. Right-click a media, file, link, PDF, audio, or text card, choose Rename..., give it a title, and confirm the card title and search results use the custom title while paste/copy still uses the original payload.
|
18. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update.
|
||||||
19. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
|
19. Right-click a media, file, link, PDF, audio, or text card, choose Rename..., give it a title, and confirm the card title and search results use the custom title while paste/copy still uses the original payload.
|
||||||
20. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped.
|
20. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
|
||||||
21. Drag an unassigned card onto the renamed collection chip and confirm the chip count increases and the card appears when that collection is selected.
|
21. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped.
|
||||||
22. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly.
|
22. Drag an unassigned card onto the renamed collection chip and confirm the chip count increases and the card appears when that collection is selected.
|
||||||
|
23. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly.
|
||||||
|
|
||||||
## Copy And Paste
|
## Copy And Paste
|
||||||
|
|
||||||
|
|||||||
@@ -592,6 +592,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func configureCollectionKeyboardNavigation(for chip: CollectionChipView) {
|
private func configureCollectionKeyboardNavigation(for chip: CollectionChipView) {
|
||||||
|
chip.onStartSearch = { [weak self] text in
|
||||||
|
self?.startSearchFromShelf(text)
|
||||||
|
}
|
||||||
chip.onMoveFocus = { [weak self, weak chip] delta in
|
chip.onMoveFocus = { [weak self, weak chip] delta in
|
||||||
self?.moveCollectionFocus(from: chip, delta: delta)
|
self?.moveCollectionFocus(from: chip, delta: delta)
|
||||||
}
|
}
|
||||||
@@ -744,6 +747,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
card.onSelect = { [weak self] selected in
|
card.onSelect = { [weak self] selected in
|
||||||
self?.viewModel.selectItem(at: selected)
|
self?.viewModel.selectItem(at: selected)
|
||||||
}
|
}
|
||||||
|
card.onStartSearch = { [weak self] text in
|
||||||
|
self?.startSearchFromShelf(text)
|
||||||
|
}
|
||||||
card.onMoveSelection = { [weak self] delta in
|
card.onMoveSelection = { [weak self] delta in
|
||||||
self?.moveSelectionFromFocusedCard(delta)
|
self?.moveSelectionFromFocusedCard(delta)
|
||||||
}
|
}
|
||||||
@@ -1225,6 +1231,13 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
window?.makeFirstResponder(searchField)
|
window?.makeFirstResponder(searchField)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func startSearchFromShelf(_ text: String) {
|
||||||
|
guard !text.isEmpty else { return }
|
||||||
|
focusSearchField()
|
||||||
|
searchField.stringValue += text
|
||||||
|
updateSearchText()
|
||||||
|
}
|
||||||
|
|
||||||
func setBottomSafeInset(_ inset: CGFloat) {
|
func setBottomSafeInset(_ inset: CGFloat) {
|
||||||
bottomSafeInset = max(Metrics.minimumBottomInset, inset)
|
bottomSafeInset = max(Metrics.minimumBottomInset, inset)
|
||||||
mainStack?.edgeInsets = contentInsets()
|
mainStack?.edgeInsets = contentInsets()
|
||||||
@@ -1478,6 +1491,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
debugPressFocusedResponder(characters: "", keyCode: keyCode)
|
debugPressFocusedResponder(characters: "", keyCode: keyCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func debugTypeFocusedResponder(_ characters: String, keyCode: UInt16) {
|
||||||
|
debugPressFocusedResponder(characters: characters, keyCode: keyCode)
|
||||||
|
}
|
||||||
|
|
||||||
private func debugPressFocusedResponder(characters: String, keyCode: UInt16) {
|
private func debugPressFocusedResponder(characters: String, keyCode: UInt16) {
|
||||||
guard let window,
|
guard let window,
|
||||||
let event = NSEvent.keyEvent(
|
let event = NSEvent.keyEvent(
|
||||||
@@ -1813,6 +1830,14 @@ private enum ClipboardItemDragPasteboard {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func shelfSearchText(from event: NSEvent) -> String? {
|
||||||
|
let blockedModifiers: NSEvent.ModifierFlags = [.command, .control, .option, .function]
|
||||||
|
guard event.modifierFlags.intersection(blockedModifiers).isEmpty else { return nil }
|
||||||
|
guard let characters = event.characters, !characters.isEmpty else { return nil }
|
||||||
|
guard characters.rangeOfCharacter(from: .controlCharacters) == nil else { return nil }
|
||||||
|
return characters
|
||||||
|
}
|
||||||
|
|
||||||
private enum ClipboardCardDragContext {
|
private enum ClipboardCardDragContext {
|
||||||
static var itemID: UUID?
|
static var itemID: UUID?
|
||||||
}
|
}
|
||||||
@@ -1960,6 +1985,7 @@ 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 onStartSearch: (String) -> Void = { _ in }
|
||||||
var onMoveFocus: (Int) -> Void = { _ in }
|
var onMoveFocus: (Int) -> Void = { _ in }
|
||||||
var onSelectFirst: () -> Void = {}
|
var onSelectFirst: () -> Void = {}
|
||||||
var onSelectLast: () -> Void = {}
|
var onSelectLast: () -> Void = {}
|
||||||
@@ -2126,7 +2152,11 @@ private final class CollectionChipView: NSView {
|
|||||||
case 124:
|
case 124:
|
||||||
onMoveFocus(1)
|
onMoveFocus(1)
|
||||||
default:
|
default:
|
||||||
super.keyDown(with: event)
|
if let text = shelfSearchText(from: event) {
|
||||||
|
onStartSearch(text)
|
||||||
|
} else {
|
||||||
|
super.keyDown(with: event)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2313,6 +2343,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
var onIgnoreSourceApp: (Int) -> Void = { _ in }
|
var onIgnoreSourceApp: (Int) -> Void = { _ in }
|
||||||
var onIgnoreKind: (Int) -> Void = { _ in }
|
var onIgnoreKind: (Int) -> Void = { _ in }
|
||||||
var onDelete: (Int) -> Void = { _ in }
|
var onDelete: (Int) -> Void = { _ in }
|
||||||
|
var onStartSearch: (String) -> Void = { _ in }
|
||||||
|
|
||||||
private let index: Int
|
private let index: Int
|
||||||
private let itemID: UUID
|
private let itemID: UUID
|
||||||
@@ -2447,7 +2478,11 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
case 124:
|
case 124:
|
||||||
onMoveSelection(1)
|
onMoveSelection(1)
|
||||||
default:
|
default:
|
||||||
super.keyDown(with: event)
|
if let text = shelfSearchText(from: event) {
|
||||||
|
onStartSearch(text)
|
||||||
|
} else {
|
||||||
|
super.keyDown(with: event)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -246,6 +246,23 @@ final class ClipboardPanelViewTests: XCTestCase {
|
|||||||
XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Clipboard"])
|
XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Clipboard"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testTypingFromFocusedCollectionChipStartsSearch() {
|
||||||
|
let fixture = makePanelFixture()
|
||||||
|
fixture.store.upsert(makeTextItem("Alpha note", store: fixture.store))
|
||||||
|
fixture.store.upsert(makeTextItem("Quantum reference", store: fixture.store))
|
||||||
|
drainMainQueue()
|
||||||
|
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||||
|
|
||||||
|
XCTAssertTrue(fixture.view.debugFocusCollectionChip(.links))
|
||||||
|
fixture.view.debugTypeFocusedResponder("q", keyCode: 12)
|
||||||
|
drainMainQueue()
|
||||||
|
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||||
|
|
||||||
|
XCTAssertTrue(fixture.view.isSearchFieldEditing)
|
||||||
|
XCTAssertEqual(fixture.view.debugSearchFieldText, "q")
|
||||||
|
XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Quantum reference"])
|
||||||
|
}
|
||||||
|
|
||||||
func testCollectionRailAddButtonCreatesEmptyCollection() {
|
func testCollectionRailAddButtonCreatesEmptyCollection() {
|
||||||
let fixture = makePanelFixture()
|
let fixture = makePanelFixture()
|
||||||
|
|
||||||
@@ -380,6 +397,23 @@ final class ClipboardPanelViewTests: XCTestCase {
|
|||||||
XCTAssertEqual(fixture.viewModel.statusMessage, "")
|
XCTAssertEqual(fixture.viewModel.statusMessage, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testTypingFromFocusedCardStartsSearch() {
|
||||||
|
let fixture = makePanelFixture()
|
||||||
|
fixture.store.upsert(makeTextItem("Alpha note", store: fixture.store))
|
||||||
|
fixture.store.upsert(makeTextItem("Quantum card", store: fixture.store))
|
||||||
|
drainMainQueue()
|
||||||
|
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||||
|
|
||||||
|
XCTAssertTrue(fixture.view.debugFocusCard(at: 0))
|
||||||
|
fixture.view.debugTypeFocusedResponder("q", keyCode: 12)
|
||||||
|
drainMainQueue()
|
||||||
|
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||||
|
|
||||||
|
XCTAssertTrue(fixture.view.isSearchFieldEditing)
|
||||||
|
XCTAssertEqual(fixture.view.debugSearchFieldText, "q")
|
||||||
|
XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Quantum card"])
|
||||||
|
}
|
||||||
|
|
||||||
func testFocusedPreviewableCardSpaceOpensQuickLook() {
|
func testFocusedPreviewableCardSpaceOpensQuickLook() {
|
||||||
let fixture = makePanelFixture()
|
let fixture = makePanelFixture()
|
||||||
fixture.store.upsert(makeItem(kind: .url, text: "https://example.com/read", store: fixture.store))
|
fixture.store.upsert(makeItem(kind: .url, text: "https://example.com/read", store: fixture.store))
|
||||||
|
|||||||
Reference in New Issue
Block a user