Skip to main content

gpui_base/
scrollbar.rs

1use std::{cell::Cell, ops::Deref, panic::Location, rc::Rc};
2
3use instant::{Duration, Instant};
4
5use crate::{
6    AxisExt,
7    animation::{ease_in_cubic, ease_out_cubic},
8    theme::ActiveTheme as _,
9};
10use gpui::{
11    Anchor, App, Axis, Background, BorderStyle, Bounds, ContentMask, CursorStyle, Edges, Element,
12    ElementId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, IntoElement,
13    IsZero, LayoutId, ListState, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels,
14    Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style, UniformListScrollHandle, Window,
15    fill, point, prelude::FluentBuilder, 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: Pixels = px(48.);
23
24const THUMB_WIDTH: Pixels = px(6.);
25const THUMB_RADIUS: Pixels = Pixels::ZERO;
26const THUMB_INSET: Pixels = px(4.);
27
28const THUMB_ACTIVE_WIDTH: Pixels = px(8.);
29const THUMB_ACTIVE_RADIUS: Pixels = Pixels::ZERO;
30const THUMB_ACTIVE_INSET: Pixels = px(4.);
31
32/// How long visibility is held after the last activity, when the styled layer
33/// projects no [`ScrollbarMotion`] of its own.
34///
35/// This is a visibility hold rather than motion: without it [`ScrollbarMode::Scrolling`]
36/// could never reveal the scrollbar.
37const DEFAULT_IDLE: Duration = Duration::from_secs(2);
38
39fn clamp_thumb_radius(radius: Pixels, bounds: Bounds<Pixels>) -> Pixels {
40    radius
41        .min(bounds.size.width / 2.)
42        .min(bounds.size.height / 2.)
43}
44
45/// Scrollbar show mode.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default, JsonSchema)]
47pub enum ScrollbarMode {
48    /// Show scrollbar when scrolling, will fade out after idle.
49    #[default]
50    Scrolling,
51    /// Show scrollbar on hover.
52    Hover,
53    /// Always show scrollbar.
54    Always,
55}
56
57impl ScrollbarMode {
58    fn is_hover(&self) -> bool {
59        matches!(self, Self::Hover)
60    }
61
62    fn is_always(&self) -> bool {
63        matches!(self, Self::Always)
64    }
65}
66
67/// A trait for scroll handles that can get and set offset.
68pub trait ScrollbarHandle: 'static {
69    /// Bounds of the viewport the scrollbar overlays.
70    fn viewport_bounds(&self) -> Bounds<Pixels>;
71    /// Get the current offset of the scroll handle.
72    fn offset(&self) -> Point<Pixels>;
73    /// Set the offset of the scroll handle.
74    fn set_offset(&self, offset: Point<Pixels>);
75    /// The full size of the content, including padding.
76    fn content_size(&self) -> Size<Pixels>;
77    /// Called when start dragging the scrollbar thumb.
78    fn start_drag(&self) {}
79    /// Called when end dragging the scrollbar thumb.
80    fn end_drag(&self) {}
81}
82
83impl ScrollbarHandle for ScrollHandle {
84    fn viewport_bounds(&self) -> Bounds<Pixels> {
85        self.bounds()
86    }
87
88    fn offset(&self) -> Point<Pixels> {
89        self.offset()
90    }
91
92    fn set_offset(&self, offset: Point<Pixels>) {
93        self.set_offset(offset);
94    }
95
96    fn content_size(&self) -> Size<Pixels> {
97        (self.max_offset() + self.bounds().size.into()).into()
98    }
99}
100
101impl ScrollbarHandle for UniformListScrollHandle {
102    fn viewport_bounds(&self) -> Bounds<Pixels> {
103        self.0.borrow().base_handle.bounds()
104    }
105
106    fn offset(&self) -> Point<Pixels> {
107        self.0.borrow().base_handle.offset()
108    }
109
110    fn set_offset(&self, offset: Point<Pixels>) {
111        self.0.borrow_mut().base_handle.set_offset(offset)
112    }
113
114    fn content_size(&self) -> Size<Pixels> {
115        let base_handle = &self.0.borrow().base_handle;
116        (base_handle.max_offset() + base_handle.bounds().size.into()).into()
117    }
118}
119
120impl ScrollbarHandle for ListState {
121    fn viewport_bounds(&self) -> Bounds<Pixels> {
122        ListState::viewport_bounds(self)
123    }
124
125    fn offset(&self) -> Point<Pixels> {
126        self.scroll_px_offset_for_scrollbar()
127    }
128
129    fn set_offset(&self, offset: Point<Pixels>) {
130        self.set_offset_from_scrollbar(offset);
131    }
132
133    fn content_size(&self) -> Size<Pixels> {
134        self.viewport_bounds().size + self.max_offset_for_scrollbar().into()
135    }
136
137    fn start_drag(&self) {
138        self.scrollbar_drag_started();
139    }
140
141    fn end_drag(&self) {
142        self.scrollbar_drag_ended();
143    }
144}
145
146#[doc(hidden)]
147#[derive(Debug, Clone)]
148struct ScrollbarState(Rc<Cell<ScrollbarStateInner>>);
149
150#[doc(hidden)]
151#[derive(Debug, Clone, Copy)]
152struct ScrollbarStateInner {
153    hovered_axis: Option<Axis>,
154    hovered_on_thumb: Option<Axis>,
155    dragged_axis: Option<Axis>,
156    drag_pos: Point<Pixels>,
157    last_scroll_offset: Point<Pixels>,
158    last_scroll_time: Option<Instant>,
159    // Last update offset
160    last_update: Instant,
161    idle_timer_scheduled: bool,
162    visibility: VisibilityAnimation,
163    vertical_width: WidthAnimation,
164    horizontal_width: WidthAnimation,
165}
166
167impl Default for ScrollbarState {
168    fn default() -> Self {
169        let now = Instant::now();
170        Self(Rc::new(Cell::new(ScrollbarStateInner {
171            hovered_axis: None,
172            hovered_on_thumb: None,
173            dragged_axis: None,
174            drag_pos: point(px(0.), px(0.)),
175            last_scroll_offset: point(px(0.), px(0.)),
176            last_scroll_time: None,
177            last_update: now,
178            idle_timer_scheduled: false,
179            visibility: VisibilityAnimation::hidden(now),
180            vertical_width: WidthAnimation::new(now),
181            horizontal_width: WidthAnimation::new(now),
182        })))
183    }
184}
185
186#[derive(Debug, Clone, Copy)]
187struct ScalarTransition<T> {
188    from: T,
189    target: T,
190    started_at: Instant,
191    duration: Duration,
192}
193
194impl<T: Copy + PartialEq> ScalarTransition<T> {
195    fn settled(value: T, now: Instant) -> Self {
196        Self {
197            from: value,
198            target: value,
199            started_at: now,
200            duration: Duration::ZERO,
201        }
202    }
203
204    fn sample(&self, now: Instant, interpolate: impl FnOnce(T, T, f32) -> T) -> (T, bool) {
205        if self.from == self.target || self.duration.is_zero() {
206            return (self.target, false);
207        }
208        let linear = now.saturating_duration_since(self.started_at).as_secs_f32()
209            / self.duration.as_secs_f32();
210        if linear >= 1.0 {
211            (self.target, false)
212        } else {
213            (
214                interpolate(self.from, self.target, linear.clamp(0.0, 1.0)),
215                true,
216            )
217        }
218    }
219
220    fn start(&mut self, from: T, target: T, duration: Duration, now: Instant) {
221        self.from = from;
222        self.target = target;
223        self.started_at = now;
224        self.duration = duration;
225    }
226
227    fn settle(&mut self, target: T, now: Instant) {
228        self.start(target, target, Duration::ZERO, now);
229    }
230}
231
232#[derive(Debug, Clone, Copy)]
233struct WidthAnimation {
234    transition: ScalarTransition<Pixels>,
235    initialized: bool,
236}
237
238impl WidthAnimation {
239    fn new(now: Instant) -> Self {
240        Self {
241            transition: ScalarTransition::settled(Pixels::ZERO, now),
242            initialized: false,
243        }
244    }
245
246    fn sample(&self, now: Instant) -> (Pixels, bool) {
247        self.transition.sample(now, |from, target, linear| {
248            from + (target - from) * ease_out_cubic(linear)
249        })
250    }
251
252    /// Move toward `target` over `duration`. A zero duration adopts the target
253    /// immediately, which is how reduced motion and a motionless theme arrive here.
254    fn set_target(&mut self, target: Pixels, duration: Duration, now: Instant) -> (Pixels, bool) {
255        if duration.is_zero() || !self.initialized {
256            self.transition.settle(target, now);
257            self.initialized = true;
258        } else if self.transition.target != target {
259            let from = self.sample(now).0;
260            self.transition.start(from, target, duration, now);
261        }
262        self.sample(now)
263    }
264}
265
266/// How a scrollbar becomes visible.
267///
268/// The styled layer chooses the choreography; Base only plays it.
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
270pub enum ScrollbarEntrance {
271    /// Fade in without moving.
272    #[default]
273    Fade,
274    /// Slide in from the nearest edge while fading.
275    SlideAndFade,
276}
277
278/// Motion tokens used by [`Scrollbar`].
279///
280/// Base installs no motion of its own: every transition duration defaults to
281/// zero, so visibility and thumb width snap. Product timing belongs to the
282/// styled layer, which projects it through [`crate::ScrollbarTheme`].
283#[derive(Debug, Clone, Copy, PartialEq)]
284pub struct ScrollbarMotion {
285    idle: Duration,
286    enter: Duration,
287    exit: Duration,
288    expand: Duration,
289    entrance: ScrollbarEntrance,
290    thumb_hover_entrance: ScrollbarEntrance,
291}
292
293impl Default for ScrollbarMotion {
294    fn default() -> Self {
295        Self {
296            idle: DEFAULT_IDLE,
297            enter: Duration::ZERO,
298            exit: Duration::ZERO,
299            expand: Duration::ZERO,
300            entrance: ScrollbarEntrance::Fade,
301            thumb_hover_entrance: ScrollbarEntrance::Fade,
302        }
303    }
304}
305
306impl ScrollbarMotion {
307    /// How long visibility is held after the last scroll, drag, or hover.
308    pub fn with_idle(mut self, idle: Duration) -> Self {
309        self.idle = idle;
310        self
311    }
312
313    /// How long the scrollbar takes to become fully visible.
314    pub fn with_enter(mut self, enter: Duration) -> Self {
315        self.enter = enter;
316        self
317    }
318
319    /// How long the scrollbar takes to fade away once the idle hold expires.
320    pub fn with_exit(mut self, exit: Duration) -> Self {
321        self.exit = exit;
322        self
323    }
324
325    /// How long the thumb takes to reach a new width.
326    pub fn with_expand(mut self, expand: Duration) -> Self {
327        self.expand = expand;
328        self
329    }
330
331    /// Which entrance choreography to play.
332    pub fn with_entrance(mut self, entrance: ScrollbarEntrance) -> Self {
333        self.entrance = entrance;
334        self
335    }
336
337    /// Which entrance choreography to play when hover reveals the thumb.
338    pub fn with_thumb_hover_entrance(mut self, entrance: ScrollbarEntrance) -> Self {
339        self.thumb_hover_entrance = entrance;
340        self
341    }
342
343    pub fn idle(&self) -> Duration {
344        self.idle
345    }
346
347    pub fn enter(&self) -> Duration {
348        self.enter
349    }
350
351    pub fn exit(&self) -> Duration {
352        self.exit
353    }
354
355    pub fn expand(&self) -> Duration {
356        self.expand
357    }
358
359    pub fn entrance(&self) -> ScrollbarEntrance {
360        self.entrance
361    }
362
363    pub fn thumb_hover_entrance(&self) -> ScrollbarEntrance {
364        self.thumb_hover_entrance
365    }
366
367    fn entrance_for(&self, mode: ScrollbarMode, thumb_hovered: bool) -> ScrollbarEntrance {
368        if mode.is_hover() && thumb_hovered {
369            self.thumb_hover_entrance
370        } else {
371            self.entrance
372        }
373    }
374}
375
376#[derive(Debug, Clone, Copy)]
377struct VisibilityAnimation {
378    opacity: ScalarTransition<f32>,
379    position: ScalarTransition<f32>,
380    entrance: ScrollbarEntrance,
381}
382
383#[derive(Debug, Clone, Copy)]
384struct VisibilitySample {
385    opacity: f32,
386    position: f32,
387    running: bool,
388}
389
390impl VisibilityAnimation {
391    fn hidden(now: Instant) -> Self {
392        Self {
393            opacity: ScalarTransition::settled(0.0, now),
394            position: ScalarTransition::settled(0.0, now),
395            entrance: ScrollbarEntrance::Fade,
396        }
397    }
398
399    fn sample(&self, now: Instant) -> VisibilitySample {
400        let entering =
401            self.opacity.target > self.opacity.from || self.position.target > self.position.from;
402        let (opacity, opacity_running) = self.opacity.sample(now, |from, target, linear| {
403            let factor = if entering {
404                linear
405            } else {
406                ease_in_cubic(linear)
407            };
408            from + (target - from) * factor
409        });
410        let (position, position_running) = self.position.sample(now, |from, target, linear| {
411            let factor = if entering {
412                ease_out_cubic(linear)
413            } else {
414                ease_in_cubic(linear)
415            };
416            from + (target - from) * factor
417        });
418
419        VisibilitySample {
420            opacity,
421            position,
422            running: opacity_running || position_running,
423        }
424    }
425
426    /// Reverse or start a transition toward `visible`.
427    ///
428    /// The leg runs for `enter` or `exit` scaled by the distance still to cover,
429    /// so an interrupted transition keeps its speed instead of restarting.
430    fn set_visible(
431        &mut self,
432        visible: bool,
433        entrance: ScrollbarEntrance,
434        enter: Duration,
435        exit: Duration,
436        now: Instant,
437    ) {
438        let target = if visible { 1.0 } else { 0.0 };
439        let full_duration = if visible { enter } else { exit };
440        if full_duration.is_zero() {
441            // A motionless policy — reduced motion, an always-visible scrollbar,
442            // or a theme that projects no motion — adopts the target outright,
443            // even if a transition was in flight when the policy changed.
444            self.opacity.settle(target, now);
445            self.position.settle(target, now);
446            self.entrance = entrance;
447            return;
448        }
449        if self.opacity.target == target
450            && self.position.target == target
451            && self.entrance == entrance
452        {
453            return;
454        }
455
456        let sample = self.sample(now);
457        let from_position = if visible && entrance == ScrollbarEntrance::Fade {
458            1.0
459        } else {
460            sample.position
461        };
462        let distance = (target - sample.opacity)
463            .abs()
464            .max((target - from_position).abs());
465        let duration = full_duration.mul_f32(distance);
466        self.opacity.start(sample.opacity, target, duration, now);
467        self.position.start(from_position, target, duration, now);
468        self.entrance = entrance;
469    }
470}
471
472fn visibility_translation(axis: Axis, track_width: Pixels, progress: f32) -> Point<Pixels> {
473    let offset = track_width * (1.0 - progress.clamp(0.0, 1.0));
474    if axis.is_vertical() {
475        point(offset, px(0.))
476    } else {
477        point(px(0.), offset)
478    }
479}
480
481fn wants_visible(
482    mode: ScrollbarMode,
483    is_hovered: bool,
484    is_dragging: bool,
485    last_scroll_time: Option<Instant>,
486    idle: Duration,
487    now: Instant,
488) -> bool {
489    mode.is_always()
490        || is_dragging
491        || (mode.is_hover() && is_hovered)
492        || last_scroll_time.is_some_and(|last| now.saturating_duration_since(last) < idle)
493}
494
495fn tracks_thumb_hover(mode: ScrollbarMode, is_visible: bool) -> bool {
496    mode.is_hover() || is_visible
497}
498
499fn hover_keeps_visible(mode: ScrollbarMode, is_hovered: bool, is_currently_visible: bool) -> bool {
500    is_hovered && (mode.is_hover() || (mode == ScrollbarMode::Scrolling && is_currently_visible))
501}
502
503impl Deref for ScrollbarState {
504    type Target = Rc<Cell<ScrollbarStateInner>>;
505
506    fn deref(&self) -> &Self::Target {
507        &self.0
508    }
509}
510
511impl ScrollbarStateInner {
512    fn with_drag_pos(&self, axis: Axis, pos: Point<Pixels>) -> Self {
513        let mut state = *self;
514        if axis.is_vertical() {
515            state.drag_pos.y = pos.y;
516        } else {
517            state.drag_pos.x = pos.x;
518        }
519
520        state.dragged_axis = Some(axis);
521        state
522    }
523
524    fn with_unset_drag_pos(&self, now: Instant) -> Self {
525        let mut state = *self;
526        state.dragged_axis = None;
527        state.last_scroll_time = Some(now);
528        state
529    }
530
531    fn with_hovered(&self, axis: Option<Axis>, now: Instant) -> Self {
532        let mut state = *self;
533        state.hovered_axis = axis;
534        state.last_scroll_time = Some(now);
535        state
536    }
537
538    fn with_hovered_on_thumb(&self, axis: Option<Axis>) -> Self {
539        let mut state = *self;
540        state.hovered_on_thumb = axis;
541        if self.is_scrollbar_visible() {
542            if axis.is_some() {
543                state.last_scroll_time = Some(Instant::now());
544            }
545        }
546        state
547    }
548
549    fn with_last_scroll(
550        &self,
551        last_scroll_offset: Point<Pixels>,
552        last_scroll_time: Option<Instant>,
553    ) -> Self {
554        let mut state = *self;
555        state.last_scroll_offset = last_scroll_offset;
556        state.last_scroll_time = last_scroll_time;
557        state
558    }
559
560    fn with_last_update(&self, t: Instant) -> Self {
561        let mut state = *self;
562        state.last_update = t;
563        state
564    }
565
566    fn with_idle_timer_scheduled(&self, scheduled: bool) -> Self {
567        let mut state = *self;
568        state.idle_timer_scheduled = scheduled;
569        state
570    }
571
572    fn is_scrollbar_visible(&self) -> bool {
573        self.dragged_axis.is_some() || self.visibility.sample(Instant::now()).opacity > 0.0
574    }
575}
576
577/// Scrollbar axis.
578#[derive(Debug, Clone, Copy, PartialEq, Eq)]
579pub enum ScrollbarAxis {
580    /// Vertical scrollbar.
581    Vertical,
582    /// Horizontal scrollbar.
583    Horizontal,
584    /// Show both vertical and horizontal scrollbars.
585    Both,
586}
587
588/// Paint-only styles for a scrollbar track.
589#[derive(Clone, Default)]
590pub struct ScrollbarTrackStyle {
591    background: Option<Hsla>,
592    border: Option<Hsla>,
593    width: Option<Pixels>,
594}
595
596impl ScrollbarTrackStyle {
597    pub fn bg(mut self, background: Hsla) -> Self {
598        self.background = Some(background);
599        self
600    }
601
602    pub fn border_color(mut self, border: Hsla) -> Self {
603        self.border = Some(border);
604        self
605    }
606
607    pub fn width(mut self, width: impl Into<Pixels>) -> Self {
608        self.width = Some(width.into());
609        self
610    }
611}
612
613impl FluentBuilder for ScrollbarTrackStyle {}
614
615/// Paint-only styles for a scrollbar thumb.
616#[derive(Clone, Default)]
617pub struct ScrollbarThumbStyle {
618    background: Option<Background>,
619    width: Option<Pixels>,
620    inset: Option<Pixels>,
621    radius: Option<Pixels>,
622    min_length: Option<Pixels>,
623}
624
625impl ScrollbarThumbStyle {
626    pub fn bg(mut self, background: impl Into<Background>) -> Self {
627        self.background = Some(background.into());
628        self
629    }
630
631    pub fn width(mut self, width: impl Into<Pixels>) -> Self {
632        self.width = Some(width.into());
633        self
634    }
635
636    pub fn inset(mut self, inset: impl Into<Pixels>) -> Self {
637        self.inset = Some(inset.into());
638        self
639    }
640
641    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
642        self.radius = Some(radius.into());
643        self
644    }
645
646    pub fn min_length(mut self, min_length: impl Into<Pixels>) -> Self {
647        self.min_length = Some(min_length.into());
648        self
649    }
650}
651
652impl FluentBuilder for ScrollbarThumbStyle {}
653
654/// Typed paint styles supported by [`Scrollbar`].
655#[derive(Clone, Default)]
656pub struct ScrollbarStyles {
657    track: ScrollbarTrackStyle,
658    track_hover: ScrollbarTrackStyle,
659    track_active: ScrollbarTrackStyle,
660    thumb: ScrollbarThumbStyle,
661    thumb_hover: ScrollbarThumbStyle,
662    thumb_active: ScrollbarThumbStyle,
663}
664
665impl ScrollbarStyles {
666    pub fn track(mut self, build: impl FnOnce(ScrollbarTrackStyle) -> ScrollbarTrackStyle) -> Self {
667        self.track = build(self.track);
668        self
669    }
670
671    pub fn track_hover(
672        mut self,
673        build: impl FnOnce(ScrollbarTrackStyle) -> ScrollbarTrackStyle,
674    ) -> Self {
675        self.track_hover = build(self.track_hover);
676        self
677    }
678
679    pub fn track_active(
680        mut self,
681        build: impl FnOnce(ScrollbarTrackStyle) -> ScrollbarTrackStyle,
682    ) -> Self {
683        self.track_active = build(self.track_active);
684        self
685    }
686
687    pub fn thumb(mut self, build: impl FnOnce(ScrollbarThumbStyle) -> ScrollbarThumbStyle) -> Self {
688        self.thumb = build(self.thumb);
689        self
690    }
691
692    pub fn thumb_hover(
693        mut self,
694        build: impl FnOnce(ScrollbarThumbStyle) -> ScrollbarThumbStyle,
695    ) -> Self {
696        self.thumb_hover = build(self.thumb_hover);
697        self
698    }
699
700    pub fn thumb_active(
701        mut self,
702        build: impl FnOnce(ScrollbarThumbStyle) -> ScrollbarThumbStyle,
703    ) -> Self {
704        self.thumb_active = build(self.thumb_active);
705        self
706    }
707}
708
709impl FluentBuilder for ScrollbarStyles {}
710
711impl From<Axis> for ScrollbarAxis {
712    fn from(axis: Axis) -> Self {
713        match axis {
714            Axis::Vertical => Self::Vertical,
715            Axis::Horizontal => Self::Horizontal,
716        }
717    }
718}
719
720impl ScrollbarAxis {
721    /// Return true if the scrollbar axis is vertical.
722    #[inline]
723    pub fn is_vertical(&self) -> bool {
724        matches!(self, Self::Vertical)
725    }
726
727    /// Return true if the scrollbar axis is horizontal.
728    #[inline]
729    pub fn is_horizontal(&self) -> bool {
730        matches!(self, Self::Horizontal)
731    }
732
733    /// Return true if the scrollbar axis is both vertical and horizontal.
734    #[inline]
735    pub fn is_both(&self) -> bool {
736        matches!(self, Self::Both)
737    }
738
739    /// Return true if the scrollbar has vertical axis.
740    #[inline]
741    pub fn has_vertical(&self) -> bool {
742        matches!(self, Self::Vertical | Self::Both)
743    }
744
745    /// Return true if the scrollbar has horizontal axis.
746    #[inline]
747    pub fn has_horizontal(&self) -> bool {
748        matches!(self, Self::Horizontal | Self::Both)
749    }
750
751    #[inline]
752    fn all(&self) -> Vec<Axis> {
753        match self {
754            Self::Vertical => vec![Axis::Vertical],
755            Self::Horizontal => vec![Axis::Horizontal],
756            // This should keep Horizontal first, Vertical is the primary axis
757            // if Vertical not need display, then Horizontal will not keep right margin.
758            Self::Both => vec![Axis::Horizontal, Axis::Vertical],
759        }
760    }
761}
762
763/// Scrollbar control for scroll-area or a uniform-list.
764pub struct Scrollbar {
765    pub(crate) id: ElementId,
766    axis: ScrollbarAxis,
767    mode: Option<ScrollbarMode>,
768    scroll_handle: Rc<dyn ScrollbarHandle>,
769    scroll_size: Option<Size<Pixels>>,
770    viewport_bounds: Option<Bounds<Pixels>>,
771    use_layout_bounds: bool,
772    /// Maximum frames per second for scrolling by drag. Default is 120 FPS.
773    ///
774    /// This is used to limit the update rate of the scrollbar when it is
775    /// being dragged for some complex interactions for reducing CPU usage.
776    max_fps: usize,
777    styles: ScrollbarStyles,
778}
779
780impl Scrollbar {
781    /// Create a new scrollbar.
782    ///
783    /// This will have both vertical and horizontal scrollbars.
784    #[track_caller]
785    pub fn new<H: ScrollbarHandle + Clone>(scroll_handle: &H) -> Self {
786        let caller = Location::caller();
787        Self {
788            id: ElementId::CodeLocation(*caller),
789            axis: ScrollbarAxis::Both,
790            mode: None,
791            scroll_handle: Rc::new(scroll_handle.clone()),
792            max_fps: 120,
793            scroll_size: None,
794            viewport_bounds: None,
795            use_layout_bounds: false,
796            styles: ScrollbarStyles::default(),
797        }
798    }
799
800    /// Create with horizontal scrollbar.
801    #[track_caller]
802    pub fn horizontal<H: ScrollbarHandle + Clone>(scroll_handle: &H) -> Self {
803        Self::new(scroll_handle).axis(ScrollbarAxis::Horizontal)
804    }
805
806    /// Create with vertical scrollbar.
807    #[track_caller]
808    pub fn vertical<H: ScrollbarHandle + Clone>(scroll_handle: &H) -> Self {
809        Self::new(scroll_handle).axis(ScrollbarAxis::Vertical)
810    }
811
812    /// Set a specific element id, default is the [`Location::caller`].
813    ///
814    /// NOTE: In most cases, you don't need to set a specific id for scrollbar.
815    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
816        self.id = id.into();
817        self
818    }
819
820    /// Set the scrollbar show mode [`ScrollbarMode`].
821    ///
822    /// If unset, the current application theme projection is used.
823    pub fn mode(mut self, mode: ScrollbarMode) -> Self {
824        self.mode = Some(mode);
825        self
826    }
827
828    /// Set a special scroll size of the content area, default is None.
829    ///
830    /// Default will sync the `content_size` from `scroll_handle`.
831    pub fn scroll_size(mut self, scroll_size: Size<Pixels>) -> Self {
832        self.scroll_size = Some(scroll_size);
833        self
834    }
835
836    /// Override the viewport bounds that this scrollbar overlays.
837    ///
838    /// Most scroll containers should rely on the bounds reported by their
839    /// scroll handle. Custom-painted viewports, such as the text editor, can
840    /// use this when their visible bounds differ from the handle's layout
841    /// bounds.
842    pub fn viewport_bounds(mut self, bounds: Bounds<Pixels>) -> Self {
843        self.viewport_bounds = Some(bounds);
844        self
845    }
846
847    /// Use the scrollbar element's layout bounds as its viewport.
848    ///
849    /// This is useful for composite widgets whose scrollbar viewport excludes
850    /// fixed headers or columns and is therefore defined by a positioned
851    /// overlay container rather than by the underlying scroll handle.
852    pub fn viewport_from_layout(mut self) -> Self {
853        self.use_layout_bounds = true;
854        self
855    }
856
857    fn resolved_viewport_bounds(&self, layout_bounds: Bounds<Pixels>) -> Bounds<Pixels> {
858        self.viewport_bounds.unwrap_or_else(|| {
859            if self.use_layout_bounds {
860                layout_bounds
861            } else {
862                self.scroll_handle.viewport_bounds()
863            }
864        })
865    }
866
867    /// Set scrollbar axis.
868    pub fn axis(mut self, axis: impl Into<ScrollbarAxis>) -> Self {
869        self.axis = axis.into();
870        self
871    }
872
873    pub fn styles(mut self, build: impl FnOnce(ScrollbarStyles) -> ScrollbarStyles) -> Self {
874        self.styles = build(self.styles);
875        self
876    }
877
878    /// Set maximum frames per second for scrolling by drag. Default is 120 FPS.
879    ///
880    /// If you have very high CPU usage, consider reducing this value to improve performance.
881    ///
882    /// Available values: 30..120
883    #[doc(hidden)]
884    pub fn max_fps(mut self, max_fps: usize) -> Self {
885        self.max_fps = max_fps.clamp(30, 120);
886        self
887    }
888
889    // Get the width of the scrollbar.
890    #[doc(hidden)]
891    pub const fn width() -> Pixels {
892        WIDTH
893    }
894
895    fn resolve_track(
896        &self,
897        cx: &App,
898        state: &ScrollbarTrackStyle,
899        global_state: &ScrollbarTrackStyle,
900        default_border: Hsla,
901    ) -> (Hsla, Hsla) {
902        let theme = cx.theme();
903        let global = theme.scrollbar.styles();
904        (
905            state
906                .background
907                .or(self.styles.track.background)
908                .or(global_state.background)
909                .or(global.track.background)
910                .unwrap_or_else(gpui::transparent_black),
911            state
912                .border
913                .or(self.styles.track.border)
914                .or(global_state.border)
915                .or(global.track.border)
916                .unwrap_or(default_border),
917        )
918    }
919
920    fn resolve_thumb(
921        &self,
922        cx: &App,
923        state: &ScrollbarThumbStyle,
924        global_state: &ScrollbarThumbStyle,
925        defaults: ScrollbarThumbStyle,
926    ) -> (Background, Pixels, Pixels, Pixels, Pixels) {
927        let theme = cx.theme();
928        let global = theme.scrollbar.styles();
929        (
930            state
931                .background
932                .or(self.styles.thumb.background)
933                .or(global_state.background)
934                .or(global.thumb.background)
935                .unwrap_or_else(|| defaults.background.unwrap()),
936            state
937                .width
938                .or(self.styles.thumb.width)
939                .or(global_state.width)
940                .or(global.thumb.width)
941                .unwrap_or_else(|| defaults.width.unwrap()),
942            state
943                .inset
944                .or(self.styles.thumb.inset)
945                .or(global_state.inset)
946                .or(global.thumb.inset)
947                .unwrap_or_else(|| defaults.inset.unwrap()),
948            state
949                .radius
950                .or(self.styles.thumb.radius)
951                .or(global_state.radius)
952                .or(global.thumb.radius)
953                .unwrap_or_else(|| defaults.radius.unwrap()),
954            state
955                .min_length
956                .or(self.styles.thumb.min_length)
957                .or(global_state.min_length)
958                .or(global.thumb.min_length)
959                .or(defaults.min_length)
960                .unwrap_or(MIN_THUMB_SIZE),
961        )
962    }
963
964    /// The thumb colour a scrollbar falls back to when nothing has overridden
965    /// it, taken from the active theme rather than fixed.
966    ///
967    /// It used to be literal black at these alphas. That reads as an ordinary
968    /// grey thumb on a light surface and as very nearly nothing at all on a
969    /// dark one, and no palette an application installed could change it:
970    /// `Theme` carries `scrollbar` beside `tokens` rather than derived from
971    /// them, so a theme swap moved every token except the ones the scrollbar
972    /// actually paints with.
973    ///
974    /// `foreground` is the token that already means "ink on this surface" and
975    /// already flips with the appearance, so on a light theme this stays within
976    /// a hair of the old constant and on a dark one it becomes visible. An
977    /// explicit `ScrollbarStyles` still wins: this is the bottom of the
978    /// cascade, not a new top of it.
979    fn thumb_default_background(cx: &App, alpha: f32) -> Background {
980        cx.theme().tokens.colors.foreground.alpha(alpha).into()
981    }
982
983    fn thumb_defaults(
984        background: Background,
985        width: Pixels,
986        inset: Pixels,
987        radius: Pixels,
988    ) -> ScrollbarThumbStyle {
989        ScrollbarThumbStyle {
990            background: Some(background),
991            width: Some(width),
992            inset: Some(inset),
993            radius: Some(radius),
994            min_length: Some(MIN_THUMB_SIZE),
995        }
996    }
997
998    fn style_for_active(
999        &self,
1000        cx: &App,
1001    ) -> (Background, Hsla, Hsla, Pixels, Pixels, Pixels, Pixels) {
1002        let theme = cx.theme();
1003        let global = theme.scrollbar.styles();
1004        let (track, border) = self.resolve_track(
1005            cx,
1006            &self.styles.track_active,
1007            &global.track_active,
1008            gpui::transparent_black(),
1009        );
1010        let (thumb, width, inset, radius, min_length) = self.resolve_thumb(
1011            cx,
1012            &self.styles.thumb_active,
1013            &global.thumb_active,
1014            Self::thumb_defaults(
1015                Self::thumb_default_background(cx, 0.55),
1016                THUMB_ACTIVE_WIDTH,
1017                THUMB_ACTIVE_INSET,
1018                THUMB_ACTIVE_RADIUS,
1019            ),
1020        );
1021        (thumb, track, border, width, inset, radius, min_length)
1022    }
1023
1024    fn style_for_hovered_thumb(
1025        &self,
1026        cx: &App,
1027    ) -> (Background, Hsla, Hsla, Pixels, Pixels, Pixels, Pixels) {
1028        let theme = cx.theme();
1029        let global = theme.scrollbar.styles();
1030        let (track, border) = self.resolve_track(
1031            cx,
1032            &self.styles.track_active,
1033            &global.track_active,
1034            gpui::transparent_black(),
1035        );
1036        let (thumb, width, inset, radius, min_length) = self.resolve_thumb(
1037            cx,
1038            &self.styles.thumb_hover,
1039            &global.thumb_hover,
1040            Self::thumb_defaults(
1041                Self::thumb_default_background(cx, 0.55),
1042                THUMB_ACTIVE_WIDTH,
1043                THUMB_ACTIVE_INSET,
1044                THUMB_ACTIVE_RADIUS,
1045            ),
1046        );
1047        (thumb, track, border, width, inset, radius, min_length)
1048    }
1049
1050    fn style_for_hovered_bar(
1051        &self,
1052        cx: &App,
1053    ) -> (Background, Hsla, Hsla, Pixels, Pixels, Pixels, Pixels) {
1054        let theme = cx.theme();
1055        let global = theme.scrollbar.styles();
1056        let (track, border) = self.resolve_track(
1057            cx,
1058            &self.styles.track_hover,
1059            &global.track_hover,
1060            gpui::transparent_black(),
1061        );
1062        let (thumb, width, inset, radius, min_length) = self.resolve_thumb(
1063            cx,
1064            &self.styles.thumb,
1065            &global.thumb,
1066            Self::thumb_defaults(
1067                Self::thumb_default_background(cx, 0.35),
1068                THUMB_WIDTH,
1069                THUMB_INSET,
1070                THUMB_RADIUS,
1071            ),
1072        );
1073        (thumb, track, border, width, inset, radius, min_length)
1074    }
1075
1076    fn style_for_normal(
1077        &self,
1078        cx: &App,
1079    ) -> (Background, Hsla, Hsla, Pixels, Pixels, Pixels, Pixels) {
1080        let theme = cx.theme();
1081        let global = theme.scrollbar.styles();
1082
1083        let (track, border) = self.resolve_track(
1084            cx,
1085            &self.styles.track,
1086            &global.track,
1087            gpui::transparent_black(),
1088        );
1089        let (thumb, width, inset, radius, min_length) = self.resolve_thumb(
1090            cx,
1091            &self.styles.thumb,
1092            &global.thumb,
1093            Self::thumb_defaults(
1094                Self::thumb_default_background(cx, 0.35),
1095                THUMB_WIDTH,
1096                THUMB_INSET,
1097                THUMB_RADIUS,
1098            ),
1099        );
1100        (thumb, track, border, width, inset, radius, min_length)
1101    }
1102}
1103
1104impl IntoElement for Scrollbar {
1105    type Element = Self;
1106
1107    fn into_element(self) -> Self::Element {
1108        self
1109    }
1110}
1111
1112#[doc(hidden)]
1113pub struct PrepaintState {
1114    hitbox: Hitbox,
1115    scrollbar_state: ScrollbarState,
1116    states: Vec<AxisPrepaintState>,
1117}
1118
1119#[doc(hidden)]
1120pub struct AxisPrepaintState {
1121    axis: Axis,
1122    bar_hitbox: Hitbox,
1123    bounds: Bounds<Pixels>,
1124    radius: Pixels,
1125    bg: Hsla,
1126    border: Hsla,
1127    thumb_bounds: Bounds<Pixels>,
1128    // Bounds of thumb to be rendered.
1129    thumb_fill_bounds: Bounds<Pixels>,
1130    thumb_bg: Background,
1131    scroll_size: Pixels,
1132    container_size: Pixels,
1133    thumb_size: Pixels,
1134    margin_end: Pixels,
1135    track_width: Pixels,
1136    visibility_opacity: f32,
1137    visibility_position: f32,
1138    visibility_requested: bool,
1139}
1140
1141impl Element for Scrollbar {
1142    type RequestLayoutState = ();
1143    type PrepaintState = PrepaintState;
1144
1145    fn id(&self) -> Option<gpui::ElementId> {
1146        Some(self.id.clone())
1147    }
1148
1149    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
1150        None
1151    }
1152
1153    fn request_layout(
1154        &mut self,
1155        _: Option<&GlobalElementId>,
1156        _: Option<&InspectorElementId>,
1157        window: &mut Window,
1158        cx: &mut App,
1159    ) -> (LayoutId, Self::RequestLayoutState) {
1160        let mut style = Style::default();
1161        style.position = Position::Absolute;
1162        style.flex_grow = 1.0;
1163        style.flex_shrink = 1.0;
1164        style.size.width = relative(1.).into();
1165        style.size.height = relative(1.).into();
1166
1167        (window.request_layout(style, None, cx), ())
1168    }
1169
1170    fn prepaint(
1171        &mut self,
1172        _: Option<&GlobalElementId>,
1173        _: Option<&InspectorElementId>,
1174        bounds: Bounds<Pixels>,
1175        _: &mut Self::RequestLayoutState,
1176        window: &mut Window,
1177        cx: &mut App,
1178    ) -> Self::PrepaintState {
1179        let bounds = self.resolved_viewport_bounds(bounds);
1180        let hitbox = window.with_content_mask(Some(ContentMask { bounds }), |window| {
1181            window.insert_hitbox(bounds, HitboxBehavior::Normal)
1182        });
1183
1184        let state = window
1185            .use_state(cx, |_, _| ScrollbarState::default())
1186            .read(cx)
1187            .clone();
1188
1189        let now = Instant::now();
1190        let base_theme = cx.theme();
1191        let mode = self.mode.unwrap_or(base_theme.scrollbar.mode());
1192        let motion = base_theme.scrollbar.motion();
1193        // Always-visible scrollbars skip visibility motion but still animate
1194        // their activity width. Reduced motion snaps every channel.
1195        let reduce_motion = cx.reduce_motion();
1196        let (enter, exit) = if !mode.is_always() && !reduce_motion {
1197            (motion.enter(), motion.exit())
1198        } else {
1199            (Duration::ZERO, Duration::ZERO)
1200        };
1201        let expand = if reduce_motion {
1202            Duration::ZERO
1203        } else {
1204            motion.expand()
1205        };
1206
1207        let mut inner = state.get();
1208        let current_offset = self.scroll_handle.offset();
1209        if current_offset != inner.last_scroll_offset {
1210            inner = inner.with_last_scroll(current_offset, Some(now));
1211        }
1212
1213        let is_hovered = inner.hovered_axis.is_some() || inner.hovered_on_thumb.is_some();
1214        let is_dragging = inner.dragged_axis.is_some();
1215        let is_currently_visible = inner.visibility.sample(now).opacity > 0.0;
1216        let visible = hover_keeps_visible(mode, is_hovered, is_currently_visible)
1217            || wants_visible(
1218                mode,
1219                is_hovered,
1220                is_dragging,
1221                inner.last_scroll_time,
1222                motion.idle(),
1223                now,
1224            );
1225        inner.visibility.set_visible(
1226            visible,
1227            motion.entrance_for(mode, inner.hovered_on_thumb.is_some()),
1228            enter,
1229            exit,
1230            now,
1231        );
1232        let visibility = inner.visibility.sample(now);
1233        if visibility.running {
1234            window.request_animation_frame();
1235        }
1236
1237        if !is_hovered && !is_dragging {
1238            if let Some(last_time) = inner.last_scroll_time {
1239                let elapsed = now.saturating_duration_since(last_time);
1240                if elapsed < motion.idle() && !inner.idle_timer_scheduled {
1241                    inner.idle_timer_scheduled = true;
1242                    let state = state.clone();
1243                    let current_view = window.current_view();
1244                    let next_delay = motion.idle() - elapsed;
1245                    window
1246                        .spawn(cx, async move |cx| {
1247                            cx.background_executor().timer(next_delay).await;
1248                            state.set(state.get().with_idle_timer_scheduled(false));
1249                            cx.update(|_, cx| cx.notify(current_view)).ok();
1250                        })
1251                        .detach();
1252                }
1253            }
1254        }
1255        state.set(inner);
1256
1257        let mut states = vec![];
1258        let mut has_both = self.axis.is_both();
1259        let scroll_size = self
1260            .scroll_size
1261            .unwrap_or(self.scroll_handle.content_size());
1262
1263        for axis in self.axis.all().into_iter() {
1264            let is_vertical = axis.is_vertical();
1265            let track_width = self
1266                .styles
1267                .track
1268                .width
1269                .or(cx.theme().scrollbar.styles().track.width)
1270                .unwrap_or(WIDTH);
1271            let (scroll_area_size, container_size, scroll_position) = if is_vertical {
1272                (
1273                    scroll_size.height,
1274                    hitbox.size.height,
1275                    self.scroll_handle.offset().y,
1276                )
1277            } else {
1278                (
1279                    scroll_size.width,
1280                    hitbox.size.width,
1281                    self.scroll_handle.offset().x,
1282                )
1283            };
1284
1285            // The horizontal scrollbar is set avoid overlapping with the vertical scrollbar, if the vertical scrollbar is visible.
1286            let margin_end = if has_both && !is_vertical {
1287                track_width
1288            } else {
1289                px(0.)
1290            };
1291
1292            // Hide scrollbar, if the scroll area is smaller than the container.
1293            if scroll_area_size <= container_size {
1294                has_both = false;
1295                continue;
1296            }
1297
1298            let bounds = Bounds {
1299                origin: if is_vertical {
1300                    point(
1301                        hitbox.origin.x + hitbox.size.width - track_width,
1302                        hitbox.origin.y,
1303                    )
1304                } else {
1305                    point(
1306                        hitbox.origin.x,
1307                        hitbox.origin.y + hitbox.size.height - track_width,
1308                    )
1309                },
1310                size: gpui::Size {
1311                    width: if is_vertical {
1312                        track_width
1313                    } else {
1314                        hitbox.size.width
1315                    },
1316                    height: if is_vertical {
1317                        hitbox.size.height
1318                    } else {
1319                        track_width
1320                    },
1321                },
1322            };
1323
1324            let is_always_to_show = mode.is_always();
1325            let is_hover_to_show = mode.is_hover();
1326            let is_hovered_on_bar = state.get().hovered_axis == Some(axis);
1327            let is_hovered_on_thumb = state.get().hovered_on_thumb == Some(axis);
1328
1329            let (thumb_bg, bar_bg, bar_border, mut thumb_width, inset, radius, min_length) =
1330                if state.get().dragged_axis == Some(axis) {
1331                    self.style_for_active(cx)
1332                } else if (is_hover_to_show || mode == ScrollbarMode::Scrolling)
1333                    && (is_hovered_on_bar || is_hovered_on_thumb)
1334                {
1335                    if is_hovered_on_thumb {
1336                        self.style_for_hovered_thumb(cx)
1337                    } else {
1338                        self.style_for_hovered_bar(cx)
1339                    }
1340                } else if is_always_to_show && (is_hovered_on_bar || is_hovered_on_thumb) {
1341                    if is_hovered_on_thumb {
1342                        self.style_for_hovered_thumb(cx)
1343                    } else {
1344                        self.style_for_hovered_bar(cx)
1345                    }
1346                } else {
1347                    self.style_for_normal(cx)
1348                };
1349
1350            let mut width_animation = if is_vertical {
1351                state.get().vertical_width
1352            } else {
1353                state.get().horizontal_width
1354            };
1355            let (animated_width, running) = width_animation.set_target(thumb_width, expand, now);
1356            let mut updated = state.get();
1357            if is_vertical {
1358                updated.vertical_width = width_animation;
1359            } else {
1360                updated.horizontal_width = width_animation;
1361            }
1362            state.set(updated);
1363            thumb_width = animated_width;
1364            if running {
1365                window.request_animation_frame();
1366            }
1367
1368            let thumb_size = (container_size / scroll_area_size * container_size).max(min_length);
1369            let thumb_start = -(scroll_position / (scroll_area_size - container_size)
1370                * (container_size - margin_end - thumb_size));
1371            let thumb_end = (thumb_start + thumb_size).min(container_size - margin_end);
1372
1373            // The clickable area of the thumb
1374            let thumb_length = thumb_end - thumb_start - inset * 2;
1375            let thumb_bounds = if is_vertical {
1376                Bounds::from_anchor_and_size(
1377                    Anchor::TopRight,
1378                    bounds.top_right() + point(-inset, inset + thumb_start),
1379                    size(track_width, thumb_length),
1380                )
1381            } else {
1382                Bounds::from_anchor_and_size(
1383                    Anchor::BottomLeft,
1384                    bounds.bottom_left() + point(inset + thumb_start, -inset),
1385                    size(thumb_length, track_width),
1386                )
1387            };
1388
1389            // The actual render area of the thumb
1390            let thumb_fill_bounds = if is_vertical {
1391                Bounds::from_anchor_and_size(
1392                    Anchor::TopRight,
1393                    bounds.top_right() + point(-inset, inset + thumb_start),
1394                    size(thumb_width, thumb_length),
1395                )
1396            } else {
1397                Bounds::from_anchor_and_size(
1398                    Anchor::BottomLeft,
1399                    bounds.bottom_left() + point(inset + thumb_start, -inset),
1400                    size(thumb_length, thumb_width),
1401                )
1402            };
1403
1404            let bar_hitbox = window.with_content_mask(Some(ContentMask { bounds }), |window| {
1405                window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal)
1406            });
1407
1408            states.push(AxisPrepaintState {
1409                axis,
1410                bar_hitbox,
1411                bounds,
1412                radius,
1413                bg: bar_bg,
1414                border: bar_border,
1415                thumb_bounds,
1416                thumb_fill_bounds,
1417                thumb_bg,
1418                scroll_size: scroll_area_size,
1419                container_size,
1420                thumb_size: thumb_length,
1421                margin_end,
1422                track_width,
1423                visibility_opacity: visibility.opacity,
1424                visibility_position: visibility.position,
1425                visibility_requested: visible,
1426            })
1427        }
1428
1429        PrepaintState {
1430            hitbox,
1431            states,
1432            scrollbar_state: state,
1433        }
1434    }
1435
1436    fn paint(
1437        &mut self,
1438        _: Option<&GlobalElementId>,
1439        _: Option<&InspectorElementId>,
1440        _: Bounds<Pixels>,
1441        _: &mut Self::RequestLayoutState,
1442        prepaint: &mut Self::PrepaintState,
1443        window: &mut Window,
1444        cx: &mut App,
1445    ) {
1446        let scrollbar_state = &prepaint.scrollbar_state;
1447        let theme = cx.theme();
1448        let mode = self.mode.unwrap_or(theme.scrollbar.mode());
1449        let view_id = window.current_view();
1450        let hitbox_bounds = prepaint.hitbox.bounds;
1451        let is_hover_to_show = mode.is_hover();
1452
1453        window.with_content_mask(
1454            Some(ContentMask {
1455                bounds: hitbox_bounds,
1456            }),
1457            |window| {
1458                for state in prepaint.states.iter() {
1459                    let axis = state.axis;
1460                    let mut radius = state.radius;
1461                    if theme.tokens.radius.md.is_zero() {
1462                        radius = px(0.);
1463                    }
1464                    radius = clamp_thumb_radius(radius, state.thumb_fill_bounds);
1465                    let bounds = state.bounds;
1466                    let thumb_bounds = state.thumb_bounds;
1467                    let scroll_area_size = state.scroll_size;
1468                    let container_size = state.container_size;
1469                    let thumb_size = state.thumb_size;
1470                    let margin_end = state.margin_end;
1471                    let is_vertical = axis.is_vertical();
1472                    let visibility_opacity = state.visibility_opacity;
1473                    let is_visible = state.visibility_requested || visibility_opacity > 0.0;
1474                    let translation =
1475                        visibility_translation(axis, state.track_width, state.visibility_position);
1476                    let painted_bounds = state.bounds + translation;
1477                    let painted_thumb_bounds = state.thumb_fill_bounds + translation;
1478                    let painted_track_bg = state.bg.opacity(visibility_opacity);
1479                    let painted_border = state.border.opacity(visibility_opacity);
1480                    let painted_thumb_bg = state.thumb_bg.clone().opacity(visibility_opacity);
1481
1482                    window.set_cursor_style(CursorStyle::default(), &state.bar_hitbox);
1483
1484                    window.paint_layer(hitbox_bounds, |cx| {
1485                        cx.paint_quad(fill(painted_bounds, painted_track_bg));
1486
1487                        cx.paint_quad(PaintQuad {
1488                            bounds: painted_bounds,
1489                            corner_radii: (0.).into(),
1490                            background: gpui::transparent_black().into(),
1491                            border_widths: if is_vertical {
1492                                Edges {
1493                                    top: px(0.),
1494                                    right: px(0.),
1495                                    bottom: px(0.),
1496                                    left: px(0.),
1497                                }
1498                            } else {
1499                                Edges {
1500                                    top: px(0.),
1501                                    right: px(0.),
1502                                    bottom: px(0.),
1503                                    left: px(0.),
1504                                }
1505                            },
1506                            border_color: painted_border,
1507                            border_style: BorderStyle::default(),
1508                        });
1509
1510                        cx.paint_quad(
1511                            fill(painted_thumb_bounds, painted_thumb_bg).corner_radii(radius),
1512                        );
1513                    });
1514
1515                    window.on_mouse_event({
1516                        let state = scrollbar_state.clone();
1517                        let scroll_handle = self.scroll_handle.clone();
1518
1519                        move |event: &ScrollWheelEvent, phase, _, cx| {
1520                            if phase.bubble() && hitbox_bounds.contains(&event.position) {
1521                                if scroll_handle.offset() != state.get().last_scroll_offset {
1522                                    state.set(state.get().with_last_scroll(
1523                                        scroll_handle.offset(),
1524                                        Some(Instant::now()),
1525                                    ));
1526                                    cx.notify(view_id);
1527                                }
1528                            }
1529                        }
1530                    });
1531
1532                    let safe_range = (-scroll_area_size + container_size)..px(0.);
1533
1534                    if is_visible {
1535                        window.on_mouse_event({
1536                            let state = scrollbar_state.clone();
1537                            let scroll_handle = self.scroll_handle.clone();
1538
1539                            move |event: &MouseDownEvent, phase, _, cx| {
1540                                if phase.bubble() && bounds.contains(&event.position) {
1541                                    cx.stop_propagation();
1542
1543                                    if thumb_bounds.contains(&event.position) {
1544                                        // click on the thumb bar, set the drag position
1545                                        let pos = event.position - thumb_bounds.origin;
1546
1547                                        scroll_handle.start_drag();
1548                                        state.set(state.get().with_drag_pos(axis, pos));
1549                                    } else {
1550                                        // click on the scrollbar, jump to the position
1551                                        // Set the thumb bar center to the click position
1552                                        let offset = scroll_handle.offset();
1553                                        let percentage = if is_vertical {
1554                                            (event.position.y - thumb_size / 2. - bounds.origin.y)
1555                                                / (bounds.size.height - thumb_size)
1556                                        } else {
1557                                            (event.position.x - thumb_size / 2. - bounds.origin.x)
1558                                                / (bounds.size.width - thumb_size)
1559                                        }
1560                                        .min(1.);
1561
1562                                        if is_vertical {
1563                                            scroll_handle.set_offset(point(
1564                                                offset.x,
1565                                                (-scroll_area_size * percentage)
1566                                                    .clamp(safe_range.start, safe_range.end),
1567                                            ));
1568                                        } else {
1569                                            scroll_handle.set_offset(point(
1570                                                (-scroll_area_size * percentage)
1571                                                    .clamp(safe_range.start, safe_range.end),
1572                                                offset.y,
1573                                            ));
1574                                        }
1575                                    }
1576
1577                                    cx.notify(view_id);
1578                                }
1579                            }
1580                        });
1581                    }
1582
1583                    window.on_mouse_event({
1584                        let scroll_handle = self.scroll_handle.clone();
1585                        let state = scrollbar_state.clone();
1586                        let max_fps_duration = Duration::from_millis((1000 / self.max_fps) as u64);
1587
1588                        move |event: &MouseMoveEvent, _, _, cx| {
1589                            let mut notify = false;
1590                            // When is hover to show mode or it was visible,
1591                            // we need to update the hovered state and increase the last_scroll_time.
1592                            let need_hover_to_update = is_hover_to_show || is_visible;
1593                            // Update hovered state for scrollbar
1594                            if bounds.contains(&event.position) && need_hover_to_update {
1595                                let hover_changed = state.get().hovered_axis != Some(axis);
1596                                state.set(state.get().with_hovered(Some(axis), Instant::now()));
1597                                notify |= hover_changed;
1598                            } else if state.get().hovered_axis == Some(axis) {
1599                                state.set(state.get().with_hovered(None, Instant::now()));
1600                                notify = true;
1601                            }
1602
1603                            // Update hovered state for scrollbar thumb
1604                            if tracks_thumb_hover(mode, is_visible)
1605                                && thumb_bounds.contains(&event.position)
1606                            {
1607                                if state.get().hovered_on_thumb != Some(axis) {
1608                                    state.set(state.get().with_hovered_on_thumb(Some(axis)));
1609                                    notify = true;
1610                                }
1611                            } else {
1612                                if state.get().hovered_on_thumb == Some(axis) {
1613                                    state.set(state.get().with_hovered_on_thumb(None));
1614                                    notify = true;
1615                                }
1616                            }
1617
1618                            // Move thumb position on dragging
1619                            if state.get().dragged_axis == Some(axis) && event.dragging() {
1620                                // Stop the event propagation to avoid selecting text or other side effects.
1621                                cx.stop_propagation();
1622
1623                                // drag_pos is the position of the mouse down event
1624                                // We need to keep the thumb bar still at the origin down position
1625                                let drag_pos = state.get().drag_pos;
1626
1627                                let percentage = (if is_vertical {
1628                                    (event.position.y - drag_pos.y - bounds.origin.y)
1629                                        / (bounds.size.height - thumb_size)
1630                                } else {
1631                                    (event.position.x - drag_pos.x - bounds.origin.x)
1632                                        / (bounds.size.width - thumb_size - margin_end)
1633                                })
1634                                .clamp(0., 1.);
1635
1636                                let offset = if is_vertical {
1637                                    point(
1638                                        scroll_handle.offset().x,
1639                                        (-(scroll_area_size - container_size) * percentage)
1640                                            .clamp(safe_range.start, safe_range.end),
1641                                    )
1642                                } else {
1643                                    point(
1644                                        (-(scroll_area_size - container_size) * percentage)
1645                                            .clamp(safe_range.start, safe_range.end),
1646                                        scroll_handle.offset().y,
1647                                    )
1648                                };
1649
1650                                if (scroll_handle.offset().y - offset.y).abs() > px(1.)
1651                                    || (scroll_handle.offset().x - offset.x).abs() > px(1.)
1652                                {
1653                                    // Limit update rate
1654                                    if state.get().last_update.elapsed() > max_fps_duration {
1655                                        scroll_handle.set_offset(offset);
1656                                        state.set(state.get().with_last_update(Instant::now()));
1657                                        notify = true;
1658                                    }
1659                                }
1660                            }
1661
1662                            if notify {
1663                                cx.notify(view_id);
1664                            }
1665                        }
1666                    });
1667
1668                    window.on_mouse_event({
1669                        let state = scrollbar_state.clone();
1670                        let scroll_handle = self.scroll_handle.clone();
1671
1672                        move |_event: &MouseUpEvent, phase, _, cx| {
1673                            if phase.bubble() && state.get().dragged_axis == Some(axis) {
1674                                scroll_handle.end_drag();
1675                                state.set(state.get().with_unset_drag_pos(Instant::now()));
1676                                cx.notify(view_id);
1677                            }
1678                        }
1679                    });
1680                }
1681            },
1682        );
1683    }
1684}
1685
1686#[cfg(test)]
1687mod tests {
1688    use super::*;
1689
1690    use std::cell::Cell;
1691
1692    use gpui::{
1693        Context, Modifiers, MouseButton, ParentElement as _, Render, Styled as _, TestAppContext,
1694        VisualTestContext, div,
1695    };
1696
1697    #[test]
1698    fn thumb_radius_is_limited_by_its_actual_bounds() {
1699        let vertical_thumb = Bounds::new(Point::default(), size(px(8.), px(80.)));
1700        let horizontal_thumb = Bounds::new(Point::default(), size(px(80.), px(6.)));
1701
1702        assert_eq!(clamp_thumb_radius(px(6.), vertical_thumb), px(4.));
1703        assert_eq!(clamp_thumb_radius(px(6.), horizontal_thumb), px(3.));
1704        assert_eq!(
1705            clamp_thumb_radius(Pixels::ZERO, vertical_thumb),
1706            Pixels::ZERO
1707        );
1708    }
1709
1710    /// Timing standing in for what a styled layer projects. Base itself ships
1711    /// none of these; see [`motionless_base_snaps_every_transition`].
1712    const ENTER: Duration = Duration::from_millis(300);
1713    const EXIT: Duration = Duration::from_millis(500);
1714    const EXPAND: Duration = Duration::from_millis(300);
1715
1716    #[test]
1717    fn visibility_animation_uses_direction_specific_curves_and_durations() {
1718        let start = Instant::now();
1719        let mut animation = VisibilityAnimation::hidden(start);
1720
1721        animation.set_visible(true, ScrollbarEntrance::SlideAndFade, ENTER, EXIT, start);
1722        assert_eq!(animation.sample(start).opacity, 0.0);
1723        let entering = animation.sample(start + ENTER / 2).position;
1724        assert!(entering > 0.5, "ease-out must advance quickly");
1725        let entered = animation.sample(start + ENTER);
1726        assert_eq!(entered.opacity, 1.0);
1727        assert_eq!(entered.position, 1.0);
1728
1729        animation.set_visible(
1730            false,
1731            ScrollbarEntrance::SlideAndFade,
1732            ENTER,
1733            EXIT,
1734            start + ENTER,
1735        );
1736        let exiting = animation.sample(start + ENTER + EXIT / 2).opacity;
1737        assert!(exiting > 0.5, "ease-in must remain visible early in exit");
1738        assert_eq!(animation.sample(start + ENTER + EXIT).opacity, 0.0);
1739    }
1740
1741    #[test]
1742    fn entrance_fades_linearly_while_position_eases_out() {
1743        let start = Instant::now();
1744        let mut animation = VisibilityAnimation::hidden(start);
1745        animation.set_visible(true, ScrollbarEntrance::SlideAndFade, ENTER, EXIT, start);
1746
1747        let halfway = animation.sample(start + ENTER / 2);
1748        assert!((halfway.opacity - 0.5).abs() < 0.001);
1749        assert!(halfway.position > halfway.opacity);
1750    }
1751
1752    #[test]
1753    fn fade_entrance_snaps_position_and_animates_opacity() {
1754        let start = Instant::now();
1755        let mut animation = VisibilityAnimation::hidden(start);
1756        animation.set_visible(true, ScrollbarEntrance::Fade, ENTER, EXIT, start);
1757
1758        let initial = animation.sample(start);
1759        assert_eq!(initial.opacity, 0.0);
1760        assert_eq!(initial.position, 1.0);
1761        let halfway = animation.sample(start + ENTER / 2);
1762        assert!((halfway.opacity - 0.5).abs() < 0.001);
1763        assert_eq!(halfway.position, 1.0);
1764    }
1765
1766    #[test]
1767    fn active_visibility_adopts_a_changed_entrance_policy() {
1768        let start = Instant::now();
1769        let halfway = start + ENTER / 2;
1770        let mut animation = VisibilityAnimation::hidden(start);
1771        animation.set_visible(true, ScrollbarEntrance::SlideAndFade, ENTER, EXIT, start);
1772        let before = animation.sample(halfway);
1773
1774        animation.set_visible(true, ScrollbarEntrance::Fade, ENTER, EXIT, halfway);
1775        let after = animation.sample(halfway);
1776
1777        assert_eq!(
1778            after.opacity, before.opacity,
1779            "policy changes must not flash"
1780        );
1781        assert_eq!(after.position, 1.0, "fade entrance must stop stale sliding");
1782    }
1783
1784    #[test]
1785    fn base_ships_no_motion_of_its_own() {
1786        let motion = ScrollbarMotion::default();
1787        assert_eq!(motion.enter(), Duration::ZERO);
1788        assert_eq!(motion.exit(), Duration::ZERO);
1789        assert_eq!(motion.expand(), Duration::ZERO);
1790        assert_eq!(motion.entrance(), ScrollbarEntrance::Fade);
1791        assert_eq!(motion.thumb_hover_entrance(), ScrollbarEntrance::Fade);
1792        assert_eq!(
1793            motion.idle(),
1794            DEFAULT_IDLE,
1795            "the visibility hold is behavior, not motion, and must stay usable"
1796        );
1797    }
1798
1799    #[test]
1800    fn hover_mode_slides_only_when_the_thumb_is_hovered() {
1801        let motion = ScrollbarMotion::default()
1802            .with_entrance(ScrollbarEntrance::Fade)
1803            .with_thumb_hover_entrance(ScrollbarEntrance::SlideAndFade);
1804
1805        assert_eq!(
1806            motion.entrance_for(ScrollbarMode::Hover, false),
1807            ScrollbarEntrance::Fade
1808        );
1809        assert_eq!(
1810            motion.entrance_for(ScrollbarMode::Hover, true),
1811            ScrollbarEntrance::SlideAndFade
1812        );
1813        assert_eq!(
1814            motion.entrance_for(ScrollbarMode::Scrolling, true),
1815            ScrollbarEntrance::Fade
1816        );
1817    }
1818
1819    #[test]
1820    fn hidden_scrolling_mode_does_not_track_thumb_hover() {
1821        assert!(!tracks_thumb_hover(ScrollbarMode::Scrolling, false));
1822        assert!(tracks_thumb_hover(ScrollbarMode::Scrolling, true));
1823        assert!(tracks_thumb_hover(ScrollbarMode::Hover, false));
1824    }
1825
1826    #[test]
1827    fn visible_scrolling_mode_stays_visible_while_hovered() {
1828        assert!(!hover_keeps_visible(ScrollbarMode::Scrolling, true, false));
1829        assert!(hover_keeps_visible(ScrollbarMode::Scrolling, true, true));
1830        assert!(hover_keeps_visible(ScrollbarMode::Hover, true, false));
1831    }
1832
1833    #[test]
1834    fn motionless_base_snaps_every_transition() {
1835        let now = Instant::now();
1836        let motion = ScrollbarMotion::default();
1837        let mut visibility = VisibilityAnimation::hidden(now);
1838
1839        visibility.set_visible(true, motion.entrance(), motion.enter(), motion.exit(), now);
1840        let shown = visibility.sample(now);
1841        assert_eq!(shown.opacity, 1.0);
1842        assert_eq!(shown.position, 1.0);
1843        assert!(!shown.running, "a motionless theme must request no frames");
1844        assert_eq!(
1845            visibility_translation(Axis::Vertical, px(16.), shown.position),
1846            Point::default()
1847        );
1848
1849        visibility.set_visible(false, motion.entrance(), motion.enter(), motion.exit(), now);
1850        let hidden = visibility.sample(now);
1851        assert_eq!(hidden.opacity, 0.0);
1852        assert_eq!(hidden.position, 0.0);
1853        assert!(!hidden.running);
1854
1855        let mut width = WidthAnimation::new(now);
1856        assert_eq!(
1857            width.set_target(px(8.), motion.expand(), now),
1858            (px(8.), false)
1859        );
1860    }
1861
1862    #[test]
1863    fn a_zero_duration_settles_a_transition_already_in_flight() {
1864        let start = Instant::now();
1865        let mut animation = VisibilityAnimation::hidden(start);
1866        animation.set_visible(true, ScrollbarEntrance::SlideAndFade, ENTER, EXIT, start);
1867
1868        // Reduced motion turns on midway through the entrance.
1869        let midway = start + ENTER / 2;
1870        animation.set_visible(
1871            true,
1872            ScrollbarEntrance::SlideAndFade,
1873            Duration::ZERO,
1874            Duration::ZERO,
1875            midway,
1876        );
1877        let settled = animation.sample(midway);
1878        assert_eq!(settled.opacity, 1.0);
1879        assert_eq!(settled.position, 1.0);
1880        assert!(!settled.running);
1881    }
1882
1883    #[test]
1884    fn thumb_expansion_animates_in_both_directions() {
1885        let start = Instant::now();
1886        let mut animation = WidthAnimation::new(start);
1887        assert_eq!(animation.set_target(px(6.), EXPAND, start), (px(6.), false));
1888
1889        let (initial, running) = animation.set_target(px(8.), EXPAND, start);
1890        assert_eq!(initial, px(6.));
1891        assert!(running);
1892        let (expanded_halfway, _) = animation.sample(start + EXPAND / 2);
1893        assert!(expanded_halfway > px(7.));
1894
1895        let reversal = start + EXPAND / 2;
1896        let before = animation.sample(reversal).0;
1897        let (after_reversal, running) = animation.set_target(px(6.), EXPAND, reversal);
1898        assert_eq!(after_reversal, before);
1899        assert!(running);
1900        assert_eq!(animation.sample(reversal + EXPAND).0, px(6.));
1901    }
1902
1903    #[test]
1904    fn reduced_motion_snaps_thumb_expansion() {
1905        let now = Instant::now();
1906        let mut animation = WidthAnimation::new(now);
1907        assert_eq!(
1908            animation.set_target(px(8.), Duration::ZERO, now),
1909            (px(8.), false)
1910        );
1911    }
1912
1913    #[test]
1914    fn visibility_translation_moves_toward_the_nearest_edge() {
1915        assert_eq!(
1916            visibility_translation(Axis::Vertical, px(16.), 0.0),
1917            point(px(16.), px(0.))
1918        );
1919        assert_eq!(
1920            visibility_translation(Axis::Horizontal, px(16.), 0.0),
1921            point(px(0.), px(16.))
1922        );
1923        assert_eq!(
1924            visibility_translation(Axis::Vertical, px(16.), 1.0),
1925            Point::default()
1926        );
1927    }
1928
1929    #[test]
1930    fn visibility_animation_reverses_from_current_progress() {
1931        let start = Instant::now();
1932        let mut animation = VisibilityAnimation::hidden(start);
1933        animation.set_visible(true, ScrollbarEntrance::SlideAndFade, ENTER, EXIT, start);
1934        let reversal_time = start + Duration::from_millis(60);
1935        let before = animation.sample(reversal_time);
1936
1937        animation.set_visible(
1938            false,
1939            ScrollbarEntrance::SlideAndFade,
1940            ENTER,
1941            EXIT,
1942            reversal_time,
1943        );
1944        let reversed = animation.sample(reversal_time);
1945        assert_eq!(reversed.opacity, before.opacity);
1946        assert_eq!(reversed.position, before.position);
1947        let after = animation.sample(reversal_time + Duration::from_millis(10));
1948        assert!(after.opacity < before.opacity);
1949        assert!(after.position < before.position);
1950    }
1951
1952    #[test]
1953    fn always_hover_drag_and_recent_scroll_request_visibility() {
1954        let now = Instant::now();
1955        let idle = DEFAULT_IDLE;
1956        assert!(wants_visible(
1957            ScrollbarMode::Always,
1958            false,
1959            false,
1960            None,
1961            idle,
1962            now
1963        ));
1964        assert!(wants_visible(
1965            ScrollbarMode::Hover,
1966            true,
1967            false,
1968            None,
1969            idle,
1970            now
1971        ));
1972        assert!(wants_visible(
1973            ScrollbarMode::Scrolling,
1974            false,
1975            true,
1976            None,
1977            idle,
1978            now
1979        ));
1980        assert!(wants_visible(
1981            ScrollbarMode::Scrolling,
1982            false,
1983            false,
1984            Some(now - idle + Duration::from_millis(1)),
1985            idle,
1986            now,
1987        ));
1988        assert!(!wants_visible(
1989            ScrollbarMode::Scrolling,
1990            false,
1991            false,
1992            Some(now - idle),
1993            idle,
1994            now,
1995        ));
1996
1997        let mut settled = VisibilityAnimation::hidden(now - ENTER);
1998        settled.set_visible(
1999            true,
2000            ScrollbarEntrance::SlideAndFade,
2001            ENTER,
2002            EXIT,
2003            now - ENTER,
2004        );
2005        assert!(!settled.sample(now).running, "idle hold must not animate");
2006    }
2007
2008    #[test]
2009    fn leaving_hover_starts_a_fresh_idle_hold() {
2010        let entered_at = Instant::now();
2011        let left_at = entered_at + Duration::from_secs(5);
2012        let state = ScrollbarState::default().get();
2013
2014        let hovered = state.with_hovered(Some(Axis::Vertical), entered_at);
2015        assert_eq!(hovered.last_scroll_time, Some(entered_at));
2016        let left = hovered.with_hovered(None, left_at);
2017        assert_eq!(left.last_scroll_time, Some(left_at));
2018        assert!(wants_visible(
2019            ScrollbarMode::Hover,
2020            false,
2021            false,
2022            left.last_scroll_time,
2023            DEFAULT_IDLE,
2024            left_at + DEFAULT_IDLE - Duration::from_millis(1),
2025        ));
2026    }
2027
2028    #[test]
2029    fn idle_boundary_starts_the_exit_without_a_jump() {
2030        let activity = Instant::now();
2031        let exit_start = activity + DEFAULT_IDLE;
2032        let mut animation = VisibilityAnimation::hidden(activity - ENTER);
2033        animation.set_visible(true, ScrollbarEntrance::Fade, ENTER, EXIT, activity - ENTER);
2034        assert!(!wants_visible(
2035            ScrollbarMode::Scrolling,
2036            false,
2037            false,
2038            Some(activity),
2039            DEFAULT_IDLE,
2040            exit_start,
2041        ));
2042
2043        animation.set_visible(false, ScrollbarEntrance::Fade, ENTER, EXIT, exit_start);
2044        let start = animation.sample(exit_start);
2045        assert_eq!(start.opacity, 1.0);
2046        assert_eq!(start.position, 1.0);
2047        assert_eq!(animation.sample(exit_start + EXIT).opacity, 0.0);
2048    }
2049
2050    #[derive(Clone)]
2051    struct TestHandle {
2052        offset: Rc<Cell<Point<Pixels>>>,
2053        content_size: Size<Pixels>,
2054        drag_starts: Rc<Cell<usize>>,
2055        drag_ends: Rc<Cell<usize>>,
2056    }
2057
2058    impl TestHandle {
2059        fn new(content_size: Size<Pixels>) -> Self {
2060            Self {
2061                offset: Rc::new(Cell::new(Point::default())),
2062                content_size,
2063                drag_starts: Rc::new(Cell::new(0)),
2064                drag_ends: Rc::new(Cell::new(0)),
2065            }
2066        }
2067    }
2068
2069    impl ScrollbarHandle for TestHandle {
2070        fn viewport_bounds(&self) -> Bounds<Pixels> {
2071            Bounds::new(Point::default(), size(px(100.), px(100.)))
2072        }
2073
2074        fn offset(&self) -> Point<Pixels> {
2075            self.offset.get()
2076        }
2077
2078        fn set_offset(&self, offset: Point<Pixels>) {
2079            self.offset.set(offset);
2080        }
2081
2082        fn content_size(&self) -> Size<Pixels> {
2083            self.content_size
2084        }
2085
2086        fn start_drag(&self) {
2087            self.drag_starts.set(self.drag_starts.get() + 1);
2088        }
2089
2090        fn end_drag(&self) {
2091            self.drag_ends.set(self.drag_ends.get() + 1);
2092        }
2093    }
2094
2095    struct ScrollbarHarness {
2096        handle: TestHandle,
2097        axis: ScrollbarAxis,
2098        mode: ScrollbarMode,
2099    }
2100
2101    impl Render for ScrollbarHarness {
2102        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2103            div()
2104                .relative()
2105                .size(px(100.))
2106                .child(Scrollbar::new(&self.handle).axis(self.axis).mode(self.mode))
2107        }
2108    }
2109
2110    fn harness(
2111        cx: &mut TestAppContext,
2112        axis: ScrollbarAxis,
2113        mode: ScrollbarMode,
2114        content_size: Size<Pixels>,
2115    ) -> (&mut VisualTestContext, TestHandle) {
2116        let handle = TestHandle::new(content_size);
2117        let (_, cx) = cx.add_window_view({
2118            let handle = handle.clone();
2119            move |_, _| ScrollbarHarness { handle, axis, mode }
2120        });
2121        cx.update(|window, cx| window.draw(cx).clear(cx));
2122        (cx, handle)
2123    }
2124
2125    #[test]
2126    fn explicit_viewport_bounds_override_handle_bounds() {
2127        let expected = Bounds::new(point(px(12.), px(24.)), size(px(240.), px(96.)));
2128        let scrollbar = Scrollbar::vertical(&TestHandle::new(size(px(240.), px(480.))))
2129            .viewport_bounds(expected);
2130
2131        assert_eq!(
2132            scrollbar.resolved_viewport_bounds(Bounds::default()),
2133            expected
2134        );
2135    }
2136
2137    #[test]
2138    fn layout_viewport_uses_current_element_bounds() {
2139        let expected = Bounds::new(point(px(20.), px(30.)), size(px(180.), px(12.)));
2140        let scrollbar =
2141            Scrollbar::horizontal(&TestHandle::new(size(px(600.), px(12.)))).viewport_from_layout();
2142
2143        assert_eq!(scrollbar.resolved_viewport_bounds(expected), expected);
2144    }
2145
2146    #[test]
2147    fn typed_styles_are_fluent_and_include_geometry() {
2148        let track = gpui::hsla(0.1, 0.2, 0.3, 1.0);
2149        let border = gpui::hsla(0.2, 0.3, 0.4, 1.0);
2150        let thumb = gpui::hsla(0.3, 0.4, 0.5, 1.0);
2151        let hover = gpui::hsla(0.4, 0.5, 0.6, 1.0);
2152        let active = gpui::hsla(0.5, 0.6, 0.7, 1.0);
2153        let scrollbar = Scrollbar::new(&TestHandle::new(Size::default())).styles(|styles| {
2154            styles
2155                .track(|style| {
2156                    style
2157                        .width(px(14.))
2158                        .bg(track)
2159                        .border_color(border)
2160                        .when(false, |style| style.width(px(99.)))
2161                })
2162                .thumb(|style| {
2163                    style
2164                        .width(px(7.))
2165                        .inset(px(3.))
2166                        .radius(px(3.5))
2167                        .min_length(px(40.))
2168                        .bg(thumb)
2169                })
2170                .thumb_hover(|style| style.width(px(9.)).bg(hover))
2171                .thumb_active(|style| style.radius(px(4.5)).bg(active))
2172        });
2173
2174        assert_eq!(scrollbar.styles.track.background, Some(track));
2175        assert_eq!(scrollbar.styles.track.border, Some(border));
2176        assert_eq!(scrollbar.styles.track.width, Some(px(14.)));
2177        assert!(scrollbar.styles.thumb.background.is_some());
2178        assert_eq!(scrollbar.styles.thumb.width, Some(px(7.)));
2179        assert_eq!(scrollbar.styles.thumb.inset, Some(px(3.)));
2180        assert_eq!(scrollbar.styles.thumb.radius, Some(px(3.5)));
2181        assert_eq!(scrollbar.styles.thumb.min_length, Some(px(40.)));
2182        assert!(scrollbar.styles.thumb_hover.background.is_some());
2183        assert_eq!(scrollbar.styles.thumb_hover.width, Some(px(9.)));
2184        assert!(scrollbar.styles.thumb_active.background.is_some());
2185        assert_eq!(scrollbar.styles.thumb_active.radius, Some(px(4.5)));
2186    }
2187
2188    #[gpui::test]
2189    fn unstyled_thumb_follows_the_theme_rather_than_a_fixed_colour(cx: &mut TestAppContext) {
2190        cx.update(|cx| {
2191            let scrollbar = Scrollbar::new(&TestHandle::new(Size::default()));
2192
2193            let light = gpui::hsla(0., 0., 0.04, 1.0);
2194            crate::Theme::global_mut(cx).tokens.colors.foreground = light;
2195            let (on_light, ..) = scrollbar.style_for_normal(cx);
2196
2197            let dark = gpui::hsla(0., 0., 0.98, 1.0);
2198            crate::Theme::global_mut(cx).tokens.colors.foreground = dark;
2199            let (on_dark, ..) = scrollbar.style_for_normal(cx);
2200
2201            assert_eq!(on_light, Background::from(light.alpha(0.35)));
2202            assert_eq!(on_dark, Background::from(dark.alpha(0.35)));
2203            // The point of the change: a thumb that never moved with the
2204            // palette was invisible on one of the two surfaces.
2205            assert_ne!(on_light, on_dark);
2206        });
2207    }
2208
2209    #[gpui::test]
2210    fn a_styled_thumb_still_beats_the_theme_derived_default(cx: &mut TestAppContext) {
2211        cx.update(|cx| {
2212            let chosen = gpui::hsla(0.6, 0.5, 0.5, 1.0);
2213            crate::Theme::global_mut(cx).tokens.colors.foreground = gpui::hsla(0., 0., 0.98, 1.0);
2214
2215            let scrollbar = Scrollbar::new(&TestHandle::new(Size::default()))
2216                .styles(|styles| styles.thumb(|style| style.bg(chosen)));
2217            let (thumb, ..) = scrollbar.style_for_normal(cx);
2218
2219            assert_eq!(thumb, Background::from(chosen));
2220        });
2221    }
2222
2223    #[gpui::test]
2224    fn instance_styles_override_theme_scrollbar_defaults(cx: &mut TestAppContext) {
2225        cx.update(|cx| {
2226            let theme_track = gpui::hsla(0.1, 0.2, 0.3, 1.0);
2227            let theme_thumb = gpui::hsla(0.2, 0.3, 0.4, 1.0);
2228            let instance_thumb = gpui::hsla(0.3, 0.4, 0.5, 1.0);
2229
2230            crate::Theme::global_mut(cx).scrollbar = crate::ScrollbarTheme::new()
2231                .with_mode(ScrollbarMode::Always)
2232                .with_motion(ScrollbarMotion::default())
2233                .with_styles(
2234                    ScrollbarStyles::default()
2235                        .track(|style| style.width(px(13.)).bg(theme_track))
2236                        .thumb(|style| style.width(px(7.)).bg(theme_thumb)),
2237                );
2238
2239            let scrollbar = Scrollbar::new(&TestHandle::new(Size::default()))
2240                .styles(|styles| styles.thumb(|style| style.bg(instance_thumb)));
2241            let (thumb, track, _, width, _, _, _) = scrollbar.style_for_normal(cx);
2242
2243            assert_eq!(thumb, Background::from(instance_thumb));
2244            assert_eq!(track, theme_track);
2245            assert_eq!(width, px(7.));
2246            assert_eq!(cx.theme().scrollbar.styles().track.width, Some(px(13.)));
2247        });
2248    }
2249
2250    #[gpui::test]
2251    fn auto_hide_modes_use_a_six_pixel_resting_thumb(cx: &mut TestAppContext) {
2252        cx.update(|cx| {
2253            let handle = TestHandle::new(Size::default());
2254            let scrolling = Scrollbar::new(&handle).mode(ScrollbarMode::Scrolling);
2255            let always = Scrollbar::new(&handle).mode(ScrollbarMode::Always);
2256
2257            assert_eq!(scrolling.style_for_normal(cx).3, px(6.));
2258            assert_eq!(always.style_for_normal(cx).3, px(6.));
2259        });
2260    }
2261
2262    #[gpui::test]
2263    fn every_mode_expands_only_for_thumb_hover(cx: &mut TestAppContext) {
2264        cx.update(|cx| {
2265            let handle = TestHandle::new(Size::default());
2266            for mode in [
2267                ScrollbarMode::Scrolling,
2268                ScrollbarMode::Hover,
2269                ScrollbarMode::Always,
2270            ] {
2271                let scrollbar = Scrollbar::new(&handle).mode(mode);
2272                assert_eq!(scrollbar.style_for_normal(cx).3, px(6.));
2273                assert_eq!(scrollbar.style_for_hovered_bar(cx).3, px(6.));
2274                assert_eq!(scrollbar.style_for_hovered_thumb(cx).3, px(8.));
2275            }
2276        });
2277    }
2278
2279    #[gpui::test]
2280    fn vertical_track_click_updates_vertical_offset(cx: &mut TestAppContext) {
2281        let (cx, vertical) = harness(
2282            cx,
2283            ScrollbarAxis::Vertical,
2284            ScrollbarMode::Always,
2285            size(px(100.), px(500.)),
2286        );
2287        cx.simulate_click(point(px(95.), px(80.)), Modifiers::default());
2288        assert!(vertical.offset().y < px(0.));
2289        assert_eq!(vertical.offset().x, px(0.));
2290    }
2291
2292    #[gpui::test]
2293    fn horizontal_track_click_updates_horizontal_offset(cx: &mut TestAppContext) {
2294        let (cx, horizontal) = harness(
2295            cx,
2296            ScrollbarAxis::Horizontal,
2297            ScrollbarMode::Always,
2298            size(px(500.), px(100.)),
2299        );
2300        cx.simulate_click(point(px(80.), px(95.)), Modifiers::default());
2301        assert!(horizontal.offset().x < px(0.));
2302        assert_eq!(horizontal.offset().y, px(0.));
2303    }
2304
2305    #[gpui::test]
2306    fn no_overflow_has_no_interactive_track(cx: &mut TestAppContext) {
2307        let (cx, handle) = harness(
2308            cx,
2309            ScrollbarAxis::Both,
2310            ScrollbarMode::Always,
2311            size(px(100.), px(100.)),
2312        );
2313        cx.simulate_click(point(px(95.), px(80.)), Modifiers::default());
2314        assert_eq!(handle.offset(), Point::default());
2315    }
2316
2317    #[gpui::test]
2318    fn hidden_hover_scrollbar_ignores_track_click(cx: &mut TestAppContext) {
2319        let (cx, handle) = harness(
2320            cx,
2321            ScrollbarAxis::Vertical,
2322            ScrollbarMode::Hover,
2323            size(px(100.), px(500.)),
2324        );
2325        cx.simulate_click(point(px(95.), px(80.)), Modifiers::default());
2326        assert_eq!(handle.offset(), Point::default());
2327    }
2328
2329    #[gpui::test]
2330    fn hidden_hover_scrollbar_ignores_thumb_drag(cx: &mut TestAppContext) {
2331        let (cx, handle) = harness(
2332            cx,
2333            ScrollbarAxis::Vertical,
2334            ScrollbarMode::Hover,
2335            size(px(100.), px(500.)),
2336        );
2337        cx.simulate_mouse_down(
2338            point(px(95.), px(20.)),
2339            MouseButton::Left,
2340            Modifiers::default(),
2341        );
2342        cx.simulate_mouse_move(
2343            point(px(95.), px(70.)),
2344            Some(MouseButton::Left),
2345            Modifiers::default(),
2346        );
2347        cx.simulate_mouse_up(
2348            point(px(95.), px(70.)),
2349            MouseButton::Left,
2350            Modifiers::default(),
2351        );
2352
2353        assert_eq!(handle.drag_starts.get(), 0);
2354        assert_eq!(handle.drag_ends.get(), 0);
2355        assert_eq!(handle.offset(), Point::default());
2356    }
2357
2358    #[gpui::test]
2359    fn hovering_reveals_scrollbar_for_track_interaction(cx: &mut TestAppContext) {
2360        let (cx, handle) = harness(
2361            cx,
2362            ScrollbarAxis::Vertical,
2363            ScrollbarMode::Hover,
2364            size(px(100.), px(500.)),
2365        );
2366        // Base ships no motion, so the reveal is immediate.
2367        cx.simulate_mouse_move(point(px(95.), px(50.)), None, Modifiers::default());
2368        cx.run_until_parked();
2369
2370        cx.simulate_click(point(px(95.), px(80.)), Modifiers::default());
2371        assert!(handle.offset().y < px(0.));
2372    }
2373
2374    #[gpui::test]
2375    fn thumb_drag_notifies_handle_start_and_end(cx: &mut TestAppContext) {
2376        let (cx, handle) = harness(
2377            cx,
2378            ScrollbarAxis::Vertical,
2379            ScrollbarMode::Always,
2380            size(px(100.), px(500.)),
2381        );
2382        cx.simulate_mouse_down(
2383            point(px(95.), px(20.)),
2384            MouseButton::Left,
2385            Modifiers::default(),
2386        );
2387        cx.simulate_mouse_move(
2388            point(px(95.), px(70.)),
2389            Some(MouseButton::Left),
2390            Modifiers::default(),
2391        );
2392        cx.simulate_mouse_up(
2393            point(px(95.), px(70.)),
2394            MouseButton::Left,
2395            Modifiers::default(),
2396        );
2397
2398        assert_eq!(handle.drag_starts.get(), 1);
2399        assert_eq!(handle.drag_ends.get(), 1);
2400    }
2401}