Skip to main content

gpui_base/
scrollable_mask.rs

1use std::cell::{Cell, RefCell};
2use std::rc::Rc;
3
4use instant::Instant;
5
6use gpui::{
7    App, Axis, BorderStyle, Bounds, ContentMask, Edges, Element, ElementId, GlobalElementId,
8    Hitbox, Hsla, InteractiveElement as _, IntoElement, IsZero as _, LayoutId, OngoingScroll,
9    PaintQuad, ParentElement as _, Point, Position, ScrollDelta, ScrollEvent, ScrollHandle,
10    StatefulInteractiveElement as _, Style, StyleRefinement, Styled as _, Window, div, px,
11    relative,
12};
13use gpui::{Corners, Pixels};
14
15use crate::{AxisExt, OngoingScrollExt as _, ScrollbarHandle, StyledExt as _};
16
17thread_local! {
18    static LAST_HORIZONTAL_SCROLL_CLAIM: Cell<Option<HorizontalScrollClaim>> =
19        const { Cell::new(None) };
20}
21
22/// Offsets closer than this count as unchanged, so a sub-pixel residue left at
23/// an edge does not read as room to scroll.
24const CLAIM_EPSILON: Pixels = px(0.5);
25
26/// A scroll step that a horizontal [`ScrollableMask`] used to move its content.
27#[derive(Clone, Copy, Debug, PartialEq)]
28pub struct HorizontalScrollClaim {
29    /// When the step was handled.
30    at: Instant,
31    /// How far the content moved: positive moves it right (reveals what is on
32    /// the left), negative moves it left.
33    delta_x: Pixels,
34}
35
36impl HorizontalScrollClaim {
37    /// When the step was handled.
38    pub fn at(&self) -> Instant {
39        self.at
40    }
41
42    /// How far the content moved: positive moves it right, negative moves it left.
43    pub fn delta_x(&self) -> Pixels {
44        self.delta_x
45    }
46}
47
48/// The last scroll step a horizontal [`ScrollableMask`] used to move its
49/// content, i.e. one that changed the clamped offset rather than pushing
50/// against an edge.
51///
52/// Hosts that put their own pan gesture over GPUI content (e.g. a swipe that
53/// opens a side drawer on mobile) use it to let a horizontal strip such as a
54/// markdown table scroll first: while the strip still has room in the swipe's
55/// direction it claims the swipe; once it reaches that edge the claims stop and
56/// the host may take over. Read on the thread that dispatches input.
57pub fn last_horizontal_scroll_claim() -> Option<HorizontalScrollClaim> {
58    LAST_HORIZONTAL_SCROLL_CLAIM.get()
59}
60
61/// Default element id for a mask: the call site, so two masks built in
62/// different places never share their per-gesture axis lock.
63#[inline]
64#[track_caller]
65fn caller_id() -> ElementId {
66    ElementId::CodeLocation(*std::panic::Location::caller())
67}
68
69/// A horizontal scroll viewport that only consumes horizontal wheel deltas.
70///
71/// GPUI's native `overflow_x_scroll` maps vertical wheel input onto horizontal
72/// scrolling when there is no vertical overflow. This wrapper keeps the visual
73/// clipping and scroll offset, while delegating wheel input to [`ScrollableMask`]
74/// so vertical wheel events can continue bubbling to the parent scroller.
75pub(crate) fn horizontal_scroll_area(
76    id: impl Into<ElementId>,
77    scroll_handle: &ScrollHandle,
78    style: &StyleRefinement,
79    child: impl IntoElement,
80) -> impl IntoElement {
81    let id = id.into();
82
83    // The mask must be a sibling of the scrolled element (like in Table), not
84    // a child of it: children are prepainted with the scroll offset applied,
85    // which would slide the mask away from the viewport as the content
86    // scrolls, leaving the uncovered part to the parent scroller.
87    //
88    // The frame cover is a sibling for the same reason: it keeps the rounded
89    // corners clean at every scroll offset (see [`RoundedFrameCover`]).
90    let frame = RoundedFrameCover {
91        corner_radii: Corners {
92            top_left: style.corner_radii.top_left,
93            top_right: style.corner_radii.top_right,
94            bottom_left: style.corner_radii.bottom_left,
95            bottom_right: style.corner_radii.bottom_right,
96        },
97        backdrop: None,
98        frame_border: px(0.),
99    };
100
101    div()
102        .w_full()
103        .relative()
104        .child(
105            div()
106                .id(id.clone())
107                .w_full()
108                .refine_style(style)
109                .overflow_hidden()
110                .track_scroll(scroll_handle)
111                .child(child),
112        )
113        .child(ScrollableMask::new(Axis::Horizontal, scroll_handle).id(id))
114        .child(frame)
115}
116
117/// Keeps a rounded scroll viewport's corners clean at every scroll offset.
118///
119/// GPUI clips children with a rectangular content mask, so content scrolled
120/// under a rounded frame paints square into the corner notches — the regions
121/// inside the frame's rect corners but outside its border arcs. Rounding the
122/// content itself (e.g. a table's first row) only helps while its own corners
123/// sit at the viewport corners; once the content scrolls, the clipped edge
124/// meets the notch square again. The cover fills each notch with the backdrop
125/// color, which reads as a rounded clip. The frame's own border needs no help:
126/// gpui paints an element's border after its children, so it already draws
127/// over both the scrolled content and these covers.
128///
129/// Like [`ScrollableMask`], it must be a sibling of the scrolled element so it
130/// does not move with the scroll offset.
131pub struct RoundedFrameCover {
132    corner_radii: Corners<Option<gpui::AbsoluteLength>>,
133    backdrop: Option<Hsla>,
134    frame_border: Pixels,
135}
136
137impl RoundedFrameCover {
138    /// A frame with the same radius on all four corners.
139    pub fn uniform(radius: Pixels) -> Self {
140        let radius = Some(radius.into());
141        Self {
142            corner_radii: Corners {
143                top_left: radius,
144                top_right: radius,
145                bottom_left: radius,
146                bottom_right: radius,
147            },
148            backdrop: None,
149            frame_border: px(0.),
150        }
151    }
152
153    /// The frame's border width, when the cover is a child of the bordered
154    /// element itself: the cover's bounds are then the content box, so the
155    /// painted notches are grown outward by this width to reach the border
156    /// box. The border itself needs no repaint — gpui paints an element's
157    /// border after its children, so the frame's own border already draws
158    /// over the covers.
159    pub fn frame_border(mut self, width: Pixels) -> Self {
160        self.frame_border = width;
161        self
162    }
163
164    /// The color behind the frame, used to fill the corner notches.
165    /// Defaults to the theme background.
166    pub fn backdrop(mut self, color: Hsla) -> Self {
167        self.backdrop = Some(color);
168        self
169    }
170}
171
172impl IntoElement for RoundedFrameCover {
173    type Element = Self;
174
175    fn into_element(self) -> Self::Element {
176        self
177    }
178}
179
180impl Element for RoundedFrameCover {
181    type RequestLayoutState = ();
182    type PrepaintState = Bounds<Pixels>;
183
184    fn id(&self) -> Option<ElementId> {
185        None
186    }
187
188    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
189        None
190    }
191
192    fn request_layout(
193        &mut self,
194        _: Option<&GlobalElementId>,
195        _: Option<&gpui::InspectorElementId>,
196        window: &mut Window,
197        cx: &mut App,
198    ) -> (LayoutId, Self::RequestLayoutState) {
199        // Same layout trick as `ScrollableMask`: absolute and parent-sized.
200        let mut style = Style::default();
201        style.position = Position::Absolute;
202        style.flex_grow = 1.0;
203        style.flex_shrink = 1.0;
204        style.size.width = relative(1.).into();
205        style.size.height = relative(1.).into();
206
207        (window.request_layout(style, None, cx), ())
208    }
209
210    fn prepaint(
211        &mut self,
212        _: Option<&GlobalElementId>,
213        _: Option<&gpui::InspectorElementId>,
214        bounds: Bounds<Pixels>,
215        _: &mut Self::RequestLayoutState,
216        _: &mut Window,
217        _: &mut App,
218    ) -> Self::PrepaintState {
219        // Shift up by one height to cover the viewport, like `ScrollableMask`.
220        Bounds {
221            origin: Point {
222                x: bounds.origin.x,
223                y: bounds.origin.y - bounds.size.height,
224            },
225            size: bounds.size,
226        }
227    }
228
229    fn paint(
230        &mut self,
231        _: Option<&GlobalElementId>,
232        _: Option<&gpui::InspectorElementId>,
233        _: Bounds<Pixels>,
234        _: &mut Self::RequestLayoutState,
235        cover_bounds: &mut Self::PrepaintState,
236        window: &mut Window,
237        cx: &mut App,
238    ) {
239        use crate::theme::ActiveTheme as _;
240
241        let mut bounds = *cover_bounds;
242        if !self.frame_border.is_zero() {
243            bounds.origin.x -= self.frame_border;
244            bounds.origin.y -= self.frame_border;
245            bounds.size.width += self.frame_border * 2.;
246            bounds.size.height += self.frame_border * 2.;
247        }
248        let rem_size = window.rem_size();
249        let resolve = |corner: Option<gpui::AbsoluteLength>| {
250            corner
251                .map(|radius| radius.to_pixels(rem_size))
252                .filter(|radius| !radius.is_zero())
253        };
254        let radii = Corners {
255            top_left: resolve(self.corner_radii.top_left),
256            top_right: resolve(self.corner_radii.top_right),
257            bottom_left: resolve(self.corner_radii.bottom_left),
258            bottom_right: resolve(self.corner_radii.bottom_right),
259        };
260        if radii.top_left.is_none()
261            && radii.top_right.is_none()
262            && radii.bottom_left.is_none()
263            && radii.bottom_right.is_none()
264        {
265            return;
266        }
267
268        let backdrop = self.backdrop.unwrap_or(cx.theme().tokens.colors.background);
269        let corners = [
270            (bounds.origin, 1., 1., radii.top_left),
271            (bounds.top_right(), -1., 1., radii.top_right),
272            (bounds.bottom_right(), -1., -1., radii.bottom_right),
273            (
274                Point {
275                    x: bounds.origin.x,
276                    y: bounds.bottom_right().y,
277                },
278                1.,
279                -1.,
280                radii.bottom_left,
281            ),
282        ];
283        for (corner, x_dir, y_dir, radius) in corners {
284            let Some(radius) = radius else { continue };
285            if let Some(path) = corner_notch_path(corner, x_dir, y_dir, radius) {
286                window.paint_path(path, backdrop);
287            }
288        }
289    }
290}
291
292/// The notch at one frame corner: the square of side `radius` anchored at
293/// `corner`, minus the quarter disc of the border arc. `x_dir`/`y_dir` point
294/// from the corner toward the frame's inside (`±1`).
295fn corner_notch_path(
296    corner: Point<Pixels>,
297    x_dir: f32,
298    y_dir: f32,
299    radius: Pixels,
300) -> Option<gpui::Path<Pixels>> {
301    let mut builder = gpui::PathBuilder::fill();
302    builder.move_to(corner);
303    builder.line_to(Point {
304        x: corner.x + radius * x_dir,
305        y: corner.y,
306    });
307    builder.arc_to(
308        Point {
309            x: radius,
310            y: radius,
311        },
312        px(0.),
313        false,
314        // The quarter arc nearest the corner: counterclockwise when the two
315        // inward directions agree in sign, clockwise otherwise.
316        x_dir * y_dir < 0.,
317        Point {
318            x: corner.x,
319            y: corner.y + radius * y_dir,
320        },
321    );
322    builder.close();
323    builder.build().ok()
324}
325
326/// Make a scrollable mask element to cover the parent view with the mouse wheel event listening.
327///
328/// When the mouse wheel is scrolled, will move the `scroll_handle` scrolling with the `axis` direction.
329/// You can use this `scroll_handle` to control what you want to scroll.
330/// This is only can handle once axis scrolling.
331///
332/// Axis-dominant wheel events are consumed in the capture phase, so the mask
333/// wins over ancestor scrollers (e.g. `gpui::list`) that register their
334/// listeners after their children; events dominated by the other axis keep
335/// propagating. The mask stays inert while occluded.
336///
337/// Dominance is decided per gesture, not per event: a precise (trackpad) delta
338/// locks onto the axis its gesture started on, so mid-swipe wobble cannot flip
339/// which mask consumes the events. Line deltas keep the per-event comparison.
340///
341/// At the scroll edge the two axes differ, matching platform scrollers:
342/// a vertical mask hands the event over to the ancestor scroller (CSS
343/// `overscroll-behavior: auto` chaining), while a horizontal mask keeps
344/// consuming it — a bubbled horizontal delta would get mapped onto a
345/// vertical-only ancestor by gpui's own wheel listener (see #2468).
346///
347/// A mouse wheel has no horizontal axis, so a horizontal mask leaves it to the
348/// parent. Over a native horizontal scroller, [`Self::vertical_wheel`] scrolls
349/// sideways with it instead.
350pub struct ScrollableMask<H: ScrollbarHandle + Clone = ScrollHandle> {
351    axis: Axis,
352    id: ElementId,
353    scroll_handle: H,
354    /// Only turn a plain wheel's vertical lines into horizontal scrolling,
355    /// leaving every other event to the scroller below.
356    vertical_wheel: bool,
357    debug: Option<Hsla>,
358}
359
360impl<H: ScrollbarHandle + Clone> ScrollableMask<H> {
361    /// Create a new scrollable mask element.
362    #[track_caller]
363    pub fn new(axis: Axis, scroll_handle: &H) -> Self {
364        Self {
365            scroll_handle: scroll_handle.clone(),
366            axis,
367            id: caller_id(),
368            vertical_wheel: false,
369            debug: None,
370        }
371    }
372
373    /// Create a mask over a native horizontal scroller — one with
374    /// `overflow_x_scroll` and a locked scroll axis, such as a strip of tabs —
375    /// that scrolls it sideways with a plain mouse wheel's vertical lines, the
376    /// only axis such a wheel has.
377    ///
378    /// Once the scroller cannot move that way, the wheel goes on to the parent,
379    /// so a page still scrolls past the strip. Trackpad gestures and
380    /// horizontal wheels are left to the scroller, with its own axis lock and
381    /// bounce.
382    #[track_caller]
383    pub fn vertical_wheel(scroll_handle: &H) -> Self {
384        Self {
385            vertical_wheel: true,
386            ..Self::new(Axis::Horizontal, scroll_handle)
387        }
388    }
389
390    /// Set a specific element id, default is the [`std::panic::Location::caller`].
391    ///
392    /// Only needed when one call site creates several masks of the same axis,
393    /// which would otherwise share their gesture axis lock.
394    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
395        self.id = id.into();
396        self
397    }
398
399    /// Enable the debug border, to show the mask bounds.
400    #[allow(dead_code)]
401    pub fn debug(mut self) -> Self {
402        self.debug = Some(gpui::yellow());
403        self
404    }
405}
406
407impl<H: ScrollbarHandle + Clone> IntoElement for ScrollableMask<H> {
408    type Element = Self;
409
410    fn into_element(self) -> Self::Element {
411        self
412    }
413}
414
415impl<H: ScrollbarHandle + Clone> Element for ScrollableMask<H> {
416    type RequestLayoutState = ();
417    type PrepaintState = Hitbox;
418
419    // An id is needed to keep the gesture's axis lock across frames. The axis
420    // suffix keeps both masks of one scroller apart when they share an id.
421    fn id(&self) -> Option<ElementId> {
422        let axis = match self.axis {
423            Axis::Horizontal => "horizontal",
424            Axis::Vertical => "vertical",
425        };
426
427        Some((self.id.clone(), axis).into())
428    }
429
430    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
431        None
432    }
433
434    fn request_layout(
435        &mut self,
436        _: Option<&GlobalElementId>,
437        _: Option<&gpui::InspectorElementId>,
438        window: &mut Window,
439        cx: &mut App,
440    ) -> (LayoutId, Self::RequestLayoutState) {
441        let mut style = Style::default();
442        // Cover the parent whatever its layout: pinned to its top-left corner
443        // rather than left at a static position that depends on whether the
444        // parent stacks its children in a column or a row.
445        style.position = Position::Absolute;
446        style.inset.top = px(0.).into();
447        style.inset.left = px(0.).into();
448        style.flex_grow = 1.0;
449        style.flex_shrink = 1.0;
450        style.size.width = relative(1.).into();
451        style.size.height = relative(1.).into();
452
453        (window.request_layout(style, None, cx), ())
454    }
455
456    fn prepaint(
457        &mut self,
458        _: Option<&GlobalElementId>,
459        _: Option<&gpui::InspectorElementId>,
460        bounds: Bounds<Pixels>,
461        _: &mut Self::RequestLayoutState,
462        window: &mut Window,
463        _: &mut App,
464    ) -> Self::PrepaintState {
465        window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal)
466    }
467
468    fn paint(
469        &mut self,
470        global_id: Option<&GlobalElementId>,
471        _: Option<&gpui::InspectorElementId>,
472        _: Bounds<Pixels>,
473        _: &mut Self::RequestLayoutState,
474        hitbox: &mut Self::PrepaintState,
475        window: &mut Window,
476        _: &mut App,
477    ) {
478        let is_horizontal = self.axis.is_horizontal();
479        let vertical_wheel = self.vertical_wheel;
480        let line_height = window.line_height();
481        let bounds = hitbox.bounds;
482        let ongoing_scroll = global_id
483            .map(|global_id| {
484                window.with_element_state::<Rc<RefCell<OngoingScroll>>, _>(global_id, |state, _| {
485                    let state = state.unwrap_or_default();
486                    (state.clone(), state)
487                })
488            })
489            .unwrap_or_default();
490
491        window.with_content_mask(Some(ContentMask { bounds }), |window| {
492            if let Some(color) = self.debug {
493                window.paint_quad(PaintQuad {
494                    bounds,
495                    border_widths: Edges::all(px(1.0)),
496                    border_color: color,
497                    background: gpui::transparent_white().into(),
498                    corner_radii: Corners::all(px(0.)),
499                    border_style: BorderStyle::default(),
500                });
501            }
502
503            window.on_mouse_event({
504                let view_id = window.current_view();
505                let scroll_handle = self.scroll_handle.clone();
506                let hitbox_id = hitbox.id;
507                let ongoing_scroll = ongoing_scroll.clone();
508
509                move |event: &ScrollEvent, phase, window, cx| {
510                    // Handle in the capture phase: ancestor scrollers such as
511                    // `gpui::list` register their wheel listeners after their
512                    // children paint, so in the bubble phase (reverse
513                    // registration order) they run first and would consume the
514                    // vertical component of a trackpad swipe before this mask
515                    // could stop the propagation.
516                    //
517                    // `should_handle_scroll` (instead of a raw bounds check)
518                    // keeps the mask inert when it is occluded, e.g. below an
519                    // open dialog or context menu.
520                    if !(phase.capture() && hitbox_id.should_handle_scroll(window)) {
521                        return;
522                    }
523
524                    let mut offset = scroll_handle.offset();
525                    let mut delta = event.delta.pixel_delta(line_height);
526
527                    // A plain mouse wheel's vertical lines move the scroller
528                    // sideways; once it is at its edge the parent scrolls
529                    // instead, as for a vertical mask.
530                    if vertical_wheel {
531                        let ScrollDelta::Lines(lines) = event.delta else {
532                            return;
533                        };
534                        if lines.x != 0. || lines.y == 0. || !lines.y.is_finite() {
535                            return;
536                        }
537                        let axis_max = (scroll_handle.content_size().width
538                            - scroll_handle.viewport_bounds().size.width)
539                            .max(px(0.));
540                        let current = offset.x.clamp(-axis_max, px(0.));
541                        let new_offset = (current + delta.y).clamp(-axis_max, px(0.));
542                        if new_offset == current {
543                            return;
544                        }
545
546                        offset.x = new_offset;
547                        scroll_handle.set_offset(offset);
548                        cx.notify(view_id);
549                        cx.stop_propagation();
550                        return;
551                    }
552
553                    // Lock the gesture to the axis it started on, so a diagonal
554                    // trackpad swipe cannot flip which mask consumes it from one
555                    // event to the next. Line deltas carry no touch phase.
556                    if event.delta.precise() {
557                        ongoing_scroll
558                            .borrow_mut()
559                            .lock_axis(&mut delta, event.touch_phase);
560                    }
561
562                    // Limit for only one way scrolling at same time.
563                    // When use MacBook touchpad we may get both x and y delta,
564                    // only allows the one that more to scroll.
565                    if !delta.x.is_zero() && !delta.y.is_zero() {
566                        if delta.x.abs() > delta.y.abs() {
567                            delta.y = px(0.);
568                        } else {
569                            delta.x = px(0.);
570                        }
571                    }
572
573                    if !is_horizontal {
574                        // The current offset must be clamped too: after a
575                        // bubbled event, the scrolled element's own listener
576                        // pushes the shared offset beyond the edge unclamped
577                        // (the div only clamps on prepaint), and that
578                        // transient overscroll would read as "room to scroll".
579                        // `ScrollbarHandle` has no `max_offset`; recover it from
580                        // the definition `content_size = viewport + max_offset`.
581                        let axis_max = (scroll_handle.content_size().height
582                            - scroll_handle.viewport_bounds().size.height)
583                            .max(px(0.));
584                        let current = offset.y.clamp(-axis_max, px(0.));
585                        let new_offset = (current + delta.y).clamp(-axis_max, px(0.));
586                        if new_offset == current {
587                            // At the edge or no overflow: bubble to the parent.
588                            return;
589                        }
590
591                        offset.y = new_offset;
592                        scroll_handle.set_offset(offset);
593                        cx.notify(view_id);
594                        cx.stop_propagation();
595                        return;
596                    }
597
598                    // Record whether this step actually moves the content
599                    // (see [`last_horizontal_scroll_claim`]). The current offset
600                    // is clamped for comparison only: the next offset must use
601                    // the raw value, which may sit past the edge until prepaint.
602                    if !delta.x.is_zero() {
603                        let axis_max = (scroll_handle.content_size().width
604                            - scroll_handle.viewport_bounds().size.width)
605                            .max(px(0.));
606                        let current = offset.x.clamp(-axis_max, px(0.));
607                        let moved = (offset.x + delta.x).clamp(-axis_max, px(0.)) - current;
608                        if moved.abs() >= CLAIM_EPSILON {
609                            LAST_HORIZONTAL_SCROLL_CLAIM.set(Some(HorizontalScrollClaim {
610                                at: Instant::now(),
611                                delta_x: moved,
612                            }));
613                        }
614                    }
615
616                    offset.x += delta.x;
617
618                    // NOTE: `set_offset` does not clamp (clamping happens in
619                    // the div's prepaint), so any non-zero horizontal-dominant
620                    // delta passes this guard — even at the scroll edge the
621                    // event is consumed rather than turned into a parent
622                    // scroll.
623                    if offset != scroll_handle.offset() {
624                        scroll_handle.set_offset(offset);
625                        cx.notify(view_id);
626                        cx.stop_propagation();
627                    }
628                }
629            });
630        });
631    }
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637    use gpui::{
638        Context, IntoElement, ListAlignment, ListState, Render, ScrollDelta, ScrollEvent,
639        TestAppContext, VisualTestContext, Window, div, list, point, px,
640    };
641
642    #[test]
643    fn corner_notch_paths_stay_inside_their_corner_squares() {
644        let width = px(200.);
645        let height = px(100.);
646        let radius = px(8.);
647        let cases = [
648            ("top left", point(px(0.), px(0.)), 1., 1.),
649            ("top right", point(width, px(0.)), -1., 1.),
650            ("bottom right", point(width, height), -1., -1.),
651            ("bottom left", point(px(0.), height), 1., -1.),
652        ];
653        for (name, corner, x_dir, y_dir) in cases {
654            let path =
655                corner_notch_path(corner, x_dir, y_dir, radius).expect("the notch tessellates");
656            let bounds = path.bounds;
657            // Curve tessellation may inset fractionally; one pixel of slack.
658            let tolerance = 1.0;
659            assert!(
660                (f32::from(bounds.size.width) - f32::from(radius)).abs() <= tolerance
661                    && (f32::from(bounds.size.height) - f32::from(radius)).abs() <= tolerance,
662                "{name}: notch size {:?} must stay a {radius:?} square",
663                bounds.size,
664            );
665            let expected_origin = point(
666                if x_dir > 0. {
667                    corner.x
668                } else {
669                    corner.x - radius
670                },
671                if y_dir > 0. {
672                    corner.y
673                } else {
674                    corner.y - radius
675                },
676            );
677            assert!(
678                (f32::from(bounds.origin.x) - f32::from(expected_origin.x)).abs() <= tolerance
679                    && (f32::from(bounds.origin.y) - f32::from(expected_origin.y)).abs()
680                        <= tolerance,
681                "{name}: notch bounds {bounds:?} must anchor at the frame corner",
682            );
683        }
684    }
685
686    struct HorizontalScrollAreaTest {
687        scroll_handle: ScrollHandle,
688    }
689
690    impl Render for HorizontalScrollAreaTest {
691        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
692            div().w(px(100.)).h(px(40.)).child(horizontal_scroll_area(
693                "horizontal-scroll-area",
694                &self.scroll_handle,
695                &Default::default(),
696                div().w(px(300.)).h(px(40.)),
697            ))
698        }
699    }
700
701    #[gpui::test]
702    fn horizontal_scroll_area_ignores_vertical_wheel(cx: &mut TestAppContext) {
703        let scroll_handle = ScrollHandle::new();
704        let (_, cx) = cx.add_window_view({
705            let scroll_handle = scroll_handle.clone();
706            move |_, _| HorizontalScrollAreaTest {
707                scroll_handle: scroll_handle.clone(),
708            }
709        });
710        let cx: &mut VisualTestContext = cx;
711        cx.run_until_parked();
712        cx.update(|window, cx| {
713            _ = window.draw(cx);
714        });
715
716        cx.simulate_event(ScrollEvent {
717            position: point(px(10.), px(10.)),
718            delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
719            ..Default::default()
720        });
721
722        assert_eq!(scroll_handle.offset().x, px(0.));
723    }
724
725    /// Reproduces the markdown table case: the scroll area lives inside a
726    /// `gpui::list` item. The list registers its wheel listener after its
727    /// items paint, so in the bubble phase (reverse registration order) the
728    /// list runs first and consumes `delta.y` of every trackpad swipe.
729    struct ListWithHorizontalAreaTest {
730        scroll_handle: ScrollHandle,
731        list_state: ListState,
732        occluded: bool,
733    }
734
735    impl Render for ListWithHorizontalAreaTest {
736        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
737            let scroll_handle = self.scroll_handle.clone();
738            let mut root = div().w(px(100.)).h(px(100.)).child(
739                list(self.list_state.clone(), move |ix, _, _| {
740                    if ix == 0 {
741                        horizontal_scroll_area(
742                            "horizontal-scroll-area",
743                            &scroll_handle,
744                            &Default::default(),
745                            div().w(px(300.)).h(px(40.)),
746                        )
747                        .into_any_element()
748                    } else {
749                        div().w(px(100.)).h(px(40.)).into_any_element()
750                    }
751                })
752                .w_full()
753                .h_full(),
754            );
755            if self.occluded {
756                // An overlay above the list, like an open dialog or menu.
757                root = root.child(div().absolute().top_0().left_0().size_full().occlude());
758            }
759            root
760        }
761    }
762
763    fn setup_list_test<'a>(
764        cx: &'a mut TestAppContext,
765        scroll_handle: &ScrollHandle,
766        list_state: &ListState,
767        occluded: bool,
768    ) -> &'a mut VisualTestContext {
769        let (_, cx) = cx.add_window_view({
770            let scroll_handle = scroll_handle.clone();
771            let list_state = list_state.clone();
772            move |_, _| ListWithHorizontalAreaTest {
773                scroll_handle: scroll_handle.clone(),
774                list_state: list_state.clone(),
775                occluded,
776            }
777        });
778        cx.run_until_parked();
779        cx.update(|window, cx| {
780            _ = window.draw(cx);
781        });
782        cx
783    }
784
785    #[gpui::test]
786    fn horizontal_scroll_area_in_list_keeps_horizontal_dominant_wheel(cx: &mut TestAppContext) {
787        let scroll_handle = ScrollHandle::new();
788        let list_state = ListState::new(10, ListAlignment::Top, px(0.));
789        let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
790
791        // A trackpad swipe is rarely axis-pure: horizontal dominant with a
792        // small vertical component.
793        cx.simulate_event(ScrollEvent {
794            position: point(px(10.), px(10.)),
795            delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
796            ..Default::default()
797        });
798
799        // The area consumes the horizontal delta...
800        assert_eq!(scroll_handle.offset().x, px(-40.));
801        // ...and the outer list must not scroll vertically.
802        let scroll_top = list_state.logical_scroll_top();
803        assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
804    }
805
806    #[gpui::test]
807    fn horizontal_scroll_area_in_list_bubbles_vertical_dominant_wheel(cx: &mut TestAppContext) {
808        let scroll_handle = ScrollHandle::new();
809        let list_state = ListState::new(10, ListAlignment::Top, px(0.));
810        let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
811
812        cx.simulate_event(ScrollEvent {
813            position: point(px(10.), px(10.)),
814            delta: ScrollDelta::Pixels(point(px(-10.), px(-40.))),
815            ..Default::default()
816        });
817
818        // Vertical dominant: the list scrolls, the area does not.
819        assert_eq!(scroll_handle.offset().x, px(0.));
820        let scroll_top = list_state.logical_scroll_top();
821        assert_ne!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
822    }
823
824    /// A `MessageScroller`-shaped nesting: a `gpui::list` with a vertical
825    /// mask bound to its `ListState`, inside an ancestor vertical scroller.
826    struct ListWithVerticalMaskTest {
827        outer_handle: ScrollHandle,
828        list_state: ListState,
829    }
830
831    impl Render for ListWithVerticalMaskTest {
832        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
833            div()
834                .id("outer")
835                .w(px(100.))
836                .h(px(100.))
837                .overflow_y_scroll()
838                .track_scroll(&self.outer_handle)
839                .child(
840                    div().w_full().h(px(400.)).child(
841                        div()
842                            .relative()
843                            .w_full()
844                            .h(px(80.))
845                            .overflow_hidden()
846                            .child(
847                                list(self.list_state.clone(), |_, _, _| {
848                                    div().w_full().h(px(40.)).into_any_element()
849                                })
850                                .w_full()
851                                .h_full(),
852                            )
853                            .child(ScrollableMask::new(Axis::Vertical, &self.list_state)),
854                    ),
855                )
856        }
857    }
858
859    fn setup_vertical_mask_test<'a>(
860        cx: &'a mut TestAppContext,
861        outer_handle: &ScrollHandle,
862        list_state: &ListState,
863    ) -> &'a mut VisualTestContext {
864        let (_, cx) = cx.add_window_view({
865            let outer_handle = outer_handle.clone();
866            let list_state = list_state.clone();
867            move |_, _| ListWithVerticalMaskTest {
868                outer_handle: outer_handle.clone(),
869                list_state: list_state.clone(),
870            }
871        });
872        cx.run_until_parked();
873        cx.update(|window, cx| {
874            _ = window.draw(cx);
875        });
876        cx
877    }
878
879    #[gpui::test]
880    fn vertical_mask_contains_wheel_inside_the_list(cx: &mut TestAppContext) {
881        let outer_handle = ScrollHandle::new();
882        // A large overdraw measures every row up front, so the mask sees the
883        // full content height.
884        let list_state = ListState::new(10, ListAlignment::Top, px(1000.));
885        let cx = setup_vertical_mask_test(cx, &outer_handle, &list_state);
886
887        cx.simulate_event(ScrollEvent {
888            position: point(px(10.), px(10.)),
889            delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
890            ..Default::default()
891        });
892
893        // The inner list consumes the wheel; the ancestor must not move.
894        assert_eq!(list_state.scroll_px_offset_for_scrollbar().y, px(-40.));
895        assert_eq!(outer_handle.offset().y, px(0.));
896    }
897
898    #[gpui::test]
899    fn vertical_mask_chains_to_the_ancestor_at_the_edge(cx: &mut TestAppContext) {
900        let outer_handle = ScrollHandle::new();
901        let list_state = ListState::new(10, ListAlignment::Top, px(1000.));
902        // Start the inner list at its bottom edge (400 - 80 = 320).
903        list_state.scroll_to(gpui::ListOffset {
904            item_ix: 8,
905            offset_in_item: px(0.),
906        });
907        let cx = setup_vertical_mask_test(cx, &outer_handle, &list_state);
908
909        cx.simulate_event(ScrollEvent {
910            position: point(px(10.), px(10.)),
911            delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
912            ..Default::default()
913        });
914
915        // At the edge the mask lets the event bubble to the ancestor.
916        assert_eq!(list_state.scroll_px_offset_for_scrollbar().y, px(-320.));
917        assert_eq!(outer_handle.offset().y, px(-40.));
918    }
919
920    #[gpui::test]
921    fn horizontal_scroll_area_covers_viewport_after_scrolled(cx: &mut TestAppContext) {
922        let scroll_handle = ScrollHandle::new();
923        let list_state = ListState::new(10, ListAlignment::Top, px(0.));
924        let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
925
926        // Scroll the area to the middle, then repaint.
927        scroll_handle.set_offset(point(px(-150.), px(0.)));
928        cx.update(|window, cx| {
929            _ = window.draw(cx);
930        });
931
932        // Swipe over the right side of the viewport. The mask must still
933        // cover it — it must not slide away with the scrolled content.
934        cx.simulate_event(ScrollEvent {
935            position: point(px(90.), px(10.)),
936            delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
937            ..Default::default()
938        });
939
940        assert_eq!(scroll_handle.offset().x, px(-190.));
941        let scroll_top = list_state.logical_scroll_top();
942        assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
943    }
944
945    #[gpui::test]
946    fn horizontal_scroll_area_traps_wheel_at_edge(cx: &mut TestAppContext) {
947        let scroll_handle = ScrollHandle::new();
948        let list_state = ListState::new(10, ListAlignment::Top, px(0.));
949        let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
950
951        // Scroll the area to its right edge (300 - 100 = 200).
952        scroll_handle.set_offset(point(px(-200.), px(0.)));
953        cx.update(|window, cx| {
954            _ = window.draw(cx);
955        });
956
957        cx.simulate_event(ScrollEvent {
958            position: point(px(10.), px(10.)),
959            delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
960            ..Default::default()
961        });
962
963        // A horizontal mask consumes even at the edge: a bubbled horizontal
964        // delta would be axis-mapped onto the vertical list (#2468).
965        let scroll_top = list_state.logical_scroll_top();
966        assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
967    }
968
969    #[gpui::test]
970    fn horizontal_scroll_area_ignores_wheel_when_occluded(cx: &mut TestAppContext) {
971        let scroll_handle = ScrollHandle::new();
972        let list_state = ListState::new(10, ListAlignment::Top, px(0.));
973        let cx = setup_list_test(cx, &scroll_handle, &list_state, true);
974
975        cx.simulate_event(ScrollEvent {
976            position: point(px(10.), px(10.)),
977            delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
978            ..Default::default()
979        });
980
981        // An overlay (dialog, context menu) occludes the area: the area
982        // must not scroll underneath it.
983        assert_eq!(scroll_handle.offset().x, px(0.));
984    }
985
986    #[gpui::test]
987    fn horizontal_scroll_area_uses_horizontal_wheel(cx: &mut TestAppContext) {
988        let scroll_handle = ScrollHandle::new();
989        let (_, cx) = cx.add_window_view({
990            let scroll_handle = scroll_handle.clone();
991            move |_, _| HorizontalScrollAreaTest {
992                scroll_handle: scroll_handle.clone(),
993            }
994        });
995        let cx: &mut VisualTestContext = cx;
996        cx.run_until_parked();
997        cx.update(|window, cx| {
998            _ = window.draw(cx);
999        });
1000
1001        cx.simulate_event(ScrollEvent {
1002            position: point(px(10.), px(10.)),
1003            delta: ScrollDelta::Pixels(point(px(-40.), px(0.))),
1004            ..Default::default()
1005        });
1006
1007        assert_eq!(scroll_handle.offset().x, px(-40.));
1008    }
1009
1010    #[gpui::test]
1011    fn horizontal_scroll_area_claims_only_steps_that_move(cx: &mut TestAppContext) {
1012        let scroll_handle = ScrollHandle::new();
1013        let (_, cx) = cx.add_window_view({
1014            let scroll_handle = scroll_handle.clone();
1015            move |_, _| HorizontalScrollAreaTest {
1016                scroll_handle: scroll_handle.clone(),
1017            }
1018        });
1019        let cx: &mut VisualTestContext = cx;
1020        cx.run_until_parked();
1021        cx.update(|window, cx| {
1022            _ = window.draw(cx);
1023        });
1024
1025        // At the left edge, a swipe toward the start has no room: no claim.
1026        LAST_HORIZONTAL_SCROLL_CLAIM.set(None);
1027        cx.simulate_event(ScrollEvent {
1028            position: point(px(10.), px(10.)),
1029            delta: ScrollDelta::Pixels(point(px(40.), px(0.))),
1030            ..Default::default()
1031        });
1032        assert!(last_horizontal_scroll_claim().is_none());
1033
1034        // The overscroll only lasts until the next prepaint clamps it, and
1035        // `simulate_event` draws after each event, so dispatch these steps
1036        // within one update.
1037        cx.update(|window, cx| {
1038            let mut dispatch = |delta_x: f32| {
1039                window.dispatch_event(
1040                    gpui::PlatformInput::Scroll(ScrollEvent {
1041                        position: point(px(10.), px(10.)),
1042                        delta: ScrollDelta::Pixels(point(px(delta_x), px(0.))),
1043                        ..Default::default()
1044                    }),
1045                    cx,
1046                );
1047            };
1048
1049            // Reversing an overscroll without re-entering the range does not
1050            // move content.
1051            scroll_handle.set_offset(point(px(40.), px(0.)));
1052            dispatch(-10.);
1053            assert!(last_horizontal_scroll_claim().is_none());
1054            assert_eq!(scroll_handle.offset().x, px(30.));
1055
1056            // Only the part of a step that re-enters the range counts as movement.
1057            dispatch(-40.);
1058        });
1059        let claim = last_horizontal_scroll_claim().expect("a moving step claims");
1060        assert_eq!(claim.delta_x(), px(-10.));
1061        assert_eq!(scroll_handle.offset().x, px(-10.));
1062
1063        // Ignored input keeps the last meaningful claim intact.
1064        for delta in [point(px(0.), px(-40.)), point(px(-0.25), px(0.))] {
1065            cx.simulate_event(ScrollEvent {
1066                position: point(px(10.), px(10.)),
1067                delta: ScrollDelta::Pixels(delta),
1068                ..Default::default()
1069            });
1070            assert_eq!(last_horizontal_scroll_claim(), Some(claim));
1071        }
1072    }
1073
1074    fn setup_horizontal_area_test<'a>(
1075        cx: &'a mut TestAppContext,
1076        scroll_handle: &ScrollHandle,
1077    ) -> &'a mut VisualTestContext {
1078        let (_, cx) = cx.add_window_view({
1079            let scroll_handle = scroll_handle.clone();
1080            move |_, _| HorizontalScrollAreaTest {
1081                scroll_handle: scroll_handle.clone(),
1082            }
1083        });
1084        let cx: &mut VisualTestContext = cx;
1085        cx.run_until_parked();
1086        cx.update(|window, cx| {
1087            _ = window.draw(cx);
1088        });
1089        cx
1090    }
1091
1092    #[gpui::test]
1093    fn horizontal_mask_keeps_axis_lock_within_a_gesture(cx: &mut TestAppContext) {
1094        let scroll_handle = ScrollHandle::new();
1095        let cx = setup_horizontal_area_test(cx, &scroll_handle);
1096
1097        cx.simulate_event(ScrollEvent {
1098            position: point(px(10.), px(10.)),
1099            delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
1100            ..Default::default()
1101        });
1102        assert_eq!(scroll_handle.offset().x, px(-40.));
1103
1104        // Same gesture, now leaning vertical but under the unlock ratio: the
1105        // lock holds and the horizontal offset keeps moving. Comparing this
1106        // event alone would zero `delta.x` and stall the scroller at -40.
1107        cx.simulate_event(ScrollEvent {
1108            position: point(px(10.), px(10.)),
1109            delta: ScrollDelta::Pixels(point(px(-10.), px(-15.))),
1110            ..Default::default()
1111        });
1112        assert_eq!(scroll_handle.offset().x, px(-50.));
1113    }
1114
1115    #[gpui::test]
1116    fn horizontal_mask_releases_axis_lock_on_a_strong_turn(cx: &mut TestAppContext) {
1117        let scroll_handle = ScrollHandle::new();
1118        let cx = setup_horizontal_area_test(cx, &scroll_handle);
1119
1120        cx.simulate_event(ScrollEvent {
1121            position: point(px(10.), px(10.)),
1122            delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
1123            ..Default::default()
1124        });
1125        assert_eq!(scroll_handle.offset().x, px(-40.));
1126
1127        // Past the unlock ratio the gesture is no longer horizontal, so the
1128        // event stops driving this scroller.
1129        cx.simulate_event(ScrollEvent {
1130            position: point(px(10.), px(10.)),
1131            delta: ScrollDelta::Pixels(point(px(-10.), px(-25.))),
1132            ..Default::default()
1133        });
1134        assert_eq!(scroll_handle.offset().x, px(-40.));
1135    }
1136
1137    /// Reproduces the DataTable case: a vertically scrollable element
1138    /// nested inside an outer vertical scroller.
1139    struct NestedVerticalScrollTest {
1140        outer_handle: ScrollHandle,
1141        inner_handle: ScrollHandle,
1142        inner_content_height: Pixels,
1143    }
1144
1145    impl Render for NestedVerticalScrollTest {
1146        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1147            div()
1148                .id("outer")
1149                .w(px(100.))
1150                .h(px(100.))
1151                .overflow_y_scroll()
1152                .track_scroll(&self.outer_handle)
1153                .child(
1154                    div()
1155                        .relative()
1156                        .w_full()
1157                        .h(px(60.))
1158                        .child(
1159                            div()
1160                                .id("inner")
1161                                .size_full()
1162                                .overflow_y_scroll()
1163                                .track_scroll(&self.inner_handle)
1164                                .child(div().w_full().h(self.inner_content_height)),
1165                        )
1166                        .child(ScrollableMask::new(Axis::Vertical, &self.inner_handle)),
1167                )
1168                .child(div().w_full().h(px(400.)))
1169        }
1170    }
1171
1172    fn setup_nested_vertical_test<'a>(
1173        cx: &'a mut TestAppContext,
1174        outer_handle: &ScrollHandle,
1175        inner_handle: &ScrollHandle,
1176        inner_content_height: Pixels,
1177    ) -> &'a mut VisualTestContext {
1178        let (_, cx) = cx.add_window_view({
1179            let outer_handle = outer_handle.clone();
1180            let inner_handle = inner_handle.clone();
1181            move |_, _| NestedVerticalScrollTest {
1182                outer_handle: outer_handle.clone(),
1183                inner_handle: inner_handle.clone(),
1184                inner_content_height,
1185            }
1186        });
1187        cx.run_until_parked();
1188        cx.update(|window, cx| {
1189            _ = window.draw(cx);
1190        });
1191        cx
1192    }
1193
1194    #[gpui::test]
1195    fn vertical_mask_consumes_wheel_when_scrollable(cx: &mut TestAppContext) {
1196        let outer_handle = ScrollHandle::new();
1197        let inner_handle = ScrollHandle::new();
1198        let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(300.));
1199
1200        cx.simulate_event(ScrollEvent {
1201            position: point(px(10.), px(10.)),
1202            delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1203            ..Default::default()
1204        });
1205
1206        // The inner scroller consumes the event; the outer one must not move.
1207        assert_eq!(inner_handle.offset().y, px(-40.));
1208        assert_eq!(outer_handle.offset().y, px(0.));
1209    }
1210
1211    #[gpui::test]
1212    fn vertical_mask_hands_off_to_parent_at_edge(cx: &mut TestAppContext) {
1213        let outer_handle = ScrollHandle::new();
1214        let inner_handle = ScrollHandle::new();
1215        let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(300.));
1216
1217        // Scroll the inner element to its bottom edge (300 - 60 = 240).
1218        inner_handle.set_offset(point(px(0.), px(-240.)));
1219        cx.update(|window, cx| {
1220            _ = window.draw(cx);
1221        });
1222
1223        cx.simulate_event(ScrollEvent {
1224            position: point(px(10.), px(10.)),
1225            delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1226            ..Default::default()
1227        });
1228
1229        // At the edge the event bubbles: the outer scroller takes over.
1230        assert_eq!(outer_handle.offset().y, px(-40.));
1231        // The inner offset is clamped back to the edge on the next prepaint.
1232        cx.update(|window, cx| {
1233            _ = window.draw(cx);
1234        });
1235        assert_eq!(inner_handle.offset().y, px(-240.));
1236    }
1237
1238    #[gpui::test]
1239    fn vertical_mask_bubbles_when_no_overflow(cx: &mut TestAppContext) {
1240        let outer_handle = ScrollHandle::new();
1241        let inner_handle = ScrollHandle::new();
1242        // Inner content (40) fits its 60px viewport: nothing to scroll.
1243        let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(40.));
1244
1245        cx.simulate_event(ScrollEvent {
1246            position: point(px(10.), px(10.)),
1247            delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1248            ..Default::default()
1249        });
1250
1251        assert_eq!(outer_handle.offset().y, px(-40.));
1252        cx.update(|window, cx| {
1253            _ = window.draw(cx);
1254        });
1255        assert_eq!(inner_handle.offset().y, px(0.));
1256    }
1257
1258    #[gpui::test]
1259    fn vertical_mask_ignores_transient_overscroll(cx: &mut TestAppContext) {
1260        let outer_handle = ScrollHandle::new();
1261        let inner_handle = ScrollHandle::new();
1262        let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(300.));
1263
1264        inner_handle.set_offset(point(px(0.), px(-240.)));
1265        cx.update(|window, cx| {
1266            _ = window.draw(cx);
1267        });
1268
1269        // Two wheel events at the edge with no redraw in between: the first
1270        // one leaves the inner offset beyond the edge unclamped, which must
1271        // not read as "room to scroll" and swallow the second event.
1272        for _ in 0..2 {
1273            cx.simulate_event(ScrollEvent {
1274                position: point(px(10.), px(10.)),
1275                delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1276                ..Default::default()
1277            });
1278        }
1279
1280        assert_eq!(outer_handle.offset().y, px(-80.));
1281        cx.update(|window, cx| {
1282            _ = window.draw(cx);
1283        });
1284        assert_eq!(inner_handle.offset().y, px(-240.));
1285    }
1286
1287    /// The vertical mask nested in a `gpui::list` ancestor: the list
1288    /// registers its wheel listener after its items paint, so only a
1289    /// capture-phase mask can stop it from scrolling on the same event.
1290    struct ListWithVerticalAreaTest {
1291        scroll_handle: ScrollHandle,
1292        list_state: ListState,
1293    }
1294
1295    impl Render for ListWithVerticalAreaTest {
1296        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1297            let scroll_handle = self.scroll_handle.clone();
1298            div().w(px(100.)).h(px(100.)).child(
1299                list(self.list_state.clone(), move |ix, _, _| {
1300                    if ix == 0 {
1301                        div()
1302                            .relative()
1303                            .w_full()
1304                            .h(px(60.))
1305                            .child(
1306                                div()
1307                                    .id("inner")
1308                                    .size_full()
1309                                    .overflow_y_scroll()
1310                                    .track_scroll(&scroll_handle)
1311                                    .child(div().w_full().h(px(300.))),
1312                            )
1313                            .child(ScrollableMask::new(Axis::Vertical, &scroll_handle))
1314                            .into_any_element()
1315                    } else {
1316                        div().w(px(100.)).h(px(40.)).into_any_element()
1317                    }
1318                })
1319                .w_full()
1320                .h_full(),
1321            )
1322        }
1323    }
1324
1325    #[gpui::test]
1326    fn vertical_mask_in_list_consumes_wheel_when_scrollable(cx: &mut TestAppContext) {
1327        let scroll_handle = ScrollHandle::new();
1328        let list_state = ListState::new(10, ListAlignment::Top, px(0.));
1329        let (_, cx) = cx.add_window_view({
1330            let scroll_handle = scroll_handle.clone();
1331            let list_state = list_state.clone();
1332            move |_, _| ListWithVerticalAreaTest {
1333                scroll_handle: scroll_handle.clone(),
1334                list_state: list_state.clone(),
1335            }
1336        });
1337        cx.run_until_parked();
1338        cx.update(|window, cx| {
1339            _ = window.draw(cx);
1340        });
1341
1342        cx.simulate_event(ScrollEvent {
1343            position: point(px(10.), px(10.)),
1344            delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1345            ..Default::default()
1346        });
1347
1348        // The inner scroller consumes the event; the list must not scroll.
1349        assert_eq!(scroll_handle.offset().y, px(-40.));
1350        let scroll_top = list_state.logical_scroll_top();
1351        assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
1352    }
1353
1354    /// A 100px strip of 300px content, locked to its axis like a tab strip,
1355    /// with a vertical-wheel mask, inside a vertical scroller.
1356    struct VerticalWheelTest {
1357        strip: ScrollHandle,
1358        page: ScrollHandle,
1359    }
1360
1361    impl Render for VerticalWheelTest {
1362        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1363            div()
1364                .id("page")
1365                .w(px(100.))
1366                .h(px(60.))
1367                .overflow_y_scroll()
1368                .track_scroll(&self.page)
1369                .child(
1370                    div()
1371                        .relative()
1372                        .w(px(100.))
1373                        .h(px(40.))
1374                        .child(
1375                            div()
1376                                .id("strip")
1377                                .size_full()
1378                                .flex()
1379                                .overflow_x_scroll()
1380                                .restrict_scroll_to_axis()
1381                                .track_scroll(&self.strip)
1382                                .child(div().flex_none().w(px(300.)).h(px(40.))),
1383                        )
1384                        .child(ScrollableMask::vertical_wheel(&self.strip)),
1385                )
1386                .child(div().h(px(200.)))
1387        }
1388    }
1389
1390    fn vertical_wheel_test(
1391        cx: &mut TestAppContext,
1392    ) -> (&mut VisualTestContext, ScrollHandle, ScrollHandle) {
1393        let (strip, page) = (ScrollHandle::new(), ScrollHandle::new());
1394        let (_, cx) = cx.add_window_view({
1395            let (strip, page) = (strip.clone(), page.clone());
1396            move |_, _| VerticalWheelTest { strip, page }
1397        });
1398        cx.run_until_parked();
1399        cx.update(|window, cx| window.draw(cx).clear(cx));
1400        (cx, strip, page)
1401    }
1402
1403    fn wheel(cx: &mut VisualTestContext, delta: ScrollDelta) {
1404        cx.simulate_event(ScrollEvent {
1405            position: point(px(10.), px(10.)),
1406            delta,
1407            ..Default::default()
1408        });
1409        cx.update(|window, cx| window.draw(cx).clear(cx));
1410    }
1411
1412    #[gpui::test]
1413    fn vertical_wheel_scrolls_the_strip_and_then_the_parent(cx: &mut TestAppContext) {
1414        let (cx, strip, page) = vertical_wheel_test(cx);
1415        let line_height = cx.update(|window, _| window.line_height());
1416
1417        wheel(cx, ScrollDelta::Lines(point(0., -1.)));
1418        assert_eq!(strip.offset().x, -line_height);
1419        assert_eq!(page.offset().y, px(0.));
1420
1421        // Past the strip's end, the wheel scrolls the page.
1422        wheel(cx, ScrollDelta::Lines(point(0., -100.)));
1423        assert_eq!(strip.offset().x, px(-200.));
1424        assert_eq!(page.offset().y, px(0.));
1425        wheel(cx, ScrollDelta::Lines(point(0., -1.)));
1426        assert_eq!(strip.offset().x, px(-200.));
1427        assert!(page.offset().y < px(0.));
1428
1429        // A delta that is no number does not move the strip.
1430        wheel(cx, ScrollDelta::Lines(point(0., f32::NAN)));
1431        assert_eq!(strip.offset().x, px(-200.));
1432    }
1433
1434    #[gpui::test]
1435    fn vertical_wheel_leaves_trackpads_and_horizontal_wheels_to_the_scroller(
1436        cx: &mut TestAppContext,
1437    ) {
1438        let (cx, strip, page) = vertical_wheel_test(cx);
1439        let line_height = cx.update(|window, _| window.line_height());
1440
1441        // A vertical trackpad swipe keeps the strip's axis lock.
1442        wheel(cx, ScrollDelta::Pixels(point(px(0.), px(-20.))));
1443        assert_eq!(strip.offset().x, px(0.));
1444        assert_eq!(page.offset().y, px(-20.));
1445
1446        // A horizontal wheel reaches the strip's own listener.
1447        wheel(cx, ScrollDelta::Lines(point(-1., 0.)));
1448        assert_eq!(strip.offset().x, -line_height);
1449    }
1450}