virtual_list: Add VirtualListScrollHandle to support scroll_to_item. (#1108)

- Also fixed Table scroll to selected column support.


https://github.com/user-attachments/assets/3b7c51a7-addf-4657-9f9f-de9047fffab8
This commit is contained in:
Jason Lee 2025-07-31 18:09:23 +08:00 committed by GitHub
parent cb6695c774
commit efa2c7c9ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 309 additions and 100 deletions

28
Cargo.lock generated
View file

@ -733,7 +733,7 @@ dependencies = [
"bitflags 2.9.1",
"cexpr",
"clang-sys",
"itertools 0.10.5",
"itertools 0.11.0",
"lazy_static",
"lazycell",
"log",
@ -756,7 +756,7 @@ dependencies = [
"bitflags 2.9.1",
"cexpr",
"clang-sys",
"itertools 0.10.5",
"itertools 0.11.0",
"log",
"prettyplease",
"proc-macro2",
@ -1432,7 +1432,7 @@ dependencies = [
[[package]]
name = "collections"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed.git#65759d43163bd36cce8f8aa492ba2628ca005a7a"
source = "git+https://github.com/zed-industries/zed.git#5488398986487afa6e5b38ea55f768271117b18b"
dependencies = [
"indexmap 2.9.0",
"rustc-hash 2.1.0",
@ -1990,7 +1990,7 @@ dependencies = [
[[package]]
name = "derive_refineable"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed.git#65759d43163bd36cce8f8aa492ba2628ca005a7a"
source = "git+https://github.com/zed-industries/zed.git#5488398986487afa6e5b38ea55f768271117b18b"
dependencies = [
"proc-macro2",
"quote",
@ -3142,7 +3142,7 @@ dependencies = [
[[package]]
name = "gpui"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed.git#65759d43163bd36cce8f8aa492ba2628ca005a7a"
source = "git+https://github.com/zed-industries/zed.git#5488398986487afa6e5b38ea55f768271117b18b"
dependencies = [
"anyhow",
"as-raw-xcb-connection",
@ -3327,7 +3327,7 @@ dependencies = [
[[package]]
name = "gpui_macros"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed.git#65759d43163bd36cce8f8aa492ba2628ca005a7a"
source = "git+https://github.com/zed-industries/zed.git#5488398986487afa6e5b38ea55f768271117b18b"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@ -3597,7 +3597,7 @@ dependencies = [
[[package]]
name = "http_client"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed.git#65759d43163bd36cce8f8aa492ba2628ca005a7a"
source = "git+https://github.com/zed-industries/zed.git#5488398986487afa6e5b38ea55f768271117b18b"
dependencies = [
"anyhow",
"bytes",
@ -3615,7 +3615,7 @@ dependencies = [
[[package]]
name = "http_client_tls"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed.git#65759d43163bd36cce8f8aa492ba2628ca005a7a"
source = "git+https://github.com/zed-industries/zed.git#5488398986487afa6e5b38ea55f768271117b18b"
dependencies = [
"rustls",
"rustls-platform-verifier",
@ -4441,7 +4441,7 @@ dependencies = [
[[package]]
name = "media"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed.git#65759d43163bd36cce8f8aa492ba2628ca005a7a"
source = "git+https://github.com/zed-industries/zed.git#5488398986487afa6e5b38ea55f768271117b18b"
dependencies = [
"anyhow",
"bindgen 0.71.1",
@ -6390,7 +6390,7 @@ dependencies = [
[[package]]
name = "refineable"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed.git#65759d43163bd36cce8f8aa492ba2628ca005a7a"
source = "git+https://github.com/zed-industries/zed.git#5488398986487afa6e5b38ea55f768271117b18b"
dependencies = [
"derive_refineable",
"workspace-hack",
@ -6545,7 +6545,7 @@ dependencies = [
[[package]]
name = "reqwest_client"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed.git#65759d43163bd36cce8f8aa492ba2628ca005a7a"
source = "git+https://github.com/zed-industries/zed.git#5488398986487afa6e5b38ea55f768271117b18b"
dependencies = [
"anyhow",
"bytes",
@ -7121,7 +7121,7 @@ checksum = "d369a96f978623eb3dc28807c4852d6cc617fed53da5d3c400feff1ef34a714a"
[[package]]
name = "semantic_version"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed.git#65759d43163bd36cce8f8aa492ba2628ca005a7a"
source = "git+https://github.com/zed-industries/zed.git#5488398986487afa6e5b38ea55f768271117b18b"
dependencies = [
"anyhow",
"serde",
@ -7704,7 +7704,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "sum_tree"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed.git#65759d43163bd36cce8f8aa492ba2628ca005a7a"
source = "git+https://github.com/zed-industries/zed.git#5488398986487afa6e5b38ea55f768271117b18b"
dependencies = [
"arrayvec",
"log",
@ -8931,7 +8931,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "util"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed.git#65759d43163bd36cce8f8aa492ba2628ca005a7a"
source = "git+https://github.com/zed-industries/zed.git#5488398986487afa6e5b38ea55f768271117b18b"
dependencies = [
"anyhow",
"async-fs 2.1.2",

View file

@ -2,7 +2,7 @@ use std::{ops::Range, rc::Rc};
use gpui::{
div, px, size, App, AppContext, Context, Div, Entity, FocusHandle, Focusable,
InteractiveElement, IntoElement, ParentElement, Pixels, Render, ScrollHandle, Size, Styled,
InteractiveElement, IntoElement, ParentElement, Pixels, Render, ScrollStrategy, Size, Styled,
Window,
};
use gpui_component::{
@ -10,12 +10,12 @@ use gpui_component::{
divider::Divider,
h_flex,
scroll::{Scrollbar, ScrollbarAxis, ScrollbarState},
v_flex, v_virtual_list, ActiveTheme as _, Selectable,
v_flex, v_virtual_list, ActiveTheme as _, Selectable, Sizable, VirtualListScrollHandle,
};
pub struct VirtualListStory {
focus_handle: FocusHandle,
scroll_handle: ScrollHandle,
scroll_handle: VirtualListScrollHandle,
scroll_state: ScrollbarState,
items: Vec<String>,
item_sizes: Rc<Vec<Size<Pixels>>>,
@ -34,7 +34,7 @@ impl VirtualListStory {
Self {
focus_handle: cx.focus_handle(),
scroll_handle: ScrollHandle::new(),
scroll_handle: VirtualListScrollHandle::new(),
scroll_state: ScrollbarState::default(),
items,
item_sizes: Rc::new(item_sizes),
@ -79,80 +79,125 @@ impl VirtualListStory {
}
fn render_buttons(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
v_flex()
.gap_2()
.justify_between()
.child(
h_flex()
.gap_2()
.justify_between()
.child(
h_flex()
.gap_2()
.child(
ButtonGroup::new("test-cases")
.outline()
.compact()
.child(
Button::new("test-0")
.label("Size 0")
.selected(self.size_mode == 0),
)
.child(
Button::new("test-1")
.label("Size 1")
.selected(self.size_mode == 1),
)
.child(
Button::new("test-2")
.label("Size 2")
.selected(self.size_mode == 2),
)
.child(
Button::new("test-3")
.label("Size 3")
.selected(self.size_mode == 3),
)
.on_click(cx.listener(|view, clicks: &Vec<usize>, _, cx| {
if clicks.contains(&0) {
view.change_test_cases(0, cx)
} else if clicks.contains(&1) {
view.change_test_cases(1, cx)
} else if clicks.contains(&2) {
view.change_test_cases(2, cx)
} else if clicks.contains(&3) {
view.change_test_cases(3, cx)
}
})),
)
.child(Divider::vertical().px_2())
.child(
ButtonGroup::new("scrollbars")
.outline()
.compact()
.child(
Button::new("test-axis-both")
.label("Both Scrollbar")
.selected(self.axis.is_both()),
)
.child(
Button::new("test-axis-vertical")
.label("Vertical")
.selected(self.axis.is_vertical()),
)
.child(
Button::new("test-axis-horizontal")
.label("Horizontal")
.selected(self.axis.is_horizontal()),
)
.on_click(cx.listener(|view, clicks: &Vec<usize>, _, cx| {
if clicks.contains(&0) {
view.change_axis(ScrollbarAxis::Both, cx)
} else if clicks.contains(&1) {
view.change_axis(ScrollbarAxis::Vertical, cx)
} else if clicks.contains(&2) {
view.change_axis(ScrollbarAxis::Horizontal, cx)
}
})),
),
)
.child(format!("visible_range: {:?}", self.visible_range)),
)
.child(
h_flex()
.gap_2()
.child(
ButtonGroup::new("test-cases")
Button::new("scroll-to0")
.small()
.outline()
.compact()
.child(
Button::new("test-0")
.label("Size 0")
.selected(self.size_mode == 0),
)
.child(
Button::new("test-1")
.label("Size 1")
.selected(self.size_mode == 1),
)
.child(
Button::new("test-2")
.label("Size 2")
.selected(self.size_mode == 2),
)
.child(
Button::new("test-3")
.label("Size 3")
.selected(self.size_mode == 3),
)
.on_click(cx.listener(|view, clicks: &Vec<usize>, _, cx| {
if clicks.contains(&0) {
view.change_test_cases(0, cx)
} else if clicks.contains(&1) {
view.change_test_cases(1, cx)
} else if clicks.contains(&2) {
view.change_test_cases(2, cx)
} else if clicks.contains(&3) {
view.change_test_cases(3, cx)
}
.label("Scroll to Top")
.on_click(cx.listener(|this, _, _, _| {
this.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
})),
)
.child(Divider::vertical().px_2())
.child(
ButtonGroup::new("scrollbars")
Button::new("scroll-to1")
.small()
.outline()
.compact()
.child(
Button::new("test-axis-both")
.label("Both Scrollbar")
.selected(self.axis.is_both()),
)
.child(
Button::new("test-axis-vertical")
.label("Vertical")
.selected(self.axis.is_vertical()),
)
.child(
Button::new("test-axis-horizontal")
.label("Horizontal")
.selected(self.axis.is_horizontal()),
)
.on_click(cx.listener(|view, clicks: &Vec<usize>, _, cx| {
if clicks.contains(&0) {
view.change_axis(ScrollbarAxis::Both, cx)
} else if clicks.contains(&1) {
view.change_axis(ScrollbarAxis::Vertical, cx)
} else if clicks.contains(&2) {
view.change_axis(ScrollbarAxis::Horizontal, cx)
}
.label("Scroll to 50")
.on_click(cx.listener(|this, _, _, _| {
this.scroll_handle.scroll_to_item(50, ScrollStrategy::Top);
})),
)
.child(
Button::new("scroll-to2")
.small()
.outline()
.label("Scroll to 25 (center)")
.on_click(cx.listener(|this, _, _, _| {
this.scroll_handle
.scroll_to_item(25, ScrollStrategy::Center);
})),
)
.child(
Button::new("scroll-to-bottom")
.small()
.outline()
.label("Scroll to Bottom")
.on_click(cx.listener(|this, _, _, _| {
this.scroll_handle.scroll_to_bottom();
})),
),
)
.child(format!("visible_range: {:?}", self.visible_range))
}
}

View file

@ -76,7 +76,7 @@ pub use root::{ContextModal, Root};
pub use styled::*;
pub use time::*;
pub use title_bar::*;
pub use virtual_list::{h_virtual_list, v_virtual_list, VirtualList};
pub use virtual_list::{h_virtual_list, v_virtual_list, VirtualList, VirtualListScrollHandle};
pub use window_border::{window_border, window_paddings, WindowBorder};
pub use icon::*;

View file

@ -7,12 +7,13 @@ use crate::{
popup_menu::PopupMenu,
scroll::{self, ScrollableMask, Scrollbar, ScrollbarState},
v_flex, ActiveTheme, Icon, IconName, Sizable, Size, StyleSized as _, StyledExt,
VirtualListScrollHandle,
};
use gpui::{
actions, canvas, div, prelude::FluentBuilder, px, uniform_list, App, AppContext, Axis, Bounds,
Context, Div, DragMoveEvent, Edges, EventEmitter, FocusHandle, Focusable, InteractiveElement,
IntoElement, KeyBinding, ListSizingBehavior, MouseButton, MouseDownEvent, ParentElement,
Pixels, Point, Render, ScrollHandle, ScrollStrategy, ScrollWheelEvent, SharedString,
Pixels, Point, Render, ScrollStrategy, ScrollWheelEvent, SharedString,
StatefulInteractiveElement as _, Styled, Task, UniformListScrollHandle, Window,
};
@ -103,7 +104,7 @@ pub struct Table<D: TableDelegate> {
pub vertical_scroll_handle: UniformListScrollHandle,
pub vertical_scroll_state: ScrollbarState,
pub horizontal_scroll_handle: ScrollHandle,
pub horizontal_scroll_handle: VirtualListScrollHandle,
pub horizontal_scroll_state: ScrollbarState,
scrollbar_visible: Edges<bool>,
@ -137,7 +138,7 @@ where
focus_handle: cx.focus_handle(),
delegate,
col_groups: Vec::new(),
horizontal_scroll_handle: ScrollHandle::new(),
horizontal_scroll_handle: VirtualListScrollHandle::new(),
vertical_scroll_handle: UniformListScrollHandle::new(),
vertical_scroll_state: ScrollbarState::default(),
horizontal_scroll_state: ScrollbarState::default(),
@ -269,6 +270,17 @@ where
cx.notify();
}
fn fixed_left_cols_count(&self) -> usize {
if !self.col_fixed {
return 0;
}
self.col_groups
.iter()
.filter(|col| col.column.fixed == Some(ColumnFixed::Left))
.count()
}
/// Scroll to the row at the given index.
pub fn scroll_to_row(&mut self, row_ix: usize, cx: &mut Context<Self>) {
self.vertical_scroll_handle
@ -277,11 +289,13 @@ where
}
// Scroll to the column at the given index.
// TODO: Fix scroll to selected col, this was not working after fixed col.
// pub fn scroll_to_col(&mut self, col_ix: usize, window: &mut Window, cx: &mut Context<Self>) {
// self.horizontal_scroll_handle.scroll_to_item(col_ix);
// cx.notify();
// }
pub fn scroll_to_col(&mut self, col_ix: usize, cx: &mut Context<Self>) {
let col_ix = col_ix.saturating_sub(self.fixed_left_cols_count());
self.horizontal_scroll_handle
.scroll_to_item(col_ix, ScrollStrategy::Top);
cx.notify();
}
/// Returns the selected row index.
pub fn selected_row(&self) -> Option<usize> {
@ -310,11 +324,8 @@ where
pub fn set_selected_col(&mut self, col_ix: usize, cx: &mut Context<Self>) {
self.selection_state = SelectionState::Column;
self.selected_col = Some(col_ix);
if let Some(_col_ix) = self.selected_col {
// TODO: Fix scroll to selected col, this was not working after fixed col.
// if self.col_groups[col_ix].fixed.is_none() {
// self.horizontal_scroll_handle.scroll_to_item(col_ix);
// }
if let Some(col_ix) = self.selected_col {
self.scroll_to_col(col_ix, cx);
}
cx.emit(TableEvent::SelectColumn(col_ix));
cx.notify();

View file

@ -10,16 +10,147 @@
//! Unlike the `uniform_list`, the each item can have different size.
//!
//! This is useful for more complex layout, for example, a table with different row height.
use std::{cmp, ops::Range, rc::Rc};
use std::{
cell::RefCell,
cmp,
ops::{Deref, Range},
rc::Rc,
};
use gpui::{
div, point, px, size, Along, AnyElement, App, AvailableSpace, Axis, Bounds, ContentMask,
Context, Div, Element, ElementId, Entity, GlobalElementId, Hitbox, InteractiveElement,
IntoElement, IsZero as _, Pixels, Render, ScrollHandle, Size, Stateful,
Context, Div, Element, ElementId, Entity, GlobalElementId, Half, Hitbox, InteractiveElement,
IntoElement, IsZero as _, Pixels, Point, Render, ScrollHandle, ScrollStrategy, Size, Stateful,
StatefulInteractiveElement, StyleRefinement, Styled, Window,
};
use smallvec::SmallVec;
use crate::{scroll::ScrollHandleOffsetable, AxisExt};
struct VirtualListScrollHandleState {
axis: Axis,
items_bounds: Vec<Bounds<Pixels>>,
}
#[derive(Clone)]
pub struct VirtualListScrollHandle {
state: Rc<RefCell<VirtualListScrollHandleState>>,
base_handle: ScrollHandle,
}
impl From<ScrollHandle> for VirtualListScrollHandle {
fn from(handle: ScrollHandle) -> Self {
let mut this = VirtualListScrollHandle::new();
this.base_handle = handle;
this
}
}
impl AsRef<ScrollHandle> for VirtualListScrollHandle {
fn as_ref(&self) -> &ScrollHandle {
&self.base_handle
}
}
impl ScrollHandleOffsetable for VirtualListScrollHandle {
fn offset(&self) -> Point<Pixels> {
self.base_handle.offset()
}
fn set_offset(&self, offset: Point<Pixels>) {
self.base_handle.set_offset(offset);
}
fn content_size(&self) -> Size<Pixels> {
self.base_handle.content_size()
}
}
impl Deref for VirtualListScrollHandle {
type Target = ScrollHandle;
fn deref(&self) -> &Self::Target {
&self.base_handle
}
}
impl VirtualListScrollHandle {
pub fn new() -> Self {
VirtualListScrollHandle {
state: Rc::new(RefCell::new(VirtualListScrollHandleState {
axis: Axis::Vertical,
items_bounds: vec![],
})),
base_handle: ScrollHandle::default(),
}
}
pub fn base_handle(&self) -> &ScrollHandle {
&self.base_handle
}
fn set_items_bounds(&self, items_bounds: Vec<Bounds<Pixels>>) {
self.state.borrow_mut().items_bounds = items_bounds;
}
fn set_axis(&self, axis: Axis) {
self.state.borrow_mut().axis = axis;
}
/// Scroll to the item at the given index.
pub fn scroll_to_item(&self, ix: usize, strategy: ScrollStrategy) {
let state = self.state.borrow();
let Some(bounds) = state.items_bounds.get(ix) else {
return;
};
let axis = state.axis;
let mut scroll_offset = self.base_handle.offset();
let container_bounds = self.base_handle().bounds();
match strategy {
ScrollStrategy::Center => {
if axis.is_vertical() {
scroll_offset.y = container_bounds.top() + container_bounds.size.height.half()
- bounds.top()
- bounds.size.height.half()
} else {
scroll_offset.x = container_bounds.left() + container_bounds.size.width.half()
- bounds.left()
- bounds.size.width.half()
}
}
_ => {
// Ref: https://github.com/zed-industries/zed/blob/0d145289e0867a8d5d63e5e1397a5ca69c9d49c3/crates/gpui/src/elements/div.rs#L3026
if axis.is_vertical() {
if bounds.top() + scroll_offset.y < container_bounds.top() {
scroll_offset.y = container_bounds.top() - bounds.top();
} else if bounds.bottom() + scroll_offset.y > container_bounds.bottom() {
scroll_offset.y = container_bounds.bottom() - bounds.bottom();
}
} else {
if bounds.left() + scroll_offset.x < container_bounds.left() {
scroll_offset.x = container_bounds.left() - bounds.left();
} else if bounds.right() + scroll_offset.x > container_bounds.right() {
scroll_offset.x = container_bounds.right() - bounds.right();
}
}
}
}
self.base_handle.set_offset(scroll_offset);
}
/// Scrolls to the bottom of the list.
pub fn scroll_to_bottom(&self) {
let state = self.state.borrow();
self.scroll_to_item(
state.items_bounds.len().saturating_sub(1),
ScrollStrategy::Top,
);
}
}
/// Create a [`VirtualList`] in vertical direction.
///
/// This is like `uniform_list` in GPUI, but support two axis.
@ -70,7 +201,7 @@ where
V: Render,
{
let id: ElementId = id.into();
let scroll_handle = ScrollHandle::default();
let scroll_handle = VirtualListScrollHandle::new();
let render_range = move |visible_range, window: &mut Window, cx: &mut App| {
view.update(cx, |this, cx| {
f(this, visible_range, window, cx)
@ -100,7 +231,7 @@ pub struct VirtualList {
id: ElementId,
axis: Axis,
base: Stateful<Div>,
scroll_handle: ScrollHandle,
scroll_handle: VirtualListScrollHandle,
items_count: usize,
item_sizes: Rc<Vec<Size<Pixels>>>,
render_items: Box<
@ -115,7 +246,7 @@ impl Styled for VirtualList {
}
impl VirtualList {
pub fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self {
pub fn track_scroll(mut self, scroll_handle: &VirtualListScrollHandle) -> Self {
self.base = self.base.track_scroll(&scroll_handle);
self.scroll_handle = scroll_handle.clone();
self
@ -124,7 +255,7 @@ impl VirtualList {
/// Specify for table.
///
/// Table is special, because the `scroll_handle` is based on Table head (That is not a virtual list).
pub(crate) fn with_scroll_handle(mut self, scroll_handle: &ScrollHandle) -> Self {
pub(crate) fn with_scroll_handle(mut self, scroll_handle: &VirtualListScrollHandle) -> Self {
self.base = div().id(self.id.clone()).size_full();
self.scroll_handle = scroll_handle.clone();
self
@ -311,6 +442,28 @@ impl Element for VirtualList {
},
};
// Update scroll_handle with the item bounds
let items_bounds = item_origins
.iter()
.enumerate()
.map(|(i, &origin)| {
let item_size = item_sizes[i];
Bounds {
origin: match self.axis {
Axis::Horizontal => point(bounds.left() + origin + padding.left, px(0.)),
Axis::Vertical => point(px(0.), bounds.top() + origin + padding.top),
},
size: match self.axis {
Axis::Horizontal => size(item_size, bounds.size.height),
Axis::Vertical => size(bounds.size.width, item_size),
},
}
})
.collect::<Vec<_>>();
self.scroll_handle.set_axis(self.axis);
self.scroll_handle.set_items_bounds(items_bounds);
self.base.interactivity().prepaint(
global_id,
inspector_id,