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;
30
31use gpui::prelude::*;
32use gpui::{div, px, AnyElement, App, Context, EventEmitter, IntoElement, SharedString, Window};
33
34use super::Content;
35use crate::reactive::Signal;
36use crate::style::{surface, Variant};
37use crate::theme::{theme, Size};
38
39/// Emitted when a selectable item is clicked. Carries the item's index into
40/// the **source** vector (not its display position), so it stays valid under
41/// any filter/sort projection.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum DataViewEvent {
44    Selected(usize),
45}
46
47/// How the items flow.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub enum DataViewLayout {
50    /// A vertical list (the default).
51    #[default]
52    List,
53    /// Rows of `n` equal-width cells.
54    Grid(usize),
55}
56
57type ItemBuilder<T> = Box<dyn Fn(&T, usize, &mut Window, &mut App) -> AnyElement + 'static>;
58type FilterFn<T> = Box<dyn Fn(&T) -> bool + 'static>;
59type SortFn<T> = Box<dyn Fn(&T, &T) -> Ordering + 'static>;
60type FilterRef<'a, T> = Option<&'a dyn Fn(&T) -> bool>;
61type SortRef<'a, T> = Option<&'a dyn Fn(&T, &T) -> Ordering>;
62
63/// A signal-bound collection view. Create with
64/// `cx.new(|cx| DataView::new(cx, &signal).item(...))`.
65pub struct DataView<T: 'static> {
66    source: Signal<Vec<T>>,
67    item: Option<ItemBuilder<T>>,
68    filter: Option<FilterFn<T>>,
69    sort: Option<SortFn<T>>,
70    layout: DataViewLayout,
71    gap: Size,
72    empty: Option<Content>,
73    selectable: bool,
74    selected: Option<usize>,
75}
76
77impl<T: 'static> EventEmitter<DataViewEvent> for DataView<T> {}
78
79impl<T: 'static> DataView<T> {
80    /// Bind the view to a collection signal. Every `set`/`update` on the
81    /// signal repaints the view.
82    pub fn new(cx: &mut Context<Self>, source: &Signal<Vec<T>>) -> Self {
83        cx.observe(source.entity(), |this, source, cx| {
84            // Drop a selection whose item fell off the end: keeping the stale
85            // index would hand callers an out-of-range value and silently
86            // re-select whatever item lands there after the source regrows.
87            let len = source.read(cx).len();
88            if this.selected.is_some_and(|i| i >= len) {
89                this.selected = None;
90            }
91            cx.notify();
92        })
93        .detach();
94        DataView {
95            source: source.clone(),
96            item: None,
97            filter: None,
98            sort: None,
99            layout: DataViewLayout::List,
100            gap: Size::Sm,
101            empty: None,
102            selectable: false,
103            selected: None,
104        }
105    }
106
107    /// The item template, re-invoked every frame with the borrowed item and
108    /// its source index — items always show live data.
109    pub fn item<E>(
110        mut self,
111        template: impl Fn(&T, usize, &mut Window, &mut App) -> E + 'static,
112    ) -> Self
113    where
114        E: IntoElement,
115    {
116        self.item = Some(Box::new(move |item, ix, window, cx| {
117            template(item, ix, window, cx).into_any_element()
118        }));
119        self
120    }
121
122    /// Show only the items matching `pred`. A projection: the source vector
123    /// is untouched.
124    pub fn filter(mut self, pred: impl Fn(&T) -> bool + 'static) -> Self {
125        self.filter = Some(Box::new(pred));
126        self
127    }
128
129    /// Display order (stable sort). A projection: the source vector is
130    /// untouched.
131    pub fn sort_by(mut self, cmp: impl Fn(&T, &T) -> Ordering + 'static) -> Self {
132        self.sort = Some(Box::new(cmp));
133        self
134    }
135
136    pub fn layout(mut self, layout: DataViewLayout) -> Self {
137        self.layout = layout;
138        self
139    }
140
141    /// Spacing between items (default `Sm`).
142    pub fn gap(mut self, gap: Size) -> Self {
143        self.gap = gap;
144        self
145    }
146
147    /// Shown when the projection yields nothing (empty source or everything
148    /// filtered out). Rebuilt each render.
149    pub fn empty<E>(mut self, content: impl Fn(&mut Window, &mut App) -> E + 'static) -> Self
150    where
151        E: IntoElement,
152    {
153        self.empty = Some(Box::new(move |window, cx| {
154            content(window, cx).into_any_element()
155        }));
156        self
157    }
158
159    /// Enable single selection: items get hover/selected styling and clicks
160    /// emit [`DataViewEvent::Selected`].
161    pub fn selectable(mut self) -> Self {
162        self.selectable = true;
163        self
164    }
165
166    /// The selected **source** index, if any.
167    pub fn selected_index(&self) -> Option<usize> {
168        self.selected
169    }
170}
171
172/// The display order: indices into `items`, filtered then stably sorted.
173fn projection<T>(items: &[T], filter: FilterRef<'_, T>, sort: SortRef<'_, T>) -> Vec<usize> {
174    let mut order: Vec<usize> = (0..items.len())
175        .filter(|&i| filter.is_none_or(|keep| keep(&items[i])))
176        .collect();
177    if let Some(cmp) = sort {
178        order.sort_by(|&a, &b| cmp(&items[a], &items[b]));
179    }
180    order
181}
182
183impl<T: 'static> Render for DataView<T> {
184    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
185        let t = theme(cx);
186        let gap = t.spacing(self.gap);
187        let radius = t.radius(t.default_radius);
188        let hover_bg = t.surface_hover().hsla();
189        let dimmed = t.dimmed().hsla();
190        let font_sm = t.font_size(Size::Sm);
191        // Same treatment as an active NavLink: the primary color's Light
192        // (tinted) surface.
193        let sel = surface(t, t.primary_color, Variant::Light);
194        let (selected_bg, selected_fg) = (sel.bg, sel.fg);
195
196        // Build the projected items while the source entity is leased; the
197        // template borrows each item in place — no clone of the collection.
198        let template = self.item.as_ref();
199        let filter = self.filter.as_deref();
200        let sort = self.sort.as_deref();
201        let entity = self.source.entity().clone();
202        let built: Vec<(usize, AnyElement)> = entity.update(cx, |items, cx| {
203            let order = projection(items, filter, sort);
204            match template {
205                Some(build) => order
206                    .into_iter()
207                    .map(|i| (i, build(&items[i], i, window, cx)))
208                    .collect(),
209                None => Vec::new(),
210            }
211        });
212
213        let selectable = self.selectable;
214        // The source observer prunes out-of-range selections, so this index
215        // is always valid for the current collection.
216        let selected = self.selected;
217
218        if built.is_empty() {
219            let content = match &self.empty {
220                Some(build) => build(window, cx),
221                None => div()
222                    .text_size(px(font_sm))
223                    .text_color(dimmed)
224                    .child(SharedString::new_static("Nothing to show"))
225                    .into_any_element(),
226            };
227            return div()
228                .w_full()
229                .flex()
230                .justify_center()
231                .py(px(16.0))
232                .child(content);
233        }
234
235        let cells: Vec<AnyElement> = built
236            .into_iter()
237            .map(|(source_ix, element)| {
238                if !selectable {
239                    return element;
240                }
241                let is_selected = selected == Some(source_ix);
242                let mut cell = div()
243                    .id(("guise-dataview-item", source_ix))
244                    .px(px(10.0))
245                    .py(px(8.0))
246                    .rounded(px(radius))
247                    .cursor_pointer()
248                    .child(element)
249                    .on_click(cx.listener(move |this, _ev, _window, cx| {
250                        this.selected = Some(source_ix);
251                        cx.emit(DataViewEvent::Selected(source_ix));
252                        cx.notify();
253                    }));
254                cell = if is_selected {
255                    cell.bg(selected_bg).text_color(selected_fg)
256                } else {
257                    cell.hover(move |s| s.bg(hover_bg))
258                };
259                cell.into_any_element()
260            })
261            .collect();
262
263        let root = div().w_full().flex().flex_col().gap(px(gap));
264        match self.layout {
265            DataViewLayout::List => root.children(cells),
266            DataViewLayout::Grid(cols) => {
267                let cols = cols.max(1);
268                let count = cells.len();
269                let mut rows = Vec::new();
270                let mut row = Vec::new();
271                for (i, cell) in cells.into_iter().enumerate() {
272                    row.push(div().flex_1().min_w(px(0.0)).child(cell));
273                    if row.len() == cols || i + 1 == count {
274                        // Pad the last row so cells keep equal widths.
275                        while row.len() < cols {
276                            row.push(div().flex_1().min_w(px(0.0)));
277                        }
278                        rows.push(div().flex().gap(px(gap)).children(std::mem::take(&mut row)));
279                    }
280                }
281                root.children(rows)
282            }
283        }
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn identity_without_projections() {
293        assert_eq!(projection(&[10, 20, 30], None, None), vec![0, 1, 2]);
294        assert_eq!(projection::<i32>(&[], None, None), Vec::<usize>::new());
295    }
296
297    #[test]
298    fn filter_keeps_source_indices() {
299        let even = |n: &i32| n % 2 == 0;
300        let order = projection(&[1, 2, 3, 4, 5, 6], Some(&even), None);
301        assert_eq!(order, vec![1, 3, 5]);
302    }
303
304    #[test]
305    fn sort_orders_indices_without_moving_items() {
306        let cmp = |a: &i32, b: &i32| a.cmp(b);
307        let items = [30, 10, 20];
308        let order = projection(&items, None, Some(&cmp));
309        assert_eq!(order, vec![1, 2, 0]);
310        // The source is untouched; the order just points into it.
311        assert_eq!(items, [30, 10, 20]);
312    }
313
314    #[test]
315    fn filter_then_sort_compose() {
316        let over_two = |n: &i32| *n > 2;
317        let desc = |a: &i32, b: &i32| b.cmp(a);
318        let order = projection(&[1, 4, 3, 2, 5], Some(&over_two), Some(&desc));
319        assert_eq!(order, vec![4, 1, 2]); // values 5, 4, 3
320    }
321
322    #[test]
323    fn sort_is_stable_for_equal_keys() {
324        let by_len = |a: &&str, b: &&str| a.len().cmp(&b.len());
325        let items = ["bb", "aa", "c", "dd"];
326        let order = projection(&items, None, Some(&by_len));
327        // "c" first, then the three two-char items in source order.
328        assert_eq!(order, vec![2, 0, 1, 3]);
329    }
330}