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 /// A cell that is text.
144 pub value: Style,
145 /// A cell holding badges or chips. They carry their own tone, so this is
146 /// what sits under one rather than what paints it.
147 pub tokens: Style,
148 /// A cell holding controls.
149 pub actions: Style,
150 /// A cell whose value is itself a link.
151 pub link: Style,
152 /// The row under the cursor, for a caller rendering with a
153 /// [`TableState`](ratatui::widgets::TableState).
154 pub selected: Style,
155 /// Cells between columns. Counted when deciding what fits, so a table that
156 /// narrows and a table that draws agree about the room available.
157 pub column_spacing: u16,
158 /// Drawn after the heading of an ascending column.
159 pub ascending: &'static str,
160 /// Drawn after the heading of a descending column.
161 pub descending: &'static str,
162}
163
164impl Default for TableStyle {
165 fn default() -> Self {
166 Self {
167 header: Style::new().add_modifier(Modifier::BOLD),
168 sorted: Style::new().add_modifier(Modifier::BOLD),
169 value: Style::new(),
170 tokens: Style::new(),
171 actions: Style::new(),
172 link: Style::new().add_modifier(Modifier::UNDERLINED),
173 selected: Style::new().add_modifier(Modifier::REVERSED),
174 column_spacing: 1,
175 // The pair audiofiles already draws, so a sorted column points the
176 // same way in a terminal as it does in the egui browser.
177 ascending: " \u{25B2}",
178 descending: " \u{25BC}",
179 }
180 }
181}
182
183impl TableStyle {
184 /// The house table, from a loaded theme.
185 ///
186 /// This is the lift `mnw-cli` and `viewer` were each doing by hand: a muted
187 /// bold heading, the ordered column brought back up to primary, actions and
188 /// links on the action colour rather than on the cell's text colour, and
189 /// selection carried by the background alone.
190 ///
191 /// Selection carries no foreground on purpose. A row can be red for a failed
192 /// upload or green for a published item, and repainting its text on
193 /// selection loses that distinction on exactly the row the user is looking
194 /// at. `mnw-cli`'s `selected_style` found this and its comment says so;
195 /// this is that comment's code, in the library, once.
196 #[cfg(feature = "theme")]
197 #[must_use]
198 pub fn from_theme(theme: &crate::Theme) -> Self {
199 Self {
200 header: Style::new()
201 .fg(theme.content_muted)
202 .add_modifier(Modifier::BOLD),
203 sorted: Style::new()
204 .fg(theme.content_primary)
205 .add_modifier(Modifier::BOLD),
206 value: Style::new().fg(theme.content_primary),
207 // A token paints its own background, and a tone underneath it would
208 // fight the one sitting on it. Secondary is what shows through the
209 // gaps.
210 tokens: Style::new().fg(theme.content_secondary),
211 actions: Style::new().fg(theme.action_primary),
212 link: Style::new()
213 .fg(theme.action_primary)
214 .add_modifier(Modifier::UNDERLINED),
215 selected: Style::new()
216 .bg(theme.surface_raised)
217 .add_modifier(Modifier::BOLD),
218 column_spacing: 1,
219 ascending: " \u{25B2}",
220 descending: " \u{25BC}",
221 }
222 }
223
224 /// The style a cell of this part takes.
225 ///
226 /// [`CellPart`] is `#[non_exhaustive]`, and a member added upstream lands on
227 /// [`value`](Self::value): a part this renderer has not learned draws as
228 /// text, which is a cell rendering plainly rather than a build that stops.
229 /// Grep this when adopting a new `makeover-layout`.
230 #[must_use]
231 pub fn for_part(&self, part: Option<CellPart>) -> Style {
232 match part {
233 Some(CellPart::Tokens) => self.tokens,
234 Some(CellPart::Actions) => self.actions,
235 Some(CellPart::Link) => self.link,
236 _ => self.value,
237 }
238 }
239}
240
241/// The heading, with the caret if the table is ordered by this column.
242///
243/// A column [`sorted`](Column::sorted) but not
244/// [`sortable`](Column::sortable) still gets its caret. Both combinations mean
245/// something, which is why the description holds the two fields apart: a list
246/// ordered by a key the user cannot change is a real thing, and the caret is how
247/// it says so.
248fn heading<'a>(column: &Column<'a>, style: &TableStyle) -> Line<'a> {
249 match column.sorted {
250 Some(Sort::Ascending) => Line::from(format!("{}{}", column.name, style.ascending)),
251 Some(Sort::Descending) => Line::from(format!("{}{}", column.name, style.descending)),
252 None => Line::from(column.name),
253 }
254}
255
256/// How wide a column wants to be, in cells, at its narrowest.
257///
258/// The floor for a fill column rather than its appetite, because narrowing asks
259/// what a layout costs at minimum and a fill column costs its floor.
260fn min_width<'a, R>(column: &Column<'a>, rows: &[R], sizing: &Sizing<'_>, style: &TableStyle) -> u16
261where
262 R: AsRef<[Cell<'a>]>,
263{
264 match column.width {
265 Width::Content => measure(column, rows, style),
266 Width::Fixed => sizing.length_for(column.name),
267 // Includes a width added to the description since this renderer was
268 // built. Taking the slack above a floor is the behaviour that makes no
269 // claim, which is the same fallback the webview renderer's `auto` track
270 // is chosen to be.
271 _ => sizing.length_for(column.name),
272 }
273}
274
275/// The widest thing in a column, heading included.
276///
277/// The heading counts because it is drawn: a column sized to its cells alone
278/// truncates its own name, and a two-character column called `duration` reads as
279/// `du`. The caret counts for the same reason, which is why this measures
280/// [`heading`] rather than [`Column::name`].
281fn measure<'a, R>(column: &Column<'a>, rows: &[R], style: &TableStyle) -> u16
282where
283 R: AsRef<[Cell<'a>]>,
284{
285 let widest = rows
286 .iter()
287 .filter_map(|row| {
288 row.as_ref()
289 .iter()
290 .find(|cell| cell.column == column.name)
291 .map(|cell| cell.content.width())
292 })
293 .max()
294 .unwrap_or(0);
295 u16::try_from(widest.max(heading(column, style).width())).unwrap_or(u16::MAX)
296}
297
298/// Whether the columns kept at `cutoff` fit in `width`.
299fn fits<'a, R>(
300 columns: &[Column<'a>],
301 rows: &[R],
302 sizing: &Sizing<'_>,
303 style: &TableStyle,
304 cutoff: Priority,
305 width: u16,
306) -> bool
307where
308 R: AsRef<[Cell<'a>]>,
309{
310 let kept: Vec<&Column<'a>> = columns.iter().filter(|c| c.kept_at(cutoff)).collect();
311 let gaps = u32::from(style.column_spacing) * (kept.len().saturating_sub(1)) as u32;
312 let tracks: u32 = kept
313 .iter()
314 .map(|c| u32::from(min_width(c, rows, sizing, style)))
315 .sum();
316 tracks + gaps <= u32::from(width)
317}
318
319/// The weakest cutoff whose columns fit in `width`.
320///
321/// Raised until the layout fits, and never past [`Priority::Essential`]: the
322/// essential columns are what makes a row identify itself, so a window too
323/// narrow for them gets them truncated rather than dropped. Nothing here counts
324/// positions, so which column drops is a property of the column.
325#[must_use]
326pub fn cutoff_for<'a, R>(
327 columns: &[Column<'a>],
328 rows: &[R],
329 sizing: &Sizing<'_>,
330 style: &TableStyle,
331 width: u16,
332) -> Priority
333where
334 R: AsRef<[Cell<'a>]>,
335{
336 for cutoff in CUTOFFS {
337 if fits(columns, rows, sizing, style, cutoff, width) {
338 return cutoff;
339 }
340 }
341 Priority::Essential
342}
343
344/// The tracks for the columns kept at `cutoff`.
345///
346/// Only the surviving tracks, which is what keeps the track list and the hiding
347/// in agreement. A caller that dropped a cell but left its track would get a
348/// column of empty space, which is the other half of the goingson bug the
349/// webview renderer's `grid_template_columns` names.
350#[must_use]
351pub fn constraints<'a, R>(
352 columns: &[Column<'a>],
353 rows: &[R],
354 sizing: &Sizing<'_>,
355 style: &TableStyle,
356 cutoff: Priority,
357) -> Vec<Constraint>
358where
359 R: AsRef<[Cell<'a>]>,
360{
361 columns
362 .iter()
363 .filter(|column| column.kept_at(cutoff))
364 .map(|column| match column.width {
365 // Takes what it needs and no more, which is a fixed track once the
366 // needing has been measured.
367 Width::Content => Constraint::Length(measure(column, rows, style)),
368 Width::Fixed => Constraint::Length(sizing.length_for(column.name)),
369 // `Min` and not `Fill`: a fill column absorbs the slack *and* keeps
370 // its floor, which is what `minmax(len, 1fr)` says at the webview
371 // renderer. `Fill` would let it collapse below the floor when a
372 // fixed column takes the room.
373 _ => Constraint::Min(sizing.length_for(column.name)),
374 })
375 .collect()
376}
377
378/// One row's cells, in column order.
379///
380/// Ordered by the columns and not by the cells, so a row cannot silently
381/// disagree with its table about what comes where. A column with no cell gets an
382/// empty cell, which keeps the tracks aligned; a cell naming no column is
383/// dropped, because there is nowhere to put it. That is
384/// `makeover-webview`'s `cells_html` rule, and it has to be the same rule or the
385/// two renderers disagree about a row they were handed identically.
386#[must_use]
387pub fn row<'a>(
388 columns: &[Column<'a>],
389 cells: &[Cell<'a>],
390 style: &TableStyle,
391 cutoff: Priority,
392) -> Row<'a> {
393 Row::new(
394 columns
395 .iter()
396 .filter(|column| column.kept_at(cutoff))
397 .map(|column| {
398 let found = cells.iter().find(|cell| cell.column == column.name);
399 let part = found.and_then(|cell| cell.part);
400 let content = found.map_or_else(Line::default, |cell| cell.content.clone());
401 TrackCell::from(content).style(style.for_part(part))
402 })
403 .collect::<Vec<_>>(),
404 )
405}
406
407/// The heading row for the columns kept at `cutoff`.
408///
409/// Exposed beside [`table`] because a caller assembling its own
410/// [`Table`] still has to draw a header that agrees with the body about what
411/// just disappeared. Assembling it a second time by hand is how they stop
412/// agreeing.
413#[must_use]
414pub fn header<'a>(columns: &[Column<'a>], style: &TableStyle, cutoff: Priority) -> Row<'a> {
415 Row::new(
416 columns
417 .iter()
418 .filter(|column| column.kept_at(cutoff))
419 .map(|column| {
420 let tone = if column.sorted.is_some() {
421 style.sorted
422 } else {
423 style.header
424 };
425 TrackCell::from(heading(column, style)).style(tone)
426 })
427 .collect::<Vec<_>>(),
428 )
429 .style(style.header)
430}
431
432/// A described table, sized and narrowed for `width`.
433///
434/// Hands back a [`Table`] rather than drawing one. Selection and scroll live in
435/// the app's [`TableState`](ratatui::widgets::TableState), and the row highlight
436/// is already set from [`TableStyle::selected`], so a caller renders this with
437/// `render_stateful_widget` and gets the house selection without saying anything
438/// further.
439///
440/// `width` is the area the table will be drawn in, which is what narrowing is
441/// decided against. Pass the [`Rect`](ratatui::layout::Rect) width that
442/// [`frame`](crate::frame) handed back rather than the region's own, or the
443/// table budgets for the two cells the edge took.
444#[must_use]
445pub fn table<'a, R>(
446 columns: &[Column<'a>],
447 rows: &[R],
448 sizing: &Sizing<'_>,
449 style: &TableStyle,
450 width: u16,
451) -> Table<'a>
452where
453 R: AsRef<[Cell<'a>]>,
454{
455 let cutoff = cutoff_for(columns, rows, sizing, style, width);
456 let widths = constraints(columns, rows, sizing, style, cutoff);
457 let body: Vec<Row<'a>> = rows
458 .iter()
459 .map(|cells| row(columns, cells.as_ref(), style, cutoff))
460 .collect();
461
462 Table::new(body, widths)
463 .header(header(columns, style, cutoff))
464 .column_spacing(style.column_spacing)
465 .row_highlight_style(style.selected)
466}
467
468/// Whether a table drawn at `width` would leave anything overflowing.
469///
470/// True only when the essential columns alone do not fit, since that is the one
471/// case narrowing cannot answer. A caller that would rather show fewer rows than
472/// truncate a cell can ask this and draw something else.
473#[must_use]
474pub fn overflows<'a, R>(
475 columns: &[Column<'a>],
476 rows: &[R],
477 sizing: &Sizing<'_>,
478 style: &TableStyle,
479 width: u16,
480) -> bool
481where
482 R: AsRef<[Cell<'a>]>,
483{
484 !fits(columns, rows, sizing, style, Priority::Essential, width)
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490
491 fn columns() -> Vec<Column<'static>> {
492 vec![
493 Column {
494 name: "name",
495 width: Width::Fill,
496 priority: Priority::Essential,
497 sortable: true,
498 sorted: Some(Sort::Ascending),
499 },
500 Column {
501 name: "size",
502 width: Width::Fixed,
503 priority: Priority::Secondary,
504 sortable: true,
505 sorted: None,
506 },
507 Column {
508 name: "note",
509 width: Width::Content,
510 priority: Priority::Optional,
511 sortable: false,
512 sorted: None,
513 },
514 ]
515 }
516
517 fn sizing() -> Sizing<'static> {
518 Sizing {
519 lengths: &[("name", 10), ("size", 6)],
520 fallback: 4,
521 }
522 }
523
524 fn rows() -> Vec<Vec<Cell<'static>>> {
525 vec![
526 vec![
527 Cell::new("name", "alpha"),
528 Cell::new("size", "1kb"),
529 Cell::new("note", "a longer note"),
530 ],
531 vec![Cell::new("name", "beta"), Cell::new("size", "2kb")],
532 ]
533 }
534
535 fn cell_text(row: &Row<'_>) -> Vec<String> {
536 // Rendering is the only way to read a ratatui Row back, and reading it
537 // back is the point: these tests assert what a user sees.
538 use ratatui::layout::Rect;
539 use ratatui::widgets::Widget;
540 let mut buf = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 60, 1));
541 Table::new(vec![row.clone()], [Constraint::Length(18); 3])
542 .column_spacing(1)
543 .render(Rect::new(0, 0, 60, 1), &mut buf);
544 (0..3)
545 .map(|i| {
546 let start = i * 19;
547 (start..start + 18)
548 .map(|x| buf[(x as u16, 0)].symbol())
549 .collect::<String>()
550 .trim_end()
551 .to_owned()
552 })
553 .collect()
554 }
555
556 #[test]
557 fn cells_are_ordered_by_the_columns_and_not_by_the_row() {
558 // The row hands them over backwards. The table decides the order, which
559 // is what stops a row silently disagreeing with its own header.
560 let cols = columns();
561 let out_of_order = vec![
562 Cell::new("note", "third"),
563 Cell::new("name", "first"),
564 Cell::new("size", "second"),
565 ];
566 let drawn = row(
567 &cols,
568 &out_of_order,
569 &TableStyle::default(),
570 Priority::Optional,
571 );
572 assert_eq!(cell_text(&drawn), vec!["first", "second", "third"]);
573 }
574
575 #[test]
576 fn a_cell_naming_no_column_is_dropped_and_a_column_with_no_cell_keeps_its_place() {
577 let cols = columns();
578 let cells = vec![Cell::new("note", "kept"), Cell::new("nonesuch", "lost")];
579 let drawn = row(&cols, &cells, &TableStyle::default(), Priority::Optional);
580 // Two empty tracks, then the note. The empties are what keeps the third
581 // column under the third heading.
582 assert_eq!(cell_text(&drawn), vec!["", "", "kept"]);
583 }
584
585 #[test]
586 fn a_content_column_is_measured_from_its_widest_cell() {
587 let style = TableStyle::default();
588 let widths = constraints(&columns(), &rows(), &sizing(), &style, Priority::Optional);
589 assert_eq!(widths[2], Constraint::Length("a longer note".len() as u16));
590 }
591
592 #[test]
593 fn a_content_column_never_truncates_its_own_heading() {
594 // The cells are two characters wide and the heading is eight. Sizing to
595 // the cells alone would draw the column as `du`.
596 let cols = vec![Column {
597 name: "duration",
598 width: Width::Content,
599 priority: Priority::Essential,
600 sortable: false,
601 sorted: None,
602 }];
603 let rows = vec![vec![Cell::new("duration", "3s")]];
604 let widths = constraints(
605 &cols,
606 &rows,
607 &sizing(),
608 &TableStyle::default(),
609 Priority::Optional,
610 );
611 assert_eq!(widths[0], Constraint::Length(8));
612 }
613
614 #[test]
615 fn a_caret_is_part_of_what_a_heading_costs() {
616 // Measured off `heading` and not off `name`, or the sorted column is
617 // exactly two cells too narrow and drops its own arrow.
618 let cols = vec![Column {
619 name: "size",
620 width: Width::Content,
621 priority: Priority::Essential,
622 sortable: true,
623 sorted: Some(Sort::Descending),
624 }];
625 let rows: Vec<Vec<Cell<'_>>> = vec![];
626 let style = TableStyle::default();
627 let widths = constraints(&cols, &rows, &sizing(), &style, Priority::Optional);
628 assert_eq!(
629 widths[0],
630 Constraint::Length(6),
631 "size plus a space and a caret"
632 );
633 }
634
635 #[test]
636 fn narrowing_drops_the_optional_column_first_and_the_essential_one_never() {
637 let style = TableStyle::default();
638 let (cols, rows, sz) = (columns(), rows(), sizing());
639 // Everything: 10 + 6 + 13 tracks and two gaps.
640 assert_eq!(
641 cutoff_for(&cols, &rows, &sz, &style, 40),
642 Priority::Optional
643 );
644 // No room for the note.
645 assert_eq!(
646 cutoff_for(&cols, &rows, &sz, &style, 20),
647 Priority::Secondary
648 );
649 // No room for the size either.
650 assert_eq!(
651 cutoff_for(&cols, &rows, &sz, &style, 12),
652 Priority::Essential
653 );
654 // No room for anything, and the essential column stays anyway.
655 assert_eq!(
656 cutoff_for(&cols, &rows, &sz, &style, 2),
657 Priority::Essential
658 );
659 assert!(overflows(&cols, &rows, &sz, &style, 2));
660 assert!(!overflows(&cols, &rows, &sz, &style, 12));
661 }
662
663 #[test]
664 fn a_column_inserted_left_of_the_cut_does_not_change_what_drops() {
665 // The goingson bug, as a test. `nth-child(n+5)` against a seven-column
666 // table hides whatever lands at position five, so inserting a column
667 // anywhere left of the cut moves it onto a different column with nothing
668 // edited and nothing reported.
669 //
670 // Asserted at a fixed cutoff, because that is where the two ways of
671 // addressing a column disagree. A narrower budget SHOULD drop more
672 // columns, and does below; what must not change is which ones, in what
673 // order, for a given cutoff.
674 let dropped = |cols: &[Column<'_>], cutoff| -> Vec<String> {
675 cols.iter()
676 .filter(|c| !c.kept_at(cutoff))
677 .map(|c| c.name.to_owned())
678 .collect()
679 };
680 let before = columns();
681 let mut after = vec![Column {
682 name: "mark",
683 width: Width::Fixed,
684 priority: Priority::Essential,
685 sortable: false,
686 sorted: None,
687 }];
688 after.extend(columns());
689
690 for cutoff in CUTOFFS {
691 assert_eq!(
692 dropped(&before, cutoff),
693 dropped(&after, cutoff),
694 "inserting a column changed what {cutoff:?} drops"
695 );
696 }
697 assert_eq!(dropped(&before, Priority::Secondary), vec!["note"]);
698 }
699
700 #[test]
701 fn a_column_never_outlives_a_more_essential_one() {
702 // The ordering claim narrowing rests on: whatever the budget, the set
703 // kept is closed upward. A layout that dropped `size` while keeping
704 // `note` would be counting something other than priority.
705 let style = TableStyle::default();
706 let (cols, rows, sz) = (columns(), rows(), sizing());
707 for width in 0..48u16 {
708 let cutoff = cutoff_for(&cols, &rows, &sz, &style, width);
709 let kept: Vec<&str> = cols
710 .iter()
711 .filter(|c| c.kept_at(cutoff))
712 .map(|c| c.name)
713 .collect();
714 assert!(
715 kept.contains(&"name"),
716 "the essential column left at {width}"
717 );
718 if kept.contains(&"note") {
719 assert!(
720 kept.contains(&"size"),
721 "optional outlived secondary at {width}"
722 );
723 }
724 }
725 }
726
727 #[test]
728 fn a_dropped_column_takes_its_track_with_it() {
729 // A cell hidden with its track left behind is a column of empty space,
730 // which is the half of the goingson bug that survives fixing the other.
731 let style = TableStyle::default();
732 let widths = constraints(&columns(), &rows(), &sizing(), &style, Priority::Secondary);
733 assert_eq!(widths.len(), 2);
734 let drawn = row(&columns(), &rows()[0], &style, Priority::Secondary);
735 assert_eq!(cell_text(&drawn), vec!["alpha", "1kb", ""]);
736 }
737
738 #[test]
739 fn a_fill_column_keeps_its_floor_while_taking_the_slack() {
740 // `Min` and not `Fill`, which is `minmax(10, 1fr)` at the webview
741 // renderer. A `Fill` track collapses under a fixed neighbour.
742 let style = TableStyle::default();
743 let widths = constraints(&columns(), &rows(), &sizing(), &style, Priority::Optional);
744 assert_eq!(widths[0], Constraint::Min(10));
745 assert_eq!(widths[1], Constraint::Length(6));
746 }
747
748 #[test]
749 fn a_column_with_no_length_of_its_own_takes_the_fallback() {
750 let cols = vec![Column {
751 name: "unlisted",
752 width: Width::Fixed,
753 priority: Priority::Essential,
754 sortable: false,
755 sorted: None,
756 }];
757 let rows: Vec<Vec<Cell<'_>>> = vec![];
758 let widths = constraints(
759 &cols,
760 &rows,
761 &sizing(),
762 &TableStyle::default(),
763 Priority::Optional,
764 );
765 assert_eq!(widths[0], Constraint::Length(4));
766 }
767
768 #[test]
769 fn the_ordered_column_draws_a_caret_and_the_others_do_not() {
770 let style = TableStyle::default();
771 let head = header(&columns(), &style, Priority::Optional);
772 assert_eq!(
773 cell_text(&head),
774 vec!["name \u{25B2}", "size", "note"],
775 "only the column in force carries one"
776 );
777 }
778
779 #[test]
780 fn a_column_sorted_without_being_sortable_still_draws_its_caret() {
781 // A list ordered by a key the user cannot change is a real thing to
782 // describe, which is why the description holds the two fields apart.
783 // Drawing the caret only for a sortable column would collapse them.
784 let cols = vec![Column {
785 name: "rank",
786 width: Width::Content,
787 priority: Priority::Essential,
788 sortable: false,
789 sorted: Some(Sort::Descending),
790 }];
791 let head = header(&cols, &TableStyle::default(), Priority::Optional);
792 assert_eq!(cell_text(&head), vec!["rank \u{25BC}", "", ""]);
793 }
794
795 #[test]
796 fn the_parts_a_cell_can_be_are_styled_apart() {
797 // The drift `CellPart` exists to end: one style for a whole cell paints
798 // a control as though it were text.
799 let style = TableStyle::default();
800 assert_eq!(style.for_part(Some(CellPart::Value)), style.value);
801 assert_eq!(style.for_part(Some(CellPart::Tokens)), style.tokens);
802 assert_eq!(style.for_part(Some(CellPart::Actions)), style.actions);
803 assert_eq!(style.for_part(Some(CellPart::Link)), style.link);
804 assert_ne!(style.for_part(Some(CellPart::Link)), style.value);
805 // A cell mixing parts says nothing, and takes the text style.
806 assert_eq!(style.for_part(None), style.value);
807 }
808
809 #[test]
810 fn a_table_narrows_itself_from_the_width_it_is_given() {
811 // The whole path in one call, which is what a consumer actually uses.
812 let style = TableStyle::default();
813 let wide = table(&columns(), &rows(), &sizing(), &style, 40);
814 let narrow = table(&columns(), &rows(), &sizing(), &style, 20);
815 use ratatui::layout::Rect;
816 use ratatui::widgets::Widget;
817
818 let mut buf = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 40, 3));
819 wide.render(Rect::new(0, 0, 40, 3), &mut buf);
820 let head: String = (0..40).map(|x| buf[(x, 0)].symbol()).collect();
821 assert!(head.contains("note"));
822
823 let mut buf = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 20, 3));
824 narrow.render(Rect::new(0, 0, 20, 3), &mut buf);
825 let head: String = (0..20).map(|x| buf[(x, 0)].symbol()).collect();
826 assert!(!head.contains("note"), "the optional column is gone");
827 assert!(head.contains("name"), "the essential one is not");
828 }
829
830 #[test]
831 fn selection_is_carried_by_the_background_alone() {
832 // A row can be red for a failure or green for a success, and a
833 // foreground on the selection loses that on exactly the row being looked
834 // at. Asserted on the default so a caller who supplies no theme still
835 // gets the rule.
836 let style = TableStyle::default();
837 assert!(style.selected.fg.is_none());
838 }
839}