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, ScrollHandle, ScrollWheelEvent, Size, Style,
15 TouchDragEvent, TouchPhase, UniformListScrollHandle, Window, fill, point,
16 prelude::FluentBuilder, px, relative, size,
17};
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20
21const 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
33const 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default, JsonSchema)]
48pub enum ScrollbarMode {
49 #[default]
51 Scrolling,
52 Hover,
54 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
68pub trait ScrollbarHandle: 'static {
70 fn viewport_bounds(&self) -> Bounds<Pixels>;
72 fn offset(&self) -> Point<Pixels>;
74 fn set_offset(&self, offset: Point<Pixels>);
76 fn content_size(&self) -> Size<Pixels>;
78 fn start_drag(&self) {}
80 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: 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
275pub enum ScrollbarEntrance {
276 #[default]
278 Fade,
279 SlideAndFade,
281}
282
283#[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 pub fn with_idle(mut self, idle: Duration) -> Self {
314 self.idle = idle;
315 self
316 }
317
318 pub fn with_enter(mut self, enter: Duration) -> Self {
320 self.enter = enter;
321 self
322 }
323
324 pub fn with_exit(mut self, exit: Duration) -> Self {
326 self.exit = exit;
327 self
328 }
329
330 pub fn with_expand(mut self, expand: Duration) -> Self {
332 self.expand = expand;
333 self
334 }
335
336 pub fn with_entrance(mut self, entrance: ScrollbarEntrance) -> Self {
338 self.entrance = entrance;
339 self
340 }
341
342 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
630pub enum ScrollbarAxis {
631 Vertical,
633 Horizontal,
635 Both,
637}
638
639#[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#[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#[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 #[inline]
774 pub fn is_vertical(&self) -> bool {
775 matches!(self, Self::Vertical)
776 }
777
778 #[inline]
780 pub fn is_horizontal(&self) -> bool {
781 matches!(self, Self::Horizontal)
782 }
783
784 #[inline]
786 pub fn is_both(&self) -> bool {
787 matches!(self, Self::Both)
788 }
789
790 #[inline]
792 pub fn has_vertical(&self) -> bool {
793 matches!(self, Self::Vertical | Self::Both)
794 }
795
796 #[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 Self::Both => vec![Axis::Horizontal, Axis::Vertical],
810 }
811 }
812}
813
814pub 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 use_layout_bounds: bool,
823 max_fps: usize,
828 styles: ScrollbarStyles,
829}
830
831impl Scrollbar {
832 #[track_caller]
836 pub fn new<H: ScrollbarHandle + Clone>(scroll_handle: &H) -> Self {
837 let caller = Location::caller();
838 Self {
839 id: ElementId::CodeLocation(*caller),
840 axis: ScrollbarAxis::Both,
841 mode: None,
842 scroll_handle: Rc::new(scroll_handle.clone()),
843 max_fps: 120,
844 scroll_size: None,
845 viewport_bounds: None,
846 use_layout_bounds: false,
847 styles: ScrollbarStyles::default(),
848 }
849 }
850
851 #[track_caller]
853 pub fn horizontal<H: ScrollbarHandle + Clone>(scroll_handle: &H) -> Self {
854 Self::new(scroll_handle).axis(ScrollbarAxis::Horizontal)
855 }
856
857 #[track_caller]
859 pub fn vertical<H: ScrollbarHandle + Clone>(scroll_handle: &H) -> Self {
860 Self::new(scroll_handle).axis(ScrollbarAxis::Vertical)
861 }
862
863 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
867 self.id = id.into();
868 self
869 }
870
871 pub fn mode(mut self, mode: ScrollbarMode) -> Self {
875 self.mode = Some(mode);
876 self
877 }
878
879 pub fn scroll_size(mut self, scroll_size: Size<Pixels>) -> Self {
883 self.scroll_size = Some(scroll_size);
884 self
885 }
886
887 pub fn viewport_bounds(mut self, bounds: Bounds<Pixels>) -> Self {
894 self.viewport_bounds = Some(bounds);
895 self
896 }
897
898 pub fn viewport_from_layout(mut self) -> Self {
904 self.use_layout_bounds = true;
905 self
906 }
907
908 fn resolved_viewport_bounds(&self, layout_bounds: Bounds<Pixels>) -> Bounds<Pixels> {
909 self.viewport_bounds.unwrap_or_else(|| {
910 if self.use_layout_bounds {
911 layout_bounds
912 } else {
913 self.scroll_handle.viewport_bounds()
914 }
915 })
916 }
917
918 pub fn axis(mut self, axis: impl Into<ScrollbarAxis>) -> Self {
920 self.axis = axis.into();
921 self
922 }
923
924 pub fn styles(mut self, build: impl FnOnce(ScrollbarStyles) -> ScrollbarStyles) -> Self {
925 self.styles = build(self.styles);
926 self
927 }
928
929 #[doc(hidden)]
935 pub fn max_fps(mut self, max_fps: usize) -> Self {
936 self.max_fps = max_fps.clamp(30, 120);
937 self
938 }
939
940 #[doc(hidden)]
942 pub const fn width() -> Pixels {
943 WIDTH
944 }
945
946 fn resolve_track(
947 &self,
948 cx: &App,
949 state: &ScrollbarTrackStyle,
950 global_state: &ScrollbarTrackStyle,
951 default_border: Hsla,
952 ) -> (Hsla, Hsla) {
953 let theme = cx.theme();
954 let global = theme.scrollbar.styles();
955 (
956 state
957 .background
958 .or(self.styles.track.background)
959 .or(global_state.background)
960 .or(global.track.background)
961 .unwrap_or_else(gpui::transparent_black),
962 state
963 .border
964 .or(self.styles.track.border)
965 .or(global_state.border)
966 .or(global.track.border)
967 .unwrap_or(default_border),
968 )
969 }
970
971 fn resolve_thumb(
972 &self,
973 cx: &App,
974 state: &ScrollbarThumbStyle,
975 global_state: &ScrollbarThumbStyle,
976 defaults: ScrollbarThumbStyle,
977 ) -> (Background, Pixels, Pixels, Pixels, Pixels) {
978 let theme = cx.theme();
979 let global = theme.scrollbar.styles();
980 (
981 state
982 .background
983 .or(self.styles.thumb.background)
984 .or(global_state.background)
985 .or(global.thumb.background)
986 .unwrap_or_else(|| defaults.background.unwrap()),
987 state
988 .width
989 .or(self.styles.thumb.width)
990 .or(global_state.width)
991 .or(global.thumb.width)
992 .unwrap_or_else(|| defaults.width.unwrap()),
993 state
994 .inset
995 .or(self.styles.thumb.inset)
996 .or(global_state.inset)
997 .or(global.thumb.inset)
998 .unwrap_or_else(|| defaults.inset.unwrap()),
999 state
1000 .radius
1001 .or(self.styles.thumb.radius)
1002 .or(global_state.radius)
1003 .or(global.thumb.radius)
1004 .unwrap_or_else(|| defaults.radius.unwrap()),
1005 state
1006 .min_length
1007 .or(self.styles.thumb.min_length)
1008 .or(global_state.min_length)
1009 .or(global.thumb.min_length)
1010 .or(defaults.min_length)
1011 .unwrap_or(MIN_THUMB_SIZE),
1012 )
1013 }
1014
1015 fn thumb_default_background(cx: &App, alpha: f32) -> Background {
1031 cx.theme().tokens.colors.foreground.alpha(alpha).into()
1032 }
1033
1034 fn thumb_defaults(
1035 background: Background,
1036 width: Pixels,
1037 inset: Pixels,
1038 radius: Pixels,
1039 ) -> ScrollbarThumbStyle {
1040 ScrollbarThumbStyle {
1041 background: Some(background),
1042 width: Some(width),
1043 inset: Some(inset),
1044 radius: Some(radius),
1045 min_length: Some(MIN_THUMB_SIZE),
1046 }
1047 }
1048
1049 fn style_for_active(
1050 &self,
1051 cx: &App,
1052 ) -> (Background, Hsla, Hsla, Pixels, Pixels, Pixels, Pixels) {
1053 let theme = cx.theme();
1054 let global = theme.scrollbar.styles();
1055 let (track, border) = self.resolve_track(
1056 cx,
1057 &self.styles.track_active,
1058 &global.track_active,
1059 gpui::transparent_black(),
1060 );
1061 let (thumb, width, inset, radius, min_length) = self.resolve_thumb(
1062 cx,
1063 &self.styles.thumb_active,
1064 &global.thumb_active,
1065 Self::thumb_defaults(
1066 Self::thumb_default_background(cx, 0.55),
1067 THUMB_ACTIVE_WIDTH,
1068 THUMB_ACTIVE_INSET,
1069 THUMB_ACTIVE_RADIUS,
1070 ),
1071 );
1072 (thumb, track, border, width, inset, radius, min_length)
1073 }
1074
1075 fn style_for_hovered_thumb(
1076 &self,
1077 cx: &App,
1078 ) -> (Background, Hsla, Hsla, Pixels, Pixels, Pixels, Pixels) {
1079 let theme = cx.theme();
1080 let global = theme.scrollbar.styles();
1081 let (track, border) = self.resolve_track(
1082 cx,
1083 &self.styles.track_active,
1084 &global.track_active,
1085 gpui::transparent_black(),
1086 );
1087 let (thumb, width, inset, radius, min_length) = self.resolve_thumb(
1088 cx,
1089 &self.styles.thumb_hover,
1090 &global.thumb_hover,
1091 Self::thumb_defaults(
1092 Self::thumb_default_background(cx, 0.55),
1093 THUMB_ACTIVE_WIDTH,
1094 THUMB_ACTIVE_INSET,
1095 THUMB_ACTIVE_RADIUS,
1096 ),
1097 );
1098 (thumb, track, border, width, inset, radius, min_length)
1099 }
1100
1101 fn style_for_hovered_bar(
1102 &self,
1103 cx: &App,
1104 ) -> (Background, Hsla, Hsla, Pixels, Pixels, Pixels, Pixels) {
1105 let theme = cx.theme();
1106 let global = theme.scrollbar.styles();
1107 let (track, border) = self.resolve_track(
1108 cx,
1109 &self.styles.track_hover,
1110 &global.track_hover,
1111 gpui::transparent_black(),
1112 );
1113 let (thumb, width, inset, radius, min_length) = self.resolve_thumb(
1114 cx,
1115 &self.styles.thumb,
1116 &global.thumb,
1117 Self::thumb_defaults(
1118 Self::thumb_default_background(cx, 0.35),
1119 THUMB_WIDTH,
1120 THUMB_INSET,
1121 THUMB_RADIUS,
1122 ),
1123 );
1124 (thumb, track, border, width, inset, radius, min_length)
1125 }
1126
1127 fn style_for_normal(
1128 &self,
1129 cx: &App,
1130 ) -> (Background, Hsla, Hsla, Pixels, Pixels, Pixels, Pixels) {
1131 let theme = cx.theme();
1132 let global = theme.scrollbar.styles();
1133
1134 let (track, border) = self.resolve_track(
1135 cx,
1136 &self.styles.track,
1137 &global.track,
1138 gpui::transparent_black(),
1139 );
1140 let (thumb, width, inset, radius, min_length) = self.resolve_thumb(
1141 cx,
1142 &self.styles.thumb,
1143 &global.thumb,
1144 Self::thumb_defaults(
1145 Self::thumb_default_background(cx, 0.35),
1146 THUMB_WIDTH,
1147 THUMB_INSET,
1148 THUMB_RADIUS,
1149 ),
1150 );
1151 (thumb, track, border, width, inset, radius, min_length)
1152 }
1153}
1154
1155impl IntoElement for Scrollbar {
1156 type Element = Self;
1157
1158 fn into_element(self) -> Self::Element {
1159 self
1160 }
1161}
1162
1163#[doc(hidden)]
1164pub struct PrepaintState {
1165 hitbox: Hitbox,
1166 scrollbar_state: ScrollbarState,
1167 states: Vec<AxisPrepaintState>,
1168}
1169
1170#[doc(hidden)]
1171pub struct AxisPrepaintState {
1172 axis: Axis,
1173 bar_hitbox: Hitbox,
1174 bounds: Bounds<Pixels>,
1175 radius: Pixels,
1176 bg: Hsla,
1177 border: Hsla,
1178 thumb_bounds: Bounds<Pixels>,
1179 thumb_fill_bounds: Bounds<Pixels>,
1181 thumb_bg: Background,
1182 geometry: ThumbGeometry,
1183 active_geometry: ThumbGeometry,
1184 track_width: Pixels,
1185 visibility_opacity: f32,
1186 visibility_position: f32,
1187 visibility_requested: bool,
1188}
1189
1190#[derive(Clone, Copy)]
1193struct ThumbGeometry {
1194 origin: Pixels,
1195 inset: Pixels,
1196 length: Pixels,
1197 travel: Pixels,
1198 extent: Pixels,
1199}
1200
1201impl ThumbGeometry {
1202 fn new(
1203 origin: Pixels,
1204 container: Pixels,
1205 content: Pixels,
1206 margin_end: Pixels,
1207 inset: Pixels,
1208 min_length: Pixels,
1209 ) -> Self {
1210 let track = (container - margin_end).max(px(0.));
1211 let logical_length = (container / content * container).max(min_length).min(track);
1212 let inset = inset.clamp(px(0.), logical_length / 2.);
1213 Self {
1214 origin,
1215 inset,
1216 length: logical_length - inset * 2.,
1217 travel: track - logical_length,
1218 extent: content - container,
1219 }
1220 }
1221
1222 fn start(self, offset: Pixels) -> Pixels {
1223 self.origin + self.inset + (-offset / self.extent).clamp(0., 1.) * self.travel
1224 }
1225
1226 fn offset(self, position: Pixels, grab: Pixels) -> Pixels {
1227 if self.travel <= px(0.) {
1228 return px(0.);
1229 }
1230 -self.extent * ((position - self.origin - self.inset - grab) / self.travel).clamp(0., 1.)
1231 }
1232
1233 fn drag_offset(
1234 self,
1235 axis: Axis,
1236 position: Point<Pixels>,
1237 grab: Point<Pixels>,
1238 mut offset: Point<Pixels>,
1239 ) -> Point<Pixels> {
1240 if self.travel <= px(0.) {
1241 return offset;
1242 }
1243 if axis.is_vertical() {
1244 offset.y = self.offset(position.y, grab.y);
1245 } else {
1246 offset.x = self.offset(position.x, grab.x);
1247 }
1248 offset
1249 }
1250}
1251
1252impl Element for Scrollbar {
1253 type RequestLayoutState = ();
1254 type PrepaintState = PrepaintState;
1255
1256 fn id(&self) -> Option<gpui::ElementId> {
1257 Some(self.id.clone())
1258 }
1259
1260 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
1261 None
1262 }
1263
1264 fn request_layout(
1265 &mut self,
1266 _: Option<&GlobalElementId>,
1267 _: Option<&InspectorElementId>,
1268 window: &mut Window,
1269 cx: &mut App,
1270 ) -> (LayoutId, Self::RequestLayoutState) {
1271 let mut style = Style::default();
1272 style.position = Position::Absolute;
1273 style.flex_grow = 1.0;
1274 style.flex_shrink = 1.0;
1275 style.size.width = relative(1.).into();
1276 style.size.height = relative(1.).into();
1277
1278 (window.request_layout(style, None, cx), ())
1279 }
1280
1281 fn prepaint(
1282 &mut self,
1283 _: Option<&GlobalElementId>,
1284 _: Option<&InspectorElementId>,
1285 bounds: Bounds<Pixels>,
1286 _: &mut Self::RequestLayoutState,
1287 window: &mut Window,
1288 cx: &mut App,
1289 ) -> Self::PrepaintState {
1290 let bounds = self.resolved_viewport_bounds(bounds);
1291 let hitbox = window.with_content_mask(Some(ContentMask { bounds }), |window| {
1292 window.insert_hitbox(bounds, HitboxBehavior::Normal)
1293 });
1294
1295 let state = window
1296 .use_state(cx, |_, _| ScrollbarState::default())
1297 .read(cx)
1298 .clone();
1299
1300 let now = Instant::now();
1301 let base_theme = cx.theme();
1302 let mode = self.mode.unwrap_or(base_theme.scrollbar.mode());
1303 let motion = base_theme.scrollbar.motion();
1304 let reduce_motion = cx.reduce_motion();
1307 let (enter, exit) = if !mode.is_always() && !reduce_motion {
1308 (motion.enter(), motion.exit())
1309 } else {
1310 (Duration::ZERO, Duration::ZERO)
1311 };
1312 let expand = if reduce_motion {
1313 Duration::ZERO
1314 } else {
1315 motion.expand()
1316 };
1317
1318 let mut inner = state.get();
1319 let current_offset = self.scroll_handle.offset();
1320 if current_offset != inner.last_scroll_offset {
1321 inner = inner.with_last_scroll(current_offset, Some(now));
1322 }
1323
1324 let is_hovered = inner.hovered_axis.is_some() || inner.hovered_on_thumb.is_some();
1325 let is_dragging = inner.dragged_axis.is_some();
1326 let is_currently_visible = inner.visibility.sample(now).opacity > 0.0;
1327 let visible = hover_keeps_visible(mode, is_hovered, is_currently_visible)
1328 || wants_visible(
1329 mode,
1330 is_hovered,
1331 is_dragging,
1332 inner.last_scroll_time,
1333 motion.idle(),
1334 now,
1335 );
1336 inner.visibility.set_visible(
1337 visible,
1338 motion.entrance_for(mode, inner.hovered_on_thumb.is_some()),
1339 enter,
1340 exit,
1341 now,
1342 );
1343 let visibility = inner.visibility.sample(now);
1344 if visibility.running {
1345 window.request_animation_frame();
1346 }
1347
1348 if !is_hovered && !is_dragging {
1349 if let Some(last_time) = inner.last_scroll_time {
1350 let elapsed = now.saturating_duration_since(last_time);
1351 if elapsed < motion.idle() && !inner.idle_timer_scheduled {
1352 inner.idle_timer_scheduled = true;
1353 let state = state.clone();
1354 let current_view = window.current_view();
1355 let next_delay = motion.idle() - elapsed;
1356 window
1357 .spawn(cx, async move |cx| {
1358 cx.background_executor().timer(next_delay).await;
1359 state.set(state.get().with_idle_timer_scheduled(false));
1360 cx.update(|_, cx| cx.notify(current_view)).ok();
1361 })
1362 .detach();
1363 }
1364 }
1365 }
1366 state.set(inner);
1367
1368 let mut states = vec![];
1369 let mut has_both = self.axis.is_both();
1370 let scroll_size = self
1371 .scroll_size
1372 .unwrap_or(self.scroll_handle.content_size());
1373
1374 for axis in self.axis.all().into_iter() {
1375 let is_vertical = axis.is_vertical();
1376 let track_width = self
1377 .styles
1378 .track
1379 .width
1380 .or(cx.theme().scrollbar.styles().track.width)
1381 .unwrap_or(WIDTH);
1382 let (scroll_area_size, container_size, scroll_position) = if is_vertical {
1383 (
1384 scroll_size.height,
1385 hitbox.size.height,
1386 self.scroll_handle.offset().y,
1387 )
1388 } else {
1389 (
1390 scroll_size.width,
1391 hitbox.size.width,
1392 self.scroll_handle.offset().x,
1393 )
1394 };
1395
1396 let margin_end = if has_both && !is_vertical {
1398 track_width
1399 } else {
1400 px(0.)
1401 };
1402
1403 if scroll_area_size <= container_size {
1405 has_both = false;
1406 continue;
1407 }
1408
1409 let bounds = Bounds {
1410 origin: if is_vertical {
1411 point(
1412 hitbox.origin.x + hitbox.size.width - track_width,
1413 hitbox.origin.y,
1414 )
1415 } else {
1416 point(
1417 hitbox.origin.x,
1418 hitbox.origin.y + hitbox.size.height - track_width,
1419 )
1420 },
1421 size: gpui::Size {
1422 width: if is_vertical {
1423 track_width
1424 } else {
1425 hitbox.size.width
1426 },
1427 height: if is_vertical {
1428 hitbox.size.height
1429 } else {
1430 track_width
1431 },
1432 },
1433 };
1434
1435 let is_always_to_show = mode.is_always();
1436 let is_hover_to_show = mode.is_hover();
1437 let is_hovered_on_bar = state.get().hovered_axis == Some(axis);
1438 let is_hovered_on_thumb = state.get().hovered_on_thumb == Some(axis);
1439
1440 let (thumb_bg, bar_bg, bar_border, mut thumb_width, inset, radius, min_length) =
1441 if state.get().dragged_axis == Some(axis) {
1442 self.style_for_active(cx)
1443 } else if (is_hover_to_show || mode == ScrollbarMode::Scrolling)
1444 && (is_hovered_on_bar || is_hovered_on_thumb)
1445 {
1446 if is_hovered_on_thumb {
1447 self.style_for_hovered_thumb(cx)
1448 } else {
1449 self.style_for_hovered_bar(cx)
1450 }
1451 } else if is_always_to_show && (is_hovered_on_bar || is_hovered_on_thumb) {
1452 if is_hovered_on_thumb {
1453 self.style_for_hovered_thumb(cx)
1454 } else {
1455 self.style_for_hovered_bar(cx)
1456 }
1457 } else {
1458 self.style_for_normal(cx)
1459 };
1460
1461 let mut width_animation = if is_vertical {
1462 state.get().vertical_width
1463 } else {
1464 state.get().horizontal_width
1465 };
1466 let (animated_width, running) = width_animation.set_target(thumb_width, expand, now);
1467 let mut updated = state.get();
1468 if is_vertical {
1469 updated.vertical_width = width_animation;
1470 } else {
1471 updated.horizontal_width = width_animation;
1472 }
1473 state.set(updated);
1474 thumb_width = animated_width;
1475 if running {
1476 window.request_animation_frame();
1477 }
1478
1479 let origin = if is_vertical {
1480 bounds.origin.y
1481 } else {
1482 bounds.origin.x
1483 };
1484 let geometry = ThumbGeometry::new(
1485 origin,
1486 container_size,
1487 scroll_area_size,
1488 margin_end,
1489 inset,
1490 min_length,
1491 );
1492 let (_, _, _, _, active_inset, _, active_min_length) = self.style_for_active(cx);
1493 let active_geometry = ThumbGeometry::new(
1494 origin,
1495 container_size,
1496 scroll_area_size,
1497 margin_end,
1498 active_inset,
1499 active_min_length,
1500 );
1501 let thumb_start = geometry.start(scroll_position) - origin;
1502
1503 let thumb_length = geometry.length;
1505 let thumb_bounds = if is_vertical {
1506 Bounds::from_anchor_and_size(
1507 Anchor::TopRight,
1508 bounds.top_right() + point(-inset, thumb_start),
1509 size(track_width, thumb_length),
1510 )
1511 } else {
1512 Bounds::from_anchor_and_size(
1513 Anchor::BottomLeft,
1514 bounds.bottom_left() + point(thumb_start, -inset),
1515 size(thumb_length, track_width),
1516 )
1517 };
1518
1519 let thumb_fill_bounds = if is_vertical {
1521 Bounds::from_anchor_and_size(
1522 Anchor::TopRight,
1523 bounds.top_right() + point(-inset, thumb_start),
1524 size(thumb_width, thumb_length),
1525 )
1526 } else {
1527 Bounds::from_anchor_and_size(
1528 Anchor::BottomLeft,
1529 bounds.bottom_left() + point(thumb_start, -inset),
1530 size(thumb_length, thumb_width),
1531 )
1532 };
1533
1534 let bar_hitbox = window.with_content_mask(Some(ContentMask { bounds }), |window| {
1535 window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal)
1536 });
1537
1538 states.push(AxisPrepaintState {
1539 axis,
1540 bar_hitbox,
1541 bounds,
1542 radius,
1543 bg: bar_bg,
1544 border: bar_border,
1545 thumb_bounds,
1546 thumb_fill_bounds,
1547 thumb_bg,
1548 geometry,
1549 active_geometry,
1550 track_width,
1551 visibility_opacity: visibility.opacity,
1552 visibility_position: visibility.position,
1553 visibility_requested: visible,
1554 })
1555 }
1556
1557 PrepaintState {
1558 hitbox,
1559 states,
1560 scrollbar_state: state,
1561 }
1562 }
1563
1564 fn paint(
1565 &mut self,
1566 _: Option<&GlobalElementId>,
1567 _: Option<&InspectorElementId>,
1568 _: Bounds<Pixels>,
1569 _: &mut Self::RequestLayoutState,
1570 prepaint: &mut Self::PrepaintState,
1571 window: &mut Window,
1572 cx: &mut App,
1573 ) {
1574 let scrollbar_state = &prepaint.scrollbar_state;
1575 let theme = cx.theme();
1576 let mode = self.mode.unwrap_or(theme.scrollbar.mode());
1577 let view_id = window.current_view();
1578 let hitbox_bounds = prepaint.hitbox.bounds;
1579 let is_hover_to_show = mode.is_hover();
1580
1581 window.with_content_mask(
1582 Some(ContentMask {
1583 bounds: hitbox_bounds,
1584 }),
1585 |window| {
1586 for state in prepaint.states.iter() {
1587 let axis = state.axis;
1588 let mut radius = state.radius;
1589 if theme.tokens.radius.md.is_zero() {
1590 radius = px(0.);
1591 }
1592 radius = clamp_thumb_radius(radius, state.thumb_fill_bounds);
1593 let bounds = state.bounds;
1594 let thumb_bounds = state.thumb_bounds;
1595 let geometry = state.geometry;
1596 let active_geometry = state.active_geometry;
1597 let is_vertical = axis.is_vertical();
1598 let visibility_opacity = state.visibility_opacity;
1599 let is_visible = state.visibility_requested || visibility_opacity > 0.0;
1600 let translation =
1601 visibility_translation(axis, state.track_width, state.visibility_position);
1602 let painted_bounds = state.bounds + translation;
1603 let painted_thumb_bounds = state.thumb_fill_bounds + translation;
1604 let painted_track_bg = state.bg.opacity(visibility_opacity);
1605 let painted_border = state.border.opacity(visibility_opacity);
1606 let painted_thumb_bg = state.thumb_bg.clone().opacity(visibility_opacity);
1607
1608 window.set_cursor_style(CursorStyle::default(), &state.bar_hitbox);
1609
1610 window.paint_layer(hitbox_bounds, |cx| {
1611 cx.paint_quad(fill(painted_bounds, painted_track_bg));
1612
1613 cx.paint_quad(PaintQuad {
1614 bounds: painted_bounds,
1615 corner_radii: (0.).into(),
1616 background: gpui::transparent_black().into(),
1617 border_widths: if is_vertical {
1618 Edges {
1619 top: px(0.),
1620 right: px(0.),
1621 bottom: px(0.),
1622 left: px(0.),
1623 }
1624 } else {
1625 Edges {
1626 top: px(0.),
1627 right: px(0.),
1628 bottom: px(0.),
1629 left: px(0.),
1630 }
1631 },
1632 border_color: painted_border,
1633 border_style: BorderStyle::default(),
1634 });
1635
1636 cx.paint_quad(
1637 fill(painted_thumb_bounds, painted_thumb_bg).corner_radii(radius),
1638 );
1639 });
1640
1641 window.on_mouse_event({
1642 let state = scrollbar_state.clone();
1643 let scroll_handle = self.scroll_handle.clone();
1644
1645 move |event: &ScrollWheelEvent, phase, _, cx| {
1646 if phase.bubble() && hitbox_bounds.contains(&event.position) {
1647 if scroll_handle.offset() != state.get().last_scroll_offset {
1648 state.set(state.get().with_last_scroll(
1649 scroll_handle.offset(),
1650 Some(Instant::now()),
1651 ));
1652 cx.notify(view_id);
1653 }
1654 }
1655 }
1656 });
1657
1658 window.on_mouse_event({
1661 let state = scrollbar_state.clone();
1662 let scroll_handle = self.scroll_handle.clone();
1663 let max_fps_duration = Duration::from_secs_f64(1. / self.max_fps as f64);
1664 move |event: &TouchDragEvent, phase, window, cx| {
1665 if !phase.bubble() {
1666 return;
1667 }
1668 if event.phase == TouchPhase::Started {
1669 if !is_visible
1670 || window.default_prevented()
1671 || !thumb_bounds.contains(&event.start_position)
1672 {
1673 return;
1674 }
1675 scroll_handle.start_drag();
1676 state.set(state.get().with_drag_pos(
1677 axis,
1678 event.start_position - thumb_bounds.origin,
1679 ));
1680 } else if state.get().dragged_axis != Some(axis) {
1681 return;
1682 }
1683 if event.phase != TouchPhase::Cancelled {
1684 scroll_handle.set_offset(active_geometry.drag_offset(
1685 axis,
1686 event.position,
1687 state.get().drag_pos,
1688 scroll_handle.offset(),
1689 ));
1690 }
1691 if matches!(event.phase, TouchPhase::Ended | TouchPhase::Cancelled) {
1692 scroll_handle.end_drag();
1693 state.set(state.get().with_unset_drag_pos(Instant::now()));
1694 }
1695 window.prevent_default();
1696 cx.stop_propagation();
1697 if event.phase == TouchPhase::Moved {
1698 state.notify_drag(
1699 Instant::now(),
1700 max_fps_duration,
1701 view_id,
1702 window,
1703 cx,
1704 );
1705 } else {
1706 cx.notify(view_id);
1707 }
1708 }
1709 });
1710
1711 if is_visible {
1712 window.on_mouse_event({
1713 let state = scrollbar_state.clone();
1714 let scroll_handle = self.scroll_handle.clone();
1715
1716 move |event: &MouseDownEvent, phase, _, cx| {
1717 if phase.bubble() && bounds.contains(&event.position) {
1718 cx.stop_propagation();
1719
1720 if thumb_bounds.contains(&event.position) {
1721 let pos = event.position - thumb_bounds.origin;
1723
1724 scroll_handle.start_drag();
1725 state.set(state.get().with_drag_pos(axis, pos));
1726 scroll_handle.set_offset(active_geometry.drag_offset(
1729 axis,
1730 event.position,
1731 pos,
1732 scroll_handle.offset(),
1733 ));
1734 } else {
1735 let center =
1738 point(geometry.length / 2., geometry.length / 2.);
1739 scroll_handle.set_offset(geometry.drag_offset(
1740 axis,
1741 event.position,
1742 center,
1743 scroll_handle.offset(),
1744 ));
1745 }
1746
1747 cx.notify(view_id);
1748 }
1749 }
1750 });
1751 }
1752
1753 window.on_mouse_event({
1754 let scroll_handle = self.scroll_handle.clone();
1755 let state = scrollbar_state.clone();
1756 let max_fps_duration = Duration::from_secs_f64(1. / self.max_fps as f64);
1757
1758 move |event: &MouseMoveEvent, _, window, cx| {
1759 let mut notify = false;
1760 let need_hover_to_update = is_hover_to_show || is_visible;
1763 if bounds.contains(&event.position) && need_hover_to_update {
1765 let hover_changed = state.get().hovered_axis != Some(axis);
1766 state.set(state.get().with_hovered(Some(axis), Instant::now()));
1767 notify |= hover_changed;
1768 } else if state.get().hovered_axis == Some(axis) {
1769 state.set(state.get().with_hovered(None, Instant::now()));
1770 notify = true;
1771 }
1772
1773 if tracks_thumb_hover(mode, is_visible)
1775 && thumb_bounds.contains(&event.position)
1776 {
1777 if state.get().hovered_on_thumb != Some(axis) {
1778 state.set(state.get().with_hovered_on_thumb(Some(axis)));
1779 notify = true;
1780 }
1781 } else {
1782 if state.get().hovered_on_thumb == Some(axis) {
1783 state.set(state.get().with_hovered_on_thumb(None));
1784 notify = true;
1785 }
1786 }
1787
1788 if state.get().dragged_axis == Some(axis) && event.dragging() {
1790 cx.stop_propagation();
1792
1793 let offset = active_geometry.drag_offset(
1794 axis,
1795 event.position,
1796 state.get().drag_pos,
1797 scroll_handle.offset(),
1798 );
1799 if scroll_handle.offset() != offset {
1800 scroll_handle.set_offset(offset);
1802 notify = true;
1803 }
1804 if notify {
1805 state.notify_drag(
1806 Instant::now(),
1807 max_fps_duration,
1808 view_id,
1809 window,
1810 cx,
1811 );
1812 }
1813 return;
1814 }
1815
1816 if notify {
1817 cx.notify(view_id);
1818 }
1819 }
1820 });
1821
1822 window.on_mouse_event({
1823 let state = scrollbar_state.clone();
1824 let scroll_handle = self.scroll_handle.clone();
1825
1826 move |event: &MouseUpEvent, phase, _, cx| {
1827 if phase.bubble() && state.get().dragged_axis == Some(axis) {
1828 scroll_handle.set_offset(active_geometry.drag_offset(
1829 axis,
1830 event.position,
1831 state.get().drag_pos,
1832 scroll_handle.offset(),
1833 ));
1834 scroll_handle.end_drag();
1835 state.set(state.get().with_unset_drag_pos(Instant::now()));
1836 cx.notify(view_id);
1837 }
1838 }
1839 });
1840 }
1841 },
1842 );
1843 }
1844}
1845
1846#[cfg(test)]
1847mod tests {
1848 use super::*;
1849
1850 use std::cell::{Cell, RefCell};
1851
1852 use gpui::{
1853 Context, Modifiers, MouseButton, ParentElement as _, Render, Styled as _, TestAppContext,
1854 VisualTestContext, div,
1855 };
1856
1857 #[test]
1858 fn thumb_radius_is_limited_by_its_actual_bounds() {
1859 let vertical_thumb = Bounds::new(Point::default(), size(px(8.), px(80.)));
1860 let horizontal_thumb = Bounds::new(Point::default(), size(px(80.), px(6.)));
1861
1862 assert_eq!(clamp_thumb_radius(px(6.), vertical_thumb), px(4.));
1863 assert_eq!(clamp_thumb_radius(px(6.), horizontal_thumb), px(3.));
1864 assert_eq!(
1865 clamp_thumb_radius(Pixels::ZERO, vertical_thumb),
1866 Pixels::ZERO
1867 );
1868 }
1869
1870 const ENTER: Duration = Duration::from_millis(300);
1873 const EXIT: Duration = Duration::from_millis(500);
1874 const EXPAND: Duration = Duration::from_millis(300);
1875
1876 #[test]
1877 fn visibility_animation_uses_direction_specific_curves_and_durations() {
1878 let start = Instant::now();
1879 let mut animation = VisibilityAnimation::hidden(start);
1880
1881 animation.set_visible(true, ScrollbarEntrance::SlideAndFade, ENTER, EXIT, start);
1882 assert_eq!(animation.sample(start).opacity, 0.0);
1883 let entering = animation.sample(start + ENTER / 2).position;
1884 assert!(entering > 0.5, "ease-out must advance quickly");
1885 let entered = animation.sample(start + ENTER);
1886 assert_eq!(entered.opacity, 1.0);
1887 assert_eq!(entered.position, 1.0);
1888
1889 animation.set_visible(
1890 false,
1891 ScrollbarEntrance::SlideAndFade,
1892 ENTER,
1893 EXIT,
1894 start + ENTER,
1895 );
1896 let exiting = animation.sample(start + ENTER + EXIT / 2).opacity;
1897 assert!(exiting > 0.5, "ease-in must remain visible early in exit");
1898 assert_eq!(animation.sample(start + ENTER + EXIT).opacity, 0.0);
1899 }
1900
1901 #[test]
1902 fn entrance_fades_linearly_while_position_eases_out() {
1903 let start = Instant::now();
1904 let mut animation = VisibilityAnimation::hidden(start);
1905 animation.set_visible(true, ScrollbarEntrance::SlideAndFade, ENTER, EXIT, start);
1906
1907 let halfway = animation.sample(start + ENTER / 2);
1908 assert!((halfway.opacity - 0.5).abs() < 0.001);
1909 assert!(halfway.position > halfway.opacity);
1910 }
1911
1912 #[test]
1913 fn fade_entrance_snaps_position_and_animates_opacity() {
1914 let start = Instant::now();
1915 let mut animation = VisibilityAnimation::hidden(start);
1916 animation.set_visible(true, ScrollbarEntrance::Fade, ENTER, EXIT, start);
1917
1918 let initial = animation.sample(start);
1919 assert_eq!(initial.opacity, 0.0);
1920 assert_eq!(initial.position, 1.0);
1921 let halfway = animation.sample(start + ENTER / 2);
1922 assert!((halfway.opacity - 0.5).abs() < 0.001);
1923 assert_eq!(halfway.position, 1.0);
1924 }
1925
1926 #[test]
1927 fn active_visibility_adopts_a_changed_entrance_policy() {
1928 let start = Instant::now();
1929 let halfway = start + ENTER / 2;
1930 let mut animation = VisibilityAnimation::hidden(start);
1931 animation.set_visible(true, ScrollbarEntrance::SlideAndFade, ENTER, EXIT, start);
1932 let before = animation.sample(halfway);
1933
1934 animation.set_visible(true, ScrollbarEntrance::Fade, ENTER, EXIT, halfway);
1935 let after = animation.sample(halfway);
1936
1937 assert_eq!(
1938 after.opacity, before.opacity,
1939 "policy changes must not flash"
1940 );
1941 assert_eq!(after.position, 1.0, "fade entrance must stop stale sliding");
1942 }
1943
1944 #[test]
1945 fn base_ships_no_motion_of_its_own() {
1946 let motion = ScrollbarMotion::default();
1947 assert_eq!(motion.enter(), Duration::ZERO);
1948 assert_eq!(motion.exit(), Duration::ZERO);
1949 assert_eq!(motion.expand(), Duration::ZERO);
1950 assert_eq!(motion.entrance(), ScrollbarEntrance::Fade);
1951 assert_eq!(motion.thumb_hover_entrance(), ScrollbarEntrance::Fade);
1952 assert_eq!(
1953 motion.idle(),
1954 DEFAULT_IDLE,
1955 "the visibility hold is behavior, not motion, and must stay usable"
1956 );
1957 }
1958
1959 #[test]
1960 fn hover_mode_slides_only_when_the_thumb_is_hovered() {
1961 let motion = ScrollbarMotion::default()
1962 .with_entrance(ScrollbarEntrance::Fade)
1963 .with_thumb_hover_entrance(ScrollbarEntrance::SlideAndFade);
1964
1965 assert_eq!(
1966 motion.entrance_for(ScrollbarMode::Hover, false),
1967 ScrollbarEntrance::Fade
1968 );
1969 assert_eq!(
1970 motion.entrance_for(ScrollbarMode::Hover, true),
1971 ScrollbarEntrance::SlideAndFade
1972 );
1973 assert_eq!(
1974 motion.entrance_for(ScrollbarMode::Scrolling, true),
1975 ScrollbarEntrance::Fade
1976 );
1977 }
1978
1979 #[test]
1980 fn hidden_scrolling_mode_does_not_track_thumb_hover() {
1981 assert!(!tracks_thumb_hover(ScrollbarMode::Scrolling, false));
1982 assert!(tracks_thumb_hover(ScrollbarMode::Scrolling, true));
1983 assert!(tracks_thumb_hover(ScrollbarMode::Hover, false));
1984 }
1985
1986 #[test]
1987 fn visible_scrolling_mode_stays_visible_while_hovered() {
1988 assert!(!hover_keeps_visible(ScrollbarMode::Scrolling, true, false));
1989 assert!(hover_keeps_visible(ScrollbarMode::Scrolling, true, true));
1990 assert!(hover_keeps_visible(ScrollbarMode::Hover, true, false));
1991 }
1992
1993 #[test]
1994 fn motionless_base_snaps_every_transition() {
1995 let now = Instant::now();
1996 let motion = ScrollbarMotion::default();
1997 let mut visibility = VisibilityAnimation::hidden(now);
1998
1999 visibility.set_visible(true, motion.entrance(), motion.enter(), motion.exit(), now);
2000 let shown = visibility.sample(now);
2001 assert_eq!(shown.opacity, 1.0);
2002 assert_eq!(shown.position, 1.0);
2003 assert!(!shown.running, "a motionless theme must request no frames");
2004 assert_eq!(
2005 visibility_translation(Axis::Vertical, px(16.), shown.position),
2006 Point::default()
2007 );
2008
2009 visibility.set_visible(false, motion.entrance(), motion.enter(), motion.exit(), now);
2010 let hidden = visibility.sample(now);
2011 assert_eq!(hidden.opacity, 0.0);
2012 assert_eq!(hidden.position, 0.0);
2013 assert!(!hidden.running);
2014
2015 let mut width = WidthAnimation::new(now);
2016 assert_eq!(
2017 width.set_target(px(8.), motion.expand(), now),
2018 (px(8.), false)
2019 );
2020 }
2021
2022 #[test]
2023 fn a_zero_duration_settles_a_transition_already_in_flight() {
2024 let start = Instant::now();
2025 let mut animation = VisibilityAnimation::hidden(start);
2026 animation.set_visible(true, ScrollbarEntrance::SlideAndFade, ENTER, EXIT, start);
2027
2028 let midway = start + ENTER / 2;
2030 animation.set_visible(
2031 true,
2032 ScrollbarEntrance::SlideAndFade,
2033 Duration::ZERO,
2034 Duration::ZERO,
2035 midway,
2036 );
2037 let settled = animation.sample(midway);
2038 assert_eq!(settled.opacity, 1.0);
2039 assert_eq!(settled.position, 1.0);
2040 assert!(!settled.running);
2041 }
2042
2043 #[test]
2044 fn thumb_expansion_animates_in_both_directions() {
2045 let start = Instant::now();
2046 let mut animation = WidthAnimation::new(start);
2047 assert_eq!(animation.set_target(px(6.), EXPAND, start), (px(6.), false));
2048
2049 let (initial, running) = animation.set_target(px(8.), EXPAND, start);
2050 assert_eq!(initial, px(6.));
2051 assert!(running);
2052 let (expanded_halfway, _) = animation.sample(start + EXPAND / 2);
2053 assert!(expanded_halfway > px(7.));
2054
2055 let reversal = start + EXPAND / 2;
2056 let before = animation.sample(reversal).0;
2057 let (after_reversal, running) = animation.set_target(px(6.), EXPAND, reversal);
2058 assert_eq!(after_reversal, before);
2059 assert!(running);
2060 assert_eq!(animation.sample(reversal + EXPAND).0, px(6.));
2061 }
2062
2063 #[test]
2064 fn reduced_motion_snaps_thumb_expansion() {
2065 let now = Instant::now();
2066 let mut animation = WidthAnimation::new(now);
2067 assert_eq!(
2068 animation.set_target(px(8.), Duration::ZERO, now),
2069 (px(8.), false)
2070 );
2071 }
2072
2073 #[test]
2074 fn visibility_translation_moves_toward_the_nearest_edge() {
2075 assert_eq!(
2076 visibility_translation(Axis::Vertical, px(16.), 0.0),
2077 point(px(16.), px(0.))
2078 );
2079 assert_eq!(
2080 visibility_translation(Axis::Horizontal, px(16.), 0.0),
2081 point(px(0.), px(16.))
2082 );
2083 assert_eq!(
2084 visibility_translation(Axis::Vertical, px(16.), 1.0),
2085 Point::default()
2086 );
2087 }
2088
2089 #[test]
2090 fn visibility_animation_reverses_from_current_progress() {
2091 let start = Instant::now();
2092 let mut animation = VisibilityAnimation::hidden(start);
2093 animation.set_visible(true, ScrollbarEntrance::SlideAndFade, ENTER, EXIT, start);
2094 let reversal_time = start + Duration::from_millis(60);
2095 let before = animation.sample(reversal_time);
2096
2097 animation.set_visible(
2098 false,
2099 ScrollbarEntrance::SlideAndFade,
2100 ENTER,
2101 EXIT,
2102 reversal_time,
2103 );
2104 let reversed = animation.sample(reversal_time);
2105 assert_eq!(reversed.opacity, before.opacity);
2106 assert_eq!(reversed.position, before.position);
2107 let after = animation.sample(reversal_time + Duration::from_millis(10));
2108 assert!(after.opacity < before.opacity);
2109 assert!(after.position < before.position);
2110 }
2111
2112 #[test]
2113 fn always_hover_drag_and_recent_scroll_request_visibility() {
2114 let now = Instant::now();
2115 let idle = DEFAULT_IDLE;
2116 assert!(wants_visible(
2117 ScrollbarMode::Always,
2118 false,
2119 false,
2120 None,
2121 idle,
2122 now
2123 ));
2124 assert!(wants_visible(
2125 ScrollbarMode::Hover,
2126 true,
2127 false,
2128 None,
2129 idle,
2130 now
2131 ));
2132 assert!(wants_visible(
2133 ScrollbarMode::Scrolling,
2134 false,
2135 true,
2136 None,
2137 idle,
2138 now
2139 ));
2140 assert!(wants_visible(
2141 ScrollbarMode::Scrolling,
2142 false,
2143 false,
2144 Some(now - idle + Duration::from_millis(1)),
2145 idle,
2146 now,
2147 ));
2148 assert!(!wants_visible(
2149 ScrollbarMode::Scrolling,
2150 false,
2151 false,
2152 Some(now - idle),
2153 idle,
2154 now,
2155 ));
2156
2157 let mut settled = VisibilityAnimation::hidden(now - ENTER);
2158 settled.set_visible(
2159 true,
2160 ScrollbarEntrance::SlideAndFade,
2161 ENTER,
2162 EXIT,
2163 now - ENTER,
2164 );
2165 assert!(!settled.sample(now).running, "idle hold must not animate");
2166 }
2167
2168 #[test]
2169 fn leaving_hover_starts_a_fresh_idle_hold() {
2170 let entered_at = Instant::now();
2171 let left_at = entered_at + Duration::from_secs(5);
2172 let state = ScrollbarState::default().get();
2173
2174 let hovered = state.with_hovered(Some(Axis::Vertical), entered_at);
2175 assert_eq!(hovered.last_scroll_time, Some(entered_at));
2176 let left = hovered.with_hovered(None, left_at);
2177 assert_eq!(left.last_scroll_time, Some(left_at));
2178 assert!(wants_visible(
2179 ScrollbarMode::Hover,
2180 false,
2181 false,
2182 left.last_scroll_time,
2183 DEFAULT_IDLE,
2184 left_at + DEFAULT_IDLE - Duration::from_millis(1),
2185 ));
2186 }
2187
2188 #[test]
2189 fn idle_boundary_starts_the_exit_without_a_jump() {
2190 let activity = Instant::now();
2191 let exit_start = activity + DEFAULT_IDLE;
2192 let mut animation = VisibilityAnimation::hidden(activity - ENTER);
2193 animation.set_visible(true, ScrollbarEntrance::Fade, ENTER, EXIT, activity - ENTER);
2194 assert!(!wants_visible(
2195 ScrollbarMode::Scrolling,
2196 false,
2197 false,
2198 Some(activity),
2199 DEFAULT_IDLE,
2200 exit_start,
2201 ));
2202
2203 animation.set_visible(false, ScrollbarEntrance::Fade, ENTER, EXIT, exit_start);
2204 let start = animation.sample(exit_start);
2205 assert_eq!(start.opacity, 1.0);
2206 assert_eq!(start.position, 1.0);
2207 assert_eq!(animation.sample(exit_start + EXIT).opacity, 0.0);
2208 }
2209
2210 #[derive(Clone)]
2211 struct TestHandle {
2212 offset: Rc<Cell<Point<Pixels>>>,
2213 content_size: Size<Pixels>,
2214 drag_starts: Rc<Cell<usize>>,
2215 drag_ends: Rc<Cell<usize>>,
2216 }
2217
2218 impl TestHandle {
2219 fn new(content_size: Size<Pixels>) -> Self {
2220 Self {
2221 offset: Rc::new(Cell::new(Point::default())),
2222 content_size,
2223 drag_starts: Rc::new(Cell::new(0)),
2224 drag_ends: Rc::new(Cell::new(0)),
2225 }
2226 }
2227 }
2228
2229 impl ScrollbarHandle for TestHandle {
2230 fn viewport_bounds(&self) -> Bounds<Pixels> {
2231 Bounds::new(Point::default(), size(px(100.), px(100.)))
2232 }
2233
2234 fn offset(&self) -> Point<Pixels> {
2235 self.offset.get()
2236 }
2237
2238 fn set_offset(&self, offset: Point<Pixels>) {
2239 self.offset.set(offset);
2240 }
2241
2242 fn content_size(&self) -> Size<Pixels> {
2243 self.content_size
2244 }
2245
2246 fn start_drag(&self) {
2247 self.drag_starts.set(self.drag_starts.get() + 1);
2248 }
2249
2250 fn end_drag(&self) {
2251 self.drag_ends.set(self.drag_ends.get() + 1);
2252 }
2253 }
2254
2255 struct ScrollbarHarness {
2256 handle: TestHandle,
2257 axis: ScrollbarAxis,
2258 mode: ScrollbarMode,
2259 }
2260
2261 impl Render for ScrollbarHarness {
2262 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2263 div()
2264 .relative()
2265 .size(px(100.))
2266 .child(Scrollbar::new(&self.handle).axis(self.axis).mode(self.mode))
2267 }
2268 }
2269
2270 fn harness(
2271 cx: &mut TestAppContext,
2272 axis: ScrollbarAxis,
2273 mode: ScrollbarMode,
2274 content_size: Size<Pixels>,
2275 ) -> (&mut VisualTestContext, TestHandle) {
2276 let handle = TestHandle::new(content_size);
2277 let (_, cx) = cx.add_window_view({
2278 let handle = handle.clone();
2279 move |_, _| ScrollbarHarness { handle, axis, mode }
2280 });
2281 cx.update(|window, cx| window.draw(cx).clear(cx));
2282 (cx, handle)
2283 }
2284
2285 #[test]
2286 fn explicit_viewport_bounds_override_handle_bounds() {
2287 let expected = Bounds::new(point(px(12.), px(24.)), size(px(240.), px(96.)));
2288 let scrollbar = Scrollbar::vertical(&TestHandle::new(size(px(240.), px(480.))))
2289 .viewport_bounds(expected);
2290
2291 assert_eq!(
2292 scrollbar.resolved_viewport_bounds(Bounds::default()),
2293 expected
2294 );
2295 }
2296
2297 #[test]
2298 fn layout_viewport_uses_current_element_bounds() {
2299 let expected = Bounds::new(point(px(20.), px(30.)), size(px(180.), px(12.)));
2300 let scrollbar =
2301 Scrollbar::horizontal(&TestHandle::new(size(px(600.), px(12.)))).viewport_from_layout();
2302
2303 assert_eq!(scrollbar.resolved_viewport_bounds(expected), expected);
2304 }
2305
2306 #[test]
2307 fn typed_styles_are_fluent_and_include_geometry() {
2308 let track = gpui::hsla(0.1, 0.2, 0.3, 1.0);
2309 let border = gpui::hsla(0.2, 0.3, 0.4, 1.0);
2310 let thumb = gpui::hsla(0.3, 0.4, 0.5, 1.0);
2311 let hover = gpui::hsla(0.4, 0.5, 0.6, 1.0);
2312 let active = gpui::hsla(0.5, 0.6, 0.7, 1.0);
2313 let scrollbar = Scrollbar::new(&TestHandle::new(Size::default())).styles(|styles| {
2314 styles
2315 .track(|style| {
2316 style
2317 .width(px(14.))
2318 .bg(track)
2319 .border_color(border)
2320 .when(false, |style| style.width(px(99.)))
2321 })
2322 .thumb(|style| {
2323 style
2324 .width(px(7.))
2325 .inset(px(3.))
2326 .radius(px(3.5))
2327 .min_length(px(40.))
2328 .bg(thumb)
2329 })
2330 .thumb_hover(|style| style.width(px(9.)).bg(hover))
2331 .thumb_active(|style| style.radius(px(4.5)).bg(active))
2332 });
2333
2334 assert_eq!(scrollbar.styles.track.background, Some(track));
2335 assert_eq!(scrollbar.styles.track.border, Some(border));
2336 assert_eq!(scrollbar.styles.track.width, Some(px(14.)));
2337 assert!(scrollbar.styles.thumb.background.is_some());
2338 assert_eq!(scrollbar.styles.thumb.width, Some(px(7.)));
2339 assert_eq!(scrollbar.styles.thumb.inset, Some(px(3.)));
2340 assert_eq!(scrollbar.styles.thumb.radius, Some(px(3.5)));
2341 assert_eq!(scrollbar.styles.thumb.min_length, Some(px(40.)));
2342 assert!(scrollbar.styles.thumb_hover.background.is_some());
2343 assert_eq!(scrollbar.styles.thumb_hover.width, Some(px(9.)));
2344 assert!(scrollbar.styles.thumb_active.background.is_some());
2345 assert_eq!(scrollbar.styles.thumb_active.radius, Some(px(4.5)));
2346 }
2347
2348 #[gpui::test]
2349 fn unstyled_thumb_follows_the_theme_rather_than_a_fixed_colour(cx: &mut TestAppContext) {
2350 cx.update(|cx| {
2351 let scrollbar = Scrollbar::new(&TestHandle::new(Size::default()));
2352
2353 let light = gpui::hsla(0., 0., 0.04, 1.0);
2354 crate::Theme::global_mut(cx).tokens.colors.foreground = light;
2355 let (on_light, ..) = scrollbar.style_for_normal(cx);
2356
2357 let dark = gpui::hsla(0., 0., 0.98, 1.0);
2358 crate::Theme::global_mut(cx).tokens.colors.foreground = dark;
2359 let (on_dark, ..) = scrollbar.style_for_normal(cx);
2360
2361 assert_eq!(on_light, Background::from(light.alpha(0.35)));
2362 assert_eq!(on_dark, Background::from(dark.alpha(0.35)));
2363 assert_ne!(on_light, on_dark);
2366 });
2367 }
2368
2369 #[gpui::test]
2370 fn a_styled_thumb_still_beats_the_theme_derived_default(cx: &mut TestAppContext) {
2371 cx.update(|cx| {
2372 let chosen = gpui::hsla(0.6, 0.5, 0.5, 1.0);
2373 crate::Theme::global_mut(cx).tokens.colors.foreground = gpui::hsla(0., 0., 0.98, 1.0);
2374
2375 let scrollbar = Scrollbar::new(&TestHandle::new(Size::default()))
2376 .styles(|styles| styles.thumb(|style| style.bg(chosen)));
2377 let (thumb, ..) = scrollbar.style_for_normal(cx);
2378
2379 assert_eq!(thumb, Background::from(chosen));
2380 });
2381 }
2382
2383 #[gpui::test]
2384 fn instance_styles_override_theme_scrollbar_defaults(cx: &mut TestAppContext) {
2385 cx.update(|cx| {
2386 let theme_track = gpui::hsla(0.1, 0.2, 0.3, 1.0);
2387 let theme_thumb = gpui::hsla(0.2, 0.3, 0.4, 1.0);
2388 let instance_thumb = gpui::hsla(0.3, 0.4, 0.5, 1.0);
2389
2390 crate::Theme::global_mut(cx).scrollbar = crate::ScrollbarTheme::new()
2391 .with_mode(ScrollbarMode::Always)
2392 .with_motion(ScrollbarMotion::default())
2393 .with_styles(
2394 ScrollbarStyles::default()
2395 .track(|style| style.width(px(13.)).bg(theme_track))
2396 .thumb(|style| style.width(px(7.)).bg(theme_thumb)),
2397 );
2398
2399 let scrollbar = Scrollbar::new(&TestHandle::new(Size::default()))
2400 .styles(|styles| styles.thumb(|style| style.bg(instance_thumb)));
2401 let (thumb, track, _, width, _, _, _) = scrollbar.style_for_normal(cx);
2402
2403 assert_eq!(thumb, Background::from(instance_thumb));
2404 assert_eq!(track, theme_track);
2405 assert_eq!(width, px(7.));
2406 assert_eq!(cx.theme().scrollbar.styles().track.width, Some(px(13.)));
2407 });
2408 }
2409
2410 #[gpui::test]
2411 fn auto_hide_modes_use_a_six_pixel_resting_thumb(cx: &mut TestAppContext) {
2412 cx.update(|cx| {
2413 let handle = TestHandle::new(Size::default());
2414 let scrolling = Scrollbar::new(&handle).mode(ScrollbarMode::Scrolling);
2415 let always = Scrollbar::new(&handle).mode(ScrollbarMode::Always);
2416
2417 assert_eq!(scrolling.style_for_normal(cx).3, px(6.));
2418 assert_eq!(always.style_for_normal(cx).3, px(6.));
2419 });
2420 }
2421
2422 #[gpui::test]
2423 fn every_mode_expands_only_for_thumb_hover(cx: &mut TestAppContext) {
2424 cx.update(|cx| {
2425 let handle = TestHandle::new(Size::default());
2426 for mode in [
2427 ScrollbarMode::Scrolling,
2428 ScrollbarMode::Hover,
2429 ScrollbarMode::Always,
2430 ] {
2431 let scrollbar = Scrollbar::new(&handle).mode(mode);
2432 assert_eq!(scrollbar.style_for_normal(cx).3, px(6.));
2433 assert_eq!(scrollbar.style_for_hovered_bar(cx).3, px(6.));
2434 assert_eq!(scrollbar.style_for_hovered_thumb(cx).3, px(8.));
2435 }
2436 });
2437 }
2438
2439 #[gpui::test]
2440 fn vertical_track_click_updates_vertical_offset(cx: &mut TestAppContext) {
2441 let (cx, vertical) = harness(
2442 cx,
2443 ScrollbarAxis::Vertical,
2444 ScrollbarMode::Always,
2445 size(px(100.), px(500.)),
2446 );
2447 cx.simulate_click(point(px(95.), px(80.)), Modifiers::default());
2448 assert!(vertical.offset().y < px(0.));
2449 assert_eq!(vertical.offset().x, px(0.));
2450 }
2451
2452 #[gpui::test]
2453 fn horizontal_track_click_updates_horizontal_offset(cx: &mut TestAppContext) {
2454 let (cx, horizontal) = harness(
2455 cx,
2456 ScrollbarAxis::Horizontal,
2457 ScrollbarMode::Always,
2458 size(px(500.), px(100.)),
2459 );
2460 cx.simulate_click(point(px(80.), px(95.)), Modifiers::default());
2461 assert!(horizontal.offset().x < px(0.));
2462 assert_eq!(horizontal.offset().y, px(0.));
2463 }
2464
2465 #[gpui::test]
2466 fn no_overflow_has_no_interactive_track(cx: &mut TestAppContext) {
2467 let (cx, handle) = harness(
2468 cx,
2469 ScrollbarAxis::Both,
2470 ScrollbarMode::Always,
2471 size(px(100.), px(100.)),
2472 );
2473 cx.simulate_click(point(px(95.), px(80.)), Modifiers::default());
2474 assert_eq!(handle.offset(), Point::default());
2475 }
2476
2477 #[gpui::test]
2478 fn hidden_hover_scrollbar_ignores_track_click(cx: &mut TestAppContext) {
2479 let (cx, handle) = harness(
2480 cx,
2481 ScrollbarAxis::Vertical,
2482 ScrollbarMode::Hover,
2483 size(px(100.), px(500.)),
2484 );
2485 cx.simulate_click(point(px(95.), px(80.)), Modifiers::default());
2486 assert_eq!(handle.offset(), Point::default());
2487 }
2488
2489 #[gpui::test]
2490 fn hidden_hover_scrollbar_ignores_thumb_drag(cx: &mut TestAppContext) {
2491 let (cx, handle) = harness(
2492 cx,
2493 ScrollbarAxis::Vertical,
2494 ScrollbarMode::Hover,
2495 size(px(100.), px(500.)),
2496 );
2497 cx.simulate_mouse_down(
2498 point(px(95.), px(20.)),
2499 MouseButton::Left,
2500 Modifiers::default(),
2501 );
2502 cx.simulate_mouse_move(
2503 point(px(95.), px(70.)),
2504 Some(MouseButton::Left),
2505 Modifiers::default(),
2506 );
2507 cx.simulate_mouse_up(
2508 point(px(95.), px(70.)),
2509 MouseButton::Left,
2510 Modifiers::default(),
2511 );
2512
2513 assert_eq!(handle.drag_starts.get(), 0);
2514 assert_eq!(handle.drag_ends.get(), 0);
2515 assert_eq!(handle.offset(), Point::default());
2516 }
2517
2518 #[gpui::test]
2519 fn hovering_reveals_scrollbar_for_track_interaction(cx: &mut TestAppContext) {
2520 let (cx, handle) = harness(
2521 cx,
2522 ScrollbarAxis::Vertical,
2523 ScrollbarMode::Hover,
2524 size(px(100.), px(500.)),
2525 );
2526 cx.simulate_mouse_move(point(px(95.), px(50.)), None, Modifiers::default());
2528 cx.run_until_parked();
2529
2530 cx.simulate_click(point(px(95.), px(80.)), Modifiers::default());
2531 assert!(handle.offset().y < px(0.));
2532 }
2533
2534 #[gpui::test]
2535 fn drag_notifications_are_throttled_and_deliver_the_latest_offset(cx: &mut TestAppContext) {
2536 let handle = TestHandle::new(size(px(100.), px(1000.)));
2537 let (view, cx) = cx.add_window_view({
2538 let handle = handle.clone();
2539 move |_, _| ScrollbarHarness {
2540 handle,
2541 axis: ScrollbarAxis::Vertical,
2542 mode: ScrollbarMode::Always,
2543 }
2544 });
2545 cx.update(|window, cx| window.draw(cx).clear(cx));
2546 let observed = Rc::new(RefCell::new(Vec::new()));
2547 let _subscription = cx.update(|_, cx| {
2548 let observed = observed.clone();
2549 let handle = handle.clone();
2550 cx.observe(&view, move |_, _| {
2551 observed.borrow_mut().push(handle.offset().y)
2552 })
2553 });
2554 let state = ScrollbarState::default();
2555 let now = Instant::now();
2556 let interval = Duration::from_secs_f64(1. / 30.);
2557 let mut inner = state.get().with_drag_pos(Axis::Vertical, Point::default());
2558 inner.last_update = now;
2559 state.set(inner);
2560 let update = |offset, elapsed, cx: &mut VisualTestContext| {
2561 handle.set_offset(point(px(0.), px(offset)));
2562 cx.update(|window, cx| {
2563 state.notify_drag(now + elapsed, interval, view.entity_id(), window, cx)
2564 });
2565 cx.run_until_parked();
2566 };
2567 update(-10., Duration::ZERO, cx);
2568 cx.executor().advance_clock(Duration::from_millis(16));
2569 update(-20., Duration::from_millis(16), cx);
2570 update(-30., Duration::from_millis(16), cx);
2571 cx.update(|window, cx| {
2573 window.simulate_next_frame(cx);
2574 });
2575 cx.run_until_parked();
2576 assert!(observed.borrow().is_empty());
2577 cx.executor().advance_clock(Duration::from_millis(16));
2578 cx.run_until_parked();
2579 assert!(observed.borrow().is_empty());
2580 cx.executor().advance_clock(Duration::from_millis(2));
2581 cx.run_until_parked();
2582 assert_eq!(
2583 *observed.borrow(),
2584 vec![px(-30.)],
2585 "one trailing notification must see the newest offset even when movement stops"
2586 );
2587
2588 let next = state.get().last_update;
2590 handle.set_offset(point(px(0.), px(-40.)));
2591 cx.update(|window, cx| state.notify_drag(next, interval, view.entity_id(), window, cx));
2592 cx.run_until_parked();
2593 state.set(state.get().with_unset_drag_pos(Instant::now()));
2594 handle.set_offset(point(px(0.), px(-50.)));
2595 cx.update(|_, cx| cx.notify(view.entity_id()));
2596 assert_eq!(*observed.borrow(), vec![px(-30.), px(-50.)]);
2597 cx.executor().advance_clock(interval);
2598 cx.run_until_parked();
2599 assert_eq!(
2600 *observed.borrow(),
2601 vec![px(-30.), px(-50.)],
2602 "release must not leave a stale trailing notification"
2603 );
2604 }
2605
2606 #[test]
2607 fn full_track_thumb_does_not_reset_the_scroll_offset() {
2608 let geometry = ThumbGeometry::new(px(20.), px(40.), px(500.), px(0.), px(4.), px(64.));
2609 let offset = point(px(0.), px(-100.));
2610 assert_eq!(
2611 geometry.drag_offset(
2612 Axis::Vertical,
2613 point(px(0.), px(50.)),
2614 point(px(0.), px(8.)),
2615 offset
2616 ),
2617 offset
2618 );
2619 }
2620
2621 #[gpui::test]
2622 fn repeated_touch_and_mouse_drags_keep_the_painted_grab_point(cx: &mut TestAppContext) {
2623 struct Probe {
2624 scrollbar: Scrollbar,
2625 painted: Rc<Cell<Bounds<Pixels>>>,
2626 }
2627 impl IntoElement for Probe {
2628 type Element = Self;
2629 fn into_element(self) -> Self {
2630 self
2631 }
2632 }
2633 impl Element for Probe {
2634 type RequestLayoutState = ();
2635 type PrepaintState = PrepaintState;
2636 fn id(&self) -> Option<ElementId> {
2637 Element::id(&self.scrollbar)
2638 }
2639 fn source_location(&self) -> Option<&'static Location<'static>> {
2640 None
2641 }
2642 fn request_layout(
2643 &mut self,
2644 id: Option<&GlobalElementId>,
2645 inspector: Option<&InspectorElementId>,
2646 window: &mut Window,
2647 cx: &mut App,
2648 ) -> (LayoutId, ()) {
2649 self.scrollbar.request_layout(id, inspector, window, cx)
2650 }
2651 fn prepaint(
2652 &mut self,
2653 id: Option<&GlobalElementId>,
2654 inspector: Option<&InspectorElementId>,
2655 bounds: Bounds<Pixels>,
2656 layout: &mut (),
2657 window: &mut Window,
2658 cx: &mut App,
2659 ) -> PrepaintState {
2660 let state = self
2661 .scrollbar
2662 .prepaint(id, inspector, bounds, layout, window, cx);
2663 self.painted.set(state.states[0].thumb_fill_bounds);
2664 state
2665 }
2666 fn paint(
2667 &mut self,
2668 id: Option<&GlobalElementId>,
2669 inspector: Option<&InspectorElementId>,
2670 bounds: Bounds<Pixels>,
2671 layout: &mut (),
2672 state: &mut PrepaintState,
2673 window: &mut Window,
2674 cx: &mut App,
2675 ) {
2676 self.scrollbar
2677 .paint(id, inspector, bounds, layout, state, window, cx);
2678 }
2679 }
2680 struct Root {
2681 handle: TestHandle,
2682 painted: Rc<Cell<Bounds<Pixels>>>,
2683 axis: ScrollbarAxis,
2684 viewport: Bounds<Pixels>,
2685 }
2686 impl Render for Root {
2687 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2688 div().size(px(400.)).child(Probe {
2689 scrollbar: Scrollbar::new(&self.handle)
2690 .axis(self.axis)
2691 .mode(ScrollbarMode::Always)
2692 .viewport_bounds(self.viewport)
2693 .styles(|style| {
2694 style
2695 .thumb(|style| style.inset(px(6.)).min_length(px(64.)))
2696 .thumb_active(|style| style.inset(px(3.)).min_length(px(72.)))
2697 }),
2698 painted: self.painted.clone(),
2699 })
2700 }
2701 }
2702 for touch in [true, false] {
2703 for vertical in [true, false] {
2704 for (origin, viewport_size, content_size) in [
2705 (Point::default(), 200., 1000.),
2706 (point(px(30.), px(40.)), 240., 500.),
2707 ] {
2708 let handle = TestHandle::new(size(px(content_size), px(content_size)));
2709 handle.set_offset(point(px(-100.), px(-100.)));
2710 let painted = Rc::new(Cell::new(Bounds::default()));
2711 let (_, cx) = cx.add_window_view({
2712 let handle = handle.clone();
2713 let painted = painted.clone();
2714 move |_, _| Root {
2715 handle,
2716 painted,
2717 axis: if vertical {
2718 ScrollbarAxis::Vertical
2719 } else {
2720 ScrollbarAxis::Both
2721 },
2722 viewport: Bounds::new(
2723 origin,
2724 size(px(viewport_size), px(viewport_size)),
2725 ),
2726 }
2727 });
2728 cx.update(|window, cx| window.draw(cx).clear(cx));
2729 let initial = painted.get();
2730 let start = initial.center();
2731 let grab = if vertical {
2732 start.y - initial.origin.y
2733 } else {
2734 start.x - initial.origin.x
2735 };
2736 let axis_delta = |value| {
2737 if vertical {
2738 point(px(0.), px(value))
2739 } else {
2740 point(px(value), px(0.))
2741 }
2742 };
2743 let axis_value =
2744 |point: Point<Pixels>| if vertical { point.y } else { point.x };
2745 let started_position = if touch { start + axis_delta(5.) } else { start };
2746 if touch {
2747 cx.simulate_event(TouchDragEvent {
2748 phase: TouchPhase::Started,
2749 start_position: start,
2750 position: started_position,
2751 });
2752 } else {
2753 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
2754 }
2755 cx.update(|window, cx| window.draw(cx).clear(cx));
2756 assert!(
2757 (axis_value(painted.get().origin) + grab - axis_value(started_position))
2758 .abs()
2759 < px(0.1),
2760 "starting must preserve the grab point and first displacement"
2761 );
2762 let mut reference_offset = px(0.);
2763 let mut offset_per_pixel = 0.;
2764 for delta in [0., 24., -8., 32., -10., 8., 0.] {
2765 let position = start + axis_delta(delta);
2766 if touch {
2767 cx.simulate_event(TouchDragEvent {
2768 phase: TouchPhase::Moved,
2769 start_position: start,
2770 position,
2771 });
2772 } else {
2773 cx.simulate_mouse_move(
2774 position,
2775 Some(MouseButton::Left),
2776 Modifiers::default(),
2777 );
2778 }
2779 cx.update(|window, cx| window.draw(cx).clear(cx));
2780 if delta == 0. {
2781 reference_offset = axis_value(handle.offset());
2782 }
2783 if delta == 24. {
2784 offset_per_pixel =
2785 (axis_value(handle.offset()) - reference_offset) / px(24.);
2786 }
2787 let actual = if vertical {
2788 painted.get().origin.y + grab
2789 } else {
2790 painted.get().origin.x + grab
2791 };
2792 let expected = if vertical { position.y } else { position.x };
2793 assert!(
2794 (actual - expected).abs() < px(0.1),
2795 "touch={touch} vertical={vertical} delta={delta}: painted grab {actual:?}, pointer {expected:?}"
2796 );
2797 }
2798 let expected_offset = reference_offset + px(12.) * offset_per_pixel;
2800 if touch {
2801 cx.simulate_event(TouchDragEvent {
2802 phase: TouchPhase::Ended,
2803 start_position: start,
2804 position: start + axis_delta(12.),
2805 });
2806 } else {
2807 cx.simulate_mouse_up(
2808 start + axis_delta(12.),
2809 MouseButton::Left,
2810 Modifiers::default(),
2811 );
2812 }
2813 assert!(
2814 (axis_value(handle.offset()) - expected_offset).abs() < px(0.1),
2815 "release must flush the last displacement"
2816 );
2817 let released_offset = handle.offset();
2818 cx.update(|window, cx| window.draw(cx).clear(cx));
2819 assert_eq!(
2820 handle.offset(),
2821 released_offset,
2822 "leaving active styling must not scroll"
2823 );
2824 }
2825 }
2826 }
2827 }
2828
2829 #[gpui::test]
2830 fn touch_thumb_drag_moves_down_and_cancel_releases_handle(cx: &mut TestAppContext) {
2831 let (cx, handle) = harness(
2832 cx,
2833 ScrollbarAxis::Vertical,
2834 ScrollbarMode::Always,
2835 size(px(100.), px(500.)),
2836 );
2837 let start_position = point(px(95.), px(20.));
2838 for (phase, position) in [
2839 (TouchPhase::Started, start_position),
2840 (TouchPhase::Moved, point(px(95.), px(45.))),
2841 (TouchPhase::Cancelled, point(px(95.), px(45.))),
2842 ] {
2843 cx.simulate_event(TouchDragEvent {
2844 phase,
2845 start_position,
2846 position,
2847 });
2848 }
2849 assert!(
2850 handle.offset().y < px(0.),
2851 "thumb down must scroll toward later content"
2852 );
2853 assert_eq!(handle.offset().x, px(0.));
2854 assert_eq!(handle.drag_starts.get(), 1);
2855 assert_eq!(handle.drag_ends.get(), 1);
2856 }
2857
2858 #[gpui::test]
2859 fn thumb_drag_notifies_handle_start_and_end(cx: &mut TestAppContext) {
2860 let (cx, handle) = harness(
2861 cx,
2862 ScrollbarAxis::Vertical,
2863 ScrollbarMode::Always,
2864 size(px(100.), px(500.)),
2865 );
2866 cx.simulate_mouse_down(
2867 point(px(95.), px(20.)),
2868 MouseButton::Left,
2869 Modifiers::default(),
2870 );
2871 cx.simulate_mouse_move(
2872 point(px(95.), px(70.)),
2873 Some(MouseButton::Left),
2874 Modifiers::default(),
2875 );
2876 cx.simulate_mouse_up(
2877 point(px(95.), px(70.)),
2878 MouseButton::Left,
2879 Modifiers::default(),
2880 );
2881
2882 assert_eq!(handle.drag_starts.get(), 1);
2883 assert_eq!(handle.drag_ends.get(), 1);
2884 }
2885}