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
}
fn char_width(c: char) -> usize {
unicode_width::UnicodeWidthChar::width(c).unwrap_or(0)
}
pub fn wrapped_height(text: &str, width: u16) -> u16 {
if width == 0 {
return 0;
}
let width = usize::from(width);
let mut rows: usize = 0;
for line in text.lines() {
let mut used = 0usize;
let mut rows_here = 1usize;
for word in line.split_inclusive(' ') {
let w = display_width(word);
if used > 0 && used + w > width {
rows_here += 1;
used = w;
} else {
used += w;
}
while used > width {
rows_here += 1;
used -= width;
}
}
rows += rows_here;
}
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::*;
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);
}
}