Skip to main content

ccf_gpui_widgets/widgets/
scrollbar.rs

1//! Scrollbar component - Scrollbar control for scrollable containers.
2//!
3//! This module provides an internal scrollbar widget used by the [`Scrollable`](super::scrollable::Scrollable) component.
4//! It handles the rendering and interaction of scrollbar thumbs and tracks.
5
6use std::{cell::Cell, rc::Rc, time::Instant};
7
8use gpui::{
9    fill, point, px, relative, size, App, Axis, Bounds, ContentMask, Corner, CursorStyle, Element,
10    GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, IntoElement, LayoutId,
11    MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, Position, ScrollHandle,
12    ScrollWheelEvent, Size, Style, Window,
13};
14
15use crate::theme::{get_theme_or, Theme};
16
17/// Width of the scrollbar track
18pub(crate) const WIDTH: Pixels = px(12.0);
19const MIN_THUMB_SIZE: f32 = 48.;
20
21const THUMB_WIDTH: Pixels = px(6.);
22const THUMB_RADIUS: Pixels = px(3.);
23const THUMB_INSET: Pixels = px(3.);
24
25const THUMB_ACTIVE_WIDTH: Pixels = px(8.);
26const THUMB_ACTIVE_RADIUS: Pixels = px(4.);
27const THUMB_ACTIVE_INSET: Pixels = px(2.);
28
29const FADE_OUT_DURATION: f32 = 3.0;
30
31/// Axis options for scrollbar display
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum ScrollbarAxis {
34    /// Vertical scrollbar only
35    Vertical,
36    /// Horizontal scrollbar only
37    Horizontal,
38    /// Both vertical and horizontal scrollbars
39    Both,
40}
41
42impl ScrollbarAxis {
43    /// Check if this is vertical only
44    pub fn is_vertical(&self) -> bool {
45        matches!(self, Self::Vertical)
46    }
47
48    /// Check if this is horizontal only
49    pub fn is_horizontal(&self) -> bool {
50        matches!(self, Self::Horizontal)
51    }
52
53    /// Check if this is both
54    pub fn is_both(&self) -> bool {
55        matches!(self, Self::Both)
56    }
57
58    /// Check if this includes vertical scrolling
59    #[inline]
60    pub fn has_vertical(&self) -> bool {
61        matches!(self, Self::Vertical | Self::Both)
62    }
63
64    /// Check if this includes horizontal scrolling
65    #[inline]
66    pub fn has_horizontal(&self) -> bool {
67        matches!(self, Self::Horizontal | Self::Both)
68    }
69
70    #[inline]
71    fn all(&self) -> Vec<Axis> {
72        match self {
73            Self::Vertical => vec![Axis::Vertical],
74            Self::Horizontal => vec![Axis::Horizontal],
75            Self::Both => vec![Axis::Horizontal, Axis::Vertical],
76        }
77    }
78}
79
80/// Shared state for scrollbar interaction
81#[derive(Debug, Clone)]
82pub struct ScrollbarState(pub(crate) Rc<Cell<ScrollbarStateInner>>);
83
84/// Internal scrollbar state
85#[derive(Debug, Clone, Copy)]
86pub struct ScrollbarStateInner {
87    pub(crate) hovered_on_thumb: Option<Axis>,
88    pub(crate) dragged_axis: Option<Axis>,
89    pub(crate) drag_pos: Point<Pixels>,
90    pub(crate) last_scroll_offset: Point<Pixels>,
91    pub(crate) last_scroll_time: Option<Instant>,
92    pub(crate) last_update: Instant,
93}
94
95impl Default for ScrollbarState {
96    fn default() -> Self {
97        Self(Rc::new(Cell::new(ScrollbarStateInner {
98            hovered_on_thumb: None,
99            dragged_axis: None,
100            drag_pos: point(px(0.), px(0.)),
101            last_scroll_offset: point(px(0.), px(0.)),
102            last_scroll_time: None,
103            last_update: Instant::now(),
104        })))
105    }
106}
107
108impl ScrollbarState {
109    /// Initialize scrollbar as visible (resets fade timer)
110    #[allow(dead_code)]
111    pub fn init_visible(&self) {
112        let inner = self.0.get();
113        self.0
114            .set(inner.with_last_scroll(inner.last_scroll_offset, Some(Instant::now())));
115    }
116}
117
118impl ScrollbarStateInner {
119    fn with_drag_pos(&self, axis: Axis, pos: Point<Pixels>) -> Self {
120        let mut state = *self;
121        if axis == Axis::Vertical {
122            state.drag_pos.y = pos.y;
123        } else {
124            state.drag_pos.x = pos.x;
125        }
126        state.dragged_axis = Some(axis);
127        state
128    }
129
130    fn with_unset_drag_pos(&self) -> Self {
131        let mut state = *self;
132        state.dragged_axis = None;
133        state
134    }
135
136    fn with_hovered_on_thumb(&self, axis: Option<Axis>) -> Self {
137        let mut state = *self;
138        state.hovered_on_thumb = axis;
139        if axis.is_some() {
140            state.last_scroll_time = Some(Instant::now());
141        }
142        state
143    }
144
145    fn with_last_scroll(
146        &self,
147        last_scroll_offset: Point<Pixels>,
148        last_scroll_time: Option<Instant>,
149    ) -> Self {
150        let mut state = *self;
151        state.last_scroll_offset = last_scroll_offset;
152        state.last_scroll_time = last_scroll_time;
153        state
154    }
155
156    fn with_last_update(&self, t: Instant) -> Self {
157        let mut state = *self;
158        state.last_update = t;
159        state
160    }
161
162    fn is_scrollbar_visible(&self) -> bool {
163        if self.dragged_axis.is_some() {
164            return true;
165        }
166
167        if let Some(last_time) = self.last_scroll_time {
168            let elapsed = Instant::now().duration_since(last_time).as_secs_f32();
169            elapsed < FADE_OUT_DURATION
170        } else {
171            false
172        }
173    }
174}
175
176/// Scrollbar element for rendering scrollbar UI
177pub struct Scrollbar {
178    axis: ScrollbarAxis,
179    scroll_handle: ScrollHandle,
180    state: ScrollbarState,
181    scroll_size: Option<Size<Pixels>>,
182    always_visible: bool,
183    horizontal_at_top: bool,
184    custom_theme: Option<Theme>,
185}
186
187impl Scrollbar {
188    /// Create a new scrollbar
189    pub fn new(axis: ScrollbarAxis, state: &ScrollbarState, scroll_handle: &ScrollHandle) -> Self {
190        Self {
191            state: state.clone(),
192            axis,
193            scroll_handle: scroll_handle.clone(),
194            scroll_size: None,
195            always_visible: false,
196            horizontal_at_top: false,
197            custom_theme: None,
198        }
199    }
200
201    /// Create a vertical scrollbar
202    #[allow(dead_code)]
203    pub fn vertical(state: &ScrollbarState, scroll_handle: &ScrollHandle) -> Self {
204        Self::new(ScrollbarAxis::Vertical, state, scroll_handle)
205    }
206
207    /// Create a horizontal scrollbar
208    #[allow(dead_code)]
209    pub fn horizontal(state: &ScrollbarState, scroll_handle: &ScrollHandle) -> Self {
210        Self::new(ScrollbarAxis::Horizontal, state, scroll_handle)
211    }
212
213    /// Create scrollbars for both axes
214    #[allow(dead_code)]
215    pub fn both(state: &ScrollbarState, scroll_handle: &ScrollHandle) -> Self {
216        Self::new(ScrollbarAxis::Both, state, scroll_handle)
217    }
218
219    /// Set scrollbar to always be visible (builder pattern)
220    #[must_use]
221    pub fn always_visible(mut self) -> Self {
222        self.always_visible = true;
223        self
224    }
225
226    /// Set the scrollbar axis (builder pattern)
227    #[must_use]
228    #[allow(dead_code)]
229    pub fn axis(mut self, axis: ScrollbarAxis) -> Self {
230        self.axis = axis;
231        self
232    }
233
234    /// Position horizontal scrollbar at top instead of bottom (builder pattern)
235    #[must_use]
236    #[allow(dead_code)]
237    pub fn horizontal_top(mut self) -> Self {
238        self.horizontal_at_top = true;
239        self
240    }
241
242    /// Set the scroll content size (builder pattern)
243    #[must_use]
244    #[allow(dead_code)]
245    pub fn scroll_size(mut self, scroll_size: Size<Pixels>) -> Self {
246        self.scroll_size = Some(scroll_size);
247        self
248    }
249
250    /// Set custom theme (builder pattern)
251    #[must_use]
252    pub fn theme(mut self, theme: Theme) -> Self {
253        self.custom_theme = Some(theme);
254        self
255    }
256
257    fn get_thumb_color(&self, cx: &App) -> Hsla {
258        let theme = get_theme_or(cx, self.custom_theme.as_ref());
259        gpui::rgb(theme.text_muted).into()
260    }
261
262    fn get_track_color(&self, cx: &App) -> Hsla {
263        let theme = get_theme_or(cx, self.custom_theme.as_ref());
264        let mut color: Hsla = gpui::rgb(theme.bg_input).into();
265        color.a = 0.3;
266        color
267    }
268
269    fn get_hover_thumb_color(&self, cx: &App) -> Hsla {
270        let theme = get_theme_or(cx, self.custom_theme.as_ref());
271        let mut color: Hsla = gpui::rgb(theme.text_muted).into();
272        color.a = 0.8;
273        color
274    }
275}
276
277impl IntoElement for Scrollbar {
278    type Element = Self;
279
280    fn into_element(self) -> Self::Element {
281        self
282    }
283}
284
285/// Prepaint state for a single axis scrollbar
286pub struct AxisPrepaintState {
287    axis: Axis,
288    bar_hitbox: Hitbox,
289    bounds: Bounds<Pixels>,
290    radius: Pixels,
291    bg: Hsla,
292    thumb_bounds: Bounds<Pixels>,
293    thumb_fill_bounds: Bounds<Pixels>,
294    thumb_bg: Hsla,
295    scroll_size: Pixels,
296    container_size: Pixels,
297    thumb_size: Pixels,
298    margin_end: Pixels,
299}
300
301/// Prepaint state for the scrollbar element
302pub struct PrepaintState {
303    hitbox: Hitbox,
304    states: Vec<AxisPrepaintState>,
305}
306
307impl Element for Scrollbar {
308    type RequestLayoutState = ();
309    type PrepaintState = PrepaintState;
310
311    fn id(&self) -> Option<gpui::ElementId> {
312        None
313    }
314
315    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
316        None
317    }
318
319    fn request_layout(
320        &mut self,
321        _: Option<&GlobalElementId>,
322        _: Option<&InspectorElementId>,
323        window: &mut Window,
324        cx: &mut App,
325    ) -> (LayoutId, Self::RequestLayoutState) {
326        let mut style = Style {
327            position: Position::Absolute,
328            flex_grow: 1.0,
329            flex_shrink: 1.0,
330            ..Default::default()
331        };
332        style.size.width = relative(1.).into();
333        style.size.height = relative(1.).into();
334
335        (window.request_layout(style, None, cx), ())
336    }
337
338    fn prepaint(
339        &mut self,
340        _: Option<&GlobalElementId>,
341        _: Option<&InspectorElementId>,
342        bounds: Bounds<Pixels>,
343        _: &mut Self::RequestLayoutState,
344        window: &mut Window,
345        cx: &mut App,
346    ) -> Self::PrepaintState {
347        let hitbox = window.with_content_mask(Some(ContentMask { bounds }), |window| {
348            window.insert_hitbox(bounds, HitboxBehavior::Normal)
349        });
350
351        let mut states = vec![];
352        let mut has_both = self.axis.is_both();
353
354        let scroll_size = self
355            .scroll_size
356            .unwrap_or_else(|| self.scroll_handle.max_offset() + self.scroll_handle.bounds().size);
357
358        for axis in self.axis.all().into_iter() {
359            let is_vertical = axis == Axis::Vertical;
360            let (scroll_area_size, container_size, scroll_position) = if is_vertical {
361                (
362                    scroll_size.height,
363                    hitbox.size.height,
364                    self.scroll_handle.offset().y,
365                )
366            } else {
367                (
368                    scroll_size.width,
369                    hitbox.size.width,
370                    self.scroll_handle.offset().x,
371                )
372            };
373
374            let margin_end = if has_both && !is_vertical {
375                WIDTH
376            } else {
377                px(0.)
378            };
379
380            if scroll_area_size <= container_size {
381                has_both = false;
382                continue;
383            }
384
385            let thumb_length =
386                (container_size / scroll_area_size * container_size).max(px(MIN_THUMB_SIZE));
387            let thumb_start = -(scroll_position / (scroll_area_size - container_size)
388                * (container_size - margin_end - thumb_length));
389            let thumb_end = (thumb_start + thumb_length).min(container_size - margin_end);
390
391            let bounds = Bounds {
392                origin: if is_vertical {
393                    point(hitbox.origin.x + hitbox.size.width - WIDTH, hitbox.origin.y)
394                } else if self.horizontal_at_top {
395                    point(hitbox.origin.x, hitbox.origin.y)
396                } else {
397                    point(
398                        hitbox.origin.x,
399                        hitbox.origin.y + hitbox.size.height - WIDTH,
400                    )
401                },
402                size: size(
403                    if is_vertical { WIDTH } else { hitbox.size.width },
404                    if is_vertical { hitbox.size.height } else { WIDTH },
405                ),
406            };
407
408            let state_inner = self.state.0.get();
409            let is_hovered_on_thumb = state_inner.hovered_on_thumb == Some(axis);
410            let is_dragged = state_inner.dragged_axis == Some(axis);
411
412            let (thumb_bg, track_bg, _thumb_width, inset, radius) =
413                if is_dragged || is_hovered_on_thumb {
414                    (
415                        self.get_hover_thumb_color(cx),
416                        self.get_track_color(cx),
417                        THUMB_ACTIVE_WIDTH,
418                        THUMB_ACTIVE_INSET,
419                        THUMB_ACTIVE_RADIUS,
420                    )
421                } else {
422                    (
423                        self.get_thumb_color(cx),
424                        self.get_track_color(cx),
425                        THUMB_WIDTH,
426                        THUMB_INSET,
427                        THUMB_RADIUS,
428                    )
429                };
430
431            let thumb_length = thumb_end - thumb_start - inset * 2;
432            let thumb_bounds = if is_vertical {
433                Bounds::from_corner_and_size(
434                    Corner::TopRight,
435                    bounds.top_right() + point(-inset, inset + thumb_start),
436                    size(WIDTH, thumb_length),
437                )
438            } else if self.horizontal_at_top {
439                Bounds::from_corner_and_size(
440                    Corner::TopLeft,
441                    bounds.origin + point(inset + thumb_start, inset),
442                    size(thumb_length, WIDTH),
443                )
444            } else {
445                Bounds::from_corner_and_size(
446                    Corner::BottomLeft,
447                    bounds.bottom_left() + point(inset + thumb_start, -inset),
448                    size(thumb_length, WIDTH),
449                )
450            };
451
452            let thumb_fill_bounds = if is_vertical {
453                Bounds::from_corner_and_size(
454                    Corner::TopRight,
455                    bounds.top_right() + point(-inset, inset + thumb_start),
456                    size(THUMB_WIDTH, thumb_length),
457                )
458            } else if self.horizontal_at_top {
459                Bounds::from_corner_and_size(
460                    Corner::TopLeft,
461                    bounds.origin + point(inset + thumb_start, inset),
462                    size(thumb_length, THUMB_WIDTH),
463                )
464            } else {
465                Bounds::from_corner_and_size(
466                    Corner::BottomLeft,
467                    bounds.bottom_left() + point(inset + thumb_start, -inset),
468                    size(thumb_length, THUMB_WIDTH),
469                )
470            };
471
472            let bar_hitbox = window.with_content_mask(Some(ContentMask { bounds }), |window| {
473                window.insert_hitbox(bounds, HitboxBehavior::Normal)
474            });
475
476            states.push(AxisPrepaintState {
477                axis,
478                bar_hitbox,
479                bounds,
480                radius,
481                bg: track_bg,
482                thumb_bounds,
483                thumb_fill_bounds,
484                thumb_bg,
485                scroll_size: scroll_area_size,
486                container_size,
487                thumb_size: thumb_length,
488                margin_end,
489            })
490        }
491
492        PrepaintState { hitbox, states }
493    }
494
495    fn paint(
496        &mut self,
497        _: Option<&GlobalElementId>,
498        _: Option<&InspectorElementId>,
499        _: Bounds<Pixels>,
500        _: &mut Self::RequestLayoutState,
501        prepaint: &mut Self::PrepaintState,
502        window: &mut Window,
503        _cx: &mut App,
504    ) {
505        let view_id = window.current_view();
506        let hitbox_bounds = prepaint.hitbox.bounds;
507        let is_visible = self.state.0.get().is_scrollbar_visible() || self.always_visible;
508
509        if self.scroll_handle.offset() != self.state.0.get().last_scroll_offset {
510            self.state.0.set(
511                self.state
512                    .0
513                    .get()
514                    .with_last_scroll(self.scroll_handle.offset(), Some(Instant::now())),
515            );
516            // Don't notify here - let GPUI handle scroll updates naturally.
517            // Calling cx.notify() in paint causes a re-render loop that resets scroll position.
518        }
519
520        if !is_visible && !self.always_visible {
521            return;
522        }
523
524        window.with_content_mask(
525            Some(ContentMask {
526                bounds: hitbox_bounds,
527            }),
528            |window| {
529                for state in prepaint.states.iter() {
530                    let axis = state.axis;
531                    let radius = state.radius;
532                    let bounds = state.bounds;
533                    let thumb_bounds = state.thumb_bounds;
534                    let scroll_area_size = state.scroll_size;
535                    let container_size = state.container_size;
536                    let thumb_size = state.thumb_size;
537                    let margin_end = state.margin_end;
538                    let is_vertical = axis == Axis::Vertical;
539
540                    window.set_cursor_style(CursorStyle::default(), &state.bar_hitbox);
541
542                    window.paint_layer(hitbox_bounds, |cx| {
543                        cx.paint_quad(fill(state.bounds, state.bg));
544
545                        cx.paint_quad(
546                            fill(state.thumb_fill_bounds, state.thumb_bg).corner_radii(radius),
547                        );
548                    });
549
550                    window.on_mouse_event({
551                        let state = self.state.clone();
552                        let scroll_handle = self.scroll_handle.clone();
553
554                        move |event: &ScrollWheelEvent, phase, _hitbox, cx| {
555                            if phase.bubble() && hitbox_bounds.contains(&event.position)
556                                && scroll_handle.offset() != state.0.get().last_scroll_offset {
557                                    state.0.set(state.0.get().with_last_scroll(
558                                        scroll_handle.offset(),
559                                        Some(Instant::now()),
560                                    ));
561                                    cx.notify(view_id);
562                                }
563                        }
564                    });
565
566                    let safe_range = (-scroll_area_size + container_size)..px(0.);
567
568                    window.on_mouse_event({
569                        let state = self.state.clone();
570                        let scroll_handle = self.scroll_handle.clone();
571
572                        move |event: &MouseDownEvent, phase, _hitbox, cx| {
573                            if phase.bubble() && bounds.contains(&event.position) {
574                                cx.stop_propagation();
575
576                                if thumb_bounds.contains(&event.position) {
577                                    let pos = event.position - thumb_bounds.origin;
578                                    state.0.set(state.0.get().with_drag_pos(axis, pos));
579                                    cx.notify(view_id);
580                                } else {
581                                    let offset = scroll_handle.offset();
582                                    let percentage = if is_vertical {
583                                        (event.position.y - thumb_size / 2. - bounds.origin.y)
584                                            / (bounds.size.height - thumb_size)
585                                    } else {
586                                        (event.position.x - thumb_size / 2. - bounds.origin.x)
587                                            / (bounds.size.width - thumb_size)
588                                    }
589                                    .min(1.);
590
591                                    if is_vertical {
592                                        scroll_handle.set_offset(point(
593                                            offset.x,
594                                            (-scroll_area_size * percentage)
595                                                .clamp(safe_range.start, safe_range.end),
596                                        ));
597                                    } else {
598                                        scroll_handle.set_offset(point(
599                                            (-scroll_area_size * percentage)
600                                                .clamp(safe_range.start, safe_range.end),
601                                            offset.y,
602                                        ));
603                                    }
604                                }
605                            }
606                        }
607                    });
608
609                    window.on_mouse_event({
610                        let scroll_handle = self.scroll_handle.clone();
611                        let state = self.state.clone();
612
613                        move |event: &MouseMoveEvent, _phase, _hitbox, cx| {
614                            let mut notify = false;
615
616                            if thumb_bounds.contains(&event.position) {
617                                if state.0.get().hovered_on_thumb != Some(axis) {
618                                    state.0.set(state.0.get().with_hovered_on_thumb(Some(axis)));
619                                    notify = true;
620                                }
621                            } else if state.0.get().hovered_on_thumb == Some(axis) {
622                                state.0.set(state.0.get().with_hovered_on_thumb(None));
623                                notify = true;
624                            }
625
626                            if state.0.get().dragged_axis == Some(axis) && event.dragging() {
627                                let drag_pos = state.0.get().drag_pos;
628
629                                let percentage = (if is_vertical {
630                                    (event.position.y - drag_pos.y - bounds.origin.y)
631                                        / (bounds.size.height - thumb_size)
632                                } else {
633                                    (event.position.x - drag_pos.x - bounds.origin.x)
634                                        / (bounds.size.width - thumb_size - margin_end)
635                                })
636                                .clamp(0., 1.);
637
638                                let offset = if is_vertical {
639                                    point(
640                                        scroll_handle.offset().x,
641                                        (-(scroll_area_size - container_size) * percentage)
642                                            .clamp(safe_range.start, safe_range.end),
643                                    )
644                                } else {
645                                    point(
646                                        (-(scroll_area_size - container_size) * percentage)
647                                            .clamp(safe_range.start, safe_range.end),
648                                        scroll_handle.offset().y,
649                                    )
650                                };
651
652                                if (scroll_handle.offset().y - offset.y).abs() > px(1.)
653                                    || (scroll_handle.offset().x - offset.x).abs() > px(1.)
654                                {
655                                    scroll_handle.set_offset(offset);
656                                    state.0.set(state.0.get().with_last_update(Instant::now()));
657                                    notify = true;
658                                }
659                            }
660
661                            if notify {
662                                cx.notify(view_id);
663                            }
664                        }
665                    });
666
667                    window.on_mouse_event({
668                        let state = self.state.clone();
669
670                        move |_event: &MouseUpEvent, phase, _hitbox, cx| {
671                            if phase.bubble() {
672                                state.0.set(state.0.get().with_unset_drag_pos());
673                                cx.notify(view_id);
674                            }
675                        }
676                    });
677                }
678            },
679        );
680    }
681}