diff --git a/README.md b/README.md index f96768f..fa509b2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index ca65fbb..d085604 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -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. diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index aea5851..39efef7 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -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 { diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index d94cc19..6f09a07 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -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() {