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