Skip to content

scroll: Bounce scrollable content like NSScrollView, by default on macOS - #3500

Merged
huacnlee merged 10 commits into
longbridge:mainfrom
shreeve:feat/input-scroll-bounce
Oct 14, 2026
Merged

huacnlee merged 10 commits into
longbridge:mainfrom
shreeve:feat/input-scroll-bounce

Conversation

@shreeve

@shreeve shreeve commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Description

Scrollable content now bounces at its ends the way NSScrollView does, and on macOS it does so by default, as native scroll views do. Bounce comes from one shared overscroll engine and one wrapper, ScrollBounce. It covers multiline inputs, List, DataTable, Tree, Scrollable, Select, Command, Sidebar, Diff, MessageScroller, the settings page and scrollable TextView. Logical scroll positions stay clamped. Content moves inside its clip, while input frames, table headers, search fields, footers and scrollbars stay fixed.

Bounce depends on how precise the scroll input is, not on the device name:

  • Trackpads and Magic Mouse send precise pixel scrolling (ScrollDelta::Pixels). It bounces.
  • Ordinary mouse wheels send line scrolling (ScrollDelta::Lines). It only scrolls, and cancels any bounce in progress.

One default, one rule for nested areas

  • Default. ScrollBounce::enabled_by_default() is the only place the default is decided. It is on for iOS, Android and macOS, the platforms whose own scroll views bounce, and off elsewhere. Components keep their scroll_bounce(bool) builder as an override; when it is not set, they defer to ScrollBounce. Inputs use the same default.
  • Nested areas. These follow the platform. The innermost area under the pointer takes the whole gesture, including at its ends: it bounces there rather than handing the rest of the gesture to the area around it.
    • How it works. Each area claims the wheel event in the capture phase, before its children paint, so the innermost claim is the one that stands. Only that area then acts. Text inputs take part through the same claim.
    • Disabled areas. An area whose bounce is disabled still scrolls its own child and keeps the gesture; it just stops at its ends.
    • Effect on GPUI divs. Until now, nested GPUI scroll divs scrolled together.
  • Popup menus. These do not bounce, matching macOS menus.

Matching NSScrollView

The desktop motion is fitted to a native NSScrollView, measured on macOS by driving it with synthesized trackpad events, and cross-checked against AppKit's own rubber-band functions:

NSScrollView This PR
Stretch for 100 px of travel past the end (300 px viewport) 7 px 7 px
Largest stretch, by drag or fling 36 px in Finder 36 px, eased into
Release held still: time to halve 55 ms, moving at once 55 ms
Fling into the edge at 1500 px/s: peak ≈14 px, ≈80 ms after impact ≈14 px, ≈80 ms
  • Stretch. It follows AppKit's hyperbolic curve (coefficient 0.075). AppKit stretches by unaccelerated travel, which GPUI does not report, so a hard swipe would run far past native. ScrollBounceMotion therefore gains a maximum stretch, 36 px by default on desktop. The stretch eases into it, and a fling never throws the edge past it.
  • Return. A released or struck edge follows (x + 0.31·v·t)·e^(-12.5t), using the same constants as AppKit's _NSElasticDeltaForTimeDelta. A fling throws the edge once per gesture, and momentum back into the content scrolls it while the edge returns.
  • Fling speed. It is read over the last 50 ms. Scroll packets carry no timestamp, so a slow frame hands over several at once. Read one by one, a fling through a long list hit the top about 4× too hard.
  • Touch and lift. GPUI reports MayBegin as Started, and a cancelled touch as a Moved of nothing. A touch that lifts without moving now lets go of a held edge.

iOS and Android keep the UIScrollView tuning (0.55, 524 ms, no maximum). Reduced motion disables displacement. Component documentation is synchronized in English and Chinese.

Public API

gpui-base

  • pub const fn ScrollBounce::enabled_by_default() -> bool: whether scroll areas bounce unless told otherwise. True on iOS, Android and macOS.
  • pub fn ScrollBounce::scroll_wheel(self, enabled: bool) -> Self: chooses whether the wrapper owns vertical wheel scrolling through the shared engine. Defaults to true. Pass false for the child-owned touch mode.
  • pub fn ScrollBounceMotion::with_max_stretch(self, max_stretch: f32) -> Self: the farthest the edge goes past its end, whether stretched or thrown.
  • pub fn ScrollBounceMotion::max_stretch(&self) -> Option<f32>: reads it back.
  • impl Default for ScrollBounceMotion: now depends on the platform. Unchanged on iOS and Android. Elsewhere it is fitted to NSScrollView: tracking 0.075, response ≈503 ms, max_stretch 36 px.
  • pub fn InputBaseState<M: MultiLineMode>::scroll_bounce(self, scroll_bounce: bool) -> Self: precise-scroll boundary bounce for TextareaState and EditorState.
  • pub fn InputBaseState<M: MultiLineMode>::set_scroll_bounce(&mut self, scroll_bounce: bool, cx: &mut Context<Self>): changes the setting at runtime.

gpui-component

Each of these overrides the platform default for that component:

  • pub fn List<D: ListDelegate>::scroll_bounce(self, enabled: bool) -> Self
  • pub fn DataTable<D: TableDelegate>::scroll_bounce(self, enabled: bool) -> Self: headers and scrollbars stay fixed.
  • pub fn Tree::scroll_bounce(self, enabled: bool) -> Self
  • pub fn Scrollable<E>::scroll_bounce(self, enabled: bool) -> Self
  • pub fn Select<D>::scroll_bounce(self, enabled: bool) -> Self
  • pub fn Command::scroll_bounce(self, enabled: bool) -> Self: the search field and footer stay fixed.

Breaking Changes

No existing signature changes. Every existing call still compiles. Four defaults change behavior, compared with main:

1. ScrollBounce is enabled by default on macOS. On main it was enabled only on iOS and Android. A ScrollBounce created without .enabled(..) now bounces on macOS too. To keep it still:

- ScrollBounce::new(id, &handle, child)
+ ScrollBounce::new(id, &handle, child).enabled(false)

2. ScrollBounce owns vertical wheel scrolling by default. On main, the child scrolled itself and the wrapper only stretched the deltas the child left unused. Now the wrapper scrolls the handle itself, through the shared engine. Code that relied on the child-owned mode, such as touch emulation or synthesized drags, must ask for it:

- ScrollBounce::new(id, &handle, child).enabled(true)
+ ScrollBounce::new(id, &handle, child).enabled(true).scroll_wheel(false)

3. ScrollBounceMotion::default() changes on macOS, Windows and Linux. The defaults are now tuned to match NSScrollView, replacing the UIScrollView tuning:

  • tracking goes from 0.55 to 0.075.
  • response goes from 524 ms to ≈503 ms.
  • The stretch is capped at 36 px.

iOS and Android are unchanged. To keep the previous feel:

- ScrollBounce::new(id, &handle, child)
+ ScrollBounce::new(id, &handle, child).motion(
+     ScrollBounceMotion::default()
+         .with_tracking(0.55)
+         .with_response(Duration::from_millis(524)),
+ )

4. Scrollable components scroll differently.

  • They bounce on macOS. Scrollable (overflow_y_scrollbar()), List, DataTable, Tree, Select, Command, Textarea, Editor, Sidebar, Diff, MessageScroller, the settings page and scrollable TextView now bounce on precise scrolling. On main none of them did. Each one that has a builder can turn it off:

    - List::new(&state)
    + List::new(&state).scroll_bounce(false)
  • Nested areas no longer scroll together, on every platform. The innermost area under the pointer takes the whole gesture, including at its ends. On main:

    • Nested GPUI scroll divs scrolled together.
    • MessageScroller handed the wheel to an ancestor scroller once it reached an end.

    Neither happens now. Sidebar, Diff, MessageScroller, the settings page and TextView have no scroll_bounce builder; they always follow the platform default.

Everything else listed under Public API is new in this PR, and so additive.

Validation

  • cargo test -p gpui-base -p gpui-component --lib passes (1361 + 670). The tests include:
    • Tests pinned to the NSScrollView measurements: stretch, the 36 px limit, still release, fling peak and single throw, reverse momentum without a jump.
    • A fling whose packets arrive in a burst after slow frames.
    • A touch that lifts without moving.
    • Nested areas, where the inner one takes the gesture whether it is enabled or disabled, and a textarea inside a bouncing area.
    • Bounce interruption, ordinary-wheel exclusion, fixed input/scrollbar geometry, real List/DataTable scrolling and fixed table headers.
  • The CI test command (cargo test --workspace --exclude gpui-shell --features gpui-component-story/test-support with the example crates excluded), cargo test -p gpui-base-examples, cargo clippy with --deny warnings, cargo fmt --check, typos and script/check-ai pass.
  • The stretch and return match AppKit's _NSHyperbolicElasticDeltaForReboundDelta and _NSElasticDeltaForTimeDelta, called directly. The maximum stretch follows a measurement in Finder.
  • Checked in the Story Gallery with a physical trackpad: list and command palette edges, top and bottom.

AI assistance

The original Input bounce implementation was AI-assisted with Claude Code. Codex assisted with the shared engine, review corrections, component integration, regression tests, and synchronized feature documentation. Claude Code measured NSScrollView, refitted the motion, fixed the momentum and release behavior, made bounce the macOS default with one nesting rule, and added Select, Command, Sidebar, Diff, MessageScroller, settings page and TextView support.

🤖 Generated with Claude Code

A trackpad swipe or a fling that runs into the end of a native macOS text view
stretches the text past it and springs back. `scroll_bounce(true)` does the
same for a multi-line input whose text is taller than it, with the spring and
rubber-band curve of `ScrollBounce`. The input scrolls itself, so it cannot sit
inside that wrapper, which would move its frame and only sees a packet after
the input has handled it; the input drives the shared `Physics` instead.
Scrolling stays exact: the stretch only moves where the text and gutter are
drawn, inside the frame that clips them, and the scrollbar stays put.

A finger drags the edge. Momentum or a mouse wheel flick that runs into it
throws it instead: its packets measure speed rather than travel, so each adds
to the edge's peak, capped, and the spring carries it out and back. `Physics`
gains `kick` for that. As with `ScrollBounce`, it is on by default on iOS and
Android and off elsewhere; under reduced motion the text stops at its ends
without stretching. The Textarea and Editor stories turn it on.
@huacnlee huacnlee added input Breaking Changes Contains breaking changes; excluded from automatic cherry-picks to stable. labels Oct 14, 2026 — with ChatGPT Codex Connector
@huacnlee huacnlee removed the Breaking Changes Contains breaking changes; excluded from automatic cherry-picks to stable. label Oct 14, 2026
@huacnlee huacnlee changed the title input: Add scroll_bounce to stretch text past its ends and spring back scroll: Share precise-input bounce across scrollable components Oct 14, 2026
huacnlee and others added 2 commits October 14, 2026 12:07
Fit the desktop bounce to an NSScrollView measured on macOS. The stretch
follows about 0.075 of the travel instead of UIKit's 0.55, so a trackpad
swipe no longer pulls open most of the viewport. A released edge returns at
full speed along `(x + 0.31·v·t)·e^(-12.5t)`, as AppKit's does, instead of
easing out from rest.

A fling that runs into the edge throws it in proportion to its speed, once,
instead of a fixed 30 px nudge that later momentum packets kept repeating
after the edge settled. Momentum back into the text scrolls it while the
edge keeps returning, instead of snapping the edge home. A finger resting on
the trackpad keeps holding the stretch until it lifts.

iOS and Android keep the UIScrollView tuning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Select's dropdown list and Command's list bounce like List; the search field
and footer stay fixed. The Select and Command stories and the Story Gallery
palettes turn it on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@huacnlee huacnlee changed the title scroll: Share precise-input bounce across scrollable components scroll: Bounce scrollable content at its ends like NSScrollView Oct 14, 2026
huacnlee and others added 5 commits October 14, 2026 13:42
…ouch

Packets carry no timestamp, so a slow frame hands over several at once and
each read as arriving instantly. A fling through a long list, as it hits the
top, threw the edge about four times as far as NSScrollView. Read the speed
as the distance over the last 50 ms instead.

GPUI reports `MayBegin` as `Started` and a cancelled touch as a `Moved` of
nothing, so touching a returning edge and lifting without moving left it
held. Let go of it there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AppKit stretches by unaccelerated travel, which GPUI does not report, so a
hard swipe ran far past what Finder allows. `ScrollBounceMotion` gains a
max stretch, 36 px by default off touch platforms: the stretch eases into
it, and a fling is thrown no farther.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A borderless `Command` paints its own square background over the
dialog's rounded corners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rmost

`ScrollBounce::enabled_by_default` is now the one default, on where the
platform's own scroll views bounce: iOS, Android and macOS. Inputs follow
it too. `ScrollBounce` owns wheel scrolling by default; `scroll_wheel(false)`
keeps the child-owned touch mode.

With bounce on everywhere, an outer area captured the wheel before the
areas inside it. Each area under the pointer now claims the event before
its children paint, so the innermost claim stands, and only that area
acts. It keeps the gesture at its ends too, as nested NSScrollViews do. A
disabled area still scrolls its child and keeps the gesture, and text
that can scroll claims it from the areas around it.

Scrollable TextView documents bounce as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Components keep `scroll_bounce` as an override and otherwise leave the
default to `ScrollBounce`. Sidebar, Diff, MessageScroller and the
settings page now bounce too; MessageScroller's vertical mask gives way
to the bounce, which keeps the wheel from its ancestors. Popup menus stay
still, as macOS menus do.

The stories drop their explicit `scroll_bounce(true)`, and the docs say
where it is on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@huacnlee huacnlee changed the title scroll: Bounce scrollable content at its ends like NSScrollView scroll: Bounce scrollable content like NSScrollView, by default on macOS Oct 14, 2026
@huacnlee huacnlee removed the input label Oct 14, 2026
The example drives the bounce with a mouse drag standing in for touch,
the mode `ScrollBounce` no longer uses by default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@huacnlee huacnlee added the Breaking Changes Contains breaking changes; excluded from automatic cherry-picks to stable. label Oct 14, 2026
@huacnlee
huacnlee enabled auto-merge (squash) October 14, 2026 07:41
@huacnlee
huacnlee disabled auto-merge October 14, 2026 07:41
@huacnlee
huacnlee enabled auto-merge (squash) October 14, 2026 07:41
@huacnlee
huacnlee merged commit 14c26d3 into longbridge:main Oct 14, 2026
18 checks passed
@shreeve

shreeve commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

Looks great!!! Thanks Jason!

@shreeve
shreeve deleted the feat/input-scroll-bounce branch October 15, 2026 06:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Breaking Changes Contains breaking changes; excluded from automatic cherry-picks to stable.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants