From 73c2d64a2ef5af80d45f5254833d89fb301e27d7 Mon Sep 17 00:00:00 2001 From: mobrava <82764703+mobrava@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:59:34 +0900 Subject: [PATCH] Scroll the sideways card rows with a plain wheel Scrolling vertically over the card row, a pinboard row, or the tab strip now moves it sideways. Those rows only run horizontally, so a wheel that only reports vertical movement did nothing and Shift had to be held. A scroll that is already sideways is left alone, so Shift + wheel, a thumb wheel, and trackpad sideways gestures are unchanged, and a view that also scrolls vertically keeps the normal meaning of the wheel. The event's vertical movement is moved onto the horizontal axis and handed back to AppKit, which keeps its line height, acceleration, and edge clamping instead of reimplementing them. The choice comes from the direction of the scroll rather than the kind of device, because a mouse reports the same fine deltas a trackpad does once its vendor software enables smooth scrolling. Within one gesture the first decision is held until momentum finishes, so a swipe that drifts off axis cannot flip between sideways and vertical frame by frame. Covers the scrolling request in #26. The Simplified Chinese settings localization and the hover based Quick Look target in that issue are untouched, so it stays open. --- .github/workflows/build.yml | 2 +- Clipbara/Panel/PanelController.swift | 60 +++++++ .../Utilities/WheelScrollTranslation.swift | 128 ++++++++++++++ Tests/WheelScrollTranslationTests.swift | 157 ++++++++++++++++++ docs/testing/issue-26-wheel-scroll.md | 43 +++++ docs/testing/issue-8.md | 4 +- project.yml | 1 + ...st-tab-shortcuts.sh => test-and-launch.sh} | 12 +- 8 files changed, 398 insertions(+), 9 deletions(-) create mode 100644 Clipbara/Utilities/WheelScrollTranslation.swift create mode 100644 Tests/WheelScrollTranslationTests.swift create mode 100644 docs/testing/issue-26-wheel-scroll.md rename scripts/{test-tab-shortcuts.sh => test-and-launch.sh} (79%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f651484..287d613 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,7 +31,7 @@ jobs: CODE_SIGNING_ALLOWED=NO \ build - - name: Test panel tab shortcuts + - name: Run unit tests run: | xcodebuild \ -project Clipbara.xcodeproj \ diff --git a/Clipbara/Panel/PanelController.swift b/Clipbara/Panel/PanelController.swift index 1ee6f02..f1fd72a 100644 --- a/Clipbara/Panel/PanelController.swift +++ b/Clipbara/Panel/PanelController.swift @@ -17,6 +17,8 @@ final class PanelController { private(set) var isVisible: Bool = false private var clickMonitor: Any? private var mouseMonitor: Any? + private var scrollMonitor: Any? + private var wheelTranslator = WheelScrollTranslation.Translator() private var keyMonitor: Any? var onPanelWillHide: (() -> Void)? weak var appState: AppState? @@ -152,6 +154,7 @@ final class PanelController { appState.markPanelPresented() installClickMonitor() installMouseMonitor() + installScrollMonitor() installKeyMonitor() } @@ -198,6 +201,7 @@ final class PanelController { removeClickMonitor() removeMouseMonitor() + removeScrollMonitor() removeKeyMonitor() panel.hasShadow = false @@ -273,6 +277,62 @@ final class PanelController { } } + // MARK: - Scroll Monitor (mouse wheel over the sideways card rows) + + private func installScrollMonitor() { + scrollMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in + let handled: Bool = MainActor.assumeIsolated { [weak self] in + self?.translateWheelToHorizontalScroll(event) ?? false + } + return handled ? nil : event + } + } + + private func removeScrollMonitor() { + if let monitor = scrollMonitor { + NSEvent.removeMonitor(monitor) + scrollMonitor = nil + } + } + + /// Re-sends a mouse wheel as horizontal movement over a sideways card row. + private func translateWheelToHorizontalScroll(_ event: NSEvent) -> Bool { + guard isVisible, + let window = event.window, + window === panel || window === quickLookPanel, + let scrollView = horizontalScrollView(under: event, in: window) else { return false } + + let clip = scrollView.contentView.bounds.size + let document = scrollView.documentView?.frame.size ?? .zero + let input = WheelScrollTranslation.Input( + deltaX: event.scrollingDeltaX, + deltaY: event.scrollingDeltaY, + phase: WheelScrollTranslation.phase(of: event), + canScrollHorizontally: document.width - clip.width > 0.5, + canScrollVertically: document.height - clip.height > 0.5 + ) + guard wheelTranslator.shouldTranslate(input) else { return false } + + guard let horizontalEvent = WheelScrollTranslation.horizontalCopy(of: event) else { return false } + scrollView.scrollWheel(with: horizontalEvent) + return true + } + + private func horizontalScrollView(under event: NSEvent, in window: NSWindow) -> NSScrollView? { + guard let contentView = window.contentView else { return nil } + let point = contentView.convert(event.locationInWindow, from: nil) + guard let hit = contentView.hitTest(point) else { return nil } + + var view: NSView? = hit + while let current = view { + if let scrollView = current as? NSScrollView { + return scrollView + } + view = current.superview + } + return nil + } + private func releaseTextFocusIfNeeded(for event: NSEvent) { guard isVisible, let panel else { return } diff --git a/Clipbara/Utilities/WheelScrollTranslation.swift b/Clipbara/Utilities/WheelScrollTranslation.swift new file mode 100644 index 0000000..5a1c539 --- /dev/null +++ b/Clipbara/Utilities/WheelScrollTranslation.swift @@ -0,0 +1,128 @@ +import AppKit + +/// Turns vertical scrolling into horizontal scrolling over rows that only run +/// sideways, such as the card row and the tab strip. +/// +/// A plain mouse wheel only reports vertical movement, so without this those +/// rows ignore it and the user has to hold Shift. The decision is made from the +/// direction of the scroll rather than the kind of device, because a mouse can +/// report the same high resolution deltas a trackpad does once its vendor +/// software enables smooth scrolling. +enum WheelScrollTranslation { + /// Where a scroll sits in a continuous gesture. A wheel notch reports + /// `none`, while a trackpad or touch surface reports a began/changed/ended + /// run followed by momentum. + enum Phase { + case none + case began + case changed + case ended + case momentum + case momentumEnded + } + + struct Input { + let deltaX: Double + let deltaY: Double + let phase: Phase + let canScrollHorizontally: Bool + let canScrollVertically: Bool + } + + /// Holds the decision for the length of one gesture. + /// + /// A swipe is never perfectly straight. Deciding per event would let a + /// mostly vertical swipe flip to horizontal and back as the dominant axis + /// changes, so the choice made when the gesture starts is kept until it + /// finishes, momentum included. + struct Translator { + private var latched: Bool? + + init() {} + + mutating func shouldTranslate(_ input: Input) -> Bool { + // Only rows that run sideways and nowhere else. A view that also + // scrolls vertically, such as a zoomed Quick Look image, keeps the + // normal meaning of the scroll. + guard input.canScrollHorizontally, !input.canScrollVertically else { + latched = nil + return false + } + + switch input.phase { + case .none: + // A wheel notch stands on its own. + return isVertical(input) + + case .began: + let decision = isVertical(input) + latched = decision + return decision + + case .changed, .momentum: + if let latched { return latched } + let decision = isVertical(input) + latched = decision + return decision + + case .ended: + return latched ?? isVertical(input) + + case .momentumEnded: + let decision = latched ?? isVertical(input) + latched = nil + return decision + } + } + } + + /// Vertical movement that is not already a sideways scroll. Shift + wheel + /// and a sideways swipe both arrive with horizontal movement, so they are + /// left to AppKit and keep behaving as they do today. + static func isVertical(_ input: Input) -> Bool { + abs(input.deltaY) > abs(input.deltaX) && input.deltaY != 0 + } + + /// Copies a scroll event with its vertical movement moved onto the + /// horizontal axis. Handing the copy back to AppKit keeps its own line + /// height, acceleration, and edge clamping instead of reimplementing them. + /// Axis 1 is vertical and axis 2 is horizontal in `CGEvent` scroll fields. + static func horizontalCopy(of event: NSEvent) -> NSEvent? { + guard let copy = event.cgEvent?.copy() else { return nil } + + let lines = copy.getDoubleValueField(.scrollWheelEventDeltaAxis1) + let points = copy.getDoubleValueField(.scrollWheelEventPointDeltaAxis1) + let fixed = copy.getDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1) + + copy.setDoubleValueField(.scrollWheelEventDeltaAxis2, value: lines) + copy.setDoubleValueField(.scrollWheelEventPointDeltaAxis2, value: points) + copy.setDoubleValueField(.scrollWheelEventFixedPtDeltaAxis2, value: fixed) + copy.setDoubleValueField(.scrollWheelEventDeltaAxis1, value: 0) + copy.setDoubleValueField(.scrollWheelEventPointDeltaAxis1, value: 0) + copy.setDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1, value: 0) + + return NSEvent(cgEvent: copy) + } + + static func phase(of event: NSEvent) -> Phase { + let momentum = event.momentumPhase + if momentum.contains(.began) || momentum.contains(.changed) { + return .momentum + } + if momentum.contains(.ended) || momentum.contains(.cancelled) { + return .momentumEnded + } + + let phase = event.phase + if phase.contains(.began) || phase.contains(.mayBegin) { + return .began + } + if phase.contains(.changed) || phase.contains(.stationary) { + return .changed + } + if phase.contains(.ended) || phase.contains(.cancelled) { + return .ended + } + return .none + } +} diff --git a/Tests/WheelScrollTranslationTests.swift b/Tests/WheelScrollTranslationTests.swift new file mode 100644 index 0000000..d12d295 --- /dev/null +++ b/Tests/WheelScrollTranslationTests.swift @@ -0,0 +1,157 @@ +import AppKit +import XCTest + +final class WheelScrollTranslationTests: XCTestCase { + private func input( + dx: Double = 0, + dy: Double = -1, + phase: WheelScrollTranslation.Phase = .none, + horizontal: Bool = true, + vertical: Bool = false + ) -> WheelScrollTranslation.Input { + WheelScrollTranslation.Input( + deltaX: dx, + deltaY: dy, + phase: phase, + canScrollHorizontally: horizontal, + canScrollVertically: vertical + ) + } + + // MARK: - Which scrolls move a sideways row + + func testAWheelNotchMovesASidewaysRow() { + var translator = WheelScrollTranslation.Translator() + XCTAssertTrue(translator.shouldTranslate(input(dy: -1))) + XCTAssertTrue(translator.shouldTranslate(input(dy: 1))) + XCTAssertTrue(translator.shouldTranslate(input(dy: -3))) + } + + func testASidewaysScrollIsLeftAlone() { + // Shift + wheel, a thumb wheel, and a sideways swipe all arrive as + // horizontal movement and already work. + var translator = WheelScrollTranslation.Translator() + XCTAssertFalse(translator.shouldTranslate(input(dx: -1, dy: 0))) + XCTAssertFalse(translator.shouldTranslate(input(dx: -3, dy: 1))) + } + + func testAHighResolutionVerticalScrollAlsoMovesTheRow() { + // A mouse with vendor smooth scrolling reports the same fine deltas a + // trackpad does, so the device kind cannot be the deciding factor. + var translator = WheelScrollTranslation.Translator() + XCTAssertTrue(translator.shouldTranslate(input(dx: 0.4, dy: -6.2, phase: .began))) + } + + func testViewsThatAlsoScrollVerticallyKeepTheScroll() { + var translator = WheelScrollTranslation.Translator() + XCTAssertFalse(translator.shouldTranslate(input(dy: -1, vertical: true))) + } + + func testViewsThatCannotScrollSidewaysAreIgnored() { + var translator = WheelScrollTranslation.Translator() + XCTAssertFalse(translator.shouldTranslate(input(dy: -1, horizontal: false))) + XCTAssertFalse(translator.shouldTranslate(input(dy: -1, horizontal: false, vertical: true))) + } + + func testAnEmptyScrollDoesNothing() { + var translator = WheelScrollTranslation.Translator() + XCTAssertFalse(translator.shouldTranslate(input(dx: 0, dy: 0))) + } + + // MARK: - One decision per gesture + + func testAWobblyVerticalSwipeDoesNotFlipMidGesture() { + var translator = WheelScrollTranslation.Translator() + XCTAssertTrue(translator.shouldTranslate(input(dx: 0.2, dy: -8, phase: .began))) + // The swipe drifts sideways for a frame. It must not snap back to a + // vertical scroll and jitter. + XCTAssertTrue(translator.shouldTranslate(input(dx: -5, dy: -1, phase: .changed))) + XCTAssertTrue(translator.shouldTranslate(input(dx: 0, dy: -4, phase: .changed))) + XCTAssertTrue(translator.shouldTranslate(input(dx: 0, dy: 0, phase: .ended))) + } + + func testASidewaysSwipeStaysNativeForItsWholeRun() { + var translator = WheelScrollTranslation.Translator() + XCTAssertFalse(translator.shouldTranslate(input(dx: -9, dy: 0.3, phase: .began))) + XCTAssertFalse(translator.shouldTranslate(input(dx: -1, dy: 4, phase: .changed))) + XCTAssertFalse(translator.shouldTranslate(input(dx: 0, dy: 0, phase: .ended))) + } + + func testMomentumKeepsTheDecisionOfItsGesture() { + var translator = WheelScrollTranslation.Translator() + XCTAssertTrue(translator.shouldTranslate(input(dx: 0, dy: -9, phase: .began))) + XCTAssertTrue(translator.shouldTranslate(input(dx: 0, dy: 0, phase: .ended))) + XCTAssertTrue(translator.shouldTranslate(input(dx: -3, dy: -2, phase: .momentum))) + XCTAssertTrue(translator.shouldTranslate(input(dx: 0, dy: 0, phase: .momentumEnded))) + } + + func testANewGestureDecidesAgain() { + var translator = WheelScrollTranslation.Translator() + XCTAssertTrue(translator.shouldTranslate(input(dx: 0, dy: -9, phase: .began))) + XCTAssertTrue(translator.shouldTranslate(input(dx: 0, dy: 0, phase: .momentumEnded))) + XCTAssertFalse(translator.shouldTranslate(input(dx: -9, dy: 0, phase: .began))) + XCTAssertFalse(translator.shouldTranslate(input(dx: 0, dy: -9, phase: .changed))) + } + + func testLeavingASidewaysRowClearsTheDecision() { + var translator = WheelScrollTranslation.Translator() + XCTAssertTrue(translator.shouldTranslate(input(dx: 0, dy: -9, phase: .began))) + // The pointer moves onto a view that scrolls both ways. + XCTAssertFalse(translator.shouldTranslate(input(dx: 0, dy: -9, phase: .changed, vertical: true))) + // Back on a sideways row, the stale decision must not be reused. + XCTAssertFalse(translator.shouldTranslate(input(dx: -9, dy: 0, phase: .changed))) + } + + // MARK: - Axis swap + + private func wheelEvent(lines: Int32) throws -> NSEvent { + let cg = try XCTUnwrap(CGEvent( + scrollWheelEvent2Source: nil, + units: .line, + wheelCount: 1, + wheel1: lines, + wheel2: 0, + wheel3: 0 + )) + return try XCTUnwrap(NSEvent(cgEvent: cg)) + } + + func testTheSwapMovesVerticalMovementOntoTheHorizontalAxis() throws { + let wheel = try wheelEvent(lines: 3) + XCTAssertEqual(wheel.scrollingDeltaX, 0) + XCTAssertNotEqual(wheel.scrollingDeltaY, 0) + + let swapped = try XCTUnwrap(WheelScrollTranslation.horizontalCopy(of: wheel)) + XCTAssertEqual(swapped.scrollingDeltaX, wheel.scrollingDeltaY) + XCTAssertEqual(swapped.scrollingDeltaY, 0) + XCTAssertEqual(swapped.deltaX, wheel.deltaY) + XCTAssertEqual(swapped.deltaY, 0) + } + + func testTheSwapKeepsTheScrollDirection() throws { + let down = try wheelEvent(lines: -3) + let up = try wheelEvent(lines: 3) + + let swappedDown = try XCTUnwrap(WheelScrollTranslation.horizontalCopy(of: down)) + let swappedUp = try XCTUnwrap(WheelScrollTranslation.horizontalCopy(of: up)) + + XCTAssertLessThan(swappedDown.scrollingDeltaX, 0) + XCTAssertGreaterThan(swappedUp.scrollingDeltaX, 0) + XCTAssertEqual(swappedDown.scrollingDeltaX, -swappedUp.scrollingDeltaX) + } + + func testTheSwapKeepsTheEventAScrollEvent() throws { + let wheel = try wheelEvent(lines: 1) + let swapped = try XCTUnwrap(WheelScrollTranslation.horizontalCopy(of: wheel)) + + XCTAssertEqual(swapped.type, .scrollWheel) + XCTAssertFalse(swapped.hasPreciseScrollingDeltas) + XCTAssertEqual(swapped.hasPreciseScrollingDeltas, wheel.hasPreciseScrollingDeltas) + XCTAssertEqual(swapped.isDirectionInvertedFromDevice, wheel.isDirectionInvertedFromDevice) + } + + func testAWheelNotchReportsNoGesturePhase() throws { + let wheel = try wheelEvent(lines: 1) + XCTAssertEqual(WheelScrollTranslation.phase(of: wheel), .none) + } +} diff --git a/docs/testing/issue-26-wheel-scroll.md b/docs/testing/issue-26-wheel-scroll.md new file mode 100644 index 0000000..bb3eda4 --- /dev/null +++ b/docs/testing/issue-26-wheel-scroll.md @@ -0,0 +1,43 @@ +# Issue #26: mouse wheel over the sideways card rows + +## Contract + +- Scrolling vertically over the card row, a pinboard row, or the tab strip moves it sideways. +- A scroll that is already sideways is left alone, so Shift + wheel, a thumb wheel, and trackpad sideways gestures behave exactly as they do today. +- A view that also scrolls vertically, such as a Quick Look image at actual size, keeps the normal meaning of the scroll. +- The direction follows the system setting, since the swapped event carries the same values the device reported. +- No change to selection, paste, or the keyboard shortcuts. + +The event's vertical movement is moved onto the horizontal axis and handed back to AppKit, so line height, acceleration, and edge clamping stay native rather than being reimplemented. + +The choice is made from the direction of the scroll, not from the kind of device. A mouse reports the same fine deltas a trackpad does once its vendor software turns on smooth scrolling, so treating precise deltas as "this is a trackpad, leave it alone" would skip exactly the mice this is meant to fix. Within one continuous gesture the first decision is kept until the gesture and its momentum finish, so a swipe that drifts off axis cannot flip between sideways and vertical frame by frame. + +## Automated checks + +```sh +bash scripts/test-and-launch.sh +``` + +Covered by unit tests: + +- which scrolls get translated: a wheel notch, a high resolution vertical scroll, an already sideways scroll, both-axis views, and rows that do not scroll sideways +- one decision per gesture: a wobbly vertical swipe does not flip, a sideways swipe stays native for its whole run, momentum inherits the decision, a new gesture decides again, and leaving the row clears it +- the axis swap itself: vertical movement lands on the horizontal axis, the direction is preserved, and the copy stays a scroll event + +Not covered by unit tests: delivery of a real device event, the scroll view lookup under the pointer, and how the scrolling feels. + +## Manual verification + +1. Open the panel with enough clips to overflow the row. Scroll the wheel over the cards. The row moves sideways, and the direction matches the rest of the system. +2. Scroll over a pinboard row and over the tab strip when it overflows. Both move sideways. +3. Hold Shift and scroll. It still moves sideways, at the same speed as before this change. +4. Open Quick Look on an image and switch to actual size. The scroll still moves the image up and down. +5. On a trackpad, confirm sideways gestures are unchanged, and that a vertical swipe followed by momentum moves the row smoothly in one direction rather than jittering. +6. If the mouse has vendor software with smooth scrolling, check it with that setting both on and off. +7. Confirm clicking a card still pastes as before, and that scrolling over other windows is unaffected while the panel is open. + +## Verification status + +- 2026-09-11: `scripts/test-and-launch.sh` passed end to end. 26 unit tests, 0 failures, Debug build succeeded. +- 2026-09-11: scrolling the card row with an MX Master 3S was checked by hand and moved the row sideways. +- Not yet covered: the tab strip and pinboard rows, Quick Look at actual size, trackpad momentum, and the vendor smooth scrolling setting in both positions. diff --git a/docs/testing/issue-8.md b/docs/testing/issue-8.md index 0c3d1c4..ceebd57 100644 --- a/docs/testing/issue-8.md +++ b/docs/testing/issue-8.md @@ -14,7 +14,7 @@ ## Automated checks ```sh -bash scripts/test-tab-shortcuts.sh +bash scripts/test-and-launch.sh ``` The script generates the project, runs the 11 unhosted XCTest methods, builds Debug, and relaunches Clipbara. Unit tests do not launch the app or access its data. Relaunching the Debug app uses its normal data store. Logs and an xcresult bundle are written to the printed output directory. A nonzero step stops the script. @@ -39,6 +39,6 @@ Record pass/fail or unavailable for each case. If testing creates clipboard reco ## Verification status -- 2026-09-11: `scripts/test-tab-shortcuts.sh` passed end to end. 11 unit tests, 0 failures, Debug build succeeded, Debug app relaunched. +- 2026-09-11: `scripts/test-and-launch.sh` passed end to end. 11 unit tests, 0 failures, Debug build succeeded, Debug app relaunched. - 2026-09-11: tab switching, switching while searching, switching with Quick Look open, and switching with a pinboard dialog open were checked by hand and behaved as specified. - Not yet covered: the paste regression case in step 9 and multi-tab scroll-into-view with an overflowing tab strip. diff --git a/project.yml b/project.yml index 63cf234..d2b02f9 100644 --- a/project.yml +++ b/project.yml @@ -93,6 +93,7 @@ targets: sources: - path: Tests - path: Clipbara/Utilities/PanelTabShortcut.swift + - path: Clipbara/Utilities/WheelScrollTranslation.swift settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.minsang.ClipbaraTests diff --git a/scripts/test-tab-shortcuts.sh b/scripts/test-and-launch.sh similarity index 79% rename from scripts/test-tab-shortcuts.sh rename to scripts/test-and-launch.sh index 3554720..0fe72c1 100755 --- a/scripts/test-tab-shortcuts.sh +++ b/scripts/test-and-launch.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Build and run the unhosted panel tab shortcut tests, then build and relaunch Clipbara. -# Usage: bash scripts/test-tab-shortcuts.sh [output-directory] +# Run the unhosted unit tests, then build and relaunch Clipbara. +# Usage: bash scripts/test-and-launch.sh [output-directory] set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -OUTPUT="${1:-$ROOT/build/tab-shortcut-verification}" +OUTPUT="${1:-$ROOT/build/verification}" mkdir -p "$OUTPUT" OUTPUT="$(cd "$OUTPUT" && pwd)" RUN="$OUTPUT/$(date +%Y%m%d-%H%M%S)-$$" @@ -20,14 +20,14 @@ echo "==> Log: $RUN/verification.log" echo "==> Generating Xcode project..." xcodegen generate -echo "==> Running shortcut unit tests (no app launch, no store access)..." +echo "==> Running unit tests (no app launch, no store access)..." xcodebuild \ -project Clipbara.xcodeproj \ -scheme ClipbaraTests \ -configuration Debug \ -destination 'platform=macOS' \ -derivedDataPath "$OUTPUT/DerivedData" \ - -resultBundlePath "$RUN/ShortcutTests.xcresult" \ + -resultBundlePath "$RUN/UnitTests.xcresult" \ -parallel-testing-enabled NO \ CODE_SIGNING_ALLOWED=NO \ test @@ -53,6 +53,6 @@ fi open "$APP" echo "==> Unit tests and the build passed, and the Debug app was launched." -echo " UI behaviour is not covered here. Follow docs/testing/issue-8.md to verify it." +echo " UI behaviour is not covered here. Follow the notes under docs/testing to verify it." echo " This script is done and the window can be closed." echo " Log: $RUN/verification.log"