Skip to main content

ui/
scroll.rs

1//! What gpui's own scroll handles leave to the app: a container that scrolls
2//! the way it was asked to, a bar to show the position, and a rule for which
3//! pane a wheel belongs to.
4//!
5//! [`pane`] is the container — an axis given at construction, the way
6//! SwiftUI's `ScrollView` takes one, because gpui's is a style field that
7//! defaults to unset and gets guessed at. Read its docs before reaching for
8//! `div().overflow_y_scroll()`; the guess is a real bug and not a small one.
9//!
10//! gpui scrolls that pane perfectly well and draws nothing while it does, so a
11//! bezel app has no way to show how far down it is. That is [`scrollbar`]: an
12//! overlay the caller lays over its own pane, because a wrapper that swallowed
13//! the content would have to re-implement layout for it. Nesting two panes is
14//! [`claim_wheel`]'s business.
15//!
16//! ```ignore
17//! div().relative()                                  // the bar is absolute in here
18//!     .child(
19//!         scroll::pane("pane", Axes::Vertical)
20//!             .size_full()
21//!             .track_scroll(&self.scroll)           // gpui's handle, the app's field
22//!             .child(content),
23//!     )
24//!     .child(scroll::scrollbar("pane-bar", &self.scroll, &self.scroll_bar))
25//! ```
26//!
27//! The bar must span the container it reports on — its track *is* the viewport,
28//! in the coordinates [`thumb`] answers in.
29//!
30//! The geometry is transcribed from zed's own scrollbar (`thumb_ranges` in
31//! `crates/ui/src/components/scrollbar.rs`), which is 1722 lines of settings
32//! system around the fifteen that matter. Two of gpui's conventions are easy to
33//! get backwards and both are load-bearing here: `max_offset` is the *overflow*
34//! (content minus viewport, not content), and `offset` is **negative** as you
35//! scroll down.
36//!
37//! [`transient`] is the same bar, shown only while its content moves.
38//! [`Overlay`] manages its own state and supports either axis. Its default is
39//! [`Visibility::Scrolling`]; [`set_visibility`] updates all default overlays,
40//! including Markdown code blocks and tables. [`Viewport`] also owns the handle.
41
42mod bar;
43mod claim;
44mod drift;
45mod follow;
46mod overlay;
47
48pub use bar::*;
49pub use claim::*;
50pub use drift::*;
51pub use follow::*;
52pub use overlay::{Overlay, Viewport, Visibility, set_visibility, visibility};
53
54use std::{cell::Cell, ops::Range, rc::Rc, time::Duration};
55
56use gpui::{
57    Animation, AnimationExt, App, Axis, Bounds, Div, DragMoveEvent, ElementId, Empty, MouseButton,
58    Pixels, Point, ScrollHandle, SharedString, Stateful, Window, canvas, div, point, prelude::*,
59    px,
60};
61
62use motion::Painter;
63use theme::ink;
64use web_time::Instant;
65
66/// Shortest a thumb may get, however long the document — below this it stops
67/// being something a pointer can catch.
68pub const MIN_THUMB: Pixels = px(25.0);
69/// Space between the overlay track and the viewport edges, along the axis the
70/// bar runs.
71const INSET: f32 = 4.0;
72const BAR_INSET: Pixels = px(INSET);
73/// Width of the strip the thumb sits in.
74const TRACK: f32 = 10.0;
75/// Room a bar is centred in across its axis when the caller reserves none.
76const CHANNEL: f32 = 2.0 * INSET + TRACK;
77/// Width of the thumb itself, centred in the track.
78pub(crate) const THUMB: f32 = 4.0;
79/// Length of one [`rail`] mark, and its thickness.
80const MARK: f32 = 16.0;
81const MARK_THICK: f32 = 2.0;
82/// Between two marks. The track's width, so a rail and a bar on the same pane
83/// are cut to one rhythm.
84const MARK_GAP: f32 = TRACK;
85/// How far the rail stands off the edge it is pinned to.
86const RAIL_INSET: f32 = 12.0;
87/// What a rail needs beside the content before it will paint at all.
88const RAIL_ROOM: f32 = RAIL_INSET + MARK;
89
90// ---------------------------------------------------------------------------
91// Pane — a scroll container whose axis is an argument, not a modifier
92// ---------------------------------------------------------------------------
93
94/// Which way a [`pane`] scrolls. SwiftUI's `Axis.Set`, which gpui's [`Axis`]
95/// has no spelling for: a pane that scrolls both ways is not one of two
96/// directions.
97#[derive(Clone, Copy, Debug, PartialEq, Eq)]
98pub enum Axes {
99    Vertical,
100    Horizontal,
101    Both,
102}
103
104impl Axes {
105    pub fn vertical(self) -> bool {
106        matches!(self, Axes::Vertical | Axes::Both)
107    }
108
109    pub fn horizontal(self) -> bool {
110        matches!(self, Axes::Horizontal | Axes::Both)
111    }
112
113    /// The gpui axis this is, where it is only one.
114    pub fn axis(self) -> Option<Axis> {
115        match self {
116            Axes::Vertical => Some(Axis::Vertical),
117            Axes::Horizontal => Some(Axis::Horizontal),
118            Axes::Both => None,
119        }
120    }
121}
122
123/// A scroll container, with the axis as an argument rather than a modifier you
124/// can forget.
125///
126/// ```ignore
127/// scroll::pane("log", Axes::Vertical)
128///     .size_full()
129///     .track_scroll(&self.scroll)
130///     .child(content)
131/// ```
132///
133/// Returns an element for the caller to fill, the way [`crate::stack::row`]
134/// does — it takes no children and lays nothing out, so the pane stays the
135/// app's and only its scroll behaviour is decided here. The id is gpui's
136/// requirement, not ours: a scroll container has state to track.
137///
138/// # Why this exists rather than `div().overflow_y_scroll()`
139///
140/// gpui makes scrollability a late-bound style field with no default, and then
141/// has to guess what to do when a gesture's axis is not one the container
142/// scrolls: it **remaps the delta onto whichever axis the container can
143/// scroll**. A sideways swipe over a vertical list scrolls it down; a downward
144/// swipe over a wide table pans it sideways. `restrict_scroll_to_axis` turns
145/// that off, but it is opt-in per element, so every pane that forgets it is
146/// wrong and nothing says so.
147///
148/// SwiftUI has no such case to guess at — `ScrollView(.vertical)` takes its
149/// axis at construction, so there is no container whose axis is unstated. This
150/// is that: ask for an axis, get a pane that answers only to it.
151///
152/// A horizontal pane also contains a sideways gesture ([`contain_sideways`]),
153/// because the pane it is nested in usually belongs to a consumer and is not
154/// ours to restrict.
155///
156/// `Axes::Both` inherits gpui's dominant-axis lock — a diagonal gesture moves
157/// one axis, not two. gpui exposes no builder for `allow_concurrent_scroll`.
158pub fn pane(id: impl Into<ElementId>, axes: Axes) -> Stateful<Div> {
159    scrolls(div().id(id), axes)
160}
161
162/// [`pane`], keeping the wheel it can act on: the pane a consumer nests inside
163/// another and never wires a handle to.
164///
165/// [`claim_wheel`] asks the caller for a [`ScrollHandle`] and a [`ClaimState`],
166/// because the caller usually has the handle already — it is scrolling the pane
167/// from elsewhere. A bounded box inside someone else's page has neither, and a
168/// pane that is only ever read by the wheel that moves it should not make a
169/// consumer hold two fields to stop it dragging the page behind it. Both live
170/// in keyed element state here, so the pane is still one call.
171///
172/// ```ignore
173/// scroll::claiming_pane("output", Axes::Vertical, window, cx).child(text)
174/// ```
175///
176/// The chaining is [`claim_wheel`]'s: at its ends the pane hands the wheel back
177/// to the page.
178pub fn claiming_pane(
179    id: impl Into<ElementId>,
180    axes: Axes,
181    window: &mut Window,
182    cx: &mut App,
183) -> Stateful<Div> {
184    let id = id.into();
185    let held = window.use_keyed_state(id.clone(), cx, |_, _| Claiming::default());
186    let (handle, state) = {
187        let held = held.read(cx);
188        (held.handle.clone(), held.state.clone())
189    };
190    claim_wheel(pane(id, axes).track_scroll(&handle), &handle, axes, &state)
191}
192
193/// What [`claiming_pane`] keeps between frames: the handle it reads its own
194/// travel off, and where that travel stood before the wheel being dispatched.
195#[derive(Default)]
196struct Claiming {
197    handle: ScrollHandle,
198    state: ClaimState,
199}
200
201/// [`pane`]'s answer applied to an element that already exists — a container
202/// that scrolls only at some widths, or one another builder handed back.
203///
204/// ```ignore
205/// strip.when(compact, |strip| scroll::scrolls(strip, Axes::Horizontal))
206/// ```
207pub fn scrolls<E: gpui::StatefulInteractiveElement>(el: E, axes: Axes) -> E {
208    let el = match axes {
209        Axes::Vertical => el.overflow_y_scroll(),
210        Axes::Horizontal => el.overflow_x_scroll(),
211        Axes::Both => el.overflow_scroll(),
212    }
213    .restrict_scroll_to_axis();
214    match axes.horizontal() {
215        true => contain_sideways(el),
216        false => el,
217    }
218}
219
220/// Keep a sideways gesture inside the pane it started in.
221///
222/// The other half of [`pane`], and the half [`Axes::Vertical`] does not want:
223/// a vertical pane at its end should hand the wheel to the page behind it
224/// ([`claim_wheel`] is that chaining), but a sideways gesture reaching a
225/// vertical ancestor is never right — unless that ancestor is restricted too,
226/// it will remap the delta and scroll down.
227///
228/// Applied by [`pane`] for the axes that need it. Public because a consumer
229/// wrapping bezel's content in a scroller of its own has the same problem and
230/// the same fix.
231///
232/// Registered before the element's own handler and so run after it — gpui
233/// bubbles the list backwards — which is why the pane has already moved by the
234/// time the event stops here.
235pub fn contain_sideways<E: gpui::InteractiveElement>(el: E) -> E {
236    contain_wheel(el, Axes::Horizontal)
237}
238
239/// Keep every wheel inside the pane it landed on — `overscroll-behavior:
240/// contain`, where [`claim_wheel`] is the chaining kind.
241///
242/// For a box with a cap on it, where the content is a program's output rather
243/// than a document: it is a window onto something, and a wheel over a window
244/// belongs to what is inside it. Chaining asks the pane to prove it moved,
245/// which it reads off a handle carrying the previous frame's layout — under a
246/// pane whose content is still arriving that reads as "did not move", and the
247/// page takes the wheel while the box is still scrolling (user report,
248/// DEV-13).
249///
250/// The page is still reachable: move the pointer off the box.
251pub fn contain_wheel<E: gpui::InteractiveElement>(el: E, axes: Axes) -> E {
252    el.on_scroll_wheel(move |event, window, cx| {
253        let delta = event.delta.pixel_delta(window.line_height());
254        // The dominant axis, not "any horizontal component": a trackpad puts a
255        // little of both into every gesture, and a mostly-vertical one still
256        // belongs to the page.
257        let sideways = delta.x.abs() > delta.y.abs();
258        if (sideways && axes.horizontal()) || (!sideways && axes.vertical()) {
259            cx.stop_propagation();
260        }
261    })
262}