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, 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> {
301 axis: Axis,
302 id: ElementId,
303 scroll_handle: H,
304 debug: Option<Hsla>,
305}
306
307impl<H: ScrollbarHandle + Clone> ScrollableMask<H> {
308 #[track_caller]
310 pub fn new(axis: Axis, scroll_handle: &H) -> Self {
311 Self {
312 scroll_handle: scroll_handle.clone(),
313 axis,
314 id: caller_id(),
315 debug: None,
316 }
317 }
318
319 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
324 self.id = id.into();
325 self
326 }
327
328 #[allow(dead_code)]
330 pub fn debug(mut self) -> Self {
331 self.debug = Some(gpui::yellow());
332 self
333 }
334}
335
336impl<H: ScrollbarHandle + Clone> IntoElement for ScrollableMask<H> {
337 type Element = Self;
338
339 fn into_element(self) -> Self::Element {
340 self
341 }
342}
343
344impl<H: ScrollbarHandle + Clone> Element for ScrollableMask<H> {
345 type RequestLayoutState = ();
346 type PrepaintState = Hitbox;
347
348 fn id(&self) -> Option<ElementId> {
351 let axis = match self.axis {
352 Axis::Horizontal => "horizontal",
353 Axis::Vertical => "vertical",
354 };
355
356 Some((self.id.clone(), axis).into())
357 }
358
359 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
360 None
361 }
362
363 fn request_layout(
364 &mut self,
365 _: Option<&GlobalElementId>,
366 _: Option<&gpui::InspectorElementId>,
367 window: &mut Window,
368 cx: &mut App,
369 ) -> (LayoutId, Self::RequestLayoutState) {
370 let mut style = Style::default();
371 style.position = Position::Absolute;
373 style.flex_grow = 1.0;
374 style.flex_shrink = 1.0;
375 style.size.width = relative(1.).into();
376 style.size.height = relative(1.).into();
377
378 (window.request_layout(style, None, cx), ())
379 }
380
381 fn prepaint(
382 &mut self,
383 _: Option<&GlobalElementId>,
384 _: Option<&gpui::InspectorElementId>,
385 bounds: Bounds<Pixels>,
386 _: &mut Self::RequestLayoutState,
387 window: &mut Window,
388 _: &mut App,
389 ) -> Self::PrepaintState {
390 let cover_bounds = Bounds {
392 origin: Point {
393 x: bounds.origin.x,
394 y: bounds.origin.y - bounds.size.height,
395 },
396 size: bounds.size,
397 };
398
399 window.insert_hitbox(cover_bounds, gpui::HitboxBehavior::Normal)
400 }
401
402 fn paint(
403 &mut self,
404 global_id: Option<&GlobalElementId>,
405 _: Option<&gpui::InspectorElementId>,
406 _: Bounds<Pixels>,
407 _: &mut Self::RequestLayoutState,
408 hitbox: &mut Self::PrepaintState,
409 window: &mut Window,
410 _: &mut App,
411 ) {
412 let is_horizontal = self.axis.is_horizontal();
413 let line_height = window.line_height();
414 let bounds = hitbox.bounds;
415 let ongoing_scroll = global_id
416 .map(|global_id| {
417 window.with_element_state::<Rc<RefCell<OngoingScroll>>, _>(global_id, |state, _| {
418 let state = state.unwrap_or_default();
419 (state.clone(), state)
420 })
421 })
422 .unwrap_or_default();
423
424 window.with_content_mask(Some(ContentMask { bounds }), |window| {
425 if let Some(color) = self.debug {
426 window.paint_quad(PaintQuad {
427 bounds,
428 border_widths: Edges::all(px(1.0)),
429 border_color: color,
430 background: gpui::transparent_white().into(),
431 corner_radii: Corners::all(px(0.)),
432 border_style: BorderStyle::default(),
433 });
434 }
435
436 window.on_mouse_event({
437 let view_id = window.current_view();
438 let scroll_handle = self.scroll_handle.clone();
439 let hitbox_id = hitbox.id;
440 let ongoing_scroll = ongoing_scroll.clone();
441
442 move |event: &ScrollWheelEvent, phase, window, cx| {
443 if !(phase.capture() && hitbox_id.should_handle_scroll(window)) {
454 return;
455 }
456
457 let mut offset = scroll_handle.offset();
458 let mut delta = event.delta.pixel_delta(line_height);
459
460 if event.delta.precise() {
464 ongoing_scroll
465 .borrow_mut()
466 .lock_axis(&mut delta, event.touch_phase);
467 }
468
469 if !delta.x.is_zero() && !delta.y.is_zero() {
473 if delta.x.abs() > delta.y.abs() {
474 delta.y = px(0.);
475 } else {
476 delta.x = px(0.);
477 }
478 }
479
480 if !is_horizontal {
481 let axis_max = (scroll_handle.content_size().height
489 - scroll_handle.viewport_bounds().size.height)
490 .max(px(0.));
491 let current = offset.y.clamp(-axis_max, px(0.));
492 let new_offset = (current + delta.y).clamp(-axis_max, px(0.));
493 if new_offset == current {
494 return;
496 }
497
498 offset.y = new_offset;
499 scroll_handle.set_offset(offset);
500 cx.notify(view_id);
501 cx.stop_propagation();
502 return;
503 }
504
505 offset.x += delta.x;
506
507 if offset != scroll_handle.offset() {
513 scroll_handle.set_offset(offset);
514 cx.notify(view_id);
515 cx.stop_propagation();
516 }
517 }
518 });
519 });
520 }
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526 use gpui::{
527 Context, IntoElement, ListAlignment, ListState, Render, ScrollDelta, ScrollWheelEvent,
528 TestAppContext, VisualTestContext, Window, div, list, point, px,
529 };
530
531 #[test]
532 fn corner_notch_paths_stay_inside_their_corner_squares() {
533 let width = px(200.);
534 let height = px(100.);
535 let radius = px(8.);
536 let cases = [
537 ("top left", point(px(0.), px(0.)), 1., 1.),
538 ("top right", point(width, px(0.)), -1., 1.),
539 ("bottom right", point(width, height), -1., -1.),
540 ("bottom left", point(px(0.), height), 1., -1.),
541 ];
542 for (name, corner, x_dir, y_dir) in cases {
543 let path =
544 corner_notch_path(corner, x_dir, y_dir, radius).expect("the notch tessellates");
545 let bounds = path.bounds;
546 let tolerance = 1.0;
548 assert!(
549 (f32::from(bounds.size.width) - f32::from(radius)).abs() <= tolerance
550 && (f32::from(bounds.size.height) - f32::from(radius)).abs() <= tolerance,
551 "{name}: notch size {:?} must stay a {radius:?} square",
552 bounds.size,
553 );
554 let expected_origin = point(
555 if x_dir > 0. {
556 corner.x
557 } else {
558 corner.x - radius
559 },
560 if y_dir > 0. {
561 corner.y
562 } else {
563 corner.y - radius
564 },
565 );
566 assert!(
567 (f32::from(bounds.origin.x) - f32::from(expected_origin.x)).abs() <= tolerance
568 && (f32::from(bounds.origin.y) - f32::from(expected_origin.y)).abs()
569 <= tolerance,
570 "{name}: notch bounds {bounds:?} must anchor at the frame corner",
571 );
572 }
573 }
574
575 struct HorizontalScrollAreaTest {
576 scroll_handle: ScrollHandle,
577 }
578
579 impl Render for HorizontalScrollAreaTest {
580 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
581 div().w(px(100.)).h(px(40.)).child(horizontal_scroll_area(
582 "horizontal-scroll-area",
583 &self.scroll_handle,
584 &Default::default(),
585 div().w(px(300.)).h(px(40.)),
586 ))
587 }
588 }
589
590 #[gpui::test]
591 fn horizontal_scroll_area_ignores_vertical_wheel(cx: &mut TestAppContext) {
592 let scroll_handle = ScrollHandle::new();
593 let (_, cx) = cx.add_window_view({
594 let scroll_handle = scroll_handle.clone();
595 move |_, _| HorizontalScrollAreaTest {
596 scroll_handle: scroll_handle.clone(),
597 }
598 });
599 let cx: &mut VisualTestContext = cx;
600 cx.run_until_parked();
601 cx.update(|window, cx| {
602 _ = window.draw(cx);
603 });
604
605 cx.simulate_event(ScrollWheelEvent {
606 position: point(px(10.), px(10.)),
607 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
608 ..Default::default()
609 });
610
611 assert_eq!(scroll_handle.offset().x, px(0.));
612 }
613
614 struct ListWithHorizontalAreaTest {
619 scroll_handle: ScrollHandle,
620 list_state: ListState,
621 occluded: bool,
622 }
623
624 impl Render for ListWithHorizontalAreaTest {
625 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
626 let scroll_handle = self.scroll_handle.clone();
627 let mut root = div().w(px(100.)).h(px(100.)).child(
628 list(self.list_state.clone(), move |ix, _, _| {
629 if ix == 0 {
630 horizontal_scroll_area(
631 "horizontal-scroll-area",
632 &scroll_handle,
633 &Default::default(),
634 div().w(px(300.)).h(px(40.)),
635 )
636 .into_any_element()
637 } else {
638 div().w(px(100.)).h(px(40.)).into_any_element()
639 }
640 })
641 .w_full()
642 .h_full(),
643 );
644 if self.occluded {
645 root = root.child(div().absolute().top_0().left_0().size_full().occlude());
647 }
648 root
649 }
650 }
651
652 fn setup_list_test<'a>(
653 cx: &'a mut TestAppContext,
654 scroll_handle: &ScrollHandle,
655 list_state: &ListState,
656 occluded: bool,
657 ) -> &'a mut VisualTestContext {
658 let (_, cx) = cx.add_window_view({
659 let scroll_handle = scroll_handle.clone();
660 let list_state = list_state.clone();
661 move |_, _| ListWithHorizontalAreaTest {
662 scroll_handle: scroll_handle.clone(),
663 list_state: list_state.clone(),
664 occluded,
665 }
666 });
667 cx.run_until_parked();
668 cx.update(|window, cx| {
669 _ = window.draw(cx);
670 });
671 cx
672 }
673
674 #[gpui::test]
675 fn horizontal_scroll_area_in_list_keeps_horizontal_dominant_wheel(cx: &mut TestAppContext) {
676 let scroll_handle = ScrollHandle::new();
677 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
678 let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
679
680 cx.simulate_event(ScrollWheelEvent {
683 position: point(px(10.), px(10.)),
684 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
685 ..Default::default()
686 });
687
688 assert_eq!(scroll_handle.offset().x, px(-40.));
690 let scroll_top = list_state.logical_scroll_top();
692 assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
693 }
694
695 #[gpui::test]
696 fn horizontal_scroll_area_in_list_bubbles_vertical_dominant_wheel(cx: &mut TestAppContext) {
697 let scroll_handle = ScrollHandle::new();
698 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
699 let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
700
701 cx.simulate_event(ScrollWheelEvent {
702 position: point(px(10.), px(10.)),
703 delta: ScrollDelta::Pixels(point(px(-10.), px(-40.))),
704 ..Default::default()
705 });
706
707 assert_eq!(scroll_handle.offset().x, px(0.));
709 let scroll_top = list_state.logical_scroll_top();
710 assert_ne!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
711 }
712
713 struct ListWithVerticalMaskTest {
716 outer_handle: ScrollHandle,
717 list_state: ListState,
718 }
719
720 impl Render for ListWithVerticalMaskTest {
721 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
722 div()
723 .id("outer")
724 .w(px(100.))
725 .h(px(100.))
726 .overflow_y_scroll()
727 .track_scroll(&self.outer_handle)
728 .child(
729 div().w_full().h(px(400.)).child(
730 div()
731 .relative()
732 .w_full()
733 .h(px(80.))
734 .overflow_hidden()
735 .child(
736 list(self.list_state.clone(), |_, _, _| {
737 div().w_full().h(px(40.)).into_any_element()
738 })
739 .w_full()
740 .h_full(),
741 )
742 .child(ScrollableMask::new(Axis::Vertical, &self.list_state)),
743 ),
744 )
745 }
746 }
747
748 fn setup_vertical_mask_test<'a>(
749 cx: &'a mut TestAppContext,
750 outer_handle: &ScrollHandle,
751 list_state: &ListState,
752 ) -> &'a mut VisualTestContext {
753 let (_, cx) = cx.add_window_view({
754 let outer_handle = outer_handle.clone();
755 let list_state = list_state.clone();
756 move |_, _| ListWithVerticalMaskTest {
757 outer_handle: outer_handle.clone(),
758 list_state: list_state.clone(),
759 }
760 });
761 cx.run_until_parked();
762 cx.update(|window, cx| {
763 _ = window.draw(cx);
764 });
765 cx
766 }
767
768 #[gpui::test]
769 fn vertical_mask_contains_wheel_inside_the_list(cx: &mut TestAppContext) {
770 let outer_handle = ScrollHandle::new();
771 let list_state = ListState::new(10, ListAlignment::Top, px(1000.));
774 let cx = setup_vertical_mask_test(cx, &outer_handle, &list_state);
775
776 cx.simulate_event(ScrollWheelEvent {
777 position: point(px(10.), px(10.)),
778 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
779 ..Default::default()
780 });
781
782 assert_eq!(list_state.scroll_px_offset_for_scrollbar().y, px(-40.));
784 assert_eq!(outer_handle.offset().y, px(0.));
785 }
786
787 #[gpui::test]
788 fn vertical_mask_chains_to_the_ancestor_at_the_edge(cx: &mut TestAppContext) {
789 let outer_handle = ScrollHandle::new();
790 let list_state = ListState::new(10, ListAlignment::Top, px(1000.));
791 list_state.scroll_to(gpui::ListOffset {
793 item_ix: 8,
794 offset_in_item: px(0.),
795 });
796 let cx = setup_vertical_mask_test(cx, &outer_handle, &list_state);
797
798 cx.simulate_event(ScrollWheelEvent {
799 position: point(px(10.), px(10.)),
800 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
801 ..Default::default()
802 });
803
804 assert_eq!(list_state.scroll_px_offset_for_scrollbar().y, px(-320.));
806 assert_eq!(outer_handle.offset().y, px(-40.));
807 }
808
809 #[gpui::test]
810 fn horizontal_scroll_area_covers_viewport_after_scrolled(cx: &mut TestAppContext) {
811 let scroll_handle = ScrollHandle::new();
812 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
813 let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
814
815 scroll_handle.set_offset(point(px(-150.), px(0.)));
817 cx.update(|window, cx| {
818 _ = window.draw(cx);
819 });
820
821 cx.simulate_event(ScrollWheelEvent {
824 position: point(px(90.), px(10.)),
825 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
826 ..Default::default()
827 });
828
829 assert_eq!(scroll_handle.offset().x, px(-190.));
830 let scroll_top = list_state.logical_scroll_top();
831 assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
832 }
833
834 #[gpui::test]
835 fn horizontal_scroll_area_traps_wheel_at_edge(cx: &mut TestAppContext) {
836 let scroll_handle = ScrollHandle::new();
837 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
838 let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
839
840 scroll_handle.set_offset(point(px(-200.), px(0.)));
842 cx.update(|window, cx| {
843 _ = window.draw(cx);
844 });
845
846 cx.simulate_event(ScrollWheelEvent {
847 position: point(px(10.), px(10.)),
848 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
849 ..Default::default()
850 });
851
852 let scroll_top = list_state.logical_scroll_top();
855 assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
856 }
857
858 #[gpui::test]
859 fn horizontal_scroll_area_ignores_wheel_when_occluded(cx: &mut TestAppContext) {
860 let scroll_handle = ScrollHandle::new();
861 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
862 let cx = setup_list_test(cx, &scroll_handle, &list_state, true);
863
864 cx.simulate_event(ScrollWheelEvent {
865 position: point(px(10.), px(10.)),
866 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
867 ..Default::default()
868 });
869
870 assert_eq!(scroll_handle.offset().x, px(0.));
873 }
874
875 #[gpui::test]
876 fn horizontal_scroll_area_uses_horizontal_wheel(cx: &mut TestAppContext) {
877 let scroll_handle = ScrollHandle::new();
878 let (_, cx) = cx.add_window_view({
879 let scroll_handle = scroll_handle.clone();
880 move |_, _| HorizontalScrollAreaTest {
881 scroll_handle: scroll_handle.clone(),
882 }
883 });
884 let cx: &mut VisualTestContext = cx;
885 cx.run_until_parked();
886 cx.update(|window, cx| {
887 _ = window.draw(cx);
888 });
889
890 cx.simulate_event(ScrollWheelEvent {
891 position: point(px(10.), px(10.)),
892 delta: ScrollDelta::Pixels(point(px(-40.), px(0.))),
893 ..Default::default()
894 });
895
896 assert_eq!(scroll_handle.offset().x, px(-40.));
897 }
898
899 fn setup_horizontal_area_test<'a>(
900 cx: &'a mut TestAppContext,
901 scroll_handle: &ScrollHandle,
902 ) -> &'a mut VisualTestContext {
903 let (_, cx) = cx.add_window_view({
904 let scroll_handle = scroll_handle.clone();
905 move |_, _| HorizontalScrollAreaTest {
906 scroll_handle: scroll_handle.clone(),
907 }
908 });
909 let cx: &mut VisualTestContext = cx;
910 cx.run_until_parked();
911 cx.update(|window, cx| {
912 _ = window.draw(cx);
913 });
914 cx
915 }
916
917 #[gpui::test]
918 fn horizontal_mask_keeps_axis_lock_within_a_gesture(cx: &mut TestAppContext) {
919 let scroll_handle = ScrollHandle::new();
920 let cx = setup_horizontal_area_test(cx, &scroll_handle);
921
922 cx.simulate_event(ScrollWheelEvent {
923 position: point(px(10.), px(10.)),
924 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
925 ..Default::default()
926 });
927 assert_eq!(scroll_handle.offset().x, px(-40.));
928
929 cx.simulate_event(ScrollWheelEvent {
933 position: point(px(10.), px(10.)),
934 delta: ScrollDelta::Pixels(point(px(-10.), px(-15.))),
935 ..Default::default()
936 });
937 assert_eq!(scroll_handle.offset().x, px(-50.));
938 }
939
940 #[gpui::test]
941 fn horizontal_mask_releases_axis_lock_on_a_strong_turn(cx: &mut TestAppContext) {
942 let scroll_handle = ScrollHandle::new();
943 let cx = setup_horizontal_area_test(cx, &scroll_handle);
944
945 cx.simulate_event(ScrollWheelEvent {
946 position: point(px(10.), px(10.)),
947 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
948 ..Default::default()
949 });
950 assert_eq!(scroll_handle.offset().x, px(-40.));
951
952 cx.simulate_event(ScrollWheelEvent {
955 position: point(px(10.), px(10.)),
956 delta: ScrollDelta::Pixels(point(px(-10.), px(-25.))),
957 ..Default::default()
958 });
959 assert_eq!(scroll_handle.offset().x, px(-40.));
960 }
961
962 struct NestedVerticalScrollTest {
965 outer_handle: ScrollHandle,
966 inner_handle: ScrollHandle,
967 inner_content_height: Pixels,
968 }
969
970 impl Render for NestedVerticalScrollTest {
971 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
972 div()
973 .id("outer")
974 .w(px(100.))
975 .h(px(100.))
976 .overflow_y_scroll()
977 .track_scroll(&self.outer_handle)
978 .child(
979 div()
980 .relative()
981 .w_full()
982 .h(px(60.))
983 .child(
984 div()
985 .id("inner")
986 .size_full()
987 .overflow_y_scroll()
988 .track_scroll(&self.inner_handle)
989 .child(div().w_full().h(self.inner_content_height)),
990 )
991 .child(ScrollableMask::new(Axis::Vertical, &self.inner_handle)),
992 )
993 .child(div().w_full().h(px(400.)))
994 }
995 }
996
997 fn setup_nested_vertical_test<'a>(
998 cx: &'a mut TestAppContext,
999 outer_handle: &ScrollHandle,
1000 inner_handle: &ScrollHandle,
1001 inner_content_height: Pixels,
1002 ) -> &'a mut VisualTestContext {
1003 let (_, cx) = cx.add_window_view({
1004 let outer_handle = outer_handle.clone();
1005 let inner_handle = inner_handle.clone();
1006 move |_, _| NestedVerticalScrollTest {
1007 outer_handle: outer_handle.clone(),
1008 inner_handle: inner_handle.clone(),
1009 inner_content_height,
1010 }
1011 });
1012 cx.run_until_parked();
1013 cx.update(|window, cx| {
1014 _ = window.draw(cx);
1015 });
1016 cx
1017 }
1018
1019 #[gpui::test]
1020 fn vertical_mask_consumes_wheel_when_scrollable(cx: &mut TestAppContext) {
1021 let outer_handle = ScrollHandle::new();
1022 let inner_handle = ScrollHandle::new();
1023 let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(300.));
1024
1025 cx.simulate_event(ScrollWheelEvent {
1026 position: point(px(10.), px(10.)),
1027 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1028 ..Default::default()
1029 });
1030
1031 assert_eq!(inner_handle.offset().y, px(-40.));
1033 assert_eq!(outer_handle.offset().y, px(0.));
1034 }
1035
1036 #[gpui::test]
1037 fn vertical_mask_hands_off_to_parent_at_edge(cx: &mut TestAppContext) {
1038 let outer_handle = ScrollHandle::new();
1039 let inner_handle = ScrollHandle::new();
1040 let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(300.));
1041
1042 inner_handle.set_offset(point(px(0.), px(-240.)));
1044 cx.update(|window, cx| {
1045 _ = window.draw(cx);
1046 });
1047
1048 cx.simulate_event(ScrollWheelEvent {
1049 position: point(px(10.), px(10.)),
1050 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1051 ..Default::default()
1052 });
1053
1054 assert_eq!(outer_handle.offset().y, px(-40.));
1056 cx.update(|window, cx| {
1058 _ = window.draw(cx);
1059 });
1060 assert_eq!(inner_handle.offset().y, px(-240.));
1061 }
1062
1063 #[gpui::test]
1064 fn vertical_mask_bubbles_when_no_overflow(cx: &mut TestAppContext) {
1065 let outer_handle = ScrollHandle::new();
1066 let inner_handle = ScrollHandle::new();
1067 let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(40.));
1069
1070 cx.simulate_event(ScrollWheelEvent {
1071 position: point(px(10.), px(10.)),
1072 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1073 ..Default::default()
1074 });
1075
1076 assert_eq!(outer_handle.offset().y, px(-40.));
1077 cx.update(|window, cx| {
1078 _ = window.draw(cx);
1079 });
1080 assert_eq!(inner_handle.offset().y, px(0.));
1081 }
1082
1083 #[gpui::test]
1084 fn vertical_mask_ignores_transient_overscroll(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.)));
1090 cx.update(|window, cx| {
1091 _ = window.draw(cx);
1092 });
1093
1094 for _ in 0..2 {
1098 cx.simulate_event(ScrollWheelEvent {
1099 position: point(px(10.), px(10.)),
1100 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1101 ..Default::default()
1102 });
1103 }
1104
1105 assert_eq!(outer_handle.offset().y, px(-80.));
1106 cx.update(|window, cx| {
1107 _ = window.draw(cx);
1108 });
1109 assert_eq!(inner_handle.offset().y, px(-240.));
1110 }
1111
1112 struct ListWithVerticalAreaTest {
1116 scroll_handle: ScrollHandle,
1117 list_state: ListState,
1118 }
1119
1120 impl Render for ListWithVerticalAreaTest {
1121 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1122 let scroll_handle = self.scroll_handle.clone();
1123 div().w(px(100.)).h(px(100.)).child(
1124 list(self.list_state.clone(), move |ix, _, _| {
1125 if ix == 0 {
1126 div()
1127 .relative()
1128 .w_full()
1129 .h(px(60.))
1130 .child(
1131 div()
1132 .id("inner")
1133 .size_full()
1134 .overflow_y_scroll()
1135 .track_scroll(&scroll_handle)
1136 .child(div().w_full().h(px(300.))),
1137 )
1138 .child(ScrollableMask::new(Axis::Vertical, &scroll_handle))
1139 .into_any_element()
1140 } else {
1141 div().w(px(100.)).h(px(40.)).into_any_element()
1142 }
1143 })
1144 .w_full()
1145 .h_full(),
1146 )
1147 }
1148 }
1149
1150 #[gpui::test]
1151 fn vertical_mask_in_list_consumes_wheel_when_scrollable(cx: &mut TestAppContext) {
1152 let scroll_handle = ScrollHandle::new();
1153 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
1154 let (_, cx) = cx.add_window_view({
1155 let scroll_handle = scroll_handle.clone();
1156 let list_state = list_state.clone();
1157 move |_, _| ListWithVerticalAreaTest {
1158 scroll_handle: scroll_handle.clone(),
1159 list_state: list_state.clone(),
1160 }
1161 });
1162 cx.run_until_parked();
1163 cx.update(|window, cx| {
1164 _ = window.draw(cx);
1165 });
1166
1167 cx.simulate_event(ScrollWheelEvent {
1168 position: point(px(10.), px(10.)),
1169 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1170 ..Default::default()
1171 });
1172
1173 assert_eq!(scroll_handle.offset().y, px(-40.));
1175 let scroll_top = list_state.logical_scroll_top();
1176 assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
1177 }
1178}