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("");
}
}
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 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)>,
}
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));
}
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 (index, (column, width, _)) in self.resolved.iter().enumerate() {
if index > 0 {
spans.push(Span::raw(" ".repeat(GUTTER as usize)));
}
if *width == 0 {
continue;
}
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 (index, (column, width, declared)) in self.resolved.iter().enumerate() {
if index > 0 {
spans.push(Span::raw(" ".repeat(GUTTER as usize)));
}
if *width == 0 {
continue;
}
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)
}
}
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 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 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()
}
#[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);
}
}