Skip to main content

ui/
table.rs

1//! Table — for rows that are *tuples*.
2//!
3//! Most lists of things in this library are records, and a record reads better
4//! as a card: [`group_box`](crate::widgets::Scaffolding::group_box) + `card_row` + `row_title` +
5//! `meta_line` already does that, and does it better than a table would. Reach
6//! for this one only when the third column of every row has to line up, because
7//! reading *down* it is the point.
8//!
9//! Which is also the failure this component exists to prevent. A header and a
10//! body that size their own cells drift apart the moment either changes, and
11//! nothing catches it — both halves look right on their own. So the columns are
12//! declared once and shared:
13//!
14//! ```ignore
15//! const COLUMNS: &[Column] = ..;                     // one declaration
16//!
17//! table(&theme)
18//!     .child(header(&theme).children(COLUMNS.iter().enumerate().map(|(index, column)| {
19//!         header_cell(&theme, column, sorted_direction(index))
20//!             .id(("column", index))
21//!             .on_click(cx.listener(move |view, _, _, cx| view.sort_by(index, cx)))
22//!     })))
23//!     .children(rows.iter().enumerate().map(|(index, item)| {
24//!         row(&theme, COLUMNS, index == 0, false, vec![
25//!             item.name.clone().into_any_element(),
26//!             item.kind.clone().into_any_element(),
27//!         ])
28//!     }))
29//! ```
30//!
31//! Sorting is the caller's: [`next_sort`] says what a click on a heading means,
32//! the caller sorts its own rows, and this module paints the arrow. Nothing here
33//! holds data, so nothing here can hold it out of date.
34
35use gpui::{AnyElement, Pixels, SharedString, div, prelude::*, px, relative};
36
37use theme::{TextStyle, Theme, Typeset, ink};
38
39use crate::icons;
40
41/// How wide a column is: a fixed measure, or a share of what is left after the
42/// fixed ones have taken theirs.
43#[derive(Clone, Copy, Debug, PartialEq)]
44pub enum Width {
45    Fixed(Pixels),
46    Flex(f32),
47}
48
49/// Which edge a cell's content sits against.
50///
51/// There is no `Center`, deliberately: in a column of data it is almost always
52/// the wrong answer, and offering it is how tables end up with one.
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub enum Align {
55    Start,
56    End,
57}
58
59/// One column, declared once and handed to both the header and every row.
60#[derive(Clone, Debug)]
61pub struct Column {
62    pub label: SharedString,
63    pub width: Width,
64    pub align: Align,
65}
66
67impl Column {
68    pub fn new(label: impl Into<SharedString>, width: Width) -> Self {
69        Self {
70            label: label.into(),
71            width,
72            align: Align::Start,
73        }
74    }
75
76    /// Right-align this column — what a number wants, so its digits line up by
77    /// place value rather than by however wide the last one was.
78    pub fn align_end(mut self) -> Self {
79        self.align = Align::End;
80        self
81    }
82}
83
84/// Which column a table is sorted by, and which way.
85#[derive(Clone, Copy, Debug, PartialEq, Eq)]
86pub struct Sort {
87    pub column: usize,
88    pub ascending: bool,
89}
90
91/// What a click on `column`'s heading means: the sorted column reverses, any
92/// other column starts ascending.
93///
94/// Starting fresh rather than inheriting the previous column's direction is the
95/// part worth being deliberate about — carrying it over means clicking a new
96/// heading can sort it descending, which reads as the table ignoring the click.
97pub fn next_sort(current: Option<Sort>, column: usize) -> Sort {
98    match current {
99        Some(sort) if sort.column == column => Sort {
100            column,
101            ascending: !sort.ascending,
102        },
103        _ => Sort {
104            column,
105            ascending: true,
106        },
107    }
108}
109
110/// Horizontal padding on every cell, header and body alike — the one number
111/// that has to agree for columns to line up.
112const CELL_X: f32 = 12.0;
113
114/// The frame. Clipped, so a row's hover wash cannot square off the corners.
115pub fn table(theme: &Theme) -> gpui::Div {
116    div()
117        .flex()
118        .flex_col()
119        .w_full()
120        .rounded(px(Theme::panel_radius()))
121        .border_1()
122        .border_color(theme.border)
123        .overflow_hidden()
124}
125
126/// The heading strip. Fill [`header_cell`]s into it, one per column.
127pub fn header(theme: &Theme) -> gpui::Div {
128    div()
129        .flex()
130        .flex_row()
131        .items_center()
132        .w_full()
133        .bg(ink(0.03))
134        .border_b_1()
135        .border_color(theme.border)
136}
137
138/// One heading. `sorted` carries the direction when this is the sorted column,
139/// and `None` when it is not.
140///
141/// Returns a plain `Div` like the rest of this crate: a table that does not sort
142/// simply never adds the `.id`/`.on_click` that would make it.
143pub fn header_cell(theme: &Theme, column: &Column, sorted: Option<bool>) -> gpui::Div {
144    cell_frame(column)
145        .py(px(8.0))
146        .gap(px(4.0))
147        .text_style(TextStyle::Subheadline)
148        .font_weight(gpui::FontWeight::MEDIUM)
149        .text_color(if sorted.is_some() {
150            theme.text
151        } else {
152            theme.text_muted
153        })
154        .cursor_pointer()
155        .child(column.label.clone())
156        .when_some(sorted, |cell, ascending| {
157            cell.child(
158                icons::icon(if ascending {
159                    icons::glyph::ArrowUp
160                } else {
161                    icons::glyph::ArrowDown
162                })
163                .size(px(11.0))
164                .text_color(theme.text_muted),
165            )
166        })
167}
168
169/// One body row, its cells zipped onto the columns.
170///
171/// The zip is the whole point — a cell is never sized where it is written, so a
172/// row cannot drift from the header. `cells` shorter than `columns` is a bug in
173/// the caller rather than a shape to render, and the assert says so in debug
174/// builds; release truncates rather than panicking at a user.
175pub fn row(
176    theme: &Theme,
177    columns: &[Column],
178    first: bool,
179    selected: bool,
180    cells: Vec<AnyElement>,
181) -> gpui::Div {
182    debug_assert_eq!(
183        cells.len(),
184        columns.len(),
185        "a table row must have one cell per column"
186    );
187    let mut row = div()
188        .flex()
189        .flex_row()
190        .items_center()
191        .w_full()
192        .when(!first, |row| {
193            row.border_t_1().border_color(theme.border.opacity(0.6))
194        })
195        .text_style(TextStyle::Callout)
196        .text_color(theme.text);
197    row = if selected {
198        row.bg(theme.card_selected_bg())
199    } else {
200        // The same wash `card_row` uses, so a table and a card list read as one
201        // system rather than two.
202        row.hover(|s| s.bg(theme.element_hover))
203    };
204    row.children(
205        columns
206            .iter()
207            .zip(cells)
208            .map(|(column, content)| cell_frame(column).py(px(9.0)).child(content)),
209    )
210}
211
212/// Width and alignment from the column, and nothing else — the shared shape
213/// that makes a header cell and a body cell land in the same place.
214fn cell_frame(column: &Column) -> gpui::Div {
215    let cell = div()
216        .flex()
217        .flex_row()
218        .items_center()
219        .min_w_0()
220        .px(px(CELL_X))
221        .when(column.align == Align::End, |cell| cell.justify_end());
222    match column.width {
223        Width::Fixed(width) => cell.flex_none().w(width),
224        // A zero basis, so the share is of the whole remaining space rather than
225        // of whatever the content happens to measure.
226        Width::Flex(weight) => cell
227            .flex_grow(weight)
228            .flex_shrink(1.0)
229            .flex_basis(relative(0.0)),
230    }
231}