//! Column layout and row structure for tables. //! //! `makeover-webview`'s `list` module in the shape a terminal allows. It owns //! the same four things: which columns exist, how wide they are, which ones //! survive a narrow viewport, and what each part of a cell is. It does not own //! what goes in a cell, for the reason that module states: a cell holds whatever //! the app builds, and a description expressive enough to emit a task row's five //! nested spans is a templating language wearing a description's name. //! //! # What ratatui already answers //! //! Most of the drawing. [`ratatui::widgets::Table`] lays tracks out from //! [`Constraint`]s, draws a header, highlights a selected row and scrolls //! through [`TableState`](ratatui::widgets::TableState). So this is a mapping //! layer over it rather than a second table implementation, and it hands back a //! `Table` instead of painting one: selection and scroll belong to the app's //! state, and a function that painted would have to take that state to give it //! back. //! //! Two things ratatui does not answer, and they are what this module is: //! //! - **Content measurement.** There is no track that sizes to what is in it, so //! [`Width::Content`] is measured here from the cells and the heading. //! - **Narrowing.** A terminal window is resized far more often than a browser //! one, and [`Priority`] is how a column earns its place. See below. //! //! # Why positions are the bug //! //! Carried from the webview renderer verbatim, because the mistake is not a CSS //! mistake. goingson hides its mobile columns with `nth-child(n+5)` against a //! seven-column table; insert a column left of the cut and the wrong one //! disappears, silently, because nothing in the rule knows what column five //! *is*. A renderer narrows by raising a cutoff and never by counting, which is //! the whole reason [`Priority`] exists. `a_column_inserted_left_of_the_cut_does_not_change_what_drops` //! is that bug as a test. //! //! # What it costs when nothing fits //! //! [`Priority::Essential`] never drops, so a window narrower than the essential //! columns leaves them overflowing rather than emptying the table. That is //! deliberate: a row that cannot identify itself is not a narrower row, it is a //! different one, and ratatui truncates a cell it cannot fit. Truncated and //! present beats absent. use makeover_layout::{CellPart, Column, Priority, Sort, Width}; use ratatui::layout::Constraint; use ratatui::style::{Modifier, Style}; use ratatui::text::Line; use ratatui::widgets::{Cell as TrackCell, Row, Table}; /// The cutoffs, weakest first. /// /// [`Priority`] is `#[non_exhaustive]` and a tier added upstream has to be added /// here in its place in the sequence, or a table will never narrow to it. Grep /// this when adopting a new `makeover-layout`, the way /// `makeover-webview`'s `part_class` asks to be grepped. The cost of missing one /// is a column that drops later than it should, which is visible, rather than a /// build that stops. const CUTOFFS: [Priority; 3] = [Priority::Optional, Priority::Secondary, Priority::Essential]; /// The lengths the description deferred, in cells. /// /// [`Width`] says `Content`, `Fixed` or `Fill` and carries no magnitude, because /// a magnitude is an answer for one renderer and the description is read by /// three. `makeover-webview`'s `Sizing` is this same type holding CSS lengths; /// this one holds terminal cells, and both are looked up by column name for the /// same reason: an app's columns are not all one size. #[derive(Debug, Clone, Copy, Default)] pub struct Sizing<'a> { /// `(column name, cells)`. The track for a [`Width::Fixed`] column and the /// floor for a [`Width::Fill`] one. pub lengths: &'a [(&'a str, u16)], /// Used for a column with no entry above. pub fallback: u16, } impl Sizing<'_> { /// The length for a named column. fn length_for(&self, name: &str) -> u16 { self.lengths .iter() .find(|(column, _)| *column == name) .map_or(self.fallback, |(_, length)| *length) } } /// One cell of a row. /// /// The contents are a ratatui [`Line`] rather than a string, which is this /// crate's version of the webview `Cell` holding markup: the app owns what goes /// in the cell, spans and all, and says which column it belongs to by name. #[derive(Debug, Clone)] pub struct Cell<'a> { /// Which column this fills, by name. pub column: &'a str, /// What the cell holds, when the whole cell is one thing. /// /// `None` for a cell mixing parts. A cell holding a value *and* a strip of /// tokens *and* a control is three parts in one cell, and a terminal cell /// has one style to give, so the app styles the spans itself. This field is /// for the single-part case, which is the common one. pub part: Option, /// The contents. pub content: Line<'a>, } impl<'a> Cell<'a> { /// A cell with no cell part. #[must_use] pub fn new(column: &'a str, content: impl Into>) -> Self { Self { column, part: None, content: content.into(), } } /// The same cell, saying which part it is. #[must_use] pub fn part(mut self, part: CellPart) -> Self { self.part = Some(part); self } } /// The tones and metrics a table draws with. /// /// Apart from [`Palette`] rather than added to it, and the split is the one /// `makeover-immediate` draws between its palette and its `FieldStyle`: /// [`Palette`] answers what a *surface* is, which is what /// [`frame`](crate::frame) needs, and a table is the first thing in this crate /// that draws text. Folding text tones into [`Palette`] would make every /// consumer that only paints a bevel supply six colours it never uses. /// /// [`from_theme`](Self::from_theme) is the answer for anyone with a loaded /// theme, and is what a consumer should reach for first. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TableStyle { /// The heading row. pub header: Style, /// The heading of the column the table is ordered by. pub sorted: Style, /// The heading of a column that offers to reorder and is not doing it now. /// /// The middle of three tones (wiki `three-tone-convention`): it answers a /// press, so it is neither the emphasised thing nor the inert one. A /// heading that took [`header`](Self::header) here would be indistinguishable /// from a column that cannot be reordered at all, which is the state this /// separates it from. pub sortable: Style, /// A cell that is text. pub value: Style, /// A cell holding badges or chips. They carry their own tone, so this is /// what sits under one rather than what paints it. pub tokens: Style, /// A cell holding controls. pub actions: Style, /// A cell whose value is itself a link. pub link: Style, /// The row under the cursor, for a caller rendering with a /// [`TableState`](ratatui::widgets::TableState). pub selected: Style, /// Cells between columns. Counted when deciding what fits, so a table that /// narrows and a table that draws agree about the room available. pub column_spacing: u16, /// The caret drawn after the heading of an ascending column. /// /// Defaults to [`Sort::glyph`], which is where the spelling lives now: three /// renderers holding the same literal agreed by coincidence. Still a knob, /// because a terminal is the one host that may not be able to draw it — a /// font without the geometric-shapes block leaves a box, and `"^"` is a /// better caret than a tofu. /// /// Bare, with no leading space: the gap is [`heading`]'s, written once for /// all three states rather than baked into two strings and forgotten in the /// third. pub ascending: &'static str, /// The caret drawn after the heading of a descending column. pub descending: &'static str, } impl Default for TableStyle { fn default() -> Self { Self { header: Style::new().add_modifier(Modifier::BOLD), sorted: Style::new().add_modifier(Modifier::BOLD), // Nothing of its own. A cell style patches the row's, so a colour // is the only thing that could separate this from the header row it // sits in, and the colourless default has none to spend: the idle // caret is what says the heading answers a press. `from_theme` is // where the three tones are real. sortable: Style::new(), value: Style::new(), tokens: Style::new(), actions: Style::new(), link: Style::new().add_modifier(Modifier::UNDERLINED), selected: Style::new().add_modifier(Modifier::REVERSED), column_spacing: 1, ascending: Sort::Ascending.glyph(), descending: Sort::Descending.glyph(), } } } impl TableStyle { /// The house table, from a loaded theme. /// /// This is the lift `mnw-cli` and `viewer` were each doing by hand: a muted /// bold heading, the ordered column brought back up to primary, actions and /// links on the action colour rather than on the cell's text colour, and /// selection carried by the background alone. /// /// Selection carries no foreground on purpose. A row can be red for a failed /// upload or green for a published item, and repainting its text on /// selection loses that distinction on exactly the row the user is looking /// at. `mnw-cli`'s `selected_style` found this and its comment says so; /// this is that comment's code, in the library, once. #[cfg(feature = "theme")] #[must_use] pub fn from_theme(theme: &crate::Theme) -> Self { Self { header: Style::new() .fg(theme.content_muted) .add_modifier(Modifier::BOLD), sorted: Style::new() .fg(theme.content_primary) .add_modifier(Modifier::BOLD), sortable: Style::new().fg(theme.content_secondary), value: Style::new().fg(theme.content_primary), // A token paints its own background, and a tone underneath it would // fight the one sitting on it. Secondary is what shows through the // gaps. tokens: Style::new().fg(theme.content_secondary), actions: Style::new().fg(theme.action_primary), link: Style::new() .fg(theme.action_primary) .add_modifier(Modifier::UNDERLINED), selected: Style::new() .bg(theme.surface_raised) .add_modifier(Modifier::BOLD), column_spacing: 1, ascending: Sort::Ascending.glyph(), descending: Sort::Descending.glyph(), } } /// The style a cell of this part takes. /// /// [`CellPart`] is `#[non_exhaustive]`, and a member added upstream lands on /// [`value`](Self::value): a part this renderer has not learned draws as /// text, which is a cell rendering plainly rather than a build that stops. /// Grep this when adopting a new `makeover-layout`. #[must_use] pub fn for_part(&self, part: Option) -> Style { match part { Some(CellPart::Tokens) => self.tokens, Some(CellPart::Actions) => self.actions, Some(CellPart::Link) => self.link, _ => self.value, } } } /// The heading, with the caret if this column is ordered by or offers to be. /// /// A column [`sorted`](Column::sorted) but not /// [`sortable`](Column::sortable) still gets its caret. Both combinations mean /// something, which is why the description holds the two fields apart: a list /// ordered by a key the user cannot change is a real thing, and the caret is how /// it says so. /// /// A column sortable and *not* sorted draws the idle mark, in the ascending /// spelling because that is the direction a first press takes. The tone is what /// separates it from the column in force, and [`header`] picks that; here the /// point is the width. This is what closes the reflow: pressing a heading used /// to widen its column by two cells and shift every column after it, because /// [`measure`] sizes from this function and the caret appeared with the press. fn heading<'a>(column: &Column<'a>, style: &TableStyle) -> Line<'a> { let caret = match column.sorted { Some(Sort::Ascending) => style.ascending, Some(Sort::Descending) => style.descending, None if column.sortable => style.ascending, None => return Line::from(column.name), }; // The gap, once, rather than inside each of the two style strings. A // consumer swapping the glyph for an ASCII one does not have to remember to // bring a space with it. Line::from(format!("{} {caret}", column.name)) } /// How wide a column wants to be, in cells, at its narrowest. /// /// The floor for a fill column rather than its appetite, because narrowing asks /// what a layout costs at minimum and a fill column costs its floor. fn min_width<'a, R>(column: &Column<'a>, rows: &[R], sizing: &Sizing<'_>, style: &TableStyle) -> u16 where R: AsRef<[Cell<'a>]>, { match column.width { Width::Content => measure(column, rows, style), Width::Fixed => sizing.length_for(column.name), // Includes a width added to the description since this renderer was // built. Taking the slack above a floor is the behaviour that makes no // claim, which is the same fallback the webview renderer's `auto` track // is chosen to be. _ => sizing.length_for(column.name), } } /// The widest thing in a column, heading included. /// /// The heading counts because it is drawn: a column sized to its cells alone /// truncates its own name, and a two-character column called `duration` reads as /// `du`. The caret counts for the same reason, which is why this measures /// [`heading`] rather than [`Column::name`]. fn measure<'a, R>(column: &Column<'a>, rows: &[R], style: &TableStyle) -> u16 where R: AsRef<[Cell<'a>]>, { let widest = rows .iter() .filter_map(|row| { row.as_ref() .iter() .find(|cell| cell.column == column.name) .map(|cell| cell.content.width()) }) .max() .unwrap_or(0); u16::try_from(widest.max(heading(column, style).width())).unwrap_or(u16::MAX) } /// Whether the columns kept at `cutoff` fit in `width`. fn fits<'a, R>( columns: &[Column<'a>], rows: &[R], sizing: &Sizing<'_>, style: &TableStyle, cutoff: Priority, width: u16, ) -> bool where R: AsRef<[Cell<'a>]>, { let kept: Vec<&Column<'a>> = columns.iter().filter(|c| c.kept_at(cutoff)).collect(); let gaps = u32::from(style.column_spacing) * (kept.len().saturating_sub(1)) as u32; let tracks: u32 = kept .iter() .map(|c| u32::from(min_width(c, rows, sizing, style))) .sum(); tracks + gaps <= u32::from(width) } /// The weakest cutoff whose columns fit in `width`. /// /// Raised until the layout fits, and never past [`Priority::Essential`]: the /// essential columns are what makes a row identify itself, so a window too /// narrow for them gets them truncated rather than dropped. Nothing here counts /// positions, so which column drops is a property of the column. #[must_use] pub fn cutoff_for<'a, R>( columns: &[Column<'a>], rows: &[R], sizing: &Sizing<'_>, style: &TableStyle, width: u16, ) -> Priority where R: AsRef<[Cell<'a>]>, { for cutoff in CUTOFFS { if fits(columns, rows, sizing, style, cutoff, width) { return cutoff; } } Priority::Essential } /// The tracks for the columns kept at `cutoff`. /// /// Only the surviving tracks, which is what keeps the track list and the hiding /// in agreement. A caller that dropped a cell but left its track would get a /// column of empty space, which is the other half of the goingson bug the /// webview renderer's `grid_template_columns` names. #[must_use] pub fn constraints<'a, R>( columns: &[Column<'a>], rows: &[R], sizing: &Sizing<'_>, style: &TableStyle, cutoff: Priority, ) -> Vec where R: AsRef<[Cell<'a>]>, { columns .iter() .filter(|column| column.kept_at(cutoff)) .map(|column| match column.width { // Takes what it needs and no more, which is a fixed track once the // needing has been measured. Width::Content => Constraint::Length(measure(column, rows, style)), Width::Fixed => Constraint::Length(sizing.length_for(column.name)), // `Min` and not `Fill`: a fill column absorbs the slack *and* keeps // its floor, which is what `minmax(len, 1fr)` says at the webview // renderer. `Fill` would let it collapse below the floor when a // fixed column takes the room. _ => Constraint::Min(sizing.length_for(column.name)), }) .collect() } /// One row's cells, in column order. /// /// Ordered by the columns and not by the cells, so a row cannot silently /// disagree with its table about what comes where. A column with no cell gets an /// empty cell, which keeps the tracks aligned; a cell naming no column is /// dropped, because there is nowhere to put it. That is /// `makeover-webview`'s `cells_html` rule, and it has to be the same rule or the /// two renderers disagree about a row they were handed identically. #[must_use] pub fn row<'a>( columns: &[Column<'a>], cells: &[Cell<'a>], style: &TableStyle, cutoff: Priority, ) -> Row<'a> { Row::new( columns .iter() .filter(|column| column.kept_at(cutoff)) .map(|column| { let found = cells.iter().find(|cell| cell.column == column.name); let part = found.and_then(|cell| cell.part); let content = found.map_or_else(Line::default, |cell| cell.content.clone()); TrackCell::from(content).style(style.for_part(part)) }) .collect::>(), ) } /// The heading row for the columns kept at `cutoff`. /// /// Exposed beside [`table`] because a caller assembling its own /// [`Table`] still has to draw a header that agrees with the body about what /// just disappeared. Assembling it a second time by hand is how they stop /// agreeing. #[must_use] pub fn header<'a>(columns: &[Column<'a>], style: &TableStyle, cutoff: Priority) -> Row<'a> { Row::new( columns .iter() .filter(|column| column.kept_at(cutoff)) .map(|column| { // Three states, three tones (wiki `three-tone-convention`). In // force, offering, and not a control at all -- and the middle // one is the state that had nowhere to be said, so a heading // you could press looked exactly like one you could not. let tone = match (column.sorted, column.sortable) { (Some(_), _) => style.sorted, (None, true) => style.sortable, (None, false) => style.header, }; TrackCell::from(heading(column, style)).style(tone) }) .collect::>(), ) .style(style.header) } /// A described table, sized and narrowed for `width`. /// /// Hands back a [`Table`] rather than drawing one. Selection and scroll live in /// the app's [`TableState`](ratatui::widgets::TableState), and the row highlight /// is already set from [`TableStyle::selected`], so a caller renders this with /// `render_stateful_widget` and gets the house selection without saying anything /// further. /// /// `width` is the area the table will be drawn in, which is what narrowing is /// decided against. Pass the [`Rect`](ratatui::layout::Rect) width that /// [`frame`](crate::frame) handed back rather than the region's own, or the /// table budgets for the two cells the edge took. #[must_use] pub fn table<'a, R>( columns: &[Column<'a>], rows: &[R], sizing: &Sizing<'_>, style: &TableStyle, width: u16, ) -> Table<'a> where R: AsRef<[Cell<'a>]>, { let cutoff = cutoff_for(columns, rows, sizing, style, width); let widths = constraints(columns, rows, sizing, style, cutoff); let body: Vec> = rows .iter() .map(|cells| row(columns, cells.as_ref(), style, cutoff)) .collect(); Table::new(body, widths) .header(header(columns, style, cutoff)) .column_spacing(style.column_spacing) .row_highlight_style(style.selected) } /// Whether a table drawn at `width` would leave anything overflowing. /// /// True only when the essential columns alone do not fit, since that is the one /// case narrowing cannot answer. A caller that would rather show fewer rows than /// truncate a cell can ask this and draw something else. #[must_use] pub fn overflows<'a, R>( columns: &[Column<'a>], rows: &[R], sizing: &Sizing<'_>, style: &TableStyle, width: u16, ) -> bool where R: AsRef<[Cell<'a>]>, { !fits(columns, rows, sizing, style, Priority::Essential, width) } #[cfg(test)] mod tests { use super::*; fn columns() -> Vec> { vec![ Column { name: "name", width: Width::Fill, priority: Priority::Essential, sortable: true, sorted: Some(Sort::Ascending), }, Column { name: "size", width: Width::Fixed, priority: Priority::Secondary, sortable: true, sorted: None, }, Column { name: "note", width: Width::Content, priority: Priority::Optional, sortable: false, sorted: None, }, ] } fn sizing() -> Sizing<'static> { Sizing { lengths: &[("name", 10), ("size", 6)], fallback: 4, } } fn rows() -> Vec>> { vec![ vec![ Cell::new("name", "alpha"), Cell::new("size", "1kb"), Cell::new("note", "a longer note"), ], vec![Cell::new("name", "beta"), Cell::new("size", "2kb")], ] } fn cell_text(row: &Row<'_>) -> Vec { // Rendering is the only way to read a ratatui Row back, and reading it // back is the point: these tests assert what a user sees. use ratatui::layout::Rect; use ratatui::widgets::Widget; let mut buf = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 60, 1)); Table::new(vec![row.clone()], [Constraint::Length(18); 3]) .column_spacing(1) .render(Rect::new(0, 0, 60, 1), &mut buf); (0..3) .map(|i| { let start = i * 19; (start..start + 18) .map(|x| buf[(x as u16, 0)].symbol()) .collect::() .trim_end() .to_owned() }) .collect() } /// The foreground each of the three heading cells was drawn in. /// /// Read off a rendered buffer for [`cell_text`]'s reason: a ratatui `Row` /// hands nothing back, and what is asserted is what a user sees. fn cell_colors(row: &Row<'_>) -> Vec> { use ratatui::layout::Rect; use ratatui::widgets::Widget; let mut buf = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 60, 1)); Table::new(vec![row.clone()], [Constraint::Length(18); 3]) .column_spacing(1) .render(Rect::new(0, 0, 60, 1), &mut buf); (0..3).map(|i| buf[(i * 19, 0)].fg).map(Some).collect() } #[test] fn cells_are_ordered_by_the_columns_and_not_by_the_row() { // The row hands them over backwards. The table decides the order, which // is what stops a row silently disagreeing with its own header. let cols = columns(); let out_of_order = vec![ Cell::new("note", "third"), Cell::new("name", "first"), Cell::new("size", "second"), ]; let drawn = row( &cols, &out_of_order, &TableStyle::default(), Priority::Optional, ); assert_eq!(cell_text(&drawn), vec!["first", "second", "third"]); } #[test] fn a_cell_naming_no_column_is_dropped_and_a_column_with_no_cell_keeps_its_place() { let cols = columns(); let cells = vec![Cell::new("note", "kept"), Cell::new("nonesuch", "lost")]; let drawn = row(&cols, &cells, &TableStyle::default(), Priority::Optional); // Two empty tracks, then the note. The empties are what keeps the third // column under the third heading. assert_eq!(cell_text(&drawn), vec!["", "", "kept"]); } #[test] fn a_content_column_is_measured_from_its_widest_cell() { let style = TableStyle::default(); let widths = constraints(&columns(), &rows(), &sizing(), &style, Priority::Optional); assert_eq!(widths[2], Constraint::Length("a longer note".len() as u16)); } #[test] fn a_content_column_never_truncates_its_own_heading() { // The cells are two characters wide and the heading is eight. Sizing to // the cells alone would draw the column as `du`. let cols = vec![Column { name: "duration", width: Width::Content, priority: Priority::Essential, sortable: false, sorted: None, }]; let rows = vec![vec![Cell::new("duration", "3s")]]; let widths = constraints( &cols, &rows, &sizing(), &TableStyle::default(), Priority::Optional, ); assert_eq!(widths[0], Constraint::Length(8)); } #[test] fn a_caret_is_part_of_what_a_heading_costs() { // Measured off `heading` and not off `name`, or the sorted column is // exactly two cells too narrow and drops its own arrow. let cols = vec![Column { name: "size", width: Width::Content, priority: Priority::Essential, sortable: true, sorted: Some(Sort::Descending), }]; let rows: Vec>> = vec![]; let style = TableStyle::default(); let widths = constraints(&cols, &rows, &sizing(), &style, Priority::Optional); assert_eq!( widths[0], Constraint::Length(6), "size plus a space and a caret" ); } #[test] fn narrowing_drops_the_optional_column_first_and_the_essential_one_never() { let style = TableStyle::default(); let (cols, rows, sz) = (columns(), rows(), sizing()); // Everything: 10 + 6 + 13 tracks and two gaps. assert_eq!( cutoff_for(&cols, &rows, &sz, &style, 40), Priority::Optional ); // No room for the note. assert_eq!( cutoff_for(&cols, &rows, &sz, &style, 20), Priority::Secondary ); // No room for the size either. assert_eq!( cutoff_for(&cols, &rows, &sz, &style, 12), Priority::Essential ); // No room for anything, and the essential column stays anyway. assert_eq!( cutoff_for(&cols, &rows, &sz, &style, 2), Priority::Essential ); assert!(overflows(&cols, &rows, &sz, &style, 2)); assert!(!overflows(&cols, &rows, &sz, &style, 12)); } #[test] fn a_column_inserted_left_of_the_cut_does_not_change_what_drops() { // The goingson bug, as a test. `nth-child(n+5)` against a seven-column // table hides whatever lands at position five, so inserting a column // anywhere left of the cut moves it onto a different column with nothing // edited and nothing reported. // // Asserted at a fixed cutoff, because that is where the two ways of // addressing a column disagree. A narrower budget SHOULD drop more // columns, and does below; what must not change is which ones, in what // order, for a given cutoff. let dropped = |cols: &[Column<'_>], cutoff| -> Vec { cols.iter() .filter(|c| !c.kept_at(cutoff)) .map(|c| c.name.to_owned()) .collect() }; let before = columns(); let mut after = vec![Column { name: "mark", width: Width::Fixed, priority: Priority::Essential, sortable: false, sorted: None, }]; after.extend(columns()); for cutoff in CUTOFFS { assert_eq!( dropped(&before, cutoff), dropped(&after, cutoff), "inserting a column changed what {cutoff:?} drops" ); } assert_eq!(dropped(&before, Priority::Secondary), vec!["note"]); } #[test] fn a_column_never_outlives_a_more_essential_one() { // The ordering claim narrowing rests on: whatever the budget, the set // kept is closed upward. A layout that dropped `size` while keeping // `note` would be counting something other than priority. let style = TableStyle::default(); let (cols, rows, sz) = (columns(), rows(), sizing()); for width in 0..48u16 { let cutoff = cutoff_for(&cols, &rows, &sz, &style, width); let kept: Vec<&str> = cols .iter() .filter(|c| c.kept_at(cutoff)) .map(|c| c.name) .collect(); assert!( kept.contains(&"name"), "the essential column left at {width}" ); if kept.contains(&"note") { assert!( kept.contains(&"size"), "optional outlived secondary at {width}" ); } } } #[test] fn a_dropped_column_takes_its_track_with_it() { // A cell hidden with its track left behind is a column of empty space, // which is the half of the goingson bug that survives fixing the other. let style = TableStyle::default(); let widths = constraints(&columns(), &rows(), &sizing(), &style, Priority::Secondary); assert_eq!(widths.len(), 2); let drawn = row(&columns(), &rows()[0], &style, Priority::Secondary); assert_eq!(cell_text(&drawn), vec!["alpha", "1kb", ""]); } #[test] fn a_fill_column_keeps_its_floor_while_taking_the_slack() { // `Min` and not `Fill`, which is `minmax(10, 1fr)` at the webview // renderer. A `Fill` track collapses under a fixed neighbour. let style = TableStyle::default(); let widths = constraints(&columns(), &rows(), &sizing(), &style, Priority::Optional); assert_eq!(widths[0], Constraint::Min(10)); assert_eq!(widths[1], Constraint::Length(6)); } #[test] fn a_column_with_no_length_of_its_own_takes_the_fallback() { let cols = vec![Column { name: "unlisted", width: Width::Fixed, priority: Priority::Essential, sortable: false, sorted: None, }]; let rows: Vec>> = vec![]; let widths = constraints( &cols, &rows, &sizing(), &TableStyle::default(), Priority::Optional, ); assert_eq!(widths[0], Constraint::Length(4)); } #[test] fn a_heading_carries_a_caret_when_it_is_ordered_by_or_offers_to_be() { let style = TableStyle::default(); let head = header(&columns(), &style, Priority::Optional); assert_eq!( cell_text(&head), vec!["name \u{25B2}", "size \u{25B2}", "note"], "in force and offering both carry one; not a control carries none" ); } #[test] fn the_three_states_of_a_heading_are_three_tones() { // wiki `three-tone-convention`. The middle state is the one that had // nowhere to be said: a heading you can press looked exactly like one // you cannot, and the idle caret alone does not separate them, because // a sorted-but-unsortable column draws a caret too. use ratatui::style::Color; let style = TableStyle { sorted: Style::new().fg(Color::Red), sortable: Style::new().fg(Color::Green), header: Style::new().fg(Color::Blue), ..TableStyle::default() }; let drawn = cell_colors(&header(&columns(), &style, Priority::Optional)); assert_eq!( drawn, vec![Some(Color::Red), Some(Color::Green), Some(Color::Blue)] ); // The colourless default separates them by the caret and nothing else, // and that is the honest limit rather than an oversight: a cell style // patches the row's, so a plain cell under a bold header row is drawn // bold whatever it holds. Three tones need three colours, which is what // `from_theme` is for. let house = TableStyle::default(); let plain = cell_colors(&header(&columns(), &house, Priority::Optional)); assert_eq!(plain[0], plain[1], "no colour to spend, so none is claimed"); } #[test] fn pressing_a_heading_does_not_move_the_columns_after_it() { // The reflow the idle caret closes. `measure` sizes from `heading`, so // a caret that appeared with the press widened its own column by two // cells and shifted the rest of the row sideways. let style = TableStyle::default(); let offering = Column { name: "size", width: Width::Content, priority: Priority::Secondary, sortable: true, sorted: None, }; let in_force = Column { sorted: Some(Sort::Descending), ..offering }; let rows: Vec>> = vec![]; assert_eq!( measure(&offering, &rows, &style), measure(&in_force, &rows, &style) ); // And the column that is not a control at all is narrower, which is the // width that would be wrong to reserve: it has no caret to draw. let inert = Column { sortable: false, ..offering }; assert!(measure(&inert, &rows, &style) < measure(&offering, &rows, &style)); } #[test] fn a_column_sorted_without_being_sortable_still_draws_its_caret() { // A list ordered by a key the user cannot change is a real thing to // describe, which is why the description holds the two fields apart. // Drawing the caret only for a sortable column would collapse them. let cols = vec![Column { name: "rank", width: Width::Content, priority: Priority::Essential, sortable: false, sorted: Some(Sort::Descending), }]; let head = header(&cols, &TableStyle::default(), Priority::Optional); assert_eq!(cell_text(&head), vec!["rank \u{25BC}", "", ""]); } #[test] fn the_parts_a_cell_can_be_are_styled_apart() { // The drift `CellPart` exists to end: one style for a whole cell paints // a control as though it were text. let style = TableStyle::default(); assert_eq!(style.for_part(Some(CellPart::Value)), style.value); assert_eq!(style.for_part(Some(CellPart::Tokens)), style.tokens); assert_eq!(style.for_part(Some(CellPart::Actions)), style.actions); assert_eq!(style.for_part(Some(CellPart::Link)), style.link); assert_ne!(style.for_part(Some(CellPart::Link)), style.value); // A cell mixing parts says nothing, and takes the text style. assert_eq!(style.for_part(None), style.value); } #[test] fn a_table_narrows_itself_from_the_width_it_is_given() { // The whole path in one call, which is what a consumer actually uses. let style = TableStyle::default(); let wide = table(&columns(), &rows(), &sizing(), &style, 40); let narrow = table(&columns(), &rows(), &sizing(), &style, 20); use ratatui::layout::Rect; use ratatui::widgets::Widget; let mut buf = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 40, 3)); wide.render(Rect::new(0, 0, 40, 3), &mut buf); let head: String = (0..40).map(|x| buf[(x, 0)].symbol()).collect(); assert!(head.contains("note")); let mut buf = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 20, 3)); narrow.render(Rect::new(0, 0, 20, 3), &mut buf); let head: String = (0..20).map(|x| buf[(x, 0)].symbol()).collect(); assert!(!head.contains("note"), "the optional column is gone"); assert!(head.contains("name"), "the essential one is not"); } #[test] fn selection_is_carried_by_the_background_alone() { // A row can be red for a failure or green for a success, and a // foreground on the selection loses that on exactly the row being looked // at. Asserted on the default so a caller who supplies no theme still // gets the rule. let style = TableStyle::default(); assert!(style.selected.fg.is_none()); } }