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
|
- `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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user