use std::collections::{HashMap, HashSet};
use unicode_width::UnicodeWidthStr;
use crate::{
layout::{
Grid, SubgraphBounds,
grid::{Attach, BAR_THICKNESS, EdgeLineStyle, arrow, endpoint},
layered::GridPos,
router,
},
types::{
BarOrientation, Direction, EdgeEndpoint, EdgeStyle, Graph, Node, NodeShape, NodeStyle, Rgb,
},
};
const LABEL_PADDING: usize = 2;
#[derive(Debug, Clone, Copy)]
struct NodeGeom {
pub width: usize,
pub height: usize,
pub text_row: usize,
}
impl NodeGeom {
fn for_node(node: &Node) -> Self {
let label_w = node.label_width();
let inner_w = label_w + LABEL_PADDING * 2;
let extra_lines = node.label_line_count().saturating_sub(1);
match node.shape {
NodeShape::Rectangle | NodeShape::Rounded | NodeShape::Diamond => NodeGeom {
width: inner_w,
height: 3 + extra_lines,
text_row: 1,
},
NodeShape::Circle | NodeShape::Stadium | NodeShape::Hexagon | NodeShape::Asymmetric => {
NodeGeom {
width: inner_w + 2,
height: 3 + extra_lines,
text_row: 1,
}
}
NodeShape::Subroutine => NodeGeom {
width: inner_w + 2,
height: 3 + extra_lines,
text_row: 1,
},
NodeShape::Parallelogram
| NodeShape::ParallelogramBackslash
| NodeShape::Trapezoid
| NodeShape::TrapezoidInverted => NodeGeom {
width: inner_w + 2,
height: 3 + extra_lines,
text_row: 1,
},
NodeShape::Cylinder => NodeGeom {
width: inner_w,
height: 4 + extra_lines,
text_row: 2,
},
NodeShape::DoubleCircle => NodeGeom {
width: inner_w + 4,
height: 5 + extra_lines,
text_row: 2,
},
NodeShape::Bar(BarOrientation::Horizontal) => NodeGeom {
width: 5,
height: BAR_THICKNESS,
text_row: 0,
},
NodeShape::Bar(BarOrientation::Vertical) => NodeGeom {
width: BAR_THICKNESS,
height: 5,
text_row: 0,
},
NodeShape::Note => NodeGeom {
width: inner_w,
height: 3 + extra_lines,
text_row: 1,
},
}
}
fn cx(self) -> usize {
self.width / 2
}
fn cy(self) -> usize {
self.height / 2
}
}
fn exit_point(pos: GridPos, geom: NodeGeom, dir: Direction) -> Attach {
let (c, r) = pos;
match dir {
Direction::LeftToRight => Attach {
col: c + geom.width, row: r + geom.cy(),
},
Direction::RightToLeft => Attach {
col: c.saturating_sub(1),
row: r + geom.cy(),
},
Direction::TopToBottom => Attach {
col: c + geom.cx(),
row: r + geom.height, },
Direction::BottomToTop => Attach {
col: c + geom.cx(),
row: r.saturating_sub(1),
},
}
}
fn entry_point(pos: GridPos, geom: NodeGeom, dir: Direction) -> Attach {
let (c, r) = pos;
match dir {
Direction::LeftToRight => Attach {
col: c.saturating_sub(1), row: r + geom.cy(),
},
Direction::RightToLeft => Attach {
col: c + geom.width,
row: r + geom.cy(),
},
Direction::TopToBottom => Attach {
col: c + geom.cx(),
row: r, },
Direction::BottomToTop => Attach {
col: c + geom.cx(),
row: r + geom.height - 1, },
}
}
fn exit_point_back_edge(pos: GridPos, geom: NodeGeom, dir: Direction) -> Attach {
let (c, r) = pos;
match dir {
Direction::LeftToRight | Direction::RightToLeft => Attach {
col: c + geom.cx(),
row: r + geom.height, },
Direction::TopToBottom | Direction::BottomToTop => Attach {
col: c + geom.width, row: r + geom.cy(),
},
}
}
fn entry_point_back_edge(pos: GridPos, geom: NodeGeom, dir: Direction) -> Attach {
let (c, r) = pos;
match dir {
Direction::LeftToRight | Direction::RightToLeft => Attach {
col: c + geom.cx(),
row: r + geom.height - 1,
},
Direction::TopToBottom | Direction::BottomToTop => Attach {
col: c + geom.width,
row: r + geom.cy(),
},
}
}
fn tip_char_for_back_edge(dir: Direction) -> char {
match dir {
Direction::LeftToRight | Direction::RightToLeft => arrow::UP,
Direction::TopToBottom | Direction::BottomToTop => arrow::LEFT,
}
}
fn back_edge_border_cells(
pos: GridPos,
geom: NodeGeom,
dir: Direction,
) -> ((usize, usize), (usize, usize)) {
let (c, r) = pos;
match dir {
Direction::LeftToRight | Direction::RightToLeft => {
let col = c + geom.cx();
let border_row = r + geom.height - 1;
let path_row = r + geom.height;
((col, border_row), (col, path_row))
}
Direction::TopToBottom | Direction::BottomToTop => {
let row = r + geom.cy();
let border_col = c + geom.width - 1;
let path_col = c + geom.width;
((border_col, row), (path_col, row))
}
}
}
fn has_rounded_bottom_border(shape: NodeShape) -> bool {
matches!(
shape,
NodeShape::Rounded
| NodeShape::Circle
| NodeShape::Stadium
| NodeShape::Note
| NodeShape::DoubleCircle
)
}
fn is_back_edge(from_pos: GridPos, to_pos: GridPos, dir: Direction) -> bool {
let (fc, fr) = from_pos;
let (tc, tr) = to_pos;
match dir {
Direction::LeftToRight => tc < fc,
Direction::RightToLeft => tc > fc,
Direction::TopToBottom => tr < fr,
Direction::BottomToTop => tr > fr,
}
}
fn endpoint_char_back(dir: Direction) -> char {
match dir {
Direction::LeftToRight => arrow::LEFT,
Direction::RightToLeft => arrow::RIGHT,
Direction::TopToBottom => arrow::UP,
Direction::BottomToTop => arrow::DOWN,
}
}
fn tip_char(dir: Direction) -> char {
match dir {
Direction::LeftToRight => arrow::RIGHT,
Direction::RightToLeft => arrow::LEFT,
Direction::TopToBottom => arrow::DOWN,
Direction::BottomToTop => arrow::UP,
}
}
fn grid_size(
graph: &Graph,
positions: &HashMap<String, GridPos>,
geoms: &HashMap<String, NodeGeom>,
sg_bounds: &[SubgraphBounds],
) -> (usize, usize) {
let mut max_col = 0usize;
let mut max_row = 0usize;
for node in &graph.nodes {
if let (Some(&(c, r)), Some(&g)) = (positions.get(&node.id), geoms.get(&node.id)) {
max_col = max_col.max(c + g.width + 4);
max_row = max_row.max(r + g.height + 4);
}
}
for b in sg_bounds {
max_col = max_col.max(b.col + b.width + 4);
max_row = max_row.max(b.row + b.height + 4);
}
let max_label_w = graph
.edges
.iter()
.filter_map(|e| e.label.as_deref())
.map(UnicodeWidthStr::width)
.max()
.unwrap_or(0);
if max_label_w > 0 {
max_col += max_label_w + 2;
max_row += max_label_w + 2;
}
let has_back_edge = graph.edges.iter().any(|e| {
if e.from == e.to {
return true;
}
let Some(&fp) = positions.get(&e.from) else {
return false;
};
let Some(&tp) = positions.get(&e.to) else {
return false;
};
is_back_edge(fp, tp, graph.direction)
});
if has_back_edge {
match graph.direction {
Direction::LeftToRight | Direction::RightToLeft => {
max_row += 4;
}
Direction::TopToBottom | Direction::BottomToTop => {
max_col += 4;
}
}
}
(max_col.max(1), max_row.max(1))
}
pub fn render(
graph: &Graph,
positions: &HashMap<String, GridPos>,
sg_bounds: &[SubgraphBounds],
) -> String {
render_inner(graph, positions, sg_bounds, false)
}
pub fn render_color(
graph: &Graph,
positions: &HashMap<String, GridPos>,
sg_bounds: &[SubgraphBounds],
) -> String {
render_inner(graph, positions, sg_bounds, true)
}
fn render_inner(
graph: &Graph,
positions: &HashMap<String, GridPos>,
sg_bounds: &[SubgraphBounds],
with_color: bool,
) -> String {
let geoms: HashMap<String, NodeGeom> = graph
.nodes
.iter()
.map(|n| (n.id.clone(), NodeGeom::for_node(n)))
.collect();
let (width, height) = grid_size(graph, positions, &geoms, sg_bounds);
let mut grid = Grid::new(width, height);
for bounds in sg_bounds.iter().rev() {
let style = if with_color {
graph.subgraph_styles.get(&bounds.id)
} else {
None
};
draw_subgraph_border(&mut grid, bounds, style);
}
let mut node_rects: Vec<(usize, usize, usize, usize)> = Vec::with_capacity(graph.nodes.len());
for node in &graph.nodes {
let Some(&(col, row)) = positions.get(&node.id) else {
continue;
};
let Some(&geom) = geoms.get(&node.id) else {
continue;
};
grid.mark_node_box(col, row, geom.width, geom.height);
node_rects.push((col, row, geom.width, geom.height));
}
if !node_rects.is_empty() {
let hull_min_col = node_rects.iter().map(|r| r.0).min().unwrap_or(0);
let hull_min_row = node_rects.iter().map(|r| r.1).min().unwrap_or(0);
let hull_max_col = node_rects.iter().map(|r| r.0 + r.2).max().unwrap_or(0);
let hull_max_row = node_rects.iter().map(|r| r.1 + r.3).max().unwrap_or(0);
if hull_max_col > hull_min_col && hull_max_row > hull_min_row {
grid.mark_inner_area(
hull_min_col,
hull_min_row,
hull_max_col - hull_min_col,
hull_max_row - hull_min_row,
);
}
}
let attach_points = compute_spread_attaches(graph, positions, &geoms);
let edge_is_back_flags: Vec<bool> = graph
.edges
.iter()
.map(|e| {
if e.from == e.to {
return true;
}
let fp = positions.get(&e.from).copied();
let tp = positions.get(&e.to).copied();
match (fp, tp) {
(Some(fp), Some(tp)) => is_back_edge(fp, tp, graph.direction),
_ => false,
}
})
.collect();
let mut forward_outgoing_counts: HashMap<&str, usize> = HashMap::new();
for (edge_idx, edge) in graph.edges.iter().enumerate() {
if !edge_is_back_flags[edge_idx] {
*forward_outgoing_counts
.entry(edge.from.as_str())
.or_default() += 1;
}
}
let mut directed_pair_counts: HashMap<(&str, &str), usize> = HashMap::new();
for edge in &graph.edges {
*directed_pair_counts
.entry((edge.from.as_str(), edge.to.as_str()))
.or_default() += 1;
}
let mut back_edge_border_joins: Vec<(usize, usize, bool, bool)> = Vec::new();
let mut back_edge_path_joins: Vec<(usize, usize)> = Vec::new();
for (edge_idx, edge) in graph.edges.iter().enumerate() {
if !edge_is_back_flags[edge_idx] {
continue;
}
if let (Some(fp), Some(fg), Some(tp), Some(tg)) = (
positions.get(&edge.from).copied(),
geoms.get(&edge.from).copied(),
positions.get(&edge.to).copied(),
geoms.get(&edge.to).copied(),
) {
if edge.from == edge.to {
continue;
}
let (sb, sp) = back_edge_border_cells(fp, fg, graph.direction);
let (db, _) = back_edge_border_cells(tp, tg, graph.direction);
let skip_src_border = matches!(
graph.direction,
Direction::LeftToRight | Direction::RightToLeft
) && graph
.node(&edge.from)
.is_some_and(|n| has_rounded_bottom_border(n.shape));
back_edge_border_joins.push((sb.0, sb.1, false, skip_src_border));
back_edge_border_joins.push((db.0, db.1, true, false));
back_edge_path_joins.push(sp);
}
}
let paths = router::route_all(
&mut grid,
graph,
&attach_points,
|edge_idx| {
if edge_is_back_flags.get(edge_idx).copied().unwrap_or(false) {
tip_char_for_back_edge(graph.direction)
} else {
tip_char(graph.direction)
}
},
|edge_idx| edge_is_back_flags.get(edge_idx).copied().unwrap_or(false),
);
let mut pending_labels: Vec<(usize, usize, String, Option<crate::types::Rgb>)> = Vec::new();
let mut placed_labels: Vec<(usize, usize, usize, usize)> = Vec::new();
let mut prior_path_cells_by_pair: HashMap<(&str, &str), HashSet<(usize, usize)>> =
HashMap::new();
for (edge_idx, edge) in graph.edges.iter().enumerate() {
let Some(Some((src, dst))) = attach_points.get(edge_idx) else {
continue;
};
let (src, _dst) = (*src, *dst);
let edge_pair = (edge.from.as_str(), edge.to.as_str());
let has_parallel_same_direction =
directed_pair_counts.get(&edge_pair).copied().unwrap_or(0) > 1;
let edge_is_back = edge_is_back_flags[edge_idx];
let horizontal_first = graph.direction.is_horizontal();
let path = &paths[edge_idx];
if let Some(path) = path.as_ref()
&& let Some(&(tip_c, tip_r)) = path.last()
&& edge.end != EdgeEndpoint::Arrow
{
grid.unprotect_cell(tip_c, tip_r);
let glyph = match edge.end {
EdgeEndpoint::None => {
if edge_is_back {
if horizontal_first { '│' } else { '─' }
} else if horizontal_first {
'─'
} else {
'│'
}
}
EdgeEndpoint::Circle => endpoint::CIRCLE,
EdgeEndpoint::Cross => endpoint::CROSS,
EdgeEndpoint::Arrow => unreachable!(),
};
grid.set(tip_c, tip_r, glyph);
if edge.end != EdgeEndpoint::None {
grid.protect_cell(tip_c, tip_r);
}
}
if let Some(path) = path.as_ref() {
let line_style = match edge.style {
EdgeStyle::Solid => EdgeLineStyle::Solid,
EdgeStyle::Dotted => EdgeLineStyle::Dotted,
EdgeStyle::Thick => EdgeLineStyle::Thick,
};
if path.len() > 1 {
grid.overdraw_path_style(&path[..path.len() - 1], line_style);
}
if edge.start == EdgeEndpoint::Arrow && path.len() >= 2 {
let back_tip = endpoint_char_back(graph.direction);
grid.set(src.col, src.row, back_tip);
grid.protect_cell(src.col, src.row);
}
if with_color
&& let Some(es) = graph.edge_styles.get(&edge_idx)
&& let Some(stroke) = es.stroke
{
grid.paint_fg_path(path, stroke);
}
}
if let (Some(lbl), Some(path)) = (&edge.label, path.as_ref())
&& let Some((lbl_col, lbl_row)) = {
let has_sibling_outgoing = forward_outgoing_counts
.get(edge.from.as_str())
.copied()
.unwrap_or(0)
> 1;
let prior_path_cells = has_parallel_same_direction
.then(|| prior_path_cells_by_pair.get(&edge_pair))
.flatten();
let label_context = LabelPlacementContext {
dir: graph.direction,
node_rects: &node_rects,
sg_bounds,
grid: &grid,
edge_is_back,
has_sibling_outgoing,
prior_path_cells,
};
label_position(path, lbl, &mut placed_labels, &label_context)
}
{
let lbl_color = if with_color {
graph
.edge_styles
.get(&edge_idx)
.and_then(|es| es.color.or(es.stroke))
} else {
None
};
pending_labels.push((lbl_col, lbl_row, lbl.clone(), lbl_color));
}
if has_parallel_same_direction && let Some(path) = path.as_ref() {
prior_path_cells_by_pair
.entry(edge_pair)
.or_default()
.extend(path.iter().copied());
}
}
for node in &graph.nodes {
let Some(&pos) = positions.get(&node.id) else {
continue;
};
let Some(&geom) = geoms.get(&node.id) else {
continue;
};
draw_node_box(&mut grid, node, pos, geom);
if with_color && let Some(style) = graph.node_styles.get(&node.id).copied() {
paint_node_colors(&mut grid, pos, geom, style);
}
}
let border_junction = match graph.direction {
Direction::LeftToRight | Direction::RightToLeft => '┬',
Direction::TopToBottom | Direction::BottomToTop => '├',
};
let path_junction_lr = '┴'; for (col, row, is_dest, skip_border_stamp) in &back_edge_border_joins {
if *is_dest || *skip_border_stamp {
continue;
}
grid.set(*col, *row, border_junction);
}
for (col, row) in &back_edge_path_joins {
let current = grid.get(*col, *row);
let is_exit_collision = matches!(
graph.direction,
Direction::LeftToRight | Direction::RightToLeft
) && current == '├';
if current != '─' && current != '│' && !is_exit_collision {
continue;
}
let glyph = match graph.direction {
Direction::LeftToRight | Direction::RightToLeft => path_junction_lr,
Direction::TopToBottom => '┘', Direction::BottomToTop => '┐', };
grid.set(*col, *row, glyph);
}
for (lbl_col, lbl_row, lbl, lbl_color) in &pending_labels {
for (i, line) in lbl.lines().enumerate() {
let row = lbl_row + i;
grid.write_text_protected(*lbl_col, row, line);
if let Some(c) = lbl_color {
let line_w = UnicodeWidthStr::width(line);
grid.paint_fg_rect(*lbl_col, row, line_w, 1, *c);
}
}
}
for node in &graph.nodes {
let Some(&pos) = positions.get(&node.id) else {
continue;
};
let Some(&geom) = geoms.get(&node.id) else {
continue;
};
let click_url = graph.click_targets.get(&node.id).map(|ct| ct.url.as_str());
draw_label_centred(&mut grid, node, pos, geom, click_url);
}
if with_color {
grid.render_with_colors()
} else {
grid.render()
}
}
fn paint_node_colors(grid: &mut Grid, pos: GridPos, geom: NodeGeom, style: NodeStyle) {
let (col, row) = pos;
let w = geom.width;
let h = geom.height;
if w < 2 || h < 2 {
return;
}
if let Some(stroke) = style.stroke {
paint_box_border_fg(grid, col, row, w, h, stroke);
}
let inner_col = col + 1;
let inner_row = row + 1;
let inner_w = w - 2;
let inner_h = h - 2;
if let Some(fill) = style.fill {
grid.paint_bg_rect(inner_col, inner_row, inner_w, inner_h, fill);
}
if let Some(text_color) = style.color {
grid.paint_fg_rect(inner_col, inner_row, inner_w, inner_h, text_color);
}
}
fn paint_box_border_fg(grid: &mut Grid, col: usize, row: usize, w: usize, h: usize, color: Rgb) {
if w < 2 || h < 2 {
return;
}
for x in col..(col + w) {
grid.set_fg(x, row, color);
grid.set_fg(x, row + h - 1, color);
}
for y in (row + 1)..(row + h - 1) {
grid.set_fg(col, y, color);
grid.set_fg(col + w - 1, y, color);
}
}
fn compute_spread_attaches(
graph: &Graph,
positions: &HashMap<String, GridPos>,
geoms: &HashMap<String, NodeGeom>,
) -> Vec<Option<(Attach, Attach)>> {
let mut pairs: Vec<Option<(Attach, Attach)>> = graph
.edges
.iter()
.map(|edge| {
let from_pos = *positions.get(&edge.from)?;
let to_pos = *positions.get(&edge.to)?;
let from_geom = *geoms.get(&edge.from)?;
let to_geom = *geoms.get(&edge.to)?;
if edge.from == edge.to || is_back_edge(from_pos, to_pos, graph.direction) {
let src = exit_point_back_edge(from_pos, from_geom, graph.direction);
let dst = entry_point_back_edge(to_pos, to_geom, graph.direction);
Some((src, dst))
} else {
let src = exit_point(from_pos, from_geom, graph.direction);
let dst = entry_point(to_pos, to_geom, graph.direction);
Some((src, dst))
}
})
.collect();
let mut dst_groups: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
for (i, pair) in pairs.iter().enumerate() {
if let Some((_, dst)) = pair {
dst_groups.entry((dst.col, dst.row)).or_default().push(i);
}
}
for indices in dst_groups.values() {
if indices.len() <= 1 {
continue;
}
let first_edge = &graph.edges[indices[0]];
let Some(&to_pos) = positions.get(&first_edge.to) else {
continue;
};
let Some(&to_geom) = geoms.get(&first_edge.to) else {
continue;
};
spread_destinations(&mut pairs, indices, to_pos, to_geom, graph.direction);
}
let mut src_groups: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
for (i, pair) in pairs.iter().enumerate() {
if let Some((src, _)) = pair {
src_groups.entry((src.col, src.row)).or_default().push(i);
}
}
for indices in src_groups.values() {
if indices.len() <= 1 {
continue;
}
let first_edge = &graph.edges[indices[0]];
let Some(&from_pos) = positions.get(&first_edge.from) else {
continue;
};
let Some(&from_geom) = geoms.get(&first_edge.from) else {
continue;
};
spread_sources(&mut pairs, indices, from_pos, from_geom, graph.direction);
}
pairs
}
fn spread_destinations(
pairs: &mut [Option<(Attach, Attach)>],
indices: &[usize],
to_pos: GridPos,
to_geom: NodeGeom,
dir: Direction,
) {
let n = indices.len();
let (to_col, to_row) = to_pos;
match dir {
Direction::LeftToRight | Direction::RightToLeft => {
let min_row = to_row;
let max_row = to_row + to_geom.height.saturating_sub(1);
if max_row < min_row || max_row - min_row + 1 < n {
return;
}
let centre = (to_row + to_geom.cy()) as isize;
let spread_range = (max_row - min_row) as isize;
let step = if n > 1 {
(spread_range / (n as isize - 1)).clamp(1, 2)
} else {
1
};
for (i, &idx) in indices.iter().enumerate() {
let offset = (i as isize - (n as isize - 1) / 2) * step;
let new_row = (centre + offset)
.max(min_row as isize)
.min(max_row as isize) as usize;
if let Some((_, dst)) = &mut pairs[idx] {
dst.row = new_row;
}
}
}
Direction::TopToBottom | Direction::BottomToTop => {
let min_col = to_col;
let max_col = to_col + to_geom.width.saturating_sub(1);
if max_col < min_col || max_col - min_col + 1 < n {
return;
}
let centre = (to_col + to_geom.cx()) as isize;
let spread_range = (max_col - min_col) as isize;
let step = if n > 1 {
(spread_range / (n as isize - 1)).clamp(1, 2)
} else {
1
};
for (i, &idx) in indices.iter().enumerate() {
let offset = (i as isize - (n as isize - 1) / 2) * step;
let new_col = (centre + offset)
.max(min_col as isize)
.min(max_col as isize) as usize;
if let Some((_, dst)) = &mut pairs[idx] {
dst.col = new_col;
}
}
}
}
}
fn spread_sources(
pairs: &mut [Option<(Attach, Attach)>],
indices: &[usize],
from_pos: GridPos,
from_geom: NodeGeom,
dir: Direction,
) {
let n = indices.len();
let (from_col, from_row) = from_pos;
match dir {
Direction::LeftToRight | Direction::RightToLeft => {
let min_row = from_row;
let max_row = from_row + from_geom.height.saturating_sub(1);
if min_row > max_row {
return;
}
let available = max_row - min_row + 1;
if available < 2 {
return; }
let centre = (from_row + from_geom.cy()) as isize;
let spread_range = (max_row - min_row) as isize;
let step = if n > 1 {
(spread_range / (n as isize - 1)).clamp(1, 2)
} else {
1
};
for (i, &idx) in indices.iter().enumerate() {
let offset = (i as isize - (n as isize - 1) / 2) * step;
let new_row = (centre + offset)
.max(min_row as isize)
.min(max_row as isize) as usize;
if let Some((src, _)) = &mut pairs[idx] {
src.row = new_row;
}
}
}
Direction::TopToBottom | Direction::BottomToTop => {
let min_col = from_col;
let max_col = from_col + from_geom.width.saturating_sub(1);
if min_col > max_col {
return;
}
let available = max_col - min_col + 1;
if available < 2 {
return;
}
let centre = (from_col + from_geom.cx()) as isize;
let spread_range = (max_col - min_col) as isize;
let step = if n > 1 {
(spread_range / (n as isize - 1)).clamp(1, 2)
} else {
1
};
for (i, &idx) in indices.iter().enumerate() {
let offset = (i as isize - (n as isize - 1) / 2) * step;
let new_col = (centre + offset)
.max(min_col as isize)
.min(max_col as isize) as usize;
if let Some((src, _)) = &mut pairs[idx] {
src.col = new_col;
}
}
}
}
}
fn draw_node_box(grid: &mut Grid, node: &Node, pos: GridPos, geom: NodeGeom) {
let (col, row) = pos;
for y in (row + 1)..(row + geom.height.saturating_sub(1)) {
for x in (col + 1)..(col + geom.width.saturating_sub(1)) {
grid.set(x, y, ' ');
}
}
match node.shape {
NodeShape::Rectangle => {
grid.draw_box(col, row, geom.width, geom.height);
}
NodeShape::Rounded => {
grid.draw_rounded_box(col, row, geom.width, geom.height);
}
NodeShape::Diamond => {
grid.draw_diamond(col, row, geom.width, geom.height);
}
NodeShape::Circle => {
grid.draw_rounded_box(col, row, geom.width, geom.height);
let mid = row + geom.cy();
grid.set(col, mid, '(');
grid.set(col + geom.width - 1, mid, ')');
}
NodeShape::Stadium => {
grid.draw_stadium(col, row, geom.width, geom.height);
}
NodeShape::Subroutine => {
grid.draw_subroutine(col, row, geom.width, geom.height);
}
NodeShape::Cylinder => {
grid.draw_cylinder(col, row, geom.width, geom.height);
}
NodeShape::Hexagon => {
grid.draw_hexagon(col, row, geom.width, geom.height);
}
NodeShape::Asymmetric => {
grid.draw_asymmetric(col, row, geom.width, geom.height);
}
NodeShape::Parallelogram => {
grid.draw_parallelogram(col, row, geom.width, geom.height);
}
NodeShape::Trapezoid => {
grid.draw_trapezoid(col, row, geom.width, geom.height);
}
NodeShape::ParallelogramBackslash => {
grid.draw_parallelogram_backslash(col, row, geom.width, geom.height);
}
NodeShape::TrapezoidInverted => {
grid.draw_trapezoid_inverted(col, row, geom.width, geom.height);
}
NodeShape::DoubleCircle => {
grid.draw_double_circle(col, row, geom.width, geom.height);
}
NodeShape::Bar(BarOrientation::Horizontal) => {
grid.draw_horizontal_bar(col, row, geom.width);
}
NodeShape::Bar(BarOrientation::Vertical) => {
grid.draw_vertical_bar(col, row, geom.height);
}
NodeShape::Note => {
grid.draw_rounded_box(col, row, geom.width, geom.height);
}
}
}
fn draw_subgraph_border(grid: &mut Grid, bounds: &SubgraphBounds, style: Option<&NodeStyle>) {
let (col, row, w, h) = (bounds.col, bounds.row, bounds.width, bounds.height);
if w < 2 || h < 2 {
return;
}
grid.draw_rounded_box(col, row, w, h);
if let Some(style) = style
&& let Some(stroke) = style.stroke
{
paint_box_border_fg(grid, col, row, w, h, stroke);
}
for x in col..(col + w) {
grid.protect_cell(x, row);
grid.protect_cell(x, row + h - 1);
}
for y in (row + 1)..(row + h - 1) {
grid.protect_cell(col, y);
grid.protect_cell(col + w - 1, y);
}
use crate::layout::grid::{DIR_DOWN, DIR_LEFT, DIR_RIGHT, DIR_UP};
for x in (col + 1)..(col + w - 1) {
grid.seed_border_dirs(x, row, DIR_LEFT | DIR_RIGHT);
grid.seed_border_dirs(x, row + h - 1, DIR_LEFT | DIR_RIGHT);
}
for y in (row + 1)..(row + h - 1) {
grid.seed_border_dirs(col, y, DIR_UP | DIR_DOWN);
grid.seed_border_dirs(col + w - 1, y, DIR_UP | DIR_DOWN);
}
let label_col = col + 2;
let label_row = row;
let max_label_w = w.saturating_sub(4);
let label = truncate_to_width(&bounds.label, max_label_w);
if !label.is_empty() {
grid.write_text_protected(label_col, label_row, &label);
let label_display_w = unicode_width::UnicodeWidthStr::width(label.as_str());
for dx in 0..label_display_w {
grid.clear_dirs(label_col + dx, label_row);
}
}
}
fn truncate_to_width(s: &str, max_width: usize) -> String {
let mut out = String::new();
let mut w = 0;
for ch in s.chars() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
if w + cw > max_width {
break;
}
out.push(ch);
w += cw;
}
out
}
fn draw_label_centred(
grid: &mut Grid,
node: &Node,
pos: GridPos,
geom: NodeGeom,
click_url: Option<&str>,
) {
if matches!(node.shape, NodeShape::Bar(_)) {
return;
}
let (col, row) = pos;
let interior_w = geom.width.saturating_sub(2);
for (i, line) in node.label.lines().enumerate() {
let line_w = UnicodeWidthStr::width(line);
let text_col = if line_w <= interior_w {
col + 1 + (interior_w - line_w) / 2
} else {
col + 1
};
let text_row = row + geom.text_row + i;
grid.write_text(text_col, text_row, line);
if let Some(url) = click_url {
let link_w = line_w.max(1); grid.paint_hyperlink(text_col, text_row, link_w, 1, url);
}
}
}
struct LabelPlacementContext<'a> {
dir: Direction,
node_rects: &'a [(usize, usize, usize, usize)],
sg_bounds: &'a [SubgraphBounds],
grid: &'a Grid,
edge_is_back: bool,
has_sibling_outgoing: bool,
prior_path_cells: Option<&'a HashSet<(usize, usize)>>,
}
fn label_position(
path: &[(usize, usize)],
label: &str,
placed: &mut Vec<(usize, usize, usize, usize)>,
context: &LabelPlacementContext<'_>,
) -> Option<(usize, usize)> {
if path.len() < 2 {
return None;
}
let lbl_w = label.lines().map(UnicodeWidthStr::width).max().unwrap_or(0);
let lbl_h = label.lines().count().max(1);
if lbl_w == 0 {
return None;
}
let candidates = candidate_positions(
path,
context.dir,
lbl_w,
context.edge_is_back,
context.has_sibling_outgoing,
context.sg_bounds,
);
if candidates.is_empty() {
return None;
}
for &(c, r) in &candidates {
let row_ok = !collides(c, r, lbl_w, placed)
&& !overlaps_prior_path(c, r, lbl_w, context.prior_path_cells)
&& !overlaps_node_interior(c, r, lbl_w, context.node_rects)
&& !overlaps_node_border_row(c, r, lbl_w, context.node_rects)
&& !overlaps_subgraph_border(c, r, lbl_w, context.sg_bounds)
&& !label_abuts_subgraph_right_wall(c, r, lbl_w, context.sg_bounds)
&& !label_touches_path_corner(c, r, lbl_w, context.grid);
if !row_ok {
continue;
}
let extra_rows_ok = (1..lbl_h).all(|dr| {
let rr = r + dr;
!overlaps_subgraph_border(c, rr, lbl_w, context.sg_bounds)
});
if extra_rows_ok {
placed.push((c, r, lbl_w, lbl_h));
return Some((c, r));
}
}
for &(c, r) in &candidates {
if !collides(c, r, lbl_w, placed)
&& !label_spans_subgraph_border_cell(c, r, lbl_w, context.sg_bounds)
{
placed.push((c, r, lbl_w, lbl_h));
return Some((c, r));
}
}
None
}
fn overlaps_prior_path(
col: usize,
row: usize,
w: usize,
prior_path_cells: Option<&HashSet<(usize, usize)>>,
) -> bool {
let Some(prior_path_cells) = prior_path_cells else {
return false;
};
(col..col + w).any(|c| prior_path_cells.contains(&(c, row)))
}
fn label_touches_path_corner(col: usize, row: usize, w: usize, grid: &Grid) -> bool {
const CORNERS: &[char] = &[
'┘', '└', '┐', '┌', '┤', '├', '┬', '┴', '┼',
'╯', '╰', '╮', '╭', '▴', '▾', '▸', '◂',
];
if col > 0 && CORNERS.contains(&grid.get(col - 1, row)) {
return true;
}
if CORNERS.contains(&grid.get(col + w, row)) {
return true;
}
false
}
fn candidate_positions(
path: &[(usize, usize)],
dir: Direction,
lbl_w: usize,
edge_is_back: bool,
has_sibling_outgoing: bool,
sg_bounds: &[SubgraphBounds],
) -> Vec<(usize, usize)> {
const MIN_DOGLEG_SIDE_LABEL_WIDTH: usize = 8;
match dir {
Direction::LeftToRight | Direction::RightToLeft => {
let mut out = Vec::new();
let raw_longest_seg = longest_horizontal_segment_with_range(path);
let last_seg = last_horizontal_segment_with_range(path);
let longest_seg = if edge_is_back {
match (raw_longest_seg, last_seg) {
(Some(lng), Some(last)) if lng.1 >= last.1 => Some(lng),
_ => last_seg,
}
} else {
raw_longest_seg
};
let vert_dominance = last_vertical_segment_with_len(path)
.map(|(_, _, vlen)| vlen)
.unwrap_or(0);
let is_vert_dominant = vert_dominance >= 4;
let longest_seg = match longest_seg {
Some(lng) if is_vert_dominant => {
let seg_len = lng.3.saturating_sub(lng.2);
if seg_len < lbl_w {
match last_seg {
Some(last) if last != lng => Some(last),
_ => None, }
} else {
Some(lng)
}
}
_ => longest_seg,
};
let append_seg_candidates =
|out: &mut Vec<(usize, usize)>,
mid_col: usize,
seg_row: usize,
lo_col: usize,
hi_col: usize,
exclude_row: Option<usize>| {
let third = (hi_col - lo_col) / 3;
let col_anchors = [mid_col, lo_col + third, lo_col + 2 * third];
let row_offsets: [isize; 8] = [-1, 1, -2, 2, -3, 3, -4, 4];
out.reserve(col_anchors.len() * row_offsets.len() * 2);
for &c in &col_anchors {
for &dr in &row_offsets {
let r = (seg_row as isize + dr).max(0) as usize;
if exclude_row == Some(r) {
continue;
}
let inside_sg = sg_bounds.iter().any(|sg| {
let bottom = sg.row + sg.height;
r > sg.row && r < bottom.saturating_sub(1)
});
if inside_sg {
out.push((c, r));
}
}
}
for &c in &col_anchors {
for &dr in &row_offsets {
let r = (seg_row as isize + dr).max(0) as usize;
if exclude_row == Some(r) {
continue;
}
let inside_sg = sg_bounds.iter().any(|sg| {
let bottom = sg.row + sg.height;
r > sg.row && r < bottom.saturating_sub(1)
});
if !inside_sg {
out.push((c, r));
}
}
}
};
let dogleg_ok = !is_vert_dominant
|| longest_seg
.map(|lng| lng.3.saturating_sub(lng.2) >= lbl_w)
.unwrap_or(false);
if dogleg_ok
&& lbl_w >= MIN_DOGLEG_SIDE_LABEL_WIDTH
&& has_sibling_outgoing
&& !edge_is_back
&& let Some((seg_col, seg_row, len)) = last_vertical_segment_with_len(path)
&& len >= 4
{
let left_col = seg_col.saturating_sub(lbl_w + 1);
let right_col = seg_col + 1;
let row_offsets: [isize; 5] = [0, -1, 1, -2, 2];
out.reserve(row_offsets.len() * 2);
for &dr in &row_offsets {
let r = (seg_row as isize + dr).max(0) as usize;
out.push((left_col, r));
out.push((right_col, r));
}
}
if let Some((mid_col, seg_row, lo_col, hi_col)) = longest_seg {
let last_row_for_exclusion = last_seg
.filter(|(_, lr, _, _)| *lr != seg_row)
.map(|(_, lr, _, _)| lr);
append_seg_candidates(
&mut out,
mid_col,
seg_row,
lo_col,
hi_col,
last_row_for_exclusion,
);
if let Some((last_mid, last_row, last_lo, last_hi)) = last_seg {
if last_row != seg_row || last_lo != lo_col || last_hi != hi_col {
append_seg_candidates(
&mut out, last_mid, last_row, last_lo, last_hi,
None, );
}
}
} else {
let path_tip = path.last().copied().unwrap_or((0, 0));
let tip_row = path_tip.1;
let tip_stub_start = {
let mut i = path.len().saturating_sub(1);
while i > 0 && path[i - 1].1 == tip_row {
i -= 1;
}
i
};
let tip_stub_len = path.len() - tip_stub_start;
let (seg_col, seg_row) = last_vertical_segment(path).unwrap_or(path_tip);
let col_anchors = [seg_col + 1, seg_col.saturating_sub(lbl_w + 1)];
if tip_stub_len >= 2 {
let tip_col = path[tip_stub_start].0.min(path_tip.0);
let left_anchor = tip_col.saturating_sub(lbl_w);
let row_offsets: [isize; 4] = [0, -1, 1, -2];
out.reserve(col_anchors.len() * row_offsets.len() + row_offsets.len());
for &dr in &row_offsets {
let r = (tip_row as isize + dr).max(0) as usize;
out.push((left_anchor, r));
}
for &c in &col_anchors {
for &dr in &row_offsets {
let r = (seg_row as isize + dr).max(0) as usize;
out.push((c, r));
}
}
} else {
let row_offsets: [isize; 4] = [2, 1, 3, 0];
out.reserve(col_anchors.len() * row_offsets.len());
for &c in &col_anchors {
for &dr in &row_offsets {
let r = (seg_row as isize + dr).max(0) as usize;
out.push((c, r));
}
}
}
}
out
}
Direction::TopToBottom | Direction::BottomToTop => {
let (seg_col, seg_row) = match last_vertical_segment(path) {
Some(v) => v,
None => return Vec::new(),
};
let mut out = Vec::new();
if has_sibling_outgoing
&& !edge_is_back
&& let Some(branch_dir) = last_horizontal_segment_direction(path)
{
let preferred_col = if branch_dir < 0 {
seg_col.saturating_sub(lbl_w + 1)
} else {
seg_col + 1
};
let row_offsets: [isize; 5] = [0, -1, 1, -2, 2];
out.reserve(row_offsets.len());
for &dr in &row_offsets {
let r = (seg_row as isize + dr).max(0) as usize;
out.push((preferred_col, r));
}
}
let col_anchors = [seg_col + 1, seg_col.saturating_sub(1), seg_col + 2];
let row_offsets: [isize; 8] = [0, -1, 1, -2, 2, -3, 3, -4];
out.reserve(col_anchors.len() * row_offsets.len());
for &c in &col_anchors {
for &dr in &row_offsets {
let r = (seg_row as isize + dr).max(0) as usize;
out.push((c, r));
}
}
out
}
}
}
fn last_horizontal_segment_with_range(
path: &[(usize, usize)],
) -> Option<(usize, usize, usize, usize)> {
if path.len() < 2 {
return None;
}
let n = path.len();
let mut i = n.saturating_sub(2);
loop {
let row = path[i].1;
let mut start = i;
while start > 0 && path[start - 1].1 == row {
start -= 1;
}
let run_len = i - start + 1;
if run_len >= 2 {
let lo_col = path[start].0.min(path[i].0);
let hi_col = path[start].0.max(path[i].0);
let mid_col = (lo_col + hi_col) / 2;
return Some((mid_col, row, lo_col, hi_col));
}
if i == 0 {
break;
}
i = start.saturating_sub(1);
if i == 0 && path[0].1 != row {
break;
}
}
None
}
fn longest_horizontal_segment_with_range(
path: &[(usize, usize)],
) -> Option<(usize, usize, usize, usize)> {
if path.len() < 2 {
return None;
}
let mut best: Option<(usize, usize, usize, usize, usize)> = None;
let n = path.len();
let mut i = n.saturating_sub(2);
loop {
let row = path[i].1;
let mut start = i;
while start > 0 && path[start - 1].1 == row {
start -= 1;
}
let run_len = i - start + 1;
if run_len >= 2 {
let lo_col = path[start].0.min(path[i].0);
let hi_col = path[start].0.max(path[i].0);
let seg_len = hi_col - lo_col;
let mid_col = (lo_col + hi_col) / 2;
if best.is_none() || seg_len > best.unwrap().0 {
best = Some((seg_len, mid_col, row, lo_col, hi_col));
}
}
if i == 0 {
break;
}
i = start.saturating_sub(1);
if i == 0 && path[0].1 != row {
break;
}
}
best.map(|(_len, mid, row, lo, hi)| (mid, row, lo, hi))
}
fn last_horizontal_segment_direction(path: &[(usize, usize)]) -> Option<isize> {
for pair in path.windows(2).rev() {
let ((from_col, from_row), (to_col, to_row)) = (pair[0], pair[1]);
if from_row == to_row {
return match to_col.cmp(&from_col) {
std::cmp::Ordering::Less => Some(-1),
std::cmp::Ordering::Greater => Some(1),
std::cmp::Ordering::Equal => continue,
};
}
}
None
}
fn last_vertical_segment(path: &[(usize, usize)]) -> Option<(usize, usize)> {
last_vertical_segment_with_len(path).map(|(col, row, _len)| (col, row))
}
fn last_vertical_segment_with_len(path: &[(usize, usize)]) -> Option<(usize, usize, usize)> {
if path.len() < 2 {
return None;
}
let n = path.len();
let mut i = n.saturating_sub(2);
loop {
let col = path[i].0;
let mut start = i;
while start > 0 && path[start - 1].0 == col {
start -= 1;
}
let run_len = i - start + 1;
if run_len >= 2 {
let mid_row = (path[start].1 + path[i].1) / 2;
return Some((col, mid_row, run_len));
}
if i == 0 {
break;
}
i = start.saturating_sub(1);
if i == 0 && path[0].0 != col {
break;
}
}
None
}
fn collides(col: usize, row: usize, w: usize, placed: &[(usize, usize, usize, usize)]) -> bool {
for &(pc, pr, pw, ph) in placed {
let row_overlaps = (row >= pr && row < pr + ph) || (pr >= row && pr < row + 1);
if row_overlaps {
let padded_start = col.saturating_sub(1);
let padded_end = col + w + 1;
let no_col_overlap = padded_end <= pc || pc + pw <= padded_start;
if !no_col_overlap {
return true;
}
}
}
false
}
fn overlaps_node_interior(
col: usize,
row: usize,
w: usize,
node_rects: &[(usize, usize, usize, usize)],
) -> bool {
for &(nc, nr, nw, nh) in node_rects {
if nw < 2 || nh < 2 {
continue;
}
let int_left = nc; let int_right = nc + nw; let int_top = nr + 1;
let int_bottom = nr + nh - 1; let row_in_interior = row >= int_top && row < int_bottom;
if !row_in_interior {
continue;
}
let col_overlaps = !(col + w <= int_left || int_right <= col);
if col_overlaps {
return true;
}
}
false
}
fn overlaps_node_border_row(
col: usize,
row: usize,
w: usize,
node_rects: &[(usize, usize, usize, usize)],
) -> bool {
let label_end = col + w; for &(nc, nr, nw, nh) in node_rects {
if nw == 0 || nh == 0 {
continue;
}
let bottom = nr + nh - 1;
if row != nr && row != bottom {
continue;
}
let right_excl = nc + nw; let col_overlaps = !(label_end <= nc || right_excl <= col);
if col_overlaps {
return true;
}
}
false
}
fn overlaps_subgraph_border(
col: usize,
row: usize,
w: usize,
sg_bounds: &[SubgraphBounds],
) -> bool {
let label_end = col + w; for sg in sg_bounds {
if sg.width == 0 || sg.height == 0 {
continue;
}
let right = sg.col + sg.width - 1;
let bottom = sg.row + sg.height - 1;
if row == sg.row || row == bottom {
let col_overlaps = !(label_end <= sg.col || right < col);
if col_overlaps {
return true;
}
continue;
}
let row_in_height = row > sg.row && row < bottom;
if !row_in_height {
continue;
}
let hits_left = col <= sg.col && sg.col < label_end;
let hits_right = col <= right && right < label_end;
if hits_left || hits_right {
return true;
}
}
false
}
fn label_abuts_subgraph_right_wall(
col: usize,
row: usize,
w: usize,
sg_bounds: &[SubgraphBounds],
) -> bool {
for sg in sg_bounds {
if sg.width == 0 || sg.height == 0 {
continue;
}
let right = sg.col + sg.width - 1;
let bottom = sg.row + sg.height - 1;
if row <= sg.row || row >= bottom {
continue;
}
if col + w == right {
return true;
}
}
false
}
fn label_spans_subgraph_border_cell(
col: usize,
row: usize,
w: usize,
sg_bounds: &[SubgraphBounds],
) -> bool {
let label_end = col + w; for sg in sg_bounds {
if sg.width == 0 || sg.height == 0 {
continue;
}
let right = sg.col + sg.width - 1;
let bottom = sg.row + sg.height - 1;
if row == sg.row || row == bottom {
let col_overlaps = !(label_end <= sg.col || right < col);
if col_overlaps {
return true;
}
continue;
}
if row <= sg.row || row >= bottom {
continue;
}
if (col <= sg.col && sg.col < label_end) || (col <= right && right < label_end) {
return true;
}
}
false
}
pub fn osc8_wrap(url: &str, text: &str) -> String {
format!("\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
layout::layered::{LayoutConfig, layout},
parser,
};
fn render_diagram(src: &str) -> String {
let graph = parser::parse(src).unwrap();
let crate::layout::layered::LayoutResult { positions, .. } =
layout(&graph, &LayoutConfig::default());
let sg_bounds = crate::layout::subgraph::compute_subgraph_bounds(&graph, &positions);
render(&graph, &positions, &sg_bounds)
}
#[test]
fn lr_output_contains_node_labels() {
let out = render_diagram("graph LR\nA[Start] --> B[End]");
assert!(out.contains("Start"), "missing 'Start' in:\n{out}");
assert!(out.contains("End"), "missing 'End' in:\n{out}");
}
#[test]
fn td_output_contains_node_labels() {
let out = render_diagram("graph TD\nA[Top] --> B[Bottom]");
assert!(out.contains("Top"), "missing 'Top' in:\n{out}");
assert!(out.contains("Bottom"), "missing 'Bottom' in:\n{out}");
}
fn one_box() -> Vec<(usize, usize, usize, usize)> {
vec![(10, 5, 10, 5)]
}
#[test]
fn label_fully_inside_interior_overlaps() {
assert!(overlaps_node_interior(12, 7, 4, &one_box()));
}
#[test]
fn label_on_top_border_does_not_overlap() {
assert!(!overlaps_node_interior(12, 5, 4, &one_box()));
}
#[test]
fn label_on_bottom_border_does_not_overlap() {
assert!(!overlaps_node_interior(12, 9, 4, &one_box()));
}
#[test]
fn label_above_box_does_not_overlap() {
assert!(!overlaps_node_interior(12, 4, 4, &one_box()));
}
#[test]
fn label_to_the_right_does_not_overlap() {
assert!(!overlaps_node_interior(25, 7, 4, &one_box()));
}
#[test]
fn label_extending_past_right_border_partially_overlaps() {
assert!(overlaps_node_interior(17, 7, 8, &one_box()));
}
#[test]
fn label_extending_into_left_border_partially_overlaps() {
assert!(overlaps_node_interior(5, 7, 8, &one_box()));
}
#[test]
fn label_skipping_over_box_horizontally_does_not_overlap() {
assert!(!overlaps_node_interior(5, 7, 4, &one_box()));
}
#[test]
fn empty_node_rects_never_overlaps() {
assert!(!overlaps_node_interior(0, 0, 100, &[]));
}
#[test]
fn tiny_boxes_have_no_interior() {
let boxes = vec![(10, 10, 1, 1)];
assert!(!overlaps_node_interior(10, 10, 1, &boxes));
}
fn factory_box() -> Vec<(usize, usize, usize, usize)> {
vec![(2, 3, 11, 3)]
}
#[test]
fn label_on_node_border_row_overlapping_columns_is_protected() {
let label_w = 6; assert!(overlaps_node_border_row(6, 5, label_w, &factory_box())); assert!(overlaps_node_border_row(6, 3, label_w, &factory_box())); }
#[test]
fn label_on_border_row_outside_node_columns_is_fine() {
let label_w = 4;
assert!(!overlaps_node_border_row(20, 5, label_w, &factory_box()));
assert!(!overlaps_node_border_row(0, 5, 1, &factory_box()));
}
#[test]
fn label_on_node_interior_row_passes_border_check() {
let label_w = 4;
assert!(!overlaps_node_border_row(6, 4, label_w, &factory_box())); }
#[test]
fn label_on_node_border_row_outside_canvas_extent_is_fine() {
assert!(!overlaps_node_border_row(0, 0, 5, &[]));
assert!(!overlaps_node_border_row(0, 0, 5, &[(0, 0, 0, 3)]));
}
fn ci_subgraph() -> Vec<SubgraphBounds> {
vec![SubgraphBounds {
id: "CI".to_string(),
label: "CI".to_string(),
col: 0,
row: 0,
width: 41, height: 7, depth: 0,
}]
}
#[test]
fn label_on_subgraph_top_or_bottom_border_is_protected() {
let w = 4; assert!(overlaps_subgraph_border(5, 0, w, &ci_subgraph()));
assert!(overlaps_subgraph_border(5, 6, w, &ci_subgraph()));
}
#[test]
fn label_overlapping_subgraph_left_or_right_border_column_is_protected() {
let w = 4;
assert!(overlaps_subgraph_border(40, 3, w, &ci_subgraph()));
assert!(overlaps_subgraph_border(0, 3, w, &ci_subgraph()));
}
#[test]
fn label_immediately_outside_subgraph_border_is_allowed() {
let w = 4;
assert!(!overlaps_subgraph_border(41, 3, w, &ci_subgraph()));
}
#[test]
fn label_ending_one_before_right_wall_is_protected() {
let w = 4; assert!(label_abuts_subgraph_right_wall(36, 3, w, &ci_subgraph()));
assert!(!overlaps_subgraph_border(36, 3, w, &ci_subgraph()));
}
#[test]
fn label_well_outside_subgraph_is_fine() {
let w = 4;
assert!(!overlaps_subgraph_border(100, 3, w, &ci_subgraph()));
assert!(!overlaps_subgraph_border(5, 100, w, &ci_subgraph()));
}
#[test]
fn empty_sg_bounds_never_overlaps() {
assert!(!overlaps_subgraph_border(0, 0, 100, &[]));
}
#[test]
fn osc8_wrap_format() {
let s = osc8_wrap("https://example.com", "Hello");
assert_eq!(s, "\x1b]8;;https://example.com\x1b\\Hello\x1b]8;;\x1b\\");
}
#[test]
fn click_directive_renders_osc8_in_plain_mode() {
let src = "graph LR\nA[Start] --> B[End]\nclick A \"https://example.com\"";
let out = crate::render(src).unwrap();
assert!(
out.contains("\x1b]8;;https://example.com\x1b\\"),
"OSC 8 open sequence missing in output:\n{out:?}"
);
assert!(
out.contains("\x1b]8;;\x1b\\"),
"OSC 8 close sequence missing in output:\n{out:?}"
);
assert!(out.contains("Start"), "label 'Start' not in output");
assert!(
!out.contains("\x1b]8;;https://b"),
"unexpected OSC 8 for node B"
);
}
#[test]
fn no_click_directive_produces_no_escape_sequences() {
let src = "graph LR\nA[Start] --> B[End]";
let out = crate::render(src).unwrap();
assert!(
!out.contains('\x1b'),
"unexpected escape sequence in output without click directive"
);
}
#[test]
fn click_directive_renders_osc8_in_color_mode() {
let src = "graph LR\nA[Start] --> B[End]\nclick A \"https://color.example\"";
let opts = crate::RenderOptions {
color: true,
..Default::default()
};
let out = crate::render_with_options(src, &opts).unwrap();
assert!(
out.contains("\x1b]8;;https://color.example\x1b\\"),
"OSC 8 missing in color render:\n{out:?}"
);
}
#[test]
fn rhombus_uses_diagonal_corners() {
for label in &["Hi", "Rhombus", "This is a long rhombus label"] {
let src = format!("graph LR\nD{{{label}}}");
let out = render_diagram(&src);
assert!(
out.contains('╱'),
"diagonal corner '╱' missing for label {label:?} in:\n{out}"
);
assert!(
out.contains('╲'),
"diagonal corner '╲' missing for label {label:?} in:\n{out}"
);
assert!(
!out.contains('◇'),
"old '◇' marker still present for label {label:?} in:\n{out}"
);
}
}
fn path_from(pairs: &[(usize, usize)]) -> Vec<(usize, usize)> {
pairs.to_vec()
}
#[test]
fn longest_seg_single_segment_matches_last() {
let path = path_from(&[
(2, 0),
(3, 0),
(4, 0),
(5, 0),
(6, 0),
(7, 0),
(8, 0),
(9, 0),
(10, 0),
]);
let (mid, row, lo, hi) = longest_horizontal_segment_with_range(&path).unwrap();
assert_eq!(row, 0);
assert_eq!(lo, 2);
assert_eq!(hi, 9); assert_eq!(mid, 5, "midpoint of [2, 9] should be 5");
}
#[test]
fn longest_seg_picks_source_side_on_l_route() {
let mut path: Vec<(usize, usize)> = (0..=19).map(|c| (c, 0)).collect();
path.extend((1..=3).map(|r| (19, r)));
path.extend((20..=21).map(|c| (c, 3)));
let (_mid, row, _lo, _hi) = longest_horizontal_segment_with_range(&path).unwrap();
assert_eq!(
row, 0,
"longest segment is on source row 0, not destination row 3"
);
}
#[test]
fn longest_seg_tie_keeps_later_segment() {
let mut path: Vec<(usize, usize)> = (0..=8).map(|c| (c, 0)).collect();
path.extend((1..=3).map(|r| (8, r)));
path.extend((9..=17).map(|c| (c, 3)));
let (_mid, row, _lo, _hi) = longest_horizontal_segment_with_range(&path).unwrap();
assert_eq!(
row, 3,
"on a tie the destination-side segment (found first) should win"
);
}
#[test]
fn label_falls_back_when_longest_segment_too_short() {
let src = r#"flowchart LR
A --> B
A -.-> C
A ==> D
A -- "labelled" --> E
A -. "dashed label" .-> F
A == "thick label" ==> G"#;
let out = render_diagram(src);
for label in &["dashed label", "thick label"] {
let label_row = out
.lines()
.find(|l| l.contains(label))
.unwrap_or_else(|| panic!("{label:?} not found in output:\n{out}"));
let has_edge_glyph = label_row.chars().any(|c| {
matches!(
c,
'─' | '┄' | '━' | '│' | '┆' | '┃' | '▸' | '▹' | '▶' | '╱' | '╲'
)
});
assert!(
has_edge_glyph,
"label {label:?} is on a row with no edge glyphs (floating label):\n {label_row:?}\nFull output:\n{out}"
);
}
}
#[test]
fn named_choice_renders_label_inside_diamond() {
let src = "stateDiagram-v2
state if_state <<choice>>
[*] --> if_state
if_state --> True: condition
if_state --> False: !condition";
let out = crate::render(src).expect("state diagram render must succeed");
assert!(
out.contains('╱'),
"missing diagonal corner '╱' for named <<choice>> in:\n{out}"
);
assert!(
out.contains("if_state"),
"named <<choice>> label 'if_state' missing from rendered output:\n{out}"
);
}
#[test]
fn anonymous_choice_renders_empty_diamond() {
let src = "stateDiagram-v2
[*] --> <<choice>>
<<choice>> --> Pass: success
<<choice>> --> Fail: error";
let out = crate::render(src).expect("state diagram render must succeed");
assert!(
out.contains('╱'),
"missing diagonal corner '╱' for anonymous <<choice>> in:\n{out}"
);
assert!(
!out.contains("__choice_"),
"synthetic choice id leaked into rendered output:\n{out}"
);
assert!(
!out.contains("choice_1"),
"partial synthetic id 'choice_1' leaked into rendered output:\n{out}"
);
}
#[test]
fn back_edge_attach_does_not_pierce_source_perimeter() {
let src = "stateDiagram-v2
[*] --> Idle
Idle --> Running : start
Running --> Paused : pause
Paused --> Running : resume
Running --> Idle : stop
Idle --> [*]";
let out = crate::render(src).expect("render must succeed");
let lines: Vec<&str> = out.lines().collect();
let label_row = lines
.iter()
.position(|l| l.contains("Running"))
.expect("Running label row not found");
let bottom_border_row = label_row + 1;
let border_row_str = lines
.get(bottom_border_row)
.expect("Running box bottom border row must exist");
assert!(
!border_row_str.contains('┬'),
"B12 regression: `┬` found on Running box bottom border row.\n\
The rounded arc `╰──╯` must not be pierced.\n\
Border row: {border_row_str:?}\nFull output:\n{out}"
);
let perimeter_row = lines
.get(bottom_border_row + 1)
.expect("row below Running bottom border must exist");
assert!(
!perimeter_row.contains('├'),
"B9 regression: `├` found on perimeter row adjacent to Running box bottom border.\n\
Expected `┴` (exit stub) instead of `├` (pierce glyph).\n\
Perimeter row: {perimeter_row:?}\nFull output:\n{out}"
);
assert!(
perimeter_row.contains('┴'),
"Expected `┴` (back-edge exit stub) on the perimeter row below Running's bottom \
border, but it was not found.\nPerimeter row: {perimeter_row:?}\nFull output:\n{out}"
);
}
#[test]
fn back_edge_source_attach_does_not_pierce_rounded_box_bottom() {
let src = "stateDiagram-v2
[*] --> CircuitOpen
CircuitOpen --> HALF_OPEN : timeout
HALF_OPEN --> CircuitClosed : success
HALF_OPEN --> CircuitOpen : failure
CircuitClosed --> CircuitOpen : 5 errors";
let out = crate::render(src).expect("render must succeed");
let lines: Vec<&str> = out.lines().collect();
for (i, line) in lines.iter().enumerate() {
if line.contains('╰') && line.contains('╯') {
assert!(
!line.contains('┬'),
"B12 regression: `┬` found on rounded box bottom border at line {i}.\n\
The `╰──╯` arc must not be pierced by a junction glyph.\n\
Line: {line:?}\nFull output:\n{out}"
);
}
}
assert!(
out.contains('┴'),
"Expected at least one `┴` (back-edge perimeter exit stub) in the output, \
but none found.\nFull output:\n{out}"
);
}
#[test]
fn route_does_not_pierce_subgraph_title_row() {
let src = "flowchart TB
subgraph frontend [Frontend]
UI[Browser UI]
SW[Service Worker]
end
subgraph backend [Backend]
API[REST API]
DB[(Postgres)]
end
UI --> API
SW --> API
API --> DB";
let out = crate::render(src).expect("render must succeed");
assert!(
out.contains("Backend"),
"B-title regression: 'Backend' subgraph title is not intact.\n\
Route(s) likely overwrote a title character with a junction glyph.\n\
Full output:\n{out}"
);
assert!(
out.contains("Frontend"),
"B-title regression: 'Frontend' subgraph title is not intact.\n\
Full output:\n{out}"
);
}
}