feat: add scrollbar for virtualized lists
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
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<RefCell<usize>>,
|
||||
}
|
||||
|
||||
impl Render for UniformShell {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> 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<Self>) -> 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<RefCell<usize>>,
|
||||
) {
|
||||
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<Pixels>,
|
||||
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<Pixels> {
|
||||
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<Pixels> {
|
||||
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<Pixels> {
|
||||
point(
|
||||
self.thumb_center().x,
|
||||
self.thumb_center().y + (self.track_length() - self.thumb_length()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn press(visual: &mut VisualTestContext, position: Point<Pixels>) {
|
||||
visual.simulate_event(MouseDownEvent {
|
||||
button: MouseButton::Left,
|
||||
position,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
fn drag_to(visual: &mut VisualTestContext, position: Point<Pixels>) {
|
||||
visual.simulate_event(MouseMoveEvent {
|
||||
position,
|
||||
pressed_button: Some(MouseButton::Left),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
fn release(visual: &mut VisualTestContext, position: Point<Pixels>) {
|
||||
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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user