From 2893733d4755ca55bec35fc7b2724e84337ae136 Mon Sep 17 00:00:00 2001 From: Simon Olofsson Date: Tue, 29 Sep 2026 19:00:46 +0200 Subject: [PATCH] feat: say why a mermaid diagram was not rendered Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/markdown/blocks.rs | 33 ++++++++++++--- src/markdown/mermaid.rs | 73 +++++++++++++++++++++++----------- src/tests/markdown_embedded.rs | 68 +++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index fbce2c1..6f55b4d 100644 --- a/README.md +++ b/README.md @@ -355,7 +355,7 @@ See [`gruvbox.toml`](gruvbox.toml) for a complete example with all available col - **Syntax highlighting** : *Common aliases like `py`, `cpp`, `json`, `toml`, `ps1`, `dockerfile`*. - **Line numbers** : *Toggle display with `Shift+L`, jump to a line with `Ctrl+L` or `:`*. - **LaTeX support** : *Inline, block, and `latex` / `tex` code blocks rendered as formulas*. -- **Mermaid diagrams** : *`mermaid` code blocks rendered as ASCII diagrams*. +- **Mermaid diagrams** : *`mermaid` code blocks rendered as ASCII diagrams, or shown as source with the width they need when they don't fit*. - **Clickable links** : *`Ctrl+Click` to open, double-click to copy, hover feedback*. - **Code block interactions** : *Focus and copy with `y/Y` / `c/C`, or double-click on a block*. - **Mouse capture** : *`Shift+M` to toggle mouse capture and let the terminal handle selection*. diff --git a/src/markdown/blocks.rs b/src/markdown/blocks.rs index 883c1e8..db20d1a 100644 --- a/src/markdown/blocks.rs +++ b/src/markdown/blocks.rs @@ -533,11 +533,19 @@ pub(super) fn push_mermaid_block_lines( .render_width .saturating_sub(prefix_width + MERMAID_CHROME_WIDTH); let rendered = mermaid::render(content, max_diagram_width); - let use_rendered = rendered.is_some(); - let content_lines: Vec<&str> = if let Some(ref r) = rendered { - r.lines().collect() - } else { - content.lines().collect() + let use_rendered = rendered.is_ok(); + let content_lines: Vec<&str> = match rendered { + Ok(ref r) => r.lines().collect(), + Err(_) => content.lines().collect(), + }; + let label = match rendered { + Err(mermaid::Fallback::TooWide(needed)) => too_wide_label( + needed + prefix_width + MERMAID_CHROME_WIDTH, + ctx.render_width, + // The header frames the label as "┌─ label ┐". + ctx.render_width.saturating_sub(prefix_width + 5), + ), + _ => "mermaid".to_string(), }; let content_style = Style::default().fg(ctx.theme.mermaid_block_fg); push_special_block_lines_with_prefix( @@ -546,7 +554,7 @@ pub(super) fn push_mermaid_block_lines( ctx.theme, prefix, SpecialBlockCtx { - label: "mermaid", + label: &label, content_lines: &content_lines, show_line_numbers: !use_rendered && ctx.code_line_numbers, center: use_rendered, @@ -561,6 +569,19 @@ pub(super) fn push_mermaid_block_lines( ) } +/// Says why the source shows instead of the diagram, in terminal columns. +/// Drops detail from the end until the label fits the header. +fn too_wide_label(needed: usize, has: usize, max_label_width: usize) -> String { + [ + format!("mermaid · not rendered, needs {needed} columns, has {has}"), + format!("mermaid · not rendered, needs {needed} columns"), + "mermaid · not rendered".to_string(), + ] + .into_iter() + .find(|label| UnicodeWidthStr::width(label.as_str()) <= max_label_width) + .unwrap_or_else(|| "mermaid".to_string()) +} + pub(super) fn push_rule_line( lines: &mut Vec>, render_width: usize, diff --git a/src/markdown/mermaid.rs b/src/markdown/mermaid.rs index fc54b5a..0626898 100644 --- a/src/markdown/mermaid.rs +++ b/src/markdown/mermaid.rs @@ -6,50 +6,77 @@ use std::fmt::Write; use super::width::{display_width, iter_cluster_widths, truncate_display_width}; use unicode_segmentation::UnicodeSegmentation; -pub(crate) fn render(content: &str, max_width: usize) -> Option { +/// Why a diagram falls back to its source. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Fallback { + /// The narrowest layout tried is this many columns wide. + TooWide(usize), + Unsupported, +} + +pub(crate) fn render(content: &str, max_width: usize) -> Result { let trimmed = content.trim(); if trimmed.is_empty() { - return None; + return Err(Fallback::Unsupported); } + let mut narrowest: Option = None; + let mut accept = |rendered: String| -> Option { + let width = widest_line(&rendered); + if max_width > 0 && width <= max_width { + return Some(rendered); + } + narrowest = Some(narrowest.map_or(width, |n| n.min(width))); + None + }; + if trimmed.starts_with("pie") { - return render_pie(trimmed).filter(|rendered| fits_width(rendered, max_width)); + if let Some(rendered) = render_pie(trimmed).and_then(&mut accept) { + return Ok(rendered); + } + return Err(fallback(narrowest)); } let rendered = render_diagram(trimmed, OutputFormat::Text, &RenderConfig::default()).ok(); - if rendered - .as_deref() - .is_some_and(|rendered| fits_width(rendered, max_width)) - { - return rendered; + let parsed = rendered.is_some(); + if let Some(rendered) = rendered.and_then(&mut accept) { + return Ok(rendered); } if trimmed.starts_with("classDiagram") { - rendered.as_ref()?; + if !parsed { + return Err(Fallback::Unsupported); + } if !horizontal_class_layout_cannot_fit(trimmed, max_width) { if let Some(horizontal) = use_horizontal_class_direction(trimmed) { - if let Ok(rendered) = + if let Some(rendered) = render_diagram(&horizontal, OutputFormat::Text, &RenderConfig::default()) + .ok() + .and_then(&mut accept) { - if fits_width(&rendered, max_width) { - return Some(rendered); - } + return Ok(rendered); } } } - return render_vertical_class_diagram(trimmed, max_width); + return render_vertical_class_diagram(trimmed, max_width).ok_or(fallback(narrowest)); + } + + if let Some(rendered) = use_vertical_direction(trimmed) + .and_then(|vertical| { + render_diagram(&vertical, OutputFormat::Text, &RenderConfig::default()).ok() + }) + .and_then(&mut accept) + { + return Ok(rendered); } + Err(fallback(narrowest)) +} - let vertical = use_vertical_direction(trimmed)?; - render_diagram(&vertical, OutputFormat::Text, &RenderConfig::default()) - .ok() - .filter(|rendered| fits_width(rendered, max_width)) +fn fallback(narrowest: Option) -> Fallback { + narrowest.map_or(Fallback::Unsupported, Fallback::TooWide) } -fn fits_width(rendered: &str, max_width: usize) -> bool { - max_width > 0 - && rendered - .lines() - .all(|line| display_width(line) <= max_width) +fn widest_line(rendered: &str) -> usize { + rendered.lines().map(display_width).max().unwrap_or(0) } fn use_vertical_direction(content: &str) -> Option { diff --git a/src/tests/markdown_embedded.rs b/src/tests/markdown_embedded.rs index 9f1f92a..02ec29f 100644 --- a/src/tests/markdown_embedded.rs +++ b/src/tests/markdown_embedded.rs @@ -1,4 +1,5 @@ use super::{rendered_non_empty_lines, test_assets, test_md_theme}; +use crate::markdown::width::display_width; use crate::markdown::{parse_markdown, parse_markdown_with_width}; use crate::*; @@ -250,6 +251,73 @@ fn oversized_mermaid_falls_back_instead_of_wrapping_diagram_rows() { ); } +fn mermaid_header(src: &str, width: usize) -> String { + let (ss, theme) = test_assets(); + let (lines, _, _, _) = + parse_markdown_with_width(src, &ss, &theme, width, &test_md_theme(), false, true).into(); + rendered_non_empty_lines(&lines) + .into_iter() + .find(|line| line.contains("┌─ mermaid")) + .expect("expected mermaid block header") +} + +fn oversized_flowchart() -> String { + format!( + "```mermaid\nflowchart TD\n A[{}]\n```\n", + "oversized ".repeat(10) + ) +} + +#[test] +fn oversized_mermaid_header_says_how_wide_it_needs_to_be() { + let src = oversized_flowchart(); + let header = mermaid_header(&src, 60); + let needed: usize = header + .split("needs ") + .nth(1) + .and_then(|rest| rest.split(' ').next()) + .and_then(|n| n.parse().ok()) + .unwrap_or_else(|| panic!("expected the needed width in {header:?}")); + + assert!( + header.contains("not rendered") && header.contains("has 60"), + "header should say the diagram was not rendered: {header:?}" + ); + assert!( + mermaid_header(&src, needed - 1).contains("not rendered"), + "one column less than needed should still fall back" + ); + assert!( + !mermaid_header(&src, needed).contains("not rendered"), + "the needed width should be enough to render the diagram" + ); +} + +#[test] +fn oversized_mermaid_header_drops_detail_to_fit() { + let src = oversized_flowchart(); + for width in [20, 30, 40, 50, 60] { + let header = mermaid_header(&src, width); + assert!( + display_width(&header) <= width, + "header should fit {width} columns: {header:?}" + ); + } + assert!(mermaid_header(&src, 40).contains("not rendered")); + assert!(!mermaid_header(&src, 40).contains("needs")); +} + +#[test] +fn unsupported_mermaid_header_has_no_width_note() { + let src = "```mermaid\ngantt\n title Schedule\n section Dev\n```\n"; + let header = mermaid_header(src, 80); + + assert!( + !header.contains("not rendered"), + "unsupported diagrams are not a width problem: {header:?}" + ); +} + #[test] fn oversized_class_diagram_uses_vertical_cards() { let (ss, theme) = test_assets();