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};
const CUTOFFS: [Priority; 3] = [Priority::Optional, Priority::Secondary, Priority::Essential];
#[derive(Debug, Clone, Copy, Default)]
pub struct Sizing<'a> {
pub lengths: &'a [(&'a str, u16)],
pub fallback: u16,
}
impl Sizing<'_> {
fn length_for(&self, name: &str) -> u16 {
self.lengths
.iter()
.find(|(column, _)| *column == name)
.map_or(self.fallback, |(_, length)| *length)
}
}
#[derive(Debug, Clone)]
pub struct Cell<'a> {
pub column: &'a str,
pub part: Option<CellPart>,
pub content: Line<'a>,
}
impl<'a> Cell<'a> {
#[must_use]
pub fn new(column: &'a str, content: impl Into<Line<'a>>) -> Self {
Self {
column,
part: None,
content: content.into(),
}
}
#[must_use]
pub fn part(mut self, part: CellPart) -> Self {
self.part = Some(part);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TableStyle {
pub header: Style,
pub sorted: Style,
pub value: Style,
pub tokens: Style,
pub actions: Style,
pub link: Style,
pub selected: Style,
pub column_spacing: u16,
pub ascending: &'static str,
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),
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: " \u{25B2}",
descending: " \u{25BC}",
}
}
}
impl TableStyle {
#[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),
value: Style::new().fg(theme.content_primary),
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: " \u{25B2}",
descending: " \u{25BC}",
}
}
#[must_use]
pub fn for_part(&self, part: Option<CellPart>) -> Style {
match part {
Some(CellPart::Tokens) => self.tokens,
Some(CellPart::Actions) => self.actions,
Some(CellPart::Link) => self.link,
_ => self.value,
}
}
}
fn heading<'a>(column: &Column<'a>, style: &TableStyle) -> Line<'a> {
match column.sorted {
Some(Sort::Ascending) => Line::from(format!("{}{}", column.name, style.ascending)),
Some(Sort::Descending) => Line::from(format!("{}{}", column.name, style.descending)),
None => Line::from(column.name),
}
}
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),
_ => sizing.length_for(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)
}
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)
}
#[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
}
#[must_use]
pub fn constraints<'a, R>(
columns: &[Column<'a>],
rows: &[R],
sizing: &Sizing<'_>,
style: &TableStyle,
cutoff: Priority,
) -> Vec<Constraint>
where
R: AsRef<[Cell<'a>]>,
{
columns
.iter()
.filter(|column| column.kept_at(cutoff))
.map(|column| match column.width {
Width::Content => Constraint::Length(measure(column, rows, style)),
Width::Fixed => Constraint::Length(sizing.length_for(column.name)),
_ => Constraint::Min(sizing.length_for(column.name)),
})
.collect()
}
#[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::<Vec<_>>(),
)
}
#[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| {
let tone = if column.sorted.is_some() {
style.sorted
} else {
style.header
};
TrackCell::from(heading(column, style)).style(tone)
})
.collect::<Vec<_>>(),
)
.style(style.header)
}
#[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<Row<'a>> = 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)
}
#[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<Column<'static>> {
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<Cell<'static>>> {
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<String> {
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::<String>()
.trim_end()
.to_owned()
})
.collect()
}
#[test]
fn cells_are_ordered_by_the_columns_and_not_by_the_row() {
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);
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() {
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() {
let cols = vec![Column {
name: "size",
width: Width::Content,
priority: Priority::Essential,
sortable: true,
sorted: Some(Sort::Descending),
}];
let rows: Vec<Vec<Cell<'_>>> = 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());
assert_eq!(
cutoff_for(&cols, &rows, &sz, &style, 40),
Priority::Optional
);
assert_eq!(
cutoff_for(&cols, &rows, &sz, &style, 20),
Priority::Secondary
);
assert_eq!(
cutoff_for(&cols, &rows, &sz, &style, 12),
Priority::Essential
);
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() {
let dropped = |cols: &[Column<'_>], cutoff| -> Vec<String> {
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() {
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() {
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() {
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<Cell<'_>>> = vec![];
let widths = constraints(
&cols,
&rows,
&sizing(),
&TableStyle::default(),
Priority::Optional,
);
assert_eq!(widths[0], Constraint::Length(4));
}
#[test]
fn the_ordered_column_draws_a_caret_and_the_others_do_not() {
let style = TableStyle::default();
let head = header(&columns(), &style, Priority::Optional);
assert_eq!(
cell_text(&head),
vec!["name \u{25B2}", "size", "note"],
"only the column in force carries one"
);
}
#[test]
fn a_column_sorted_without_being_sortable_still_draws_its_caret() {
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() {
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);
assert_eq!(style.for_part(None), style.value);
}
#[test]
fn a_table_narrows_itself_from_the_width_it_is_given() {
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() {
let style = TableStyle::default();
assert!(style.selected.fg.is_none());
}
}