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 composite_attached_marker_target<'a>(
id: &str,
sg_bounds: &'a [SubgraphBounds],
) -> Option<&'a SubgraphBounds> {
let suffix = id
.strip_prefix("__start__")
.or_else(|| id.strip_prefix("__end__"))?;
if suffix.is_empty() {
return None;
}
if let Some(sg) = sg_bounds.iter().find(|sg| sg.id == suffix) {
return Some(sg);
}
let last = suffix.rsplit("__").next()?;
sg_bounds.iter().find(|sg| sg.id == last)
}
fn compute_externally_attached_markers(
graph: &Graph,
sg_bounds: &[SubgraphBounds],
) -> HashSet<String> {
let mut result = HashSet::new();
for node in &graph.nodes {
let Some(target) = composite_attached_marker_target(&node.id, sg_bounds) else {
continue;
};
let Some(composite) = graph.subgraphs.iter().find(|sg| sg.id == target.id) else {
continue;
};
let composite_members: HashSet<String> =
graph.all_nodes_in_subgraph(composite).into_iter().collect();
let only_external = graph
.edges
.iter()
.filter(|e| e.from == node.id || e.to == node.id)
.all(|e| {
let other = if e.from == node.id { &e.to } else { &e.from };
!composite_members.contains(other)
});
if only_external {
result.insert(node.id.clone());
}
}
result
}
fn endpoint_geom(
id: &str,
positions: &HashMap<String, GridPos>,
geoms: &HashMap<String, NodeGeom>,
sg_bounds: &[SubgraphBounds],
externally_attached_markers: &HashSet<String>,
) -> Option<(GridPos, NodeGeom)> {
if externally_attached_markers.contains(id)
&& let Some(target) = composite_attached_marker_target(id, sg_bounds)
{
let pos = (target.col, target.row);
let geom = NodeGeom {
width: target.width,
height: target.height,
text_row: 1,
};
return Some((pos, geom));
}
if let (Some(&pos), Some(&geom)) = (positions.get(id), geoms.get(id)) {
return Some((pos, geom));
}
sg_bounds.iter().find(|sg| sg.id == id).map(|sg| {
let pos = (sg.col, sg.row);
let geom = NodeGeom {
width: sg.width,
height: sg.height,
text_row: 1,
};
(pos, geom)
})
}
fn endpoint_pos(
id: &str,
positions: &HashMap<String, GridPos>,
sg_bounds: &[SubgraphBounds],
externally_attached_markers: &HashSet<String>,
) -> Option<GridPos> {
if externally_attached_markers.contains(id)
&& let Some(target) = composite_attached_marker_target(id, sg_bounds)
{
return Some((target.col, target.row));
}
if let Some(&p) = positions.get(id) {
return Some(p);
}
sg_bounds
.iter()
.find(|sg| sg.id == id)
.map(|sg| (sg.col, sg.row))
}
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 same_layer(from_pos: GridPos, to_pos: GridPos, dir: Direction) -> bool {
let (fc, fr) = from_pos;
let (tc, tr) = to_pos;
match dir {
Direction::LeftToRight | Direction::RightToLeft => fc == tc,
Direction::TopToBottom | Direction::BottomToTop => fr == tr,
}
}
fn perpendicular_direction(dir: Direction) -> Direction {
match dir {
Direction::LeftToRight | Direction::RightToLeft => Direction::TopToBottom,
Direction::TopToBottom | Direction::BottomToTop => Direction::LeftToRight,
}
}
fn edge_effective_direction(
graph: &Graph,
edge: &crate::types::Edge,
positions: &HashMap<String, GridPos>,
sg_bounds: &[SubgraphBounds],
externally_attached_markers: &HashSet<String>,
) -> Direction {
if edge.from == edge.to {
return graph.direction;
}
match (
endpoint_pos(
&edge.from,
positions,
sg_bounds,
externally_attached_markers,
),
endpoint_pos(&edge.to, positions, sg_bounds, externally_attached_markers),
) {
(Some(fp), Some(tp)) if same_layer(fp, tp, graph.direction) => {
perpendicular_direction(graph.direction)
}
_ => graph.direction,
}
}
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],
externally_attached_markers: &HashSet<String>,
) -> (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) = endpoint_pos(&e.from, positions, sg_bounds, externally_attached_markers)
else {
return false;
};
let Some(tp) = endpoint_pos(&e.to, positions, sg_bounds, externally_attached_markers)
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 externally_attached_markers = compute_externally_attached_markers(graph, sg_bounds);
let (width, height) = grid_size(
graph,
positions,
&geoms,
sg_bounds,
&externally_attached_markers,
);
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 {
if externally_attached_markers.contains(&node.id) {
continue;
}
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 edge_effective_dirs: Vec<Direction> = graph
.edges
.iter()
.map(|edge| {
edge_effective_direction(
graph,
edge,
positions,
sg_bounds,
&externally_attached_markers,
)
})
.collect();
let attach_points = compute_spread_attaches(
graph,
positions,
&geoms,
sg_bounds,
&externally_attached_markers,
&edge_effective_dirs,
);
let edge_is_back_flags: Vec<bool> = graph
.edges
.iter()
.enumerate()
.map(|(idx, e)| {
if e.from == e.to {
return true;
}
let dir = edge_effective_dirs
.get(idx)
.copied()
.unwrap_or(graph.direction);
let fp = endpoint_pos(&e.from, positions, sg_bounds, &externally_attached_markers);
let tp = endpoint_pos(&e.to, positions, sg_bounds, &externally_attached_markers);
match (fp, tp) {
(Some(fp), Some(tp)) => is_back_edge(fp, tp, dir),
_ => 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, Direction)> = Vec::new();
let mut back_edge_path_joins: Vec<(usize, usize, Direction)> = Vec::new();
for (edge_idx, edge) in graph.edges.iter().enumerate() {
if !edge_is_back_flags[edge_idx] {
continue;
}
if let (Some((fp, fg)), Some((tp, tg))) = (
endpoint_geom(
&edge.from,
positions,
&geoms,
sg_bounds,
&externally_attached_markers,
),
endpoint_geom(
&edge.to,
positions,
&geoms,
sg_bounds,
&externally_attached_markers,
),
) {
if edge.from == edge.to {
continue;
}
let dir = edge_effective_dirs
.get(edge_idx)
.copied()
.unwrap_or(graph.direction);
let (sb, sp) = back_edge_border_cells(fp, fg, dir);
let (db, _) = back_edge_border_cells(tp, tg, dir);
let skip_src_border = matches!(dir, 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, dir));
back_edge_border_joins.push((db.0, db.1, true, false, dir));
back_edge_path_joins.push((sp.0, sp.1, dir));
}
}
let mut paths = router::route_all(
&mut grid,
graph,
&attach_points,
|edge_idx| {
let dir = edge_effective_dirs
.get(edge_idx)
.copied()
.unwrap_or(graph.direction);
if edge_is_back_flags.get(edge_idx).copied().unwrap_or(false) {
tip_char_for_back_edge(dir)
} else {
tip_char(dir)
}
},
|edge_idx| edge_is_back_flags.get(edge_idx).copied().unwrap_or(false),
);
let edge_has_label: Vec<bool> = graph.edges.iter().map(|e| e.label.is_some()).collect();
let enable_endpoint_corner_nudge = graph_supports_simple_lr_fanout_heuristics(graph);
crate::layout::nudge::run(
&mut grid,
&mut paths,
&edge_is_back_flags,
&edge_has_label,
&node_rects,
enable_endpoint_corner_nudge,
|edge_idx| {
let dir = edge_effective_dirs
.get(edge_idx)
.copied()
.unwrap_or(graph.direction);
if edge_is_back_flags.get(edge_idx).copied().unwrap_or(false) {
tip_char_for_back_edge(dir)
} else {
tip_char(dir)
}
},
);
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: edge_effective_dirs
.get(edge_idx)
.copied()
.unwrap_or(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 {
if externally_attached_markers.contains(&node.id) {
continue;
}
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 path_junction_lr_corner_left = '\u{2518}'; let path_junction_lr_corner_right = '\u{2514}'; for (col, row, is_dest, skip_border_stamp, dir) in &back_edge_border_joins {
if *is_dest || *skip_border_stamp {
continue;
}
let border_junction = match dir {
Direction::LeftToRight | Direction::RightToLeft => '┬',
Direction::TopToBottom | Direction::BottomToTop => '├',
};
grid.set(*col, *row, border_junction);
}
for (col, row, dir) in &back_edge_path_joins {
let current = grid.get(*col, *row);
let is_exit_collision =
matches!(*dir, Direction::LeftToRight | Direction::RightToLeft) && current == '├';
if current != '─' && current != '│' && !is_exit_collision {
continue;
}
let glyph = if is_exit_collision {
'\u{2534}' } else {
match dir {
Direction::LeftToRight => path_junction_lr_corner_left,
Direction::RightToLeft => path_junction_lr_corner_right,
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 {
if externally_attached_markers.contains(&node.id) {
continue;
}
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>,
sg_bounds: &[SubgraphBounds],
externally_attached_markers: &HashSet<String>,
edge_effective_dirs: &[Direction],
) -> Vec<Option<(Attach, Attach)>> {
let mut pairs: Vec<Option<(Attach, Attach)>> = graph
.edges
.iter()
.enumerate()
.map(|(idx, edge)| {
let (from_pos, from_geom) = endpoint_geom(
&edge.from,
positions,
geoms,
sg_bounds,
externally_attached_markers,
)?;
let (to_pos, to_geom) = endpoint_geom(
&edge.to,
positions,
geoms,
sg_bounds,
externally_attached_markers,
)?;
let dir = edge_effective_dirs
.get(idx)
.copied()
.unwrap_or(graph.direction);
if edge.from == edge.to || is_back_edge(from_pos, to_pos, dir) {
let src = exit_point_back_edge(from_pos, from_geom, dir);
let dst = entry_point_back_edge(to_pos, to_geom, dir);
Some((src, dst))
} else {
let src = exit_point(from_pos, from_geom, dir);
let dst = entry_point(to_pos, to_geom, dir);
Some((src, dst))
}
})
.collect();
let mut src_groups: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
for (i, pair) in pairs.iter().enumerate() {
if let Some((src, _)) = pair {
let dir = edge_effective_dirs
.get(i)
.copied()
.unwrap_or(graph.direction);
if dir != graph.direction {
continue;
}
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, from_geom)) = endpoint_geom(
&first_edge.from,
positions,
geoms,
sg_bounds,
externally_attached_markers,
) else {
continue;
};
let reorder_for_lr_fanout = source_reordering_allowed(graph, indices);
spread_sources(
&mut pairs,
indices,
from_pos,
from_geom,
graph.direction,
reorder_for_lr_fanout,
);
}
let mut dst_groups: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
for (i, pair) in pairs.iter().enumerate() {
if let Some((_, dst)) = pair {
let dir = edge_effective_dirs
.get(i)
.copied()
.unwrap_or(graph.direction);
if dir != graph.direction {
continue;
}
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, to_geom)) = endpoint_geom(
&first_edge.to,
positions,
geoms,
sg_bounds,
externally_attached_markers,
) else {
continue;
};
let interior_clamp_for_lr = destination_interior_clamp_allowed(graph, indices);
let reorder_for_lr_fanout = destination_reordering_allowed(graph, indices);
spread_destinations(
&mut pairs,
indices,
to_pos,
to_geom,
graph.direction,
interior_clamp_for_lr,
reorder_for_lr_fanout,
);
}
pairs
}
fn spread_destinations(
pairs: &mut [Option<(Attach, Attach)>],
indices: &[usize],
to_pos: GridPos,
to_geom: NodeGeom,
dir: Direction,
interior_clamp: bool,
reorder_for_lr_fanout: bool,
) {
let n = indices.len();
let (to_col, to_row) = to_pos;
let mut ordered_indices = indices.to_vec();
if reorder_for_lr_fanout && matches!(dir, Direction::LeftToRight | Direction::RightToLeft) {
ordered_indices.sort_by_key(|&idx| {
pairs[idx]
.as_ref()
.map(|(src, _)| (src.row, src.col))
.unwrap_or((usize::MAX, usize::MAX))
});
} else if reorder_for_lr_fanout
&& matches!(dir, Direction::TopToBottom | Direction::BottomToTop)
{
ordered_indices.sort_by_key(|&idx| {
pairs[idx]
.as_ref()
.map(|(src, _)| (src.col, src.row))
.unwrap_or((usize::MAX, usize::MAX))
});
}
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
};
let (clamp_min, clamp_max) = if interior_clamp && to_geom.height.saturating_sub(2) >= n
{
(to_row + 1, to_row + to_geom.height - 2)
} else {
(min_row, max_row)
};
for (i, &idx) in ordered_indices.iter().enumerate() {
let offset = (2 * i as isize - (n as isize - 1)) * step / 2;
let new_row = (centre + offset)
.max(clamp_min as isize)
.min(clamp_max 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
};
let (clamp_min, clamp_max) = if interior_clamp && to_geom.width.saturating_sub(2) >= n {
(to_col + 1, to_col + to_geom.width - 2)
} else {
(min_col, max_col)
};
for (i, &idx) in ordered_indices.iter().enumerate() {
let offset = (2 * i as isize - (n as isize - 1)) * step / 2;
let new_col = (centre + offset)
.max(clamp_min as isize)
.min(clamp_max 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,
reorder_for_lr_fanout: bool,
) {
let mut ordered_indices = indices.to_vec();
if reorder_for_lr_fanout && matches!(dir, Direction::LeftToRight | Direction::RightToLeft) {
ordered_indices.sort_by_key(|&idx| {
pairs[idx]
.as_ref()
.map(|(src, dst)| (src.col.abs_diff(dst.col), dst.row, dst.col))
.unwrap_or((usize::MAX, usize::MAX, usize::MAX))
});
}
let n = ordered_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 ordered_indices.iter().enumerate() {
let offset = (2 * i as isize - (n as isize - 1)) * step / 2;
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 ordered_indices.iter().enumerate() {
let offset = (2 * i as isize - (n as isize - 1)) * step / 2;
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 source_reordering_allowed(graph: &Graph, indices: &[usize]) -> bool {
if !graph_supports_simple_lr_fanout_heuristics(graph) {
return false;
}
indices.iter().all(|&idx| {
graph.edges.get(idx).is_some_and(|edge| {
graph
.node(&edge.from)
.is_some_and(|node| shape_supports_lr_fanout_ordering(node.shape))
&& graph
.node(&edge.to)
.is_some_and(|node| shape_supports_lr_fanout_ordering(node.shape))
})
})
}
fn destination_interior_clamp_allowed(graph: &Graph, indices: &[usize]) -> bool {
if !graph_supports_simple_lr_fanout_heuristics(graph) {
return false;
}
indices.iter().all(|&idx| {
graph.edges.get(idx).is_some_and(|edge| {
graph
.node(&edge.from)
.is_some_and(|node| shape_supports_lr_fanout_ordering(node.shape))
&& graph
.node(&edge.to)
.is_some_and(|node| shape_supports_lr_fanout_ordering(node.shape))
})
})
}
fn destination_reordering_allowed(graph: &Graph, indices: &[usize]) -> bool {
if !graph_supports_simple_lr_fanout_heuristics(graph) {
return false;
}
indices.iter().all(|&idx| {
graph.edges.get(idx).is_some_and(|edge| {
graph
.node(&edge.from)
.is_some_and(|node| shape_supports_lr_fanout_ordering(node.shape))
&& graph
.node(&edge.to)
.is_some_and(|node| shape_supports_lr_fanout_ordering(node.shape))
})
})
}
fn graph_supports_simple_lr_fanout_heuristics(graph: &Graph) -> bool {
matches!(
graph.direction,
Direction::LeftToRight | Direction::RightToLeft
) && graph.subgraphs.is_empty()
&& graph
.nodes
.iter()
.all(|node| shape_supports_lr_fanout_ordering(node.shape))
}
fn shape_supports_lr_fanout_ordering(shape: NodeShape) -> bool {
matches!(shape, NodeShape::Rectangle | NodeShape::Cylinder)
}
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);
}
for x in (col + 1)..(col + w - 1) {
grid.clear_dirs(x, row);
grid.clear_dirs(x, row + h - 1);
}
}
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] = &[
'┘', '└', '┐', '┌', '┤', '├', '┬', '┴', '┼',
'╯', '╰', '╮', '╭', '▴', '▾', '▸', '◂',
'\u{2501}', '\u{2503}', '\u{2504}', '\u{2506}', '\u{254D}', '\u{254F}', ];
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 = if edge_is_back {
match dir {
Direction::LeftToRight => [
hi_col.saturating_sub(lbl_w / 2),
lo_col + 2 * third,
mid_col,
],
Direction::RightToLeft => [lo_col + lbl_w / 2, lo_col + third, mid_col],
_ => [mid_col, lo_col + third, lo_col + 2 * third],
}
} else {
[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);
let lines: Vec<&str> = out.lines().collect();
let has_edge_glyph = |line: &str| {
line.chars().any(|c| {
matches!(
c,
'─' | '┄' | '━' | '│' | '┆' | '┃' | '▸' | '▹' | '▶' | '╱' | '╲'
)
})
};
for label in &["dashed label", "thick label"] {
let label_row_idx = lines
.iter()
.position(|l| l.contains(label))
.unwrap_or_else(|| panic!("{label:?} not found in output:\n{out}"));
let neighbours = label_row_idx.saturating_sub(1)..(label_row_idx + 2).min(lines.len());
let connected = neighbours.clone().any(|i| has_edge_glyph(lines[i]));
assert!(
connected,
"label {label:?} (line {label_row_idx}) is not visually \
connected to an edge — no edge glyphs in lines \
{:?}.\nFull output:\n{out}",
neighbours.collect::<Vec<_>>(),
);
}
}
#[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('┴') || out.contains('┘') || out.contains('└'),
"Expected at least one back-edge perimeter exit stub (`┴` / `┘` / `└`) \
in the output, but none found.\nFull output:\n{out}"
);
}
#[test]
fn final_state_renders_at_rightmost_column() {
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<Vec<char>> = out.lines().map(|l| l.chars().collect()).collect();
let paused_needle: Vec<char> = "│ Paused".chars().collect();
let paused_col = lines
.iter()
.find_map(|l| {
l.windows(paused_needle.len())
.position(|w| w == paused_needle.as_slice())
})
.expect("Paused box left border missing");
let final_box_col = (0..lines.len().saturating_sub(1))
.find_map(|r| {
let row = &lines[r];
let next = &lines[r + 1];
row.iter().enumerate().find_map(|(c, &ch)| {
if ch != '\u{256D}' {
return None;
}
if next.get(c + 1).copied() == Some('\u{256D}') {
Some(c)
} else {
None
}
})
})
.expect("final-state nested-rounded-box outline missing");
assert!(
final_box_col >= paused_col,
"final state at col {final_box_col} should be ≥ Paused at col \
{paused_col} (terminal sinks should be in the rightmost layer). \
Full output:\n{out}"
);
}
#[test]
fn note_interior_contains_no_routing_glyphs() {
let src = "stateDiagram-v2
[*] --> CircuitOpen
CircuitOpen --> CircuitClosed : timeout reached
CircuitClosed --> CircuitOpen : 5 errors
note right of CircuitOpen
Open state rejects all
traffic for cool-down period.
end note";
let out = crate::render(src).expect("render must succeed");
let lines: Vec<Vec<char>> = out.lines().map(|l| l.chars().collect()).collect();
let mut note_top: Option<usize> = None;
let mut note_bot: Option<usize> = None;
let mut note_left: Option<usize> = None;
let mut note_right: Option<usize> = None;
for (r, line) in lines.iter().enumerate() {
if line.contains(&'\u{256D}') && line.contains(&'\u{256E}') {
let l = line.iter().position(|&c| c == '\u{256D}').unwrap();
let rr = line.iter().rposition(|&c| c == '\u{256E}').unwrap();
if line[(l + 1)..rr].iter().all(|&c| c == '\u{2500}') {
note_top = Some(r);
note_left = Some(l);
note_right = Some(rr);
}
} else if note_top.is_some() && line.contains(&'\u{2570}') && line.contains(&'\u{256F}')
{
let l = line.iter().position(|&c| c == '\u{2570}').unwrap();
let rr = line.iter().rposition(|&c| c == '\u{256F}').unwrap();
if l == note_left.unwrap()
&& rr == note_right.unwrap()
&& line[(l + 1)..rr].iter().all(|&c| c == '\u{2500}')
{
note_bot = Some(r);
break;
}
}
}
let (top, bot, left, right) = match (note_top, note_bot, note_left, note_right) {
(Some(t), Some(b), Some(l), Some(r)) => (t, b, l, r),
_ => panic!("could not locate note bounding box in:\n{out}"),
};
let routing_glyphs: Vec<char> = vec![
'\u{2502}', '\u{2500}', '\u{253C}', '\u{252C}', '\u{2534}', '\u{251C}', '\u{2524}',
'\u{256E}', '\u{256F}', '\u{256D}', '\u{2570}', '\u{2518}', '\u{2514}', '\u{2510}',
'\u{250C}',
];
for (r, line) in lines.iter().enumerate().take(bot).skip(top + 1) {
for c in (left + 1)..right {
let ch = line.get(c).copied().unwrap_or(' ');
assert!(
!routing_glyphs.contains(&ch),
"routing glyph {ch:?} found inside note interior at \
({c}, {r}) — note bbox: ({left},{top})-({right},{bot}). \
Full output:\n{out}"
);
}
}
}
#[test]
fn back_edge_left_terminus_uses_corner_not_t_junction() {
let src = "stateDiagram-v2
state Active {
[*] --> Idle
Idle --> Working: task
Working --> Idle: done
}
[*] --> Active";
let out = crate::render(src).expect("render must succeed");
let route_row = out
.lines()
.find(|l| {
let chars: Vec<char> = l.chars().collect();
let has_left_corner = chars.contains(&'\u{2514}'); has_left_corner && (chars.contains(&'\u{2518}') || chars.contains(&'\u{2534}'))
})
.expect("back-edge route row not found");
let chars: Vec<char> = route_row.chars().collect();
let left_corner_idx = chars.iter().position(|&c| c == '\u{2514}').unwrap();
let endpoint = chars[(left_corner_idx + 1)..]
.iter()
.find(|&&c| c == '\u{2518}' || c == '\u{2534}')
.copied()
.expect("no route endpoint glyph (┘ or ┴) after └");
assert_eq!(
endpoint, '\u{2518}',
"back-edge route's right endpoint is `┴` (T-junction with phantom \
rightward continuation) — should be `┘` (clean bottom-right corner). \
Route row:\n{route_row}\n\nFull output:\n{out}"
);
}
#[test]
fn fan_out_labels_distribute_with_row_separation() {
let src = "flowchart LR
Start --> Decision{Decision}
Decision -->|yes| Build
Decision -->|no| Skip
Build --> Deploy
Skip --> Deploy";
let out = crate::render(src).expect("render must succeed");
let lines: Vec<&str> = out.lines().collect();
let yes_row = lines
.iter()
.position(|l| l.contains("yes"))
.expect("yes label missing");
let no_row = lines
.iter()
.position(|l| l.contains("no") && !l.contains("yes"))
.expect("no label on its own row missing");
let distance = yes_row.abs_diff(no_row);
assert!(
distance >= 2,
"fan-out labels 'yes' (line {yes_row}) and 'no' (line {no_row}) \
are only {distance} row(s) apart — labels at a parallel-edge \
fan-out should be separated by ≥ 2 rows. Full output:\n{out}"
);
}
#[test]
fn merging_arrows_into_shared_destination_are_not_adjacent() {
let src = "flowchart LR
Start --> Decision{Decision}
Decision -->|yes| Build
Decision -->|no| Skip
Build --> Deploy
Skip --> Deploy";
let out = crate::render(src).expect("render must succeed");
let lines: Vec<Vec<char>> = out.lines().map(|l| l.chars().collect()).collect();
let max_cols = lines.iter().map(|l| l.len()).max().unwrap_or(0);
let mut adjacent_pairs = 0usize;
for col in 0..max_cols {
for row in 0..lines.len().saturating_sub(1) {
let here = lines[row].get(col).copied().unwrap_or(' ');
let below = lines[row + 1].get(col).copied().unwrap_or(' ');
if here == '\u{25B8}' && below == '\u{25B8}' {
adjacent_pairs += 1;
}
}
}
assert_eq!(
adjacent_pairs, 0,
"found {adjacent_pairs} pair(s) of `▸` glyphs on adjacent rows in \
the same column — arrow tips at a shared destination should be \
distributed with ≥ 2-row separation.\n\nFull output:\n{out}"
);
}
#[test]
fn edge_labels_not_flush_against_thick_or_dotted_lines() {
let src = "flowchart LR
A --> B
A -.-> C
A ==> D
A -- \"labelled\" --> E
A -. \"dashed label\" .-> F
A == \"thick label\" ==> G";
let out = crate::render(src).expect("render must succeed");
let problem_glyphs = [
'\u{2501}', '\u{2503}', '\u{2504}', '\u{2506}', '\u{254D}', '\u{254F}',
];
for label in &["labelled", "dashed label", "thick label"] {
let line = out
.lines()
.find(|l| l.contains(label))
.unwrap_or_else(|| panic!("label {label:?} missing in output:\n{out}"));
let label_byte_pos = line.find(label).unwrap();
if label_byte_pos == 0 {
continue;
}
let prev_char = line[..label_byte_pos].chars().last().unwrap();
assert!(
!problem_glyphs.contains(&prev_char),
"edge label {label:?} sits flush against thick/dotted glyph \
{prev_char:?} (chars before label: {:?}). Row:\n{line}\n\nFull:\n{out}",
line[..label_byte_pos]
.chars()
.rev()
.take(8)
.collect::<Vec<_>>()
);
}
}
#[test]
fn subgraph_border_does_not_overlap_downstream_node_box() {
let src = "graph LR
subgraph Supervisor
direction TB
F[Factory] -->|creates| W[Worker]
W -->|panics/exits| F
end
W -->|beat every cycle| HB[Heartbeat]";
let out = crate::render(src).expect("render must succeed");
let lines: Vec<Vec<char>> = out.lines().map(|l| l.chars().collect()).collect();
let (hb_row, hb_col) = lines
.iter()
.enumerate()
.find_map(|(r, l)| {
let chars: Vec<char> = "Heartbeat".chars().collect();
l.windows(chars.len())
.position(|w| w == chars.as_slice())
.map(|c| (r, c))
})
.expect("Heartbeat label not rendered");
let hb_left = hb_col.saturating_sub(2);
let hb_right = hb_col + "Heartbeat".chars().count() + 1;
for r in hb_row.saturating_sub(1)..=hb_row + 1 {
for c in hb_left..=hb_right {
let ch = lines.get(r).and_then(|l| l.get(c)).copied().unwrap_or(' ');
assert!(
!matches!(ch, '\u{256D}' | '\u{256E}' | '\u{2570}' | '\u{256F}'),
"subgraph corner glyph {ch:?} at ({c},{r}) overlaps Heartbeat box \
[{hb_left}..={hb_right}, {}..={}]\n\nFull:\n{out}",
hb_row.saturating_sub(1),
hb_row + 1
);
if c > hb_left && c < hb_right && ch == '\u{2502}' {
panic!(
"interior `│` at ({c},{r}) inside Heartbeat box — \
Supervisor's vertical border is piercing through. \
hb_left={hb_left}, hb_right={hb_right}\n\nFull:\n{out}"
);
}
}
}
}
#[test]
fn back_edges_share_return_corridor() {
let src = "graph LR
A --> B --> C --> D --> E
E -->|back1| A
D -->|back2| B";
let out = crate::render(src).expect("render must succeed");
let lines: Vec<Vec<char>> = out.lines().map(|l| l.chars().collect()).collect();
let lines_str: Vec<String> = lines.iter().map(|l| l.iter().collect()).collect();
let box_count = ["│ A │", "│ B │", "│ C │", "│ D │", "│ E │"]
.iter()
.filter(|needle| lines_str.iter().any(|l| l.contains(*needle)))
.count();
assert_eq!(
box_count, 5,
"trap-check: not all 5 chain boxes rendered ({box_count}/5).\nFull:\n{out}"
);
let back_tip_count = lines
.iter()
.map(|l| {
l.iter()
.filter(|&&c| matches!(c, '\u{25C2}' | '\u{25B4}' | '\u{25C0}' | '\u{25BE}'))
.count()
})
.sum::<usize>();
assert!(
back_tip_count >= 2,
"trap-check: expected ≥2 back-edge arrow tips; found {back_tip_count}.\n\
A nudge pass that drops a path during shift-apply would fail here \
without actually changing the corridor layout.\nFull:\n{out}"
);
let any_t_junction = lines.iter().any(|l| l.contains(&'\u{2534}'));
assert!(
any_t_junction,
"trap-check: no `┴` glyphs at all — back-edges aren't reaching the \
perimeter. Test cannot meaningfully assert sharing without taps.\n\
Full:\n{out}"
);
let (bottom_corridor_row, t_junction_count) = lines
.iter()
.enumerate()
.rev()
.find_map(|(r, line)| {
let count = line.iter().filter(|&&c| c == '\u{2534}').count();
if count > 0 { Some((r, count)) } else { None }
})
.expect("checked above that ≥1 `┴` exists");
assert!(
t_junction_count >= 2,
"Bug 5: bottom corridor row {bottom_corridor_row} has only \
{t_junction_count} `┴` junction(s); both back-edges should \
share this row (expected ≥2). Without sharing, each back-edge \
carves its own row and only one tap lands on the bottommost \
corridor.\nRender:\n{out}"
);
}
#[test]
fn perimeter_back_edge_label_close_to_endpoint() {
let src = "flowchart LR
A --> B
B --> C
C --> D
D --> E
E --> F
F -->|done| A";
let out = crate::render(src).expect("render must succeed");
let lines: Vec<Vec<char>> = out.lines().map(|l| l.chars().collect()).collect();
let find_char = |line: &[char], needle: char| line.iter().position(|&c| c == needle);
let find_substr = |line: &[char], s: &str| -> Option<usize> {
let chars: Vec<char> = s.chars().collect();
line.windows(chars.len())
.position(|w| w == chars.as_slice())
};
let (label_row, label_col) = lines
.iter()
.enumerate()
.find_map(|(r, l)| find_substr(l, "done").map(|c| (r, c)))
.expect("'done' label missing");
let (f_row, f_col) = lines
.iter()
.enumerate()
.find_map(|(r, l)| {
if l.contains(&'│') {
find_char(l, 'F').map(|c| (r, c))
} else {
None
}
})
.expect("F box row missing");
let (a_row, a_col) = lines
.iter()
.enumerate()
.find_map(|(r, l)| {
if l.contains(&'│') {
find_char(l, 'A').map(|c| (r, c))
} else {
None
}
})
.expect("A box row missing");
let d_f = label_col.abs_diff(f_col) + label_row.abs_diff(f_row);
let d_a = label_col.abs_diff(a_col) + label_row.abs_diff(a_row);
assert!(
d_f <= 15 || d_a <= 15,
"label 'done' at ({label_col},{label_row}) is far from F ({f_col},{f_row}) \
dist={d_f} AND from A ({a_col},{a_row}) dist={d_a}\nFull:\n{out}"
);
}
#[test]
fn route_corners_clear_non_endpoint_node_halos() {
let src = "graph LR
A --> Z
B --> Z
C --> Z
D --> Z";
let out = crate::render(src).expect("render must succeed");
let lines: Vec<Vec<char>> = out.lines().map(|l| l.chars().collect()).collect();
let needle: Vec<char> = "│ B │".chars().collect();
let (b_row, b_col) = lines
.iter()
.enumerate()
.find_map(|(r, l)| {
l.windows(needle.len())
.position(|w| w == needle.as_slice())
.map(|c| (r, c))
})
.expect("trap-check: B box not rendered (fixture render is broken)");
for label in ["│ A │", "│ C │", "│ D │", "│ Z │"] {
let n: Vec<char> = label.chars().collect();
let found = lines
.iter()
.any(|l| l.windows(n.len()).any(|w| w == n.as_slice()));
assert!(found, "trap-check: {label} not rendered. Full:\n{out}");
}
for label in ["A", "B", "C", "D"] {
let needle: Vec<char> = format!("│ {label} │").chars().collect();
let (row, col) = lines
.iter()
.enumerate()
.find_map(|(r, l)| {
l.windows(needle.len())
.position(|w| w == needle.as_slice())
.map(|c| (r, c))
})
.unwrap_or_else(|| panic!("trap-check: {label} box not rendered. Full:\n{out}"));
let halo = lines
.get(row)
.and_then(|l| l.get(col + needle.len()))
.copied()
.unwrap_or(' ');
assert_ne!(
halo, ' ',
"trap-check: {label} has no visible outgoing route in its source halo.\nFull:\n{out}"
);
}
let halo_col = b_col + 5;
let mut bad_glyphs = Vec::new();
for r in b_row.saturating_sub(1)..=b_row + 1 {
let ch = lines
.get(r)
.and_then(|l| l.get(halo_col))
.copied()
.unwrap_or(' ');
if matches!(
ch,
'\u{250C}'
| '\u{2510}'
| '\u{2514}'
| '\u{2518}'
| '\u{252C}'
| '\u{2534}'
| '\u{251C}'
| '\u{2524}'
| '\u{253C}'
) {
bad_glyphs.push((r, ch));
}
}
assert!(
bad_glyphs.is_empty(),
"B is not on any A↔Z route, but its right halo column ({halo_col}) \
carries route corners {bad_glyphs:?}. Edges should detour around \
non-endpoint node halos.\nFull:\n{out}"
);
}
#[test]
fn routes_do_not_hug_non_endpoint_node_borders() {
let src = "graph LR
W[Worker]
W --> A[Alpha]
W --> B[Beta]
W --> C[Gamma]
W --> D[Delta]
W --> E[Epsilon]";
let out = crate::render(src).expect("render must succeed");
let lines: Vec<Vec<char>> = out.lines().map(|l| l.chars().collect()).collect();
let (worker_row, worker_col) = lines
.iter()
.enumerate()
.find_map(|(r, l)| {
let s: String = l.iter().collect();
s.find("Worker").map(|c| (r, c))
})
.expect("Worker label not rendered");
let halo_col = worker_col + "Worker".len() + 2;
let any_corner = lines.iter().flatten().any(|&c| {
matches!(
c,
'\u{250C}'
| '\u{2510}'
| '\u{2514}'
| '\u{2518}'
| '\u{252C}'
| '\u{2534}'
| '\u{251C}'
| '\u{2524}'
| '\u{253C}'
)
});
assert!(
any_corner,
"no corner glyphs in render — diagram empty:\n{out}"
);
let mut halo_corners = 0;
for r in worker_row.saturating_sub(1)..=worker_row + 1 {
let ch = lines
.get(r)
.and_then(|l| l.get(halo_col))
.copied()
.unwrap_or(' ');
if matches!(
ch,
'\u{250C}'
| '\u{2510}'
| '\u{2514}'
| '\u{2518}'
| '\u{252C}'
| '\u{2534}'
| '\u{251C}'
| '\u{2524}'
| '\u{253C}'
) {
halo_corners += 1;
}
}
assert!(
halo_corners <= 1,
"{halo_corners} corner glyphs in halo column {halo_col} adjacent to Worker — \
routes are hugging Worker's right border. Render:\n{out}"
);
}
#[test]
fn diamond_interior_has_no_routing_glyphs() {
let src = "flowchart LR
A --> B{Decision}
A --> C[Cached]
B --> D[Done]
C --> D";
let out = crate::render(src).expect("render must succeed");
let lines: Vec<Vec<char>> = out.lines().map(|l| l.chars().collect()).collect();
let top_idx = lines
.iter()
.position(|l| l.contains(&'\u{2571}') && l.contains(&'\u{2572}'))
.expect("diamond top corner row missing — `╱` and `╲` not both found");
let bot_idx = (top_idx + 1..lines.len())
.find(|&i| lines[i].contains(&'\u{2572}') && lines[i].contains(&'\u{2571}'))
.expect("diamond bottom corner row missing");
let left = lines[top_idx]
.iter()
.position(|&c| c == '\u{2571}')
.unwrap();
let right = lines[top_idx]
.iter()
.rposition(|&c| c == '\u{2572}')
.unwrap();
let any_edge_glyph = lines.iter().any(|l| {
l.iter()
.enumerate()
.any(|(c, &ch)| ch == '\u{2502}' && (c < left.saturating_sub(1) || c > right + 1))
});
assert!(
any_edge_glyph,
"no edge glyphs anywhere outside the diamond — render is a no-op:\n{out}"
);
for (r, line) in lines.iter().enumerate().take(bot_idx).skip(top_idx + 1) {
for c in (left + 2)..(right - 1) {
let ch = line.get(c).copied().unwrap_or(' ');
assert_ne!(
ch, '\u{253C}',
"cross-junction `┼` at ({c},{r}) inside diamond bbox \
[{left}..={right}, {top_idx}..={bot_idx}].\nFull:\n{out}"
);
}
}
}
#[test]
fn subgraph_bottom_border_has_at_most_one_junction_glyph() {
let src = "graph LR
subgraph Supervisor
direction TB
F[Factory] -->|creates| W[Worker]
W -->|panics/exits| F
end
W -->|beat every cycle| HB[Heartbeat]
HB -->|checked every 10s| WD[Watchdog]
WD -->|stall > 120s| CT[Cancel Token]
CT -->|stops| W
W -->|check before DB call| CB{Circuit Breaker}
W -->|acquire permit| SEM[Semaphore]";
let out = crate::render(src).expect("render must succeed");
let lines: Vec<&str> = out.lines().collect();
let br_line = lines
.iter()
.find(|l| {
l.contains('\u{2570}')
&& l.contains('\u{256F}')
&& !l.chars().any(|c| c.is_alphanumeric())
})
.expect("subgraph bottom-border row missing");
let junctions = ['\u{253C}', '\u{252C}', '\u{2534}', '\u{251C}', '\u{2524}'];
let count = br_line.chars().filter(|c| junctions.contains(c)).count();
assert!(
count <= 1,
"subgraph bottom border has {count} junction glyph(s); expected ≤ 1. \
Line: {br_line:?}\n\nFull output:\n{out}"
);
}
#[test]
fn subgraph_title_row_has_no_junction_glyphs() {
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");
let backend_title_line = out
.lines()
.find(|l| l.contains("Backend"))
.expect("Backend label missing from output");
let junctions = ['\u{253C}', '\u{252C}', '\u{2534}', '\u{251C}', '\u{2524}'];
for ch in backend_title_line.chars() {
assert!(
!junctions.contains(&ch),
"junction glyph {ch:?} found in Backend's title border row \
— routing pierced the title bar at a non-letter column. \
Title row:\n{backend_title_line}\n\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}"
);
}
}