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