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
12pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Element {
17 #[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 #[track_caller]
33 fn vertical_scrollbar<H: ScrollbarHandle + Clone>(self, scroll_handle: &H) -> Self {
34 self.scrollbar(scroll_handle, ScrollbarAxis::Vertical)
35 }
36
37 #[track_caller]
39 fn horizontal_scrollbar<H: ScrollbarHandle + Clone>(self, scroll_handle: &H) -> Self {
40 self.scrollbar(scroll_handle, ScrollbarAxis::Horizontal)
41 }
42
43 #[track_caller]
46 fn overflow_scrollbar(self) -> Scrollable<Self> {
47 Scrollable::new(self, ScrollbarAxis::Both)
48 }
49
50 #[track_caller]
53 fn overflow_x_scrollbar(self) -> Scrollable<Self> {
54 Scrollable::new(self, ScrollbarAxis::Horizontal)
55 }
56
57 #[track_caller]
60 fn overflow_y_scrollbar(self) -> Scrollable<Self> {
61 Scrollable::new(self, ScrollbarAxis::Vertical)
62 }
63}
64
65#[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 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
93 self.id = id.into();
94 self
95 }
96
97 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 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 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 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 .lock_scroll_axis()
189 .child(content);
190
191 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 .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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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(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 assert!(first_after_scroll.left() < first_initial.left());
1166
1167 assert_eq!(second_after_scroll.left(), second_initial.left());
1169 }
1170}