WIP: add rail overflow edge fades
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
|
||||
- `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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user