Skip to main content

guise/
scrollbar.rs

1//! `Scrollbar` — a draggable thumb over anything that tracks a `ScrollHandle`.
2//!
3//! gpui scrolls a container but draws nothing for it, so a long list gives no
4//! hint of how much there is or that the end has been reached. `ScrollArea`
5//! carries one of these by default; use `Scrollbar` directly for a container
6//! you scroll yourself: give the container `.track_scroll(&handle)`, put it in
7//! a `.relative()` parent, and add `Scrollbar::new(id, &handle)` beside it. A
8//! virtualized `list(state, ..)` has no `ScrollHandle`; `Scrollbar::for_list`
9//! reads its `ListState` instead. The bar lays itself over the whole parent,
10//! draws nothing while the content fits, and follows the source — including
11//! when the wheel or a program moves it.
12//!
13//! The geometry is pure functions over three lengths (viewport, scrollable
14//! extent, track) so it can be tested without a window.
15
16use gpui::prelude::*;
17use gpui::{
18  div, point, px, Axis, Bounds, ElementId, Entity, IntoElement, ListState, MouseButton,
19  MouseDownEvent, Pixels, Render, ScrollHandle, Window,
20};
21
22use crate::devtools::Probed;
23use crate::theme::{theme, Size};
24
25/// Shortest thumb, so a very long document leaves something to grab.
26const MIN_THUMB: f32 = 24.0;
27
28/// Where the thumb sits along a track: `(start, len)` in px, or `None` when
29/// nothing overflows and there is nothing to draw. `offset` is how far the
30/// content has scrolled (positive), `max` how far it can.
31pub(crate) fn thumb(viewport: f32, max: f32, offset: f32, track: f32) -> Option<(f32, f32)> {
32  if max <= 0.5 || viewport <= 0.0 || track <= 0.0 {
33    return None;
34  }
35  let len = (track * viewport / (viewport + max))
36    .max(MIN_THUMB)
37    .min(track);
38  let travel = track - len;
39  let start = travel * (offset / max).clamp(0.0, 1.0);
40  Some((start, len))
41}
42
43/// The scroll offset (positive, px) that puts the thumb's start at `pos` along
44/// the track — the inverse of [`thumb`], used while dragging.
45pub(crate) fn offset_for(pos: f32, max: f32, track: f32, len: f32) -> f32 {
46  let travel = track - len;
47  if travel <= 0.0 {
48    return 0.0;
49  }
50  (pos / travel).clamp(0.0, 1.0) * max
51}
52
53/// The drag payload. `on_drag_move::<ScrollDrag>` is what keeps the events
54/// coming when the pointer leaves the thumb, but it fires for every bar on the
55/// page — so the payload names the bar being dragged and the others ignore it.
56struct ScrollDrag(ElementId);
57
58/// gpui shows the entity a drag constructs under the pointer; an empty one.
59struct Ghost;
60
61impl Render for Ghost {
62  fn render(&mut self, _window: &mut Window, _cx: &mut gpui::Context<Self>) -> impl IntoElement {
63    div()
64  }
65}
66
67/// What the bar reads and moves: a plain container's handle, or a virtualized
68/// list's state (which measures its items lazily and so needs the drag hints).
69#[derive(Clone)]
70enum Source {
71  Handle(ScrollHandle),
72  List(ListState),
73}
74
75impl Source {
76  fn bounds(&self) -> Bounds<Pixels> {
77    match self {
78      Source::Handle(h) => h.bounds(),
79      Source::List(l) => l.viewport_bounds(),
80    }
81  }
82
83  fn max_offset(&self) -> gpui::Size<Pixels> {
84    match self {
85      Source::Handle(h) => h.max_offset(),
86      Source::List(l) => l.max_offset_for_scrollbar(),
87    }
88  }
89
90  fn offset(&self) -> gpui::Point<Pixels> {
91    match self {
92      Source::Handle(h) => h.offset(),
93      Source::List(l) => l.scroll_px_offset_for_scrollbar(),
94    }
95  }
96
97  fn set_offset(&self, to: gpui::Point<Pixels>) {
98    match self {
99      Source::Handle(h) => h.set_offset(to),
100      Source::List(l) => l.set_offset_from_scrollbar(to),
101    }
102  }
103
104  fn drag(&self, started: bool) {
105    if let Source::List(l) = self {
106      if started {
107        l.scrollbar_drag_started();
108      } else {
109        l.scrollbar_drag_ended();
110      }
111    }
112  }
113}
114
115/// A scrollbar for a container tracked by a `ScrollHandle`, or for a `list`.
116/// Vertical unless `.horizontal(true)`.
117#[derive(IntoElement)]
118pub struct Scrollbar {
119  id: ElementId,
120  source: Source,
121  axis: Axis,
122}
123
124impl Scrollbar {
125  pub fn new(id: impl Into<ElementId>, handle: &ScrollHandle) -> Self {
126    Scrollbar {
127      id: id.into(),
128      source: Source::Handle(handle.clone()),
129      axis: Axis::Vertical,
130    }
131  }
132
133  /// A bar for a virtualized `list(state, ..)`, which scrolls by its
134  /// `ListState` rather than a `ScrollHandle`.
135  pub fn for_list(id: impl Into<ElementId>, state: &ListState) -> Self {
136    Scrollbar {
137      id: id.into(),
138      source: Source::List(state.clone()),
139      axis: Axis::Vertical,
140    }
141  }
142
143  /// Run along the bottom edge for a horizontally scrolling container.
144  pub fn horizontal(mut self, horizontal: bool) -> Self {
145    self.axis = if horizontal {
146      Axis::Horizontal
147    } else {
148      Axis::Vertical
149    };
150    self
151  }
152}
153
154/// `(viewport, extent, offset, track origin)` along `axis`, from the handle.
155fn measure(handle: &Source, axis: Axis) -> (f32, f32, f32, f32) {
156  let bounds: Bounds<Pixels> = handle.bounds();
157  let max = handle.max_offset();
158  let offset = handle.offset();
159  match axis {
160    Axis::Vertical => (
161      bounds.size.height.into(),
162      max.height.into(),
163      -f32::from(offset.y),
164      bounds.origin.y.into(),
165    ),
166    Axis::Horizontal => (
167      bounds.size.width.into(),
168      max.width.into(),
169      -f32::from(offset.x),
170      bounds.origin.x.into(),
171    ),
172  }
173}
174
175impl RenderOnce for Scrollbar {
176  fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
177    let axis = self.axis;
178    let vertical = axis == Axis::Vertical;
179    let (viewport, max, offset, origin) = measure(&self.source, axis);
180    // The handle learns its size when the container is first laid out, which
181    // is after this renders; ask for the frame that has it.
182    if viewport <= 0.0 {
183      window.request_animation_frame();
184    }
185    let Some((start, len)) = thumb(viewport, max, offset, viewport) else {
186      return div()
187        .probe("Scrollbar")
188        .attr("visible", "no")
189        .into_any_element();
190    };
191
192    let t = theme(cx);
193    let girth = t.spacing(Size::Sm);
194    let inset = t.spacing(Size::Xs) / 4.0;
195    let radius = t.radius(Size::Xl);
196    let rest = t.dimmed().hsla().opacity(0.45);
197    let active = t.dimmed().hsla().opacity(0.8);
198
199    // Where in the thumb the pointer took hold, so it doesn't jump to centre
200    // under the cursor when the drag starts.
201    let grab: Entity<f32> = window.use_keyed_state((self.id.clone(), "grab"), cx, |_, _| 0.0);
202
203    let pos = move |p: gpui::Point<Pixels>| -> f32 {
204      if vertical {
205        f32::from(p.y) - origin
206      } else {
207        f32::from(p.x) - origin
208      }
209    };
210
211    let handle = self.source.clone();
212    let on_track = {
213      let handle = handle.clone();
214      move |ev: &MouseDownEvent, window: &mut Window, _cx: &mut gpui::App| {
215        // A click on the bare track centres the thumb there.
216        let to = offset_for(pos(ev.position) - len / 2.0, max, viewport, len);
217        set(&handle, axis, to);
218        window.refresh();
219      }
220    };
221    let on_thumb = {
222      let grab = grab.clone();
223      let source = handle.clone();
224      move |ev: &MouseDownEvent, _window: &mut Window, cx: &mut gpui::App| {
225        let inside = pos(ev.position) - start;
226        source.drag(true);
227        grab.update(cx, |g, _| *g = inside);
228        cx.stop_propagation();
229      }
230    };
231    let on_move = {
232      let handle = handle.clone();
233      let grab = grab.clone();
234      let id = self.id.clone();
235      move |ev: &gpui::DragMoveEvent<ScrollDrag>, window: &mut Window, cx: &mut gpui::App| {
236        if ev.drag(cx).0 != id {
237          return;
238        }
239        let held = *grab.read(cx);
240        let to = offset_for(pos(ev.event.position) - held, max, viewport, len);
241        set(&handle, axis, to);
242        window.refresh();
243      }
244    };
245
246    let mut thumb = div()
247      .id((self.id.clone(), "thumb"))
248      .absolute()
249      .rounded(px(radius))
250      .bg(rest)
251      .hover(move |s| s.bg(active))
252      .on_mouse_down(MouseButton::Left, on_thumb)
253      .on_mouse_up(MouseButton::Left, {
254        let source = handle.clone();
255        move |_, _, _| source.drag(false)
256      })
257      .on_mouse_up_out(MouseButton::Left, {
258        let source = handle.clone();
259        move |_, _, _| source.drag(false)
260      })
261      .on_drag(ScrollDrag(self.id.clone()), |_, _, _, cx| cx.new(|_| Ghost));
262    thumb = if vertical {
263      thumb
264        .left(px(inset))
265        .right(px(inset))
266        .top(px(start))
267        .h(px(len))
268    } else {
269      thumb
270        .top(px(inset))
271        .bottom(px(inset))
272        .left(px(start))
273        .w(px(len))
274    };
275
276    let mut track = div()
277      .id(self.id)
278      .absolute()
279      .on_mouse_down(MouseButton::Left, on_track)
280      .on_drag_move::<ScrollDrag>(on_move)
281      .child(thumb);
282    track = if vertical {
283      track.top_0().bottom_0().right_0().w(px(girth))
284    } else {
285      track.left_0().right_0().bottom_0().h(px(girth))
286    };
287    track
288      .probe("Scrollbar")
289      .attr("visible", "yes")
290      .into_any_element()
291  }
292}
293
294/// Scroll to `offset` px (positive) along `axis`, leaving the other axis alone.
295fn set(handle: &Source, axis: Axis, offset: f32) {
296  let now = handle.offset();
297  handle.set_offset(match axis {
298    Axis::Vertical => point(now.x, px(-offset)),
299    Axis::Horizontal => point(px(-offset), now.y),
300  });
301}
302
303#[cfg(test)]
304mod tests {
305  use super::*;
306
307  #[test]
308  fn nothing_to_draw_when_the_content_fits() {
309    assert_eq!(thumb(400.0, 0.0, 0.0, 400.0), None);
310    assert_eq!(thumb(0.0, 100.0, 0.0, 0.0), None);
311  }
312
313  #[test]
314  fn thumb_is_the_visible_fraction_of_the_track() {
315    // 400 visible of 1600 total: a quarter of the track.
316    let (start, len) = thumb(400.0, 1200.0, 0.0, 400.0).unwrap();
317    assert_eq!(start, 0.0);
318    assert_eq!(len, 100.0);
319  }
320
321  #[test]
322  fn thumb_reaches_the_far_end_at_full_scroll() {
323    let (start, len) = thumb(400.0, 1200.0, 1200.0, 400.0).unwrap();
324    assert_eq!(start + len, 400.0);
325  }
326
327  #[test]
328  fn thumb_never_shrinks_below_a_grabbable_size() {
329    let (_, len) = thumb(400.0, 400_000.0, 0.0, 400.0).unwrap();
330    assert_eq!(len, MIN_THUMB);
331  }
332
333  #[test]
334  fn overscroll_is_clamped_onto_the_track() {
335    let (start, len) = thumb(400.0, 1200.0, 5000.0, 400.0).unwrap();
336    assert_eq!(start + len, 400.0);
337    let (start, _) = thumb(400.0, 1200.0, -50.0, 400.0).unwrap();
338    assert_eq!(start, 0.0);
339  }
340
341  #[test]
342  fn dragging_inverts_the_geometry() {
343    let (start, len) = thumb(400.0, 1200.0, 600.0, 400.0).unwrap();
344    let back = offset_for(start, 1200.0, 400.0, len);
345    assert!((back - 600.0).abs() < 0.01, "got {back}");
346    assert_eq!(offset_for(-30.0, 1200.0, 400.0, len), 0.0);
347    assert_eq!(offset_for(9000.0, 1200.0, 400.0, len), 1200.0);
348  }
349
350  #[test]
351  fn a_track_with_no_travel_stays_at_the_top() {
352    assert_eq!(offset_for(10.0, 100.0, 24.0, 24.0), 0.0);
353  }
354}