gpui_component/scroll/
scrollbar.rs

1use std::{
2    cell::Cell,
3    ops::Deref,
4    rc::Rc,
5    time::{Duration, Instant},
6};
7
8use crate::{ActiveTheme, AxisExt};
9use gpui::{
10    fill, point, px, relative, size, App, Axis, BorderStyle, Bounds, ContentMask, Corner,
11    CursorStyle, Edges, Element, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId,
12    IntoElement, IsZero, LayoutId, ListState, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
13    PaintQuad, Pixels, Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style, Timer,
14    UniformListScrollHandle, Window,
15};
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18
19/// The width of the scrollbar (THUMB_ACTIVE_INSET * 2 + THUMB_ACTIVE_WIDTH)
20const WIDTH: Pixels = px(2. * 2. + 8.);
21const MIN_THUMB_SIZE: f32 = 48.;
22
23const THUMB_WIDTH: Pixels = px(6.);
24const THUMB_RADIUS: Pixels = px(6. / 2.);
25const THUMB_INSET: Pixels = px(2.);
26
27const THUMB_ACTIVE_WIDTH: Pixels = px(8.);
28const THUMB_ACTIVE_RADIUS: Pixels = px(8. / 2.);
29const THUMB_ACTIVE_INSET: Pixels = px(2.);
30
31const FADE_OUT_DURATION: f32 = 3.0;
32const FADE_OUT_DELAY: f32 = 2.0;
33
34/// Scrollbar show mode.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default, JsonSchema)]
36pub enum ScrollbarShow {
37    /// Show scrollbar when scrolling, will fade out after idle.
38    #[default]
39    Scrolling,
40    /// Show scrollbar on hover.
41    Hover,
42    /// Always show scrollbar.
43    Always,
44}
45
46impl ScrollbarShow {
47    fn is_hover(&self) -> bool {
48        matches!(self, Self::Hover)
49    }
50
51    fn is_always(&self) -> bool {
52        matches!(self, Self::Always)
53    }
54}
55
56/// A trait for scroll handles that can get and set offset.
57pub trait ScrollHandleOffsetable {
58    /// Get the current offset of the scroll handle.
59    fn offset(&self) -> Point<Pixels>;
60    /// Set the offset of the scroll handle.
61    fn set_offset(&self, offset: Point<Pixels>);
62    /// The full size of the content, including padding.
63    fn content_size(&self) -> Size<Pixels>;
64    /// Called when start dragging the scrollbar thumb.
65    fn start_drag(&self) {}
66    /// Called when end dragging the scrollbar thumb.
67    fn end_drag(&self) {}
68}
69
70impl ScrollHandleOffsetable for ScrollHandle {
71    fn offset(&self) -> Point<Pixels> {
72        self.offset()
73    }
74
75    fn set_offset(&self, offset: Point<Pixels>) {
76        self.set_offset(offset);
77    }
78
79    fn content_size(&self) -> Size<Pixels> {
80        self.max_offset() + self.bounds().size
81    }
82}
83
84impl ScrollHandleOffsetable for UniformListScrollHandle {
85    fn offset(&self) -> Point<Pixels> {
86        self.0.borrow().base_handle.offset()
87    }
88
89    fn set_offset(&self, offset: Point<Pixels>) {
90        self.0.borrow_mut().base_handle.set_offset(offset)
91    }
92
93    fn content_size(&self) -> Size<Pixels> {
94        let base_handle = &self.0.borrow().base_handle;
95        base_handle.max_offset() + base_handle.bounds().size
96    }
97}
98
99impl ScrollHandleOffsetable for ListState {
100    fn offset(&self) -> Point<Pixels> {
101        self.scroll_px_offset_for_scrollbar()
102    }
103
104    fn set_offset(&self, offset: Point<Pixels>) {
105        self.set_offset_from_scrollbar(offset);
106    }
107
108    fn content_size(&self) -> Size<Pixels> {
109        self.viewport_bounds().size + self.max_offset_for_scrollbar()
110    }
111
112    fn start_drag(&self) {
113        self.scrollbar_drag_started();
114    }
115
116    fn end_drag(&self) {
117        self.scrollbar_drag_ended();
118    }
119}
120
121#[doc(hidden)]
122#[derive(Debug, Clone)]
123pub struct ScrollbarState(Rc<Cell<ScrollbarStateInner>>);
124
125#[doc(hidden)]
126#[derive(Debug, Clone, Copy)]
127pub struct ScrollbarStateInner {
128    hovered_axis: Option<Axis>,
129    hovered_on_thumb: Option<Axis>,
130    dragged_axis: Option<Axis>,
131    drag_pos: Point<Pixels>,
132    last_scroll_offset: Point<Pixels>,
133    last_scroll_time: Option<Instant>,
134    // Last update offset
135    last_update: Instant,
136    idle_timer_scheduled: bool,
137}
138
139impl Default for ScrollbarState {
140    fn default() -> Self {
141        Self(Rc::new(Cell::new(ScrollbarStateInner {
142            hovered_axis: None,
143            hovered_on_thumb: None,
144            dragged_axis: None,
145            drag_pos: point(px(0.), px(0.)),
146            last_scroll_offset: point(px(0.), px(0.)),
147            last_scroll_time: None,
148            last_update: Instant::now(),
149            idle_timer_scheduled: false,
150        })))
151    }
152}
153
154impl Deref for ScrollbarState {
155    type Target = Rc<Cell<ScrollbarStateInner>>;
156
157    fn deref(&self) -> &Self::Target {
158        &self.0
159    }
160}
161
162impl ScrollbarStateInner {
163    fn with_drag_pos(&self, axis: Axis, pos: Point<Pixels>) -> Self {
164        let mut state = *self;
165        if axis.is_vertical() {
166            state.drag_pos.y = pos.y;
167        } else {
168            state.drag_pos.x = pos.x;
169        }
170
171        state.dragged_axis = Some(axis);
172        state
173    }
174
175    fn with_unset_drag_pos(&self) -> Self {
176        let mut state = *self;
177        state.dragged_axis = None;
178        state
179    }
180
181    fn with_hovered(&self, axis: Option<Axis>) -> Self {
182        let mut state = *self;
183        state.hovered_axis = axis;
184        if axis.is_some() {
185            state.last_scroll_time = Some(std::time::Instant::now());
186        }
187        state
188    }
189
190    fn with_hovered_on_thumb(&self, axis: Option<Axis>) -> Self {
191        let mut state = *self;
192        state.hovered_on_thumb = axis;
193        if self.is_scrollbar_visible() {
194            if axis.is_some() {
195                state.last_scroll_time = Some(std::time::Instant::now());
196            }
197        }
198        state
199    }
200
201    fn with_last_scroll(
202        &self,
203        last_scroll_offset: Point<Pixels>,
204        last_scroll_time: Option<Instant>,
205    ) -> Self {
206        let mut state = *self;
207        state.last_scroll_offset = last_scroll_offset;
208        state.last_scroll_time = last_scroll_time;
209        state
210    }
211
212    fn with_last_scroll_time(&self, t: Option<Instant>) -> Self {
213        let mut state = *self;
214        state.last_scroll_time = t;
215        state
216    }
217
218    fn with_last_update(&self, t: Instant) -> Self {
219        let mut state = *self;
220        state.last_update = t;
221        state
222    }
223
224    fn with_idle_timer_scheduled(&self, scheduled: bool) -> Self {
225        let mut state = *self;
226        state.idle_timer_scheduled = scheduled;
227        state
228    }
229
230    fn is_scrollbar_visible(&self) -> bool {
231        // On drag
232        if self.dragged_axis.is_some() {
233            return true;
234        }
235
236        if let Some(last_time) = self.last_scroll_time {
237            let elapsed = Instant::now().duration_since(last_time).as_secs_f32();
238            elapsed < FADE_OUT_DURATION
239        } else {
240            false
241        }
242    }
243}
244
245/// Scrollbar axis.
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub enum ScrollbarAxis {
248    /// Vertical scrollbar.
249    Vertical,
250    /// Horizontal scrollbar.
251    Horizontal,
252    /// Show both vertical and horizontal scrollbars.
253    Both,
254}
255
256impl From<Axis> for ScrollbarAxis {
257    fn from(axis: Axis) -> Self {
258        match axis {
259            Axis::Vertical => Self::Vertical,
260            Axis::Horizontal => Self::Horizontal,
261        }
262    }
263}
264
265impl ScrollbarAxis {
266    /// Return true if the scrollbar axis is vertical.
267    #[inline]
268    pub fn is_vertical(&self) -> bool {
269        matches!(self, Self::Vertical)
270    }
271
272    /// Return true if the scrollbar axis is horizontal.
273    #[inline]
274    pub fn is_horizontal(&self) -> bool {
275        matches!(self, Self::Horizontal)
276    }
277
278    /// Return true if the scrollbar axis is both vertical and horizontal.
279    #[inline]
280    pub fn is_both(&self) -> bool {
281        matches!(self, Self::Both)
282    }
283
284    /// Return true if the scrollbar has vertical axis.
285    #[inline]
286    pub fn has_vertical(&self) -> bool {
287        matches!(self, Self::Vertical | Self::Both)
288    }
289
290    /// Return true if the scrollbar has horizontal axis.
291    #[inline]
292    pub fn has_horizontal(&self) -> bool {
293        matches!(self, Self::Horizontal | Self::Both)
294    }
295
296    #[inline]
297    fn all(&self) -> Vec<Axis> {
298        match self {
299            Self::Vertical => vec![Axis::Vertical],
300            Self::Horizontal => vec![Axis::Horizontal],
301            // This should keep Horizontal first, Vertical is the primary axis
302            // if Vertical not need display, then Horizontal will not keep right margin.
303            Self::Both => vec![Axis::Horizontal, Axis::Vertical],
304        }
305    }
306}
307
308/// Scrollbar control for scroll-area or a uniform-list.
309pub struct Scrollbar {
310    axis: ScrollbarAxis,
311    scrollbar_show: Option<ScrollbarShow>,
312    scroll_handle: Rc<dyn ScrollHandleOffsetable>,
313    state: ScrollbarState,
314    scroll_size: Option<Size<Pixels>>,
315    /// Maximum frames per second for scrolling by drag. Default is 120 FPS.
316    ///
317    /// This is used to limit the update rate of the scrollbar when it is
318    /// being dragged for some complex interactions for reducing CPU usage.
319    max_fps: usize,
320}
321
322impl Scrollbar {
323    fn new(
324        axis: impl Into<ScrollbarAxis>,
325        state: &ScrollbarState,
326        scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static),
327    ) -> Self {
328        Self {
329            state: state.clone(),
330            axis: axis.into(),
331            scrollbar_show: None,
332            scroll_handle: Rc::new(scroll_handle.clone()),
333            max_fps: 120,
334            scroll_size: None,
335        }
336    }
337
338    /// Create with vertical and horizontal scrollbar.
339    pub fn both(
340        state: &ScrollbarState,
341        scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static),
342    ) -> Self {
343        Self::new(ScrollbarAxis::Both, state, scroll_handle)
344    }
345
346    /// Create with horizontal scrollbar.
347    pub fn horizontal(
348        state: &ScrollbarState,
349        scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static),
350    ) -> Self {
351        Self::new(ScrollbarAxis::Horizontal, state, scroll_handle)
352    }
353
354    /// Create with vertical scrollbar.
355    pub fn vertical(
356        state: &ScrollbarState,
357        scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static),
358    ) -> Self {
359        Self::new(ScrollbarAxis::Vertical, state, scroll_handle)
360    }
361
362    /// Create vertical scrollbar for uniform list.
363    pub fn uniform_scroll(
364        state: &ScrollbarState,
365        scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static),
366    ) -> Self {
367        Self::new(ScrollbarAxis::Vertical, state, scroll_handle)
368    }
369
370    /// Set the scrollbar show mode [`ScrollbarShow`], if not set use the `cx.theme().scrollbar_show`.
371    pub fn scrollbar_show(mut self, scrollbar_show: ScrollbarShow) -> Self {
372        self.scrollbar_show = Some(scrollbar_show);
373        self
374    }
375
376    /// Set a special scroll size of the content area, default is None.
377    ///
378    /// Default will sync the `content_size` from `scroll_handle`.
379    pub fn scroll_size(mut self, scroll_size: Size<Pixels>) -> Self {
380        self.scroll_size = Some(scroll_size);
381        self
382    }
383
384    /// Set scrollbar axis.
385    pub fn axis(mut self, axis: impl Into<ScrollbarAxis>) -> Self {
386        self.axis = axis.into();
387        self
388    }
389
390    /// Set maximum frames per second for scrolling by drag. Default is 120 FPS.
391    ///
392    /// If you have very high CPU usage, consider reducing this value to improve performance.
393    ///
394    /// Available values: 30..120
395    pub(crate) fn max_fps(mut self, max_fps: usize) -> Self {
396        self.max_fps = max_fps.clamp(30, 120);
397        self
398    }
399
400    // Get the width of the scrollbar.
401    pub(crate) const fn width() -> Pixels {
402        WIDTH
403    }
404
405    fn style_for_active(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
406        (
407            cx.theme().scrollbar_thumb_hover,
408            cx.theme().scrollbar,
409            cx.theme().border,
410            THUMB_ACTIVE_WIDTH,
411            THUMB_ACTIVE_INSET,
412            THUMB_ACTIVE_RADIUS,
413        )
414    }
415
416    fn style_for_hovered_thumb(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
417        (
418            cx.theme().scrollbar_thumb_hover,
419            cx.theme().scrollbar,
420            cx.theme().border,
421            THUMB_ACTIVE_WIDTH,
422            THUMB_ACTIVE_INSET,
423            THUMB_ACTIVE_RADIUS,
424        )
425    }
426
427    fn style_for_hovered_bar(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
428        (
429            cx.theme().scrollbar_thumb,
430            cx.theme().scrollbar,
431            gpui::transparent_black(),
432            THUMB_ACTIVE_WIDTH,
433            THUMB_ACTIVE_INSET,
434            THUMB_ACTIVE_RADIUS,
435        )
436    }
437
438    fn style_for_normal(&self, cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
439        let scrollbar_show = self.scrollbar_show.unwrap_or(cx.theme().scrollbar_show);
440        let (width, inset, radius) = match scrollbar_show {
441            ScrollbarShow::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS),
442            _ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS),
443        };
444
445        (
446            cx.theme().scrollbar_thumb,
447            cx.theme().scrollbar,
448            gpui::transparent_black(),
449            width,
450            inset,
451            radius,
452        )
453    }
454
455    fn style_for_idle(&self, cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
456        let scrollbar_show = self.scrollbar_show.unwrap_or(cx.theme().scrollbar_show);
457        let (width, inset, radius) = match scrollbar_show {
458            ScrollbarShow::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS),
459            _ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS),
460        };
461
462        (
463            gpui::transparent_black(),
464            gpui::transparent_black(),
465            gpui::transparent_black(),
466            width,
467            inset,
468            radius,
469        )
470    }
471}
472
473impl IntoElement for Scrollbar {
474    type Element = Self;
475
476    fn into_element(self) -> Self::Element {
477        self
478    }
479}
480
481#[doc(hidden)]
482pub struct PrepaintState {
483    hitbox: Hitbox,
484    states: Vec<AxisPrepaintState>,
485}
486
487#[doc(hidden)]
488pub struct AxisPrepaintState {
489    axis: Axis,
490    bar_hitbox: Hitbox,
491    bounds: Bounds<Pixels>,
492    radius: Pixels,
493    bg: Hsla,
494    border: Hsla,
495    thumb_bounds: Bounds<Pixels>,
496    // Bounds of thumb to be rendered.
497    thumb_fill_bounds: Bounds<Pixels>,
498    thumb_bg: Hsla,
499    scroll_size: Pixels,
500    container_size: Pixels,
501    thumb_size: Pixels,
502    margin_end: Pixels,
503}
504
505impl Element for Scrollbar {
506    type RequestLayoutState = ();
507    type PrepaintState = PrepaintState;
508
509    fn id(&self) -> Option<gpui::ElementId> {
510        None
511    }
512
513    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
514        None
515    }
516
517    fn request_layout(
518        &mut self,
519        _: Option<&GlobalElementId>,
520        _: Option<&InspectorElementId>,
521        window: &mut Window,
522        cx: &mut App,
523    ) -> (LayoutId, Self::RequestLayoutState) {
524        let mut style = Style::default();
525        style.position = Position::Absolute;
526        style.flex_grow = 1.0;
527        style.flex_shrink = 1.0;
528        style.size.width = relative(1.).into();
529        style.size.height = relative(1.).into();
530
531        (window.request_layout(style, None, cx), ())
532    }
533
534    fn prepaint(
535        &mut self,
536        _: Option<&GlobalElementId>,
537        _: Option<&InspectorElementId>,
538        bounds: Bounds<Pixels>,
539        _: &mut Self::RequestLayoutState,
540        window: &mut Window,
541        cx: &mut App,
542    ) -> Self::PrepaintState {
543        let hitbox = window.with_content_mask(Some(ContentMask { bounds }), |window| {
544            window.insert_hitbox(bounds, HitboxBehavior::Normal)
545        });
546
547        let mut states = vec![];
548        let mut has_both = self.axis.is_both();
549        let scroll_size = self
550            .scroll_size
551            .unwrap_or(self.scroll_handle.content_size());
552
553        for axis in self.axis.all().into_iter() {
554            let is_vertical = axis.is_vertical();
555            let (scroll_area_size, container_size, scroll_position) = if is_vertical {
556                (
557                    scroll_size.height,
558                    hitbox.size.height,
559                    self.scroll_handle.offset().y,
560                )
561            } else {
562                (
563                    scroll_size.width,
564                    hitbox.size.width,
565                    self.scroll_handle.offset().x,
566                )
567            };
568
569            // The horizontal scrollbar is set avoid overlapping with the vertical scrollbar, if the vertical scrollbar is visible.
570            let margin_end = if has_both && !is_vertical {
571                WIDTH
572            } else {
573                px(0.)
574            };
575
576            // Hide scrollbar, if the scroll area is smaller than the container.
577            if scroll_area_size <= container_size {
578                has_both = false;
579                continue;
580            }
581
582            let thumb_length =
583                (container_size / scroll_area_size * container_size).max(px(MIN_THUMB_SIZE));
584            let thumb_start = -(scroll_position / (scroll_area_size - container_size)
585                * (container_size - margin_end - thumb_length));
586            let thumb_end = (thumb_start + thumb_length).min(container_size - margin_end);
587
588            let bounds = Bounds {
589                origin: if is_vertical {
590                    point(hitbox.origin.x + hitbox.size.width - WIDTH, hitbox.origin.y)
591                } else {
592                    point(
593                        hitbox.origin.x,
594                        hitbox.origin.y + hitbox.size.height - WIDTH,
595                    )
596                },
597                size: gpui::Size {
598                    width: if is_vertical {
599                        WIDTH
600                    } else {
601                        hitbox.size.width
602                    },
603                    height: if is_vertical {
604                        hitbox.size.height
605                    } else {
606                        WIDTH
607                    },
608                },
609            };
610
611            let scrollbar_show = self.scrollbar_show.unwrap_or(cx.theme().scrollbar_show);
612            let state = self.state.clone();
613            let is_always_to_show = scrollbar_show.is_always();
614            let is_hover_to_show = scrollbar_show.is_hover();
615            let is_hovered_on_bar = state.get().hovered_axis == Some(axis);
616            let is_hovered_on_thumb = state.get().hovered_on_thumb == Some(axis);
617            let is_offset_changed = state.get().last_scroll_offset != self.scroll_handle.offset();
618
619            let (thumb_bg, bar_bg, bar_border, thumb_width, inset, radius) =
620                if state.get().dragged_axis == Some(axis) {
621                    Self::style_for_active(cx)
622                } else if is_hover_to_show && (is_hovered_on_bar || is_hovered_on_thumb) {
623                    if is_hovered_on_thumb {
624                        Self::style_for_hovered_thumb(cx)
625                    } else {
626                        Self::style_for_hovered_bar(cx)
627                    }
628                } else if is_offset_changed {
629                    self.style_for_normal(cx)
630                } else if is_always_to_show {
631                    if is_hovered_on_thumb {
632                        Self::style_for_hovered_thumb(cx)
633                    } else {
634                        Self::style_for_hovered_bar(cx)
635                    }
636                } else {
637                    let mut idle_state = self.style_for_idle(cx);
638                    // Delay 2s to fade out the scrollbar thumb (in 1s)
639                    if let Some(last_time) = state.get().last_scroll_time {
640                        let elapsed = Instant::now().duration_since(last_time).as_secs_f32();
641                        if is_hovered_on_bar {
642                            state.set(state.get().with_last_scroll_time(Some(Instant::now())));
643                            idle_state = if is_hovered_on_thumb {
644                                Self::style_for_hovered_thumb(cx)
645                            } else {
646                                Self::style_for_hovered_bar(cx)
647                            };
648                        } else if elapsed < FADE_OUT_DELAY {
649                            idle_state.0 = cx.theme().scrollbar_thumb;
650
651                            if !state.get().idle_timer_scheduled {
652                                let state = state.clone();
653                                state.set(state.get().with_idle_timer_scheduled(true));
654                                let current_view = window.current_view();
655                                let next_delay = Duration::from_secs_f32(FADE_OUT_DELAY - elapsed);
656                                window
657                                    .spawn(cx, async move |cx| {
658                                        Timer::after(next_delay).await;
659                                        state.set(state.get().with_idle_timer_scheduled(false));
660                                        cx.update(|_, cx| cx.notify(current_view)).ok();
661                                    })
662                                    .detach();
663                            }
664                        } else if elapsed < FADE_OUT_DURATION {
665                            let opacity = 1.0 - (elapsed - FADE_OUT_DELAY).powi(10);
666                            idle_state.0 = cx.theme().scrollbar_thumb.opacity(opacity);
667
668                            window.request_animation_frame();
669                        }
670                    }
671
672                    idle_state
673                };
674
675            // The clickable area of the thumb
676            let thumb_length = thumb_end - thumb_start - inset * 2;
677            let thumb_bounds = if is_vertical {
678                Bounds::from_corner_and_size(
679                    Corner::TopRight,
680                    bounds.top_right() + point(-inset, inset + thumb_start),
681                    size(WIDTH, thumb_length),
682                )
683            } else {
684                Bounds::from_corner_and_size(
685                    Corner::BottomLeft,
686                    bounds.bottom_left() + point(inset + thumb_start, -inset),
687                    size(thumb_length, WIDTH),
688                )
689            };
690
691            // The actual render area of the thumb
692            let thumb_fill_bounds = if is_vertical {
693                Bounds::from_corner_and_size(
694                    Corner::TopRight,
695                    bounds.top_right() + point(-inset, inset + thumb_start),
696                    size(thumb_width, thumb_length),
697                )
698            } else {
699                Bounds::from_corner_and_size(
700                    Corner::BottomLeft,
701                    bounds.bottom_left() + point(inset + thumb_start, -inset),
702                    size(thumb_length, thumb_width),
703                )
704            };
705
706            let bar_hitbox = window.with_content_mask(Some(ContentMask { bounds }), |window| {
707                window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal)
708            });
709
710            states.push(AxisPrepaintState {
711                axis,
712                bar_hitbox,
713                bounds,
714                radius,
715                bg: bar_bg,
716                border: bar_border,
717                thumb_bounds,
718                thumb_fill_bounds,
719                thumb_bg,
720                scroll_size: scroll_area_size,
721                container_size,
722                thumb_size: thumb_length,
723                margin_end,
724            })
725        }
726
727        PrepaintState { hitbox, states }
728    }
729
730    fn paint(
731        &mut self,
732        _: Option<&GlobalElementId>,
733        _: Option<&InspectorElementId>,
734        _: Bounds<Pixels>,
735        _: &mut Self::RequestLayoutState,
736        prepaint: &mut Self::PrepaintState,
737        window: &mut Window,
738        cx: &mut App,
739    ) {
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 = self.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() != self.state.get().last_scroll_offset {
748            self.state.set(
749                self.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 = self.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 = self.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 = self.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 scroll_handle = self.scroll_handle.clone();
967                        let state = self.state.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}