1use std::cell::{Cell, RefCell};
2use std::rc::Rc;
3
4use instant::Instant;
5
6use gpui::{
7 App, Axis, BorderStyle, Bounds, ContentMask, Edges, Element, ElementId, GlobalElementId,
8 Hitbox, Hsla, InteractiveElement as _, IntoElement, IsZero as _, LayoutId, OngoingScroll,
9 PaintQuad, ParentElement as _, Point, Position, ScrollDelta, ScrollEvent, ScrollHandle,
10 StatefulInteractiveElement as _, Style, StyleRefinement, Styled as _, Window, div, px,
11 relative,
12};
13use gpui::{Corners, Pixels};
14
15use crate::{AxisExt, OngoingScrollExt as _, ScrollbarHandle, StyledExt as _};
16
17thread_local! {
18 static LAST_HORIZONTAL_SCROLL_CLAIM: Cell<Option<HorizontalScrollClaim>> =
19 const { Cell::new(None) };
20}
21
22const CLAIM_EPSILON: Pixels = px(0.5);
25
26#[derive(Clone, Copy, Debug, PartialEq)]
28pub struct HorizontalScrollClaim {
29 at: Instant,
31 delta_x: Pixels,
34}
35
36impl HorizontalScrollClaim {
37 pub fn at(&self) -> Instant {
39 self.at
40 }
41
42 pub fn delta_x(&self) -> Pixels {
44 self.delta_x
45 }
46}
47
48pub fn last_horizontal_scroll_claim() -> Option<HorizontalScrollClaim> {
58 LAST_HORIZONTAL_SCROLL_CLAIM.get()
59}
60
61#[inline]
64#[track_caller]
65fn caller_id() -> ElementId {
66 ElementId::CodeLocation(*std::panic::Location::caller())
67}
68
69pub(crate) fn horizontal_scroll_area(
76 id: impl Into<ElementId>,
77 scroll_handle: &ScrollHandle,
78 style: &StyleRefinement,
79 child: impl IntoElement,
80) -> impl IntoElement {
81 let id = id.into();
82
83 let frame = RoundedFrameCover {
91 corner_radii: Corners {
92 top_left: style.corner_radii.top_left,
93 top_right: style.corner_radii.top_right,
94 bottom_left: style.corner_radii.bottom_left,
95 bottom_right: style.corner_radii.bottom_right,
96 },
97 backdrop: None,
98 frame_border: px(0.),
99 };
100
101 div()
102 .w_full()
103 .relative()
104 .child(
105 div()
106 .id(id.clone())
107 .w_full()
108 .refine_style(style)
109 .overflow_hidden()
110 .track_scroll(scroll_handle)
111 .child(child),
112 )
113 .child(ScrollableMask::new(Axis::Horizontal, scroll_handle).id(id))
114 .child(frame)
115}
116
117pub struct RoundedFrameCover {
132 corner_radii: Corners<Option<gpui::AbsoluteLength>>,
133 backdrop: Option<Hsla>,
134 frame_border: Pixels,
135}
136
137impl RoundedFrameCover {
138 pub fn uniform(radius: Pixels) -> Self {
140 let radius = Some(radius.into());
141 Self {
142 corner_radii: Corners {
143 top_left: radius,
144 top_right: radius,
145 bottom_left: radius,
146 bottom_right: radius,
147 },
148 backdrop: None,
149 frame_border: px(0.),
150 }
151 }
152
153 pub fn frame_border(mut self, width: Pixels) -> Self {
160 self.frame_border = width;
161 self
162 }
163
164 pub fn backdrop(mut self, color: Hsla) -> Self {
167 self.backdrop = Some(color);
168 self
169 }
170}
171
172impl IntoElement for RoundedFrameCover {
173 type Element = Self;
174
175 fn into_element(self) -> Self::Element {
176 self
177 }
178}
179
180impl Element for RoundedFrameCover {
181 type RequestLayoutState = ();
182 type PrepaintState = Bounds<Pixels>;
183
184 fn id(&self) -> Option<ElementId> {
185 None
186 }
187
188 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
189 None
190 }
191
192 fn request_layout(
193 &mut self,
194 _: Option<&GlobalElementId>,
195 _: Option<&gpui::InspectorElementId>,
196 window: &mut Window,
197 cx: &mut App,
198 ) -> (LayoutId, Self::RequestLayoutState) {
199 let mut style = Style::default();
201 style.position = Position::Absolute;
202 style.flex_grow = 1.0;
203 style.flex_shrink = 1.0;
204 style.size.width = relative(1.).into();
205 style.size.height = relative(1.).into();
206
207 (window.request_layout(style, None, cx), ())
208 }
209
210 fn prepaint(
211 &mut self,
212 _: Option<&GlobalElementId>,
213 _: Option<&gpui::InspectorElementId>,
214 bounds: Bounds<Pixels>,
215 _: &mut Self::RequestLayoutState,
216 _: &mut Window,
217 _: &mut App,
218 ) -> Self::PrepaintState {
219 Bounds {
221 origin: Point {
222 x: bounds.origin.x,
223 y: bounds.origin.y - bounds.size.height,
224 },
225 size: bounds.size,
226 }
227 }
228
229 fn paint(
230 &mut self,
231 _: Option<&GlobalElementId>,
232 _: Option<&gpui::InspectorElementId>,
233 _: Bounds<Pixels>,
234 _: &mut Self::RequestLayoutState,
235 cover_bounds: &mut Self::PrepaintState,
236 window: &mut Window,
237 cx: &mut App,
238 ) {
239 use crate::theme::ActiveTheme as _;
240
241 let mut bounds = *cover_bounds;
242 if !self.frame_border.is_zero() {
243 bounds.origin.x -= self.frame_border;
244 bounds.origin.y -= self.frame_border;
245 bounds.size.width += self.frame_border * 2.;
246 bounds.size.height += self.frame_border * 2.;
247 }
248 let rem_size = window.rem_size();
249 let resolve = |corner: Option<gpui::AbsoluteLength>| {
250 corner
251 .map(|radius| radius.to_pixels(rem_size))
252 .filter(|radius| !radius.is_zero())
253 };
254 let radii = Corners {
255 top_left: resolve(self.corner_radii.top_left),
256 top_right: resolve(self.corner_radii.top_right),
257 bottom_left: resolve(self.corner_radii.bottom_left),
258 bottom_right: resolve(self.corner_radii.bottom_right),
259 };
260 if radii.top_left.is_none()
261 && radii.top_right.is_none()
262 && radii.bottom_left.is_none()
263 && radii.bottom_right.is_none()
264 {
265 return;
266 }
267
268 let backdrop = self.backdrop.unwrap_or(cx.theme().tokens.colors.background);
269 let corners = [
270 (bounds.origin, 1., 1., radii.top_left),
271 (bounds.top_right(), -1., 1., radii.top_right),
272 (bounds.bottom_right(), -1., -1., radii.bottom_right),
273 (
274 Point {
275 x: bounds.origin.x,
276 y: bounds.bottom_right().y,
277 },
278 1.,
279 -1.,
280 radii.bottom_left,
281 ),
282 ];
283 for (corner, x_dir, y_dir, radius) in corners {
284 let Some(radius) = radius else { continue };
285 if let Some(path) = corner_notch_path(corner, x_dir, y_dir, radius) {
286 window.paint_path(path, backdrop);
287 }
288 }
289 }
290}
291
292fn corner_notch_path(
296 corner: Point<Pixels>,
297 x_dir: f32,
298 y_dir: f32,
299 radius: Pixels,
300) -> Option<gpui::Path<Pixels>> {
301 let mut builder = gpui::PathBuilder::fill();
302 builder.move_to(corner);
303 builder.line_to(Point {
304 x: corner.x + radius * x_dir,
305 y: corner.y,
306 });
307 builder.arc_to(
308 Point {
309 x: radius,
310 y: radius,
311 },
312 px(0.),
313 false,
314 x_dir * y_dir < 0.,
317 Point {
318 x: corner.x,
319 y: corner.y + radius * y_dir,
320 },
321 );
322 builder.close();
323 builder.build().ok()
324}
325
326pub struct ScrollableMask<H: ScrollbarHandle + Clone = ScrollHandle> {
351 axis: Axis,
352 id: ElementId,
353 scroll_handle: H,
354 vertical_wheel: bool,
357 debug: Option<Hsla>,
358}
359
360impl<H: ScrollbarHandle + Clone> ScrollableMask<H> {
361 #[track_caller]
363 pub fn new(axis: Axis, scroll_handle: &H) -> Self {
364 Self {
365 scroll_handle: scroll_handle.clone(),
366 axis,
367 id: caller_id(),
368 vertical_wheel: false,
369 debug: None,
370 }
371 }
372
373 #[track_caller]
383 pub fn vertical_wheel(scroll_handle: &H) -> Self {
384 Self {
385 vertical_wheel: true,
386 ..Self::new(Axis::Horizontal, scroll_handle)
387 }
388 }
389
390 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
395 self.id = id.into();
396 self
397 }
398
399 #[allow(dead_code)]
401 pub fn debug(mut self) -> Self {
402 self.debug = Some(gpui::yellow());
403 self
404 }
405}
406
407impl<H: ScrollbarHandle + Clone> IntoElement for ScrollableMask<H> {
408 type Element = Self;
409
410 fn into_element(self) -> Self::Element {
411 self
412 }
413}
414
415impl<H: ScrollbarHandle + Clone> Element for ScrollableMask<H> {
416 type RequestLayoutState = ();
417 type PrepaintState = Hitbox;
418
419 fn id(&self) -> Option<ElementId> {
422 let axis = match self.axis {
423 Axis::Horizontal => "horizontal",
424 Axis::Vertical => "vertical",
425 };
426
427 Some((self.id.clone(), axis).into())
428 }
429
430 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
431 None
432 }
433
434 fn request_layout(
435 &mut self,
436 _: Option<&GlobalElementId>,
437 _: Option<&gpui::InspectorElementId>,
438 window: &mut Window,
439 cx: &mut App,
440 ) -> (LayoutId, Self::RequestLayoutState) {
441 let mut style = Style::default();
442 style.position = Position::Absolute;
446 style.inset.top = px(0.).into();
447 style.inset.left = px(0.).into();
448 style.flex_grow = 1.0;
449 style.flex_shrink = 1.0;
450 style.size.width = relative(1.).into();
451 style.size.height = relative(1.).into();
452
453 (window.request_layout(style, None, cx), ())
454 }
455
456 fn prepaint(
457 &mut self,
458 _: Option<&GlobalElementId>,
459 _: Option<&gpui::InspectorElementId>,
460 bounds: Bounds<Pixels>,
461 _: &mut Self::RequestLayoutState,
462 window: &mut Window,
463 _: &mut App,
464 ) -> Self::PrepaintState {
465 window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal)
466 }
467
468 fn paint(
469 &mut self,
470 global_id: Option<&GlobalElementId>,
471 _: Option<&gpui::InspectorElementId>,
472 _: Bounds<Pixels>,
473 _: &mut Self::RequestLayoutState,
474 hitbox: &mut Self::PrepaintState,
475 window: &mut Window,
476 _: &mut App,
477 ) {
478 let is_horizontal = self.axis.is_horizontal();
479 let vertical_wheel = self.vertical_wheel;
480 let line_height = window.line_height();
481 let bounds = hitbox.bounds;
482 let ongoing_scroll = global_id
483 .map(|global_id| {
484 window.with_element_state::<Rc<RefCell<OngoingScroll>>, _>(global_id, |state, _| {
485 let state = state.unwrap_or_default();
486 (state.clone(), state)
487 })
488 })
489 .unwrap_or_default();
490
491 window.with_content_mask(Some(ContentMask { bounds }), |window| {
492 if let Some(color) = self.debug {
493 window.paint_quad(PaintQuad {
494 bounds,
495 border_widths: Edges::all(px(1.0)),
496 border_color: color,
497 background: gpui::transparent_white().into(),
498 corner_radii: Corners::all(px(0.)),
499 border_style: BorderStyle::default(),
500 });
501 }
502
503 window.on_mouse_event({
504 let view_id = window.current_view();
505 let scroll_handle = self.scroll_handle.clone();
506 let hitbox_id = hitbox.id;
507 let ongoing_scroll = ongoing_scroll.clone();
508
509 move |event: &ScrollEvent, phase, window, cx| {
510 if !(phase.capture() && hitbox_id.should_handle_scroll(window)) {
521 return;
522 }
523
524 let mut offset = scroll_handle.offset();
525 let mut delta = event.delta.pixel_delta(line_height);
526
527 if vertical_wheel {
531 let ScrollDelta::Lines(lines) = event.delta else {
532 return;
533 };
534 if lines.x != 0. || lines.y == 0. || !lines.y.is_finite() {
535 return;
536 }
537 let axis_max = (scroll_handle.content_size().width
538 - scroll_handle.viewport_bounds().size.width)
539 .max(px(0.));
540 let current = offset.x.clamp(-axis_max, px(0.));
541 let new_offset = (current + delta.y).clamp(-axis_max, px(0.));
542 if new_offset == current {
543 return;
544 }
545
546 offset.x = new_offset;
547 scroll_handle.set_offset(offset);
548 cx.notify(view_id);
549 cx.stop_propagation();
550 return;
551 }
552
553 if is_horizontal
556 && !delta.y.is_zero()
557 && scroll_handle.content_size().width
558 <= scroll_handle.viewport_bounds().size.width
559 {
560 return;
561 }
562
563 if event.delta.precise() {
567 ongoing_scroll
568 .borrow_mut()
569 .lock_axis(&mut delta, event.touch_phase);
570 }
571
572 if !delta.x.is_zero() && !delta.y.is_zero() {
576 if delta.x.abs() > delta.y.abs() {
577 delta.y = px(0.);
578 } else {
579 delta.x = px(0.);
580 }
581 }
582
583 if !is_horizontal {
584 let axis_max = (scroll_handle.content_size().height
592 - scroll_handle.viewport_bounds().size.height)
593 .max(px(0.));
594 let current = offset.y.clamp(-axis_max, px(0.));
595 let new_offset = (current + delta.y).clamp(-axis_max, px(0.));
596 if new_offset == current {
597 return;
599 }
600
601 offset.y = new_offset;
602 scroll_handle.set_offset(offset);
603 cx.notify(view_id);
604 cx.stop_propagation();
605 return;
606 }
607
608 if !delta.x.is_zero() {
613 let axis_max = (scroll_handle.content_size().width
614 - scroll_handle.viewport_bounds().size.width)
615 .max(px(0.));
616 let current = offset.x.clamp(-axis_max, px(0.));
617 let moved = (offset.x + delta.x).clamp(-axis_max, px(0.)) - current;
618 if moved.abs() >= CLAIM_EPSILON {
619 LAST_HORIZONTAL_SCROLL_CLAIM.set(Some(HorizontalScrollClaim {
620 at: Instant::now(),
621 delta_x: moved,
622 }));
623 }
624 }
625
626 offset.x += delta.x;
627
628 if offset != scroll_handle.offset() {
634 scroll_handle.set_offset(offset);
635 cx.notify(view_id);
636 cx.stop_propagation();
637 }
638 }
639 });
640 });
641 }
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647 use gpui::{
648 Context, IntoElement, ListAlignment, ListState, Render, ScrollDelta, ScrollEvent,
649 TestAppContext, VisualTestContext, Window, div, list, point, px,
650 };
651
652 #[test]
653 fn corner_notch_paths_stay_inside_their_corner_squares() {
654 let width = px(200.);
655 let height = px(100.);
656 let radius = px(8.);
657 let cases = [
658 ("top left", point(px(0.), px(0.)), 1., 1.),
659 ("top right", point(width, px(0.)), -1., 1.),
660 ("bottom right", point(width, height), -1., -1.),
661 ("bottom left", point(px(0.), height), 1., -1.),
662 ];
663 for (name, corner, x_dir, y_dir) in cases {
664 let path =
665 corner_notch_path(corner, x_dir, y_dir, radius).expect("the notch tessellates");
666 let bounds = path.bounds;
667 let tolerance = 1.0;
669 assert!(
670 (f32::from(bounds.size.width) - f32::from(radius)).abs() <= tolerance
671 && (f32::from(bounds.size.height) - f32::from(radius)).abs() <= tolerance,
672 "{name}: notch size {:?} must stay a {radius:?} square",
673 bounds.size,
674 );
675 let expected_origin = point(
676 if x_dir > 0. {
677 corner.x
678 } else {
679 corner.x - radius
680 },
681 if y_dir > 0. {
682 corner.y
683 } else {
684 corner.y - radius
685 },
686 );
687 assert!(
688 (f32::from(bounds.origin.x) - f32::from(expected_origin.x)).abs() <= tolerance
689 && (f32::from(bounds.origin.y) - f32::from(expected_origin.y)).abs()
690 <= tolerance,
691 "{name}: notch bounds {bounds:?} must anchor at the frame corner",
692 );
693 }
694 }
695
696 struct HorizontalScrollAreaTest {
697 scroll_handle: ScrollHandle,
698 }
699
700 impl Render for HorizontalScrollAreaTest {
701 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
702 div().w(px(100.)).h(px(40.)).child(horizontal_scroll_area(
703 "horizontal-scroll-area",
704 &self.scroll_handle,
705 &Default::default(),
706 div().w(px(300.)).h(px(40.)),
707 ))
708 }
709 }
710
711 #[gpui::test]
712 fn horizontal_scroll_area_ignores_vertical_wheel(cx: &mut TestAppContext) {
713 let scroll_handle = ScrollHandle::new();
714 let (_, cx) = cx.add_window_view({
715 let scroll_handle = scroll_handle.clone();
716 move |_, _| HorizontalScrollAreaTest {
717 scroll_handle: scroll_handle.clone(),
718 }
719 });
720 let cx: &mut VisualTestContext = cx;
721 cx.run_until_parked();
722 cx.update(|window, cx| {
723 _ = window.draw(cx);
724 });
725
726 cx.simulate_event(ScrollEvent {
727 position: point(px(10.), px(10.)),
728 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
729 ..Default::default()
730 });
731
732 assert_eq!(scroll_handle.offset().x, px(0.));
733 }
734
735 struct ListWithHorizontalAreaTest {
740 scroll_handle: ScrollHandle,
741 list_state: ListState,
742 occluded: bool,
743 }
744
745 impl Render for ListWithHorizontalAreaTest {
746 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
747 let scroll_handle = self.scroll_handle.clone();
748 let mut root = div().w(px(100.)).h(px(100.)).child(
749 list(self.list_state.clone(), move |ix, _, _| {
750 if ix == 0 {
751 horizontal_scroll_area(
752 "horizontal-scroll-area",
753 &scroll_handle,
754 &Default::default(),
755 div().w(px(300.)).h(px(40.)),
756 )
757 .into_any_element()
758 } else {
759 div().w(px(100.)).h(px(40.)).into_any_element()
760 }
761 })
762 .w_full()
763 .h_full(),
764 );
765 if self.occluded {
766 root = root.child(div().absolute().top_0().left_0().size_full().occlude());
768 }
769 root
770 }
771 }
772
773 fn setup_list_test<'a>(
774 cx: &'a mut TestAppContext,
775 scroll_handle: &ScrollHandle,
776 list_state: &ListState,
777 occluded: bool,
778 ) -> &'a mut VisualTestContext {
779 let (_, cx) = cx.add_window_view({
780 let scroll_handle = scroll_handle.clone();
781 let list_state = list_state.clone();
782 move |_, _| ListWithHorizontalAreaTest {
783 scroll_handle: scroll_handle.clone(),
784 list_state: list_state.clone(),
785 occluded,
786 }
787 });
788 cx.run_until_parked();
789 cx.update(|window, cx| {
790 _ = window.draw(cx);
791 });
792 cx
793 }
794
795 #[gpui::test]
796 fn horizontal_scroll_area_in_list_keeps_horizontal_dominant_wheel(cx: &mut TestAppContext) {
797 let scroll_handle = ScrollHandle::new();
798 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
799 let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
800
801 cx.simulate_event(ScrollEvent {
804 position: point(px(10.), px(10.)),
805 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
806 ..Default::default()
807 });
808
809 assert_eq!(scroll_handle.offset().x, px(-40.));
811 let scroll_top = list_state.logical_scroll_top();
813 assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
814 }
815
816 #[gpui::test]
817 fn horizontal_scroll_area_in_list_bubbles_vertical_dominant_wheel(cx: &mut TestAppContext) {
818 let scroll_handle = ScrollHandle::new();
819 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
820 let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
821
822 cx.simulate_event(ScrollEvent {
823 position: point(px(10.), px(10.)),
824 delta: ScrollDelta::Pixels(point(px(-10.), px(-40.))),
825 ..Default::default()
826 });
827
828 assert_eq!(scroll_handle.offset().x, px(0.));
830 let scroll_top = list_state.logical_scroll_top();
831 assert_ne!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
832 }
833
834 struct ListWithFittingAreaTest(ListState);
837
838 impl Render for ListWithFittingAreaTest {
839 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
840 div().w(px(100.)).h(px(100.)).child(
841 list(self.0.clone(), |ix, _, _| {
842 if ix == 0 {
843 horizontal_scroll_area(
844 "fitting-area",
845 &ScrollHandle::new(),
846 &Default::default(),
847 div().w(px(50.)).h(px(40.)),
848 )
849 .into_any_element()
850 } else {
851 div().w(px(100.)).h(px(40.)).into_any_element()
852 }
853 })
854 .w_full()
855 .h_full(),
856 )
857 }
858 }
859
860 #[gpui::test]
861 fn horizontal_scroll_area_that_fits_bubbles_horizontal_dominant_wheel(cx: &mut TestAppContext) {
862 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
863 let (_, cx) = cx.add_window_view({
864 let list_state = list_state.clone();
865 move |_, _| ListWithFittingAreaTest(list_state.clone())
866 });
867 cx.update(|window, cx| {
868 _ = window.draw(cx);
869 });
870
871 cx.simulate_event(ScrollEvent {
872 position: point(px(10.), px(10.)),
873 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
874 ..Default::default()
875 });
876 let scroll_top = list_state.logical_scroll_top();
878 assert_ne!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
879 }
880
881 struct ListWithVerticalMaskTest {
884 outer_handle: ScrollHandle,
885 list_state: ListState,
886 }
887
888 impl Render for ListWithVerticalMaskTest {
889 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
890 div()
891 .id("outer")
892 .w(px(100.))
893 .h(px(100.))
894 .overflow_y_scroll()
895 .track_scroll(&self.outer_handle)
896 .child(
897 div().w_full().h(px(400.)).child(
898 div()
899 .relative()
900 .w_full()
901 .h(px(80.))
902 .overflow_hidden()
903 .child(
904 list(self.list_state.clone(), |_, _, _| {
905 div().w_full().h(px(40.)).into_any_element()
906 })
907 .w_full()
908 .h_full(),
909 )
910 .child(ScrollableMask::new(Axis::Vertical, &self.list_state)),
911 ),
912 )
913 }
914 }
915
916 fn setup_vertical_mask_test<'a>(
917 cx: &'a mut TestAppContext,
918 outer_handle: &ScrollHandle,
919 list_state: &ListState,
920 ) -> &'a mut VisualTestContext {
921 let (_, cx) = cx.add_window_view({
922 let outer_handle = outer_handle.clone();
923 let list_state = list_state.clone();
924 move |_, _| ListWithVerticalMaskTest {
925 outer_handle: outer_handle.clone(),
926 list_state: list_state.clone(),
927 }
928 });
929 cx.run_until_parked();
930 cx.update(|window, cx| {
931 _ = window.draw(cx);
932 });
933 cx
934 }
935
936 #[gpui::test]
937 fn vertical_mask_contains_wheel_inside_the_list(cx: &mut TestAppContext) {
938 let outer_handle = ScrollHandle::new();
939 let list_state = ListState::new(10, ListAlignment::Top, px(1000.));
942 let cx = setup_vertical_mask_test(cx, &outer_handle, &list_state);
943
944 cx.simulate_event(ScrollEvent {
945 position: point(px(10.), px(10.)),
946 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
947 ..Default::default()
948 });
949
950 assert_eq!(list_state.scroll_px_offset_for_scrollbar().y, px(-40.));
952 assert_eq!(outer_handle.offset().y, px(0.));
953 }
954
955 #[gpui::test]
956 fn vertical_mask_chains_to_the_ancestor_at_the_edge(cx: &mut TestAppContext) {
957 let outer_handle = ScrollHandle::new();
958 let list_state = ListState::new(10, ListAlignment::Top, px(1000.));
959 list_state.scroll_to(gpui::ListOffset {
961 item_ix: 8,
962 offset_in_item: px(0.),
963 });
964 let cx = setup_vertical_mask_test(cx, &outer_handle, &list_state);
965
966 cx.simulate_event(ScrollEvent {
967 position: point(px(10.), px(10.)),
968 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
969 ..Default::default()
970 });
971
972 assert_eq!(list_state.scroll_px_offset_for_scrollbar().y, px(-320.));
974 assert_eq!(outer_handle.offset().y, px(-40.));
975 }
976
977 #[gpui::test]
978 fn horizontal_scroll_area_covers_viewport_after_scrolled(cx: &mut TestAppContext) {
979 let scroll_handle = ScrollHandle::new();
980 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
981 let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
982
983 scroll_handle.set_offset(point(px(-150.), px(0.)));
985 cx.update(|window, cx| {
986 _ = window.draw(cx);
987 });
988
989 cx.simulate_event(ScrollEvent {
992 position: point(px(90.), px(10.)),
993 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
994 ..Default::default()
995 });
996
997 assert_eq!(scroll_handle.offset().x, px(-190.));
998 let scroll_top = list_state.logical_scroll_top();
999 assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
1000 }
1001
1002 #[gpui::test]
1003 fn horizontal_scroll_area_traps_wheel_at_edge(cx: &mut TestAppContext) {
1004 let scroll_handle = ScrollHandle::new();
1005 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
1006 let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
1007
1008 scroll_handle.set_offset(point(px(-200.), px(0.)));
1010 cx.update(|window, cx| {
1011 _ = window.draw(cx);
1012 });
1013
1014 cx.simulate_event(ScrollEvent {
1015 position: point(px(10.), px(10.)),
1016 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
1017 ..Default::default()
1018 });
1019
1020 let scroll_top = list_state.logical_scroll_top();
1023 assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
1024 }
1025
1026 #[gpui::test]
1027 fn horizontal_scroll_area_ignores_wheel_when_occluded(cx: &mut TestAppContext) {
1028 let scroll_handle = ScrollHandle::new();
1029 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
1030 let cx = setup_list_test(cx, &scroll_handle, &list_state, true);
1031
1032 cx.simulate_event(ScrollEvent {
1033 position: point(px(10.), px(10.)),
1034 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
1035 ..Default::default()
1036 });
1037
1038 assert_eq!(scroll_handle.offset().x, px(0.));
1041 }
1042
1043 #[gpui::test]
1044 fn horizontal_scroll_area_uses_horizontal_wheel(cx: &mut TestAppContext) {
1045 let scroll_handle = ScrollHandle::new();
1046 let (_, cx) = cx.add_window_view({
1047 let scroll_handle = scroll_handle.clone();
1048 move |_, _| HorizontalScrollAreaTest {
1049 scroll_handle: scroll_handle.clone(),
1050 }
1051 });
1052 let cx: &mut VisualTestContext = cx;
1053 cx.run_until_parked();
1054 cx.update(|window, cx| {
1055 _ = window.draw(cx);
1056 });
1057
1058 cx.simulate_event(ScrollEvent {
1059 position: point(px(10.), px(10.)),
1060 delta: ScrollDelta::Pixels(point(px(-40.), px(0.))),
1061 ..Default::default()
1062 });
1063
1064 assert_eq!(scroll_handle.offset().x, px(-40.));
1065 }
1066
1067 #[gpui::test]
1068 fn horizontal_scroll_area_claims_only_steps_that_move(cx: &mut TestAppContext) {
1069 let scroll_handle = ScrollHandle::new();
1070 let (_, cx) = cx.add_window_view({
1071 let scroll_handle = scroll_handle.clone();
1072 move |_, _| HorizontalScrollAreaTest {
1073 scroll_handle: scroll_handle.clone(),
1074 }
1075 });
1076 let cx: &mut VisualTestContext = cx;
1077 cx.run_until_parked();
1078 cx.update(|window, cx| {
1079 _ = window.draw(cx);
1080 });
1081
1082 LAST_HORIZONTAL_SCROLL_CLAIM.set(None);
1084 cx.simulate_event(ScrollEvent {
1085 position: point(px(10.), px(10.)),
1086 delta: ScrollDelta::Pixels(point(px(40.), px(0.))),
1087 ..Default::default()
1088 });
1089 assert!(last_horizontal_scroll_claim().is_none());
1090
1091 cx.update(|window, cx| {
1095 let mut dispatch = |delta_x: f32| {
1096 window.dispatch_event(
1097 gpui::PlatformInput::Scroll(ScrollEvent {
1098 position: point(px(10.), px(10.)),
1099 delta: ScrollDelta::Pixels(point(px(delta_x), px(0.))),
1100 ..Default::default()
1101 }),
1102 cx,
1103 );
1104 };
1105
1106 scroll_handle.set_offset(point(px(40.), px(0.)));
1109 dispatch(-10.);
1110 assert!(last_horizontal_scroll_claim().is_none());
1111 assert_eq!(scroll_handle.offset().x, px(30.));
1112
1113 dispatch(-40.);
1115 });
1116 let claim = last_horizontal_scroll_claim().expect("a moving step claims");
1117 assert_eq!(claim.delta_x(), px(-10.));
1118 assert_eq!(scroll_handle.offset().x, px(-10.));
1119
1120 for delta in [point(px(0.), px(-40.)), point(px(-0.25), px(0.))] {
1122 cx.simulate_event(ScrollEvent {
1123 position: point(px(10.), px(10.)),
1124 delta: ScrollDelta::Pixels(delta),
1125 ..Default::default()
1126 });
1127 assert_eq!(last_horizontal_scroll_claim(), Some(claim));
1128 }
1129 }
1130
1131 fn setup_horizontal_area_test<'a>(
1132 cx: &'a mut TestAppContext,
1133 scroll_handle: &ScrollHandle,
1134 ) -> &'a mut VisualTestContext {
1135 let (_, cx) = cx.add_window_view({
1136 let scroll_handle = scroll_handle.clone();
1137 move |_, _| HorizontalScrollAreaTest {
1138 scroll_handle: scroll_handle.clone(),
1139 }
1140 });
1141 let cx: &mut VisualTestContext = cx;
1142 cx.run_until_parked();
1143 cx.update(|window, cx| {
1144 _ = window.draw(cx);
1145 });
1146 cx
1147 }
1148
1149 #[gpui::test]
1150 fn horizontal_mask_keeps_axis_lock_within_a_gesture(cx: &mut TestAppContext) {
1151 let scroll_handle = ScrollHandle::new();
1152 let cx = setup_horizontal_area_test(cx, &scroll_handle);
1153
1154 cx.simulate_event(ScrollEvent {
1155 position: point(px(10.), px(10.)),
1156 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
1157 ..Default::default()
1158 });
1159 assert_eq!(scroll_handle.offset().x, px(-40.));
1160
1161 cx.simulate_event(ScrollEvent {
1165 position: point(px(10.), px(10.)),
1166 delta: ScrollDelta::Pixels(point(px(-10.), px(-15.))),
1167 ..Default::default()
1168 });
1169 assert_eq!(scroll_handle.offset().x, px(-50.));
1170 }
1171
1172 #[gpui::test]
1173 fn horizontal_mask_releases_axis_lock_on_a_strong_turn(cx: &mut TestAppContext) {
1174 let scroll_handle = ScrollHandle::new();
1175 let cx = setup_horizontal_area_test(cx, &scroll_handle);
1176
1177 cx.simulate_event(ScrollEvent {
1178 position: point(px(10.), px(10.)),
1179 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
1180 ..Default::default()
1181 });
1182 assert_eq!(scroll_handle.offset().x, px(-40.));
1183
1184 cx.simulate_event(ScrollEvent {
1187 position: point(px(10.), px(10.)),
1188 delta: ScrollDelta::Pixels(point(px(-10.), px(-25.))),
1189 ..Default::default()
1190 });
1191 assert_eq!(scroll_handle.offset().x, px(-40.));
1192 }
1193
1194 struct NestedVerticalScrollTest {
1197 outer_handle: ScrollHandle,
1198 inner_handle: ScrollHandle,
1199 inner_content_height: Pixels,
1200 }
1201
1202 impl Render for NestedVerticalScrollTest {
1203 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1204 div()
1205 .id("outer")
1206 .w(px(100.))
1207 .h(px(100.))
1208 .overflow_y_scroll()
1209 .track_scroll(&self.outer_handle)
1210 .child(
1211 div()
1212 .relative()
1213 .w_full()
1214 .h(px(60.))
1215 .child(
1216 div()
1217 .id("inner")
1218 .size_full()
1219 .overflow_y_scroll()
1220 .track_scroll(&self.inner_handle)
1221 .child(div().w_full().h(self.inner_content_height)),
1222 )
1223 .child(ScrollableMask::new(Axis::Vertical, &self.inner_handle)),
1224 )
1225 .child(div().w_full().h(px(400.)))
1226 }
1227 }
1228
1229 fn setup_nested_vertical_test<'a>(
1230 cx: &'a mut TestAppContext,
1231 outer_handle: &ScrollHandle,
1232 inner_handle: &ScrollHandle,
1233 inner_content_height: Pixels,
1234 ) -> &'a mut VisualTestContext {
1235 let (_, cx) = cx.add_window_view({
1236 let outer_handle = outer_handle.clone();
1237 let inner_handle = inner_handle.clone();
1238 move |_, _| NestedVerticalScrollTest {
1239 outer_handle: outer_handle.clone(),
1240 inner_handle: inner_handle.clone(),
1241 inner_content_height,
1242 }
1243 });
1244 cx.run_until_parked();
1245 cx.update(|window, cx| {
1246 _ = window.draw(cx);
1247 });
1248 cx
1249 }
1250
1251 #[gpui::test]
1252 fn vertical_mask_consumes_wheel_when_scrollable(cx: &mut TestAppContext) {
1253 let outer_handle = ScrollHandle::new();
1254 let inner_handle = ScrollHandle::new();
1255 let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(300.));
1256
1257 cx.simulate_event(ScrollEvent {
1258 position: point(px(10.), px(10.)),
1259 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1260 ..Default::default()
1261 });
1262
1263 assert_eq!(inner_handle.offset().y, px(-40.));
1265 assert_eq!(outer_handle.offset().y, px(0.));
1266 }
1267
1268 #[gpui::test]
1269 fn vertical_mask_hands_off_to_parent_at_edge(cx: &mut TestAppContext) {
1270 let outer_handle = ScrollHandle::new();
1271 let inner_handle = ScrollHandle::new();
1272 let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(300.));
1273
1274 inner_handle.set_offset(point(px(0.), px(-240.)));
1276 cx.update(|window, cx| {
1277 _ = window.draw(cx);
1278 });
1279
1280 cx.simulate_event(ScrollEvent {
1281 position: point(px(10.), px(10.)),
1282 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1283 ..Default::default()
1284 });
1285
1286 assert_eq!(outer_handle.offset().y, px(-40.));
1288 cx.update(|window, cx| {
1290 _ = window.draw(cx);
1291 });
1292 assert_eq!(inner_handle.offset().y, px(-240.));
1293 }
1294
1295 #[gpui::test]
1296 fn vertical_mask_bubbles_when_no_overflow(cx: &mut TestAppContext) {
1297 let outer_handle = ScrollHandle::new();
1298 let inner_handle = ScrollHandle::new();
1299 let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(40.));
1301
1302 cx.simulate_event(ScrollEvent {
1303 position: point(px(10.), px(10.)),
1304 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1305 ..Default::default()
1306 });
1307
1308 assert_eq!(outer_handle.offset().y, px(-40.));
1309 cx.update(|window, cx| {
1310 _ = window.draw(cx);
1311 });
1312 assert_eq!(inner_handle.offset().y, px(0.));
1313 }
1314
1315 #[gpui::test]
1316 fn vertical_mask_ignores_transient_overscroll(cx: &mut TestAppContext) {
1317 let outer_handle = ScrollHandle::new();
1318 let inner_handle = ScrollHandle::new();
1319 let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(300.));
1320
1321 inner_handle.set_offset(point(px(0.), px(-240.)));
1322 cx.update(|window, cx| {
1323 _ = window.draw(cx);
1324 });
1325
1326 for _ in 0..2 {
1330 cx.simulate_event(ScrollEvent {
1331 position: point(px(10.), px(10.)),
1332 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1333 ..Default::default()
1334 });
1335 }
1336
1337 assert_eq!(outer_handle.offset().y, px(-80.));
1338 cx.update(|window, cx| {
1339 _ = window.draw(cx);
1340 });
1341 assert_eq!(inner_handle.offset().y, px(-240.));
1342 }
1343
1344 struct ListWithVerticalAreaTest {
1348 scroll_handle: ScrollHandle,
1349 list_state: ListState,
1350 }
1351
1352 impl Render for ListWithVerticalAreaTest {
1353 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1354 let scroll_handle = self.scroll_handle.clone();
1355 div().w(px(100.)).h(px(100.)).child(
1356 list(self.list_state.clone(), move |ix, _, _| {
1357 if ix == 0 {
1358 div()
1359 .relative()
1360 .w_full()
1361 .h(px(60.))
1362 .child(
1363 div()
1364 .id("inner")
1365 .size_full()
1366 .overflow_y_scroll()
1367 .track_scroll(&scroll_handle)
1368 .child(div().w_full().h(px(300.))),
1369 )
1370 .child(ScrollableMask::new(Axis::Vertical, &scroll_handle))
1371 .into_any_element()
1372 } else {
1373 div().w(px(100.)).h(px(40.)).into_any_element()
1374 }
1375 })
1376 .w_full()
1377 .h_full(),
1378 )
1379 }
1380 }
1381
1382 #[gpui::test]
1383 fn vertical_mask_in_list_consumes_wheel_when_scrollable(cx: &mut TestAppContext) {
1384 let scroll_handle = ScrollHandle::new();
1385 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
1386 let (_, cx) = cx.add_window_view({
1387 let scroll_handle = scroll_handle.clone();
1388 let list_state = list_state.clone();
1389 move |_, _| ListWithVerticalAreaTest {
1390 scroll_handle: scroll_handle.clone(),
1391 list_state: list_state.clone(),
1392 }
1393 });
1394 cx.run_until_parked();
1395 cx.update(|window, cx| {
1396 _ = window.draw(cx);
1397 });
1398
1399 cx.simulate_event(ScrollEvent {
1400 position: point(px(10.), px(10.)),
1401 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1402 ..Default::default()
1403 });
1404
1405 assert_eq!(scroll_handle.offset().y, px(-40.));
1407 let scroll_top = list_state.logical_scroll_top();
1408 assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
1409 }
1410
1411 struct VerticalWheelTest {
1414 strip: ScrollHandle,
1415 page: ScrollHandle,
1416 }
1417
1418 impl Render for VerticalWheelTest {
1419 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1420 div()
1421 .id("page")
1422 .w(px(100.))
1423 .h(px(60.))
1424 .overflow_y_scroll()
1425 .track_scroll(&self.page)
1426 .child(
1427 div()
1428 .relative()
1429 .w(px(100.))
1430 .h(px(40.))
1431 .child(
1432 div()
1433 .id("strip")
1434 .size_full()
1435 .flex()
1436 .overflow_x_scroll()
1437 .restrict_scroll_to_axis()
1438 .track_scroll(&self.strip)
1439 .child(div().flex_none().w(px(300.)).h(px(40.))),
1440 )
1441 .child(ScrollableMask::vertical_wheel(&self.strip)),
1442 )
1443 .child(div().h(px(200.)))
1444 }
1445 }
1446
1447 fn vertical_wheel_test(
1448 cx: &mut TestAppContext,
1449 ) -> (&mut VisualTestContext, ScrollHandle, ScrollHandle) {
1450 let (strip, page) = (ScrollHandle::new(), ScrollHandle::new());
1451 let (_, cx) = cx.add_window_view({
1452 let (strip, page) = (strip.clone(), page.clone());
1453 move |_, _| VerticalWheelTest { strip, page }
1454 });
1455 cx.run_until_parked();
1456 cx.update(|window, cx| window.draw(cx).clear(cx));
1457 (cx, strip, page)
1458 }
1459
1460 fn wheel(cx: &mut VisualTestContext, delta: ScrollDelta) {
1461 cx.simulate_event(ScrollEvent {
1462 position: point(px(10.), px(10.)),
1463 delta,
1464 ..Default::default()
1465 });
1466 cx.update(|window, cx| window.draw(cx).clear(cx));
1467 }
1468
1469 #[gpui::test]
1470 fn vertical_wheel_scrolls_the_strip_and_then_the_parent(cx: &mut TestAppContext) {
1471 let (cx, strip, page) = vertical_wheel_test(cx);
1472 let line_height = cx.update(|window, _| window.line_height());
1473
1474 wheel(cx, ScrollDelta::Lines(point(0., -1.)));
1475 assert_eq!(strip.offset().x, -line_height);
1476 assert_eq!(page.offset().y, px(0.));
1477
1478 wheel(cx, ScrollDelta::Lines(point(0., -100.)));
1480 assert_eq!(strip.offset().x, px(-200.));
1481 assert_eq!(page.offset().y, px(0.));
1482 wheel(cx, ScrollDelta::Lines(point(0., -1.)));
1483 assert_eq!(strip.offset().x, px(-200.));
1484 assert!(page.offset().y < px(0.));
1485
1486 wheel(cx, ScrollDelta::Lines(point(0., f32::NAN)));
1488 assert_eq!(strip.offset().x, px(-200.));
1489 }
1490
1491 #[gpui::test]
1492 fn vertical_wheel_leaves_trackpads_and_horizontal_wheels_to_the_scroller(
1493 cx: &mut TestAppContext,
1494 ) {
1495 let (cx, strip, page) = vertical_wheel_test(cx);
1496 let line_height = cx.update(|window, _| window.line_height());
1497
1498 wheel(cx, ScrollDelta::Pixels(point(px(0.), px(-20.))));
1500 assert_eq!(strip.offset().x, px(0.));
1501 assert_eq!(page.offset().y, px(-20.));
1502
1503 wheel(cx, ScrollDelta::Lines(point(-1., 0.)));
1505 assert_eq!(strip.offset().x, -line_height);
1506 }
1507}