diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index 64686a9..fa6396a 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -566,9 +566,30 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } private func updateSelection() { + var selectedCard: ClipboardItemCardView? for (index, card) in cardViews.enumerated() { - card.setSelected(index == viewModel.selectedIndex) + let selected = index == viewModel.selectedIndex + card.setSelected(selected) + if selected { + selectedCard = card + } } + + if let selectedCard { + scrollCardIntoView(selectedCard) + } + } + + private func scrollCardIntoView(_ card: NSView) { + guard scrollView.documentView === itemsStack else { return } + guard card.window != nil else { return } + scrollView.layoutSubtreeIfNeeded() + itemsStack.layoutSubtreeIfNeeded() + + let frame = card.convert(card.bounds, to: itemsStack) + let paddedFrame = frame.insetBy(dx: -Metrics.cardSpacing, dy: 0) + itemsStack.scrollToVisible(paddedFrame) + scrollView.reflectScrolledClipView(scrollView.contentView) } private func updateStatus(_ message: String) { @@ -846,6 +867,18 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { cardViews.map(\.debugHeaderBadgeSymbol) } + var debugSelectedCardFrameInDocument: NSRect { + guard viewModel.selectedIndex >= 0, viewModel.selectedIndex < cardViews.count else { + return .zero + } + let card = cardViews[viewModel.selectedIndex] + return card.convert(card.bounds, to: itemsStack) + } + + var debugCardRailVisibleRect: NSRect { + scrollView.contentView.bounds + } + var debugFirstCardMenuTitles: [String] { cardViews.first?.debugMenuTitles ?? [] } diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index 4ce6e2a..1b4edc6 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -243,6 +243,38 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertTrue(fixture.view.debugCustomCollectionTitles.contains("Product References")) } + func testSelectionScrollsCardRailToKeepSelectedCardVisible() { + let fixture = makePanelFixture() + fixture.window.setFrame(NSRect(x: 0, y: 0, width: 620, height: 520), display: true) + + for index in 0..<8 { + fixture.store.upsert(makeTextItem("Scrollable clipboard item \(index)", store: fixture.store)) + drainMainQueue() + } + fixture.window.contentView?.layoutSubtreeIfNeeded() + + fixture.viewModel.selectFirstItem() + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + XCTAssertLessThanOrEqual(fixture.view.debugCardRailVisibleRect.minX, 1) + + fixture.viewModel.selectItem(at: fixture.viewModel.visibleItems.count - 1) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + let visibleRect = fixture.view.debugCardRailVisibleRect + let selectedFrame = fixture.view.debugSelectedCardFrameInDocument + XCTAssertGreaterThan(visibleRect.minX, 0) + XCTAssertLessThanOrEqual(selectedFrame.minX, visibleRect.maxX) + XCTAssertGreaterThanOrEqual(visibleRect.maxX + 1, selectedFrame.maxX) + + fixture.viewModel.selectItem(at: 0) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertLessThanOrEqual(fixture.view.debugCardRailVisibleRect.minX, 1) + } + func testFilteredEmptyStateNamesCurrentCollection() { let fixture = makePanelFixture() fixture.store.upsert(makeTextItem("Only text exists", store: fixture.store))