use std::num::NonZeroUsize;
use std::ops::Range;
use ratatui::text::Line;
use unicode_segmentation::UnicodeSegmentation;
use super::inline_col_map::{InlineColMap, RefLabels};
pub const MIN_COL_WIDTH: usize = 3;
pub fn char_cells(ch: char) -> usize {
unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0)
}
pub fn str_cells(s: &str) -> usize {
s.chars().map(char_cells).sum()
}
pub fn cells_of(chars: &[char]) -> usize {
chars.iter().map(|&c| char_cells(c)).sum()
}
pub fn is_soft_break_space(ch: char) -> bool {
ch.is_whitespace() && ch != '\u{00A0}'
}
pub const PER_COL_OVERHEAD: usize = 3;
pub const ROW_END_OVERHEAD: usize = 1;
pub fn compute_widths(
cell_max_widths: &[Vec<usize>],
cell_min_widths: &[Vec<usize>],
col_count: usize,
viewport_width: usize,
user_widths: Option<&[Option<usize>]>,
) -> Vec<usize> {
if col_count == 0 {
return Vec::new();
}
let mut col_max = vec![MIN_COL_WIDTH; col_count];
let mut col_min = vec![MIN_COL_WIDTH; col_count];
for row in cell_max_widths {
for (i, w) in row.iter().take(col_count).enumerate() {
col_max[i] = col_max[i].max(*w);
}
}
for row in cell_min_widths {
for (i, w) in row.iter().take(col_count).enumerate() {
col_min[i] = col_min[i].max(*w);
}
}
for i in 0..col_count {
if col_min[i] > col_max[i] {
col_max[i] = col_min[i];
}
}
let mut widths = col_max.clone();
let mut pinned = vec![false; col_count];
if let Some(uw) = user_widths {
for (i, w) in uw.iter().take(col_count).enumerate() {
if let Some(val) = w {
widths[i] = (*val).max(MIN_COL_WIDTH);
pinned[i] = true;
}
}
}
let border_budget = PER_COL_OVERHEAD * col_count + ROW_END_OVERHEAD;
let pinned_total: usize = widths
.iter()
.enumerate()
.filter(|(i, _)| pinned[*i])
.map(|(_, w)| *w)
.sum();
let available = viewport_width
.saturating_sub(border_budget)
.saturating_sub(pinned_total);
let unpinned: Vec<usize> = (0..col_count).filter(|i| !pinned[*i]).collect();
let unpinned_max_total: usize = unpinned.iter().map(|i| col_max[*i]).sum();
let unpinned_min_total: usize = unpinned.iter().map(|i| col_min[*i]).sum();
if unpinned_max_total <= available {
for &i in &unpinned {
widths[i] = col_max[i];
}
} else if unpinned_min_total <= available {
let slack = available - unpinned_min_total;
let total_weight: usize = unpinned.iter().map(|i| col_max[*i] - col_min[*i]).sum();
if let Some(total_weight) = NonZeroUsize::new(total_weight) {
let mut residuals: Vec<(usize, usize)> = Vec::with_capacity(unpinned.len());
let mut assigned = 0usize;
for &i in &unpinned {
let weight = col_max[i] - col_min[i];
let numer = slack * weight;
let extra = numer / total_weight;
let remainder = numer % total_weight;
widths[i] = col_min[i] + extra;
assigned += extra;
residuals.push((i, remainder));
}
residuals.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
let mut leftover = slack.saturating_sub(assigned);
for (i, _) in residuals {
if leftover == 0 {
break;
}
if widths[i] < col_max[i] {
widths[i] += 1;
leftover -= 1;
}
}
} else {
for &i in &unpinned {
widths[i] = col_min[i];
}
}
} else {
for &i in &unpinned {
widths[i] = col_min[i];
}
}
widths
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Joint {
Inside,
Cluster,
Glyph,
}
pub fn joints(chars: &[char]) -> Vec<Joint> {
if chars.iter().all(char::is_ascii) {
return vec![Joint::Cluster; chars.len() + 1];
}
joints_in_str(&chars.iter().collect::<String>(), chars.len())
}
pub fn joints_in_str(text: &str, n_chars: usize) -> Vec<Joint> {
let mut out = vec![Joint::Cluster; n_chars + 1];
let mut idx = 0usize;
let mut prev: Option<(char, bool)> = None;
for cluster in text.graphemes(true) {
let len = cluster.chars().count();
let wide = str_cells(cluster) > 1;
for joint in &mut out[idx + 1..idx + len] {
*joint = Joint::Inside;
}
if let (Some((before, prev_wide)), Some(after)) = (prev, cluster.chars().next()) {
let spaced = before.is_whitespace() || after.is_whitespace();
let bound = no_break_after(before)
|| no_break_before(after)
|| [before, after]
.into_iter()
.any(|c| is_no_break_char(c) || is_ambiguous_quote(c));
if (wide || prev_wide) && !spaced && !bound {
out[idx] = Joint::Glyph;
}
}
prev = cluster.chars().last().map(|c| (c, wide));
idx += len;
}
out
}
pub fn is_no_break_char(ch: char) -> bool {
matches!(ch, '\u{a0}' | '\u{202f}' | '\u{2060}' | '\u{feff}')
}
pub fn is_ambiguous_quote(ch: char) -> bool {
matches!(ch, '"' | '\'' | '\u{2019}')
}
pub fn no_break_before(ch: char) -> bool {
matches!(
ch,
')' | ']'
| '}'
| '.'
| ','
| ':'
| ';'
| '!'
| '?'
| '\u{201d}' | '\u{bb}' | '\u{2026}' | '\u{2025}' | '\u{3001}' | '\u{3002}' | '\u{3005}' | '\u{3009}' | '\u{300b}' | '\u{300d}' | '\u{300f}' | '\u{3011}' | '\u{3015}' | '\u{3017}' | '\u{3019}' | '\u{301f}' | '\u{309d}' | '\u{309e}' | '\u{30fb}' | '\u{30fc}' | '\u{30fd}' | '\u{30fe}' | '\u{ff01}' | '\u{ff09}' | '\u{ff0c}' | '\u{ff0e}' | '\u{ff1a}' | '\u{ff1b}' | '\u{ff1f}' | '\u{ff3d}' | '\u{ff5d}' | '\u{ff63}' )
}
pub fn no_break_after(ch: char) -> bool {
matches!(
ch,
'(' | '['
| '{'
| '\u{2018}' | '\u{201c}' | '\u{ab}' | '\u{bf}' | '\u{a1}' | '\u{3008}' | '\u{300a}' | '\u{300c}' | '\u{300e}' | '\u{3010}' | '\u{3014}' | '\u{3016}' | '\u{3018}' | '\u{301d}' | '\u{ff08}' | '\u{ff3b}' | '\u{ff5b}' | '\u{ff62}' )
}
pub fn last_cluster_start(chars: &[char]) -> usize {
let joints = joints(chars);
(0..chars.len())
.rev()
.find(|&i| joints[i] != Joint::Inside)
.unwrap_or(0)
}
pub fn word_ranges(chars: &[char]) -> Vec<Range<usize>> {
let joints = joints(chars);
word_ranges_with(chars, &joints)
}
fn word_ranges_with(chars: &[char], joints: &[Joint]) -> Vec<Range<usize>> {
let mut words = Vec::new();
let mut i = 0usize;
while i < chars.len() {
if is_soft_break_space(chars[i]) {
i += 1;
continue;
}
let start = i;
i += 1;
while i < chars.len() && !is_soft_break_space(chars[i]) && joints[i] != Joint::Glyph {
i += 1;
}
words.push(start..i);
}
words
}
pub fn wrap_ranges(chars: &[char], width: usize) -> Vec<Range<usize>> {
if width == 0 || chars.is_empty() {
return vec![Range {
start: 0,
end: chars.len(),
}];
}
let joints = joints(chars);
let mut rows: Vec<Range<usize>> = Vec::new();
let mut current: Option<(Range<usize>, usize)> = None;
let mut token_start = 0usize;
for word in word_ranges_with(chars, &joints) {
let token = token_start..word.end;
token_start = word.end;
let w = cells_of(&chars[token.clone()]);
if let Some((row, row_w)) = current.as_mut() {
if *row_w + w <= width {
row.end = token.end;
*row_w += w;
continue;
}
}
if let Some((row, _)) = current.take() {
rows.push(row);
}
let start = if rows.is_empty() {
token.start
} else {
word.start
};
let w = cells_of(&chars[start..token.end]);
if w <= width {
current = Some((start..token.end, w));
} else {
split_at_clusters(chars, &joints, start..token.end, width, true, &mut rows);
}
}
if let Some((row, _)) = current {
rows.push(row);
}
if rows.is_empty() {
rows.push(0..0);
}
rows
}
pub fn wrap_cell(text: &str, width: usize) -> Vec<String> {
let chars: Vec<char> = text.chars().collect();
wrap_ranges(&chars, width)
.into_iter()
.map(|r| chars[r].iter().collect())
.collect()
}
pub fn wrap_cell_with_indices(text: &str, width: usize) -> Vec<(usize, String)> {
let chars: Vec<char> = text.chars().collect();
wrap_ranges(&chars, width)
.into_iter()
.map(|r| (r.start, chars[r].iter().collect()))
.collect()
}
pub fn hard_wrap_ranges(chars: &[char], width: usize) -> Vec<Range<usize>> {
if width == 0 || chars.is_empty() {
return vec![Range {
start: 0,
end: chars.len(),
}];
}
let joints = joints(chars);
let mut rows = Vec::new();
split_at_clusters(chars, &joints, 0..chars.len(), width, false, &mut rows);
rows
}
fn split_at_clusters(
chars: &[char],
joints: &[Joint],
range: Range<usize>,
width: usize,
prefer_punctuation: bool,
rows: &mut Vec<Range<usize>>,
) {
let mut start = range.start;
let mut w = 0usize;
let mut i = range.start;
while i < range.end {
let mut next = i + 1;
while next < range.end && joints[next] == Joint::Inside {
next += 1;
}
let cw = cells_of(&chars[i..next]);
if w + cw > width && i > start {
let mut cut = i;
if prefer_punctuation {
let p = start + preferred_cut(i - start, |k| chars[start + k]);
if joints[p] != Joint::Inside && cells_of(&chars[p..i]) + cw <= width {
cut = p;
}
}
rows.push(start..cut);
start = cut;
w = cells_of(&chars[start..i]);
}
w += cw;
i = next;
}
rows.push(start..range.end);
}
pub fn is_break_after(ch: char) -> bool {
matches!(
ch,
'_' | '-'
| '.'
| ','
| ';'
| ':'
| '/'
| '\\'
| '('
| ')'
| '['
| ']'
| '{'
| '}'
| '='
| '&'
| '?'
| '#'
| '@'
)
}
pub fn preferred_cut(len: usize, char_at: impl Fn(usize) -> char) -> usize {
let lookback = len / 2;
for back in 0..lookback {
let i = len - 1 - back;
if is_break_after(char_at(i)) {
return i + 1;
}
}
len
}
pub fn parse_column_widths_comment(text: &str) -> Option<Vec<Option<usize>>> {
let start = text.find("<!-- tui-columns:")?;
let after = &text[start + "<!-- tui-columns:".len()..];
let end_rel = after.find("-->")?;
let body = after[..end_rel].trim();
let inner = body.strip_prefix('[')?.strip_suffix(']')?;
let mut widths = Vec::new();
for part in inner.split(',') {
let s = part.trim();
if s.is_empty() {
continue;
}
if s == "_" {
widths.push(None);
} else {
widths.push(Some(s.parse::<usize>().ok()?));
}
}
if widths.is_empty() || widths.iter().all(Option::is_none) {
None
} else {
Some(widths)
}
}
pub fn format_column_widths_comment(widths: &[Option<usize>]) -> String {
let body: Vec<String> = widths
.iter()
.map(|w| match w {
Some(v) => v.to_string(),
None => "_".to_owned(),
})
.collect();
format!("<!-- tui-columns: [{}] -->", body.join(", "))
}
pub fn raw_pipe_positions(row: &str) -> Vec<usize> {
let mut positions = Vec::new();
let mut escaped = false;
for (i, ch) in row.chars().enumerate() {
if ch == '|' && !escaped {
positions.push(i);
escaped = false;
} else if ch == '\\' {
escaped = !escaped;
} else {
escaped = false;
}
}
positions
}
pub fn raw_cells(row: &str, content_col: usize) -> Vec<Range<usize>> {
let chars: Vec<char> = row.chars().collect();
let mut end = chars.len();
while end > content_col && chars[end - 1].is_whitespace() {
end -= 1;
}
let mut start = content_col.min(end);
while start < end && chars[start].is_whitespace() {
start += 1;
}
if start >= end {
return Vec::new();
}
let mut pipes: Vec<usize> = raw_pipe_positions(row)
.into_iter()
.filter(|p| (start..end).contains(p))
.collect();
let open = if pipes.first() == Some(&start) {
pipes.remove(0);
start + 1
} else {
start
};
let close = if end > open && pipes.last() == Some(&(end - 1)) {
pipes.pop();
end - 1
} else {
end
};
let mut cells = Vec::with_capacity(pipes.len() + 1);
let mut from = open;
for p in pipes {
cells.push(from..p);
from = p + 1;
}
cells.push(from..close.max(from));
cells
}
pub fn paired_raw_cells(row: &str, content_col: usize, drawn: usize) -> Option<Vec<Range<usize>>> {
let mut cells = raw_cells(row, content_col);
cells.truncate(drawn);
(!cells.is_empty()).then_some(cells)
}
fn raw_cell_at(cells: &[Range<usize>], col: usize) -> usize {
cells
.iter()
.take_while(|c| c.start <= col)
.count()
.saturating_sub(1)
}
pub fn drawn_cell_at(
row: &str,
content_col: usize,
drawn: usize,
col: usize,
) -> Option<(usize, Range<usize>)> {
let mut cells = raw_cells(row, content_col);
let dropped = cells.len() > drawn;
cells.truncate(drawn);
let last = cells.last()?;
if col > last.end {
if dropped {
return None;
}
if cells.len() < drawn && row.chars().nth(last.end) == Some('|') {
return Some((cells.len(), col..col));
}
}
let i = raw_cell_at(&cells, col);
Some((i, cells[i].clone()))
}
pub fn rendered_pipe_positions(line: &Line<'_>) -> Vec<usize> {
let mut positions = Vec::new();
let mut col = 0usize;
for span in &line.spans {
for ch in span.content.chars() {
if ch == '│' {
positions.push(col);
}
col += 1;
}
}
positions
}
pub fn rendered_pipe_cells(line: &Line<'_>) -> Vec<usize> {
let mut positions = Vec::new();
let mut col = 0usize;
for span in &line.spans {
for ch in span.content.chars() {
if ch == '│' {
positions.push(col);
}
col += char_cells(ch);
}
}
positions
}
pub fn table_raw_col_to_rendered(
raw_row: &str,
content_col: usize,
row_lines: &[Line<'_>],
raw_col: usize,
labels: &RefLabels,
) -> Option<(usize, usize)> {
let first = row_lines.first()?;
let pipe_cells = rendered_pipe_cells(first);
let drawn = pipe_cells.len().saturating_sub(1);
let raw_chars: Vec<char> = raw_row.chars().collect();
let (cell_idx, raw_cell) =
drawn_cell_at(raw_row, content_col, drawn, raw_col.min(raw_chars.len()))?;
let raw_cell_start = raw_cell.start;
let width = pipe_cells[cell_idx + 1].saturating_sub(pipe_cells[cell_idx] + 3);
let cell = CellContent::new(&raw_chars[raw_cell], width, labels);
let offset = raw_col.saturating_sub(raw_cell_start);
let (sub, in_chunk) = match offset.checked_sub(cell.leading) {
None => (0, None),
Some(k) => {
let rendered = match k.checked_sub(cell.raw_len) {
None => cell.map.raw_to_rendered(k),
Some(past) => cell.map.rendered_len() + past,
};
let sub = cell
.chunks
.iter()
.rposition(|(start, _)| *start <= rendered)
.unwrap_or(0);
let (start, text) = &cell.chunks[sub];
let pos = rendered - start;
let pos = if sub + 1 < cell.chunks.len() {
pos.min(text.chars().count())
} else {
pos
};
(sub.min(row_lines.len() - 1), Some(pos))
}
};
let pipes = rendered_pipe_positions(&row_lines[sub]);
let (open, close) = (*pipes.get(cell_idx)?, *pipes.get(cell_idx + 1)?);
let col = in_chunk.map_or(open + 1, |pos| open + 2 + pos);
Some((sub, col.min(close.saturating_sub(1))))
}
pub fn unpadded_cell_shift(raw_cell: &str) -> usize {
usize::from(!raw_cell.starts_with(char::is_whitespace))
}
pub struct CellContent {
pub leading: usize,
pub raw_len: usize,
pub map: InlineColMap,
pub chunks: Vec<(usize, String)>,
}
impl CellContent {
pub fn new(raw_cell: &[char], width: usize, labels: &RefLabels) -> Self {
let leading = raw_cell.iter().take_while(|c| c.is_whitespace()).count();
let trailing = raw_cell[leading..]
.iter()
.rev()
.take_while(|c| c.is_whitespace())
.count();
let content = &raw_cell[leading..raw_cell.len() - trailing];
let map = InlineColMap::build_inline(&content.iter().collect::<String>(), labels);
let rendered: String = map.rendered_to_raw_vec()[..map.rendered_len()]
.iter()
.map(|&raw| content.get(raw).copied().unwrap_or(' '))
.collect();
Self {
leading,
raw_len: content.len(),
map,
chunks: wrap_cell_with_indices(&rendered, width.max(1)),
}
}
}
pub fn table_raw_col_range_to_rendered_segments(
raw_row: &str,
content_col: usize,
rendered_line: &Line<'_>,
raw_start: usize,
raw_end: usize,
sub: usize,
labels: &RefLabels,
) -> Vec<(usize, usize)> {
let rendered_pipes = rendered_pipe_positions(rendered_line);
let rendered_cells = rendered_pipe_cells(rendered_line);
let Some(cells) =
paired_raw_cells(raw_row, content_col, rendered_pipes.len().saturating_sub(1))
else {
return Vec::new();
};
let raw_chars: Vec<char> = raw_row.chars().collect();
let mut out = Vec::new();
for (i, raw_cell) in cells.into_iter().enumerate() {
let raw_cell_start = raw_cell.start;
let width = rendered_cells[i + 1].saturating_sub(rendered_cells[i] + 3);
let cell = CellContent::new(&raw_chars[raw_cell], width, labels);
let Some((chunk_start, chunk_text)) = cell.chunks.get(sub) else {
continue;
};
let content_start = raw_cell_start + cell.leading;
let to_rendered = |raw: usize| {
cell.map
.raw_to_rendered(raw.saturating_sub(content_start).min(cell.raw_len))
};
let s = to_rendered(raw_start).max(*chunk_start);
let e = to_rendered(raw_end).min(chunk_start + chunk_text.chars().count());
if s >= e {
continue;
}
let rend_chunk_start = rendered_pipes[i] + 2;
out.push((
rend_chunk_start + (s - chunk_start),
rend_chunk_start + (e - chunk_start),
));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn maxes_eq_mins(cells: Vec<Vec<usize>>) -> (Vec<Vec<usize>>, Vec<Vec<usize>>) {
let mins = cells.clone();
(cells, mins)
}
#[test]
fn compute_widths_uses_natural_when_room_available() {
let (maxes, mins) = maxes_eq_mins(vec![vec![2, 4, 3], vec![3, 5, 2]]);
let widths = compute_widths(&maxes, &mins, 3, 80, None);
assert_eq!(widths, vec![3, 5, 3]); }
#[test]
fn compute_widths_returns_mins_when_max_exceeds_budget_and_no_slack() {
let (maxes, mins) = maxes_eq_mins(vec![vec![10, 10, 10]]);
let widths = compute_widths(&maxes, &mins, 3, 20, None);
assert_eq!(widths, vec![10, 10, 10]);
assert!(widths.iter().all(|w| *w >= MIN_COL_WIDTH));
}
#[test]
fn compute_widths_distributes_slack_proportionally_to_max_minus_min() {
let maxes = vec![vec![20, 5]];
let mins = vec![vec![4, 5]];
let widths = compute_widths(&maxes, &mins, 2, 22, None);
assert_eq!(widths, vec![10, 5]);
}
#[test]
fn compute_widths_caps_each_column_at_max_during_distribution() {
let maxes = vec![vec![8, 8]];
let mins = vec![vec![3, 3]];
let widths = compute_widths(&maxes, &mins, 2, 23, None);
assert_eq!(widths, vec![8, 8]);
}
#[test]
fn compute_widths_respects_user_override() {
let (maxes, mins) = maxes_eq_mins(vec![vec![1, 1, 1]]);
let widths = compute_widths(&maxes, &mins, 3, 80, Some(&[Some(15), Some(7), Some(20)]));
assert_eq!(widths, vec![15, 7, 20]);
}
#[test]
fn compute_widths_clamps_user_override_to_min() {
let (maxes, mins) = maxes_eq_mins(vec![vec![1, 1]]);
let widths = compute_widths(&maxes, &mins, 2, 80, Some(&[Some(1), Some(0)])); assert_eq!(widths, vec![MIN_COL_WIDTH, MIN_COL_WIDTH]);
}
#[test]
fn compute_widths_mixes_pinned_and_auto_columns() {
let (maxes, mins) = maxes_eq_mins(vec![vec![3, 6]]);
let widths = compute_widths(&maxes, &mins, 2, 80, Some(&[Some(5), None]));
assert_eq!(widths, vec![5, 6]);
}
#[test]
fn compute_widths_shrink_leaves_pinned_columns_alone() {
let maxes = vec![vec![10, 10]];
let mins = vec![vec![10, 4]];
let widths = compute_widths(&maxes, &mins, 2, 17, Some(&[Some(8), None]));
assert_eq!(widths[0], 8); assert!(widths[1] >= MIN_COL_WIDTH);
assert!(widths[1] <= 10);
}
#[test]
fn compute_widths_narrow_prose_column_stays_at_min_with_no_slack() {
let maxes = vec![vec![12]];
let mins = vec![vec![3]];
let widths = compute_widths(&maxes, &mins, 1, 7, None);
assert_eq!(widths, vec![3]);
}
#[test]
fn wrap_cell_breaks_on_spaces() {
let rows = wrap_cell("hello world foo bar", 11);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].trim(), "hello world");
assert_eq!(rows[1].trim(), "foo bar");
}
#[test]
fn wrap_cell_empty_returns_one_empty_row() {
let rows = wrap_cell("", 10);
assert_eq!(rows, vec!["".to_owned()]);
}
#[test]
fn wrap_cell_hard_splits_long_word() {
let rows = wrap_cell("supercalifragilistic", 6);
assert!(rows.len() >= 2);
for r in &rows {
assert!(str_cells(r) <= 6);
}
let joined: String = rows.join("");
assert_eq!(joined, "supercalifragilistic");
}
#[test]
fn wrap_cell_hard_split_prefers_punctuation_break() {
let rows = wrap_cell("really_long_name", 10);
assert_eq!(rows, vec!["really_", "long_name"]);
}
#[test]
fn wrap_cell_hard_split_falls_back_to_full_width_without_punctuation() {
let rows = wrap_cell("abcdefghijklmnop", 10);
assert_eq!(rows, vec!["abcdefghij", "klmnop"]);
}
const FAMILY: &str = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}";
fn chars(s: &str) -> Vec<char> {
s.chars().collect()
}
fn words(s: &str) -> Vec<String> {
let cs = chars(s);
word_ranges(&cs)
.into_iter()
.map(|r| cs[r].iter().collect())
.collect()
}
#[test]
fn str_cells_is_the_per_char_sum_the_painter_draws() {
assert_eq!(str_cells("日本"), 4);
assert_eq!(str_cells("\u{2764}\u{FE0F}"), 1);
assert_eq!(str_cells(FAMILY), 6);
assert_eq!(str_cells("e\u{301}"), 1);
assert_eq!(str_cells("ab\u{7}"), 2, "control chars take no cell");
}
#[test]
fn joints_on_ascii_are_all_cluster_boundaries() {
assert!(joints(&chars("ab cd")).iter().all(|j| *j == Joint::Cluster));
}
#[test]
fn joints_break_on_both_sides_of_a_wide_glyph() {
use Joint::*;
assert_eq!(
joints(&chars("a日本b")),
vec![Cluster, Glyph, Glyph, Glyph, Cluster]
);
}
#[test]
fn joints_never_break_inside_a_cluster() {
use Joint::*;
let js = joints(&chars(FAMILY));
assert_eq!(js, vec![Cluster, Inside, Inside, Inside, Inside, Cluster]);
assert_eq!(
joints(&chars("e\u{301}x")),
vec![Cluster, Inside, Cluster, Cluster]
);
}
#[test]
fn joints_do_not_break_where_punctuation_binds() {
use Joint::*;
assert_eq!(
joints(&chars("日。本")),
vec![Cluster, Cluster, Glyph, Cluster]
);
assert_eq!(
joints(&chars("「日」x")),
vec![Cluster, Cluster, Cluster, Glyph, Cluster]
);
}
#[test]
fn joints_do_not_break_beside_a_word_joiner_or_a_quote() {
use Joint::*;
assert_eq!(
joints(&chars("日\u{2060}本")),
vec![Cluster, Cluster, Cluster, Cluster]
);
assert_eq!(
joints(&chars("'日本'")),
vec![Cluster, Cluster, Glyph, Cluster, Cluster]
);
assert_eq!(
joints(&chars("日’本")),
vec![Cluster, Cluster, Cluster, Cluster]
);
}
#[test]
fn joints_in_str_matches_joints() {
for text in ["日本 語", "「日」x", "e\u{301}日👨\u{200d}👩", "a日\u{a0}b"] {
assert_eq!(
joints_in_str(text, text.chars().count()),
joints(&chars(text)),
"{text:?}"
);
}
}
#[test]
fn joints_do_not_break_beside_whitespace() {
let js = joints(&chars("\u{A0}日 本"));
assert_eq!(js[1], Joint::Cluster);
assert_eq!(js[3], Joint::Cluster);
}
#[test]
fn word_ranges_make_each_wide_glyph_a_word() {
assert_eq!(words("ab 日本語c"), vec!["ab", "日", "本", "語", "c"]);
assert_eq!(words(&format!("x{FAMILY}y")), vec!["x", FAMILY, "y"]);
assert_eq!(words(" "), Vec::<String>::new());
}
#[test]
fn wrap_cell_fills_a_row_with_cjk_glyphs() {
assert_eq!(wrap_cell("abc 日本語日本", 10), vec!["abc 日本語", "日本"]);
}
#[test]
fn wrap_cell_breaks_cjk_between_glyphs_at_the_floor() {
assert_eq!(wrap_cell("日本語", 3), vec!["日", "本", "語"]);
}
#[test]
fn wrap_cell_keeps_a_cluster_whole_even_when_it_overflows() {
let rows = wrap_cell(&format!("a{FAMILY}b"), 3);
assert_eq!(
rows,
vec!["a".to_owned(), FAMILY.to_owned(), "b".to_owned()]
);
}
#[test]
fn wrap_cell_hard_split_cuts_between_clusters() {
let e = "e\u{301}";
let rows = wrap_cell(&e.repeat(3), 2);
assert_eq!(rows, vec![e.repeat(2), e.to_owned()]);
}
#[test]
fn wrap_cell_drops_the_leading_space_after_a_hard_split() {
assert_eq!(wrap_cell("aaaaaaaa bb", 5), vec!["aaaaa", "aaa", "bb"]);
let rows = wrap_cell_with_indices("aaaaaaaa bb", 5);
assert_eq!(rows[2], (9, "bb".to_owned()));
}
#[test]
fn wrap_cell_with_indices_reports_char_starts_for_wide_text() {
assert_eq!(
wrap_cell_with_indices("日本語 x", 4),
vec![(0, "日本".to_owned()), (2, "語 x".to_owned())]
);
}
#[test]
fn wrap_cell_drops_a_punctuation_cut_that_would_overflow() {
let text = format!("abc.\u{A0}{FAMILY}");
assert_eq!(
wrap_cell(&text, 6),
vec!["abc.\u{A0}".to_owned(), FAMILY.to_owned()]
);
}
#[test]
fn hard_wrap_ranges_cuts_by_cells_keeping_spaces_and_clusters() {
let text = chars(&format!("a b{FAMILY}日c"));
let rows: Vec<String> = hard_wrap_ranges(&text, 4)
.into_iter()
.map(|r| text[r].iter().collect())
.collect();
assert_eq!(
rows,
vec!["a b".to_owned(), FAMILY.to_owned(), "日c".to_owned()]
);
}
#[test]
fn wrap_ranges_rows_never_exceed_the_width() {
let text = chars("表格 wide 日本語テキスト and `code_span_here` 한국어 x.y\u{A0}日");
for width in 2..20 {
for r in wrap_ranges(&text, width) {
assert!(cells_of(&text[r.clone()]) <= width, "{r:?} at {width}");
}
for r in hard_wrap_ranges(&text, width) {
assert!(cells_of(&text[r.clone()]) <= width, "{r:?} at {width}");
}
}
}
#[test]
fn preferred_cut_ignores_punctuation_in_leading_half() {
let chunk: Vec<char> = "a_bcdefghi".chars().collect();
assert_eq!(preferred_cut(chunk.len(), |i| chunk[i]), chunk.len());
}
#[test]
fn parse_column_widths_comment_roundtrip() {
let widths = vec![Some(20), Some(15), Some(30)];
let s = format_column_widths_comment(&widths);
assert_eq!(s, "<!-- tui-columns: [20, 15, 30] -->");
let parsed = parse_column_widths_comment(&s).unwrap();
assert_eq!(parsed, widths);
}
#[test]
fn parse_column_widths_comment_handles_embedded_text() {
let src = "some doc text\n<!-- tui-columns: [5, 7] -->\nrest of doc";
let parsed = parse_column_widths_comment(src).unwrap();
assert_eq!(parsed, vec![Some(5), Some(7)]);
}
#[test]
fn parse_column_widths_comment_returns_none_when_absent() {
assert!(parse_column_widths_comment("no widths here").is_none());
assert!(parse_column_widths_comment("<!-- tui-columns: [a, b] -->").is_none());
}
#[test]
fn parse_column_widths_comment_supports_auto_placeholders() {
let parsed = parse_column_widths_comment("<!-- tui-columns: [10, _, 30] -->").unwrap();
assert_eq!(parsed, vec![Some(10), None, Some(30)]);
}
#[test]
fn parse_column_widths_comment_all_auto_returns_none() {
assert!(parse_column_widths_comment("<!-- tui-columns: [_, _] -->").is_none());
}
#[test]
fn format_column_widths_comment_emits_underscore_for_auto() {
let widths = vec![Some(5), None, Some(12)];
assert_eq!(
format_column_widths_comment(&widths),
"<!-- tui-columns: [5, _, 12] -->"
);
}
use ratatui::text::{Line, Span};
fn line_with(s: &str) -> Line<'static> {
Line::from(vec![Span::raw(s.to_owned())])
}
#[test]
fn raw_pipe_positions_basic() {
assert_eq!(raw_pipe_positions("| a | b |"), vec![0, 4, 8]);
}
#[test]
fn raw_pipe_positions_skips_escaped_pipes() {
let row = r"| a \| x | b |";
let pipes = raw_pipe_positions(row);
assert_eq!(pipes, vec![0, 9, 13]);
}
#[test]
fn raw_cells_split_as_gfm_does() {
let text = |row: &str, col: usize| -> Vec<String> {
let chars: Vec<char> = row.chars().collect();
raw_cells(row, col)
.into_iter()
.map(|r| chars[r].iter().collect())
.collect()
};
assert_eq!(text("| a | b |", 0), [" a ", " b "]);
assert_eq!(text("a | b", 0), ["a ", " b"]);
assert_eq!(text("| a | b", 0), [" a ", " b"]);
assert_eq!(text("a | b | \n", 0), ["a ", " b "]);
assert_eq!(text("> | a | b |", 2), [" a ", " b "]);
assert_eq!(text("[^a|b]: | x | y |", 8), [" x ", " y "]);
assert_eq!(text(r"| a \| x | b |", 0), [r" a \| x ", " b "]);
assert_eq!(text("| |", 0), [" "]);
assert!(raw_cells(" ", 0).is_empty());
assert!(raw_cells("> ", 2).is_empty());
}
#[test]
fn paired_raw_cells_drop_the_excess_and_find_a_columns_cell() {
let row = "| 1 | 2 | 3 | 4 |";
let cells = paired_raw_cells(row, 0, 2).unwrap();
assert_eq!(cells, vec![1..4, 5..8]);
assert_eq!(raw_cell_at(&cells, 0), 0, "the leading pipe");
assert_eq!(
raw_cell_at(&cells, 4),
0,
"a pipe belongs to the cell before it"
);
assert_eq!(raw_cell_at(&cells, 5), 1);
assert_eq!(raw_cell_at(&cells, 16), 1, "past the drawn cells");
assert!(
paired_raw_cells(row, 0, 0).is_none(),
"a border draws no cell"
);
}
#[test]
fn drawn_cell_at_pairs_short_and_long_rows() {
let short = "| 1 |";
assert_eq!(drawn_cell_at(short, 0, 3, 2), Some((0, 1..4)));
assert_eq!(
drawn_cell_at(short, 0, 3, 4),
Some((0, 1..4)),
"the closing pipe belongs to the cell before it"
);
assert_eq!(
drawn_cell_at(short, 0, 3, 5),
Some((1, 5..5)),
"the row's end, past the closing pipe, is the first missing cell"
);
assert_eq!(drawn_cell_at("| 1 | ", 0, 3, 5), Some((1, 5..5)));
assert_eq!(
drawn_cell_at("| 1", 0, 3, 3),
Some((0, 1..3)),
"without a closing pipe the row's end stays in the last cell"
);
assert_eq!(drawn_cell_at("| 1 ", 0, 3, 5), Some((0, 1..3)));
assert_eq!(
drawn_cell_at("> | 1 |", 2, 3, 7),
Some((1, 7..7)),
"behind a prefix"
);
let long = "| 1 | 2 | 3 | 4 |";
assert_eq!(drawn_cell_at(long, 0, 2, 6), Some((1, 5..8)));
assert_eq!(
drawn_cell_at(long, 0, 2, 8),
Some((1, 5..8)),
"the pipe closing the last drawn cell"
);
assert_eq!(drawn_cell_at(long, 0, 2, 10), None, "in a dropped cell");
assert_eq!(drawn_cell_at(long, 0, 2, 17), None, "the row's end");
assert_eq!(drawn_cell_at(long, 0, 0, 2), None, "a border draws no cell");
assert_eq!(drawn_cell_at(" ", 0, 2, 0), None, "a blank row");
}
#[test]
fn unpadded_cell_shift_skips_the_pad_only_without_leading_whitespace() {
assert_eq!(unpadded_cell_shift(" a "), 0);
assert_eq!(unpadded_cell_shift("\ta"), 0);
assert_eq!(unpadded_cell_shift("a "), 1);
assert_eq!(unpadded_cell_shift(""), 1, "a short row's missing cell");
}
#[test]
fn rendered_pipe_cells_count_wide_glyphs_as_two() {
let line = line_with("│ 日 │ b │");
assert_eq!(rendered_pipe_positions(&line), vec![0, 4, 8]);
assert_eq!(rendered_pipe_cells(&line), vec![0, 5, 9]);
}
#[test]
fn last_cluster_start_skips_back_over_zero_width_marks() {
assert_eq!(last_cluster_start(&chars("abc")), 2);
assert_eq!(last_cluster_start(&chars("a\u{2764}\u{FE0F}")), 1);
assert_eq!(last_cluster_start(&chars(&format!("x{FAMILY}"))), 1);
assert_eq!(last_cluster_start(&[]), 0);
}
#[test]
fn rendered_pipe_positions_counts_box_drawing_pipes() {
let line = line_with("│ a │ bb │");
let pipes = rendered_pipe_positions(&line);
assert_eq!(pipes, vec![0, 4, 9]);
}
#[test]
fn table_raw_col_to_rendered_maps_plain_cells() {
let raw = "| a | b |";
let rendered = [line_with("│ a │ b │")];
let labels = RefLabels::default();
let at = |col| table_raw_col_to_rendered(raw, 0, &rendered, col, &labels);
assert_eq!(at(2), Some((0, 2)));
assert_eq!(at(1), Some((0, 1)));
assert_eq!(at(6), Some((0, 6)));
assert_eq!(at(3), Some((0, 3)));
assert_eq!(at(4), Some((0, 3)));
}
#[test]
fn table_raw_col_to_rendered_lands_on_the_glyph_past_hidden_markers() {
let raw = "| a | **b** x |";
let rendered = [line_with("│ a │ b x │")];
let labels = RefLabels::default();
let at =
|c: &str| table_raw_col_to_rendered(raw, 0, &rendered, raw.find(c).unwrap(), &labels);
assert_eq!(at("x"), Some((0, 8)));
assert_eq!(at("b*"), Some((0, 6)));
assert_eq!(at("**b"), Some((0, 6)));
}
#[test]
fn table_raw_col_to_rendered_finds_the_wrap_sub_line() {
let raw = "| x | **alpha** bravo |";
let rendered = [line_with("│ x │ alpha │"), line_with("│ │ bravo │")];
let labels = RefLabels::default();
let at =
|c: &str| table_raw_col_to_rendered(raw, 0, &rendered, raw.find(c).unwrap(), &labels);
assert_eq!(at("bravo"), Some((1, 6)));
assert_eq!(at("avo"), Some((1, 8)));
assert_eq!(at("alpha"), Some((0, 6)));
}
#[test]
fn table_raw_col_to_rendered_counts_chars_past_wide_glyphs() {
let raw = "| 日本 | ab |";
let rendered = [line_with("│ 日本 │ ab │")];
let col = raw.find('b').map(|b| raw[..b].chars().count()).unwrap();
let (sub, at) =
table_raw_col_to_rendered(raw, 0, &rendered, col, &RefLabels::default()).unwrap();
assert_eq!(sub, 0);
assert_eq!(rendered[0].spans[0].content.chars().nth(at), Some('b'));
}
#[test]
fn rendered_segments_map_continuation_chunk() {
let raw = "| x | alpha bravo |";
let rendered = line_with("│ │ bravo │");
let labels = RefLabels::default();
let segs = table_raw_col_range_to_rendered_segments(raw, 0, &rendered, 13, 16, 1, &labels);
assert_eq!(segs, vec![(7, 10)]);
assert!(
table_raw_col_range_to_rendered_segments(raw, 0, &rendered, 2, 3, 1, &labels)
.is_empty()
);
assert!(
table_raw_col_range_to_rendered_segments(raw, 0, &rendered, 13, 16, 2, &labels)
.is_empty()
);
}
#[test]
fn rendered_segments_skip_hidden_markers() {
let raw = "| a | *b* x |";
let rendered = line_with("│ a │ b x │");
let labels = RefLabels::default();
let x = raw.find('x').unwrap();
assert_eq!(
table_raw_col_range_to_rendered_segments(raw, 0, &rendered, x, x + 1, 0, &labels),
vec![(8, 9)]
);
let b = raw.find('*').unwrap();
assert_eq!(
table_raw_col_range_to_rendered_segments(raw, 0, &rendered, b, b + 3, 0, &labels),
vec![(6, 7)]
);
let raw = "| 2. a |";
let rendered = line_with("│ 2. a │");
let a = raw.find('a').unwrap();
assert_eq!(
table_raw_col_range_to_rendered_segments(raw, 0, &rendered, a, a + 1, 0, &labels),
vec![(5, 6)]
);
}
#[test]
fn rendered_segments_empty_on_pipe_mismatch() {
let raw = "| a | b |";
let rendered = line_with("├───┼───┤");
assert!(table_raw_col_range_to_rendered_segments(
raw,
0,
&rendered,
2,
3,
0,
&RefLabels::default()
)
.is_empty());
}
#[test]
fn table_raw_col_to_rendered_returns_none_on_pipe_mismatch() {
let raw = "| a | b |";
let rendered = [line_with("├───┼───┤")];
assert!(table_raw_col_to_rendered(raw, 0, &rendered, 2, &RefLabels::default()).is_none());
}
}