Skip to main content

ui/
scroll.rs

1//! A styled scrollbar over gpui's own scroll handles.
2//!
3//! gpui scrolls a `div` perfectly well and draws nothing while it does, so a
4//! bezel app has no way to show how far down it is. This is that bar, and only
5//! that bar: the caller keeps its own `overflow_y_scroll` container, because a
6//! wrapper that swallowed the content would have to re-implement layout for it.
7//!
8//! ```ignore
9//! div().relative()                                  // the bar is absolute in here
10//!     .child(
11//!         div()
12//!             .id("pane")
13//!             .size_full()
14//!             .overflow_y_scroll()
15//!             .track_scroll(&self.scroll)           // gpui's handle, the app's field
16//!             .child(content),
17//!     )
18//!     .child(scroll::scrollbar("pane-bar", &self.scroll, &self.scroll_bar))
19//! ```
20//!
21//! The bar must span the container it reports on — its track *is* the viewport,
22//! in the coordinates [`thumb`] answers in.
23//!
24//! The geometry is transcribed from zed's own scrollbar (`thumb_ranges` in
25//! `crates/ui/src/components/scrollbar.rs`), which is 1722 lines of settings
26//! system around the fifteen that matter. Two of gpui's conventions are easy to
27//! get backwards and both are load-bearing here: `max_offset` is the *overflow*
28//! (content minus viewport, not content), and `offset` is **negative** as you
29//! scroll down.
30//!
31//! [`transient`] is the same bar, shown only while its content moves.
32
33use std::{cell::Cell, ops::Range, rc::Rc, time::Duration};
34
35use gpui::{
36    Animation, AnimationExt, App, DragMoveEvent, ElementId, Empty, MouseButton, Pixels,
37    ScrollHandle, SharedString, Window, canvas, div, point, prelude::*, px,
38};
39
40use motion::Painter;
41use theme::ink;
42
43/// Shortest a thumb may get, however long the document — below this it stops
44/// being something a pointer can catch.
45pub const MIN_THUMB: Pixels = px(25.0);
46/// Width of the strip the thumb sits in.
47const TRACK: f32 = 10.0;
48/// Width of the thumb itself, centred in the track.
49const THUMB: f32 = 6.0;
50
51/// Where the thumb sits in a track of `viewport` length, as a range from the
52/// track's start — or `None` when there is nothing to scroll.
53///
54/// `None` also covers a viewport of zero (the frame before layout has run) and
55/// a thumb that would not fit, which is zed's third guard: with a viewport
56/// shorter than [`MIN_THUMB`] a bar would be all thumb and no travel.
57pub fn thumb(
58    viewport: Pixels,
59    max_offset: Pixels,
60    offset: Pixels,
61    min: Pixels,
62) -> Option<Range<Pixels>> {
63    if viewport <= px(0.0) || max_offset <= px(0.0) {
64        return None;
65    }
66    let content = viewport + max_offset;
67    let size = min.max(viewport * (viewport / content));
68    if size > viewport {
69        return None;
70    }
71    // Negative going down, and never past either end — a wheel can overshoot.
72    let travelled = offset.clamp(-max_offset, px(0.0)).abs();
73    let start = (travelled / max_offset) * (viewport - size);
74    Some(start..start + size)
75}
76
77/// The inverse: the scroll offset that puts the thumb's top at `top`.
78///
79/// Negative, because that is the direction gpui counts in, and clamped to the
80/// scrollable range so a drag past either end simply stops.
81pub fn offset_for_thumb(top: Pixels, viewport: Pixels, max_offset: Pixels, size: Pixels) -> Pixels {
82    let travel = viewport - size;
83    if travel <= px(0.0) || max_offset <= px(0.0) {
84        return px(0.0);
85    }
86    -(max_offset * (top / travel).clamp(0.0, 1.0))
87}
88
89/// The drag payload. Carries the bar's id because, unlike a split, an app has
90/// several of these on screen at once and `on_drag_move` filters by type alone
91/// — without the id every bar in the window would answer one thumb's gesture.
92#[derive(Clone)]
93pub struct ScrollbarDrag(pub SharedString);
94
95/// Where in the thumb a drag was grabbed.
96///
97/// Shaped like gpui's `ScrollHandle` — an `Rc` cell the view holds one field of
98/// and the bar clones — for the same reason: both mutate through `&self`, so
99/// the bar carries its whole gesture without the view wiring a single listener.
100/// Without it the thumb would jump its middle to the pointer on every press.
101#[derive(Clone)]
102pub struct ScrollbarState {
103    grab: Rc<Cell<Option<Pixels>>>,
104    /// A drag runs in event-dispatch context, where the window cannot resolve
105    /// which view is asking — so the bar carries its own.
106    painter: Painter,
107}
108
109impl ScrollbarState {
110    pub fn new(painter: Painter) -> Self {
111        Self {
112            grab: Rc::new(Cell::new(None)),
113            painter,
114        }
115    }
116
117    /// Whether a thumb drag is in flight.
118    pub fn dragging(&self) -> bool {
119        self.grab.get().is_some()
120    }
121
122    /// One drag move of the thumb: filter the gesture to this bar's track, then
123    /// translate the pointer into a scroll offset.
124    fn drag(
125        &self,
126        track_id: &SharedString,
127        handle: &ScrollHandle,
128        event: &DragMoveEvent<ScrollbarDrag>,
129        cx: &mut App,
130    ) {
131        // Another bar's thumb: `on_drag_move` filters by payload type, and
132        // every bar in the window shares this one.
133        if event.drag(cx).0 != *track_id {
134            return;
135        }
136        let viewport = handle.bounds().size.height;
137        let max_offset = handle.max_offset().y;
138        let Some(range) = thumb(viewport, max_offset, handle.offset().y, MIN_THUMB) else {
139            return;
140        };
141        let size = range.end - range.start;
142        let pointer = event.event.position.y - event.bounds.top();
143        // First move of this drag: the offset has not shifted yet, so the
144        // thumb is still where the press landed on it and the grab is
145        // simply the difference. Held for the rest of the gesture — read it
146        // again later and it would answer "wherever the pointer is now",
147        // which is a thumb that never moves.
148        let grab = self.grab.get().unwrap_or_else(|| {
149            let grab = (pointer - range.start).clamp(px(0.0), size);
150            self.grab.set(Some(grab));
151            grab
152        });
153        let offset = offset_for_thumb(pointer - grab, viewport, max_offset, size);
154        handle.set_offset(point(handle.offset().x, offset));
155        self.painter.notify(cx);
156    }
157}
158
159/// The bar: an overlay strip along the right edge of whatever it is laid over,
160/// showing nothing at all when the content fits.
161///
162/// Overlay rather than a gutter, so a bar arriving or leaving never reflows the
163/// content beneath it.
164///
165/// Its geometry comes from the handle as the *last* frame left it, which is all
166/// a render pass can see; the canvas at the end asks for one more frame when
167/// layout disagrees, so the bar is right on the frame after it first appears
168/// rather than whenever something else happens to repaint.
169///
170/// No `&Theme`, unlike most of this crate — a scrollbar is a neutral overlay
171/// rather than a toned surface, so the thumb is [`ink`], which already follows
172/// the appearance on its own. A parameter it ignored would be worse than none.
173pub fn scrollbar(
174    id: impl Into<SharedString>,
175    handle: &ScrollHandle,
176    state: &ScrollbarState,
177) -> gpui::AnyElement {
178    let id = id.into();
179    let viewport = handle.bounds().size.height;
180    let max_offset = handle.max_offset().y;
181    let Some(range) = thumb(viewport, max_offset, handle.offset().y, MIN_THUMB) else {
182        return Empty.into_any_element();
183    };
184    let size = range.end - range.start;
185    let dragging = state.dragging();
186
187    let track_id = id.clone();
188    let drag_handle = handle.clone();
189    let drag_state = state.clone();
190    let release_state = state.clone();
191    let released = move |_: &gpui::MouseUpEvent, _: &mut Window, _: &mut App| {
192        release_state.grab.set(None);
193    };
194
195    div()
196        .id(SharedString::from(format!("{id}-track")))
197        .absolute()
198        .top_0()
199        .right_0()
200        .bottom_0()
201        .w(px(TRACK))
202        .flex()
203        .justify_center()
204        .on_drag_move(move |event, _, cx| {
205            drag_state.drag(&track_id, &drag_handle, event, cx);
206        })
207        // Both, because a release can land anywhere on screen; a grab left set
208        // would make the next press continue the last gesture.
209        .on_mouse_up(MouseButton::Left, released.clone())
210        .on_mouse_up_out(MouseButton::Left, released)
211        .child(
212            div()
213                .id(SharedString::from(format!("{id}-thumb")))
214                .absolute()
215                .top(range.start)
216                .h(size)
217                .w(px(THUMB))
218                .rounded_full()
219                .bg(if dragging { ink(0.38) } else { ink(0.2) })
220                .hover(|s| s.bg(ink(0.32)))
221                .on_drag(ScrollbarDrag(id.clone()), |_, _, _, cx| cx.new(|_| Empty)),
222        )
223        .child(
224            canvas(
225                move |bounds, window, _| {
226                    // Laid out taller or shorter than the geometry above was
227                    // computed from: that geometry came from last frame's
228                    // handle. Ask for the frame that will paint it right.
229                    // Self-limiting — once they agree, nothing is requested.
230                    if (bounds.size.height - viewport).abs() > px(0.5) {
231                        window.request_animation_frame();
232                    }
233                },
234                |_, _, _, _| {},
235            )
236            .absolute()
237            .size_full(),
238        )
239        .into_any_element()
240}
241
242// ---------------------------------------------------------------------------
243// Transient — the same bar, only while the content moves
244// ---------------------------------------------------------------------------
245
246/// How long the thumb stays after the last scroll before fading — the idle
247/// window *is* the fade, because the fork's `Animation` has no delay.
248pub const TRANSIENT_IDLE: Duration = Duration::from_millis(1000);
249
250/// The show-and-fade state behind [`transient`]: the last frame's scroll
251/// state (a change is activity), a generation counter (a fresh animation id
252/// restarts the fade — `AnimationElement` pins its clock to the id it first
253/// laid out with), and the hover flag that holds the thumb up while the
254/// pointer is on the strip.
255#[derive(Clone)]
256pub struct TransientState {
257    cell: Rc<Cell<(Pixels, Pixels, u64, bool)>>,
258    bar: ScrollbarState,
259}
260
261impl TransientState {
262    pub fn new(painter: Painter) -> Self {
263        Self {
264            cell: Rc::new(Cell::new(Default::default())),
265            bar: ScrollbarState::new(painter),
266        }
267    }
268}
269
270/// The same bar as [`scrollbar`], but it only earns its place while the
271/// content moves: activity raises the thumb, and it fades out over
272/// [`TRANSIENT_IDLE`] once the scrolling stops. Hovering the strip or
273/// dragging the thumb holds it up. With `reduce_motion` there is nothing to
274/// animate, so it renders as the always-on bar.
275pub fn transient(
276    id: impl Into<SharedString>,
277    handle: &ScrollHandle,
278    state: &TransientState,
279    reduce_motion: bool,
280) -> gpui::AnyElement {
281    let id = id.into();
282    let viewport = handle.bounds().size.height;
283    let max_offset = handle.max_offset().y;
284    let Some(range) = thumb(viewport, max_offset, handle.offset().y, MIN_THUMB) else {
285        return Empty.into_any_element();
286    };
287    let size = range.end - range.start;
288
289    // Any change in the scroll state is activity: bump the generation so the
290    // fade restarts under a fresh animation id. The half-pixel slack keeps a
291    // sub-pixel layout jitter from re-showing a settled bar.
292    let mut cell = state.cell.get();
293    if (handle.offset().y - cell.0).abs() > px(0.5) || (max_offset - cell.1).abs() > px(0.5) {
294        cell.0 = handle.offset().y;
295        cell.1 = max_offset;
296        cell.2 += 1;
297        state.cell.set(cell);
298    }
299    let generation = cell.2;
300    let dragging = state.bar.dragging();
301
302    let track_id = id.clone();
303    let drag_handle = handle.clone();
304    let drag_state = state.clone();
305    let release_state = state.clone();
306    let released = move |_: &gpui::MouseUpEvent, _: &mut Window, _: &mut App| {
307        release_state.bar.grab.set(None);
308    };
309
310    let track = div()
311        .id(SharedString::from(format!("{id}-track")))
312        .absolute()
313        .top_0()
314        .right_0()
315        .bottom_0()
316        .w(px(TRACK))
317        .flex()
318        .justify_center()
319        .on_drag_move(move |event, _, cx| {
320            drag_state.bar.drag(&track_id, &drag_handle, event, cx);
321        })
322        // Both, because a release can land anywhere on screen; a grab left set
323        // would make the next press continue the last gesture.
324        .on_mouse_up(MouseButton::Left, released.clone())
325        .on_mouse_up_out(MouseButton::Left, released)
326        .map(|track| {
327            if reduce_motion {
328                track
329            } else {
330                // The thumb's stand-in at rest: hovering the strip raises it,
331                // and leaving starts its fade.
332                let hover_state = state.clone();
333                let hover_painter = state.bar.painter;
334                track.on_hover(move |hovered: &bool, _, cx: &mut App| {
335                    let mut cell = hover_state.cell.get();
336                    if cell.3 == *hovered {
337                        return;
338                    }
339                    cell.3 = *hovered;
340                    cell.2 += 1;
341                    hover_state.cell.set(cell);
342                    hover_painter.notify(cx);
343                })
344            }
345        });
346
347    let thumb = div()
348        .id(SharedString::from(format!("{id}-thumb")))
349        .absolute()
350        .top(range.start)
351        .h(size)
352        .w(px(THUMB))
353        .rounded_full()
354        .bg(if dragging { ink(0.38) } else { ink(0.2) })
355        .hover(|s| s.bg(ink(0.32)))
356        .on_drag(ScrollbarDrag(id.clone()), |_, _, _, cx| cx.new(|_| Empty));
357
358    let thumb: gpui::AnyElement = if reduce_motion {
359        thumb.into_any_element()
360    } else {
361        // The thumb's presence is the animation: progress 0 is fully up, and
362        // progress 1 — a full idle window later — is hidden, so no frame is
363        // requested once the fade completes.
364        let anim = state.clone();
365        thumb
366            .with_animation(
367                ElementId::from(format!("{id}-fade-{generation}")),
368                Animation::new(TRANSIENT_IDLE),
369                move |el, p| {
370                    let cell = anim.cell.get();
371                    if cell.3 || anim.bar.dragging() {
372                        el
373                    } else if p < 1.0 {
374                        el.opacity(1.0 - p)
375                    } else {
376                        el.hidden()
377                    }
378                },
379            )
380            .into_any_element()
381    };
382
383    track
384        .child(thumb)
385        .child(
386            canvas(
387                move |bounds, window, _| {
388                    // Laid out taller or shorter than the geometry above was
389                    // computed from: that geometry came from last frame's
390                    // handle. Ask for the frame that will paint it right.
391                    // Self-limiting — once they agree, nothing is requested.
392                    if (bounds.size.height - viewport).abs() > px(0.5) {
393                        window.request_animation_frame();
394                    }
395                },
396                |_, _, _, _| {},
397            )
398            .absolute()
399            .size_full(),
400        )
401        .into_any_element()
402}
403
404// ---------------------------------------------------------------------------
405// Follow — a view pinned to the bottom of content that grows under it
406// ---------------------------------------------------------------------------
407
408/// How close to the bottom still counts as following. A wheel lands on
409/// fractional offsets and a re-layout can move the end by a hair; without slack
410/// a view would unpin itself for a rounding error nobody asked for.
411pub const FOLLOW_SLACK: Pixels = px(4.0);
412
413/// Whether `offset` is at the end of the scrollable range, within `slack`.
414///
415/// Both of gpui's conventions bite here, so: `max_offset` is the *overflow* and
416/// `offset` is **negative** going down, which makes the distance still to go
417/// `max_offset - |offset|`. Content that fits is always "at the bottom" — there
418/// is nowhere else to be, and answering `false` would unpin an empty log.
419pub fn at_bottom(max_offset: Pixels, offset: Pixels, slack: Pixels) -> bool {
420    if max_offset <= px(0.0) {
421        return true;
422    }
423    let travelled = offset.clamp(-max_offset, px(0.0)).abs();
424    max_offset - travelled <= slack
425}
426
427/// Whether a [`follow`] view is still pinned, and the overflow it last saw.
428///
429/// Shaped like [`ScrollbarState`] and for the same reason: it mutates through
430/// `&self`, so the element carries the whole behaviour without the view wiring
431/// a listener. Starts pinned — a transcript or a log opens on its newest line.
432#[derive(Clone)]
433pub struct FollowState(Rc<Cell<(bool, Pixels)>>);
434
435impl Default for FollowState {
436    fn default() -> Self {
437        Self(Rc::new(Cell::new((true, px(0.0)))))
438    }
439}
440
441impl FollowState {
442    pub fn new() -> Self {
443        Self::default()
444    }
445
446    /// Whether the view is following. An app shows its "jump to latest" affordance
447    /// on `!following()`, which is the only reason this is public.
448    pub fn following(&self) -> bool {
449        self.0.get().0
450    }
451
452    /// Re-pin. What that "jump to latest" button calls; the next frame does the
453    /// scrolling.
454    pub fn follow(&self) {
455        let (_, last) = self.0.get();
456        self.0.set((true, last));
457    }
458}
459
460/// Keep `handle` pinned to the bottom of its content while the user leaves it
461/// there, and get out of the way the moment they scroll up.
462///
463/// Drop it in beside [`scrollbar`], over the same container:
464///
465/// ```ignore
466/// div().relative()
467///     .child(div().id("log").size_full().overflow_y_scroll().track_scroll(&self.scroll).child(rows))
468///     .child(scroll::follow(&self.scroll, &self.follow))
469///     .child(scroll::scrollbar("log-bar", &self.scroll, &self.bar))
470/// ```
471///
472/// **Telling appended content from a user scroll is the whole problem**, and
473/// neither is an event this can subscribe to — both surface as the same handle
474/// reading differently than last frame. The overflow is what separates them: if
475/// it changed, the content grew and the pin is left as the user last set it; if
476/// it did not, the offset moved because the *user* moved it, and being at the
477/// end is what re-pins. So scrolling up releases, and scrolling back down
478/// re-attaches, with no gesture to hook.
479///
480/// The correction lands a frame late — the scrolling div was laid out with the
481/// old offset before this runs — which is why it asks for that frame. At a
482/// streaming cadence it is invisible, and it converges rather than spinning:
483/// once pinned and at the end, nothing is requested.
484pub fn follow(handle: &ScrollHandle, state: &FollowState) -> gpui::AnyElement {
485    let handle = handle.clone();
486    let state = state.clone();
487    canvas(
488        move |_, window, _| {
489            let max_offset = handle.max_offset().y;
490            let offset = handle.offset().y;
491            let (was_pinned, last_max) = state.0.get();
492
493            let pinned = if (max_offset - last_max).abs() > px(0.5) {
494                was_pinned
495            } else {
496                at_bottom(max_offset, offset, FOLLOW_SLACK)
497            };
498
499            if pinned && (offset + max_offset).abs() > px(0.5) {
500                handle.set_offset(point(handle.offset().x, -max_offset));
501                window.request_animation_frame();
502            }
503            state.0.set((pinned, max_offset));
504        },
505        |_, _, _, _| {},
506    )
507    .absolute()
508    .size_full()
509    .into_any_element()
510}