use gpui::{ Bounds, Context, IntoElement, ListAlignment, ListState, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render, Styled as _, TestAppContext, UniformListScrollHandle, VisualTestContext, Window, div, list, point, px, uniform_list, }; use gpui_native_core::init; use gpui_native_core::scrollbar::{Scrollbar, ScrollbarHandle}; use gpui_native_theme::ActiveTheme as _; use std::cell::RefCell; use std::rc::Rc; const ROWS: usize = 50; const ROW_HEIGHT: f32 = 24.; const VIEWPORT_HEIGHT: f32 = 300.; struct UniformShell { rows: usize, handle: UniformListScrollHandle, renders: Rc>, } impl Render for UniformShell { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { *self.renders.borrow_mut() += 1; div().size_full().child( div() .relative() .h(px(VIEWPORT_HEIGHT)) .w(px(400.)) .child( uniform_list("rows", self.rows, |range, _, _| { range .map(|ix| div().h(px(ROW_HEIGHT)).child(format!("row {ix}"))) .collect() }) .track_scroll(&self.handle) .h_full() .w_full(), ) .child(Scrollbar::vertical(self.handle.clone())), ) } } struct ListShell { state: ListState, } impl Render for ListShell { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { div().size_full().child( div() .relative() .h(px(VIEWPORT_HEIGHT)) .w(px(400.)) .child( list(self.state.clone(), |ix, _, _| { div() .h(px(ROW_HEIGHT)) .child(format!("row {ix}")) .into_any_element() }) .h_full() .w_full(), ) .child(Scrollbar::vertical(self.state.clone())), ) } } fn uniform_shell( rows: usize, ) -> ( TestAppContext, VisualTestContext, UniformListScrollHandle, Rc>, ) { let mut cx = TestAppContext::single(); cx.update(init); let handle = UniformListScrollHandle::new(); let handle_for_view = handle.clone(); let renders = Rc::new(RefCell::new(0)); let renders_for_view = renders.clone(); let window = cx.add_window(move |_, _| UniformShell { rows, handle: handle_for_view, renders: renders_for_view, }); let mut visual = VisualTestContext::from_window(window.into(), &cx); visual.update(|window, cx| { window.draw(cx).clear(cx); }); (cx, visual, handle, renders) } fn list_shell() -> (TestAppContext, VisualTestContext, ListState) { let mut cx = TestAppContext::single(); cx.update(init); let state = ListState::new(ROWS, ListAlignment::Top, px(400.)).with_uniform_item_height(px(ROW_HEIGHT)); let state_for_view = state.clone(); let window = cx.add_window(move |_, _| ListShell { state: state_for_view, }); let mut visual = VisualTestContext::from_window(window.into(), &cx); visual.update(|window, cx| { window.draw(cx).clear(cx); }); (cx, visual, state) } /// The scrollbar geometry the tests need, straight from the handle and the /// theme the component reads. struct Metrics { viewport: Bounds, max_offset: Pixels, width: Pixels, inset: Pixels, min_thumb: Pixels, } impl Metrics { fn measure(cx: &TestAppContext, handle: &impl ScrollbarHandle) -> Self { cx.update(|cx| { let theme = cx.theme(); Self { viewport: handle.viewport(), max_offset: handle.max_offset().y, width: theme.scrollbar.width, inset: theme.scrollbar.inset, min_thumb: theme.scrollbar.min_thumb, } }) } fn track_length(&self) -> Pixels { self.viewport.size.height - self.inset * 2.0 } fn thumb_length(&self) -> Pixels { let content = self.viewport.size.height + self.max_offset; (self.viewport.size.height / content * self.track_length()) .max(self.min_thumb) .min(self.track_length()) } fn thumb_center(&self) -> Point { point( self.viewport.right() - self.inset - self.width / 2.0, self.viewport.top() + self.inset + self.thumb_length() / 2.0, ) } fn track_near_bottom(&self) -> Point { point( self.viewport.right() - self.inset - self.width / 2.0, self.viewport.top() + self.inset + self.track_length() - px(1.), ) } fn full_drag_target(&self) -> Point { point( self.thumb_center().x, self.thumb_center().y + (self.track_length() - self.thumb_length()), ) } } fn press(visual: &mut VisualTestContext, position: Point) { visual.simulate_event(MouseDownEvent { button: MouseButton::Left, position, ..Default::default() }); } fn drag_to(visual: &mut VisualTestContext, position: Point) { visual.simulate_event(MouseMoveEvent { position, pressed_button: Some(MouseButton::Left), ..Default::default() }); } fn release(visual: &mut VisualTestContext, position: Point) { visual.simulate_event(MouseUpEvent { button: MouseButton::Left, position, ..Default::default() }); } #[test] fn dragging_the_thumb_scrolls_the_uniform_list() { let (cx, mut visual, handle, renders) = uniform_shell(ROWS); let metrics = Metrics::measure(&cx, &handle); assert!( metrics.max_offset > px(0.), "50 24px rows overflow a 300px viewport" ); press(&mut visual, metrics.thumb_center()); let renders_after_press = *renders.borrow(); drag_to(&mut visual, metrics.full_drag_target()); let renders_after_move = *renders.borrow(); release(&mut visual, metrics.full_drag_target()); let offset = handle.offset().y; assert!( (offset.abs() - metrics.max_offset).abs() < px(0.5), "a full drag reaches the end, got {offset}" ); assert!( renders_after_move > renders_after_press, "dragging must repaint the owning view, or the scroll never shows" ); } #[test] fn clicking_the_track_jumps_to_the_position() { let (cx, mut visual, handle, _) = uniform_shell(ROWS); let metrics = Metrics::measure(&cx, &handle); press(&mut visual, metrics.track_near_bottom()); release(&mut visual, metrics.track_near_bottom()); let offset = handle.offset().y; assert!( (offset.abs() - metrics.max_offset).abs() < px(0.5), "a track click near the bottom jumps to the end, got {offset}" ); } #[test] fn a_fitted_list_ignores_the_scrollbar() { let (cx, mut visual, handle, _) = uniform_shell(5); let metrics = Metrics::measure(&cx, &handle); assert_eq!(metrics.max_offset, px(0.), "5 rows fit the viewport"); press(&mut visual, metrics.track_near_bottom()); drag_to(&mut visual, metrics.full_drag_target()); release(&mut visual, metrics.full_drag_target()); assert_eq!(handle.offset().y, px(0.), "nothing scrolled"); } #[test] fn dragging_the_thumb_drives_a_list_state_and_reports_the_drag() { let (cx, mut visual, state) = list_shell(); let metrics = Metrics::measure(&cx, &state); assert!(metrics.max_offset > px(0.)); press(&mut visual, metrics.thumb_center()); assert!( state.is_scrollbar_dragging(), "the drag freezes the list's measured height" ); drag_to(&mut visual, metrics.full_drag_target()); let offset = state.offset().y; assert!( (offset.abs() - metrics.max_offset).abs() < px(0.5), "a full drag reaches the end, got {offset}" ); release(&mut visual, metrics.full_drag_target()); assert!( !state.is_scrollbar_dragging(), "the drag ends with the mouse" ); }