From a1d2ef651426ebc66f558727694b460e823042dc Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Fri, 29 May 2026 18:54:59 +0530 Subject: [PATCH] gpui: Add item_is_above_viewport and item_is_below_viewport APIs to ListState (#58061) In prep for handling the above-viewport case in https://github.com/zed-industries/zed/pull/57632, which currently only handles below case. This PR adds `ListState::item_is_above_viewport` and `ListState::item_is_below_viewport` methods, which report whether a given list item is entirely outside the current viewport. Both return `None` when the list has not measured enough layout to answer. Release Notes: - N/A --- crates/gpui/src/elements/list.rs | 146 +++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 7f80d788925..5a729dcc5f5 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -741,6 +741,44 @@ impl ListState { pub fn viewport_bounds(&self) -> Bounds { self.0.borrow().last_layout_bounds.unwrap_or_default() } + + /// Returns whether the item is entirely above the viewport, or `None` if + /// the list has not measured enough layout to know. + pub fn item_is_above_viewport(&self, ix: usize) -> Option { + let viewport_bounds = self.viewport_bounds(); + if viewport_bounds.size.height == px(0.0) { + return None; + } + + let scroll_top = self.logical_scroll_top(); + if ix < scroll_top.item_ix { + // Rows before the logical scroll top have no item bounds, but + // their position relative to the viewport is known from scroll state. + return Some(true); + } + + let item_bounds = self.bounds_for_item(ix)?; + Some(item_bounds.bottom() <= viewport_bounds.top()) + } + + /// Returns whether the item is entirely below the viewport, or `None` if + /// the list has not measured enough layout to know. + pub fn item_is_below_viewport(&self, ix: usize) -> Option { + let viewport_bounds = self.viewport_bounds(); + if viewport_bounds.size.height == px(0.0) { + return None; + } + + let scroll_top = self.logical_scroll_top(); + if ix < scroll_top.item_ix { + // Rows before the logical scroll top have no item bounds, but + // their position relative to the viewport is known from scroll state. + return Some(false); + } + + let item_bounds = self.bounds_for_item(ix)?; + Some(item_bounds.top() >= viewport_bounds.bottom()) + } } impl StateInner { @@ -1644,6 +1682,114 @@ mod test { assert_eq!(offset.offset_in_item, px(0.)); } + struct TestListView(ListState); + impl Render for TestListView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(20.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + #[gpui::test] + fn test_item_viewport_queries_return_none_before_layout(_cx: &mut TestAppContext) { + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + assert_eq!(state.item_is_above_viewport(0), None); + assert_eq!(state.item_is_below_viewport(0), None); + } + + #[gpui::test] + fn test_item_viewport_queries_before_logical_scroll_top(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + state.scroll_to(gpui::ListOffset { + item_ix: 2, + offset_in_item: px(0.), + }); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + assert_eq!(state.item_is_above_viewport(1), Some(true)); + assert_eq!(state.item_is_below_viewport(1), Some(false)); + } + + #[gpui::test] + fn test_item_viewport_queries_measured_item_inside_viewport(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + state.scroll_to(gpui::ListOffset { + item_ix: 2, + offset_in_item: px(0.), + }); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + assert_eq!(state.item_is_above_viewport(2), Some(false)); + assert_eq!(state.item_is_below_viewport(2), Some(false)); + } + + #[gpui::test] + fn test_item_viewport_queries_measured_item_above_viewport(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + state.scroll_to(gpui::ListOffset { + item_ix: 2, + offset_in_item: px(20.), + }); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + assert_eq!(state.item_is_above_viewport(2), Some(true)); + assert_eq!(state.item_is_below_viewport(2), Some(false)); + } + + #[gpui::test] + fn test_item_viewport_queries_measured_item_below_viewport(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + state.scroll_to(gpui::ListOffset { + item_ix: 2, + offset_in_item: px(0.), + }); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + assert_eq!(state.item_is_above_viewport(3), Some(false)); + assert_eq!(state.item_is_below_viewport(3), Some(true)); + } + + #[gpui::test] + fn test_item_viewport_queries_after_scroll_to_end_before_layout(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + state.scroll_to_end(); + + assert_eq!(state.logical_scroll_top().item_ix, state.item_count()); + assert_eq!(state.item_is_above_viewport(0), Some(true)); + assert_eq!(state.item_is_below_viewport(0), Some(false)); + } + #[gpui::test] fn test_measure_all_after_width_change(cx: &mut TestAppContext) { let cx = cx.add_empty_window();