Scroll dead zone: rail wheel gestures onto their dominant axis (input.scrollDeadZone) - #66
Merged
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the scroll dead zone behind the existing
input.scrollDeadZoneuser 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
Hticks interleaved into a stream ofVticks — there is no diagonal delta to clamp, only a stream to filter. That makes it a naturalIInputTransformer(whose own doc names filters as the intended use), sitting besideMouseClickSynthesizerand sharing its posture: purely synchronous, no timers, every decision made inline on the event's ownTimestamp, 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:
ScrollViewer'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.ScrollDeadZoneEnabledflips 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 survivesChangeCapabilities, and the filter alters no capabilities, soApplyDecorationProjectionsis untouched. Startup applier andUserOptionsSession.ApplyLiveboth consume the key; the catalog row dropsReservedForFuture, 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_AreInForceForTheFirstStampper that test's every-applied-key convention,WheelAxisLockOptionsas asealed recordin 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 (SendInputdeliberately 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.Tests1,065 ·Cursorial.UI.Tests3,457 — all green.🤖 Generated with Claude Code
https://claude.ai/code/session_01A4i5CZSjkU1TbAgiYBomhL