diff --git a/README.md b/README.md index 3a20bbd..9f36759 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ 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, visible focus chrome, and VoiceOver action hints - 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 diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index ee3dff0..888dffa 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -37,19 +37,20 @@ Use this checklist before a release or after changes to panel, pasteboard, setti 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. -7. 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. -8. Press `Esc` once with a non-empty search field and confirm search clears. -9. Press `Esc` again and confirm the panel closes. -10. Reopen the panel, change sort segments, and confirm each segment updates results. -11. 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. -12. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases. -13. 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. -14. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update. -15. 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. -16. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry. -17. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped. -18. Drag an unassigned card onto the renamed collection chip and confirm the chip count increases and the card appears when that collection is selected. -19. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly. +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. 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. +9. Press `Esc` once with a non-empty search field and confirm search clears. +10. Press `Esc` again and confirm the panel closes. +11. Reopen the panel, change sort segments, and confirm each segment updates results. +12. 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. +13. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases. +14. 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. +15. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update. +16. 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. +17. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry. +18. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped. +19. Drag an unassigned card onto the renamed collection chip and confirm the chip count increases and the card appears when that collection is selected. +20. 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 diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index ac958fa..59cae84 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -1205,6 +1205,28 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { cardViews.compactMap { $0.accessibilityLabel() } } + var debugCardAccessibilityValues: [String] { + cardViews.compactMap { $0.accessibilityValue() as? String } + } + + var debugCardAccessibilityHelps: [String] { + cardViews.compactMap { $0.accessibilityHelp() } + } + + var debugCardAcceptsFirstResponder: [Bool] { + cardViews.map(\.acceptsFirstResponder) + } + + var debugKeyboardFocusedCardIndexes: [Int] { + cardViews.enumerated().compactMap { index, card in + card.debugIsKeyboardFocused ? index : nil + } + } + + var debugCardBorderWidths: [CGFloat] { + cardViews.map(\.debugBorderWidth) + } + var debugCardPreviewSummaries: [String] { cardViews.map(\.debugPreviewSummary) } @@ -1334,7 +1356,20 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { return window?.makeFirstResponder(chip) ?? false } + func debugFocusCard(at index: Int) -> Bool { + guard index >= 0, index < cardViews.count else { return false } + return window?.makeFirstResponder(cardViews[index]) ?? false + } + + func debugPressFocusedResponderWithReturn() { + debugPressFocusedResponder(characters: "\r", keyCode: 36) + } + func debugPressFocusedResponderWithSpace() { + debugPressFocusedResponder(characters: " ", keyCode: 49) + } + + private func debugPressFocusedResponder(characters: String, keyCode: UInt16) { guard let window, let event = NSEvent.keyEvent( with: .keyDown, @@ -1343,10 +1378,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { timestamp: 0, windowNumber: window.windowNumber, context: nil, - characters: " ", - charactersIgnoringModifiers: " ", + characters: characters, + charactersIgnoringModifiers: characters, isARepeat: false, - keyCode: 49 + keyCode: keyCode ) else { return } @@ -2001,6 +2036,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { private weak var quickPasteBadgeLabel: NSTextField? private var isSelected = false private var isHovered = false + private var isKeyboardFocused = false private var mouseDownLocation: NSPoint? private var trackingAreaRef: NSTrackingArea? @@ -2045,11 +2081,15 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { func setSelected(_ selected: Bool) { isSelected = selected - contentView.layer?.borderWidth = 1 - contentView.layer?.borderColor = selected ? Palette.selectedBorder : Palette.border + contentView.layer?.borderWidth = isKeyboardFocused ? 2 : 1 if selected { contentView.layer?.backgroundColor = Palette.selectedSurface - contentView.layer?.borderColor = Palette.selectedBorder + contentView.layer?.borderColor = isKeyboardFocused + ? NSColor.controlAccentColor.withAlphaComponent(0.86).cgColor + : Palette.selectedBorder + } else if isKeyboardFocused { + contentView.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor + contentView.layer?.borderColor = NSColor.controlAccentColor.withAlphaComponent(0.58).cgColor } else if isHovered { contentView.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor contentView.layer?.borderColor = NSColor.controlAccentColor.withAlphaComponent(0.28).cgColor @@ -2057,13 +2097,52 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { contentView.layer?.backgroundColor = Palette.cardSurface contentView.layer?.borderColor = Palette.border } - layer?.shadowOpacity = selected ? 0.16 : (isHovered ? 0.12 : 0.08) - layer?.shadowRadius = selected ? 16 : 12 - layer?.shadowOffset = NSSize(width: 0, height: selected ? 6 : 4) - layer?.transform = selected ? CATransform3DMakeTranslation(0, -4, 0) : CATransform3DIdentity + let emphasized = selected || isKeyboardFocused + layer?.shadowOpacity = emphasized ? 0.16 : (isHovered ? 0.12 : 0.08) + layer?.shadowRadius = emphasized ? 16 : 12 + layer?.shadowOffset = NSSize(width: 0, height: emphasized ? 6 : 4) + layer?.transform = emphasized ? CATransform3DMakeTranslation(0, -4, 0) : CATransform3DIdentity + setAccessibilityValue(selected ? "Selected" : "Not selected") updateActionRailVisibility() } + override var acceptsFirstResponder: Bool { + true + } + + override func becomeFirstResponder() -> Bool { + isKeyboardFocused = true + onSelect(index) + setSelected(isSelected) + return true + } + + override func resignFirstResponder() -> Bool { + isKeyboardFocused = false + setSelected(isSelected) + return true + } + + override func keyDown(with event: NSEvent) { + switch event.keyCode { + case 36, 76: + onPaste(index) + case 49: + if canPreview { + onPreview(index) + } else { + onPaste(index) + } + default: + super.keyDown(with: event) + } + } + + override func accessibilityPerformPress() -> Bool { + onPaste(index) + return true + } + override func updateTrackingAreas() { super.updateTrackingAreas() if let trackingAreaRef { @@ -2212,6 +2291,14 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { quickPasteBadgeLabel?.stringValue } + var debugIsKeyboardFocused: Bool { + isKeyboardFocused + } + + var debugBorderWidth: CGFloat { + contentView.layer?.borderWidth ?? 0 + } + var debugFooterDetailText: String { footerDetailLabel.stringValue } @@ -2630,9 +2717,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { setAccessibilityElement(true) setAccessibilityRole(.button) setAccessibilityLabel(accessibilityTitle(for: item)) - setAccessibilityHelp("Selects this clipboard item. Double-click to paste.") + setAccessibilityHelp(accessibilityHelpText()) widthAnchor.constraint(equalToConstant: layout.width).isActive = true heightAnchor.constraint(equalToConstant: layout.height).isActive = true + focusRingType = .default contentView.wantsLayer = true contentView.layer?.cornerRadius = 8 @@ -3643,6 +3731,13 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return "\(kindLabel(for: item.kind)): \(summary)" } + private func accessibilityHelpText() -> String { + if canPreview { + return "Press Return to paste. Press Space for Quick Look." + } + return "Press Return or Space to paste." + } + private func row(_ views: [NSView]) -> NSStackView { let stack = NSStackView(views: views) stack.orientation = .horizontal diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index 9e2931c..8e2afe8 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -12,6 +12,11 @@ final class ClipboardPanelViewTests: XCTestCase { let settings: SettingsModel let store: ClipboardStore let cacheService: ClipboardCacheService + let previewProbe: PreviewProbe + } + + private final class PreviewProbe { + var requestCount = 0 } override func tearDown() { @@ -294,6 +299,46 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeIsHidden) } + func testCardsAreKeyboardFocusableAndReturnPastesFocusedCard() { + let fixture = makePanelFixture() + fixture.store.upsert(makeTextItem("Older text card", store: fixture.store)) + fixture.store.upsert(makeTextItem("Newest text card", store: fixture.store)) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual(fixture.view.debugCardAcceptsFirstResponder, [true, true]) + XCTAssertTrue(fixture.view.debugFocusCard(at: 1)) + drainMainQueue() + + XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "Older text card") + XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [1]) + XCTAssertEqual(fixture.view.debugCardBorderWidths[1], 2) + XCTAssertEqual(fixture.view.debugCardAccessibilityValues[1], "Selected") + XCTAssertEqual(fixture.view.debugCardAccessibilityHelps[1], "Press Return or Space to paste.") + + fixture.view.debugPressFocusedResponderWithReturn() + drainMainQueue() + + XCTAssertEqual(fixture.viewModel.statusMessage, "Copied") + } + + func testFocusedPreviewableCardSpaceOpensQuickLook() { + let fixture = makePanelFixture() + fixture.store.upsert(makeItem(kind: .url, text: "https://example.com/read", store: fixture.store)) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) + drainMainQueue() + XCTAssertEqual(fixture.view.debugCardAccessibilityHelps.first, "Press Return to paste. Press Space for Quick Look.") + + fixture.view.debugPressFocusedResponderWithSpace() + drainMainQueue() + + XCTAssertEqual(fixture.previewProbe.requestCount, 1) + XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "https://example.com/read") + } + func testCardHeaderUsesKindSymbolBadgeWhenSourceIconIsUnavailable() { let fixture = makePanelFixture() fixture.store.upsert(makeItem(kind: .url, text: "https://example.com", store: fixture.store)) @@ -836,11 +881,13 @@ final class ClipboardPanelViewTests: XCTestCase { let cacheService = ClipboardCacheService() let store = makeStore(settings: settings, cacheService: cacheService) let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) + let previewProbe = PreviewProbe() let view = ClipboardPanelView( viewModel: viewModel, onClose: {}, - onSettings: {} + onSettings: {}, + onPreview: { previewProbe.requestCount += 1 } ) let window = NSPanel( @@ -857,7 +904,8 @@ final class ClipboardPanelViewTests: XCTestCase { viewModel: viewModel, settings: settings, store: store, - cacheService: cacheService + cacheService: cacheService, + previewProbe: previewProbe ) }