pub mod list;
pub mod table;
pub mod util;
use std::borrow::Cow;
use std::cell::Cell;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Widget;
use tui_big_text::{BigText, PixelSize};
use crate::config::Theme;
use self::util::{link_fallback, link_style_for, nbsps, spaces};
use super::ast::{inlines_to_plain, to_u32, Block, Inline, LineSpan, MetadataKind, SrcLines};
use super::highlight::{self, Token};
use super::render_cache::{is_cache_worthy, BlockMap, CachedRows, RenderCache, RenderSettings};
use super::row_origin::{ContentKind, RowOrigin, RowSink};
use super::segment_break;
use super::table_layout::{self, str_cells};
const IMAGE_PREFIX: &str = "Image: ";
pub const CODE_PAD_COLS: usize = 1;
pub type ImageRowOverride<'t> = &'t dyn Fn(&str, usize) -> Option<usize>;
pub struct Renderer<'t> {
pub(super) theme: &'t Theme,
viewport_width: usize,
inset: Cell<usize>,
surface: Cell<Option<Color>>,
code_wrap: bool,
image_max_height: usize,
image_row_override: Option<ImageRowOverride<'t>>,
image_block_seq: Cell<usize>,
pub(super) row_striping: bool,
big_h1: bool,
syntax_highlighting: bool,
reflow_paragraphs: bool,
}
impl<'t> Renderer<'t> {
pub fn new(theme: &'t Theme) -> Self {
Self {
theme,
viewport_width: 80,
inset: Cell::new(0),
surface: Cell::new(None),
code_wrap: false,
image_max_height: 24,
image_row_override: None,
image_block_seq: Cell::new(0),
row_striping: false,
big_h1: false,
syntax_highlighting: false,
reflow_paragraphs: false,
}
}
pub fn with_viewport_width(mut self, width: usize) -> Self {
self.viewport_width = width;
self
}
#[allow(dead_code)]
pub fn with_code_wrap(mut self, wrap: bool) -> Self {
self.code_wrap = wrap;
self
}
pub fn with_image_max_height(mut self, rows: usize) -> Self {
self.image_max_height = rows.max(1);
self
}
pub fn with_image_row_override(mut self, override_fn: ImageRowOverride<'t>) -> Self {
self.image_row_override = Some(override_fn);
self
}
pub fn with_row_striping(mut self, on: bool) -> Self {
self.row_striping = on;
self
}
pub fn with_big_h1(mut self, on: bool) -> Self {
self.big_h1 = on;
self
}
pub fn with_syntax_highlighting(mut self, on: bool) -> Self {
self.syntax_highlighting = on;
self
}
pub fn with_reflow_paragraphs(mut self, on: bool) -> Self {
self.reflow_paragraphs = on;
self
}
#[allow(dead_code)]
pub fn render(&self, blocks: &[Block]) -> Vec<Line<'static>> {
let mut sink = RowSink::default();
for block in blocks {
self.render_block(block, &mut sink, "", true);
}
sink.lines
}
pub fn render_with_counts(&self, blocks: &[Block]) -> (RowSink, Vec<usize>) {
let mut rows = RowSink::default();
let mut counts = Vec::with_capacity(blocks.len());
for block in blocks {
let before = rows.len();
self.render_block(block, &mut rows, "", true);
counts.push(rows.len() - before);
}
(rows, counts)
}
pub fn render_with_counts_cached(
&self,
blocks: &[Block],
cache: &mut RenderCache,
) -> (RowSink, Vec<usize>) {
let mut prev = cache.begin_build(RenderSettings {
theme_addr: self.theme as *const Theme as usize,
viewport_width: self.viewport_width,
code_wrap: self.code_wrap,
image_max_height: self.image_max_height,
row_striping: self.row_striping,
big_h1: self.big_h1,
syntax_highlighting: self.syntax_highlighting,
reflow_paragraphs: self.reflow_paragraphs,
highlight_generation: if self.syntax_highlighting {
highlight::warm_generation()
} else {
0
},
highlight_retry_epoch: if self.syntax_highlighting {
highlight::retry_epoch()
} else {
0
},
});
let mut rows = RowSink::with_capacity(cache.last_row_count);
let mut counts = Vec::with_capacity(blocks.len());
for block in blocks {
let before = rows.len();
if matches!(block, Block::ImageBlock { .. }) || !is_cache_worthy(block) {
self.render_block(block, &mut rows, "", true);
} else {
let hash = BlockMap::hash(block);
if let Some(hit) = cache.entries.get(hash, block) {
rows.extend_from(&hit.lines, &hit.origins);
} else if let Some((key, hit)) = prev.take(hash, block) {
rows.extend_from(&hit.lines, &hit.origins);
cache.entries.insert_new(hash, key, hit);
} else {
self.render_block(block, &mut rows, "", true);
cache.entries.insert_new(
hash,
block.clone(),
CachedRows {
lines: rows.lines[before..].to_vec(),
origins: rows.origins[before..].to_vec(),
},
);
}
}
counts.push(rows.len() - before);
}
cache.last_row_count = rows.len();
(rows, counts)
}
pub(super) fn width(&self) -> usize {
self.viewport_width.saturating_sub(self.inset.get())
}
fn inset_by<R>(&self, cells: usize, f: impl FnOnce() -> R) -> R {
let outer = self.inset.replace(self.inset.get() + cells);
let r = f();
self.inset.set(outer);
r
}
fn on_surface<R>(&self, bg: Option<Color>, f: impl FnOnce() -> R) -> R {
let outer = self.surface.replace(bg.or(self.surface.get()));
let r = f();
self.surface.set(outer);
r
}
pub(super) fn surface_bg(&self) -> Color {
self.surface.get().unwrap_or(Color::Reset)
}
pub(super) fn render_block(
&self,
block: &Block,
out: &mut RowSink,
indent_prefix: &str,
top_level: bool,
) {
match block {
Block::Heading {
level,
inlines,
src,
} => {
self.render_heading(*level, inlines, src, top_level, out);
}
Block::Paragraph { inlines, src } => {
if let Some(body) =
crate::markdown::parser::post_pass::display_math_block_body(block)
{
self.render_code_block(Some("math"), &body, true, src, CodeCols::Formula, out);
} else {
self.render_paragraph(inlines, src, out, indent_prefix);
}
}
Block::CodeBlock {
language,
content,
fenced,
src,
} => {
self.render_code_block(
language.as_deref(),
content,
*fenced,
src,
CodeCols::PerLine,
out,
);
}
Block::BlockQuote {
blocks,
span,
hidden,
} => {
self.render_blockquote(blocks, span, hidden, out);
}
Block::List {
ordered,
start,
items,
..
} => {
self.render_list(*ordered, *start, items, out, indent_prefix);
}
Block::HorizontalRule { src } => {
out.push(
Line::styled("─".repeat(self.width().max(1)), self.theme.rule),
RowOrigin::chrome(Some(src.first)),
);
}
Block::Table {
col_count,
headers,
rows,
user_widths,
src,
} => {
self.render_table(*col_count, headers, rows, user_widths.as_deref(), src, out);
}
Block::Html(html, src) => {
let prefix_cells = str_cells(indent_prefix);
for (k, line) in html.lines().enumerate() {
let row =
Line::styled(format!("{indent_prefix}{line}"), self.theme.code_block_text);
let origin = verbatim_origin(src, k, prefix_cells).hung_under_indent(&row);
out.push(row, origin);
}
}
Block::HtmlComment(..) => {
}
Block::ImageBlock { alt, url, src } => {
self.render_image_block(alt, url, src, out);
}
Block::MetadataBlock { kind, content, src } => {
self.render_metadata_block(*kind, content, src, out);
}
Block::FootnoteDefinition {
label,
blocks,
span,
hidden,
} => {
self.render_footnote_definition(label, blocks, span, hidden, out);
}
}
debug_assert_eq!(
out.lines.len(),
out.origins.len(),
"rows and origins drifted"
);
}
fn render_metadata_block(
&self,
kind: MetadataKind,
content: &str,
src: &SrcLines,
out: &mut RowSink,
) {
let delim = kind.delimiter();
out.push(
Line::styled(delim.to_string(), self.theme.frontmatter_delimiter),
RowOrigin::chrome(Some(src.first)),
);
let mut k = 0;
for line in content.lines() {
k += 1;
let row = self.metadata_line(kind, line);
let origin = verbatim_origin(src, k, 0).hung_under_indent(&row);
out.push(row, origin);
}
out.push(
Line::styled(delim.to_string(), self.theme.frontmatter_delimiter),
RowOrigin::chrome(Some(line_at(src, k + 1))),
);
}
fn metadata_line(&self, kind: MetadataKind, line: &str) -> Line<'static> {
let sep = match kind {
MetadataKind::Yaml => ':',
MetadataKind::Toml => '=',
};
if let Some(idx) = metadata_key_end(line, sep) {
let (key, rest) = line.split_at(idx);
return Line::from(vec![
Span::styled(key.to_string(), self.theme.frontmatter_key),
Span::styled(rest.to_string(), self.theme.frontmatter_value),
]);
}
Line::styled(line.to_string(), self.theme.frontmatter_value)
}
fn render_footnote_definition(
&self,
label: &str,
blocks: &[Block],
span: &LineSpan,
hidden: &[u32],
out: &mut RowSink,
) {
let leader = format!(" {label}. ");
let shift = str_cells(&leader);
let mut body = RowSink::default();
self.inset_by(shift, || {
render_children(&mut body, blocks, None, hidden, |_, b, body| {
self.render_block(b, body, "", false);
});
});
let cont_indent = spaces(shift);
let back = " ↩";
if body.is_empty() {
out.push(
Line::from(vec![
Span::styled(leader, self.theme.footnote),
Span::styled(back.to_string(), self.theme.footnote),
]),
RowOrigin::chrome(Some(span.start)),
);
return;
}
let last = body.len() - 1;
for (i, (line, origin)) in body.into_rows().enumerate() {
let mut spans: Vec<Span<'static>> = Vec::new();
if i == 0 {
spans.push(Span::styled(leader.clone(), self.theme.footnote));
} else {
spans.push(Span::styled(cont_indent.clone(), self.theme.footnote));
}
spans.extend(line.spans);
if i == last {
spans.push(Span::styled(back.to_string(), self.theme.footnote));
}
out.push(Line::from(spans), origin.shifted(shift));
}
}
fn render_image_block(&self, alt: &str, url: &str, src: &SrcLines, out: &mut RowSink) {
let name = if alt.trim().is_empty() {
link_fallback(url)
} else {
alt.to_owned()
};
let placeholder = Line::from(vec![
Span::styled(format!("[{}", IMAGE_PREFIX), self.theme.image_placeholder),
Span::styled(
name,
self.theme
.image_placeholder
.add_modifier(Modifier::UNDERLINED),
),
Span::styled("]", self.theme.image_placeholder),
]);
let ordinal = self.image_block_seq.get();
self.image_block_seq.set(ordinal + 1);
let rows = self
.image_row_override
.and_then(|f| f(url, ordinal))
.unwrap_or(self.image_max_height)
.max(1);
let last_line = src.len().saturating_sub(1);
let origin = |k: usize| {
let line = line_at(src, k.min(last_line));
let end = if k + 1 == rows {
line_at(src, src.len()).max(line + 1)
} else {
line + 1
};
RowOrigin {
lines: Some(line..end),
cols: super::row_origin::ColOrigin::Chrome,
hang: 0,
}
};
out.push(placeholder, origin(0));
for k in 1..rows {
out.push(Line::raw(""), origin(k));
}
}
fn render_heading(
&self,
level: pulldown_cmark::HeadingLevel,
inlines: &[Inline],
src: &SrcLines,
top_level: bool,
out: &mut RowSink,
) {
use pulldown_cmark::HeadingLevel::*;
let setext = src.len() >= 2;
let text_lines = if setext { src.len() - 1 } else { 1 };
let underline = setext.then(|| line_at(src, text_lines));
let rule_origin = RowOrigin::chrome(Some(underline.unwrap_or(src.first)));
if level == H1 && self.big_h1 {
let glyph_origin = RowOrigin {
lines: Some(src.first..line_at(src, text_lines)),
cols: super::row_origin::ColOrigin::Chrome,
hang: 0,
};
if self.try_render_h1_big(inlines, &glyph_origin, &rule_origin, out) {
return;
}
}
let prefix = match level {
H1 => " ",
H2 => " ",
H3 => " ",
H4 => " ",
H5 => " ",
H6 => " ",
};
let style = self.theme.heading_style(level);
let prefix_style = style.remove_modifier(Modifier::UNDERLINED | Modifier::CROSSED_OUT);
let mut spans = vec![Span::styled(prefix, prefix_style)];
spans.extend(self.render_inlines(&collapse_soft_breaks(inlines), style));
let kind = if text_lines > 1 {
ContentKind::Flow
} else {
ContentKind::Inline
};
let text_origin = match src.col(0) {
Some(raw_col) => RowOrigin::content(
src.first..line_at(src, text_lines),
raw_col,
to_u32(str_cells(prefix)),
kind,
),
None => RowOrigin::chrome(Some(src.first)),
};
out.push(Line::from(spans), text_origin);
if level == H1 {
out.push(
Line::styled("─".repeat(self.width().max(1)), self.theme.h1_rule),
rule_origin,
);
} else if level == H2 && top_level && setext {
out.push(
Line::styled("─".repeat(self.width().max(1)), self.theme.rule),
rule_origin,
);
}
}
fn try_render_h1_big(
&self,
inlines: &[Inline],
glyph_origin: &RowOrigin,
rule_origin: &RowOrigin,
out: &mut RowSink,
) -> bool {
const GLYPH_W_PER_CHAR: usize = 4;
const GLYPH_H: u16 = 2;
const MAX_WRAPPED_LINES: usize = 2;
let plain = inlines_to_plain(inlines);
let normalized = normalise_for_big_text(plain.trim());
if normalized.is_empty() || !normalized.is_ascii() {
return false;
}
let viewport = self.width().max(1);
let max_chars = viewport / GLYPH_W_PER_CHAR;
if max_chars == 0 {
return false;
}
let chunks = match word_wrap_for_big_text(&normalized, max_chars) {
Some(c) if c.len() <= MAX_WRAPPED_LINES => c,
_ => return false,
};
let bg_style = Style::default().bg(self.theme.palette.bg);
let text_style = self
.theme
.h1
.remove_modifier(Modifier::UNDERLINED)
.bg(self.theme.palette.bg);
let shadow_color = self.theme.palette.bg_muted;
let blank_spacer = Line::styled(" ".repeat(viewport), bg_style);
for (chunk_idx, chunk) in chunks.iter().enumerate() {
if chunk_idx > 0 {
out.push(blank_spacer.clone(), glyph_origin.clone());
}
let chunk_glyph_w = chunk.len() * GLYPH_W_PER_CHAR;
let area = Rect::new(0, 0, viewport as u16, GLYPH_H);
let mut buf = Buffer::empty(area);
buf.set_style(area, bg_style);
let big = BigText::builder()
.pixel_size(PixelSize::Octant)
.style(text_style)
.centered()
.lines(vec![Line::from(chunk.clone())])
.build();
big.render(area, &mut buf);
let bottom = GLYPH_H - 1;
let glyph_start_x = (viewport.saturating_sub(chunk_glyph_w)) / 2;
for (i, ch) in chunk.chars().enumerate() {
if ch == ' ' {
continue;
}
let cell_start = glyph_start_x + i * GLYPH_W_PER_CHAR;
for x in cell_start..cell_start + GLYPH_W_PER_CHAR {
buf[(x as u16, bottom)].set_bg(shadow_color);
}
}
for y in 0..GLYPH_H {
out.push(buffer_row_to_line(&buf, y), glyph_origin.clone());
}
}
out.push(
Line::styled("─".repeat(viewport), self.theme.h1_rule),
rule_origin.clone(),
);
true
}
fn render_paragraph(
&self,
inlines: &[Inline],
src: &SrcLines,
out: &mut RowSink,
indent_prefix: &str,
) {
let reflow = paragraph_reflows(self.reflow_paragraphs, inlines);
let prefix = if indent_prefix.bytes().all(|b| b == b' ') {
spaces(indent_prefix.len())
} else {
Cow::Owned(indent_prefix.to_owned())
};
let inlines = paragraph_row_inlines(inlines, reflow);
let mut rows =
paragraph_rows(&inlines, src, reflow, to_u32(str_cells(indent_prefix))).peekable();
while let Some((segment, origin)) = rows.next() {
let mut spans: Vec<Span<'static>> = Vec::new();
if !prefix.is_empty() {
spans.push(Span::raw(prefix.clone()));
}
spans.extend(self.render_inlines(segment, Style::default()));
#[allow(clippy::nonminimal_bool)]
let keep = rows.peek().is_some()
|| (!spans.is_empty() && !(spans.len() == 1 && spans[0].content.trim().is_empty()));
if keep {
out.push(Line::from(spans), origin);
}
}
}
fn code_body_row(&self, text: &str, tokens: &[Token], block_width: usize) -> Line<'static> {
let base = self.theme.code_block_text;
let fill = block_width
.saturating_sub(CODE_PAD_COLS)
.saturating_sub(str_cells(text));
if tokens.is_empty() {
return Line::styled(format!(" {text}{}", " ".repeat(fill)), base);
}
let chars: Vec<char> = text.chars().collect();
let mut spans: Vec<Span<'static>> = Vec::with_capacity(tokens.len() * 2 + 2);
spans.push(Span::styled(spaces(CODE_PAD_COLS), base));
let run = |from: usize, to: usize| -> String { chars[from..to].iter().collect() };
let mut col = 0usize;
for token in tokens {
let start = token.range.start.min(chars.len()).max(col);
let end = token.range.end.min(chars.len()).max(start);
if col < start {
spans.push(Span::styled(run(col, start), base));
}
if start < end {
let style = base.patch(highlight::style_for(self.theme, token.class));
spans.push(Span::styled(run(start, end), style));
}
col = end;
}
if col < chars.len() {
spans.push(Span::styled(run(col, chars.len()), base));
}
if fill > 0 {
spans.push(Span::styled(spaces(fill), base));
}
Line::from(spans).style(base)
}
fn render_code_block(
&self,
language: Option<&str>,
content: &str,
fenced: bool,
src: &SrcLines,
cols: CodeCols,
out: &mut RowSink,
) {
let mut raw_lines: Vec<&str> = content.split('\n').collect();
if raw_lines.last() == Some(&"") {
raw_lines.pop();
}
let block_width = self.width().max(1);
let body_first = usize::from(fenced);
let first_col = src.col(0);
let body_origin = |i: usize, chunk_start: usize| -> RowOrigin {
let k = body_first + i;
if k >= src.len() {
return RowOrigin::chrome(src.span().end.checked_sub(1));
}
let col = match cols {
CodeCols::PerLine => src.col(k),
CodeCols::Formula => src.col(k).or(first_col),
};
let line = line_at(src, k);
match col {
Some(c) => RowOrigin::content(
line..line.saturating_add(1),
c.saturating_add(to_u32(chunk_start)),
to_u32(CODE_PAD_COLS),
ContentKind::Verbatim,
),
None => RowOrigin::chrome(Some(line)),
}
};
if fenced {
let fence = RowOrigin::chrome(Some(src.first));
if let Some(lang) = language {
out.push(
Line::styled(format!(" {} ", lang), self.theme.code_block_lang),
fence,
);
} else {
let padded = nbsps(block_width);
out.push(Line::styled(padded, self.theme.code_block_text), fence);
}
}
let tokens = if self.syntax_highlighting {
highlight::highlight_block(language, &raw_lines)
} else {
Vec::new()
};
let row_tokens = |i: usize| tokens.get(i).map(Vec::as_slice).unwrap_or(&[]);
if self.code_wrap {
let chunk = self.width().saturating_sub(CODE_PAD_COLS).max(1);
for (i, line) in raw_lines.iter().enumerate() {
let chars: Vec<char> = line.chars().collect();
if chars.is_empty() {
let padded = nbsps(block_width);
out.push(
Line::styled(padded, self.theme.code_block_text),
body_origin(i, 0),
);
continue;
}
for range in table_layout::hard_wrap_ranges(&chars, chunk) {
let (start, end) = (range.start, range.end);
let slice: String = chars[range].iter().collect();
let seg = highlight::slice_tokens(row_tokens(i), start, end);
let row = self.code_body_row(&slice, &seg, block_width);
let origin = body_origin(i, start).hung_under_indent(&row);
out.push(row, origin);
}
}
} else {
for (i, line) in raw_lines.iter().enumerate() {
if line.is_empty() {
let padded = nbsps(block_width);
out.push(
Line::styled(padded, self.theme.code_block_text),
body_origin(i, 0),
);
} else {
let row = self.code_body_row(line, row_tokens(i), block_width);
let origin = body_origin(i, 0).hung_under_indent(&row);
out.push(row, origin);
}
}
}
if fenced {
let padded = nbsps(block_width);
let k = body_first + raw_lines.len();
let closing = (k < src.len()).then(|| line_at(src, k));
out.push(
Line::styled(padded, self.theme.code_block_text),
RowOrigin::chrome(closing),
);
}
}
fn render_blockquote(
&self,
blocks: &[Block],
span: &LineSpan,
hidden: &[u32],
out: &mut RowSink,
) {
let bar_cells = str_cells("▎ ");
let mut inner = RowSink::default();
self.on_surface(self.theme.blockquote_text.bg, || {
self.inset_by(bar_cells, || {
render_children(&mut inner, blocks, Some(span), hidden, |_, block, inner| {
self.render_block(block, inner, "", false);
});
})
});
for (line, origin) in inner.into_rows() {
let base = self.theme.blockquote_text.patch(line.style);
let bar = Span::styled("▎ ", base.patch(self.theme.blockquote_bar));
let mut spans = vec![bar];
for span in line.spans {
let content = span.content.into_owned();
spans.push(Span::styled(content, base.patch(span.style)));
}
out.push(Line::from(spans).style(base), origin.shifted(bar_cells));
}
}
fn rendered_inline_width(&self, inline: &Inline) -> usize {
match inline {
Inline::Text(t) | Inline::Html(t) => str_cells(t),
Inline::Bold(inner)
| Inline::Italic(inner)
| Inline::Strikethrough(inner)
| Inline::Highlight(inner) => self.rendered_inlines_width(inner),
Inline::Code(c) => str_cells(c),
Inline::Link { text, url, .. } => {
let text_width = self.rendered_inlines_width(text);
if text_width == 0 {
str_cells(&link_fallback(url))
} else {
text_width
}
}
Inline::Image { alt, url } => {
let name_width = if alt.trim().is_empty() {
str_cells(&link_fallback(url))
} else {
str_cells(alt)
};
str_cells(IMAGE_PREFIX) + name_width + 2
}
Inline::HtmlComment(_) => 0,
Inline::FootnoteReference { label } => {
let marker = reference_marker(std::iter::once(label.as_str()));
str_cells(&marker)
}
Inline::Math { source, display } => {
let delim = if *display { "$$" } else { "$" };
str_cells(delim) + str_cells(source) + str_cells(delim)
}
Inline::SoftBreak | Inline::HardBreak => 1,
}
}
pub(super) fn rendered_inlines_width(&self, inlines: &[Inline]) -> usize {
let mut total = 0;
let mut i = 0;
while i < inlines.len() {
if let Some((marker, run_len)) = footnote_run_at(inlines, i) {
total += str_cells(&marker);
i += run_len;
continue;
}
total += self.rendered_inline_width(&inlines[i]);
i += 1;
}
total
}
pub(super) fn render_inlines(&self, inlines: &[Inline], base: Style) -> Vec<Span<'static>> {
let mut out: Vec<Span<'static>> = Vec::new();
let mut i = 0;
while i < inlines.len() {
if let Some((marker, run_len)) = footnote_run_at(inlines, i) {
out.push(Span::styled(marker, base.patch(self.theme.footnote)));
i += run_len;
continue;
}
if i > 0 && link_at_edge(&inlines[i - 1], true) && link_at_edge(&inlines[i], false) {
out.push(Span::raw(""));
}
out.extend(self.render_inline(&inlines[i], base));
i += 1;
}
out
}
fn render_inline(&self, inline: &Inline, base: Style) -> Vec<Span<'static>> {
match inline {
Inline::Text(text) | Inline::Html(text) => vec![Span::styled(text.clone(), base)],
Inline::Bold(inner) => {
let style = base.patch(self.theme.bold);
self.render_inlines(inner, style)
}
Inline::Italic(inner) => {
let style = base.patch(self.theme.italic);
self.render_inlines(inner, style)
}
Inline::Strikethrough(inner) => {
let style = base.patch(self.theme.strikethrough);
self.render_inlines(inner, style)
}
Inline::Highlight(inner) => {
let style = base.patch(self.theme.highlight);
self.render_inlines(inner, style)
}
Inline::Code(code) => {
let style = if base.add_modifier.contains(Modifier::CROSSED_OUT) {
self.theme.code_span_dim.add_modifier(Modifier::CROSSED_OUT)
} else {
self.theme.code_span
};
vec![Span::styled(code.clone(), style)]
}
Inline::Link { text, url, .. } => {
let style = link_style_for(url, self.theme);
if shows_fallback(text) {
vec![Span::styled(link_fallback(url), style)]
} else {
self.render_inlines(text, style)
}
}
Inline::Image { alt, url } => {
let name = if alt.trim().is_empty() {
link_fallback(url)
} else {
alt.clone()
};
vec![
Span::styled(format!("[{}", IMAGE_PREFIX), self.theme.image_placeholder),
Span::styled(
name,
self.theme
.image_placeholder
.add_modifier(Modifier::UNDERLINED),
),
Span::styled("]", self.theme.image_placeholder),
]
}
Inline::HtmlComment(_) => {
Vec::new()
}
Inline::FootnoteReference { label } => {
vec![Span::styled(
reference_marker(std::iter::once(label.as_str())),
base.patch(self.theme.footnote),
)]
}
Inline::Math { source, display } => {
let delim = if *display { "$$" } else { "$" };
vec![Span::styled(
format!("{delim}{source}{delim}"),
base.patch(self.theme.code_span),
)]
}
Inline::SoftBreak => vec![Span::styled(" ", base)],
Inline::HardBreak => {
vec![Span::styled(" ", base)]
}
}
}
}
pub(super) fn paragraph_rows<'a>(
inlines: &'a [Inline],
src: &'a SrcLines,
join: bool,
prefix_cells: u32,
) -> impl Iterator<Item = (&'a [Inline], RowOrigin)> + 'a {
let len = src.len();
let next_start = move |from: usize| (from..len).find(|&k| src.col(k).is_some());
let mut line = 0usize;
inlines
.split(move |i| !join && matches!(i, Inline::HardBreak | Inline::SoftBreak))
.map(move |segment| {
if join {
let raw_col = (0..len).find_map(|k| src.col(k)).unwrap_or(0);
let origin =
RowOrigin::content(src.span(), raw_col, prefix_cells, ContentKind::Flow);
return (segment, origin);
}
let Some(k) = next_start(line) else {
line = len;
return (segment, RowOrigin::chrome(src.span().end.checked_sub(1)));
};
let end = next_start(k + 1).unwrap_or(len);
line = end;
let kind = if end - k > 1 {
ContentKind::Flow
} else {
ContentKind::Inline
};
let raw_col = src.col(k).unwrap_or(0);
let lines = line_at(src, k)..line_at(src, end);
(
segment,
RowOrigin::content(lines, raw_col, prefix_cells, kind),
)
})
}
pub(super) fn push_gap_rows(out: &mut RowSink, lines: std::ops::Range<u32>, hidden: &[u32]) {
for line in lines.filter(|line| !hidden.contains(line)) {
out.push(Line::from(""), RowOrigin::chrome(Some(line)));
}
}
pub(super) fn render_children(
out: &mut RowSink,
blocks: &[Block],
outer: Option<&LineSpan>,
hidden: &[u32],
mut render: impl FnMut(usize, &Block, &mut RowSink),
) {
let mut next_line = outer.map(|span| span.start);
for (i, block) in blocks.iter().enumerate() {
let span = block.span();
if let Some(line) = next_line {
push_gap_rows(out, line..span.start, hidden);
}
render(i, block, out);
next_line = Some(next_line.map_or(span.end, |line| line.max(span.end)));
}
if let Some(outer) = outer {
push_gap_rows(out, next_line.unwrap_or(outer.start)..outer.end, hidden);
}
}
pub(crate) fn paragraph_reflows(reflow: bool, inlines: &[Inline]) -> bool {
reflow && !holds(inlines, |i| matches!(i, Inline::HardBreak))
}
pub(crate) fn paragraph_row_inlines(inlines: &[Inline], join: bool) -> Cow<'_, [Inline]> {
if join {
collapse_soft_breaks(inlines)
} else {
split_at_breaks(inlines)
}
}
pub(crate) fn collapse_soft_breaks(inlines: &[Inline]) -> Cow<'_, [Inline]> {
if !holds(inlines, |i| matches!(i, Inline::SoftBreak)) {
return Cow::Borrowed(inlines);
}
let mut scan = BreakScan::default();
scan.walk(inlines);
if !scan.vanishing.contains(&true) {
return Cow::Borrowed(inlines);
}
let mut vanishing = scan.vanishing.into_iter();
Cow::Owned(drop_vanishing(inlines, &mut vanishing))
}
#[derive(Default)]
struct BreakScan {
vanishing: Vec<bool>,
last: Option<char>,
pending: Option<(usize, char)>,
}
impl BreakScan {
fn walk(&mut self, inlines: &[Inline]) {
for inline in inlines {
match inline {
Inline::Text(t) | Inline::Html(t) | Inline::Code(t) => self.text(t),
Inline::Bold(inner)
| Inline::Italic(inner)
| Inline::Strikethrough(inner)
| Inline::Highlight(inner) => self.walk(inner),
Inline::Link { text, url, .. } => {
if shows_fallback(text) {
self.text(&link_fallback(url));
} else {
self.walk(text);
}
}
Inline::Image { .. } | Inline::FootnoteReference { .. } => self.text("[]"),
Inline::Math { .. } => self.text("$"),
Inline::HtmlComment(_) => {}
Inline::HardBreak => self.text(" "),
Inline::SoftBreak => {
self.pending = self.last.map(|before| (self.vanishing.len(), before));
self.vanishing.push(false);
self.last = Some(' ');
}
}
}
}
fn text(&mut self, text: &str) {
let Some(first) = text.chars().next() else {
return;
};
if let Some((k, before)) = self.pending.take() {
self.vanishing[k] = segment_break::vanishes(before, first);
}
self.last = text.chars().next_back();
}
}
fn drop_vanishing(inlines: &[Inline], vanishing: &mut impl Iterator<Item = bool>) -> Vec<Inline> {
let mut out = Vec::with_capacity(inlines.len());
for inline in inlines {
match inline {
Inline::SoftBreak => {
if vanishing.next() != Some(true) {
out.push(Inline::SoftBreak);
}
}
Inline::Link { text, .. } if shows_fallback(text) => out.push(inline.clone()),
_ => match children(inline).filter(|c| holds(c, |i| matches!(i, Inline::SoftBreak))) {
Some(inner) => out.push(with_children(inline, drop_vanishing(inner, vanishing))),
None => out.push(inline.clone()),
},
}
}
out
}
fn shows_fallback(text: &[Inline]) -> bool {
text.iter().all(|inline| match inline {
Inline::Text(t) | Inline::Html(t) | Inline::Code(t) => t.trim().is_empty(),
Inline::Image { alt, .. } => alt.trim().is_empty(),
Inline::Bold(inner)
| Inline::Italic(inner)
| Inline::Strikethrough(inner)
| Inline::Highlight(inner)
| Inline::Link { text: inner, .. } => shows_fallback(inner),
Inline::Math { .. } => false,
Inline::HtmlComment(_)
| Inline::FootnoteReference { .. }
| Inline::SoftBreak
| Inline::HardBreak => true,
})
}
pub(crate) fn split_at_breaks(inlines: &[Inline]) -> Cow<'_, [Inline]> {
if !inlines
.iter()
.any(|i| children(i).is_some_and(|c| holds(c, is_break)))
{
return Cow::Borrowed(inlines);
}
let mut out = Vec::with_capacity(inlines.len() + 2);
for inline in inlines {
push_split(inline, &mut out);
}
Cow::Owned(out)
}
fn push_split(inline: &Inline, out: &mut Vec<Inline>) {
let Some(inner) = children(inline).filter(|c| holds(c, is_break)) else {
out.push(inline.clone());
return;
};
if matches!(inline, Inline::Link { text, .. } if shows_fallback(text)) {
push_breaks(inner, out);
out.push(with_children(inline, Vec::new()));
return;
}
let mut flat = Vec::with_capacity(inner.len() + 2);
for child in inner {
push_split(child, &mut flat);
}
let mut half = Vec::new();
for child in flat {
if is_break(&child) {
if !half.is_empty() {
out.push(with_children(inline, std::mem::take(&mut half)));
}
out.push(child);
} else {
half.push(child);
}
}
if !half.is_empty() {
out.push(with_children(inline, half));
}
}
fn push_breaks(inlines: &[Inline], out: &mut Vec<Inline>) {
for inline in inlines {
if is_break(inline) {
out.push(inline.clone());
} else if let Some(inner) = children(inline) {
push_breaks(inner, out);
}
}
}
fn is_break(inline: &Inline) -> bool {
matches!(inline, Inline::SoftBreak | Inline::HardBreak)
}
fn holds(inlines: &[Inline], pred: impl Fn(&Inline) -> bool + Copy) -> bool {
inlines
.iter()
.any(|i| pred(i) || children(i).is_some_and(|c| holds(c, pred)))
}
fn link_at_edge(inline: &Inline, end: bool) -> bool {
match inline {
Inline::Link { .. } => true,
_ => children(inline)
.and_then(|c| if end { c.last() } else { c.first() })
.is_some_and(|c| link_at_edge(c, end)),
}
}
fn children(inline: &Inline) -> Option<&[Inline]> {
match inline {
Inline::Bold(inner)
| Inline::Italic(inner)
| Inline::Strikethrough(inner)
| Inline::Highlight(inner)
| Inline::Link { text: inner, .. } => Some(inner),
_ => None,
}
}
fn with_children(like: &Inline, inner: Vec<Inline>) -> Inline {
match like {
Inline::Bold(_) => Inline::Bold(inner),
Inline::Italic(_) => Inline::Italic(inner),
Inline::Strikethrough(_) => Inline::Strikethrough(inner),
Inline::Highlight(_) => Inline::Highlight(inner),
Inline::Link { url, title, .. } => Inline::Link {
text: inner,
url: url.clone(),
title: title.clone(),
},
leaf => unreachable!("`children` has no container {leaf:?}"),
}
}
#[derive(Debug, Clone, Copy)]
enum CodeCols {
PerLine,
Formula,
}
fn line_at(src: &SrcLines, k: usize) -> u32 {
src.first.saturating_add(to_u32(k))
}
fn verbatim_origin(src: &SrcLines, k: usize, prefix_cells: usize) -> RowOrigin {
let line = line_at(src, k);
match src.col(k) {
Some(c) => RowOrigin::content(
line..line.saturating_add(1),
c,
to_u32(prefix_cells),
ContentKind::Verbatim,
),
None => RowOrigin::chrome(Some(line)),
}
}
pub(crate) fn reference_marker<'a>(labels: impl IntoIterator<Item = &'a str>) -> String {
let mut out = String::from("[");
for (i, label) in labels.into_iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(label);
}
out.push(']');
out
}
fn footnote_run_at(inlines: &[Inline], start: usize) -> Option<(String, usize)> {
if !matches!(inlines.get(start), Some(Inline::FootnoteReference { .. })) {
return None;
}
let labels: Vec<&str> = inlines[start..]
.iter()
.map_while(|inline| match inline {
Inline::FootnoteReference { label } => Some(label.as_str()),
_ => None,
})
.collect();
let run_len = labels.len();
Some((reference_marker(labels), run_len))
}
fn normalise_for_big_text(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'\u{2014}' | '\u{2013}' => out.push('-'), '\u{2026}' => out.push_str("..."), '\u{2018}' | '\u{2019}' => out.push('\''), '\u{201C}' | '\u{201D}' => out.push('"'), '\u{00A0}' => out.push(' '), other => out.push(other),
}
}
out
}
fn word_wrap_for_big_text(text: &str, max_chars: usize) -> Option<Vec<String>> {
let mut lines: Vec<String> = Vec::new();
let mut current = String::new();
for word in text.split_whitespace() {
if word.chars().count() > max_chars {
return None;
}
let needed = if current.is_empty() {
word.chars().count()
} else {
current.chars().count() + 1 + word.chars().count()
};
if needed <= max_chars {
if !current.is_empty() {
current.push(' ');
}
current.push_str(word);
} else {
lines.push(std::mem::take(&mut current));
current.push_str(word);
}
}
if !current.is_empty() {
lines.push(current);
}
if lines.is_empty() {
None
} else {
Some(lines)
}
}
fn buffer_row_to_line(buf: &Buffer, y: u16) -> Line<'static> {
let width = buf.area.width;
let mut spans: Vec<Span<'static>> = Vec::new();
let mut current = String::new();
let mut current_style: Option<Style> = None;
for x in 0..width {
let cell = &buf[(x, y)];
let style = Style::default()
.fg(cell.fg)
.bg(cell.bg)
.add_modifier(cell.modifier);
match current_style {
Some(prev) if prev == style => current.push_str(cell.symbol()),
_ => {
if let Some(prev) = current_style.take() {
spans.push(Span::styled(std::mem::take(&mut current), prev));
}
current.push_str(cell.symbol());
current_style = Some(style);
}
}
}
if let Some(prev) = current_style {
spans.push(Span::styled(current, prev));
}
Line::from(spans)
}
fn metadata_key_end(line: &str, sep: char) -> Option<usize> {
let idx = line.find(sep)?;
if line[..idx]
.trim_start()
.trim_start_matches("- ")
.trim()
.is_empty()
{
return None;
}
let end = idx + sep.len_utf8();
let rest = &line[end..];
if rest.is_empty() || rest.starts_with(' ') {
Some(end)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Theme;
use crate::markdown::parser::parse;
fn renderer() -> Renderer<'static> {
let theme = Box::leak(Box::new(Theme::default()));
Renderer::new(theme)
}
fn render(md: &str) -> Vec<Line<'static>> {
let blocks = parse(md);
renderer().render(&blocks)
}
#[test]
fn shows_fallback_matches_blank_plain_text() {
let text = |s: &str| Inline::Text(s.into());
let cases: Vec<Vec<Inline>> = vec![
vec![],
vec![text("")],
vec![text(" \u{3000}")],
vec![text("a")],
vec![text(" "), Inline::SoftBreak, text(" ")],
vec![Inline::HardBreak],
vec![Inline::Bold(vec![text(" ")]), Inline::Italic(vec![])],
vec![Inline::Bold(vec![text(" x ")])],
vec![Inline::Code(" ".into())],
vec![Inline::Code("c".into())],
vec![Inline::Html("<b>".into())],
vec![Inline::HtmlComment("<!-- c -->".into())],
vec![Inline::Math {
source: String::new(),
display: false,
}],
];
for inlines in cases {
assert_eq!(
shows_fallback(&inlines),
inlines_to_plain(&inlines).trim().is_empty(),
"{inlines:?}"
);
}
}
#[test]
fn a_math_block_row_without_a_column_takes_the_opening_lines() {
let block = Block::Paragraph {
inlines: vec![Inline::Math {
source: "\nx\ny\n".into(),
display: true,
}],
src: SrcLines::new(0, &[Some(2), Some(1), None, None]),
};
let (rows, _) = renderer().render_with_counts(&[block]);
let raw_col = |row: usize| match &rows.origins[row].cols {
crate::markdown::row_origin::ColOrigin::Content { raw_col, .. } => *raw_col,
other => panic!("row {row} isn't content: {other:?}"),
};
assert_eq!((raw_col(1), raw_col(2)), (1, 2), "{:?}", rows.origins);
}
#[test]
fn cached_render_matches_uncached() {
let src = "# Title\n\nSome **bold** prose.\n\n- a\n- b\n\n\
| x | y |\n|---|---|\n| 1 | 2 |\n\n```\ncode\n```\n";
let blocks = parse(src);
let r = renderer().with_viewport_width(60).with_row_striping(true);
let (plain_lines, plain_counts) = r.render_with_counts(&blocks);
let mut cache = RenderCache::default();
let cold = r.render_with_counts_cached(&blocks, &mut cache);
let warm = r.render_with_counts_cached(&blocks, &mut cache);
assert_eq!(cold.0, plain_lines);
assert_eq!(cold.1, plain_counts);
assert_eq!(warm.0, plain_lines);
assert_eq!(warm.1, plain_counts);
}
#[test]
fn cache_evicts_dropped_blocks_and_shares_duplicates() {
let r = renderer();
let mut cache = RenderCache::default();
let first = parse("```\nalpha\n```\n\n```\nbeta\n```\n\n```\nalpha\n```\n");
assert_eq!(first.len(), 3, "two duplicates plus one distinct block");
r.render_with_counts_cached(&first, &mut cache);
assert_eq!(cache.entries.len(), 2, "duplicates share one entry");
let second = parse("```\nbeta\n```\n\n```\ngamma\n```\n");
r.render_with_counts_cached(&second, &mut cache);
assert_eq!(cache.entries.len(), 2);
assert!(!cache
.entries
.keys()
.any(|b| matches!(b, Block::CodeBlock { content, .. } if content.trim() == "alpha")));
}
#[test]
fn cache_cleared_on_settings_change() {
let blocks = parse("```\ncode\n```\n");
let mut cache = RenderCache::default();
let narrow = renderer().with_viewport_width(40);
let narrow_lines = narrow
.render_with_counts_cached(&blocks, &mut cache)
.0
.lines;
let wide = renderer().with_viewport_width(120);
let wide_lines = wide.render_with_counts_cached(&blocks, &mut cache).0.lines;
assert_ne!(
narrow_lines, wide_lines,
"block must re-render at new width"
);
assert_eq!(wide_lines, wide.render(&blocks));
}
#[test]
fn cache_cleared_when_syntax_highlighting_toggles() {
let src = "```rust\nfn main() {}\n```\n";
warm_fence_languages(src);
let blocks = parse(src);
let mut cache = RenderCache::default();
let off = renderer().with_syntax_highlighting(false);
let off_lines = off.render_with_counts_cached(&blocks, &mut cache).0.lines;
let on = renderer().with_syntax_highlighting(true);
let on_lines = on.render_with_counts_cached(&blocks, &mut cache).0.lines;
assert_ne!(off_lines, on_lines, "the toggle must re-render the block");
assert_eq!(on_lines, on.render(&blocks));
let off_again = off.render_with_counts_cached(&blocks, &mut cache).0.lines;
assert_eq!(off_again, off_lines);
}
#[test]
fn image_blocks_bypass_cache() {
let blocks = vec![Block::ImageBlock {
alt: "a".into(),
url: "img.png".into(),
src: Default::default(),
}];
let mut cache = RenderCache::default();
renderer().render_with_counts_cached(&blocks, &mut cache);
assert!(cache.entries.is_empty());
}
#[test]
fn cheap_blocks_bypass_cache() {
let blocks = parse("# Heading\n\nplain **prose** here.\n\n- a\n- b\n\n---\n");
let mut cache = RenderCache::default();
renderer().render_with_counts_cached(&blocks, &mut cache);
assert!(
cache.entries.is_empty(),
"no Table or CodeBlock present, so nothing is cache-worthy: {:?}",
cache.entries.keys().collect::<Vec<_>>()
);
}
#[test]
fn is_cache_worthy_follows_nested_expensive_content() {
use crate::markdown::ast::ListItem;
let code = || Block::CodeBlock {
language: None,
content: "x\n".into(),
fenced: true,
src: Default::default(),
};
let table = || Block::Table {
col_count: 1,
headers: vec![vec![]],
rows: vec![],
user_widths: None,
src: Default::default(),
};
let item = |blocks| ListItem {
blocks,
task: None,
span: Default::default(),
hidden: vec![],
};
let list = |items| Block::List {
ordered: false,
start: None,
items,
span: Default::default(),
};
assert!(is_cache_worthy(&code()));
assert!(is_cache_worthy(&table()));
assert!(is_cache_worthy(&Block::BlockQuote {
blocks: vec![table()],
span: Default::default(),
hidden: vec![],
}));
assert!(is_cache_worthy(&list(vec![item(vec![code()])])));
assert!(!is_cache_worthy(&Block::Paragraph {
inlines: vec![],
src: Default::default(),
}));
assert!(!is_cache_worthy(&Block::HorizontalRule {
src: Default::default()
}));
assert!(!is_cache_worthy(&Block::BlockQuote {
blocks: vec![Block::Paragraph {
inlines: vec![],
src: Default::default(),
}],
span: Default::default(),
hidden: vec![],
}));
assert!(!is_cache_worthy(&list(vec![item(vec![
Block::Paragraph {
inlines: vec![],
src: Default::default(),
}
])])));
}
#[test]
fn heading_produces_lines() {
let lines = render("# Hello\n");
assert!(!lines.is_empty());
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("Hello"));
}
#[test]
fn soft_breaks_split_rows_by_default() {
let lines = renderer()
.with_viewport_width(80)
.render(&parse("one\ntwo\nthree\n"));
assert_eq!(lines.len(), 3);
let texts: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert_eq!(texts, vec!["one", "two", "three"]);
}
#[test]
fn a_blank_link_over_a_break_shows_its_fallback_on_its_last_line() {
for (src, rows) in [
("a [\n](https://x) b\n", &["a ", "https://x b"][..]),
("a **[\n](./d/x.md)** b\n", &["a ", "x.md b"]),
("a [\\\n](https://x) b\n", &["a ", "https://x b"]),
] {
let lines = renderer().with_viewport_width(80).render(&parse(src));
let texts: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert_eq!(texts, rows, "{src:?}");
}
}
#[test]
fn reflow_joins_soft_breaks_into_one_flow() {
let lines = renderer()
.with_viewport_width(80)
.with_reflow_paragraphs(true)
.render(&parse("one\ntwo\nthree\n"));
assert_eq!(lines.len(), 1, "soft-broken lines should reflow to one row");
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text.trim_end(), "one two three");
}
#[test]
fn reflow_drops_a_soft_break_between_cjk_glyphs() {
for (src, flow, plain) in [
("日本語の\nリスト\n", "日本語のリスト", None),
("日本\n、続き\n", "日本、続き", None),
("**日本**\n*語*\n", "日本語", None),
("*日\n本*\n", "日本", None),
("[日\n本](u)\n語\n", "日本語", None),
("日本\nabc\n", "日本 abc", None),
("abc\n日本\n", "abc 日本", None),
("한국어\n문장\n", "한국어 문장", None),
(
"日本\n\n",
"日本 [Image: 画像]",
Some("日本画像"),
),
("前`日本\n語`後\n", "前日本 語後", None),
(
"日本\n[\n](https://x)\nabc\n語\n本\n",
"日本 https://x abc 語本",
Some("日本 abc 語本"),
),
] {
let blocks = parse(src);
let lines = renderer()
.with_viewport_width(80)
.with_reflow_paragraphs(true)
.render(&blocks);
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text.trim_end(), flow, "{src:?}");
let Block::Paragraph { inlines, .. } = &blocks[0] else {
panic!("{blocks:?}");
};
assert_eq!(
inlines_to_plain(inlines),
plain.unwrap_or(flow),
"plain text of {src:?}"
);
}
}
#[test]
fn adjacent_links_are_separated_by_an_empty_span() {
for (src, spans) in [
("[日](a)\n[本](b)\n", &["日", "", "本"][..]),
("[a](x)[b](y)\n", &["a", "", "b"]),
("**[a](x)**[b](y)\n", &["a", "", "b"]),
("[a](x)\n[b](y)c\n", &["a", " ", "b", "c"]),
] {
let lines = renderer()
.with_viewport_width(80)
.with_reflow_paragraphs(true)
.render(&parse(src));
let got: Vec<&str> = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(got, spans, "{src:?}");
}
}
#[test]
fn setext_heading_drops_a_soft_break_between_cjk_glyphs() {
for (src, row) in [
("日本\n語\n===\n", " 日本語"),
("日本\nabc\n---\n", " 日本 abc"),
] {
let lines = renderer().with_viewport_width(80).render(&parse(src));
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text.trim_end(), row, "{src:?}");
}
}
#[test]
fn collapse_soft_breaks_borrows_unless_a_break_vanishes() {
let blocks = parse("one\ntwo *three\nfour*\n");
let Block::Paragraph { inlines, .. } = &blocks[0] else {
panic!("{blocks:?}");
};
assert!(matches!(collapse_soft_breaks(inlines), Cow::Borrowed(_)));
let blocks = parse("日本\n語\n");
let Block::Paragraph { inlines, .. } = &blocks[0] else {
panic!("{blocks:?}");
};
assert_eq!(
collapse_soft_breaks(inlines).as_ref(),
[Inline::Text("日本".into()), Inline::Text("語".into())]
);
assert!(matches!(
paragraph_row_inlines(inlines, false),
Cow::Borrowed(_)
));
}
#[test]
fn hard_break_paragraph_falls_back_to_one_row_per_line() {
let lines = renderer()
.with_viewport_width(80)
.with_reflow_paragraphs(true)
.render(&parse("one\ntwo \nthree\n"));
let texts: Vec<String> = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
.trim_end()
.to_string()
})
.collect();
assert_eq!(texts, vec!["one", "two", "three"]);
}
#[test]
fn reflow_joins_nested_paragraphs_into_one_flow_each() {
let lines = renderer()
.with_viewport_width(80)
.with_reflow_paragraphs(true)
.render(&parse(
"- a\n soft\n\n later\n para\n- b\\\n hard\n\n> q\n> more\n\n[^n]: one\n two\n",
));
let texts: Vec<String> = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
.trim_end()
.to_string()
})
.collect();
assert_eq!(
texts,
[
"• a soft",
"",
" later para",
"• b",
" hard",
"▎ q more",
" n. one two ↩",
],
"{texts:?}"
);
}
#[test]
fn an_items_flush_blocks_sit_under_its_text() {
let texts = |src: &str| -> Vec<String> {
renderer()
.with_viewport_width(20)
.render(&parse(src))
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
};
let table = ["┌─────┐", "│ a │", "┝━━━━━┥", "│ 1 │", "└─────┘"];
let indented = |n: usize| table.map(|r| format!("{}{r}", " ".repeat(n)));
let rows = texts("- x\n\n | a |\n |---|\n | 1 |\n");
assert_eq!(rows[2..], indented(2), "under `• `");
let rows = texts("10. | a |\n |---|\n | 1 |\n");
assert_eq!(rows[1..], indented(4), "on a marker line, under `10. `");
let rows = texts("- x\n - y\n\n | a |\n |---|\n | 1 |\n");
assert_eq!(rows[3..], indented(6), "in a nested item");
let rows = texts("- x\n\n ---\n\n > q\n");
assert_eq!(
rows[2],
format!(" {}", "─".repeat(18)),
"a rule fills what's left"
);
assert_eq!(rows[4], " ▎ q");
let rows = texts("- x\n\n ```\n code\n ```\n");
assert!(rows[3].starts_with(" code"), "{:?}", rows[3]);
assert_eq!(rows[3].chars().count(), 20, "the surface ends at the edge");
}
#[test]
fn an_items_indent_stays_off_a_nested_surface() {
let theme = Box::leak(Box::new(Theme::default()));
let wash = theme.blockquote_text.bg;
assert!(wash.is_some(), "the default theme washes quotes");
let rows = Renderer::new(theme).render(&parse("- x\n\n > q\n"));
assert_eq!(rows[2].style.bg, wash, "the quote's own row");
assert_eq!(rows[2].spans[0].content, " ");
assert_eq!(rows[2].spans[0].style.bg, Some(Color::Reset), "the page");
let rows = Renderer::new(theme).render(&parse("> - x\n>\n> ```\n> c\n> ```\n"));
let code = &rows[3];
assert_eq!(code.style.bg, theme.code_block_text.bg, "{code:?}");
assert_eq!(code.spans[1].content, " ", "after the outer bar");
assert_eq!(code.spans[1].style.bg, wash, "the outer quote");
}
#[test]
fn an_items_later_blocks_sit_under_its_text_behind_a_row_per_blank_line() {
let texts = |src: &str| -> Vec<String> {
renderer()
.with_viewport_width(80)
.render(&parse(src))
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
.trim_end()
.to_string()
})
.collect()
};
assert_eq!(texts("- a\n\n b\n"), ["• a", "", " b"]);
assert_eq!(texts("1. a\n\n b\n"), ["1. a", "", " b"]);
assert_eq!(texts("- [ ] a\n\n b\n"), ["• [ ] a", "", " b"]);
assert_eq!(texts("- a\n\n\n b\n"), ["• a", "", "", " b"]);
assert_eq!(texts("- a\n\n - b\n"), ["• a", "", " • b"]);
assert_eq!(texts("- a\n\n [r]: /u\n\n b\n"), ["• a", "", "", " b"]);
assert_eq!(texts("> - a\n>\n> b\n"), ["▎ • a", "▎", "▎ b"]);
assert_eq!(texts("[^n]: a\n\n b\n"), [" n. a", "", " b ↩"]);
assert_eq!(
texts("[^n]: a\n\n [r]: /u\n\n b\n"),
[" n. a", "", "", " b ↩"]
);
assert_eq!(
texts("- x\n - b\n\n c\n - d\n\n - e\n"),
["• x", " • b", "", " c", " • d", "", " • e"]
);
}
#[test]
fn reflow_emits_one_logical_line_wider_than_viewport() {
let lines = renderer()
.with_viewport_width(10)
.with_reflow_paragraphs(true)
.render(&parse("alpha\nbeta\ngamma\ndelta\n"));
assert_eq!(
lines.len(),
1,
"the renderer joins soft breaks into one line; wrapping is downstream"
);
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text.trim_end(), "alpha beta gamma delta");
}
#[test]
fn big_h1_emits_two_glyph_rows_plus_rule() {
let theme = Box::leak(Box::new(Theme::default()));
let r = Renderer::new(theme).with_big_h1(true);
let lines = r.render(&parse("# Hi\n"));
assert_eq!(
lines.len(),
3,
"expected 2 glyph rows + rule, got {}",
lines.len()
);
for (i, line) in lines.iter().take(2).enumerate() {
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.chars().any(|c| !c.is_ascii() && c != ' '),
"row {i} had no block glyph: {text:?}"
);
}
let rule: String = lines[2].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
rule.contains('─'),
"expected rule glyph in last line, got {rule:?}"
);
}
#[test]
fn big_h1_falls_back_for_non_ascii_title() {
let theme = Box::leak(Box::new(Theme::default()));
let r = Renderer::new(theme).with_big_h1(true);
let lines = r.render(&parse("# Héllo\n"));
assert_eq!(lines.len(), 2, "expected plain 2-line H1 fallback");
}
#[test]
fn big_h1_falls_back_for_unbreakable_word_wider_than_viewport() {
let theme = Box::leak(Box::new(Theme::default()));
let r = Renderer::new(theme)
.with_big_h1(true)
.with_viewport_width(80);
let lines = r.render(&parse("# AAAAAAAAAAAAAAAAAAAAA\n"));
assert_eq!(lines.len(), 2, "expected plain 2-line H1 fallback");
}
#[test]
fn big_h1_falls_back_when_more_than_two_wrapped_lines_needed() {
let theme = Box::leak(Box::new(Theme::default()));
let r = Renderer::new(theme)
.with_big_h1(true)
.with_viewport_width(40);
let lines = r.render(&parse("# alphabet beanbags carriers\n"));
assert_eq!(
lines.len(),
2,
"expected plain fallback for 3-line wrap, got {}",
lines.len()
);
}
#[test]
fn big_h1_word_wraps_to_two_big_lines_with_blank_spacer() {
let theme = Box::leak(Box::new(Theme::default()));
let r = Renderer::new(theme)
.with_big_h1(true)
.with_viewport_width(40);
let lines = r.render(&parse("# hello world!\n"));
assert_eq!(
lines.len(),
6,
"expected 2 chunks × 2 glyphs + spacer + rule, got {}",
lines.len()
);
for &i in &[0usize, 1, 3, 4] {
let text: String = lines[i].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.chars().any(|c| !c.is_ascii() && c != ' '),
"glyph row {i} had no block glyph: {text:?}"
);
}
let spacer: String = lines[2].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
!spacer.chars().any(|c| !c.is_ascii() && c != ' '),
"spacer row should be blank, got {spacer:?}"
);
let rule: String = lines[5].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(rule.contains('─'), "expected rule on last line");
}
#[test]
fn big_h1_renders_em_dash_via_ascii_substitution() {
let theme = Box::leak(Box::new(Theme::default()));
let r = Renderer::new(theme).with_big_h1(true);
let lines = r.render(&parse("# A — B\n"));
assert_eq!(
lines.len(),
3,
"em dash should transliterate; got plain fallback ({} lines)",
lines.len()
);
}
#[test]
fn normalise_substitutes_common_typography() {
assert_eq!(normalise_for_big_text("hello — world"), "hello - world");
assert_eq!(normalise_for_big_text("a–b"), "a-b");
assert_eq!(normalise_for_big_text("yes…"), "yes...");
assert_eq!(normalise_for_big_text("‘x’"), "'x'");
assert_eq!(normalise_for_big_text("“x”"), "\"x\"");
assert_eq!(normalise_for_big_text("a\u{00A0}b"), "a b");
assert_eq!(normalise_for_big_text("café"), "café");
}
#[test]
fn big_h1_off_by_default() {
let theme = Box::leak(Box::new(Theme::default()));
let r = Renderer::new(theme);
let lines = r.render(&parse("# Hi\n"));
assert_eq!(lines.len(), 2, "default Renderer should not produce big H1");
}
#[test]
fn paragraph_produces_lines() {
let lines = render("Hello world\n");
assert!(!lines.is_empty());
}
#[test]
fn horizontal_rule_produces_dashes() {
let lines = render("---\n");
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains('─'));
}
#[test]
fn code_block_has_content() {
let lines = render("```\nfoo\n```\n");
let body_text: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(body_text.contains("foo"));
}
fn warm_fence_languages(src: &str) {
for line in src.lines() {
let Some(info) = line.trim_start().strip_prefix("```") else {
continue;
};
if !info.trim().is_empty() {
highlight::warm_inline(Some(info.trim()));
}
}
}
fn render_highlighted(src: &str) -> Vec<Line<'static>> {
warm_fence_languages(src);
let blocks = parse(src);
renderer().with_syntax_highlighting(true).render(&blocks)
}
fn spans_of(line: &Line<'static>) -> Vec<(String, Style)> {
line.spans
.iter()
.map(|s| (s.content.to_string(), s.style))
.collect()
}
fn plain_text(line: &Line<'static>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn a_highlighted_code_block_splits_its_body_into_styled_spans() {
let theme = Theme::default();
let lines = render_highlighted("```rust\nfn main() {}\n```\n");
let spans = spans_of(&lines[1]);
assert!(spans.len() > 1, "body should be tokenized, got {spans:?}");
let keyword = spans.iter().find(|(t, _)| t == "fn").expect("an `fn` span");
assert_eq!(keyword.1.fg, theme.syntax_keyword.fg);
let func = spans
.iter()
.find(|(t, _)| t == "main")
.expect("a `main` span");
assert_eq!(func.1.fg, theme.syntax_function.fg);
}
#[test]
fn token_styles_are_patched_over_the_code_surface() {
let theme = Theme::default();
let lines = render_highlighted("```rust\nfn main() {}\n```\n");
for (text, style) in spans_of(&lines[1]) {
assert_eq!(
style.bg, theme.code_block_text.bg,
"span {text:?} lost the code surface background"
);
}
}
#[test]
fn highlighting_does_not_change_the_text_or_the_row_count() {
let src = "```rust\nfn main() {}\nlet x = 1;\n```\n";
let plain = render(src);
let lit = render_highlighted(src);
assert_eq!(plain.len(), lit.len());
for (a, b) in plain.iter().zip(&lit) {
assert_eq!(plain_text(a), plain_text(b));
}
}
#[test]
fn an_unknown_language_renders_exactly_like_highlighting_off() {
for src in [
"```frobnicate\nfn main() {}\n```\n",
"```\nfn main() {}\n```\n",
" indented code\n",
] {
let plain = render(src);
let lit = render_highlighted(src);
assert_eq!(
plain.iter().map(spans_of).collect::<Vec<_>>(),
lit.iter().map(spans_of).collect::<Vec<_>>(),
"{src:?} should be untouched by highlighting"
);
}
}
#[test]
fn the_language_label_row_keeps_the_whole_info_string() {
let lines = render_highlighted("```rust,ignore\nfn main() {}\n```\n");
assert!(plain_text(&lines[0]).contains("rust,ignore"));
let spans = spans_of(&lines[1]);
assert!(spans.iter().any(|(t, _)| t == "fn"));
}
#[test]
fn a_wrapped_token_keeps_its_style_on_both_rows() {
let theme = Theme::default();
let long = format!("let s = \"{}\";", "x".repeat(60));
let src = format!("```rust\n{long}\n```\n");
warm_fence_languages(&src);
let blocks = parse(&src);
let lines = renderer()
.with_viewport_width(20)
.with_code_wrap(true)
.with_syntax_highlighting(true)
.render(&blocks);
let string_rows = lines
.iter()
.filter(|l| {
spans_of(l)
.iter()
.any(|(t, s)| t.contains('x') && s.fg == theme.syntax_string.fg)
})
.count();
assert!(
string_rows > 1,
"the literal should stay styled across every wrapped row"
);
}
#[test]
fn a_code_row_of_wide_glyphs_pads_to_the_viewport_edge_in_cells() {
for src in [
"```\n日本語のコード\n```\n",
"```rust\nlet 日本 = 1;\n```\n",
] {
warm_fence_languages(src);
let blocks = parse(src);
let lines = renderer()
.with_viewport_width(20)
.with_syntax_highlighting(true)
.render(&blocks);
let body = lines
.iter()
.find(|l| plain_text(l).contains('日'))
.expect("the code row");
assert_eq!(str_cells(&plain_text(body)), 20, "in {src:?}");
}
}
#[test]
fn code_wrap_splits_by_cells_between_clusters() {
let blocks = parse("```\n日本語のコードはとても長い行\n```\n");
let lines = renderer()
.with_viewport_width(10)
.with_code_wrap(true)
.render(&blocks);
let body: Vec<String> = lines
.iter()
.map(plain_text)
.filter(|t| t.chars().any(|c| !c.is_whitespace()))
.collect();
assert_eq!(body.len(), 4, "{body:?}");
assert!(body.iter().all(|t| str_cells(t) == 10), "{body:?}");
let e = "e\u{301}";
let blocks = parse(&format!("```\n{}\n```\n", e.repeat(12)));
let lines = renderer()
.with_viewport_width(10)
.with_code_wrap(true)
.render(&blocks);
assert!(
lines.iter().all(|l| !plain_text(l).starts_with(" \u{301}")),
"a row starts with a stranded accent"
);
}
#[test]
fn a_one_cell_viewport_still_wraps_code_one_char_per_row() {
let blocks = parse("```\nabc\n```\n");
let lines = renderer()
.with_viewport_width(1)
.with_code_wrap(true)
.render(&blocks);
assert_eq!(lines.len(), 5);
}
#[test]
fn blockquote_has_bar() {
let lines = render("> quote\n");
let first_text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(first_text.contains('▎'));
}
#[test]
fn blockquote_blank_line_rendered() {
let lines = render("> first\n>\n> third\n");
assert_eq!(lines.len(), 3, "got {} lines", lines.len());
for line in &lines {
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.starts_with("▎"),
"line did not start with bar: {text:?}"
);
}
let middle: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
middle.trim_start_matches('▎').trim().is_empty(),
"middle line not blank: {middle:?}"
);
}
#[test]
fn soft_break_produces_new_line() {
let lines = render("alpha\nbeta\ngamma\n");
assert_eq!(lines.len(), 3);
let texts: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert_eq!(texts, vec!["alpha", "beta", "gamma"]);
}
fn line_text(line: &Line<'_>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn web_link_shows_only_text() {
let lines = render("[Google](https://google.com)\n");
assert_eq!(line_text(&lines[0]), "Google");
}
#[test]
fn file_link_shows_only_text() {
let lines = render("[Plan](./plan.md)\n");
assert_eq!(line_text(&lines[0]), "Plan");
}
#[test]
fn web_link_without_text_shows_url() {
let lines = render("[](https://google.com)\n");
assert_eq!(line_text(&lines[0]), "https://google.com");
}
#[test]
fn file_link_without_text_shows_filename_only() {
let lines = render("[](/home/mjw/Work/plan.md)\n");
assert_eq!(line_text(&lines[0]), "plan.md");
}
#[test]
fn image_with_alt_shows_alt_prefixed() {
let lines = render("\n");
assert_eq!(line_text(&lines[0]), "[Image: Cat]");
}
#[test]
fn image_without_alt_shows_filename_prefixed() {
let lines = render("\n");
assert_eq!(line_text(&lines[0]), "[Image: me.jpg]");
}
#[test]
fn inline_code_inside_strikethrough_uses_dim_code_style() {
let theme = Box::leak(Box::new(Theme::default()));
let r = Renderer::new(theme);
let blocks = parse("~~before `snippet` after~~\n");
let lines = r.render(&blocks);
let span = lines[0]
.spans
.iter()
.find(|s| s.content.trim() == "snippet")
.expect("code-span span");
assert_eq!(span.style.fg, theme.code_span_dim.fg);
assert!(
span.style.add_modifier.contains(Modifier::CROSSED_OUT),
"code span inside strikethrough should still be struck through"
);
let plain_lines = r.render(&parse("alpha `snippet` beta\n"));
let plain_span = plain_lines[0]
.spans
.iter()
.find(|s| s.content.trim() == "snippet")
.expect("code-span span");
assert_eq!(plain_span.style.fg, theme.code_span.fg);
}
#[test]
fn inline_code_inside_checked_task_item_uses_dim_code_style() {
let theme = Box::leak(Box::new(Theme::default()));
let r = Renderer::new(theme);
let blocks = parse("- [x] do `thing` now\n");
let lines = r.render(&blocks);
let span = lines[0]
.spans
.iter()
.find(|s| s.content.trim() == "thing")
.expect("code-span span");
assert_eq!(span.style.fg, theme.code_span_dim.fg);
assert!(span.style.add_modifier.contains(Modifier::CROSSED_OUT));
}
#[test]
fn link_text_is_underlined() {
let lines = render("[Google](https://google.com)\n");
let span = lines[0]
.spans
.iter()
.find(|s| s.content == "Google")
.expect("link text span");
assert!(span.style.add_modifier.contains(Modifier::UNDERLINED));
}
#[test]
fn table_has_thick_header_separator_and_inter_row_borders() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n";
let lines = render(src);
let texts: Vec<String> = lines.iter().map(line_text).collect();
assert_eq!(texts.len(), 7, "got {texts:#?}");
assert!(texts[0].starts_with('┌'), "top: {:?}", texts[0]);
assert!(
texts[2].starts_with('┝') && texts[2].contains('━') && texts[2].contains('┿'),
"thick sep: {:?}",
texts[2]
);
assert!(
texts[4].starts_with('├') && texts[4].contains('┼'),
"thin sep: {:?}",
texts[4]
);
assert!(texts[6].starts_with('└'), "bottom: {:?}", texts[6]);
}
#[test]
fn table_multirow_cell_preserves_inline_styles() {
let theme = Box::leak(Box::new(Theme::default()));
let r = Renderer::new(theme).with_viewport_width(28);
let blocks = parse(
"| Name | Notes |\n\
|---|---|\n\
| a | This row has a **really** long note |\n",
);
let lines = r.render(&blocks);
let mut found_bold = false;
for line in &lines {
for span in &line.spans {
if span.style.add_modifier.contains(Modifier::BOLD)
&& span.content.contains("really")
{
found_bold = true;
}
}
}
assert!(
found_bold,
"wrapped cell lost the **really** bold styling — multi-row \
rendering must preserve inline formatting (lines: {lines:#?})",
);
}
#[test]
fn table_breaks_long_inline_code_to_fit_viewport() {
let src = "| id | code |\n\
|----|------|\n\
| 1 | `some_extremely_long_identifier_name` |\n";
let lines = renderer().with_viewport_width(30).render(&parse(src));
for line in &lines {
assert!(
line.width() <= 30,
"table must compress to the viewport; overflowing line: {:?}",
line_text(line)
);
}
let texts: Vec<String> = lines.iter().map(line_text).collect();
assert!(
!texts
.iter()
.any(|t| t.contains("some_extremely_long_identifier_name")),
"code span should hard-split across rows: {texts:#?}"
);
let squashed: String = texts
.join("")
.chars()
.filter(|c| !c.is_whitespace() && *c != '│')
.collect();
assert!(
squashed.contains("some_extremely_long_identifier_name"),
"split must preserve every character in order: {texts:#?}"
);
}
#[test]
fn table_code_span_wraps_without_pads() {
let src = "| intro `breakable_code_name` | x |\n\
|---|---|\n\
| a | b |\n";
let lines = renderer().with_viewport_width(25).render(&parse(src));
let texts: Vec<String> = lines.iter().map(line_text).collect();
let joined = texts.join("\n");
assert!(
joined.contains("breakable_") && !joined.contains("breakable_code_name"),
"code span must hard-split across wrap rows: {texts:#?}"
);
assert!(
!joined.contains('\u{00A0}'),
"code spans render without pad cells: {texts:#?}"
);
}
#[test]
fn table_breaks_long_link_to_fit_viewport() {
let src = "| id | link |\n\
|----|------|\n\
| 1 | [see-the-full-reference-document-here](https://example.com) |\n";
let lines = renderer().with_viewport_width(30).render(&parse(src));
for line in &lines {
assert!(
line.width() <= 30,
"table must compress to the viewport; overflowing line: {:?}",
line_text(line)
);
}
}
#[test]
fn width_sized_blocks_fit_inside_their_containers() {
let table = "| alpha beta gamma | delta epsilon zeta eta |\n\
|---|---|\n\
| one two three four | five six seven eight nine |\n";
let body = format!("{table}\n---\n\n```\ncode\n```\n\nend\n");
let quote = |depth: usize| {
let bar = "> ".repeat(depth);
body.lines()
.map(|l| format!("{bar}{l}").trim_end().to_string() + "\n")
.collect::<String>()
};
let footnote = format!(
"x[^n]\n\n[^n]: lead\n\n{}",
body.lines()
.map(|l| format!(" {l}").trim_end().to_string() + "\n")
.collect::<String>()
);
for src in [quote(1), quote(2), footnote] {
let lines = renderer().with_viewport_width(30).render(&parse(&src));
for line in &lines {
assert!(
line.width() <= 30,
"overflowing line in {src:?}: {:?}",
line_text(line)
);
}
let texts: Vec<String> = lines.iter().map(line_text).collect();
assert!(
texts.iter().any(|t| t.ends_with('┐') && str_cells(t) == 30),
"the table should still use the full width available: {texts:#?}"
);
}
}
#[test]
fn table_never_breaks_long_prose_word() {
let src = "| id | word |\n\
|----|------|\n\
| 1 | someextremelylongunbrokenword |\n";
let lines = renderer().with_viewport_width(30).render(&parse(src));
let texts: Vec<String> = lines.iter().map(line_text).collect();
assert!(
texts
.iter()
.any(|t| t.contains("someextremelylongunbrokenword")),
"prose word must stay intact on one row: {texts:#?}"
);
assert!(
lines.iter().any(|l| l.width() > 30),
"table should overflow rather than break prose"
);
}
#[test]
fn ordered_list_right_aligns_numbers_when_double_digit() {
let mut src = String::new();
for i in 1..=12u32 {
src.push_str(&format!("{i}. item {i}\n"));
}
let lines = render(&src);
assert!(
line_text(&lines[0]).starts_with(" 1. "),
"got {:?}",
line_text(&lines[0])
);
assert!(
line_text(&lines[8]).starts_with(" 9. "),
"got {:?}",
line_text(&lines[8])
);
assert!(
line_text(&lines[9]).starts_with("10. "),
"got {:?}",
line_text(&lines[9])
);
}
#[test]
fn nested_ordered_list_aligns_with_source_indent() {
let lines = render("1. outer\n 1. inner\n2. next\n");
assert_eq!(lines.len(), 3);
assert_eq!(line_text(&lines[0]), "1. outer");
assert_eq!(line_text(&lines[1]), " 1. inner");
assert_eq!(line_text(&lines[2]), "2. next");
}
#[test]
fn nested_bullet_list_uses_four_space_indent() {
let lines = render("- outer\n - inner\n- next\n");
assert_eq!(lines.len(), 3);
assert_eq!(line_text(&lines[0]), "• outer");
assert_eq!(line_text(&lines[1]), " • inner");
assert_eq!(line_text(&lines[2]), "• next");
}
#[test]
fn task_items_render_as_bullet_plus_checkbox() {
let lines = render("- [ ] outer\n - [ ] inner\n- [ ] next\n");
assert_eq!(lines.len(), 3);
assert_eq!(line_text(&lines[0]), "• [ ] outer");
assert_eq!(line_text(&lines[1]), " • [ ] inner");
assert_eq!(line_text(&lines[2]), "• [ ] next");
}
#[test]
fn task_and_plain_bullets_coexist_in_one_list() {
let lines = render("- regular\n- [ ] task\n- [x] done\n");
assert_eq!(line_text(&lines[0]), "• regular");
assert_eq!(line_text(&lines[1]), "• [ ] task");
assert_eq!(line_text(&lines[2]), "• [✓] done");
}
#[test]
fn loose_task_list_still_renders_checkboxes() {
let lines = render("- [ ] parent\n\n - [ ] nested\n- [ ] sibling\n");
assert!(
line_text(&lines[0]).starts_with("• [ ] parent"),
"parent should render as a task item with bullet, got {:?}",
line_text(&lines[0])
);
assert!(
line_text(&lines[lines.len() - 1]).starts_with("• [ ] sibling"),
"sibling should render as a task item with bullet, got {:?}",
line_text(&lines[lines.len() - 1])
);
}
#[test]
fn image_name_is_underlined_but_prefix_is_not() {
let lines = render("\n");
let prefix = lines[0]
.spans
.iter()
.find(|s| s.content == "[Image: ")
.expect("prefix span");
let name = lines[0]
.spans
.iter()
.find(|s| s.content == "Cat")
.expect("name span");
assert!(!prefix.style.add_modifier.contains(Modifier::UNDERLINED));
assert!(name.style.add_modifier.contains(Modifier::UNDERLINED));
}
#[test]
fn block_level_html_comment_renders_zero_lines() {
let lines = render("<!-- hidden -->\n");
assert_eq!(lines.len(), 0, "got {} lines: {lines:?}", lines.len());
}
#[test]
fn block_level_html_comment_between_paragraphs_is_invisible() {
let lines = render("alpha\n\n<!-- hidden -->\n\nbeta\n");
let texts: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert!(
!texts.iter().any(|t| t.contains("<!--")),
"comment leaked: {texts:?}"
);
assert!(texts.iter().any(|t| t.contains("alpha")));
assert!(texts.iter().any(|t| t.contains("beta")));
}
#[test]
fn inline_html_comment_is_hidden_from_paragraph() {
let lines = render("before <!-- inline --> after\n");
assert_eq!(lines.len(), 1);
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(!text.contains("<!--"), "got {text:?}");
assert!(text.contains("before"));
assert!(text.contains("after"));
}
#[test]
fn paragraph_containing_only_inline_comments_renders_zero_lines() {
let lines = render("<!-- only --><!-- comments -->\n");
assert_eq!(lines.len(), 0, "got {lines:?}");
}
#[test]
fn setext_h2_renders_as_atx_h2_plus_its_underline_rule() {
let atx_lines = render("## H2 text\n");
let setext_lines = render("H2 text\n---\n");
assert_eq!(atx_lines.len(), 1);
assert_eq!(setext_lines.len(), 2, "{setext_lines:?}");
assert_eq!(setext_lines[0], atx_lines[0]);
assert!(line_text(&setext_lines[1]).chars().all(|c| c == '─'));
let nested = render("- a\n\n H2 text\n ---\n");
assert!(
!nested.iter().map(line_text).any(|t| t.contains('─')),
"{:?}",
nested.iter().map(line_text).collect::<Vec<_>>()
);
}
}