Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*.
Expand Down
33 changes: 27 additions & 6 deletions src/markdown/blocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -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<Line<'static>>,
render_width: usize,
Expand Down
73 changes: 50 additions & 23 deletions src/markdown/mermaid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
/// 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<String, Fallback> {
let trimmed = content.trim();
if trimmed.is_empty() {
return None;
return Err(Fallback::Unsupported);
}
let mut narrowest: Option<usize> = None;
let mut accept = |rendered: String| -> Option<String> {
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<usize>) -> 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<String> {
Expand Down
68 changes: 68 additions & 0 deletions src/tests/markdown_embedded.rs
Original file line number Diff line number Diff line change
@@ -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::*;

Expand Down Expand Up @@ -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();
Expand Down