Skip to main content

herogpui_components/
scrollbar.rs

1//! Painted scrollbar — the GPUI stand-in for HeroUI's CSS `scrollbar` token.
2//!
3//! GPUI's `overflow_*_scroll` moves content and `scrollbar_width` only
4//! reserves gutter space; it paints nothing. This overlay reads a
5//! [`ScrollHandle`] and draws a thumb in `--scrollbar`. Overlay versus
6//! always-visible follows [`gpui::App::should_auto_hide_scrollbars`],
7//! unless [`Scrollbar::auto_hide`] pins the thumb visible the way a
8//! styled webkit scrollbar is.
9//!
10//! HeroUI styles scrollbars through CSS, so there is no v3 prop to port:
11//! the instance-level builders here ([`Scrollbar::track`],
12//! [`Scrollbar::inset`], [`Scrollbar::radius`], [`Scrollbar::thumb_color`],
13//! [`Scrollbar::thumb_hover_color`], [`Scrollbar::auto_hide`]) and the
14//! [`Scrollbar::sx`] slot are repository extensions, following the same
15//! `sx`-ownership contract as every other component.
16
17use std::time::{Duration, Instant};
18
19use gpui::{
20    canvas, div, prelude::*, px, App, Bounds, Div, ElementId, Hsla, IntoElement, MouseButton,
21    MouseDownEvent, MouseMoveEvent, Pixels, Point, RenderOnce, ScrollHandle, Window,
22};
23use herogpui_core::{element_id, Orientation};
24use herogpui_theme::ActiveTheme;
25
26const THUMB_MIN: f32 = 24.0;
27const TRACK: f32 = 8.0;
28const IDLE: Duration = Duration::from_millis(800);
29
30#[derive(Clone, Copy)]
31struct Drag {
32    pointer: f32,
33    offset: f32,
34}
35
36#[derive(Clone)]
37struct BarState {
38    drag: Option<Drag>,
39    hover: bool,
40    last_offset: f32,
41    last_moved: Option<Instant>,
42}
43
44impl Default for BarState {
45    fn default() -> Self {
46        Self {
47            drag: None,
48            hover: false,
49            last_offset: 0.0,
50            last_moved: None,
51        }
52    }
53}
54
55/// A painted overlay thumb bound to one [`ScrollHandle`].
56#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
57#[derive(IntoElement)]
58pub struct Scrollbar {
59    id: ElementId,
60    handle: ScrollHandle,
61    orientation: Orientation,
62    /// Track thickness, in place of the private 8px default.
63    track: Option<Pixels>,
64    /// Shrink the painted thumb from every edge. `None` paints edge to edge.
65    inset: Option<Pixels>,
66    /// Thumb corner radius, in place of half the effective track.
67    radius: Option<Pixels>,
68    /// Thumb fill, in place of the `--scrollbar` token.
69    thumb_color: Option<Hsla>,
70    /// The fill the thumb takes while the pointer is anywhere over the bar's
71    /// track — the thumb itself paints without a hitbox, so there is no
72    /// thumb-only hover. `None` keeps the thumb static on hover.
73    thumb_hover_color: Option<Hsla>,
74    /// Whether the platform's auto-hide preference may hide the thumb.
75    auto_hide: bool,
76    /// The `sx` slot, refined over the root style at the end of render.
77    sx: Option<Box<gpui::StyleRefinement>>,
78}
79
80impl Scrollbar {
81    /// Creates a scrollbar for the given scroll handle.
82    pub fn new(id: impl Into<ElementId>, handle: ScrollHandle) -> Self {
83        Self {
84            id: id.into(),
85            handle,
86            orientation: Orientation::Vertical,
87            track: None,
88            inset: None,
89            radius: None,
90            thumb_color: None,
91            thumb_hover_color: None,
92            auto_hide: true,
93            sx: None,
94        }
95    }
96
97    /// Sets the scrollbar orientation.
98    pub fn orientation(mut self, orientation: Orientation) -> Self {
99        self.orientation = orientation;
100        self
101    }
102
103    /// The track thickness, in place of the 8px box this overlay has always
104    /// drawn. The thumb's default corner radius derives from the effective
105    /// thickness, so overriding the track alone re-derives the stock pill
106    /// shape. A definite cross-axis `sx` size (`h` on a horizontal bar, `w`
107    /// on a vertical one) wins over this builder, matching the shared
108    /// theme-default → instance → `sx` order.
109    pub fn track(mut self, thickness: impl Into<Pixels>) -> Self {
110        self.track = Some(thickness.into());
111        self
112    }
113
114    /// Shrink the painted thumb from every edge of its box — the webkit
115    /// `background-clip: content-box` look. The default inset of 0 paints
116    /// the thumb edge to edge, as today. The inset is visual only: hit
117    /// testing and thumb length still use the unpainted box.
118    pub fn inset(mut self, inset: impl Into<Pixels>) -> Self {
119        self.inset = Some(inset.into());
120        self
121    }
122
123    /// The thumb's corner radius, in place of half the effective track.
124    /// Per-corner `sx` radii win over this builder corner by corner.
125    /// Not a v3 prop; this overlay is a repository extension and this is a
126    /// per-component repository extension in the same sense as
127    /// `Button::radius`.
128    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
129        self.radius = Some(radius.into());
130        self
131    }
132
133    /// The thumb fill, in place of the `--scrollbar` token. An `sx`
134    /// background outranks it: the thumb paints the bar's only fill, so per
135    /// the `sx`-ownership contract the override is its resting colour.
136    pub fn thumb_color(mut self, color: impl Into<Hsla>) -> Self {
137        self.thumb_color = Some(color.into());
138        self
139    }
140
141    /// The fill the thumb takes while the pointer is anywhere over the bar's
142    /// track, in place of today's static colour — the overlay never restyled
143    /// on hover before this builder, and leaving it unset keeps exactly
144    /// that. Unlike a webkit `::-webkit-scrollbar-thumb:hover` rule, the
145    /// swap is not thumb-only: the thumb paints at paint time with no
146    /// hitbox of its own, so the gate is the track root's hover. The swap
147    /// is also immediate, not a transition — the thumb is painted from the
148    /// scroll handle outside gpui's style hover system.
149    pub fn thumb_hover_color(mut self, color: impl Into<Hsla>) -> Self {
150        self.thumb_hover_color = Some(color.into());
151        self
152    }
153
154    /// Whether the OS preference may hide the thumb when the content is
155    /// idle. `auto_hide(false)` pins the thumb visible the way a styled
156    /// webkit scrollbar is, bypassing
157    /// [`gpui::App::should_auto_hide_scrollbars`]; `true` or unset keeps the
158    /// stock behavior.
159    pub fn auto_hide(mut self, v: bool) -> Self {
160        self.auto_hide = v;
161        self
162    }
163
164    /// The one slot for caller-owned low-level styling: GPUI's styling
165    /// methods (`bg`, `w`, `h`, `rounded`, …) applied to the track root
166    /// after every value the theme and the instance builders chose, so they
167    /// win. Per the shared `sx`-ownership contract the thumb — the bar's
168    /// only painted part — reads the override back: an `sx` background is
169    /// the thumb's resting fill (it also paints the transparent gutter, so
170    /// [`Scrollbar::thumb_color`] is the seam that moves only the thumb), a
171    /// definite cross-axis `w`/`h` is the track thickness, and per-corner
172    /// radii shape the thumb.
173    pub fn sx(mut self, style: impl FnOnce(Div) -> Div) -> Self {
174        crate::util::refine_sx(&mut self.sx, style);
175        self
176    }
177}
178
179impl RenderOnce for Scrollbar {
180    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
181        let handle = self.handle;
182        let horizontal = self.orientation.is_horizontal();
183        let max = if horizontal {
184            f32::from(handle.max_offset().x)
185        } else {
186            f32::from(handle.max_offset().y)
187        };
188        if max < 1.0 {
189            return div().into_any_element();
190        }
191
192        let offset = if horizontal {
193            f32::from(handle.offset().x)
194        } else {
195            f32::from(handle.offset().y)
196        };
197        let state = window.use_keyed_state(element_id::scoped(&self.id, "bar"), cx, |_, _| {
198            BarState::default()
199        });
200        let snapshot = state.read(cx).clone();
201        if (snapshot.last_offset - offset).abs() > 0.5 {
202            state.update(cx, |s, _| {
203                s.last_offset = offset;
204                s.last_moved = Some(Instant::now());
205            });
206        }
207
208        let platform_auto_hide = cx.should_auto_hide_scrollbars();
209        let live = state.read(cx);
210        let recently_moved = live.last_moved.is_some_and(|at| at.elapsed() < IDLE);
211        let show_thumb = thumb_visible(
212            self.auto_hide,
213            platform_auto_hide,
214            live.hover,
215            live.drag.is_some(),
216            recently_moved,
217        );
218        let hovered = live.hover;
219        let token = cx.colors().scrollbar;
220
221        // `sx` ownership (components.md): the thumb paints the bar's only
222        // fill, so the `sx` background is its resting colour — ahead of the
223        // instance override — and the hover endpoint is resolved against
224        // that resting value, so a bare `sx` background cannot be eased
225        // back over.
226        let sx_background = crate::util::sx_background(&self.sx);
227        let resting = sx_background.or(self.thumb_color).unwrap_or(token);
228        let (idle_fill, hover_fill) = crate::util::fade_endpoints(
229            Some((resting, resting)),
230            sx_background,
231            self.thumb_hover_color,
232        )
233        .unwrap_or((resting, resting));
234        let thumb_fill = if hovered { hover_fill } else { idle_fill };
235
236        // The track box: a definite cross-axis `sx` size is the thickness,
237        // then the instance `track`, then the private const. The default
238        // radius derives from the effective thickness, which is what keeps
239        // the stock pill shape when only the track is overridden.
240        let sx_size = crate::util::sx_pixel_size(&self.sx);
241        let thickness = if horizontal {
242            sx_size.height.or(self.track).unwrap_or_else(|| px(TRACK))
243        } else {
244            sx_size.width.or(self.track).unwrap_or_else(|| px(TRACK))
245        };
246        let default_radius = px(f32::from(thickness) / 2.0);
247        let corners = crate::util::fill_unspecified_corners(
248            crate::util::sx_radius(&self.sx),
249            self.radius.or(Some(default_radius)),
250        );
251        let corner_radii = gpui::Corners {
252            top_left: corners.top_left.unwrap_or(default_radius),
253            top_right: corners.top_right.unwrap_or(default_radius),
254            bottom_right: corners.bottom_right.unwrap_or(default_radius),
255            bottom_left: corners.bottom_left.unwrap_or(default_radius),
256        };
257        let inset = self.inset.unwrap_or_else(|| px(0.));
258        let axis = self.orientation;
259        let handle_move = handle.clone();
260        let drag_state = state.clone();
261        let hover_state = state.clone();
262        let leave_state = state.clone();
263
264        let mut track = div()
265            .id(self.id)
266            .absolute()
267            .overflow_hidden()
268            .when(horizontal, |el| {
269                el.left_0().right_0().bottom_0().h(thickness)
270            })
271            .when(!horizontal, |el| {
272                el.top_0().bottom_0().right_0().w(thickness)
273            })
274            .on_mouse_down(MouseButton::Left, {
275                let handle = handle.clone();
276                let state = state.clone();
277                move |ev: &MouseDownEvent, window, cx| {
278                    jump_or_grab(&handle, axis, ev.position, &state, window, cx);
279                }
280            })
281            .on_mouse_move({
282                let handle = handle_move;
283                let state = drag_state;
284                move |ev: &MouseMoveEvent, window, cx| {
285                    drag_to(&handle, axis, ev.position, &state, window, cx);
286                }
287            })
288            .on_mouse_up(MouseButton::Left, {
289                let state = state.clone();
290                move |_, _, cx| {
291                    state.update(cx, |s, cx| {
292                        s.drag = None;
293                        cx.notify();
294                    });
295                }
296            })
297            .on_hover(move |inside, _, cx| {
298                if *inside {
299                    hover_state.update(cx, |s, cx| {
300                        s.hover = true;
301                        cx.notify();
302                    });
303                } else {
304                    leave_state.update(cx, |s, cx| {
305                        s.hover = false;
306                        cx.notify();
307                    });
308                }
309            });
310
311        if show_thumb {
312            let paint_handle = handle;
313            track = track.child(
314                canvas(
315                    move |bounds, _, _| bounds,
316                    move |bounds, _, window, _| {
317                        if let Some(thumb_bounds) = thumb_bounds(&paint_handle, axis, bounds) {
318                            let painted = inset_bounds(thumb_bounds, inset);
319                            window.paint_quad(
320                                gpui::fill(painted, thumb_fill).corner_radii(corner_radii),
321                            );
322                        }
323                    },
324                )
325                .size_full(),
326            );
327        }
328
329        crate::util::apply_sx(track, &self.sx).into_any_element()
330    }
331}
332
333/// Whether the thumb paints this frame.
334///
335/// `auto_hide` is the caller's [`Scrollbar::auto_hide`]; when it is `false`
336/// the platform preference is bypassed outright. Otherwise a platform that
337/// auto-hides still shows the thumb while the pointer is over the bar, a
338/// drag is live, or the content moved recently.
339fn thumb_visible(
340    auto_hide: bool,
341    platform_auto_hide: bool,
342    hover: bool,
343    dragging: bool,
344    recently_moved: bool,
345) -> bool {
346    !auto_hide || !platform_auto_hide || hover || dragging || recently_moved
347}
348
349/// Shrinks a thumb box by `inset` on every side, collapsing to an empty box
350/// rather than a negative-sized one when the inset exhausts an axis.
351fn inset_bounds(bounds: Bounds<Pixels>, inset: Pixels) -> Bounds<Pixels> {
352    let inset = f32::from(inset);
353    Bounds {
354        origin: Point {
355            x: bounds.origin.x + px(inset),
356            y: bounds.origin.y + px(inset),
357        },
358        size: gpui::Size {
359            width: px((f32::from(bounds.size.width) - 2.0 * inset).max(0.0)),
360            height: px((f32::from(bounds.size.height) - 2.0 * inset).max(0.0)),
361        },
362    }
363}
364
365fn axis_size(bounds: Bounds<Pixels>, horizontal: bool) -> f32 {
366    if horizontal {
367        f32::from(bounds.size.width)
368    } else {
369        f32::from(bounds.size.height)
370    }
371}
372
373fn axis_origin(bounds: Bounds<Pixels>, horizontal: bool) -> f32 {
374    if horizontal {
375        f32::from(bounds.origin.x)
376    } else {
377        f32::from(bounds.origin.y)
378    }
379}
380
381fn thumb_metrics(
382    handle: &ScrollHandle,
383    axis: Orientation,
384    track: Bounds<Pixels>,
385) -> Option<(f32, f32, f32)> {
386    let horizontal = axis.is_horizontal();
387    let max = if horizontal {
388        f32::from(handle.max_offset().x)
389    } else {
390        f32::from(handle.max_offset().y)
391    };
392    if max < 1.0 {
393        return None;
394    }
395    let offset = if horizontal {
396        f32::from(handle.offset().x)
397    } else {
398        f32::from(handle.offset().y)
399    };
400    let track_len = axis_size(track, horizontal);
401    if track_len < 1.0 {
402        return None;
403    }
404    let content = track_len + max;
405    let thumb_len = (track_len * (track_len / content))
406        .max(THUMB_MIN)
407        .min(track_len);
408    let travel = (track_len - thumb_len).max(0.0);
409    let t = (-offset / max).clamp(0.0, 1.0);
410    Some((thumb_len, travel * t, max))
411}
412
413fn thumb_bounds(
414    handle: &ScrollHandle,
415    axis: Orientation,
416    track: Bounds<Pixels>,
417) -> Option<Bounds<Pixels>> {
418    let (thumb_len, thumb_start, _) = thumb_metrics(handle, axis, track)?;
419    let horizontal = axis.is_horizontal();
420    Some(if horizontal {
421        Bounds {
422            origin: Point {
423                x: track.origin.x + px(thumb_start),
424                y: track.origin.y,
425            },
426            size: gpui::Size {
427                width: px(thumb_len),
428                height: track.size.height,
429            },
430        }
431    } else {
432        Bounds {
433            origin: Point {
434                x: track.origin.x,
435                y: track.origin.y + px(thumb_start),
436            },
437            size: gpui::Size {
438                width: track.size.width,
439                height: px(thumb_len),
440            },
441        }
442    })
443}
444
445fn jump_or_grab(
446    handle: &ScrollHandle,
447    axis: Orientation,
448    pointer: Point<Pixels>,
449    state: &gpui::Entity<BarState>,
450    window: &mut Window,
451    cx: &mut App,
452) {
453    let track = handle.bounds();
454    let Some((thumb_len, thumb_start, max)) = thumb_metrics(handle, axis, track) else {
455        return;
456    };
457    let horizontal = axis.is_horizontal();
458    let pointer_v = if horizontal {
459        f32::from(pointer.x)
460    } else {
461        f32::from(pointer.y)
462    };
463    let origin = axis_origin(track, horizontal);
464    let rel = pointer_v - origin;
465    let offset = if horizontal {
466        f32::from(handle.offset().x)
467    } else {
468        f32::from(handle.offset().y)
469    };
470    if rel >= thumb_start && rel <= thumb_start + thumb_len {
471        state.update(cx, |s, cx| {
472            s.drag = Some(Drag {
473                pointer: pointer_v,
474                offset,
475            });
476            cx.notify();
477        });
478        return;
479    }
480    let travel = (axis_size(track, horizontal) - thumb_len).max(1.0);
481    let t = ((rel - thumb_len / 2.0) / travel).clamp(0.0, 1.0);
482    set_axis_offset(handle, axis, -t * max);
483    state.update(cx, |s, cx| {
484        s.last_offset = -t * max;
485        s.last_moved = Some(Instant::now());
486        cx.notify();
487    });
488    let _ = window;
489}
490
491fn drag_to(
492    handle: &ScrollHandle,
493    axis: Orientation,
494    pointer: Point<Pixels>,
495    state: &gpui::Entity<BarState>,
496    _window: &mut Window,
497    cx: &mut App,
498) {
499    let Some(drag) = state.read(cx).drag else {
500        return;
501    };
502    let track = handle.bounds();
503    let Some((thumb_len, _, max)) = thumb_metrics(handle, axis, track) else {
504        return;
505    };
506    let horizontal = axis.is_horizontal();
507    let pointer_v = if horizontal {
508        f32::from(pointer.x)
509    } else {
510        f32::from(pointer.y)
511    };
512    let travel = (axis_size(track, horizontal) - thumb_len).max(1.0);
513    let delta = (pointer_v - drag.pointer) / travel * max;
514    let next = (drag.offset - delta).clamp(-max, 0.0);
515    set_axis_offset(handle, axis, next);
516    state.update(cx, |s, cx| {
517        s.last_offset = next;
518        s.last_moved = Some(Instant::now());
519        cx.notify();
520    });
521}
522
523fn set_axis_offset(handle: &ScrollHandle, axis: Orientation, value: f32) {
524    let mut point = handle.offset();
525    if axis.is_horizontal() {
526        point.x = px(value);
527    } else {
528        point.y = px(value);
529    }
530    handle.set_offset(point);
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    /// The visibility decision, table-tested because the headless test
538    /// platform always answers `should_auto_hide_scrollbars()` with `false`
539    /// and cannot be flipped — the platform-hiding path is only reachable
540    /// here.
541    #[test]
542    fn the_visibility_decision_follows_the_platform_and_the_caller() {
543        // Platform asks for auto-hide, caller left the default: the thumb
544        // only paints while the pointer is over the bar, a drag is live, or
545        // the content just moved.
546        assert!(!thumb_visible(true, true, false, false, false));
547        assert!(thumb_visible(true, true, true, false, false));
548        assert!(thumb_visible(true, true, false, true, false));
549        assert!(thumb_visible(true, true, false, false, true));
550        // `auto_hide(false)` bypasses the platform entirely.
551        assert!(thumb_visible(false, true, false, false, false));
552        // A platform that never auto-hides keeps the always-on behavior.
553        assert!(thumb_visible(true, false, false, false, false));
554        assert!(thumb_visible(false, false, false, false, false));
555    }
556
557    #[test]
558    fn inset_shrinks_every_edge_without_going_negative() {
559        let bounds = Bounds {
560            origin: Point {
561                x: px(10.0),
562                y: px(20.0),
563            },
564            size: gpui::Size {
565                width: px(8.0),
566                height: px(40.0),
567            },
568        };
569        let untouched = inset_bounds(bounds, px(0.0));
570        assert_eq!(untouched.origin.x, px(10.0));
571        assert_eq!(untouched.size.width, px(8.0));
572        assert_eq!(untouched.size.height, px(40.0));
573
574        let shrunk = inset_bounds(bounds, px(2.0));
575        assert_eq!(shrunk.origin.x, px(12.0));
576        assert_eq!(shrunk.origin.y, px(22.0));
577        assert_eq!(shrunk.size.width, px(4.0));
578        assert_eq!(shrunk.size.height, px(36.0));
579
580        let collapsed = inset_bounds(bounds, px(30.0));
581        assert_eq!(collapsed.size.width, px(0.0));
582        assert_eq!(collapsed.size.height, px(0.0));
583    }
584}
585
586crate::util::impl_component_styled!(Scrollbar);