WIP: pan horizontal rails with vertical scroll
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 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
|
- 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
|
||||||
|
|||||||
@@ -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.
|
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.
|
||||||
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. 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. 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. Press `Esc` once with a non-empty search field and confirm search clears.
|
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` again and confirm the panel closes.
|
10. Press `Esc` once with a non-empty search field and confirm search clears.
|
||||||
11. Reopen the panel, change sort segments, and confirm each segment updates results.
|
11. Press `Esc` again and confirm the panel closes.
|
||||||
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.
|
12. Reopen the panel, change sort segments, and confirm each segment updates results.
|
||||||
13. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases.
|
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. 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. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases.
|
||||||
15. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update.
|
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 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. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update.
|
||||||
17. 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 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 a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped.
|
18. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
|
||||||
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.
|
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. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly.
|
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
|
## Copy And Paste
|
||||||
|
|
||||||
|
|||||||
@@ -221,12 +221,12 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
private let onPreview: () -> Void
|
private let onPreview: () -> Void
|
||||||
|
|
||||||
private let searchField = NSSearchField()
|
private let searchField = NSSearchField()
|
||||||
private let collectionScrollView = NSScrollView()
|
private let collectionScrollView = HorizontalRailScrollView()
|
||||||
private let collectionStack = NSStackView()
|
private let collectionStack = NSStackView()
|
||||||
private let addCollectionButton = NSButton()
|
private let addCollectionButton = NSButton()
|
||||||
private let stackChip = CollectionChipView(title: "Stack", color: .systemGreen)
|
private let stackChip = CollectionChipView(title: "Stack", color: .systemGreen)
|
||||||
private let itemsStack = NSStackView()
|
private let itemsStack = NSStackView()
|
||||||
private let scrollView = NSScrollView()
|
private let scrollView = HorizontalRailScrollView()
|
||||||
private let statusLabel = NSTextField(labelWithString: "")
|
private let statusLabel = NSTextField(labelWithString: "")
|
||||||
private let statusResultCountLabel = NSTextField(labelWithString: "")
|
private let statusResultCountLabel = NSTextField(labelWithString: "")
|
||||||
private let statusIndicator = NSView()
|
private let statusIndicator = NSView()
|
||||||
@@ -1279,6 +1279,14 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
scrollView.contentView.bounds
|
scrollView.contentView.bounds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var debugCardRailDocumentWidth: CGFloat {
|
||||||
|
scrollView.documentView?.frame.width ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func debugScrollCardRailVertically(deltaY: CGFloat) {
|
||||||
|
scrollView.scrollHorizontallyByVerticalDelta(deltaY)
|
||||||
|
}
|
||||||
|
|
||||||
var debugFirstCardMenuTitles: [String] {
|
var debugFirstCardMenuTitles: [String] {
|
||||||
cardViews.first?.debugMenuTitles ?? []
|
cardViews.first?.debugMenuTitles ?? []
|
||||||
}
|
}
|
||||||
@@ -1428,6 +1436,14 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
collectionScrollView.documentView?.frame.width ?? 0
|
collectionScrollView.documentView?.frame.width ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var debugCollectionRailVisibleRect: NSRect {
|
||||||
|
collectionScrollView.contentView.bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
func debugScrollCollectionRailVertically(deltaY: CGFloat) {
|
||||||
|
collectionScrollView.scrollHorizontallyByVerticalDelta(deltaY)
|
||||||
|
}
|
||||||
|
|
||||||
var debugEmptyStateText: (title: String, detail: String)? {
|
var debugEmptyStateText: (title: String, detail: String)? {
|
||||||
emptyStateText
|
emptyStateText
|
||||||
}
|
}
|
||||||
@@ -1673,6 +1689,45 @@ private enum ClipboardCardDragContext {
|
|||||||
static var itemID: UUID?
|
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 {
|
private final class CollectionChipView: NSView {
|
||||||
let titleText: String
|
let titleText: String
|
||||||
private let color: NSColor
|
private let color: NSColor
|
||||||
|
|||||||
@@ -419,6 +419,7 @@ final class ClipboardPanelViewTests: XCTestCase {
|
|||||||
|
|
||||||
func testCollectionRailUsesScrollableDocumentForCrowdedCustomCollections() {
|
func testCollectionRailUsesScrollableDocumentForCrowdedCustomCollections() {
|
||||||
let fixture = makePanelFixture()
|
let fixture = makePanelFixture()
|
||||||
|
fixture.window.setFrame(NSRect(x: 0, y: 0, width: 620, height: 520), display: true)
|
||||||
let names = [
|
let names = [
|
||||||
"Client Work",
|
"Client Work",
|
||||||
"Research Archive",
|
"Research Archive",
|
||||||
@@ -445,6 +446,17 @@ final class ClipboardPanelViewTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
XCTAssertTrue(fixture.view.debugCustomCollectionTitles.contains("Client Work"))
|
XCTAssertTrue(fixture.view.debugCustomCollectionTitles.contains("Client Work"))
|
||||||
XCTAssertTrue(fixture.view.debugCustomCollectionTitles.contains("Product References"))
|
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() {
|
func testSelectionScrollsCardRailToKeepSelectedCardVisible() {
|
||||||
@@ -479,6 +491,42 @@ final class ClipboardPanelViewTests: XCTestCase {
|
|||||||
XCTAssertLessThanOrEqual(fixture.view.debugCardRailVisibleRect.minX, 1)
|
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() {
|
func testFilteredEmptyStateNamesCurrentCollection() {
|
||||||
let fixture = makePanelFixture()
|
let fixture = makePanelFixture()
|
||||||
fixture.store.upsert(makeTextItem("Only text exists", store: fixture.store))
|
fixture.store.upsert(makeTextItem("Only text exists", store: fixture.store))
|
||||||
|
|||||||
Reference in New Issue
Block a user