From 23cd8b64a997d4863c055ca09066fb4aa22451d2 Mon Sep 17 00:00:00 2001 From: Akshay Kolli Date: Wed, 8 Jul 2026 03:29:12 -0400 Subject: [PATCH] Animation --- .github/workflows/ci.yml | 24 ----- .../clipbored/views/ClipboardPanelView.swift | 87 ++++++++++++++++--- .../ClipboardPanelViewTests.swift | 28 ++++++ 3 files changed, 101 insertions(+), 38 deletions(-) delete mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 44c6242..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - test: - name: Test And Build - runs-on: macos-14 - - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Show Swift version - run: swift --version - - - name: Run tests - run: swift test -q - - - name: Build app bundle - run: ./scripts/build-macos-app.sh diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index 32c2df1..6033bb6 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -2094,14 +2094,17 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } card.onHover = { [weak self, weak card] selected, mouseLocation in guard let self else { return false } - guard self.claimMouseHoverSelection(at: selected, mouseLocation: mouseLocation) else { + guard let hoverIndex = self.claimMouseHoverSelection(at: selected, mouseLocation: mouseLocation) else { return false } - self.viewModel.selectItem(at: selected, mode: .hover) - if !self.isSearchFieldEditing, let card { - self.window?.makeFirstResponder(card) + self.viewModel.selectItem(at: hoverIndex, mode: .hover) + if !self.isSearchFieldEditing { + let focusedCard = self.cardView(at: hoverIndex) ?? (hoverIndex == selected ? card : nil) + if let focusedCard { + self.window?.makeFirstResponder(focusedCard) + } } - return true + return hoverIndex == selected } card.onHoverExit = { [weak self] index in self?.handleCardHoverExit(at: index) @@ -3821,6 +3824,14 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { cardView(at: index)?.debugSetHovered(true) } + func debugHoverCard(at index: Int, mouseLocationInPanel location: NSPoint) { + guard index >= 0, index < cardItemCount else { return } + renderCardIfNeeded(at: index) + hoverSelectionRequiresFreshMouseMovement = false + hoverSelectionKeyboardBarrierLocation = nil + cardView(at: index)?.debugSetHovered(true, mouseLocation: convert(location, to: nil)) + } + func debugUnhoverCard(at index: Int) { guard index >= 0, index < cardItemCount else { return } renderCardIfNeeded(at: index) @@ -4367,22 +4378,61 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { clearCardHoverStates() } - private func claimMouseHoverSelection(at index: Int, mouseLocation: NSPoint?) -> Bool { + private func claimMouseHoverSelection(at candidateIndex: Int, mouseLocation: NSPoint?) -> Int? { if hoverSelectionRequiresFreshMouseMovement { - guard let location = mouseLocation else { return false } + guard let location = mouseLocation else { return nil } if let barrier = hoverSelectionKeyboardBarrierLocation, abs(location.x - barrier.x) < 0.5, abs(location.y - barrier.y) < 0.5 { - return false + return nil } hoverSelectionRequiresFreshMouseMovement = false hoverSelectionKeyboardBarrierLocation = nil } + + guard let index = visualHoverIndex(candidateIndex: candidateIndex, mouseLocation: mouseLocation) else { + return nil + } cardSelectionInputSource = .mouse selectionScrollSuppressionCount = 3 hoveredCardIndex = index clearCardHoverStates(except: index) - return true + if index != candidateIndex { + cardView(at: index)?.setHoverState(true, notifySelection: false, mouseLocation: mouseLocation) + } + return index + } + + private func visualHoverIndex(candidateIndex: Int, mouseLocation: NSPoint?) -> Int? { + guard currentPanelLayout == .vertical, + let mouseLocation else { + return candidateIndex + } + return visualCardIndex(atWindowLocation: mouseLocation) + } + + private func visualCardIndex(atWindowLocation location: NSPoint) -> Int? { + let point = convert(location, from: nil) + let hitSlop: CGFloat = 1 + let hits = cardSlots.compactMap { index, slot -> (index: Int, zPosition: CGFloat)? in + guard slot.card != nil, + let frame = visualSlotFrameInPanel(slot), + frame.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point) else { + return nil + } + return (index, slot.visualZPosition) + } + return hits.sorted { lhs, rhs in + if lhs.zPosition == rhs.zPosition { + return lhs.index < rhs.index + } + return lhs.zPosition > rhs.zPosition + }.first?.index + } + + private func visualSlotFrameInPanel(_ slot: ClipboardItemCardSlotView) -> NSRect? { + guard let superview = slot.superview else { return nil } + return superview.convert(slot.visualFrame, to: self) } private func clearCardHoverStates(except retainedIndex: Int? = nil) { @@ -5698,7 +5748,19 @@ private final class ClipboardItemCardSlotView: NSView { } } + fileprivate var visualFrame: NSRect { + layer?.presentation()?.frame ?? frame + } + + fileprivate var visualZPosition: CGFloat { + CGFloat(layer?.presentation()?.zPosition ?? layer?.zPosition ?? 0) + } + #if DEBUG + var debugPresentationFrame: NSRect { + visualFrame + } + var debugCardTopOffset: CGFloat { topConstraint?.constant ?? inactiveTopOffset } @@ -5707,9 +5769,6 @@ private final class ClipboardItemCardSlotView: NSView { CGFloat(layer?.zPosition ?? 0) } - var debugPresentationFrame: NSRect { - layer?.presentation()?.frame ?? frame - } #endif } @@ -6612,8 +6671,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } } - func debugSetHovered(_ hovered: Bool) { - setHoverState(hovered, notifySelection: hovered) + func debugSetHovered(_ hovered: Bool, mouseLocation: NSPoint? = nil) { + setHoverState(hovered, notifySelection: hovered, mouseLocation: mouseLocation) } var debugBorderWidth: CGFloat { diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index 5c2f3b7..36c1ff4 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -518,6 +518,34 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugCardExpandedDetailFramesInPanel[1].height, 0, accuracy: 0.5) } + func testHoverDuringCollapseUsesVisualCardUnderMouseInsteadOfCollapsedTargetSlot() { + let fixture = makePanelFixture() + fixture.window.setFrame(NSRect(x: 0, y: 0, width: 336, height: 760), display: true) + for index in 0..<8 { + fixture.store.upsert(makeTextItem("Visual hover row \(index)", store: fixture.store)) + } + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + fixture.view.debugHoverCard(at: 1) + RunLoop.main.run(until: Date().addingTimeInterval(fixture.view.debugCardExpansionAnimationDuration + 0.04)) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + XCTAssertEqual(fixture.view.debugHoveredCardIndexes, [1]) + + fixture.view.debugUnhoverCard(at: 1) + RunLoop.main.run(until: Date().addingTimeInterval(0.06)) + let visualRowFrame = fixture.view.debugCardSlotPresentationFramesInPanel[2] + let visualRowPoint = NSPoint(x: visualRowFrame.midX, y: visualRowFrame.midY) + + fixture.view.debugHoverCard(at: 6, mouseLocationInPanel: visualRowPoint) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual(fixture.view.debugHoveredCardIndexes, [2]) + XCTAssertEqual(fixture.viewModel.selectedIndex, 2) + } + func testToolbarControlsExposeVoiceOverActionHints() { let fixture = makePanelFixture()