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