use std::borrow::Cow;
use std::ops::Range;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::theme::Theme;
pub fn display_width(text: &str) -> usize {
UnicodeWidthStr::width(text)
}
pub fn cell_width(text: &str) -> u16 {
u16::try_from(display_width(text)).unwrap_or(u16::MAX)
}
pub fn truncate(text: &str, width: usize) -> String {
if width == 0 {
return String::new();
}
if walked_width(text) <= width {
return text.to_string();
}
let mut out = text[..prefix_fitting(text, width - 1)].to_string();
out.push('…');
out
}
pub fn char_width(c: char) -> usize {
unicode_width::UnicodeWidthChar::width(c).unwrap_or(0)
}
fn prefix_fitting(text: &str, cells: usize) -> usize {
let mut used = 0usize;
for (at, glyph) in glyphs(text) {
used += glyph_width(glyph);
if used > cells {
return at;
}
}
text.len()
}
pub(crate) fn walked_width(text: &str) -> usize {
glyphs(text).map(|(_, glyph)| glyph_width(glyph)).sum()
}
fn glyphs(text: &str) -> impl Iterator<Item = (usize, &str)> {
text.grapheme_indices(true)
}
fn glyph_width(glyph: &str) -> usize {
if glyph.contains(char::is_control) {
return 0;
}
display_width(glyph)
+ glyph
.chars()
.filter(|c| matches!(c, '\u{ff9e}' | '\u{ff9f}'))
.count()
}
fn break_lines(text: &str, width: usize, mut emit: impl FnMut(Range<usize>)) {
if width == 0 {
return;
}
let mut any = false;
let mut offset = 0usize;
for terminated in text.split_inclusive('\n') {
let line = terminated
.strip_suffix('\n')
.map_or(terminated, |line| line.strip_suffix('\r').unwrap_or(line));
let base = offset;
offset += terminated.len();
let mut emit = |row: Range<usize>| emit(base + row.start..base + row.end);
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 = walked_width(word);
if used > 0 && used + w > width {
emit(start..cursor);
(any, emitted_here) = (true, true);
start = cursor;
used = 0;
}
let begins_a_broken_row = emitted_here && start == cursor;
cursor += word.len();
used += w;
if begins_a_broken_row {
start += taken_by_a_break(&line[start..cursor]);
used = walked_width(&line[start..cursor]);
}
while used > width {
let mut cut = start + prefix_fitting(&line[start..cursor], width);
if cut == start {
match glyphs(&line[start..cursor]).next() {
Some((_, glyph)) => cut += glyph.len(),
None => break,
}
}
emit(start..cut);
(any, emitted_here) = (true, true);
start = cut + taken_by_a_break(&line[cut..cursor]);
used = walked_width(&line[start..cursor]);
}
}
if start < cursor || !emitted_here {
emit(start..cursor);
any = true;
}
}
if !any {
emit(0..0);
}
}
fn taken_by_a_break(text: &str) -> usize {
glyphs(text)
.find(|(_, glyph)| !glyph.chars().all(char::is_whitespace))
.map_or(text.len(), |(at, _)| at)
}
pub(crate) struct WrapSource {
width: usize,
run: usize,
}
impl WrapSource {
pub(crate) fn new(width: usize) -> Self {
Self { width, run: 0 }
}
pub(crate) fn needs(&mut self, c: char) -> bool {
if c == '\n' || !c.is_whitespace() {
self.run = 0;
return true;
}
if self.run > self.width {
return false;
}
self.run += char_width(c);
true
}
pub(crate) fn row_cells(width: usize) -> usize {
width.saturating_mul(2).saturating_add(2)
}
}
pub fn wrap(text: &str, width: usize) -> Vec<String> {
let mut out = Vec::new();
break_lines(text, width, |row| out.push(text[row].to_string()));
out
}
pub fn wrap_line(line: &Line<'_>, width: u16) -> Vec<Line<'static>> {
if width == 0 {
return Vec::new();
}
let width = usize::from(width);
let text: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
let mut output = Vec::new();
break_lines(&text, width, |range| {
let row = &text[range.clone()];
let spans = styled_byte_range(&line.spans, range.start, range.end);
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| walked_width(&span.content)).sum();
let row = if rendered_width > width {
Line::from(Span::styled(truncate(row, width), style))
} else {
Line::from(spans)
};
output.push(row.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());
let mut first = true;
break_lines(text, width, |row| {
if !first {
out.push('\n');
}
first = false;
out.push_str(&text[row]);
});
out
}
pub fn fitted_rows(text: &str, area: Rect, abridged: bool) -> String {
let width = usize::from(area.width);
let height = usize::from(area.height);
let mut lines = wrap(text, width);
if lines.len() > height || abridged {
lines.truncate(height);
if let Some(last) = lines.last_mut() {
*last = truncate(&format!("{}…", last.trim_end()), width);
}
}
lines.join("\n")
}
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(test)]
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);
self.line(|column, _| (Cow::Owned(crate::glyphs::utility(column.label)), style))
}
pub fn row(&self, cells: &[Span<'_>]) -> Line<'static> {
self.line(|_, declared| match cells.get(declared) {
Some(span) => (Cow::Borrowed(span.content.as_ref()), span.style),
None => (Cow::Borrowed(""), Style::default()),
})
}
fn line<'a>(&self, cell: impl Fn(&Column, usize) -> (Cow<'a, str>, Style)) -> 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) = cell(column, *declared);
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| walked_width(&s.content)).sum();
if used + part_width > width {
if spans.is_empty() {
let whole: String = part.iter().map(|s| s.content.as_ref()).collect();
let mut abridged = truncate(&whole, width);
for span in part {
if abridged.is_empty() {
break;
}
let own = walked_width(&span.content);
let rest = abridged.split_off(prefix_fitting(&abridged, own));
spans.push(Span::styled(
std::mem::replace(&mut abridged, rest),
span.style,
));
}
}
break;
}
used += part_width;
spans.extend(part);
}
Line::from(spans)
}
pub fn way_out_last(
mut parts: Vec<Vec<Span<'static>>>,
separator: &Span<'static>,
width: u16,
) -> Vec<Vec<Span<'static>>> {
let width = usize::from(width);
let gap = display_width(&separator.content);
let row = |parts: &[Vec<Span<'static>>]| {
parts
.iter()
.flatten()
.map(|span| display_width(&span.content))
.sum::<usize>()
+ gap * parts.len().saturating_sub(1)
};
while parts.len() > 1 && row(&parts) > width {
parts.remove(parts.len() - 2);
}
for part in parts.iter_mut().skip(1) {
part.insert(0, separator.clone());
}
parts
}
fn fit(text: &str, width: u16, align: Align) -> String {
let width = usize::from(width);
if width == 0 {
return String::new();
}
let actual = walked_width(text);
if actual > width {
let mut out = truncate(text, width);
out.push_str(&" ".repeat(width.saturating_sub(walked_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::*;
#[test]
fn a_first_part_abridged_at_a_span_boundary_still_says_so() {
use ratatui::style::Style;
let part = || {
vec![
Span::styled(" 38", Style::default()),
Span::styled("%", Style::default()),
]
};
let text = |line: &Line<'static>| -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
};
let cut = assemble(vec![part()], 3);
assert_eq!(
text(&cut),
" 3\u{2026}",
"the cut at the span boundary is marked"
);
assert_eq!(display_width(&text(&cut)), 3);
assert_eq!(text(&assemble(vec![part()], 2)), " \u{2026}");
let whole = assemble(vec![part()], 4);
assert_eq!(text(&whole), " 38%");
assert_eq!(
whole.spans.len(),
2,
"no spans are merged when nothing is cut"
);
assert_eq!(text(&assemble(vec![part()], 0)), "");
}
#[test]
fn an_abridged_part_gives_each_span_its_own_text() {
use ratatui::style::{Color, Style};
let (lead_style, rest_style) = (Style::new().fg(Color::Red), Style::new().fg(Color::Blue));
for (lead, rest) in [("\u{2600}\u{fe0f}", "b\u{2026}"), ("a\u{7}", "bc\u{2026}")] {
let line = assemble(
vec![vec![
Span::styled(lead, lead_style),
Span::styled("bcdef", rest_style),
]],
4,
);
let spans: Vec<(&str, Style)> = line
.spans
.iter()
.map(|span| (span.content.as_ref(), span.style))
.collect();
assert_eq!(spans, [(lead, lead_style), (rest, rest_style)], "{lead:?}");
let drawn: String = spans
.iter()
.flat_map(|(text, _)| text.chars())
.filter(|c| !c.is_control())
.collect();
assert!(display_width(&drawn) <= 4, "{drawn:?} is wider than 4");
}
}
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",
"\nblank line first",
"\n\n",
"Done. Next one. And another",
"ab cdef ",
"Done.\u{a0} Next\u{a0}\u{a0}one \u{a0} \u{a0}and\u{a0}",
"今日は晴れ\u{3000}明日は雨\u{3000}\u{3000}です\u{3000}",
"tab\t \tand\t\tcr\r \r\n\u{2003}em\u{2003} space",
" \u{301}mark on a space \u{3000}\u{301}",
];
let corpus = samples
.iter()
.map(ToString::to_string)
.chain(crashing_corpus());
for text in corpus {
for width in 1..40u8 {
let rows = wrap(&text, usize::from(width));
assert_eq!(
u16::try_from(rows.len()).unwrap(),
wrapped_height(&text, u16::from(width)),
"{text:?} at {width}"
);
assert_eq!(
wrapped(&text, u16::from(width)),
rows.join("\n"),
"{text:?} at {width}"
);
let line = Line::from(Span::raw(text.as_str()));
let styled: Vec<String> = wrap_line(&line, width.into())
.iter()
.map(|row| row.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert_eq!(styled.len(), rows.len(), "{text:?} at {width}");
for (styled, row) in styled.iter().zip(&rows) {
if walked_width(row) <= usize::from(width) {
assert_eq!(styled, row, "{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_word_that_fills_the_row_leaves_its_space_behind() {
assert_eq!(wrap("abcde fghij", 5), ["abcde", "fghij"]);
assert_eq!(wrap("abcde fgh", 5), ["abcde", "fgh"]);
assert_eq!(wrap("abcde ", 5), ["abcde"]);
assert_eq!(
wrap("No tasks yet. Press", 5),
["No ", "tasks", "yet. ", "Press"]
);
assert_eq!(wrapped_height("abcde fghij", 5), 2);
assert_eq!(wrap("abcde fghij", 5), ["abcde", "fghij"]);
assert_eq!(wrap("Done. Next one", 5), ["Done.", "Next ", "one"]);
assert_eq!(wrap("abcde fgh", 5), ["abcde", "fgh"]);
assert_eq!(wrap("ab cdef", 3), ["ab ", "cde", "f"]);
assert_eq!(wrap("ab ", 3), ["ab "]);
assert_eq!(wrapped_height("abcde fghij", 5), 2);
assert_eq!(wrapped_height("ab cdef", 3), 3);
assert_eq!(wrap("Done.\u{a0} Next one", 5), ["Done.", "Next ", "one"]);
assert_eq!(wrap("abcde\u{a0}fgh", 5), ["abcde", "fgh"]);
assert_eq!(
wrap("今日は晴れ\u{3000}明日は雨", 10),
["今日は晴れ", "明日は雨"]
);
assert_eq!(wrap("今日は晴れ\u{3000}", 10), ["今日は晴れ"]);
assert_eq!(wrap("abcd \t efgh", 5), ["abcd ", "efgh"]);
assert_eq!(wrap("abcde \u{3000}fgh", 5), ["abcde", "fgh"]);
assert_eq!(wrapped_height("今日は晴れ\u{3000}", 10), 1);
assert_eq!(wrap("abcde \u{301}fg", 5), ["abcde", "\u{301}fg"]);
assert_eq!(wrap(" ab\n cd", 5), [" ab", " cd"]);
assert_eq!(wrap("\u{3000}ab\n\tcd", 5), ["\u{3000}ab", "\tcd"]);
assert_eq!(wrap("a\n \nb", 5), ["a", " ", "b"]);
assert_eq!(wrap("a\n\nb", 5), ["a", "", "b"]);
for spans in [
vec![Span::raw("abcde "), Span::raw("fghij")],
vec![Span::raw("abcde "), Span::raw("fghij")],
vec![Span::raw("abcde "), Span::raw(" fghij")],
] {
let line = Line::from(spans);
let rows: Vec<String> = wrap_line(&line, 5)
.iter()
.map(|row| row.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert_eq!(rows, ["abcde", "fghij"], "{line:?}");
}
}
#[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() {
let needle = concat!(".wr", "ap(");
let mut found = Vec::new();
for (path, text) in crate::docs::source_files("src") {
if path == std::path::Path::new("grid.rs") {
continue;
}
let name = path.file_name().unwrap_or_default().to_string_lossy();
for line in text.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("//") {
continue;
}
if line.contains(needle) {
found.push(format!("{name}: {}", trimmed.trim_end()));
}
}
}
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)] = &[
(
"battery.rs",
1,
"the cell's caps are `body + 2` wide, and `body` is cut to the \
width less the chrome beside it before anything is built",
),
(
"todo.rs",
1,
"the priority marker: three short fixed strings",
),
(
"watchlog.rs",
2,
"entry text truncated; the rule line is built to width",
),
];
let needle = concat!("Line::fr", "om(vec![");
let mut found = Vec::new();
for directory in ["src/widgets", "src/plugin"] {
for (path, text) in crate::docs::source_files(directory) {
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(),
);
}
}
}
}
for (module, allowed, _) in ALLOWED {
let actual = found.iter().filter(|f| f == module).count();
assert!(
actual >= *allowed,
"{module} is allowed {allowed} composite line(s) and builds only \
{actual}. Bring the count down to what is there, or take the \
entry out: an allowance that outlives its line passes the next \
one unexamined."
);
}
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 account for the line \
in `ALLOWED` 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 a_glyph_too_wide_for_its_row_keeps_its_selector_and_joins() {
let heart = "\u{2764}\u{fe0f}";
let family = "\u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}";
for glyph in [heart, family] {
let text = glyph.repeat(2);
assert_eq!(wrap(&text, 1), [glyph, glyph], "{text:?}");
assert_eq!(wrapped_height(&text, 1), 2, "{text:?}");
}
}
#[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 a_wrapped_line_keeps_its_line_style_on_every_branch() {
let red = Style::default().fg(ratatui::style::Color::Red);
let bold = Style::default().add_modifier(Modifier::BOLD);
let italic = Style::default().add_modifier(Modifier::ITALIC);
let copied = wrap_line(
&Line::from(vec![
Span::styled("a", bold),
Span::raw("b"),
Span::raw("cd"),
])
.style(red),
2,
);
let collapsed = wrap_line(
&Line::from(vec![
Span::styled("👩", bold),
Span::styled("\u{200d}💻", italic),
])
.style(red),
2,
);
assert_eq!(copied.len(), 2, "{copied:?}");
assert_eq!(
copied[0].spans.len(),
2,
"a row that fits must take the copying branch: {copied:?}"
);
assert_eq!(copied[1].spans[0].style, Style::default(), "{copied:?}");
assert_eq!(collapsed.len(), 1, "{collapsed:?}");
assert_eq!(
collapsed[0].spans.len(),
1,
"the joined emoji must take the collapsing branch: {collapsed:?}"
);
assert_eq!(collapsed[0].spans[0].style, bold, "{collapsed:?}");
for row in copied.iter().chain(&collapsed) {
assert_eq!(row.style, red, "a row lost its line's style: {row:?}");
}
}
#[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),
("clocks", crate::widgets::clocks::COLUMNS_MERIDIEM),
("keymap_dialog", crate::keymap_dialog::COLUMNS),
("notes", crate::widgets::notes::COLUMNS),
("stocks", crate::widgets::stocks::COLUMNS),
("temperature", crate::widgets::temperature::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() {
let mut found = Vec::new();
for (path, text) in crate::docs::source_files("src") {
let declared = text.matches(concat!(": &[", "Column] = &[")).count();
for _ in 0..declared {
found.push(path.file_stem().unwrap().to_string_lossy().into_owned());
}
}
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_is_cut_to_exactly_its_width() {
let grid = Grid::new(&columns(), 4);
let row = grid.row(&[Span::raw("14:00")]);
assert_eq!(
width_of(&row),
4,
"a grid narrower than its fixed columns is cut to the width it was given"
);
}
#[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 a_cut_through_emoji_sequences_is_no_wider_than_asked_for() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let drawn = |text: &str| -> usize {
let mut buffer = Buffer::empty(Rect::new(0, 0, 200, 1));
usize::from(buffer.set_stringn(0, 0, text, 200, Style::default()).0)
};
let glyphs = [
"\u{2764}\u{fe0f}", "\u{2600}\u{fe0f}", "\u{1f469}\u{200d}\u{1f4bb}", "\u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}", "\u{1f44d}\u{1f3fd}", "\u{31}\u{fe0f}\u{20e3}", "a",
"\u{754c}", "\u{1f3fd}\u{200d}\u{1f469}", "\u{31}\u{fe0f}\u{20e3}\u{200d}\u{1f469}", "\u{1f1ef}\u{1f1f5}\u{200d}\u{1f469}", "a\u{1f3fd}\u{200d}\u{1f469}", "\u{ff76}\u{ff9e}", ];
let mut texts: Vec<String> = glyphs.iter().map(|g| g.repeat(5)).collect();
texts.extend(glyphs.iter().map(|g| format!("ab{g}cd{g}{g}ef")));
texts.push(glyphs.concat());
let mut cut = 0;
for text in &texts {
for width in 1..=14usize {
let truncated = truncate(text, width);
assert!(
drawn(&truncated) <= width,
"truncate({text:?}, {width}) = {truncated:?} draws {}",
drawn(&truncated)
);
let column = u16::try_from(width).expect("small");
let fitted = fit(text, column, Align::Left);
assert!(
drawn(&fitted) <= width,
"fit({text:?}, {width}) = {fitted:?}"
);
let line = assemble(vec![vec![Span::raw(text.clone())]], column);
let assembled: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
drawn(&assembled) <= width,
"assemble({text:?}, {width}) = {assembled:?}"
);
if truncated != *text {
cut += 1;
assert!(truncated.ends_with('\u{2026}'), "{truncated:?}");
}
}
}
assert!(cut > 100, "the sweep cut something: {cut}");
}
#[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_cell_width_too_wide_for_a_u16_saturates() {
assert_eq!(cell_width(&"x".repeat(70_000)), u16::MAX);
assert_eq!(cell_width("a🌞b"), 4, "measured in cells, not chars");
assert_eq!(cell_width(""), 0);
}
#[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);
}
}