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`]; [`crate::AppExt::set_scrollbar_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;
46pub(crate) mod overlay;
47
48pub use bar::*;
49pub use claim::*;
50pub use drift::*;
51pub use follow::*;
52pub use overlay::{Overlay, Viewport, 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/// Gap between the thumb and the pane's edge across its axis, unless the
76/// caller sets one.
77const MARGIN: f32 = 2.0;
78/// Width of the thumb itself.
79pub(crate) const THUMB: f32 = 4.0;
80/// Length of one [`rail`] mark, and its thickness.
81const MARK: f32 = 16.0;
82const MARK_THICK: f32 = 2.0;
83/// Between two marks. The track's width, so a rail and a bar on the same pane
84/// are cut to one rhythm.
85const MARK_GAP: f32 = TRACK;
86/// How far the rail stands off the edge it is pinned to.
87const RAIL_INSET: f32 = 12.0;
88/// What a rail needs beside the content before it will paint at all.
89const RAIL_ROOM: f32 = RAIL_INSET + MARK;
90
91// ---------------------------------------------------------------------------
92// Pane — a scroll container whose axis is an argument, not a modifier
93// ---------------------------------------------------------------------------
94
95/// Which way a [`pane`] scrolls. SwiftUI's `Axis.Set`, which gpui's [`Axis`]
96/// has no spelling for: a pane that scrolls both ways is not one of two
97/// directions.
98#[derive(Clone, Copy, Debug, PartialEq, Eq)]
99pub enum Axes {
100 Vertical,
101 Horizontal,
102 Both,
103}
104
105impl Axes {
106 pub fn vertical(self) -> bool {
107 matches!(self, Axes::Vertical | Axes::Both)
108 }
109
110 pub fn horizontal(self) -> bool {
111 matches!(self, Axes::Horizontal | Axes::Both)
112 }
113
114 /// The gpui axis this is, where it is only one.
115 pub fn axis(self) -> Option<Axis> {
116 match self {
117 Axes::Vertical => Some(Axis::Vertical),
118 Axes::Horizontal => Some(Axis::Horizontal),
119 Axes::Both => None,
120 }
121 }
122}
123
124/// A scroll container, with the axis as an argument rather than a modifier you
125/// can forget.
126///
127/// ```ignore
128/// scroll::pane("log", Axes::Vertical)
129/// .size_full()
130/// .track_scroll(&self.scroll)
131/// .child(content)
132/// ```
133///
134/// Returns an element for the caller to fill, the way [`crate::stack::row`]
135/// does — it takes no children and lays nothing out, so the pane stays the
136/// app's and only its scroll behaviour is decided here. The id is gpui's
137/// requirement, not ours: a scroll container has state to track.
138///
139/// # Why this exists rather than `div().overflow_y_scroll()`
140///
141/// gpui makes scrollability a late-bound style field with no default, and then
142/// has to guess what to do when a gesture's axis is not one the container
143/// scrolls: it **remaps the delta onto whichever axis the container can
144/// scroll**. A sideways swipe over a vertical list scrolls it down; a downward
145/// swipe over a wide table pans it sideways. `restrict_scroll_to_axis` turns
146/// that off, but it is opt-in per element, so every pane that forgets it is
147/// wrong and nothing says so.
148///
149/// SwiftUI has no such case to guess at — `ScrollView(.vertical)` takes its
150/// axis at construction, so there is no container whose axis is unstated. This
151/// is that: ask for an axis, get a pane that answers only to it.
152///
153/// A horizontal pane also contains a sideways gesture ([`contain_sideways`]),
154/// because the pane it is nested in usually belongs to a consumer and is not
155/// ours to restrict.
156///
157/// `Axes::Both` inherits gpui's dominant-axis lock — a diagonal gesture moves
158/// one axis, not two. gpui exposes no builder for `allow_concurrent_scroll`.
159pub fn pane(id: impl Into<ElementId>, axes: Axes) -> Stateful<Div> {
160 scrolls(div().id(id), axes)
161}
162
163/// [`pane`], keeping the wheel it can act on: the pane a consumer nests inside
164/// another and never wires a handle to.
165///
166/// [`claim_wheel`] asks the caller for a [`ScrollHandle`] and a [`ClaimState`],
167/// because the caller usually has the handle already — it is scrolling the pane
168/// from elsewhere. A bounded box inside someone else's page has neither, and a
169/// pane that is only ever read by the wheel that moves it should not make a
170/// consumer hold two fields to stop it dragging the page behind it. Both live
171/// in keyed element state here, so the pane is still one call.
172///
173/// ```ignore
174/// scroll::claiming_pane("output", Axes::Vertical, window, cx).child(text)
175/// ```
176///
177/// The chaining is [`claim_wheel`]'s: at its ends the pane hands the wheel back
178/// to the page.
179pub fn claiming_pane(
180 id: impl Into<ElementId>,
181 axes: Axes,
182 window: &mut Window,
183 cx: &mut App,
184) -> Stateful<Div> {
185 let id = id.into();
186 let held = window.use_keyed_state(id.clone(), cx, |_, _| Claiming::default());
187 let (handle, state) = {
188 let held = held.read(cx);
189 (held.handle.clone(), held.state.clone())
190 };
191 claim_wheel(pane(id, axes).track_scroll(&handle), &handle, axes, &state)
192}
193
194/// What [`claiming_pane`] keeps between frames: the handle it reads its own
195/// travel off, and where that travel stood before the wheel being dispatched.
196#[derive(Default)]
197struct Claiming {
198 handle: ScrollHandle,
199 state: ClaimState,
200}
201
202/// [`pane`]'s answer applied to an element that already exists — a container
203/// that scrolls only at some widths, or one another builder handed back.
204///
205/// ```ignore
206/// strip.when(compact, |strip| scroll::scrolls(strip, Axes::Horizontal))
207/// ```
208pub fn scrolls<E: gpui::StatefulInteractiveElement>(el: E, axes: Axes) -> E {
209 let el = match axes {
210 Axes::Vertical => el.overflow_y_scroll(),
211 Axes::Horizontal => el.overflow_x_scroll(),
212 Axes::Both => el.overflow_scroll(),
213 }
214 .restrict_scroll_to_axis();
215 match axes.horizontal() {
216 true => contain_sideways(el),
217 false => el,
218 }
219}
220
221/// Keep a sideways gesture inside the pane it started in.
222///
223/// The other half of [`pane`], and the half [`Axes::Vertical`] does not want:
224/// a vertical pane at its end should hand the wheel to the page behind it
225/// ([`claim_wheel`] is that chaining), but a sideways gesture reaching a
226/// vertical ancestor is never right — unless that ancestor is restricted too,
227/// it will remap the delta and scroll down.
228///
229/// Applied by [`pane`] for the axes that need it. Public because a consumer
230/// wrapping bezel's content in a scroller of its own has the same problem and
231/// the same fix.
232///
233/// Registered before the element's own handler and so run after it — gpui
234/// bubbles the list backwards — which is why the pane has already moved by the
235/// time the event stops here.
236pub fn contain_sideways<E: gpui::InteractiveElement>(el: E) -> E {
237 contain_wheel(el, Axes::Horizontal)
238}
239
240/// Keep every wheel inside the pane it landed on — `overscroll-behavior:
241/// contain`, where [`claim_wheel`] is the chaining kind.
242///
243/// For a box with a cap on it, where the content is a program's output rather
244/// than a document: it is a window onto something, and a wheel over a window
245/// belongs to what is inside it. Chaining asks the pane to prove it moved,
246/// which it reads off a handle carrying the previous frame's layout — under a
247/// pane whose content is still arriving that reads as "did not move", and the
248/// page takes the wheel while the box is still scrolling (user report,
249/// DEV-13).
250///
251/// The page is still reachable: move the pointer off the box.
252pub fn contain_wheel<E: gpui::InteractiveElement>(el: E, axes: Axes) -> E {
253 el.on_scroll_wheel(move |event, window, cx| {
254 let delta = event.delta.pixel_delta(window.line_height());
255 // The dominant axis, not "any horizontal component": a trackpad puts a
256 // little of both into every gesture, and a mostly-vertical one still
257 // belongs to the page.
258 let sideways = delta.x.abs() > delta.y.abs();
259 if (sideways && axes.horizontal()) || (!sideways && axes.vertical()) {
260 cx.stop_propagation();
261 }
262 })
263}