feat: add scrollbar for virtualized lists

This commit is contained in:
2026-11-04 13:05:39 +07:00
parent 4bfc816cbe
commit 50da1d4a9d
10 changed files with 1033 additions and 23 deletions
+273
View File
@@ -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"
);
}