use unicode_width::UnicodeWidthStr;
use crate::block_diagram::{Block, BlockDiagram, BlockEdge};
const COL_GAP: usize = 1;
const MIN_CELL_INNER: usize = 1;
pub fn render(diag: &BlockDiagram, max_width: Option<usize>) -> String {
if diag.blocks.is_empty() {
return render_edge_summary(&diag.edges);
}
let cols = diag.columns.max(1);
let grid_placements = compute_placements(&diag.blocks, cols);
let col_inner_widths = compute_col_widths(&diag.blocks, &grid_placements, cols);
let col_inner_widths = apply_max_width(col_inner_widths, max_width, cols);
let row_count = grid_placements
.iter()
.map(|p| p.row + 1)
.max()
.unwrap_or(0);
let mut out = String::new();
for row in 0..row_count {
let mut row_blocks: Vec<(usize, usize, usize, &Block)> = grid_placements
.iter()
.filter(|p| p.row == row)
.map(|p| (p.col_start, p.col_end, p.block_idx, &diag.blocks[p.block_idx]))
.collect();
row_blocks.sort_by_key(|&(col_start, _, _, _)| col_start);
let mut top = String::new();
let mut col_cursor = 0usize;
for (col_start, col_end, _, _block) in &row_blocks {
if *col_start > col_cursor {
for _ in 0..((*col_start - col_cursor) * (MIN_CELL_INNER + 2 + COL_GAP)) {
top.push(' ');
}
}
let inner_w = spanned_inner_width(&col_inner_widths, *col_start, *col_end);
top.push('\u{250C}'); for _ in 0..inner_w + 2 {
top.push('\u{2500}'); }
top.push('\u{2510}'); col_cursor = *col_end;
top.push_str(&" ".repeat(COL_GAP));
}
out.push_str(top.trim_end());
out.push('\n');
let mut mid = String::new();
col_cursor = 0;
for (col_start, col_end, _, block) in &row_blocks {
if *col_start > col_cursor {
for _ in 0..((*col_start - col_cursor) * (MIN_CELL_INNER + 2 + COL_GAP)) {
mid.push(' ');
}
}
let inner_w = spanned_inner_width(&col_inner_widths, *col_start, *col_end);
let label = block.display_text();
let label_w = UnicodeWidthStr::width(label);
let label = if label_w > inner_w {
truncate_to_width(label, inner_w)
} else {
label.to_string()
};
let label_w = UnicodeWidthStr::width(label.as_str());
let total_pad = inner_w.saturating_sub(label_w);
let left_pad = total_pad / 2;
let right_pad = total_pad - left_pad;
mid.push('\u{2502}'); mid.push(' ');
for _ in 0..left_pad {
mid.push(' ');
}
mid.push_str(&label);
for _ in 0..right_pad {
mid.push(' ');
}
mid.push(' ');
mid.push('\u{2502}'); col_cursor = *col_end;
mid.push_str(&" ".repeat(COL_GAP));
}
out.push_str(mid.trim_end());
out.push('\n');
let mut bot = String::new();
col_cursor = 0;
for (col_start, col_end, _, _block) in &row_blocks {
if *col_start > col_cursor {
for _ in 0..((*col_start - col_cursor) * (MIN_CELL_INNER + 2 + COL_GAP)) {
bot.push(' ');
}
}
let inner_w = spanned_inner_width(&col_inner_widths, *col_start, *col_end);
bot.push('\u{2514}'); for _ in 0..inner_w + 2 {
bot.push('\u{2500}'); }
bot.push('\u{2518}'); col_cursor = *col_end;
bot.push_str(&" ".repeat(COL_GAP));
}
out.push_str(bot.trim_end());
out.push('\n');
if row + 1 < row_count {
out.push('\n');
}
}
let edge_part = render_edge_summary(&diag.edges);
if !edge_part.is_empty() {
out.push('\n');
out.push_str(&edge_part);
}
while out.ends_with('\n') {
out.pop();
}
out
}
#[derive(Debug)]
struct Placement {
block_idx: usize,
row: usize,
col_start: usize, col_end: usize, }
fn compute_placements(blocks: &[Block], cols: usize) -> Vec<Placement> {
let mut placements = Vec::with_capacity(blocks.len());
let mut row = 0usize;
let mut col = 0usize;
for (idx, block) in blocks.iter().enumerate() {
let span = block.col_span.min(cols).max(1);
if col + span > cols && col > 0 {
row += 1;
col = 0;
}
placements.push(Placement {
block_idx: idx,
row,
col_start: col,
col_end: col + span,
});
col += span;
if col >= cols {
row += 1;
col = 0;
}
}
placements
}
fn compute_col_widths(
blocks: &[Block],
placements: &[Placement],
cols: usize,
) -> Vec<usize> {
let mut col_widths = vec![MIN_CELL_INNER; cols];
for p in placements {
let block = &blocks[p.block_idx];
let label = block.display_text();
let lw = UnicodeWidthStr::width(label);
let span = p.col_end - p.col_start;
if span == 1 {
col_widths[p.col_start] = col_widths[p.col_start].max(lw);
} else {
let gap_absorbed = (span - 1) * (COL_GAP + 2);
let needed_per_col = lw.saturating_sub(gap_absorbed).div_ceil(span);
let needed_per_col = needed_per_col.max(MIN_CELL_INNER);
for col_w in col_widths
.iter_mut()
.take(p.col_end)
.skip(p.col_start)
{
*col_w = (*col_w).max(needed_per_col);
}
}
}
col_widths
}
fn apply_max_width(mut col_widths: Vec<usize>, max_width: Option<usize>, cols: usize) -> Vec<usize> {
let Some(budget) = max_width else {
return col_widths;
};
let natural = grid_natural_width(&col_widths, cols);
if natural <= budget {
return col_widths;
}
let overhead = budget.saturating_sub(grid_overhead(cols));
let total_inner: usize = col_widths.iter().sum();
if total_inner == 0 {
return col_widths;
}
for _ in 0..100 {
let current = grid_natural_width(&col_widths, cols);
if current <= budget {
break;
}
let max_w = *col_widths.iter().max().unwrap_or(&MIN_CELL_INNER);
if max_w <= MIN_CELL_INNER {
break;
}
let _ = overhead;
for w in &mut col_widths {
if *w == max_w {
*w -= 1;
break;
}
}
}
col_widths
}
fn grid_natural_width(col_widths: &[usize], cols: usize) -> usize {
grid_overhead(cols) + col_widths.iter().take(cols).sum::<usize>()
}
fn grid_overhead(cols: usize) -> usize {
if cols == 0 {
return 0;
}
cols * 2 + (cols - 1) * COL_GAP
}
fn spanned_inner_width(col_widths: &[usize], col_start: usize, col_end: usize) -> usize {
let span = col_end - col_start;
let base: usize = col_widths[col_start..col_end.min(col_widths.len())]
.iter()
.sum();
if span <= 1 {
base
} else {
base + (span - 1) * (COL_GAP + 2)
}
}
fn truncate_to_width(s: &str, max_w: usize) -> String {
if max_w == 0 {
return String::new();
}
let w = UnicodeWidthStr::width(s);
if w <= max_w {
return s.to_string();
}
let target = max_w.saturating_sub(1);
let mut result = String::new();
let mut used = 0usize;
for ch in s.chars() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
if used + cw > target {
break;
}
result.push(ch);
used += cw;
}
result.push('\u{2026}'); result
}
fn render_edge_summary(edges: &[BlockEdge]) -> String {
if edges.is_empty() {
return String::new();
}
let mut out = String::from("Edges:\n");
for edge in edges {
if let Some(label) = &edge.label {
out.push_str(&format!(
" {} \u{2500}\u{2500}\u{25BA} {} [{}]\n",
edge.source, edge.target, label
));
} else {
out.push_str(&format!(
" {} \u{2500}\u{2500}\u{25BA} {}\n",
edge.source, edge.target
));
}
}
while out.ends_with('\n') {
out.pop();
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::block_diagram::parse;
fn parsed(src: &str) -> BlockDiagram {
parse(src).expect("parse should succeed")
}
#[test]
fn renders_single_block() {
let diag = parsed("block-beta\n A");
let out = render(&diag, None);
assert!(out.contains('A'), "block label 'A' must appear in output:\n{out}");
assert!(out.contains('\u{250C}'), "top-left corner ┌ must appear:\n{out}");
assert!(out.contains('\u{2518}'), "bottom-right corner ┘ must appear:\n{out}");
}
#[test]
fn renders_blocks_with_text_labels() {
let diag = parsed("block-beta\n columns 2\n a[\"Alpha\"] b[\"Beta\"]");
let out = render(&diag, None);
assert!(out.contains("Alpha"), "Alpha label missing:\n{out}");
assert!(out.contains("Beta"), "Beta label missing:\n{out}");
}
#[test]
fn renders_edge_summary() {
let diag = parsed("block-beta\n A\n B\n A --> B");
let out = render(&diag, None);
assert!(
out.contains("Edges:"),
"Edges: header missing:\n{out}"
);
assert!(out.contains('A') && out.contains('B'), "edge endpoints missing:\n{out}");
assert!(out.contains('\u{25BA}'), "arrow glyph ► missing:\n{out}");
}
#[test]
fn empty_diagram_renders_without_panic() {
let diag = BlockDiagram::default();
let out = render(&diag, None);
assert!(!out.contains('\u{250C}'), "no box should be drawn for empty diagram");
}
#[test]
fn max_width_truncates_long_labels() {
let diag = parsed("block-beta\n a[\"This is a very long label that overflows\"]");
let out = render(&diag, Some(20));
for line in out.lines() {
let w = UnicodeWidthStr::width(line);
assert!(
w <= 22, "line width {w} exceeds budget: {line:?}"
);
}
}
#[test]
fn spanning_block_renders_wider_box() {
let diag = parsed("block-beta\n columns 3\n a b:2 c\n d e f");
let out = render(&diag, None);
for id in &["a", "b", "c", "d", "e", "f"] {
assert!(out.contains(id), "block {id} missing from output:\n{out}");
}
let total_corners: usize = out
.lines()
.map(|l| l.chars().filter(|&c| c == '\u{250C}').count())
.sum();
assert!(
total_corners >= 6,
"expected ≥6 ┌ corners across all rows, got {total_corners}:\n{out}"
);
assert!(out.contains("b "), "b label with trailing space missing:\n{out}");
}
#[test]
fn labelled_edge_appears_in_summary() {
let diag = parsed("block-beta\n A B\n A -->|calls| B");
let out = render(&diag, None);
assert!(
out.contains("calls"),
"edge label 'calls' missing from summary:\n{out}"
);
}
#[test]
fn multi_row_grid_has_blank_line_separator() {
let diag = parsed("block-beta\n columns 1\n A\n B\n C");
let out = render(&diag, None);
let blank_lines = out.lines().filter(|l| l.trim().is_empty()).count();
assert!(
blank_lines >= 2,
"expected ≥2 blank separator lines, got {blank_lines}:\n{out}"
);
}
}