diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index b3812bb7cb5747ff40bd6d05a39b9ee7bebbdda1..9eb2de936c2e1db1d80cc3627db5594152e7223e 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -235,6 +235,10 @@ path = "examples/window_shadow.rs" name = "grid_layout" path = "examples/grid_layout.rs" +[[example]] +name = "list_example" +path = "examples/list_example.rs" + [[example]] name = "mouse_pressure" path = "examples/mouse_pressure.rs" diff --git a/crates/gpui/examples/list_example.rs b/crates/gpui/examples/list_example.rs new file mode 100644 index 0000000000000000000000000000000000000000..7aeff7c24ec3755edf1e37f5ff1cc496c9fb597e --- /dev/null +++ b/crates/gpui/examples/list_example.rs @@ -0,0 +1,170 @@ +#![cfg_attr(target_family = "wasm", no_main)] + +use gpui::{ + App, Bounds, Context, ListAlignment, ListState, Render, Window, WindowBounds, WindowOptions, + div, list, prelude::*, px, rgb, size, +}; +use gpui_platform::application; + +const ITEM_COUNT: usize = 40; +const SCROLLBAR_WIDTH: f32 = 12.; + +struct BottomListDemo { + list_state: ListState, +} + +impl BottomListDemo { + fn new() -> Self { + Self { + list_state: ListState::new(ITEM_COUNT, ListAlignment::Bottom, px(500.)).measure_all(), + } + } +} + +impl Render for BottomListDemo { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let max_offset = self.list_state.max_offset_for_scrollbar().y; + let current_offset = -self.list_state.scroll_px_offset_for_scrollbar().y; + + let viewport_height = self.list_state.viewport_bounds().size.height; + + let raw_fraction = if max_offset > px(0.) { + current_offset / max_offset + } else { + 0. + }; + + let total_height = viewport_height + max_offset; + let thumb_height = if total_height > px(0.) { + px(viewport_height.as_f32() * viewport_height.as_f32() / total_height.as_f32()) + .max(px(30.)) + } else { + px(30.) + }; + + let track_space = viewport_height - thumb_height; + let thumb_top = track_space * raw_fraction; + + let bug_detected = raw_fraction > 1.0; + + div() + .size_full() + .bg(rgb(0xFFFFFF)) + .flex() + .flex_col() + .p_4() + .gap_2() + .child( + div() + .text_sm() + .flex() + .flex_col() + .gap_1() + .child(format!( + "offset: {:.0} / max: {:.0} | fraction: {:.3}", + current_offset.as_f32(), + max_offset.as_f32(), + raw_fraction, + )) + .child( + div() + .text_color(if bug_detected { + rgb(0xCC0000) + } else { + rgb(0x008800) + }) + .child(if bug_detected { + format!( + "BUG: fraction is {:.3} (> 1.0) — thumb is off-track!", + raw_fraction + ) + } else { + "OK: fraction <= 1.0 — thumb is within track.".to_string() + }), + ), + ) + .child( + div() + .flex_1() + .flex() + .flex_row() + .overflow_hidden() + .border_1() + .border_color(rgb(0xCCCCCC)) + .rounded_sm() + .child( + list(self.list_state.clone(), |index, _window, _cx| { + let height = px(30. + (index % 5) as f32 * 10.); + div() + .h(height) + .w_full() + .flex() + .items_center() + .px_3() + .border_b_1() + .border_color(rgb(0xEEEEEE)) + .bg(if index % 2 == 0 { + rgb(0xFAFAFA) + } else { + rgb(0xFFFFFF) + }) + .text_sm() + .child(format!("Item {index}")) + .into_any() + }) + .flex_1(), + ) + // Scrollbar track + .child( + div() + .w(px(SCROLLBAR_WIDTH)) + .h_full() + .flex_shrink_0() + .bg(rgb(0xE0E0E0)) + .relative() + .child( + // Thumb — position is unclamped to expose the bug + div() + .absolute() + .top(thumb_top) + .w_full() + .h(thumb_height) + .bg(if bug_detected { + rgb(0xCC0000) + } else { + rgb(0x888888) + }) + .rounded_sm(), + ), + ), + ) + } +} + +fn run_example() { + application().run(|cx: &mut App| { + let bounds = Bounds::centered(None, size(px(400.), px(500.)), cx); + cx.open_window( + WindowOptions { + focus: true, + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |_, cx| cx.new(|_| BottomListDemo::new()), + ) + .unwrap(); + cx.activate(true); + }); +} + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index b84241e9e0f79fe5cf8a24514cbf57982247a76b..578900085334baf27ab90ae77748fb7fd362e8ad 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -493,18 +493,17 @@ impl ListState { /// This value remains constant while dragging to prevent the scrollbar from moving away unexpectedly. pub fn max_offset_for_scrollbar(&self) -> Point { let state = self.0.borrow(); - let bounds = state.last_layout_bounds.unwrap_or_default(); - - let height = state - .scrollbar_drag_start_height - .unwrap_or_else(|| state.items.summary().height); - - point(Pixels::ZERO, Pixels::ZERO.max(height - bounds.size.height)) + point(Pixels::ZERO, state.max_scroll_offset()) } /// Returns the current scroll offset adjusted for the scrollbar pub fn scroll_px_offset_for_scrollbar(&self) -> Point { let state = &self.0.borrow(); + + if state.logical_scroll_top.is_none() && state.alignment == ListAlignment::Bottom { + return Point::new(px(0.), -state.max_scroll_offset()); + } + let logical_scroll_top = state.logical_scroll_top(); let mut cursor = state.items.cursor::(()); @@ -526,6 +525,14 @@ impl ListState { } impl StateInner { + fn max_scroll_offset(&self) -> Pixels { + let bounds = self.last_layout_bounds.unwrap_or_default(); + let height = self + .scrollbar_drag_start_height + .unwrap_or_else(|| self.items.summary().height); + (height - bounds.size.height).max(px(0.)) + } + fn visible_range( items: &SumTree, height: Pixels, @@ -1449,4 +1456,46 @@ mod test { assert_eq!(offset.item_ix, 2); assert_eq!(offset.offset_in_item, px(20.)); } + + #[gpui::test] + fn test_bottom_aligned_scrollbar_offset_at_end(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + const ITEMS: usize = 10; + const ITEM_SIZE: f32 = 50.0; + + let state = ListState::new( + ITEMS, + crate::ListAlignment::Bottom, + px(ITEMS as f32 * ITEM_SIZE), + ); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(ITEM_SIZE)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| { + cx.new(|_| TestView(state.clone())).into_any_element() + }); + + // Bottom-aligned lists start pinned to the end: logical_scroll_top returns + // item_ix == item_count, meaning no explicit scroll position has been set. + assert_eq!(state.logical_scroll_top().item_ix, ITEMS); + + let max_offset = state.max_offset_for_scrollbar(); + let scroll_offset = state.scroll_px_offset_for_scrollbar(); + + assert_eq!( + -scroll_offset.y, max_offset.y, + "scrollbar offset ({}) should equal max offset ({}) when list is pinned to bottom", + -scroll_offset.y, max_offset.y, + ); + } }