diff --git a/README.md b/README.md index 9f36759..f96768f 100644 --- a/README.md +++ b/README.md @@ -16,7 +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 +- Keyboard-focusable cards with Return-to-paste, Space-to-preview for Quick Look capable clips, vertical wheel/trackpad panning in horizontal rails, 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 888dffa..ca65fbb 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -38,19 +38,20 @@ Use this checklist before a release or after changes to panel, pasteboard, setti 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. 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. +8. Use a mouse wheel or two-finger vertical scroll over the card shelf and a crowded collection rail; confirm each pans horizontally and clamps at both ends. +9. 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. Press `Esc` once with a non-empty search field and confirm search clears. +11. Press `Esc` again and confirm the panel closes. +12. Reopen the panel, change sort segments, and confirm each segment updates results. +13. 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. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases. +15. 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. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update. +17. 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. 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 card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped. +20. 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. 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 59cae84..aea5851 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -221,12 +221,12 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { private let onPreview: () -> Void private let searchField = NSSearchField() - private let collectionScrollView = NSScrollView() + private let collectionScrollView = HorizontalRailScrollView() private let collectionStack = NSStackView() private let addCollectionButton = NSButton() private let stackChip = CollectionChipView(title: "Stack", color: .systemGreen) private let itemsStack = NSStackView() - private let scrollView = NSScrollView() + private let scrollView = HorizontalRailScrollView() private let statusLabel = NSTextField(labelWithString: "") private let statusResultCountLabel = NSTextField(labelWithString: "") private let statusIndicator = NSView() @@ -1279,6 +1279,14 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { scrollView.contentView.bounds } + var debugCardRailDocumentWidth: CGFloat { + scrollView.documentView?.frame.width ?? 0 + } + + func debugScrollCardRailVertically(deltaY: CGFloat) { + scrollView.scrollHorizontallyByVerticalDelta(deltaY) + } + var debugFirstCardMenuTitles: [String] { cardViews.first?.debugMenuTitles ?? [] } @@ -1428,6 +1436,14 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { collectionScrollView.documentView?.frame.width ?? 0 } + var debugCollectionRailVisibleRect: NSRect { + collectionScrollView.contentView.bounds + } + + func debugScrollCollectionRailVertically(deltaY: CGFloat) { + collectionScrollView.scrollHorizontallyByVerticalDelta(deltaY) + } + var debugEmptyStateText: (title: String, detail: String)? { emptyStateText } @@ -1673,6 +1689,45 @@ private enum ClipboardCardDragContext { static var itemID: UUID? } +private final class HorizontalRailScrollView: NSScrollView { + override func scrollWheel(with event: NSEvent) { + let horizontalDelta = event.scrollingDeltaX + let verticalDelta = event.scrollingDeltaY + if abs(verticalDelta) > abs(horizontalDelta), + abs(verticalDelta) > 0, + canScrollHorizontally { + scrollHorizontally(by: -verticalDelta) + return + } + + super.scrollWheel(with: event) + } + + func scrollHorizontallyByVerticalDelta(_ deltaY: CGFloat) { + scrollHorizontally(by: -deltaY) + } + + private var canScrollHorizontally: Bool { + maxHorizontalOffset > 0 + } + + private var maxHorizontalOffset: CGFloat { + guard let documentView else { return 0 } + return max(0, documentView.frame.width - contentView.bounds.width) + } + + private func scrollHorizontally(by deltaX: CGFloat) { + let maxOffset = maxHorizontalOffset + guard maxOffset > 0 else { return } + let origin = contentView.bounds.origin + let targetX = min(max(origin.x + deltaX, 0), maxOffset) + guard targetX != origin.x else { return } + + contentView.scroll(to: NSPoint(x: targetX, y: origin.y)) + reflectScrolledClipView(contentView) + } +} + private final class CollectionChipView: NSView { let titleText: String private let color: NSColor diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index 8e2afe8..d94cc19 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -419,6 +419,7 @@ final class ClipboardPanelViewTests: XCTestCase { func testCollectionRailUsesScrollableDocumentForCrowdedCustomCollections() { let fixture = makePanelFixture() + fixture.window.setFrame(NSRect(x: 0, y: 0, width: 620, height: 520), display: true) let names = [ "Client Work", "Research Archive", @@ -445,6 +446,17 @@ final class ClipboardPanelViewTests: XCTestCase { ) XCTAssertTrue(fixture.view.debugCustomCollectionTitles.contains("Client Work")) XCTAssertTrue(fixture.view.debugCustomCollectionTitles.contains("Product References")) + + XCTAssertEqual(fixture.view.debugCollectionRailVisibleRect.minX, 0, accuracy: 0.5) + fixture.view.debugScrollCollectionRailVertically(deltaY: -220) + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertGreaterThan(fixture.view.debugCollectionRailVisibleRect.minX, 0) + + fixture.view.debugScrollCollectionRailVertically(deltaY: 10_000) + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual(fixture.view.debugCollectionRailVisibleRect.minX, 0, accuracy: 0.5) } func testSelectionScrollsCardRailToKeepSelectedCardVisible() { @@ -479,6 +491,42 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertLessThanOrEqual(fixture.view.debugCardRailVisibleRect.minX, 1) } + func testVerticalWheelPansHorizontalCardRailAndClamps() { + let fixture = makePanelFixture() + fixture.window.setFrame(NSRect(x: 0, y: 0, width: 620, height: 520), display: true) + + for index in 0..<8 { + fixture.store.upsert(makeTextItem("Wheel scroll item \(index)", store: fixture.store)) + drainMainQueue() + } + fixture.window.contentView?.layoutSubtreeIfNeeded() + fixture.viewModel.selectItem(at: 0) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertGreaterThan( + fixture.view.debugCardRailDocumentWidth, + fixture.view.debugCardRailVisibleRect.width + 1 + ) + XCTAssertEqual(fixture.view.debugCardRailVisibleRect.minX, 0, accuracy: 0.5) + + fixture.view.debugScrollCardRailVertically(deltaY: -240) + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertGreaterThan(fixture.view.debugCardRailVisibleRect.minX, 0) + + fixture.view.debugScrollCardRailVertically(deltaY: -10_000) + fixture.window.contentView?.layoutSubtreeIfNeeded() + + let maxOffset = fixture.view.debugCardRailDocumentWidth - fixture.view.debugCardRailVisibleRect.width + XCTAssertEqual(fixture.view.debugCardRailVisibleRect.minX, maxOffset, accuracy: 1) + + fixture.view.debugScrollCardRailVertically(deltaY: 10_000) + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual(fixture.view.debugCardRailVisibleRect.minX, 0, accuracy: 1) + } + func testFilteredEmptyStateNamesCurrentCollection() { let fixture = makePanelFixture() fixture.store.upsert(makeTextItem("Only text exists", store: fixture.store))