makeover_tui/table.rs
1//! Column layout and row structure for tables.
2//!
3//! `makeover-webview`'s `list` module in the shape a terminal allows. It owns
4//! the same four things: which columns exist, how wide they are, which ones
5//! survive a narrow viewport, and what each part of a cell is. It does not own
6//! what goes in a cell, for the reason that module states: a cell holds whatever
7//! the app builds, and a description expressive enough to emit a task row's five
8//! nested spans is a templating language wearing a description's name.
9//!
10//! # What ratatui already answers
11//!
12//! Most of the drawing. [`ratatui::widgets::Table`] lays tracks out from
13//! [`Constraint`]s, draws a header, highlights a selected row and scrolls
14//! through [`TableState`](ratatui::widgets::TableState). So this is a mapping
15//! layer over it rather than a second table implementation, and it hands back a
16//! `Table` instead of painting one: selection and scroll belong to the app's
17//! state, and a function that painted would have to take that state to give it
18//! back.
19//!
20//! Two things ratatui does not answer, and they are what this module is:
21//!
22//! - **Content measurement.** There is no track that sizes to what is in it, so
23//! [`Width::Content`] is measured here from the cells and the heading.
24//! - **Narrowing.** A terminal window is resized far more often than a browser
25//! one, and [`Priority`] is how a column earns its place. See below.
26//!
27//! # Why positions are the bug
28//!
29//! Carried from the webview renderer verbatim, because the mistake is not a CSS
30//! mistake. goingson hides its mobile columns with `nth-child(n+5)` against a
31//! seven-column table; insert a column left of the cut and the wrong one
32//! disappears, silently, because nothing in the rule knows what column five
33//! *is*. A renderer narrows by raising a cutoff and never by counting, which is
34//! the whole reason [`Priority`] exists. `a_column_inserted_left_of_the_cut_does_not_change_what_drops`
35//! is that bug as a test.
36//!
37//! # What it costs when nothing fits
38//!
39//! [`Priority::Essential`] never drops, so a window narrower than the essential
40//! columns leaves them overflowing rather than emptying the table. That is
41//! deliberate: a row that cannot identify itself is not a narrower row, it is a
42//! different one, and ratatui truncates a cell it cannot fit. Truncated and
43//! present beats absent.
44//!
45//! # The table model in cells
46//!
47//! Wiki `table-model`, which the webview renderer draws in pixels. A cell is
48//! one line tall and an edge occupies a whole cell, so three of its facts are
49//! translated rather than copied:
50//!
51//! - **No hairlines and no frame.** A rule between rows costs a row, which
52//! halves what a screen shows. The stripe separates rows alone.
53//! - **No 45px row.** A row is one line. The block padding survives as a cell
54//! of ground at either end, [`TableStyle::column_spacing`] wide, the way
55//! `gap-group` pads both ends of a webview row.
56//! - **A cursor row.** A terminal has a cursor where a browser has a hover, and
57//! it has to read over the stripe at a glance; see [`TableStyle::selected`].
58//!
59//! The rest carries over as it is: rows on the raised ground with a stripe on
60//! alternate rows, the header as a sunken strip in secondary ink, and a table
61//! holding code keeping its header while dropping the stripe.
62
63use makeover_layout::{CellPart, Column, ColumnKind, Priority, Sort, Width};
64use ratatui::layout::Constraint;
65use ratatui::style::{Modifier, Style};
66use ratatui::text::Line;
67use ratatui::widgets::{Cell as TrackCell, Row, Table};
68
69/// The cutoffs, weakest first.
70///
71/// [`Priority`] is `#[non_exhaustive]` and a tier added upstream has to be added
72/// here in its place in the sequence, or a table will never narrow to it. Grep
73/// this when adopting a new `makeover-layout`, the way
74/// `makeover-webview`'s `part_class` asks to be grepped. The cost of missing one
75/// is a column that drops later than it should, which is visible, rather than a
76/// build that stops.
77const CUTOFFS: [Priority; 3] = [Priority::Optional, Priority::Secondary, Priority::Essential];
78
79/// The lengths the description deferred, in cells.
80///
81/// [`Width`] says `Content`, `Fixed` or `Fill` and carries no magnitude, because
82/// a magnitude is an answer for one renderer and the description is read by
83/// three. `makeover-webview`'s `Sizing` is this same type holding CSS lengths;
84/// this one holds terminal cells, and both are looked up by column name for the
85/// same reason: an app's columns are not all one size.
86#[derive(Debug, Clone, Copy, Default)]
87pub struct Sizing<'a> {
88 /// `(column name, cells)`. The track for a [`Width::Fixed`] column and the
89 /// floor for a [`Width::Fill`] one.
90 pub lengths: &'a [(&'a str, u16)],
91 /// Used for a column with no entry above.
92 pub fallback: u16,
93}
94
95impl Sizing<'_> {
96 /// The length for a named column.
97 fn length_for(&self, name: &str) -> u16 {
98 self.lengths
99 .iter()
100 .find(|(column, _)| *column == name)
101 .map_or(self.fallback, |(_, length)| *length)
102 }
103}
104
105/// One cell of a row.
106///
107/// The contents are a ratatui [`Line`] rather than a string, which is this
108/// crate's version of the webview `Cell` holding markup: the app owns what goes
109/// in the cell, spans and all, and says which column it belongs to by name.
110#[derive(Debug, Clone)]
111pub struct Cell<'a> {
112 /// Which column this fills, by name.
113 pub column: &'a str,
114 /// What the cell holds, when the whole cell is one thing.
115 ///
116 /// `None` for a cell mixing parts. A cell holding a value *and* a strip of
117 /// tokens *and* a control is three parts in one cell, and a terminal cell
118 /// has one style to give, so the app styles the spans itself. This field is
119 /// for the single-part case, which is the common one.
120 pub part: Option<CellPart>,
121 /// The contents.
122 pub content: Line<'a>,
123}
124
125impl<'a> Cell<'a> {
126 /// A cell with no cell part.
127 #[must_use]
128 pub fn new(column: &'a str, content: impl Into<Line<'a>>) -> Self {
129 Self {
130 column,
131 part: None,
132 content: content.into(),
133 }
134 }
135
136 /// The same cell, saying which part it is.
137 #[must_use]
138 pub fn part(mut self, part: CellPart) -> Self {
139 self.part = Some(part);
140 self
141 }
142}
143
144/// The tones and metrics a table draws with.
145///
146/// Apart from [`Palette`] rather than added to it, and the split is the one
147/// `makeover-immediate` draws between its palette and its `FieldStyle`:
148/// [`Palette`] answers what a *surface* is, which is what
149/// [`frame`](crate::frame) needs, and a table is the first thing in this crate
150/// that draws text. Folding text tones into [`Palette`] would make every
151/// consumer that only paints a bevel supply six colours it never uses.
152///
153/// [`from_theme`](Self::from_theme) is the answer for anyone with a loaded
154/// theme, and is what a consumer should reach for first.
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub struct TableStyle {
157 /// The heading row.
158 pub header: Style,
159 /// The heading of the column the table is ordered by.
160 pub sorted: Style,
161 /// The heading of a column that offers to reorder and is not doing it now.
162 ///
163 /// The middle of three tones (wiki `three-tone-convention`): it answers a
164 /// press, so it is neither the emphasised thing nor the inert one. A
165 /// heading that took [`header`](Self::header) here would be indistinguishable
166 /// from a column that cannot be reordered at all, which is the state this
167 /// separates it from.
168 pub sortable: Style,
169 /// A cell that is text.
170 pub value: Style,
171 /// A cell holding badges or chips. They carry their own tone, so this is
172 /// what sits under one rather than what paints it.
173 pub tokens: Style,
174 /// A cell holding controls.
175 pub actions: Style,
176 /// A cell whose value is itself a link.
177 pub link: Style,
178 /// The row under the cursor, for a caller rendering with a
179 /// [`TableState`](ratatui::widgets::TableState).
180 pub selected: Style,
181 /// What a row sits on.
182 pub ground: Style,
183 /// What every second row sits on, counting from the first body row.
184 ///
185 /// Not drawn in a table holding a [`ColumnKind::Code`] column, which is
186 /// read as source: one row per line, where a stripe breaks the reading.
187 pub stripe: Style,
188 /// Cells between columns, and the ground at either end of a row. Counted
189 /// when deciding what fits, so a table that narrows and a table that draws
190 /// agree about the room available.
191 pub column_spacing: u16,
192 /// The caret drawn after the heading of an ascending column.
193 ///
194 /// Defaults to [`Sort::glyph`], which is where the spelling lives now: three
195 /// renderers holding the same literal agreed by coincidence. Still a knob,
196 /// because a terminal is the one host that may not be able to draw it — a
197 /// font without the geometric-shapes block leaves a box, and `"^"` is a
198 /// better caret than a tofu.
199 ///
200 /// Bare, with no leading space: the gap is [`heading`]'s, written once for
201 /// all three states rather than baked into two strings and forgotten in the
202 /// third.
203 pub ascending: &'static str,
204 /// The caret drawn after the heading of a descending column.
205 pub descending: &'static str,
206}
207
208impl Default for TableStyle {
209 fn default() -> Self {
210 Self {
211 header: Style::new().add_modifier(Modifier::BOLD),
212 sorted: Style::new().add_modifier(Modifier::BOLD),
213 // Nothing of its own. A cell style patches the row's, so a colour
214 // is the only thing that could separate this from the header row it
215 // sits in, and the colourless default has none to spend: the idle
216 // caret is what says the heading answers a press. `from_theme` is
217 // where the three tones are real.
218 sortable: Style::new(),
219 value: Style::new(),
220 tokens: Style::new(),
221 actions: Style::new(),
222 link: Style::new().add_modifier(Modifier::UNDERLINED),
223 selected: Style::new().add_modifier(Modifier::REVERSED),
224 ground: Style::new(),
225 stripe: Style::new(),
226 column_spacing: 1,
227 ascending: Sort::Ascending.glyph(),
228 descending: Sort::Descending.glyph(),
229 }
230 }
231}
232
233impl TableStyle {
234 /// The house table, from a loaded theme.
235 ///
236 /// The table model (wiki `table-model`): rows on the raised ground with a
237 /// stripe, the heading a sunken strip in bold secondary ink with the ordered
238 /// column brought up to primary, actions and links on the action colour
239 /// rather than on the cell's text colour, and selection carried by the
240 /// background alone.
241 ///
242 /// Where the terminal cannot tell the strip from the ground, which is
243 /// sixteen colours on most themes, the heading is underlined instead: the
244 /// separation is what the strip is for, and a line is what is left to say
245 /// it with.
246 ///
247 /// Selection carries no foreground on purpose. A row can be red for a failed
248 /// upload or green for a published item, and repainting its text on
249 /// selection loses that distinction on exactly the row the user is looking
250 /// at. `mnw-cli`'s `selected_style` found this and its comment says so;
251 /// this is that comment's code, in the library, once.
252 #[cfg(feature = "theme")]
253 #[must_use]
254 pub fn from_theme(theme: &crate::Theme) -> Self {
255 let mut strip = Style::new()
256 .fg(theme.content_secondary)
257 .bg(theme.surface_sunken)
258 .add_modifier(Modifier::BOLD);
259 if !crate::Palette::shows(theme.surface_sunken, theme.surface_raised) {
260 strip = strip.add_modifier(Modifier::UNDERLINED);
261 }
262 Self {
263 header: strip,
264 sorted: Style::new()
265 .fg(theme.content_primary)
266 .add_modifier(Modifier::BOLD),
267 // The strip's own ink. Offering and inert differ by the idle caret,
268 // which is the webview's split too: the strip separates the header
269 // now, so no heading has to stay quiet by being pale.
270 sortable: Style::new().fg(theme.content_secondary),
271 value: Style::new().fg(theme.content_primary),
272 // A token paints its own background, and a tone underneath it would
273 // fight the one sitting on it. Secondary is what shows through the
274 // gaps.
275 tokens: Style::new().fg(theme.content_secondary),
276 actions: Style::new().fg(theme.action_primary),
277 link: Style::new()
278 .fg(theme.action_primary)
279 .add_modifier(Modifier::UNDERLINED),
280 selected: Style::new()
281 .bg(theme.row_selected)
282 .add_modifier(Modifier::BOLD),
283 ground: Style::new().bg(theme.surface_raised),
284 stripe: Style::new().bg(theme.row_stripe),
285 column_spacing: 1,
286 ascending: Sort::Ascending.glyph(),
287 descending: Sort::Descending.glyph(),
288 }
289 }
290
291 /// The style a cell of this part takes.
292 ///
293 /// [`CellPart`] is `#[non_exhaustive]`, and a member added upstream lands on
294 /// [`value`](Self::value): a part this renderer has not learned draws as
295 /// text, which is a cell rendering plainly rather than a build that stops.
296 /// Grep this when adopting a new `makeover-layout`.
297 #[must_use]
298 pub fn for_part(&self, part: Option<CellPart>) -> Style {
299 match part {
300 Some(CellPart::Tokens) => self.tokens,
301 Some(CellPart::Actions) => self.actions,
302 Some(CellPart::Link) => self.link,
303 _ => self.value,
304 }
305 }
306}
307
308/// A line placed the way its column's kind says, wiki `table-model`.
309///
310/// Alignment is the one kind fact a terminal has to act on. Every cell is
311/// already the monospace face with even figures, and a terminal wraps nothing
312/// it is not told to, so a number or an actions column aligning to its end is
313/// what is left. The heading takes the same, so a label sits over its figures.
314fn aligned<'a>(column: &Column<'a>, line: Line<'a>) -> Line<'a> {
315 if column.kind.aligns_end() {
316 line.right_aligned()
317 } else {
318 line
319 }
320}
321
322/// The heading, in capitals, with the caret if this column is ordered by or
323/// offers to be.
324///
325/// Capitals because the strip's label is set that way at every host. The
326/// webview sets it small and tracked as well, and a cell has neither to give,
327/// so the strip and the weight carry the rest.
328///
329/// A column [`sorted`](Column::sorted) but not
330/// [`sortable`](Column::sortable) still gets its caret. Both combinations mean
331/// something, which is why the description holds the two fields apart: a list
332/// ordered by a key the user cannot change is a real thing, and the caret is how
333/// it says so.
334///
335/// A column sortable and *not* sorted draws the idle mark, in the ascending
336/// spelling because that is the direction a first press takes. The tone is what
337/// separates it from the column in force, and [`header`] picks that; here the
338/// point is the width. This is what closes the reflow: pressing a heading used
339/// to widen its column by two cells and shift every column after it, because
340/// [`measure`] sizes from this function and the caret appeared with the press.
341fn heading(column: &Column<'_>, style: &TableStyle) -> Line<'static> {
342 let label = column.name.to_uppercase();
343 let caret = match column.sorted {
344 Some(Sort::Ascending) => style.ascending,
345 Some(Sort::Descending) => style.descending,
346 None if column.sortable => style.ascending,
347 None => return Line::from(label),
348 };
349 // The gap, once, rather than inside each of the two style strings. A
350 // consumer swapping the glyph for an ASCII one does not have to remember to
351 // bring a space with it.
352 Line::from(format!("{label} {caret}"))
353}
354
355/// The ground at either end of a row: a track of no width, which the table's
356/// column spacing then separates from the first and last columns.
357///
358/// A track rather than a space in the first and last cells, because which
359/// columns are first and last moves as the table narrows, and an end-aligned
360/// last column would have to know to leave its space behind.
361const EDGE: Constraint = Constraint::Length(0);
362
363/// A row's cells between its two edges.
364///
365/// `Cell::new("")` and never `Cell::default()`: ratatui derives the default
366/// with a column span of zero, and a cell spanning nothing takes no track, so
367/// every cell after it lands one column to the left.
368fn edged<'a>(cells: impl Iterator<Item = TrackCell<'a>>) -> Vec<TrackCell<'a>> {
369 std::iter::once(TrackCell::new(""))
370 .chain(cells)
371 .chain(std::iter::once(TrackCell::new("")))
372 .collect()
373}
374
375/// The widest thing in a column, heading included.
376///
377/// The heading counts because it is drawn: a column sized to its cells alone
378/// truncates its own name, and a two-character column called `duration` reads as
379/// `du`. The caret counts for the same reason, which is why this measures
380/// [`heading`] rather than [`Column::name`].
381fn measure<'a, R>(column: &Column<'a>, rows: &[R], style: &TableStyle) -> u16
382where
383 R: AsRef<[Cell<'a>]>,
384{
385 let widest = rows
386 .iter()
387 .filter_map(|row| {
388 row.as_ref()
389 .iter()
390 .find(|cell| cell.column == column.name)
391 .map(|cell| cell.content.width())
392 })
393 .max()
394 .unwrap_or(0);
395 u16::try_from(widest.max(heading(column, style).width())).unwrap_or(u16::MAX)
396}
397
398/// Whether the columns kept at `cutoff` fit in `width`.
399///
400/// Budgeted at each column's declared [`floor`](Column::floor), never at its
401/// measured width. A cell is one `ch`, so this is the same number the webview
402/// hides a column under and the immediate-mode painter budgets in points, which
403/// is what makes the three hosts drop a column at one declared width. What a
404/// kept track is drawn at is still measured, in [`constraints`].
405fn fits(columns: &[Column<'_>], style: &TableStyle, cutoff: Priority, width: u16) -> bool {
406 let kept = columns.iter().filter(|c| c.kept_at(cutoff));
407 // One gap either side of every column, since the two edges are tracks.
408 let (count, floors) = kept.fold((0u32, 0u32), |(n, sum), c| {
409 (n + 1, sum + u32::from(c.floor()))
410 });
411 let gaps = u32::from(style.column_spacing) * (count + 1);
412 floors + gaps <= u32::from(width)
413}
414
415/// The weakest cutoff whose columns fit in `width`.
416///
417/// Raised until the layout fits, and never past [`Priority::Essential`]: the
418/// essential columns are what makes a row identify itself, so a window too
419/// narrow for them gets them truncated rather than dropped. Nothing here counts
420/// positions, so which column drops is a property of the column.
421#[must_use]
422pub fn cutoff_for(columns: &[Column<'_>], style: &TableStyle, width: u16) -> Priority {
423 for cutoff in CUTOFFS {
424 if fits(columns, style, cutoff, width) {
425 return cutoff;
426 }
427 }
428 Priority::Essential
429}
430
431/// The tracks for the columns kept at `cutoff`.
432///
433/// Only the surviving tracks, which is what keeps the track list and the hiding
434/// in agreement. A caller that dropped a cell but left its track would get a
435/// column of empty space, which is the other half of the goingson bug the
436/// webview renderer's `grid_template_columns` names.
437///
438/// Bracketed by the two edge tracks, which [`row`] and [`header`] fill, so the
439/// list is two longer than the columns kept.
440#[must_use]
441pub fn constraints<'a, R>(
442 columns: &[Column<'a>],
443 rows: &[R],
444 sizing: &Sizing<'_>,
445 style: &TableStyle,
446 cutoff: Priority,
447) -> Vec<Constraint>
448where
449 R: AsRef<[Cell<'a>]>,
450{
451 let tracks = columns
452 .iter()
453 .filter(|column| column.kept_at(cutoff))
454 .map(|column| match column.width {
455 // Takes what it needs and no more, which is a fixed track once the
456 // needing has been measured.
457 Width::Content => Constraint::Length(measure(column, rows, style)),
458 Width::Fixed => Constraint::Length(sizing.length_for(column.name)),
459 // `Min` and not `Fill`: a fill column absorbs the slack *and* keeps
460 // its floor, which is what `minmax(len, 1fr)` says at the webview
461 // renderer. `Fill` would let it collapse below the floor when a
462 // fixed column takes the room.
463 _ => Constraint::Min(sizing.length_for(column.name)),
464 });
465 std::iter::once(EDGE)
466 .chain(tracks)
467 .chain(std::iter::once(EDGE))
468 .collect()
469}
470
471/// One row's cells, in column order, on the ground.
472///
473/// The ground and not the stripe, since a row does not know where it falls.
474/// [`table`] lays the stripe over every second row; a caller assembling its
475/// own [`Table`] takes [`TableStyle::stripe`] for those rows itself, or asks
476/// [`striped`].
477///
478/// Ordered by the columns and not by the cells, so a row cannot silently
479/// disagree with its table about what comes where. A column with no cell gets an
480/// empty cell, which keeps the tracks aligned; a cell naming no column is
481/// dropped, because there is nowhere to put it. That is
482/// `makeover-webview`'s `cells_html` rule, and it has to be the same rule or the
483/// two renderers disagree about a row they were handed identically.
484#[must_use]
485pub fn row<'a>(
486 columns: &[Column<'a>],
487 cells: &[Cell<'a>],
488 style: &TableStyle,
489 cutoff: Priority,
490) -> Row<'a> {
491 Row::new(edged(
492 columns
493 .iter()
494 .filter(|column| column.kept_at(cutoff))
495 .map(|column| {
496 let found = cells.iter().find(|cell| cell.column == column.name);
497 let part = found.and_then(|cell| cell.part);
498 let content = found.map_or_else(Line::default, |cell| cell.content.clone());
499 TrackCell::from(aligned(column, content)).style(style.for_part(part))
500 }),
501 ))
502 .style(style.ground)
503}
504
505/// The heading row for the columns kept at `cutoff`.
506///
507/// Exposed beside [`table`] because a caller assembling its own
508/// [`Table`] still has to draw a header that agrees with the body about what
509/// just disappeared. Assembling it a second time by hand is how they stop
510/// agreeing.
511#[must_use]
512pub fn header<'a>(columns: &[Column<'a>], style: &TableStyle, cutoff: Priority) -> Row<'a> {
513 Row::new(edged(
514 columns
515 .iter()
516 .filter(|column| column.kept_at(cutoff))
517 .map(|column| {
518 // Three states, three tones (wiki `three-tone-convention`). In
519 // force, offering, and not a control at all -- and the middle
520 // one is the state that had nowhere to be said, so a heading
521 // you could press looked exactly like one you could not.
522 let tone = match (column.sorted, column.sortable) {
523 (Some(_), _) => style.sorted,
524 (None, true) => style.sortable,
525 (None, false) => style.header,
526 };
527 TrackCell::from(aligned(column, heading(column, style))).style(tone)
528 }),
529 ))
530 .style(style.header)
531}
532
533/// A described table, sized and narrowed for `width`.
534///
535/// Hands back a [`Table`] rather than drawing one. Selection and scroll live in
536/// the app's [`TableState`](ratatui::widgets::TableState), and the row highlight
537/// is already set from [`TableStyle::selected`], so a caller renders this with
538/// `render_stateful_widget` and gets the house selection without saying anything
539/// further.
540///
541/// `width` is the area the table will be drawn in, which is what narrowing is
542/// decided against. Pass the [`Rect`](ratatui::layout::Rect) width that
543/// [`frame`](crate::frame) handed back rather than the region's own, or the
544/// table budgets for the two cells the edge took.
545#[must_use]
546pub fn table<'a, R>(
547 columns: &[Column<'a>],
548 rows: &[R],
549 sizing: &Sizing<'_>,
550 style: &TableStyle,
551 width: u16,
552) -> Table<'a>
553where
554 R: AsRef<[Cell<'a>]>,
555{
556 let cutoff = cutoff_for(columns, style, width);
557 let widths = constraints(columns, rows, sizing, style, cutoff);
558 let striped = striped(columns, cutoff);
559 let body: Vec<Row<'a>> = rows
560 .iter()
561 .enumerate()
562 .map(|(index, cells)| {
563 let drawn = row(columns, cells.as_ref(), style, cutoff);
564 if striped && index % 2 == 1 {
565 drawn.style(style.stripe)
566 } else {
567 drawn
568 }
569 })
570 .collect();
571
572 Table::new(body, widths)
573 .header(header(columns, style, cutoff))
574 .column_spacing(style.column_spacing)
575 .row_highlight_style(style.selected)
576}
577
578/// Whether the rows of a table with these columns take the stripe.
579///
580/// False for a table holding code at `cutoff`, which keeps its header and
581/// drops the record treatment, as the webview renderer's code table does.
582#[must_use]
583pub fn striped(columns: &[Column<'_>], cutoff: Priority) -> bool {
584 !columns
585 .iter()
586 .any(|column| column.kept_at(cutoff) && column.kind == ColumnKind::Code)
587}
588
589/// Whether a table drawn at `width` would leave anything overflowing.
590///
591/// True only when the essential columns alone do not fit, since that is the one
592/// case narrowing cannot answer. A caller that would rather show fewer rows than
593/// truncate a cell can ask this and draw something else.
594#[must_use]
595pub fn overflows(columns: &[Column<'_>], style: &TableStyle, width: u16) -> bool {
596 !fits(columns, style, Priority::Essential, width)
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602
603 fn columns() -> Vec<Column<'static>> {
604 vec![
605 Column {
606 name: "name",
607 width: Width::Fill,
608 priority: Priority::Essential,
609 kind: ColumnKind::Text,
610 min: None,
611 sortable: true,
612 sorted: Some(Sort::Ascending),
613 },
614 Column {
615 name: "size",
616 width: Width::Fixed,
617 priority: Priority::Secondary,
618 kind: ColumnKind::Text,
619 min: None,
620 sortable: true,
621 sorted: None,
622 },
623 Column {
624 name: "note",
625 width: Width::Content,
626 priority: Priority::Optional,
627 kind: ColumnKind::Text,
628 min: None,
629 sortable: false,
630 sorted: None,
631 },
632 ]
633 }
634
635 fn sizing() -> Sizing<'static> {
636 Sizing {
637 lengths: &[("name", 10), ("size", 6)],
638 fallback: 4,
639 }
640 }
641
642 fn rows() -> Vec<Vec<Cell<'static>>> {
643 vec![
644 vec![
645 Cell::new("name", "alpha"),
646 Cell::new("size", "1kb"),
647 Cell::new("note", "a longer note"),
648 ],
649 vec![Cell::new("name", "beta"), Cell::new("size", "2kb")],
650 ]
651 }
652
653 fn cell_text(row: &Row<'_>) -> Vec<String> {
654 // Rendering is the only way to read a ratatui Row back, and reading it
655 // back is the point: these tests assert what a user sees.
656 use ratatui::layout::Rect;
657 use ratatui::widgets::Widget;
658 let mut buf = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 60, 1));
659 Table::new(vec![row.clone()], tracks())
660 .column_spacing(1)
661 .render(Rect::new(0, 0, 60, 1), &mut buf);
662 (0..3)
663 .map(|i| {
664 let start = 1 + i * 19;
665 (start..start + 18)
666 .map(|x| buf[(x as u16, 0)].symbol())
667 .collect::<String>()
668 .trim_end()
669 .to_owned()
670 })
671 .collect()
672 }
673
674 /// The foreground each of the three heading cells was drawn in.
675 ///
676 /// Read off a rendered buffer for [`cell_text`]'s reason: a ratatui `Row`
677 /// hands nothing back, and what is asserted is what a user sees.
678 fn cell_colors(row: &Row<'_>) -> Vec<Option<ratatui::style::Color>> {
679 use ratatui::layout::Rect;
680 use ratatui::widgets::Widget;
681 let mut buf = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 60, 1));
682 Table::new(vec![row.clone()], tracks())
683 .column_spacing(1)
684 .render(Rect::new(0, 0, 60, 1), &mut buf);
685 (0..3).map(|i| buf[(1 + i * 19, 0)].fg).map(Some).collect()
686 }
687
688 /// Three columns of 18 between the two edges, which puts column `i` at
689 /// `1 + i * 19`.
690 fn tracks() -> [Constraint; 5] {
691 [
692 EDGE,
693 Constraint::Length(18),
694 Constraint::Length(18),
695 Constraint::Length(18),
696 EDGE,
697 ]
698 }
699
700 #[test]
701 fn cells_are_ordered_by_the_columns_and_not_by_the_row() {
702 // The row hands them over backwards. The table decides the order, which
703 // is what stops a row silently disagreeing with its own header.
704 let cols = columns();
705 let out_of_order = vec![
706 Cell::new("note", "third"),
707 Cell::new("name", "first"),
708 Cell::new("size", "second"),
709 ];
710 let drawn = row(
711 &cols,
712 &out_of_order,
713 &TableStyle::default(),
714 Priority::Optional,
715 );
716 assert_eq!(cell_text(&drawn), vec!["first", "second", "third"]);
717 }
718
719 #[test]
720 fn a_cell_naming_no_column_is_dropped_and_a_column_with_no_cell_keeps_its_place() {
721 let cols = columns();
722 let cells = vec![Cell::new("note", "kept"), Cell::new("nonesuch", "lost")];
723 let drawn = row(&cols, &cells, &TableStyle::default(), Priority::Optional);
724 // Two empty tracks, then the note. The empties are what keeps the third
725 // column under the third heading.
726 assert_eq!(cell_text(&drawn), vec!["", "", "kept"]);
727 }
728
729 #[test]
730 fn a_content_column_is_measured_from_its_widest_cell() {
731 let style = TableStyle::default();
732 let widths = constraints(&columns(), &rows(), &sizing(), &style, Priority::Optional);
733 assert_eq!(widths[3], Constraint::Length("a longer note".len() as u16));
734 }
735
736 #[test]
737 fn a_content_column_never_truncates_its_own_heading() {
738 // The cells are two characters wide and the heading is eight. Sizing to
739 // the cells alone would draw the column as `du`.
740 let cols = vec![Column {
741 name: "duration",
742 width: Width::Content,
743 priority: Priority::Essential,
744 kind: ColumnKind::Text,
745 min: None,
746 sortable: false,
747 sorted: None,
748 }];
749 let rows = vec![vec![Cell::new("duration", "3s")]];
750 let widths = constraints(
751 &cols,
752 &rows,
753 &sizing(),
754 &TableStyle::default(),
755 Priority::Optional,
756 );
757 assert_eq!(widths[1], Constraint::Length(8));
758 }
759
760 #[test]
761 fn a_caret_is_part_of_what_a_heading_costs() {
762 // Measured off `heading` and not off `name`, or the sorted column is
763 // exactly two cells too narrow and drops its own arrow.
764 let cols = vec![Column {
765 name: "size",
766 width: Width::Content,
767 priority: Priority::Essential,
768 kind: ColumnKind::Text,
769 min: None,
770 sortable: true,
771 sorted: Some(Sort::Descending),
772 }];
773 let rows: Vec<Vec<Cell<'_>>> = vec![];
774 let style = TableStyle::default();
775 let widths = constraints(&cols, &rows, &sizing(), &style, Priority::Optional);
776 assert_eq!(
777 widths[1],
778 Constraint::Length(6),
779 "size plus a space and a caret"
780 );
781 }
782
783 #[test]
784 fn narrowing_drops_the_optional_column_first_and_the_essential_one_never() {
785 let style = TableStyle::default();
786 let cols = columns();
787 // Everything: floors of 16 (the name fills), 8 and 8, and a cell of
788 // spacing either side of each, which is 32 + 4.
789 assert_eq!(cutoff_for(&cols, &style, 36), Priority::Optional);
790 assert_eq!(cutoff_for(&cols, &style, 35), Priority::Secondary);
791 // No room for the note: 24 + 3.
792 assert_eq!(cutoff_for(&cols, &style, 27), Priority::Secondary);
793 // No room for the size either: 16 + 2.
794 assert_eq!(cutoff_for(&cols, &style, 26), Priority::Essential);
795 assert_eq!(cutoff_for(&cols, &style, 18), Priority::Essential);
796 // No room for anything, and the essential column stays anyway.
797 assert_eq!(cutoff_for(&cols, &style, 2), Priority::Essential);
798 assert!(overflows(&cols, &style, 2));
799 assert!(!overflows(&cols, &style, 18));
800 }
801
802 #[test]
803 fn a_declared_minimum_moves_the_cut_and_a_measured_cell_does_not() {
804 // The floor is what the three hosts agree on, so a long value in a cell
805 // must not change where a column drops, and a declared minimum must.
806 let style = TableStyle::default();
807 let mut cols = columns();
808 assert_eq!(cutoff_for(&cols, &style, 36), Priority::Optional);
809 cols[2] = cols[2].min(20);
810 assert_eq!(cutoff_for(&cols, &style, 36), Priority::Secondary);
811 assert_eq!(cutoff_for(&cols, &style, 48), Priority::Optional);
812 }
813
814 #[test]
815 fn a_column_inserted_left_of_the_cut_does_not_change_what_drops() {
816 // The goingson bug, as a test. `nth-child(n+5)` against a seven-column
817 // table hides whatever lands at position five, so inserting a column
818 // anywhere left of the cut moves it onto a different column with nothing
819 // edited and nothing reported.
820 //
821 // Asserted at a fixed cutoff, because that is where the two ways of
822 // addressing a column disagree. A narrower budget SHOULD drop more
823 // columns, and does below; what must not change is which ones, in what
824 // order, for a given cutoff.
825 let dropped = |cols: &[Column<'_>], cutoff| -> Vec<String> {
826 cols.iter()
827 .filter(|c| !c.kept_at(cutoff))
828 .map(|c| c.name.to_owned())
829 .collect()
830 };
831 let before = columns();
832 let mut after = vec![Column {
833 name: "mark",
834 width: Width::Fixed,
835 priority: Priority::Essential,
836 kind: ColumnKind::Text,
837 min: None,
838 sortable: false,
839 sorted: None,
840 }];
841 after.extend(columns());
842
843 for cutoff in CUTOFFS {
844 assert_eq!(
845 dropped(&before, cutoff),
846 dropped(&after, cutoff),
847 "inserting a column changed what {cutoff:?} drops"
848 );
849 }
850 assert_eq!(dropped(&before, Priority::Secondary), vec!["note"]);
851 }
852
853 #[test]
854 fn a_column_never_outlives_a_more_essential_one() {
855 // The ordering claim narrowing rests on: whatever the budget, the set
856 // kept is closed upward. A layout that dropped `size` while keeping
857 // `note` would be counting something other than priority.
858 let style = TableStyle::default();
859 let cols = columns();
860 for width in 0..48u16 {
861 let cutoff = cutoff_for(&cols, &style, width);
862 let kept: Vec<&str> = cols
863 .iter()
864 .filter(|c| c.kept_at(cutoff))
865 .map(|c| c.name)
866 .collect();
867 assert!(
868 kept.contains(&"name"),
869 "the essential column left at {width}"
870 );
871 if kept.contains(&"note") {
872 assert!(
873 kept.contains(&"size"),
874 "optional outlived secondary at {width}"
875 );
876 }
877 }
878 }
879
880 #[test]
881 fn a_dropped_column_takes_its_track_with_it() {
882 // A cell hidden with its track left behind is a column of empty space,
883 // which is the half of the goingson bug that survives fixing the other.
884 let style = TableStyle::default();
885 let widths = constraints(&columns(), &rows(), &sizing(), &style, Priority::Secondary);
886 assert_eq!(widths.len(), 4, "two columns and the two edges");
887 let drawn = row(&columns(), &rows()[0], &style, Priority::Secondary);
888 assert_eq!(cell_text(&drawn), vec!["alpha", "1kb", ""]);
889 }
890
891 #[test]
892 fn a_fill_column_keeps_its_floor_while_taking_the_slack() {
893 // `Min` and not `Fill`, which is `minmax(10, 1fr)` at the webview
894 // renderer. A `Fill` track collapses under a fixed neighbour.
895 let style = TableStyle::default();
896 let widths = constraints(&columns(), &rows(), &sizing(), &style, Priority::Optional);
897 assert_eq!(widths[1], Constraint::Min(10));
898 assert_eq!(widths[2], Constraint::Length(6));
899 }
900
901 #[test]
902 fn a_column_with_no_length_of_its_own_takes_the_fallback() {
903 let cols = vec![Column {
904 name: "unlisted",
905 width: Width::Fixed,
906 priority: Priority::Essential,
907 kind: ColumnKind::Text,
908 min: None,
909 sortable: false,
910 sorted: None,
911 }];
912 let rows: Vec<Vec<Cell<'_>>> = vec![];
913 let widths = constraints(
914 &cols,
915 &rows,
916 &sizing(),
917 &TableStyle::default(),
918 Priority::Optional,
919 );
920 assert_eq!(widths[1], Constraint::Length(4));
921 }
922
923 #[test]
924 fn a_heading_carries_a_caret_when_it_is_ordered_by_or_offers_to_be() {
925 let style = TableStyle::default();
926 let head = header(&columns(), &style, Priority::Optional);
927 assert_eq!(
928 cell_text(&head),
929 vec!["NAME \u{25B2}", "SIZE \u{25B2}", "NOTE"],
930 "in force and offering both carry one; not a control carries none"
931 );
932 }
933
934 #[test]
935 fn the_three_states_of_a_heading_are_three_tones() {
936 // wiki `three-tone-convention`. The middle state is the one that had
937 // nowhere to be said: a heading you can press looked exactly like one
938 // you cannot, and the idle caret alone does not separate them, because
939 // a sorted-but-unsortable column draws a caret too.
940 use ratatui::style::Color;
941 let style = TableStyle {
942 sorted: Style::new().fg(Color::Red),
943 sortable: Style::new().fg(Color::Green),
944 header: Style::new().fg(Color::Blue),
945 ..TableStyle::default()
946 };
947 let drawn = cell_colors(&header(&columns(), &style, Priority::Optional));
948 assert_eq!(
949 drawn,
950 vec![Some(Color::Red), Some(Color::Green), Some(Color::Blue)]
951 );
952
953 // The colourless default separates them by the caret and nothing else,
954 // and that is the honest limit rather than an oversight: a cell style
955 // patches the row's, so a plain cell under a bold header row is drawn
956 // bold whatever it holds. Three tones need three colours, which is what
957 // `from_theme` is for.
958 let house = TableStyle::default();
959 let plain = cell_colors(&header(&columns(), &house, Priority::Optional));
960 assert_eq!(plain[0], plain[1], "no colour to spend, so none is claimed");
961 }
962
963 #[test]
964 fn pressing_a_heading_does_not_move_the_columns_after_it() {
965 // The reflow the idle caret closes. `measure` sizes from `heading`, so
966 // a caret that appeared with the press widened its own column by two
967 // cells and shifted the rest of the row sideways.
968 let style = TableStyle::default();
969 let offering = Column {
970 name: "size",
971 width: Width::Content,
972 priority: Priority::Secondary,
973 kind: ColumnKind::Text,
974 min: None,
975 sortable: true,
976 sorted: None,
977 };
978 let in_force = Column {
979 sorted: Some(Sort::Descending),
980 ..offering
981 };
982 let rows: Vec<Vec<Cell<'_>>> = vec![];
983 assert_eq!(
984 measure(&offering, &rows, &style),
985 measure(&in_force, &rows, &style)
986 );
987
988 // And the column that is not a control at all is narrower, which is the
989 // width that would be wrong to reserve: it has no caret to draw.
990 let inert = Column {
991 sortable: false,
992 ..offering
993 };
994 assert!(measure(&inert, &rows, &style) < measure(&offering, &rows, &style));
995 }
996
997 #[test]
998 fn a_column_sorted_without_being_sortable_still_draws_its_caret() {
999 // A list ordered by a key the user cannot change is a real thing to
1000 // describe, which is why the description holds the two fields apart.
1001 // Drawing the caret only for a sortable column would collapse them.
1002 let cols = vec![Column {
1003 name: "rank",
1004 width: Width::Content,
1005 priority: Priority::Essential,
1006 kind: ColumnKind::Text,
1007 min: None,
1008 sortable: false,
1009 sorted: Some(Sort::Descending),
1010 }];
1011 let head = header(&cols, &TableStyle::default(), Priority::Optional);
1012 assert_eq!(cell_text(&head), vec!["RANK \u{25BC}", "", ""]);
1013 }
1014
1015 #[test]
1016 fn the_parts_a_cell_can_be_are_styled_apart() {
1017 // The drift `CellPart` exists to end: one style for a whole cell paints
1018 // a control as though it were text.
1019 let style = TableStyle::default();
1020 assert_eq!(style.for_part(Some(CellPart::Value)), style.value);
1021 assert_eq!(style.for_part(Some(CellPart::Tokens)), style.tokens);
1022 assert_eq!(style.for_part(Some(CellPart::Actions)), style.actions);
1023 assert_eq!(style.for_part(Some(CellPart::Link)), style.link);
1024 assert_ne!(style.for_part(Some(CellPart::Link)), style.value);
1025 // A cell mixing parts says nothing, and takes the text style.
1026 assert_eq!(style.for_part(None), style.value);
1027 }
1028
1029 #[test]
1030 fn a_table_narrows_itself_from_the_width_it_is_given() {
1031 // The whole path in one call, which is what a consumer actually uses.
1032 let style = TableStyle::default();
1033 let wide = table(&columns(), &rows(), &sizing(), &style, 40);
1034 let narrow = table(&columns(), &rows(), &sizing(), &style, 20);
1035 use ratatui::layout::Rect;
1036 use ratatui::widgets::Widget;
1037
1038 let mut buf = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 40, 3));
1039 wide.render(Rect::new(0, 0, 40, 3), &mut buf);
1040 let head: String = (0..40).map(|x| buf[(x, 0)].symbol()).collect();
1041 assert!(head.contains("NOTE"));
1042
1043 let mut buf = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 20, 3));
1044 narrow.render(Rect::new(0, 0, 20, 3), &mut buf);
1045 let head: String = (0..20).map(|x| buf[(x, 0)].symbol()).collect();
1046 assert!(!head.contains("NOTE"), "the optional column is gone");
1047 assert!(head.contains("NAME"), "the essential one is not");
1048 }
1049
1050 #[test]
1051 fn selection_is_carried_by_the_background_alone() {
1052 // A row can be red for a failure or green for a success, and a
1053 // foreground on the selection loses that on exactly the row being looked
1054 // at. Asserted on the default so a caller who supplies no theme still
1055 // gets the rule.
1056 let style = TableStyle::default();
1057 assert!(style.selected.fg.is_none());
1058 }
1059
1060 #[test]
1061 fn a_number_column_aligns_its_cells_and_heading_to_the_end() {
1062 let mut amount = Column::new("Amount");
1063 amount.kind = ColumnKind::Number;
1064 let prose = Column::new("Buyer");
1065 assert_eq!(
1066 aligned(&amount, Line::from("9.99")).alignment,
1067 Some(ratatui::layout::Alignment::Right)
1068 );
1069 assert_eq!(aligned(&prose, Line::from("ada")).alignment, None);
1070 }
1071
1072 #[test]
1073 fn a_row_keeps_a_cell_of_ground_at_either_end() {
1074 // The block padding, in cells. The first value does not touch the
1075 // table's left edge and an end-aligned last column does not touch its
1076 // right, whichever columns narrowing left first and last.
1077 use ratatui::layout::Rect;
1078 use ratatui::widgets::Widget;
1079 // Declared narrow, so both columns are kept in twelve cells: this is
1080 // about the ground at the ends, not about narrowing.
1081 let cols = vec![Column::new("a").min(2), {
1082 let mut n = Column::new("n").min(2);
1083 n.kind = ColumnKind::Number;
1084 n.width = Width::Fill;
1085 n
1086 }];
1087 let rows = vec![vec![Cell::new("a", "x"), Cell::new("n", "9")]];
1088 let drawn = table(&cols, &rows, &sizing(), &TableStyle::default(), 12);
1089 let mut buf = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 12, 2));
1090 drawn.render(Rect::new(0, 0, 12, 2), &mut buf);
1091 let body: String = (0..12).map(|x| buf[(x, 1)].symbol()).collect();
1092 assert!(body.starts_with(" x"), "{body:?}");
1093 assert!(body.ends_with("9 "), "{body:?}");
1094 }
1095
1096 #[test]
1097 fn every_second_row_takes_the_stripe_and_a_code_table_takes_none() {
1098 use ratatui::layout::Rect;
1099 use ratatui::style::Color;
1100 use ratatui::widgets::Widget;
1101 let style = TableStyle {
1102 ground: Style::new().bg(Color::Blue),
1103 stripe: Style::new().bg(Color::Green),
1104 ..TableStyle::default()
1105 };
1106 let grounds = |cols: &[Column<'_>]| -> Vec<Color> {
1107 let rows: Vec<Vec<Cell<'_>>> = (0..3).map(|_| vec![Cell::new("a", "x")]).collect();
1108 let mut buf = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 8, 4));
1109 table(cols, &rows, &sizing(), &style, 8).render(Rect::new(0, 0, 8, 4), &mut buf);
1110 (1..4).map(|y| buf[(0, y)].bg).collect()
1111 };
1112 let records = vec![Column::new("a").min(2)];
1113 assert_eq!(
1114 grounds(&records),
1115 vec![Color::Blue, Color::Green, Color::Blue]
1116 );
1117
1118 let mut code = Column::new("a").min(2);
1119 code.kind = ColumnKind::Code;
1120 assert_eq!(grounds(&[code]), vec![Color::Blue; 3]);
1121 }
1122}