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    //
42    // The frame cover is a sibling for the same reason: it keeps the rounded
43    // corners clean at every scroll offset (see [`RoundedFrameCover`]).
44    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
71/// Keeps a rounded scroll viewport's corners clean at every scroll offset.
72///
73/// GPUI clips children with a rectangular content mask, so content scrolled
74/// under a rounded frame paints square into the corner notches — the regions
75/// inside the frame's rect corners but outside its border arcs. Rounding the
76/// content itself (e.g. a table's first row) only helps while its own corners
77/// sit at the viewport corners; once the content scrolls, the clipped edge
78/// meets the notch square again. The cover fills each notch with the backdrop
79/// color, which reads as a rounded clip. The frame's own border needs no help:
80/// gpui paints an element's border after its children, so it already draws
81/// over both the scrolled content and these covers.
82///
83/// Like [`ScrollableMask`], it must be a sibling of the scrolled element so it
84/// does not move with the scroll offset.
85pub struct RoundedFrameCover {
86    corner_radii: Corners<Option<gpui::AbsoluteLength>>,
87    backdrop: Option<Hsla>,
88    frame_border: Pixels,
89}
90
91impl RoundedFrameCover {
92    /// A frame with the same radius on all four corners.
93    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    /// The frame's border width, when the cover is a child of the bordered
108    /// element itself: the cover's bounds are then the content box, so the
109    /// painted notches are grown outward by this width to reach the border
110    /// box. The border itself needs no repaint — gpui paints an element's
111    /// border after its children, so the frame's own border already draws
112    /// over the covers.
113    pub fn frame_border(mut self, width: Pixels) -> Self {
114        self.frame_border = width;
115        self
116    }
117
118    /// The color behind the frame, used to fill the corner notches.
119    /// Defaults to the theme background.
120    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        // Same layout trick as `ScrollableMask`: absolute and parent-sized.
154        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        // Shift up by one height to cover the viewport, like `ScrollableMask`.
174        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
246/// The notch at one frame corner: the square of side `radius` anchored at
247/// `corner`, minus the quarter disc of the border arc. `x_dir`/`y_dir` point
248/// from the corner toward the frame's inside (`±1`).
249fn 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        // The quarter arc nearest the corner: counterclockwise when the two
269        // inward directions agree in sign, clockwise otherwise.
270        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
280/// Make a scrollable mask element to cover the parent view with the mouse wheel event listening.
281///
282/// When the mouse wheel is scrolled, will move the `scroll_handle` scrolling with the `axis` direction.
283/// You can use this `scroll_handle` to control what you want to scroll.
284/// This is only can handle once axis scrolling.
285///
286/// Axis-dominant wheel events are consumed in the capture phase, so the mask
287/// wins over ancestor scrollers (e.g. `gpui::list`) that register their
288/// listeners after their children; events dominated by the other axis keep
289/// propagating. The mask stays inert while occluded.
290///
291/// Dominance is decided per gesture, not per event: a precise (trackpad) delta
292/// locks onto the axis its gesture started on, so mid-swipe wobble cannot flip
293/// which mask consumes the events. Line deltas keep the per-event comparison.
294///
295/// At the scroll edge the two axes differ, matching platform scrollers:
296/// a vertical mask hands the event over to the ancestor scroller (CSS
297/// `overscroll-behavior: auto` chaining), while a horizontal mask keeps
298/// consuming it — a bubbled horizontal delta would get mapped onto a
299/// vertical-only ancestor by gpui's own wheel listener (see #2468).
300pub 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    /// Create a new scrollable mask element.
309    #[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    /// Set a specific element id, default is the [`std::panic::Location::caller`].
320    ///
321    /// Only needed when one call site creates several masks of the same axis,
322    /// which would otherwise share their gesture axis lock.
323    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
324        self.id = id.into();
325        self
326    }
327
328    /// Enable the debug border, to show the mask bounds.
329    #[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    // An id is needed to keep the gesture's axis lock across frames. The axis
349    // suffix keeps both masks of one scroller apart when they share an id.
350    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        // Set the layout style relative to the table view to get same size.
372        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        // Move y to bounds height to cover the parent view.
391        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                    // Handle in the capture phase: ancestor scrollers such as
444                    // `gpui::list` register their wheel listeners after their
445                    // children paint, so in the bubble phase (reverse
446                    // registration order) they run first and would consume the
447                    // vertical component of a trackpad swipe before this mask
448                    // could stop the propagation.
449                    //
450                    // `should_handle_scroll` (instead of a raw bounds check)
451                    // keeps the mask inert when it is occluded, e.g. below an
452                    // open dialog or context menu.
453                    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                    // Lock the gesture to the axis it started on, so a diagonal
461                    // trackpad swipe cannot flip which mask consumes it from one
462                    // event to the next. Line deltas carry no touch phase.
463                    if event.delta.precise() {
464                        ongoing_scroll
465                            .borrow_mut()
466                            .lock_axis(&mut delta, event.touch_phase);
467                    }
468
469                    // Limit for only one way scrolling at same time.
470                    // When use MacBook touchpad we may get both x and y delta,
471                    // only allows the one that more to scroll.
472                    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                        // The current offset must be clamped too: after a
482                        // bubbled event, the scrolled element's own listener
483                        // pushes the shared offset beyond the edge unclamped
484                        // (the div only clamps on prepaint), and that
485                        // transient overscroll would read as "room to scroll".
486                        // `ScrollbarHandle` has no `max_offset`; recover it from
487                        // the definition `content_size = viewport + max_offset`.
488                        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                            // At the edge or no overflow: bubble to the parent.
495                            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                    // NOTE: `set_offset` does not clamp (clamping happens in
508                    // the div's prepaint), so any non-zero horizontal-dominant
509                    // delta passes this guard — even at the scroll edge the
510                    // event is consumed rather than turned into a parent
511                    // scroll.
512                    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            // Curve tessellation may inset fractionally; one pixel of slack.
547            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    /// Reproduces the markdown table case: the scroll area lives inside a
615    /// `gpui::list` item. The list registers its wheel listener after its
616    /// items paint, so in the bubble phase (reverse registration order) the
617    /// list runs first and consumes `delta.y` of every trackpad swipe.
618    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                // An overlay above the list, like an open dialog or menu.
646                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        // A trackpad swipe is rarely axis-pure: horizontal dominant with a
681        // small vertical component.
682        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        // The area consumes the horizontal delta...
689        assert_eq!(scroll_handle.offset().x, px(-40.));
690        // ...and the outer list must not scroll vertically.
691        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        // Vertical dominant: the list scrolls, the area does not.
708        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    /// A `MessageScroller`-shaped nesting: a `gpui::list` with a vertical
714    /// mask bound to its `ListState`, inside an ancestor vertical scroller.
715    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        // A large overdraw measures every row up front, so the mask sees the
772        // full content height.
773        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        // The inner list consumes the wheel; the ancestor must not move.
783        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        // Start the inner list at its bottom edge (400 - 80 = 320).
792        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        // At the edge the mask lets the event bubble to the ancestor.
805        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 the area to the middle, then repaint.
816        scroll_handle.set_offset(point(px(-150.), px(0.)));
817        cx.update(|window, cx| {
818            _ = window.draw(cx);
819        });
820
821        // Swipe over the right side of the viewport. The mask must still
822        // cover it — it must not slide away with the scrolled content.
823        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 the area to its right edge (300 - 100 = 200).
841        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        // A horizontal mask consumes even at the edge: a bubbled horizontal
853        // delta would be axis-mapped onto the vertical list (#2468).
854        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        // An overlay (dialog, context menu) occludes the area: the area
871        // must not scroll underneath it.
872        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        // Same gesture, now leaning vertical but under the unlock ratio: the
930        // lock holds and the horizontal offset keeps moving. Comparing this
931        // event alone would zero `delta.x` and stall the scroller at -40.
932        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        // Past the unlock ratio the gesture is no longer horizontal, so the
953        // event stops driving this scroller.
954        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    /// Reproduces the DataTable case: a vertically scrollable element
963    /// nested inside an outer vertical scroller.
964    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        // The inner scroller consumes the event; the outer one must not move.
1032        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        // Scroll the inner element to its bottom edge (300 - 60 = 240).
1043        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        // At the edge the event bubbles: the outer scroller takes over.
1055        assert_eq!(outer_handle.offset().y, px(-40.));
1056        // The inner offset is clamped back to the edge on the next prepaint.
1057        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        // Inner content (40) fits its 60px viewport: nothing to scroll.
1068        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        // Two wheel events at the edge with no redraw in between: the first
1095        // one leaves the inner offset beyond the edge unclamped, which must
1096        // not read as "room to scroll" and swallow the second event.
1097        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    /// The vertical mask nested in a `gpui::list` ancestor: the list
1113    /// registers its wheel listener after its items paint, so only a
1114    /// capture-phase mask can stop it from scrolling on the same event.
1115    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        // The inner scroller consumes the event; the list must not scroll.
1174        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}