use ratatui::{buffer::Buffer as TuiBuf, layout::Rect, style::Style, text::Line};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::document::wrap::{char_cells, visual_rows_of_chars, Indent, PaintedRows};
#[allow(dead_code)]
pub fn render_line(
line: &Line<'static>,
indent: Indent,
area: Rect,
buf: &mut TuiBuf,
visual_y: u16,
wrap: bool,
) -> u16 {
render_line_with_cursor_from_visual(line, indent, area, buf, visual_y, wrap, None, 0)
}
pub fn render_line_from_visual(
line: &Line<'static>,
indent: Indent,
area: Rect,
buf: &mut TuiBuf,
visual_y: u16,
wrap: bool,
skip_rows: usize,
) -> u16 {
render_line_with_cursor_from_visual(line, indent, area, buf, visual_y, wrap, None, skip_rows)
}
#[allow(dead_code)]
pub fn render_line_with_cursor(
line: &Line<'static>,
indent: Indent,
area: Rect,
buf: &mut TuiBuf,
visual_y: u16,
wrap: bool,
cursor_col_override: Option<(usize, Style)>,
) -> u16 {
render_line_with_cursor_from_visual(
line,
indent,
area,
buf,
visual_y,
wrap,
cursor_col_override,
0,
)
}
#[allow(clippy::too_many_arguments)]
pub fn render_line_with_cursor_from_visual(
line: &Line<'static>,
indent: Indent,
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,
indent,
area,
buf,
visual_y,
wrap,
cursor_col_override,
skip_rows,
)
.0
}
#[allow(clippy::too_many_arguments)]
pub fn render_line_reporting_cursor(
line: &Line<'static>,
indent: Indent,
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,
indent,
area,
buf,
visual_y,
wrap,
cursor_col_override,
skip_rows,
)
}
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,
Indent::NONE,
area,
buf,
visual_y,
true,
cursor_col_override,
skip_rows,
)
.0
}
#[allow(clippy::too_many_arguments)]
fn render_line_core(
line: &Line<'static>,
indent: Indent,
area: Rect,
buf: &mut TuiBuf,
visual_y: u16,
wrap: bool,
cursor_col_override: Option<(usize, Style)>,
skip_rows: 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 rows = visual_rows_of_chars(&chars, width, indent);
let indent = indent.at(width);
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 = indent.row(row_idx);
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)
}
pub(crate) fn append_zero_width(buf: &mut TuiBuf, at: (u16, u16), ch: char) {
if ch == '\u{200D}' {
return;
}
if let Some(cell) = buf.cell_mut(at) {
let mut symbol = cell.symbol().to_owned();
let width = symbol.width();
symbol.push(ch);
if symbol.graphemes(true).nth(1).is_none() && symbol.width() == width {
cell.set_symbol(&symbol);
}
}
}
#[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;
}
let mut last_cell = None;
let mut glyph_cell = None;
let mut clipped = None;
for (rel_idx, (ch, style)) in chars[start..end].iter().enumerate() {
let cells = char_cells(*ch) as u16;
if cells == 0 {
if let Some(at) = glyph_cell {
append_zero_width(buf, at, *ch);
}
continue;
}
if x + cells > area_end {
last_cell = None;
clipped = Some(abs_col_base + rel_idx);
break;
}
last_cell = Some(x);
glyph_cell = Some((x, abs_y));
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 fill_cursor =
cursor_col_override.filter(|&(col, _)| col == chars.len() || Some(col) == clipped);
let mut fill_col = clipped.unwrap_or(abs_col_base + (end - start));
while x < area_end {
if let Some(cell) = buf.cell_mut((x, abs_y)) {
if let Some((_, s)) = fill_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;
}
if let (None, Some(lx), Some((_, s))) = (cursor_cell, last_cell, eol_cursor) {
if end == chars.len() {
if let Some(cell) = buf.cell_mut((lx, abs_y)) {
cell.set_style(s);
cursor_cell = Some((lx, abs_y));
}
}
}
cursor_cell
}
#[allow(clippy::too_many_arguments)]
pub fn patch_char_cols(
line: &Line<'_>,
indent: Indent,
buf: &mut TuiBuf,
area: Rect,
y_first: u16,
rows_used: u16,
skip_rows: usize,
cols: std::ops::Range<usize>,
style: Style,
) {
let width = area.width as usize;
if width == 0 || cols.is_empty() {
return;
}
let painted = PaintedRows::new(line, indent, width);
for (painted_off, (row_off, &(row_start, row_end, _))) in
painted.rows.iter().enumerate().skip(skip_rows).enumerate()
{
if painted_off as u16 >= rows_used {
break;
}
let y = y_first + painted_off as u16;
if y >= area.y + area.height {
break;
}
let sel_start = cols.start.max(row_start);
let sel_end = cols.end.min(row_end);
if sel_start >= sel_end {
continue;
}
let mut x_off = painted.cell_of(row_off, sel_start);
for &(ch, _) in &painted.chars[sel_start..sel_end] {
let w = char_cells(ch);
for dx in 0..w {
let x = area.x + (x_off + dx) as u16;
if x >= area.x + area.width {
break;
}
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_style(cell.style().patch(style));
}
}
x_off += w;
}
}
}
fn leading_bar_prefix(chars: &[(char, Style)]) -> Vec<(char, Style)> {
let mut i = 0;
while i < chars.len() {
match chars[i].0 {
' ' => i += 1,
'▎' if chars.get(i + 1).is_some_and(|c| c.0 == ' ') => i += 2,
_ => break,
}
}
chars[..i].to_vec()
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Color;
use ratatui::text::Span;
#[test]
fn patch_char_cols_places_char_columns_in_cells() {
let sel = Style::default().bg(Color::Magenta);
let area = Rect::new(0, 0, 10, 1);
let mut buf = TuiBuf::empty(area);
patch_char_cols(
&Line::from("日本ab"),
Indent::NONE,
&mut buf,
area,
0,
1,
0,
1..3,
sel,
);
let bg = |x: u16| buf[(x, 0)].bg;
assert_ne!(bg(1), Color::Magenta);
assert_eq!(bg(2), Color::Magenta);
assert_eq!(bg(3), Color::Magenta);
assert_eq!(bg(4), Color::Magenta, "`a` is at cell 4");
assert_ne!(bg(5), Color::Magenta);
}
#[test]
fn patch_char_cols_drops_an_indent_that_leaves_no_room() {
let sel = Style::default().bg(Color::Magenta);
let area = Rect::new(0, 0, 3, 3);
let line = Line::from("• abcdef");
let indent = Indent::hanging(2);
let mut painted = TuiBuf::empty(area);
render_line(&line, indent, area, &mut painted, 0, true);
assert_eq!(painted[(0, 1)].symbol(), "a", "rows: `• ` / `abc` / `def`");
let mut buf = TuiBuf::empty(area);
patch_char_cols(&line, indent, &mut buf, area, 0, 3, 0, 2..3, sel);
assert_eq!(
buf[(0, 1)].bg,
Color::Magenta,
"`a` is at the row's first cell"
);
assert_ne!(buf[(2, 1)].bg, Color::Magenta);
}
#[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());
let nested: Vec<(char, Style)> = " ▎ q".chars().map(|c| (c, Style::default())).collect();
assert_eq!(leading_bar_prefix(&nested).len(), 4);
}
#[test]
fn wrapped_quote_in_a_list_item_repaints_indent_and_bar() {
let area = Rect::new(0, 0, 12, 3);
let mut buf = TuiBuf::empty(area);
let line = Line::from(vec![Span::raw(" ▎ "), Span::raw("alpha beta gamma")]);
let rows = render_line(&line, Indent::hanging(4), area, &mut buf, 0, true);
assert!(rows >= 2, "expected the quote to wrap, got {rows} row(s)");
let row1: String = (0..4).map(|x| buf[(x, 1)].symbol().to_string()).collect();
assert_eq!(row1, " ▎ ");
}
#[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, Indent::hanging(2), 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 eol_cursor_on_a_full_row_paints_over_the_last_char() {
let style = Style::default().fg(ratatui::style::Color::Red);
let area = Rect::new(0, 0, 10, 3);
for (text, wrap, want) in [
("abcdefghij", true, Some(((9, 0), "j"))),
("abcdefghij", false, Some(((9, 0), "j"))),
("abcdefghij abcdefghij", true, Some(((9, 1), "j"))),
("abcdefghi", true, Some(((9, 0), " "))),
("abcdefghijk", false, None),
] {
let mut buf = TuiBuf::empty(area);
let col = text.chars().count();
let (_, cursor) = render_line_reporting_cursor(
&Line::from(text),
Indent::NONE,
area,
&mut buf,
0,
wrap,
Some((col, style)),
0,
);
assert_eq!(cursor, want.map(|(cell, _)| cell), "{text:?}, wrap {wrap}");
if let Some((cell, symbol)) = want {
assert_eq!(buf[cell].fg, ratatui::style::Color::Red, "{text:?}");
assert_eq!(buf[cell].symbol(), symbol, "{text:?}");
}
}
}
#[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,
Indent::NONE,
area,
&mut buf,
0,
true,
Some((10, style)),
0,
);
assert_eq!(cursor, Some((0, 1)));
}
#[test]
fn a_lead_pads_the_first_row_and_the_cursor_with_it() {
let area = Rect::new(0, 0, 12, 3);
let mut buf = TuiBuf::empty(area);
let style = Style::default().fg(ratatui::style::Color::Red);
let indent = Indent { lead: 1, hang: 4 };
let (rows, cursor) = render_line_reporting_cursor(
&Line::from("6. alpha bravo"),
indent,
area,
&mut buf,
0,
true,
Some((3, style)),
0,
);
let row = |y: u16| (0..12u16).map(|x| buf[(x, y)].symbol()).collect::<String>();
assert_eq!(rows, 2);
assert_eq!(row(0), " 6. alpha ");
assert_eq!(row(1), " bravo ");
assert_eq!(cursor, Some((4, 0)), "on the `a`, past the pad");
}
#[test]
fn a_wide_glyph_never_straddles_the_area_edge() {
for (text, width, wrap) in [("abc日", 4, false), ("日", 1, true)] {
let mut buf = TuiBuf::empty(Rect::new(0, 0, 6, 2));
for x in 0..6 {
buf[(x, 0)].set_char('x');
}
let area = Rect::new(0, 0, width, 2);
render_line(&Line::from(text), Indent::NONE, area, &mut buf, 0, wrap);
assert_ne!(
buf[(width - 1, 0)].symbol(),
"日",
"{text:?}: the last cell"
);
assert_eq!(buf[(width, 0)].symbol(), "x", "{text:?}: past the area");
}
}
#[test]
fn a_clipped_glyph_s_blank_cell_carries_its_cursor_not_the_eol_one() {
let style = Style::default().fg(ratatui::style::Color::Red);
for (col, expected) in [(3, Some((3, 0))), (4, None)] {
let area = Rect::new(0, 0, 4, 1);
let mut buf = TuiBuf::empty(area);
let (_, cursor) = render_line_reporting_cursor(
&Line::from("abc日"),
Indent::NONE,
area,
&mut buf,
0,
false,
Some((col, style)),
0,
);
assert_eq!(cursor, expected, "cursor at column {col}");
}
}
#[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, Indent::NONE, 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 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, Indent::NONE, area, &mut buf, 0, false);
assert_eq!(
buf.cell((0, 0)).map(|c| c.symbol().to_string()),
Some("e\u{0301}".into())
);
assert_eq!(
buf.cell((1, 0)).map(|c| c.symbol().to_string()),
Some("!".into())
);
}
#[test]
fn zero_width_chars_join_the_preceding_glyph() {
let area = Rect::new(0, 0, 12, 1);
let mut buf = TuiBuf::empty(area);
let text = "\u{1112}\u{1161}\u{11AB}\u{1100}\u{116E}\u{11A8}e\u{0301}!";
render_line(&Line::from(text), Indent::NONE, area, &mut buf, 0, false);
let cells: Vec<&str> = (0..6).map(|x| buf[(x, 0)].symbol()).collect();
assert_eq!(
cells,
[
"\u{1112}\u{1161}\u{11AB}",
" ",
"\u{1100}\u{116E}\u{11A8}",
" ",
"e\u{0301}",
"!",
]
);
}
#[test]
fn zero_width_chars_that_join_no_cluster_or_widen_it_are_dropped() {
let area = Rect::new(0, 0, 10, 1);
let mut buf = TuiBuf::empty(area);
render_line(
&Line::from(
"\u{0301}\u{2764}\u{FE0F}\u{1F468}\u{200D}\u{1F469}a\tb\u{200B}c\u{231A}\u{FE0E}",
),
Indent::NONE,
area,
&mut buf,
0,
false,
);
let cells: Vec<&str> = (0..7).map(|x| buf[(x, 0)].symbol()).collect();
assert_eq!(
cells,
["\u{2764}", "\u{1F468}", " ", "\u{1F469}", " ", "a", "b"]
);
assert_eq!(buf[(7, 0)].symbol(), "c");
assert_eq!(buf[(8, 0)].symbol(), "\u{231A}");
}
}