Skip to main content

guise/data/
dataview.rs

1//! `DataView` — a collection-bound list/grid (gpui entity).
2//!
3//! The collection-binding counterpart to [`crate::reactive::Binding`]: the view
4//! observes a `Signal<Vec<T>>` and repaints whenever the collection changes —
5//! no manual wiring. Filtering and sorting are *projections* applied at render
6//! time over the borrowed data (NSArrayController-style): the source vector is
7//! never copied or reordered, the view just renders a filtered + sorted list of
8//! indices into it.
9//!
10//! ```ignore
11//! let todos = use_state(cx, vec!["Write docs".to_string(), "Ship".to_string()]);
12//! let view = cx.new(|cx| {
13//!     DataView::new(cx, &todos)
14//!         .item(|todo, _ix, _window, _cx| {
15//!             Text::new(todo.clone()).into_any_element()
16//!         })
17//!         .sort_by(|a, b| a.cmp(b))
18//!         .selectable()
19//! });
20//! cx.subscribe(&view, |_, _, DataViewEvent::Selected(ix), _| {
21//!     println!("picked source row {ix}");
22//! })
23//! .detach();
24//!
25//! // Anywhere, later: the view repaints by itself.
26//! todos.update(cx, |list| list.push("Celebrate".into()));
27//! ```
28
29use std::cmp::Ordering;
30use std::ops::Range;
31
32use gpui::prelude::*;
33use gpui::{
34    div, px, uniform_list, AnyElement, App, Context, EventEmitter, IntoElement, SharedString,
35    Window,
36};
37
38use super::Content;
39use crate::devtools::ProbedAny;
40use crate::reactive::Signal;
41use crate::style::{surface, Variant};
42use crate::theme::{theme, Size};
43
44/// Emitted when a selectable item is clicked. Carries the item's index into
45/// the **source** vector (not its display position), so it stays valid under
46/// any filter/sort projection.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum DataViewEvent {
49    Selected(usize),
50}
51
52/// How the items flow.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54pub enum DataViewLayout {
55    /// A vertical list (the default).
56    #[default]
57    List,
58    /// Rows of `n` equal-width cells.
59    Grid(usize),
60}
61
62type ItemBuilder<T> = Box<dyn Fn(&T, usize, &mut Window, &mut App) -> AnyElement + 'static>;
63type FilterFn<T> = Box<dyn Fn(&T) -> bool + 'static>;
64type SortFn<T> = Box<dyn Fn(&T, &T) -> Ordering + 'static>;
65type FilterRef<'a, T> = Option<&'a dyn Fn(&T) -> bool>;
66type SortRef<'a, T> = Option<&'a dyn Fn(&T, &T) -> Ordering>;
67
68/// A signal-bound collection view. Create with
69/// `cx.new(|cx| DataView::new(cx, &signal).item(...))`.
70pub struct DataView<T: 'static> {
71    source: Signal<Vec<T>>,
72    item: Option<ItemBuilder<T>>,
73    filter: Option<FilterFn<T>>,
74    sort: Option<SortFn<T>>,
75    layout: DataViewLayout,
76    gap: Size,
77    empty: Option<Content>,
78    selectable: bool,
79    selected: Option<usize>,
80    height: Option<f32>,
81}
82
83impl<T: 'static> EventEmitter<DataViewEvent> for DataView<T> {}
84
85impl<T: 'static> DataView<T> {
86    /// Bind the view to a collection signal. Every `set`/`update` on the
87    /// signal repaints the view.
88    pub fn new(cx: &mut Context<Self>, source: &Signal<Vec<T>>) -> Self {
89        cx.observe(source.entity(), |this, source, cx| {
90            // Drop a selection whose item fell off the end: keeping the stale
91            // index would hand callers an out-of-range value and silently
92            // re-select whatever item lands there after the source regrows.
93            let len = source.read(cx).len();
94            if this.selected.is_some_and(|i| i >= len) {
95                this.selected = None;
96            }
97            cx.notify();
98        })
99        .detach();
100        DataView {
101            source: source.clone(),
102            item: None,
103            filter: None,
104            sort: None,
105            layout: DataViewLayout::List,
106            gap: Size::Sm,
107            empty: None,
108            selectable: false,
109            selected: None,
110            height: None,
111        }
112    }
113
114    /// Fix the view height (px) and virtualize: only the items in view are
115    /// built each frame. Items (or grid rows) must share one height. Applies
116    /// to both layouts — a `Grid(n)` virtualizes whole rows of `n` cells.
117    pub fn height(mut self, height: f32) -> Self {
118        self.height = Some(height.max(0.0));
119        self
120    }
121
122    /// The item template, re-invoked every frame with the borrowed item and
123    /// its source index — items always show live data.
124    pub fn item<E>(
125        mut self,
126        template: impl Fn(&T, usize, &mut Window, &mut App) -> E + 'static,
127    ) -> Self
128    where
129        E: IntoElement,
130    {
131        self.item = Some(Box::new(move |item, ix, window, cx| {
132            template(item, ix, window, cx).into_any_element()
133        }));
134        self
135    }
136
137    /// Show only the items matching `pred`. A projection: the source vector
138    /// is untouched.
139    pub fn filter(mut self, pred: impl Fn(&T) -> bool + 'static) -> Self {
140        self.filter = Some(Box::new(pred));
141        self
142    }
143
144    /// Display order (stable sort). A projection: the source vector is
145    /// untouched.
146    pub fn sort_by(mut self, cmp: impl Fn(&T, &T) -> Ordering + 'static) -> Self {
147        self.sort = Some(Box::new(cmp));
148        self
149    }
150
151    pub fn layout(mut self, layout: DataViewLayout) -> Self {
152        self.layout = layout;
153        self
154    }
155
156    /// Spacing between items (default `Sm`).
157    pub fn gap(mut self, gap: Size) -> Self {
158        self.gap = gap;
159        self
160    }
161
162    /// Shown when the projection yields nothing (empty source or everything
163    /// filtered out). Rebuilt each render.
164    pub fn empty<E>(mut self, content: impl Fn(&mut Window, &mut App) -> E + 'static) -> Self
165    where
166        E: IntoElement,
167    {
168        self.empty = Some(Box::new(move |window, cx| {
169            content(window, cx).into_any_element()
170        }));
171        self
172    }
173
174    /// Enable single selection: items get hover/selected styling and clicks
175    /// emit [`DataViewEvent::Selected`].
176    pub fn selectable(mut self) -> Self {
177        self.selectable = true;
178        self
179    }
180
181    /// The selected **source** index, if any.
182    pub fn selected_index(&self) -> Option<usize> {
183        self.selected
184    }
185}
186
187/// The display order: indices into `items`, filtered then stably sorted.
188fn projection<T>(items: &[T], filter: FilterRef<'_, T>, sort: SortRef<'_, T>) -> Vec<usize> {
189    let mut order: Vec<usize> = (0..items.len())
190        .filter(|&i| filter.is_none_or(|keep| keep(&items[i])))
191        .collect();
192    if let Some(cmp) = sort {
193        order.sort_by(|&a, &b| cmp(&items[a], &items[b]));
194    }
195    order
196}
197
198impl<T: 'static> DataView<T> {
199    /// Build the wrapped cells for a range of **display** positions. The
200    /// projected items are built while the source entity is leased; the
201    /// template borrows each item in place — no clone of the collection.
202    fn build_cells(
203        &mut self,
204        display: Range<usize>,
205        window: &mut Window,
206        cx: &mut Context<Self>,
207    ) -> Vec<AnyElement> {
208        let t = theme(cx);
209        let radius = t.radius(t.default_radius);
210        let hover_bg = t.surface_hover().hsla();
211        // Same treatment as an active NavLink: the primary color's Light
212        // (tinted) surface.
213        let sel = surface(t, t.primary_color, Variant::Light);
214        let (selected_bg, selected_fg) = (sel.bg, sel.fg);
215
216        let template = self.item.as_ref();
217        let filter = self.filter.as_deref();
218        let sort = self.sort.as_deref();
219        let entity = self.source.entity().clone();
220        let built: Vec<(usize, AnyElement)> = entity.update(cx, |items, cx| {
221            let order = projection(items, filter, sort);
222            match template {
223                Some(build) => order
224                    .into_iter()
225                    .skip(display.start)
226                    .take(display.len())
227                    .map(|i| (i, build(&items[i], i, window, cx)))
228                    .collect(),
229                None => Vec::new(),
230            }
231        });
232
233        let selectable = self.selectable;
234        // The source observer prunes out-of-range selections, so this index
235        // is always valid for the current collection.
236        let selected = self.selected;
237
238        built
239            .into_iter()
240            .map(|(source_ix, element)| {
241                if !selectable {
242                    return element;
243                }
244                let is_selected = selected == Some(source_ix);
245                let mut cell = div()
246                    .id(("guise-dataview-item", source_ix))
247                    .px(px(10.0))
248                    .py(px(8.0))
249                    .rounded(px(radius))
250                    .cursor_pointer()
251                    .child(element)
252                    .on_click(cx.listener(move |this, _ev, _window, cx| {
253                        this.selected = Some(source_ix);
254                        cx.emit(DataViewEvent::Selected(source_ix));
255                        cx.notify();
256                    }));
257                cell = if is_selected {
258                    cell.bg(selected_bg).text_color(selected_fg)
259                } else {
260                    cell.hover(move |s| s.bg(hover_bg))
261                };
262                cell.into_any_element()
263            })
264            .collect()
265    }
266
267    /// One virtualized grid row: `cols` equal-width cells, padded at the tail.
268    fn build_grid_row(
269        &mut self,
270        row_ix: usize,
271        cols: usize,
272        gap: f32,
273        window: &mut Window,
274        cx: &mut Context<Self>,
275    ) -> AnyElement {
276        let start = row_ix * cols;
277        let cells = self.build_cells(start..start + cols, window, cx);
278        let mut wrapped: Vec<_> = cells
279            .into_iter()
280            .map(|cell| div().flex_1().min_w(px(0.0)).child(cell))
281            .collect();
282        while wrapped.len() < cols {
283            wrapped.push(div().flex_1().min_w(px(0.0)));
284        }
285        div()
286            .flex()
287            .gap(px(gap))
288            .pb(px(gap))
289            .children(wrapped)
290            .into_any_element()
291    }
292
293    /// Length of the current projection (display item count).
294    fn projected_len(&mut self, cx: &mut Context<Self>) -> usize {
295        let filter = self.filter.as_deref();
296        let sort = self.sort.as_deref();
297        let entity = self.source.entity().clone();
298        entity.update(cx, |items, _| projection(items, filter, sort).len())
299    }
300}
301
302impl<T: 'static> Render for DataView<T> {
303    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
304        let t = theme(cx);
305        let gap = t.spacing(self.gap);
306        let dimmed = t.dimmed().hsla();
307        let font_sm = t.font_size(Size::Sm);
308
309        let count = if self.item.is_some() {
310            self.projected_len(cx)
311        } else {
312            0
313        };
314
315        if count == 0 {
316            let content = match &self.empty {
317                Some(build) => build(window, cx),
318                None => div()
319                    .text_size(px(font_sm))
320                    .text_color(dimmed)
321                    .child(SharedString::new_static("Nothing to show"))
322                    .into_any_element(),
323            };
324            return div()
325                .w_full()
326                .flex()
327                .justify_center()
328                .py(px(16.0))
329                .child(content)
330                .into_any_element();
331        }
332
333        // Virtualized: uniform_list over display items (List) or whole rows
334        // of `cols` cells (Grid). Only the viewport slice is built per frame.
335        if let Some(height) = self.height {
336            let list = match self.layout {
337                DataViewLayout::List => uniform_list(
338                    "guise-dataview-body",
339                    count,
340                    cx.processor(move |this, range: Range<usize>, window, cx| {
341                        this.build_cells(range, window, cx)
342                            .into_iter()
343                            .map(|cell| div().pb(px(gap)).child(cell).into_any_element())
344                            .collect::<Vec<_>>()
345                    }),
346                ),
347                DataViewLayout::Grid(cols) => {
348                    let cols = cols.max(1);
349                    let rows = count.div_ceil(cols);
350                    uniform_list(
351                        "guise-dataview-body",
352                        rows,
353                        cx.processor(move |this, range: Range<usize>, window, cx| {
354                            range
355                                .map(|row_ix| this.build_grid_row(row_ix, cols, gap, window, cx))
356                                .collect::<Vec<_>>()
357                        }),
358                    )
359                }
360            };
361            return div()
362                .w_full()
363                .child(list.h(px(height)).w_full())
364                .into_any_element();
365        }
366
367        let cells = self.build_cells(0..count, window, cx);
368        let root = div().w_full().flex().flex_col().gap(px(gap));
369
370        let element = match self.layout {
371            DataViewLayout::List => root.children(cells).into_any_element(),
372            DataViewLayout::Grid(cols) => {
373                let cols = cols.max(1);
374                let mut rows = Vec::new();
375                let mut row = Vec::new();
376                for (i, cell) in cells.into_iter().enumerate() {
377                    row.push(div().flex_1().min_w(px(0.0)).child(cell));
378                    if row.len() == cols || i + 1 == count {
379                        // Pad the last row so cells keep equal widths.
380                        while row.len() < cols {
381                            row.push(div().flex_1().min_w(px(0.0)));
382                        }
383                        rows.push(div().flex().gap(px(gap)).children(std::mem::take(&mut row)));
384                    }
385                }
386                root.children(rows).into_any_element()
387            }
388        };
389
390        element.probe_any("DataView").into_any_element()
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    #[test]
399    fn identity_without_projections() {
400        assert_eq!(projection(&[10, 20, 30], None, None), vec![0, 1, 2]);
401        assert_eq!(projection::<i32>(&[], None, None), Vec::<usize>::new());
402    }
403
404    #[test]
405    fn filter_keeps_source_indices() {
406        let even = |n: &i32| n % 2 == 0;
407        let order = projection(&[1, 2, 3, 4, 5, 6], Some(&even), None);
408        assert_eq!(order, vec![1, 3, 5]);
409    }
410
411    #[test]
412    fn sort_orders_indices_without_moving_items() {
413        let cmp = |a: &i32, b: &i32| a.cmp(b);
414        let items = [30, 10, 20];
415        let order = projection(&items, None, Some(&cmp));
416        assert_eq!(order, vec![1, 2, 0]);
417        // The source is untouched; the order just points into it.
418        assert_eq!(items, [30, 10, 20]);
419    }
420
421    #[test]
422    fn filter_then_sort_compose() {
423        let over_two = |n: &i32| *n > 2;
424        let desc = |a: &i32, b: &i32| b.cmp(a);
425        let order = projection(&[1, 4, 3, 2, 5], Some(&over_two), Some(&desc));
426        assert_eq!(order, vec![4, 1, 2]); // values 5, 4, 3
427    }
428
429    #[test]
430    fn sort_is_stable_for_equal_keys() {
431        let by_len = |a: &&str, b: &&str| a.len().cmp(&b.len());
432        let items = ["bb", "aa", "c", "dd"];
433        let order = projection(&items, None, Some(&by_len));
434        // "c" first, then the three two-char items in source order.
435        assert_eq!(order, vec![2, 0, 1, 3]);
436    }
437}