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 div()
42 .w_full()
43 .relative()
44 .child(
45 div()
46 .id(id.clone())
47 .w_full()
48 .refine_style(style)
49 .overflow_hidden()
50 .track_scroll(scroll_handle)
51 .child(child),
52 )
53 .child(ScrollableMask::new(Axis::Horizontal, scroll_handle).id(id))
54}
55
56pub struct ScrollableMask<H: ScrollbarHandle + Clone = ScrollHandle> {
77 axis: Axis,
78 id: ElementId,
79 scroll_handle: H,
80 debug: Option<Hsla>,
81}
82
83impl<H: ScrollbarHandle + Clone> ScrollableMask<H> {
84 #[track_caller]
86 pub fn new(axis: Axis, scroll_handle: &H) -> Self {
87 Self {
88 scroll_handle: scroll_handle.clone(),
89 axis,
90 id: caller_id(),
91 debug: None,
92 }
93 }
94
95 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
100 self.id = id.into();
101 self
102 }
103
104 #[allow(dead_code)]
106 pub fn debug(mut self) -> Self {
107 self.debug = Some(gpui::yellow());
108 self
109 }
110}
111
112impl<H: ScrollbarHandle + Clone> IntoElement for ScrollableMask<H> {
113 type Element = Self;
114
115 fn into_element(self) -> Self::Element {
116 self
117 }
118}
119
120impl<H: ScrollbarHandle + Clone> Element for ScrollableMask<H> {
121 type RequestLayoutState = ();
122 type PrepaintState = Hitbox;
123
124 fn id(&self) -> Option<ElementId> {
127 let axis = match self.axis {
128 Axis::Horizontal => "horizontal",
129 Axis::Vertical => "vertical",
130 };
131
132 Some((self.id.clone(), axis).into())
133 }
134
135 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
136 None
137 }
138
139 fn request_layout(
140 &mut self,
141 _: Option<&GlobalElementId>,
142 _: Option<&gpui::InspectorElementId>,
143 window: &mut Window,
144 cx: &mut App,
145 ) -> (LayoutId, Self::RequestLayoutState) {
146 let mut style = Style::default();
147 style.position = Position::Absolute;
149 style.flex_grow = 1.0;
150 style.flex_shrink = 1.0;
151 style.size.width = relative(1.).into();
152 style.size.height = relative(1.).into();
153
154 (window.request_layout(style, None, cx), ())
155 }
156
157 fn prepaint(
158 &mut self,
159 _: Option<&GlobalElementId>,
160 _: Option<&gpui::InspectorElementId>,
161 bounds: Bounds<Pixels>,
162 _: &mut Self::RequestLayoutState,
163 window: &mut Window,
164 _: &mut App,
165 ) -> Self::PrepaintState {
166 let cover_bounds = Bounds {
168 origin: Point {
169 x: bounds.origin.x,
170 y: bounds.origin.y - bounds.size.height,
171 },
172 size: bounds.size,
173 };
174
175 window.insert_hitbox(cover_bounds, gpui::HitboxBehavior::Normal)
176 }
177
178 fn paint(
179 &mut self,
180 global_id: Option<&GlobalElementId>,
181 _: Option<&gpui::InspectorElementId>,
182 _: Bounds<Pixels>,
183 _: &mut Self::RequestLayoutState,
184 hitbox: &mut Self::PrepaintState,
185 window: &mut Window,
186 _: &mut App,
187 ) {
188 let is_horizontal = self.axis.is_horizontal();
189 let line_height = window.line_height();
190 let bounds = hitbox.bounds;
191 let ongoing_scroll = global_id
192 .map(|global_id| {
193 window.with_element_state::<Rc<RefCell<OngoingScroll>>, _>(global_id, |state, _| {
194 let state = state.unwrap_or_default();
195 (state.clone(), state)
196 })
197 })
198 .unwrap_or_default();
199
200 window.with_content_mask(Some(ContentMask { bounds }), |window| {
201 if let Some(color) = self.debug {
202 window.paint_quad(PaintQuad {
203 bounds,
204 border_widths: Edges::all(px(1.0)),
205 border_color: color,
206 background: gpui::transparent_white().into(),
207 corner_radii: Corners::all(px(0.)),
208 border_style: BorderStyle::default(),
209 });
210 }
211
212 window.on_mouse_event({
213 let view_id = window.current_view();
214 let scroll_handle = self.scroll_handle.clone();
215 let hitbox_id = hitbox.id;
216 let ongoing_scroll = ongoing_scroll.clone();
217
218 move |event: &ScrollWheelEvent, phase, window, cx| {
219 if !(phase.capture() && hitbox_id.should_handle_scroll(window)) {
230 return;
231 }
232
233 let mut offset = scroll_handle.offset();
234 let mut delta = event.delta.pixel_delta(line_height);
235
236 if event.delta.precise() {
240 ongoing_scroll
241 .borrow_mut()
242 .lock_axis(&mut delta, event.touch_phase);
243 }
244
245 if !delta.x.is_zero() && !delta.y.is_zero() {
249 if delta.x.abs() > delta.y.abs() {
250 delta.y = px(0.);
251 } else {
252 delta.x = px(0.);
253 }
254 }
255
256 if !is_horizontal {
257 let axis_max = (scroll_handle.content_size().height
265 - scroll_handle.viewport_bounds().size.height)
266 .max(px(0.));
267 let current = offset.y.clamp(-axis_max, px(0.));
268 let new_offset = (current + delta.y).clamp(-axis_max, px(0.));
269 if new_offset == current {
270 return;
272 }
273
274 offset.y = new_offset;
275 scroll_handle.set_offset(offset);
276 cx.notify(view_id);
277 cx.stop_propagation();
278 return;
279 }
280
281 offset.x += delta.x;
282
283 if offset != scroll_handle.offset() {
289 scroll_handle.set_offset(offset);
290 cx.notify(view_id);
291 cx.stop_propagation();
292 }
293 }
294 });
295 });
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use gpui::{
303 Context, IntoElement, ListAlignment, ListState, Render, ScrollDelta, ScrollWheelEvent,
304 TestAppContext, VisualTestContext, Window, div, list, point, px,
305 };
306
307 struct HorizontalScrollAreaTest {
308 scroll_handle: ScrollHandle,
309 }
310
311 impl Render for HorizontalScrollAreaTest {
312 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
313 div().w(px(100.)).h(px(40.)).child(horizontal_scroll_area(
314 "horizontal-scroll-area",
315 &self.scroll_handle,
316 &Default::default(),
317 div().w(px(300.)).h(px(40.)),
318 ))
319 }
320 }
321
322 #[gpui::test]
323 fn horizontal_scroll_area_ignores_vertical_wheel(cx: &mut TestAppContext) {
324 let scroll_handle = ScrollHandle::new();
325 let (_, cx) = cx.add_window_view({
326 let scroll_handle = scroll_handle.clone();
327 move |_, _| HorizontalScrollAreaTest {
328 scroll_handle: scroll_handle.clone(),
329 }
330 });
331 let cx: &mut VisualTestContext = cx;
332 cx.run_until_parked();
333 cx.update(|window, cx| {
334 _ = window.draw(cx);
335 });
336
337 cx.simulate_event(ScrollWheelEvent {
338 position: point(px(10.), px(10.)),
339 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
340 ..Default::default()
341 });
342
343 assert_eq!(scroll_handle.offset().x, px(0.));
344 }
345
346 struct ListWithHorizontalAreaTest {
351 scroll_handle: ScrollHandle,
352 list_state: ListState,
353 occluded: bool,
354 }
355
356 impl Render for ListWithHorizontalAreaTest {
357 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
358 let scroll_handle = self.scroll_handle.clone();
359 let mut root = div().w(px(100.)).h(px(100.)).child(
360 list(self.list_state.clone(), move |ix, _, _| {
361 if ix == 0 {
362 horizontal_scroll_area(
363 "horizontal-scroll-area",
364 &scroll_handle,
365 &Default::default(),
366 div().w(px(300.)).h(px(40.)),
367 )
368 .into_any_element()
369 } else {
370 div().w(px(100.)).h(px(40.)).into_any_element()
371 }
372 })
373 .w_full()
374 .h_full(),
375 );
376 if self.occluded {
377 root = root.child(div().absolute().top_0().left_0().size_full().occlude());
379 }
380 root
381 }
382 }
383
384 fn setup_list_test<'a>(
385 cx: &'a mut TestAppContext,
386 scroll_handle: &ScrollHandle,
387 list_state: &ListState,
388 occluded: bool,
389 ) -> &'a mut VisualTestContext {
390 let (_, cx) = cx.add_window_view({
391 let scroll_handle = scroll_handle.clone();
392 let list_state = list_state.clone();
393 move |_, _| ListWithHorizontalAreaTest {
394 scroll_handle: scroll_handle.clone(),
395 list_state: list_state.clone(),
396 occluded,
397 }
398 });
399 cx.run_until_parked();
400 cx.update(|window, cx| {
401 _ = window.draw(cx);
402 });
403 cx
404 }
405
406 #[gpui::test]
407 fn horizontal_scroll_area_in_list_keeps_horizontal_dominant_wheel(cx: &mut TestAppContext) {
408 let scroll_handle = ScrollHandle::new();
409 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
410 let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
411
412 cx.simulate_event(ScrollWheelEvent {
415 position: point(px(10.), px(10.)),
416 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
417 ..Default::default()
418 });
419
420 assert_eq!(scroll_handle.offset().x, px(-40.));
422 let scroll_top = list_state.logical_scroll_top();
424 assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
425 }
426
427 #[gpui::test]
428 fn horizontal_scroll_area_in_list_bubbles_vertical_dominant_wheel(cx: &mut TestAppContext) {
429 let scroll_handle = ScrollHandle::new();
430 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
431 let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
432
433 cx.simulate_event(ScrollWheelEvent {
434 position: point(px(10.), px(10.)),
435 delta: ScrollDelta::Pixels(point(px(-10.), px(-40.))),
436 ..Default::default()
437 });
438
439 assert_eq!(scroll_handle.offset().x, px(0.));
441 let scroll_top = list_state.logical_scroll_top();
442 assert_ne!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
443 }
444
445 struct ListWithVerticalMaskTest {
448 outer_handle: ScrollHandle,
449 list_state: ListState,
450 }
451
452 impl Render for ListWithVerticalMaskTest {
453 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
454 div()
455 .id("outer")
456 .w(px(100.))
457 .h(px(100.))
458 .overflow_y_scroll()
459 .track_scroll(&self.outer_handle)
460 .child(
461 div().w_full().h(px(400.)).child(
462 div()
463 .relative()
464 .w_full()
465 .h(px(80.))
466 .overflow_hidden()
467 .child(
468 list(self.list_state.clone(), |_, _, _| {
469 div().w_full().h(px(40.)).into_any_element()
470 })
471 .w_full()
472 .h_full(),
473 )
474 .child(ScrollableMask::new(Axis::Vertical, &self.list_state)),
475 ),
476 )
477 }
478 }
479
480 fn setup_vertical_mask_test<'a>(
481 cx: &'a mut TestAppContext,
482 outer_handle: &ScrollHandle,
483 list_state: &ListState,
484 ) -> &'a mut VisualTestContext {
485 let (_, cx) = cx.add_window_view({
486 let outer_handle = outer_handle.clone();
487 let list_state = list_state.clone();
488 move |_, _| ListWithVerticalMaskTest {
489 outer_handle: outer_handle.clone(),
490 list_state: list_state.clone(),
491 }
492 });
493 cx.run_until_parked();
494 cx.update(|window, cx| {
495 _ = window.draw(cx);
496 });
497 cx
498 }
499
500 #[gpui::test]
501 fn vertical_mask_contains_wheel_inside_the_list(cx: &mut TestAppContext) {
502 let outer_handle = ScrollHandle::new();
503 let list_state = ListState::new(10, ListAlignment::Top, px(1000.));
506 let cx = setup_vertical_mask_test(cx, &outer_handle, &list_state);
507
508 cx.simulate_event(ScrollWheelEvent {
509 position: point(px(10.), px(10.)),
510 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
511 ..Default::default()
512 });
513
514 assert_eq!(list_state.scroll_px_offset_for_scrollbar().y, px(-40.));
516 assert_eq!(outer_handle.offset().y, px(0.));
517 }
518
519 #[gpui::test]
520 fn vertical_mask_chains_to_the_ancestor_at_the_edge(cx: &mut TestAppContext) {
521 let outer_handle = ScrollHandle::new();
522 let list_state = ListState::new(10, ListAlignment::Top, px(1000.));
523 list_state.scroll_to(gpui::ListOffset {
525 item_ix: 8,
526 offset_in_item: px(0.),
527 });
528 let cx = setup_vertical_mask_test(cx, &outer_handle, &list_state);
529
530 cx.simulate_event(ScrollWheelEvent {
531 position: point(px(10.), px(10.)),
532 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
533 ..Default::default()
534 });
535
536 assert_eq!(list_state.scroll_px_offset_for_scrollbar().y, px(-320.));
538 assert_eq!(outer_handle.offset().y, px(-40.));
539 }
540
541 #[gpui::test]
542 fn horizontal_scroll_area_covers_viewport_after_scrolled(cx: &mut TestAppContext) {
543 let scroll_handle = ScrollHandle::new();
544 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
545 let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
546
547 scroll_handle.set_offset(point(px(-150.), px(0.)));
549 cx.update(|window, cx| {
550 _ = window.draw(cx);
551 });
552
553 cx.simulate_event(ScrollWheelEvent {
556 position: point(px(90.), px(10.)),
557 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
558 ..Default::default()
559 });
560
561 assert_eq!(scroll_handle.offset().x, px(-190.));
562 let scroll_top = list_state.logical_scroll_top();
563 assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
564 }
565
566 #[gpui::test]
567 fn horizontal_scroll_area_traps_wheel_at_edge(cx: &mut TestAppContext) {
568 let scroll_handle = ScrollHandle::new();
569 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
570 let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
571
572 scroll_handle.set_offset(point(px(-200.), px(0.)));
574 cx.update(|window, cx| {
575 _ = window.draw(cx);
576 });
577
578 cx.simulate_event(ScrollWheelEvent {
579 position: point(px(10.), px(10.)),
580 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
581 ..Default::default()
582 });
583
584 let scroll_top = list_state.logical_scroll_top();
587 assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
588 }
589
590 #[gpui::test]
591 fn horizontal_scroll_area_ignores_wheel_when_occluded(cx: &mut TestAppContext) {
592 let scroll_handle = ScrollHandle::new();
593 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
594 let cx = setup_list_test(cx, &scroll_handle, &list_state, true);
595
596 cx.simulate_event(ScrollWheelEvent {
597 position: point(px(10.), px(10.)),
598 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
599 ..Default::default()
600 });
601
602 assert_eq!(scroll_handle.offset().x, px(0.));
605 }
606
607 #[gpui::test]
608 fn horizontal_scroll_area_uses_horizontal_wheel(cx: &mut TestAppContext) {
609 let scroll_handle = ScrollHandle::new();
610 let (_, cx) = cx.add_window_view({
611 let scroll_handle = scroll_handle.clone();
612 move |_, _| HorizontalScrollAreaTest {
613 scroll_handle: scroll_handle.clone(),
614 }
615 });
616 let cx: &mut VisualTestContext = cx;
617 cx.run_until_parked();
618 cx.update(|window, cx| {
619 _ = window.draw(cx);
620 });
621
622 cx.simulate_event(ScrollWheelEvent {
623 position: point(px(10.), px(10.)),
624 delta: ScrollDelta::Pixels(point(px(-40.), px(0.))),
625 ..Default::default()
626 });
627
628 assert_eq!(scroll_handle.offset().x, px(-40.));
629 }
630
631 fn setup_horizontal_area_test<'a>(
632 cx: &'a mut TestAppContext,
633 scroll_handle: &ScrollHandle,
634 ) -> &'a mut VisualTestContext {
635 let (_, cx) = cx.add_window_view({
636 let scroll_handle = scroll_handle.clone();
637 move |_, _| HorizontalScrollAreaTest {
638 scroll_handle: scroll_handle.clone(),
639 }
640 });
641 let cx: &mut VisualTestContext = cx;
642 cx.run_until_parked();
643 cx.update(|window, cx| {
644 _ = window.draw(cx);
645 });
646 cx
647 }
648
649 #[gpui::test]
650 fn horizontal_mask_keeps_axis_lock_within_a_gesture(cx: &mut TestAppContext) {
651 let scroll_handle = ScrollHandle::new();
652 let cx = setup_horizontal_area_test(cx, &scroll_handle);
653
654 cx.simulate_event(ScrollWheelEvent {
655 position: point(px(10.), px(10.)),
656 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
657 ..Default::default()
658 });
659 assert_eq!(scroll_handle.offset().x, px(-40.));
660
661 cx.simulate_event(ScrollWheelEvent {
665 position: point(px(10.), px(10.)),
666 delta: ScrollDelta::Pixels(point(px(-10.), px(-15.))),
667 ..Default::default()
668 });
669 assert_eq!(scroll_handle.offset().x, px(-50.));
670 }
671
672 #[gpui::test]
673 fn horizontal_mask_releases_axis_lock_on_a_strong_turn(cx: &mut TestAppContext) {
674 let scroll_handle = ScrollHandle::new();
675 let cx = setup_horizontal_area_test(cx, &scroll_handle);
676
677 cx.simulate_event(ScrollWheelEvent {
678 position: point(px(10.), px(10.)),
679 delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
680 ..Default::default()
681 });
682 assert_eq!(scroll_handle.offset().x, px(-40.));
683
684 cx.simulate_event(ScrollWheelEvent {
687 position: point(px(10.), px(10.)),
688 delta: ScrollDelta::Pixels(point(px(-10.), px(-25.))),
689 ..Default::default()
690 });
691 assert_eq!(scroll_handle.offset().x, px(-40.));
692 }
693
694 struct NestedVerticalScrollTest {
697 outer_handle: ScrollHandle,
698 inner_handle: ScrollHandle,
699 inner_content_height: Pixels,
700 }
701
702 impl Render for NestedVerticalScrollTest {
703 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
704 div()
705 .id("outer")
706 .w(px(100.))
707 .h(px(100.))
708 .overflow_y_scroll()
709 .track_scroll(&self.outer_handle)
710 .child(
711 div()
712 .relative()
713 .w_full()
714 .h(px(60.))
715 .child(
716 div()
717 .id("inner")
718 .size_full()
719 .overflow_y_scroll()
720 .track_scroll(&self.inner_handle)
721 .child(div().w_full().h(self.inner_content_height)),
722 )
723 .child(ScrollableMask::new(Axis::Vertical, &self.inner_handle)),
724 )
725 .child(div().w_full().h(px(400.)))
726 }
727 }
728
729 fn setup_nested_vertical_test<'a>(
730 cx: &'a mut TestAppContext,
731 outer_handle: &ScrollHandle,
732 inner_handle: &ScrollHandle,
733 inner_content_height: Pixels,
734 ) -> &'a mut VisualTestContext {
735 let (_, cx) = cx.add_window_view({
736 let outer_handle = outer_handle.clone();
737 let inner_handle = inner_handle.clone();
738 move |_, _| NestedVerticalScrollTest {
739 outer_handle: outer_handle.clone(),
740 inner_handle: inner_handle.clone(),
741 inner_content_height,
742 }
743 });
744 cx.run_until_parked();
745 cx.update(|window, cx| {
746 _ = window.draw(cx);
747 });
748 cx
749 }
750
751 #[gpui::test]
752 fn vertical_mask_consumes_wheel_when_scrollable(cx: &mut TestAppContext) {
753 let outer_handle = ScrollHandle::new();
754 let inner_handle = ScrollHandle::new();
755 let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(300.));
756
757 cx.simulate_event(ScrollWheelEvent {
758 position: point(px(10.), px(10.)),
759 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
760 ..Default::default()
761 });
762
763 assert_eq!(inner_handle.offset().y, px(-40.));
765 assert_eq!(outer_handle.offset().y, px(0.));
766 }
767
768 #[gpui::test]
769 fn vertical_mask_hands_off_to_parent_at_edge(cx: &mut TestAppContext) {
770 let outer_handle = ScrollHandle::new();
771 let inner_handle = ScrollHandle::new();
772 let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(300.));
773
774 inner_handle.set_offset(point(px(0.), px(-240.)));
776 cx.update(|window, cx| {
777 _ = window.draw(cx);
778 });
779
780 cx.simulate_event(ScrollWheelEvent {
781 position: point(px(10.), px(10.)),
782 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
783 ..Default::default()
784 });
785
786 assert_eq!(outer_handle.offset().y, px(-40.));
788 cx.update(|window, cx| {
790 _ = window.draw(cx);
791 });
792 assert_eq!(inner_handle.offset().y, px(-240.));
793 }
794
795 #[gpui::test]
796 fn vertical_mask_bubbles_when_no_overflow(cx: &mut TestAppContext) {
797 let outer_handle = ScrollHandle::new();
798 let inner_handle = ScrollHandle::new();
799 let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(40.));
801
802 cx.simulate_event(ScrollWheelEvent {
803 position: point(px(10.), px(10.)),
804 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
805 ..Default::default()
806 });
807
808 assert_eq!(outer_handle.offset().y, px(-40.));
809 cx.update(|window, cx| {
810 _ = window.draw(cx);
811 });
812 assert_eq!(inner_handle.offset().y, px(0.));
813 }
814
815 #[gpui::test]
816 fn vertical_mask_ignores_transient_overscroll(cx: &mut TestAppContext) {
817 let outer_handle = ScrollHandle::new();
818 let inner_handle = ScrollHandle::new();
819 let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(300.));
820
821 inner_handle.set_offset(point(px(0.), px(-240.)));
822 cx.update(|window, cx| {
823 _ = window.draw(cx);
824 });
825
826 for _ in 0..2 {
830 cx.simulate_event(ScrollWheelEvent {
831 position: point(px(10.), px(10.)),
832 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
833 ..Default::default()
834 });
835 }
836
837 assert_eq!(outer_handle.offset().y, px(-80.));
838 cx.update(|window, cx| {
839 _ = window.draw(cx);
840 });
841 assert_eq!(inner_handle.offset().y, px(-240.));
842 }
843
844 struct ListWithVerticalAreaTest {
848 scroll_handle: ScrollHandle,
849 list_state: ListState,
850 }
851
852 impl Render for ListWithVerticalAreaTest {
853 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
854 let scroll_handle = self.scroll_handle.clone();
855 div().w(px(100.)).h(px(100.)).child(
856 list(self.list_state.clone(), move |ix, _, _| {
857 if ix == 0 {
858 div()
859 .relative()
860 .w_full()
861 .h(px(60.))
862 .child(
863 div()
864 .id("inner")
865 .size_full()
866 .overflow_y_scroll()
867 .track_scroll(&scroll_handle)
868 .child(div().w_full().h(px(300.))),
869 )
870 .child(ScrollableMask::new(Axis::Vertical, &scroll_handle))
871 .into_any_element()
872 } else {
873 div().w(px(100.)).h(px(40.)).into_any_element()
874 }
875 })
876 .w_full()
877 .h_full(),
878 )
879 }
880 }
881
882 #[gpui::test]
883 fn vertical_mask_in_list_consumes_wheel_when_scrollable(cx: &mut TestAppContext) {
884 let scroll_handle = ScrollHandle::new();
885 let list_state = ListState::new(10, ListAlignment::Top, px(0.));
886 let (_, cx) = cx.add_window_view({
887 let scroll_handle = scroll_handle.clone();
888 let list_state = list_state.clone();
889 move |_, _| ListWithVerticalAreaTest {
890 scroll_handle: scroll_handle.clone(),
891 list_state: list_state.clone(),
892 }
893 });
894 cx.run_until_parked();
895 cx.update(|window, cx| {
896 _ = window.draw(cx);
897 });
898
899 cx.simulate_event(ScrollWheelEvent {
900 position: point(px(10.), px(10.)),
901 delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
902 ..Default::default()
903 });
904
905 assert_eq!(scroll_handle.offset().y, px(-40.));
907 let scroll_top = list_state.logical_scroll_top();
908 assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
909 }
910}