WIP: pan horizontal rails with vertical scroll

This commit is contained in:
Akshay Kolli
2026-06-30 09:02:19 -07:00
parent a540421fe9
commit d07f213e80
4 changed files with 120 additions and 16 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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))