Skip to content

macOS: jogstrip momentum is invisible — a flick's travel is under one line detent #143

Description

@jonocodes

Summary

Jogstrip momentum produces no visible scrolling on macOS. The daemon-side decay runs correctly — /diag shows active_momenta going 1 → 0 over ~0.4s after a flick — but not a single wheel event reaches the application, because the whole momentum phase is worth less than one line detent and MacScrollSink quantises to kCGScrollEventUnitLine.

Found while verifying #141 (macOS input injection) against a live daemon with a browser-based echo harness: the four drag frames of a flick scrolled the target, and everything after jog_end did nothing.

The arithmetic

ScrollController decays velocity by momentum_friction = 0.9 per 1/60s frame until it drops under momentum_cutoff = 20, emitting velocity / 60 hi-res units per frame. Total travel is therefore bounded by the geometric sum:

travel = (velocity / 60) x 1 / (1 - 0.9) = velocity / 6

A fast flick at velocity 400 travels ~66 hi-res units. One detent costs MacScrollSink.DETENT = 120. So the entire momentum phase rounds to zero — and it always will, for any velocity under 720.

On Linux this doesn't arise: UinputSink emits REL_WHEEL_HI_RES and the compositor renders sub-detent deltas as smooth scrolling. The hi-res wire protocol is designed for exactly that; macOS is the platform that throws the precision away.

Why this isn't just a rounding fix

macOS does support what's needed: CGEventCreateScrollWheelEvent takes kCGScrollEventUnitPixel, which is how trackpads deliver smooth scrolling. Switching to pixel units means choosing a units → pixels ratio, and that ratio is the scroll feel — too small and the jogstrip goes sluggish, too large and it overshoots. It also interacts with the client-side scrollScale setting. That's a design decision with a human in the loop, not a bug fix, which is why it's split out from the accumulator fix that shipped alongside this report.

Note the two are independent: the accumulator fix (floor → truncate) corrects a direction asymmetry, and momentum stays invisible either way.

Options

  1. Pixel-unit eventskCGScrollEventUnitPixel with a tuned units → px constant. Smooth scrolling and working momentum; needs a feel-tuning pass on real hardware.
  2. Fractional detent accumulation across the gesture — keep line units but let the remainder persist so momentum eventually emits a detent. Cheap, but momentum would arrive as one late "clunk" rather than a glide; arguably worse than nothing.
  3. Raise the momentum velocities on macOS — a per-platform multiplier so travel clears 120 units. Papers over the quantisation and makes the drag phase inconsistent with the momentum phase.

Option 1 is the real fix.

Acceptance

  • A flick on the jogstrip visibly coasts on macOS and settles, rather than stopping dead on release.
  • Slow drags stay smooth (no stair-stepping at the detent boundary).
  • The Linux path is untouched — this lives entirely in MacScrollSink.
  • tests/test_platform_macos.py::test_scroll_momentum_travel_is_under_one_detent documents today's arithmetic and is expected to be revisited by this work; it fails loudly if the travel budget changes.

Verification harness

The measurement above came from driving the running daemon over its WebSocket (jog / jog_end frames) with a local HTML page recording scrollTop, mousemove, keydown etc. — the cheap macOS analogue of #132's echo receiver. Worth reusing for the feel-tuning pass.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinginput-injectionSynthetic input via uinput or platform equivalent

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions