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