use std::collections::{HashMap, HashSet};
use crate::graph::geometry::RoutedGraphGeometry;
use crate::graph::grid::{AttachDirection, GridLayout, Point, RoutedEdge, Segment, SubgraphBounds};
use crate::graph::{Arrow, Direction, Edge, Stroke, Subgraph};
use crate::render::text::canvas::{Canvas, CellStyle, Connections};
use crate::render::text::chars::CharSet;
pub fn calc_end_label_positions(segments: &[Segment]) -> (Option<Point>, Option<Point>) {
if segments.is_empty() {
return (None, None);
}
let total_length: usize = segments.iter().map(Segment::length).sum();
let fraction = (total_length as f64 * 0.15).floor() as usize;
let tail = {
let target = fraction;
let mut accumulated = 0usize;
let mut pos = None;
for seg in segments {
let seg_len = seg.length();
if accumulated + seg_len >= target {
pos = Some(offset_perpendicular(
seg.point_at_offset(target - accumulated),
seg,
));
break;
}
accumulated += seg_len;
}
pos.or_else(|| {
segments
.first()
.map(|s| offset_perpendicular(s.start_point(), s))
})
};
let head = {
let target = total_length.saturating_sub(fraction);
let mut accumulated = 0usize;
let mut pos = None;
for seg in segments {
let seg_len = seg.length();
if accumulated + seg_len >= target {
pos = Some(offset_perpendicular(
seg.point_at_offset(target - accumulated),
seg,
));
break;
}
accumulated += seg_len;
}
pos.or_else(|| {
segments
.last()
.map(|s| offset_perpendicular(s.end_point(), s))
})
};
(head, tail)
}
fn offset_perpendicular(point: Point, segment: &Segment) -> Point {
match segment {
Segment::Vertical { .. } => Point {
x: point.x.saturating_sub(2),
..point
},
Segment::Horizontal { .. } => Point {
y: point.y.saturating_sub(1),
..point
},
}
}
pub fn compute_edge_containment(
edges: &[Edge],
subgraphs: &HashMap<String, Subgraph>,
subgraph_bounds: &HashMap<String, SubgraphBounds>,
) -> HashMap<usize, (usize, usize)> {
let mut containment: HashMap<usize, (usize, usize)> = HashMap::new();
for edge in edges {
for (sg_id, sg) in subgraphs {
let members: HashSet<&str> = sg.nodes.iter().map(|s| s.as_str()).collect();
if !members.contains(edge.from.as_str()) || !members.contains(edge.to.as_str()) {
continue;
}
let Some(sb) = subgraph_bounds.get(sg_id) else {
continue;
};
let x_min = sb.x + 1;
let x_max = sb.x + sb.width.saturating_sub(1);
containment
.entry(edge.index)
.and_modify(|(cur_min, cur_max)| {
*cur_min = (*cur_min).max(x_min);
*cur_max = (*cur_max).min(x_max);
})
.or_insert((x_min, x_max));
}
}
containment
}
#[cfg(test)]
pub(super) use super::label_util::calc_label_position;
pub(super) use super::label_util::{effective_edge_label, label_block, label_top_for_center};
fn label_center_from_top(top_y: usize, height: usize) -> usize {
top_y + height / 2
}
fn exit_direction_from_segments(segments: &[Segment]) -> AttachDirection {
match segments.first() {
Some(Segment::Vertical { y_start, y_end, .. }) if *y_end > *y_start => {
AttachDirection::Bottom
}
Some(Segment::Vertical { .. }) => AttachDirection::Top,
Some(Segment::Horizontal { x_start, x_end, .. }) if *x_end > *x_start => {
AttachDirection::Right
}
Some(Segment::Horizontal { .. }) => AttachDirection::Left,
None => AttachDirection::Bottom,
}
}
fn source_connection(direction: AttachDirection) -> Connections {
match direction {
AttachDirection::Top => Connections {
up: true,
down: false,
left: false,
right: false,
},
AttachDirection::Bottom => Connections {
up: false,
down: true,
left: false,
right: false,
},
AttachDirection::Left => Connections {
up: false,
down: false,
left: true,
right: false,
},
AttachDirection::Right => Connections {
up: false,
down: false,
left: false,
right: true,
},
}
}
fn draw_source_launch(
canvas: &mut Canvas,
routed: &RoutedEdge,
charset: &CharSet,
edge_color: Option<(u8, u8, u8)>,
) {
if routed.edge.arrow_start != Arrow::None || routed.is_self_edge || routed.segments.is_empty() {
return;
}
let Some(direction) = routed.source_connection else {
return;
};
if canvas.set_with_connection(
routed.start.x,
routed.start.y,
source_connection(direction),
charset,
routed.edge.stroke,
) {
merge_edge_fg(canvas, routed.start.x, routed.start.y, edge_color);
}
}
fn draw_edge_path_and_arrows(canvas: &mut Canvas, routed: &RoutedEdge, charset: &CharSet) {
let edge_color = resolved_edge_stroke_color(routed);
for segment in &routed.segments {
draw_segment(canvas, segment, routed.edge.stroke, charset, edge_color);
}
draw_source_launch(canvas, routed, charset, edge_color);
if routed.edge.arrow_end != Arrow::None {
draw_arrow_with_entry(
canvas,
&routed.end,
routed.entry_direction,
charset,
routed.edge.arrow_end,
edge_color,
);
}
if routed.edge.arrow_start != Arrow::None && !routed.is_self_edge {
let exit_direction = exit_direction_from_segments(&routed.segments);
draw_arrow_with_entry(
canvas,
&routed.start,
exit_direction,
charset,
routed.edge.arrow_start,
edge_color,
);
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn render_edge(
canvas: &mut Canvas,
routed: &RoutedEdge,
charset: &CharSet,
diagram_direction: Direction,
) {
if routed.edge.stroke == Stroke::Invisible {
return;
}
draw_edge_path_and_arrows(canvas, routed, charset);
if let Some(effective) = effective_edge_label(&routed.edge) {
draw_edge_label_with_tracking(
canvas,
routed,
effective.as_ref(),
diagram_direction,
&[],
charset,
None,
);
}
}
fn draw_edge_label_with_tracking(
canvas: &mut Canvas,
routed: &RoutedEdge,
label: &str,
direction: Direction,
placed_labels: &[PlacedLabel],
charset: &CharSet,
containment: Option<(usize, usize)>,
) -> Option<PlacedLabel> {
let block = label_block(label);
let label_width = block.width;
let label_height = block.height;
let mut on_h_seg = false;
let (base_x, base_center_y) = {
match direction {
Direction::TopDown | Direction::BottomTop => {
if routed.segments.len() >= 3 {
let is_long_path = routed.segments.len() >= 6;
let h_seg = if !is_long_path {
routed
.segments
.iter()
.filter(|s| match s {
Segment::Horizontal { x_start, x_end, .. } => {
x_start.abs_diff(*x_end) >= label_width + 2
}
_ => false,
})
.max_by_key(|s| match s {
Segment::Horizontal { x_start, x_end, .. } => {
x_start.abs_diff(*x_end)
}
_ => 0,
})
} else {
None
};
if let Some(Segment::Horizontal { y, x_start, x_end }) = h_seg {
let seg_min_x = (*x_start).min(*x_end);
let seg_max_x = (*x_start).max(*x_end);
let seg_len = seg_max_x - seg_min_x;
let label_x = seg_min_x + (seg_len - label_width) / 2;
on_h_seg = true;
(label_x, *y)
} else {
vertical_label_position(canvas, routed, label_width, label_height)
}
} else {
vertical_label_position(canvas, routed, label_width, label_height)
}
}
Direction::LeftRight => {
if routed.segments.len() >= 3 {
label_on_horizontal_segment(routed, label_width)
} else {
let center_y = (routed.start.y + routed.end.y) / 2;
let max_label_end = routed.end.x.saturating_sub(1);
let min_x = routed.start.x.saturating_add(1);
let available = max_label_end.saturating_sub(routed.start.x);
let label_x = if available >= label_width {
let centered = routed.start.x + (available - label_width) / 2;
let max_x = max_label_end.saturating_sub(label_width);
centered.max(min_x).min(max_x)
} else {
min_x
};
(label_x, center_y)
}
}
Direction::RightLeft => {
if routed.segments.len() >= 3 {
label_on_horizontal_segment(routed, label_width)
} else {
let center_y = (routed.start.y + routed.end.y) / 2;
let mid_x = (routed.start.x + routed.end.x) / 2;
let label_x = mid_x.saturating_sub(label_width / 2);
let max_x = routed.start.x.saturating_sub(label_width + 1);
let min_x = routed.end.x.saturating_add(2);
let label_x = if max_x < min_x {
let available = routed.start.x.saturating_sub(routed.end.x);
if available >= label_width {
routed.end.x + (available - label_width) / 2
} else {
routed.end.x
}
} else {
label_x.max(min_x).min(max_x)
};
(label_x, center_y)
}
}
}
};
let base_y = label_top_for_center(base_center_y, label_height);
let is_simple_axis_aligned = routed.segments.len() <= 2
&& match direction {
Direction::TopDown | Direction::BottomTop => routed
.segments
.iter()
.all(|segment| matches!(segment, Segment::Vertical { .. })),
Direction::LeftRight | Direction::RightLeft => routed
.segments
.iter()
.all(|segment| matches!(segment, Segment::Horizontal { .. })),
};
let check_edge = !on_h_seg && !is_simple_axis_aligned;
let (label_x, label_y) = find_safe_label_position(
canvas,
(base_x, base_y),
(label_width, label_height),
direction,
placed_labels,
check_edge,
charset,
);
let (label_x, label_y) =
if base_center_y.abs_diff(label_center_from_top(label_y, label_height)) > 2 {
let alt_center_y = (routed.start.y + routed.end.y) / 2;
let alt_x = routed.end.x.saturating_sub(label_width / 2);
let alt_y = label_top_for_center(alt_center_y, label_height);
find_safe_label_position(
canvas,
(alt_x, alt_y),
(label_width, label_height),
direction,
placed_labels,
false,
charset,
)
} else {
(label_x, label_y)
};
let label_x = if let Some((c_min, c_max)) = containment {
let avail = c_max.saturating_sub(c_min);
if label_width <= avail {
label_x.max(c_min).min(c_max.saturating_sub(label_width))
} else {
c_min + avail.saturating_sub(label_width) / 2
}
} else {
label_x
};
let needed_width = label_x + label_width;
if needed_width > canvas.width() {
canvas.expand_width(needed_width);
}
let arrow_pos = (routed.end.x, routed.end.y);
let arrow_start_pos = (routed.start.x, routed.start.y);
write_label_block(
canvas,
&block.lines,
label_x,
label_y,
label_width,
charset,
&[arrow_pos, arrow_start_pos],
false,
);
Some(PlacedLabel {
x: label_x,
y: label_y,
width: label_width,
height: label_height,
})
}
fn vertical_label_position(
canvas: &Canvas,
routed: &RoutedEdge,
label_width: usize,
label_height: usize,
) -> (usize, usize) {
if let Some(seg) = select_label_segment(&routed.segments) {
let mut place_right = routed.end.x > routed.start.x;
let (trial_x, trial_y) =
find_label_position_on_segment_with_side(seg, label_width, place_right);
if label_adjacent_to_edge_on_far_side(
canvas,
trial_x,
trial_y,
label_width,
label_height,
place_right,
) {
place_right = !place_right;
}
find_label_position_on_segment_with_side(seg, label_width, place_right)
} else {
let center_y = (routed.start.y + routed.end.y) / 2;
(routed.end.x.saturating_sub(label_width / 2), center_y)
}
}
fn label_on_horizontal_segment(routed: &RoutedEdge, label_len: usize) -> (usize, usize) {
if let Some(Segment::Horizontal { y, x_start, x_end }) =
select_label_segment_horizontal(&routed.segments)
{
let seg_min_x = (*x_start).min(*x_end);
let seg_max_x = (*x_start).max(*x_end);
let seg_len = seg_max_x - seg_min_x;
let label_x = if seg_len >= label_len {
seg_min_x + (seg_len - label_len) / 2
} else {
seg_min_x
};
(label_x, y.saturating_sub(1))
} else {
let anchor_y = routed.start.y.saturating_sub(1);
let mid_x = (routed.start.x + routed.end.x) / 2;
(mid_x.saturating_sub(label_len / 2), anchor_y)
}
}
fn find_label_position_on_segment_with_side(
segment: &Segment,
label_len: usize,
place_right: bool,
) -> (usize, usize) {
match segment {
Segment::Vertical { x, y_start, y_end } => {
let mid_y = (*y_start + *y_end) / 2;
if place_right {
(*x + 2, mid_y)
} else {
let needed_with_gap = label_len + 1;
let label_x = if *x >= needed_with_gap {
x - needed_with_gap } else {
x.saturating_sub(label_len) };
(label_x, mid_y)
}
}
Segment::Horizontal { y, x_start, x_end } => {
let mid_x = (*x_start + *x_end) / 2;
let label_x = mid_x.saturating_sub(label_len / 2);
(label_x, y.saturating_sub(1))
}
}
}
fn find_safe_label_position(
canvas: &Canvas,
base: (usize, usize),
label_size: (usize, usize),
direction: Direction,
placed_labels: &[PlacedLabel],
check_edge_collision: bool,
charset: &CharSet,
) -> (usize, usize) {
find_safe_label_position_inner(
canvas,
base,
label_size,
direction,
placed_labels,
check_edge_collision,
true,
charset,
)
}
#[allow(clippy::too_many_arguments)]
fn find_safe_label_position_inner(
canvas: &Canvas,
base: (usize, usize),
label_size: (usize, usize),
direction: Direction,
placed_labels: &[PlacedLabel],
check_edge_collision: bool,
check_arrow_collision: bool,
charset: &CharSet,
) -> (usize, usize) {
let (base_x, base_y) = base;
let (label_width, label_height) = label_size;
let has_collision = |x, y| {
label_collides_with_node(canvas, x, y, label_width, label_height)
|| (check_edge_collision
&& label_collides_with_edge(canvas, x, y, label_width, label_height))
|| (check_arrow_collision
&& label_collides_with_arrow(canvas, x, y, label_width, label_height, charset))
|| placed_labels
.iter()
.any(|p| p.overlaps(x, y, label_width, label_height))
};
if !has_collision(base_x, base_y) {
return (base_x, base_y);
}
const VERTICAL_SHIFTS: &[(isize, isize)] = &[
(0, -1),
(0, 1),
(0, -2),
(0, 2),
(-1, 0),
(1, 0),
(-2, 0),
(2, 0),
(0, -3),
(0, 3),
(-3, 0),
(3, 0),
];
const HORIZONTAL_SHIFTS: &[(isize, isize)] = &[
(0, -1),
(0, 1),
(0, -2),
(0, 2),
(-1, 0),
(1, 0),
(0, -3),
(0, 3),
];
let shifts = match direction {
Direction::TopDown | Direction::BottomTop => VERTICAL_SHIFTS,
Direction::LeftRight | Direction::RightLeft => HORIZONTAL_SHIFTS,
};
for (dx, dy) in shifts {
let new_x = (base_x as isize + dx).max(0) as usize;
let new_y = (base_y as isize + dy).max(0) as usize;
if !has_collision(new_x, new_y) {
return (new_x, new_y);
}
}
(base_x, base_y)
}
fn label_collides_with_node(
canvas: &Canvas,
x: usize,
y: usize,
label_width: usize,
label_height: usize,
) -> bool {
(0..label_height).any(|dy| {
(0..label_width).any(|dx| canvas.get(x + dx, y + dy).is_some_and(|cell| cell.is_node))
})
}
fn label_collides_with_edge(
canvas: &Canvas,
x: usize,
y: usize,
label_width: usize,
label_height: usize,
) -> bool {
(0..label_height).any(|dy| {
(0..label_width).any(|dx| canvas.get(x + dx, y + dy).is_some_and(|cell| cell.is_edge))
})
}
fn label_collides_with_arrow(
canvas: &Canvas,
x: usize,
y: usize,
label_width: usize,
label_height: usize,
charset: &CharSet,
) -> bool {
(0..label_height).any(|dy| {
(0..label_width).any(|dx| {
canvas
.get(x + dx, y + dy)
.is_some_and(|cell| charset.is_arrow(cell.ch))
})
})
}
fn label_adjacent_to_edge_on_far_side(
canvas: &Canvas,
label_x: usize,
label_y: usize,
label_width: usize,
label_height: usize,
place_right: bool,
) -> bool {
if place_right {
let check_x = label_x + label_width;
(0..label_height).any(|dy| {
let y = label_y + dy;
(0..=1).any(|offset| {
canvas
.get(check_x + offset, y)
.is_some_and(|cell| cell.is_edge)
})
})
} else {
(0..label_height).any(|dy| {
let y = label_y + dy;
(1..=2).any(|offset| {
label_x
.checked_sub(offset)
.and_then(|x| canvas.get(x, y))
.is_some_and(|cell| cell.is_edge)
})
})
}
}
fn inner_segments(segments: &[Segment]) -> &[Segment] {
match segments.len() {
0..=2 => segments,
n => &segments[1..n - 1],
}
}
fn select_label_segment(segments: &[Segment]) -> Option<&Segment> {
fn vertical_length(s: &Segment) -> usize {
match s {
Segment::Vertical { y_start, y_end, .. } => y_start.abs_diff(*y_end),
_ => 0,
}
}
fn longest_vertical<'a>(segs: impl Iterator<Item = &'a Segment>) -> Option<&'a Segment> {
segs.filter(|s| matches!(s, Segment::Vertical { .. }))
.max_by_key(|s| vertical_length(s))
}
let is_long_path = segments.len() >= 6;
if is_long_path {
longest_vertical(inner_segments(segments).iter()).or_else(|| {
segments
.iter()
.rev()
.find(|s| matches!(s, Segment::Vertical { .. }))
})
} else {
longest_vertical(segments.iter().rev())
}
}
fn select_label_segment_horizontal(segments: &[Segment]) -> Option<&Segment> {
fn horizontal_length(s: &Segment) -> usize {
match s {
Segment::Horizontal { x_start, x_end, .. } => x_start.abs_diff(*x_end),
_ => 0,
}
}
fn longest_horizontal<'a>(segs: impl Iterator<Item = &'a Segment>) -> Option<&'a Segment> {
segs.filter(|s| matches!(s, Segment::Horizontal { .. }))
.max_by_key(|s| horizontal_length(s))
}
let is_long_path = segments.len() >= 6;
if is_long_path {
longest_horizontal(inner_segments(segments).iter()).or_else(|| {
segments
.iter()
.rev()
.find(|s| matches!(s, Segment::Horizontal { .. }))
})
} else {
segments
.iter()
.rev()
.find(|s| matches!(s, Segment::Horizontal { .. }))
}
}
fn resolved_edge_stroke_color(routed: &RoutedEdge) -> Option<(u8, u8, u8)> {
routed
.edge
.style
.stroke
.as_ref()
.and_then(|color| color.to_rgb())
}
fn merge_edge_fg(canvas: &mut Canvas, x: usize, y: usize, rgb: Option<(u8, u8, u8)>) {
let Some((r, g, b)) = rgb else {
return;
};
canvas.merge_style(x, y, CellStyle::fg_rgb(r, g, b));
}
fn draw_segment(
canvas: &mut Canvas,
segment: &Segment,
stroke: Stroke,
charset: &CharSet,
edge_color: Option<(u8, u8, u8)>,
) {
match segment {
Segment::Vertical { x, y_start, y_end } => {
let (y_min, y_max) = if y_start < y_end {
(*y_start, *y_end)
} else {
(*y_end, *y_start)
};
for y in y_min..=y_max {
let connections = Connections {
up: y > y_min,
down: y < y_max,
left: false,
right: false,
};
if canvas.set_with_connection(*x, y, connections, charset, stroke) {
merge_edge_fg(canvas, *x, y, edge_color);
}
}
}
Segment::Horizontal { y, x_start, x_end } => {
let (x_min, x_max) = if x_start < x_end {
(*x_start, *x_end)
} else {
(*x_end, *x_start)
};
for x in x_min..=x_max {
let connections = Connections {
up: false,
down: false,
left: x > x_min,
right: x < x_max,
};
if canvas.set_with_connection(x, *y, connections, charset, stroke) {
merge_edge_fg(canvas, x, *y, edge_color);
}
}
}
}
}
fn draw_arrow_with_entry(
canvas: &mut Canvas,
point: &Point,
entry_direction: AttachDirection,
charset: &CharSet,
arrow_type: Arrow,
edge_color: Option<(u8, u8, u8)>,
) {
if canvas
.get(point.x, point.y)
.is_some_and(|cell| cell.is_node)
{
return;
}
let arrow_char = match (arrow_type, entry_direction) {
(Arrow::Normal, AttachDirection::Top) => charset.arrow_down,
(Arrow::Normal, AttachDirection::Bottom) => charset.arrow_up,
(Arrow::Normal, AttachDirection::Left) => charset.arrow_right,
(Arrow::Normal, AttachDirection::Right) => charset.arrow_left,
(Arrow::Cross, AttachDirection::Top) => charset.arrow_cross_down,
(Arrow::Cross, AttachDirection::Bottom) => charset.arrow_cross_up,
(Arrow::Cross, AttachDirection::Left) => charset.arrow_cross_right,
(Arrow::Cross, AttachDirection::Right) => charset.arrow_cross_left,
(Arrow::Circle, AttachDirection::Top) => charset.arrow_circle_down,
(Arrow::Circle, AttachDirection::Bottom) => charset.arrow_circle_up,
(Arrow::Circle, AttachDirection::Left) => charset.arrow_circle_right,
(Arrow::Circle, AttachDirection::Right) => charset.arrow_circle_left,
(Arrow::OpenTriangle, AttachDirection::Top) => charset.arrow_open_down,
(Arrow::OpenTriangle, AttachDirection::Bottom) => charset.arrow_open_up,
(Arrow::OpenTriangle, AttachDirection::Left) => charset.arrow_open_right,
(Arrow::OpenTriangle, AttachDirection::Right) => charset.arrow_open_left,
(Arrow::Diamond, _) => charset.arrow_diamond,
(Arrow::OpenDiamond, _) => charset.arrow_open_diamond,
(Arrow::None, _) => return,
};
let (ax, ay) = match canvas.get(point.x, point.y) {
Some(cell) if cell.is_subgraph_title || cell.is_subgraph_border => {
let (nx, ny) = match entry_direction {
AttachDirection::Top => (point.x, point.y + 1),
AttachDirection::Bottom => (point.x, point.y.saturating_sub(1)),
AttachDirection::Left => (point.x + 1, point.y),
AttachDirection::Right => (point.x.saturating_sub(1), point.y),
};
if canvas.get(nx, ny).is_some_and(|inner| inner.is_node) {
(point.x, point.y)
} else {
(nx, ny)
}
}
_ => (point.x, point.y),
};
if canvas.set(ax, ay, arrow_char) {
merge_edge_fg(canvas, ax, ay, edge_color);
}
}
#[cfg(test)]
fn draw_arrow(canvas: &mut Canvas, point: &Point, direction: Direction, charset: &CharSet) {
let arrow_char = match direction {
Direction::TopDown => charset.arrow_down,
Direction::BottomTop => charset.arrow_up,
Direction::LeftRight => charset.arrow_right,
Direction::RightLeft => charset.arrow_left,
};
canvas.set(point.x, point.y, arrow_char);
}
#[derive(Debug, Clone)]
struct PlacedLabel {
x: usize,
y: usize,
width: usize,
height: usize,
}
impl PlacedLabel {
fn overlaps(&self, x: usize, y: usize, width: usize, height: usize) -> bool {
let self_start_x = self.x.saturating_sub(1);
let self_end_x = self.x + self.width + 1;
let self_end_y = self.y + self.height;
let other_start_x = x.saturating_sub(1);
let other_end_x = x + width + 1;
let other_end_y = y + height;
other_start_x < self_end_x
&& self_start_x < other_end_x
&& y < self_end_y
&& self.y < other_end_y
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn render_all_edges(
canvas: &mut Canvas,
routed_edges: &[RoutedEdge],
charset: &CharSet,
diagram_direction: Direction,
) {
let layout = GridLayout::default();
render_all_edges_with_labels(
canvas,
routed_edges,
charset,
diagram_direction,
&HashMap::new(),
&layout,
None,
)
}
#[allow(clippy::too_many_arguments)]
pub fn render_all_edges_with_labels(
canvas: &mut Canvas,
routed_edges: &[RoutedEdge],
charset: &CharSet,
diagram_direction: Direction,
edge_containment: &HashMap<usize, (usize, usize)>,
layout: &GridLayout,
routed_geometry: Option<&RoutedGraphGeometry>,
) {
for routed in routed_edges {
if routed.edge.stroke == Stroke::Invisible {
continue;
}
draw_edge_path_and_arrows(canvas, routed, charset);
}
use super::label_placement::{RenderTimePlacementScope, compute_label_placements};
let render_time_placements = compute_label_placements(
routed_edges,
routed_geometry,
layout,
edge_containment,
canvas.width(),
canvas.height(),
RenderTimePlacementScope::AllBodyLabels,
);
let mut placed_labels: Vec<PlacedLabel> = Vec::new();
for routed in routed_edges {
let Some(effective) = effective_edge_label(&routed.edge) else {
continue;
};
let label = effective.as_ref();
let Some(rt) = render_time_placements.get(&routed.edge.index).copied() else {
continue;
};
let base_x = rt.center.0.saturating_sub(rt.label_dims.0 / 2);
let base_y = label_top_for_center(rt.center.1, rt.label_dims.1);
if let Some(p) = draw_label_direct(canvas, label, base_x, base_y, charset, rt.is_backward) {
placed_labels.push(p);
}
}
for routed in routed_edges {
if routed.edge.stroke == Stroke::Invisible {
continue;
}
let has_head = routed.edge.head_label.is_some();
let has_tail = routed.edge.tail_label.is_some();
if !has_head && !has_tail {
continue;
}
let (head_pos, tail_pos) = calc_end_label_positions(&routed.segments);
if let (Some(label), Some(pos)) = (&routed.edge.head_label, head_pos) {
let block = label_block(label);
let base_x = pos.x.saturating_sub(block.width / 2);
let base_y = label_top_for_center(pos.y, block.height);
let (safe_x, safe_y) = find_safe_label_position(
canvas,
(base_x, base_y),
(block.width, block.height),
diagram_direction,
&placed_labels,
false,
charset,
);
if let Some(p) = draw_label_direct(canvas, label, safe_x, safe_y, charset, false) {
placed_labels.push(p);
}
}
if let (Some(label), Some(pos)) = (&routed.edge.tail_label, tail_pos) {
let block = label_block(label);
let base_x = pos.x.saturating_sub(block.width / 2);
let base_y = label_top_for_center(pos.y, block.height);
let (safe_x, safe_y) = find_safe_label_position(
canvas,
(base_x, base_y),
(block.width, block.height),
diagram_direction,
&placed_labels,
false,
charset,
);
if let Some(p) = draw_label_direct(canvas, label, safe_x, safe_y, charset, false) {
placed_labels.push(p);
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn write_label_block(
canvas: &mut Canvas,
lines: &[&str],
x: usize,
y: usize,
block_width: usize,
charset: &CharSet,
blocked_points: &[(usize, usize)],
overwrite_arrows: bool,
) {
for (line_idx, line) in lines.iter().enumerate() {
let row_y = y + line_idx;
let line_width = line.chars().count();
let line_x = x + (block_width.saturating_sub(line_width) / 2);
for (ch_idx, ch) in line.chars().enumerate() {
let cell_x = line_x + ch_idx;
if blocked_points
.iter()
.any(|&(bx, by)| bx == cell_x && by == row_y)
{
continue;
}
let can_write = canvas.get(cell_x, row_y).is_some_and(|cell| {
!cell.is_node && (overwrite_arrows || !charset.is_arrow(cell.ch))
});
if can_write {
canvas.set(cell_x, row_y, ch);
}
}
}
}
fn draw_label_direct(
canvas: &mut Canvas,
label: &str,
x: usize,
y: usize,
charset: &CharSet,
overwrite_arrows: bool,
) -> Option<PlacedLabel> {
let block = label_block(label);
let label_width = block.width;
let label_height = block.height;
let needed_width = x + label_width;
if needed_width > canvas.width() {
canvas.expand_width(needed_width);
}
write_label_block(
canvas,
&block.lines,
x,
y,
label_width,
charset,
&[],
overwrite_arrows,
);
Some(PlacedLabel {
x,
y,
width: label_width,
height: label_height,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::Edge;
#[test]
fn test_draw_arrow_directions() {
let charset = CharSet::unicode();
let mut canvas = Canvas::new(10, 10);
draw_arrow(&mut canvas, &Point::new(1, 1), Direction::TopDown, &charset);
assert_eq!(canvas.get(1, 1).unwrap().ch, '▼');
let mut canvas = Canvas::new(10, 10);
draw_arrow(
&mut canvas,
&Point::new(1, 1),
Direction::BottomTop,
&charset,
);
assert_eq!(canvas.get(1, 1).unwrap().ch, '▲');
let mut canvas = Canvas::new(10, 10);
draw_arrow(
&mut canvas,
&Point::new(1, 1),
Direction::LeftRight,
&charset,
);
assert_eq!(canvas.get(1, 1).unwrap().ch, '►');
let mut canvas = Canvas::new(10, 10);
draw_arrow(
&mut canvas,
&Point::new(1, 1),
Direction::RightLeft,
&charset,
);
assert_eq!(canvas.get(1, 1).unwrap().ch, '◄');
}
#[test]
fn test_segment_length() {
let vertical = Segment::Vertical {
x: 5,
y_start: 10,
y_end: 20,
};
assert_eq!(vertical.length(), 10);
let horizontal = Segment::Horizontal {
y: 5,
x_start: 20,
x_end: 10,
};
assert_eq!(horizontal.length(), 10);
}
#[test]
fn test_label_collides_with_edge() {
let mut canvas = Canvas::new(20, 10);
let charset = CharSet::unicode();
let connections = Connections {
up: false,
down: false,
left: true,
right: true,
};
for x in 5..15 {
canvas.set_with_connection(x, 5, connections, &charset, Stroke::Solid);
}
assert!(label_collides_with_edge(&canvas, 7, 5, 5, 1));
assert!(!label_collides_with_edge(&canvas, 7, 4, 5, 1));
assert!(!label_collides_with_edge(&canvas, 7, 6, 5, 1));
assert!(label_collides_with_edge(&canvas, 3, 5, 5, 1)); }
#[test]
fn test_select_label_segment_horizontal_short_path() {
let segments = vec![
Segment::Horizontal {
y: 5,
x_start: 10,
x_end: 20,
},
Segment::Vertical {
x: 20,
y_start: 5,
y_end: 10,
},
Segment::Horizontal {
y: 10,
x_start: 20,
x_end: 30,
},
];
let chosen = select_label_segment_horizontal(&segments);
match chosen {
Some(Segment::Horizontal { y, .. }) => assert_eq!(*y, 10),
_ => panic!("Expected last horizontal segment at y=10"),
}
}
#[test]
fn test_select_label_segment_horizontal_long_path() {
let segments = vec![
Segment::Horizontal {
y: 3,
x_start: 50,
x_end: 55,
}, Segment::Vertical {
x: 55,
y_start: 3,
y_end: 12,
},
Segment::Horizontal {
y: 12,
x_start: 55,
x_end: 5,
}, Segment::Vertical {
x: 5,
y_start: 12,
y_end: 3,
},
Segment::Horizontal {
y: 3,
x_start: 5,
x_end: 10,
}, Segment::Vertical {
x: 10,
y_start: 3,
y_end: 5,
},
Segment::Horizontal {
y: 5,
x_start: 10,
x_end: 15,
},
];
let chosen = select_label_segment_horizontal(&segments);
match chosen {
Some(Segment::Horizontal { y, .. }) => assert_eq!(*y, 12),
_ => panic!("Expected longest inner horizontal segment at y=12"),
}
}
#[test]
fn test_select_label_segment_horizontal_no_horizontal() {
let segments = vec![Segment::Vertical {
x: 5,
y_start: 0,
y_end: 10,
}];
let chosen = select_label_segment_horizontal(&segments);
assert!(
chosen.is_none(),
"Should return None when no horizontal segments exist"
);
}
#[test]
fn draw_arrow_does_not_overwrite_node_content() {
let charset = CharSet::unicode();
let mut canvas = Canvas::new(10, 10);
canvas.set(5, 5, 'X');
canvas.mark_as_node(5, 5);
let point = Point { x: 5, y: 5 };
draw_arrow_with_entry(
&mut canvas,
&point,
AttachDirection::Top,
&charset,
Arrow::Normal,
None,
);
let cell = canvas.get(5, 5).unwrap();
assert_eq!(cell.ch, 'X', "Arrow should not overwrite node content");
assert!(cell.is_node, "Cell should still be marked as node");
}
#[test]
fn draw_arrow_writes_on_non_node_cell() {
let charset = CharSet::unicode();
let mut canvas = Canvas::new(10, 10);
let point = Point { x: 5, y: 5 };
draw_arrow_with_entry(
&mut canvas,
&point,
AttachDirection::Top,
&charset,
Arrow::Normal,
None,
);
let cell = canvas.get(5, 5).unwrap();
assert_eq!(
cell.ch, charset.arrow_down,
"Arrow should be drawn on empty cell"
);
}
#[test]
fn test_cross_arrow_renders_x_character() {
let charset = CharSet::unicode();
let mut canvas = Canvas::new(10, 10);
let point = Point { x: 5, y: 5 };
draw_arrow_with_entry(
&mut canvas,
&point,
AttachDirection::Top,
&charset,
Arrow::Cross,
None,
);
let cell = canvas.get(5, 5).unwrap();
assert_eq!(cell.ch, 'x', "Cross arrow should render as 'x'");
}
#[test]
fn test_circle_arrow_renders_o_character() {
let charset = CharSet::unicode();
let mut canvas = Canvas::new(10, 10);
let point = Point { x: 5, y: 5 };
draw_arrow_with_entry(
&mut canvas,
&point,
AttachDirection::Top,
&charset,
Arrow::Circle,
None,
);
let cell = canvas.get(5, 5).unwrap();
assert_eq!(cell.ch, '○', "Circle arrow should render as '○'");
}
#[test]
fn test_cross_arrow_all_directions() {
let charset = CharSet::unicode();
let mut canvas = Canvas::new(10, 10);
draw_arrow_with_entry(
&mut canvas,
&Point::new(5, 5),
AttachDirection::Top,
&charset,
Arrow::Cross,
None,
);
assert_eq!(canvas.get(5, 5).unwrap().ch, charset.arrow_cross_down);
let mut canvas = Canvas::new(10, 10);
draw_arrow_with_entry(
&mut canvas,
&Point::new(5, 5),
AttachDirection::Bottom,
&charset,
Arrow::Cross,
None,
);
assert_eq!(canvas.get(5, 5).unwrap().ch, charset.arrow_cross_up);
let mut canvas = Canvas::new(10, 10);
draw_arrow_with_entry(
&mut canvas,
&Point::new(5, 5),
AttachDirection::Left,
&charset,
Arrow::Cross,
None,
);
assert_eq!(canvas.get(5, 5).unwrap().ch, charset.arrow_cross_right);
let mut canvas = Canvas::new(10, 10);
draw_arrow_with_entry(
&mut canvas,
&Point::new(5, 5),
AttachDirection::Right,
&charset,
Arrow::Cross,
None,
);
assert_eq!(canvas.get(5, 5).unwrap().ch, charset.arrow_cross_left);
}
#[test]
fn test_circle_arrow_all_directions() {
let charset = CharSet::unicode();
let mut canvas = Canvas::new(10, 10);
draw_arrow_with_entry(
&mut canvas,
&Point::new(5, 5),
AttachDirection::Top,
&charset,
Arrow::Circle,
None,
);
assert_eq!(canvas.get(5, 5).unwrap().ch, charset.arrow_circle_down);
let mut canvas = Canvas::new(10, 10);
draw_arrow_with_entry(
&mut canvas,
&Point::new(5, 5),
AttachDirection::Bottom,
&charset,
Arrow::Circle,
None,
);
assert_eq!(canvas.get(5, 5).unwrap().ch, charset.arrow_circle_up);
let mut canvas = Canvas::new(10, 10);
draw_arrow_with_entry(
&mut canvas,
&Point::new(5, 5),
AttachDirection::Left,
&charset,
Arrow::Circle,
None,
);
assert_eq!(canvas.get(5, 5).unwrap().ch, charset.arrow_circle_right);
let mut canvas = Canvas::new(10, 10);
draw_arrow_with_entry(
&mut canvas,
&Point::new(5, 5),
AttachDirection::Right,
&charset,
Arrow::Circle,
None,
);
assert_eq!(canvas.get(5, 5).unwrap().ch, charset.arrow_circle_left);
}
#[test]
fn calc_label_empty_segments_returns_none() {
assert_eq!(calc_label_position(&[]), None);
}
#[test]
fn calc_label_single_vertical_segment_returns_midpoint() {
let segments = vec![Segment::Vertical {
x: 5,
y_start: 10,
y_end: 20,
}];
assert_eq!(calc_label_position(&segments), Some(Point { x: 5, y: 15 }));
}
#[test]
fn calc_label_single_horizontal_segment_returns_midpoint() {
let segments = vec![Segment::Horizontal {
y: 3,
x_start: 0,
x_end: 10,
}];
assert_eq!(calc_label_position(&segments), Some(Point { x: 5, y: 3 }));
}
#[test]
fn calc_label_l_path_midpoint_at_corner() {
let segments = vec![
Segment::Vertical {
x: 5,
y_start: 0,
y_end: 6,
},
Segment::Horizontal {
y: 6,
x_start: 5,
x_end: 11,
},
];
assert_eq!(calc_label_position(&segments), Some(Point { x: 5, y: 6 }));
}
#[test]
fn calc_label_z_path_midpoint_on_middle_segment() {
let segments = vec![
Segment::Vertical {
x: 5,
y_start: 0,
y_end: 4,
},
Segment::Horizontal {
y: 4,
x_start: 5,
x_end: 15,
},
Segment::Vertical {
x: 15,
y_start: 4,
y_end: 8,
},
];
assert_eq!(calc_label_position(&segments), Some(Point { x: 10, y: 4 }));
}
#[test]
fn calc_label_zero_length_path_returns_start() {
let segments = vec![Segment::Vertical {
x: 5,
y_start: 10,
y_end: 10,
}];
assert_eq!(calc_label_position(&segments), Some(Point { x: 5, y: 10 }));
}
#[test]
fn calc_label_odd_total_length_rounds_down() {
let segments = vec![Segment::Vertical {
x: 5,
y_start: 0,
y_end: 7,
}];
assert_eq!(calc_label_position(&segments), Some(Point { x: 5, y: 3 }));
}
#[test]
fn calc_label_backward_edge_typical_shape() {
let segments = vec![
Segment::Horizontal {
y: 3,
x_start: 20,
x_end: 25,
},
Segment::Vertical {
x: 25,
y_start: 3,
y_end: 15,
},
Segment::Horizontal {
y: 15,
x_start: 25,
x_end: 20,
},
];
assert_eq!(calc_label_position(&segments), Some(Point { x: 25, y: 9 }));
}
#[test]
fn compact_bottom_launch_renders_corner_at_start_cell() {
let charset = CharSet::unicode();
let mut canvas = Canvas::new(12, 8);
let routed = RoutedEdge {
edge: Edge::new("A", "B"),
start: Point::new(2, 3),
end: Point::new(8, 6),
segments: vec![
Segment::Horizontal {
y: 3,
x_start: 2,
x_end: 8,
},
Segment::Vertical {
x: 8,
y_start: 3,
y_end: 6,
},
],
source_connection: Some(AttachDirection::Top),
entry_direction: AttachDirection::Top,
is_backward: false,
is_self_edge: false,
};
render_edge(&mut canvas, &routed, &charset, Direction::TopDown);
assert_eq!(canvas.get(2, 3).unwrap().ch, '└');
}
#[test]
fn compact_right_launch_renders_corner_at_start_cell() {
let charset = CharSet::unicode();
let mut canvas = Canvas::new(12, 8);
let routed = RoutedEdge {
edge: Edge::new("A", "B"),
start: Point::new(6, 4),
end: Point::new(2, 1),
segments: vec![
Segment::Vertical {
x: 6,
y_start: 4,
y_end: 1,
},
Segment::Horizontal {
y: 1,
x_start: 6,
x_end: 2,
},
],
source_connection: Some(AttachDirection::Left),
entry_direction: AttachDirection::Right,
is_backward: true,
is_self_edge: false,
};
render_edge(&mut canvas, &routed, &charset, Direction::LeftRight);
assert_eq!(canvas.get(6, 4).unwrap().ch, '┘');
}
}