use unicode_width::UnicodeWidthStr;
use crate::mindmap::{Mindmap, MindmapNode};
const BRANCH: &str = "\u{251C}\u{2500}\u{2500} "; const LAST_BRANCH: &str = "\u{2514}\u{2500}\u{2500} "; const PIPE: &str = "\u{2502} "; const BLANK: &str = " ";
pub fn render(diag: &Mindmap, max_width: Option<usize>) -> String {
let mut out = String::new();
render_root_box(&mut out, &diag.root.text, max_width);
for (i, child) in diag.root.children.iter().enumerate() {
let is_last = i == diag.root.children.len() - 1;
render_node(&mut out, child, "", is_last, max_width);
}
if out.ends_with('\n') {
out.pop();
}
out
}
fn render_root_box(out: &mut String, text: &str, max_width: Option<usize>) {
let box_overhead = 4usize; let corner_overhead = 2usize; let total_fixed = box_overhead + corner_overhead; let _ = total_fixed;
let text_w = UnicodeWidthStr::width(text);
let (display_text, content_w) = if let Some(budget) = max_width {
let available = budget.saturating_sub(4);
if text_w <= available {
(text.to_string(), text_w)
} else {
let truncated = truncate_text(text, available.saturating_sub(1));
let tw = UnicodeWidthStr::width(truncated.as_str());
(truncated, tw)
}
} else {
(text.to_string(), text_w)
};
let trunk_col = 1 + content_w / 2;
out.push('\u{256D}'); for _ in 0..content_w + 2 {
out.push('\u{2500}'); }
out.push('\u{256E}'); out.push('\n');
out.push('\u{2502}'); out.push(' ');
out.push_str(&display_text);
out.push(' ');
out.push('\u{2502}'); out.push('\n');
out.push('\u{2570}'); for i in 0..content_w + 2 {
if i == trunk_col {
out.push('\u{252C}'); } else {
out.push('\u{2500}'); }
}
out.push('\u{256F}'); out.push('\n');
if !display_text.is_empty() {
for _ in 0..=trunk_col {
out.push(' ');
}
out.push('\u{2502}'); out.push('\n');
}
}
fn render_node(
out: &mut String,
node: &MindmapNode,
prefix: &str,
is_last: bool,
max_width: Option<usize>,
) {
let connector = if is_last { LAST_BRANCH } else { BRANCH };
let prefix_w = UnicodeWidthStr::width(prefix) + UnicodeWidthStr::width(connector);
let text = maybe_truncate(&node.text, max_width, prefix_w);
out.push_str(prefix);
out.push_str(connector);
out.push_str(&text);
out.push('\n');
let child_prefix = if is_last {
format!("{prefix}{BLANK}")
} else {
format!("{prefix}{PIPE}")
};
for (i, child) in node.children.iter().enumerate() {
let child_is_last = i == node.children.len() - 1;
render_node(out, child, &child_prefix, child_is_last, max_width);
}
}
fn truncate_text(text: &str, available: usize) -> String {
let mut result = String::new();
let mut used = 0usize;
for ch in text.chars() {
let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
if used + w > available {
break;
}
result.push(ch);
used += w;
}
result.push('\u{2026}'); result
}
fn maybe_truncate(text: &str, max_width: Option<usize>, prefix_cols: usize) -> String {
let Some(budget) = max_width else {
return text.to_string();
};
let available = budget.saturating_sub(prefix_cols);
let text_w = UnicodeWidthStr::width(text);
if text_w <= available {
return text.to_string();
}
truncate_text(text, available.saturating_sub(1))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::mindmap::parse;
#[test]
fn single_root_renders_just_the_box() {
let diag = parse("mindmap\n root").unwrap();
let out = render(&diag, None);
assert!(out.contains("root"), "got: {out:?}");
assert!(out.contains('\u{256D}'), "top-left corner missing");
assert!(out.contains('\u{256E}'), "top-right corner missing");
assert!(out.contains('\u{2570}'), "bottom-left corner missing");
assert!(out.contains('\u{256F}'), "bottom-right corner missing");
assert!(!out.contains('\u{251C}'), "unexpected branch glyph");
assert!(!out.contains('\u{2514}'), "unexpected last-branch glyph");
}
#[test]
fn tree_uses_branch_glyphs() {
let src = "mindmap\n root\n A\n B";
let diag = parse(src).unwrap();
let out = render(&diag, None);
assert!(out.contains("A"), "node A missing");
assert!(out.contains("B"), "node B missing");
assert!(out.contains('\u{251C}'), "├ branch glyph missing");
assert!(out.contains('\u{2514}'), "└ last-branch glyph missing");
}
#[test]
fn nested_levels_indent_progressively() {
let src = "mindmap\n root\n Parent\n Child";
let diag = parse(src).unwrap();
let out = render(&diag, None);
let parent_line = out.lines().find(|l| l.contains("Parent")).unwrap();
let child_line = out.lines().find(|l| l.contains("Child")).unwrap();
let parent_indent = parent_line
.chars()
.take_while(|c| !c.is_alphanumeric() && *c != '\u{251C}' && *c != '\u{2514}')
.count();
let child_indent = child_line
.chars()
.take_while(|c| !c.is_alphanumeric() && *c != '\u{251C}' && *c != '\u{2514}')
.count();
assert!(
child_indent > parent_indent,
"child ({child_indent}) must be indented more than parent ({parent_indent})"
);
}
#[test]
fn max_width_truncates_long_node_text() {
let long_text = "A".repeat(80);
let src = format!("mindmap\n root\n {long_text}");
let diag = parse(&src).unwrap();
let out = render(&diag, Some(40));
for line in out.lines() {
let w = UnicodeWidthStr::width(line);
assert!(w <= 40, "line exceeds max_width=40 ({w} cells): {line:?}");
}
assert!(
out.contains('\u{2026}'),
"ellipsis must appear on truncated text"
);
}
}