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 event.delta.precise() {
557 ongoing_scroll
558 .borrow_mut()
559 .lock_axis(&mut delta, event.touch_phase);
560 }
561
562 if !delta.x.is_zero() && !delta.y.is_zero() {
566 if delta.x.abs() > delta.y.abs() {
567 delta.y = px(0.);
568 } else {
569 delta.x = px(0.);
570 }
571 }
572
573 if !is_horizontal {
574 let axis_max = (scroll_handle.content_size().height
582 - scroll_handle.viewport_bounds().size.height)
583 .max(px(0.));
584 let current = offset.y.clamp(-axis_max, px(0.));
585 let new_offset = (current + delta.y).clamp(-axis_max, px(0.));
586 if new_offset == current {
587 return;
589 }
590
591 offset.y = new_offset;
592 scroll_handle.set_offset(offset);
593 cx.notify(view_id);
594 cx.stop_propagation();
595 return;
596 }
597
598 if !delta.x.is_zero() {
603 let axis_max = (scroll_handle.content_size().width
604 - scroll_handle.viewport_bounds().size.width)
605 .max(px(0.));
606 let current = offset.x.clamp(-axis_max, px(0.));
607 let moved = (offset.x + delta.x).clamp(-axis_max, px(0.)) - current;
608 if moved.abs() >= CLAIM_EPSILON {
609 LAST_HORIZONTAL_SCROLL_CLAIM.set(Some(HorizontalScrollClaim {
610 at: Instant::now(),
611 delta_x: moved,
612 }));
613 }
614 }
615
616 offset.x += delta.x;
617
618 if offset != scroll_handle.offset() {
624 scroll_handle.set_offset(offset);
625 cx.notify(view_id);
626 cx.stop_propagation();
627 }
628 }
629 });
630 });
631 }
632}
633
634#[cfg(test)]
635mod tests {
636 use super::*;
637 use gpui::{
638 Context, IntoElement, ListAlignment, ListState, Render, ScrollDelta, ScrollEvent,
639 TestAppContext, VisualTestContext, Window, div, list, point, px,
640 };
641
642 #[test]
643 fn corner_notch_paths_stay_inside_their_corner_squares() {
644 let width = px(200.);
645 let height = px(100.);
646 let radius = px(8.);
647 let cases = [
648 ("top left", point(px(0.), px(0.)), 1., 1.),
649 ("top right", point(width, px(0.)), -1., 1.),
650 ("bottom right", point(width, height), -1., -1.),
651 ("bottom left", point(px(0.), height), 1., -1.),
652 ];
653 for (name, corner, x_dir, y_dir) in cases {
654 let path =
655 corner_notch_path(corner, x_dir, y_dir, radius).expect("the notch tessellates");
656 let bounds = path.bounds;
657 let tolerance = 1.0;
659 assert!(
660 (f32::from(bounds.size.width) - f32::from(radius)).abs() <= tolerance
661 && (f32::from(bounds.size.height) - f32::from(radius)).abs() <= tolerance,
662 "{name}: notch size {:?} must stay a {radius:?} square",
663 bounds.size,
664 );
665 let expected_origin = point(
666 if x_dir > 0. {
667 corner.x
668 } else {
669 corner.x - radius
670 },
671 if y_dir > 0. {
672 corner.y
673 } else {
674 corner.y - radius
675 },
676 );
677 assert!(
678 (f32::from(bounds.origin.x) - f32::from(expected_origin.x)).abs() <= tolerance
679 && (f32::from(bounds.origin.y) - f32::from(expected_origin.y)).abs()
680 <= tolerance,
681 "{name}: notch bounds {bounds:?} must anchor at the frame corner",
682 );
683 }
684 }
685
686 struct HorizontalScrollAreaTest {
687 scroll_handle: ScrollHandle,
688 }
689
690 impl Render for HorizontalScrollAreaTest {
691 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
692 div().w(px(100.)).h(px(40.)).child(horizontal_scroll_area(
693 "horizontal-scroll-area",
694 &self.scroll_handle,
695 &Default::default(),
696 div().w(px(300.)).h(px(40.)),
697 ))
698 }
699 }
700
701 #[gpui::test]
702 fn horizontal_scroll_area_ignores_vertical_wheel(cx: &mut TestAppContext) {
703 let scroll_handle = ScrollHandle::new();
704 let (_, cx) = cx.add_window_view({
705 let scroll_handle = scroll_handle.clone();
706 move |_, _| HorizontalScrollAreaTest {
707 scroll_handle: scroll_handle.clone(),
708 }
709 });
710 let cx: &mut VisualTestContext = cx;
711 cx.run_until_parked();
712 cx.update(|window, cx| {
713 _ = window.draw(cx);
714 });
715
716 cx.simulate_event(ScrollEvent {
717 position: point(px(10.), px(10.)),
718 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
719 ..Default::default()
720 });
721
722 assert_eq!(scroll_handle.offset().x, px(0.));
723 }
724
725 struct ListWithHorizontalAreaTest {
730 scroll_handle: ScrollHandle,
731 list_state: ListState,
732 occluded: bool,
733 }
734
735 impl Render for ListWithHorizontalAreaTest {
736 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
737 let scroll_handle = self.scroll_handle.clone();
738 let mut root = div().w(px(100.)).h(px(100.)).child(
739 list(self.list_state.clone(), move |ix, _, _| {
740 if ix == 0 {
741 horizontal_scroll_area(
742 "horizontal-scroll-area",
743 &scroll_handle,
744 &Default::default(),
745 div().w(px(300.)).h(px(40.)),
746 )
747 .into_any_element()
748 } else {
749 div().w(px(100.)).h(px(40.)).into_any_element()
750 }
751 })
752 .w_full()
753 .h_full(),
754 );
755 if self.occluded {
756 root = root.child(div().absolute().top_0().left_0().size_full().occlude());
758 }
759 root
760 }
761 }
762
763 fn setup_list_test<'a>(
764 cx: &'a mut TestAppContext,
765 scroll_handle: &ScrollHandle,
766 list_state: &ListState,
767 occluded: bool,
768 ) -> &'a mut VisualTestContext {
769 let (_, cx) = cx.add_window_view({
770 let scroll_handle = scroll_handle.clone();
771 let list_state = list_state.clone();
772 move |_, _| ListWithHorizontalAreaTest {
773 scroll_handle: scroll_handle.clone(),
774 list_state: list_state.clone(),
775 occluded,
776 }
777 });
778 cx.run_until_parked();
779 cx.update(|window, cx| {
780 _ = window.draw(cx);
781 });
782 cx
783 }
784
785 #[gpui::test]
786 fn horizontal_scroll_area_in_list_keeps_horizontal_dominant_wheel(cx: &mut TestAppContext) {
787 let scroll_handle = ScrollHandle::new();
788 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
789 let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
790
791 cx.simulate_event(ScrollEvent {
794 position: point(px(10.), px(10.)),
795 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
796 ..Default::default()
797 });
798
799 assert_eq!(scroll_handle.offset().x, px(-40.));
801 let scroll_top = list_state.logical_scroll_top();
803 assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
804 }
805
806 #[gpui::test]
807 fn horizontal_scroll_area_in_list_bubbles_vertical_dominant_wheel(cx: &mut TestAppContext) {
808 let scroll_handle = ScrollHandle::new();
809 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
810 let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
811
812 cx.simulate_event(ScrollEvent {
813 position: point(px(10.), px(10.)),
814 delta: ScrollDelta::Pixels(point(px(-10.), px(-40.))),
815 ..Default::default()
816 });
817
818 assert_eq!(scroll_handle.offset().x, px(0.));
820 let scroll_top = list_state.logical_scroll_top();
821 assert_ne!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
822 }
823
824 struct ListWithVerticalMaskTest {
827 outer_handle: ScrollHandle,
828 list_state: ListState,
829 }
830
831 impl Render for ListWithVerticalMaskTest {
832 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
833 div()
834 .id("outer")
835 .w(px(100.))
836 .h(px(100.))
837 .overflow_y_scroll()
838 .track_scroll(&self.outer_handle)
839 .child(
840 div().w_full().h(px(400.)).child(
841 div()
842 .relative()
843 .w_full()
844 .h(px(80.))
845 .overflow_hidden()
846 .child(
847 list(self.list_state.clone(), |_, _, _| {
848 div().w_full().h(px(40.)).into_any_element()
849 })
850 .w_full()
851 .h_full(),
852 )
853 .child(ScrollableMask::new(Axis::Vertical, &self.list_state)),
854 ),
855 )
856 }
857 }
858
859 fn setup_vertical_mask_test<'a>(
860 cx: &'a mut TestAppContext,
861 outer_handle: &ScrollHandle,
862 list_state: &ListState,
863 ) -> &'a mut VisualTestContext {
864 let (_, cx) = cx.add_window_view({
865 let outer_handle = outer_handle.clone();
866 let list_state = list_state.clone();
867 move |_, _| ListWithVerticalMaskTest {
868 outer_handle: outer_handle.clone(),
869 list_state: list_state.clone(),
870 }
871 });
872 cx.run_until_parked();
873 cx.update(|window, cx| {
874 _ = window.draw(cx);
875 });
876 cx
877 }
878
879 #[gpui::test]
880 fn vertical_mask_contains_wheel_inside_the_list(cx: &mut TestAppContext) {
881 let outer_handle = ScrollHandle::new();
882 let list_state = ListState::new(10, ListAlignment::Top, px(1000.));
885 let cx = setup_vertical_mask_test(cx, &outer_handle, &list_state);
886
887 cx.simulate_event(ScrollEvent {
888 position: point(px(10.), px(10.)),
889 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
890 ..Default::default()
891 });
892
893 assert_eq!(list_state.scroll_px_offset_for_scrollbar().y, px(-40.));
895 assert_eq!(outer_handle.offset().y, px(0.));
896 }
897
898 #[gpui::test]
899 fn vertical_mask_chains_to_the_ancestor_at_the_edge(cx: &mut TestAppContext) {
900 let outer_handle = ScrollHandle::new();
901 let list_state = ListState::new(10, ListAlignment::Top, px(1000.));
902 list_state.scroll_to(gpui::ListOffset {
904 item_ix: 8,
905 offset_in_item: px(0.),
906 });
907 let cx = setup_vertical_mask_test(cx, &outer_handle, &list_state);
908
909 cx.simulate_event(ScrollEvent {
910 position: point(px(10.), px(10.)),
911 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
912 ..Default::default()
913 });
914
915 assert_eq!(list_state.scroll_px_offset_for_scrollbar().y, px(-320.));
917 assert_eq!(outer_handle.offset().y, px(-40.));
918 }
919
920 #[gpui::test]
921 fn horizontal_scroll_area_covers_viewport_after_scrolled(cx: &mut TestAppContext) {
922 let scroll_handle = ScrollHandle::new();
923 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
924 let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
925
926 scroll_handle.set_offset(point(px(-150.), px(0.)));
928 cx.update(|window, cx| {
929 _ = window.draw(cx);
930 });
931
932 cx.simulate_event(ScrollEvent {
935 position: point(px(90.), px(10.)),
936 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
937 ..Default::default()
938 });
939
940 assert_eq!(scroll_handle.offset().x, px(-190.));
941 let scroll_top = list_state.logical_scroll_top();
942 assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
943 }
944
945 #[gpui::test]
946 fn horizontal_scroll_area_traps_wheel_at_edge(cx: &mut TestAppContext) {
947 let scroll_handle = ScrollHandle::new();
948 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
949 let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
950
951 scroll_handle.set_offset(point(px(-200.), px(0.)));
953 cx.update(|window, cx| {
954 _ = window.draw(cx);
955 });
956
957 cx.simulate_event(ScrollEvent {
958 position: point(px(10.), px(10.)),
959 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
960 ..Default::default()
961 });
962
963 let scroll_top = list_state.logical_scroll_top();
966 assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
967 }
968
969 #[gpui::test]
970 fn horizontal_scroll_area_ignores_wheel_when_occluded(cx: &mut TestAppContext) {
971 let scroll_handle = ScrollHandle::new();
972 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
973 let cx = setup_list_test(cx, &scroll_handle, &list_state, true);
974
975 cx.simulate_event(ScrollEvent {
976 position: point(px(10.), px(10.)),
977 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
978 ..Default::default()
979 });
980
981 assert_eq!(scroll_handle.offset().x, px(0.));
984 }
985
986 #[gpui::test]
987 fn horizontal_scroll_area_uses_horizontal_wheel(cx: &mut TestAppContext) {
988 let scroll_handle = ScrollHandle::new();
989 let (_, cx) = cx.add_window_view({
990 let scroll_handle = scroll_handle.clone();
991 move |_, _| HorizontalScrollAreaTest {
992 scroll_handle: scroll_handle.clone(),
993 }
994 });
995 let cx: &mut VisualTestContext = cx;
996 cx.run_until_parked();
997 cx.update(|window, cx| {
998 _ = window.draw(cx);
999 });
1000
1001 cx.simulate_event(ScrollEvent {
1002 position: point(px(10.), px(10.)),
1003 delta: ScrollDelta::Pixels(point(px(-40.), px(0.))),
1004 ..Default::default()
1005 });
1006
1007 assert_eq!(scroll_handle.offset().x, px(-40.));
1008 }
1009
1010 #[gpui::test]
1011 fn horizontal_scroll_area_claims_only_steps_that_move(cx: &mut TestAppContext) {
1012 let scroll_handle = ScrollHandle::new();
1013 let (_, cx) = cx.add_window_view({
1014 let scroll_handle = scroll_handle.clone();
1015 move |_, _| HorizontalScrollAreaTest {
1016 scroll_handle: scroll_handle.clone(),
1017 }
1018 });
1019 let cx: &mut VisualTestContext = cx;
1020 cx.run_until_parked();
1021 cx.update(|window, cx| {
1022 _ = window.draw(cx);
1023 });
1024
1025 LAST_HORIZONTAL_SCROLL_CLAIM.set(None);
1027 cx.simulate_event(ScrollEvent {
1028 position: point(px(10.), px(10.)),
1029 delta: ScrollDelta::Pixels(point(px(40.), px(0.))),
1030 ..Default::default()
1031 });
1032 assert!(last_horizontal_scroll_claim().is_none());
1033
1034 cx.update(|window, cx| {
1038 let mut dispatch = |delta_x: f32| {
1039 window.dispatch_event(
1040 gpui::PlatformInput::Scroll(ScrollEvent {
1041 position: point(px(10.), px(10.)),
1042 delta: ScrollDelta::Pixels(point(px(delta_x), px(0.))),
1043 ..Default::default()
1044 }),
1045 cx,
1046 );
1047 };
1048
1049 scroll_handle.set_offset(point(px(40.), px(0.)));
1052 dispatch(-10.);
1053 assert!(last_horizontal_scroll_claim().is_none());
1054 assert_eq!(scroll_handle.offset().x, px(30.));
1055
1056 dispatch(-40.);
1058 });
1059 let claim = last_horizontal_scroll_claim().expect("a moving step claims");
1060 assert_eq!(claim.delta_x(), px(-10.));
1061 assert_eq!(scroll_handle.offset().x, px(-10.));
1062
1063 for delta in [point(px(0.), px(-40.)), point(px(-0.25), px(0.))] {
1065 cx.simulate_event(ScrollEvent {
1066 position: point(px(10.), px(10.)),
1067 delta: ScrollDelta::Pixels(delta),
1068 ..Default::default()
1069 });
1070 assert_eq!(last_horizontal_scroll_claim(), Some(claim));
1071 }
1072 }
1073
1074 fn setup_horizontal_area_test<'a>(
1075 cx: &'a mut TestAppContext,
1076 scroll_handle: &ScrollHandle,
1077 ) -> &'a mut VisualTestContext {
1078 let (_, cx) = cx.add_window_view({
1079 let scroll_handle = scroll_handle.clone();
1080 move |_, _| HorizontalScrollAreaTest {
1081 scroll_handle: scroll_handle.clone(),
1082 }
1083 });
1084 let cx: &mut VisualTestContext = cx;
1085 cx.run_until_parked();
1086 cx.update(|window, cx| {
1087 _ = window.draw(cx);
1088 });
1089 cx
1090 }
1091
1092 #[gpui::test]
1093 fn horizontal_mask_keeps_axis_lock_within_a_gesture(cx: &mut TestAppContext) {
1094 let scroll_handle = ScrollHandle::new();
1095 let cx = setup_horizontal_area_test(cx, &scroll_handle);
1096
1097 cx.simulate_event(ScrollEvent {
1098 position: point(px(10.), px(10.)),
1099 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
1100 ..Default::default()
1101 });
1102 assert_eq!(scroll_handle.offset().x, px(-40.));
1103
1104 cx.simulate_event(ScrollEvent {
1108 position: point(px(10.), px(10.)),
1109 delta: ScrollDelta::Pixels(point(px(-10.), px(-15.))),
1110 ..Default::default()
1111 });
1112 assert_eq!(scroll_handle.offset().x, px(-50.));
1113 }
1114
1115 #[gpui::test]
1116 fn horizontal_mask_releases_axis_lock_on_a_strong_turn(cx: &mut TestAppContext) {
1117 let scroll_handle = ScrollHandle::new();
1118 let cx = setup_horizontal_area_test(cx, &scroll_handle);
1119
1120 cx.simulate_event(ScrollEvent {
1121 position: point(px(10.), px(10.)),
1122 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
1123 ..Default::default()
1124 });
1125 assert_eq!(scroll_handle.offset().x, px(-40.));
1126
1127 cx.simulate_event(ScrollEvent {
1130 position: point(px(10.), px(10.)),
1131 delta: ScrollDelta::Pixels(point(px(-10.), px(-25.))),
1132 ..Default::default()
1133 });
1134 assert_eq!(scroll_handle.offset().x, px(-40.));
1135 }
1136
1137 struct NestedVerticalScrollTest {
1140 outer_handle: ScrollHandle,
1141 inner_handle: ScrollHandle,
1142 inner_content_height: Pixels,
1143 }
1144
1145 impl Render for NestedVerticalScrollTest {
1146 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1147 div()
1148 .id("outer")
1149 .w(px(100.))
1150 .h(px(100.))
1151 .overflow_y_scroll()
1152 .track_scroll(&self.outer_handle)
1153 .child(
1154 div()
1155 .relative()
1156 .w_full()
1157 .h(px(60.))
1158 .child(
1159 div()
1160 .id("inner")
1161 .size_full()
1162 .overflow_y_scroll()
1163 .track_scroll(&self.inner_handle)
1164 .child(div().w_full().h(self.inner_content_height)),
1165 )
1166 .child(ScrollableMask::new(Axis::Vertical, &self.inner_handle)),
1167 )
1168 .child(div().w_full().h(px(400.)))
1169 }
1170 }
1171
1172 fn setup_nested_vertical_test<'a>(
1173 cx: &'a mut TestAppContext,
1174 outer_handle: &ScrollHandle,
1175 inner_handle: &ScrollHandle,
1176 inner_content_height: Pixels,
1177 ) -> &'a mut VisualTestContext {
1178 let (_, cx) = cx.add_window_view({
1179 let outer_handle = outer_handle.clone();
1180 let inner_handle = inner_handle.clone();
1181 move |_, _| NestedVerticalScrollTest {
1182 outer_handle: outer_handle.clone(),
1183 inner_handle: inner_handle.clone(),
1184 inner_content_height,
1185 }
1186 });
1187 cx.run_until_parked();
1188 cx.update(|window, cx| {
1189 _ = window.draw(cx);
1190 });
1191 cx
1192 }
1193
1194 #[gpui::test]
1195 fn vertical_mask_consumes_wheel_when_scrollable(cx: &mut TestAppContext) {
1196 let outer_handle = ScrollHandle::new();
1197 let inner_handle = ScrollHandle::new();
1198 let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(300.));
1199
1200 cx.simulate_event(ScrollEvent {
1201 position: point(px(10.), px(10.)),
1202 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1203 ..Default::default()
1204 });
1205
1206 assert_eq!(inner_handle.offset().y, px(-40.));
1208 assert_eq!(outer_handle.offset().y, px(0.));
1209 }
1210
1211 #[gpui::test]
1212 fn vertical_mask_hands_off_to_parent_at_edge(cx: &mut TestAppContext) {
1213 let outer_handle = ScrollHandle::new();
1214 let inner_handle = ScrollHandle::new();
1215 let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(300.));
1216
1217 inner_handle.set_offset(point(px(0.), px(-240.)));
1219 cx.update(|window, cx| {
1220 _ = window.draw(cx);
1221 });
1222
1223 cx.simulate_event(ScrollEvent {
1224 position: point(px(10.), px(10.)),
1225 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1226 ..Default::default()
1227 });
1228
1229 assert_eq!(outer_handle.offset().y, px(-40.));
1231 cx.update(|window, cx| {
1233 _ = window.draw(cx);
1234 });
1235 assert_eq!(inner_handle.offset().y, px(-240.));
1236 }
1237
1238 #[gpui::test]
1239 fn vertical_mask_bubbles_when_no_overflow(cx: &mut TestAppContext) {
1240 let outer_handle = ScrollHandle::new();
1241 let inner_handle = ScrollHandle::new();
1242 let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(40.));
1244
1245 cx.simulate_event(ScrollEvent {
1246 position: point(px(10.), px(10.)),
1247 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1248 ..Default::default()
1249 });
1250
1251 assert_eq!(outer_handle.offset().y, px(-40.));
1252 cx.update(|window, cx| {
1253 _ = window.draw(cx);
1254 });
1255 assert_eq!(inner_handle.offset().y, px(0.));
1256 }
1257
1258 #[gpui::test]
1259 fn vertical_mask_ignores_transient_overscroll(cx: &mut TestAppContext) {
1260 let outer_handle = ScrollHandle::new();
1261 let inner_handle = ScrollHandle::new();
1262 let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(300.));
1263
1264 inner_handle.set_offset(point(px(0.), px(-240.)));
1265 cx.update(|window, cx| {
1266 _ = window.draw(cx);
1267 });
1268
1269 for _ in 0..2 {
1273 cx.simulate_event(ScrollEvent {
1274 position: point(px(10.), px(10.)),
1275 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1276 ..Default::default()
1277 });
1278 }
1279
1280 assert_eq!(outer_handle.offset().y, px(-80.));
1281 cx.update(|window, cx| {
1282 _ = window.draw(cx);
1283 });
1284 assert_eq!(inner_handle.offset().y, px(-240.));
1285 }
1286
1287 struct ListWithVerticalAreaTest {
1291 scroll_handle: ScrollHandle,
1292 list_state: ListState,
1293 }
1294
1295 impl Render for ListWithVerticalAreaTest {
1296 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1297 let scroll_handle = self.scroll_handle.clone();
1298 div().w(px(100.)).h(px(100.)).child(
1299 list(self.list_state.clone(), move |ix, _, _| {
1300 if ix == 0 {
1301 div()
1302 .relative()
1303 .w_full()
1304 .h(px(60.))
1305 .child(
1306 div()
1307 .id("inner")
1308 .size_full()
1309 .overflow_y_scroll()
1310 .track_scroll(&scroll_handle)
1311 .child(div().w_full().h(px(300.))),
1312 )
1313 .child(ScrollableMask::new(Axis::Vertical, &scroll_handle))
1314 .into_any_element()
1315 } else {
1316 div().w(px(100.)).h(px(40.)).into_any_element()
1317 }
1318 })
1319 .w_full()
1320 .h_full(),
1321 )
1322 }
1323 }
1324
1325 #[gpui::test]
1326 fn vertical_mask_in_list_consumes_wheel_when_scrollable(cx: &mut TestAppContext) {
1327 let scroll_handle = ScrollHandle::new();
1328 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
1329 let (_, cx) = cx.add_window_view({
1330 let scroll_handle = scroll_handle.clone();
1331 let list_state = list_state.clone();
1332 move |_, _| ListWithVerticalAreaTest {
1333 scroll_handle: scroll_handle.clone(),
1334 list_state: list_state.clone(),
1335 }
1336 });
1337 cx.run_until_parked();
1338 cx.update(|window, cx| {
1339 _ = window.draw(cx);
1340 });
1341
1342 cx.simulate_event(ScrollEvent {
1343 position: point(px(10.), px(10.)),
1344 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1345 ..Default::default()
1346 });
1347
1348 assert_eq!(scroll_handle.offset().y, px(-40.));
1350 let scroll_top = list_state.logical_scroll_top();
1351 assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
1352 }
1353
1354 struct VerticalWheelTest {
1357 strip: ScrollHandle,
1358 page: ScrollHandle,
1359 }
1360
1361 impl Render for VerticalWheelTest {
1362 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1363 div()
1364 .id("page")
1365 .w(px(100.))
1366 .h(px(60.))
1367 .overflow_y_scroll()
1368 .track_scroll(&self.page)
1369 .child(
1370 div()
1371 .relative()
1372 .w(px(100.))
1373 .h(px(40.))
1374 .child(
1375 div()
1376 .id("strip")
1377 .size_full()
1378 .flex()
1379 .overflow_x_scroll()
1380 .restrict_scroll_to_axis()
1381 .track_scroll(&self.strip)
1382 .child(div().flex_none().w(px(300.)).h(px(40.))),
1383 )
1384 .child(ScrollableMask::vertical_wheel(&self.strip)),
1385 )
1386 .child(div().h(px(200.)))
1387 }
1388 }
1389
1390 fn vertical_wheel_test(
1391 cx: &mut TestAppContext,
1392 ) -> (&mut VisualTestContext, ScrollHandle, ScrollHandle) {
1393 let (strip, page) = (ScrollHandle::new(), ScrollHandle::new());
1394 let (_, cx) = cx.add_window_view({
1395 let (strip, page) = (strip.clone(), page.clone());
1396 move |_, _| VerticalWheelTest { strip, page }
1397 });
1398 cx.run_until_parked();
1399 cx.update(|window, cx| window.draw(cx).clear(cx));
1400 (cx, strip, page)
1401 }
1402
1403 fn wheel(cx: &mut VisualTestContext, delta: ScrollDelta) {
1404 cx.simulate_event(ScrollEvent {
1405 position: point(px(10.), px(10.)),
1406 delta,
1407 ..Default::default()
1408 });
1409 cx.update(|window, cx| window.draw(cx).clear(cx));
1410 }
1411
1412 #[gpui::test]
1413 fn vertical_wheel_scrolls_the_strip_and_then_the_parent(cx: &mut TestAppContext) {
1414 let (cx, strip, page) = vertical_wheel_test(cx);
1415 let line_height = cx.update(|window, _| window.line_height());
1416
1417 wheel(cx, ScrollDelta::Lines(point(0., -1.)));
1418 assert_eq!(strip.offset().x, -line_height);
1419 assert_eq!(page.offset().y, px(0.));
1420
1421 wheel(cx, ScrollDelta::Lines(point(0., -100.)));
1423 assert_eq!(strip.offset().x, px(-200.));
1424 assert_eq!(page.offset().y, px(0.));
1425 wheel(cx, ScrollDelta::Lines(point(0., -1.)));
1426 assert_eq!(strip.offset().x, px(-200.));
1427 assert!(page.offset().y < px(0.));
1428
1429 wheel(cx, ScrollDelta::Lines(point(0., f32::NAN)));
1431 assert_eq!(strip.offset().x, px(-200.));
1432 }
1433
1434 #[gpui::test]
1435 fn vertical_wheel_leaves_trackpads_and_horizontal_wheels_to_the_scroller(
1436 cx: &mut TestAppContext,
1437 ) {
1438 let (cx, strip, page) = vertical_wheel_test(cx);
1439 let line_height = cx.update(|window, _| window.line_height());
1440
1441 wheel(cx, ScrollDelta::Pixels(point(px(0.), px(-20.))));
1443 assert_eq!(strip.offset().x, px(0.));
1444 assert_eq!(page.offset().y, px(-20.));
1445
1446 wheel(cx, ScrollDelta::Lines(point(-1., 0.)));
1448 assert_eq!(strip.offset().x, -line_height);
1449 }
1450}