Animation

This commit is contained in:
Akshay Kolli
2026-07-08 03:29:12 -04:00
parent d86d392c3a
commit 23cd8b64a9
3 changed files with 101 additions and 38 deletions

View File

@@ -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

View File

@@ -2094,14 +2094,17 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
} }
card.onHover = { [weak self, weak card] selected, mouseLocation in card.onHover = { [weak self, weak card] selected, mouseLocation in
guard let self else { return false } 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 return false
} }
self.viewModel.selectItem(at: selected, mode: .hover) self.viewModel.selectItem(at: hoverIndex, mode: .hover)
if !self.isSearchFieldEditing, let card { if !self.isSearchFieldEditing {
self.window?.makeFirstResponder(card) 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 card.onHoverExit = { [weak self] index in
self?.handleCardHoverExit(at: index) self?.handleCardHoverExit(at: index)
@@ -3821,6 +3824,14 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
cardView(at: index)?.debugSetHovered(true) 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) { func debugUnhoverCard(at index: Int) {
guard index >= 0, index < cardItemCount else { return } guard index >= 0, index < cardItemCount else { return }
renderCardIfNeeded(at: index) renderCardIfNeeded(at: index)
@@ -4367,22 +4378,61 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
clearCardHoverStates() clearCardHoverStates()
} }
private func claimMouseHoverSelection(at index: Int, mouseLocation: NSPoint?) -> Bool { private func claimMouseHoverSelection(at candidateIndex: Int, mouseLocation: NSPoint?) -> Int? {
if hoverSelectionRequiresFreshMouseMovement { if hoverSelectionRequiresFreshMouseMovement {
guard let location = mouseLocation else { return false } guard let location = mouseLocation else { return nil }
if let barrier = hoverSelectionKeyboardBarrierLocation, if let barrier = hoverSelectionKeyboardBarrierLocation,
abs(location.x - barrier.x) < 0.5, abs(location.x - barrier.x) < 0.5,
abs(location.y - barrier.y) < 0.5 { abs(location.y - barrier.y) < 0.5 {
return false return nil
} }
hoverSelectionRequiresFreshMouseMovement = false hoverSelectionRequiresFreshMouseMovement = false
hoverSelectionKeyboardBarrierLocation = nil hoverSelectionKeyboardBarrierLocation = nil
} }
guard let index = visualHoverIndex(candidateIndex: candidateIndex, mouseLocation: mouseLocation) else {
return nil
}
cardSelectionInputSource = .mouse cardSelectionInputSource = .mouse
selectionScrollSuppressionCount = 3 selectionScrollSuppressionCount = 3
hoveredCardIndex = index hoveredCardIndex = index
clearCardHoverStates(except: 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) { 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 #if DEBUG
var debugPresentationFrame: NSRect {
visualFrame
}
var debugCardTopOffset: CGFloat { var debugCardTopOffset: CGFloat {
topConstraint?.constant ?? inactiveTopOffset topConstraint?.constant ?? inactiveTopOffset
} }
@@ -5707,9 +5769,6 @@ private final class ClipboardItemCardSlotView: NSView {
CGFloat(layer?.zPosition ?? 0) CGFloat(layer?.zPosition ?? 0)
} }
var debugPresentationFrame: NSRect {
layer?.presentation()?.frame ?? frame
}
#endif #endif
} }
@@ -6612,8 +6671,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
} }
} }
func debugSetHovered(_ hovered: Bool) { func debugSetHovered(_ hovered: Bool, mouseLocation: NSPoint? = nil) {
setHoverState(hovered, notifySelection: hovered) setHoverState(hovered, notifySelection: hovered, mouseLocation: mouseLocation)
} }
var debugBorderWidth: CGFloat { var debugBorderWidth: CGFloat {

View File

@@ -518,6 +518,34 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(fixture.view.debugCardExpandedDetailFramesInPanel[1].height, 0, accuracy: 0.5) 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() { func testToolbarControlsExposeVoiceOverActionHints() {
let fixture = makePanelFixture() let fixture = makePanelFixture()