Skip to main content

liora_components/
virtualized_list.rs

1use crate::draggable::{DragAxis, DragState, drag_handle, reorder_indices};
2use gpui::{
3    AnyElement, App, Bounds, Context, Entity, IntoElement, ListAlignment, ListState, MouseButton,
4    MouseMoveEvent, Pixels, Point, Render, Size, Window, deferred, div, list, prelude::*, px,
5};
6use std::cell::RefCell;
7use std::rc::Rc;
8use std::sync::Arc;
9
10type RenderItem = dyn Fn(usize, &mut Window, &mut App) -> AnyElement + 'static;
11type ReorderCallback = dyn Fn(usize, usize, &mut Window, &mut App) + 'static;
12
13/// A native virtualized vertical list for large or expensive item trees.
14///
15/// The component owns GPUI's [`ListState`] and renders only the visible item
16/// range plus a configurable overdraw area. Pair it with [`crate::VirtualScrollbar`]
17/// when a custom Liora scrollbar is needed.
18pub struct VirtualizedList {
19    item_count: usize,
20    list_state: ListState,
21    render_item: Arc<RenderItem>,
22    overdraw: Pixels,
23    item_spacing: Pixels,
24    height: Option<Pixels>,
25    measure_all_items: bool,
26    order: Vec<usize>,
27    draggable: bool,
28    drag_state: DragState,
29    item_bounds: Rc<RefCell<Vec<Option<Bounds<Pixels>>>>>,
30    drag_reference_bounds: Vec<Option<Bounds<Pixels>>>,
31    on_reorder: Option<Arc<ReorderCallback>>,
32}
33
34impl VirtualizedList {
35    pub fn new(
36        item_count: usize,
37        _cx: &mut Context<Self>,
38        render_item: impl Fn(usize, &mut Window, &mut App) -> AnyElement + 'static,
39    ) -> Self {
40        let overdraw = px(640.0);
41        Self {
42            item_count,
43            list_state: ListState::new(item_count, ListAlignment::Top, overdraw),
44            render_item: Arc::new(render_item),
45            overdraw,
46            item_spacing: px(0.0),
47            height: None,
48            measure_all_items: false,
49            order: (0..item_count).collect(),
50            draggable: false,
51            drag_state: DragState::default(),
52            item_bounds: Rc::new(RefCell::new(vec![None; item_count])),
53            drag_reference_bounds: vec![None; item_count],
54            on_reorder: None,
55        }
56    }
57
58    pub fn entity(
59        item_count: usize,
60        cx: &mut App,
61        render_item: impl Fn(usize, &mut Window, &mut App) -> AnyElement + 'static,
62    ) -> Entity<Self> {
63        cx.new(|cx| Self::new(item_count, cx, render_item))
64    }
65
66    pub fn list_state(&self) -> ListState {
67        self.list_state.clone()
68    }
69
70    pub fn set_item_count(&mut self, item_count: usize) {
71        if self.item_count == item_count {
72            return;
73        }
74        self.item_count = item_count;
75        self.order = (0..item_count).collect();
76        self.drag_state.cancel();
77        *self.item_bounds.borrow_mut() = vec![None; item_count];
78        self.drag_reference_bounds = vec![None; item_count];
79        self.list_state = Self::new_list_state(item_count, self.overdraw, self.measure_all_items);
80    }
81
82    pub fn set_render_item(
83        &mut self,
84        render_item: impl Fn(usize, &mut Window, &mut App) -> AnyElement + 'static,
85    ) {
86        self.render_item = Arc::new(render_item);
87    }
88
89    pub fn set_item_spacing(&mut self, spacing: impl Into<Pixels>) {
90        let spacing = spacing.into();
91        if self.item_spacing == spacing {
92            return;
93        }
94        self.item_spacing = spacing;
95        self.list_state.reset(self.item_count);
96    }
97
98    pub fn set_overdraw(&mut self, overdraw: impl Into<Pixels>) {
99        let overdraw = overdraw.into();
100        if self.overdraw == overdraw {
101            return;
102        }
103        self.overdraw = overdraw;
104        self.list_state = Self::new_list_state(self.item_count, overdraw, self.measure_all_items);
105    }
106
107    pub fn set_height(&mut self, height: Option<Pixels>) {
108        if self.height == height {
109            return;
110        }
111        self.height = height;
112        self.list_state.reset(self.item_count);
113    }
114
115    pub fn set_draggable(&mut self, draggable: bool) {
116        self.draggable = draggable;
117        if !draggable {
118            self.drag_state.cancel();
119            self.drag_reference_bounds.clear();
120        }
121    }
122
123    pub fn set_on_reorder(
124        &mut self,
125        callback: impl Fn(usize, usize, &mut Window, &mut App) + 'static,
126    ) {
127        self.on_reorder = Some(Arc::new(callback));
128    }
129
130    pub fn order(&self) -> &[usize] {
131        &self.order
132    }
133
134    fn start_drag(&mut self, index: usize, position: gpui::Point<Pixels>, cx: &mut Context<Self>) {
135        if !self.draggable {
136            return;
137        }
138        let bounds = self.item_bounds.borrow().get(index).copied().flatten();
139        self.drag_reference_bounds = self.item_bounds.borrow().clone();
140        self.drag_state.start_at(index, position, bounds);
141        cx.notify();
142    }
143
144    fn update_drag_target_from_position(
145        &mut self,
146        position: Point<Pixels>,
147        cx: &mut Context<Self>,
148    ) {
149        let Some(active) = self.drag_state.active_index() else {
150            return;
151        };
152        if self.drag_reference_bounds.is_empty() {
153            return;
154        }
155
156        let mut target = active.min(self.drag_reference_bounds.len().saturating_sub(1));
157        let mut nearest_distance = Pixels::MAX;
158        for (index, item_bounds) in self.drag_reference_bounds.iter().enumerate() {
159            let Some(item_bounds) = item_bounds else {
160                continue;
161            };
162            if item_bounds.contains(&position) {
163                target = index;
164                break;
165            }
166
167            let distance = (position.y - item_bounds.center().y).abs();
168            if distance < nearest_distance {
169                nearest_distance = distance;
170                target = index;
171            }
172        }
173
174        if self.drag_state.over_index() != Some(target) {
175            self.drag_state.set_over(target);
176            self.list_state.reset(self.item_count);
177            cx.notify();
178        }
179    }
180
181    fn hover_drag(
182        &mut self,
183        index: usize,
184        event: &MouseMoveEvent,
185        _window: &mut Window,
186        cx: &mut Context<Self>,
187    ) {
188        self.drag_state.update_position(event.position);
189        let Some(active) = self.drag_state.active_index() else {
190            return;
191        };
192        if event.pressed_button != Some(MouseButton::Left) {
193            return;
194        }
195        if index >= self.order.len() || index == active {
196            return;
197        }
198        self.update_drag_target_from_position(event.position, cx);
199    }
200
201    fn update_drag_position(
202        &mut self,
203        event: &MouseMoveEvent,
204        window: &mut Window,
205        cx: &mut Context<Self>,
206    ) {
207        if self.drag_state.active_index().is_none() {
208            return;
209        }
210        if event.pressed_button != Some(MouseButton::Left) {
211            self.finish_drag(0, window, cx);
212            return;
213        }
214        self.drag_state.update_position(event.position);
215        self.update_drag_target_from_position(event.position, cx);
216        cx.notify();
217    }
218
219    fn finish_drag(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
220        let Some((from, to)) = self.drag_state.finish() else {
221            return;
222        };
223        self.drag_reference_bounds.clear();
224        if from != to {
225            if reorder_indices(&mut self.order, from, to) {
226                self.list_state.reset(self.item_count);
227            }
228            if let Some(callback) = self.on_reorder.clone() {
229                callback(from, to, window, cx);
230            }
231        }
232        let _ = index;
233        cx.notify();
234    }
235
236    /// Measure every item once so GPUI's scrollbar math has a stable total height.
237    ///
238    /// GPUI's virtual list reports scrollbar extents from measured rows only; for
239    /// long variable-height documents this can otherwise make the thumb jump or
240    /// reach the ends before the content does. Use this when scrollbar accuracy is
241    /// more important than the first-frame cost of measuring every row.
242    pub fn measure_all_items_for_scrollbar(&mut self) {
243        if self.measure_all_items {
244            return;
245        }
246        self.measure_all_items = true;
247        self.list_state = self.list_state.clone().measure_all();
248    }
249
250    fn new_list_state(item_count: usize, overdraw: Pixels, measure_all_items: bool) -> ListState {
251        let state = ListState::new(item_count, ListAlignment::Top, overdraw);
252        if measure_all_items {
253            state.measure_all()
254        } else {
255            state
256        }
257    }
258
259    /// Mark every item for remeasurement while preserving proportional scroll.
260    ///
261    /// Updating the render closure alone does not remeasure automatically, so
262    /// callers that know item heights changed can opt into the heavier work.
263    pub fn remeasure(&self) {
264        self.list_state.reset(self.item_count);
265    }
266
267    /// Mark one item range for remeasurement while preserving proportional scroll.
268    pub fn remeasure_items(&self, range: std::ops::Range<usize>) {
269        self.list_state.splice(range.clone(), range.len());
270    }
271}
272
273impl Render for VirtualizedList {
274    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
275        let render_item = self.render_item.clone();
276        let spacing = self.item_spacing;
277        let mut display_order = self.order.clone();
278        let draggable = self.draggable;
279        let drag_state = self.drag_state.clone();
280        let drag_active = drag_state.active_index().is_some();
281        let active_item = drag_state
282            .origin_index()
283            .and_then(|index| self.order.get(index).copied());
284        if let (Some(active), Some(over)) = (drag_state.active_index(), drag_state.over_index()) {
285            reorder_indices(&mut display_order, active, over);
286        }
287        let drag_reference_bounds = self.drag_reference_bounds.clone();
288        let active_size = drag_state
289            .origin_index()
290            .and_then(|index| drag_reference_bounds.get(index).copied().flatten())
291            .map(|bounds| bounds.size);
292        let item_bounds_store = self.item_bounds.clone();
293        let entity = cx.entity().clone();
294
295        div()
296            .relative()
297            .size_full()
298            .when_some(self.height, |el, height| el.h(height))
299            .when(draggable, |el| {
300                let move_entity = entity.clone();
301                let up_entity = entity.clone();
302                let out_entity = entity.clone();
303                el.on_mouse_move(move |event, window, cx| {
304                    move_entity.update(cx, |list, cx| list.update_drag_position(event, window, cx));
305                })
306                .on_mouse_up(MouseButton::Left, move |_, window, cx| {
307                    up_entity.update(cx, |list, cx| list.finish_drag(0, window, cx));
308                })
309                .on_mouse_up_out(MouseButton::Left, move |_, window, cx| {
310                    out_entity.update(cx, |list, cx| list.finish_drag(0, window, cx));
311                })
312            })
313            .child(
314                list(self.list_state.clone(), move |index, window, cx| {
315                    let item_index = display_order.get(index).copied().unwrap_or(index);
316                    let item = (render_item)(item_index, window, cx);
317                    let is_dragging = active_item == Some(item_index);
318                    let is_over = drag_state.is_over(index);
319                    let item_entity = entity.clone();
320                    let move_entity = entity.clone();
321                    let up_entity = entity.clone();
322                    let out_entity = entity.clone();
323                    let mut shell = div()
324                        .flex()
325                        .flex_row()
326                        .items_center()
327                        .rounded_md()
328                        .border_1()
329                        .border_color(if is_dragging {
330                            gpui::rgb(0xcbd5e1).into()
331                        } else if is_over {
332                            gpui::blue()
333                        } else {
334                            gpui::transparent_black()
335                        })
336                        .opacity(1.0)
337                        .when(is_dragging, |s| s.shadow_lg());
338                    if draggable && !is_dragging {
339                        let up_entity = up_entity.clone();
340                        let out_entity = out_entity.clone();
341                        shell = shell
342                            .on_mouse_move(move |event, window, cx| {
343                                move_entity.update(cx, |list, cx| {
344                                    list.hover_drag(index, event, window, cx)
345                                });
346                            })
347                            .on_mouse_up(MouseButton::Left, move |_, window, cx| {
348                                up_entity
349                                    .update(cx, |list, cx| list.finish_drag(index, window, cx));
350                                cx.stop_propagation();
351                            })
352                            .on_mouse_up_out(MouseButton::Left, move |_, window, cx| {
353                                out_entity
354                                    .update(cx, |list, cx| list.finish_drag(index, window, cx));
355                            })
356                            .child(
357                                drag_handle(gpui::rgb(0x94a3b8).into(), false, px(32.0))
358                                    .on_mouse_down(MouseButton::Left, move |event, _, cx| {
359                                        item_entity.update(cx, |list, cx| {
360                                            list.start_drag(index, event.position, cx)
361                                        });
362                                        cx.stop_propagation();
363                                    }),
364                            )
365                            .child(item);
366                    } else if draggable {
367                        shell = shell
368                            .child(drag_handle(gpui::rgb(0x94a3b8).into(), true, px(32.0)))
369                            .child(item);
370                    } else {
371                        shell = shell.child(item);
372                    }
373
374                    let row_content = if is_dragging {
375                        let up_entity = up_entity.clone();
376                        let out_entity = out_entity.clone();
377                        let (drag_dx, drag_dy) = drag_state.offset_from_bounds(
378                            DragAxis::Vertical,
379                            drag_reference_bounds.get(index).copied().flatten(),
380                        );
381                        drag_placeholder(active_size)
382                            .child(
383                                deferred(
384                                    shell
385                                        .absolute()
386                                        .left(drag_dx)
387                                        .top(drag_dy)
388                                        .on_mouse_up(MouseButton::Left, move |_, window, cx| {
389                                            up_entity.update(cx, |list, cx| {
390                                                list.finish_drag(index, window, cx)
391                                            });
392                                            cx.stop_propagation();
393                                        })
394                                        .on_mouse_up_out(
395                                            MouseButton::Left,
396                                            move |_, window, cx| {
397                                                out_entity.update(cx, |list, cx| {
398                                                    list.finish_drag(index, window, cx)
399                                                });
400                                            },
401                                        ),
402                                )
403                                .with_priority(1000),
404                            )
405                            .into_any_element()
406                    } else {
407                        shell.into_any_element()
408                    };
409
410                    let bounds_store = item_bounds_store.clone();
411                    let row = div()
412                        .child(row_content)
413                        .on_children_prepainted(move |bounds, _, _| {
414                            if drag_active {
415                                return;
416                            }
417                            let Some(bounds) = bounds.into_iter().next() else {
418                                return;
419                            };
420                            let mut item_bounds = bounds_store.borrow_mut();
421                            if item_bounds.len() <= index {
422                                item_bounds.resize(index + 1, None);
423                            }
424                            item_bounds[index] = Some(bounds);
425                        })
426                        .into_any_element();
427
428                    if spacing > px(0.0) {
429                        div().pb(spacing).child(row).into_any_element()
430                    } else {
431                        row
432                    }
433                })
434                .size_full(),
435            )
436            .child(crate::VirtualScrollbar::new(self.list_state.clone()))
437    }
438}
439
440fn drag_placeholder(size: Option<Size<Pixels>>) -> gpui::Div {
441    div()
442        .relative()
443        .flex_none()
444        .when_some(size, |s, size| s.w(size.width).h(size.height))
445        .rounded_md()
446        .border_1()
447        .border_color(gpui::rgb(0xcbd5e1))
448        .bg(gpui::transparent_black())
449}
450
451#[cfg(test)]
452mod tests {
453    #[test]
454    fn virtualized_list_owns_list_state_and_uses_liora_scrollbar() {
455        let source = include_str!("virtualized_list.rs");
456
457        assert!(source.contains("pub struct VirtualizedList"));
458        assert!(source.contains("ListState::new"));
459        assert!(source.contains("list(self.list_state.clone()"));
460        assert!(source.contains("VirtualScrollbar::new"));
461        assert!(source.contains("set_item_spacing"));
462        assert!(source.contains("set_render_item"));
463        assert!(source.contains("measure_all_items_for_scrollbar"));
464        assert!(source.contains("set_draggable"));
465        assert!(source.contains("set_on_reorder"));
466        assert!(source.contains("drag_handle"));
467        assert!(source.contains("DragState"));
468        assert!(source.contains("display_order"));
469        assert!(source.contains("drag_placeholder"));
470        assert!(source.contains("on_children_prepainted"));
471        assert!(source.contains("drag_reference_bounds"));
472    }
473
474    #[test]
475    fn virtualized_list_resets_state_when_count_or_overdraw_changes() {
476        let source = include_str!("virtualized_list.rs");
477
478        assert!(source.contains("set_item_count"));
479        assert!(source.contains("set_overdraw"));
480        assert!(source.contains("Self::new_list_state"));
481        assert!(source.contains("pub fn remeasure(&self)"));
482        assert!(source.contains("pub fn remeasure_items"));
483    }
484}