use std::borrow::Cow;
use std::cell::RefCell;
use ratatui_core::buffer::Buffer;
use ratatui_core::layout::{Alignment, Rect};
use ratatui_core::style::{Color, Modifier, Style};
use ratatui_core::text::{Line, Span, Text as RText};
use crate::element::Element;
pub struct MarkdownStyles {
pub base: Style,
pub code_inline: Style,
pub code_block: Style,
pub bold: Style,
pub italic: Style,
pub heading: Style,
pub table_border: Style,
pub table_header: Style,
}
impl Default for MarkdownStyles {
fn default() -> Self {
let base = Style::default();
Self {
base,
code_inline: Style::default().fg(Color::Yellow),
code_block: Style::default().fg(Color::Green),
bold: base.add_modifier(Modifier::BOLD),
italic: base.add_modifier(Modifier::ITALIC),
heading: Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
table_border: Style::default().fg(Color::DarkGray),
table_header: base.add_modifier(Modifier::BOLD),
}
}
}
pub struct Markdown {
source: String,
styles: MarkdownStyles,
streaming: bool,
blocks: RefCell<Option<Vec<Block>>>,
layout: RefCell<Option<(u16, Vec<BlockLayout>)>>,
}
pub fn markdown(source: impl Into<String>) -> Markdown {
Markdown {
source: source.into(),
styles: MarkdownStyles::default(),
streaming: false,
blocks: RefCell::new(None),
layout: RefCell::new(None),
}
}
impl Markdown {
pub fn styles(mut self, styles: MarkdownStyles) -> Self {
self.styles = styles;
self.blocks.take();
self.layout.take();
self
}
pub fn streaming(mut self, streaming: bool) -> Self {
self.streaming = streaming;
self.blocks.take();
self.layout.take();
self
}
fn with_blocks<R>(&self, f: impl FnOnce(&[Block]) -> R) -> R {
let mut cache = self.blocks.borrow_mut();
let blocks = cache.get_or_insert_with(|| {
let cooked = if self.streaming {
cook_streaming_tail(&self.source)
} else {
Cow::Borrowed(self.source.as_str())
};
parse(&cooked, &self.styles)
});
f(blocks)
}
fn with_layout<R>(&self, width: u16, f: impl FnOnce(&[Block], &[BlockLayout]) -> R) -> R {
self.with_blocks(|blocks| {
let mut cache = self.layout.borrow_mut();
let valid = matches!(*cache, Some((w, _)) if w == width);
if !valid {
let layouts = blocks
.iter()
.map(|b| layout_block(b, width, self.streaming))
.collect();
*cache = Some((width, layouts));
}
match &*cache {
Some((_, layouts)) => f(blocks, layouts),
None => f(blocks, &[]),
}
})
}
}
impl Element for Markdown {
fn height(&self, width: u16) -> u16 {
if self.source.is_empty() || width == 0 {
return 0;
}
self.with_layout(width, |_, layouts| total_rows(layouts))
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if self.source.is_empty() || area.width == 0 || area.height == 0 {
return;
}
self.with_layout(area.width, |blocks, layouts| {
let mut y = area.y;
for (i, (block, layout)) in blocks.iter().zip(layouts).enumerate() {
if i > 0 {
y = y.saturating_add(1);
}
if y >= area.bottom() {
break;
}
match (block, layout) {
(Block::Text(text), BlockLayout::Text { rows }) => {
let rect = Rect::new(area.x, y, area.width, *rows).intersection(area);
if rect.height > 0 {
eye_declare_engine::wrap::render_wrapped(
borrow_text(text),
Alignment::Left,
0,
rect,
buf,
);
}
y = y.saturating_add(*rows);
}
(
Block::Table(table),
BlockLayout::Table {
col_widths,
row_heights,
rows,
},
) => {
let rect = Rect::new(area.x, y, area.width, *rows).intersection(area);
render_table(table, col_widths, row_heights, rect, buf, &self.styles);
y = y.saturating_add(*rows);
}
_ => {}
}
}
});
}
}
enum Block {
Text(RText<'static>),
Table(Table),
}
fn sanitize(s: &str) -> std::borrow::Cow<'_, str> {
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
let clean = !s.chars().any(char::is_control) && s.graphemes(true).all(|g| g.width() > 0);
if clean {
return std::borrow::Cow::Borrowed(s);
}
let mut out = String::with_capacity(s.len());
for g in s.graphemes(true) {
if g == "\t" {
out.push_str(" ");
} else if g.chars().any(char::is_control) || g.width() == 0 {
out.push('\u{FFFD}');
} else {
out.push_str(g);
}
}
std::borrow::Cow::Owned(out)
}
struct Table {
alignments: Vec<Alignment>,
header: Vec<Line<'static>>,
rows: Vec<Vec<Line<'static>>>,
open: bool,
}
fn push_merged(
line: &mut Vec<Span<'static>>,
content: impl Into<String> + AsRef<str>,
style: Style,
) {
if let Some(last) = line.last_mut()
&& last.style == style
{
last.content.to_mut().push_str(content.as_ref());
} else {
line.push(Span::styled(content.into(), style));
}
}
enum BlockLayout {
Text {
rows: u16,
},
Table {
col_widths: Vec<u16>,
row_heights: Vec<u16>,
rows: u16,
},
}
impl BlockLayout {
fn rows(&self) -> u16 {
match self {
BlockLayout::Text { rows } => *rows,
BlockLayout::Table { rows, .. } => *rows,
}
}
}
fn total_rows(layouts: &[BlockLayout]) -> u16 {
let mut total: u32 = 0;
for (i, layout) in layouts.iter().enumerate() {
if i > 0 {
total += 1;
}
total += layout.rows() as u32;
}
total.min(u32::from(u16::MAX)) as u16
}
fn layout_block(block: &Block, width: u16, streaming: bool) -> BlockLayout {
match block {
Block::Text(text) => BlockLayout::Text {
rows: eye_declare_engine::wrap::wrapped_line_count(text, width),
},
Block::Table(table) => layout_table(table, width, streaming),
}
}
fn layout_table(table: &Table, width: u16, streaming: bool) -> BlockLayout {
let ncols = table.alignments.len();
let mut widths: Vec<usize> = (0..ncols)
.map(|i| {
let mut w = table.header.get(i).map(|l| l.width()).unwrap_or(0);
for row in &table.rows {
w = w.max(row.get(i).map(|l| l.width()).unwrap_or(0));
}
w.max(1)
})
.collect();
let budget = (width as usize).saturating_sub(ncols * 3 + 1);
let total: usize = widths.iter().sum();
if total > budget {
let mut excess = total - budget;
while excess > 0 {
let Some((i, &w)) = widths.iter().enumerate().max_by_key(|(_, w)| **w) else {
break;
};
if w <= 1 {
break;
}
widths[i] -= 1;
excess -= 1;
}
} else if streaming && table.open {
if let Some(last) = widths.last_mut() {
*last += budget - total;
}
}
let col_widths: Vec<u16> = widths
.into_iter()
.map(|w| w.min(usize::from(u16::MAX)) as u16)
.collect();
let mut row_heights = Vec::with_capacity(table.rows.len() + 1);
row_heights.push(row_height(&table.header, &col_widths));
for row in &table.rows {
row_heights.push(row_height(row, &col_widths));
}
let rules: u32 = if table.rows.is_empty() { 2 } else { 3 };
let rows = (rules + row_heights.iter().map(|&h| u32::from(h)).sum::<u32>())
.min(u32::from(u16::MAX)) as u16;
BlockLayout::Table {
col_widths,
row_heights,
rows,
}
}
fn row_height(cells: &[Line<'static>], col_widths: &[u16]) -> u16 {
let mut h = 1;
for (cell, &w) in cells.iter().zip(col_widths) {
if cell.spans.is_empty() {
continue;
}
let text = RText::from(borrow_line(cell));
h = h.max(eye_declare_engine::wrap::wrapped_line_count(&text, w));
}
h
}
fn render_table(
table: &Table,
col_widths: &[u16],
row_heights: &[u16],
region: Rect,
buf: &mut Buffer,
styles: &MarkdownStyles,
) {
if region.width == 0 || region.height == 0 || col_widths.is_empty() {
return;
}
let mut border_x = Vec::with_capacity(col_widths.len() + 1);
let mut x = region.x;
border_x.push(x);
for &w in col_widths {
x = x.saturating_add(w).saturating_add(3);
border_x.push(x);
}
let mut y = region.y;
draw_rule(
buf,
region,
col_widths,
y,
('┌', '┬', '┐'),
styles.table_border,
);
y = y.saturating_add(1);
y = draw_cells(
buf,
region,
table,
col_widths,
&border_x,
&table.header,
row_heights.first().copied().unwrap_or(1),
y,
styles,
);
if !table.rows.is_empty() {
draw_rule(
buf,
region,
col_widths,
y,
('├', '┼', '┤'),
styles.table_border,
);
y = y.saturating_add(1);
for (row, &h) in table.rows.iter().zip(&row_heights[1..]) {
y = draw_cells(buf, region, table, col_widths, &border_x, row, h, y, styles);
}
}
draw_rule(
buf,
region,
col_widths,
y,
('└', '┴', '┘'),
styles.table_border,
);
}
fn draw_rule(
buf: &mut Buffer,
region: Rect,
col_widths: &[u16],
y: u16,
(left, mid, right): (char, char, char),
style: Style,
) {
if y >= region.bottom() {
return;
}
let mut s = String::new();
for (i, &w) in col_widths.iter().enumerate() {
s.push(if i == 0 { left } else { mid });
for _ in 0..(w as usize + 2) {
s.push('─');
}
}
s.push(right);
buf.set_stringn(region.x, y, &s, region.width as usize, style);
}
#[allow(clippy::too_many_arguments)]
fn draw_cells(
buf: &mut Buffer,
region: Rect,
table: &Table,
col_widths: &[u16],
border_x: &[u16],
cells: &[Line<'static>],
height: u16,
y: u16,
styles: &MarkdownStyles,
) -> u16 {
for dy in 0..height {
let yy = y.saturating_add(dy);
if yy >= region.bottom() {
break;
}
for &bx in border_x {
if bx < region.right() {
buf[(bx, yy)].set_symbol("│").set_style(styles.table_border);
}
}
}
for (i, cell) in cells.iter().enumerate().take(col_widths.len()) {
let rect =
Rect::new(border_x[i].saturating_add(2), y, col_widths[i], height).intersection(region);
if rect.width == 0 || rect.height == 0 {
continue;
}
eye_declare_engine::wrap::render_wrapped(
RText::from(borrow_line(cell)),
table.alignments.get(i).copied().unwrap_or(Alignment::Left),
0,
rect,
buf,
);
}
y.saturating_add(height)
}
fn borrow_line<'a>(line: &'a Line<'static>) -> Line<'a> {
Line::from(
line.spans
.iter()
.map(|s| Span::styled(s.content.as_ref(), s.style))
.collect::<Vec<_>>(),
)
.style(line.style)
}
fn borrow_text<'a>(text: &'a RText<'static>) -> RText<'a> {
RText::from(text.lines.iter().map(borrow_line).collect::<Vec<_>>())
}
fn parse(source: &str, styles: &MarkdownStyles) -> Vec<Block> {
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
let mut blocks: Vec<Block> = Vec::new();
let mut lines: Vec<Vec<Span<'static>>> = vec![Vec::new()];
let mut current_line = 0;
let mut style_stack: Vec<Style> = vec![styles.base];
let mut in_code_block = false;
let mut in_list_item = false;
let mut list_item_first_para = false;
let mut table: Option<TableAcc> = None;
for (event, range) in Parser::new_ext(source, Options::ENABLE_TABLES).into_offset_iter() {
match event {
Event::Start(Tag::Strong) => {
let base = style_stack.last().copied().unwrap_or(styles.base);
style_stack.push(base.add_modifier(Modifier::BOLD).patch(styles.bold));
}
Event::End(TagEnd::Strong) => {
style_stack.pop();
}
Event::Start(Tag::Emphasis) => {
let base = style_stack.last().copied().unwrap_or(styles.base);
style_stack.push(base.add_modifier(Modifier::ITALIC).patch(styles.italic));
}
Event::End(TagEnd::Emphasis) => {
style_stack.pop();
}
Event::Start(Tag::CodeBlock(_)) => {
in_code_block = true;
if !lines[current_line].is_empty() {
current_line += 1;
lines.push(Vec::new());
current_line += 1;
lines.push(Vec::new());
}
}
Event::End(TagEnd::CodeBlock) => {
in_code_block = false;
if !lines[current_line].is_empty() {
current_line += 1;
lines.push(Vec::new());
}
}
Event::Code(code) => {
let dest = match table.as_mut() {
Some(t) if t.in_cell => &mut t.cell,
Some(_) => continue,
None => &mut lines[current_line],
};
push_merged(dest, sanitize(&code), styles.code_inline);
}
Event::Text(text) => {
if let Some(t) = table.as_mut() {
if t.in_cell {
let style = style_stack.last().copied().unwrap_or(styles.base);
push_merged(&mut t.cell, sanitize(&text), style);
}
continue;
}
let current_style = if in_code_block {
styles.code_block
} else {
style_stack.last().copied().unwrap_or(styles.base)
};
let prefix = if in_code_block { " " } else { "" };
for (i, part) in text.split('\n').enumerate() {
if i > 0 {
current_line += 1;
lines.push(Vec::new());
}
if !part.is_empty() {
let part = sanitize(part);
if prefix.is_empty() {
push_merged(&mut lines[current_line], part, current_style);
} else {
push_merged(
&mut lines[current_line],
format!("{prefix}{part}"),
current_style,
);
}
}
}
}
Event::SoftBreak => {
let current_style = style_stack.last().copied().unwrap_or(styles.base);
let dest = match table.as_mut() {
Some(t) if t.in_cell => &mut t.cell,
Some(_) => continue,
None => &mut lines[current_line],
};
push_merged(dest, " ", current_style);
}
Event::HardBreak => {
match table.as_mut() {
Some(t) if t.in_cell => {
let style = style_stack.last().copied().unwrap_or(styles.base);
push_merged(&mut t.cell, " ", style);
}
Some(_) => {}
None => {
current_line += 1;
lines.push(Vec::new());
}
}
}
Event::Start(Tag::Paragraph) => {
if in_list_item && list_item_first_para {
list_item_first_para = false;
} else if current_line > 0 || !lines[0].is_empty() {
current_line += 1;
lines.push(Vec::new());
if !in_list_item {
current_line += 1;
lines.push(Vec::new());
}
}
}
Event::End(TagEnd::Paragraph) => {}
Event::Start(Tag::Heading { .. }) => {
if current_line > 0 || !lines[0].is_empty() {
current_line += 1;
lines.push(Vec::new());
current_line += 1;
lines.push(Vec::new());
}
style_stack.push(styles.heading);
}
Event::End(TagEnd::Heading(_)) => {
style_stack.pop();
}
Event::Start(Tag::Item) => {
if current_line > 0 || !lines[0].is_empty() {
current_line += 1;
lines.push(Vec::new());
}
lines[current_line].push(Span::styled("- ", Style::default().fg(Color::DarkGray)));
in_list_item = true;
list_item_first_para = true;
}
Event::End(TagEnd::Item) => {
in_list_item = false;
}
Event::Start(Tag::List(_)) if current_line > 0 || !lines[0].is_empty() => {
current_line += 1;
lines.push(Vec::new());
}
Event::End(TagEnd::List(_)) => {}
Event::Start(Tag::Table(alignments)) => {
flush_text(&mut lines, &mut current_line, &mut blocks);
table = Some(TableAcc {
alignments: alignments.iter().map(|a| convert_alignment(*a)).collect(),
header: Vec::new(),
rows: Vec::new(),
row: Vec::new(),
cell: Vec::new(),
in_cell: false,
open: source[range.end.min(source.len())..].trim().is_empty(),
});
}
Event::End(TagEnd::Table) => {
if let Some(mut t) = table.take() {
let ncols = t.alignments.len().max(1);
t.alignments.resize(ncols, Alignment::Left);
t.header.resize_with(ncols, Line::default);
for row in &mut t.rows {
row.resize_with(ncols, Line::default);
}
blocks.push(Block::Table(Table {
alignments: t.alignments,
header: t.header,
rows: t.rows,
open: t.open,
}));
}
}
Event::Start(Tag::TableHead) => {
let base = style_stack.last().copied().unwrap_or(styles.base);
style_stack.push(base.patch(styles.table_header));
}
Event::End(TagEnd::TableHead) => {
style_stack.pop();
if let Some(t) = table.as_mut() {
t.header = std::mem::take(&mut t.row);
}
}
Event::Start(Tag::TableRow) => {}
Event::End(TagEnd::TableRow) => {
if let Some(t) = table.as_mut() {
let row = std::mem::take(&mut t.row);
t.rows.push(row);
}
}
Event::Start(Tag::TableCell) => {
if let Some(t) = table.as_mut() {
t.in_cell = true;
}
}
Event::End(TagEnd::TableCell) => {
if let Some(t) = table.as_mut() {
t.in_cell = false;
let spans = std::mem::take(&mut t.cell);
t.row.push(Line::from(spans));
}
}
_ => {}
}
}
flush_text(&mut lines, &mut current_line, &mut blocks);
blocks
}
struct TableAcc {
alignments: Vec<Alignment>,
header: Vec<Line<'static>>,
rows: Vec<Vec<Line<'static>>>,
row: Vec<Line<'static>>,
cell: Vec<Span<'static>>,
in_cell: bool,
open: bool,
}
fn convert_alignment(a: pulldown_cmark::Alignment) -> Alignment {
match a {
pulldown_cmark::Alignment::Center => Alignment::Center,
pulldown_cmark::Alignment::Right => Alignment::Right,
_ => Alignment::Left,
}
}
fn flush_text(
lines: &mut Vec<Vec<Span<'static>>>,
current_line: &mut usize,
blocks: &mut Vec<Block>,
) {
while lines.last().is_some_and(|l| l.is_empty()) {
lines.pop();
}
if !lines.is_empty() {
let taken = std::mem::take(lines);
blocks.push(Block::Text(RText::from(
taken.into_iter().map(Line::from).collect::<Vec<_>>(),
)));
}
lines.clear();
lines.push(Vec::new());
*current_line = 0;
}
fn cook_streaming_tail(source: &str) -> Cow<'_, str> {
let (scan, last_line_complete) = match source.strip_suffix('\n') {
Some(s) => (s, true),
None => (source, false),
};
if scan.is_empty() {
return Cow::Borrowed(source);
}
let mut fence: Option<char> = None;
let mut first: Option<&str> = None;
let mut second: Option<(usize, &str)> = None;
let mut run_len = 0usize;
let mut offset = 0usize;
for line in scan.split('\n') {
let trimmed = line.trim_start();
let marker = if trimmed.starts_with("```") {
Some('`')
} else if trimmed.starts_with("~~~") {
Some('~')
} else {
None
};
let boundary = match (fence, marker) {
(None, Some(m)) => {
fence = Some(m);
true
}
(Some(f), Some(m)) if f == m => {
fence = None;
true
}
_ => false,
};
if boundary || fence.is_some() || !trimmed.starts_with('|') {
first = None;
second = None;
run_len = 0;
} else {
if first.is_none() {
first = Some(line);
second = None;
run_len = 0;
}
run_len += 1;
if run_len == 2 {
second = Some((offset, line));
}
}
offset += line.len() + 1;
}
let Some(header) = first else {
return Cow::Borrowed(source);
};
if header.trim() == "|" {
return Cow::Borrowed(source);
}
match (run_len, second) {
(1, _) => {
let sep = if last_line_complete { "" } else { "\n" };
let delim = delimiter_row(cell_count(header).max(1));
Cow::Owned(format!("{source}{sep}{delim}"))
}
(2, Some((delim_offset, delim))) => {
let cols = cell_count(header).max(1);
let complete =
delimiter_charset(delim) && delim.contains('-') && cell_count(delim) == cols;
if complete {
Cow::Borrowed(source)
} else if delimiter_charset(delim) && !last_line_complete {
let delim = delimiter_row(cols);
Cow::Owned(format!("{}{delim}", &source[..delim_offset]))
} else {
Cow::Borrowed(source)
}
}
_ => Cow::Borrowed(source),
}
}
fn cell_count(line: &str) -> usize {
let mut t = line.trim();
t = t.strip_prefix('|').unwrap_or(t);
if t.ends_with('|') && !t.ends_with("\\|") {
t = &t[..t.len() - 1];
}
let mut count = 1;
let mut escaped = false;
for c in t.chars() {
if escaped {
escaped = false;
continue;
}
match c {
'\\' => escaped = true,
'|' => count += 1,
_ => {}
}
}
count
}
fn delimiter_charset(line: &str) -> bool {
let t = line.trim();
!t.is_empty() && t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ' | '\t'))
}
fn delimiter_row(cols: usize) -> String {
format!("|{}", "---|".repeat(cols))
}
#[cfg(test)]
mod tests {
use super::*;
fn rendered(el: &impl Element, width: u16) -> Vec<String> {
let height = el.height(width);
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
el.render(area, &mut buf);
(0..height)
.map(|y| {
let mut line: String = (0..width).map(|x| buf[(x, y)].symbol()).collect();
while line.ends_with(' ') {
line.pop();
}
line
})
.collect()
}
#[test]
fn plain_paragraph() {
assert_eq!(rendered(&markdown("hello world"), 20), vec!["hello world"]);
}
#[test]
fn empty_source_is_zero_height() {
assert_eq!(Element::height(&markdown(""), 20), 0);
}
#[test]
fn paragraphs_separated_by_blank_line() {
let lines = rendered(&markdown("one\n\ntwo"), 20);
assert_eq!(lines, vec!["one", "", "two"]);
}
#[test]
fn heading_is_styled() {
let el = markdown("# Title");
let area = Rect::new(0, 0, 20, 1);
let mut buf = Buffer::empty(area);
el.render(area, &mut buf);
assert_eq!(buf[(0, 0)].symbol(), "T");
assert_eq!(buf[(0, 0)].style().fg, Some(Color::Cyan));
}
#[test]
fn bold_and_inline_code_styles() {
let el = markdown("a **b** `c`");
let area = Rect::new(0, 0, 20, 1);
let mut buf = Buffer::empty(area);
el.render(area, &mut buf);
assert!(buf[(2, 0)].style().add_modifier.contains(Modifier::BOLD));
assert_eq!(buf[(4, 0)].style().fg, Some(Color::Yellow));
}
#[test]
fn code_block_indented_and_colored() {
let lines = rendered(&markdown("para\n\n```\ncode here\n```"), 20);
assert!(lines.contains(&" code here".to_string()));
}
#[test]
fn list_items_prefixed() {
let lines = rendered(&markdown("- one\n- two"), 20);
assert_eq!(lines, vec!["- one", "- two"]);
}
#[test]
fn long_content_wraps_and_measures_consistently() {
let el = markdown("this is a longer paragraph that should wrap");
let h = Element::height(&el, 12);
assert!(h >= 3, "expected wrapping at width 12, got {h}");
}
#[test]
fn table_renders_with_borders() {
let lines = rendered(&markdown("| a | b |\n|---|---|\n| 1 | 2 |"), 20);
assert_eq!(
lines,
vec![
"┌───┬───┐",
"│ a │ b │",
"├───┼───┤",
"│ 1 │ 2 │",
"└───┴───┘",
]
);
}
#[test]
fn table_header_is_bold() {
let el = markdown("| ab | c |\n|---|---|\n| 1 | 2 |");
let area = Rect::new(0, 0, 20, 5);
let mut buf = Buffer::empty(area);
el.render(area, &mut buf);
assert_eq!(buf[(2, 1)].symbol(), "a");
assert!(buf[(2, 1)].style().add_modifier.contains(Modifier::BOLD));
assert!(!buf[(2, 3)].style().add_modifier.contains(Modifier::BOLD));
}
#[test]
fn table_between_text_gets_blank_separators() {
let lines = rendered(&markdown("before\n\n| a |\n|---|\n| 1 |\n\nafter"), 20);
assert_eq!(
lines,
vec![
"before",
"",
"┌───┐",
"│ a │",
"├───┤",
"│ 1 │",
"└───┘",
"",
"after",
]
);
}
#[test]
fn right_aligned_column() {
let lines = rendered(&markdown("| aa | b |\n|--:|---|\n| 1 | 2 |"), 20);
assert_eq!(lines[3], "│ 1 │ 2 │");
}
#[test]
fn wide_table_shrinks_and_wraps_cells() {
let lines = rendered(
&markdown("| a | b |\n|---|---|\n| aaaaaaaaaaaaaaaaaaaa | b |"),
16,
);
assert_eq!(lines.len(), 7, "got {lines:?}");
assert!(lines.iter().all(|l| l.chars().count() <= 16));
assert!(lines[3].contains("aaaaaaaa"));
assert!(lines[5].contains("aaaa"));
}
#[test]
fn static_partial_header_stays_text() {
assert_eq!(rendered(&markdown("| Name"), 20), vec!["| Name"]);
}
#[test]
fn streaming_partial_header_is_full_width_cell() {
let lines = rendered(&markdown("| Name").streaming(true), 20);
assert_eq!(
lines,
vec![
"┌──────────────────┐",
"│ Name │",
"└──────────────────┘",
]
);
}
#[test]
fn streaming_header_splits_when_next_cell_arrives() {
let lines = rendered(&markdown("| Name | Ag").streaming(true), 30);
assert_eq!(lines.len(), 3);
assert!(lines[1].starts_with("│ Name │ Ag"), "got {:?}", lines[1]);
assert!(lines[1].ends_with('│'));
assert_eq!(lines[0].chars().count(), 30);
}
#[test]
fn streaming_header_survives_trailing_newline() {
let lines = rendered(&markdown("| a | b |\n").streaming(true), 20);
assert!(lines[0].starts_with('┌'), "got {lines:?}");
}
#[test]
fn streaming_partial_delimiter_keeps_table() {
let lines = rendered(&markdown("| a | b |\n| --").streaming(true), 20);
assert!(lines[0].starts_with('┌'), "got {lines:?}");
assert!(lines[1].contains("│ a │ b"), "got {lines:?}");
}
#[test]
fn streaming_row_cell_grows_then_splits() {
let partial = rendered(
&markdown("| a | b |\n|---|---|\n| Alice | 3").streaming(true),
30,
);
assert!(
partial[3].starts_with("│ Alice │ 3"),
"got {:?}",
partial[3]
);
let next_row = rendered(
&markdown("| a | b |\n|---|---|\n| Alice | 30 |\n| Bob").streaming(true),
30,
);
assert!(next_row[4].starts_with("│ Bob"), "got {:?}", next_row[4]);
}
#[test]
fn streaming_complete_table_hugs_content() {
let lines = rendered(
&markdown("| a | b |\n|---|---|\n| 1 | 2 |\n\ndone").streaming(true),
30,
);
assert_eq!(lines[0], "┌───┬───┐");
assert_eq!(*lines.last().unwrap(), "done");
}
#[test]
fn streaming_ignores_pipes_in_code_fences() {
let lines = rendered(&markdown("```\n| a | b |").streaming(true), 20);
assert!(lines.iter().any(|l| l == " | a | b |"), "got {lines:?}");
assert!(!lines.iter().any(|l| l.contains('┌')), "got {lines:?}");
}
#[test]
fn streaming_height_matches_render() {
let snapshots = [
"| Na",
"| Name |",
"| Name | Age |\n",
"| Name | Age |\n| --- | -",
"| Name | Age |\n| --- | --- |\n| Alice | 30 |\n| Bob | 2",
];
for src in snapshots {
for width in [5u16, 12, 30, 80] {
let el = markdown(src).streaming(true);
let h = el.height(width);
assert!(h > 0, "zero height for {src:?} at {width}");
}
}
}
#[test]
fn every_streaming_prefix_renders() {
let doc = "Intro text.\n\n| Name | Age |\n| --- | ---: |\n| Alice | 30 |\n| Bob \\| Jr | 25 |\n\n```\n| not | a table |\n```\n\ndone";
for end in doc.char_indices().map(|(i, _)| i).chain([doc.len()]) {
let src = &doc[..end];
for width in [3u16, 10, 24, 60] {
let el = markdown(src).streaming(true);
let h = el.height(width);
let area = Rect::new(0, 0, width, h);
let mut buf = Buffer::empty(area);
el.render(area, &mut buf);
}
}
}
#[test]
fn streaming_lone_pipe_stays_raw() {
for src in ["|", "| ", "|\n"] {
assert_eq!(rendered(&markdown(src).streaming(true), 20), vec!["|"]);
}
}
#[test]
fn streaming_empty_cell_header_still_speculates() {
let lines = rendered(&markdown("||").streaming(true), 20);
assert!(lines[0].starts_with('┌'), "got {lines:?}");
}
#[test]
fn streaming_fence_marker_mismatch_stays_code() {
let lines = rendered(&markdown("```\n~~~\n| a | b |").streaming(true), 20);
assert!(lines.iter().any(|l| l == " | a | b |"), "got {lines:?}");
assert!(
!lines.iter().any(|l| l.contains('┌') || l.contains("---")),
"synthetic delimiter leaked: {lines:?}"
);
}
#[test]
fn cell_count_handles_partial_and_escaped_pipes() {
assert_eq!(cell_count("| a"), 1);
assert_eq!(cell_count("| a |"), 1);
assert_eq!(cell_count("| a | b"), 2);
assert_eq!(cell_count("| a | b |"), 2);
assert_eq!(cell_count("| a \\| b |"), 1);
assert_eq!(cell_count("|"), 1);
}
#[test]
fn fragmented_spans_with_wide_chars_render_safely() {
for source in ["\u{0}[\u{4f49}&", "x\u{604}<!"] {
let el = markdown(source);
for width in 1..8 {
let height = Element::height(&el, width);
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
el.render(area, &mut buf); }
}
}
#[test]
fn control_chars_are_sanitized() {
let lines = rendered(&markdown("a\u{0}b\tc\u{1b}[31m"), 40);
assert_eq!(lines, vec!["a\u{fffd}b c\u{fffd}[31m"]);
}
}