use std::io::{self, IsTerminal, Read};
use std::path::PathBuf;
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers, MouseEventKind,
};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::prelude::*;
use ratatui::widgets::*;
use ratatui_image::picker::Picker;
use ratatui_image::protocol::StatefulProtocol;
use ratatui_image::{Resize, StatefulImage};
use crate::core::toc::{self, TocEntry};
use crate::core::watcher::Watch;
struct WrappedText {
source: Line<'static>,
lines: Vec<Line<'static>>,
}
impl WrappedText {
fn new(source: Line<'static>) -> Self {
Self {
lines: vec![source.clone()],
source,
}
}
fn rewrap(&mut self, width: usize) {
self.lines = wrap_line(&self.source, width);
}
fn height(&self) -> usize {
self.lines.len().max(1)
}
fn text(&self) -> String {
self.source
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
}
}
enum ContentElement {
TextLine(WrappedText),
Image {
protocol: Box<StatefulProtocol>,
_alt: String,
height: u16,
},
ImagePlaceholder(WrappedText),
}
impl ContentElement {
fn row_height(&self) -> usize {
match self {
Self::TextLine(text) | Self::ImagePlaceholder(text) => text.height(),
Self::Image { height, .. } => usize::from(*height),
}
}
}
fn rewrap_elements(elements: &mut [ContentElement], width: usize) {
for element in elements.iter_mut() {
if let ContentElement::TextLine(text) | ContentElement::ImagePlaceholder(text) = element {
text.rewrap(width);
}
}
}
fn str_width(s: &str) -> usize {
Span::raw(s).width()
}
fn char_width(ch: char) -> usize {
let mut buf = [0u8; 4];
str_width(ch.encode_utf8(&mut buf))
}
fn split_at_width(s: &str, width: usize) -> (&str, &str) {
let mut used = 0usize;
for (idx, ch) in s.char_indices() {
let cw = char_width(ch);
if used + cw > width {
return s.split_at(idx);
}
used += cw;
}
(s, "")
}
fn continuation_prefix(line: &Line<'_>) -> Span<'static> {
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
let indent_len = text.chars().take_while(|c| *c == ' ').count();
let indent = " ".repeat(indent_len);
let rest = &text[indent_len..];
const GUTTER: &str = "│ ";
if rest.starts_with(GUTTER) {
let style = line.spans.first().map(|s| s.style).unwrap_or_default();
return Span::styled(format!("{indent}{GUTTER}"), style);
}
const MARKERS: &[&str] = &["• ", "☑ ", "☐ ", "▎ ", "- ", "* "];
for marker in MARKERS {
if rest.starts_with(marker) {
return Span::raw(format!("{}{}", indent, " ".repeat(str_width(marker))));
}
}
if let Some(dot) = rest.find(". ") {
let num = &rest[..dot];
if !num.is_empty() && num.chars().all(|c| c.is_ascii_digit()) {
return Span::raw(format!("{}{}", indent, " ".repeat(dot + 2)));
}
}
Span::raw(indent)
}
struct WrapToken {
text: String,
style: Style,
is_space: bool,
}
fn tokenize(line: &Line<'_>) -> Vec<WrapToken> {
let mut tokens = Vec::new();
for span in &line.spans {
let mut chunk = String::new();
let mut chunk_is_space = false;
for ch in span.content.chars() {
let is_space = ch == ' ' || ch == '\t';
if !chunk.is_empty() && is_space != chunk_is_space {
tokens.push(WrapToken {
text: std::mem::take(&mut chunk),
style: span.style,
is_space: chunk_is_space,
});
}
chunk_is_space = is_space;
chunk.push(ch);
}
if !chunk.is_empty() {
tokens.push(WrapToken {
text: chunk,
style: span.style,
is_space: chunk_is_space,
});
}
}
tokens
}
fn wrap_line(line: &Line<'static>, width: usize) -> Vec<Line<'static>> {
if width == 0 || line.width() <= width {
return vec![line.clone()];
}
let prefix = continuation_prefix(line);
let prefix_width = str_width(&prefix.content);
let (prefix, prefix_width) = if prefix_width * 2 >= width {
(Span::raw(""), 0)
} else {
(prefix, prefix_width)
};
let mut folded: Vec<Vec<Span<'static>>> = Vec::new();
let mut current: Vec<Span<'static>> = Vec::new();
let mut current_width = 0usize;
for token in tokenize(line) {
let mut remaining: &str = &token.text;
loop {
let limit = if folded.is_empty() {
width
} else {
width - prefix_width
};
if token.is_space {
let opens_a_fold = current.is_empty() && !folded.is_empty();
if !opens_a_fold && current_width + str_width(remaining) <= limit {
current_width += str_width(remaining);
current.push(Span::styled(remaining.to_string(), token.style));
}
break;
}
let token_width = str_width(remaining);
if current_width + token_width <= limit {
current.push(Span::styled(remaining.to_string(), token.style));
current_width += token_width;
break;
}
if current_width > 0 {
folded.push(std::mem::take(&mut current));
current_width = 0;
continue;
}
let (head, tail) = split_at_width(remaining, limit);
let (head, tail) = if head.is_empty() {
let idx = remaining
.char_indices()
.nth(1)
.map_or(remaining.len(), |(i, _)| i);
remaining.split_at(idx)
} else {
(head, tail)
};
current.push(Span::styled(head.to_string(), token.style));
folded.push(std::mem::take(&mut current));
current_width = 0;
remaining = tail;
if remaining.is_empty() {
break;
}
}
}
if !current.is_empty() || folded.is_empty() {
folded.push(current);
}
folded
.into_iter()
.enumerate()
.map(|(i, spans)| {
if i == 0 || prefix_width == 0 {
Line::from(spans)
} else {
let mut with_prefix = Vec::with_capacity(spans.len() + 1);
with_prefix.push(prefix.clone());
with_prefix.extend(spans);
Line::from(with_prefix)
}
})
.collect()
}
#[cfg(target_os = "macos")]
fn reattach_stdin_to_terminal() {
use std::io::IsTerminal;
if io::stdin().is_terminal() {
return;
}
let mut buffer = [0_i8; libc::PATH_MAX as usize];
let rc = unsafe {
libc::ttyname_r(
libc::STDOUT_FILENO,
buffer.as_mut_ptr().cast(),
buffer.len(),
)
};
if rc != 0 {
crate::vlog!("stdin not reattached: no terminal on stdout (ttyname_r: {rc})");
return;
}
unsafe {
let fd = libc::open(buffer.as_ptr().cast(), libc::O_RDWR);
if fd < 0 {
crate::vlog!("stdin not reattached: {}", std::io::Error::last_os_error());
return;
}
if libc::dup2(fd, libc::STDIN_FILENO) < 0 {
crate::vlog!("stdin not reattached: {}", std::io::Error::last_os_error());
} else {
crate::vlog!(
"stdin reattached to {}",
std::ffi::CStr::from_ptr(buffer.as_ptr().cast()).to_string_lossy()
);
}
if fd != libc::STDIN_FILENO {
libc::close(fd);
}
}
}
pub fn run(file_path: PathBuf) -> Result<(), Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(&file_path)?;
let toc_entries = toc::extract_toc(&content);
if !io::stdout().is_terminal() {
return Err("tui backend requires a terminal (stdout is not a TTY)".into());
}
#[cfg(target_os = "macos")]
reattach_stdin_to_terminal();
enable_raw_mode()?;
let _restore = TerminalRestore;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let needs_picker = document_needs_picker(&content);
let rendered = build_content_elements(&content, &file_path, &None);
let watch = crate::core::watcher::watch_file(&file_path)?;
let mut app = TuiApp {
content,
rendered,
toc_entries,
file_path,
watch,
picker: None,
picker_queried: false,
content_width: 0,
scroll_offset: 0,
toc_selected: 0,
focus_toc: false,
should_quit: false,
search_mode: false,
search_query: String::new(),
search_matches: Vec::new(),
current_match_idx: 0,
};
terminal.draw(|f| ui(f, &mut app))?;
if needs_picker {
ensure_picker(&mut app);
if app.picker.is_some() {
rebuild_rendered(&mut app);
}
terminal.clear()?;
}
loop {
terminal.draw(|f| ui(f, &mut app))?;
if app.watch.changes().try_recv().is_ok() {
while app.watch.changes().try_recv().is_ok() {}
if let Ok(new_content) = std::fs::read_to_string(&app.file_path) {
app.toc_entries = toc::extract_toc(&new_content);
if document_needs_picker(&new_content) {
ensure_picker(&mut app);
}
app.content = new_content;
rebuild_rendered(&mut app);
}
}
if event::poll(std::time::Duration::from_millis(100))? {
let ev = event::read()?;
if let Event::Mouse(mouse) = &ev {
match mouse.kind {
MouseEventKind::ScrollDown => {
app.scroll_offset = app.scroll_offset.saturating_add(3);
}
MouseEventKind::ScrollUp => {
app.scroll_offset = app.scroll_offset.saturating_sub(3);
}
_ => {}
}
}
if let Event::Key(key) = ev {
if app.search_mode {
match key.code {
KeyCode::Esc => {
app.search_mode = false;
app.search_query.clear();
app.search_matches.clear();
app.current_match_idx = 0;
}
KeyCode::Enter => {
if !app.search_matches.is_empty() {
app.current_match_idx =
(app.current_match_idx + 1) % app.search_matches.len();
app.scroll_offset = app.search_matches[app.current_match_idx];
}
}
KeyCode::Backspace => {
app.search_query.pop();
update_search_matches(&mut app);
}
KeyCode::Char(c) => {
app.search_query.push(c);
update_search_matches(&mut app);
}
_ => {}
}
} else {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
KeyCode::Char('t') if is_theme_toggle(key.code, key.modifiers) => {
toggle_syntax_theme();
rebuild_rendered(&mut app);
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.should_quit = true;
}
KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.search_mode = true;
}
KeyCode::Char('/') => {
app.search_mode = true;
}
KeyCode::Char('n') => {
if !app.search_matches.is_empty() {
app.current_match_idx =
(app.current_match_idx + 1) % app.search_matches.len();
app.scroll_offset = app.search_matches[app.current_match_idx];
}
}
KeyCode::Char('N') => {
if !app.search_matches.is_empty() {
app.current_match_idx = if app.current_match_idx == 0 {
app.search_matches.len() - 1
} else {
app.current_match_idx - 1
};
app.scroll_offset = app.search_matches[app.current_match_idx];
}
}
KeyCode::Down | KeyCode::Char('j') => {
if app.focus_toc {
if app.toc_selected < app.toc_entries.len().saturating_sub(1) {
app.toc_selected += 1;
}
} else {
app.scroll_offset = app.scroll_offset.saturating_add(1);
}
}
KeyCode::Up | KeyCode::Char('k') => {
if app.focus_toc {
app.toc_selected = app.toc_selected.saturating_sub(1);
} else {
app.scroll_offset = app.scroll_offset.saturating_sub(1);
}
}
KeyCode::PageDown | KeyCode::Char(' ') => {
app.scroll_offset = app.scroll_offset.saturating_add(20);
}
KeyCode::PageUp => {
app.scroll_offset = app.scroll_offset.saturating_sub(20);
}
KeyCode::Home | KeyCode::Char('g') => {
app.scroll_offset = 0;
}
KeyCode::End | KeyCode::Char('G') => {
let total_rows = total_content_rows(&app.rendered);
app.scroll_offset = total_rows.saturating_sub(1);
}
KeyCode::Tab => {
app.focus_toc = !app.focus_toc;
}
KeyCode::Enter if app.focus_toc => {
if let Some(offset) =
find_heading_row(&app.rendered, &app.toc_entries, app.toc_selected)
{
app.scroll_offset = offset;
app.focus_toc = false;
}
}
_ => {}
}
}
}
}
if app.should_quit {
break;
}
}
Ok(())
}
struct TerminalRestore;
impl Drop for TerminalRestore {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(
io::stdout(),
LeaveAlternateScreen,
DisableMouseCapture,
crossterm::cursor::Show
);
}
}
struct TuiApp {
content: String,
rendered: Vec<ContentElement>,
toc_entries: Vec<TocEntry>,
file_path: PathBuf,
watch: Watch,
picker: Option<Picker>,
picker_queried: bool,
content_width: usize,
scroll_offset: usize,
toc_selected: usize,
focus_toc: bool,
should_quit: bool,
search_mode: bool,
search_query: String,
search_matches: Vec<usize>,
current_match_idx: usize,
}
fn ensure_picker(app: &mut TuiApp) {
if app.picker_queried {
return;
}
app.picker_queried = true;
app.picker = Picker::from_query_stdio().ok();
}
fn rebuild_rendered(app: &mut TuiApp) {
let content = std::mem::take(&mut app.content);
app.rendered = build_content_elements(&content, &app.file_path, &app.picker);
rewrap_elements(&mut app.rendered, app.content_width);
app.content = content;
}
fn compute_search_matches(elements: &[ContentElement], query: &str) -> Vec<usize> {
let mut matches = Vec::new();
if query.is_empty() {
return matches;
}
let query_lower = query.to_lowercase();
let mut row_offset: usize = 0;
for element in elements {
if let ContentElement::TextLine(text) | ContentElement::ImagePlaceholder(text) = element
&& text.text().to_lowercase().contains(&query_lower)
{
matches.push(row_offset);
}
row_offset += element.row_height();
}
matches
}
fn update_search_matches(app: &mut TuiApp) {
app.search_matches = compute_search_matches(&app.rendered, &app.search_query);
app.current_match_idx = 0;
if !app.search_matches.is_empty() {
app.scroll_offset = app.search_matches[0];
}
}
fn total_content_rows(elements: &[ContentElement]) -> usize {
elements.iter().map(ContentElement::row_height).sum()
}
fn ui(f: &mut Frame, app: &mut TuiApp) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(30), Constraint::Min(1)])
.split(f.area());
let toc_items: Vec<ListItem> = app
.toc_entries
.iter()
.map(|entry| {
let indent = " ".repeat((entry.level as usize).saturating_sub(1));
let style = match entry.level {
1 => Style::default().fg(Color::Cyan).bold(),
2 => Style::default().fg(Color::Blue).bold(),
3 => Style::default().fg(Color::White),
_ => Style::default().fg(Color::DarkGray),
};
ListItem::new(format!("{}{}", indent, entry.text)).style(style)
})
.collect();
let toc_border_style = if app.focus_toc {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
let toc = List::new(toc_items)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(toc_border_style)
.title(" TOC ")
.title_style(Style::default().bold()),
)
.highlight_style(Style::default().bg(Color::DarkGray).fg(Color::White))
.highlight_symbol(">> ");
let mut toc_state = ListState::default();
if app.focus_toc {
toc_state.select(Some(app.toc_selected));
}
f.render_stateful_widget(toc, chunks[0], &mut toc_state);
let content_area = chunks[1];
let inner_area = Block::default()
.borders(Borders::ALL)
.border_style(if !app.focus_toc {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
})
.title(format!(" {} ", app.file_path.display()))
.title_style(Style::default().bold())
.inner(content_area);
if inner_area.width as usize != app.content_width {
app.content_width = inner_area.width as usize;
rewrap_elements(&mut app.rendered, app.content_width);
app.search_matches = compute_search_matches(&app.rendered, &app.search_query);
if app.current_match_idx >= app.search_matches.len() {
app.current_match_idx = 0;
}
}
let content_height = inner_area.height as usize;
let total_rows = total_content_rows(&app.rendered);
let max_scroll = total_rows.saturating_sub(content_height);
let scroll = app.scroll_offset.min(max_scroll);
let scroll_info = format!(" {}/{} ", scroll + 1, total_rows.max(1));
let border_block = Block::default()
.borders(Borders::ALL)
.border_style(if !app.focus_toc {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
})
.title(format!(" {} ", app.file_path.display()))
.title_style(Style::default().bold())
.title_bottom(Line::from(scroll_info).right_aligned());
f.render_widget(border_block, content_area);
render_content_elements(
f,
inner_area,
&mut app.rendered,
scroll,
content_height,
&app.search_matches,
app.current_match_idx,
);
let bar_text = if app.search_mode {
let match_info = if app.search_matches.is_empty() {
if app.search_query.is_empty() {
String::new()
} else {
" (no matches)".to_string()
}
} else {
format!(
" ({}/{})",
app.current_match_idx + 1,
app.search_matches.len()
)
};
format!(
" /{}{} [Enter: next | Esc: close]",
app.search_query, match_info
)
} else if !app.search_matches.is_empty() {
format!(
" Search: '{}' ({}/{}) [n/N: next/prev | /: search]",
app.search_query,
app.current_match_idx + 1,
app.search_matches.len()
)
} else {
help_bar(usize::from(content_area.width.saturating_sub(2)))
};
let available = content_area.width.saturating_sub(2);
if content_area.height > 0 && available > 0 {
let wanted = Line::from(bar_text.as_str()).width();
let width = u16::try_from(wanted.min(usize::from(available)))
.expect("clipped to a u16 above, so it fits");
let help_area = Rect {
x: content_area.x + 1,
y: content_area.bottom() - 1,
width,
height: 1,
};
let bar_style = if app.search_mode {
Style::default()
.fg(Color::Yellow)
.bg(Color::Rgb(40, 40, 40))
} else {
Style::default().fg(Color::DarkGray)
};
let help_widget = Paragraph::new(bar_text).style(bar_style);
f.render_widget(help_widget, help_area);
}
}
fn render_content_elements(
f: &mut Frame,
area: Rect,
elements: &mut [ContentElement],
scroll: usize,
content_height: usize,
search_matches: &[usize],
current_match: usize,
) {
let mut rows_skipped: usize = 0;
let mut y_offset: u16 = 0;
let available_height = content_height as u16;
let mut absolute_row: usize = 0;
for element in elements.iter_mut() {
if y_offset >= available_height {
break;
}
let elem_height = element.row_height();
let current_absolute_row = absolute_row;
absolute_row += elem_height;
if rows_skipped + elem_height <= scroll {
rows_skipped += elem_height;
continue;
}
let skip_within = scroll.saturating_sub(rows_skipped);
rows_skipped += elem_height;
match element {
ContentElement::TextLine(text) | ContentElement::ImagePlaceholder(text) => {
let is_match = search_matches.contains(¤t_absolute_row);
let is_current =
is_match && search_matches.get(current_match) == Some(¤t_absolute_row);
for line in text.lines.iter().skip(skip_within) {
if y_offset >= available_height {
break;
}
let line_area = Rect {
x: area.x,
y: area.y + y_offset,
width: area.width,
height: 1,
};
let rendered = if is_match {
highlight_line(line, is_current)
} else {
line.clone()
};
f.render_widget(Paragraph::new(rendered), line_area);
y_offset += 1;
}
}
ContentElement::Image {
protocol, height, ..
} => {
let visible_height = (*height as usize).saturating_sub(skip_within) as u16;
if visible_height == 0 {
continue;
}
let remaining = available_height - y_offset;
let render_height = visible_height.min(remaining);
if render_height == 0 {
continue;
}
let img_area = Rect {
x: area.x,
y: area.y + y_offset,
width: area.width,
height: render_height,
};
let image_widget = StatefulImage::default().resize(Resize::Fit(None));
f.render_stateful_widget(image_widget, img_area, protocol.as_mut());
y_offset += render_height;
}
}
}
}
fn highlight_line(line: &Line<'static>, is_current: bool) -> Line<'static> {
Line::from(
line.spans
.iter()
.map(|s| {
let style = if is_current {
s.style.bg(Color::Yellow).fg(Color::Black)
} else {
s.style.bg(Color::Rgb(80, 80, 0))
};
Span::styled(s.content.clone(), style)
})
.collect::<Vec<_>>(),
)
}
fn find_heading_row(
elements: &[ContentElement],
toc_entries: &[TocEntry],
toc_index: usize,
) -> Option<usize> {
let entry = toc_entries.get(toc_index)?;
let search_text = &entry.text;
let mut row_offset: usize = 0;
for element in elements {
if let ContentElement::TextLine(text) | ContentElement::ImagePlaceholder(text) = element
&& text.text().contains(search_text)
{
return Some(row_offset);
}
row_offset += element.row_height();
}
None
}
fn build_content_elements(
content: &str,
file_path: &PathBuf,
picker: &Option<Picker>,
) -> Vec<ContentElement> {
let text_lines = markdown_to_lines_with_images(content);
let canonical_file = std::fs::canonicalize(file_path).unwrap_or_else(|_| {
std::env::current_dir().map_or_else(|_| file_path.clone(), |cwd| cwd.join(file_path))
});
let base_dir = crate::core::document_base_dir(&canonical_file);
let base_dir = base_dir.as_path();
let mut elements = Vec::new();
for item in text_lines {
match item {
ParsedLine::Text(line) => {
elements.push(ContentElement::TextLine(WrappedText::new(line)));
}
ParsedLine::MermaidRef { source } => {
match crate::core::mermaid::render_mermaid_to_svg(&source) {
Ok(svg) => {
match rasterize_svg(&svg) {
Ok(dyn_img) => {
if let Some(picker) = picker {
let (img_w, img_h) = (dyn_img.width(), dyn_img.height());
let aspect = f64::from(img_h) / f64::from(img_w);
let target_cols = 100u16;
let target_rows =
(f64::from(target_cols) * aspect / 2.0).ceil() as u16;
let height = target_rows.clamp(4, 40);
let protocol = Box::new(picker.new_resize_protocol(dyn_img));
elements.push(ContentElement::Image {
protocol,
_alt: "mermaid diagram".to_string(),
height,
});
} else {
push_mermaid_fallback_code(&mut elements, &source);
}
}
Err(_) => {
push_mermaid_fallback_code(&mut elements, &source);
}
}
}
Err(_) => {
push_mermaid_fallback_code(&mut elements, &source);
}
}
}
ParsedLine::ImageRef { alt, url } => {
if let Some(picker) = picker {
match load_image(&url, base_dir) {
Ok(dyn_img) => {
let (img_w, img_h) = (dyn_img.width(), dyn_img.height());
let aspect = f64::from(img_h) / f64::from(img_w);
let target_cols = 100u16;
let target_rows = (f64::from(target_cols) * aspect / 2.0).ceil() as u16;
let height = target_rows.clamp(4, 40);
let protocol = Box::new(picker.new_resize_protocol(dyn_img));
elements.push(ContentElement::Image {
protocol,
_alt: alt,
height,
});
}
Err(_) => {
let label = if alt.is_empty() {
"image".to_string()
} else {
alt
};
elements.push(ContentElement::ImagePlaceholder(WrappedText::new(
Line::from(Span::styled(
format!("[Image: {label}]"),
Style::default().fg(Color::Magenta).italic(),
)),
)));
}
}
} else {
let label = if alt.is_empty() {
"image".to_string()
} else {
alt
};
elements.push(ContentElement::ImagePlaceholder(WrappedText::new(
Line::from(Span::styled(
format!("[Image: {label}]"),
Style::default().fg(Color::Magenta).italic(),
)),
)));
}
}
}
}
elements
}
fn push_mermaid_fallback_code(elements: &mut Vec<ContentElement>, source: &str) {
elements.push(ContentElement::TextLine(WrappedText::new(Line::from(
Span::styled(
code_frame_top("mermaid"),
Style::default().fg(Color::DarkGray),
),
))));
for line in source.lines() {
elements.push(ContentElement::TextLine(WrappedText::new(Line::from(
Span::styled(format!("│ {line}"), Style::default().fg(Color::Green)),
))));
}
elements.push(ContentElement::TextLine(WrappedText::new(Line::from(
Span::styled(CODE_FRAME_BOTTOM, Style::default().fg(Color::DarkGray)),
))));
elements.push(ContentElement::TextLine(WrappedText::new(Line::from(""))));
}
type LoadedImage = Result<image::DynamicImage, Box<dyn std::error::Error>>;
fn load_image(
url: &str,
base_dir: &std::path::Path,
) -> Result<image::DynamicImage, Box<dyn std::error::Error>> {
load_image_with(url, base_dir, crate::core::offline(), &load_image_from_http)
}
fn load_image_with(
url: &str,
base_dir: &std::path::Path,
offline: bool,
fetch: &dyn Fn(&str) -> LoadedImage,
) -> LoadedImage {
if url.starts_with("data:") {
load_image_from_data_uri(url)
} else if url.starts_with("http://") || url.starts_with("https://") {
if offline {
return Err("offline: remote images are not fetched".into());
}
fetch(url)
} else {
let path = if std::path::Path::new(url).is_absolute() {
PathBuf::from(url)
} else {
base_dir.join(url)
};
if path.exists() && !crate::core::paths::is_within_image_root(&path, base_dir) {
return Err("path traversal blocked: image path escapes the project directory".into());
}
crate::core::image_validation::validate_image_file(&path)
.map_err(|e| format!("invalid image file: {e}"))?;
if path.extension().and_then(|e| e.to_str()) == Some("svg") {
let svg_data = std::fs::read_to_string(&path)?;
return rasterize_svg(&svg_data);
}
let img = image::open(&path)?;
Ok(img)
}
}
fn load_image_from_data_uri(uri: &str) -> Result<image::DynamicImage, Box<dyn std::error::Error>> {
const MAX_DATA_URI_LEN: usize = 50 * 1024 * 1024; if uri.len() > MAX_DATA_URI_LEN {
return Err(format!(
"data URI too large ({} bytes, max {})",
uri.len(),
MAX_DATA_URI_LEN
)
.into());
}
let comma_pos = uri.find(',').ok_or("Invalid data URI: no comma found")?;
let header = &uri[..comma_pos];
let data_part = &uri[comma_pos + 1..];
let decoded = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, data_part)?;
if header.contains("image/svg") {
let svg_str = String::from_utf8(decoded)?;
return rasterize_svg(&svg_str);
}
let img = image::load_from_memory(&decoded)?;
Ok(img)
}
fn rasterize_svg(svg_data: &str) -> Result<image::DynamicImage, Box<dyn std::error::Error>> {
let options = crate::core::svg::options();
let tree = usvg::Tree::from_str(svg_data, &options)?;
let size = tree.size();
let (svg_w, svg_h) = (size.width(), size.height());
const MAX_TEXTURE_SIZE: u32 = 8192;
let scale = if svg_w > MAX_TEXTURE_SIZE as f32 || svg_h > MAX_TEXTURE_SIZE as f32 {
let scale_w = MAX_TEXTURE_SIZE as f32 / svg_w;
let scale_h = MAX_TEXTURE_SIZE as f32 / svg_h;
scale_w.min(scale_h).min(1.0) } else {
1.0
};
let width = (svg_w * scale) as u32;
let height = (svg_h * scale) as u32;
if width == 0 || height == 0 {
return Err("SVG has zero dimensions".into());
}
let mut pixmap = tiny_skia::Pixmap::new(width, height).ok_or("Failed to create pixmap")?;
resvg::render(
&tree,
tiny_skia::Transform::from_scale(scale, scale),
&mut pixmap.as_mut(),
);
let img = image::RgbaImage::from_raw(width, height, pixmap.data().to_vec())
.ok_or("Failed to create image from pixmap")?;
Ok(image::DynamicImage::ImageRgba8(img))
}
fn load_image_from_http(url: &str) -> Result<image::DynamicImage, Box<dyn std::error::Error>> {
use std::sync::OnceLock;
static AGENT: OnceLock<ureq::Agent> = OnceLock::new();
let agent = AGENT.get_or_init(|| {
ureq::Agent::config_builder()
.timeout_global(Some(std::time::Duration::from_secs(30)))
.build()
.into()
});
let response = agent.get(url).call()?;
let mut bytes = Vec::new();
response.into_body().into_reader().read_to_end(&mut bytes)?;
let img = image::load_from_memory(&bytes)?;
Ok(img)
}
enum ParsedLine {
Text(Line<'static>),
ImageRef {
alt: String,
url: String,
},
MermaidRef {
source: String,
},
}
fn document_needs_picker(content: &str) -> bool {
markdown_to_lines_with_images(content).iter().any(|item| {
matches!(
item,
ParsedLine::ImageRef { .. } | ParsedLine::MermaidRef { .. }
)
})
}
fn is_theme_toggle(code: KeyCode, modifiers: KeyModifiers) -> bool {
code == KeyCode::Char('t')
&& !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
}
const HELP_HINTS: &[&str] = &[
"q: quit",
"j/k: scroll",
"/: search",
"t: theme",
"Tab: focus",
"Space/PgDn: page",
];
fn help_bar(columns: usize) -> String {
let mut bar = String::new();
for hint in HELP_HINTS {
let separator = if bar.is_empty() { 0 } else { 3 };
if str_width(&bar) + separator + str_width(hint) + 2 > columns {
break;
}
if !bar.is_empty() {
bar.push_str(" | ");
}
bar.push_str(hint);
}
if bar.is_empty() {
bar
} else {
format!(" {bar} ")
}
}
const CODE_FRAME_BOTTOM: &str = "└─────────────────────────────────────────┘";
fn code_frame_top(label: &str) -> String {
let inner = str_width(CODE_FRAME_BOTTOM).saturating_sub(2);
let opening = format!("─ {label} ");
let fill = inner.saturating_sub(str_width(&opening));
format!("┌{}{}┐", opening, "─".repeat(fill))
}
fn terminal_background_is_light(colorfgbg: Option<&str>) -> Option<bool> {
let value = colorfgbg?;
let bg = value.rsplit(';').next()?.trim();
let index: u8 = bg.parse().ok()?;
match index {
0..=6 | 8 => Some(false),
7 | 9..=15 => Some(true),
_ => None,
}
}
fn syntax_prefers_light(setting: crate::core::Theme, colorfgbg: Option<&str>) -> bool {
match setting {
crate::core::Theme::Light => true,
crate::core::Theme::Dark => false,
crate::core::Theme::Auto => terminal_background_is_light(colorfgbg).unwrap_or(false),
}
}
const LIGHT_SYNTAX_THEME: &str = "InspiredGitHub";
const DARK_SYNTAX_THEME: &str = "base16-ocean.dark";
fn syntax_is_light() -> &'static std::sync::atomic::AtomicBool {
use std::sync::OnceLock;
static CURRENT: OnceLock<std::sync::atomic::AtomicBool> = OnceLock::new();
CURRENT.get_or_init(|| {
std::sync::atomic::AtomicBool::new(syntax_prefers_light(
crate::core::theme(),
std::env::var("COLORFGBG").ok().as_deref(),
))
})
}
fn toggle_syntax_theme() -> bool {
flip(syntax_is_light())
}
fn flip(flag: &std::sync::atomic::AtomicBool) -> bool {
use std::sync::atomic::Ordering;
let flipped = !flag.load(Ordering::Relaxed);
flag.store(flipped, Ordering::Relaxed);
flipped
}
fn syntax_assets() -> &'static SyntaxAssets {
use std::sync::OnceLock;
static ASSETS: OnceLock<SyntaxAssets> = OnceLock::new();
ASSETS.get_or_init(|| {
let syntaxes = syntect::parsing::SyntaxSet::load_defaults_newlines();
let mut themes = syntect::highlighting::ThemeSet::load_defaults();
let dark = themes.themes.remove(DARK_SYNTAX_THEME).unwrap_or_default();
let light = themes
.themes
.remove(LIGHT_SYNTAX_THEME)
.unwrap_or_else(|| dark.clone());
SyntaxAssets {
syntaxes,
light,
dark,
}
})
}
struct SyntaxAssets {
syntaxes: syntect::parsing::SyntaxSet,
light: syntect::highlighting::Theme,
dark: syntect::highlighting::Theme,
}
impl SyntaxAssets {
fn theme(&self) -> &syntect::highlighting::Theme {
if syntax_is_light().load(std::sync::atomic::Ordering::Relaxed) {
&self.light
} else {
&self.dark
}
}
}
fn syntax_background() -> Option<Color> {
let bg = syntax_assets().theme().settings.background?;
Some(Color::Rgb(bg.r, bg.g, bg.b))
}
fn syntax_foreground() -> Option<Color> {
let fg = syntax_assets().theme().settings.foreground?;
Some(Color::Rgb(fg.r, fg.g, fg.b))
}
fn highlight_code(code: &str, lang: &str) -> Vec<Vec<Span<'static>>> {
let plain = |code: &str| -> Vec<Vec<Span<'static>>> {
let mut style = Style::default().fg(syntax_foreground().unwrap_or(Color::Green));
if let Some(bg) = syntax_background() {
style = style.bg(bg);
}
code.lines()
.map(|l| vec![Span::styled(l.to_string(), style)])
.collect()
};
let assets = syntax_assets();
let (syntaxes, theme) = (&assets.syntaxes, assets.theme());
let Some(syntax) = syntaxes
.find_syntax_by_token(lang)
.or_else(|| syntaxes.find_syntax_by_extension(lang))
else {
return plain(code);
};
let background = syntax_background();
let mut highlighter = syntect::easy::HighlightLines::new(syntax, theme);
let mut out = Vec::new();
for line in code.lines() {
let with_newline = format!("{line}\n");
match highlighter.highlight_line(&with_newline, syntaxes) {
Ok(ranges) => out.push(
ranges
.into_iter()
.map(|(style, text)| {
let c = style.foreground;
let mut span_style = Style::default().fg(Color::Rgb(c.r, c.g, c.b));
if let Some(bg) = background {
span_style = span_style.bg(bg);
}
Span::styled(text.trim_end_matches('\n').to_string(), span_style)
})
.filter(|s| !s.content.is_empty())
.collect(),
),
Err(_) => return plain(code),
}
}
out
}
#[derive(Clone, Copy, Default)]
struct BlockCtx {
indent: usize,
quote: usize,
tight: bool,
}
impl BlockCtx {
fn indented(self, by: usize) -> Self {
Self {
indent: self.indent + by,
..self
}
}
fn quoted(self) -> Self {
Self {
quote: self.quote + 1,
..self
}
}
fn tight(self, tight: bool) -> Self {
Self { tight, ..self }
}
fn prefix(self) -> Vec<Span<'static>> {
let mut spans = Vec::new();
if self.indent > 0 {
spans.push(Span::raw(" ".repeat(self.indent)));
}
for _ in 0..self.quote {
spans.push(Span::styled("▎ ", Style::default().fg(Color::DarkGray)));
}
spans
}
}
struct MdRenderer {
out: Vec<ParsedLine>,
footnotes: Vec<(String, Vec<ParsedLine>)>,
}
type AstNode<'a> = comrak::arena_tree::Node<'a, std::cell::RefCell<comrak::nodes::Ast>>;
impl MdRenderer {
fn new() -> Self {
Self {
out: Vec::new(),
footnotes: Vec::new(),
}
}
fn push(&mut self, ctx: BlockCtx, mut spans: Vec<Span<'static>>) {
let mut line = ctx.prefix();
line.append(&mut spans);
self.out.push(ParsedLine::Text(Line::from(line)));
}
fn blank(&mut self) {
if matches!(self.out.last(), None | Some(ParsedLine::Text(_)))
&& self.plain_last().is_some_and(|t| t.trim().is_empty())
{
return;
}
if self.out.is_empty() {
return;
}
self.out.push(ParsedLine::Text(Line::from("")));
}
fn plain_last(&self) -> Option<String> {
match self.out.last() {
Some(ParsedLine::Text(l)) => Some(l.spans.iter().map(|s| s.content.as_ref()).collect()),
_ => None,
}
}
fn children<'a>(&mut self, node: &'a AstNode<'a>, ctx: BlockCtx) {
for child in node.children() {
self.block(child, ctx);
}
}
fn block<'a>(&mut self, node: &'a AstNode<'a>, ctx: BlockCtx) {
use comrak::nodes::{ListType, NodeValue};
let value = node.data.borrow().value.clone();
match value {
NodeValue::Document => self.children(node, ctx),
NodeValue::FrontMatter(_) => {}
NodeValue::Heading(h) => {
let text: String = inline_text(node);
let spans = inlines(node, heading_style(h.level));
if h.level <= 2 {
self.blank();
}
self.push(ctx, spans);
if let Some(rule) = heading_rule(h.level, &text) {
self.push(ctx, vec![rule]);
}
self.blank();
}
NodeValue::Paragraph => {
if let Some(image) = lone_image(node) {
self.out.push(image);
return;
}
self.push(ctx, inlines(node, Style::default()));
if !ctx.tight {
self.blank();
}
}
NodeValue::BlockQuote => {
self.children(node, ctx.quoted());
self.blank();
}
NodeValue::CodeBlock(code) => {
let lang = code
.info
.split_whitespace()
.next()
.unwrap_or("")
.to_string();
if lang == "mermaid" {
self.out.push(ParsedLine::MermaidRef {
source: code.literal.trim_end().to_string(),
});
return;
}
let background = syntax_background();
let mut gutter = Style::default().fg(Color::DarkGray);
if let Some(bg) = background {
gutter = gutter.bg(bg);
}
let width = str_width(CODE_FRAME_BOTTOM);
let label = if lang.is_empty() { "code" } else { &lang };
self.push(ctx, vec![Span::styled(code_frame_top(label), gutter)]);
for mut spans in highlight_code(code.literal.trim_end_matches('\n'), &lang) {
let mut line = vec![Span::styled("│ ", gutter)];
line.append(&mut spans);
let drawn: usize = line.iter().map(|s| str_width(&s.content)).sum();
if let Some(missing) = width.checked_sub(drawn)
&& missing > 0
{
line.push(Span::styled(" ".repeat(missing), gutter));
}
self.push(ctx, line);
}
self.push(ctx, vec![Span::styled(CODE_FRAME_BOTTOM, gutter)]);
self.blank();
}
NodeValue::List(list) => {
self.children(node, ctx.tight(list.tight));
if !ctx.tight {
self.blank();
}
}
NodeValue::Item(list) => {
let marker = match list.list_type {
ListType::Bullet => "• ".to_string(),
ListType::Ordered => format!("{}. ", list.start),
};
self.list_item(node, ctx, marker);
}
NodeValue::TaskItem(task) => {
let marker = if task.symbol.is_some() {
"☑ "
} else {
"☐ "
};
self.list_item(node, ctx, marker.to_string());
}
NodeValue::ThematicBreak => {
self.push(
ctx,
vec![Span::styled(
"─".repeat(60),
Style::default().fg(Color::DarkGray),
)],
);
self.blank();
}
NodeValue::Table(table) => self.table(node, ctx, &table.alignments),
NodeValue::FootnoteDefinition(def) => {
let mut sub = Self::new();
sub.children(node, BlockCtx::default());
self.footnotes.push((def.name, sub.out));
}
NodeValue::HtmlBlock(html) => {
for line in html.literal.lines() {
self.push(
ctx,
vec![Span::styled(
line.to_string(),
Style::default().fg(Color::DarkGray),
)],
);
}
self.blank();
}
_ => self.children(node, ctx),
}
}
fn list_item<'a>(&mut self, node: &'a AstNode<'a>, ctx: BlockCtx, marker: String) {
let before = self.out.len();
self.children(node, ctx.indented(marker.chars().count()));
if let Some(ParsedLine::Text(line)) = self.out.get_mut(before) {
let indent = ctx.indent;
let mut spans = std::mem::take(&mut line.spans);
if !spans.is_empty() && spans[0].content.chars().all(|c| c == ' ') {
spans.remove(0);
}
let mut prefixed = Vec::new();
if indent > 0 {
prefixed.push(Span::raw(" ".repeat(indent)));
}
prefixed.push(Span::styled(marker, Style::default().fg(Color::Cyan)));
prefixed.append(&mut spans);
*line = Line::from(prefixed);
}
}
fn table<'a>(
&mut self,
node: &'a AstNode<'a>,
ctx: BlockCtx,
alignments: &[comrak::nodes::TableAlignment],
) {
use comrak::nodes::NodeValue;
let mut rows: Vec<(bool, Vec<Vec<Span<'static>>>)> = Vec::new();
for row in node.children() {
let NodeValue::TableRow(is_header) = row.data.borrow().value else {
continue;
};
let cells: Vec<Vec<Span<'static>>> = row
.children()
.map(|cell| {
let style = if is_header {
Style::default().bold()
} else {
Style::default()
};
inlines(cell, style)
})
.collect();
rows.push((is_header, cells));
}
if rows.is_empty() {
return;
}
let columns = rows.iter().map(|(_, c)| c.len()).max().unwrap_or(0);
let mut widths = vec![0usize; columns];
for (_, cells) in &rows {
for (i, cell) in cells.iter().enumerate() {
let w: usize = cell.iter().map(ratatui::prelude::Span::width).sum();
widths[i] = widths[i].max(w);
}
}
let sep = Style::default().fg(Color::DarkGray);
for (index, (is_header, cells)) in rows.iter().enumerate() {
let mut line: Vec<Span<'static>> = Vec::new();
for (col, width) in widths.iter().enumerate() {
if col > 0 {
line.push(Span::styled(" │ ", sep));
}
let empty = Vec::new();
let cell = cells.get(col).unwrap_or(&empty);
let used: usize = cell.iter().map(ratatui::prelude::Span::width).sum();
let pad = width.saturating_sub(used);
let align = alignments
.get(col)
.copied()
.unwrap_or(comrak::nodes::TableAlignment::None);
let (left, right) = match align {
comrak::nodes::TableAlignment::Right => (pad, 0),
comrak::nodes::TableAlignment::Center => (pad / 2, pad - pad / 2),
_ => (0, pad),
};
if left > 0 {
line.push(Span::raw(" ".repeat(left)));
}
line.extend(cell.iter().cloned());
if right > 0 {
line.push(Span::raw(" ".repeat(right)));
}
}
self.push(ctx, line);
if *is_header || (index == 0 && rows.len() > 1) {
let rule: Vec<Span<'static>> = (0..columns)
.map(|col| {
let mut s = String::new();
if col > 0 {
s.push_str("─┼─");
}
s.push_str(&"─".repeat(widths[col]));
Span::styled(s, sep)
})
.collect();
self.push(ctx, rule);
}
}
self.blank();
}
fn finish(mut self) -> Vec<ParsedLine> {
if !self.footnotes.is_empty() {
let notes = std::mem::take(&mut self.footnotes);
self.blank();
self.push(
BlockCtx::default(),
vec![Span::styled(
"─".repeat(20),
Style::default().fg(Color::DarkGray),
)],
);
for (name, body) in notes {
let mut body = body.into_iter();
if let Some(ParsedLine::Text(first)) = body.next() {
let mut spans = vec![Span::styled(
format!("[{name}] "),
Style::default().fg(Color::Yellow).bold(),
)];
spans.extend(first.spans);
self.out.push(ParsedLine::Text(Line::from(spans)));
}
self.out.extend(body);
}
}
while matches!(self.plain_last(), Some(t) if t.trim().is_empty()) {
self.out.pop();
}
self.out
}
}
fn heading_style(level: u8) -> Style {
let base = Style::default().bold();
match level {
1 => base.fg(Color::Cyan).underlined(),
2 => base.fg(Color::Blue),
3 => base.fg(Color::Yellow),
4 => base.fg(Color::Magenta),
5 => base.fg(Color::Green),
_ => base.fg(Color::Gray),
}
}
fn heading_rule(level: u8, text: &str) -> Option<Span<'static>> {
let width = str_width(text);
match level {
1 => Some(Span::styled(
"═".repeat(width.min(60)),
Style::default().fg(Color::Cyan),
)),
2 => Some(Span::styled(
"─".repeat(width.min(50)),
Style::default().fg(Color::Blue),
)),
_ => None,
}
}
fn lone_image<'a>(paragraph: &'a AstNode<'a>) -> Option<ParsedLine> {
use comrak::nodes::NodeValue;
let mut image = None;
for child in paragraph.children() {
match &child.data.borrow().value {
NodeValue::Image(link) => {
if image.is_some() {
return None;
}
image = Some((inline_text(child), link.url.clone()));
}
NodeValue::Text(t) if t.trim().is_empty() => {}
NodeValue::SoftBreak => {}
_ => return None,
}
}
image.map(|(alt, url)| ParsedLine::ImageRef { alt, url })
}
fn inline_text<'a>(node: &'a AstNode<'a>) -> String {
use comrak::nodes::NodeValue;
let mut out = String::new();
for child in node.descendants() {
match &child.data.borrow().value {
NodeValue::Text(t) => out.push_str(t),
NodeValue::Code(c) => out.push_str(&c.literal),
NodeValue::SoftBreak | NodeValue::LineBreak => out.push(' '),
_ => {}
}
}
out
}
fn inlines<'a>(node: &'a AstNode<'a>, base: Style) -> Vec<Span<'static>> {
let mut spans = Vec::new();
for child in node.children() {
inline_into(child, base, &mut spans);
}
if spans.is_empty() {
spans.push(Span::styled(String::new(), base));
}
spans
}
fn inline_into<'a>(node: &'a AstNode<'a>, style: Style, out: &mut Vec<Span<'static>>) {
use comrak::nodes::NodeValue;
let value = node.data.borrow().value.clone();
match value {
NodeValue::Text(text) => out.push(Span::styled(text.to_string(), style)),
NodeValue::Code(code) => out.push(Span::styled(
code.literal,
style.fg(Color::Green).bg(Color::Rgb(40, 40, 40)),
)),
NodeValue::Emph => descend(node, style.italic(), out),
NodeValue::Strong => descend(node, style.bold(), out),
NodeValue::Strikethrough => descend(node, style.crossed_out(), out),
NodeValue::Underline => descend(node, style.underlined(), out),
NodeValue::SoftBreak | NodeValue::LineBreak => out.push(Span::styled(" ", style)),
NodeValue::Link(_) => descend(node, style.fg(Color::Blue).underlined(), out),
NodeValue::Image(link) => {
let alt = inline_text(node);
let label = if alt.is_empty() {
link.url.clone()
} else {
alt
};
out.push(Span::styled(
format!("[{label}]"),
style.fg(Color::Magenta).italic(),
));
}
NodeValue::FootnoteReference(fr) => out.push(Span::styled(
format!("[{}]", fr.name),
style.fg(Color::Yellow),
)),
NodeValue::HtmlInline(html) => {
out.push(Span::styled(html, style.fg(Color::DarkGray)));
}
NodeValue::Escaped => descend(node, style, out),
_ => descend(node, style, out),
}
}
fn descend<'a>(node: &'a AstNode<'a>, style: Style, out: &mut Vec<Span<'static>>) {
for child in node.children() {
inline_into(child, style, out);
}
}
fn markdown_to_lines_with_images(content: &str) -> Vec<ParsedLine> {
use comrak::{Arena, Options, parse_document};
let arena = Arena::new();
let mut options = Options::default();
options.extension.strikethrough = true;
options.extension.table = true;
options.extension.autolink = true;
options.extension.tasklist = true;
options.extension.footnotes = true;
options.extension.front_matter_delimiter = Some("---".to_string());
let root = parse_document(&arena, content, &options);
let mut renderer = MdRenderer::new();
renderer.block(root, BlockCtx::default());
renderer.finish()
}
#[cfg(test)]
mod tests {
use super::*;
fn app_for_drawing(content: &str) -> TuiApp {
let (_tx, watch) = Watch::detached();
TuiApp {
content: content.to_string(),
rendered: build_content_elements(content, &PathBuf::from("t.md"), &None),
toc_entries: crate::core::toc::extract_toc(content),
file_path: PathBuf::from("t.md"),
watch,
picker: None,
picker_queried: true,
content_width: 0,
scroll_offset: 0,
toc_selected: 0,
focus_toc: false,
should_quit: false,
search_mode: false,
search_query: String::new(),
search_matches: Vec::new(),
current_match_idx: 0,
}
}
fn search_bar_width(query: &str) -> usize {
let mut app = app_for_drawing("# Titre\n\nDu texte.\n");
app.search_mode = true;
app.search_query = query.to_string();
let backend = ratatui::backend::TestBackend::new(80, 10);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| ui(f, &mut app)).unwrap();
let buffer = terminal.backend().buffer();
let bottom = buffer.area.height - 1;
(0..buffer.area.width)
.filter(|x| buffer[(*x, bottom)].style().bg == Some(Color::Rgb(40, 40, 40)))
.count()
}
#[test]
fn an_oversized_svg_is_scaled_down_before_it_is_rasterised() {
let huge = r#"<svg xmlns="http://www.w3.org/2000/svg" width="40000" height="20000"><rect width="10" height="10"/></svg>"#;
let img = rasterize_svg(huge).expect("an oversized SVG must still render");
assert!(
img.width() <= 8192 && img.height() <= 8192,
"expected a capped surface, got {}x{}",
img.width(),
img.height()
);
assert!(
img.width() > 0 && img.height() > 0,
"the aspect ratio must survive the scaling"
);
let small = r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="10" height="10"/></svg>"#;
let img = rasterize_svg(small).expect("a small SVG must render");
assert_eq!((img.width(), img.height()), (40, 20));
}
#[test]
fn a_document_taller_than_u16_keeps_its_real_height() {
let tall = u16::MAX as usize + 10;
let mut text = WrappedText::new(Line::from("x"));
text.lines = vec![Line::from("x"); tall];
let elements = vec![ContentElement::TextLine(text)];
assert_eq!(elements[0].row_height(), tall);
assert_eq!(
total_content_rows(&elements),
tall,
"the document's height must survive being taller than a u16"
);
}
#[test]
fn the_bottom_bar_is_measured_in_columns_not_bytes() {
let ascii = search_bar_width("aa");
let accented = search_bar_width("éé");
assert!(ascii > 0, "the search bar should be drawn at all");
assert_eq!(
ascii, accented,
"two queries that are the same width on screen must fill the same cells"
);
}
#[test]
fn drawing_into_a_terminal_with_no_rows_does_not_panic() {
let mut app = app_for_drawing("# Title\n\nText.\n");
for (w, h) in [(0, 0), (1, 0), (0, 1), (1, 1), (2, 1), (2, 2), (3, 1)] {
let backend = ratatui::backend::TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| ui(f, &mut app)).unwrap();
}
}
use std::io::Write;
#[test]
fn load_image_svg_local_file() {
let dir = std::env::temp_dir().join("mdr_test_svg");
std::fs::create_dir_all(&dir).unwrap();
let svg_path = dir.join("test.svg");
let mut f = std::fs::File::create(&svg_path).unwrap();
write!(f, r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100" fill="red"/></svg>"#).unwrap();
let result = load_image("test.svg", &dir);
assert!(
result.is_ok(),
"load_image should handle SVG files but got: {:?}",
result.err()
);
let img = result.unwrap();
assert!(img.width() > 0 && img.height() > 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn build_content_elements_with_local_svg() {
let dir = std::env::temp_dir().join("mdr_test_svg_content");
std::fs::create_dir_all(&dir).unwrap();
let svg_path = dir.join("logo.svg");
let mut f = std::fs::File::create(&svg_path).unwrap();
write!(f, r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100" fill="red"/></svg>"#).unwrap();
let md = "# Hello\n\n\n\nSome text after.\n";
let md_path = dir.join("test.md");
std::fs::write(&md_path, md).unwrap();
let elements = build_content_elements(md, &md_path, &None);
let has_image_ref = elements
.iter()
.any(|e| matches!(e, ContentElement::ImagePlaceholder(_)));
assert!(
has_image_ref,
"Should find an image placeholder for the SVG reference"
);
let img = load_image("logo.svg", &dir);
assert!(
img.is_ok(),
"load_image should rasterize SVG, got: {:?}",
img.err()
);
let img = img.unwrap();
assert_eq!(img.width(), 100);
assert_eq!(img.height(), 100);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_image_svg_data_uri() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50"><circle cx="25" cy="25" r="20" fill="blue"/></svg>"#;
let b64 =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, svg.as_bytes());
let data_uri = format!("data:image/svg+xml;base64,{b64}");
let result = load_image(&data_uri, std::path::Path::new("."));
assert!(
result.is_ok(),
"load_image should handle SVG data URIs but got: {:?}",
result.err()
);
}
#[test]
fn mermaid_block_produces_mermaid_ref() {
let md = "# Title\n\n```mermaid\ngraph LR\n A-->B\n```\n\nSome text after.\n";
let items = markdown_to_lines_with_images(md);
let has_mermaid_ref = items
.iter()
.any(|item| matches!(item, ParsedLine::MermaidRef { .. }));
assert!(
has_mermaid_ref,
"Mermaid code block should produce a MermaidRef variant"
);
let mermaid_source = items
.iter()
.find_map(|item| {
if let ParsedLine::MermaidRef { source } = item {
Some(source.clone())
} else {
None
}
})
.expect("Should have a MermaidRef");
assert!(
mermaid_source.contains("graph LR"),
"MermaidRef should contain the mermaid source, got: {mermaid_source}"
);
assert!(
mermaid_source.contains("A-->B"),
"MermaidRef should contain the diagram content"
);
}
#[test]
fn mermaid_block_not_rendered_as_code_text() {
let md = "```mermaid\ngraph LR\n A-->B\n```\n";
let items = markdown_to_lines_with_images(md);
let has_green_code = items.iter().any(|item| {
if let ParsedLine::Text(line) = item {
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
text.contains("│ graph LR") || text.contains("│ A-->B")
} else {
false
}
});
assert!(
!has_green_code,
"Mermaid content should NOT appear as regular code text"
);
}
#[test]
fn non_mermaid_code_block_unchanged() {
let md = "```rust\nfn main() {}\n```\n";
let items = markdown_to_lines_with_images(md);
let has_mermaid_ref = items
.iter()
.any(|item| matches!(item, ParsedLine::MermaidRef { .. }));
assert!(
!has_mermaid_ref,
"Non-mermaid code blocks should NOT produce MermaidRef"
);
let has_code_text = items.iter().any(|item| {
if let ParsedLine::Text(line) = item {
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
text.contains("│ fn main()")
} else {
false
}
});
assert!(
has_code_text,
"Non-mermaid code should appear as regular code text"
);
}
fn plain_text(line: &Line) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn a_line_shorter_than_the_width_is_left_alone() {
let line = Line::from("hello world");
let out = wrap_line(&line, 40);
assert_eq!(out.len(), 1);
assert_eq!(plain_text(&out[0]), "hello world");
}
#[test]
fn a_long_line_is_folded_at_word_boundaries() {
let line = Line::from("the quick brown fox jumps over the lazy dog");
let out = wrap_line(&line, 20);
assert!(out.len() > 1, "a 43-column line must not fit in 20 columns");
for l in &out {
assert!(l.width() <= 20, "line too wide: {:?}", plain_text(l));
}
let joined = out
.iter()
.map(|l| plain_text(l).trim_end().to_string())
.collect::<Vec<_>>()
.join(" ");
assert_eq!(joined, "the quick brown fox jumps over the lazy dog");
}
#[test]
fn a_word_longer_than_the_width_is_hard_split() {
let line = Line::from("supercalifragilisticexpialidocious");
let out = wrap_line(&line, 10);
for l in &out {
assert!(l.width() <= 10, "line too wide: {:?}", plain_text(l));
}
let joined: String = out.iter().map(|l| plain_text(l)).collect();
assert_eq!(joined, "supercalifragilisticexpialidocious");
}
#[test]
fn wrapping_keeps_the_style_of_every_span() {
let line = Line::from(vec![
Span::styled("aaaa bbbb ", Style::default().fg(Color::Red)),
Span::styled("cccc dddd", Style::default().fg(Color::Blue)),
]);
let out = wrap_line(&line, 12);
assert!(out.len() > 1);
let by_color = |color: Color| -> String {
out.iter()
.flat_map(|l| l.spans.iter())
.filter(|s| s.style.fg == Some(color))
.map(|s| s.content.as_ref())
.collect::<String>()
.replace(' ', "")
};
assert_eq!(by_color(Color::Red), "aaaabbbb");
assert_eq!(by_color(Color::Blue), "ccccdddd");
}
#[test]
fn an_empty_line_stays_a_single_empty_line() {
let out = wrap_line(&Line::from(""), 10);
assert_eq!(out.len(), 1);
assert_eq!(plain_text(&out[0]), "");
}
#[test]
fn a_tiny_width_still_yields_the_whole_text() {
let line = Line::from("alpha beta gamma");
for width in 0..6 {
let out = wrap_line(&line, width);
assert!(!out.is_empty(), "width {width} produced no line at all");
let joined: String = out.iter().map(|l| plain_text(l)).collect();
assert!(
joined.replace(' ', "").contains("alphabetagamma"),
"width {width} lost text: {joined:?}"
);
}
}
#[test]
fn a_wrapped_list_item_keeps_its_bullet_indent() {
let line = Line::from(vec![
Span::raw(" "),
Span::styled("\u{2022} ", Style::default().fg(Color::Cyan)),
Span::raw("one two three four five six seven eight"),
]);
let out = wrap_line(&line, 20);
assert!(out.len() > 1);
let second = plain_text(&out[1]);
assert!(
second.starts_with(" "),
"continuation must line up under the item text, got {second:?}"
);
assert!(
!second.contains('\u{2022}'),
"the bullet must not be repeated: {second:?}"
);
}
#[test]
fn a_wrapped_code_line_keeps_its_gutter() {
let line = Line::from(Span::styled(
"\u{2502} let x = some_very_long_expression_here();",
Style::default().fg(Color::Green),
));
let out = wrap_line(&line, 20);
assert!(out.len() > 1);
assert!(
plain_text(&out[1]).starts_with("\u{2502} "),
"the code gutter must be repeated, got {:?}",
plain_text(&out[1])
);
}
#[test]
fn wrapped_lines_count_towards_the_scroll_height() {
let md = "a bb ccc dddd eeeee ffffff ggggggg hhhhhhhh iiiiiiiii jjjjjjjjjj\n";
let path = std::path::PathBuf::from("/tmp/mdr_wrap_height.md");
let mut elements = build_content_elements(md, &path, &None);
let unwrapped = total_content_rows(&elements);
rewrap_elements(&mut elements, 20);
let wrapped = total_content_rows(&elements);
assert!(
wrapped > unwrapped,
"wrapping must be reflected in the scroll height ({unwrapped} -> {wrapped})"
);
}
#[test]
fn search_offsets_follow_the_wrapped_layout() {
let md = "aaaa bbbb cccc dddd eeee ffff gggg hhhh\n\nneedle\n";
let path = std::path::PathBuf::from("/tmp/mdr_wrap_search.md");
let mut elements = build_content_elements(md, &path, &None);
rewrap_elements(&mut elements, 12);
let matches = compute_search_matches(&elements, "needle");
assert_eq!(matches.len(), 1, "exactly one line holds the needle");
let mut expected = 0usize;
for element in &elements {
if let ContentElement::TextLine(text) = element
&& text.text().contains("needle")
{
break;
}
expected += element.row_height();
}
assert_eq!(matches[0], expected);
assert!(
expected >= 4,
"the wrapped paragraph should push the match down, got {expected}"
);
}
#[test]
fn a_document_without_images_needs_no_picker() {
let md =
"# Title\n\nJust text with `code` and a [link](https://example.com).\n\n- a\n- b\n";
assert!(!document_needs_picker(md));
}
#[test]
fn a_document_with_a_local_image_needs_a_picker() {
assert!(document_needs_picker("# T\n\n\n"));
}
#[test]
fn a_document_with_a_remote_image_needs_a_picker() {
assert!(document_needs_picker(
"\n"
));
}
#[test]
fn a_document_with_a_mermaid_diagram_needs_a_picker() {
assert!(document_needs_picker(
"```mermaid\ngraph LR\n A-->B\n```\n"
));
}
#[test]
fn an_image_inside_a_paragraph_needs_no_picker() {
assert!(!document_needs_picker("see  in context\n"));
}
#[test]
fn an_image_written_inside_a_code_block_needs_no_picker() {
assert!(!document_needs_picker("```md\n\n```\n"));
}
fn write_svg(path: &std::path::Path) {
std::fs::write(
path,
r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="red"/></svg>"#,
)
.unwrap();
}
#[test]
fn an_image_in_a_sibling_directory_of_the_project_is_loaded() {
let tmp = tempfile::tempdir().unwrap();
let proj = tmp.path().join("proj");
std::fs::create_dir_all(proj.join(".git")).unwrap();
std::fs::create_dir_all(proj.join("docs")).unwrap();
std::fs::create_dir_all(proj.join("images")).unwrap();
write_svg(&proj.join("images/schema.svg"));
let img = load_image("../images/schema.svg", &proj.join("docs"));
assert!(
img.is_ok(),
"an image from a parent directory inside the project must load, got: {:?}",
img.err()
);
}
#[test]
fn an_image_outside_the_project_is_still_refused() {
let tmp = tempfile::tempdir().unwrap();
let proj = tmp.path().join("proj");
std::fs::create_dir_all(proj.join(".git")).unwrap();
std::fs::create_dir_all(proj.join("docs")).unwrap();
write_svg(&tmp.path().join("secret.svg"));
let img = load_image("../../secret.svg", &proj.join("docs"));
assert!(
img.is_err(),
"an image outside the enclosing project must stay refused"
);
}
#[test]
fn mermaid_build_content_elements_fallback_without_picker() {
let md = "```mermaid\ngraph LR\n A-->B\n```\n";
let md_path = std::path::PathBuf::from("/tmp/test_mermaid.md");
let elements = build_content_elements(md, &md_path, &None);
assert!(
!elements.is_empty(),
"Should produce content elements for mermaid block"
);
let has_text = elements
.iter()
.any(|e| matches!(e, ContentElement::TextLine(_)));
assert!(has_text, "Mermaid fallback should produce text lines");
}
}
#[cfg(test)]
mod fidelity_tests {
use super::*;
fn rendered(md: &str) -> Vec<String> {
markdown_to_lines_with_images(md)
.into_iter()
.filter_map(|item| match item {
ParsedLine::Text(line) => {
Some(line.spans.iter().map(|s| s.content.as_ref()).collect())
}
_ => None,
})
.collect()
}
fn colours(md: &str) -> std::collections::BTreeSet<String> {
markdown_to_lines_with_images(md)
.into_iter()
.filter_map(|item| match item {
ParsedLine::Text(line) => Some(line),
_ => None,
})
.flat_map(|line| {
line.spans
.iter()
.map(|s| format!("{:?}", s.style.fg))
.collect::<Vec<_>>()
})
.collect()
}
#[test]
fn the_code_frame_is_closed_and_square_whatever_the_label() {
for label in [
"code",
"rust",
"mermaid",
"",
"a-very-long-language-name-indeed",
] {
let top = code_frame_top(label);
assert!(top.starts_with('┌'), "{top:?}");
assert!(
top.ends_with('┐'),
"top edge left open for {label:?}: {top:?}"
);
assert_eq!(
str_width(&top),
str_width(CODE_FRAME_BOTTOM),
"top and bottom edges must line up for {label:?}: {top:?}"
);
}
}
#[test]
fn a_named_language_still_appears_in_the_frame() {
assert!(code_frame_top("rust").contains("rust"));
}
#[test]
fn the_terminal_background_is_read_from_colorfgbg() {
assert_eq!(terminal_background_is_light(Some("15;0")), Some(false));
assert_eq!(terminal_background_is_light(Some("0;15")), Some(true));
assert_eq!(
terminal_background_is_light(Some("15;default;0")),
Some(false)
);
assert_eq!(
terminal_background_is_light(Some("0;default;7")),
Some(true)
);
assert_eq!(terminal_background_is_light(None), None);
assert_eq!(terminal_background_is_light(Some("")), None);
assert_eq!(terminal_background_is_light(Some("15;default")), None);
assert_eq!(terminal_background_is_light(Some("0;99")), None);
}
#[test]
fn an_explicit_theme_always_wins_over_the_terminal() {
use crate::core::Theme;
assert!(
!syntax_prefers_light(Theme::Dark, Some("0;15")),
"an explicit dark theme must not follow a light terminal"
);
assert!(
syntax_prefers_light(Theme::Light, Some("15;0")),
"an explicit light theme must not follow a dark terminal"
);
}
#[test]
fn auto_follows_the_terminal_and_falls_back_to_dark() {
use crate::core::Theme;
assert!(syntax_prefers_light(Theme::Auto, Some("0;15")));
assert!(!syntax_prefers_light(Theme::Auto, Some("15;0")));
assert!(!syntax_prefers_light(Theme::Auto, None));
}
#[test]
fn the_theme_toggle_flips_and_reports_the_one_in_use() {
use std::sync::atomic::{AtomicBool, Ordering};
for start in [true, false] {
let flag = AtomicBool::new(start);
assert_eq!(flip(&flag), !start, "each press must flip the theme");
assert_eq!(
flag.load(Ordering::Relaxed),
!start,
"the reported theme must be the one actually stored"
);
assert_eq!(flip(&flag), start, "a second press must come back");
}
}
#[test]
fn the_two_syntax_themes_are_both_kept_in_the_cache() {
let assets = syntax_assets();
assert_ne!(
assets.light.name, assets.dark.name,
"the light and dark themes must be two different themes"
);
}
#[test]
fn both_themes_exist_in_syntect_defaults() {
let themes = syntect::highlighting::ThemeSet::load_defaults();
for name in [DARK_SYNTAX_THEME, LIGHT_SYNTAX_THEME] {
assert!(
themes.themes.contains_key(name),
"syntect has no theme {:?}; available: {:?}",
name,
themes.themes.keys().collect::<Vec<_>>()
);
}
}
#[test]
fn a_tight_list_does_not_breathe_and_a_loose_one_does() {
let tight = rendered("- un\n- deux\n- trois\n");
let blanks = tight.iter().filter(|l| l.trim().is_empty()).count();
assert_eq!(
blanks, 0,
"a tight list must not gain blank lines: {tight:?}"
);
let loose = rendered("- un\n\n- deux\n\n- trois\n");
let blanks = loose.iter().filter(|l| l.trim().is_empty()).count();
assert!(blanks >= 2, "a loose list must keep its spacing: {loose:?}");
}
#[test]
fn a_nested_list_inside_a_tight_list_stays_tight() {
let lines = rendered("- un\n- deux\n - imbriqué\n- trois\n");
assert!(
!lines.iter().any(|l| l.trim().is_empty()),
"no blank line belongs inside a tight list: {lines:?}"
);
assert!(
lines
.iter()
.any(|l| l.starts_with(" ") && l.contains("imbriqué")),
"the nested item must keep its indent: {lines:?}"
);
}
#[test]
fn every_toc_entry_can_still_be_found_in_the_rendered_lines() {
let md = "# Un\n\ntexte\n\n## Deux trois\n\ntexte\n\n##### Cinq\n\ntexte\n";
let lines = rendered(md);
for entry in crate::core::toc::extract_toc(md) {
assert!(
lines.iter().any(|l| l.contains(&entry.text)),
"TOC entry {:?} has no rendered line containing it: {:?}",
entry.text,
lines
);
}
}
#[test]
fn inline_markup_is_styled_not_printed() {
let lines = rendered("A **b** *c* ~~d~~ `e` [f](http://x) end\n");
let joined = lines.join(" ");
for raw in ["**", "~~", "`", "](", "http://x"] {
assert!(
!joined.contains(raw),
"raw {raw:?} reached the screen: {joined:?}"
);
}
for word in ["b", "c", "d", "e", "f", "end"] {
assert!(joined.contains(word), "{word:?} was dropped: {joined:?}");
}
}
#[test]
fn table_alignment_markers_are_honoured() {
let md = "| l | c | r |\n|:--|:-:|--:|\n| x | x | x |\n";
let body = rendered(md)
.into_iter()
.find(|l| l.matches('x').count() == 3)
.expect("body row");
let cells: Vec<&str> = body.split('│').collect();
assert_eq!(cells.len(), 3, "expected three cells: {body:?}");
assert!(cells[0].starts_with('x'), "left column: {:?}", cells[0]);
assert!(
cells[2].trim_start().ends_with('x'),
"right column: {:?}",
cells[2]
);
}
#[test]
fn h5_and_h6_are_rendered_as_headings_not_raw_text() {
for (md, title) in [("##### Deep\n", "Deep"), ("###### Deeper\n", "Deeper")] {
let lines = rendered(md);
assert!(
lines.iter().any(|l| l.trim() == title),
"expected a line holding just {title:?}, got {lines:?}"
);
assert!(
!lines.iter().any(|l| l.contains('#')),
"the hashes must not reach the screen, got {lines:?}"
);
}
}
#[test]
fn offline_mode_makes_no_request_at_all() {
use std::sync::atomic::{AtomicUsize, Ordering};
let calls = AtomicUsize::new(0);
let fetch = |_: &str| -> LoadedImage {
calls.fetch_add(1, Ordering::Relaxed);
Err("should never be reached".into())
};
let result = load_image_with(
"https://example.com/badge.svg",
std::path::Path::new("/"),
true,
&fetch,
);
assert!(result.is_err(), "a remote image cannot load while offline");
assert_eq!(
calls.load(Ordering::Relaxed),
0,
"offline mode must not reach the network"
);
}
#[test]
fn a_remote_image_is_fetched_when_online() {
use std::sync::atomic::{AtomicUsize, Ordering};
let calls = AtomicUsize::new(0);
let fetch = |_: &str| -> LoadedImage {
calls.fetch_add(1, Ordering::Relaxed);
Err("no network in a test".into())
};
let _ = load_image_with(
"https://example.com/badge.svg",
std::path::Path::new("/"),
false,
&fetch,
);
assert_eq!(calls.load(Ordering::Relaxed), 1, "the fetch should be used");
}
#[test]
fn only_a_bare_t_flips_the_theme() {
assert!(is_theme_toggle(KeyCode::Char('t'), KeyModifiers::NONE));
for modifier in [
KeyModifiers::CONTROL,
KeyModifiers::ALT,
KeyModifiers::SUPER,
] {
assert!(
!is_theme_toggle(KeyCode::Char('t'), modifier),
"{modifier:?}+t must not flip the theme"
);
}
assert!(!is_theme_toggle(KeyCode::Char('q'), KeyModifiers::NONE));
}
#[test]
fn an_unlabelled_fence_is_painted_like_a_highlighted_one() {
for code in ["plain text\n", "some code\n"] {
for lang in ["", "wharrgarbl"] {
for line in highlight_code(code, lang) {
for span in line {
assert!(
span.style.bg.is_some(),
"a {lang:?} fence must be painted like any other"
);
}
}
}
}
}
#[test]
fn the_help_bar_offers_the_theme_toggle_on_a_standard_terminal() {
let bar = help_bar(48);
assert!(
bar.contains("t: theme"),
"the theme toggle must survive a standard terminal: {bar:?}"
);
}
#[test]
fn the_help_bar_drops_whole_hints_and_never_overflows() {
for columns in 0..100 {
let bar = help_bar(columns);
assert!(
str_width(&bar) <= columns,
"{columns} columns produced a bar of {}: {bar:?}",
str_width(&bar)
);
for hint in HELP_HINTS {
let shown = bar.contains(hint);
let partial = !shown
&& hint
.split_once(':')
.is_some_and(|(key, _)| bar.contains(&format!("{key}:")));
assert!(!partial, "{hint:?} is cut short in {bar:?}");
}
}
}
#[test]
fn a_wide_terminal_gets_every_hint() {
let bar = help_bar(200);
for hint in HELP_HINTS {
assert!(bar.contains(hint), "{hint:?} missing from {bar:?}");
}
}
#[test]
fn a_code_block_paints_a_rectangular_panel_at_the_frame_width() {
let md = "```rust\nfn main() {\n let x: u32 = 42;\n}\n```\n";
let block: Vec<Line<'static>> = markdown_to_lines_with_images(md)
.into_iter()
.filter_map(|item| match item {
ParsedLine::Text(line) => Some(line),
_ => None,
})
.filter(|line| {
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
text.starts_with('┌') || text.starts_with('│') || text.starts_with('└')
})
.collect();
assert!(
block.len() >= 5,
"expected a frame and three code lines, got {}",
block.len()
);
let expected = str_width(CODE_FRAME_BOTTOM);
for line in &block {
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(
str_width(&text),
expected,
"every line of the block must be the frame's width, got {text:?}"
);
for span in &line.spans {
assert!(
span.style.bg.is_some(),
"every span of the block must be painted, bare one in {text:?}"
);
}
}
}
#[test]
fn code_stays_painted_once_the_lines_are_folded() {
let md = "```rust\nfn main() { let a_rather_long_identifier = 42; }\n```\n";
let block: Vec<Line<'static>> = markdown_to_lines_with_images(md)
.into_iter()
.filter_map(|item| match item {
ParsedLine::Text(line) => Some(line),
_ => None,
})
.filter(|line| {
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
text.starts_with('│')
})
.collect();
assert!(!block.is_empty(), "expected at least one code line");
for width in [20, 30, 43] {
for line in &block {
let folded = wrap_line(line, width);
assert!(folded.len() > 1 || line.width() <= width, "expected a fold");
for piece in folded {
for span in piece.spans {
assert!(
span.content.trim().is_empty() || span.style.bg.is_some(),
"a fold at {width} columns lost the painting: {:?}",
span.content
);
}
}
}
}
}
#[test]
fn a_code_block_is_syntax_highlighted() {
let md = "```rust\nfn main() { let x: u32 = 1; }\n```\n";
let used = colours(md);
assert!(
used.len() > 3,
"a highlighted Rust block should use more than a couple of colours, got {used:?}"
);
}
#[test]
fn table_cells_are_padded_to_the_column_width() {
let md = "| a | long header |\n|---|---|\n| 1 | 2 |\n";
let lines: Vec<String> = rendered(md)
.into_iter()
.filter(|l| l.contains('1') || l.contains("long header"))
.collect();
assert!(
lines.len() >= 2,
"expected header and body rows, got {lines:?}"
);
let widths: std::collections::BTreeSet<usize> =
lines.iter().map(|l| l.chars().count()).collect();
assert_eq!(
widths.len(),
1,
"every row of a table must be the same width once padded, got {lines:?}"
);
}
#[test]
fn footnotes_are_rendered() {
let md = "Some text[^1].\n\n[^1]: The note itself.\n";
let lines = rendered(md);
assert!(
lines.iter().any(|l| l.contains("The note itself")),
"the footnote body must appear, got {lines:?}"
);
assert!(
!lines.iter().any(|l| l.contains("[^1]")),
"the raw footnote syntax must not reach the screen, got {lines:?}"
);
}
}