Skip to main content

gpui_base/
scrollable_mask.rs

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