Skip to main content

gpui_kit/data/
table.rs

1//! A column-oriented table over caller-owned rows.
2//!
3//! The table sorts nothing and selects nothing. A click on a sortable header
4//! reports the key and the direction that click implies; the table then
5//! renders exactly the order and the sort indicator the caller passes back, so
6//! a host that refuses to re-sort keeps the order that still holds.
7//!
8//! # Which cells publish
9//!
10//! Every rendered row publishes a [`Role::Row`] node. Cells do not: a table of
11//! two hundred rows and six columns would bury every other assertion target in
12//! twelve hundred nodes that say nothing a row does not already say. A cell
13//! publishes a [`Role::Cell`] node only where the caller marks it with
14//! [`Cell::published`], and its id is `<row id>.<column key>`.
15//!
16//! # Materialized rows are not virtualized; a row source is
17//!
18//! [`Table::rows`] takes materialized rows, so the caller has already built
19//! every cell element by the time the table sees them, and an element can be
20//! laid out once. Virtualization needs a row it can build on demand, and it
21//! needs to build it more than once per frame — a `uniform_list` measures one
22//! row to learn the height before it builds the range it shows. A vector
23//! cannot answer that twice. So the materialized body renders every row it is
24//! given, under a header that stays put while the body scrolls.
25//!
26//! [`Table::rows_from`] is the way in for a collection larger than the
27//! viewport: a count and a closure, the same shape [`crate::data::List`] takes.
28//! A table built that way lays out only the rows [`Table::visible_rows`]
29//! admits, and asks the caller for those and no others. It is offered
30//! alongside [`Table::rows`] rather than replacing it, because a table of six
31//! settings should not have to be written as a closure over an index.
32//!
33//! A table has no keyboard navigation of its own — a click is its only way to
34//! report a row — so a caller that moves the selection somewhere the viewport
35//! has never drawn brings it into view with
36//! [`crate::data::reveal_row`], naming the table's body as
37//! `<table ident>.body`. A surface that wants the keyboard to walk a
38//! collection larger than its viewport wants [`crate::data::DataGrid`], which
39//! does that itself.
40//!
41//! # Table or DataGrid
42//!
43//! [`crate::data::DataGrid`] is the heavyweight alternative: it takes a render
44//! closure instead of rows, so it virtualizes over
45//! [`gpui::uniform_list`], and it carries the machinery an administrative
46//! surface needs — resizable and reorderable columns, a left-pinned group,
47//! three selection modes with a truthful select-all, opened rows with a detail
48//! region, and cells that become fields.
49//!
50//! Reach for `Table` for a settings summary, a short run list, a preview of a
51//! result set — anything a reader takes in at a glance. Reach for `DataGrid`
52//! when the data set is larger than the viewport, or when the surface needs
53//! any of the above. If a surface would work as either, pick `Table`: it is
54//! smaller, and a grid's machinery costs something even when nothing uses it.
55
56use std::ops::Range;
57use std::rc::Rc;
58
59use gpui::{
60    AnyElement, App, InteractiveElement, IntoElement, ListSizingBehavior, ParentElement,
61    RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
62    prelude::FluentBuilder, px, uniform_list,
63};
64use gpui_kit_semantics::{NodeSpec, Role, Semantic};
65use gpui_kit_theme::{
66    ActiveTheme, ControlSize, Elevation, Radius, Space, Surface, TextTone, Theme, TypeScale,
67};
68
69use crate::data::viewport::scroll_handle;
70use crate::foundation::{Disableable, FocusRing, Ident, Pressable, Sizable, StyledExt, text};
71
72type SortHandler = Rc<dyn Fn(SharedString, SortDirection, &mut Window, &mut App)>;
73type SelectHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
74type RenderRow = Rc<dyn Fn(usize, &mut Window, &mut App) -> Row>;
75
76/// Which way a sorted column runs. The table reports a direction and renders
77/// whatever order it is handed; it never sorts the rows itself.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum SortDirection {
80    Ascending,
81    Descending,
82}
83
84impl SortDirection {
85    /// The direction a second click on the same header implies.
86    pub fn reversed(self) -> Self {
87        match self {
88            Self::Ascending => Self::Descending,
89            Self::Descending => Self::Ascending,
90        }
91    }
92
93    pub fn as_str(self) -> &'static str {
94        match self {
95            Self::Ascending => "ascending",
96            Self::Descending => "descending",
97        }
98    }
99}
100
101/// How wide a column is: a fixed measure, or a share of what is left over.
102#[derive(Debug, Clone, Copy, PartialEq)]
103pub enum ColumnWidth {
104    Fixed(f32),
105    Flex(f32),
106}
107
108/// Where a cell's content sits inside its column.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
110pub enum Align {
111    #[default]
112    Start,
113    Center,
114    End,
115}
116
117/// One column. `key` addresses the cells that belong to it and appears in
118/// every id the column publishes.
119#[derive(Debug, Clone)]
120pub struct Column {
121    key: SharedString,
122    header: SharedString,
123    width: ColumnWidth,
124    align: Align,
125    sortable: bool,
126}
127
128impl Column {
129    pub fn new(key: impl Into<SharedString>, header: impl Into<SharedString>) -> Self {
130        Self {
131            key: key.into(),
132            header: header.into(),
133            width: ColumnWidth::Flex(1.0),
134            align: Align::default(),
135            sortable: false,
136        }
137    }
138
139    pub fn width(mut self, width: ColumnWidth) -> Self {
140        self.width = width;
141        self
142    }
143
144    pub fn fixed(self, width: f32) -> Self {
145        self.width(ColumnWidth::Fixed(width))
146    }
147
148    pub fn flex(self, share: f32) -> Self {
149        self.width(ColumnWidth::Flex(share))
150    }
151
152    pub fn align(mut self, align: Align) -> Self {
153        self.align = align;
154        self
155    }
156
157    pub fn sortable(mut self, sortable: bool) -> Self {
158        self.sortable = sortable;
159        self
160    }
161}
162
163/// One cell's content, and whether it is an assertion target.
164pub struct Cell {
165    pub(crate) content: CellContent,
166    pub(crate) text: Option<SharedString>,
167    pub(crate) published: bool,
168}
169
170pub(crate) enum CellContent {
171    Element(AnyElement),
172    Plain(SharedString),
173}
174
175impl CellContent {
176    pub(crate) fn into_element(self, theme: &Theme) -> AnyElement {
177        match self {
178            Self::Element(element) => element,
179            Self::Plain(value) => text(theme, TypeScale::Body, value).into_any_element(),
180        }
181    }
182}
183
184impl std::fmt::Debug for Cell {
185    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        formatter
187            .debug_struct("Cell")
188            .field("text", &self.text)
189            .field("published", &self.published)
190            .finish()
191    }
192}
193
194impl Cell {
195    pub fn new(content: impl IntoElement) -> Self {
196        Self {
197            content: CellContent::Element(content.into_any_element()),
198            text: None,
199            published: false,
200        }
201    }
202
203    /// The name the cell publishes when it is published.
204    pub fn text(mut self, text: impl Into<SharedString>) -> Self {
205        self.text = Some(text.into());
206        self
207    }
208
209    /// Publishes a [`Role::Cell`] node at `<row id>.<column key>`.
210    pub fn published(mut self, published: bool) -> Self {
211        self.published = published;
212        self
213    }
214}
215
216impl From<SharedString> for Cell {
217    fn from(value: SharedString) -> Self {
218        Self {
219            content: CellContent::Plain(value.clone()),
220            text: Some(value),
221            published: false,
222        }
223    }
224}
225
226impl From<&'static str> for Cell {
227    fn from(value: &'static str) -> Self {
228        SharedString::from(value).into()
229    }
230}
231
232impl From<String> for Cell {
233    fn from(value: String) -> Self {
234        SharedString::from(value).into()
235    }
236}
237
238/// One row, keyed by the identity the row already has.
239pub struct Row {
240    id: SharedString,
241    text: Option<SharedString>,
242    disabled: bool,
243    cells: Vec<(SharedString, Cell)>,
244}
245
246impl std::fmt::Debug for Row {
247    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248        formatter
249            .debug_struct("Row")
250            .field("id", &self.id)
251            .field("cells", &self.cells.len())
252            .field("disabled", &self.disabled)
253            .finish()
254    }
255}
256
257impl Row {
258    pub fn new(id: impl Into<SharedString>) -> Self {
259        Self {
260            id: id.into(),
261            text: None,
262            disabled: false,
263            cells: Vec::new(),
264        }
265    }
266
267    pub fn cell(mut self, key: impl Into<SharedString>, cell: impl Into<Cell>) -> Self {
268        self.cells.push((key.into(), cell.into()));
269        self
270    }
271
272    pub fn text(mut self, text: impl Into<SharedString>) -> Self {
273        self.text = Some(text.into());
274        self
275    }
276
277    pub fn disabled(mut self, disabled: bool) -> Self {
278        self.disabled = disabled;
279        self
280    }
281
282    fn take(&mut self, key: &SharedString) -> Option<Cell> {
283        let position = self.cells.iter().position(|(name, _)| name == key)?;
284        Some(self.cells.remove(position).1)
285    }
286}
287
288/// A row set the table builds one row at a time.
289#[derive(Clone)]
290struct RowSource {
291    count: usize,
292    render_row: RenderRow,
293}
294
295/// Everything a row needs that does not come from the row itself.
296///
297/// A virtualized body builds its rows inside a `'static` closure, which cannot
298/// borrow the table, so the few fields a row reads travel into the closure by
299/// value and the materialized body reads the same ones.
300#[derive(Clone)]
301struct Body {
302    ident: Ident,
303    columns: Vec<Column>,
304    selected: Option<SharedString>,
305    disabled: bool,
306    on_select: Option<SelectHandler>,
307}
308
309/// A table with a header that stays put while the body scrolls.
310#[derive(IntoElement)]
311pub struct Table {
312    ident: Ident,
313    columns: Vec<Column>,
314    rows: Vec<Row>,
315    source: Option<RowSource>,
316    sort: Option<(SharedString, SortDirection)>,
317    selected: Option<SharedString>,
318    row_height: Option<f32>,
319    visible_rows: Option<usize>,
320    size: ControlSize,
321    disabled: bool,
322    on_sort: Option<SortHandler>,
323    on_select: Option<SelectHandler>,
324}
325
326impl std::fmt::Debug for Table {
327    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
328        formatter
329            .debug_struct("Table")
330            .field("ident", &self.ident)
331            .field("columns", &self.columns.len())
332            .field("rows", &self.count())
333            .field("sort", &self.sort)
334            .field("selected", &self.selected)
335            .field("disabled", &self.disabled)
336            .finish()
337    }
338}
339
340impl Table {
341    pub fn new(ident: impl Into<Ident>) -> Self {
342        Self {
343            ident: ident.into(),
344            columns: Vec::new(),
345            rows: Vec::new(),
346            source: None,
347            sort: None,
348            selected: None,
349            row_height: None,
350            visible_rows: None,
351            size: ControlSize::Md,
352            disabled: false,
353            on_sort: None,
354            on_select: None,
355        }
356    }
357
358    pub fn column(mut self, column: Column) -> Self {
359        self.columns.push(column);
360        self
361    }
362
363    pub fn columns(mut self, columns: impl IntoIterator<Item = Column>) -> Self {
364        self.columns.extend(columns);
365        self
366    }
367
368    pub fn rows(mut self, rows: impl IntoIterator<Item = Row>) -> Self {
369        self.rows.extend(rows);
370        self
371    }
372
373    /// Takes a count and a closure instead of a vector, so the table can build
374    /// only the rows its viewport holds.
375    ///
376    /// Pair it with [`Table::visible_rows`]: without a bounded viewport there
377    /// is no window to skip rows outside of, and the table lays out the whole
378    /// collection just as a materialized one does. A source supersedes
379    /// anything passed to [`Table::rows`].
380    pub fn rows_from(
381        mut self,
382        count: usize,
383        render_row: impl Fn(usize, &mut Window, &mut App) -> Row + 'static,
384    ) -> Self {
385        self.source = Some(RowSource {
386            count,
387            render_row: Rc::new(render_row),
388        });
389        self
390    }
391
392    fn count(&self) -> usize {
393        self.source
394            .as_ref()
395            .map_or(self.rows.len(), |source| source.count)
396    }
397
398    /// The sort the caller applied, which is the only sort the table shows.
399    pub fn sort(mut self, sort: Option<(SharedString, SortDirection)>) -> Self {
400        self.sort = sort;
401        self
402    }
403
404    pub fn sorted_by(self, key: impl Into<SharedString>, direction: SortDirection) -> Self {
405        self.sort(Some((key.into(), direction)))
406    }
407
408    pub fn selected(mut self, id: impl Into<SharedString>) -> Self {
409        self.selected = Some(id.into());
410        self
411    }
412
413    pub fn row_height(mut self, height: f32) -> Self {
414        self.row_height = Some(height);
415        self
416    }
417
418    /// Caps the body at `rows` rows and scrolls past that, leaving the header
419    /// in place.
420    pub fn visible_rows(mut self, rows: usize) -> Self {
421        self.visible_rows = Some(rows);
422        self
423    }
424
425    pub fn on_sort(
426        mut self,
427        handler: impl Fn(SharedString, SortDirection, &mut Window, &mut App) + 'static,
428    ) -> Self {
429        self.on_sort = Some(Rc::new(handler));
430        self
431    }
432
433    pub fn on_select(
434        mut self,
435        handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
436    ) -> Self {
437        self.on_select = Some(Rc::new(handler));
438        self
439    }
440
441    fn header(&self, theme: &Theme, height: f32, cx: &mut App) -> AnyElement {
442        let mut header = div()
443            .row()
444            .w_full()
445            .h(px(height))
446            .px(px(theme.space(Space::Sm)))
447            .gap(px(theme.space(Space::Sm)))
448            .surface(theme, Surface::Raised);
449
450        for column in &self.columns {
451            let ident = self.ident.child("header").child(column.key.as_ref());
452            let hover_group = ident.child("hover").semantic_id();
453            let direction = self
454                .sort
455                .as_ref()
456                .filter(|(key, _)| key == &column.key)
457                .map(|(_, direction)| *direction);
458            let actionable = column.sortable && !self.disabled && self.on_sort.is_some();
459
460            let content = div()
461                .row()
462                .gap(px(theme.space(Space::Xs)))
463                .child(
464                    text(theme, TypeScale::Label, column.header.clone())
465                        .text_tone(theme, TextTone::Muted)
466                        .when(actionable, |element| {
467                            element.group_hover(hover_group.clone(), |style| {
468                                style.text_color(theme.colors.text)
469                            })
470                        }),
471                )
472                .children(direction.map(|direction| {
473                    text(
474                        theme,
475                        TypeScale::Label,
476                        SharedString::from(match direction {
477                            SortDirection::Ascending => "↑",
478                            SortDirection::Descending => "↓",
479                        }),
480                    )
481                }));
482
483            let mut cell = cell_frame(div().id(ident.element_id()), column, theme)
484                .group(hover_group)
485                .when(actionable, |element| {
486                    element
487                        .cursor_pointer()
488                        .tab_index(0)
489                        .pressable(cx)
490                        .focus_ring(theme)
491                })
492                .child(content);
493
494            if let (true, Some(handler)) = (actionable, self.on_sort.clone()) {
495                let key = column.key.clone();
496                let next = direction.map_or(SortDirection::Ascending, SortDirection::reversed);
497                let click = Rc::clone(&handler);
498                let clicked = key.clone();
499                cell = cell
500                    .on_click(move |_, window, cx| click(clicked.clone(), next, window, cx))
501                    .on_key_down(move |event, window, cx| {
502                        if matches!(event.keystroke.key.as_str(), "enter" | "space") {
503                            handler(key.clone(), next, window, cx);
504                            cx.stop_propagation();
505                        }
506                    });
507            }
508
509            let spec = if column.sortable {
510                NodeSpec::new(ident.semantic_id(), Role::Button)
511                    .parent(self.ident.semantic_id())
512                    .text(column.header.clone())
513                    .disabled(!actionable)
514                    // The direction a header reports is the one it currently
515                    // shows, not the one a click would ask for.
516                    .value(direction.map_or("unsorted", SortDirection::as_str))
517            } else {
518                NodeSpec::new(ident.semantic_id(), Role::Cell)
519                    .parent(self.ident.semantic_id())
520                    .text(column.header.clone())
521            };
522
523            header = header.child(cell.semantic_in(cx, spec));
524        }
525
526        header.into_any_element()
527    }
528
529    fn body(&self) -> Body {
530        Body {
531            ident: self.ident.clone(),
532            columns: self.columns.clone(),
533            selected: self.selected.clone(),
534            disabled: self.disabled,
535            on_select: self.on_select.clone(),
536        }
537    }
538}
539
540impl Body {
541    fn row_element(&self, theme: &Theme, height: f32, mut row: Row, cx: &mut App) -> AnyElement {
542        let ident = self.ident.child(row.id.as_ref());
543        let selected = self.selected.as_ref() == Some(&row.id);
544        let actionable = !row.disabled && !self.disabled && self.on_select.is_some();
545
546        let mut element = div()
547            .id(ident.element_id())
548            .row()
549            .w_full()
550            .h(px(height))
551            .px(px(theme.space(Space::Sm)))
552            .gap(px(theme.space(Space::Sm)))
553            .when(selected, |element| element.bg(theme.colors.selected))
554            .when(row.disabled, |element| {
555                element.opacity(theme.opacity.disabled)
556            })
557            .when(actionable, |element| {
558                element
559                    .cursor_pointer()
560                    .tab_index(0)
561                    .pressable(cx)
562                    .when(!selected, |element| {
563                        element.hover(|style| style.bg(theme.colors.hover.opacity(0.3)))
564                    })
565                    .focus_ring(theme)
566            });
567
568        for column in &self.columns {
569            let cell = row.take(&column.key);
570            let published = cell.as_ref().is_some_and(|cell| cell.published);
571            let text = cell.as_ref().and_then(|cell| cell.text.clone());
572            let frame = cell_frame(div(), column, theme)
573                .overflow_hidden()
574                .children(cell.map(|cell| cell.content.into_element(theme)));
575
576            let frame = if published {
577                let cell_ident = ident.child(column.key.as_ref());
578                let mut spec =
579                    NodeSpec::new(cell_ident.semantic_id(), Role::Cell).parent(ident.semantic_id());
580                if let Some(text) = text {
581                    spec = spec.text(text);
582                }
583                frame.semantic_in(cx, spec).into_any_element()
584            } else {
585                frame.into_any_element()
586            };
587            element = element.child(frame);
588        }
589
590        if let (true, Some(handler)) = (actionable, self.on_select.clone()) {
591            let id = row.id.clone();
592            element = element.on_click(move |_, window, cx| handler(id.clone(), window, cx));
593        }
594
595        let mut spec = NodeSpec::new(ident.semantic_id(), Role::Row)
596            .parent(self.ident.semantic_id())
597            .selected(selected)
598            .disabled(row.disabled || self.disabled);
599        if let Some(text) = row.text.clone() {
600            spec = spec.text(text);
601        }
602        element.semantic_in(cx, spec).into_any_element()
603    }
604}
605
606impl Disableable for Table {
607    fn disabled(mut self, disabled: bool) -> Self {
608        self.disabled = disabled;
609        self
610    }
611}
612
613impl Sizable for Table {
614    fn control_size(mut self, size: ControlSize) -> Self {
615        self.size = size;
616        self
617    }
618}
619
620impl RenderOnce for Table {
621    fn render(mut self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
622        let theme = cx.theme().clone();
623        let metrics = theme.control.get(self.size);
624        let height = self.row_height.unwrap_or(metrics.height);
625        let count = self.count();
626        let header = self.header(&theme, height, cx);
627        let context = self.body();
628
629        let body = match self.source.take() {
630            Some(source) => {
631                let ident = self.ident.child("body");
632                let scroll = scroll_handle(&ident, cx);
633                let theme = theme.clone();
634                uniform_list(
635                    ident.element_id(),
636                    count,
637                    move |range: Range<usize>, window, cx| {
638                        range
639                            .map(|index| {
640                                let row = (source.render_row)(index, window, cx);
641                                context.row_element(&theme, height, row, cx)
642                            })
643                            .collect::<Vec<_>>()
644                    },
645                )
646                .track_scroll(&scroll)
647                .w_full()
648                .with_sizing_behavior(if self.visible_rows.is_some() {
649                    ListSizingBehavior::Auto
650                } else {
651                    ListSizingBehavior::Infer
652                })
653                // A short collection still ends where its last row ends, the
654                // way a materialized body capped by `max_h` does, so a cap is
655                // not a claim about how much data there is.
656                .when_some(self.visible_rows, |element, rows| {
657                    element.h(px(height * count.min(rows) as f32))
658                })
659                .into_any_element()
660            }
661            None => {
662                let rows = std::mem::take(&mut self.rows);
663                div()
664                    .id(self.ident.child("body").element_id())
665                    .column()
666                    .w_full()
667                    .overflow_y_scroll()
668                    .when_some(self.visible_rows, |element, rows| {
669                        element.max_h(px(height * rows as f32))
670                    })
671                    .children(
672                        rows.into_iter()
673                            .map(|row| context.row_element(&theme, height, row, cx))
674                            .collect::<Vec<_>>(),
675                    )
676                    .into_any_element()
677            }
678        };
679
680        div()
681            .id(self.ident.element_id())
682            .column()
683            .w_full()
684            .radius(&theme, Radius::Card)
685            .frame(&theme, Surface::Panel, Elevation::Raised)
686            .overflow_hidden()
687            .child(header)
688            .child(body)
689            .semantic_in(
690                cx,
691                NodeSpec::new(self.ident.semantic_id(), Role::Table).value(count.to_string()),
692            )
693    }
694}
695
696fn cell_frame<E: Styled>(element: E, column: &Column, theme: &Theme) -> E {
697    let element = match column.width {
698        ColumnWidth::Fixed(width) => element.w(px(width)).flex_none(),
699        // A zero basis makes a share depend on the column's flex factor alone,
700        // so a header cell and the body cell under it always agree on where
701        // the column starts.
702        ColumnWidth::Flex(share) => element
703            .flex_grow(share)
704            .flex_shrink(1.0)
705            .flex_basis(px(0.0)),
706    };
707    let element = element.row().h_full().gap(px(theme.space(Space::Xs)));
708    match column.align {
709        Align::Start => element.justify_start(),
710        Align::Center => element.justify_center(),
711        Align::End => element.justify_end(),
712    }
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718
719    #[test]
720    fn a_second_click_on_a_sorted_header_reverses_it() {
721        assert_eq!(
722            SortDirection::Ascending.reversed(),
723            SortDirection::Descending
724        );
725        assert_eq!(
726            SortDirection::Descending.reversed(),
727            SortDirection::Ascending
728        );
729    }
730
731    #[test]
732    fn a_cell_takes_its_name_from_the_string_it_renders() {
733        let cell: Cell = "Indexing".into();
734        assert_eq!(cell.text.as_deref(), Some("Indexing"));
735        assert!(!cell.published);
736    }
737}