use pulldown_cmark::{Alignment, CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use ratatui_core::layout::Rect;
use ratatui_core::style::{Modifier, Style};
use ratatui_core::text::{Line, Span};
use unicode_segmentation::UnicodeSegmentation;
use crate::components::code_block::code_block_lines;
use crate::components::line_width;
use crate::geometry::Size;
use crate::highlight::{CodeHighlighter, Highlighter};
use crate::hyperlink::{BufferLink, LinkPolicy, apply_buffer_links};
use crate::image::{Image, ImageData, ImageLayer, ImageSupport};
use crate::style::{StyleBundle, StyleSheet, Theme};
use crate::surface::Surface;
use crate::view::{RenderCtx, View};
use crate::width::grapheme_cols;
const INDENT: u16 = 2;
const MAX_IMAGE_COLS: u16 = 60;
pub trait ImageResolver {
fn resolve(&self, url: &str) -> Option<ImageData>;
}
#[derive(Clone, Debug)]
pub struct MarkdownImage {
pub row: u16,
pub indent: u16,
pub cols: u16,
pub rows: u16,
pub data: ImageData,
pub alt: String,
}
impl MarkdownImage {
pub fn rect(&self, area: Rect) -> Rect {
let x = area.x.saturating_add(self.indent).min(area.right());
let y = area.y.saturating_add(self.row).min(area.bottom());
Rect {
x,
y,
width: self.cols.min(area.right().saturating_sub(x)),
height: self.rows.min(area.bottom().saturating_sub(y)),
}
}
}
fn image_cell_size(data: &ImageData, avail: u16) -> (u16, u16) {
let cols = avail.clamp(1, MAX_IMAGE_COLS);
let rows = (cols as u32 * data.pixel_height() / (data.pixel_width().max(1) * 2)).clamp(1, 30);
(cols, rows as u16)
}
enum MdItem {
Prose { spans: Vec<RichSpan>, indent: u16 },
Verbatim { line: Line<'static>, indent: u16 },
Table { table: TableData, indent: u16 },
Image {
data: ImageData,
alt: String,
indent: u16,
},
Blank,
}
#[derive(Clone, Debug)]
struct RichSpan {
content: String,
style: Style,
href: Option<String>,
}
impl RichSpan {
fn styled(content: impl Into<String>, style: Style, href: Option<String>) -> Self {
Self {
content: content.into(),
style,
href,
}
}
fn to_span(&self) -> Span<'static> {
Span::styled(self.content.clone(), self.style)
}
}
struct TableData {
aligns: Vec<Alignment>,
header: Vec<Cell>,
rows: Vec<Vec<Cell>>,
}
type Cell = Vec<RichSpan>;
fn parse(
source: &str,
theme: &Theme,
sheet: &StyleSheet,
highlighter: CodeHighlighter,
) -> Vec<MdItem> {
parse_with(source, theme, sheet, highlighter, None)
}
fn parse_with(
source: &str,
theme: &Theme,
sheet: &StyleSheet,
highlighter: CodeHighlighter,
resolver: Option<&dyn ImageResolver>,
) -> Vec<MdItem> {
let mut b = Builder::new(theme, sheet, highlighter, resolver);
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
for event in Parser::new_ext(source, opts) {
b.event(event);
}
b.finish();
b.items
}
struct Builder<'a> {
theme: &'a Theme,
sheet: &'a StyleSheet,
highlighter: CodeHighlighter<'a>,
items: Vec<MdItem>,
inline: Vec<RichSpan>,
style_stack: Vec<Style>,
link_stack: Vec<String>,
lists: Vec<Option<u64>>, quote_depth: u16,
pending_marker: Option<Vec<RichSpan>>,
code: Option<(String, String)>,
table: Option<TableData>,
cur_row: Vec<Cell>,
image: Option<(String, String)>,
resolver: Option<&'a dyn ImageResolver>,
}
impl<'a> Builder<'a> {
fn new(
theme: &'a Theme,
sheet: &'a StyleSheet,
highlighter: CodeHighlighter<'a>,
resolver: Option<&'a dyn ImageResolver>,
) -> Self {
Self {
theme,
sheet,
highlighter,
items: Vec::new(),
inline: Vec::new(),
style_stack: vec![Style::default().fg(theme.text)],
link_stack: Vec::new(),
lists: Vec::new(),
quote_depth: 0,
pending_marker: None,
code: None,
table: None,
cur_row: Vec::new(),
image: None,
resolver,
}
}
fn cur_style(&self) -> Style {
*self.style_stack.last().unwrap()
}
fn cur_href(&self) -> Option<String> {
self.link_stack.last().cloned()
}
fn indent(&self) -> u16 {
(self.lists.len().saturating_sub(1) as u16 + self.quote_depth) * INDENT
}
fn nested(&self) -> bool {
!self.lists.is_empty() || self.quote_depth > 0
}
fn separate(&mut self) {
if self.nested() {
return;
}
if matches!(self.items.last(), None | Some(MdItem::Blank)) {
return;
}
self.items.push(MdItem::Blank);
}
fn flush(&mut self) {
if self.inline.is_empty() && self.pending_marker.is_none() {
return;
}
let indent = self.indent();
let mut spans = self.pending_marker.take().unwrap_or_default();
spans.append(&mut self.inline);
self.items.push(MdItem::Prose { spans, indent });
}
fn push_text(&mut self, text: &str) {
if let Some((_, body)) = self.code.as_mut() {
body.push_str(text);
return;
}
if let Some((_, alt)) = self.image.as_mut() {
alt.push_str(text);
return;
}
let style = self.cur_style();
let href = self.cur_href();
if href.is_some() || style.add_modifier.contains(Modifier::UNDERLINED) {
self.inline
.push(RichSpan::styled(text.to_string(), style, href));
} else {
for span in linkify(text, style, self.sheet.link) {
self.inline.push(span);
}
}
}
fn push_image_placeholder(&mut self, url: &str, alt: &str) {
let label = if alt.trim().is_empty() { url } else { alt };
self.inline.push(RichSpan::styled(
"🖼 ",
self.sheet.image_marker.to_style(),
None,
));
self.inline.push(RichSpan::styled(
label.to_string(),
self.sheet.link.to_style(),
Some(url.to_string()),
));
}
fn event(&mut self, event: Event<'a>) {
match event {
Event::Start(tag) => self.start(tag),
Event::End(tag) => self.end(tag),
Event::Text(t) => self.push_text(&t),
Event::Code(t) => {
self.inline.push(RichSpan::styled(
t.to_string(),
self.sheet.inline_code.to_style(),
self.cur_href(),
));
}
Event::SoftBreak => {
if self.code.is_none() {
self.inline
.push(RichSpan::styled(" ", self.cur_style(), self.cur_href()));
}
}
Event::HardBreak if self.table.is_some() => {
self.inline
.push(RichSpan::styled(" ", self.cur_style(), self.cur_href()))
}
Event::HardBreak => self.flush(),
Event::Rule => {
self.separate();
self.items.push(MdItem::Prose {
spans: vec![RichSpan::styled(
"─".repeat(24),
self.sheet.rule.to_style(),
None,
)],
indent: self.indent(),
});
}
Event::TaskListMarker(done) => {
let glyph = if done { "[x] " } else { "[ ] " };
self.inline.push(RichSpan::styled(
glyph.to_string(),
self.sheet.task_marker.to_style(),
None,
));
}
_ => {}
}
}
fn start(&mut self, tag: Tag<'a>) {
match tag {
Tag::Paragraph => self.separate(),
Tag::Heading { level, .. } => {
self.separate();
let mut style = self.sheet.heading.to_style();
if level > HeadingLevel::H2 {
style = style.add_modifier(Modifier::ITALIC);
}
self.style_stack.push(style);
}
Tag::BlockQuote(_) => {
self.separate();
self.quote_depth += 1;
}
Tag::CodeBlock(kind) => {
self.separate();
let lang = match kind {
CodeBlockKind::Fenced(info) => {
info.split_whitespace().next().unwrap_or("").to_string()
}
CodeBlockKind::Indented => String::new(),
};
self.code = Some((lang, String::new()));
}
Tag::List(start) => {
self.separate();
self.lists.push(start);
}
Tag::Item => {
let marker = match self.lists.last_mut() {
Some(Some(n)) => {
let m = format!("{n}. ");
*self.lists.last_mut().unwrap() = Some(n.saturating_add(1));
m
}
_ => "• ".to_string(),
};
self.pending_marker = Some(vec![RichSpan::styled(
marker,
self.sheet.list_marker.to_style(),
None,
)]);
}
Tag::Emphasis => self.push_style_bundle(self.sheet.emphasis),
Tag::Strong => self.push_style_bundle(self.sheet.strong),
Tag::Strikethrough => self.push_style_bundle(self.sheet.strikethrough),
Tag::Link { dest_url, .. } => {
self.link_stack.push(dest_url.to_string());
self.style_stack.push(self.sheet.link.to_style());
}
Tag::Image { dest_url, .. } => {
self.image = Some((dest_url.to_string(), String::new()));
}
Tag::Table(aligns) => {
self.separate();
self.table = Some(TableData {
aligns,
header: Vec::new(),
rows: Vec::new(),
});
}
Tag::TableHead => {
self.cur_row.clear();
self.style_stack.push(self.sheet.heading.to_style());
}
Tag::TableRow => self.cur_row.clear(),
Tag::TableCell => self.inline.clear(),
_ => {}
}
}
fn end(&mut self, tag: TagEnd) {
match tag {
TagEnd::Paragraph => self.flush(),
TagEnd::Heading(_) => {
self.flush();
self.style_stack.pop();
}
TagEnd::BlockQuote(_) => {
self.flush();
self.quote_depth = self.quote_depth.saturating_sub(1);
}
TagEnd::CodeBlock => {
if let Some((lang, body)) = self.code.take() {
let body = body.strip_suffix('\n').unwrap_or(&body);
let lines: Vec<&str> = body.split('\n').collect();
let indent = self.indent();
for line in
code_block_lines(&lang, &lines, self.theme, self.highlighter, true, None)
{
self.items.push(MdItem::Verbatim { line, indent });
}
}
}
TagEnd::List(_) => {
self.lists.pop();
}
TagEnd::Item => self.flush(),
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => {
self.style_stack.pop();
}
TagEnd::Link => {
self.style_stack.pop();
self.link_stack.pop();
}
TagEnd::Image => {
if let Some((url, alt)) = self.image.take() {
match self.resolver.and_then(|r| r.resolve(&url)) {
Some(data) => {
self.flush();
let indent = self.indent();
self.items.push(MdItem::Image { data, alt, indent });
}
None => self.push_image_placeholder(&url, &alt),
}
}
}
TagEnd::TableCell => {
let cell = trim_spans(std::mem::take(&mut self.inline));
self.cur_row.push(cell);
}
TagEnd::TableHead => {
self.style_stack.pop();
if let Some(t) = self.table.as_mut() {
t.header = std::mem::take(&mut self.cur_row);
}
}
TagEnd::TableRow => {
if let Some(t) = self.table.as_mut() {
t.rows.push(std::mem::take(&mut self.cur_row));
}
}
TagEnd::Table => {
if let Some(table) = self.table.take() {
let indent = self.indent();
self.items.push(MdItem::Table { table, indent });
}
}
_ => {}
}
}
fn push_style_bundle(&mut self, bundle: StyleBundle) {
let s = bundle.apply(self.cur_style());
self.style_stack.push(s);
}
fn finish(&mut self) {
if self.table.is_some() {
self.inline.clear();
}
self.flush();
if let Some((lang, body)) = self.code.take() {
let body = body.strip_suffix('\n').unwrap_or(&body);
let lines: Vec<&str> = body.split('\n').collect();
for line in code_block_lines(&lang, &lines, self.theme, self.highlighter, true, None) {
self.items.push(MdItem::Verbatim { line, indent: 0 });
}
}
}
}
fn trim_spans(mut spans: Vec<RichSpan>) -> Vec<RichSpan> {
if let Some(first) = spans.first_mut() {
first.content = first.content.trim_start().to_string();
}
if let Some(last) = spans.last_mut() {
last.content = last.content.trim_end().to_string();
}
spans.retain(|s| !s.content.is_empty());
spans
}
fn spans_cols(spans: &[RichSpan]) -> usize {
spans
.iter()
.map(|s| crate::width::str_cols(&s.content) as usize)
.sum()
}
fn linkify(text: &str, base: Style, link: StyleBundle) -> Vec<RichSpan> {
let mut spans = Vec::new();
let mut rest = text;
while let Some(start) = rest.find("http://").or_else(|| rest.find("https://")) {
let (before, from) = rest.split_at(start);
if !before.is_empty() {
spans.push(RichSpan::styled(before.to_string(), base, None));
}
let raw = from.find(char::is_whitespace).unwrap_or(from.len());
let end = from[..raw]
.trim_end_matches(['.', ',', ';', ':', '!', '?', ')', ']'])
.len();
let (url, after) = from.split_at(end.max(1));
spans.push(RichSpan::styled(
url.to_string(),
link.apply(base),
Some(url.to_string()),
));
rest = after;
}
if !rest.is_empty() {
spans.push(RichSpan::styled(rest.to_string(), base, None));
}
if spans.is_empty() {
spans.push(RichSpan::styled(text.to_string(), base, None));
}
spans
}
fn flatten_linked(
items: &[MdItem],
width: u16,
theme: &Theme,
) -> (Vec<Line<'static>>, Vec<BufferLink>) {
let mut images = Vec::new();
flatten_linked_into(items, width, theme, &mut images)
}
fn flatten_into(
items: &[MdItem],
width: u16,
theme: &Theme,
images: &mut Vec<MarkdownImage>,
) -> Vec<Line<'static>> {
flatten_linked_into(items, width, theme, images).0
}
fn flatten_linked_into(
items: &[MdItem],
width: u16,
theme: &Theme,
images: &mut Vec<MarkdownImage>,
) -> (Vec<Line<'static>>, Vec<BufferLink>) {
let mut out = Vec::new();
let mut links = Vec::new();
for item in items {
match item {
MdItem::Blank => out.push(Line::default()),
MdItem::Prose { spans, indent } => {
let avail = width.saturating_sub(*indent).max(1);
for (row, row_links) in wrap_rich(spans, avail) {
let line_idx = out.len() as u16;
let line = prefix_line(*indent, row);
for mut bl in row_links {
bl.line = line_idx;
bl.start_col = bl.start_col.saturating_add(*indent);
bl.end_col = bl.end_col.saturating_add(*indent);
links.push(bl);
}
out.push(line);
}
}
MdItem::Verbatim { line, indent } => {
out.push(prefix_line(
*indent,
line.spans
.iter()
.map(|s| RichSpan {
content: s.content.to_string(),
style: s.style,
href: None,
})
.collect(),
));
}
MdItem::Table { table, indent } => {
let avail = width.saturating_sub(*indent).max(1);
for (row, row_links) in render_table_linked(table, avail, theme) {
let line_idx = out.len() as u16;
let line = prefix_line(*indent, row);
for mut bl in row_links {
bl.line = line_idx;
bl.start_col = bl.start_col.saturating_add(*indent);
bl.end_col = bl.end_col.saturating_add(*indent);
links.push(bl);
}
out.push(line);
}
}
MdItem::Image { data, alt, indent } => {
let avail = width.saturating_sub(*indent).max(1);
let (cols, rows) = image_cell_size(data, avail);
images.push(MarkdownImage {
row: out.len().min(u16::MAX as usize) as u16,
indent: *indent,
cols,
rows,
data: data.clone(),
alt: alt.clone(),
});
for _ in 0..rows {
out.push(Line::default());
}
}
}
}
(out, links)
}
fn prefix_line(indent: u16, mut spans: Vec<RichSpan>) -> Line<'static> {
if indent == 0 {
return Line::from(spans.into_iter().map(|s| s.to_span()).collect::<Vec<_>>());
}
let mut line = vec![Span::raw(" ".repeat(indent as usize))];
line.extend(spans.drain(..).map(|s| s.to_span()));
Line::from(line)
}
fn wrap_rich(spans: &[RichSpan], width: u16) -> Vec<(Vec<RichSpan>, Vec<BufferLink>)> {
if width == 0 {
let links = link_runs(spans, 0);
return vec![(spans.to_vec(), links)];
}
let cells: Vec<(&str, Style, Option<&str>)> = spans
.iter()
.flat_map(|s| {
let href = s.href.as_deref();
s.content.graphemes(true).map(move |g| (g, s.style, href))
})
.collect();
let mut out: Vec<(Vec<RichSpan>, Vec<BufferLink>)> = Vec::new();
let mut cur: Vec<(&str, Style, Option<&str>)> = Vec::new();
let mut cur_w = 0u16;
let mut i = 0;
let n = cells.len();
let is_break = |g: &str| g.chars().all(char::is_whitespace);
while i < n {
if is_break(cells[i].0) {
i += 1;
continue;
}
let start = i;
let mut word_w = 0u16;
while i < n && !is_break(cells[i].0) {
word_w = word_w.saturating_add(grapheme_cols(cells[i].0));
i += 1;
}
let word = &cells[start..i];
let sep = u16::from(!cur.is_empty());
if word_w <= width && cur_w + sep + word_w <= width {
if sep == 1 {
let (_, st, href) = *cur.last().expect("sep implies non-empty cur");
cur.push((" ", st, href));
cur_w += 1;
}
cur.extend_from_slice(word);
cur_w += word_w;
} else if word_w <= width {
if !cur.is_empty() {
out.push(coalesce_rich(&cur));
cur.clear();
}
cur.extend_from_slice(word);
cur_w = word_w;
} else {
if !cur.is_empty() {
out.push(coalesce_rich(&cur));
cur.clear();
cur_w = 0;
}
for &cell in word {
let w = grapheme_cols(cell.0);
if cur_w + w > width && !cur.is_empty() {
out.push(coalesce_rich(&cur));
cur.clear();
cur_w = 0;
}
cur.push(cell);
cur_w += w;
}
}
}
if !cur.is_empty() {
out.push(coalesce_rich(&cur));
}
if out.is_empty() {
out.push((Vec::new(), Vec::new()));
}
out
}
fn coalesce_rich(cells: &[(&str, Style, Option<&str>)]) -> (Vec<RichSpan>, Vec<BufferLink>) {
let mut spans: Vec<RichSpan> = Vec::new();
let mut buf = String::new();
let mut run_style: Option<Style> = None;
let mut run_href: Option<String> = None;
let flush = |spans: &mut Vec<RichSpan>,
buf: &mut String,
style: &mut Option<Style>,
href: &mut Option<String>| {
if let Some(st) = style.take() {
spans.push(RichSpan::styled(std::mem::take(buf), st, href.take()));
}
};
for &(g, st, href) in cells {
let href = href.map(str::to_string);
match (&run_style, &run_href) {
(Some(s), h) if *s == st && *h == href => buf.push_str(g),
_ => {
flush(&mut spans, &mut buf, &mut run_style, &mut run_href);
run_style = Some(st);
run_href = href;
buf.push_str(g);
}
}
}
flush(&mut spans, &mut buf, &mut run_style, &mut run_href);
let links = link_runs(&spans, 0);
(spans, links)
}
fn link_runs(spans: &[RichSpan], col_offset: u16) -> Vec<BufferLink> {
let mut links = Vec::new();
let mut col = col_offset;
let mut i = 0;
while i < spans.len() {
let Some(url) = spans[i].href.clone() else {
col = col.saturating_add(crate::width::str_cols(&spans[i].content));
i += 1;
continue;
};
let start = col;
while i < spans.len() && spans[i].href.as_deref() == Some(url.as_str()) {
col = col.saturating_add(crate::width::str_cols(&spans[i].content));
i += 1;
}
if col > start {
links.push(BufferLink {
line: 0, start_col: start,
end_col: col,
url,
});
}
}
links
}
fn render_table_linked(
table: &TableData,
width: u16,
theme: &Theme,
) -> Vec<(Vec<RichSpan>, Vec<BufferLink>)> {
let cols = table
.header
.len()
.max(table.rows.iter().map(Vec::len).max().unwrap_or(0))
.max(1);
if (width as usize) < 4 * cols + 1 {
return render_table_plain_linked(table, width, cols, theme);
}
let mut widths = vec![0usize; cols];
for (c, w) in widths.iter_mut().enumerate() {
*w = spans_cols(cell_at(&table.header, c));
for row in &table.rows {
*w = (*w).max(spans_cols(cell_at(row, c)));
}
*w = (*w).max(1);
}
let budget = (width as usize).saturating_sub(3 * cols + 1).max(cols);
while widths.iter().sum::<usize>() > budget {
let (idx, _) = widths.iter().enumerate().max_by_key(|(_, w)| **w).unwrap();
if widths[idx] <= 1 {
break;
}
widths[idx] -= 1;
}
let border = Style::default().fg(theme.dim);
let mut out = Vec::new();
out.push((
vec![RichSpan::styled(
rule_row_text('╭', '┬', '╮', &widths),
border,
None,
)],
vec![],
));
out.extend(cell_rows_linked(
&table.header,
&widths,
&table.aligns,
border,
cols,
));
out.push((
vec![RichSpan::styled(
rule_row_text('├', '┼', '┤', &widths),
border,
None,
)],
vec![],
));
for row in &table.rows {
out.extend(cell_rows_linked(row, &widths, &table.aligns, border, cols));
}
out.push((
vec![RichSpan::styled(
rule_row_text('╰', '┴', '╯', &widths),
border,
None,
)],
vec![],
));
out
}
fn render_table_plain_linked(
table: &TableData,
width: u16,
cols: usize,
theme: &Theme,
) -> Vec<(Vec<RichSpan>, Vec<BufferLink>)> {
let sep = Style::default().fg(theme.dim);
let join = |row: &[Cell]| -> Vec<RichSpan> {
let mut spans = Vec::new();
for c in 0..cols {
if c > 0 {
spans.push(RichSpan::styled(" | ".to_string(), sep, None));
}
spans.extend(cell_at(row, c).iter().cloned());
}
spans
};
let mut out = Vec::new();
out.extend(wrap_rich(&join(&table.header), width));
for row in &table.rows {
out.extend(wrap_rich(&join(row), width));
}
out
}
fn cell_at(row: &[Cell], c: usize) -> &[RichSpan] {
row.get(c).map(Vec::as_slice).unwrap_or(&[])
}
fn rule_row_text(left: char, mid: char, right: char, widths: &[usize]) -> String {
let mut s = String::new();
s.push(left);
for (i, w) in widths.iter().enumerate() {
s.push_str(&"─".repeat(w + 2));
s.push(if i + 1 == widths.len() { right } else { mid });
}
s
}
fn cell_rows_linked(
row: &[Cell],
widths: &[usize],
aligns: &[Alignment],
border: Style,
cols: usize,
) -> Vec<(Vec<RichSpan>, Vec<BufferLink>)> {
let wrapped: Vec<Vec<(Vec<RichSpan>, Vec<BufferLink>)>> = (0..cols)
.map(|c| {
let cell = cell_at(row, c);
if cell.is_empty() {
vec![(Vec::new(), Vec::new())]
} else {
let lines = wrap_rich(cell, widths[c].max(1) as u16);
if lines.is_empty() {
vec![(Vec::new(), Vec::new())]
} else {
lines
}
}
})
.collect();
let height = wrapped.iter().map(Vec::len).max().unwrap_or(1);
let empty: (Vec<RichSpan>, Vec<BufferLink>) = (Vec::new(), Vec::new());
let mut lines = Vec::new();
for r in 0..height {
let mut spans = vec![RichSpan::styled("│".to_string(), border, None)];
let mut links = Vec::new();
let mut col = 1u16; for (c, width) in widths.iter().enumerate() {
let (content, cell_links) = wrapped[c].get(r).unwrap_or(&empty);
let content_w = spans_cols(content) as u16;
let pad = (*width as u16).saturating_sub(content_w);
let align = aligns.get(c).copied().unwrap_or(Alignment::None);
let (left, right) = match align {
Alignment::Right => (pad, 0),
Alignment::Center => (pad / 2, pad - pad / 2),
_ => (0, pad),
};
spans.push(RichSpan::styled(" ".to_string(), Style::default(), None));
col += 1;
if left > 0 {
spans.push(RichSpan::styled(
" ".repeat(left as usize),
Style::default(),
None,
));
col += left;
}
for mut bl in cell_links.iter().cloned() {
bl.start_col = bl.start_col.saturating_add(col);
bl.end_col = bl.end_col.saturating_add(col);
links.push(bl);
}
spans.extend(content.iter().cloned());
col = col.saturating_add(content_w);
if right > 0 {
spans.push(RichSpan::styled(
" ".repeat(right as usize),
Style::default(),
None,
));
col += right;
}
spans.push(RichSpan::styled(" ".to_string(), Style::default(), None));
col += 1;
spans.push(RichSpan::styled("│".to_string(), border, None));
col += 1;
}
lines.push((spans, links));
}
lines
}
pub fn markdown_to_lines(
source: &str,
width: u16,
theme: &Theme,
sheet: &StyleSheet,
highlighter: CodeHighlighter,
) -> Vec<Line<'static>> {
markdown_to_linked_lines(source, width, theme, sheet, highlighter).0
}
pub fn markdown_to_linked_lines(
source: &str,
width: u16,
theme: &Theme,
sheet: &StyleSheet,
highlighter: CodeHighlighter,
) -> (Vec<Line<'static>>, Vec<BufferLink>) {
let items = parse(source, theme, sheet, highlighter);
flatten_linked(&items, width, theme)
}
fn stable_boundary(source: &str, from: usize) -> usize {
let mut fence_open = false;
let mut boundary = from;
let mut pos = from;
for line in source[from..].split_inclusive('\n') {
let trimmed = line.trim();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
fence_open = !fence_open;
} else if trimmed.is_empty() && !fence_open {
boundary = pos + line.len();
}
pos += line.len();
}
boundary
}
#[derive(Default)]
pub struct MarkdownState {
source: String,
stable_len: usize,
stable: Vec<MdItem>,
cached_theme: Option<Theme>,
cached_sheet: Option<StyleSheet>,
rendered: Vec<Line<'static>>,
settled_lines: usize,
flattened_items: usize,
rendered_width: Option<u16>,
resolver: Option<Box<dyn ImageResolver>>,
settled_images: Vec<MarkdownImage>,
frame_images: Vec<MarkdownImage>,
}
impl MarkdownState {
pub fn new() -> Self {
Self::default()
}
pub fn with_image_resolver(mut self, resolver: Box<dyn ImageResolver>) -> Self {
self.resolver = Some(resolver);
self.reset_cache();
self
}
pub fn images(&self) -> &[MarkdownImage] {
&self.frame_images
}
pub fn push_str(&mut self, delta: &str) {
self.source.push_str(delta);
}
pub fn set(&mut self, source: impl Into<String>) {
self.source = source.into();
self.reset_cache();
}
pub fn source(&self) -> &str {
&self.source
}
fn reset_cache(&mut self) {
self.stable_len = 0;
self.stable.clear();
self.rendered.clear();
self.settled_lines = 0;
self.flattened_items = 0;
self.rendered_width = None;
self.settled_images.clear();
self.frame_images.clear();
}
pub fn lines(
&mut self,
width: u16,
theme: &Theme,
sheet: &StyleSheet,
highlighter: CodeHighlighter,
) -> &[Line<'static>] {
if self.cached_theme != Some(*theme) || self.cached_sheet != Some(*sheet) {
self.cached_theme = Some(*theme);
self.cached_sheet = Some(*sheet);
self.reset_cache();
}
if self.rendered_width != Some(width) {
self.rendered_width = Some(width);
self.rendered.clear();
self.settled_lines = 0;
self.flattened_items = 0;
self.settled_images.clear();
}
let boundary = stable_boundary(&self.source, self.stable_len);
if boundary > self.stable_len {
let segment = &self.source[self.stable_len..boundary];
let mut items =
parse_with(segment, theme, sheet, highlighter, self.resolver.as_deref());
if !items.is_empty()
&& !self.stable.is_empty()
&& !matches!(self.stable.last(), Some(MdItem::Blank))
{
self.stable.push(MdItem::Blank);
}
self.stable.append(&mut items);
self.stable_len = boundary;
}
self.rendered.truncate(self.settled_lines);
if self.flattened_items < self.stable.len() {
let base = self.rendered.len() as u16;
let mut settled_imgs = Vec::new();
let settled = flatten_into(
&self.stable[self.flattened_items..],
width,
theme,
&mut settled_imgs,
);
for mut img in settled_imgs {
img.row = img.row.saturating_add(base);
self.settled_images.push(img);
}
self.rendered.extend(settled);
self.flattened_items = self.stable.len();
self.settled_lines = self.rendered.len();
}
let tail = parse_with(
&self.source[self.stable_len..],
theme,
sheet,
highlighter,
self.resolver.as_deref(),
);
let mut tail_imgs = Vec::new();
let tail_lines = flatten_into(&tail, width, theme, &mut tail_imgs);
if !self.rendered.is_empty()
&& !tail_lines.is_empty()
&& !is_blank_line(self.rendered.last().unwrap())
&& !is_blank_line(&tail_lines[0])
{
self.rendered.push(Line::default());
}
let tail_base = self.rendered.len() as u16;
self.rendered.extend(tail_lines);
self.frame_images.clear();
self.frame_images
.extend(self.settled_images.iter().cloned());
for mut img in tail_imgs {
img.row = img.row.saturating_add(tail_base);
self.frame_images.push(img);
}
&self.rendered
}
}
fn is_blank_line(line: &Line) -> bool {
line.spans.iter().all(|s| s.content.trim().is_empty())
}
pub struct Markdown<'a> {
source: String,
highlighter: CodeHighlighter<'a>,
resolver: Option<&'a dyn ImageResolver>,
image_support: ImageSupport,
image_layer: Option<ImageLayer>,
link_policy: LinkPolicy,
}
impl<'a> Markdown<'a> {
pub fn new(source: impl Into<String>) -> Self {
Self {
source: source.into(),
highlighter: CodeHighlighter::Plain,
resolver: None,
image_support: ImageSupport::None,
image_layer: None,
link_policy: LinkPolicy::default(),
}
}
pub fn highlighter(mut self, highlighter: &'a dyn Highlighter) -> Self {
self.highlighter = CodeHighlighter::With(highlighter);
self
}
pub fn link_policy(mut self, policy: LinkPolicy) -> Self {
self.link_policy = policy;
self
}
pub fn images(
mut self,
resolver: &'a dyn ImageResolver,
support: ImageSupport,
layer: &ImageLayer,
) -> Self {
self.resolver = Some(resolver);
self.image_support = support;
self.image_layer = Some(layer.clone());
self
}
fn lines_images_links(
&self,
width: u16,
theme: &Theme,
sheet: &StyleSheet,
) -> (Vec<Line<'static>>, Vec<MarkdownImage>, Vec<BufferLink>) {
let items = parse_with(&self.source, theme, sheet, self.highlighter, self.resolver);
let mut images = Vec::new();
let (lines, links) = flatten_linked_into(&items, width, theme, &mut images);
(lines, images, links)
}
}
impl View for Markdown<'_> {
fn measure(&self, available: Size) -> Size {
let (lines, _, _) =
self.lines_images_links(available.width, &Theme::default(), &StyleSheet::default());
let width = lines.iter().map(line_width).max().unwrap_or(0);
Size::new(width.min(available.width), lines.len() as u16)
}
fn render(&self, area: Rect, surface: &mut Surface, ctx: &RenderCtx) {
let (lines, images, links) = self.lines_images_links(area.width, ctx.theme, &ctx.sheet);
for (row, line) in lines.iter().enumerate() {
let y = area.y.saturating_add(row as u16);
if y >= area.bottom() {
break;
}
let mut x = area.x;
for span in &line.spans {
if x >= area.right() {
break;
}
x = surface.set_string(x, y, span.content.as_ref(), span.style);
}
}
apply_buffer_links(
surface.buffer_mut(),
ratatui_core::layout::Position {
x: area.x,
y: area.y,
},
&links,
self.link_policy,
);
for img in images {
let y = area.y.saturating_add(img.row);
let x = area.x.saturating_add(img.indent);
if y >= area.bottom() || x >= area.right() {
continue;
}
let rect = Rect {
x,
y,
width: img.cols.min(area.right() - x),
height: img.rows.min(area.bottom() - y),
};
let mut image = Image::new(img.data, img.cols, img.rows)
.support(self.image_support)
.alt(img.alt);
if let Some(layer) = &self.image_layer {
image = image.in_layer(layer);
}
image.render(rect, surface, ctx);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn text(line: &Line) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
fn plain(source: &str, width: u16) -> Vec<String> {
markdown_to_lines(
source,
width,
&Theme::default(),
&StyleSheet::default(),
CodeHighlighter::Plain,
)
.iter()
.map(text)
.collect()
}
#[test]
fn heading_is_bold_and_themed() {
let theme = Theme::default();
let lines = markdown_to_lines(
"# Title",
40,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
);
let span = &lines[0].spans[0];
assert_eq!(span.content.as_ref(), "Title");
assert!(span.style.add_modifier.contains(Modifier::BOLD));
assert_eq!(span.style.fg, Some(theme.code.heading));
}
#[test]
fn emphasis_and_strong_carry_modifiers() {
let theme = Theme::default();
let lines = markdown_to_lines(
"plain *em* and **bold**",
60,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
);
let em = lines[0]
.spans
.iter()
.find(|s| s.content.contains("em"))
.expect("emphasis span");
assert!(em.style.add_modifier.contains(Modifier::ITALIC));
let bold = lines[0]
.spans
.iter()
.find(|s| s.content.contains("bold"))
.expect("strong span");
assert!(bold.style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn inline_code_gets_code_background() {
let theme = Theme::default();
let lines = markdown_to_lines(
"use `cargo test` now",
60,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
);
let code = lines[0]
.spans
.iter()
.find(|s| s.content.contains("cargo test"))
.expect("inline code span");
assert_eq!(code.style.bg, Some(theme.code.background));
}
#[test]
fn bullet_list_renders_markers() {
let out = plain("- one\n- two", 40);
assert!(out.iter().any(|l| l.contains("• one")), "{out:?}");
assert!(out.iter().any(|l| l.contains("• two")), "{out:?}");
}
#[test]
fn ordered_list_numbers_increment() {
let out = plain("1. first\n2. second", 40);
assert!(out.iter().any(|l| l.contains("1. first")), "{out:?}");
assert!(out.iter().any(|l| l.contains("2. second")), "{out:?}");
}
#[test]
fn nested_list_is_indented() {
let out = plain("- outer\n - inner", 40);
let inner = out.iter().find(|l| l.contains("inner")).unwrap();
assert!(
inner.starts_with(" "),
"nested item should be indented: {inner:?}"
);
}
#[test]
fn fenced_code_preserves_indentation_verbatim() {
let src = "```\n indented\n```";
let out = plain(src, 40);
assert!(
out.iter().any(|l| l.contains(" indented")),
"code indentation must survive: {out:?}"
);
}
#[test]
fn fenced_code_shows_language_label() {
let out = plain("```rust\nfn main() {}\n```", 40);
assert!(out.iter().any(|l| l.contains("rust")), "{out:?}");
}
#[test]
fn code_fence_is_not_word_wrapped() {
let long = "x".repeat(60);
let src = format!("```\n{long}\n```");
let lines = markdown_to_lines(
&src,
20,
&Theme::default(),
&StyleSheet::default(),
CodeHighlighter::Plain,
);
let code_rows = lines.iter().filter(|l| text(l).contains("xxxx")).count();
assert_eq!(code_rows, 1, "code line must not wrap");
}
#[test]
fn prose_word_wraps_to_width() {
let out = plain("one two three four five six seven eight", 12);
assert!(out.len() > 1, "long prose should wrap: {out:?}");
for line in &out {
assert!(line.chars().count() <= 12, "line over width: {line:?}");
}
}
#[test]
fn table_renders_boxed_with_headers() {
let src = "| A | B |\n| - | - |\n| 1 | 2 |";
let out = plain(src, 40);
assert!(out.iter().any(|l| l.contains('╭')), "top border: {out:?}");
assert!(
out.iter().any(|l| l.contains('A') && l.contains('B')),
"header: {out:?}"
);
assert!(
out.iter().any(|l| l.contains('1') && l.contains('2')),
"row: {out:?}"
);
}
#[test]
fn table_header_cells_are_bold_and_themed() {
let theme = Theme::default();
let src = "| Name | Kind |\n| --- | --- |\n| a | b |";
let lines = markdown_to_lines(
src,
40,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
);
let head = lines
.iter()
.flat_map(|l| &l.spans)
.find(|s| s.content.contains("Name"))
.expect("header cell span");
assert!(head.style.add_modifier.contains(Modifier::BOLD));
assert_eq!(head.style.fg, Some(theme.code.heading));
}
#[test]
fn table_cell_link_is_styled() {
let theme = Theme::default();
let src = "| Site |\n| --- |\n| [yolop](https://everruns.dev) |";
let lines = markdown_to_lines(
src,
40,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
);
let link = lines
.iter()
.flat_map(|l| &l.spans)
.find(|s| s.content.contains("yolop"))
.expect("link cell span");
assert_eq!(link.style.fg, Some(theme.code.link));
assert!(link.style.add_modifier.contains(Modifier::UNDERLINED));
}
#[test]
fn table_cell_bold_and_inline_code_survive() {
let theme = Theme::default();
let src = "| Col |\n| --- |\n| **hi** and `cargo` |";
let lines = markdown_to_lines(
src,
40,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
);
let spans: Vec<&Span> = lines.iter().flat_map(|l| &l.spans).collect();
let bold = spans
.iter()
.find(|s| s.content.contains("hi"))
.expect("bold span");
assert!(bold.style.add_modifier.contains(Modifier::BOLD));
let code = spans
.iter()
.find(|s| s.content.contains("cargo"))
.expect("code span");
assert_eq!(code.style.bg, Some(theme.code.background));
}
#[test]
fn table_cell_emoji_keeps_borders_aligned() {
let src = "| Status |\n| --- |\n| ok ✅ |\n| bad |";
let lines = markdown_to_lines(
src,
40,
&Theme::default(),
&StyleSheet::default(),
CodeHighlighter::Plain,
);
let box_rows: Vec<u16> = lines
.iter()
.filter(|l| text(l).contains('│'))
.map(line_width)
.collect();
assert!(box_rows.len() >= 2, "expected boxed rows: {box_rows:?}");
assert!(
box_rows.windows(2).all(|w| w[0] == w[1]),
"boxed rows must share one width: {box_rows:?}"
);
}
#[test]
fn table_falls_back_to_plain_when_too_narrow_and_always_fits() {
let src = "| Col A | Col B |\n| --- | --- |\n| alpha | beta |";
for width in [4u16, 8, 12, 20, 48] {
let lines = markdown_to_lines(
src,
width,
&Theme::default(),
&StyleSheet::default(),
CodeHighlighter::Plain,
);
for line in &lines {
assert!(
line_width(line) <= width,
"table line exceeded width {width}: {:?}",
text(line)
);
}
let body: String = lines.iter().map(text).collect::<Vec<_>>().join("\n");
assert!(body.contains("Col"), "content survives at width {width}");
}
}
#[test]
fn bare_url_is_linkified() {
let theme = Theme::default();
let lines = markdown_to_lines(
"see https://example.com now",
60,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
);
let url = lines[0]
.spans
.iter()
.find(|s| s.content.as_ref().contains("example.com"))
.expect("url span");
assert_eq!(url.style.fg, Some(theme.code.link));
assert!(url.style.add_modifier.contains(Modifier::UNDERLINED));
}
#[test]
fn labeled_markdown_link_preserves_destination_for_ctrl_click() {
use crate::{Mouse, MouseButton, MouseKind, apply_buffer_links, ctrl_click_url};
use ratatui_core::buffer::Buffer;
use ratatui_core::layout::Position;
let theme = Theme::default();
let (lines, links) = markdown_to_linked_lines(
"See the [docs](https://example.com/api) please.",
60,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
);
assert!(
links.iter().any(|l| l.url == "https://example.com/api"),
"labeled link must yield a BufferLink: {links:?}"
);
let link = links
.iter()
.find(|l| l.url == "https://example.com/api")
.unwrap();
let plain: String = lines[link.line as usize]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(plain.contains("docs"), "label visible: {plain:?}");
assert!(
!plain.contains("example.com"),
"URL must not replace the label: {plain:?}"
);
let area = Rect::new(0, 0, 60, 3);
let mut buffer = Buffer::empty(area);
for (row, line) in lines.iter().enumerate() {
let mut x = 0u16;
for span in &line.spans {
x = buffer.set_span(x, row as u16, span, area.width).0;
}
}
apply_buffer_links(
&mut buffer,
Position { x: 0, y: 0 },
&links,
LinkPolicy::WEB,
);
let click_col = link.start_col + 1; let mut event = Mouse::at(MouseKind::Up(MouseButton::Left), click_col, link.line);
event.ctrl = true;
assert_eq!(
ctrl_click_url(&event, &buffer, area).as_deref(),
Some("https://example.com/api"),
"Ctrl+click on the label must resolve the markdown destination"
);
}
#[test]
fn custom_sheet_restyles_both_links_and_bare_urls() {
use ratatui_core::style::Color;
let theme = Theme::default();
let sheet = StyleSheet {
link: StyleBundle::new().fg(Color::Green).bold(),
..StyleSheet::from_theme(&theme)
};
let lines = markdown_to_lines(
"[docs](https://ex.com) and https://bare.example.com here",
80,
&theme,
&sheet,
CodeHighlighter::Plain,
);
let spans: Vec<&Span> = lines.iter().flat_map(|l| &l.spans).collect();
for needle in ["docs", "bare.example.com"] {
let span = spans
.iter()
.find(|s| s.content.contains(needle))
.unwrap_or_else(|| panic!("missing {needle:?} span"));
assert_eq!(span.style.fg, Some(Color::Green), "{needle}: recolored");
assert!(
span.style.add_modifier.contains(Modifier::BOLD),
"{needle}: bold"
);
assert!(
!span.style.add_modifier.contains(Modifier::UNDERLINED),
"{needle}: underline dropped by the custom rule"
);
}
}
#[test]
fn custom_sheet_restyles_headings() {
use ratatui_core::style::Color;
let theme = Theme::default();
let sheet = StyleSheet {
heading: StyleBundle::new().fg(Color::Magenta).italic(),
..StyleSheet::from_theme(&theme)
};
let lines = markdown_to_lines("# Title", 40, &theme, &sheet, CodeHighlighter::Plain);
let span = &lines[0].spans[0];
assert_eq!(span.content.as_ref(), "Title");
assert_eq!(span.style.fg, Some(Color::Magenta));
assert!(span.style.add_modifier.contains(Modifier::ITALIC));
assert!(!span.style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn sheet_change_invalidates_stream_cache() {
use ratatui_core::style::Color;
let theme = Theme::default();
let mut state = MarkdownState::new();
state.set("A [link](https://ex.com) in prose.");
let default_sheet = StyleSheet::from_theme(&theme);
let link_fg = |lines: &[Line<'static>]| {
lines
.iter()
.flat_map(|l| &l.spans)
.find(|s| s.content.contains("link"))
.expect("link span")
.style
.fg
};
assert_eq!(
link_fg(state.lines(60, &theme, &default_sheet, CodeHighlighter::Plain)),
Some(theme.code.link)
);
let recolored = StyleSheet {
link: StyleBundle::new().fg(Color::Green),
..default_sheet
};
assert_eq!(
link_fg(state.lines(60, &theme, &recolored, CodeHighlighter::Plain)),
Some(Color::Green),
"a stylesheet change must invalidate the stream cache"
);
}
#[test]
fn streaming_matches_one_shot_render() {
let full = "# Heading\n\nA paragraph of text.\n\n```rust\nfn main() {}\n```\n\nDone.";
let theme = Theme::default();
let one_shot: Vec<String> = markdown_to_lines(
full,
40,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
)
.iter()
.map(text)
.collect();
let mut state = MarkdownState::new();
let mut streamed = Vec::new();
for chunk in [
"# Head",
"ing\n\nA para",
"graph of text.\n\n```rus",
"t\nfn main() {}\n```\n\nDone.",
] {
state.push_str(chunk);
streamed = state
.lines(
40,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
)
.iter()
.map(text)
.collect();
}
assert_eq!(streamed, one_shot);
}
#[test]
fn streaming_then_resize_matches_one_shot_at_new_width() {
let theme = Theme::default();
let chunks = [
"# A wide head",
"ing that wraps when narrow\n\nA para",
"graph long enough to wrap differently at 24 columns than at 60.\n\n",
"- a bullet that also wraps\n\nDone.",
];
let full: String = chunks.concat();
let mut state = MarkdownState::new();
for chunk in chunks {
state.push_str(chunk);
let _ = state.lines(
60,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
);
}
let resized: Vec<String> = state
.lines(
24,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
)
.iter()
.map(text)
.collect();
let one_shot: Vec<String> = markdown_to_lines(
&full,
24,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
)
.iter()
.map(text)
.collect();
assert_eq!(
resized, one_shot,
"resized stream must equal a one-shot render at the new width"
);
}
#[test]
fn streaming_commits_a_stable_prefix() {
let theme = Theme::default();
let mut state = MarkdownState::new();
state.push_str("First paragraph.\n\nSecond a");
let _ = state.lines(
40,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
);
assert!(state.stable_len > 0, "expected a committed prefix");
assert!(!state.stable.is_empty());
}
#[test]
fn stable_boundary_never_splits_open_code_fence() {
let src = "```\ncode\n\nmore code";
assert_eq!(stable_boundary(src, 0), 0);
let closed = "```\ncode\n```\n\nafter";
assert!(stable_boundary(closed, 0) > 0);
}
#[test]
fn partial_emphasis_degrades_gracefully() {
let out = plain("this is **unfinished", 40);
assert!(out.iter().any(|l| l.contains("unfinished")), "{out:?}");
}
#[test]
fn theme_change_invalidates_cache() {
let mut state = MarkdownState::new();
state.push_str("Para one.\n\nPara two.\n\ntail");
let a = Theme::default();
let _ = state.lines(40, &a, &StyleSheet::from_theme(&a), CodeHighlighter::Plain);
assert!(state.stable_len > 0);
let mut b = Theme::default();
b.code.heading = ratatui_core::style::Color::Indexed(200);
let _ = state.lines(40, &b, &StyleSheet::from_theme(&b), CodeHighlighter::Plain);
assert_eq!(state.cached_theme, Some(b));
}
#[test]
fn image_renders_alt_as_a_marked_placeholder() {
let theme = Theme::default();
let lines = markdown_to_lines(
"look:  ok",
60,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
);
let whole: String = lines.iter().map(text).collect::<Vec<_>>().join("\n");
assert!(whole.contains("🖼 a cat"), "expected marked alt: {whole:?}");
assert!(whole.contains("look:") && whole.contains("ok"));
let label = lines
.iter()
.flat_map(|l| &l.spans)
.find(|s| s.content.contains("a cat"))
.expect("alt span");
assert_eq!(label.style.fg, Some(theme.code.link));
assert!(label.style.add_modifier.contains(Modifier::UNDERLINED));
}
#[test]
fn image_without_alt_shows_the_url_so_it_is_never_dropped() {
let out = plain("", 60).join("\n");
assert!(out.contains("🖼 "), "marker present: {out:?}");
assert!(out.contains("https://ex.com/x.png"), "url shown: {out:?}");
}
struct StubResolver;
impl ImageResolver for StubResolver {
fn resolve(&self, url: &str) -> Option<ImageData> {
url.contains("ok")
.then(|| ImageData::from_rgba(4, 2, vec![0u8; 4 * 2 * 4]).unwrap())
}
}
#[test]
fn resolved_block_image_reserves_rows_and_records_a_placement() {
use crate::testing::render;
let theme = Theme::default();
let layer = ImageLayer::new();
let resolver = StubResolver;
let view = Markdown::new("text before\n\n\n\nafter").images(
&resolver,
ImageSupport::Kitty,
&layer,
);
let _buf = render(&view, 40, 12, &theme);
assert_eq!(layer.len(), 1, "one block image recorded");
}
#[test]
fn unresolved_image_stays_an_inline_placeholder_even_with_images_enabled() {
use crate::testing::render;
let theme = Theme::default();
let layer = ImageLayer::new();
let resolver = StubResolver;
let view =
Markdown::new("").images(&resolver, ImageSupport::Kitty, &layer);
let buf = render(&view, 40, 4, &theme);
let mut whole = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
whole.push_str(buf[(x, y)].symbol());
}
}
assert!(whole.contains("🖼 "), "placeholder marker shown: {whole:?}");
assert!(layer.is_empty(), "declined image records no placement");
}
#[test]
fn block_image_without_graphics_support_shows_alt_fallback() {
use crate::testing::render;
let theme = Theme::default();
let layer = ImageLayer::new();
let resolver = StubResolver;
let view = Markdown::new("").images(&resolver, ImageSupport::None, &layer);
let buf = render(&view, 40, 6, &theme);
let mut whole = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
whole.push_str(buf[(x, y)].symbol());
}
}
assert!(
whole.contains("[image: a cat]"),
"alt fallback painted: {whole:?}"
);
assert!(layer.is_empty(), "no placement without graphics support");
}
#[test]
fn block_image_reserves_height_proportional_to_aspect() {
let wide = ImageData::from_rgba(40, 2, vec![0u8; 40 * 2 * 4]).unwrap();
let tall = ImageData::from_rgba(4, 40, vec![0u8; 4 * 40 * 4]).unwrap();
let (_, wr) = image_cell_size(&wide, 40);
let (_, tr) = image_cell_size(&tall, 40);
assert!(wr < tr, "tall image reserves more rows: {wr} vs {tr}");
assert!(wr >= 1 && tr <= 30, "rows are clamped: {wr}, {tr}");
}
#[test]
fn streaming_state_reports_a_block_image_placement() {
let theme = Theme::default();
let mut md = MarkdownState::new().with_image_resolver(Box::new(StubResolver));
md.set("intro line\n\n\n\ntail line");
let lines = md
.lines(
40,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
)
.to_vec();
let imgs = md.images();
assert_eq!(imgs.len(), 1, "one block image reported");
let img = &imgs[0];
assert_eq!(img.alt, "a cat");
assert!((img.row as usize) < lines.len(), "row within lines");
assert!(
is_blank_line(&lines[img.row as usize]),
"reserved row is blank"
);
assert!(text(&lines[0]).contains("intro"));
}
#[test]
fn streaming_image_row_matches_one_shot() {
let theme = Theme::default();
let doc = "# Title\n\nbefore\n\n\n\nafter paragraph here";
let mut whole = MarkdownState::new().with_image_resolver(Box::new(StubResolver));
whole.set(doc);
let _ = whole.lines(
30,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
);
let whole_row = whole.images()[0].row;
let mut streamed = MarkdownState::new().with_image_resolver(Box::new(StubResolver));
for chunk in [
"# Title\n\nbe",
"fore\n\n\n\nafter ",
"paragraph here",
] {
streamed.push_str(chunk);
let _ = streamed.lines(
30,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
);
}
assert_eq!(streamed.images().len(), 1);
assert_eq!(
streamed.images()[0].row,
whole_row,
"streamed image row matches one-shot"
);
}
#[test]
fn no_resolver_means_no_streaming_placements() {
let theme = Theme::default();
let mut md = MarkdownState::new();
md.set("");
let _ = md.lines(
40,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
);
assert!(md.images().is_empty(), "images() empty without a resolver");
}
#[test]
fn markdown_image_rect_offsets_by_area_and_row() {
let img = MarkdownImage {
row: 3,
indent: 2,
cols: 10,
rows: 4,
data: ImageData::from_rgba(2, 2, vec![0u8; 16]).unwrap(),
alt: String::new(),
};
let rect = img.rect(Rect::new(5, 1, 40, 20));
assert_eq!((rect.x, rect.y, rect.width, rect.height), (7, 4, 10, 4));
let tight = img.rect(Rect::new(5, 1, 8, 5));
assert_eq!(tight.width, 6, "clamped to area right edge");
}
}