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