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.
7//!
8//! gpui scrolls that pane perfectly well and draws nothing while it does, so a
9//! bezel app has no way to show how far down it is. That is [`scrollbar`]: an
10//! overlay the caller lays over its own pane, because a wrapper that swallowed
11//! the content would have to re-implement layout for it. Nesting two panes is
12//! [`claim_wheel`]'s business.
13//!
14//! ```ignore
15//! div().relative() // the bar is absolute in here
16//! .child(
17//! scroll::pane("pane", Axes::Vertical)
18//! .size_full()
19//! .track_scroll(&self.scroll) // gpui's handle, the app's field
20//! .child(content),
21//! )
22//! .child(scroll::scrollbar("pane-bar", &self.scroll, &self.scroll_bar))
23//! ```
24//!
25//! The bar must span the container it reports on — its track *is* the viewport,
26//! in the coordinates [`thumb`] answers in.
27//!
28//! The geometry is transcribed from zed's own scrollbar (`thumb_ranges` in
29//! `crates/ui/src/components/scrollbar.rs`), which is 1722 lines of settings
30//! system around the fifteen that matter. Two of gpui's conventions are easy to
31//! get backwards and both are load-bearing here: `max_offset` is the *overflow*
32//! (content minus viewport, not content), and `offset` is **negative** as you
33//! scroll down.
34//!
35//! [`transient`] is the same bar, shown only while its content moves.
36//! [`Overlay`] manages its own state and supports either axis. Its default is
37//! [`Visibility::Scrolling`]; [`crate::AppExt::set_scrollbar_visibility`] updates all default overlays,
38//! including Markdown code blocks and tables. [`Viewport`] also owns the handle.
39
40mod bar;
41mod claim;
42mod drift;
43mod follow;
44pub(crate) mod overlay;
45mod scroller;
46
47pub use bar::*;
48pub use claim::*;
49pub use drift::*;
50pub use follow::*;
51pub use overlay::{Overlay, Viewport, Visibility};
52pub use scroller::Scroller;
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/// A horizontal pane also contains a sideways gesture ([`contain_sideways`]).
140///
141/// `Axes::Both` inherits gpui's dominant-axis lock — a diagonal gesture moves
142/// one axis, not two. gpui exposes no builder for `allow_concurrent_scroll`.
143pub fn pane(id: impl Into<ElementId>, axes: Axes) -> Stateful<Div> {
144 scrolls(div().id(id), axes)
145}
146
147/// [`pane`], keeping the wheel it can act on: the pane a consumer nests inside
148/// another and never wires a handle to.
149///
150/// [`claim_wheel`] asks the caller for a [`ScrollHandle`] and a [`ClaimState`],
151/// because the caller usually has the handle already — it is scrolling the pane
152/// from elsewhere. A bounded box inside someone else's page has neither, and a
153/// pane that is only ever read by the wheel that moves it should not make a
154/// consumer hold two fields to stop it dragging the page behind it. Both live
155/// in keyed element state here, so the pane is still one call.
156///
157/// ```ignore
158/// scroll::claiming_pane("output", Axes::Vertical, window, cx).child(text)
159/// ```
160///
161/// The chaining is [`claim_wheel`]'s: at its ends the pane hands the wheel back
162/// to the page.
163pub fn claiming_pane(
164 id: impl Into<ElementId>,
165 axes: Axes,
166 window: &mut Window,
167 cx: &mut App,
168) -> Stateful<Div> {
169 let id = id.into();
170 let held = window.use_keyed_state(id.clone(), cx, |_, _| Claiming::default());
171 let (handle, state) = {
172 let held = held.read(cx);
173 (held.handle.clone(), held.state.clone())
174 };
175 claim_wheel(pane(id, axes).track_scroll(&handle), &handle, axes, &state)
176}
177
178/// What [`claiming_pane`] keeps between frames: the handle it reads its own
179/// travel off, and where that travel stood before the wheel being dispatched.
180#[derive(Default)]
181struct Claiming {
182 handle: ScrollHandle,
183 state: ClaimState,
184}
185
186/// [`pane`]'s answer applied to an element that already exists — a container
187/// that scrolls only at some widths, or one another builder handed back.
188///
189/// ```ignore
190/// strip.when(compact, |strip| scroll::scrolls(strip, Axes::Horizontal))
191/// ```
192pub fn scrolls<E: gpui::StatefulInteractiveElement>(el: E, axes: Axes) -> E {
193 let el = match axes {
194 Axes::Vertical => el.overflow_y_scroll(),
195 Axes::Horizontal => el.overflow_x_scroll(),
196 Axes::Both => el.overflow_scroll(),
197 };
198 match axes.horizontal() {
199 true => contain_sideways(el),
200 false => el,
201 }
202}
203
204/// Keep a sideways gesture inside the pane it started in.
205///
206/// The other half of [`pane`], and the half [`Axes::Vertical`] does not want:
207/// a vertical pane at its end should hand the wheel to the page behind it
208/// ([`claim_wheel`] is that chaining), but a sideways gesture bubbles on past
209/// a pane that moved and pans any ancestor that scrolls sideways too.
210///
211/// Applied by [`pane`] for the axes that need it. Public because a consumer
212/// wrapping bezel's content in a scroller of its own has the same problem and
213/// the same fix.
214///
215/// Registered before the element's own handler and so run after it — gpui
216/// bubbles the list backwards — which is why the pane has already moved by the
217/// time the event stops here.
218pub fn contain_sideways<E: gpui::InteractiveElement>(el: E) -> E {
219 contain_wheel(el, Axes::Horizontal)
220}
221
222/// Keep every wheel inside the pane it landed on — `overscroll-behavior:
223/// contain`, where [`claim_wheel`] is the chaining kind.
224///
225/// For a box with a cap on it, where the content is a program's output rather
226/// than a document: it is a window onto something, and a wheel over a window
227/// belongs to what is inside it. Chaining asks the pane to prove it moved,
228/// which it reads off a handle carrying the previous frame's layout — under a
229/// pane whose content is still arriving that reads as "did not move", and the
230/// page takes the wheel while the box is still scrolling (user report,
231/// DEV-13).
232///
233/// The page is still reachable: move the pointer off the box.
234pub fn contain_wheel<E: gpui::InteractiveElement>(el: E, axes: Axes) -> E {
235 el.on_scroll_wheel(move |event, window, cx| {
236 let delta = event.delta.pixel_delta(window.line_height());
237 // The dominant axis, not "any horizontal component": a trackpad puts a
238 // little of both into every gesture, and a mostly-vertical one still
239 // belongs to the page.
240 let sideways = delta.x.abs() > delta.y.abs();
241 if (sideways && axes.horizontal()) || (!sideways && axes.vertical()) {
242 cx.stop_propagation();
243 }
244 })
245}