Skip to main content

gpui_base/
scrollable_mask.rs

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/// Default element id for a mask: the call site, so two masks built in
16/// different places never share their per-gesture axis lock.
17#[inline]
18#[track_caller]
19fn caller_id() -> ElementId {
20    ElementId::CodeLocation(*std::panic::Location::caller())
21}
22
23/// A horizontal scroll viewport that only consumes horizontal wheel deltas.
24///
25/// GPUI's native `overflow_x_scroll` maps vertical wheel input onto horizontal
26/// scrolling when there is no vertical overflow. This wrapper keeps the visual
27/// clipping and scroll offset, while delegating wheel input to [`ScrollableMask`]
28/// so vertical wheel events can continue bubbling to the parent scroller.
29pub(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    // The mask must be a sibling of the scrolled element (like in Table), not
38    // a child of it: children are prepainted with the scroll offset applied,
39    // which would slide the mask away from the viewport as the content
40    // scrolls, leaving the uncovered part to the parent scroller.
41    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
56/// Make a scrollable mask element to cover the parent view with the mouse wheel event listening.
57///
58/// When the mouse wheel is scrolled, will move the `scroll_handle` scrolling with the `axis` direction.
59/// You can use this `scroll_handle` to control what you want to scroll.
60/// This is only can handle once axis scrolling.
61///
62/// Axis-dominant wheel events are consumed in the capture phase, so the mask
63/// wins over ancestor scrollers (e.g. `gpui::list`) that register their
64/// listeners after their children; events dominated by the other axis keep
65/// propagating. The mask stays inert while occluded.
66///
67/// Dominance is decided per gesture, not per event: a precise (trackpad) delta
68/// locks onto the axis its gesture started on, so mid-swipe wobble cannot flip
69/// which mask consumes the events. Line deltas keep the per-event comparison.
70///
71/// At the scroll edge the two axes differ, matching platform scrollers:
72/// a vertical mask hands the event over to the ancestor scroller (CSS
73/// `overscroll-behavior: auto` chaining), while a horizontal mask keeps
74/// consuming it — a bubbled horizontal delta would get mapped onto a
75/// vertical-only ancestor by gpui's own wheel listener (see #2468).
76pub 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    /// Create a new scrollable mask element.
85    #[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    /// Set a specific element id, default is the [`std::panic::Location::caller`].
96    ///
97    /// Only needed when one call site creates several masks of the same axis,
98    /// which would otherwise share their gesture axis lock.
99    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
100        self.id = id.into();
101        self
102    }
103
104    /// Enable the debug border, to show the mask bounds.
105    #[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    // An id is needed to keep the gesture's axis lock across frames. The axis
125    // suffix keeps both masks of one scroller apart when they share an id.
126    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        // Set the layout style relative to the table view to get same size.
148        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        // Move y to bounds height to cover the parent view.
167        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                    // Handle in the capture phase: ancestor scrollers such as
220                    // `gpui::list` register their wheel listeners after their
221                    // children paint, so in the bubble phase (reverse
222                    // registration order) they run first and would consume the
223                    // vertical component of a trackpad swipe before this mask
224                    // could stop the propagation.
225                    //
226                    // `should_handle_scroll` (instead of a raw bounds check)
227                    // keeps the mask inert when it is occluded, e.g. below an
228                    // open dialog or context menu.
229                    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                    // Lock the gesture to the axis it started on, so a diagonal
237                    // trackpad swipe cannot flip which mask consumes it from one
238                    // event to the next. Line deltas carry no touch phase.
239                    if event.delta.precise() {
240                        ongoing_scroll
241                            .borrow_mut()
242                            .lock_axis(&mut delta, event.touch_phase);
243                    }
244
245                    // Limit for only one way scrolling at same time.
246                    // When use MacBook touchpad we may get both x and y delta,
247                    // only allows the one that more to scroll.
248                    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                        // The current offset must be clamped too: after a
258                        // bubbled event, the scrolled element's own listener
259                        // pushes the shared offset beyond the edge unclamped
260                        // (the div only clamps on prepaint), and that
261                        // transient overscroll would read as "room to scroll".
262                        // `ScrollbarHandle` has no `max_offset`; recover it from
263                        // the definition `content_size = viewport + max_offset`.
264                        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                            // At the edge or no overflow: bubble to the parent.
271                            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                    // NOTE: `set_offset` does not clamp (clamping happens in
284                    // the div's prepaint), so any non-zero horizontal-dominant
285                    // delta passes this guard — even at the scroll edge the
286                    // event is consumed rather than turned into a parent
287                    // scroll.
288                    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    /// Reproduces the markdown table case: the scroll area lives inside a
347    /// `gpui::list` item. The list registers its wheel listener after its
348    /// items paint, so in the bubble phase (reverse registration order) the
349    /// list runs first and consumes `delta.y` of every trackpad swipe.
350    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                // An overlay above the list, like an open dialog or menu.
378                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        // A trackpad swipe is rarely axis-pure: horizontal dominant with a
413        // small vertical component.
414        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        // The area consumes the horizontal delta...
421        assert_eq!(scroll_handle.offset().x, px(-40.));
422        // ...and the outer list must not scroll vertically.
423        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        // Vertical dominant: the list scrolls, the area does not.
440        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    /// A `MessageScroller`-shaped nesting: a `gpui::list` with a vertical
446    /// mask bound to its `ListState`, inside an ancestor vertical scroller.
447    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        // A large overdraw measures every row up front, so the mask sees the
504        // full content height.
505        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        // The inner list consumes the wheel; the ancestor must not move.
515        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        // Start the inner list at its bottom edge (400 - 80 = 320).
524        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        // At the edge the mask lets the event bubble to the ancestor.
537        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 the area to the middle, then repaint.
548        scroll_handle.set_offset(point(px(-150.), px(0.)));
549        cx.update(|window, cx| {
550            _ = window.draw(cx);
551        });
552
553        // Swipe over the right side of the viewport. The mask must still
554        // cover it — it must not slide away with the scrolled content.
555        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 the area to its right edge (300 - 100 = 200).
573        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        // A horizontal mask consumes even at the edge: a bubbled horizontal
585        // delta would be axis-mapped onto the vertical list (#2468).
586        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        // An overlay (dialog, context menu) occludes the area: the area
603        // must not scroll underneath it.
604        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        // Same gesture, now leaning vertical but under the unlock ratio: the
662        // lock holds and the horizontal offset keeps moving. Comparing this
663        // event alone would zero `delta.x` and stall the scroller at -40.
664        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        // Past the unlock ratio the gesture is no longer horizontal, so the
685        // event stops driving this scroller.
686        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    /// Reproduces the DataTable case: a vertically scrollable element
695    /// nested inside an outer vertical scroller.
696    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        // The inner scroller consumes the event; the outer one must not move.
764        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        // Scroll the inner element to its bottom edge (300 - 60 = 240).
775        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        // At the edge the event bubbles: the outer scroller takes over.
787        assert_eq!(outer_handle.offset().y, px(-40.));
788        // The inner offset is clamped back to the edge on the next prepaint.
789        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        // Inner content (40) fits its 60px viewport: nothing to scroll.
800        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        // Two wheel events at the edge with no redraw in between: the first
827        // one leaves the inner offset beyond the edge unclamped, which must
828        // not read as "room to scroll" and swallow the second event.
829        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    /// The vertical mask nested in a `gpui::list` ancestor: the list
845    /// registers its wheel listener after its items paint, so only a
846    /// capture-phase mask can stop it from scrolling on the same event.
847    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        // The inner scroller consumes the event; the list must not scroll.
906        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}