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}