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 mut 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        // The wrapper holds the caller's maximum size. Left on the content
163        // too, it would cap the content at the area's size on the scrolled
164        // axis, so nothing would overflow and the area would not scroll.
165        let max_size = &mut content.style().max_size;
166        match self.axis {
167            ScrollbarAxis::Vertical => max_size.height = None,
168            ScrollbarAxis::Horizontal => max_size.width = None,
169            ScrollbarAxis::Both => *max_size = Default::default(),
170        }
171
172        // Keep the scroll area in the normal flow: its content size must
173        // propagate to auto-sized ancestors (e.g. a Dialog that grows with
174        // its content). An absolutely positioned scroll area would collapse
175        // such ancestors to zero height.
176        let scroll_area = div()
177            .id(area_id)
178            .size_full()
179            .flex()
180            .track_scroll(&scroll_handle)
181            .map(|this| match self.axis {
182                ScrollbarAxis::Vertical => this.flex_col().overflow_y_scroll(),
183                ScrollbarAxis::Horizontal => this.flex_row().overflow_x_scroll(),
184                ScrollbarAxis::Both => this.overflow_scroll(),
185            })
186            // On a single-axis area gpui otherwise remaps the other axis' delta
187            // onto ours, so a purely horizontal swipe scrolls this vertically.
188            .lock_scroll_axis()
189            .child(content);
190
191        // The wrapper lays the area out as a flex item along the scrolled
192        // axis, so the area shrinks to the wrapper's size. Sized by its 100%
193        // alone, the area is not held to a maximum size on the wrapper with
194        // GPUI Fast: in a column whose height follows its content, inside a
195        // row, the area came out as tall as that column and the wrapper
196        // clipped the rows past its maximum height instead of scrolling to
197        // them.
198        div()
199            .id(root_id)
200            .size_full()
201            .refine_style(&root_style)
202            .relative()
203            .flex()
204            .map(|this| match self.axis {
205                ScrollbarAxis::Horizontal => this.flex_row(),
206                ScrollbarAxis::Vertical | ScrollbarAxis::Both => this.flex_col(),
207            })
208            .child(
209                gpui_base::ScrollBounce::new(
210                    (self.id.clone(), "bounce"),
211                    &scroll_handle,
212                    scroll_area,
213                )
214                .when_some(self.scroll_bounce, |bounce, on| bounce.enabled(on)),
215            )
216            // A plain mouse wheel has only a vertical axis, which the locked
217            // horizontal area above would ignore.
218            .when(self.axis == ScrollbarAxis::Horizontal, |this| {
219                this.child(ScrollableMask::vertical_wheel(&scroll_handle))
220            })
221            .child(render_scrollbar(
222                scrollbar_id,
223                &scroll_handle,
224                self.axis,
225                window,
226                cx,
227            ))
228    }
229}
230
231impl ScrollableElement for Div {}
232impl<E> ScrollableElement for Stateful<E>
233where
234    E: ParentElement + Styled + Element,
235    Self: InteractiveElement,
236{
237}
238
239#[derive(IntoElement)]
240struct ScrollbarLayer<H: ScrollbarHandle + Clone> {
241    id: ElementId,
242    axis: ScrollbarAxis,
243    scroll_handle: Rc<H>,
244}
245
246impl<H> RenderOnce for ScrollbarLayer<H>
247where
248    H: ScrollbarHandle + Clone + 'static,
249{
250    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
251        render_scrollbar(self.id, self.scroll_handle.as_ref(), self.axis, window, cx)
252    }
253}
254
255#[inline]
256#[track_caller]
257fn caller_id() -> ElementId {
258    ElementId::CodeLocation(*Location::caller())
259}
260
261#[inline]
262fn scroll_handle_for(id: &ElementId, window: &mut Window, cx: &mut App) -> ScrollHandle {
263    window
264        .use_keyed_state(id.clone(), cx, |_, _| ScrollHandle::default())
265        .read(cx)
266        .clone()
267}
268
269/// Copies the outer layout styles from the element, so the wrapper can
270/// participate in the parent's layout the same way the source element would.
271///
272/// A flex item only drops its content-based automatic minimum size when its own
273/// overflow is not [`Overflow::Visible`]; otherwise the item refuses to shrink
274/// around its content. The scrolled overflow lives on the inner scroll area, so
275/// the wrapper has to declare the scrolled axis clipped itself — without it a
276/// scroll region used as a flex item pushes its siblings out of the container
277/// instead of scrolling, and every call site has to remember `min_h_0()` /
278/// `min_w_0()`.
279///
280/// [`Overflow::Hidden`] rather than a zero minimum: it is the axis' real
281/// behavior, it leaves an explicit `min_size` from the caller in charge, and it
282/// keeps the region's content size contributing to an auto-sized ancestor such
283/// as a Dialog that grows with its body. The mask it installs matches the inner
284/// scroll area's own mask, so nothing new is clipped.
285#[inline]
286fn root_style_from<E>(element: &mut E, axis: ScrollbarAxis) -> StyleRefinement
287where
288    E: Styled,
289{
290    let style = element.style();
291    StyleRefinement {
292        size: style.size.clone(),
293        min_size: style.min_size.clone(),
294        max_size: style.max_size.clone(),
295        flex_grow: style.flex_grow,
296        flex_shrink: style.flex_shrink,
297        flex_basis: style.flex_basis,
298        align_self: style.align_self,
299        overflow: PointRefinement {
300            x: axis.has_horizontal().then_some(Overflow::Hidden),
301            y: axis.has_vertical().then_some(Overflow::Hidden),
302        },
303        ..Default::default()
304    }
305}
306
307#[inline]
308fn render_scrollbar<H: ScrollbarHandle + Clone>(
309    id: impl Into<ElementId>,
310    scroll_handle: &H,
311    axis: ScrollbarAxis,
312    window: &mut Window,
313    cx: &mut App,
314) -> Div {
315    // Do not render scrollbar when inspector is picking elements,
316    // to allow us to pick the background elements.
317    let is_inspector_picking = window.is_inspector_picking(cx);
318    if is_inspector_picking {
319        return div();
320    }
321
322    div()
323        .absolute()
324        .inset_0()
325        .debug_selector(|| "scrollbar-overlay".to_string())
326        .child(
327            Scrollbar::new(scroll_handle)
328                .id(id)
329                .axis(axis)
330                .viewport_from_layout(),
331        )
332}
333
334#[cfg(feature = "test-support")]
335impl<E> ScrollableElement for gpui_base::test_support::Observed<E> where
336    E: ScrollableElement + Element<PrepaintState = Option<gpui::Hitbox>>
337{
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use gpui::{
344        Context, Render, ScrollDelta, ScrollEvent, TestAppContext, VisualTestContext, point, px,
345    };
346
347    fn draw(cx: &mut VisualTestContext) {
348        cx.run_until_parked();
349        cx.update(|window, cx| {
350            _ = window.draw(cx);
351        });
352    }
353
354    fn scroll(cx: &mut VisualTestContext, x: f32, y: f32, dx: f32, dy: f32) {
355        cx.simulate_event(ScrollEvent {
356            position: point(px(x), px(y)),
357            delta: ScrollDelta::Pixels(point(px(dx), px(dy))),
358            ..Default::default()
359        });
360        draw(cx);
361    }
362
363    fn row(selector: &'static str, height: f32) -> Div {
364        div()
365            .h(px(height))
366            .flex_shrink_0()
367            .debug_selector(move || selector.to_string())
368    }
369
370    fn plain_row(height: f32) -> Div {
371        div().h(px(height)).flex_shrink_0()
372    }
373
374    fn item(selector: &'static str, width: f32) -> Div {
375        div()
376            .w(px(width))
377            .h(px(20.))
378            .flex_shrink_0()
379            .debug_selector(move || selector.to_string())
380    }
381
382    fn plain_item(width: f32) -> Div {
383        div().w(px(width)).h(px(20.)).flex_shrink_0()
384    }
385
386    struct SizeFullChildTest;
387
388    impl Render for SizeFullChildTest {
389        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
390            div()
391                .w(px(100.))
392                .h(px(100.))
393                .overflow_y_scrollbar()
394                .child(
395                    div()
396                        .size_full()
397                        .child(crate::v_flex().children((0..4).map(|ix| {
398                            div().h(px(50.)).flex_shrink_0().when(ix == 3, |this| {
399                                this.debug_selector(|| "last-row".to_string())
400                            })
401                        }))),
402                )
403        }
404    }
405
406    struct AutoHeightParentTest;
407
408    impl Render for AutoHeightParentTest {
409        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
410            // Mimics Dialog: the panel height is auto (content-driven), the
411            // body is flex_1 + overflow_hidden, and the scrollable content
412            // should give the panel its intrinsic height.
413            // GPUI window roots with auto dimensions stretch to the viewport,
414            // so keep the auto-height panel below an explicit viewport root.
415            div().size_full().child(
416                crate::v_flex()
417                    .w(px(200.))
418                    .child(
419                        crate::v_flex().flex_1().overflow_hidden().child(
420                            div().flex_1().overflow_hidden().child(
421                                crate::v_flex()
422                                    .size_full()
423                                    .overflow_y_scrollbar()
424                                    .child(plain_row(50.))
425                                    .child(plain_row(50.)),
426                            ),
427                        ),
428                    )
429                    .child(row("auto-height-footer", 10.)),
430            )
431        }
432    }
433
434    struct MaxHeightParentTest;
435
436    impl Render for MaxHeightParentTest {
437        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
438            // Mimics a Dialog with `max_h`: the panel grows with content up
439            // to the max height, then the body starts scrolling.
440            crate::v_flex()
441                .w(px(200.))
442                .max_h(px(100.))
443                .child(
444                    crate::v_flex().flex_1().overflow_hidden().child(
445                        div().flex_1().overflow_hidden().child(
446                            crate::v_flex()
447                                .size_full()
448                                .overflow_y_scrollbar()
449                                .child(plain_row(50.))
450                                .child(plain_row(50.))
451                                .child(row("max-height-last-row", 50.)),
452                        ),
453                    ),
454                )
455                .child(row("max-height-footer", 10.))
456        }
457    }
458
459    #[gpui::test]
460    fn auto_height_parent_gets_content_height(cx: &mut TestAppContext) {
461        cx.update(crate::init);
462        let (_, cx) = cx.add_window_view(|_, _| AutoHeightParentTest);
463        let cx: &mut VisualTestContext = cx;
464        draw(cx);
465
466        // The two 50px rows should push the footer down to y = 100.
467        let footer = cx.debug_bounds("auto-height-footer").unwrap();
468        assert_eq!(footer.top(), px(100.));
469    }
470
471    #[gpui::test]
472    fn max_height_parent_clamps_and_scrolls(cx: &mut TestAppContext) {
473        cx.update(crate::init);
474        let (_, cx) = cx.add_window_view(|_, _| MaxHeightParentTest);
475        let cx: &mut VisualTestContext = cx;
476        draw(cx);
477
478        // Content (150) + footer (10) exceeds max_h(100): the footer is
479        // pinned at the bottom and the body gets the remaining 90px viewport.
480        let footer = cx.debug_bounds("max-height-footer").unwrap();
481        assert_eq!(footer.top(), px(90.));
482
483        let last_initial_y = cx.debug_bounds("max-height-last-row").unwrap().origin.y;
484        scroll(cx, 10., 10., 0., -50.);
485        assert!(cx.debug_bounds("max-height-last-row").unwrap().origin.y < last_initial_y);
486    }
487
488    struct MaxHeightScrollableTest;
489
490    impl Render for MaxHeightScrollableTest {
491        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
492            // Six rows of 50px in an area that may grow to 100px, between two
493            // blocks in a column as tall as its content, in a row. In such a
494            // column the area's full height once came out as the column's,
495            // 140px, rather than the 100px of the wrapper around it.
496            crate::h_flex().w(px(100.)).child(
497                crate::v_flex()
498                    .w_full()
499                    .child(plain_row(20.))
500                    .child(
501                        crate::v_flex()
502                            .max_h(px(100.))
503                            .overflow_y_scrollbar()
504                            .child(row("max-size-first-row", 50.))
505                            .children((0..4).map(|_| plain_row(50.)))
506                            .child(row("max-size-last-row", 50.)),
507                    )
508                    .child(row("max-size-below", 20.)),
509            )
510        }
511    }
512
513    #[gpui::test]
514    fn max_height_scrollable_scrolls_to_its_last_row(cx: &mut TestAppContext) {
515        cx.update(crate::init);
516        let (_, cx) = cx.add_window_view(|_, _| MaxHeightScrollableTest);
517        let cx: &mut VisualTestContext = cx;
518        draw(cx);
519
520        let first_row = cx.debug_bounds("max-size-first-row").unwrap();
521        let top = first_row.top();
522        // The area stops at its maximum height.
523        assert_eq!(
524            cx.debug_bounds("max-size-below").unwrap().top() - top,
525            px(100.)
526        );
527
528        let scroll_by = |cx: &mut VisualTestContext, dy: f32| {
529            cx.simulate_event(ScrollEvent {
530                position: first_row.center(),
531                delta: ScrollDelta::Pixels(point(px(0.), px(dy))),
532                ..Default::default()
533            });
534            draw(cx);
535        };
536        // The rows past it scroll into view, down to the last one, which
537        // ends at the area's bottom edge.
538        scroll_by(cx, -40.);
539        assert_eq!(
540            cx.debug_bounds("max-size-first-row").unwrap().top() - top,
541            px(-40.)
542        );
543        scroll_by(cx, -1000.);
544        assert_eq!(
545            cx.debug_bounds("max-size-last-row").unwrap().bottom() - top,
546            px(100.)
547        );
548    }
549
550    struct MaxWidthScrollableTest;
551
552    impl Render for MaxWidthScrollableTest {
553        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
554            // Six items of 50px in an area that may grow to 100px wide.
555            crate::h_flex().h(px(20.)).child(
556                crate::h_flex()
557                    .max_w(px(100.))
558                    .overflow_x_scrollbar()
559                    .child(item("max-size-first-item", 50.))
560                    .children((0..5).map(|_| plain_item(50.))),
561            )
562        }
563    }
564
565    #[gpui::test]
566    fn max_width_scrollable_scrolls_what_overflows_it(cx: &mut TestAppContext) {
567        cx.update(crate::init);
568        let (_, cx) = cx.add_window_view(|_, _| MaxWidthScrollableTest);
569        let cx: &mut VisualTestContext = cx;
570        draw(cx);
571
572        let first_item = cx.debug_bounds("max-size-first-item").unwrap();
573        cx.simulate_event(ScrollEvent {
574            position: first_item.center(),
575            delta: ScrollDelta::Pixels(point(px(-40.), px(0.))),
576            ..Default::default()
577        });
578        draw(cx);
579        assert_eq!(
580            cx.debug_bounds("max-size-first-item").unwrap().left() - first_item.left(),
581            px(-40.)
582        );
583    }
584
585    struct FlexItemScrollableTest;
586
587    impl Render for FlexItemScrollableTest {
588        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
589            // A fixed-height column of header, flexible scroll area, footer.
590            // The content is far taller than the room left for the area, so
591            // the area must shrink into the remaining 60px and scroll.
592            crate::v_flex()
593                .w(px(100.))
594                .h(px(100.))
595                .child(row("flex-item-header", 20.))
596                .child(
597                    crate::v_flex()
598                        .flex_1()
599                        .overflow_y_scrollbar()
600                        .children((0..6).map(|ix| {
601                            div().h(px(50.)).flex_shrink_0().when(ix == 0, |this| {
602                                this.debug_selector(|| "flex-item-first-row".to_string())
603                            })
604                        })),
605                )
606                .child(row("flex-item-footer", 20.))
607        }
608    }
609
610    #[gpui::test]
611    fn scrollable_flex_item_shrinks_below_its_content(cx: &mut TestAppContext) {
612        cx.update(crate::init);
613        let (_, cx) = cx.add_window_view(|_, _| FlexItemScrollableTest);
614        let cx: &mut VisualTestContext = cx;
615        draw(cx);
616
617        // Header and footer stay inside the 100px column, so the area took
618        // the 60px left over instead of its content height.
619        assert_eq!(cx.debug_bounds("flex-item-header").unwrap().top(), px(0.));
620        assert_eq!(cx.debug_bounds("flex-item-footer").unwrap().top(), px(80.));
621
622        // And the shrunken area really scrolls.
623        let initial_y = cx.debug_bounds("flex-item-first-row").unwrap().origin.y;
624        scroll(cx, 10., 50., 0., -50.);
625        assert!(cx.debug_bounds("flex-item-first-row").unwrap().origin.y < initial_y);
626    }
627
628    struct HorizontalFlexItemScrollableTest;
629
630    impl Render for HorizontalFlexItemScrollableTest {
631        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
632            crate::h_flex()
633                .w(px(100.))
634                .h(px(40.))
635                .child(item("horizontal-flex-item-leading", 20.))
636                .child(
637                    crate::h_flex()
638                        .flex_1()
639                        .overflow_x_scrollbar()
640                        .children((0..6).map(|ix| {
641                            div()
642                                .w(px(50.))
643                                .h(px(20.))
644                                .flex_shrink_0()
645                                .when(ix == 0, |this| {
646                                    this.debug_selector(|| {
647                                        "horizontal-flex-item-first-item".to_string()
648                                    })
649                                })
650                        })),
651                )
652                .child(item("horizontal-flex-item-trailing", 20.))
653        }
654    }
655
656    #[gpui::test]
657    fn horizontal_scrollable_flex_item_shrinks_below_its_content(cx: &mut TestAppContext) {
658        cx.update(crate::init);
659        let (_, cx) = cx.add_window_view(|_, _| HorizontalFlexItemScrollableTest);
660        let cx: &mut VisualTestContext = cx;
661        draw(cx);
662
663        assert_eq!(
664            cx.debug_bounds("horizontal-flex-item-leading")
665                .unwrap()
666                .left(),
667            px(0.)
668        );
669        assert_eq!(
670            cx.debug_bounds("horizontal-flex-item-trailing")
671                .unwrap()
672                .left(),
673            px(80.)
674        );
675
676        let initial_x = cx
677            .debug_bounds("horizontal-flex-item-first-item")
678            .unwrap()
679            .origin
680            .x;
681        scroll(cx, 50., 10., -50., 0.);
682        assert!(
683            cx.debug_bounds("horizontal-flex-item-first-item")
684                .unwrap()
685                .origin
686                .x
687                < initial_x
688        );
689    }
690
691    #[gpui::test]
692    fn horizontal_scrollable_scrolls_with_a_vertical_mouse_wheel(cx: &mut TestAppContext) {
693        cx.update(crate::init);
694        let (_, cx) = cx.add_window_view(|_, _| HorizontalFlexItemScrollableTest);
695        let cx: &mut VisualTestContext = cx;
696        draw(cx);
697
698        let first_item_x = |cx: &mut VisualTestContext| {
699            cx.debug_bounds("horizontal-flex-item-first-item")
700                .unwrap()
701                .origin
702                .x
703        };
704        let initial_x = first_item_x(cx);
705        cx.simulate_event(ScrollEvent {
706            position: point(px(50.), px(10.)),
707            delta: ScrollDelta::Lines(point(0., -1.)),
708            ..Default::default()
709        });
710        draw(cx);
711        assert!(first_item_x(cx) < initial_x);
712    }
713
714    struct ExplicitMinHeightScrollableTest;
715
716    impl Render for ExplicitMinHeightScrollableTest {
717        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
718            // An explicit `min_h` is the caller's decision and must survive.
719            crate::v_flex()
720                .w(px(100.))
721                .h(px(100.))
722                .child(row("explicit-min-header", 20.))
723                .child(
724                    crate::v_flex()
725                        .flex_1()
726                        .min_h(px(70.))
727                        .overflow_y_scrollbar()
728                        .children((0..6).map(|_| plain_row(50.))),
729                )
730                .child(row("explicit-min-footer", 20.))
731        }
732    }
733
734    #[gpui::test]
735    fn explicit_min_size_survives_the_scrollable_wrapper(cx: &mut TestAppContext) {
736        cx.update(crate::init);
737        let (_, cx) = cx.add_window_view(|_, _| ExplicitMinHeightScrollableTest);
738        let cx: &mut VisualTestContext = cx;
739        draw(cx);
740
741        // The area cannot shrink past the requested 70px, so the footer is
742        // pushed to 20 + 70 instead of being clamped into the column.
743        assert_eq!(
744            cx.debug_bounds("explicit-min-header").unwrap().top(),
745            px(0.)
746        );
747        assert_eq!(
748            cx.debug_bounds("explicit-min-footer").unwrap().top(),
749            px(90.)
750        );
751    }
752
753    struct GapLayoutTest;
754
755    struct PaddedScrollbarOverlayTest;
756
757    impl Render for PaddedScrollbarOverlayTest {
758        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
759            crate::v_flex()
760                .w(px(100.))
761                .h(px(100.))
762                .p(px(20.))
763                .overflow_y_scrollbar()
764                .children((0..4).map(|_| plain_row(50.)))
765        }
766    }
767
768    #[gpui::test]
769    fn scrollbar_overlay_ignores_content_padding(cx: &mut TestAppContext) {
770        cx.update(crate::init);
771        let (_, cx) = cx.add_window_view(|_, _| PaddedScrollbarOverlayTest);
772        let cx: &mut VisualTestContext = cx;
773        draw(cx);
774
775        let overlay = cx.debug_bounds("scrollbar-overlay").unwrap();
776        assert_eq!(overlay.origin, point(px(0.), px(0.)));
777        assert_eq!(overlay.size, gpui::size(px(100.), px(100.)));
778    }
779
780    impl Render for GapLayoutTest {
781        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
782            crate::v_flex()
783                .w(px(100.))
784                .h(px(100.))
785                .gap(px(10.))
786                .overflow_y_scrollbar()
787                .child(row("first-row", 20.))
788                .child(row("second-row", 20.))
789        }
790    }
791
792    struct IssueGapRegressionTest;
793
794    impl Render for IssueGapRegressionTest {
795        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
796            div().w(px(100.)).h(px(100.)).child(
797                crate::v_flex()
798                    .flex_1()
799                    .gap(px(30.))
800                    .overflow_y_scrollbar()
801                    .px(px(12.))
802                    .pb(px(16.))
803                    .children((0..5).map(|ix| {
804                        div()
805                            .h(px(20.))
806                            .flex_shrink_0()
807                            .when(ix == 0, |this| {
808                                this.debug_selector(|| "issue-first-card".to_string())
809                            })
810                            .when(ix == 1, |this| {
811                                this.debug_selector(|| "issue-second-card".to_string())
812                            })
813                            .when(ix == 4, |this| {
814                                this.debug_selector(|| "issue-last-card".to_string())
815                            })
816                    })),
817            )
818        }
819    }
820
821    struct HorizontalGapLayoutTest;
822
823    impl Render for HorizontalGapLayoutTest {
824        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
825            crate::h_flex()
826                .w(px(100.))
827                .h(px(40.))
828                .gap(px(10.))
829                .overflow_x_scrollbar()
830                .child(item("horizontal-first-item", 50.))
831                .child(item("horizontal-second-item", 50.))
832                .child(item("horizontal-last-item", 50.))
833        }
834    }
835
836    struct OverflowScrollbarVerticalTest;
837
838    impl Render for OverflowScrollbarVerticalTest {
839        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
840            crate::v_flex()
841                .w(px(100.))
842                .h(px(100.))
843                .gap(px(10.))
844                .overflow_scrollbar()
845                .child(row("both-axis-vertical-first-row", 50.))
846                .child(row("both-axis-vertical-second-row", 50.))
847                .child(row("both-axis-vertical-last-row", 50.))
848        }
849    }
850
851    struct OverflowScrollbarHorizontalTest;
852
853    impl Render for OverflowScrollbarHorizontalTest {
854        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
855            crate::h_flex()
856                .w(px(100.))
857                .h(px(40.))
858                .gap(px(10.))
859                .overflow_scrollbar()
860                .child(item("both-axis-horizontal-first-item", 50.))
861                .child(item("both-axis-horizontal-second-item", 50.))
862                .child(item("both-axis-horizontal-last-item", 50.))
863        }
864    }
865
866    struct IndependentScrollablesTest;
867
868    impl Render for IndependentScrollablesTest {
869        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
870            crate::h_flex()
871                .w(px(220.))
872                .h(px(100.))
873                .gap(px(20.))
874                .child(
875                    div().w(px(100.)).h(px(100.)).overflow_y_scrollbar().child(
876                        crate::v_flex()
877                            .child(plain_row(50.))
878                            .child(plain_row(50.))
879                            .child(row("left-scrollable-last-row", 50.)),
880                    ),
881                )
882                .child(
883                    div().w(px(100.)).h(px(100.)).overflow_y_scrollbar().child(
884                        crate::v_flex()
885                            .child(plain_row(50.))
886                            .child(plain_row(50.))
887                            .child(row("right-scrollable-last-row", 50.)),
888                    ),
889                )
890        }
891    }
892
893    struct NoOverflowTest;
894
895    impl Render for NoOverflowTest {
896        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
897            crate::v_flex()
898                .w(px(100.))
899                .h(px(100.))
900                .gap(px(10.))
901                .overflow_y_scrollbar()
902                .child(row("no-overflow-first-row", 20.))
903                .child(row("no-overflow-second-row", 20.))
904        }
905    }
906
907    #[gpui::test]
908    fn vertical_scrollbar_scrolls_past_a_size_full_child(cx: &mut TestAppContext) {
909        cx.update(crate::init);
910        let (_, cx) = cx.add_window_view(|_, _| SizeFullChildTest);
911        let cx: &mut VisualTestContext = cx;
912        draw(cx);
913
914        let initial_y = cx.debug_bounds("last-row").unwrap().origin.y;
915        scroll(cx, 10., 10., 0., -50.);
916
917        assert!(cx.debug_bounds("last-row").unwrap().origin.y < initial_y);
918    }
919
920    #[gpui::test]
921    fn vertical_scrollbar_preserves_source_gap(cx: &mut TestAppContext) {
922        cx.update(crate::init);
923        let (_, cx) = cx.add_window_view(|_, _| GapLayoutTest);
924        let cx: &mut VisualTestContext = cx;
925        draw(cx);
926
927        let first = cx.debug_bounds("first-row").unwrap();
928        let second = cx.debug_bounds("second-row").unwrap();
929        assert_eq!(second.top() - first.bottom(), px(10.));
930    }
931
932    #[gpui::test]
933    fn overflow_y_scrollbar_preserves_gap_for_exact_issue_chain(cx: &mut TestAppContext) {
934        cx.update(crate::init);
935        let (_, cx) = cx.add_window_view(|_, _| IssueGapRegressionTest);
936        let cx: &mut VisualTestContext = cx;
937        draw(cx);
938
939        let first = cx.debug_bounds("issue-first-card").unwrap();
940        let second = cx.debug_bounds("issue-second-card").unwrap();
941        let last_initial_y = cx.debug_bounds("issue-last-card").unwrap().origin.y;
942
943        assert_eq!(second.top() - first.bottom(), px(30.));
944        assert_eq!(first.left(), px(12.));
945
946        scroll(cx, 10., 10., 0., -50.);
947
948        let first_after_scroll = cx.debug_bounds("issue-first-card").unwrap();
949        let second_after_scroll = cx.debug_bounds("issue-second-card").unwrap();
950        let last_after_scroll_y = cx.debug_bounds("issue-last-card").unwrap().origin.y;
951
952        assert_eq!(
953            second_after_scroll.top() - first_after_scroll.bottom(),
954            px(30.)
955        );
956        assert_eq!(first_after_scroll.left(), px(12.));
957        assert!(last_after_scroll_y < last_initial_y);
958    }
959
960    #[gpui::test]
961    fn horizontal_scrollbar_preserves_source_gap_and_scrolls(cx: &mut TestAppContext) {
962        cx.update(crate::init);
963        let (_, cx) = cx.add_window_view(|_, _| HorizontalGapLayoutTest);
964        let cx: &mut VisualTestContext = cx;
965        draw(cx);
966
967        let first = cx.debug_bounds("horizontal-first-item").unwrap();
968        let second = cx.debug_bounds("horizontal-second-item").unwrap();
969        let last_initial_x = cx.debug_bounds("horizontal-last-item").unwrap().origin.x;
970
971        assert_eq!(second.left() - first.right(), px(10.));
972
973        scroll(cx, 10., 10., -50., 0.);
974
975        let first_after_scroll = cx.debug_bounds("horizontal-first-item").unwrap();
976        let second_after_scroll = cx.debug_bounds("horizontal-second-item").unwrap();
977        let last_after_scroll_x = cx.debug_bounds("horizontal-last-item").unwrap().origin.x;
978
979        assert_eq!(
980            second_after_scroll.left() - first_after_scroll.right(),
981            px(10.)
982        );
983        assert!(last_after_scroll_x < last_initial_x);
984    }
985
986    #[gpui::test]
987    fn overflow_scrollbar_preserves_vertical_source_gap(cx: &mut TestAppContext) {
988        cx.update(crate::init);
989        let (_, cx) = cx.add_window_view(|_, _| OverflowScrollbarVerticalTest);
990        let cx: &mut VisualTestContext = cx;
991        draw(cx);
992
993        let first = cx.debug_bounds("both-axis-vertical-first-row").unwrap();
994        let second = cx.debug_bounds("both-axis-vertical-second-row").unwrap();
995
996        assert_eq!(second.top() - first.bottom(), px(10.));
997    }
998
999    #[gpui::test]
1000    fn overflow_scrollbar_preserves_gap_and_scrolls_horizontally(cx: &mut TestAppContext) {
1001        cx.update(crate::init);
1002        let (_, cx) = cx.add_window_view(|_, _| OverflowScrollbarHorizontalTest);
1003        let cx: &mut VisualTestContext = cx;
1004        draw(cx);
1005
1006        let first = cx.debug_bounds("both-axis-horizontal-first-item").unwrap();
1007        let second = cx.debug_bounds("both-axis-horizontal-second-item").unwrap();
1008        let last_initial_x = cx
1009            .debug_bounds("both-axis-horizontal-last-item")
1010            .unwrap()
1011            .origin
1012            .x;
1013
1014        assert_eq!(second.left() - first.right(), px(10.));
1015
1016        scroll(cx, 10., 10., -50., 0.);
1017
1018        let first_after_scroll = cx.debug_bounds("both-axis-horizontal-first-item").unwrap();
1019        let second_after_scroll = cx.debug_bounds("both-axis-horizontal-second-item").unwrap();
1020        let last_after_scroll_x = cx
1021            .debug_bounds("both-axis-horizontal-last-item")
1022            .unwrap()
1023            .origin
1024            .x;
1025
1026        assert_eq!(
1027            second_after_scroll.left() - first_after_scroll.right(),
1028            px(10.)
1029        );
1030        assert!(last_after_scroll_x < last_initial_x);
1031    }
1032
1033    #[gpui::test]
1034    fn multiple_scrollables_keep_independent_scroll_state(cx: &mut TestAppContext) {
1035        cx.update(crate::init);
1036        let (_, cx) = cx.add_window_view(|_, _| IndependentScrollablesTest);
1037        let cx: &mut VisualTestContext = cx;
1038        draw(cx);
1039
1040        let left_initial = cx.debug_bounds("left-scrollable-last-row").unwrap();
1041        let right_initial = cx.debug_bounds("right-scrollable-last-row").unwrap();
1042
1043        scroll(cx, 10., 10., 0., -50.);
1044
1045        let left_after_scroll = cx.debug_bounds("left-scrollable-last-row").unwrap();
1046        let right_after_scroll = cx.debug_bounds("right-scrollable-last-row").unwrap();
1047
1048        assert!(left_after_scroll.top() < left_initial.top());
1049        assert_eq!(right_after_scroll.top(), right_initial.top());
1050    }
1051
1052    #[gpui::test]
1053    fn vertical_scrollbar_does_not_scroll_when_content_does_not_overflow(cx: &mut TestAppContext) {
1054        cx.update(crate::init);
1055        let (_, cx) = cx.add_window_view(|_, _| NoOverflowTest);
1056        let cx: &mut VisualTestContext = cx;
1057        draw(cx);
1058
1059        let first = cx.debug_bounds("no-overflow-first-row").unwrap();
1060        let second = cx.debug_bounds("no-overflow-second-row").unwrap();
1061
1062        assert_eq!(second.top() - first.bottom(), px(10.));
1063
1064        scroll(cx, 10., 10., 0., -50.);
1065
1066        let first_after_scroll = cx.debug_bounds("no-overflow-first-row").unwrap();
1067        let second_after_scroll = cx.debug_bounds("no-overflow-second-row").unwrap();
1068
1069        assert_eq!(first_after_scroll.top(), first.top());
1070        assert_eq!(second_after_scroll.top(), second.top());
1071        assert_eq!(
1072            second_after_scroll.top() - first_after_scroll.bottom(),
1073            px(10.)
1074        );
1075    }
1076
1077    #[gpui::test]
1078    fn horizontal_scrollbar_does_not_scroll_when_content_does_not_overflow(
1079        cx: &mut TestAppContext,
1080    ) {
1081        struct HorizontalNoOverflowTest;
1082
1083        impl Render for HorizontalNoOverflowTest {
1084            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1085                crate::h_flex()
1086                    .w(px(100.))
1087                    .h(px(40.))
1088                    .gap(px(10.))
1089                    .overflow_x_scrollbar()
1090                    .child(item("no-overflow-first-item", 20.))
1091                    .child(item("no-overflow-second-item", 20.))
1092                    .child(plain_item(20.))
1093            }
1094        }
1095
1096        cx.update(crate::init);
1097        let (_, cx) = cx.add_window_view(|_, _| HorizontalNoOverflowTest);
1098        let cx: &mut VisualTestContext = cx;
1099        draw(cx);
1100
1101        let first = cx.debug_bounds("no-overflow-first-item").unwrap();
1102        let second = cx.debug_bounds("no-overflow-second-item").unwrap();
1103
1104        assert_eq!(second.left() - first.right(), px(10.));
1105
1106        scroll(cx, 10., 10., -50., 0.);
1107
1108        let first_after_scroll = cx.debug_bounds("no-overflow-first-item").unwrap();
1109        let second_after_scroll = cx.debug_bounds("no-overflow-second-item").unwrap();
1110
1111        assert_eq!(first_after_scroll.left(), first.left());
1112        assert_eq!(second_after_scroll.left(), second.left());
1113        assert_eq!(
1114            second_after_scroll.left() - first_after_scroll.right(),
1115            px(10.)
1116        );
1117    }
1118    struct IndependentDynamicScrollablesTest;
1119
1120    impl Render for IndependentDynamicScrollablesTest {
1121        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1122            crate::v_flex().w(px(100.)).children((0..2).map(|ix| {
1123                crate::h_flex()
1124                    .w(px(100.))
1125                    .h(px(40.))
1126                    .overflow_x_scrollbar()
1127                    .id(format!("dynamic-scroll-{ix}"))
1128                    .child(
1129                        div()
1130                            .w(px(200.))
1131                            .h(px(20.))
1132                            .flex_shrink_0()
1133                            .when(ix == 0, |this| {
1134                                this.debug_selector(|| "dynamic-scroll-first".to_string())
1135                            })
1136                            .when(ix == 1, |this| {
1137                                this.debug_selector(|| "dynamic-scroll-second".to_string())
1138                            }),
1139                    )
1140            }))
1141        }
1142    }
1143
1144    #[gpui::test]
1145    fn dynamic_scrollables_with_unique_scroll_ids_keep_independent_state(cx: &mut TestAppContext) {
1146        cx.update(crate::init);
1147
1148        let (_, cx) = cx.add_window_view(|_, _| IndependentDynamicScrollablesTest);
1149        let cx: &mut VisualTestContext = cx;
1150
1151        draw(cx);
1152
1153        let first_initial = cx.debug_bounds("dynamic-scroll-first").unwrap();
1154
1155        let second_initial = cx.debug_bounds("dynamic-scroll-second").unwrap();
1156
1157        // Scroll horizontally inside the first row.
1158        scroll(cx, 10., 10., -50., 0.);
1159
1160        let first_after_scroll = cx.debug_bounds("dynamic-scroll-first").unwrap();
1161
1162        let second_after_scroll = cx.debug_bounds("dynamic-scroll-second").unwrap();
1163
1164        // First scrollable should move horizontally.
1165        assert!(first_after_scroll.left() < first_initial.left());
1166
1167        // Second scrollable must keep its own independent scroll state.
1168        assert_eq!(second_after_scroll.left(), second_initial.left());
1169    }
1170}