use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthStr;
use crate::theme::Theme;
pub fn display_width(text: &str) -> usize {
UnicodeWidthStr::width(text)
}
pub fn truncate(text: &str, width: usize) -> String {
if width == 0 {
return String::new();
}
if display_width(text) <= width {
return text.to_string();
}
let budget = width - 1;
let mut out = String::new();
let mut used = 0usize;
for c in text.chars() {
let w = char_width(c);
if used + w > budget {
break;
}
out.push(c);
used += w;
}
out.push('…');
out
}
pub fn char_width(c: char) -> usize {
unicode_width::UnicodeWidthChar::width(c).unwrap_or(0)
}
fn break_lines<'a>(text: &'a str, width: usize, mut emit: impl FnMut(&'a str)) {
if width == 0 {
return;
}
let mut any = false;
for line in text.lines() {
let mut start = 0usize;
let mut cursor = 0usize;
let mut used = 0usize;
let mut emitted_here = false;
for word in line.split_inclusive(' ') {
let w = display_width(word);
if used > 0 && used + w > width {
emit(&line[start..cursor]);
(any, emitted_here) = (true, true);
start = cursor;
used = 0;
}
cursor += word.len();
used += w;
while used > width {
let mut cut = start;
let mut taken = 0usize;
for c in line[start..cursor].chars() {
let cw = char_width(c);
if taken + cw > width {
break;
}
taken += cw;
cut += c.len_utf8();
}
if cut == start {
match line[start..cursor].chars().next() {
Some(c) => cut += c.len_utf8(),
None => break,
}
}
emit(&line[start..cut]);
(any, emitted_here) = (true, true);
start = cut;
used = display_width(&line[start..cursor]);
}
}
if start < cursor || !emitted_here {
emit(&line[start..cursor]);
any = true;
}
}
if !any {
emit(&text[..0]);
}
}
pub fn wrap(text: &str, width: usize) -> Vec<String> {
let mut out = Vec::new();
break_lines(text, width, |line| out.push(line.to_string()));
out
}
pub fn wrap_line(line: &Line<'_>, width: usize) -> Vec<Line<'static>> {
if width == 0 {
return Vec::new();
}
let text: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
let base = text.as_ptr() as usize;
let mut output = Vec::new();
break_lines(&text, width, |row| {
let spans = (row.as_ptr() as usize)
.checked_sub(base)
.filter(|start| start.saturating_add(row.len()) <= text.len())
.and_then(|start| styled_byte_range(&line.spans, start, start + row.len()));
let style = spans
.as_ref()
.and_then(|spans| spans.first())
.map_or(line.style, |span| span.style);
let spans = spans.unwrap_or_else(|| vec![Span::styled(row.to_string(), style)]);
let rendered_width: usize = spans.iter().map(|span| display_width(&span.content)).sum();
if rendered_width > width {
output.push(Line::from(Span::styled(truncate(row, width), style)));
} else {
output.push(Line::from(spans).style(line.style));
}
});
output
}
fn styled_byte_range(source: &[Span<'_>], start: usize, end: usize) -> Option<Vec<Span<'static>>> {
let expected = end.checked_sub(start)?;
let mut output = Vec::new();
let mut offset = 0usize;
let mut copied = 0usize;
for span in source {
let span_end = offset.checked_add(span.content.len())?;
if start < span_end && end > offset {
let local_start = start.saturating_sub(offset);
let local_end = end.min(span_end).checked_sub(offset)?;
let content = span.content.get(local_start..local_end)?;
if !content.is_empty() {
copied = copied.checked_add(content.len())?;
output.push(Span::styled(content.to_string(), span.style));
}
}
offset = span_end;
if offset >= end {
break;
}
}
(copied == expected).then_some(output)
}
pub fn wrapped(text: &str, width: u16) -> String {
let width = usize::from(width);
let mut out = String::with_capacity(text.len());
break_lines(text, width, |line| {
if !out.is_empty() {
out.push('\n');
}
out.push_str(line);
});
out
}
pub fn wrapped_height(text: &str, width: u16) -> u16 {
if width == 0 {
return 0;
}
let mut rows: usize = 0;
break_lines(text, usize::from(width), |_| rows += 1);
u16::try_from(rows.max(1)).unwrap_or(u16::MAX)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Width {
Fixed(u16),
Flex(u16),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Align {
Left,
Right,
}
#[derive(Debug, Clone, Copy)]
pub struct Column {
pub label: &'static str,
pub width: Width,
pub align: Align,
pub min_total: u16,
}
impl Column {
pub const fn flex(label: &'static str, weight: u16) -> Self {
Self {
label,
width: Width::Flex(weight),
align: Align::Left,
min_total: 0,
}
}
pub const fn fixed(label: &'static str, width: u16) -> Self {
Self {
label,
width: Width::Fixed(width),
align: Align::Left,
min_total: 0,
}
}
pub const fn right(mut self) -> Self {
self.align = Align::Right;
self
}
pub const fn drops_below(mut self, total: u16) -> Self {
self.min_total = total;
self
}
}
#[derive(Debug, Clone)]
pub struct Grid {
resolved: Vec<(Column, u16, usize)>,
}
pub(crate) const GUTTER: u16 = 1;
impl Grid {
pub fn new(columns: &[Column], total: u16) -> Self {
let kept: Vec<(usize, Column)> = columns
.iter()
.copied()
.enumerate()
.filter(|(_, c)| c.min_total == 0 || total >= c.min_total)
.collect();
if kept.is_empty() {
return Self {
resolved: Vec::new(),
};
}
let gutters = GUTTER * u16::try_from(kept.len().saturating_sub(1)).unwrap_or(0);
let fixed: u16 = kept
.iter()
.filter_map(|(_, c)| match c.width {
Width::Fixed(w) => Some(w),
Width::Flex(_) => None,
})
.sum();
let flexible: u16 = kept
.iter()
.filter_map(|(_, c)| match c.width {
Width::Flex(w) => Some(w.max(1)),
Width::Fixed(_) => None,
})
.sum();
let spare = total.saturating_sub(fixed).saturating_sub(gutters);
let mut resolved = Vec::with_capacity(kept.len());
let mut handed_out = 0u16;
let flex_count = kept
.iter()
.filter(|(_, c)| matches!(c.width, Width::Flex(_)))
.count();
let mut flex_seen = 0usize;
for (declared, column) in kept {
let width = match column.width {
Width::Fixed(w) => w,
Width::Flex(weight) => {
flex_seen += 1;
if flex_seen == flex_count {
spare.saturating_sub(handed_out)
} else {
let portion = spare
.checked_mul(weight.max(1))
.and_then(|scaled| scaled.checked_div(flexible))
.unwrap_or(0);
handed_out += portion;
portion
}
}
};
resolved.push((column, width, declared));
}
let mut left = total;
let mut emitted = false;
for (_, width, _) in &mut resolved {
if *width == 0 {
continue;
}
let gutter = if emitted { GUTTER } else { 0 };
if left <= gutter {
*width = 0;
continue;
}
*width = (*width).min(left - gutter);
if *width > 0 {
left -= gutter + *width;
emitted = true;
}
}
Self { resolved }
}
pub fn is_empty(&self) -> bool {
self.resolved.is_empty()
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn has(&self, label: &str) -> bool {
self.resolved
.iter()
.any(|(c, w, _)| c.label == label && *w > 0)
}
pub fn column_width(&self, label: &str) -> u16 {
self.resolved
.iter()
.find(|(c, _, _)| c.label == label)
.map_or(0, |(_, w, _)| *w)
}
pub fn header(&self, theme: &Theme) -> Line<'static> {
let style = Style::default()
.fg(theme.label)
.add_modifier(Modifier::BOLD);
let mut spans = Vec::new();
for (column, width, _) in &self.resolved {
if *width == 0 {
continue;
}
if !spans.is_empty() {
spans.push(Span::raw(" ".repeat(GUTTER as usize)));
}
let text = crate::glyphs::utility(column.label);
spans.push(Span::styled(fit(&text, *width, column.align), style));
}
Line::from(spans)
}
pub fn row(&self, cells: &[Span<'_>]) -> Line<'static> {
let mut spans = Vec::new();
for (column, width, declared) in &self.resolved {
if *width == 0 {
continue;
}
if !spans.is_empty() {
spans.push(Span::raw(" ".repeat(GUTTER as usize)));
}
let (content, style) = match cells.get(*declared) {
Some(span) => (span.content.as_ref(), span.style),
None => ("", Style::default()),
};
spans.push(Span::styled(fit(content, *width, column.align), style));
}
Line::from(spans)
}
}
pub fn assemble(parts: Vec<Vec<Span<'static>>>, width: u16) -> Line<'static> {
let width = usize::from(width);
let mut spans: Vec<Span<'static>> = Vec::new();
let mut used = 0usize;
for part in parts {
let part_width: usize = part.iter().map(|s| display_width(&s.content)).sum();
if used + part_width > width {
if spans.is_empty() {
let mut room = width;
for span in part {
if room == 0 {
break;
}
let text = truncate(&span.content, room);
room -= display_width(&text);
spans.push(Span::styled(text, span.style));
}
}
break;
}
used += part_width;
spans.extend(part);
}
Line::from(spans)
}
fn fit(text: &str, width: u16, align: Align) -> String {
let width = width as usize;
if width == 0 {
return String::new();
}
let actual = display_width(text);
if actual > width {
let mut out = truncate(text, width);
out.push_str(&" ".repeat(width.saturating_sub(display_width(&out))));
return out;
}
let pad = " ".repeat(width - actual);
match align {
Align::Left => format!("{text}{pad}"),
Align::Right => format!("{pad}{text}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::widgets::{Paragraph, Wrap};
#[test]
fn wrapping_produces_exactly_as_many_rows_as_it_measures() {
let samples = [
"Wildfire now nine miles away from the French city of Bordeaux, mayor warns",
"short",
"",
"a supercalifragilisticexpialidociousandthensomeword in a line",
"Europa Clipper returns its first images — and they are extraordinary",
"line one\nline two is a good deal longer than the first one is",
"\u{4E2D}\u{6587}\u{6807}\u{9898}",
"\u{65E5}\u{672C}\u{8A9E}\u{306E}\u{30CB}\u{30E5}\u{30FC}\u{30B9}\u{3067}\u{3059}",
"a\u{1F31E}b\u{1F31E}c",
"\u{1F31E}\u{1F31E}\u{1F31E}\u{1F31E}",
"mixed \u{4E2D}\u{6587} and english \u{1F31E} together",
"\u{0301}\u{0301}leading marks",
"blank\n\nline in the middle",
];
for text in samples {
for width in 1..40u16 {
assert_eq!(
u16::try_from(wrap(text, usize::from(width)).len()).unwrap(),
wrapped_height(text, width),
"`{text}` at {width}"
);
}
}
}
#[test]
fn a_row_consumed_exactly_leaves_no_blank_behind() {
assert_eq!(wrap("\u{1F31E}\u{1F31E}", 1), ["\u{1F31E}", "\u{1F31E}"]);
assert_eq!(wrap("abcdef", 3), ["abc", "def"]);
assert_eq!(wrap("\u{65E5}\u{672C}", 2), ["\u{65E5}", "\u{672C}"]);
assert_eq!(wrap("a\n\nb", 4), ["a", "", "b"]);
assert_eq!(wrap("", 4), [""]);
assert_eq!(wrap("\n", 4), [""]);
}
#[test]
fn a_glyph_wider_than_the_line_does_not_hang() {
for text in [
"\u{1F31E}\u{1F31E}",
"\u{4E2D}\u{6587}\u{6807}\u{9898}",
"a\u{1F31E}b",
] {
for width in 1..4usize {
let out = wrap(text, width);
assert!(
out.len() <= text.chars().count() + 1,
"`{text}` at {width} produced {} lines",
out.len()
);
let joined: String = out.concat();
assert_eq!(joined, text, "at {width}");
}
}
}
fn crashing_corpus() -> Vec<String> {
let alphabet = ["a", "\u{1F31E}", "\u{65E5}", " ", "\n", "\u{0301}"];
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
(0..600)
.map(|_| {
let len = (next() % 9) as usize + 1;
(0..len)
.map(|_| alphabet[(next() % alphabet.len() as u64) as usize])
.collect()
})
.collect()
}
fn renders_without_panicking(paragraph: impl Fn() -> Paragraph<'static>, width: u16) -> bool {
let mut terminal = Terminal::new(TestBackend::new(width, 12)).unwrap();
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
terminal
.draw(|frame| frame.render_widget(paragraph(), frame.area()))
.unwrap();
}))
.is_ok()
}
#[test]
fn only_the_known_delete_confirmation_uses_ratatuis_own_wrapper() {
fn walk(dir: &std::path::Path, needle: &str, found: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(&path, needle, found);
} else if path.extension().is_some_and(|e| e == "rs")
&& path.file_name().is_some_and(|n| n != "grid.rs")
&& let Ok(text) = std::fs::read_to_string(&path)
{
for line in text.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("//") {
continue;
}
if line.contains(needle) {
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
found.push(format!("{name}: {}", trimmed.trim_end()));
}
}
}
}
}
let needle = concat!(".wr", "ap(");
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut found = Vec::new();
walk(&root, needle, &mut found);
found.sort();
assert_eq!(
found.len(),
1,
"expected exactly one use of ratatui's wrapper — the delete \
confirmation — and found {}:\n {}\n\n\
If this is a new site rendering text mirador did not write, wrap it \
with `grid::wrapped` instead: ratatui's wrapper panics on a leading \
combining mark, and on a double-width glyph in a word too long for \
a two-cell area, which is ordinary prose. If it really is text this \
program wrote itself, widen this count and say why.",
found.len(),
found.join("\n ")
);
assert!(
found.iter().any(|f| f.starts_with("todo.rs:")),
"the delete confirmation's wrap went missing: {found:?}"
);
}
#[test]
fn every_hand_built_composite_line_in_a_widget_is_accounted_for() {
const ALLOWED: &[(&str, usize, &str)] = &[
("agenda.rs", 1, "the row's summary is truncated to `room`"),
(
"notes.rs",
2,
"search field and caret, both cut to the pane",
),
("stocks.rs", 1, "the add field, assembled by `status_line`"),
(
"todo.rs",
1,
"the priority marker: three short fixed strings",
),
(
"watchlog.rs",
2,
"entry text truncated; the rule line is built to width",
),
];
fn walk(dir: &std::path::Path, needle: &str, found: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(&path, needle, found);
} else if path.extension().is_some_and(|e| e == "rs")
&& let Ok(text) = std::fs::read_to_string(&path)
{
let body = text.split("mod tests").next().unwrap_or(&text);
for line in body.lines() {
if line.trim_start().starts_with("//") {
continue;
}
if line.contains(needle) {
found.push(
path.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
);
}
}
}
}
}
let needle = concat!("Line::fr", "om(vec![");
let mut found = Vec::new();
let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
for directory in ["widgets", "plugin"] {
walk(&source.join(directory), needle, &mut found);
}
for name in &found {
let allowed = ALLOWED
.iter()
.find(|(module, _, _)| module == name)
.map_or(0, |(_, count, _)| *count);
let actual = found.iter().filter(|f| *f == name).count();
assert!(
actual <= allowed,
"{name} builds {actual} composite line(s) by hand; {allowed} are \
accounted for.\n\n\
A line assembled from parts and drawn at its natural width is \
cut by the terminal, which cannot tell a value from a fragment \
— `↑ 6.4 KB` where a rate was meant, `+52.0` where a price was. \
Build it with `grid::assemble` so whole parts are dropped \
instead, or bound every span yourself and add the module here \
with the reason."
);
}
}
#[test]
fn ratatuis_own_wrapper_is_why_this_module_wraps_first() {
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let straddles_a_two_cell_row = renders_without_panicking(
|| Paragraph::new("a\u{1F31E}b").wrap(Wrap { trim: false }),
2,
);
let leading_combining_mark = renders_without_panicking(
|| {
Paragraph::new("\u{0301} \u{1F31E}\u{65E5}\u{65E5}\u{65E5}\u{1F31E}\u{1F31E}a")
.wrap(Wrap { trim: false })
},
8,
);
std::panic::set_hook(hook);
assert!(
!straddles_a_two_cell_row && !leading_combining_mark,
"ratatui no longer panics here (two-cell row: {}, combining mark: {}). \
Check whether `grid::wrapped` is still needed before removing this.",
!straddles_a_two_cell_row,
!leading_combining_mark
);
}
#[test]
fn pre_wrapping_survives_what_crashes_ratatui() {
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let mut crashed = Vec::new();
for text in crashing_corpus() {
for width in 1..12u16 {
let wrapped_text = wrapped(&text, width);
if !renders_without_panicking(|| Paragraph::new(wrapped_text.clone()), width) {
crashed.push(format!("{text:?} at {width}"));
}
}
}
std::panic::set_hook(hook);
assert!(crashed.is_empty(), "still panics: {crashed:?}");
}
#[test]
fn no_wrapped_line_is_wider_than_asked_for() {
let text = "Wildfire now nine miles away from the French city of Bordeaux";
for width in 8..40usize {
for line in wrap(text, width) {
assert!(
display_width(&line) <= width,
"`{line}` is wider than {width}"
);
}
}
}
#[test]
fn styled_wrapping_uses_the_width_ratatui_will_render() {
let line = Line::from(vec![
Span::styled("👩", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(
"\u{200d}💻",
Style::default().add_modifier(Modifier::ITALIC),
),
]);
let wrapped = wrap_line(&line, 2);
assert_eq!(wrapped.len(), 1);
let rendered_width: usize = wrapped[0]
.spans
.iter()
.map(|span| display_width(&span.content))
.sum();
assert!(rendered_width <= 2, "rendered width was {rendered_width}");
}
#[test]
fn styled_wrapping_treats_embedded_line_endings_as_guarded_hard_breaks() {
let first = Style::default().add_modifier(Modifier::BOLD);
let second = Style::default().add_modifier(Modifier::ITALIC);
let third = Style::default().add_modifier(Modifier::UNDERLINED);
let line = Line::from(vec![
Span::styled("ab\r", first),
Span::styled("\né\n", second),
Span::styled("界", third),
]);
let wrapped = wrap_line(&line, 10);
let text: Vec<String> = wrapped
.iter()
.map(|row| row.spans.iter().map(|span| span.content.as_ref()).collect())
.collect();
assert_eq!(text, ["ab", "é", "界"]);
assert_eq!(wrapped[0].spans[0].style, first);
assert_eq!(wrapped[1].spans[0].style, second);
assert_eq!(wrapped[2].spans[0].style, third);
}
#[test]
fn only_an_unfittable_glyph_may_overrun_and_only_by_itself() {
let out = wrap("\u{1F31E}\u{1F31E}", 1);
for line in &out {
assert!(
line.chars().count() <= 1,
"an overrunning line holds one glyph and no more: `{line}`"
);
}
}
fn columns() -> Vec<Column> {
vec![
Column::fixed("hour", 5),
Column::flex("sky", 1),
Column::fixed("temp", 6).right(),
Column::fixed("rain", 5).right().drops_below(40),
]
}
fn width_of(line: &Line<'_>) -> usize {
line.spans.iter().map(|s| display_width(&s.content)).sum()
}
const EVERY_GRID: &[(&str, &[Column])] = &[
("calculator", crate::widgets::calculator::COLUMNS),
("clocks", crate::widgets::clocks::COLUMNS),
("notes", crate::widgets::notes::COLUMNS),
("stocks", crate::widgets::stocks::COLUMNS),
("todo", crate::widgets::todo::COLUMNS),
("weather", crate::widgets::weather::COLUMNS),
];
#[test]
fn no_grid_in_the_program_ever_overflows_its_width() {
let cells = [
Span::raw("wwwwwwwwwwwwwwwwwwww"),
Span::raw("12:34:56"),
Span::raw("日本語日本語"),
Span::raw("🌞🌞🌞"),
Span::raw("+52.07"),
Span::raw("x"),
];
for (name, columns) in EVERY_GRID {
for total in 0u16..=200 {
let grid = Grid::new(columns, total);
let header = width_of(&grid.header(&Theme::default()));
let row = width_of(&grid.row(&cells));
assert!(
header <= usize::from(total),
"{name}'s header is {header} cells wide in a {total}-cell grid; \
the terminal will cut the overhang off a label without saying so"
);
assert!(
row <= usize::from(total),
"{name} builds a {row}-cell row for a {total}-cell grid. \
The overhang is not dropped by mirador, it is dropped by the \
terminal — so the last value on the row loses its tail and \
still reads as a whole number. Give the column a \
`drops_below` threshold that suits it."
);
}
}
}
#[test]
fn an_assembled_line_never_exceeds_the_width_it_was_given() {
let parts = vec![
vec![Span::raw("12.3"), Span::raw("% "), Span::raw("LOAD")],
vec![Span::raw(" 40s")],
vec![Span::raw(" \u{2191} 6.4 KB/s")],
vec![Span::raw(" \u{65E5}\u{672C}\u{8A9E}")],
vec![Span::raw(" \u{1F31E}\u{1F31E}")],
];
for width in 0u16..=60 {
let line = assemble(parts.clone(), width);
assert!(
width_of(&line) <= usize::from(width),
"assemble produced {} cells for a width of {width}",
width_of(&line)
);
}
}
#[test]
fn a_part_that_does_not_fit_is_dropped_whole_rather_than_cut() {
let parts = vec![
vec![Span::raw("\u{2193} 6.4 KB/s")],
vec![Span::raw(" \u{2191} 1.2 MB/s")],
];
let whole = width_of(&assemble(parts.clone(), 60));
assert_eq!(whole, 23, "the fixture should need 23 cells");
let line = assemble(parts.clone(), 22);
let text: String = line.spans.iter().map(|s| s.content.to_string()).collect();
assert_eq!(text, "\u{2193} 6.4 KB/s");
assert_eq!(width_of(&assemble(parts, 23)), 23);
}
#[test]
fn a_lone_part_too_wide_to_fit_is_ellipsised_rather_than_dropped() {
let parts = vec![vec![Span::raw("^GSPC: network access is refused")]];
let line = assemble(parts, 12);
let text: String = line.spans.iter().map(|s| s.content.to_string()).collect();
assert!(
text.ends_with('\u{2026}'),
"an abridged message must say so, got {text:?}"
);
assert!(display_width(&text) <= 12);
assert!(!text.is_empty(), "a blank line reads as a broken panel");
}
#[test]
fn every_grid_in_the_program_is_on_this_list() {
fn walk(dir: &std::path::Path, found: &mut Vec<String>) {
for entry in std::fs::read_dir(dir).unwrap().flatten() {
let path = entry.path();
if path.is_dir() {
walk(&path, found);
} else if path.extension().is_some_and(|e| e == "rs") {
let text = std::fs::read_to_string(&path).unwrap();
if text.contains(concat!("const COLUMNS: &[", "Column]")) {
found.push(path.file_stem().unwrap().to_string_lossy().into_owned());
}
}
}
}
let mut found = Vec::new();
walk(
&std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"),
&mut found,
);
found.sort();
let mut listed: Vec<String> = EVERY_GRID.iter().map(|(n, _)| (*n).to_string()).collect();
listed.sort();
assert_eq!(
found, listed,
"the source declares grids {found:?} but EVERY_GRID names {listed:?}.\n\n\
Add the new one to EVERY_GRID. Until it is there, \
no_grid_in_the_program_ever_overflows_its_width does not cover it, \
and it will pass anyway — which is the failure this check exists \
to make impossible."
);
}
#[test]
fn a_grid_fills_its_width_exactly() {
for total in [20u16, 33, 40, 80, 200] {
let grid = Grid::new(&columns(), total);
let header = grid.header(&Theme::default());
assert_eq!(
width_of(&header),
total as usize,
"header did not fill width {total}"
);
}
}
#[test]
fn every_row_is_the_same_width_as_the_header() {
let grid = Grid::new(&columns(), 60);
let header = width_of(&grid.header(&Theme::default()));
for cells in [
vec![],
vec![Span::raw("14:00")],
vec![
Span::raw("14:00"),
Span::raw("partly cloudy"),
Span::raw("72°"),
Span::raw("20%"),
],
vec![
Span::raw("a"),
Span::raw("b"),
Span::raw("c"),
Span::raw("d"),
Span::raw("e"),
Span::raw("f"),
],
] {
assert_eq!(width_of(&grid.row(&cells)), header);
}
}
#[test]
fn optional_columns_drop_when_the_grid_is_narrow() {
let wide = Grid::new(&columns(), 80);
assert!(wide.has("rain"));
let narrow = Grid::new(&columns(), 30);
assert!(!narrow.has("rain"), "rain should drop below its threshold");
assert!(narrow.has("hour"), "required columns must survive");
}
#[test]
fn a_grid_with_no_room_still_produces_a_full_width_row() {
let grid = Grid::new(&columns(), 4);
let row = grid.row(&[Span::raw("14:00")]);
assert!(width_of(&row) >= 4);
}
#[test]
fn an_empty_column_list_yields_an_empty_grid() {
let grid = Grid::new(&[], 40);
assert!(grid.is_empty());
assert_eq!(width_of(&grid.row(&[Span::raw("x")])), 0);
}
#[test]
fn flex_columns_split_the_leftover_space_by_weight() {
let cols = [
Column::fixed("a", 10),
Column::flex("b", 1),
Column::flex("c", 3),
];
let grid = Grid::new(&cols, 50);
let widths: Vec<u16> = grid.resolved.iter().map(|(_, w, _)| *w).collect();
assert_eq!(widths[0], 10);
assert_eq!(widths[1] + widths[2], 38);
assert!(
widths[2] > widths[1] * 2,
"weight 3 should dominate weight 1"
);
}
#[test]
fn fit_pads_and_aligns() {
assert_eq!(fit("ab", 5, Align::Left), "ab ");
assert_eq!(fit("ab", 5, Align::Right), " ab");
assert_eq!(fit("", 3, Align::Left), " ");
assert_eq!(fit("abc", 3, Align::Left), "abc");
}
#[test]
fn fit_truncates_with_an_ellipsis_on_char_boundaries() {
assert_eq!(fit("abcdef", 4, Align::Left), "abc…");
assert_eq!(display_width(&fit("日本語テスト", 3, Align::Left)), 3);
assert_eq!(fit("abc", 1, Align::Left), "…");
assert_eq!(fit("abc", 0, Align::Left), "");
}
#[test]
fn fit_output_is_always_exactly_the_requested_width() {
for text in [
"",
"a",
"hello",
"a much longer value",
"日本語テスト",
"☀ clear",
"⛈ thunderstorm",
"🦀 rust",
] {
for width in 0..14u16 {
let out = fit(text, width, Align::Left);
assert_eq!(
display_width(&out),
width as usize,
"`{text}` at width {width} produced `{out}`"
);
}
}
}
#[test]
fn a_double_width_glyph_does_not_shift_the_columns_after_it() {
let cols = [Column::fixed("sky", 12), Column::fixed("temp", 6).right()];
let grid = Grid::new(&cols, 19);
let plain = grid.row(&[Span::raw("clear"), Span::raw("63°F")]);
let wide = grid.row(&[Span::raw("☀ clear"), Span::raw("63°F")]);
assert_eq!(
width_of(&plain),
width_of(&wide),
"a wide glyph must not change the row width"
);
}
#[test]
fn a_dropped_column_takes_its_own_value_with_it() {
let cols = [
Column::fixed("a", 4),
Column::fixed("b", 4).drops_below(100),
Column::fixed("c", 4),
];
let cells = [Span::raw("AAA"), Span::raw("BBB"), Span::raw("CCC")];
let wide = Grid::new(&cols, 100);
let text: String = wide
.row(&cells)
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(text.contains("AAA") && text.contains("BBB") && text.contains("CCC"));
let narrow = Grid::new(&cols, 20);
assert!(!narrow.has("b"), "the test needs `b` dropped");
let text: String = narrow
.row(&cells)
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
!text.contains("BBB"),
"a dropped column's value must not be rendered: `{text}`"
);
assert!(text.contains("AAA") && text.contains("CCC"), "got `{text}`");
let header: String = narrow
.header(&Theme::default())
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert_eq!(
header.find('C'),
text.find("CCC"),
"`CCC` is not under the `C` header: header `{header}` row `{text}`"
);
}
#[test]
fn headers_are_uppercased_and_bold_rather_than_letterspaced() {
let grid = Grid::new(&[Column::fixed("hour", 12)], 12);
let header = grid.header(&Theme::default());
let text: String = header.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.starts_with("HOUR"), "got `{text}`");
assert!(
header
.spans
.iter()
.all(|s| s.style.add_modifier.contains(Modifier::BOLD)),
"weight is what separates the header from the data now"
);
}
#[test]
fn every_column_in_a_header_gets_the_same_treatment() {
let cols = [Column::fixed("done", 4), Column::fixed("sky", 20)];
let grid = Grid::new(&cols, 25);
let text: String = grid
.header(&Theme::default())
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(text.starts_with("DONE"), "got `{text}`");
assert!(text.contains("SKY"), "got `{text}`");
assert!(!text.contains("S K Y"), "no letterspacing: `{text}`");
assert!(!text.contains("D O N E"), "no letterspacing: `{text}`");
}
#[test]
fn a_header_too_long_for_its_column_truncates_rather_than_shifting() {
let grid = Grid::new(
&[Column::fixed("temperature", 6), Column::fixed("b", 4)],
11,
);
let header = grid.header(&Theme::default());
assert_eq!(width_of(&header), 11);
}
}