Repository navigation
scroll: Bounce scrollable content like NSScrollView, by default on macOS - #3500
Merged
Merged
Conversation
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.
scroll_bounce to stretch text past its ends and spring backFit 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>
…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>
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
enabled auto-merge (squash)
October 14, 2026 07:41
huacnlee
disabled auto-merge
October 14, 2026 07:41
huacnlee
enabled auto-merge (squash)
October 14, 2026 07:41
Contributor
Author
|
Looks great!!! Thanks Jason! |
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.
Description
Scrollable content now bounces at its ends the way
NSScrollViewdoes, 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:
ScrollDelta::Pixels). It bounces.ScrollDelta::Lines). It only scrolls, and cancels any bounce in progress.One default, one rule for nested areas
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 theirscroll_bounce(bool)builder as an override; when it is not set, they defer toScrollBounce. Inputs use the same default.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:ScrollBounceMotiontherefore gains a maximum stretch, 36 px by default on desktop. The stretch eases into it, and a fling never throws the edge past it.(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.MayBeginasStarted, and a cancelled touch as aMovedof 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:tracking0.075,response≈503 ms,max_stretch36 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) -> Selfpub fn DataTable<D: TableDelegate>::scroll_bounce(self, enabled: bool) -> Self: headers and scrollbars stay fixed.pub fn Tree::scroll_bounce(self, enabled: bool) -> Selfpub fn Scrollable<E>::scroll_bounce(self, enabled: bool) -> Selfpub fn Select<D>::scroll_bounce(self, enabled: bool) -> Selfpub 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.
ScrollBounceis enabled by default on macOS. Onmainit was enabled only on iOS and Android. AScrollBouncecreated without.enabled(..)now bounces on macOS too. To keep it still:2.
ScrollBounceowns vertical wheel scrolling by default. Onmain, 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:3.
ScrollBounceMotion::default()changes on macOS, Windows and Linux. The defaults are now tuned to match NSScrollView, replacing the UIScrollView tuning:trackinggoes from 0.55 to 0.075.responsegoes from 524 ms to ≈503 ms.iOS and Android are unchanged. To keep the previous feel:
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. Onmainnone of them did. Each one that has a builder can turn it off: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:Neither happens now. Sidebar, Diff, MessageScroller, the settings page and TextView have no
scroll_bouncebuilder; 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 --libpasses (1361 + 670). The tests include:cargo test --workspace --exclude gpui-shell --features gpui-component-story/test-supportwith the example crates excluded),cargo test -p gpui-base-examples,cargo clippywith--deny warnings,cargo fmt --check,typosandscript/check-aipass._NSHyperbolicElasticDeltaForReboundDeltaand_NSElasticDeltaForTimeDelta, called directly. The maximum stretch follows a measurement in Finder.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