Skip to main content

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}