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
- Pixel-unit events —
kCGScrollEventUnitPixel with a tuned units → px constant. Smooth scrolling and working momentum; needs a feel-tuning pass on real hardware.
- 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.
- 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.
Summary
Jogstrip momentum produces no visible scrolling on macOS. The daemon-side decay runs correctly —
/diagshowsactive_momentagoing 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 andMacScrollSinkquantises tokCGScrollEventUnitLine.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_enddid nothing.The arithmetic
ScrollControllerdecays velocity bymomentum_friction = 0.9per 1/60s frame until it drops undermomentum_cutoff = 20, emittingvelocity / 60hi-res units per frame. Total travel is therefore bounded by the geometric sum: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:
UinputSinkemitsREL_WHEEL_HI_RESand 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:
CGEventCreateScrollWheelEventtakeskCGScrollEventUnitPixel, 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-sidescrollScalesetting. 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
kCGScrollEventUnitPixelwith a tunedunits → pxconstant. Smooth scrolling and working momentum; needs a feel-tuning pass on real hardware.Option 1 is the real fix.
Acceptance
MacScrollSink.tests/test_platform_macos.py::test_scroll_momentum_travel_is_under_one_detentdocuments 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_endframes) with a local HTML page recordingscrollTop,mousemove,keydownetc. — the cheap macOS analogue of #132's echo receiver. Worth reusing for the feel-tuning pass.