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 content = self
154 .element
155 .id(content_id)
156 .flex_none()
157 .map(|this| match self.axis {
158 ScrollbarAxis::Vertical => this.h_auto().min_h_full(),
159 ScrollbarAxis::Horizontal => this.w_auto().min_w_full(),
160 ScrollbarAxis::Both => this.size_auto().min_size_full(),
161 });
162
163 let scroll_area = div()
168 .id(area_id)
169 .size_full()
170 .flex()
171 .track_scroll(&scroll_handle)
172 .map(|this| match self.axis {
173 ScrollbarAxis::Vertical => this.flex_col().overflow_y_scroll(),
174 ScrollbarAxis::Horizontal => this.flex_row().overflow_x_scroll(),
175 ScrollbarAxis::Both => this.overflow_scroll(),
176 })
177 .lock_scroll_axis()
180 .child(content);
181
182 div()
183 .id(root_id)
184 .size_full()
185 .refine_style(&root_style)
186 .relative()
187 .child(
188 gpui_base::ScrollBounce::new(
189 (self.id.clone(), "bounce"),
190 &scroll_handle,
191 scroll_area,
192 )
193 .when_some(self.scroll_bounce, |bounce, on| bounce.enabled(on)),
194 )
195 .when(self.axis == ScrollbarAxis::Horizontal, |this| {
198 this.child(ScrollableMask::vertical_wheel(&scroll_handle))
199 })
200 .child(render_scrollbar(
201 scrollbar_id,
202 &scroll_handle,
203 self.axis,
204 window,
205 cx,
206 ))
207 }
208}
209
210impl ScrollableElement for Div {}
211impl<E> ScrollableElement for Stateful<E>
212where
213 E: ParentElement + Styled + Element,
214 Self: InteractiveElement,
215{
216}
217
218#[derive(IntoElement)]
219struct ScrollbarLayer<H: ScrollbarHandle + Clone> {
220 id: ElementId,
221 axis: ScrollbarAxis,
222 scroll_handle: Rc<H>,
223}
224
225impl<H> RenderOnce for ScrollbarLayer<H>
226where
227 H: ScrollbarHandle + Clone + 'static,
228{
229 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
230 render_scrollbar(self.id, self.scroll_handle.as_ref(), self.axis, window, cx)
231 }
232}
233
234#[inline]
235#[track_caller]
236fn caller_id() -> ElementId {
237 ElementId::CodeLocation(*Location::caller())
238}
239
240#[inline]
241fn scroll_handle_for(id: &ElementId, window: &mut Window, cx: &mut App) -> ScrollHandle {
242 window
243 .use_keyed_state(id.clone(), cx, |_, _| ScrollHandle::default())
244 .read(cx)
245 .clone()
246}
247
248#[inline]
265fn root_style_from<E>(element: &mut E, axis: ScrollbarAxis) -> StyleRefinement
266where
267 E: Styled,
268{
269 let style = element.style();
270 StyleRefinement {
271 size: style.size.clone(),
272 min_size: style.min_size.clone(),
273 max_size: style.max_size.clone(),
274 flex_grow: style.flex_grow,
275 flex_shrink: style.flex_shrink,
276 flex_basis: style.flex_basis,
277 align_self: style.align_self,
278 overflow: PointRefinement {
279 x: axis.has_horizontal().then_some(Overflow::Hidden),
280 y: axis.has_vertical().then_some(Overflow::Hidden),
281 },
282 ..Default::default()
283 }
284}
285
286#[inline]
287fn render_scrollbar<H: ScrollbarHandle + Clone>(
288 id: impl Into<ElementId>,
289 scroll_handle: &H,
290 axis: ScrollbarAxis,
291 window: &mut Window,
292 cx: &mut App,
293) -> Div {
294 let is_inspector_picking = window.is_inspector_picking(cx);
297 if is_inspector_picking {
298 return div();
299 }
300
301 div()
302 .absolute()
303 .inset_0()
304 .debug_selector(|| "scrollbar-overlay".to_string())
305 .child(
306 Scrollbar::new(scroll_handle)
307 .id(id)
308 .axis(axis)
309 .viewport_from_layout(),
310 )
311}
312
313#[cfg(feature = "test-support")]
314impl<E> ScrollableElement for gpui_base::test_support::Observed<E> where
315 E: ScrollableElement + Element<PrepaintState = Option<gpui::Hitbox>>
316{
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use gpui::{
323 Context, Render, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point,
324 px,
325 };
326
327 fn draw(cx: &mut VisualTestContext) {
328 cx.run_until_parked();
329 cx.update(|window, cx| {
330 _ = window.draw(cx);
331 });
332 }
333
334 fn scroll(cx: &mut VisualTestContext, x: f32, y: f32, dx: f32, dy: f32) {
335 cx.simulate_event(ScrollWheelEvent {
336 position: point(px(x), px(y)),
337 delta: ScrollDelta::Pixels(point(px(dx), px(dy))),
338 ..Default::default()
339 });
340 draw(cx);
341 }
342
343 fn row(selector: &'static str, height: f32) -> Div {
344 div()
345 .h(px(height))
346 .flex_shrink_0()
347 .debug_selector(move || selector.to_string())
348 }
349
350 fn plain_row(height: f32) -> Div {
351 div().h(px(height)).flex_shrink_0()
352 }
353
354 fn item(selector: &'static str, width: f32) -> Div {
355 div()
356 .w(px(width))
357 .h(px(20.))
358 .flex_shrink_0()
359 .debug_selector(move || selector.to_string())
360 }
361
362 fn plain_item(width: f32) -> Div {
363 div().w(px(width)).h(px(20.)).flex_shrink_0()
364 }
365
366 struct SizeFullChildTest;
367
368 impl Render for SizeFullChildTest {
369 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
370 div()
371 .w(px(100.))
372 .h(px(100.))
373 .overflow_y_scrollbar()
374 .child(
375 div()
376 .size_full()
377 .child(crate::v_flex().children((0..4).map(|ix| {
378 div().h(px(50.)).flex_shrink_0().when(ix == 3, |this| {
379 this.debug_selector(|| "last-row".to_string())
380 })
381 }))),
382 )
383 }
384 }
385
386 struct AutoHeightParentTest;
387
388 impl Render for AutoHeightParentTest {
389 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
390 div().size_full().child(
396 crate::v_flex()
397 .w(px(200.))
398 .child(
399 crate::v_flex().flex_1().overflow_hidden().child(
400 div().flex_1().overflow_hidden().child(
401 crate::v_flex()
402 .size_full()
403 .overflow_y_scrollbar()
404 .child(plain_row(50.))
405 .child(plain_row(50.)),
406 ),
407 ),
408 )
409 .child(row("auto-height-footer", 10.)),
410 )
411 }
412 }
413
414 struct MaxHeightParentTest;
415
416 impl Render for MaxHeightParentTest {
417 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
418 crate::v_flex()
421 .w(px(200.))
422 .max_h(px(100.))
423 .child(
424 crate::v_flex().flex_1().overflow_hidden().child(
425 div().flex_1().overflow_hidden().child(
426 crate::v_flex()
427 .size_full()
428 .overflow_y_scrollbar()
429 .child(plain_row(50.))
430 .child(plain_row(50.))
431 .child(row("max-height-last-row", 50.)),
432 ),
433 ),
434 )
435 .child(row("max-height-footer", 10.))
436 }
437 }
438
439 #[gpui::test]
440 fn auto_height_parent_gets_content_height(cx: &mut TestAppContext) {
441 cx.update(crate::init);
442 let (_, cx) = cx.add_window_view(|_, _| AutoHeightParentTest);
443 let cx: &mut VisualTestContext = cx;
444 draw(cx);
445
446 let footer = cx.debug_bounds("auto-height-footer").unwrap();
448 assert_eq!(footer.top(), px(100.));
449 }
450
451 #[gpui::test]
452 fn max_height_parent_clamps_and_scrolls(cx: &mut TestAppContext) {
453 cx.update(crate::init);
454 let (_, cx) = cx.add_window_view(|_, _| MaxHeightParentTest);
455 let cx: &mut VisualTestContext = cx;
456 draw(cx);
457
458 let footer = cx.debug_bounds("max-height-footer").unwrap();
461 assert_eq!(footer.top(), px(90.));
462
463 let last_initial_y = cx.debug_bounds("max-height-last-row").unwrap().origin.y;
464 scroll(cx, 10., 10., 0., -50.);
465 assert!(cx.debug_bounds("max-height-last-row").unwrap().origin.y < last_initial_y);
466 }
467
468 struct FlexItemScrollableTest;
469
470 impl Render for FlexItemScrollableTest {
471 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
472 crate::v_flex()
476 .w(px(100.))
477 .h(px(100.))
478 .child(row("flex-item-header", 20.))
479 .child(
480 crate::v_flex()
481 .flex_1()
482 .overflow_y_scrollbar()
483 .children((0..6).map(|ix| {
484 div().h(px(50.)).flex_shrink_0().when(ix == 0, |this| {
485 this.debug_selector(|| "flex-item-first-row".to_string())
486 })
487 })),
488 )
489 .child(row("flex-item-footer", 20.))
490 }
491 }
492
493 #[gpui::test]
494 fn scrollable_flex_item_shrinks_below_its_content(cx: &mut TestAppContext) {
495 cx.update(crate::init);
496 let (_, cx) = cx.add_window_view(|_, _| FlexItemScrollableTest);
497 let cx: &mut VisualTestContext = cx;
498 draw(cx);
499
500 assert_eq!(cx.debug_bounds("flex-item-header").unwrap().top(), px(0.));
503 assert_eq!(cx.debug_bounds("flex-item-footer").unwrap().top(), px(80.));
504
505 let initial_y = cx.debug_bounds("flex-item-first-row").unwrap().origin.y;
507 scroll(cx, 10., 50., 0., -50.);
508 assert!(cx.debug_bounds("flex-item-first-row").unwrap().origin.y < initial_y);
509 }
510
511 struct HorizontalFlexItemScrollableTest;
512
513 impl Render for HorizontalFlexItemScrollableTest {
514 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
515 crate::h_flex()
516 .w(px(100.))
517 .h(px(40.))
518 .child(item("horizontal-flex-item-leading", 20.))
519 .child(
520 crate::h_flex()
521 .flex_1()
522 .overflow_x_scrollbar()
523 .children((0..6).map(|ix| {
524 div()
525 .w(px(50.))
526 .h(px(20.))
527 .flex_shrink_0()
528 .when(ix == 0, |this| {
529 this.debug_selector(|| {
530 "horizontal-flex-item-first-item".to_string()
531 })
532 })
533 })),
534 )
535 .child(item("horizontal-flex-item-trailing", 20.))
536 }
537 }
538
539 #[gpui::test]
540 fn horizontal_scrollable_flex_item_shrinks_below_its_content(cx: &mut TestAppContext) {
541 cx.update(crate::init);
542 let (_, cx) = cx.add_window_view(|_, _| HorizontalFlexItemScrollableTest);
543 let cx: &mut VisualTestContext = cx;
544 draw(cx);
545
546 assert_eq!(
547 cx.debug_bounds("horizontal-flex-item-leading")
548 .unwrap()
549 .left(),
550 px(0.)
551 );
552 assert_eq!(
553 cx.debug_bounds("horizontal-flex-item-trailing")
554 .unwrap()
555 .left(),
556 px(80.)
557 );
558
559 let initial_x = cx
560 .debug_bounds("horizontal-flex-item-first-item")
561 .unwrap()
562 .origin
563 .x;
564 scroll(cx, 50., 10., -50., 0.);
565 assert!(
566 cx.debug_bounds("horizontal-flex-item-first-item")
567 .unwrap()
568 .origin
569 .x
570 < initial_x
571 );
572 }
573
574 #[gpui::test]
575 fn horizontal_scrollable_scrolls_with_a_vertical_mouse_wheel(cx: &mut TestAppContext) {
576 cx.update(crate::init);
577 let (_, cx) = cx.add_window_view(|_, _| HorizontalFlexItemScrollableTest);
578 let cx: &mut VisualTestContext = cx;
579 draw(cx);
580
581 let first_item_x = |cx: &mut VisualTestContext| {
582 cx.debug_bounds("horizontal-flex-item-first-item")
583 .unwrap()
584 .origin
585 .x
586 };
587 let initial_x = first_item_x(cx);
588 cx.simulate_event(ScrollWheelEvent {
589 position: point(px(50.), px(10.)),
590 delta: ScrollDelta::Lines(point(0., -1.)),
591 ..Default::default()
592 });
593 draw(cx);
594 assert!(first_item_x(cx) < initial_x);
595 }
596
597 struct ExplicitMinHeightScrollableTest;
598
599 impl Render for ExplicitMinHeightScrollableTest {
600 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
601 crate::v_flex()
603 .w(px(100.))
604 .h(px(100.))
605 .child(row("explicit-min-header", 20.))
606 .child(
607 crate::v_flex()
608 .flex_1()
609 .min_h(px(70.))
610 .overflow_y_scrollbar()
611 .children((0..6).map(|_| plain_row(50.))),
612 )
613 .child(row("explicit-min-footer", 20.))
614 }
615 }
616
617 #[gpui::test]
618 fn explicit_min_size_survives_the_scrollable_wrapper(cx: &mut TestAppContext) {
619 cx.update(crate::init);
620 let (_, cx) = cx.add_window_view(|_, _| ExplicitMinHeightScrollableTest);
621 let cx: &mut VisualTestContext = cx;
622 draw(cx);
623
624 assert_eq!(
627 cx.debug_bounds("explicit-min-header").unwrap().top(),
628 px(0.)
629 );
630 assert_eq!(
631 cx.debug_bounds("explicit-min-footer").unwrap().top(),
632 px(90.)
633 );
634 }
635
636 struct GapLayoutTest;
637
638 struct PaddedScrollbarOverlayTest;
639
640 impl Render for PaddedScrollbarOverlayTest {
641 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
642 crate::v_flex()
643 .w(px(100.))
644 .h(px(100.))
645 .p(px(20.))
646 .overflow_y_scrollbar()
647 .children((0..4).map(|_| plain_row(50.)))
648 }
649 }
650
651 #[gpui::test]
652 fn scrollbar_overlay_ignores_content_padding(cx: &mut TestAppContext) {
653 cx.update(crate::init);
654 let (_, cx) = cx.add_window_view(|_, _| PaddedScrollbarOverlayTest);
655 let cx: &mut VisualTestContext = cx;
656 draw(cx);
657
658 let overlay = cx.debug_bounds("scrollbar-overlay").unwrap();
659 assert_eq!(overlay.origin, point(px(0.), px(0.)));
660 assert_eq!(overlay.size, gpui::size(px(100.), px(100.)));
661 }
662
663 impl Render for GapLayoutTest {
664 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
665 crate::v_flex()
666 .w(px(100.))
667 .h(px(100.))
668 .gap(px(10.))
669 .overflow_y_scrollbar()
670 .child(row("first-row", 20.))
671 .child(row("second-row", 20.))
672 }
673 }
674
675 struct IssueGapRegressionTest;
676
677 impl Render for IssueGapRegressionTest {
678 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
679 div().w(px(100.)).h(px(100.)).child(
680 crate::v_flex()
681 .flex_1()
682 .gap(px(30.))
683 .overflow_y_scrollbar()
684 .px(px(12.))
685 .pb(px(16.))
686 .children((0..5).map(|ix| {
687 div()
688 .h(px(20.))
689 .flex_shrink_0()
690 .when(ix == 0, |this| {
691 this.debug_selector(|| "issue-first-card".to_string())
692 })
693 .when(ix == 1, |this| {
694 this.debug_selector(|| "issue-second-card".to_string())
695 })
696 .when(ix == 4, |this| {
697 this.debug_selector(|| "issue-last-card".to_string())
698 })
699 })),
700 )
701 }
702 }
703
704 struct HorizontalGapLayoutTest;
705
706 impl Render for HorizontalGapLayoutTest {
707 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
708 crate::h_flex()
709 .w(px(100.))
710 .h(px(40.))
711 .gap(px(10.))
712 .overflow_x_scrollbar()
713 .child(item("horizontal-first-item", 50.))
714 .child(item("horizontal-second-item", 50.))
715 .child(item("horizontal-last-item", 50.))
716 }
717 }
718
719 struct OverflowScrollbarVerticalTest;
720
721 impl Render for OverflowScrollbarVerticalTest {
722 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
723 crate::v_flex()
724 .w(px(100.))
725 .h(px(100.))
726 .gap(px(10.))
727 .overflow_scrollbar()
728 .child(row("both-axis-vertical-first-row", 50.))
729 .child(row("both-axis-vertical-second-row", 50.))
730 .child(row("both-axis-vertical-last-row", 50.))
731 }
732 }
733
734 struct OverflowScrollbarHorizontalTest;
735
736 impl Render for OverflowScrollbarHorizontalTest {
737 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
738 crate::h_flex()
739 .w(px(100.))
740 .h(px(40.))
741 .gap(px(10.))
742 .overflow_scrollbar()
743 .child(item("both-axis-horizontal-first-item", 50.))
744 .child(item("both-axis-horizontal-second-item", 50.))
745 .child(item("both-axis-horizontal-last-item", 50.))
746 }
747 }
748
749 struct IndependentScrollablesTest;
750
751 impl Render for IndependentScrollablesTest {
752 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
753 crate::h_flex()
754 .w(px(220.))
755 .h(px(100.))
756 .gap(px(20.))
757 .child(
758 div().w(px(100.)).h(px(100.)).overflow_y_scrollbar().child(
759 crate::v_flex()
760 .child(plain_row(50.))
761 .child(plain_row(50.))
762 .child(row("left-scrollable-last-row", 50.)),
763 ),
764 )
765 .child(
766 div().w(px(100.)).h(px(100.)).overflow_y_scrollbar().child(
767 crate::v_flex()
768 .child(plain_row(50.))
769 .child(plain_row(50.))
770 .child(row("right-scrollable-last-row", 50.)),
771 ),
772 )
773 }
774 }
775
776 struct NoOverflowTest;
777
778 impl Render for NoOverflowTest {
779 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
780 crate::v_flex()
781 .w(px(100.))
782 .h(px(100.))
783 .gap(px(10.))
784 .overflow_y_scrollbar()
785 .child(row("no-overflow-first-row", 20.))
786 .child(row("no-overflow-second-row", 20.))
787 }
788 }
789
790 #[gpui::test]
791 fn vertical_scrollbar_scrolls_past_a_size_full_child(cx: &mut TestAppContext) {
792 cx.update(crate::init);
793 let (_, cx) = cx.add_window_view(|_, _| SizeFullChildTest);
794 let cx: &mut VisualTestContext = cx;
795 draw(cx);
796
797 let initial_y = cx.debug_bounds("last-row").unwrap().origin.y;
798 scroll(cx, 10., 10., 0., -50.);
799
800 assert!(cx.debug_bounds("last-row").unwrap().origin.y < initial_y);
801 }
802
803 #[gpui::test]
804 fn vertical_scrollbar_preserves_source_gap(cx: &mut TestAppContext) {
805 cx.update(crate::init);
806 let (_, cx) = cx.add_window_view(|_, _| GapLayoutTest);
807 let cx: &mut VisualTestContext = cx;
808 draw(cx);
809
810 let first = cx.debug_bounds("first-row").unwrap();
811 let second = cx.debug_bounds("second-row").unwrap();
812 assert_eq!(second.top() - first.bottom(), px(10.));
813 }
814
815 #[gpui::test]
816 fn overflow_y_scrollbar_preserves_gap_for_exact_issue_chain(cx: &mut TestAppContext) {
817 cx.update(crate::init);
818 let (_, cx) = cx.add_window_view(|_, _| IssueGapRegressionTest);
819 let cx: &mut VisualTestContext = cx;
820 draw(cx);
821
822 let first = cx.debug_bounds("issue-first-card").unwrap();
823 let second = cx.debug_bounds("issue-second-card").unwrap();
824 let last_initial_y = cx.debug_bounds("issue-last-card").unwrap().origin.y;
825
826 assert_eq!(second.top() - first.bottom(), px(30.));
827 assert_eq!(first.left(), px(12.));
828
829 scroll(cx, 10., 10., 0., -50.);
830
831 let first_after_scroll = cx.debug_bounds("issue-first-card").unwrap();
832 let second_after_scroll = cx.debug_bounds("issue-second-card").unwrap();
833 let last_after_scroll_y = cx.debug_bounds("issue-last-card").unwrap().origin.y;
834
835 assert_eq!(
836 second_after_scroll.top() - first_after_scroll.bottom(),
837 px(30.)
838 );
839 assert_eq!(first_after_scroll.left(), px(12.));
840 assert!(last_after_scroll_y < last_initial_y);
841 }
842
843 #[gpui::test]
844 fn horizontal_scrollbar_preserves_source_gap_and_scrolls(cx: &mut TestAppContext) {
845 cx.update(crate::init);
846 let (_, cx) = cx.add_window_view(|_, _| HorizontalGapLayoutTest);
847 let cx: &mut VisualTestContext = cx;
848 draw(cx);
849
850 let first = cx.debug_bounds("horizontal-first-item").unwrap();
851 let second = cx.debug_bounds("horizontal-second-item").unwrap();
852 let last_initial_x = cx.debug_bounds("horizontal-last-item").unwrap().origin.x;
853
854 assert_eq!(second.left() - first.right(), px(10.));
855
856 scroll(cx, 10., 10., -50., 0.);
857
858 let first_after_scroll = cx.debug_bounds("horizontal-first-item").unwrap();
859 let second_after_scroll = cx.debug_bounds("horizontal-second-item").unwrap();
860 let last_after_scroll_x = cx.debug_bounds("horizontal-last-item").unwrap().origin.x;
861
862 assert_eq!(
863 second_after_scroll.left() - first_after_scroll.right(),
864 px(10.)
865 );
866 assert!(last_after_scroll_x < last_initial_x);
867 }
868
869 #[gpui::test]
870 fn overflow_scrollbar_preserves_vertical_source_gap(cx: &mut TestAppContext) {
871 cx.update(crate::init);
872 let (_, cx) = cx.add_window_view(|_, _| OverflowScrollbarVerticalTest);
873 let cx: &mut VisualTestContext = cx;
874 draw(cx);
875
876 let first = cx.debug_bounds("both-axis-vertical-first-row").unwrap();
877 let second = cx.debug_bounds("both-axis-vertical-second-row").unwrap();
878
879 assert_eq!(second.top() - first.bottom(), px(10.));
880 }
881
882 #[gpui::test]
883 fn overflow_scrollbar_preserves_gap_and_scrolls_horizontally(cx: &mut TestAppContext) {
884 cx.update(crate::init);
885 let (_, cx) = cx.add_window_view(|_, _| OverflowScrollbarHorizontalTest);
886 let cx: &mut VisualTestContext = cx;
887 draw(cx);
888
889 let first = cx.debug_bounds("both-axis-horizontal-first-item").unwrap();
890 let second = cx.debug_bounds("both-axis-horizontal-second-item").unwrap();
891 let last_initial_x = cx
892 .debug_bounds("both-axis-horizontal-last-item")
893 .unwrap()
894 .origin
895 .x;
896
897 assert_eq!(second.left() - first.right(), px(10.));
898
899 scroll(cx, 10., 10., -50., 0.);
900
901 let first_after_scroll = cx.debug_bounds("both-axis-horizontal-first-item").unwrap();
902 let second_after_scroll = cx.debug_bounds("both-axis-horizontal-second-item").unwrap();
903 let last_after_scroll_x = cx
904 .debug_bounds("both-axis-horizontal-last-item")
905 .unwrap()
906 .origin
907 .x;
908
909 assert_eq!(
910 second_after_scroll.left() - first_after_scroll.right(),
911 px(10.)
912 );
913 assert!(last_after_scroll_x < last_initial_x);
914 }
915
916 #[gpui::test]
917 fn multiple_scrollables_keep_independent_scroll_state(cx: &mut TestAppContext) {
918 cx.update(crate::init);
919 let (_, cx) = cx.add_window_view(|_, _| IndependentScrollablesTest);
920 let cx: &mut VisualTestContext = cx;
921 draw(cx);
922
923 let left_initial = cx.debug_bounds("left-scrollable-last-row").unwrap();
924 let right_initial = cx.debug_bounds("right-scrollable-last-row").unwrap();
925
926 scroll(cx, 10., 10., 0., -50.);
927
928 let left_after_scroll = cx.debug_bounds("left-scrollable-last-row").unwrap();
929 let right_after_scroll = cx.debug_bounds("right-scrollable-last-row").unwrap();
930
931 assert!(left_after_scroll.top() < left_initial.top());
932 assert_eq!(right_after_scroll.top(), right_initial.top());
933 }
934
935 #[gpui::test]
936 fn vertical_scrollbar_does_not_scroll_when_content_does_not_overflow(cx: &mut TestAppContext) {
937 cx.update(crate::init);
938 let (_, cx) = cx.add_window_view(|_, _| NoOverflowTest);
939 let cx: &mut VisualTestContext = cx;
940 draw(cx);
941
942 let first = cx.debug_bounds("no-overflow-first-row").unwrap();
943 let second = cx.debug_bounds("no-overflow-second-row").unwrap();
944
945 assert_eq!(second.top() - first.bottom(), px(10.));
946
947 scroll(cx, 10., 10., 0., -50.);
948
949 let first_after_scroll = cx.debug_bounds("no-overflow-first-row").unwrap();
950 let second_after_scroll = cx.debug_bounds("no-overflow-second-row").unwrap();
951
952 assert_eq!(first_after_scroll.top(), first.top());
953 assert_eq!(second_after_scroll.top(), second.top());
954 assert_eq!(
955 second_after_scroll.top() - first_after_scroll.bottom(),
956 px(10.)
957 );
958 }
959
960 #[gpui::test]
961 fn horizontal_scrollbar_does_not_scroll_when_content_does_not_overflow(
962 cx: &mut TestAppContext,
963 ) {
964 struct HorizontalNoOverflowTest;
965
966 impl Render for HorizontalNoOverflowTest {
967 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
968 crate::h_flex()
969 .w(px(100.))
970 .h(px(40.))
971 .gap(px(10.))
972 .overflow_x_scrollbar()
973 .child(item("no-overflow-first-item", 20.))
974 .child(item("no-overflow-second-item", 20.))
975 .child(plain_item(20.))
976 }
977 }
978
979 cx.update(crate::init);
980 let (_, cx) = cx.add_window_view(|_, _| HorizontalNoOverflowTest);
981 let cx: &mut VisualTestContext = cx;
982 draw(cx);
983
984 let first = cx.debug_bounds("no-overflow-first-item").unwrap();
985 let second = cx.debug_bounds("no-overflow-second-item").unwrap();
986
987 assert_eq!(second.left() - first.right(), px(10.));
988
989 scroll(cx, 10., 10., -50., 0.);
990
991 let first_after_scroll = cx.debug_bounds("no-overflow-first-item").unwrap();
992 let second_after_scroll = cx.debug_bounds("no-overflow-second-item").unwrap();
993
994 assert_eq!(first_after_scroll.left(), first.left());
995 assert_eq!(second_after_scroll.left(), second.left());
996 assert_eq!(
997 second_after_scroll.left() - first_after_scroll.right(),
998 px(10.)
999 );
1000 }
1001 struct IndependentDynamicScrollablesTest;
1002
1003 impl Render for IndependentDynamicScrollablesTest {
1004 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1005 crate::v_flex().w(px(100.)).children((0..2).map(|ix| {
1006 crate::h_flex()
1007 .w(px(100.))
1008 .h(px(40.))
1009 .overflow_x_scrollbar()
1010 .id(format!("dynamic-scroll-{ix}"))
1011 .child(
1012 div()
1013 .w(px(200.))
1014 .h(px(20.))
1015 .flex_shrink_0()
1016 .when(ix == 0, |this| {
1017 this.debug_selector(|| "dynamic-scroll-first".to_string())
1018 })
1019 .when(ix == 1, |this| {
1020 this.debug_selector(|| "dynamic-scroll-second".to_string())
1021 }),
1022 )
1023 }))
1024 }
1025 }
1026
1027 #[gpui::test]
1028 fn dynamic_scrollables_with_unique_scroll_ids_keep_independent_state(cx: &mut TestAppContext) {
1029 cx.update(crate::init);
1030
1031 let (_, cx) = cx.add_window_view(|_, _| IndependentDynamicScrollablesTest);
1032 let cx: &mut VisualTestContext = cx;
1033
1034 draw(cx);
1035
1036 let first_initial = cx.debug_bounds("dynamic-scroll-first").unwrap();
1037
1038 let second_initial = cx.debug_bounds("dynamic-scroll-second").unwrap();
1039
1040 scroll(cx, 10., 10., -50., 0.);
1042
1043 let first_after_scroll = cx.debug_bounds("dynamic-scroll-first").unwrap();
1044
1045 let second_after_scroll = cx.debug_bounds("dynamic-scroll-second").unwrap();
1046
1047 assert!(first_after_scroll.left() < first_initial.left());
1049
1050 assert_eq!(second_after_scroll.left(), second_initial.left());
1052 }
1053}