Skip to main content

gpui_kit/data/
list.rs

1//! A virtualized list over a caller-owned data set.
2//!
3//! The list never holds the rows. It asks the caller to render one index at a
4//! time, and the caller stamps each row with the business identity that row
5//! already has, so a semantic id never encodes where the row happens to sit.
6//!
7//! # Only rendered rows are published
8//!
9//! Virtualization means the window holds a viewport, not a data set. A row
10//! outside the viewport is not laid out, has no bounds, and publishes no
11//! semantic node, so a test can only assert what is on screen. The list node
12//! itself carries the total in `value`, which is how a test states the honest
13//! version: a thousand items exist, twelve of them are rendered.
14//!
15//! Virtualization needs a bounded viewport. With [`List::visible_rows`] the
16//! list renders only the rows that fit; without it the list sizes itself to
17//! its content and every row is laid out.
18
19use std::cell::RefCell;
20use std::collections::HashMap;
21use std::ops::Range;
22use std::rc::Rc;
23
24use gpui::{
25    AnyElement, App, InteractiveElement, IntoElement, ListSizingBehavior, ParentElement,
26    RenderOnce, ScrollStrategy, SharedString, StatefulInteractiveElement, Styled, Window, div,
27    point, prelude::FluentBuilder, px, uniform_list,
28};
29use gpui_kit_semantics::{NodeSpec, Role, Semantic};
30use gpui_kit_theme::{ActiveTheme, ControlSize, Space, Theme};
31
32use crate::data::viewport::scroll_handle;
33pub use crate::data::viewport::scroll_to_row;
34use crate::foundation::{Disableable, FocusRing, Ident, Pressable, Sizable, StyledExt};
35use crate::interaction::dnd::{
36    self, DragItem, DropAxis, DropIntent, DropPosition, MakingWay, RowTarget, SurfaceDrag,
37};
38
39type SelectHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
40type RenderRow = Rc<dyn Fn(usize, &mut Window, &mut App) -> ListItem>;
41type ReorderHandler = Rc<dyn Fn(&DropIntent, &mut Window, &mut App)>;
42type Accepts = Rc<dyn Fn(&DragItem, &DropPosition) -> bool>;
43
44/// One row, named by the caller.
45pub struct ListItem {
46    id: SharedString,
47    text: Option<SharedString>,
48    disabled: bool,
49    content: AnyElement,
50}
51
52impl std::fmt::Debug for ListItem {
53    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        formatter
55            .debug_struct("ListItem")
56            .field("id", &self.id)
57            .field("disabled", &self.disabled)
58            .finish()
59    }
60}
61
62impl ListItem {
63    /// `id` is the row's business identity, not its index.
64    pub fn new(id: impl Into<SharedString>, content: impl IntoElement) -> Self {
65        Self {
66            id: id.into(),
67            text: None,
68            disabled: false,
69            content: content.into_any_element(),
70        }
71    }
72
73    /// The name the row publishes, for a test or a screen reader that has only
74    /// the tree to go on.
75    pub fn text(mut self, text: impl Into<SharedString>) -> Self {
76        self.text = Some(text.into());
77        self
78    }
79
80    pub fn disabled(mut self, disabled: bool) -> Self {
81        self.disabled = disabled;
82        self
83    }
84}
85
86/// A list that renders only the rows its viewport holds.
87#[derive(IntoElement)]
88pub struct List {
89    ident: Ident,
90    count: usize,
91    render_row: RenderRow,
92    selected: Option<SharedString>,
93    row_height: Option<f32>,
94    visible_rows: Option<usize>,
95    size: ControlSize,
96    disabled: bool,
97    on_select: Option<SelectHandler>,
98    reorderable: bool,
99    accepts: Option<Accepts>,
100    on_reorder: Option<ReorderHandler>,
101}
102
103impl std::fmt::Debug for List {
104    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        formatter
106            .debug_struct("List")
107            .field("ident", &self.ident)
108            .field("count", &self.count)
109            .field("selected", &self.selected)
110            .field("visible_rows", &self.visible_rows)
111            .field("disabled", &self.disabled)
112            .field("has_handler", &self.on_select.is_some())
113            .finish()
114    }
115}
116
117impl List {
118    pub fn new(
119        ident: impl Into<Ident>,
120        count: usize,
121        render_row: impl Fn(usize, &mut Window, &mut App) -> ListItem + 'static,
122    ) -> Self {
123        Self {
124            ident: ident.into(),
125            count,
126            render_row: Rc::new(render_row),
127            selected: None,
128            row_height: None,
129            visible_rows: None,
130            size: ControlSize::Md,
131            disabled: false,
132            on_select: None,
133            reorderable: false,
134            accepts: None,
135            on_reorder: None,
136        }
137    }
138
139    pub fn selected(mut self, id: impl Into<SharedString>) -> Self {
140        self.selected = Some(id.into());
141        self
142    }
143
144    /// Every row is this tall. Uniform height is what makes the list cheap;
145    /// the default comes from the control scale for the list's size.
146    pub fn row_height(mut self, height: f32) -> Self {
147        self.row_height = Some(height);
148        self
149    }
150
151    /// Bounds the viewport to `rows` rows, which is what lets the list skip
152    /// the rows it does not show.
153    pub fn visible_rows(mut self, rows: usize) -> Self {
154        self.visible_rows = Some(rows);
155        self
156    }
157
158    pub fn on_select(
159        mut self,
160        handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
161    ) -> Self {
162        self.on_select = Some(Rc::new(handler));
163        self
164    }
165
166    /// Lets a row be picked up and put down somewhere else in the list.
167    ///
168    /// The whole row is the handle. A list row's ordinary action is a click,
169    /// and GPUI only calls a press a drag once it has travelled two pixels, so
170    /// both fit on the same row without a grip column the caller would have to
171    /// render into every row it owns.
172    pub fn reorderable(mut self, reorderable: bool) -> Self {
173        self.reorderable = reorderable;
174        self
175    }
176
177    /// Whether this list takes a payload, and where.
178    ///
179    /// Without one, a reorderable list takes its own rows and nothing else.
180    /// Anything wider than that is policy, and policy is the caller's.
181    pub fn accepts(
182        mut self,
183        predicate: impl Fn(&DragItem, &DropPosition) -> bool + 'static,
184    ) -> Self {
185        self.accepts = Some(Rc::new(predicate));
186        self
187    }
188
189    /// Reports where a dropped row should go. The list does not move it.
190    pub fn on_reorder(
191        mut self,
192        handler: impl Fn(&DropIntent, &mut Window, &mut App) + 'static,
193    ) -> Self {
194        self.on_reorder = Some(Rc::new(handler));
195        self
196    }
197}
198
199impl Disableable for List {
200    fn disabled(mut self, disabled: bool) -> Self {
201        self.disabled = disabled;
202        self
203    }
204}
205
206impl Sizable for List {
207    fn control_size(mut self, size: ControlSize) -> Self {
208        self.size = size;
209        self
210    }
211}
212
213impl RenderOnce for List {
214    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
215        let theme = cx.theme().clone();
216        let metrics = theme.control.get(self.size);
217        let row_height = self.row_height.unwrap_or(metrics.height);
218        let ident = self.ident.clone();
219        let count = self.count;
220        let handler = self
221            .on_select
222            .clone()
223            .filter(|_| !self.disabled)
224            .filter(|_| count > 0);
225        let render_row = Rc::clone(&self.render_row);
226        let scroll = scroll_handle(&ident, cx);
227        let reorder = self.reorder(window, cx);
228
229        // Which index published which id on the last frame. The rows fill it
230        // during prepaint, so the keyboard handler, which runs later, can name
231        // the row it is moving away from without consulting the data set.
232        let rendered: Rendered = Rc::new(RefCell::new(HashMap::new()));
233
234        let rows = {
235            let ident = ident.clone();
236            let theme = theme.clone();
237            let selected = self.selected.clone();
238            let handler = handler.clone();
239            let render_row = Rc::clone(&render_row);
240            let rendered = Rc::clone(&rendered);
241            let reorder = reorder.clone();
242            uniform_list(
243                ident.child("rows").element_id(),
244                count,
245                move |range: Range<usize>, window, cx| {
246                    rendered
247                        .borrow_mut()
248                        .retain(|index, _| range.contains(index));
249                    range
250                        .map(|index| {
251                            let item = render_row(index, window, cx);
252                            rendered.borrow_mut().insert(index, item.id.clone());
253                            row_element(
254                                &ident,
255                                &theme,
256                                row_height,
257                                item,
258                                index,
259                                selected.as_ref(),
260                                handler.as_ref(),
261                                reorder.as_ref(),
262                                window,
263                                cx,
264                            )
265                        })
266                        .collect::<Vec<_>>()
267                },
268            )
269        }
270        .track_scroll(&scroll)
271        .w_full()
272        .with_sizing_behavior(if self.visible_rows.is_some() {
273            ListSizingBehavior::Auto
274        } else {
275            ListSizingBehavior::Infer
276        })
277        .when_some(self.visible_rows, |element, rows| {
278            element.h(px(row_height * rows as f32))
279        });
280
281        let mut container = div().id(ident.element_id()).column().w_full().child(rows);
282
283        // A drag that reaches the edge of the viewport has run out of list to
284        // aim at, so the list brings the next row to the pointer rather than
285        // asking the pointer to go somewhere it cannot.
286        if reorder.is_some() {
287            let rendered = Rc::clone(&rendered);
288            let scroll = scroll.clone();
289            container = container.on_drag_move::<DragItem>(move |event, window, _| {
290                let pointer = event.event.position;
291                if !event.bounds.contains(&pointer) {
292                    return;
293                }
294                let band = px(row_height);
295                let rendered = rendered.borrow();
296                let next = if pointer.y < event.bounds.top() + band {
297                    rendered.keys().min().and_then(|first| first.checked_sub(1))
298                } else if pointer.y > event.bounds.bottom() - band {
299                    rendered
300                        .keys()
301                        .max()
302                        .map(|last| last + 1)
303                        .filter(|next| *next < count)
304                } else {
305                    None
306                };
307                let Some(next) = next else {
308                    return;
309                };
310                scroll.scroll_to_item(next, ScrollStrategy::Nearest);
311                window.refresh();
312            });
313        }
314
315        if let Some(handler) = handler {
316            let selected = self.selected.clone();
317            let rendered = Rc::clone(&rendered);
318            container = container.on_key_down(move |event, window, cx| {
319                let from = current_index(&rendered, selected.as_ref());
320                let Some(target) = target_index(event.keystroke.key.as_str(), from, count) else {
321                    return;
322                };
323                let delta = if from.is_some_and(|from| target < from) {
324                    -1
325                } else {
326                    1
327                };
328                let Some((index, id)) = selectable(&render_row, target, delta, count, window, cx)
329                else {
330                    return;
331                };
332                // The list scrolls to what it reported, so the row the caller
333                // is being told about is one the typist can see. Scrolling is
334                // the list's own state, so it asks for the frame that applies
335                // it rather than waiting for the caller to notice.
336                scroll.scroll_to_item(index, ScrollStrategy::Nearest);
337                window.refresh();
338                if Some(&id) == selected.as_ref() {
339                    return;
340                }
341                handler(id, window, cx);
342                cx.stop_propagation();
343            });
344        }
345
346        container.semantic_in(
347            cx,
348            NodeSpec::new(ident.semantic_id(), Role::List).value(count.to_string()),
349        )
350    }
351}
352
353type Rendered = Rc<RefCell<HashMap<usize, SharedString>>>;
354
355/// What a row needs to take part in a reorder.
356#[derive(Clone)]
357struct Reorder {
358    surface: SharedString,
359    drag: Option<SurfaceDrag>,
360    accepts: Accepts,
361    on_drop: ReorderHandler,
362}
363
364impl List {
365    fn reorder(&self, window: &mut Window, cx: &mut App) -> Option<Reorder> {
366        if self.disabled || !self.reorderable {
367            return None;
368        }
369        let on_drop = self.on_reorder.clone()?;
370        let surface = self.ident.semantic_id();
371        let accepts = self.accepts.clone().unwrap_or_else(|| {
372            let own = surface.clone();
373            Rc::new(move |item: &DragItem, _: &DropPosition| item.source == own)
374        });
375        Some(Reorder {
376            drag: dnd::surface_drag(&surface, window, cx),
377            surface,
378            accepts,
379            on_drop,
380        })
381    }
382}
383
384#[allow(clippy::too_many_arguments)]
385fn row_element(
386    list: &Ident,
387    theme: &Theme,
388    height: f32,
389    item: ListItem,
390    index: usize,
391    selected: Option<&SharedString>,
392    handler: Option<&SelectHandler>,
393    reorder: Option<&Reorder>,
394    window: &mut Window,
395    cx: &mut App,
396) -> AnyElement {
397    let ident = list.child(item.id.as_ref());
398    let selected = selected == Some(&item.id);
399    let actionable = !item.disabled && handler.is_some();
400    let draggable = reorder.filter(|_| !item.disabled);
401    let drag = draggable.and_then(|reorder| reorder.drag.as_ref());
402    let carried = drag.is_some_and(|drag| drag.carries(&item.id));
403    let landing = drag.and_then(|drag| drag.indicator_for(&item.id));
404    let label = item.text.clone().unwrap_or_else(|| item.id.clone());
405
406    let mut row = div()
407        .id(ident.element_id())
408        .row()
409        .w_full()
410        .h(px(height))
411        .px(px(theme.space(Space::Sm)))
412        .gap(px(theme.space(Space::Sm)))
413        .when(selected, |element| element.bg(theme.colors.selected))
414        .when(item.disabled, |element| {
415            element.opacity(theme.opacity.disabled)
416        })
417        // The row the pointer is carrying stays where the data still says it
418        // is, and says so by receding rather than by leaving a hole.
419        .when(carried, |element| element.opacity(theme.opacity.muted))
420        .when(actionable, |element| {
421            element
422                .cursor_pointer()
423                .tab_index(0)
424                .pressable(cx)
425                .when(!selected, |element| {
426                    element.hover(|style| style.bg(theme.colors.hover.opacity(0.3)))
427                })
428                .focus_ring(theme)
429        })
430        .child(div().flex_1().overflow_hidden().child(item.content))
431        .children(landing.map(|(position, accepted)| {
432            dnd::indicator(&position, accepted, DropAxis::Vertical, cx)
433        }));
434
435    if let (true, Some(handler)) = (actionable, handler) {
436        let id = item.id.clone();
437        let handler = Rc::clone(handler);
438        row = row.on_click(move |_, window, cx| handler(id.clone(), window, cx));
439    }
440
441    if let Some(reorder) = draggable {
442        row = dnd::draggable(
443            row,
444            DragItem::new(reorder.surface.clone(), item.id.clone(), label),
445        );
446        row = dnd::drop_target(
447            row,
448            RowTarget {
449                surface: reorder.surface.clone(),
450                id: item.id.clone(),
451                index,
452                allow_into: false,
453                axis: DropAxis::Vertical,
454                accepts: Rc::clone(&reorder.accepts),
455                on_drop: Rc::clone(&reorder.on_drop),
456            },
457        );
458    }
459
460    let mut spec = NodeSpec::new(ident.semantic_id(), Role::Row)
461        .parent(list.semantic_id())
462        .selected(selected)
463        .disabled(item.disabled);
464    if let Some(text) = item.text {
465        spec = spec.text(text);
466    }
467    let row = row.semantic_in(cx, spec);
468
469    match draggable {
470        Some(reorder) => {
471            let shift = reorder
472                .drag
473                .as_ref()
474                .filter(|drag| drag.makes_way(index))
475                .map_or(px(0.0), |_| dnd::make_way_gap(cx, DropAxis::Vertical));
476            row.make_way(ident.semantic_id(), point(px(0.0), shift), window, cx)
477                .into_any_element()
478        }
479        None => row.into_any_element(),
480    }
481}
482
483/// Where the reported selection sits among the rows that were rendered.
484///
485/// A selection scrolled out of the viewport has no known index, so a move
486/// starts from the top of what is visible rather than from nowhere.
487fn current_index(rendered: &Rendered, selected: Option<&SharedString>) -> Option<usize> {
488    let rendered = rendered.borrow();
489    selected
490        .and_then(|id| {
491            rendered
492                .iter()
493                .find(|(_, row)| *row == id)
494                .map(|(index, _)| *index)
495        })
496        .or_else(|| rendered.keys().min().copied())
497}
498
499fn target_index(key: &str, from: Option<usize>, count: usize) -> Option<usize> {
500    match key {
501        "up" => from?.checked_sub(1),
502        "down" => match from {
503            Some(from) => Some(from + 1).filter(|next| *next < count),
504            None => Some(0),
505        },
506        "home" => Some(0),
507        "end" => count.checked_sub(1),
508        _ => None,
509    }
510}
511
512/// The first row from `target` in `delta`'s direction that accepts selection.
513///
514/// Naming a row that was never rendered means asking the caller to build it,
515/// which is the only way a list that does not hold the data can report a row
516/// the typist cannot yet see.
517fn selectable(
518    render_row: &RenderRow,
519    target: usize,
520    delta: isize,
521    count: usize,
522    window: &mut Window,
523    cx: &mut App,
524) -> Option<(usize, SharedString)> {
525    let mut index = target as isize;
526    while index >= 0 && (index as usize) < count {
527        let item = render_row(index as usize, window, cx);
528        if !item.disabled {
529            return Some((index as usize, item.id));
530        }
531        index += delta;
532    }
533    None
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    #[test]
541    fn a_move_stops_at_the_ends_instead_of_wrapping() {
542        assert_eq!(target_index("up", Some(0), 10), None);
543        assert_eq!(target_index("down", Some(9), 10), None);
544        assert_eq!(target_index("down", Some(3), 10), Some(4));
545        assert_eq!(target_index("home", Some(3), 10), Some(0));
546        assert_eq!(target_index("end", Some(3), 10), Some(9));
547    }
548
549    #[test]
550    fn a_move_without_a_selection_enters_at_the_top() {
551        assert_eq!(target_index("down", None, 10), Some(0));
552        assert_eq!(target_index("end", None, 0), None);
553    }
554}