use ratatui::{buffer::Buffer as TuiBuf, layout::Rect, style::Style, text::Line};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthChar;
pub fn char_cells(ch: char) -> usize {
UnicodeWidthChar::width(ch).unwrap_or(0)
}
pub fn char_idx_at_cell_col<I>(iter: I, target_cell: usize, indent: usize) -> usize
where
I: IntoIterator<Item = char>,
{
if target_cell <= indent {
return 0;
}
let mut acc = indent;
let mut count = 0;
for ch in iter {
let w = char_cells(ch);
if acc + w > target_cell {
return if acc == target_cell { count } else { count + 1 };
}
acc += w;
count += 1;
}
count
}
pub fn cell_col_at_char_idx<I>(iter: I, char_idx: usize, indent: usize) -> usize
where
I: IntoIterator<Item = char>,
{
let mut acc = indent;
for (i, ch) in iter.into_iter().enumerate() {
if i >= char_idx {
break;
}
acc += char_cells(ch);
}
acc
}
fn chars_within_cell_budget(chars: &[(char, Style)], start: usize, cell_budget: usize) -> usize {
let mut total = 0usize;
let mut count = 0usize;
for (ch, _) in &chars[start..] {
let w = char_cells(*ch);
if count > 0 && total + w > cell_budget {
break;
}
total += w;
count += 1;
if total >= cell_budget {
break;
}
}
count
}
#[allow(dead_code)]
pub fn render_line(
line: &Line<'static>,
area: Rect,
buf: &mut TuiBuf,
visual_y: u16,
wrap: bool,
) -> u16 {
render_line_with_cursor_from_visual(line, area, buf, visual_y, wrap, None, 0)
}
pub fn render_line_from_visual(
line: &Line<'static>,
area: Rect,
buf: &mut TuiBuf,
visual_y: u16,
wrap: bool,
skip_rows: usize,
) -> u16 {
render_line_with_cursor_from_visual(line, area, buf, visual_y, wrap, None, skip_rows)
}
#[allow(dead_code)]
pub fn render_line_with_cursor(
line: &Line<'static>,
area: Rect,
buf: &mut TuiBuf,
visual_y: u16,
wrap: bool,
cursor_col_override: Option<(usize, Style)>,
) -> u16 {
render_line_with_cursor_from_visual(line, area, buf, visual_y, wrap, cursor_col_override, 0)
}
pub fn render_line_with_cursor_from_visual(
line: &Line<'static>,
area: Rect,
buf: &mut TuiBuf,
visual_y: u16,
wrap: bool,
cursor_col_override: Option<(usize, Style)>,
skip_rows: usize,
) -> u16 {
render_line_reporting_cursor(
line,
area,
buf,
visual_y,
wrap,
cursor_col_override,
skip_rows,
)
.0
}
pub fn render_line_reporting_cursor(
line: &Line<'static>,
area: Rect,
buf: &mut TuiBuf,
visual_y: u16,
wrap: bool,
cursor_col_override: Option<(usize, Style)>,
skip_rows: usize,
) -> (u16, Option<(u16, u16)>) {
render_line_core(
line,
area,
buf,
visual_y,
wrap,
cursor_col_override,
skip_rows,
None,
)
}
pub fn render_raw_line_with_cursor(
line: &Line<'static>,
area: Rect,
buf: &mut TuiBuf,
visual_y: u16,
cursor_col_override: Option<(usize, Style)>,
skip_rows: usize,
) -> u16 {
render_line_core(
line,
area,
buf,
visual_y,
true,
cursor_col_override,
skip_rows,
Some(0),
)
.0
}
#[allow(clippy::too_many_arguments)]
fn render_line_core(
line: &Line<'static>,
area: Rect,
buf: &mut TuiBuf,
visual_y: u16,
wrap: bool,
cursor_col_override: Option<(usize, Style)>,
skip_rows: usize,
hanging_indent: Option<usize>,
) -> (u16, Option<(u16, u16)>) {
if visual_y >= area.height {
return (0, None);
}
let width = area.width as usize;
if width == 0 {
return (1, None);
}
let abs_y = area.y + visual_y;
let line_style = line.style;
let mut chars: Vec<(char, Style)> = Vec::new();
for span in &line.spans {
let style = line_style.patch(span.style);
for ch in span.content.chars() {
chars.push((ch, style));
}
}
if !wrap {
if skip_rows > 0 {
return (0, None);
}
let cursor_cell = paint_row(
&chars,
0,
chars.len(),
0,
0,
&[],
area,
buf,
abs_y,
line_style,
cursor_col_override,
);
return (1, cursor_cell);
}
let indent = hanging_indent.unwrap_or_else(|| compute_hanging_indent(line));
let rows = visual_rows_of_chars(&chars, width, indent);
let effective_indent = if indent + 1 >= width { 0 } else { indent };
let cont_prefix = leading_bar_prefix(&chars);
let mut cursor_cell = None;
let mut cur_visual = visual_y;
for (row_idx, &(start, row_end, _next_start)) in rows.iter().enumerate().skip(skip_rows) {
if cur_visual >= area.height {
break;
}
let cur_abs_y = area.y + cur_visual;
let row_indent = if row_idx == 0 { 0 } else { effective_indent };
let row_prefix: &[(char, Style)] = if row_idx == 0 { &[] } else { &cont_prefix };
let row_override = match (
row_idx.checked_sub(1).and_then(|p| rows.get(p)),
cursor_col_override,
) {
(Some(&(_, prev_end, prev_next)), Some((col, style)))
if col >= prev_end && col < prev_next =>
{
Some((start, style))
}
_ => cursor_col_override,
};
if let Some(cell) = paint_row(
&chars,
start,
row_end,
start,
row_indent,
row_prefix,
area,
buf,
cur_abs_y,
line_style,
row_override,
) {
cursor_cell = Some(cell);
}
cur_visual += 1;
}
(cur_visual - visual_y, cursor_cell)
}
#[allow(clippy::too_many_arguments)]
fn paint_row(
chars: &[(char, Style)],
start: usize,
end: usize,
abs_col_base: usize,
row_indent: usize,
cont_prefix: &[(char, Style)],
area: Rect,
buf: &mut TuiBuf,
abs_y: u16,
line_style: Style,
cursor_col_override: Option<(usize, Style)>,
) -> Option<(u16, u16)> {
let mut cursor_cell = None;
let mut x = area.x;
let area_end = area.x + area.width;
let mut prefix_iter = cont_prefix.iter();
for _ in 0..row_indent {
if x >= area_end {
break;
}
if let Some(cell) = buf.cell_mut((x, abs_y)) {
match prefix_iter.next() {
Some((ch, style)) => {
cell.set_char(*ch);
cell.set_style(*style);
}
None => {
cell.set_char(' ');
cell.set_style(line_style);
}
}
}
x += 1;
}
for (rel_idx, (ch, style)) in chars[start..end].iter().enumerate() {
let cells = char_cells(*ch) as u16;
if cells == 0 || x >= area_end {
if cells == 0 {
continue;
}
break;
}
let abs_col = abs_col_base + rel_idx;
let cursor_style = cursor_col_override
.filter(|(col, _)| *col == abs_col)
.map(|(_, s)| s);
if let Some(cell) = buf.cell_mut((x, abs_y)) {
cell.set_char(*ch);
cell.set_style(cursor_style.unwrap_or(*style));
}
if cursor_style.is_some() {
cursor_cell = Some((x, abs_y));
}
x += cells;
}
let eol_cursor = cursor_col_override.filter(|&(col, _)| col == chars.len());
let mut fill_col = abs_col_base + (end - start);
while x < area_end {
if let Some(cell) = buf.cell_mut((x, abs_y)) {
if let Some((_, s)) = eol_cursor.filter(|&(col, _)| col == fill_col) {
cell.set_char(' ');
cell.set_style(s);
cursor_cell = Some((x, abs_y));
} else {
cell.set_style(line_style);
}
}
x += 1;
fill_col += 1;
}
cursor_cell
}
fn absorbed_next_start(chars: &[(char, Style)], end: usize) -> usize {
let lone_space = chars.get(end).is_some_and(|(c, _)| *c == ' ')
&& end + 1 < chars.len()
&& chars[end + 1].0 != ' ';
if lone_space {
end + 1
} else {
end
}
}
fn cluster_starts(chars: &[(char, Style)]) -> Option<Vec<bool>> {
if chars.iter().all(|(ch, _)| ch.is_ascii()) {
return None;
}
let text: String = chars.iter().map(|(ch, _)| *ch).collect();
let mut starts = vec![false; chars.len() + 1];
let mut char_idx = 0usize;
for cluster in UnicodeSegmentation::graphemes(text.as_str(), true) {
starts[char_idx] = true;
char_idx += cluster.chars().count();
}
starts[chars.len()] = true;
Some(starts)
}
fn is_cluster_boundary(clusters: Option<&[bool]>, i: usize) -> bool {
clusters.is_none_or(|starts| starts.get(i).copied().unwrap_or(true))
}
fn snap_to_cluster_boundary(clusters: Option<&[bool]>, start: usize, end: usize) -> usize {
let mut snapped = end;
while snapped > start && !is_cluster_boundary(clusters, snapped) {
snapped -= 1;
}
if snapped == start {
end
} else {
snapped
}
}
fn is_no_break_char(ch: char) -> bool {
matches!(ch, '\u{a0}' | '\u{202f}' | '\u{2060}' | '\u{feff}')
}
fn is_opening_delimiter(ch: char) -> bool {
matches!(
ch,
'(' | '[' | '{' | '\u{201c}' | '\u{2018}' | '\u{ab}' | '\u{bf}' | '\u{a1}'
)
}
fn is_intra_word_punctuation(ch: char) -> bool {
matches!(ch, '.' | ',' | ':' | '\'' | '\u{2019}' | '_')
}
fn is_break_after(chars: &[(char, Style)], i: usize, clusters: Option<&[bool]>) -> bool {
if !is_cluster_boundary(clusters, i + 1) {
return false;
}
let ch = chars[i].0;
if ch.is_alphanumeric() || is_no_break_char(ch) {
return false;
}
let prev_alnum = i > 0 && chars[i - 1].0.is_alphanumeric();
let next_alnum = chars.get(i + 1).is_some_and(|(c, _)| c.is_alphanumeric());
if is_opening_delimiter(ch) && next_alnum {
return false;
}
if matches!(ch, '"' | '\'') && next_alnum && !prev_alnum {
return false;
}
if is_intra_word_punctuation(ch) && prev_alnum && next_alnum {
return false;
}
true
}
fn ends_with_lone_word(chars: &[(char, Style)], start: usize, break_at: usize) -> bool {
if !chars[break_at].0.is_whitespace() || break_at < start + 2 {
return false;
}
let word = break_at - 1;
chars[word].0.is_alphanumeric() && word > start && chars[word - 1].0.is_whitespace()
}
pub fn visual_rows_of_chars(
chars: &[(char, Style)],
width: usize,
indent: usize,
) -> Vec<(usize, usize, usize)> {
let mut rows = Vec::new();
if width == 0 {
rows.push((0, chars.len(), chars.len()));
return rows;
}
let indent = if indent + 1 >= width { 0 } else { indent };
let clusters = cluster_starts(chars);
let clusters = clusters.as_deref();
let mut start = 0;
let mut row_idx = 0usize;
loop {
if start >= chars.len() {
if rows.is_empty() {
rows.push((0, 0, 0));
}
break;
}
let row_width = if row_idx == 0 {
width
} else {
width.saturating_sub(indent).max(1)
};
let n_chars = chars_within_cell_budget(chars, start, row_width);
let remaining = chars.len() - start;
let (row_end, next_start) = if n_chars >= remaining {
(chars.len(), chars.len())
} else {
let window_end = snap_to_cluster_boundary(clusters, start, start + n_chars);
let break_at = (start..window_end)
.rev()
.find(|&i| is_break_after(chars, i, clusters));
let end = match break_at {
Some(bp) => {
let bp = if ends_with_lone_word(chars, start, bp) {
(start..bp - 1)
.rev()
.find(|&i| is_break_after(chars, i, clusters))
.unwrap_or(bp)
} else {
bp
};
bp + 1
}
None => window_end,
};
(end, absorbed_next_start(chars, end))
};
rows.push((start, row_end, next_start));
if next_start >= chars.len() || next_start == start {
break;
}
start = next_start;
row_idx += 1;
}
if rows.is_empty() {
rows.push((0, 0, 0));
}
rows
}
pub fn visual_rows_of_str(text: &str, width: usize) -> Vec<(usize, usize, usize)> {
let chars: Vec<(char, Style)> = text.chars().map(|c| (c, Style::default())).collect();
visual_rows_of_chars(&chars, width, 0)
}
pub fn visual_rows_for_line(line: &Line<'_>, width: usize) -> usize {
if width == 0 {
return 1;
}
let chars: Vec<(char, Style)> = line
.spans
.iter()
.flat_map(|span| span.content.chars().map(move |c| (c, span.style)))
.collect();
if chars.is_empty() {
return 1;
}
let indent = compute_hanging_indent(line);
visual_rows_of_chars(&chars, width, indent).len().max(1)
}
pub fn compute_hanging_indent(line: &Line<'_>) -> usize {
let chars: Vec<char> = line.spans.iter().flat_map(|s| s.content.chars()).collect();
compute_hanging_indent_chars(&chars)
}
pub fn compute_hanging_indent_str(text: &str) -> usize {
let chars: Vec<char> = text.chars().collect();
compute_hanging_indent_chars(&chars)
}
fn compute_hanging_indent_chars(chars: &[char]) -> usize {
let mut i = 0;
while i < chars.len() && chars[i] == ' ' {
i += 1;
}
let leading = i;
if blockquote_prefix_unit(&chars[i..]) {
let after = i + 2;
return 2 + compute_hanging_indent_chars(&chars[after..]);
}
if chars.get(i) == Some(&'•') && chars.get(i + 1) == Some(&' ') {
return text_start_after_optional_task_prefix(chars, i + 2);
}
if matches!(chars.get(i), Some('-') | Some('*') | Some('+')) && chars.get(i + 1) == Some(&' ') {
return text_start_after_optional_task_prefix(chars, i + 2);
}
let digit_count = chars[i..].iter().take_while(|c| c.is_ascii_digit()).count();
if digit_count > 0
&& matches!(chars.get(i + digit_count), Some('.') | Some(')'))
&& chars.get(i + digit_count + 1) == Some(&' ')
{
return text_start_after_optional_task_prefix(chars, i + digit_count + 2);
}
if leading > 0 {
return leading;
}
0
}
fn is_task_marker(chars: &[char], i: usize) -> bool {
chars.get(i) == Some(&'[')
&& matches!(chars.get(i + 1), Some(' ') | Some('x') | Some('X'))
&& chars.get(i + 2) == Some(&']')
&& chars.get(i + 3) == Some(&' ')
}
fn text_start_after_optional_task_prefix(chars: &[char], pos: usize) -> usize {
if is_task_marker(chars, pos) {
pos + 4
} else {
pos
}
}
fn blockquote_prefix_unit(chars: &[char]) -> bool {
matches!(chars.first(), Some('â–Ž') | Some('>')) && chars.get(1) == Some(&' ')
}
fn leading_bar_prefix(chars: &[(char, Style)]) -> Vec<(char, Style)> {
let mut out = Vec::new();
let mut i = 0;
while i + 1 < chars.len() && chars[i].0 == 'â–Ž' && chars[i + 1].0 == ' ' {
out.push(chars[i]);
out.push(chars[i + 1]);
i += 2;
}
out
}
pub fn last_col_in_row(row: (usize, usize, usize), is_last_row: bool) -> usize {
let (start, end, _) = row;
if is_last_row {
end
} else {
end.saturating_sub(1).max(start)
}
}
pub fn sub_line_of_col(rows: &[(usize, usize, usize)], raw_col: usize) -> (usize, usize) {
for (i, &(s, e, n)) in rows.iter().enumerate() {
if raw_col < n {
if raw_col >= e && i + 1 < rows.len() {
return (i + 1, 0);
}
let row_width = e - s;
let visual_col = raw_col.saturating_sub(s).min(row_width);
return (i, visual_col);
}
}
if let Some(&(s, e, _)) = rows.last() {
let last_idx = rows.len() - 1;
let row_width = e - s;
let visual_col = raw_col.saturating_sub(s).min(row_width);
return (last_idx, visual_col);
}
(0, 0)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::text::Span;
#[test]
fn visual_rows_short_line() {
let rows = visual_rows_of_str("hello", 10);
assert_eq!(rows, vec![(0, 5, 5)]);
}
#[test]
fn visual_rows_wraps_at_space() {
let rows = visual_rows_of_str("hello world foo", 10);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0], (0, 6, 6)); assert_eq!(rows[1], (6, 15, 15)); }
#[test]
fn visual_rows_force_break_on_long_word() {
let rows = visual_rows_of_str("abcdefghijklmnop", 8);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0], (0, 8, 8));
assert_eq!(rows[1], (8, 16, 16));
}
#[test]
fn visual_rows_empty_string() {
let rows = visual_rows_of_str("", 10);
assert_eq!(rows, vec![(0, 0, 0)]);
}
#[test]
fn hanging_indent_bullet() {
let line = Line::from(vec![Span::raw("• "), Span::raw("foo bar")]);
assert_eq!(compute_hanging_indent(&line), 2);
}
#[test]
fn hanging_indent_raw_bullet() {
let line = Line::from(vec![Span::raw("- foo bar")]);
assert_eq!(compute_hanging_indent(&line), 2);
}
#[test]
fn hanging_indent_ordered_single_digit() {
let line = Line::from(vec![Span::raw("1. "), Span::raw("foo")]);
assert_eq!(compute_hanging_indent(&line), 3);
}
#[test]
fn hanging_indent_ordered_padded() {
let line = Line::from(vec![Span::raw(" 1. "), Span::raw("foo")]);
assert_eq!(compute_hanging_indent(&line), 4);
}
#[test]
fn hanging_indent_ordered_double_digit() {
let line = Line::from(vec![Span::raw("10. "), Span::raw("foo")]);
assert_eq!(compute_hanging_indent(&line), 4);
}
#[test]
fn hanging_indent_rendered_task_includes_bullet_and_checkbox() {
let line = Line::from(vec![Span::raw("• [ ] "), Span::raw("foo")]);
assert_eq!(compute_hanging_indent(&line), 6);
}
#[test]
fn hanging_indent_task_raw_revealed() {
let line = Line::from(vec![Span::raw("- [ ] foo")]);
assert_eq!(compute_hanging_indent(&line), 6);
}
#[test]
fn hanging_indent_nested_bullet() {
let line = Line::from(vec![Span::raw(" • "), Span::raw("inner")]);
assert_eq!(compute_hanging_indent(&line), 4);
}
#[test]
fn hanging_indent_continuation_paragraph() {
let line = Line::from(vec![Span::raw(" "), Span::raw("more text")]);
assert_eq!(compute_hanging_indent(&line), 3);
}
#[test]
fn hanging_indent_plain_paragraph() {
let line = Line::from(vec![Span::raw("Hello world")]);
assert_eq!(compute_hanging_indent(&line), 0);
}
#[test]
fn hanging_indent_blockquote() {
let line = Line::from(vec![Span::raw("â–Ž "), Span::raw("quoted")]);
assert_eq!(compute_hanging_indent(&line), 2);
}
#[test]
fn hanging_indent_blockquote_raw_marker() {
assert_eq!(compute_hanging_indent_str("> quoted text"), 2);
}
#[test]
fn hanging_indent_nested_blockquote() {
let line = Line::from(vec![Span::raw("â–Ž â–Ž "), Span::raw("quoted")]);
assert_eq!(compute_hanging_indent(&line), 4);
}
#[test]
fn hanging_indent_list_inside_blockquote() {
let line = Line::from(vec![Span::raw("▎ • "), Span::raw("item")]);
assert_eq!(compute_hanging_indent(&line), 4);
}
#[test]
fn leading_bar_prefix_captures_rendered_bars_only() {
let rendered: Vec<(char, Style)> = "â–Ž â–Ž quoted"
.chars()
.map(|c| (c, Style::default()))
.collect();
assert_eq!(leading_bar_prefix(&rendered).len(), 4);
let raw: Vec<(char, Style)> = "> quoted".chars().map(|c| (c, Style::default())).collect();
assert!(leading_bar_prefix(&raw).is_empty());
}
#[test]
fn wrapped_blockquote_repaints_bar_on_continuation_rows() {
let area = Rect::new(0, 0, 10, 3);
let mut buf = TuiBuf::empty(area);
let line = Line::from(vec![Span::raw("â–Ž "), Span::raw("alpha beta gamma")]);
let rows = render_line(&line, area, &mut buf, 0, true);
assert!(rows >= 2, "expected the quote to wrap, got {rows} row(s)");
assert_eq!(
buf.cell((0, 0)).map(|c| c.symbol().to_string()),
Some("â–Ž".into())
);
assert_eq!(
buf.cell((0, 1)).map(|c| c.symbol().to_string()),
Some("â–Ž".into())
);
assert_eq!(
buf.cell((1, 1)).map(|c| c.symbol().to_string()),
Some(" ".into())
);
assert_ne!(
buf.cell((2, 1)).map(|c| c.symbol().to_string()),
Some(" ".into())
);
}
#[test]
fn visual_rows_with_indent_word_aligned() {
let chars: Vec<(char, Style)> = "• hello world foo"
.chars()
.map(|c| (c, Style::default()))
.collect();
let rows = visual_rows_of_chars(&chars, 10, 2);
assert_eq!(rows[0].0, 0);
assert!(rows.len() >= 2);
assert_eq!(rows.last().map(|r| r.1), Some(17));
}
#[test]
fn visual_rows_with_indent_zero_matches_flat() {
let s = "hello world foo";
let chars: Vec<(char, Style)> = s.chars().map(|c| (c, Style::default())).collect();
let with_indent = visual_rows_of_chars(&chars, 10, 0);
let flat = visual_rows_of_str(s, 10);
assert_eq!(with_indent, flat);
}
#[test]
fn visual_rows_for_line_counts_indent_extra_rows() {
let line = Line::from(vec![Span::raw("• "), Span::raw("hello world foo bar baz")]);
let with_marker = visual_rows_for_line(&line, 10);
let line_flat = Line::from(vec![Span::raw("hello world foo bar baz")]);
let flat = visual_rows_for_line(&line_flat, 10);
assert!(with_marker >= flat);
}
#[test]
fn visual_rows_preserves_interior_whitespace_across_wrap() {
let rows = visual_rows_of_str("a b", 5);
assert_eq!(rows[0], (0, 5, 5));
assert_eq!(rows[1], (5, 10, 10));
assert_eq!(rows[2], (10, 15, 15));
assert_eq!(rows[3], (15, 16, 16));
}
#[test]
fn contraction_apostrophe_is_not_a_break_point() {
let rows = visual_rows_of_str("when they're here", 12);
assert_eq!(rows[0], (0, 5, 5)); assert_eq!(rows[1], (5, 17, 17)); }
#[test]
fn smart_apostrophe_is_not_a_break_point() {
let rows = visual_rows_of_str("when they\u{2019}re here", 12);
assert_eq!(rows[0], (0, 5, 5));
assert_eq!(rows[1], (5, 17, 17));
}
#[test]
fn intra_word_punctuation_keeps_tokens_whole() {
for text in ["value 3.14159 x", "count 1,000,00 x", "meet 12:30:00 x"] {
let rows = visual_rows_of_str(text, 12);
assert_eq!(
rows[0].1,
text.find(' ').unwrap() + 1,
"{text} broke inside its token"
);
}
}
#[test]
fn url_still_breaks_after_the_scheme_slashes() {
let rows = visual_rows_of_str("see https://example.com/x", 20);
assert_eq!(rows[0], (0, 12, 12)); }
#[test]
fn a_url_path_breaks_at_a_slash_rather_than_mid_segment() {
let text = "at github.com/user/repo/blob/main/x";
let rows = visual_rows_of_str(text, 20);
let chars: Vec<char> = text.chars().collect();
for &(start, end, _) in &rows {
let row: String = chars[start..end].iter().collect();
assert!(
row.ends_with('/') || end == chars.len(),
"row {row:?} broke mid-segment instead of after a slash"
);
}
}
#[test]
fn no_break_after_an_opening_delimiter() {
let rows = visual_rows_of_str("a note (remark) here", 11);
assert_eq!(rows[0], (0, 7, 7)); }
#[test]
fn nbsp_is_never_a_break_point() {
let text = "aa\u{a0}bb cc";
let rows = visual_rows_of_str(text, 5);
assert_eq!(rows[0].1, 5);
}
#[test]
fn one_letter_word_is_carried_down_to_its_noun() {
let rows = visual_rows_of_str("tell them a story", 14);
assert_eq!(rows[0], (0, 10, 10)); assert_eq!(rows[1], (10, 17, 17)); }
#[test]
fn a_lone_word_starting_the_row_is_left_alone() {
let rows = visual_rows_of_str("a xyzzyplugh", 3);
assert_eq!(rows[0], (0, 2, 2)); }
#[test]
fn hard_break_absorbs_the_following_space() {
let rows = visual_rows_of_str("abcdefghij klm", 10);
assert_eq!(rows[0], (0, 10, 11));
assert_eq!(rows[1], (11, 14, 14));
}
#[test]
fn absorbed_space_maps_the_cursor_to_the_next_row_start() {
let rows = visual_rows_of_str("abcdefghij klm", 10);
assert_eq!(sub_line_of_col(&rows, 10), (1, 0));
assert_eq!(sub_line_of_col(&rows, 11), (1, 0));
}
#[test]
fn a_run_of_spaces_at_a_hard_break_is_preserved() {
let rows = visual_rows_of_str("abcdefghij klm", 10);
assert_eq!(rows[0], (0, 10, 10));
}
#[test]
fn a_trailing_space_at_a_hard_break_is_not_absorbed() {
let rows = visual_rows_of_str("abcdefghij ", 10);
assert_eq!(rows[0], (0, 10, 10));
assert_eq!(rows[1], (10, 11, 11));
}
#[test]
fn last_col_in_row_clamps_against_end_not_next_start() {
let rows = visual_rows_of_str("abcdefghij klm", 10);
assert_eq!(rows[0], (0, 10, 11));
assert_eq!(last_col_in_row(rows[0], false), 9);
assert_eq!(last_col_in_row(rows[1], true), 14);
assert_eq!(last_col_in_row((7, 8, 8), false), 7);
}
#[test]
fn cursor_on_an_absorbed_space_paints_on_the_next_row() {
let line = Line::from("abcdefghij klm");
let area = Rect::new(0, 0, 10, 3);
let mut buf = TuiBuf::empty(area);
let style = Style::default().fg(ratatui::style::Color::Red);
let (_, cursor) =
render_line_reporting_cursor(&line, area, &mut buf, 0, true, Some((10, style)), 0);
assert_eq!(cursor, Some((0, 1)));
}
#[test]
fn a_soft_break_on_punctuation_absorbs_the_space_after_it() {
let rows = visual_rows_of_str("abcde. fgh", 6);
assert_eq!(rows[0], (0, 6, 7));
assert_eq!(rows[1], (7, 10, 10));
}
#[test]
fn a_zwj_sequence_is_never_split_across_rows() {
let text = "a \u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466} family here";
let rows = visual_rows_of_str(text, 8);
let chars: Vec<char> = text.chars().collect();
for &(start, end, _) in &rows {
let row: String = chars[start..end].iter().collect();
assert!(
!row.starts_with('\u{200d}') && !row.ends_with('\u{200d}'),
"row {row:?} ends or starts inside the cluster"
);
}
}
#[test]
fn a_combining_mark_stays_with_its_base_char() {
let text = "cafe\u{301} au lait";
let rows = visual_rows_of_str(text, 5);
let chars: Vec<char> = text.chars().collect();
for &(_, end, _) in &rows {
assert_ne!(
chars.get(end),
Some(&'\u{301}'),
"row ended between the base char and its combining mark"
);
}
}
#[test]
fn a_regional_indicator_pair_stays_whole() {
let text = "go \u{1f1ef}\u{1f1f5} now";
let rows = visual_rows_of_str(text, 5);
let chars: Vec<char> = text.chars().collect();
for &(_, end, _) in &rows {
assert_ne!(
chars.get(end),
Some(&'\u{1f1f5}'),
"row ended between the two halves of the flag"
);
}
}
#[test]
fn a_cluster_wider_than_the_row_still_makes_progress() {
let text = "\u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}";
let rows = visual_rows_of_str(text, 3);
assert!(rows.len() > 1);
assert!(rows.iter().all(|&(s, e, _)| e > s));
}
#[test]
fn ascii_text_takes_the_no_segmentation_fast_path() {
let chars: Vec<(char, Style)> = "hello world foo bar"
.chars()
.map(|c| (c, Style::default()))
.collect();
assert!(cluster_starts(&chars).is_none());
}
#[test]
fn wrap_budget_is_cells_not_chars_for_wide_chars() {
let chars: Vec<(char, Style)> = "🥇🥇🥇".chars().map(|c| (c, Style::default())).collect();
let rows = visual_rows_of_chars(&chars, 4, 0);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0], (0, 2, 2));
assert_eq!(rows[1], (2, 3, 3));
}
#[test]
fn wrap_force_breaks_when_single_wide_char_exceeds_width() {
let chars: Vec<(char, Style)> = "🥇🥇".chars().map(|c| (c, Style::default())).collect();
let rows = visual_rows_of_chars(&chars, 1, 0);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0], (0, 1, 1));
assert_eq!(rows[1], (1, 2, 2));
}
#[test]
fn render_line_paints_wide_char_using_two_cells() {
let area = Rect::new(0, 0, 10, 1);
let mut buf = TuiBuf::empty(area);
let line = Line::from(vec![Span::raw("A🥇B")]);
render_line(&line, area, &mut buf, 0, false);
assert_eq!(
buf.cell((0, 0)).map(|c| c.symbol().to_string()),
Some("A".into())
);
assert_eq!(
buf.cell((1, 0)).map(|c| c.symbol().to_string()),
Some("🥇".into())
);
assert_eq!(
buf.cell((3, 0)).map(|c| c.symbol().to_string()),
Some("B".into())
);
}
#[test]
fn char_idx_at_cell_col_forbidden_indent_zone_snaps_to_row_start() {
let chars = ['x', 'y', 'z'];
for cell in 0..=2 {
assert_eq!(
char_idx_at_cell_col(chars.iter().copied(), cell, 2),
0,
"indent zone cell {cell} did not snap to row start",
);
}
assert_eq!(char_idx_at_cell_col(chars.iter().copied(), 3, 2), 1);
}
#[test]
fn char_idx_at_cell_col_snaps_past_wide_char() {
let chars = ['🥇', 'B'];
assert_eq!(char_idx_at_cell_col(chars.iter().copied(), 0, 0), 0);
assert_eq!(char_idx_at_cell_col(chars.iter().copied(), 1, 0), 1);
assert_eq!(char_idx_at_cell_col(chars.iter().copied(), 2, 0), 1);
}
#[test]
fn cell_col_at_char_idx_round_trips_with_wide_chars() {
let chars = ['A', '🥇', 'B'];
assert_eq!(cell_col_at_char_idx(chars.iter().copied(), 0, 0), 0);
assert_eq!(cell_col_at_char_idx(chars.iter().copied(), 1, 0), 1);
assert_eq!(cell_col_at_char_idx(chars.iter().copied(), 2, 0), 3);
assert_eq!(cell_col_at_char_idx(chars.iter().copied(), 3, 0), 4);
}
#[test]
fn zero_width_combining_mark_does_not_advance_cell_cursor() {
let area = Rect::new(0, 0, 4, 1);
let mut buf = TuiBuf::empty(area);
let line = Line::from(vec![Span::raw("e\u{0301}!")]);
render_line(&line, area, &mut buf, 0, false);
assert_eq!(
buf.cell((0, 0)).map(|c| c.symbol().to_string()),
Some("e".into())
);
assert_eq!(
buf.cell((1, 0)).map(|c| c.symbol().to_string()),
Some("!".into())
);
}
}