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