Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
CODE_SIGNING_ALLOWED=NO \
build

- name: Test panel tab shortcuts
- name: Run unit tests
run: |
xcodebuild \
-project Clipbara.xcodeproj \
Expand Down
60 changes: 60 additions & 0 deletions Clipbara/Panel/PanelController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -152,6 +154,7 @@ final class PanelController {
appState.markPanelPresented()
installClickMonitor()
installMouseMonitor()
installScrollMonitor()
installKeyMonitor()
}

Expand Down Expand Up @@ -198,6 +201,7 @@ final class PanelController {

removeClickMonitor()
removeMouseMonitor()
removeScrollMonitor()
removeKeyMonitor()

panel.hasShadow = false
Expand Down Expand Up @@ -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 }

Expand Down
128 changes: 128 additions & 0 deletions Clipbara/Utilities/WheelScrollTranslation.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
157 changes: 157 additions & 0 deletions Tests/WheelScrollTranslationTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading