vercel-labs / vercel-labs/native
A detented wheel notch teleports instead of travelling on the engine scroll path
Nobody has claimed this yet.
- Dominant language
- Zig
- Stars
- 7.7k
- Forks
- 314
- Avg merge
- 5h
- Merged PRs (30d)
- 13
Description
ScrollAxisState.applyWheelWithRubberband does next.offset += delta and the content arrives at its new position in one step. That is right for a touchpad or a high-resolution wheel, which deliver a continuous stream the hardware has already smoothed. It is wrong for a detented wheel, and the difference is not a matter of degree.
A detent is discrete by construction: one notch is a fixed distance however slowly it arrives. On Linux the GTK host multiplies a notch by 40, so in a 690 point viewport one click teleports the content 40 points, about 6% of the screen, with no intermediate position ever computed. At the five to ten notches a second a reader actually turns a wheel that is a row of discrete jumps, and no frame rate fixes it, because there is nothing in between to draw. It was reported to me as teeth on a gear rather than a sweeping second hand.
macOS never sees this: gpu_surface_scroll_drivers is true on the AppKit host, so NSScrollView owns the physics and applyWheel never runs for an eligible region. Linux and Windows answer false and run the engine's path.
What I did
A detented notch sets a target and a clock instead of moving the offset. Each frame samples
progress = clamp((timestamp_ns - start_ns) / duration_ms, 0, 1)
offset = start_offset + (clamp(target) - start_offset) * ease(progress)
and retires the ease at progress 1, writing the target exactly.
Sampled from the frame event's own timestamp_ns rather than stepped by a dt, which matters more than it looks. It lands on schedule at 23fps or at 60, and if frames stop entirely (an occluded window, a long dispatch) the next one computes progress past 1 and lands exactly on target: no overshoot, no accumulated error, no catch-up sprint.
The delta accumulates onto the pending target, never onto the drawn position. Spinning faster than the ease completes therefore cannot lose distance. This is the part I would look at first in any implementation, because it is the part that is easy to get wrong and hard to notice.
Quadratic ease-out, 1 - (1-t)², chosen by arithmetic rather than taste. At the 43ms frame interval I measured, the cubic this engine already carries for layout tweens puts 20.8 points of a 40 point notch on the first drawn frame and 0.1 on the last, which is the teleport again in miniature. The quadratic spends 15.5 / 11.7 / 7.9 / 4.2 / 0.7.
The duration adapts to the notch rate: twice the observed gap between notches, floored at 50ms, capped at the token. A fixed duration accelerates on a fast spin, and it took a user report to see why. Every notch restarts the curve over the whole remaining gap, so while notches arrive faster than the ease finishes the target runs ahead and the gap grows, and speed is proportional to the gap. The content speeds up until the two balance: about 1.3 notches of lag at one per 100ms, about 2.3 at one per 50ms. The adaptive term holds it near 1.3 at any rate.
Three things that had to come with it
An axis has to say whether it CONSUMED the event, not just whether it drew. applyCanvasWidgetScrollAxis returns ?RectF and null means both "drew nothing" and "did not take this, give it to the region behind me". Those coincide only while every handled scroll moves pixels in the same breath. An armed ease moves nothing, so conflated it reads as a refusal, the route walk hands the same notch to the parent, and one wheel click scrolls both the list and the page behind it.
advanceCanvasWidgetKineticScrollForFrame returns without stepping when input is pending. Right while a wheel event moves the offset itself, since the frame already holds the new position. Fatal once a notch moves nothing on arrival: every input-carrying frame would draw the position before it, which is the stutter the change exists to remove. I made the flag silence only the decay, which a zero dt does by construction.
canvasWidgetScrollCanConsumeAxis has to read the pending target. Reading the drawn offset lets a region already easing toward its own maximum report that it can still consume, clamp to an unchanged target, and leak the notch to its parent.
The host half
Only a host can tell a notched wheel from a touchpad, and GTK already tells us: native_sdk_gpu_scroll branches on GDK_SCROLL_UNIT_WHEEL to apply its 40x and then throws the fact away. I carry it through to WidgetPointerEvent as one bool, defaulting false, so a host that says nothing gets exactly the behaviour it has. Windows would set it for WM_MOUSEWHEEL, macOS for !hasPreciseScrollingDeltas.
A coalesced flush is detented only while every event folded into it was.
Worth knowing about the test surface
Nothing executable reaches native_sdk_gpu_scroll or this decision. native automate wheel builds a .gpu_surface_input and calls dispatchPlatformEvent directly, so the whole battery passes whether any of this is right or wrong. My two tests dispatch a detented event themselves and drive the frame clock by hand. The first failed on its first run and found a real bug: the eased branch was gated on a local that only picks which entry point runs, not on whether the region actually bounces, so the feature was inert for every top-level scroll region and live for nested ones.
Happy to paste the diff if it is useful. It is about nine files.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reading native_sdk_gpu_scroll and WidgetPointerEvent, then trace applyCanvasWidgetScrollAxis, advanceCanvasWidgetKineticScrollForFrame, and canvasWidgetScrollCanConsumeAxis. Use the two hand-driven frame-clock tests described in the issue rather than native automate wheel. Done means detented events animate along the engine path without losing accumulated target distance, leaking to parent regions, or stuttering on input frames.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- zig
- Domain
- desktop
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100