Skip to content

Scroll dead zone: rail wheel gestures onto their dominant axis (input.scrollDeadZone) - #66

Merged
mstrobel merged 2 commits into
developfrom
feat/scroll-dead-zone
Jul 30, 2026
Merged

Scroll dead zone: rail wheel gestures onto their dominant axis (input.scrollDeadZone)#66
mstrobel merged 2 commits into
developfrom
feat/scroll-dead-zone

Conversation

@mstrobel

Copy link
Copy Markdown
Owner

Implements the scroll dead zone behind the existing input.scrollDeadZone user option (reserved until now): wheel gestures rail onto their dominant axis, so the stray cross-axis ticks a trackpad sheds during fast one-axis scrolling stop nudging the other axis.

Why it's a stream filter, not a delta clamp

Terminals quantize scrolling into single-axis, whole-notch SGR events (±120, one axis per event). Trackpad drift therefore arrives as isolated H ticks interleaved into a stream of V ticks — there is no diagonal delta to clamp, only a stream to filter. That makes it a natural IInputTransformer (whose own doc names filters as the intended use), sitting beside MouseClickSynthesizer and sharing its posture: purely synchronous, no timers, every decision made inline on the event's own Timestamp, deterministic to test.

The gesture model

A gesture is a run of wheel events with no 200 ms gap; its first event locks the axis. Same-axis ticks pass through; cross-axis ticks are absorbed into a tug-of-war charge (each absorbed tick adds its magnitude; each same-axis tick discharges twice its own). Reaching 360 units — three notches — re-rails the lock and delivers the breaking event.

The charge is what separates intent from accident: a real direction change looks like the primary stream stopping, so sustained cross ticks accumulate to the threshold in a fraction of a second, while drift — isolated ticks between dense primary ticks — is discharged before it can ever get there. The absorbed ticks are never replayed; that would turn the protection into a deferred jump.

Deliberate trades, chosen over latency:

  • The first tick decides the lock. Buffering the gesture head would distinguish lead-drift from intent, but only by adding a timer and delivery latency to everything; a wrongly-seeded lock self-corrects through the break valve instead.
  • Diagonal wobble rails to the dominant axis. That is what the option advertises; turning it off restores raw deltas.
  • Raw axes, modifiers ignoredScrollViewer's Shift+wheel→horizontal convention maps a railed raw-Y gesture exactly as it mapped the unfiltered one.

Wiring: always assembled, toggled live

The §10.4 device chain is single-shot, so the filter is always in it (disabled = one-branch pass-through) and UIApplication.ScrollDeadZoneEnabled flips a volatile on the live instance — no reassembly, order-independent with startup (the field carries the value into assembly; the setter forwards after), and renegotiation needs nothing: the chain survives ChangeCapabilities, and the filter alters no capabilities, so ApplyDecorationProjections is untouched. Startup applier and UserOptionsSession.ApplyLive both consume the key; the catalog row drops ReservedForFuture, so the Options dialog toggle now works.

What adversarial review caught

A multi-agent review pass confirmed one real algorithm bug before merge: the break valve originally existed only on the pure-cross leg, so a cross-dominant mixed stream (deliberate push with a grazing locked-axis component — synthesized/high-res sources only; terminal input can't produce it) could never re-rail while its charge grew without bound. The valve now lives on both legs, pinned by test. It also drove: startup-file coverage added to ResolvedOptions_AreInForceForTheFirstStamp per that test's every-applied-key convention, WheelAxisLockOptions as a sealed record in its own file per sibling idiom, and honest doc wording for the lazy disable-reset.

Testing

All three lanes are covered: the persisted startup file, the session live-apply, and — end to end — raw SGR wheel bytes through SendBytes, the one headless entry that exercises the real pump and decorated chain (SendInput deliberately bypasses it).

Everything was proven red-first: 13/17 core tests fail with the filter neutered, both E2E tests fail with the chain wiring removed, and the two review-driven tests each fail under precisely the variant they pin (missing mixed-leg valve / hard-reset discharge).

Cursorial.Core.Tests 1,065 · Cursorial.UI.Tests 3,457 — all green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01A4i5CZSjkU1TbAgiYBomhL

mstrobel and others added 2 commits July 30, 2026 09:51
…o their dominant axis

Trackpads make it nearly impossible to scroll far along one axis without shedding occasional
ticks along the other, and because terminals quantize scrolling into single-axis SGR notch
events, that drift arrives as stray cross-axis Wheel events interleaved into the dominant
stream — every one of them visibly nudging whatever scrolls on that axis.

The transformer is the "scroll dead zone": a gesture (a run of wheel events with no
GestureTimeout-sized gap) locks onto the axis of its first event; same-axis events pass
untouched, cross-axis events charge a tug-of-war accumulator (each suppressed tick adds its
magnitude, each same-axis tick discharges twice its own), and reaching BreakThreshold re-rails
the gesture — so isolated drift can never accumulate, while a genuine direction change breaks
through on its third consecutive notch at the defaults.

Decisions worth recording:

- The first tick decides the lock. Buffering the gesture head would separate lead-drift from
  intent, but only by adding latency and a timer to everything; a wrongly-seeded lock
  self-corrects through the break valve instead. Purely synchronous, like MouseClickSynthesizer:
  every decision happens inline on the event's own Timestamp, so it is deterministic to test.

- The break valve lives on BOTH legs. A cross-axis push arriving as mixed events with a grazing
  locked-axis component (|secondary| > 2·|primary|) charges past the threshold on the primary
  leg; without the check there it could never re-rail and the charge would grow without bound.
  Unreachable for single-axis terminal input — caught by adversarial review, pinned by test.

- The discharge is proportional, not a hard reset. For whole-notch input they are equivalent
  (one primary notch clears any sub-break charge), but for sub-notch deltas from synthesized or
  high-resolution sources a grazing primary component must not erase a deliberate cross push.

- Suppressed events still refresh gesture liveness — a drift trickle must keep its gesture's
  lock alive, not age it out and then seed a fresh gesture with more drift.

- The lock rails RAW axes and ignores modifiers: a Shift+vertical gesture rails on raw Y and
  maps to horizontal downstream exactly as without the filter.

- Enabled is a volatile live toggle (the user-option hook), safe to flip mid-stream; gesture
  state stays enumeration-local so one instance is reusable across streams.

Tests: 19 in WheelAxisLockTests, each behavior verified red with the implementation (or the
specific leg under test) reverted. Cursorial.Core.Tests: 1,065 green.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4i5CZSjkU1TbAgiYBomhL
The option key existed (reserved) with no framework consumer; this closes the loop from the
persisted store to the running input chain.

- WheelAxisLock is ALWAYS assembled into the §10.4 chain (between numpad translation and click
  synthesis): the device chain is single-shot, so the live toggle must find the filter already
  in place — disabled it is a one-branch pass-through. Renegotiation needed nothing: the chain
  survives ChangeCapabilities, and the filter alters no capabilities, so
  ApplyDecorationProjections is untouched.

- UIApplication.ScrollDeadZoneEnabled follows the NerdFontAvailable posture (equality-gated,
  VerifyAccess) and is order-independent with chain assembly: the field carries the value into
  the transformer at assembly, and the setter forwards to the live instance afterwards.

- Startup path: UserConfigurationApplier applies the key (removed from its reserved-keys
  comment); live path: UserOptionsSession.ApplyLive re-applies it with absent-means-default
  (off) semantics. The catalog row drops ReservedForFuture, so the Options dialog row sheds its
  "(*)" annotation and becomes a working toggle.

Tests deliberately cover all three lanes: the startup file (ResolvedOptions_AreInForceForTheFirstStamp,
per that test's every-applied-key convention), the session live-apply, and — end to end — raw
SGR wheel bytes through SendBytes, the one headless entry that exercises the real pump and
decorated chain (SendInput deliberately bypasses it). The E2E tests were verified red with the
chain wiring removed; the toggle test pins that flipping the option reaches the running chain
without reassembly.

docs: §10.4 assembly order updated (it also never mentioned numpad translation); user_options.md
wish-list line updated from the old horizontal-only phrasing to the both-axes behavior.

Cursorial.UI.Tests: 3,457 green.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4i5CZSjkU1TbAgiYBomhL
@mstrobel
mstrobel merged commit f63a7aa into develop Jul 30, 2026
2 of 3 checks passed
@mstrobel
mstrobel deleted the feat/scroll-dead-zone branch July 30, 2026 17:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant