Skip to content

Smooth scrolling: mouse-wheel notches glide like Chrome's, with a setting - #1005

Merged
zeronsh[bot] merged 9 commits into
zeronsh:mainfrom
alexandre-vl:zeron/smooth-scroll-settings-request
Oct 27, 2026
Merged

zeronsh[bot] merged 9 commits into
zeronsh:mainfrom
alexandre-vl:zeron/smooth-scroll-settings-request

Conversation

@alexandre-vl

@alexandre-vl alexandre-vl commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1004

The problem

A mouse-wheel notch moved content in one jump. GPUI hands non-precise wheels to containers as ScrollDelta::Lines, and they apply them at once, so a 3-line notch scrolls the transcript 60px in a single frame. Chrome on macOS glides the same notch over 200ms. The in-app browser stepped too, because WKWebView applies a non-precise tick at once (120px).

The change

Settings → Appearance → Motion → Smooth scrolling is on by default. It affects mouse wheels only, since trackpads already stream pixels. Reduce motion turns it off, and the row says so.

  • smooth_scroll wraps the window root. It takes each notch at the end of the capture phase and replays it over the next frames as fractional Lines slices. Every container keeps its own line height, and a notch travels exactly as far as before: slices sit on a 1/4096-line grid, so they sum exactly.
  • Chrome's curve is ported from cc::ScrollOffsetAnimationCurve (kMouseWheel): ease-in-out cubic-bezier(0.42, 0, 0.58, 1) over 200ms, shortening to 100ms for long moves. A notch in flight retargets at the current velocity (UpdateTarget), so a spin is one continuous glide. One departure: a notch against the glide drops the rest of that axis instead of netting it into a target clamped to the scroller, because the root can't see scroller bounds.
  • Left raw: precise input; fractional lines outside macOS (X11 reports touchpads that way, and X11, Wayland and Windows report high-resolution wheels that way), since a standard notch there is whole lines; notches with Ctrl, Alt, Cmd or Fn held (zoom stays discrete), and WebView2, which eases notches itself. A click stops a glide.
  • In-app browser (macOS): AppKit hands a page its wheel directly, so it never reaches the root. A local event monitor (PageGlide) holds the page's ticks and replays the same glide as precise pixel deltas. WebKit routes those like a trackpad's: to the scroller under the pointer, clamped at its edges.
  • Transcript: upward input now breaks the stick-to-bottom pin as it arrives, as use-stick-to-bottom's wheel handler does. Before, the pin broke only when one event moved the view more than 1px, and that check was deferred until after the spring's frame step had pulled the view back. A glided notch up from the bottom of a chat drifted back down. Recording the demo below caught this.

Demo

Both takes get the same notches in the same window: three single notches, then a quick spin. Left: smoothing off (before). Right: on (this PR).

Smooth scrolling off vs on

Full resolution at 60fps, real time then ½ speed: smooth-scroll-off-vs-on.mp4

Dark Light
Settings, dark Settings, light

With Reduce motion on:

Smooth scrolling under reduced motion

Measured offsets for one 3-line notch:

  • Transcript (from the recording above): off moves 60px in one frame. On moves 0 → 1 → 3 → 7 → 13 → 20 → 29 → 37 → 47 → 50 → 54 → 58 → 59 → 60px over about 200ms, landing exactly where off does.
  • In-app browser (Zeron's real WKWebView, page scrollY after the tick): +14ms 1px · +47ms 11 · +80ms 36 · +114ms 70 · +148ms 99 · +181ms 117 · +197ms 120. Before, all 120px landed at once.
How the media was made

A throwaway fixture (not committed) ran the production Shell against an isolated engine in a real window. It dispatched the same ScrollDelta::Lines notches on the same schedule for both takes and captured every presented frame with its timestamp: about 120Hz while idle, about 60Hz while the transcript re-lays out each frame. The videos are resampled from those timestamps. The stills and frames are composited onto an opaque backdrop, because the frosted theme renders translucent.

Tests

  • smooth_scroll (14 tests):
    • the curve's shape and landing, and Chrome's durations;
    • retargeting, checked against Chromium's own unit-test numbers;
    • merging and reversing notches, frame-rate independence, and whole-row accumulators;
    • GPUI fixtures for precise, modified, reduced-motion and raw-region input, interruptions, and an empty notch.
  • browser::macos (2 tests): a tick replays in whole pixels to exactly its lines, and the replay is a precise event at the tick's window location.
  • transcript::tests::a_glided_notch_leaves_the_bottom_and_stays_where_it_lands fails without the pin fix. The arrival-time escape applies to wheel notches only; precise input keeps its old distance test.
  • settings and shell: on by default, persisted as smoothScrolling.
  • Full -p zeron-ui --lib --test voice_orb_geometry on this head (macOS): 1898 + 11 passed, 0 failed. The browser-fixture feature (library and browser-fixture example) builds.

Not verified: a physical mouse in the macOS in-app browser, i.e. AppKit's real event routing into the monitor. The replay path was verified in Zeron's real WKWebView with synthetic ticks, because this machine can't post real wheel events.

🤖 Generated with Claude Code

@alexandre-vl
alexandre-vl marked this pull request as ready for review October 24, 2026 17:29
@zeronsh
zeronsh Bot force-pushed the zeron/smooth-scroll-settings-request branch 3 times, most recently from 6a8356e to 0eaaaf2 Compare October 27, 2026 03:10
alexandre-vl and others added 8 commits October 27, 2026 03:32
…ting

A mouse-wheel notch moved content in one jump: GPUI hands non-precise
wheels to containers as `ScrollDelta::Lines`, applied at once. The window
root now takes each notch at the end of the capture phase and replays it
over the following frames as fractional `Lines` slices, on Chrome's wheel
curve (`cc::ScrollOffsetAnimationCurve` for `kMouseWheel`): ease-in-out,
cubic-bezier(0.42, 0, 0.58, 1), over 200ms, shortening to 100ms for long
moves. A notch in flight retargets at the current speed, so a spin is one
continuous glide. Slices stay in lines, so every container keeps its own
line height and a notch travels exactly as far as before.

Precise input, notches with Ctrl, Alt, Cmd or Fn held, and content that
eases notches itself (WebView2) stay raw; a press ends a glide.

The in-app browser on macOS glides too. AppKit hands a page its wheel
directly and WKWebView applies a tick at once, so a local event monitor
holds the page's ticks and replays the same glide as precise pixel deltas,
which WebKit routes like a trackpad's: to the scroller under the pointer,
clamped at its edges.

Settings → Appearance → Motion → Smooth scrolling, on by default. Reduced
motion turns it off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pin broke only when one wheel event moved the view more than 1px from
the bottom, decided in a deferred check. A glided notch starts and ends
with sub-pixel slices, and its slices arrive on frames, where the spring's
step runs before that check: it pulled the view back and recorded the
distance the check compared against. A notch up from the bottom of a chat
drifted back down. Upward input over a scrollable transcript now breaks
the pin on the spot, as use-stick-to-bottom's wheel handler does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`on_scroll_wheel` broke the pin for any upward wheel that bubbled to the
transcript root, including one an overlay above the list let through
(an image viewer at its pan limit, say) while the list never moved. The
list's scroll handler now marks the event it took, and only that event
can escape the pin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: zeronsh[bot] <346184242+zeronsh[bot]@users.noreply.github.com>
Only `browser::macos` reads it; elsewhere `gliding` was dead code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: zeronsh[bot] <346184242+zeronsh[bot]@users.noreply.github.com>
X11 reports touchpads (and high-resolution wheels) as fractional
`ScrollDelta::Lines`, so every touchpad event was held back and eased
over 200ms. Outside macOS, where trackpads are flagged precise, only a
whole-line delta is a wheel notch; fractional lines now pass through.

The transcript's arrival-time pin escape is for wheel notches only:
precise input keeps the distance test it had before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: zeronsh[bot] <346184242+zeronsh[bot]@users.noreply.github.com>
No fixture calls fixture_wheel; it served a throwaway capture that was never
committed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: zeronsh[bot] <346184242+zeronsh[bot]@users.noreply.github.com>
The Motion row joins the settings index under Appearance → Motion, with the
words people reach for: mouse, wheel, glide, ease.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: zeronsh[bot] <346184242+zeronsh[bot]@users.noreply.github.com>
@zeronsh
zeronsh Bot force-pushed the zeron/smooth-scroll-settings-request branch from 0eaaaf2 to bf9c2c1 Compare October 27, 2026 03:34
Appshots is hidden off macOS and Linux, so search can't find its rows there
and the test failed on Windows since zeronsh#1018.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: zeronsh[bot] <346184242+zeronsh[bot]@users.noreply.github.com>

@zeronsh zeronsh Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security audit: clean. No network, new hosts, build/CI changes or new crates (objc2-core-graphics was already locked); the only unsafe is AppKit monitor/timer FFI. Media is plain PNG/GIF/H.264 with no metadata or trailing bytes.

Review: precise input, fractional lines off macOS, modified notches and WebView2 pass through untouched. The frame loop stops once a glide lands (on_next_frame is only re-queued while active), so the display link parks. Reduced motion and pause-in-background both turn it off. Every wheel consumer in the app is proportional to its delta, so slicing a notch is safe. The settings row matches its Motion siblings.

Follow-ups pushed: rebased onto main, dropped the unused fixture_wheel hook, added the row to settings search (#1018), and gated #1018's Appshots test to the platforms that show Appshots (it failed on Windows). zeron-ui lib tests are green locally on Linux, on this head and merged with main; macOS and Windows CI are green on e3afc8a.

@zeronsh
zeronsh Bot merged commit 3d2f1e6 into zeronsh:main Oct 27, 2026
26 checks passed
zeronsh Bot added a commit that referenced this pull request Oct 27, 2026
a_pick_keeps_the_keyboard_in_the_field searched for "destination" and
expected Appshots' Destination row. Appshots has no page off macOS and
Linux, so the search (rightly) finds none of it on Windows and the test
failed there since #1018. #1005 gated the whole test to macOS and Linux;
this runs the focus check on Windows again.

- a_pick_keeps_the_keyboard_in_the_field drops that cfg gate and picks
  Files' Autosave, a row every platform shows, by Enter and by a click.
- appshots_leaves_a_pick_the_keyboard keeps the Appshots case, the page
  that takes the keyboard as it opens, where Appshots exists; on Windows
  it holds that "appshot", "destination" and "capture" find no Appshots
  hit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: zeronsh[bot] <346184242+zeronsh[bot]@users.noreply.github.com>
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.

Mouse-wheel scrolling jumps instead of gliding (app and in-app browser)

2 participants