WIP: add rail overflow edge fades

This commit is contained in:
Akshay Kolli
2026-06-30 09:06:49 -07:00
parent d07f213e80
commit eedccf8422
4 changed files with 111 additions and 2 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 - `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, vertical wheel/trackpad panning in horizontal rails, 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 and overflow edge fades 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

View File

@@ -38,7 +38,7 @@ 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. 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. 8. 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. 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. 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. 10. Press `Esc` once with a non-empty search field and confirm search clears.
11. Press `Esc` again and confirm the panel closes. 11. Press `Esc` again and confirm the panel closes.

View File

@@ -1283,6 +1283,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
scrollView.documentView?.frame.width ?? 0 scrollView.documentView?.frame.width ?? 0
} }
var debugCardRailOverflowFadeVisibility: [Bool] {
scrollView.overflowFadeVisibility
}
func debugScrollCardRailVertically(deltaY: CGFloat) { func debugScrollCardRailVertically(deltaY: CGFloat) {
scrollView.scrollHorizontallyByVerticalDelta(deltaY) scrollView.scrollHorizontallyByVerticalDelta(deltaY)
} }
@@ -1440,6 +1444,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
collectionScrollView.contentView.bounds collectionScrollView.contentView.bounds
} }
var debugCollectionRailOverflowFadeVisibility: [Bool] {
collectionScrollView.overflowFadeVisibility
}
func debugScrollCollectionRailVertically(deltaY: CGFloat) { func debugScrollCollectionRailVertically(deltaY: CGFloat) {
collectionScrollView.scrollHorizontallyByVerticalDelta(deltaY) collectionScrollView.scrollHorizontallyByVerticalDelta(deltaY)
} }
@@ -1690,6 +1698,20 @@ private enum ClipboardCardDragContext {
} }
private final class HorizontalRailScrollView: NSScrollView { private final class HorizontalRailScrollView: NSScrollView {
private let leadingFade = RailEdgeFadeView(edge: .leading)
private let trailingFade = RailEdgeFadeView(edge: .trailing)
private let fadeWidth: CGFloat = 26
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
configureOverflowFades()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
configureOverflowFades()
}
override func scrollWheel(with event: NSEvent) { override func scrollWheel(with event: NSEvent) {
let horizontalDelta = event.scrollingDeltaX let horizontalDelta = event.scrollingDeltaX
let verticalDelta = event.scrollingDeltaY let verticalDelta = event.scrollingDeltaY
@@ -1701,12 +1723,47 @@ private final class HorizontalRailScrollView: NSScrollView {
} }
super.scrollWheel(with: event) super.scrollWheel(with: event)
updateOverflowFades()
}
override func layout() {
super.layout()
updateOverflowFades()
}
override func reflectScrolledClipView(_ clipView: NSClipView) {
super.reflectScrolledClipView(clipView)
updateOverflowFades()
} }
func scrollHorizontallyByVerticalDelta(_ deltaY: CGFloat) { func scrollHorizontallyByVerticalDelta(_ deltaY: CGFloat) {
scrollHorizontally(by: -deltaY) scrollHorizontally(by: -deltaY)
} }
var overflowFadeVisibility: [Bool] {
updateOverflowFades()
return [!leadingFade.isHidden, !trailingFade.isHidden]
}
private func configureOverflowFades() {
leadingFade.translatesAutoresizingMaskIntoConstraints = false
trailingFade.translatesAutoresizingMaskIntoConstraints = false
leadingFade.isHidden = true
trailingFade.isHidden = true
addSubview(leadingFade)
addSubview(trailingFade)
NSLayoutConstraint.activate([
leadingFade.leadingAnchor.constraint(equalTo: leadingAnchor),
leadingFade.topAnchor.constraint(equalTo: topAnchor),
leadingFade.bottomAnchor.constraint(equalTo: bottomAnchor),
leadingFade.widthAnchor.constraint(equalToConstant: fadeWidth),
trailingFade.trailingAnchor.constraint(equalTo: trailingAnchor),
trailingFade.topAnchor.constraint(equalTo: topAnchor),
trailingFade.bottomAnchor.constraint(equalTo: bottomAnchor),
trailingFade.widthAnchor.constraint(equalToConstant: fadeWidth)
])
}
private var canScrollHorizontally: Bool { private var canScrollHorizontally: Bool {
maxHorizontalOffset > 0 maxHorizontalOffset > 0
} }
@@ -1726,6 +1783,50 @@ private final class HorizontalRailScrollView: NSScrollView {
contentView.scroll(to: NSPoint(x: targetX, y: origin.y)) contentView.scroll(to: NSPoint(x: targetX, y: origin.y))
reflectScrolledClipView(contentView) reflectScrolledClipView(contentView)
} }
private func updateOverflowFades() {
let maxOffset = maxHorizontalOffset
guard maxOffset > 0 else {
leadingFade.isHidden = true
trailingFade.isHidden = true
return
}
let currentX = contentView.bounds.minX
leadingFade.isHidden = currentX <= 0.5
trailingFade.isHidden = currentX >= maxOffset - 0.5
}
}
private final class RailEdgeFadeView: NSView {
enum Edge {
case leading
case trailing
}
private let edge: Edge
init(edge: Edge) {
self.edge = edge
super.init(frame: .zero)
wantsLayer = true
setAccessibilityElement(false)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func hitTest(_ point: NSPoint) -> NSView? {
nil
}
override func draw(_ dirtyRect: NSRect) {
let color = NSColor.windowBackgroundColor.withAlphaComponent(0.88)
let clear = color.withAlphaComponent(0)
let gradient = NSGradient(colors: edge == .leading ? [color, clear] : [clear, color])
gradient?.draw(in: bounds, angle: 0)
}
} }
private final class CollectionChipView: NSView { private final class CollectionChipView: NSView {

View File

@@ -62,6 +62,7 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertGreaterThanOrEqual(fixture.view.debugDocumentViewFrame.width, 292) XCTAssertGreaterThanOrEqual(fixture.view.debugDocumentViewFrame.width, 292)
XCTAssertGreaterThanOrEqual(fixture.view.debugDocumentViewFrame.height, 244) XCTAssertGreaterThanOrEqual(fixture.view.debugDocumentViewFrame.height, 244)
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["text-preview"]) XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["text-preview"])
XCTAssertEqual(fixture.view.debugCardRailOverflowFadeVisibility, [false, false])
} }
func testCompactCardsFitTwoItemsOnNarrowDockShelf() { func testCompactCardsFitTwoItemsOnNarrowDockShelf() {
@@ -448,15 +449,18 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertTrue(fixture.view.debugCustomCollectionTitles.contains("Product References")) XCTAssertTrue(fixture.view.debugCustomCollectionTitles.contains("Product References"))
XCTAssertEqual(fixture.view.debugCollectionRailVisibleRect.minX, 0, accuracy: 0.5) XCTAssertEqual(fixture.view.debugCollectionRailVisibleRect.minX, 0, accuracy: 0.5)
XCTAssertEqual(fixture.view.debugCollectionRailOverflowFadeVisibility, [false, true])
fixture.view.debugScrollCollectionRailVertically(deltaY: -220) fixture.view.debugScrollCollectionRailVertically(deltaY: -220)
fixture.window.contentView?.layoutSubtreeIfNeeded() fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertGreaterThan(fixture.view.debugCollectionRailVisibleRect.minX, 0) XCTAssertGreaterThan(fixture.view.debugCollectionRailVisibleRect.minX, 0)
XCTAssertEqual(fixture.view.debugCollectionRailOverflowFadeVisibility, [true, true])
fixture.view.debugScrollCollectionRailVertically(deltaY: 10_000) fixture.view.debugScrollCollectionRailVertically(deltaY: 10_000)
fixture.window.contentView?.layoutSubtreeIfNeeded() fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCollectionRailVisibleRect.minX, 0, accuracy: 0.5) XCTAssertEqual(fixture.view.debugCollectionRailVisibleRect.minX, 0, accuracy: 0.5)
XCTAssertEqual(fixture.view.debugCollectionRailOverflowFadeVisibility, [false, true])
} }
func testSelectionScrollsCardRailToKeepSelectedCardVisible() { func testSelectionScrollsCardRailToKeepSelectedCardVisible() {
@@ -509,22 +513,26 @@ final class ClipboardPanelViewTests: XCTestCase {
fixture.view.debugCardRailVisibleRect.width + 1 fixture.view.debugCardRailVisibleRect.width + 1
) )
XCTAssertEqual(fixture.view.debugCardRailVisibleRect.minX, 0, accuracy: 0.5) XCTAssertEqual(fixture.view.debugCardRailVisibleRect.minX, 0, accuracy: 0.5)
XCTAssertEqual(fixture.view.debugCardRailOverflowFadeVisibility, [false, true])
fixture.view.debugScrollCardRailVertically(deltaY: -240) fixture.view.debugScrollCardRailVertically(deltaY: -240)
fixture.window.contentView?.layoutSubtreeIfNeeded() fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertGreaterThan(fixture.view.debugCardRailVisibleRect.minX, 0) XCTAssertGreaterThan(fixture.view.debugCardRailVisibleRect.minX, 0)
XCTAssertEqual(fixture.view.debugCardRailOverflowFadeVisibility, [true, true])
fixture.view.debugScrollCardRailVertically(deltaY: -10_000) fixture.view.debugScrollCardRailVertically(deltaY: -10_000)
fixture.window.contentView?.layoutSubtreeIfNeeded() fixture.window.contentView?.layoutSubtreeIfNeeded()
let maxOffset = fixture.view.debugCardRailDocumentWidth - fixture.view.debugCardRailVisibleRect.width let maxOffset = fixture.view.debugCardRailDocumentWidth - fixture.view.debugCardRailVisibleRect.width
XCTAssertEqual(fixture.view.debugCardRailVisibleRect.minX, maxOffset, accuracy: 1) XCTAssertEqual(fixture.view.debugCardRailVisibleRect.minX, maxOffset, accuracy: 1)
XCTAssertEqual(fixture.view.debugCardRailOverflowFadeVisibility, [true, false])
fixture.view.debugScrollCardRailVertically(deltaY: 10_000) fixture.view.debugScrollCardRailVertically(deltaY: 10_000)
fixture.window.contentView?.layoutSubtreeIfNeeded() fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardRailVisibleRect.minX, 0, accuracy: 1) XCTAssertEqual(fixture.view.debugCardRailVisibleRect.minX, 0, accuracy: 1)
XCTAssertEqual(fixture.view.debugCardRailOverflowFadeVisibility, [false, true])
} }
func testFilteredEmptyStateNamesCurrentCollection() { func testFilteredEmptyStateNamesCurrentCollection() {