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