Skip to main content

gpui_component/scroll/
scrollable.rs

1use std::{panic::Location, rc::Rc};
2
3use crate::{InteractiveElementExt as _, StyledExt};
4
5use super::{ScrollableMask, Scrollbar, ScrollbarAxis, ScrollbarHandle};
6use gpui::{
7    App, Div, Element, ElementId, InteractiveElement, IntoElement, Overflow, ParentElement,
8    PointRefinement, RenderOnce, ScrollHandle, Stateful, StatefulInteractiveElement,
9    StyleRefinement, Styled, Window, div, prelude::FluentBuilder,
10};
11
12/// A trait for elements that can be made scrollable with scrollbars.
13///
14/// The wrapped element is the scroll area itself, rather than being inserted as
15/// a child of a new scroll area.
16pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Element {
17    /// Adds a scrollbar to the element.
18    #[track_caller]
19    fn scrollbar<H: ScrollbarHandle + Clone>(
20        self,
21        scroll_handle: &H,
22        axis: impl Into<ScrollbarAxis>,
23    ) -> Self {
24        self.child(ScrollbarLayer {
25            id: caller_id(),
26            axis: axis.into(),
27            scroll_handle: Rc::new(scroll_handle.clone()),
28        })
29    }
30
31    /// Adds a vertical scrollbar to the element.
32    #[track_caller]
33    fn vertical_scrollbar<H: ScrollbarHandle + Clone>(self, scroll_handle: &H) -> Self {
34        self.scrollbar(scroll_handle, ScrollbarAxis::Vertical)
35    }
36
37    /// Adds a horizontal scrollbar to the element.
38    #[track_caller]
39    fn horizontal_scrollbar<H: ScrollbarHandle + Clone>(self, scroll_handle: &H) -> Self {
40        self.scrollbar(scroll_handle, ScrollbarAxis::Horizontal)
41    }
42
43    /// Almost equivalent to [`StatefulInteractiveElement::overflow_scroll`], but adds scrollbars.
44    /// Preserves the source element as the scrollable container.
45    #[track_caller]
46    fn overflow_scrollbar(self) -> Scrollable<Self> {
47        Scrollable::new(self, ScrollbarAxis::Both)
48    }
49
50    /// Almost equivalent to [`StatefulInteractiveElement::overflow_x_scroll`], but adds Horizontal scrollbar.
51    /// Preserves the source element as the scrollable container.
52    #[track_caller]
53    fn overflow_x_scrollbar(self) -> Scrollable<Self> {
54        Scrollable::new(self, ScrollbarAxis::Horizontal)
55    }
56
57    /// Almost equivalent to [`StatefulInteractiveElement::overflow_y_scroll`], but adds Vertical scrollbar.
58    /// Preserves the source element as the scrollable container.
59    #[track_caller]
60    fn overflow_y_scrollbar(self) -> Scrollable<Self> {
61        Scrollable::new(self, ScrollbarAxis::Vertical)
62    }
63}
64
65/// A scrollable element wrapper that renders the original element as the scroll area and overlays scrollbars.
66#[derive(IntoElement)]
67pub struct Scrollable<E: InteractiveElement + Styled + ParentElement + Element> {
68    id: ElementId,
69    element: E,
70    axis: ScrollbarAxis,
71    scroll_bounce: Option<bool>,
72}
73
74impl<E> Scrollable<E>
75where
76    E: InteractiveElement + Styled + ParentElement + Element,
77{
78    #[track_caller]
79    fn new(element: E, axis: impl Into<ScrollbarAxis>) -> Self {
80        Self {
81            id: caller_id(),
82            element,
83            axis: axis.into(),
84            scroll_bounce: None,
85        }
86    }
87
88    /// Set a specific element id, default is the [`std::panic::Location::caller`].
89    ///
90    /// Only needed when one call site creates several scrollables, which would
91    /// otherwise share a single scroll position.
92    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
93        self.id = id.into();
94        self
95    }
96
97    /// Bounce at the vertical boundaries. On by default on iOS, Android and
98    /// macOS, off elsewhere; horizontal-only areas do not bounce.
99    pub fn scroll_bounce(mut self, enabled: bool) -> Self {
100        self.scroll_bounce = Some(enabled);
101        self
102    }
103}
104
105impl<E> Styled for Scrollable<E>
106where
107    E: InteractiveElement + Styled + ParentElement + Element,
108{
109    fn style(&mut self) -> &mut StyleRefinement {
110        self.element.style()
111    }
112}
113
114impl<E> ParentElement for Scrollable<E>
115where
116    E: InteractiveElement + Styled + ParentElement + Element,
117{
118    fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
119        self.element.extend(elements)
120    }
121}
122
123impl<E> InteractiveElement for Scrollable<E>
124where
125    E: InteractiveElement + Styled + ParentElement + Element,
126{
127    fn interactivity(&mut self) -> &mut gpui::Interactivity {
128        self.element.interactivity()
129    }
130
131    fn track_focus(mut self, focus: &gpui::FocusHandle) -> Self {
132        self.element = self.element.track_focus(focus);
133        self
134    }
135}
136
137impl<E> RenderOnce for Scrollable<E>
138where
139    E: InteractiveElement + Styled + ParentElement + Element + 'static,
140{
141    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
142        let scroll_handle = scroll_handle_for(&self.id, window, cx);
143
144        // Preserve the caller-requested size on the wrapper, while keeping the
145        // caller's element as the actual scroll-tracked layout container.
146        let root_style = root_style_from(&mut self.element, self.axis);
147
148        let root_id = self.id.clone();
149        let area_id = (self.id.clone(), "area");
150        let content_id = (self.id.clone(), "content");
151        let scrollbar_id = (self.id.clone(), "scrollbar");
152
153        let content = self
154            .element
155            .id(content_id)
156            .flex_none()
157            .map(|this| match self.axis {
158                ScrollbarAxis::Vertical => this.h_auto().min_h_full(),
159                ScrollbarAxis::Horizontal => this.w_auto().min_w_full(),
160                ScrollbarAxis::Both => this.size_auto().min_size_full(),
161            });
162
163        // Keep the scroll area in the normal flow: its content size must
164        // propagate to auto-sized ancestors (e.g. a Dialog that grows with
165        // its content). An absolutely positioned scroll area would collapse
166        // such ancestors to zero height.
167        let scroll_area = div()
168            .id(area_id)
169            .size_full()
170            .flex()
171            .track_scroll(&scroll_handle)
172            .map(|this| match self.axis {
173                ScrollbarAxis::Vertical => this.flex_col().overflow_y_scroll(),
174                ScrollbarAxis::Horizontal => this.flex_row().overflow_x_scroll(),
175                ScrollbarAxis::Both => this.overflow_scroll(),
176            })
177            // On a single-axis area gpui otherwise remaps the other axis' delta
178            // onto ours, so a purely horizontal swipe scrolls this vertically.
179            .lock_scroll_axis()
180            .child(content);
181
182        div()
183            .id(root_id)
184            .size_full()
185            .refine_style(&root_style)
186            .relative()
187            .child(
188                gpui_base::ScrollBounce::new(
189                    (self.id.clone(), "bounce"),
190                    &scroll_handle,
191                    scroll_area,
192                )
193                .when_some(self.scroll_bounce, |bounce, on| bounce.enabled(on)),
194            )
195            // A plain mouse wheel has only a vertical axis, which the locked
196            // horizontal area above would ignore.
197            .when(self.axis == ScrollbarAxis::Horizontal, |this| {
198                this.child(ScrollableMask::vertical_wheel(&scroll_handle))
199            })
200            .child(render_scrollbar(
201                scrollbar_id,
202                &scroll_handle,
203                self.axis,
204                window,
205                cx,
206            ))
207    }
208}
209
210impl ScrollableElement for Div {}
211impl<E> ScrollableElement for Stateful<E>
212where
213    E: ParentElement + Styled + Element,
214    Self: InteractiveElement,
215{
216}
217
218#[derive(IntoElement)]
219struct ScrollbarLayer<H: ScrollbarHandle + Clone> {
220    id: ElementId,
221    axis: ScrollbarAxis,
222    scroll_handle: Rc<H>,
223}
224
225impl<H> RenderOnce for ScrollbarLayer<H>
226where
227    H: ScrollbarHandle + Clone + 'static,
228{
229    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
230        render_scrollbar(self.id, self.scroll_handle.as_ref(), self.axis, window, cx)
231    }
232}
233
234#[inline]
235#[track_caller]
236fn caller_id() -> ElementId {
237    ElementId::CodeLocation(*Location::caller())
238}
239
240#[inline]
241fn scroll_handle_for(id: &ElementId, window: &mut Window, cx: &mut App) -> ScrollHandle {
242    window
243        .use_keyed_state(id.clone(), cx, |_, _| ScrollHandle::default())
244        .read(cx)
245        .clone()
246}
247
248/// Copies the outer layout styles from the element, so the wrapper can
249/// participate in the parent's layout the same way the source element would.
250///
251/// A flex item only drops its content-based automatic minimum size when its own
252/// overflow is not [`Overflow::Visible`]; otherwise the item refuses to shrink
253/// around its content. The scrolled overflow lives on the inner scroll area, so
254/// the wrapper has to declare the scrolled axis clipped itself — without it a
255/// scroll region used as a flex item pushes its siblings out of the container
256/// instead of scrolling, and every call site has to remember `min_h_0()` /
257/// `min_w_0()`.
258///
259/// [`Overflow::Hidden`] rather than a zero minimum: it is the axis' real
260/// behavior, it leaves an explicit `min_size` from the caller in charge, and it
261/// keeps the region's content size contributing to an auto-sized ancestor such
262/// as a Dialog that grows with its body. The mask it installs matches the inner
263/// scroll area's own mask, so nothing new is clipped.
264#[inline]
265fn root_style_from<E>(element: &mut E, axis: ScrollbarAxis) -> StyleRefinement
266where
267    E: Styled,
268{
269    let style = element.style();
270    StyleRefinement {
271        size: style.size.clone(),
272        min_size: style.min_size.clone(),
273        max_size: style.max_size.clone(),
274        flex_grow: style.flex_grow,
275        flex_shrink: style.flex_shrink,
276        flex_basis: style.flex_basis,
277        align_self: style.align_self,
278        overflow: PointRefinement {
279            x: axis.has_horizontal().then_some(Overflow::Hidden),
280            y: axis.has_vertical().then_some(Overflow::Hidden),
281        },
282        ..Default::default()
283    }
284}
285
286#[inline]
287fn render_scrollbar<H: ScrollbarHandle + Clone>(
288    id: impl Into<ElementId>,
289    scroll_handle: &H,
290    axis: ScrollbarAxis,
291    window: &mut Window,
292    cx: &mut App,
293) -> Div {
294    // Do not render scrollbar when inspector is picking elements,
295    // to allow us to pick the background elements.
296    let is_inspector_picking = window.is_inspector_picking(cx);
297    if is_inspector_picking {
298        return div();
299    }
300
301    div()
302        .absolute()
303        .inset_0()
304        .debug_selector(|| "scrollbar-overlay".to_string())
305        .child(
306            Scrollbar::new(scroll_handle)
307                .id(id)
308                .axis(axis)
309                .viewport_from_layout(),
310        )
311}
312
313#[cfg(feature = "test-support")]
314impl<E> ScrollableElement for gpui_base::test_support::Observed<E> where
315    E: ScrollableElement + Element<PrepaintState = Option<gpui::Hitbox>>
316{
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use gpui::{
323        Context, Render, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point,
324        px,
325    };
326
327    fn draw(cx: &mut VisualTestContext) {
328        cx.run_until_parked();
329        cx.update(|window, cx| {
330            _ = window.draw(cx);
331        });
332    }
333
334    fn scroll(cx: &mut VisualTestContext, x: f32, y: f32, dx: f32, dy: f32) {
335        cx.simulate_event(ScrollWheelEvent {
336            position: point(px(x), px(y)),
337            delta: ScrollDelta::Pixels(point(px(dx), px(dy))),
338            ..Default::default()
339        });
340        draw(cx);
341    }
342
343    fn row(selector: &'static str, height: f32) -> Div {
344        div()
345            .h(px(height))
346            .flex_shrink_0()
347            .debug_selector(move || selector.to_string())
348    }
349
350    fn plain_row(height: f32) -> Div {
351        div().h(px(height)).flex_shrink_0()
352    }
353
354    fn item(selector: &'static str, width: f32) -> Div {
355        div()
356            .w(px(width))
357            .h(px(20.))
358            .flex_shrink_0()
359            .debug_selector(move || selector.to_string())
360    }
361
362    fn plain_item(width: f32) -> Div {
363        div().w(px(width)).h(px(20.)).flex_shrink_0()
364    }
365
366    struct SizeFullChildTest;
367
368    impl Render for SizeFullChildTest {
369        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
370            div()
371                .w(px(100.))
372                .h(px(100.))
373                .overflow_y_scrollbar()
374                .child(
375                    div()
376                        .size_full()
377                        .child(crate::v_flex().children((0..4).map(|ix| {
378                            div().h(px(50.)).flex_shrink_0().when(ix == 3, |this| {
379                                this.debug_selector(|| "last-row".to_string())
380                            })
381                        }))),
382                )
383        }
384    }
385
386    struct AutoHeightParentTest;
387
388    impl Render for AutoHeightParentTest {
389        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
390            // Mimics Dialog: the panel height is auto (content-driven), the
391            // body is flex_1 + overflow_hidden, and the scrollable content
392            // should give the panel its intrinsic height.
393            // GPUI window roots with auto dimensions stretch to the viewport,
394            // so keep the auto-height panel below an explicit viewport root.
395            div().size_full().child(
396                crate::v_flex()
397                    .w(px(200.))
398                    .child(
399                        crate::v_flex().flex_1().overflow_hidden().child(
400                            div().flex_1().overflow_hidden().child(
401                                crate::v_flex()
402                                    .size_full()
403                                    .overflow_y_scrollbar()
404                                    .child(plain_row(50.))
405                                    .child(plain_row(50.)),
406                            ),
407                        ),
408                    )
409                    .child(row("auto-height-footer", 10.)),
410            )
411        }
412    }
413
414    struct MaxHeightParentTest;
415
416    impl Render for MaxHeightParentTest {
417        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
418            // Mimics a Dialog with `max_h`: the panel grows with content up
419            // to the max height, then the body starts scrolling.
420            crate::v_flex()
421                .w(px(200.))
422                .max_h(px(100.))
423                .child(
424                    crate::v_flex().flex_1().overflow_hidden().child(
425                        div().flex_1().overflow_hidden().child(
426                            crate::v_flex()
427                                .size_full()
428                                .overflow_y_scrollbar()
429                                .child(plain_row(50.))
430                                .child(plain_row(50.))
431                                .child(row("max-height-last-row", 50.)),
432                        ),
433                    ),
434                )
435                .child(row("max-height-footer", 10.))
436        }
437    }
438
439    #[gpui::test]
440    fn auto_height_parent_gets_content_height(cx: &mut TestAppContext) {
441        cx.update(crate::init);
442        let (_, cx) = cx.add_window_view(|_, _| AutoHeightParentTest);
443        let cx: &mut VisualTestContext = cx;
444        draw(cx);
445
446        // The two 50px rows should push the footer down to y = 100.
447        let footer = cx.debug_bounds("auto-height-footer").unwrap();
448        assert_eq!(footer.top(), px(100.));
449    }
450
451    #[gpui::test]
452    fn max_height_parent_clamps_and_scrolls(cx: &mut TestAppContext) {
453        cx.update(crate::init);
454        let (_, cx) = cx.add_window_view(|_, _| MaxHeightParentTest);
455        let cx: &mut VisualTestContext = cx;
456        draw(cx);
457
458        // Content (150) + footer (10) exceeds max_h(100): the footer is
459        // pinned at the bottom and the body gets the remaining 90px viewport.
460        let footer = cx.debug_bounds("max-height-footer").unwrap();
461        assert_eq!(footer.top(), px(90.));
462
463        let last_initial_y = cx.debug_bounds("max-height-last-row").unwrap().origin.y;
464        scroll(cx, 10., 10., 0., -50.);
465        assert!(cx.debug_bounds("max-height-last-row").unwrap().origin.y < last_initial_y);
466    }
467
468    struct FlexItemScrollableTest;
469
470    impl Render for FlexItemScrollableTest {
471        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
472            // A fixed-height column of header, flexible scroll area, footer.
473            // The content is far taller than the room left for the area, so
474            // the area must shrink into the remaining 60px and scroll.
475            crate::v_flex()
476                .w(px(100.))
477                .h(px(100.))
478                .child(row("flex-item-header", 20.))
479                .child(
480                    crate::v_flex()
481                        .flex_1()
482                        .overflow_y_scrollbar()
483                        .children((0..6).map(|ix| {
484                            div().h(px(50.)).flex_shrink_0().when(ix == 0, |this| {
485                                this.debug_selector(|| "flex-item-first-row".to_string())
486                            })
487                        })),
488                )
489                .child(row("flex-item-footer", 20.))
490        }
491    }
492
493    #[gpui::test]
494    fn scrollable_flex_item_shrinks_below_its_content(cx: &mut TestAppContext) {
495        cx.update(crate::init);
496        let (_, cx) = cx.add_window_view(|_, _| FlexItemScrollableTest);
497        let cx: &mut VisualTestContext = cx;
498        draw(cx);
499
500        // Header and footer stay inside the 100px column, so the area took
501        // the 60px left over instead of its content height.
502        assert_eq!(cx.debug_bounds("flex-item-header").unwrap().top(), px(0.));
503        assert_eq!(cx.debug_bounds("flex-item-footer").unwrap().top(), px(80.));
504
505        // And the shrunken area really scrolls.
506        let initial_y = cx.debug_bounds("flex-item-first-row").unwrap().origin.y;
507        scroll(cx, 10., 50., 0., -50.);
508        assert!(cx.debug_bounds("flex-item-first-row").unwrap().origin.y < initial_y);
509    }
510
511    struct HorizontalFlexItemScrollableTest;
512
513    impl Render for HorizontalFlexItemScrollableTest {
514        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
515            crate::h_flex()
516                .w(px(100.))
517                .h(px(40.))
518                .child(item("horizontal-flex-item-leading", 20.))
519                .child(
520                    crate::h_flex()
521                        .flex_1()
522                        .overflow_x_scrollbar()
523                        .children((0..6).map(|ix| {
524                            div()
525                                .w(px(50.))
526                                .h(px(20.))
527                                .flex_shrink_0()
528                                .when(ix == 0, |this| {
529                                    this.debug_selector(|| {
530                                        "horizontal-flex-item-first-item".to_string()
531                                    })
532                                })
533                        })),
534                )
535                .child(item("horizontal-flex-item-trailing", 20.))
536        }
537    }
538
539    #[gpui::test]
540    fn horizontal_scrollable_flex_item_shrinks_below_its_content(cx: &mut TestAppContext) {
541        cx.update(crate::init);
542        let (_, cx) = cx.add_window_view(|_, _| HorizontalFlexItemScrollableTest);
543        let cx: &mut VisualTestContext = cx;
544        draw(cx);
545
546        assert_eq!(
547            cx.debug_bounds("horizontal-flex-item-leading")
548                .unwrap()
549                .left(),
550            px(0.)
551        );
552        assert_eq!(
553            cx.debug_bounds("horizontal-flex-item-trailing")
554                .unwrap()
555                .left(),
556            px(80.)
557        );
558
559        let initial_x = cx
560            .debug_bounds("horizontal-flex-item-first-item")
561            .unwrap()
562            .origin
563            .x;
564        scroll(cx, 50., 10., -50., 0.);
565        assert!(
566            cx.debug_bounds("horizontal-flex-item-first-item")
567                .unwrap()
568                .origin
569                .x
570                < initial_x
571        );
572    }
573
574    #[gpui::test]
575    fn horizontal_scrollable_scrolls_with_a_vertical_mouse_wheel(cx: &mut TestAppContext) {
576        cx.update(crate::init);
577        let (_, cx) = cx.add_window_view(|_, _| HorizontalFlexItemScrollableTest);
578        let cx: &mut VisualTestContext = cx;
579        draw(cx);
580
581        let first_item_x = |cx: &mut VisualTestContext| {
582            cx.debug_bounds("horizontal-flex-item-first-item")
583                .unwrap()
584                .origin
585                .x
586        };
587        let initial_x = first_item_x(cx);
588        cx.simulate_event(ScrollWheelEvent {
589            position: point(px(50.), px(10.)),
590            delta: ScrollDelta::Lines(point(0., -1.)),
591            ..Default::default()
592        });
593        draw(cx);
594        assert!(first_item_x(cx) < initial_x);
595    }
596
597    struct ExplicitMinHeightScrollableTest;
598
599    impl Render for ExplicitMinHeightScrollableTest {
600        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
601            // An explicit `min_h` is the caller's decision and must survive.
602            crate::v_flex()
603                .w(px(100.))
604                .h(px(100.))
605                .child(row("explicit-min-header", 20.))
606                .child(
607                    crate::v_flex()
608                        .flex_1()
609                        .min_h(px(70.))
610                        .overflow_y_scrollbar()
611                        .children((0..6).map(|_| plain_row(50.))),
612                )
613                .child(row("explicit-min-footer", 20.))
614        }
615    }
616
617    #[gpui::test]
618    fn explicit_min_size_survives_the_scrollable_wrapper(cx: &mut TestAppContext) {
619        cx.update(crate::init);
620        let (_, cx) = cx.add_window_view(|_, _| ExplicitMinHeightScrollableTest);
621        let cx: &mut VisualTestContext = cx;
622        draw(cx);
623
624        // The area cannot shrink past the requested 70px, so the footer is
625        // pushed to 20 + 70 instead of being clamped into the column.
626        assert_eq!(
627            cx.debug_bounds("explicit-min-header").unwrap().top(),
628            px(0.)
629        );
630        assert_eq!(
631            cx.debug_bounds("explicit-min-footer").unwrap().top(),
632            px(90.)
633        );
634    }
635
636    struct GapLayoutTest;
637
638    struct PaddedScrollbarOverlayTest;
639
640    impl Render for PaddedScrollbarOverlayTest {
641        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
642            crate::v_flex()
643                .w(px(100.))
644                .h(px(100.))
645                .p(px(20.))
646                .overflow_y_scrollbar()
647                .children((0..4).map(|_| plain_row(50.)))
648        }
649    }
650
651    #[gpui::test]
652    fn scrollbar_overlay_ignores_content_padding(cx: &mut TestAppContext) {
653        cx.update(crate::init);
654        let (_, cx) = cx.add_window_view(|_, _| PaddedScrollbarOverlayTest);
655        let cx: &mut VisualTestContext = cx;
656        draw(cx);
657
658        let overlay = cx.debug_bounds("scrollbar-overlay").unwrap();
659        assert_eq!(overlay.origin, point(px(0.), px(0.)));
660        assert_eq!(overlay.size, gpui::size(px(100.), px(100.)));
661    }
662
663    impl Render for GapLayoutTest {
664        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
665            crate::v_flex()
666                .w(px(100.))
667                .h(px(100.))
668                .gap(px(10.))
669                .overflow_y_scrollbar()
670                .child(row("first-row", 20.))
671                .child(row("second-row", 20.))
672        }
673    }
674
675    struct IssueGapRegressionTest;
676
677    impl Render for IssueGapRegressionTest {
678        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
679            div().w(px(100.)).h(px(100.)).child(
680                crate::v_flex()
681                    .flex_1()
682                    .gap(px(30.))
683                    .overflow_y_scrollbar()
684                    .px(px(12.))
685                    .pb(px(16.))
686                    .children((0..5).map(|ix| {
687                        div()
688                            .h(px(20.))
689                            .flex_shrink_0()
690                            .when(ix == 0, |this| {
691                                this.debug_selector(|| "issue-first-card".to_string())
692                            })
693                            .when(ix == 1, |this| {
694                                this.debug_selector(|| "issue-second-card".to_string())
695                            })
696                            .when(ix == 4, |this| {
697                                this.debug_selector(|| "issue-last-card".to_string())
698                            })
699                    })),
700            )
701        }
702    }
703
704    struct HorizontalGapLayoutTest;
705
706    impl Render for HorizontalGapLayoutTest {
707        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
708            crate::h_flex()
709                .w(px(100.))
710                .h(px(40.))
711                .gap(px(10.))
712                .overflow_x_scrollbar()
713                .child(item("horizontal-first-item", 50.))
714                .child(item("horizontal-second-item", 50.))
715                .child(item("horizontal-last-item", 50.))
716        }
717    }
718
719    struct OverflowScrollbarVerticalTest;
720
721    impl Render for OverflowScrollbarVerticalTest {
722        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
723            crate::v_flex()
724                .w(px(100.))
725                .h(px(100.))
726                .gap(px(10.))
727                .overflow_scrollbar()
728                .child(row("both-axis-vertical-first-row", 50.))
729                .child(row("both-axis-vertical-second-row", 50.))
730                .child(row("both-axis-vertical-last-row", 50.))
731        }
732    }
733
734    struct OverflowScrollbarHorizontalTest;
735
736    impl Render for OverflowScrollbarHorizontalTest {
737        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
738            crate::h_flex()
739                .w(px(100.))
740                .h(px(40.))
741                .gap(px(10.))
742                .overflow_scrollbar()
743                .child(item("both-axis-horizontal-first-item", 50.))
744                .child(item("both-axis-horizontal-second-item", 50.))
745                .child(item("both-axis-horizontal-last-item", 50.))
746        }
747    }
748
749    struct IndependentScrollablesTest;
750
751    impl Render for IndependentScrollablesTest {
752        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
753            crate::h_flex()
754                .w(px(220.))
755                .h(px(100.))
756                .gap(px(20.))
757                .child(
758                    div().w(px(100.)).h(px(100.)).overflow_y_scrollbar().child(
759                        crate::v_flex()
760                            .child(plain_row(50.))
761                            .child(plain_row(50.))
762                            .child(row("left-scrollable-last-row", 50.)),
763                    ),
764                )
765                .child(
766                    div().w(px(100.)).h(px(100.)).overflow_y_scrollbar().child(
767                        crate::v_flex()
768                            .child(plain_row(50.))
769                            .child(plain_row(50.))
770                            .child(row("right-scrollable-last-row", 50.)),
771                    ),
772                )
773        }
774    }
775
776    struct NoOverflowTest;
777
778    impl Render for NoOverflowTest {
779        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
780            crate::v_flex()
781                .w(px(100.))
782                .h(px(100.))
783                .gap(px(10.))
784                .overflow_y_scrollbar()
785                .child(row("no-overflow-first-row", 20.))
786                .child(row("no-overflow-second-row", 20.))
787        }
788    }
789
790    #[gpui::test]
791    fn vertical_scrollbar_scrolls_past_a_size_full_child(cx: &mut TestAppContext) {
792        cx.update(crate::init);
793        let (_, cx) = cx.add_window_view(|_, _| SizeFullChildTest);
794        let cx: &mut VisualTestContext = cx;
795        draw(cx);
796
797        let initial_y = cx.debug_bounds("last-row").unwrap().origin.y;
798        scroll(cx, 10., 10., 0., -50.);
799
800        assert!(cx.debug_bounds("last-row").unwrap().origin.y < initial_y);
801    }
802
803    #[gpui::test]
804    fn vertical_scrollbar_preserves_source_gap(cx: &mut TestAppContext) {
805        cx.update(crate::init);
806        let (_, cx) = cx.add_window_view(|_, _| GapLayoutTest);
807        let cx: &mut VisualTestContext = cx;
808        draw(cx);
809
810        let first = cx.debug_bounds("first-row").unwrap();
811        let second = cx.debug_bounds("second-row").unwrap();
812        assert_eq!(second.top() - first.bottom(), px(10.));
813    }
814
815    #[gpui::test]
816    fn overflow_y_scrollbar_preserves_gap_for_exact_issue_chain(cx: &mut TestAppContext) {
817        cx.update(crate::init);
818        let (_, cx) = cx.add_window_view(|_, _| IssueGapRegressionTest);
819        let cx: &mut VisualTestContext = cx;
820        draw(cx);
821
822        let first = cx.debug_bounds("issue-first-card").unwrap();
823        let second = cx.debug_bounds("issue-second-card").unwrap();
824        let last_initial_y = cx.debug_bounds("issue-last-card").unwrap().origin.y;
825
826        assert_eq!(second.top() - first.bottom(), px(30.));
827        assert_eq!(first.left(), px(12.));
828
829        scroll(cx, 10., 10., 0., -50.);
830
831        let first_after_scroll = cx.debug_bounds("issue-first-card").unwrap();
832        let second_after_scroll = cx.debug_bounds("issue-second-card").unwrap();
833        let last_after_scroll_y = cx.debug_bounds("issue-last-card").unwrap().origin.y;
834
835        assert_eq!(
836            second_after_scroll.top() - first_after_scroll.bottom(),
837            px(30.)
838        );
839        assert_eq!(first_after_scroll.left(), px(12.));
840        assert!(last_after_scroll_y < last_initial_y);
841    }
842
843    #[gpui::test]
844    fn horizontal_scrollbar_preserves_source_gap_and_scrolls(cx: &mut TestAppContext) {
845        cx.update(crate::init);
846        let (_, cx) = cx.add_window_view(|_, _| HorizontalGapLayoutTest);
847        let cx: &mut VisualTestContext = cx;
848        draw(cx);
849
850        let first = cx.debug_bounds("horizontal-first-item").unwrap();
851        let second = cx.debug_bounds("horizontal-second-item").unwrap();
852        let last_initial_x = cx.debug_bounds("horizontal-last-item").unwrap().origin.x;
853
854        assert_eq!(second.left() - first.right(), px(10.));
855
856        scroll(cx, 10., 10., -50., 0.);
857
858        let first_after_scroll = cx.debug_bounds("horizontal-first-item").unwrap();
859        let second_after_scroll = cx.debug_bounds("horizontal-second-item").unwrap();
860        let last_after_scroll_x = cx.debug_bounds("horizontal-last-item").unwrap().origin.x;
861
862        assert_eq!(
863            second_after_scroll.left() - first_after_scroll.right(),
864            px(10.)
865        );
866        assert!(last_after_scroll_x < last_initial_x);
867    }
868
869    #[gpui::test]
870    fn overflow_scrollbar_preserves_vertical_source_gap(cx: &mut TestAppContext) {
871        cx.update(crate::init);
872        let (_, cx) = cx.add_window_view(|_, _| OverflowScrollbarVerticalTest);
873        let cx: &mut VisualTestContext = cx;
874        draw(cx);
875
876        let first = cx.debug_bounds("both-axis-vertical-first-row").unwrap();
877        let second = cx.debug_bounds("both-axis-vertical-second-row").unwrap();
878
879        assert_eq!(second.top() - first.bottom(), px(10.));
880    }
881
882    #[gpui::test]
883    fn overflow_scrollbar_preserves_gap_and_scrolls_horizontally(cx: &mut TestAppContext) {
884        cx.update(crate::init);
885        let (_, cx) = cx.add_window_view(|_, _| OverflowScrollbarHorizontalTest);
886        let cx: &mut VisualTestContext = cx;
887        draw(cx);
888
889        let first = cx.debug_bounds("both-axis-horizontal-first-item").unwrap();
890        let second = cx.debug_bounds("both-axis-horizontal-second-item").unwrap();
891        let last_initial_x = cx
892            .debug_bounds("both-axis-horizontal-last-item")
893            .unwrap()
894            .origin
895            .x;
896
897        assert_eq!(second.left() - first.right(), px(10.));
898
899        scroll(cx, 10., 10., -50., 0.);
900
901        let first_after_scroll = cx.debug_bounds("both-axis-horizontal-first-item").unwrap();
902        let second_after_scroll = cx.debug_bounds("both-axis-horizontal-second-item").unwrap();
903        let last_after_scroll_x = cx
904            .debug_bounds("both-axis-horizontal-last-item")
905            .unwrap()
906            .origin
907            .x;
908
909        assert_eq!(
910            second_after_scroll.left() - first_after_scroll.right(),
911            px(10.)
912        );
913        assert!(last_after_scroll_x < last_initial_x);
914    }
915
916    #[gpui::test]
917    fn multiple_scrollables_keep_independent_scroll_state(cx: &mut TestAppContext) {
918        cx.update(crate::init);
919        let (_, cx) = cx.add_window_view(|_, _| IndependentScrollablesTest);
920        let cx: &mut VisualTestContext = cx;
921        draw(cx);
922
923        let left_initial = cx.debug_bounds("left-scrollable-last-row").unwrap();
924        let right_initial = cx.debug_bounds("right-scrollable-last-row").unwrap();
925
926        scroll(cx, 10., 10., 0., -50.);
927
928        let left_after_scroll = cx.debug_bounds("left-scrollable-last-row").unwrap();
929        let right_after_scroll = cx.debug_bounds("right-scrollable-last-row").unwrap();
930
931        assert!(left_after_scroll.top() < left_initial.top());
932        assert_eq!(right_after_scroll.top(), right_initial.top());
933    }
934
935    #[gpui::test]
936    fn vertical_scrollbar_does_not_scroll_when_content_does_not_overflow(cx: &mut TestAppContext) {
937        cx.update(crate::init);
938        let (_, cx) = cx.add_window_view(|_, _| NoOverflowTest);
939        let cx: &mut VisualTestContext = cx;
940        draw(cx);
941
942        let first = cx.debug_bounds("no-overflow-first-row").unwrap();
943        let second = cx.debug_bounds("no-overflow-second-row").unwrap();
944
945        assert_eq!(second.top() - first.bottom(), px(10.));
946
947        scroll(cx, 10., 10., 0., -50.);
948
949        let first_after_scroll = cx.debug_bounds("no-overflow-first-row").unwrap();
950        let second_after_scroll = cx.debug_bounds("no-overflow-second-row").unwrap();
951
952        assert_eq!(first_after_scroll.top(), first.top());
953        assert_eq!(second_after_scroll.top(), second.top());
954        assert_eq!(
955            second_after_scroll.top() - first_after_scroll.bottom(),
956            px(10.)
957        );
958    }
959
960    #[gpui::test]
961    fn horizontal_scrollbar_does_not_scroll_when_content_does_not_overflow(
962        cx: &mut TestAppContext,
963    ) {
964        struct HorizontalNoOverflowTest;
965
966        impl Render for HorizontalNoOverflowTest {
967            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
968                crate::h_flex()
969                    .w(px(100.))
970                    .h(px(40.))
971                    .gap(px(10.))
972                    .overflow_x_scrollbar()
973                    .child(item("no-overflow-first-item", 20.))
974                    .child(item("no-overflow-second-item", 20.))
975                    .child(plain_item(20.))
976            }
977        }
978
979        cx.update(crate::init);
980        let (_, cx) = cx.add_window_view(|_, _| HorizontalNoOverflowTest);
981        let cx: &mut VisualTestContext = cx;
982        draw(cx);
983
984        let first = cx.debug_bounds("no-overflow-first-item").unwrap();
985        let second = cx.debug_bounds("no-overflow-second-item").unwrap();
986
987        assert_eq!(second.left() - first.right(), px(10.));
988
989        scroll(cx, 10., 10., -50., 0.);
990
991        let first_after_scroll = cx.debug_bounds("no-overflow-first-item").unwrap();
992        let second_after_scroll = cx.debug_bounds("no-overflow-second-item").unwrap();
993
994        assert_eq!(first_after_scroll.left(), first.left());
995        assert_eq!(second_after_scroll.left(), second.left());
996        assert_eq!(
997            second_after_scroll.left() - first_after_scroll.right(),
998            px(10.)
999        );
1000    }
1001    struct IndependentDynamicScrollablesTest;
1002
1003    impl Render for IndependentDynamicScrollablesTest {
1004        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1005            crate::v_flex().w(px(100.)).children((0..2).map(|ix| {
1006                crate::h_flex()
1007                    .w(px(100.))
1008                    .h(px(40.))
1009                    .overflow_x_scrollbar()
1010                    .id(format!("dynamic-scroll-{ix}"))
1011                    .child(
1012                        div()
1013                            .w(px(200.))
1014                            .h(px(20.))
1015                            .flex_shrink_0()
1016                            .when(ix == 0, |this| {
1017                                this.debug_selector(|| "dynamic-scroll-first".to_string())
1018                            })
1019                            .when(ix == 1, |this| {
1020                                this.debug_selector(|| "dynamic-scroll-second".to_string())
1021                            }),
1022                    )
1023            }))
1024        }
1025    }
1026
1027    #[gpui::test]
1028    fn dynamic_scrollables_with_unique_scroll_ids_keep_independent_state(cx: &mut TestAppContext) {
1029        cx.update(crate::init);
1030
1031        let (_, cx) = cx.add_window_view(|_, _| IndependentDynamicScrollablesTest);
1032        let cx: &mut VisualTestContext = cx;
1033
1034        draw(cx);
1035
1036        let first_initial = cx.debug_bounds("dynamic-scroll-first").unwrap();
1037
1038        let second_initial = cx.debug_bounds("dynamic-scroll-second").unwrap();
1039
1040        // Scroll horizontally inside the first row.
1041        scroll(cx, 10., 10., -50., 0.);
1042
1043        let first_after_scroll = cx.debug_bounds("dynamic-scroll-first").unwrap();
1044
1045        let second_after_scroll = cx.debug_bounds("dynamic-scroll-second").unwrap();
1046
1047        // First scrollable should move horizontally.
1048        assert!(first_after_scroll.left() < first_initial.left());
1049
1050        // Second scrollable must keep its own independent scroll state.
1051        assert_eq!(second_after_scroll.left(), second_initial.left());
1052    }
1053}