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                    // With nothing to scroll sideways, a swipe that moves
554                    // vertically at all belongs to the parent scroller.
555                    if is_horizontal
556                        && !delta.y.is_zero()
557                        && scroll_handle.content_size().width
558                            <= scroll_handle.viewport_bounds().size.width
559                    {
560                        return;
561                    }
562
563                    // Lock the gesture to the axis it started on, so a diagonal
564                    // trackpad swipe cannot flip which mask consumes it from one
565                    // event to the next. Line deltas carry no touch phase.
566                    if event.delta.precise() {
567                        ongoing_scroll
568                            .borrow_mut()
569                            .lock_axis(&mut delta, event.touch_phase);
570                    }
571
572                    // Limit for only one way scrolling at same time.
573                    // When use MacBook touchpad we may get both x and y delta,
574                    // only allows the one that more to scroll.
575                    if !delta.x.is_zero() && !delta.y.is_zero() {
576                        if delta.x.abs() > delta.y.abs() {
577                            delta.y = px(0.);
578                        } else {
579                            delta.x = px(0.);
580                        }
581                    }
582
583                    if !is_horizontal {
584                        // The current offset must be clamped too: after a
585                        // bubbled event, the scrolled element's own listener
586                        // pushes the shared offset beyond the edge unclamped
587                        // (the div only clamps on prepaint), and that
588                        // transient overscroll would read as "room to scroll".
589                        // `ScrollbarHandle` has no `max_offset`; recover it from
590                        // the definition `content_size = viewport + max_offset`.
591                        let axis_max = (scroll_handle.content_size().height
592                            - scroll_handle.viewport_bounds().size.height)
593                            .max(px(0.));
594                        let current = offset.y.clamp(-axis_max, px(0.));
595                        let new_offset = (current + delta.y).clamp(-axis_max, px(0.));
596                        if new_offset == current {
597                            // At the edge or no overflow: bubble to the parent.
598                            return;
599                        }
600
601                        offset.y = new_offset;
602                        scroll_handle.set_offset(offset);
603                        cx.notify(view_id);
604                        cx.stop_propagation();
605                        return;
606                    }
607
608                    // Record whether this step actually moves the content
609                    // (see [`last_horizontal_scroll_claim`]). The current offset
610                    // is clamped for comparison only: the next offset must use
611                    // the raw value, which may sit past the edge until prepaint.
612                    if !delta.x.is_zero() {
613                        let axis_max = (scroll_handle.content_size().width
614                            - scroll_handle.viewport_bounds().size.width)
615                            .max(px(0.));
616                        let current = offset.x.clamp(-axis_max, px(0.));
617                        let moved = (offset.x + delta.x).clamp(-axis_max, px(0.)) - current;
618                        if moved.abs() >= CLAIM_EPSILON {
619                            LAST_HORIZONTAL_SCROLL_CLAIM.set(Some(HorizontalScrollClaim {
620                                at: Instant::now(),
621                                delta_x: moved,
622                            }));
623                        }
624                    }
625
626                    offset.x += delta.x;
627
628                    // NOTE: `set_offset` does not clamp (clamping happens in
629                    // the div's prepaint), so any non-zero horizontal-dominant
630                    // delta passes this guard — even at the scroll edge the
631                    // event is consumed rather than turned into a parent
632                    // scroll.
633                    if offset != scroll_handle.offset() {
634                        scroll_handle.set_offset(offset);
635                        cx.notify(view_id);
636                        cx.stop_propagation();
637                    }
638                }
639            });
640        });
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647    use gpui::{
648        Context, IntoElement, ListAlignment, ListState, Render, ScrollDelta, ScrollEvent,
649        TestAppContext, VisualTestContext, Window, div, list, point, px,
650    };
651
652    #[test]
653    fn corner_notch_paths_stay_inside_their_corner_squares() {
654        let width = px(200.);
655        let height = px(100.);
656        let radius = px(8.);
657        let cases = [
658            ("top left", point(px(0.), px(0.)), 1., 1.),
659            ("top right", point(width, px(0.)), -1., 1.),
660            ("bottom right", point(width, height), -1., -1.),
661            ("bottom left", point(px(0.), height), 1., -1.),
662        ];
663        for (name, corner, x_dir, y_dir) in cases {
664            let path =
665                corner_notch_path(corner, x_dir, y_dir, radius).expect("the notch tessellates");
666            let bounds = path.bounds;
667            // Curve tessellation may inset fractionally; one pixel of slack.
668            let tolerance = 1.0;
669            assert!(
670                (f32::from(bounds.size.width) - f32::from(radius)).abs() <= tolerance
671                    && (f32::from(bounds.size.height) - f32::from(radius)).abs() <= tolerance,
672                "{name}: notch size {:?} must stay a {radius:?} square",
673                bounds.size,
674            );
675            let expected_origin = point(
676                if x_dir > 0. {
677                    corner.x
678                } else {
679                    corner.x - radius
680                },
681                if y_dir > 0. {
682                    corner.y
683                } else {
684                    corner.y - radius
685                },
686            );
687            assert!(
688                (f32::from(bounds.origin.x) - f32::from(expected_origin.x)).abs() <= tolerance
689                    && (f32::from(bounds.origin.y) - f32::from(expected_origin.y)).abs()
690                        <= tolerance,
691                "{name}: notch bounds {bounds:?} must anchor at the frame corner",
692            );
693        }
694    }
695
696    struct HorizontalScrollAreaTest {
697        scroll_handle: ScrollHandle,
698    }
699
700    impl Render for HorizontalScrollAreaTest {
701        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
702            div().w(px(100.)).h(px(40.)).child(horizontal_scroll_area(
703                "horizontal-scroll-area",
704                &self.scroll_handle,
705                &Default::default(),
706                div().w(px(300.)).h(px(40.)),
707            ))
708        }
709    }
710
711    #[gpui::test]
712    fn horizontal_scroll_area_ignores_vertical_wheel(cx: &mut TestAppContext) {
713        let scroll_handle = ScrollHandle::new();
714        let (_, cx) = cx.add_window_view({
715            let scroll_handle = scroll_handle.clone();
716            move |_, _| HorizontalScrollAreaTest {
717                scroll_handle: scroll_handle.clone(),
718            }
719        });
720        let cx: &mut VisualTestContext = cx;
721        cx.run_until_parked();
722        cx.update(|window, cx| {
723            _ = window.draw(cx);
724        });
725
726        cx.simulate_event(ScrollEvent {
727            position: point(px(10.), px(10.)),
728            delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
729            ..Default::default()
730        });
731
732        assert_eq!(scroll_handle.offset().x, px(0.));
733    }
734
735    /// Reproduces the markdown table case: the scroll area lives inside a
736    /// `gpui::list` item. The list registers its wheel listener after its
737    /// items paint, so in the bubble phase (reverse registration order) the
738    /// list runs first and consumes `delta.y` of every trackpad swipe.
739    struct ListWithHorizontalAreaTest {
740        scroll_handle: ScrollHandle,
741        list_state: ListState,
742        occluded: bool,
743    }
744
745    impl Render for ListWithHorizontalAreaTest {
746        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
747            let scroll_handle = self.scroll_handle.clone();
748            let mut root = div().w(px(100.)).h(px(100.)).child(
749                list(self.list_state.clone(), move |ix, _, _| {
750                    if ix == 0 {
751                        horizontal_scroll_area(
752                            "horizontal-scroll-area",
753                            &scroll_handle,
754                            &Default::default(),
755                            div().w(px(300.)).h(px(40.)),
756                        )
757                        .into_any_element()
758                    } else {
759                        div().w(px(100.)).h(px(40.)).into_any_element()
760                    }
761                })
762                .w_full()
763                .h_full(),
764            );
765            if self.occluded {
766                // An overlay above the list, like an open dialog or menu.
767                root = root.child(div().absolute().top_0().left_0().size_full().occlude());
768            }
769            root
770        }
771    }
772
773    fn setup_list_test<'a>(
774        cx: &'a mut TestAppContext,
775        scroll_handle: &ScrollHandle,
776        list_state: &ListState,
777        occluded: bool,
778    ) -> &'a mut VisualTestContext {
779        let (_, cx) = cx.add_window_view({
780            let scroll_handle = scroll_handle.clone();
781            let list_state = list_state.clone();
782            move |_, _| ListWithHorizontalAreaTest {
783                scroll_handle: scroll_handle.clone(),
784                list_state: list_state.clone(),
785                occluded,
786            }
787        });
788        cx.run_until_parked();
789        cx.update(|window, cx| {
790            _ = window.draw(cx);
791        });
792        cx
793    }
794
795    #[gpui::test]
796    fn horizontal_scroll_area_in_list_keeps_horizontal_dominant_wheel(cx: &mut TestAppContext) {
797        let scroll_handle = ScrollHandle::new();
798        let list_state = ListState::new(10, ListAlignment::Top, px(0.));
799        let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
800
801        // A trackpad swipe is rarely axis-pure: horizontal dominant with a
802        // small vertical component.
803        cx.simulate_event(ScrollEvent {
804            position: point(px(10.), px(10.)),
805            delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
806            ..Default::default()
807        });
808
809        // The area consumes the horizontal delta...
810        assert_eq!(scroll_handle.offset().x, px(-40.));
811        // ...and the outer list must not scroll vertically.
812        let scroll_top = list_state.logical_scroll_top();
813        assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
814    }
815
816    #[gpui::test]
817    fn horizontal_scroll_area_in_list_bubbles_vertical_dominant_wheel(cx: &mut TestAppContext) {
818        let scroll_handle = ScrollHandle::new();
819        let list_state = ListState::new(10, ListAlignment::Top, px(0.));
820        let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
821
822        cx.simulate_event(ScrollEvent {
823            position: point(px(10.), px(10.)),
824            delta: ScrollDelta::Pixels(point(px(-10.), px(-40.))),
825            ..Default::default()
826        });
827
828        // Vertical dominant: the list scrolls, the area does not.
829        assert_eq!(scroll_handle.offset().x, px(0.));
830        let scroll_top = list_state.logical_scroll_top();
831        assert_ne!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
832    }
833
834    /// A list whose first row is a horizontal scroll area with content that
835    /// fits, like a narrow Markdown table.
836    struct ListWithFittingAreaTest(ListState);
837
838    impl Render for ListWithFittingAreaTest {
839        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
840            div().w(px(100.)).h(px(100.)).child(
841                list(self.0.clone(), |ix, _, _| {
842                    if ix == 0 {
843                        horizontal_scroll_area(
844                            "fitting-area",
845                            &ScrollHandle::new(),
846                            &Default::default(),
847                            div().w(px(50.)).h(px(40.)),
848                        )
849                        .into_any_element()
850                    } else {
851                        div().w(px(100.)).h(px(40.)).into_any_element()
852                    }
853                })
854                .w_full()
855                .h_full(),
856            )
857        }
858    }
859
860    #[gpui::test]
861    fn horizontal_scroll_area_that_fits_bubbles_horizontal_dominant_wheel(cx: &mut TestAppContext) {
862        let list_state = ListState::new(10, ListAlignment::Top, px(0.));
863        let (_, cx) = cx.add_window_view({
864            let list_state = list_state.clone();
865            move |_, _| ListWithFittingAreaTest(list_state.clone())
866        });
867        cx.update(|window, cx| {
868            _ = window.draw(cx);
869        });
870
871        cx.simulate_event(ScrollEvent {
872            position: point(px(10.), px(10.)),
873            delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
874            ..Default::default()
875        });
876        // Nothing to scroll sideways: the list takes the swipe.
877        let scroll_top = list_state.logical_scroll_top();
878        assert_ne!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
879    }
880
881    /// A `MessageScroller`-shaped nesting: a `gpui::list` with a vertical
882    /// mask bound to its `ListState`, inside an ancestor vertical scroller.
883    struct ListWithVerticalMaskTest {
884        outer_handle: ScrollHandle,
885        list_state: ListState,
886    }
887
888    impl Render for ListWithVerticalMaskTest {
889        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
890            div()
891                .id("outer")
892                .w(px(100.))
893                .h(px(100.))
894                .overflow_y_scroll()
895                .track_scroll(&self.outer_handle)
896                .child(
897                    div().w_full().h(px(400.)).child(
898                        div()
899                            .relative()
900                            .w_full()
901                            .h(px(80.))
902                            .overflow_hidden()
903                            .child(
904                                list(self.list_state.clone(), |_, _, _| {
905                                    div().w_full().h(px(40.)).into_any_element()
906                                })
907                                .w_full()
908                                .h_full(),
909                            )
910                            .child(ScrollableMask::new(Axis::Vertical, &self.list_state)),
911                    ),
912                )
913        }
914    }
915
916    fn setup_vertical_mask_test<'a>(
917        cx: &'a mut TestAppContext,
918        outer_handle: &ScrollHandle,
919        list_state: &ListState,
920    ) -> &'a mut VisualTestContext {
921        let (_, cx) = cx.add_window_view({
922            let outer_handle = outer_handle.clone();
923            let list_state = list_state.clone();
924            move |_, _| ListWithVerticalMaskTest {
925                outer_handle: outer_handle.clone(),
926                list_state: list_state.clone(),
927            }
928        });
929        cx.run_until_parked();
930        cx.update(|window, cx| {
931            _ = window.draw(cx);
932        });
933        cx
934    }
935
936    #[gpui::test]
937    fn vertical_mask_contains_wheel_inside_the_list(cx: &mut TestAppContext) {
938        let outer_handle = ScrollHandle::new();
939        // A large overdraw measures every row up front, so the mask sees the
940        // full content height.
941        let list_state = ListState::new(10, ListAlignment::Top, px(1000.));
942        let cx = setup_vertical_mask_test(cx, &outer_handle, &list_state);
943
944        cx.simulate_event(ScrollEvent {
945            position: point(px(10.), px(10.)),
946            delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
947            ..Default::default()
948        });
949
950        // The inner list consumes the wheel; the ancestor must not move.
951        assert_eq!(list_state.scroll_px_offset_for_scrollbar().y, px(-40.));
952        assert_eq!(outer_handle.offset().y, px(0.));
953    }
954
955    #[gpui::test]
956    fn vertical_mask_chains_to_the_ancestor_at_the_edge(cx: &mut TestAppContext) {
957        let outer_handle = ScrollHandle::new();
958        let list_state = ListState::new(10, ListAlignment::Top, px(1000.));
959        // Start the inner list at its bottom edge (400 - 80 = 320).
960        list_state.scroll_to(gpui::ListOffset {
961            item_ix: 8,
962            offset_in_item: px(0.),
963        });
964        let cx = setup_vertical_mask_test(cx, &outer_handle, &list_state);
965
966        cx.simulate_event(ScrollEvent {
967            position: point(px(10.), px(10.)),
968            delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
969            ..Default::default()
970        });
971
972        // At the edge the mask lets the event bubble to the ancestor.
973        assert_eq!(list_state.scroll_px_offset_for_scrollbar().y, px(-320.));
974        assert_eq!(outer_handle.offset().y, px(-40.));
975    }
976
977    #[gpui::test]
978    fn horizontal_scroll_area_covers_viewport_after_scrolled(cx: &mut TestAppContext) {
979        let scroll_handle = ScrollHandle::new();
980        let list_state = ListState::new(10, ListAlignment::Top, px(0.));
981        let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
982
983        // Scroll the area to the middle, then repaint.
984        scroll_handle.set_offset(point(px(-150.), px(0.)));
985        cx.update(|window, cx| {
986            _ = window.draw(cx);
987        });
988
989        // Swipe over the right side of the viewport. The mask must still
990        // cover it — it must not slide away with the scrolled content.
991        cx.simulate_event(ScrollEvent {
992            position: point(px(90.), px(10.)),
993            delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
994            ..Default::default()
995        });
996
997        assert_eq!(scroll_handle.offset().x, px(-190.));
998        let scroll_top = list_state.logical_scroll_top();
999        assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
1000    }
1001
1002    #[gpui::test]
1003    fn horizontal_scroll_area_traps_wheel_at_edge(cx: &mut TestAppContext) {
1004        let scroll_handle = ScrollHandle::new();
1005        let list_state = ListState::new(10, ListAlignment::Top, px(0.));
1006        let cx = setup_list_test(cx, &scroll_handle, &list_state, false);
1007
1008        // Scroll the area to its right edge (300 - 100 = 200).
1009        scroll_handle.set_offset(point(px(-200.), px(0.)));
1010        cx.update(|window, cx| {
1011            _ = window.draw(cx);
1012        });
1013
1014        cx.simulate_event(ScrollEvent {
1015            position: point(px(10.), px(10.)),
1016            delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
1017            ..Default::default()
1018        });
1019
1020        // A horizontal mask consumes even at the edge: a bubbled horizontal
1021        // delta would be axis-mapped onto the vertical list (#2468).
1022        let scroll_top = list_state.logical_scroll_top();
1023        assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
1024    }
1025
1026    #[gpui::test]
1027    fn horizontal_scroll_area_ignores_wheel_when_occluded(cx: &mut TestAppContext) {
1028        let scroll_handle = ScrollHandle::new();
1029        let list_state = ListState::new(10, ListAlignment::Top, px(0.));
1030        let cx = setup_list_test(cx, &scroll_handle, &list_state, true);
1031
1032        cx.simulate_event(ScrollEvent {
1033            position: point(px(10.), px(10.)),
1034            delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
1035            ..Default::default()
1036        });
1037
1038        // An overlay (dialog, context menu) occludes the area: the area
1039        // must not scroll underneath it.
1040        assert_eq!(scroll_handle.offset().x, px(0.));
1041    }
1042
1043    #[gpui::test]
1044    fn horizontal_scroll_area_uses_horizontal_wheel(cx: &mut TestAppContext) {
1045        let scroll_handle = ScrollHandle::new();
1046        let (_, cx) = cx.add_window_view({
1047            let scroll_handle = scroll_handle.clone();
1048            move |_, _| HorizontalScrollAreaTest {
1049                scroll_handle: scroll_handle.clone(),
1050            }
1051        });
1052        let cx: &mut VisualTestContext = cx;
1053        cx.run_until_parked();
1054        cx.update(|window, cx| {
1055            _ = window.draw(cx);
1056        });
1057
1058        cx.simulate_event(ScrollEvent {
1059            position: point(px(10.), px(10.)),
1060            delta: ScrollDelta::Pixels(point(px(-40.), px(0.))),
1061            ..Default::default()
1062        });
1063
1064        assert_eq!(scroll_handle.offset().x, px(-40.));
1065    }
1066
1067    #[gpui::test]
1068    fn horizontal_scroll_area_claims_only_steps_that_move(cx: &mut TestAppContext) {
1069        let scroll_handle = ScrollHandle::new();
1070        let (_, cx) = cx.add_window_view({
1071            let scroll_handle = scroll_handle.clone();
1072            move |_, _| HorizontalScrollAreaTest {
1073                scroll_handle: scroll_handle.clone(),
1074            }
1075        });
1076        let cx: &mut VisualTestContext = cx;
1077        cx.run_until_parked();
1078        cx.update(|window, cx| {
1079            _ = window.draw(cx);
1080        });
1081
1082        // At the left edge, a swipe toward the start has no room: no claim.
1083        LAST_HORIZONTAL_SCROLL_CLAIM.set(None);
1084        cx.simulate_event(ScrollEvent {
1085            position: point(px(10.), px(10.)),
1086            delta: ScrollDelta::Pixels(point(px(40.), px(0.))),
1087            ..Default::default()
1088        });
1089        assert!(last_horizontal_scroll_claim().is_none());
1090
1091        // The overscroll only lasts until the next prepaint clamps it, and
1092        // `simulate_event` draws after each event, so dispatch these steps
1093        // within one update.
1094        cx.update(|window, cx| {
1095            let mut dispatch = |delta_x: f32| {
1096                window.dispatch_event(
1097                    gpui::PlatformInput::Scroll(ScrollEvent {
1098                        position: point(px(10.), px(10.)),
1099                        delta: ScrollDelta::Pixels(point(px(delta_x), px(0.))),
1100                        ..Default::default()
1101                    }),
1102                    cx,
1103                );
1104            };
1105
1106            // Reversing an overscroll without re-entering the range does not
1107            // move content.
1108            scroll_handle.set_offset(point(px(40.), px(0.)));
1109            dispatch(-10.);
1110            assert!(last_horizontal_scroll_claim().is_none());
1111            assert_eq!(scroll_handle.offset().x, px(30.));
1112
1113            // Only the part of a step that re-enters the range counts as movement.
1114            dispatch(-40.);
1115        });
1116        let claim = last_horizontal_scroll_claim().expect("a moving step claims");
1117        assert_eq!(claim.delta_x(), px(-10.));
1118        assert_eq!(scroll_handle.offset().x, px(-10.));
1119
1120        // Ignored input keeps the last meaningful claim intact.
1121        for delta in [point(px(0.), px(-40.)), point(px(-0.25), px(0.))] {
1122            cx.simulate_event(ScrollEvent {
1123                position: point(px(10.), px(10.)),
1124                delta: ScrollDelta::Pixels(delta),
1125                ..Default::default()
1126            });
1127            assert_eq!(last_horizontal_scroll_claim(), Some(claim));
1128        }
1129    }
1130
1131    fn setup_horizontal_area_test<'a>(
1132        cx: &'a mut TestAppContext,
1133        scroll_handle: &ScrollHandle,
1134    ) -> &'a mut VisualTestContext {
1135        let (_, cx) = cx.add_window_view({
1136            let scroll_handle = scroll_handle.clone();
1137            move |_, _| HorizontalScrollAreaTest {
1138                scroll_handle: scroll_handle.clone(),
1139            }
1140        });
1141        let cx: &mut VisualTestContext = cx;
1142        cx.run_until_parked();
1143        cx.update(|window, cx| {
1144            _ = window.draw(cx);
1145        });
1146        cx
1147    }
1148
1149    #[gpui::test]
1150    fn horizontal_mask_keeps_axis_lock_within_a_gesture(cx: &mut TestAppContext) {
1151        let scroll_handle = ScrollHandle::new();
1152        let cx = setup_horizontal_area_test(cx, &scroll_handle);
1153
1154        cx.simulate_event(ScrollEvent {
1155            position: point(px(10.), px(10.)),
1156            delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
1157            ..Default::default()
1158        });
1159        assert_eq!(scroll_handle.offset().x, px(-40.));
1160
1161        // Same gesture, now leaning vertical but under the unlock ratio: the
1162        // lock holds and the horizontal offset keeps moving. Comparing this
1163        // event alone would zero `delta.x` and stall the scroller at -40.
1164        cx.simulate_event(ScrollEvent {
1165            position: point(px(10.), px(10.)),
1166            delta: ScrollDelta::Pixels(point(px(-10.), px(-15.))),
1167            ..Default::default()
1168        });
1169        assert_eq!(scroll_handle.offset().x, px(-50.));
1170    }
1171
1172    #[gpui::test]
1173    fn horizontal_mask_releases_axis_lock_on_a_strong_turn(cx: &mut TestAppContext) {
1174        let scroll_handle = ScrollHandle::new();
1175        let cx = setup_horizontal_area_test(cx, &scroll_handle);
1176
1177        cx.simulate_event(ScrollEvent {
1178            position: point(px(10.), px(10.)),
1179            delta: ScrollDelta::Pixels(point(px(-40.), px(-10.))),
1180            ..Default::default()
1181        });
1182        assert_eq!(scroll_handle.offset().x, px(-40.));
1183
1184        // Past the unlock ratio the gesture is no longer horizontal, so the
1185        // event stops driving this scroller.
1186        cx.simulate_event(ScrollEvent {
1187            position: point(px(10.), px(10.)),
1188            delta: ScrollDelta::Pixels(point(px(-10.), px(-25.))),
1189            ..Default::default()
1190        });
1191        assert_eq!(scroll_handle.offset().x, px(-40.));
1192    }
1193
1194    /// Reproduces the DataTable case: a vertically scrollable element
1195    /// nested inside an outer vertical scroller.
1196    struct NestedVerticalScrollTest {
1197        outer_handle: ScrollHandle,
1198        inner_handle: ScrollHandle,
1199        inner_content_height: Pixels,
1200    }
1201
1202    impl Render for NestedVerticalScrollTest {
1203        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1204            div()
1205                .id("outer")
1206                .w(px(100.))
1207                .h(px(100.))
1208                .overflow_y_scroll()
1209                .track_scroll(&self.outer_handle)
1210                .child(
1211                    div()
1212                        .relative()
1213                        .w_full()
1214                        .h(px(60.))
1215                        .child(
1216                            div()
1217                                .id("inner")
1218                                .size_full()
1219                                .overflow_y_scroll()
1220                                .track_scroll(&self.inner_handle)
1221                                .child(div().w_full().h(self.inner_content_height)),
1222                        )
1223                        .child(ScrollableMask::new(Axis::Vertical, &self.inner_handle)),
1224                )
1225                .child(div().w_full().h(px(400.)))
1226        }
1227    }
1228
1229    fn setup_nested_vertical_test<'a>(
1230        cx: &'a mut TestAppContext,
1231        outer_handle: &ScrollHandle,
1232        inner_handle: &ScrollHandle,
1233        inner_content_height: Pixels,
1234    ) -> &'a mut VisualTestContext {
1235        let (_, cx) = cx.add_window_view({
1236            let outer_handle = outer_handle.clone();
1237            let inner_handle = inner_handle.clone();
1238            move |_, _| NestedVerticalScrollTest {
1239                outer_handle: outer_handle.clone(),
1240                inner_handle: inner_handle.clone(),
1241                inner_content_height,
1242            }
1243        });
1244        cx.run_until_parked();
1245        cx.update(|window, cx| {
1246            _ = window.draw(cx);
1247        });
1248        cx
1249    }
1250
1251    #[gpui::test]
1252    fn vertical_mask_consumes_wheel_when_scrollable(cx: &mut TestAppContext) {
1253        let outer_handle = ScrollHandle::new();
1254        let inner_handle = ScrollHandle::new();
1255        let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(300.));
1256
1257        cx.simulate_event(ScrollEvent {
1258            position: point(px(10.), px(10.)),
1259            delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1260            ..Default::default()
1261        });
1262
1263        // The inner scroller consumes the event; the outer one must not move.
1264        assert_eq!(inner_handle.offset().y, px(-40.));
1265        assert_eq!(outer_handle.offset().y, px(0.));
1266    }
1267
1268    #[gpui::test]
1269    fn vertical_mask_hands_off_to_parent_at_edge(cx: &mut TestAppContext) {
1270        let outer_handle = ScrollHandle::new();
1271        let inner_handle = ScrollHandle::new();
1272        let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(300.));
1273
1274        // Scroll the inner element to its bottom edge (300 - 60 = 240).
1275        inner_handle.set_offset(point(px(0.), px(-240.)));
1276        cx.update(|window, cx| {
1277            _ = window.draw(cx);
1278        });
1279
1280        cx.simulate_event(ScrollEvent {
1281            position: point(px(10.), px(10.)),
1282            delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1283            ..Default::default()
1284        });
1285
1286        // At the edge the event bubbles: the outer scroller takes over.
1287        assert_eq!(outer_handle.offset().y, px(-40.));
1288        // The inner offset is clamped back to the edge on the next prepaint.
1289        cx.update(|window, cx| {
1290            _ = window.draw(cx);
1291        });
1292        assert_eq!(inner_handle.offset().y, px(-240.));
1293    }
1294
1295    #[gpui::test]
1296    fn vertical_mask_bubbles_when_no_overflow(cx: &mut TestAppContext) {
1297        let outer_handle = ScrollHandle::new();
1298        let inner_handle = ScrollHandle::new();
1299        // Inner content (40) fits its 60px viewport: nothing to scroll.
1300        let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(40.));
1301
1302        cx.simulate_event(ScrollEvent {
1303            position: point(px(10.), px(10.)),
1304            delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1305            ..Default::default()
1306        });
1307
1308        assert_eq!(outer_handle.offset().y, px(-40.));
1309        cx.update(|window, cx| {
1310            _ = window.draw(cx);
1311        });
1312        assert_eq!(inner_handle.offset().y, px(0.));
1313    }
1314
1315    #[gpui::test]
1316    fn vertical_mask_ignores_transient_overscroll(cx: &mut TestAppContext) {
1317        let outer_handle = ScrollHandle::new();
1318        let inner_handle = ScrollHandle::new();
1319        let cx = setup_nested_vertical_test(cx, &outer_handle, &inner_handle, px(300.));
1320
1321        inner_handle.set_offset(point(px(0.), px(-240.)));
1322        cx.update(|window, cx| {
1323            _ = window.draw(cx);
1324        });
1325
1326        // Two wheel events at the edge with no redraw in between: the first
1327        // one leaves the inner offset beyond the edge unclamped, which must
1328        // not read as "room to scroll" and swallow the second event.
1329        for _ in 0..2 {
1330            cx.simulate_event(ScrollEvent {
1331                position: point(px(10.), px(10.)),
1332                delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1333                ..Default::default()
1334            });
1335        }
1336
1337        assert_eq!(outer_handle.offset().y, px(-80.));
1338        cx.update(|window, cx| {
1339            _ = window.draw(cx);
1340        });
1341        assert_eq!(inner_handle.offset().y, px(-240.));
1342    }
1343
1344    /// The vertical mask nested in a `gpui::list` ancestor: the list
1345    /// registers its wheel listener after its items paint, so only a
1346    /// capture-phase mask can stop it from scrolling on the same event.
1347    struct ListWithVerticalAreaTest {
1348        scroll_handle: ScrollHandle,
1349        list_state: ListState,
1350    }
1351
1352    impl Render for ListWithVerticalAreaTest {
1353        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1354            let scroll_handle = self.scroll_handle.clone();
1355            div().w(px(100.)).h(px(100.)).child(
1356                list(self.list_state.clone(), move |ix, _, _| {
1357                    if ix == 0 {
1358                        div()
1359                            .relative()
1360                            .w_full()
1361                            .h(px(60.))
1362                            .child(
1363                                div()
1364                                    .id("inner")
1365                                    .size_full()
1366                                    .overflow_y_scroll()
1367                                    .track_scroll(&scroll_handle)
1368                                    .child(div().w_full().h(px(300.))),
1369                            )
1370                            .child(ScrollableMask::new(Axis::Vertical, &scroll_handle))
1371                            .into_any_element()
1372                    } else {
1373                        div().w(px(100.)).h(px(40.)).into_any_element()
1374                    }
1375                })
1376                .w_full()
1377                .h_full(),
1378            )
1379        }
1380    }
1381
1382    #[gpui::test]
1383    fn vertical_mask_in_list_consumes_wheel_when_scrollable(cx: &mut TestAppContext) {
1384        let scroll_handle = ScrollHandle::new();
1385        let list_state = ListState::new(10, ListAlignment::Top, px(0.));
1386        let (_, cx) = cx.add_window_view({
1387            let scroll_handle = scroll_handle.clone();
1388            let list_state = list_state.clone();
1389            move |_, _| ListWithVerticalAreaTest {
1390                scroll_handle: scroll_handle.clone(),
1391                list_state: list_state.clone(),
1392            }
1393        });
1394        cx.run_until_parked();
1395        cx.update(|window, cx| {
1396            _ = window.draw(cx);
1397        });
1398
1399        cx.simulate_event(ScrollEvent {
1400            position: point(px(10.), px(10.)),
1401            delta: ScrollDelta::Pixels(point(px(0.), px(-40.))),
1402            ..Default::default()
1403        });
1404
1405        // The inner scroller consumes the event; the list must not scroll.
1406        assert_eq!(scroll_handle.offset().y, px(-40.));
1407        let scroll_top = list_state.logical_scroll_top();
1408        assert_eq!((scroll_top.item_ix, scroll_top.offset_in_item), (0, px(0.)));
1409    }
1410
1411    /// A 100px strip of 300px content, locked to its axis like a tab strip,
1412    /// with a vertical-wheel mask, inside a vertical scroller.
1413    struct VerticalWheelTest {
1414        strip: ScrollHandle,
1415        page: ScrollHandle,
1416    }
1417
1418    impl Render for VerticalWheelTest {
1419        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1420            div()
1421                .id("page")
1422                .w(px(100.))
1423                .h(px(60.))
1424                .overflow_y_scroll()
1425                .track_scroll(&self.page)
1426                .child(
1427                    div()
1428                        .relative()
1429                        .w(px(100.))
1430                        .h(px(40.))
1431                        .child(
1432                            div()
1433                                .id("strip")
1434                                .size_full()
1435                                .flex()
1436                                .overflow_x_scroll()
1437                                .restrict_scroll_to_axis()
1438                                .track_scroll(&self.strip)
1439                                .child(div().flex_none().w(px(300.)).h(px(40.))),
1440                        )
1441                        .child(ScrollableMask::vertical_wheel(&self.strip)),
1442                )
1443                .child(div().h(px(200.)))
1444        }
1445    }
1446
1447    fn vertical_wheel_test(
1448        cx: &mut TestAppContext,
1449    ) -> (&mut VisualTestContext, ScrollHandle, ScrollHandle) {
1450        let (strip, page) = (ScrollHandle::new(), ScrollHandle::new());
1451        let (_, cx) = cx.add_window_view({
1452            let (strip, page) = (strip.clone(), page.clone());
1453            move |_, _| VerticalWheelTest { strip, page }
1454        });
1455        cx.run_until_parked();
1456        cx.update(|window, cx| window.draw(cx).clear(cx));
1457        (cx, strip, page)
1458    }
1459
1460    fn wheel(cx: &mut VisualTestContext, delta: ScrollDelta) {
1461        cx.simulate_event(ScrollEvent {
1462            position: point(px(10.), px(10.)),
1463            delta,
1464            ..Default::default()
1465        });
1466        cx.update(|window, cx| window.draw(cx).clear(cx));
1467    }
1468
1469    #[gpui::test]
1470    fn vertical_wheel_scrolls_the_strip_and_then_the_parent(cx: &mut TestAppContext) {
1471        let (cx, strip, page) = vertical_wheel_test(cx);
1472        let line_height = cx.update(|window, _| window.line_height());
1473
1474        wheel(cx, ScrollDelta::Lines(point(0., -1.)));
1475        assert_eq!(strip.offset().x, -line_height);
1476        assert_eq!(page.offset().y, px(0.));
1477
1478        // Past the strip's end, the wheel scrolls the page.
1479        wheel(cx, ScrollDelta::Lines(point(0., -100.)));
1480        assert_eq!(strip.offset().x, px(-200.));
1481        assert_eq!(page.offset().y, px(0.));
1482        wheel(cx, ScrollDelta::Lines(point(0., -1.)));
1483        assert_eq!(strip.offset().x, px(-200.));
1484        assert!(page.offset().y < px(0.));
1485
1486        // A delta that is no number does not move the strip.
1487        wheel(cx, ScrollDelta::Lines(point(0., f32::NAN)));
1488        assert_eq!(strip.offset().x, px(-200.));
1489    }
1490
1491    #[gpui::test]
1492    fn vertical_wheel_leaves_trackpads_and_horizontal_wheels_to_the_scroller(
1493        cx: &mut TestAppContext,
1494    ) {
1495        let (cx, strip, page) = vertical_wheel_test(cx);
1496        let line_height = cx.update(|window, _| window.line_height());
1497
1498        // A vertical trackpad swipe keeps the strip's axis lock.
1499        wheel(cx, ScrollDelta::Pixels(point(px(0.), px(-20.))));
1500        assert_eq!(strip.offset().x, px(0.));
1501        assert_eq!(page.offset().y, px(-20.));
1502
1503        // A horizontal wheel reaches the strip's own listener.
1504        wheel(cx, ScrollDelta::Lines(point(-1., 0.)));
1505        assert_eq!(strip.offset().x, -line_height);
1506    }
1507}