Skip to main content

gpui_component/scroll/
scrollable.rs

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