diff --git a/Copy/Shelf/ShelfRootView.swift b/Copy/Shelf/ShelfRootView.swift index 4303a3d..208cee3 100644 --- a/Copy/Shelf/ShelfRootView.swift +++ b/Copy/Shelf/ShelfRootView.swift @@ -644,6 +644,7 @@ private struct ShelfItemsRow: View { // Gives the drag-select the row's scroll view, which it scrolls while // the pointer is held at an edge. .background(CardRowScrollViewFinder { viewModel.cardSweep.scrollView = $0 }) + .background(ShelfWheelScrollBridge()) .coordinateSpace(name: CardSweep.contentSpace) .onGeometryChange(for: CGPoint.self) { $0.frame(in: .named(CardSweep.viewportSpace)).origin diff --git a/Copy/Shelf/ShelfWheelScroll.swift b/Copy/Shelf/ShelfWheelScroll.swift new file mode 100644 index 0000000..08551bc --- /dev/null +++ b/Copy/Shelf/ShelfWheelScroll.swift @@ -0,0 +1,50 @@ +import AppKit +import SwiftUI + +enum ShelfWheelScroll { + static func horizontalOffset(horizontal: CGFloat, vertical: CGFloat) -> CGFloat? { + guard horizontal == 0, vertical != 0 else { return nil } + return -vertical + } +} + +struct ShelfWheelScrollBridge: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { ShelfWheelScrollView() } + + func updateNSView(_ nsView: NSView, context: Context) {} +} + +private final class ShelfWheelScrollView: NSView { + private var monitor: Any? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + installMonitor() + } + + deinit { + if let monitor { NSEvent.removeMonitor(monitor) } + } + + private func installMonitor() { + guard monitor == nil else { return } + monitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in + guard let self, let scroll = self.enclosingScrollView, + event.window === self.window, + let offset = ShelfWheelScroll.horizontalOffset( + horizontal: event.scrollingDeltaX, vertical: event.scrollingDeltaY), + self.contains(event: event, in: scroll) else { return event } + + let maximum = max(0, (scroll.documentView?.frame.width ?? 0) - scroll.contentSize.width) + let x = min(max(0, scroll.contentView.bounds.origin.x + offset), maximum) + scroll.contentView.scroll(to: NSPoint(x: x, y: scroll.contentView.bounds.origin.y)) + scroll.reflectScrolledClipView(scroll.contentView) + return nil + } + } + + private func contains(event: NSEvent, in scroll: NSScrollView) -> Bool { + let point = scroll.contentView.convert(event.locationInWindow, from: nil) + return scroll.contentView.bounds.contains(point) + } +} diff --git a/CopyTests/ShelfWheelScrollTests.swift b/CopyTests/ShelfWheelScrollTests.swift new file mode 100644 index 0000000..9a852aa --- /dev/null +++ b/CopyTests/ShelfWheelScrollTests.swift @@ -0,0 +1,20 @@ +import AppKit +import XCTest + +final class ShelfWheelScrollTests: XCTestCase { + func testDownwardWheelMovementBecomesRightwardShelfMovement() { + XCTAssertEqual(ShelfWheelScroll.horizontalOffset(horizontal: 0, vertical: -3), 3) + } + + func testUpwardWheelMovementBecomesLeftwardShelfMovement() { + XCTAssertEqual(ShelfWheelScroll.horizontalOffset(horizontal: 0, vertical: 3), -3) + } + + func testHorizontalTrackpadMovementStaysWithAppKit() { + XCTAssertNil(ShelfWheelScroll.horizontalOffset(horizontal: 2, vertical: 0)) + } + + func testCombinedAxisTrackpadMovementStaysWithAppKit() { + XCTAssertNil(ShelfWheelScroll.horizontalOffset(horizontal: 2, vertical: -3)) + } +} diff --git a/project.yml b/project.yml index a795756..b783512 100644 --- a/project.yml +++ b/project.yml @@ -71,6 +71,7 @@ targets: sources: - CopyTests - Copy/PasteStack/PasteStackModel.swift + - Copy/Shelf/ShelfWheelScroll.swift - Copy/PasteStack/PasteStackBatchConfirmation.swift dependencies: - package: CopyCore