use ratatui::{buffer::Buffer as TuiBuf, layout::Rect, style::Style, text::Line};
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 };
if let Some(cell) = paint_row(
&chars,
start,
row_end,
start,
row_indent,
row_prefix,
area,
buf,
cur_abs_y,
line_style,
cursor_col_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
}
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 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 = start + n_chars;
let break_rel = chars[start..window_end]
.iter()
.enumerate()
.rev()
.find(|(_, (ch, _))| !ch.is_alphanumeric())
.map(|(i, _)| i);
match break_rel {
Some(bp) => {
let end = start + bp + 1;
(end, end)
}
None => (window_end, window_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 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 {
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 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())
);
}
}