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
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}
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 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 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 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 .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#[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 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 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 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 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 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 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 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 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 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 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(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 assert!(first_after_scroll.left() < first_initial.left());
994
995 assert_eq!(second_after_scroll.left(), second_initial.left());
997 }
998}