1use std::cell::RefCell;
2use std::rc::Rc;
3
4use gpui::{
5 App, Axis, BorderStyle, Bounds, ContentMask, Edges, Element, ElementId, GlobalElementId,
6 Hitbox, Hsla, InteractiveElement as _, IntoElement, IsZero as _, LayoutId, OngoingScroll,
7 PaintQuad, ParentElement as _, Point, Position, ScrollDelta, ScrollHandle, ScrollWheelEvent,
8 StatefulInteractiveElement as _, Style, StyleRefinement, Styled as _, Window, div, px,
9 relative,
10};
11use gpui::{Corners, Pixels};
12
13use crate::{AxisExt, OngoingScrollExt as _, ScrollbarHandle, StyledExt as _};
14
15#[inline]
18#[track_caller]
19fn caller_id() -> ElementId {
20 ElementId::CodeLocation(*std::panic::Location::caller())
21}
22
23pub(crate) fn horizontal_scroll_area(
30 id: impl Into<ElementId>,
31 scroll_handle: &ScrollHandle,
32 style: &StyleRefinement,
33 child: impl IntoElement,
34) -> impl IntoElement {
35 let id = id.into();
36
37 let frame = RoundedFrameCover {
45 corner_radii: Corners {
46 top_left: style.corner_radii.top_left,
47 top_right: style.corner_radii.top_right,
48 bottom_left: style.corner_radii.bottom_left,
49 bottom_right: style.corner_radii.bottom_right,
50 },
51 backdrop: None,
52 frame_border: px(0.),
53 };
54
55 div()
56 .w_full()
57 .relative()
58 .child(
59 div()
60 .id(id.clone())
61 .w_full()
62 .refine_style(style)
63 .overflow_hidden()
64 .track_scroll(scroll_handle)
65 .child(child),
66 )
67 .child(ScrollableMask::new(Axis::Horizontal, scroll_handle).id(id))
68 .child(frame)
69}
70
71pub struct RoundedFrameCover {
86 corner_radii: Corners<Option<gpui::AbsoluteLength>>,
87 backdrop: Option<Hsla>,
88 frame_border: Pixels,
89}
90
91impl RoundedFrameCover {
92 pub fn uniform(radius: Pixels) -> Self {
94 let radius = Some(radius.into());
95 Self {
96 corner_radii: Corners {
97 top_left: radius,
98 top_right: radius,
99 bottom_left: radius,
100 bottom_right: radius,
101 },
102 backdrop: None,
103 frame_border: px(0.),
104 }
105 }
106
107 pub fn frame_border(mut self, width: Pixels) -> Self {
114 self.frame_border = width;
115 self
116 }
117
118 pub fn backdrop(mut self, color: Hsla) -> Self {
121 self.backdrop = Some(color);
122 self
123 }
124}
125
126impl IntoElement for RoundedFrameCover {
127 type Element = Self;
128
129 fn into_element(self) -> Self::Element {
130 self
131 }
132}
133
134impl Element for RoundedFrameCover {
135 type RequestLayoutState = ();
136 type PrepaintState = Bounds<Pixels>;
137
138 fn id(&self) -> Option<ElementId> {
139 None
140 }
141
142 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
143 None
144 }
145
146 fn request_layout(
147 &mut self,
148 _: Option<&GlobalElementId>,
149 _: Option<&gpui::InspectorElementId>,
150 window: &mut Window,
151 cx: &mut App,
152 ) -> (LayoutId, Self::RequestLayoutState) {
153 let mut style = Style::default();
155 style.position = Position::Absolute;
156 style.flex_grow = 1.0;
157 style.flex_shrink = 1.0;
158 style.size.width = relative(1.).into();
159 style.size.height = relative(1.).into();
160
161 (window.request_layout(style, None, cx), ())
162 }
163
164 fn prepaint(
165 &mut self,
166 _: Option<&GlobalElementId>,
167 _: Option<&gpui::InspectorElementId>,
168 bounds: Bounds<Pixels>,
169 _: &mut Self::RequestLayoutState,
170 _: &mut Window,
171 _: &mut App,
172 ) -> Self::PrepaintState {
173 Bounds {
175 origin: Point {
176 x: bounds.origin.x,
177 y: bounds.origin.y - bounds.size.height,
178 },
179 size: bounds.size,
180 }
181 }
182
183 fn paint(
184 &mut self,
185 _: Option<&GlobalElementId>,
186 _: Option<&gpui::InspectorElementId>,
187 _: Bounds<Pixels>,
188 _: &mut Self::RequestLayoutState,
189 cover_bounds: &mut Self::PrepaintState,
190 window: &mut Window,
191 cx: &mut App,
192 ) {
193 use crate::theme::ActiveTheme as _;
194
195 let mut bounds = *cover_bounds;
196 if !self.frame_border.is_zero() {
197 bounds.origin.x -= self.frame_border;
198 bounds.origin.y -= self.frame_border;
199 bounds.size.width += self.frame_border * 2.;
200 bounds.size.height += self.frame_border * 2.;
201 }
202 let rem_size = window.rem_size();
203 let resolve = |corner: Option<gpui::AbsoluteLength>| {
204 corner
205 .map(|radius| radius.to_pixels(rem_size))
206 .filter(|radius| !radius.is_zero())
207 };
208 let radii = Corners {
209 top_left: resolve(self.corner_radii.top_left),
210 top_right: resolve(self.corner_radii.top_right),
211 bottom_left: resolve(self.corner_radii.bottom_left),
212 bottom_right: resolve(self.corner_radii.bottom_right),
213 };
214 if radii.top_left.is_none()
215 && radii.top_right.is_none()
216 && radii.bottom_left.is_none()
217 && radii.bottom_right.is_none()
218 {
219 return;
220 }
221
222 let backdrop = self.backdrop.unwrap_or(cx.theme().tokens.colors.background);
223 let corners = [
224 (bounds.origin, 1., 1., radii.top_left),
225 (bounds.top_right(), -1., 1., radii.top_right),
226 (bounds.bottom_right(), -1., -1., radii.bottom_right),
227 (
228 Point {
229 x: bounds.origin.x,
230 y: bounds.bottom_right().y,
231 },
232 1.,
233 -1.,
234 radii.bottom_left,
235 ),
236 ];
237 for (corner, x_dir, y_dir, radius) in corners {
238 let Some(radius) = radius else { continue };
239 if let Some(path) = corner_notch_path(corner, x_dir, y_dir, radius) {
240 window.paint_path(path, backdrop);
241 }
242 }
243 }
244}
245
246fn corner_notch_path(
250 corner: Point<Pixels>,
251 x_dir: f32,
252 y_dir: f32,
253 radius: Pixels,
254) -> Option<gpui::Path<Pixels>> {
255 let mut builder = gpui::PathBuilder::fill();
256 builder.move_to(corner);
257 builder.line_to(Point {
258 x: corner.x + radius * x_dir,
259 y: corner.y,
260 });
261 builder.arc_to(
262 Point {
263 x: radius,
264 y: radius,
265 },
266 px(0.),
267 false,
268 x_dir * y_dir < 0.,
271 Point {
272 x: corner.x,
273 y: corner.y + radius * y_dir,
274 },
275 );
276 builder.close();
277 builder.build().ok()
278}
279
280pub struct ScrollableMask<H: ScrollbarHandle + Clone = ScrollHandle> {
305 axis: Axis,
306 id: ElementId,
307 scroll_handle: H,
308 vertical_wheel: bool,
311 debug: Option<Hsla>,
312}
313
314impl<H: ScrollbarHandle + Clone> ScrollableMask<H> {
315 #[track_caller]
317 pub fn new(axis: Axis, scroll_handle: &H) -> Self {
318 Self {
319 scroll_handle: scroll_handle.clone(),
320 axis,
321 id: caller_id(),
322 vertical_wheel: false,
323 debug: None,
324 }
325 }
326
327 #[track_caller]
337 pub fn vertical_wheel(scroll_handle: &H) -> Self {
338 Self {
339 vertical_wheel: true,
340 ..Self::new(Axis::Horizontal, scroll_handle)
341 }
342 }
343
344 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
349 self.id = id.into();
350 self
351 }
352
353 #[allow(dead_code)]
355 pub fn debug(mut self) -> Self {
356 self.debug = Some(gpui::yellow());
357 self
358 }
359}
360
361impl<H: ScrollbarHandle + Clone> IntoElement for ScrollableMask<H> {
362 type Element = Self;
363
364 fn into_element(self) -> Self::Element {
365 self
366 }
367}
368
369impl<H: ScrollbarHandle + Clone> Element for ScrollableMask<H> {
370 type RequestLayoutState = ();
371 type PrepaintState = Hitbox;
372
373 fn id(&self) -> Option<ElementId> {
376 let axis = match self.axis {
377 Axis::Horizontal => "horizontal",
378 Axis::Vertical => "vertical",
379 };
380
381 Some((self.id.clone(), axis).into())
382 }
383
384 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
385 None
386 }
387
388 fn request_layout(
389 &mut self,
390 _: Option<&GlobalElementId>,
391 _: Option<&gpui::InspectorElementId>,
392 window: &mut Window,
393 cx: &mut App,
394 ) -> (LayoutId, Self::RequestLayoutState) {
395 let mut style = Style::default();
396 style.position = Position::Absolute;
400 style.inset.top = px(0.).into();
401 style.inset.left = px(0.).into();
402 style.flex_grow = 1.0;
403 style.flex_shrink = 1.0;
404 style.size.width = relative(1.).into();
405 style.size.height = relative(1.).into();
406
407 (window.request_layout(style, None, cx), ())
408 }
409
410 fn prepaint(
411 &mut self,
412 _: Option<&GlobalElementId>,
413 _: Option<&gpui::InspectorElementId>,
414 bounds: Bounds<Pixels>,
415 _: &mut Self::RequestLayoutState,
416 window: &mut Window,
417 _: &mut App,
418 ) -> Self::PrepaintState {
419 window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal)
420 }
421
422 fn paint(
423 &mut self,
424 global_id: Option<&GlobalElementId>,
425 _: Option<&gpui::InspectorElementId>,
426 _: Bounds<Pixels>,
427 _: &mut Self::RequestLayoutState,
428 hitbox: &mut Self::PrepaintState,
429 window: &mut Window,
430 _: &mut App,
431 ) {
432 let is_horizontal = self.axis.is_horizontal();
433 let vertical_wheel = self.vertical_wheel;
434 let line_height = window.line_height();
435 let bounds = hitbox.bounds;
436 let ongoing_scroll = global_id
437 .map(|global_id| {
438 window.with_element_state::<Rc<RefCell<OngoingScroll>>, _>(global_id, |state, _| {
439 let state = state.unwrap_or_default();
440 (state.clone(), state)
441 })
442 })
443 .unwrap_or_default();
444
445 window.with_content_mask(Some(ContentMask { bounds }), |window| {
446 if let Some(color) = self.debug {
447 window.paint_quad(PaintQuad {
448 bounds,
449 border_widths: Edges::all(px(1.0)),
450 border_color: color,
451 background: gpui::transparent_white().into(),
452 corner_radii: Corners::all(px(0.)),
453 border_style: BorderStyle::default(),
454 });
455 }
456
457 window.on_mouse_event({
458 let view_id = window.current_view();
459 let scroll_handle = self.scroll_handle.clone();
460 let hitbox_id = hitbox.id;
461 let ongoing_scroll = ongoing_scroll.clone();
462
463 move |event: &ScrollWheelEvent, phase, window, cx| {
464 if !(phase.capture() && hitbox_id.should_handle_scroll(window)) {
475 return;
476 }
477
478 let mut offset = scroll_handle.offset();
479 let mut delta = event.delta.pixel_delta(line_height);
480
481 if vertical_wheel {
485 let ScrollDelta::Lines(lines) = event.delta else {
486 return;
487 };
488 if lines.x != 0. || lines.y == 0. || !lines.y.is_finite() {
489 return;
490 }
491 let axis_max = (scroll_handle.content_size().width
492 - scroll_handle.viewport_bounds().size.width)
493 .max(px(0.));
494 let current = offset.x.clamp(-axis_max, px(0.));
495 let new_offset = (current + delta.y).clamp(-axis_max, px(0.));
496 if new_offset == current {
497 return;
498 }
499
500 offset.x = new_offset;
501 scroll_handle.set_offset(offset);
502 cx.notify(view_id);
503 cx.stop_propagation();
504 return;
505 }
506
507 if event.delta.precise() {
511 ongoing_scroll
512 .borrow_mut()
513 .lock_axis(&mut delta, event.touch_phase);
514 }
515
516 if !delta.x.is_zero() && !delta.y.is_zero() {
520 if delta.x.abs() > delta.y.abs() {
521 delta.y = px(0.);
522 } else {
523 delta.x = px(0.);
524 }
525 }
526
527 if !is_horizontal {
528 let axis_max = (scroll_handle.content_size().height
536 - scroll_handle.viewport_bounds().size.height)
537 .max(px(0.));
538 let current = offset.y.clamp(-axis_max, px(0.));
539 let new_offset = (current + delta.y).clamp(-axis_max, px(0.));
540 if new_offset == current {
541 return;
543 }
544
545 offset.y = new_offset;
546 scroll_handle.set_offset(offset);
547 cx.notify(view_id);
548 cx.stop_propagation();
549 return;
550 }
551
552 offset.x += delta.x;
553
554 if offset != scroll_handle.offset() {
560 scroll_handle.set_offset(offset);
561 cx.notify(view_id);
562 cx.stop_propagation();
563 }
564 }
565 });
566 });
567 }
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573 use gpui::{
574 Context, IntoElement, ListAlignment, ListState, Render, ScrollDelta, ScrollWheelEvent,
575 TestAppContext, VisualTestContext, Window, div, list, point, px,
576 };
577
578 #[test]
579 fn corner_notch_paths_stay_inside_their_corner_squares() {
580 let width = px(200.);
581 let height = px(100.);
582 let radius = px(8.);
583 let cases = [
584 ("top left", point(px(0.), px(0.)), 1., 1.),
585 ("top right", point(width, px(0.)), -1., 1.),
586 ("bottom right", point(width, height), -1., -1.),
587 ("bottom left", point(px(0.), height), 1., -1.),
588 ];
589 for (name, corner, x_dir, y_dir) in cases {
590 let path =
591 corner_notch_path(corner, x_dir, y_dir, radius).expect("the notch tessellates");
592 let bounds = path.bounds;
593 let tolerance = 1.0;
595 assert!(
596 (f32::from(bounds.size.width) - f32::from(radius)).abs() <= tolerance
597 && (f32::from(bounds.size.height) - f32::from(radius)).abs() <= tolerance,
598 "{name}: notch size {:?} must stay a {radius:?} square",
599 bounds.size,
600 );
601 let expected_origin = point(
602 if x_dir > 0. {
603 corner.x
604 } else {
605 corner.x - radius
606 },
607 if y_dir > 0. {
608 corner.y
609 } else {
610 corner.y - radius
611 },
612 );
613 assert!(
614 (f32::from(bounds.origin.x) - f32::from(expected_origin.x)).abs() <= tolerance
615 && (f32::from(bounds.origin.y) - f32::from(expected_origin.y)).abs()
616 <= tolerance,
617 "{name}: notch bounds {bounds:?} must anchor at the frame corner",
618 );
619 }
620 }
621
622 struct HorizontalScrollAreaTest {
623 scroll_handle: ScrollHandle,
624 }
625
626 impl Render for HorizontalScrollAreaTest {
627 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
628 div().w(px(100.)).h(px(40.)).child(horizontal_scroll_area(
629 "horizontal-scroll-area",
630 &self.scroll_handle,
631 &Default::default(),
632 div().w(px(300.)).h(px(40.)),
633 ))
634 }
635 }
636
637 #[gpui::test]
638 fn horizontal_scroll_area_ignores_vertical_wheel(cx: &mut TestAppContext) {
639 let scroll_handle = ScrollHandle::new();
640 let (_, cx) = cx.add_window_view({
641 let scroll_handle = scroll_handle.clone();
642 move |_, _| HorizontalScrollAreaTest {
643 scroll_handle: scroll_handle.clone(),
644 }
645 });
646 let cx: &mut VisualTestContext = cx;
647 cx.run_until_parked();
648 cx.update(|window, cx| {
649 _ = window.draw(cx);
650 });
651
652 cx.simulate_event(ScrollWheelEvent {
653 position: point(px(10.), px(10.)),
654 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
655 ..Default::default()
656 });
657
658 assert_eq!(scroll_handle.offset().x, px(0.));
659 }
660
661 struct ListWithHorizontalAreaTest {
666 scroll_handle: ScrollHandle,
667 list_state: ListState,
668 occluded: bool,
669 }
670
671 impl Render for ListWithHorizontalAreaTest {
672 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
673 let scroll_handle = self.scroll_handle.clone();
674 let mut root = div().w(px(100.)).h(px(100.)).child(
675 list(self.list_state.clone(), move |ix, _, _| {
676 if ix == 0 {
677 horizontal_scroll_area(
678 "horizontal-scroll-area",
679 &scroll_handle,
680 &Default::default(),
681 div().w(px(300.)).h(px(40.)),
682 )
683 .into_any_element()
684 } else {
685 div().w(px(100.)).h(px(40.)).into_any_element()
686 }
687 })
688 .w_full()
689 .h_full(),
690 );
691 if self.occluded {
692 root = root.child(div().absolute().top_0().left_0().size_full().occlude());
694 }
695 root
696 }
697 }
698
699 fn setup_list_test<'a>(
700 cx: &'a mut TestAppContext,
701 scroll_handle: &ScrollHandle,
702 list_state: &ListState,
703 occluded: bool,
704 ) -> &'a mut VisualTestContext {
705 let (_, cx) = cx.add_window_view({
706 let scroll_handle = scroll_handle.clone();
707 let list_state = list_state.clone();
708 move |_, _| ListWithHorizontalAreaTest {
709 scroll_handle: scroll_handle.clone(),
710 list_state: list_state.clone(),
711 occluded,
712 }
713 });
714 cx.run_until_parked();
715 cx.update(|window, cx| {
716 _ = window.draw(cx);
717 });
718 cx
719 }
720
721 #[gpui::test]
722 fn horizontal_scroll_area_in_list_keeps_horizontal_dominant_wheel(cx: &mut TestAppContext) {
723 let scroll_handle = ScrollHandle::new();
724 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
725 let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
726
727 cx.simulate_event(ScrollWheelEvent {
730 position: point(px(10.), px(10.)),
731 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
732 ..Default::default()
733 });
734
735 assert_eq!(scroll_handle.offset().x, px(-40.));
737 let scroll_top = list_state.logical_scroll_top();
739 assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
740 }
741
742 #[gpui::test]
743 fn horizontal_scroll_area_in_list_bubbles_vertical_dominant_wheel(cx: &mut TestAppContext) {
744 let scroll_handle = ScrollHandle::new();
745 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
746 let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
747
748 cx.simulate_event(ScrollWheelEvent {
749 position: point(px(10.), px(10.)),
750 delta: ScrollDelta::Pixels(point(px(-10.), px(-40.))),
751 ..Default::default()
752 });
753
754 assert_eq!(scroll_handle.offset().x, px(0.));
756 let scroll_top = list_state.logical_scroll_top();
757 assert_ne!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
758 }
759
760 struct ListWithVerticalMaskTest {
763 outer_handle: ScrollHandle,
764 list_state: ListState,
765 }
766
767 impl Render for ListWithVerticalMaskTest {
768 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
769 div()
770 .id("outer")
771 .w(px(100.))
772 .h(px(100.))
773 .overflow_y_scroll()
774 .track_scroll(&self.outer_handle)
775 .child(
776 div().w_full().h(px(400.)).child(
777 div()
778 .relative()
779 .w_full()
780 .h(px(80.))
781 .overflow_hidden()
782 .child(
783 list(self.list_state.clone(), |_, _, _| {
784 div().w_full().h(px(40.)).into_any_element()
785 })
786 .w_full()
787 .h_full(),
788 )
789 .child(ScrollableMask::new(Axis::Vertical, &self.list_state)),
790 ),
791 )
792 }
793 }
794
795 fn setup_vertical_mask_test<'a>(
796 cx: &'a mut TestAppContext,
797 outer_handle: &ScrollHandle,
798 list_state: &ListState,
799 ) -> &'a mut VisualTestContext {
800 let (_, cx) = cx.add_window_view({
801 let outer_handle = outer_handle.clone();
802 let list_state = list_state.clone();
803 move |_, _| ListWithVerticalMaskTest {
804 outer_handle: outer_handle.clone(),
805 list_state: list_state.clone(),
806 }
807 });
808 cx.run_until_parked();
809 cx.update(|window, cx| {
810 _ = window.draw(cx);
811 });
812 cx
813 }
814
815 #[gpui::test]
816 fn vertical_mask_contains_wheel_inside_the_list(cx: &mut TestAppContext) {
817 let outer_handle = ScrollHandle::new();
818 let list_state = ListState::new(10, ListAlignment::Top, px(1000.));
821 let cx = setup_vertical_mask_test(cx, &outer_handle, &list_state);
822
823 cx.simulate_event(ScrollWheelEvent {
824 position: point(px(10.), px(10.)),
825 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
826 ..Default::default()
827 });
828
829 assert_eq!(list_state.scroll_px_offset_for_scrollbar().y, px(-40.));
831 assert_eq!(outer_handle.offset().y, px(0.));
832 }
833
834 #[gpui::test]
835 fn vertical_mask_chains_to_the_ancestor_at_the_edge(cx: &mut TestAppContext) {
836 let outer_handle = ScrollHandle::new();
837 let list_state = ListState::new(10, ListAlignment::Top, px(1000.));
838 list_state.scroll_to(gpui::ListOffset {
840 item_ix: 8,
841 offset_in_item: px(0.),
842 });
843 let cx = setup_vertical_mask_test(cx, &outer_handle, &list_state);
844
845 cx.simulate_event(ScrollWheelEvent {
846 position: point(px(10.), px(10.)),
847 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
848 ..Default::default()
849 });
850
851 assert_eq!(list_state.scroll_px_offset_for_scrollbar().y, px(-320.));
853 assert_eq!(outer_handle.offset().y, px(-40.));
854 }
855
856 #[gpui::test]
857 fn horizontal_scroll_area_covers_viewport_after_scrolled(cx: &mut TestAppContext) {
858 let scroll_handle = ScrollHandle::new();
859 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
860 let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
861
862 scroll_handle.set_offset(point(px(-150.), px(0.)));
864 cx.update(|window, cx| {
865 _ = window.draw(cx);
866 });
867
868 cx.simulate_event(ScrollWheelEvent {
871 position: point(px(90.), px(10.)),
872 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
873 ..Default::default()
874 });
875
876 assert_eq!(scroll_handle.offset().x, px(-190.));
877 let scroll_top = list_state.logical_scroll_top();
878 assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
879 }
880
881 #[gpui::test]
882 fn horizontal_scroll_area_traps_wheel_at_edge(cx: &mut TestAppContext) {
883 let scroll_handle = ScrollHandle::new();
884 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
885 let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
886
887 scroll_handle.set_offset(point(px(-200.), px(0.)));
889 cx.update(|window, cx| {
890 _ = window.draw(cx);
891 });
892
893 cx.simulate_event(ScrollWheelEvent {
894 position: point(px(10.), px(10.)),
895 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
896 ..Default::default()
897 });
898
899 let scroll_top = list_state.logical_scroll_top();
902 assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
903 }
904
905 #[gpui::test]
906 fn horizontal_scroll_area_ignores_wheel_when_occluded(cx: &mut TestAppContext) {
907 let scroll_handle = ScrollHandle::new();
908 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
909 let cx = setup_list_test(cx, &scroll_handle, &list_state, true);
910
911 cx.simulate_event(ScrollWheelEvent {
912 position: point(px(10.), px(10.)),
913 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
914 ..Default::default()
915 });
916
917 assert_eq!(scroll_handle.offset().x, px(0.));
920 }
921
922 #[gpui::test]
923 fn horizontal_scroll_area_uses_horizontal_wheel(cx: &mut TestAppContext) {
924 let scroll_handle = ScrollHandle::new();
925 let (_, cx) = cx.add_window_view({
926 let scroll_handle = scroll_handle.clone();
927 move |_, _| HorizontalScrollAreaTest {
928 scroll_handle: scroll_handle.clone(),
929 }
930 });
931 let cx: &mut VisualTestContext = cx;
932 cx.run_until_parked();
933 cx.update(|window, cx| {
934 _ = window.draw(cx);
935 });
936
937 cx.simulate_event(ScrollWheelEvent {
938 position: point(px(10.), px(10.)),
939 delta: ScrollDelta::Pixels(point(px(-40.), px(0.))),
940 ..Default::default()
941 });
942
943 assert_eq!(scroll_handle.offset().x, px(-40.));
944 }
945
946 fn setup_horizontal_area_test<'a>(
947 cx: &'a mut TestAppContext,
948 scroll_handle: &ScrollHandle,
949 ) -> &'a mut VisualTestContext {
950 let (_, cx) = cx.add_window_view({
951 let scroll_handle = scroll_handle.clone();
952 move |_, _| HorizontalScrollAreaTest {
953 scroll_handle: scroll_handle.clone(),
954 }
955 });
956 let cx: &mut VisualTestContext = cx;
957 cx.run_until_parked();
958 cx.update(|window, cx| {
959 _ = window.draw(cx);
960 });
961 cx
962 }
963
964 #[gpui::test]
965 fn horizontal_mask_keeps_axis_lock_within_a_gesture(cx: &mut TestAppContext) {
966 let scroll_handle = ScrollHandle::new();
967 let cx = setup_horizontal_area_test(cx, &scroll_handle);
968
969 cx.simulate_event(ScrollWheelEvent {
970 position: point(px(10.), px(10.)),
971 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
972 ..Default::default()
973 });
974 assert_eq!(scroll_handle.offset().x, px(-40.));
975
976 cx.simulate_event(ScrollWheelEvent {
980 position: point(px(10.), px(10.)),
981 delta: ScrollDelta::Pixels(point(px(-10.), px(-15.))),
982 ..Default::default()
983 });
984 assert_eq!(scroll_handle.offset().x, px(-50.));
985 }
986
987 #[gpui::test]
988 fn horizontal_mask_releases_axis_lock_on_a_strong_turn(cx: &mut TestAppContext) {
989 let scroll_handle = ScrollHandle::new();
990 let cx = setup_horizontal_area_test(cx, &scroll_handle);
991
992 cx.simulate_event(ScrollWheelEvent {
993 position: point(px(10.), px(10.)),
994 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
995 ..Default::default()
996 });
997 assert_eq!(scroll_handle.offset().x, px(-40.));
998
999 cx.simulate_event(ScrollWheelEvent {
1002 position: point(px(10.), px(10.)),
1003 delta: ScrollDelta::Pixels(point(px(-10.), px(-25.))),
1004 ..Default::default()
1005 });
1006 assert_eq!(scroll_handle.offset().x, px(-40.));
1007 }
1008
1009 struct NestedVerticalScrollTest {
1012 outer_handle: ScrollHandle,
1013 inner_handle: ScrollHandle,
1014 inner_content_height: Pixels,
1015 }
1016
1017 impl Render for NestedVerticalScrollTest {
1018 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1019 div()
1020 .id("outer")
1021 .w(px(100.))
1022 .h(px(100.))
1023 .overflow_y_scroll()
1024 .track_scroll(&self.outer_handle)
1025 .child(
1026 div()
1027 .relative()
1028 .w_full()
1029 .h(px(60.))
1030 .child(
1031 div()
1032 .id("inner")
1033 .size_full()
1034 .overflow_y_scroll()
1035 .track_scroll(&self.inner_handle)
1036 .child(div().w_full().h(self.inner_content_height)),
1037 )
1038 .child(ScrollableMask::new(Axis::Vertical, &self.inner_handle)),
1039 )
1040 .child(div().w_full().h(px(400.)))
1041 }
1042 }
1043
1044 fn setup_nested_vertical_test<'a>(
1045 cx: &'a mut TestAppContext,
1046 outer_handle: &ScrollHandle,
1047 inner_handle: &ScrollHandle,
1048 inner_content_height: Pixels,
1049 ) -> &'a mut VisualTestContext {
1050 let (_, cx) = cx.add_window_view({
1051 let outer_handle = outer_handle.clone();
1052 let inner_handle = inner_handle.clone();
1053 move |_, _| NestedVerticalScrollTest {
1054 outer_handle: outer_handle.clone(),
1055 inner_handle: inner_handle.clone(),
1056 inner_content_height,
1057 }
1058 });
1059 cx.run_until_parked();
1060 cx.update(|window, cx| {
1061 _ = window.draw(cx);
1062 });
1063 cx
1064 }
1065
1066 #[gpui::test]
1067 fn vertical_mask_consumes_wheel_when_scrollable(cx: &mut TestAppContext) {
1068 let outer_handle = ScrollHandle::new();
1069 let inner_handle = ScrollHandle::new();
1070 let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(300.));
1071
1072 cx.simulate_event(ScrollWheelEvent {
1073 position: point(px(10.), px(10.)),
1074 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1075 ..Default::default()
1076 });
1077
1078 assert_eq!(inner_handle.offset().y, px(-40.));
1080 assert_eq!(outer_handle.offset().y, px(0.));
1081 }
1082
1083 #[gpui::test]
1084 fn vertical_mask_hands_off_to_parent_at_edge(cx: &mut TestAppContext) {
1085 let outer_handle = ScrollHandle::new();
1086 let inner_handle = ScrollHandle::new();
1087 let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(300.));
1088
1089 inner_handle.set_offset(point(px(0.), px(-240.)));
1091 cx.update(|window, cx| {
1092 _ = window.draw(cx);
1093 });
1094
1095 cx.simulate_event(ScrollWheelEvent {
1096 position: point(px(10.), px(10.)),
1097 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1098 ..Default::default()
1099 });
1100
1101 assert_eq!(outer_handle.offset().y, px(-40.));
1103 cx.update(|window, cx| {
1105 _ = window.draw(cx);
1106 });
1107 assert_eq!(inner_handle.offset().y, px(-240.));
1108 }
1109
1110 #[gpui::test]
1111 fn vertical_mask_bubbles_when_no_overflow(cx: &mut TestAppContext) {
1112 let outer_handle = ScrollHandle::new();
1113 let inner_handle = ScrollHandle::new();
1114 let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(40.));
1116
1117 cx.simulate_event(ScrollWheelEvent {
1118 position: point(px(10.), px(10.)),
1119 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1120 ..Default::default()
1121 });
1122
1123 assert_eq!(outer_handle.offset().y, px(-40.));
1124 cx.update(|window, cx| {
1125 _ = window.draw(cx);
1126 });
1127 assert_eq!(inner_handle.offset().y, px(0.));
1128 }
1129
1130 #[gpui::test]
1131 fn vertical_mask_ignores_transient_overscroll(cx: &mut TestAppContext) {
1132 let outer_handle = ScrollHandle::new();
1133 let inner_handle = ScrollHandle::new();
1134 let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(300.));
1135
1136 inner_handle.set_offset(point(px(0.), px(-240.)));
1137 cx.update(|window, cx| {
1138 _ = window.draw(cx);
1139 });
1140
1141 for _ in 0..2 {
1145 cx.simulate_event(ScrollWheelEvent {
1146 position: point(px(10.), px(10.)),
1147 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1148 ..Default::default()
1149 });
1150 }
1151
1152 assert_eq!(outer_handle.offset().y, px(-80.));
1153 cx.update(|window, cx| {
1154 _ = window.draw(cx);
1155 });
1156 assert_eq!(inner_handle.offset().y, px(-240.));
1157 }
1158
1159 struct ListWithVerticalAreaTest {
1163 scroll_handle: ScrollHandle,
1164 list_state: ListState,
1165 }
1166
1167 impl Render for ListWithVerticalAreaTest {
1168 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1169 let scroll_handle = self.scroll_handle.clone();
1170 div().w(px(100.)).h(px(100.)).child(
1171 list(self.list_state.clone(), move |ix, _, _| {
1172 if ix == 0 {
1173 div()
1174 .relative()
1175 .w_full()
1176 .h(px(60.))
1177 .child(
1178 div()
1179 .id("inner")
1180 .size_full()
1181 .overflow_y_scroll()
1182 .track_scroll(&scroll_handle)
1183 .child(div().w_full().h(px(300.))),
1184 )
1185 .child(ScrollableMask::new(Axis::Vertical, &scroll_handle))
1186 .into_any_element()
1187 } else {
1188 div().w(px(100.)).h(px(40.)).into_any_element()
1189 }
1190 })
1191 .w_full()
1192 .h_full(),
1193 )
1194 }
1195 }
1196
1197 #[gpui::test]
1198 fn vertical_mask_in_list_consumes_wheel_when_scrollable(cx: &mut TestAppContext) {
1199 let scroll_handle = ScrollHandle::new();
1200 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
1201 let (_, cx) = cx.add_window_view({
1202 let scroll_handle = scroll_handle.clone();
1203 let list_state = list_state.clone();
1204 move |_, _| ListWithVerticalAreaTest {
1205 scroll_handle: scroll_handle.clone(),
1206 list_state: list_state.clone(),
1207 }
1208 });
1209 cx.run_until_parked();
1210 cx.update(|window, cx| {
1211 _ = window.draw(cx);
1212 });
1213
1214 cx.simulate_event(ScrollWheelEvent {
1215 position: point(px(10.), px(10.)),
1216 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1217 ..Default::default()
1218 });
1219
1220 assert_eq!(scroll_handle.offset().y, px(-40.));
1222 let scroll_top = list_state.logical_scroll_top();
1223 assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
1224 }
1225
1226 struct VerticalWheelTest {
1229 strip: ScrollHandle,
1230 page: ScrollHandle,
1231 }
1232
1233 impl Render for VerticalWheelTest {
1234 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1235 div()
1236 .id("page")
1237 .w(px(100.))
1238 .h(px(60.))
1239 .overflow_y_scroll()
1240 .track_scroll(&self.page)
1241 .child(
1242 div()
1243 .relative()
1244 .w(px(100.))
1245 .h(px(40.))
1246 .child(
1247 div()
1248 .id("strip")
1249 .size_full()
1250 .flex()
1251 .overflow_x_scroll()
1252 .restrict_scroll_to_axis()
1253 .track_scroll(&self.strip)
1254 .child(div().flex_none().w(px(300.)).h(px(40.))),
1255 )
1256 .child(ScrollableMask::vertical_wheel(&self.strip)),
1257 )
1258 .child(div().h(px(200.)))
1259 }
1260 }
1261
1262 fn vertical_wheel_test(
1263 cx: &mut TestAppContext,
1264 ) -> (&mut VisualTestContext, ScrollHandle, ScrollHandle) {
1265 let (strip, page) = (ScrollHandle::new(), ScrollHandle::new());
1266 let (_, cx) = cx.add_window_view({
1267 let (strip, page) = (strip.clone(), page.clone());
1268 move |_, _| VerticalWheelTest { strip, page }
1269 });
1270 cx.run_until_parked();
1271 cx.update(|window, cx| window.draw(cx).clear(cx));
1272 (cx, strip, page)
1273 }
1274
1275 fn wheel(cx: &mut VisualTestContext, delta: ScrollDelta) {
1276 cx.simulate_event(ScrollWheelEvent {
1277 position: point(px(10.), px(10.)),
1278 delta,
1279 ..Default::default()
1280 });
1281 cx.update(|window, cx| window.draw(cx).clear(cx));
1282 }
1283
1284 #[gpui::test]
1285 fn vertical_wheel_scrolls_the_strip_and_then_the_parent(cx: &mut TestAppContext) {
1286 let (cx, strip, page) = vertical_wheel_test(cx);
1287 let line_height = cx.update(|window, _| window.line_height());
1288
1289 wheel(cx, ScrollDelta::Lines(point(0., -1.)));
1290 assert_eq!(strip.offset().x, -line_height);
1291 assert_eq!(page.offset().y, px(0.));
1292
1293 wheel(cx, ScrollDelta::Lines(point(0., -100.)));
1295 assert_eq!(strip.offset().x, px(-200.));
1296 assert_eq!(page.offset().y, px(0.));
1297 wheel(cx, ScrollDelta::Lines(point(0., -1.)));
1298 assert_eq!(strip.offset().x, px(-200.));
1299 assert!(page.offset().y < px(0.));
1300
1301 wheel(cx, ScrollDelta::Lines(point(0., f32::NAN)));
1303 assert_eq!(strip.offset().x, px(-200.));
1304 }
1305
1306 #[gpui::test]
1307 fn vertical_wheel_leaves_trackpads_and_horizontal_wheels_to_the_scroller(
1308 cx: &mut TestAppContext,
1309 ) {
1310 let (cx, strip, page) = vertical_wheel_test(cx);
1311 let line_height = cx.update(|window, _| window.line_height());
1312
1313 wheel(cx, ScrollDelta::Pixels(point(px(0.), px(-20.))));
1315 assert_eq!(strip.offset().x, px(0.));
1316 assert_eq!(page.offset().y, px(-20.));
1317
1318 wheel(cx, ScrollDelta::Lines(point(-1., 0.)));
1320 assert_eq!(strip.offset().x, -line_height);
1321 }
1322}