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