gpui_component/scroll/
scrollbar.rs

1use std::{
2    cell::Cell,
3    ops::Deref,
4    panic::Location,
5    rc::Rc,
6    time::{Duration, Instant},
7};
8
9use crate::{ActiveTheme, AxisExt};
10use gpui::{
11    App, Axis, BorderStyle, Bounds, ContentMask, Corner, CursorStyle, Edges, Element, ElementId,
12    GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, IntoElement, IsZero,
13    LayoutId, ListState, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point,
14    Position, ScrollHandle, ScrollWheelEvent, Size, Style, Timer, UniformListScrollHandle, Window,
15    fill, point, px, relative, size,
16};
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20/// The width of the scrollbar (THUMB_ACTIVE_INSET * 2 + THUMB_ACTIVE_WIDTH)
21const WIDTH: Pixels = px(4. * 2. + 8.);
22const MIN_THUMB_SIZE: f32 = 48.;
23
24const THUMB_WIDTH: Pixels = px(6.);
25const THUMB_RADIUS: Pixels = px(6. / 2.);
26const THUMB_INSET: Pixels = px(4.);
27
28const THUMB_ACTIVE_WIDTH: Pixels = px(8.);
29const THUMB_ACTIVE_RADIUS: Pixels = px(8. / 2.);
30const THUMB_ACTIVE_INSET: Pixels = px(4.);
31
32const FADE_OUT_DURATION: f32 = 3.0;
33const FADE_OUT_DELAY: f32 = 2.0;
34
35/// Scrollbar show mode.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default, JsonSchema)]
37pub enum ScrollbarShow {
38    /// Show scrollbar when scrolling, will fade out after idle.
39    #[default]
40    Scrolling,
41    /// Show scrollbar on hover.
42    Hover,
43    /// Always show scrollbar.
44    Always,
45}
46
47impl ScrollbarShow {
48    fn is_hover(&self) -> bool {
49        matches!(self, Self::Hover)
50    }
51
52    fn is_always(&self) -> bool {
53        matches!(self, Self::Always)
54    }
55}
56
57/// A trait for scroll handles that can get and set offset.
58pub trait ScrollbarHandle: 'static {
59    /// Get the current offset of the scroll handle.
60    fn offset(&self) -> Point<Pixels>;
61    /// Set the offset of the scroll handle.
62    fn set_offset(&self, offset: Point<Pixels>);
63    /// The full size of the content, including padding.
64    fn content_size(&self) -> Size<Pixels>;
65    /// Called when start dragging the scrollbar thumb.
66    fn start_drag(&self) {}
67    /// Called when end dragging the scrollbar thumb.
68    fn end_drag(&self) {}
69}
70
71impl ScrollbarHandle for ScrollHandle {
72    fn offset(&self) -> Point<Pixels> {
73        self.offset()
74    }
75
76    fn set_offset(&self, offset: Point<Pixels>) {
77        self.set_offset(offset);
78    }
79
80    fn content_size(&self) -> Size<Pixels> {
81        self.max_offset() + self.bounds().size
82    }
83}
84
85impl ScrollbarHandle for UniformListScrollHandle {
86    fn offset(&self) -> Point<Pixels> {
87        self.0.borrow().base_handle.offset()
88    }
89
90    fn set_offset(&self, offset: Point<Pixels>) {
91        self.0.borrow_mut().base_handle.set_offset(offset)
92    }
93
94    fn content_size(&self) -> Size<Pixels> {
95        let base_handle = &self.0.borrow().base_handle;
96        base_handle.max_offset() + base_handle.bounds().size
97    }
98}
99
100impl ScrollbarHandle for ListState {
101    fn offset(&self) -> Point<Pixels> {
102        self.scroll_px_offset_for_scrollbar()
103    }
104
105    fn set_offset(&self, offset: Point<Pixels>) {
106        self.set_offset_from_scrollbar(offset);
107    }
108
109    fn content_size(&self) -> Size<Pixels> {
110        self.viewport_bounds().size + self.max_offset_for_scrollbar()
111    }
112
113    fn start_drag(&self) {
114        self.scrollbar_drag_started();
115    }
116
117    fn end_drag(&self) {
118        self.scrollbar_drag_ended();
119    }
120}
121
122#[doc(hidden)]
123#[derive(Debug, Clone)]
124struct ScrollbarState(Rc<Cell<ScrollbarStateInner>>);
125
126#[doc(hidden)]
127#[derive(Debug, Clone, Copy)]
128struct ScrollbarStateInner {
129    hovered_axis: Option<Axis>,
130    hovered_on_thumb: Option<Axis>,
131    dragged_axis: Option<Axis>,
132    drag_pos: Point<Pixels>,
133    last_scroll_offset: Point<Pixels>,
134    last_scroll_time: Option<Instant>,
135    // Last update offset
136    last_update: Instant,
137    idle_timer_scheduled: bool,
138}
139
140impl Default for ScrollbarState {
141    fn default() -> Self {
142        Self(Rc::new(Cell::new(ScrollbarStateInner {
143            hovered_axis: None,
144            hovered_on_thumb: None,
145            dragged_axis: None,
146            drag_pos: point(px(0.), px(0.)),
147            last_scroll_offset: point(px(0.), px(0.)),
148            last_scroll_time: None,
149            last_update: Instant::now(),
150            idle_timer_scheduled: false,
151        })))
152    }
153}
154
155impl Deref for ScrollbarState {
156    type Target = Rc<Cell<ScrollbarStateInner>>;
157
158    fn deref(&self) -> &Self::Target {
159        &self.0
160    }
161}
162
163impl ScrollbarStateInner {
164    fn with_drag_pos(&self, axis: Axis, pos: Point<Pixels>) -> Self {
165        let mut state = *self;
166        if axis.is_vertical() {
167            state.drag_pos.y = pos.y;
168        } else {
169            state.drag_pos.x = pos.x;
170        }
171
172        state.dragged_axis = Some(axis);
173        state
174    }
175
176    fn with_unset_drag_pos(&self) -> Self {
177        let mut state = *self;
178        state.dragged_axis = None;
179        state
180    }
181
182    fn with_hovered(&self, axis: Option<Axis>) -> Self {
183        let mut state = *self;
184        state.hovered_axis = axis;
185        if axis.is_some() {
186            state.last_scroll_time = Some(std::time::Instant::now());
187        }
188        state
189    }
190
191    fn with_hovered_on_thumb(&self, axis: Option<Axis>) -> Self {
192        let mut state = *self;
193        state.hovered_on_thumb = axis;
194        if self.is_scrollbar_visible() {
195            if axis.is_some() {
196                state.last_scroll_time = Some(std::time::Instant::now());
197            }
198        }
199        state
200    }
201
202    fn with_last_scroll(
203        &self,
204        last_scroll_offset: Point<Pixels>,
205        last_scroll_time: Option<Instant>,
206    ) -> Self {
207        let mut state = *self;
208        state.last_scroll_offset = last_scroll_offset;
209        state.last_scroll_time = last_scroll_time;
210        state
211    }
212
213    fn with_last_scroll_time(&self, t: Option<Instant>) -> Self {
214        let mut state = *self;
215        state.last_scroll_time = t;
216        state
217    }
218
219    fn with_last_update(&self, t: Instant) -> Self {
220        let mut state = *self;
221        state.last_update = t;
222        state
223    }
224
225    fn with_idle_timer_scheduled(&self, scheduled: bool) -> Self {
226        let mut state = *self;
227        state.idle_timer_scheduled = scheduled;
228        state
229    }
230
231    fn is_scrollbar_visible(&self) -> bool {
232        // On drag
233        if self.dragged_axis.is_some() {
234            return true;
235        }
236
237        if let Some(last_time) = self.last_scroll_time {
238            let elapsed = Instant::now().duration_since(last_time).as_secs_f32();
239            elapsed < FADE_OUT_DURATION
240        } else {
241            false
242        }
243    }
244}
245
246/// Scrollbar axis.
247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
248pub enum ScrollbarAxis {
249    /// Vertical scrollbar.
250    Vertical,
251    /// Horizontal scrollbar.
252    Horizontal,
253    /// Show both vertical and horizontal scrollbars.
254    Both,
255}
256
257impl From<Axis> for ScrollbarAxis {
258    fn from(axis: Axis) -> Self {
259        match axis {
260            Axis::Vertical => Self::Vertical,
261            Axis::Horizontal => Self::Horizontal,
262        }
263    }
264}
265
266impl ScrollbarAxis {
267    /// Return true if the scrollbar axis is vertical.
268    #[inline]
269    pub fn is_vertical(&self) -> bool {
270        matches!(self, Self::Vertical)
271    }
272
273    /// Return true if the scrollbar axis is horizontal.
274    #[inline]
275    pub fn is_horizontal(&self) -> bool {
276        matches!(self, Self::Horizontal)
277    }
278
279    /// Return true if the scrollbar axis is both vertical and horizontal.
280    #[inline]
281    pub fn is_both(&self) -> bool {
282        matches!(self, Self::Both)
283    }
284
285    /// Return true if the scrollbar has vertical axis.
286    #[inline]
287    pub fn has_vertical(&self) -> bool {
288        matches!(self, Self::Vertical | Self::Both)
289    }
290
291    /// Return true if the scrollbar has horizontal axis.
292    #[inline]
293    pub fn has_horizontal(&self) -> bool {
294        matches!(self, Self::Horizontal | Self::Both)
295    }
296
297    #[inline]
298    fn all(&self) -> Vec<Axis> {
299        match self {
300            Self::Vertical => vec![Axis::Vertical],
301            Self::Horizontal => vec![Axis::Horizontal],
302            // This should keep Horizontal first, Vertical is the primary axis
303            // if Vertical not need display, then Horizontal will not keep right margin.
304            Self::Both => vec![Axis::Horizontal, Axis::Vertical],
305        }
306    }
307}
308
309/// Scrollbar control for scroll-area or a uniform-list.
310pub struct Scrollbar {
311    pub(crate) id: ElementId,
312    axis: ScrollbarAxis,
313    scrollbar_show: Option<ScrollbarShow>,
314    scroll_handle: Rc<dyn ScrollbarHandle>,
315    scroll_size: Option<Size<Pixels>>,
316    /// Maximum frames per second for scrolling by drag. Default is 120 FPS.
317    ///
318    /// This is used to limit the update rate of the scrollbar when it is
319    /// being dragged for some complex interactions for reducing CPU usage.
320    max_fps: usize,
321}
322
323impl Scrollbar {
324    /// Create a new scrollbar.
325    ///
326    /// This will have both vertical and horizontal scrollbars.
327    #[track_caller]
328    pub fn new<H: ScrollbarHandle + Clone>(scroll_handle: &H) -> Self {
329        let caller = Location::caller();
330        Self {
331            id: ElementId::CodeLocation(*caller),
332            axis: ScrollbarAxis::Both,
333            scrollbar_show: None,
334            scroll_handle: Rc::new(scroll_handle.clone()),
335            max_fps: 120,
336            scroll_size: None,
337        }
338    }
339
340    /// Create with horizontal scrollbar.
341    #[track_caller]
342    pub fn horizontal<H: ScrollbarHandle + Clone>(scroll_handle: &H) -> Self {
343        Self::new(scroll_handle).axis(ScrollbarAxis::Horizontal)
344    }
345
346    /// Create with vertical scrollbar.
347    #[track_caller]
348    pub fn vertical<H: ScrollbarHandle + Clone>(scroll_handle: &H) -> Self {
349        Self::new(scroll_handle).axis(ScrollbarAxis::Vertical)
350    }
351
352    /// Set a specific element id, default is the [`Location::caller`].
353    ///
354    /// NOTE: In most cases, you don't need to set a specific id for scrollbar.
355    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
356        self.id = id.into();
357        self
358    }
359
360    /// Set the scrollbar show mode [`ScrollbarShow`], if not set use the `cx.theme().scrollbar_show`.
361    pub fn scrollbar_show(mut self, scrollbar_show: ScrollbarShow) -> Self {
362        self.scrollbar_show = Some(scrollbar_show);
363        self
364    }
365
366    /// Set a special scroll size of the content area, default is None.
367    ///
368    /// Default will sync the `content_size` from `scroll_handle`.
369    pub fn scroll_size(mut self, scroll_size: Size<Pixels>) -> Self {
370        self.scroll_size = Some(scroll_size);
371        self
372    }
373
374    /// Set scrollbar axis.
375    pub fn axis(mut self, axis: impl Into<ScrollbarAxis>) -> Self {
376        self.axis = axis.into();
377        self
378    }
379
380    /// Set maximum frames per second for scrolling by drag. Default is 120 FPS.
381    ///
382    /// If you have very high CPU usage, consider reducing this value to improve performance.
383    ///
384    /// Available values: 30..120
385    pub(crate) fn max_fps(mut self, max_fps: usize) -> Self {
386        self.max_fps = max_fps.clamp(30, 120);
387        self
388    }
389
390    // Get the width of the scrollbar.
391    pub(crate) const fn width() -> Pixels {
392        WIDTH
393    }
394
395    fn style_for_active(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
396        (
397            cx.theme().scrollbar_thumb_hover,
398            cx.theme().scrollbar,
399            cx.theme().border,
400            THUMB_ACTIVE_WIDTH,
401            THUMB_ACTIVE_INSET,
402            THUMB_ACTIVE_RADIUS,
403        )
404    }
405
406    fn style_for_hovered_thumb(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
407        (
408            cx.theme().scrollbar_thumb_hover,
409            cx.theme().scrollbar,
410            cx.theme().border,
411            THUMB_ACTIVE_WIDTH,
412            THUMB_ACTIVE_INSET,
413            THUMB_ACTIVE_RADIUS,
414        )
415    }
416
417    fn style_for_hovered_bar(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
418        (
419            cx.theme().scrollbar_thumb,
420            cx.theme().scrollbar,
421            gpui::transparent_black(),
422            THUMB_ACTIVE_WIDTH,
423            THUMB_ACTIVE_INSET,
424            THUMB_ACTIVE_RADIUS,
425        )
426    }
427
428    fn style_for_normal(&self, cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
429        let scrollbar_show = self.scrollbar_show.unwrap_or(cx.theme().scrollbar_show);
430        let (width, inset, radius) = match scrollbar_show {
431            ScrollbarShow::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS),
432            _ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS),
433        };
434
435        (
436            cx.theme().scrollbar_thumb,
437            cx.theme().scrollbar,
438            gpui::transparent_black(),
439            width,
440            inset,
441            radius,
442        )
443    }
444
445    fn style_for_idle(&self, cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
446        let scrollbar_show = self.scrollbar_show.unwrap_or(cx.theme().scrollbar_show);
447        let (width, inset, radius) = match scrollbar_show {
448            ScrollbarShow::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS),
449            _ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS),
450        };
451
452        (
453            gpui::transparent_black(),
454            gpui::transparent_black(),
455            gpui::transparent_black(),
456            width,
457            inset,
458            radius,
459        )
460    }
461}
462
463impl IntoElement for Scrollbar {
464    type Element = Self;
465
466    fn into_element(self) -> Self::Element {
467        self
468    }
469}
470
471#[doc(hidden)]
472pub struct PrepaintState {
473    hitbox: Hitbox,
474    scrollbar_state: ScrollbarState,
475    states: Vec<AxisPrepaintState>,
476}
477
478#[doc(hidden)]
479pub struct AxisPrepaintState {
480    axis: Axis,
481    bar_hitbox: Hitbox,
482    bounds: Bounds<Pixels>,
483    radius: Pixels,
484    bg: Hsla,
485    border: Hsla,
486    thumb_bounds: Bounds<Pixels>,
487    // Bounds of thumb to be rendered.
488    thumb_fill_bounds: Bounds<Pixels>,
489    thumb_bg: Hsla,
490    scroll_size: Pixels,
491    container_size: Pixels,
492    thumb_size: Pixels,
493    margin_end: Pixels,
494}
495
496impl Element for Scrollbar {
497    type RequestLayoutState = ();
498    type PrepaintState = PrepaintState;
499
500    fn id(&self) -> Option<gpui::ElementId> {
501        Some(self.id.clone())
502    }
503
504    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
505        None
506    }
507
508    fn request_layout(
509        &mut self,
510        _: Option<&GlobalElementId>,
511        _: Option<&InspectorElementId>,
512        window: &mut Window,
513        cx: &mut App,
514    ) -> (LayoutId, Self::RequestLayoutState) {
515        let mut style = Style::default();
516        style.position = Position::Absolute;
517        style.flex_grow = 1.0;
518        style.flex_shrink = 1.0;
519        style.size.width = relative(1.).into();
520        style.size.height = relative(1.).into();
521
522        (window.request_layout(style, None, cx), ())
523    }
524
525    fn prepaint(
526        &mut self,
527        _: Option<&GlobalElementId>,
528        _: Option<&InspectorElementId>,
529        bounds: Bounds<Pixels>,
530        _: &mut Self::RequestLayoutState,
531        window: &mut Window,
532        cx: &mut App,
533    ) -> Self::PrepaintState {
534        let hitbox = window.with_content_mask(Some(ContentMask { bounds }), |window| {
535            window.insert_hitbox(bounds, HitboxBehavior::Normal)
536        });
537
538        let state = window
539            .use_state(cx, |_, _| ScrollbarState::default())
540            .read(cx)
541            .clone();
542
543        let mut states = vec![];
544        let mut has_both = self.axis.is_both();
545        let scroll_size = self
546            .scroll_size
547            .unwrap_or(self.scroll_handle.content_size());
548
549        for axis in self.axis.all().into_iter() {
550            let is_vertical = axis.is_vertical();
551            let (scroll_area_size, container_size, scroll_position) = if is_vertical {
552                (
553                    scroll_size.height,
554                    hitbox.size.height,
555                    self.scroll_handle.offset().y,
556                )
557            } else {
558                (
559                    scroll_size.width,
560                    hitbox.size.width,
561                    self.scroll_handle.offset().x,
562                )
563            };
564
565            // The horizontal scrollbar is set avoid overlapping with the vertical scrollbar, if the vertical scrollbar is visible.
566            let margin_end = if has_both && !is_vertical {
567                WIDTH
568            } else {
569                px(0.)
570            };
571
572            // Hide scrollbar, if the scroll area is smaller than the container.
573            if scroll_area_size <= container_size {
574                has_both = false;
575                continue;
576            }
577
578            let thumb_length =
579                (container_size / scroll_area_size * container_size).max(px(MIN_THUMB_SIZE));
580            let thumb_start = -(scroll_position / (scroll_area_size - container_size)
581                * (container_size - margin_end - thumb_length));
582            let thumb_end = (thumb_start + thumb_length).min(container_size - margin_end);
583
584            let bounds = Bounds {
585                origin: if is_vertical {
586                    point(hitbox.origin.x + hitbox.size.width - WIDTH, hitbox.origin.y)
587                } else {
588                    point(
589                        hitbox.origin.x,
590                        hitbox.origin.y + hitbox.size.height - WIDTH,
591                    )
592                },
593                size: gpui::Size {
594                    width: if is_vertical {
595                        WIDTH
596                    } else {
597                        hitbox.size.width
598                    },
599                    height: if is_vertical {
600                        hitbox.size.height
601                    } else {
602                        WIDTH
603                    },
604                },
605            };
606
607            let scrollbar_show = self.scrollbar_show.unwrap_or(cx.theme().scrollbar_show);
608            let is_always_to_show = scrollbar_show.is_always();
609            let is_hover_to_show = scrollbar_show.is_hover();
610            let is_hovered_on_bar = state.get().hovered_axis == Some(axis);
611            let is_hovered_on_thumb = state.get().hovered_on_thumb == Some(axis);
612            let is_offset_changed = state.get().last_scroll_offset != self.scroll_handle.offset();
613
614            let (thumb_bg, bar_bg, bar_border, thumb_width, inset, radius) =
615                if state.get().dragged_axis == Some(axis) {
616                    Self::style_for_active(cx)
617                } else if is_hover_to_show && (is_hovered_on_bar || is_hovered_on_thumb) {
618                    if is_hovered_on_thumb {
619                        Self::style_for_hovered_thumb(cx)
620                    } else {
621                        Self::style_for_hovered_bar(cx)
622                    }
623                } else if is_offset_changed {
624                    self.style_for_normal(cx)
625                } else if is_always_to_show {
626                    if is_hovered_on_thumb {
627                        Self::style_for_hovered_thumb(cx)
628                    } else {
629                        Self::style_for_hovered_bar(cx)
630                    }
631                } else {
632                    let mut idle_state = self.style_for_idle(cx);
633                    // Delay 2s to fade out the scrollbar thumb (in 1s)
634                    if let Some(last_time) = state.get().last_scroll_time {
635                        let elapsed = Instant::now().duration_since(last_time).as_secs_f32();
636                        if is_hovered_on_bar {
637                            state.set(state.get().with_last_scroll_time(Some(Instant::now())));
638                            idle_state = if is_hovered_on_thumb {
639                                Self::style_for_hovered_thumb(cx)
640                            } else {
641                                Self::style_for_hovered_bar(cx)
642                            };
643                        } else if elapsed < FADE_OUT_DELAY {
644                            idle_state.0 = cx.theme().scrollbar_thumb;
645
646                            if !state.get().idle_timer_scheduled {
647                                let state = state.clone();
648                                state.set(state.get().with_idle_timer_scheduled(true));
649                                let current_view = window.current_view();
650                                let next_delay = Duration::from_secs_f32(FADE_OUT_DELAY - elapsed);
651                                window
652                                    .spawn(cx, async move |cx| {
653                                        Timer::after(next_delay).await;
654                                        state.set(state.get().with_idle_timer_scheduled(false));
655                                        cx.update(|_, cx| cx.notify(current_view)).ok();
656                                    })
657                                    .detach();
658                            }
659                        } else if elapsed < FADE_OUT_DURATION {
660                            let opacity = 1.0 - (elapsed - FADE_OUT_DELAY).powi(10);
661                            idle_state.0 = cx.theme().scrollbar_thumb.opacity(opacity);
662
663                            window.request_animation_frame();
664                        }
665                    }
666
667                    idle_state
668                };
669
670            // The clickable area of the thumb
671            let thumb_length = thumb_end - thumb_start - inset * 2;
672            let thumb_bounds = if is_vertical {
673                Bounds::from_corner_and_size(
674                    Corner::TopRight,
675                    bounds.top_right() + point(-inset, inset + thumb_start),
676                    size(WIDTH, thumb_length),
677                )
678            } else {
679                Bounds::from_corner_and_size(
680                    Corner::BottomLeft,
681                    bounds.bottom_left() + point(inset + thumb_start, -inset),
682                    size(thumb_length, WIDTH),
683                )
684            };
685
686            // The actual render area of the thumb
687            let thumb_fill_bounds = if is_vertical {
688                Bounds::from_corner_and_size(
689                    Corner::TopRight,
690                    bounds.top_right() + point(-inset, inset + thumb_start),
691                    size(thumb_width, thumb_length),
692                )
693            } else {
694                Bounds::from_corner_and_size(
695                    Corner::BottomLeft,
696                    bounds.bottom_left() + point(inset + thumb_start, -inset),
697                    size(thumb_length, thumb_width),
698                )
699            };
700
701            let bar_hitbox = window.with_content_mask(Some(ContentMask { bounds }), |window| {
702                window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal)
703            });
704
705            states.push(AxisPrepaintState {
706                axis,
707                bar_hitbox,
708                bounds,
709                radius,
710                bg: bar_bg,
711                border: bar_border,
712                thumb_bounds,
713                thumb_fill_bounds,
714                thumb_bg,
715                scroll_size: scroll_area_size,
716                container_size,
717                thumb_size: thumb_length,
718                margin_end,
719            })
720        }
721
722        PrepaintState {
723            hitbox,
724            states,
725            scrollbar_state: state,
726        }
727    }
728
729    fn paint(
730        &mut self,
731        _: Option<&GlobalElementId>,
732        _: Option<&InspectorElementId>,
733        _: Bounds<Pixels>,
734        _: &mut Self::RequestLayoutState,
735        prepaint: &mut Self::PrepaintState,
736        window: &mut Window,
737        cx: &mut App,
738    ) {
739        let scrollbar_state = &prepaint.scrollbar_state;
740        let scrollbar_show = self.scrollbar_show.unwrap_or(cx.theme().scrollbar_show);
741        let view_id = window.current_view();
742        let hitbox_bounds = prepaint.hitbox.bounds;
743        let is_visible = scrollbar_state.get().is_scrollbar_visible() || scrollbar_show.is_always();
744        let is_hover_to_show = scrollbar_show.is_hover();
745
746        // Update last_scroll_time when offset is changed.
747        if self.scroll_handle.offset() != scrollbar_state.get().last_scroll_offset {
748            scrollbar_state.set(
749                scrollbar_state
750                    .get()
751                    .with_last_scroll(self.scroll_handle.offset(), Some(Instant::now())),
752            );
753            cx.notify(view_id);
754        }
755
756        window.with_content_mask(
757            Some(ContentMask {
758                bounds: hitbox_bounds,
759            }),
760            |window| {
761                for state in prepaint.states.iter() {
762                    let axis = state.axis;
763                    let mut radius = state.radius;
764                    if cx.theme().radius.is_zero() {
765                        radius = px(0.);
766                    }
767                    let bounds = state.bounds;
768                    let thumb_bounds = state.thumb_bounds;
769                    let scroll_area_size = state.scroll_size;
770                    let container_size = state.container_size;
771                    let thumb_size = state.thumb_size;
772                    let margin_end = state.margin_end;
773                    let is_vertical = axis.is_vertical();
774
775                    window.set_cursor_style(CursorStyle::default(), &state.bar_hitbox);
776
777                    window.paint_layer(hitbox_bounds, |cx| {
778                        cx.paint_quad(fill(state.bounds, state.bg));
779
780                        cx.paint_quad(PaintQuad {
781                            bounds,
782                            corner_radii: (0.).into(),
783                            background: gpui::transparent_black().into(),
784                            border_widths: if is_vertical {
785                                Edges {
786                                    top: px(0.),
787                                    right: px(0.),
788                                    bottom: px(0.),
789                                    left: px(0.),
790                                }
791                            } else {
792                                Edges {
793                                    top: px(0.),
794                                    right: px(0.),
795                                    bottom: px(0.),
796                                    left: px(0.),
797                                }
798                            },
799                            border_color: state.border,
800                            border_style: BorderStyle::default(),
801                        });
802
803                        cx.paint_quad(
804                            fill(state.thumb_fill_bounds, state.thumb_bg).corner_radii(radius),
805                        );
806                    });
807
808                    window.on_mouse_event({
809                        let state = scrollbar_state.clone();
810                        let scroll_handle = self.scroll_handle.clone();
811
812                        move |event: &ScrollWheelEvent, phase, _, cx| {
813                            if phase.bubble() && hitbox_bounds.contains(&event.position) {
814                                if scroll_handle.offset() != state.get().last_scroll_offset {
815                                    state.set(state.get().with_last_scroll(
816                                        scroll_handle.offset(),
817                                        Some(Instant::now()),
818                                    ));
819                                    cx.notify(view_id);
820                                }
821                            }
822                        }
823                    });
824
825                    let safe_range = (-scroll_area_size + container_size)..px(0.);
826
827                    if is_hover_to_show || is_visible {
828                        window.on_mouse_event({
829                            let state = scrollbar_state.clone();
830                            let scroll_handle = self.scroll_handle.clone();
831
832                            move |event: &MouseDownEvent, phase, _, cx| {
833                                if phase.bubble() && bounds.contains(&event.position) {
834                                    cx.stop_propagation();
835
836                                    if thumb_bounds.contains(&event.position) {
837                                        // click on the thumb bar, set the drag position
838                                        let pos = event.position - thumb_bounds.origin;
839
840                                        scroll_handle.start_drag();
841                                        state.set(state.get().with_drag_pos(axis, pos));
842
843                                        cx.notify(view_id);
844                                    } else {
845                                        // click on the scrollbar, jump to the position
846                                        // Set the thumb bar center to the click position
847                                        let offset = scroll_handle.offset();
848                                        let percentage = if is_vertical {
849                                            (event.position.y - thumb_size / 2. - bounds.origin.y)
850                                                / (bounds.size.height - thumb_size)
851                                        } else {
852                                            (event.position.x - thumb_size / 2. - bounds.origin.x)
853                                                / (bounds.size.width - thumb_size)
854                                        }
855                                        .min(1.);
856
857                                        if is_vertical {
858                                            scroll_handle.set_offset(point(
859                                                offset.x,
860                                                (-scroll_area_size * percentage)
861                                                    .clamp(safe_range.start, safe_range.end),
862                                            ));
863                                        } else {
864                                            scroll_handle.set_offset(point(
865                                                (-scroll_area_size * percentage)
866                                                    .clamp(safe_range.start, safe_range.end),
867                                                offset.y,
868                                            ));
869                                        }
870                                    }
871                                }
872                            }
873                        });
874                    }
875
876                    window.on_mouse_event({
877                        let scroll_handle = self.scroll_handle.clone();
878                        let state = scrollbar_state.clone();
879                        let max_fps_duration = Duration::from_millis((1000 / self.max_fps) as u64);
880
881                        move |event: &MouseMoveEvent, _, _, cx| {
882                            let mut notify = false;
883                            // When is hover to show mode or it was visible,
884                            // we need to update the hovered state and increase the last_scroll_time.
885                            let need_hover_to_update = is_hover_to_show || is_visible;
886                            // Update hovered state for scrollbar
887                            if bounds.contains(&event.position) && need_hover_to_update {
888                                state.set(state.get().with_hovered(Some(axis)));
889
890                                if state.get().hovered_axis != Some(axis) {
891                                    notify = true;
892                                }
893                            } else {
894                                if state.get().hovered_axis == Some(axis) {
895                                    if state.get().hovered_axis.is_some() {
896                                        state.set(state.get().with_hovered(None));
897                                        notify = true;
898                                    }
899                                }
900                            }
901
902                            // Update hovered state for scrollbar thumb
903                            if thumb_bounds.contains(&event.position) {
904                                if state.get().hovered_on_thumb != Some(axis) {
905                                    state.set(state.get().with_hovered_on_thumb(Some(axis)));
906                                    notify = true;
907                                }
908                            } else {
909                                if state.get().hovered_on_thumb == Some(axis) {
910                                    state.set(state.get().with_hovered_on_thumb(None));
911                                    notify = true;
912                                }
913                            }
914
915                            // Move thumb position on dragging
916                            if state.get().dragged_axis == Some(axis) && event.dragging() {
917                                // Stop the event propagation to avoid selecting text or other side effects.
918                                cx.stop_propagation();
919
920                                // drag_pos is the position of the mouse down event
921                                // We need to keep the thumb bar still at the origin down position
922                                let drag_pos = state.get().drag_pos;
923
924                                let percentage = (if is_vertical {
925                                    (event.position.y - drag_pos.y - bounds.origin.y)
926                                        / (bounds.size.height - thumb_size)
927                                } else {
928                                    (event.position.x - drag_pos.x - bounds.origin.x)
929                                        / (bounds.size.width - thumb_size - margin_end)
930                                })
931                                .clamp(0., 1.);
932
933                                let offset = if is_vertical {
934                                    point(
935                                        scroll_handle.offset().x,
936                                        (-(scroll_area_size - container_size) * percentage)
937                                            .clamp(safe_range.start, safe_range.end),
938                                    )
939                                } else {
940                                    point(
941                                        (-(scroll_area_size - container_size) * percentage)
942                                            .clamp(safe_range.start, safe_range.end),
943                                        scroll_handle.offset().y,
944                                    )
945                                };
946
947                                if (scroll_handle.offset().y - offset.y).abs() > px(1.)
948                                    || (scroll_handle.offset().x - offset.x).abs() > px(1.)
949                                {
950                                    // Limit update rate
951                                    if state.get().last_update.elapsed() > max_fps_duration {
952                                        scroll_handle.set_offset(offset);
953                                        state.set(state.get().with_last_update(Instant::now()));
954                                        notify = true;
955                                    }
956                                }
957                            }
958
959                            if notify {
960                                cx.notify(view_id);
961                            }
962                        }
963                    });
964
965                    window.on_mouse_event({
966                        let state = scrollbar_state.clone();
967                        let scroll_handle = self.scroll_handle.clone();
968
969                        move |_event: &MouseUpEvent, phase, _, cx| {
970                            if phase.bubble() {
971                                scroll_handle.end_drag();
972                                state.set(state.get().with_unset_drag_pos());
973                                cx.notify(view_id);
974                            }
975                        }
976                    });
977                }
978            },
979        );
980    }
981}