pub mod list;
pub mod table;
pub mod util;
use std::cell::Cell;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{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};
use super::ast::{inlines_to_plain, Block, Inline};
use super::render_cache::{RenderCache, RenderSettings};
const IMAGE_PREFIX: &str = "Image: ";
pub type ImageRowOverride<'t> = &'t dyn Fn(&str, usize) -> Option<usize>;
pub struct Renderer<'t> {
pub(super) theme: &'t Theme,
pub(super) viewport_width: usize,
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,
}
impl<'t> Renderer<'t> {
pub fn new(theme: &'t Theme) -> Self {
Self {
theme,
viewport_width: 80,
code_wrap: false,
image_max_height: 24,
image_row_override: None,
image_block_seq: Cell::new(0),
row_striping: false,
big_h1: 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
}
#[allow(dead_code)]
pub fn render(&self, blocks: &[Block]) -> Vec<Line<'static>> {
let mut lines = Vec::new();
for block in blocks {
self.render_block(block, &mut lines, "");
}
lines
}
pub fn render_with_counts(&self, blocks: &[Block]) -> (Vec<Line<'static>>, Vec<usize>) {
let mut lines = Vec::new();
let mut counts = Vec::with_capacity(blocks.len());
for block in blocks {
let before = lines.len();
self.render_block(block, &mut lines, "");
counts.push(lines.len() - before);
}
(lines, counts)
}
pub fn render_with_counts_cached(
&self,
blocks: &[Block],
cache: &mut RenderCache,
) -> (Vec<Line<'static>>, 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,
});
let mut lines = Vec::new();
let mut counts = Vec::with_capacity(blocks.len());
for block in blocks {
let before = lines.len();
if matches!(block, Block::ImageBlock { .. }) {
self.render_block(block, &mut lines, "");
} else if let Some(hit) = cache.entries.get(block) {
lines.extend(hit.iter().cloned());
} else if let Some((key, hit)) = prev.remove_entry(block) {
lines.extend(hit.iter().cloned());
cache.entries.insert(key, hit);
} else {
self.render_block(block, &mut lines, "");
cache
.entries
.insert(block.clone(), lines[before..].to_vec());
}
counts.push(lines.len() - before);
}
(lines, counts)
}
pub(super) fn render_block(
&self,
block: &Block,
out: &mut Vec<Line<'static>>,
indent_prefix: &str,
) {
match block {
Block::Heading { level, inlines } => {
self.render_heading(*level, inlines, out);
}
Block::Paragraph { inlines } => {
self.render_paragraph(inlines, out, indent_prefix);
}
Block::CodeBlock {
language,
content,
fenced,
} => {
self.render_code_block(language.as_deref(), content, *fenced, out);
}
Block::BlockQuote { blocks } => {
self.render_blockquote(blocks, out);
}
Block::List {
ordered,
start,
items,
} => {
self.render_list(*ordered, *start, items, out, indent_prefix);
}
Block::HorizontalRule => {
out.push(Line::styled(
"─".repeat(self.viewport_width.max(1)),
self.theme.rule,
));
}
Block::Table {
col_count,
headers,
rows,
user_widths,
} => {
self.render_table(*col_count, headers, rows, user_widths.as_deref(), out);
}
Block::Html(html) => {
for line in html.lines() {
out.push(Line::styled(
format!("{indent_prefix}{line}"),
self.theme.code_block_text,
));
}
}
Block::HtmlComment(_) => {
}
Block::ImageBlock { alt, url } => {
self.render_image_block(alt, url, out);
}
Block::FootnoteDefinition { label, blocks } => {
self.render_footnote_definition(label, blocks, out);
}
}
}
fn render_footnote_definition(
&self,
label: &str,
blocks: &[Block],
out: &mut Vec<Line<'static>>,
) {
let mut body: Vec<Line<'static>> = Vec::new();
for b in blocks {
self.render_block(b, &mut body, "");
}
let leader = format!(" {label}. ");
let cont_indent = " ".repeat(leader.chars().count());
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),
]));
return;
}
let last = body.len() - 1;
for (i, line) in body.into_iter().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));
}
}
fn render_image_block(&self, alt: &str, url: &str, out: &mut Vec<Line<'static>>) {
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),
]);
out.push(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);
for _ in 1..rows {
out.push(Line::raw(""));
}
}
fn render_heading(
&self,
level: pulldown_cmark::HeadingLevel,
inlines: &[Inline],
out: &mut Vec<Line<'static>>,
) {
use pulldown_cmark::HeadingLevel::*;
if level == H1 && self.big_h1 && self.try_render_h1_big(inlines, 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(inlines, style));
out.push(Line::from(spans));
if level == H1 {
out.push(Line::styled(
"─".repeat(self.viewport_width.max(1)),
self.theme.h1_rule,
));
}
}
fn try_render_h1_big(&self, inlines: &[Inline], out: &mut Vec<Line<'static>>) -> 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.viewport_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());
}
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));
}
}
out.push(Line::styled("─".repeat(viewport), self.theme.h1_rule));
true
}
fn render_paragraph(
&self,
inlines: &[Inline],
out: &mut Vec<Line<'static>>,
indent_prefix: &str,
) {
let prefix = indent_prefix.to_string();
let segments: Vec<&[Inline]> = inlines
.split(|i| matches!(i, Inline::HardBreak | Inline::SoftBreak))
.collect();
let last = segments.len() - 1;
for (i, segment) in segments.iter().enumerate() {
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 = i < last
|| (!spans.is_empty() && !(spans.len() == 1 && spans[0].content.trim().is_empty()));
if keep {
out.push(Line::from(spans));
}
}
}
fn render_code_block(
&self,
language: Option<&str>,
content: &str,
fenced: bool,
out: &mut Vec<Line<'static>>,
) {
let mut raw_lines: Vec<&str> = content.split('\n').collect();
if raw_lines.last() == Some(&"") {
raw_lines.pop();
}
let block_width = self.viewport_width.max(1);
if fenced {
if let Some(lang) = language {
out.push(Line::styled(
format!(" {} ", lang),
self.theme.code_block_lang,
));
} else {
let padded = "\u{00A0}".repeat(block_width);
out.push(Line::styled(padded, self.theme.code_block_text));
}
}
if self.code_wrap {
let wrap_at = self.viewport_width.max(1);
for line in &raw_lines {
let chars: Vec<char> = line.chars().collect();
if chars.is_empty() {
let padded = "\u{00A0}".repeat(block_width);
out.push(Line::styled(padded, self.theme.code_block_text));
continue;
}
let mut start = 0;
while start < chars.len() {
let end = (start + wrap_at - 1).min(chars.len());
let slice: String = chars[start..end].iter().collect();
let padded = format!(" {:<width$}", slice, width = block_width - 1);
out.push(Line::styled(padded, self.theme.code_block_text));
start = end;
}
}
} else {
for line in &raw_lines {
if line.is_empty() {
let padded = "\u{00A0}".repeat(block_width);
out.push(Line::styled(padded, self.theme.code_block_text));
} else {
let padded = format!(" {:<width$}", line, width = block_width - 1);
out.push(Line::styled(padded, self.theme.code_block_text));
}
}
}
if fenced {
let padded = "\u{00A0}".repeat(block_width);
out.push(Line::styled(padded, self.theme.code_block_text));
}
}
fn render_blockquote(&self, blocks: &[Block], out: &mut Vec<Line<'static>>) {
let mut inner_lines: Vec<Line<'static>> = Vec::new();
for (i, block) in blocks.iter().enumerate() {
if i > 0 {
inner_lines.push(Line::from(""));
}
self.render_block(block, &mut inner_lines, "");
}
for line in inner_lines {
let bar = Span::styled("▎ ", 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, self.theme.blockquote_text));
}
out.push(Line::from(spans));
}
}
fn rendered_inline_char_width(&self, inline: &Inline) -> usize {
match inline {
Inline::Text(t) => t.chars().count(),
Inline::Bold(inner)
| Inline::Italic(inner)
| Inline::Strikethrough(inner)
| Inline::Highlight(inner) => self.rendered_inlines_char_width(inner),
Inline::Code(c) => c.chars().count(),
Inline::Link { text, url, .. } => {
let text_width = self.rendered_inlines_char_width(text);
if text_width == 0 {
link_fallback(url).chars().count()
} else {
text_width
}
}
Inline::Image { alt, url } => {
let name_width = if alt.trim().is_empty() {
link_fallback(url).chars().count()
} else {
alt.chars().count()
};
IMAGE_PREFIX.chars().count() + name_width + 2
}
Inline::HtmlComment(_) => 0,
Inline::FootnoteReference { label } => {
reference_marker(std::iter::once(label.as_str()))
.chars()
.count()
}
Inline::SoftBreak | Inline::HardBreak => 1,
}
}
pub(super) fn rendered_inlines_char_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 += marker.chars().count();
i += run_len;
continue;
}
total += self.rendered_inline_char_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;
}
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) => 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 inlines_to_plain(text).trim().is_empty() {
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::SoftBreak => vec![Span::raw(" ")],
Inline::HardBreak => {
vec![Span::raw(" ")]
}
}
}
}
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)
}
#[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 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("alpha\n\nbeta\n\nalpha\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("beta\n\ngamma\n");
r.render_with_counts_cached(&second, &mut cache);
assert_eq!(cache.entries.len(), 2);
assert!(!cache.entries.keys().any(
|b| matches!(b, Block::Paragraph { inlines } if inlines_to_plain(inlines) == "alpha")
));
}
#[test]
fn cache_cleared_on_settings_change() {
let blocks = parse("---\n");
let mut cache = RenderCache::default();
let narrow = renderer().with_viewport_width(40);
let (narrow_lines, _) = narrow.render_with_counts_cached(&blocks, &mut cache);
let wide = renderer().with_viewport_width(120);
let (wide_lines, _) = wide.render_with_counts_cached(&blocks, &mut cache);
assert_ne!(narrow_lines, wide_lines, "rule must re-render at new width");
assert_eq!(wide_lines, wide.render(&blocks));
}
#[test]
fn image_blocks_bypass_cache() {
let blocks = vec![Block::ImageBlock {
alt: "a".into(),
url: "img.png".into(),
}];
let mut cache = RenderCache::default();
renderer().render_with_counts_cached(&blocks, &mut cache);
assert!(cache.entries.is_empty());
}
#[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 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"));
}
#[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 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_same_as_atx_h2() {
let atx_lines = render("## H2 text\n");
let setext_lines = render("H2 text\n---\n");
eprintln!(
"ATX H2 lines: {:?}",
atx_lines.iter().map(line_text).collect::<Vec<_>>()
);
eprintln!(
"Setext H2 lines: {:?}",
setext_lines.iter().map(line_text).collect::<Vec<_>>()
);
assert_eq!(
atx_lines.len(),
setext_lines.len(),
"ATX: {:?}, Setext: {:?}",
atx_lines.iter().map(line_text).collect::<Vec<_>>(),
setext_lines.iter().map(line_text).collect::<Vec<_>>()
);
assert!(
!setext_lines.iter().map(line_text).any(|t| t.contains('─')),
"Setext H2 should not have a horizontal rule: {:?}",
setext_lines.iter().map(line_text).collect::<Vec<_>>()
);
}
}