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