use super::highlight::apply_block_highlight;
use super::state::VisualRange;
use crate::app::App;
use crate::theme::{Palette, Tokens};
use ratatui::{
Frame,
layout::{Alignment, Rect},
style::Style,
text::{Line, Span, Text},
widgets::{Block, Borders, Paragraph, Wrap},
};
use std::cell::RefCell;
pub struct MermaidDrawParams<'a> {
pub fully_visible: bool,
pub id: crate::markdown::MermaidBlockId,
pub source: &'a str,
pub focused: bool,
pub cursor_line: u32,
pub block_start: u32,
pub block_end: u32,
pub clip_start: u32,
pub visual_mode: Option<VisualRange>,
}
pub fn draw_mermaid_block(
f: &mut Frame,
app: &mut App,
rect: Rect,
p: &Palette,
params: &MermaidDrawParams,
) {
use crate::mermaid::MermaidEntry;
let entry = app.mermaid_cache.get_mut(params.id);
let cursor_in_block = params.focused
&& params.cursor_line >= params.block_start
&& params.cursor_line < params.block_end;
match entry {
None => {
render_mermaid_placeholder(f, rect, "mermaid diagram", p);
}
Some(MermaidEntry::Pending) => {
render_mermaid_placeholder(f, rect, "rendering\u{2026}", p);
}
Some(MermaidEntry::Ready { protocol, .. }) => {
if params.fully_visible {
use ratatui_image::{Resize, StatefulImage};
f.render_widget(
Block::default().style(Style::default().bg(p.background)),
rect,
);
let highlighted_rows: Vec<u32> = match params.visual_mode {
Some(range) => (0..params.block_end.saturating_sub(params.block_start))
.filter(|&offset| range.contains(params.block_start + offset))
.collect(),
None if cursor_in_block => {
vec![params.cursor_line - params.block_start]
}
None => vec![],
};
for row_offset in highlighted_rows {
let row_offset = crate::cast::u16_from_u32(row_offset);
if row_offset < rect.height {
let bar_rect = Rect {
x: rect.x,
y: rect.y + row_offset,
width: rect.width,
height: 1,
};
f.render_widget(
Block::default()
.style(Style::default().bg(app.tokens.state.selection_bg)),
bar_rect,
);
}
}
let padded = padded_rect(rect, 4, 1);
let image = StatefulImage::new().resize(Resize::Fit(None));
f.render_stateful_widget(image, padded, protocol.as_mut());
} else {
render_mermaid_placeholder(f, rect, "scroll to view diagram", p);
}
}
Some(MermaidEntry::Failed {
msg,
styled_text_cache,
}) => {
let footer = format!("[mermaid \u{2014} {}]", truncate(msg.as_str(), 60));
let text = get_or_build_cache(styled_text_cache, || {
render_mermaid_source_text(params.source, &footer, &app.tokens, p)
});
render_mermaid_text_block(f, rect, text, &app.tokens, p, params);
}
Some(MermaidEntry::SourceOnly {
reason,
styled_text_cache,
}) => {
let footer = format!("[mermaid \u{2014} {reason}]");
let text = get_or_build_cache(styled_text_cache, || {
render_mermaid_source_text(params.source, &footer, &app.tokens, p)
});
render_mermaid_text_block(f, rect, text, &app.tokens, p, params);
}
Some(MermaidEntry::AsciiDiagram {
diagram,
reason,
styled_text_cache,
}) => {
let footer = format!("[mermaid \u{2014} {reason}, text-mode diagram]");
let text = get_or_build_cache(styled_text_cache, || {
render_mermaid_source_text(diagram.as_str(), &footer, &app.tokens, p)
});
render_mermaid_text_block(f, rect, text, &app.tokens, p, params);
}
}
}
pub fn padded_rect(rect: Rect, h: u16, v: u16) -> Rect {
if rect.width <= h * 2 || rect.height <= v * 2 {
return rect;
}
Rect {
x: rect.x + h,
y: rect.y + v,
width: rect.width - h * 2,
height: rect.height - v * 2,
}
}
pub fn render_mermaid_placeholder(f: &mut Frame, rect: Rect, msg: &str, p: &Palette) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(p.border_style())
.style(Style::default().bg(p.background));
let inner = block.inner(rect);
f.render_widget(block, rect);
if inner.height > 0 {
let line = Line::from(Span::styled(msg.to_string(), p.dim_style()));
let para = Paragraph::new(Text::from(vec![line])).alignment(Alignment::Center);
let y_offset = inner.height / 2;
let target = Rect {
y: inner.y + y_offset,
height: 1,
..inner
};
f.render_widget(para, target);
}
}
fn get_or_build_cache(
cache: &RefCell<Option<Text<'static>>>,
build: impl FnOnce() -> Text<'static>,
) -> Text<'static> {
if let Some(text) = cache.borrow().as_ref() {
return text.clone();
}
let text = build();
*cache.borrow_mut() = Some(text.clone());
text
}
pub fn render_mermaid_source_text(
source: &str,
footer: &str,
tokens: &Tokens,
p: &Palette,
) -> Text<'static> {
let code_style = Style::default()
.fg(tokens.syntax.code_fg)
.bg(tokens.surface.raised);
let dim_style = p.dim_style();
let mut lines: Vec<Line<'static>> = source
.lines()
.map(|l| Line::from(Span::styled(l.to_string(), code_style)))
.collect();
lines.push(Line::from(Span::styled(footer.to_string(), dim_style)));
Text::from(lines)
}
fn render_mermaid_text_block(
f: &mut Frame,
rect: Rect,
mut text: Text<'static>,
tokens: &Tokens,
p: &Palette,
params: &MermaidDrawParams,
) {
let total = text.lines.len();
let start = (params.clip_start as usize).min(total);
let inner_width = rect.width.saturating_sub(2) as usize;
if inner_width > 0
&& let Some(natural_width) = max_line_display_width(&text.lines)
&& natural_width > inner_width
{
render_mermaid_overflow_placeholder(f, rect, natural_width, inner_width, p);
return;
}
if params.focused {
apply_block_highlight(
&mut text.lines,
params.visual_mode,
params.cursor_line,
params.block_start,
params.block_end,
start,
tokens.state.selection_bg,
);
}
let block = Block::default()
.borders(Borders::ALL)
.border_style(p.border_style())
.style(Style::default().bg(p.background));
let para = Paragraph::new(text)
.block(block)
.wrap(Wrap { trim: false })
.scroll((start as u16, 0));
f.render_widget(para, rect);
}
fn max_line_display_width(lines: &[Line<'static>]) -> Option<usize> {
lines.iter().map(|l| l.width()).max()
}
fn render_mermaid_overflow_placeholder(
f: &mut Frame,
rect: Rect,
natural_width: usize,
available_width: usize,
p: &Palette,
) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(p.border_style())
.style(Style::default().bg(p.background));
let inner = block.inner(rect);
f.render_widget(block, rect);
if inner.height == 0 || inner.width == 0 {
return;
}
let lines = vec![
Line::from(Span::styled(
"Mermaid diagram too wide to display in place".to_string(),
p.dim_style(),
)),
Line::from(""),
Line::from(Span::styled(
format!("Natural width: {natural_width} cells, available: {available_width}"),
p.dim_style(),
)),
Line::from(""),
Line::from(Span::styled(
"Press Enter to open in fullscreen".to_string(),
p.dim_style(),
)),
];
let para = Paragraph::new(Text::from(lines)).alignment(ratatui::layout::Alignment::Center);
let line_count: u16 = 5;
let y_offset = inner.height.saturating_sub(line_count) / 2;
let target = Rect {
x: inner.x,
y: inner.y + y_offset,
width: inner.width,
height: line_count.min(inner.height),
};
f.render_widget(para, target);
}
pub fn truncate(s: &str, max: usize) -> &str {
if s.len() <= max { s } else { &s[..max] }
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::text::Span;
#[test]
fn max_line_display_width_handles_empty_and_unicode() {
assert_eq!(max_line_display_width(&[]), None);
let lines = vec![
Line::from(Span::raw("hi".to_string())),
Line::from(Span::raw("hello world".to_string())),
Line::from(Span::raw("┌──┐".to_string())),
];
assert_eq!(max_line_display_width(&lines), Some(11));
}
#[test]
fn max_line_display_width_counts_unicode_box_drawing_correctly() {
let lines = vec![
Line::from(Span::raw("┌────────┐".to_string())),
Line::from(Span::raw("│ Worker │".to_string())),
Line::from(Span::raw("└────────┘".to_string())),
];
assert_eq!(max_line_display_width(&lines), Some(10));
}
#[test]
fn get_or_build_cache_populates_on_first_call_and_reuses() {
let cache: RefCell<Option<Text<'static>>> = RefCell::new(None);
let call_count = std::cell::Cell::new(0u32);
let build = || {
call_count.set(call_count.get() + 1);
Text::from(vec![Line::from(Span::raw("diagram".to_string()))])
};
let t1 = get_or_build_cache(&cache, build);
assert_eq!(call_count.get(), 1, "build must run on first call");
assert_eq!(t1.lines.len(), 1);
let t2 = get_or_build_cache(&cache, || {
call_count.set(call_count.get() + 1);
Text::from(vec![Line::from(Span::raw("diagram".to_string()))])
});
assert_eq!(call_count.get(), 1, "build must not run on cache hit");
assert_eq!(t2.lines.len(), 1);
}
#[test]
fn get_or_build_cache_rebuilds_after_invalidation() {
let cache: RefCell<Option<Text<'static>>> = RefCell::new(None);
let call_count = std::cell::Cell::new(0u32);
get_or_build_cache(&cache, || {
call_count.set(call_count.get() + 1);
Text::from(vec![Line::from(Span::raw("v1".to_string()))])
});
assert_eq!(call_count.get(), 1);
*cache.borrow_mut() = None;
let rebuilt = get_or_build_cache(&cache, || {
call_count.set(call_count.get() + 1);
Text::from(vec![Line::from(Span::raw("v2".to_string()))])
});
assert_eq!(
call_count.get(),
2,
"build must run again after cache reset"
);
assert_eq!(rebuilt.lines[0].spans[0].content.as_ref(), "v2");
}
#[test]
fn overflow_check_uses_full_text_width_not_clipped_width() {
let wide_row = "A".repeat(200);
let narrow_body: Vec<Line<'static>> = (0..10)
.map(|_| Line::from(Span::raw("narrow".to_string())))
.collect();
let mut all_lines = vec![Line::from(Span::raw(wide_row.clone()))];
all_lines.extend(narrow_body.clone());
let text = Text::from(all_lines);
let full_width = max_line_display_width(&text.lines);
assert_eq!(
full_width,
Some(200),
"full text must report the widest row"
);
let clipped: Vec<Line<'static>> = narrow_body;
let clipped_width = max_line_display_width(&clipped);
assert_eq!(
clipped_width,
Some(6),
"clipped text reports only the narrower body width, demonstrating the old bug"
);
let available = 80_usize;
assert!(
full_width.is_some_and(|w| w > available),
"overflow must be detected from full text width even when clip_start > 0"
);
assert!(
clipped_width.is_none_or(|w| w <= available),
"the old drain-first path would have missed the overflow at this clip_start"
);
}
}