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
- `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, 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
- 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,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.
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. 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.
10. Press `Esc` once with a non-empty search field and confirm search clears.
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
}
var debugCardRailOverflowFadeVisibility: [Bool] {
scrollView.overflowFadeVisibility
}
func debugScrollCardRailVertically(deltaY: CGFloat) {
scrollView.scrollHorizontallyByVerticalDelta(deltaY)
}
@@ -1440,6 +1444,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
collectionScrollView.contentView.bounds
}
var debugCollectionRailOverflowFadeVisibility: [Bool] {
collectionScrollView.overflowFadeVisibility
}
func debugScrollCollectionRailVertically(deltaY: CGFloat) {
collectionScrollView.scrollHorizontallyByVerticalDelta(deltaY)
}
@@ -1690,6 +1698,20 @@ private enum ClipboardCardDragContext {
}
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) {
let horizontalDelta = event.scrollingDeltaX
let verticalDelta = event.scrollingDeltaY
@@ -1701,12 +1723,47 @@ private final class HorizontalRailScrollView: NSScrollView {
}
super.scrollWheel(with: event)
updateOverflowFades()
}
override func layout() {
super.layout()
updateOverflowFades()
}
override func reflectScrolledClipView(_ clipView: NSClipView) {
super.reflectScrolledClipView(clipView)
updateOverflowFades()
}
func scrollHorizontallyByVerticalDelta(_ deltaY: CGFloat) {
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 {
maxHorizontalOffset > 0
}
@@ -1726,6 +1783,50 @@ private final class HorizontalRailScrollView: NSScrollView {
contentView.scroll(to: NSPoint(x: targetX, y: origin.y))
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 {

View File

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