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    /// 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.
278/// A line placed the way its column's kind says, wiki `table-model`.
279///
280/// Alignment is the one kind fact a terminal has to act on. Every cell is
281/// already the monospace face with even figures, and a terminal wraps nothing
282/// it is not told to, so a number or an actions column aligning to its end is
283/// what is left. The heading takes the same, so a label sits over its figures.
284fn aligned<'a>(column: &Column<'a>, line: Line<'a>) -> Line<'a> {
285    if column.kind.aligns_end() {
286        line.right_aligned()
287    } else {
288        line
289    }
290}
291
292fn heading<'a>(column: &Column<'a>, style: &TableStyle) -> Line<'a> {
293    let caret = match column.sorted {
294        Some(Sort::Ascending) => style.ascending,
295        Some(Sort::Descending) => style.descending,
296        None if column.sortable => style.ascending,
297        None => return Line::from(column.name),
298    };
299    // The gap, once, rather than inside each of the two style strings. A
300    // consumer swapping the glyph for an ASCII one does not have to remember to
301    // bring a space with it.
302    Line::from(format!("{} {caret}", column.name))
303}
304
305/// How wide a column wants to be, in cells, at its narrowest.
306///
307/// The floor for a fill column rather than its appetite, because narrowing asks
308/// what a layout costs at minimum and a fill column costs its floor.
309fn min_width<'a, R>(column: &Column<'a>, rows: &[R], sizing: &Sizing<'_>, style: &TableStyle) -> u16
310where
311    R: AsRef<[Cell<'a>]>,
312{
313    match column.width {
314        Width::Content => measure(column, rows, style),
315        Width::Fixed => sizing.length_for(column.name),
316        // Includes a width added to the description since this renderer was
317        // built. Taking the slack above a floor is the behaviour that makes no
318        // claim, which is the same fallback the webview renderer's `auto` track
319        // is chosen to be.
320        _ => sizing.length_for(column.name),
321    }
322}
323
324/// The widest thing in a column, heading included.
325///
326/// The heading counts because it is drawn: a column sized to its cells alone
327/// truncates its own name, and a two-character column called `duration` reads as
328/// `du`. The caret counts for the same reason, which is why this measures
329/// [`heading`] rather than [`Column::name`].
330fn measure<'a, R>(column: &Column<'a>, rows: &[R], style: &TableStyle) -> u16
331where
332    R: AsRef<[Cell<'a>]>,
333{
334    let widest = rows
335        .iter()
336        .filter_map(|row| {
337            row.as_ref()
338                .iter()
339                .find(|cell| cell.column == column.name)
340                .map(|cell| cell.content.width())
341        })
342        .max()
343        .unwrap_or(0);
344    u16::try_from(widest.max(heading(column, style).width())).unwrap_or(u16::MAX)
345}
346
347/// Whether the columns kept at `cutoff` fit in `width`.
348fn fits<'a, R>(
349    columns: &[Column<'a>],
350    rows: &[R],
351    sizing: &Sizing<'_>,
352    style: &TableStyle,
353    cutoff: Priority,
354    width: u16,
355) -> bool
356where
357    R: AsRef<[Cell<'a>]>,
358{
359    let kept: Vec<&Column<'a>> = columns.iter().filter(|c| c.kept_at(cutoff)).collect();
360    let gaps = u32::from(style.column_spacing) * (kept.len().saturating_sub(1)) as u32;
361    let tracks: u32 = kept
362        .iter()
363        .map(|c| u32::from(min_width(c, rows, sizing, style)))
364        .sum();
365    tracks + gaps <= u32::from(width)
366}
367
368/// The weakest cutoff whose columns fit in `width`.
369///
370/// Raised until the layout fits, and never past [`Priority::Essential`]: the
371/// essential columns are what makes a row identify itself, so a window too
372/// narrow for them gets them truncated rather than dropped. Nothing here counts
373/// positions, so which column drops is a property of the column.
374#[must_use]
375pub fn cutoff_for<'a, R>(
376    columns: &[Column<'a>],
377    rows: &[R],
378    sizing: &Sizing<'_>,
379    style: &TableStyle,
380    width: u16,
381) -> Priority
382where
383    R: AsRef<[Cell<'a>]>,
384{
385    for cutoff in CUTOFFS {
386        if fits(columns, rows, sizing, style, cutoff, width) {
387            return cutoff;
388        }
389    }
390    Priority::Essential
391}
392
393/// The tracks for the columns kept at `cutoff`.
394///
395/// Only the surviving tracks, which is what keeps the track list and the hiding
396/// in agreement. A caller that dropped a cell but left its track would get a
397/// column of empty space, which is the other half of the goingson bug the
398/// webview renderer's `grid_template_columns` names.
399#[must_use]
400pub fn constraints<'a, R>(
401    columns: &[Column<'a>],
402    rows: &[R],
403    sizing: &Sizing<'_>,
404    style: &TableStyle,
405    cutoff: Priority,
406) -> Vec<Constraint>
407where
408    R: AsRef<[Cell<'a>]>,
409{
410    columns
411        .iter()
412        .filter(|column| column.kept_at(cutoff))
413        .map(|column| match column.width {
414            // Takes what it needs and no more, which is a fixed track once the
415            // needing has been measured.
416            Width::Content => Constraint::Length(measure(column, rows, style)),
417            Width::Fixed => Constraint::Length(sizing.length_for(column.name)),
418            // `Min` and not `Fill`: a fill column absorbs the slack *and* keeps
419            // its floor, which is what `minmax(len, 1fr)` says at the webview
420            // renderer. `Fill` would let it collapse below the floor when a
421            // fixed column takes the room.
422            _ => Constraint::Min(sizing.length_for(column.name)),
423        })
424        .collect()
425}
426
427/// One row's cells, in column order.
428///
429/// Ordered by the columns and not by the cells, so a row cannot silently
430/// disagree with its table about what comes where. A column with no cell gets an
431/// empty cell, which keeps the tracks aligned; a cell naming no column is
432/// dropped, because there is nowhere to put it. That is
433/// `makeover-webview`'s `cells_html` rule, and it has to be the same rule or the
434/// two renderers disagree about a row they were handed identically.
435#[must_use]
436pub fn row<'a>(
437    columns: &[Column<'a>],
438    cells: &[Cell<'a>],
439    style: &TableStyle,
440    cutoff: Priority,
441) -> Row<'a> {
442    Row::new(
443        columns
444            .iter()
445            .filter(|column| column.kept_at(cutoff))
446            .map(|column| {
447                let found = cells.iter().find(|cell| cell.column == column.name);
448                let part = found.and_then(|cell| cell.part);
449                let content = found.map_or_else(Line::default, |cell| cell.content.clone());
450                TrackCell::from(aligned(column, content)).style(style.for_part(part))
451            })
452            .collect::<Vec<_>>(),
453    )
454}
455
456/// The heading row for the columns kept at `cutoff`.
457///
458/// Exposed beside [`table`] because a caller assembling its own
459/// [`Table`] still has to draw a header that agrees with the body about what
460/// just disappeared. Assembling it a second time by hand is how they stop
461/// agreeing.
462#[must_use]
463pub fn header<'a>(columns: &[Column<'a>], style: &TableStyle, cutoff: Priority) -> Row<'a> {
464    Row::new(
465        columns
466            .iter()
467            .filter(|column| column.kept_at(cutoff))
468            .map(|column| {
469                // Three states, three tones (wiki `three-tone-convention`). In
470                // force, offering, and not a control at all -- and the middle
471                // one is the state that had nowhere to be said, so a heading
472                // you could press looked exactly like one you could not.
473                let tone = match (column.sorted, column.sortable) {
474                    (Some(_), _) => style.sorted,
475                    (None, true) => style.sortable,
476                    (None, false) => style.header,
477                };
478                TrackCell::from(aligned(column, heading(column, style))).style(tone)
479            })
480            .collect::<Vec<_>>(),
481    )
482    .style(style.header)
483}
484
485/// A described table, sized and narrowed for `width`.
486///
487/// Hands back a [`Table`] rather than drawing one. Selection and scroll live in
488/// the app's [`TableState`](ratatui::widgets::TableState), and the row highlight
489/// is already set from [`TableStyle::selected`], so a caller renders this with
490/// `render_stateful_widget` and gets the house selection without saying anything
491/// further.
492///
493/// `width` is the area the table will be drawn in, which is what narrowing is
494/// decided against. Pass the [`Rect`](ratatui::layout::Rect) width that
495/// [`frame`](crate::frame) handed back rather than the region's own, or the
496/// table budgets for the two cells the edge took.
497#[must_use]
498pub fn table<'a, R>(
499    columns: &[Column<'a>],
500    rows: &[R],
501    sizing: &Sizing<'_>,
502    style: &TableStyle,
503    width: u16,
504) -> Table<'a>
505where
506    R: AsRef<[Cell<'a>]>,
507{
508    let cutoff = cutoff_for(columns, rows, sizing, style, width);
509    let widths = constraints(columns, rows, sizing, style, cutoff);
510    let body: Vec<Row<'a>> = rows
511        .iter()
512        .map(|cells| row(columns, cells.as_ref(), style, cutoff))
513        .collect();
514
515    Table::new(body, widths)
516        .header(header(columns, style, cutoff))
517        .column_spacing(style.column_spacing)
518        .row_highlight_style(style.selected)
519}
520
521/// Whether a table drawn at `width` would leave anything overflowing.
522///
523/// True only when the essential columns alone do not fit, since that is the one
524/// case narrowing cannot answer. A caller that would rather show fewer rows than
525/// truncate a cell can ask this and draw something else.
526#[must_use]
527pub fn overflows<'a, R>(
528    columns: &[Column<'a>],
529    rows: &[R],
530    sizing: &Sizing<'_>,
531    style: &TableStyle,
532    width: u16,
533) -> bool
534where
535    R: AsRef<[Cell<'a>]>,
536{
537    !fits(columns, rows, sizing, style, Priority::Essential, width)
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543    use makeover_layout::ColumnKind;
544
545    fn columns() -> Vec<Column<'static>> {
546        vec![
547            Column {
548                name: "name",
549                width: Width::Fill,
550                priority: Priority::Essential,
551                kind: ColumnKind::Text,
552                sortable: true,
553                sorted: Some(Sort::Ascending),
554            },
555            Column {
556                name: "size",
557                width: Width::Fixed,
558                priority: Priority::Secondary,
559                kind: ColumnKind::Text,
560                sortable: true,
561                sorted: None,
562            },
563            Column {
564                name: "note",
565                width: Width::Content,
566                priority: Priority::Optional,
567                kind: ColumnKind::Text,
568                sortable: false,
569                sorted: None,
570            },
571        ]
572    }
573
574    fn sizing() -> Sizing<'static> {
575        Sizing {
576            lengths: &[("name", 10), ("size", 6)],
577            fallback: 4,
578        }
579    }
580
581    fn rows() -> Vec<Vec<Cell<'static>>> {
582        vec![
583            vec![
584                Cell::new("name", "alpha"),
585                Cell::new("size", "1kb"),
586                Cell::new("note", "a longer note"),
587            ],
588            vec![Cell::new("name", "beta"), Cell::new("size", "2kb")],
589        ]
590    }
591
592    fn cell_text(row: &Row<'_>) -> Vec<String> {
593        // Rendering is the only way to read a ratatui Row back, and reading it
594        // back is the point: these tests assert what a user sees.
595        use ratatui::layout::Rect;
596        use ratatui::widgets::Widget;
597        let mut buf = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 60, 1));
598        Table::new(vec![row.clone()], [Constraint::Length(18); 3])
599            .column_spacing(1)
600            .render(Rect::new(0, 0, 60, 1), &mut buf);
601        (0..3)
602            .map(|i| {
603                let start = i * 19;
604                (start..start + 18)
605                    .map(|x| buf[(x as u16, 0)].symbol())
606                    .collect::<String>()
607                    .trim_end()
608                    .to_owned()
609            })
610            .collect()
611    }
612
613    /// The foreground each of the three heading cells was drawn in.
614    ///
615    /// Read off a rendered buffer for [`cell_text`]'s reason: a ratatui `Row`
616    /// hands nothing back, and what is asserted is what a user sees.
617    fn cell_colors(row: &Row<'_>) -> Vec<Option<ratatui::style::Color>> {
618        use ratatui::layout::Rect;
619        use ratatui::widgets::Widget;
620        let mut buf = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 60, 1));
621        Table::new(vec![row.clone()], [Constraint::Length(18); 3])
622            .column_spacing(1)
623            .render(Rect::new(0, 0, 60, 1), &mut buf);
624        (0..3).map(|i| buf[(i * 19, 0)].fg).map(Some).collect()
625    }
626
627    #[test]
628    fn cells_are_ordered_by_the_columns_and_not_by_the_row() {
629        // The row hands them over backwards. The table decides the order, which
630        // is what stops a row silently disagreeing with its own header.
631        let cols = columns();
632        let out_of_order = vec![
633            Cell::new("note", "third"),
634            Cell::new("name", "first"),
635            Cell::new("size", "second"),
636        ];
637        let drawn = row(
638            &cols,
639            &out_of_order,
640            &TableStyle::default(),
641            Priority::Optional,
642        );
643        assert_eq!(cell_text(&drawn), vec!["first", "second", "third"]);
644    }
645
646    #[test]
647    fn a_cell_naming_no_column_is_dropped_and_a_column_with_no_cell_keeps_its_place() {
648        let cols = columns();
649        let cells = vec![Cell::new("note", "kept"), Cell::new("nonesuch", "lost")];
650        let drawn = row(&cols, &cells, &TableStyle::default(), Priority::Optional);
651        // Two empty tracks, then the note. The empties are what keeps the third
652        // column under the third heading.
653        assert_eq!(cell_text(&drawn), vec!["", "", "kept"]);
654    }
655
656    #[test]
657    fn a_content_column_is_measured_from_its_widest_cell() {
658        let style = TableStyle::default();
659        let widths = constraints(&columns(), &rows(), &sizing(), &style, Priority::Optional);
660        assert_eq!(widths[2], Constraint::Length("a longer note".len() as u16));
661    }
662
663    #[test]
664    fn a_content_column_never_truncates_its_own_heading() {
665        // The cells are two characters wide and the heading is eight. Sizing to
666        // the cells alone would draw the column as `du`.
667        let cols = vec![Column {
668            name: "duration",
669            width: Width::Content,
670            priority: Priority::Essential,
671            kind: ColumnKind::Text,
672            sortable: false,
673            sorted: None,
674        }];
675        let rows = vec![vec![Cell::new("duration", "3s")]];
676        let widths = constraints(
677            &cols,
678            &rows,
679            &sizing(),
680            &TableStyle::default(),
681            Priority::Optional,
682        );
683        assert_eq!(widths[0], Constraint::Length(8));
684    }
685
686    #[test]
687    fn a_caret_is_part_of_what_a_heading_costs() {
688        // Measured off `heading` and not off `name`, or the sorted column is
689        // exactly two cells too narrow and drops its own arrow.
690        let cols = vec![Column {
691            name: "size",
692            width: Width::Content,
693            priority: Priority::Essential,
694            kind: ColumnKind::Text,
695            sortable: true,
696            sorted: Some(Sort::Descending),
697        }];
698        let rows: Vec<Vec<Cell<'_>>> = vec![];
699        let style = TableStyle::default();
700        let widths = constraints(&cols, &rows, &sizing(), &style, Priority::Optional);
701        assert_eq!(
702            widths[0],
703            Constraint::Length(6),
704            "size plus a space and a caret"
705        );
706    }
707
708    #[test]
709    fn narrowing_drops_the_optional_column_first_and_the_essential_one_never() {
710        let style = TableStyle::default();
711        let (cols, rows, sz) = (columns(), rows(), sizing());
712        // Everything: 10 + 6 + 13 tracks and two gaps.
713        assert_eq!(
714            cutoff_for(&cols, &rows, &sz, &style, 40),
715            Priority::Optional
716        );
717        // No room for the note.
718        assert_eq!(
719            cutoff_for(&cols, &rows, &sz, &style, 20),
720            Priority::Secondary
721        );
722        // No room for the size either.
723        assert_eq!(
724            cutoff_for(&cols, &rows, &sz, &style, 12),
725            Priority::Essential
726        );
727        // No room for anything, and the essential column stays anyway.
728        assert_eq!(
729            cutoff_for(&cols, &rows, &sz, &style, 2),
730            Priority::Essential
731        );
732        assert!(overflows(&cols, &rows, &sz, &style, 2));
733        assert!(!overflows(&cols, &rows, &sz, &style, 12));
734    }
735
736    #[test]
737    fn a_column_inserted_left_of_the_cut_does_not_change_what_drops() {
738        // The goingson bug, as a test. `nth-child(n+5)` against a seven-column
739        // table hides whatever lands at position five, so inserting a column
740        // anywhere left of the cut moves it onto a different column with nothing
741        // edited and nothing reported.
742        //
743        // Asserted at a fixed cutoff, because that is where the two ways of
744        // addressing a column disagree. A narrower budget SHOULD drop more
745        // columns, and does below; what must not change is which ones, in what
746        // order, for a given cutoff.
747        let dropped = |cols: &[Column<'_>], cutoff| -> Vec<String> {
748            cols.iter()
749                .filter(|c| !c.kept_at(cutoff))
750                .map(|c| c.name.to_owned())
751                .collect()
752        };
753        let before = columns();
754        let mut after = vec![Column {
755            name: "mark",
756            width: Width::Fixed,
757            priority: Priority::Essential,
758            kind: ColumnKind::Text,
759            sortable: false,
760            sorted: None,
761        }];
762        after.extend(columns());
763
764        for cutoff in CUTOFFS {
765            assert_eq!(
766                dropped(&before, cutoff),
767                dropped(&after, cutoff),
768                "inserting a column changed what {cutoff:?} drops"
769            );
770        }
771        assert_eq!(dropped(&before, Priority::Secondary), vec!["note"]);
772    }
773
774    #[test]
775    fn a_column_never_outlives_a_more_essential_one() {
776        // The ordering claim narrowing rests on: whatever the budget, the set
777        // kept is closed upward. A layout that dropped `size` while keeping
778        // `note` would be counting something other than priority.
779        let style = TableStyle::default();
780        let (cols, rows, sz) = (columns(), rows(), sizing());
781        for width in 0..48u16 {
782            let cutoff = cutoff_for(&cols, &rows, &sz, &style, width);
783            let kept: Vec<&str> = cols
784                .iter()
785                .filter(|c| c.kept_at(cutoff))
786                .map(|c| c.name)
787                .collect();
788            assert!(
789                kept.contains(&"name"),
790                "the essential column left at {width}"
791            );
792            if kept.contains(&"note") {
793                assert!(
794                    kept.contains(&"size"),
795                    "optional outlived secondary at {width}"
796                );
797            }
798        }
799    }
800
801    #[test]
802    fn a_dropped_column_takes_its_track_with_it() {
803        // A cell hidden with its track left behind is a column of empty space,
804        // which is the half of the goingson bug that survives fixing the other.
805        let style = TableStyle::default();
806        let widths = constraints(&columns(), &rows(), &sizing(), &style, Priority::Secondary);
807        assert_eq!(widths.len(), 2);
808        let drawn = row(&columns(), &rows()[0], &style, Priority::Secondary);
809        assert_eq!(cell_text(&drawn), vec!["alpha", "1kb", ""]);
810    }
811
812    #[test]
813    fn a_fill_column_keeps_its_floor_while_taking_the_slack() {
814        // `Min` and not `Fill`, which is `minmax(10, 1fr)` at the webview
815        // renderer. A `Fill` track collapses under a fixed neighbour.
816        let style = TableStyle::default();
817        let widths = constraints(&columns(), &rows(), &sizing(), &style, Priority::Optional);
818        assert_eq!(widths[0], Constraint::Min(10));
819        assert_eq!(widths[1], Constraint::Length(6));
820    }
821
822    #[test]
823    fn a_column_with_no_length_of_its_own_takes_the_fallback() {
824        let cols = vec![Column {
825            name: "unlisted",
826            width: Width::Fixed,
827            priority: Priority::Essential,
828            kind: ColumnKind::Text,
829            sortable: false,
830            sorted: None,
831        }];
832        let rows: Vec<Vec<Cell<'_>>> = vec![];
833        let widths = constraints(
834            &cols,
835            &rows,
836            &sizing(),
837            &TableStyle::default(),
838            Priority::Optional,
839        );
840        assert_eq!(widths[0], Constraint::Length(4));
841    }
842
843    #[test]
844    fn a_heading_carries_a_caret_when_it_is_ordered_by_or_offers_to_be() {
845        let style = TableStyle::default();
846        let head = header(&columns(), &style, Priority::Optional);
847        assert_eq!(
848            cell_text(&head),
849            vec!["name \u{25B2}", "size \u{25B2}", "note"],
850            "in force and offering both carry one; not a control carries none"
851        );
852    }
853
854    #[test]
855    fn the_three_states_of_a_heading_are_three_tones() {
856        // wiki `three-tone-convention`. The middle state is the one that had
857        // nowhere to be said: a heading you can press looked exactly like one
858        // you cannot, and the idle caret alone does not separate them, because
859        // a sorted-but-unsortable column draws a caret too.
860        use ratatui::style::Color;
861        let style = TableStyle {
862            sorted: Style::new().fg(Color::Red),
863            sortable: Style::new().fg(Color::Green),
864            header: Style::new().fg(Color::Blue),
865            ..TableStyle::default()
866        };
867        let drawn = cell_colors(&header(&columns(), &style, Priority::Optional));
868        assert_eq!(
869            drawn,
870            vec![Some(Color::Red), Some(Color::Green), Some(Color::Blue)]
871        );
872
873        // The colourless default separates them by the caret and nothing else,
874        // and that is the honest limit rather than an oversight: a cell style
875        // patches the row's, so a plain cell under a bold header row is drawn
876        // bold whatever it holds. Three tones need three colours, which is what
877        // `from_theme` is for.
878        let house = TableStyle::default();
879        let plain = cell_colors(&header(&columns(), &house, Priority::Optional));
880        assert_eq!(plain[0], plain[1], "no colour to spend, so none is claimed");
881    }
882
883    #[test]
884    fn pressing_a_heading_does_not_move_the_columns_after_it() {
885        // The reflow the idle caret closes. `measure` sizes from `heading`, so
886        // a caret that appeared with the press widened its own column by two
887        // cells and shifted the rest of the row sideways.
888        let style = TableStyle::default();
889        let offering = Column {
890            name: "size",
891            width: Width::Content,
892            priority: Priority::Secondary,
893            kind: ColumnKind::Text,
894            sortable: true,
895            sorted: None,
896        };
897        let in_force = Column {
898            sorted: Some(Sort::Descending),
899            ..offering
900        };
901        let rows: Vec<Vec<Cell<'_>>> = vec![];
902        assert_eq!(
903            measure(&offering, &rows, &style),
904            measure(&in_force, &rows, &style)
905        );
906
907        // And the column that is not a control at all is narrower, which is the
908        // width that would be wrong to reserve: it has no caret to draw.
909        let inert = Column {
910            sortable: false,
911            ..offering
912        };
913        assert!(measure(&inert, &rows, &style) < measure(&offering, &rows, &style));
914    }
915
916    #[test]
917    fn a_column_sorted_without_being_sortable_still_draws_its_caret() {
918        // A list ordered by a key the user cannot change is a real thing to
919        // describe, which is why the description holds the two fields apart.
920        // Drawing the caret only for a sortable column would collapse them.
921        let cols = vec![Column {
922            name: "rank",
923            width: Width::Content,
924            priority: Priority::Essential,
925            kind: ColumnKind::Text,
926            sortable: false,
927            sorted: Some(Sort::Descending),
928        }];
929        let head = header(&cols, &TableStyle::default(), Priority::Optional);
930        assert_eq!(cell_text(&head), vec!["rank \u{25BC}", "", ""]);
931    }
932
933    #[test]
934    fn the_parts_a_cell_can_be_are_styled_apart() {
935        // The drift `CellPart` exists to end: one style for a whole cell paints
936        // a control as though it were text.
937        let style = TableStyle::default();
938        assert_eq!(style.for_part(Some(CellPart::Value)), style.value);
939        assert_eq!(style.for_part(Some(CellPart::Tokens)), style.tokens);
940        assert_eq!(style.for_part(Some(CellPart::Actions)), style.actions);
941        assert_eq!(style.for_part(Some(CellPart::Link)), style.link);
942        assert_ne!(style.for_part(Some(CellPart::Link)), style.value);
943        // A cell mixing parts says nothing, and takes the text style.
944        assert_eq!(style.for_part(None), style.value);
945    }
946
947    #[test]
948    fn a_table_narrows_itself_from_the_width_it_is_given() {
949        // The whole path in one call, which is what a consumer actually uses.
950        let style = TableStyle::default();
951        let wide = table(&columns(), &rows(), &sizing(), &style, 40);
952        let narrow = table(&columns(), &rows(), &sizing(), &style, 20);
953        use ratatui::layout::Rect;
954        use ratatui::widgets::Widget;
955
956        let mut buf = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 40, 3));
957        wide.render(Rect::new(0, 0, 40, 3), &mut buf);
958        let head: String = (0..40).map(|x| buf[(x, 0)].symbol()).collect();
959        assert!(head.contains("note"));
960
961        let mut buf = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 20, 3));
962        narrow.render(Rect::new(0, 0, 20, 3), &mut buf);
963        let head: String = (0..20).map(|x| buf[(x, 0)].symbol()).collect();
964        assert!(!head.contains("note"), "the optional column is gone");
965        assert!(head.contains("name"), "the essential one is not");
966    }
967
968    #[test]
969    fn selection_is_carried_by_the_background_alone() {
970        // A row can be red for a failure or green for a success, and a
971        // foreground on the selection loses that on exactly the row being looked
972        // at. Asserted on the default so a caller who supplies no theme still
973        // gets the rule.
974        let style = TableStyle::default();
975        assert!(style.selected.fg.is_none());
976    }
977
978    #[test]
979    fn a_number_column_aligns_its_cells_and_heading_to_the_end() {
980        let mut amount = Column::new("Amount");
981        amount.kind = ColumnKind::Number;
982        let prose = Column::new("Buyer");
983        assert_eq!(
984            aligned(&amount, Line::from("9.99")).alignment,
985            Some(ratatui::layout::Alignment::Right)
986        );
987        assert_eq!(aligned(&prose, Line::from("ada")).alignment, None);
988    }
989}