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