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