use crate::graph::{Direction, NodeShape};
use crate::style::StyleChars;
use super::canvas::{is_arrow, is_junction, is_vertical, Canvas};
use super::subgraph_title_y;
pub fn draw_subgraph(
canvas: &mut Canvas,
rect: &crate::graph::Rectangle,
title: Option<&str>,
style: &StyleChars,
direction: Direction,
) {
if !rect.is_valid() {
return;
}
let x = rect.x;
let y = rect.y;
let width = rect.width;
let height = rect.height;
canvas.set(x, y, style.tl);
for i in 1..width - 1 {
canvas.set(x + i, y, style.h);
}
canvas.set(x + width - 1, y, style.tr);
for j in 1..height - 1 {
canvas.set(x, y + j, style.v);
canvas.set(x + width - 1, y + j, style.v);
}
canvas.set(x, y + height - 1, style.bl);
for i in 1..width - 1 {
canvas.set(x + i, y + height - 1, style.h);
}
canvas.set(x + width - 1, y + height - 1, style.br);
if let Some(t) = title {
let title_fmt = crate::graph::subgraph_title_text(t);
if let Some(start_x) = crate::graph::subgraph_title_start_x(x, width, t, direction) {
let title_y = subgraph_title_y(rect, direction);
for (i, c) in title_fmt.chars().enumerate() {
if start_x + i < canvas.width {
canvas.set(start_x + i, title_y, c);
}
}
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn draw_node(
canvas: &mut Canvas,
x: usize,
y: usize,
width: usize,
height: usize,
label_lines: &[String],
shape: NodeShape,
style: &StyleChars,
direction: Direction,
) {
let label = label_lines.first().map(|s| s.as_str()).unwrap_or_default();
match shape {
NodeShape::Rectangle => {
draw_rectangle(canvas, x, y, width, height, label_lines, style, direction)
}
NodeShape::Rounded => {
draw_rounded(canvas, x, y, width, height, label_lines, style, direction)
}
NodeShape::Diamond => draw_diamond(canvas, x, y, width, label, style),
NodeShape::Circle => draw_circle(canvas, x, y, width, label, style),
NodeShape::DoubleCircle => draw_double_circle(canvas, x, y, width, label, style),
NodeShape::Stadium => {
draw_stadium(canvas, x, y, width, height, label_lines, style, direction)
}
NodeShape::Hexagon => {
draw_hexagon(canvas, x, y, width, height, label_lines, style, direction)
}
NodeShape::Database => {
draw_database(canvas, x, y, width, height, label_lines, style, direction)
}
NodeShape::Subroutine => {
draw_subroutine(canvas, x, y, width, height, label_lines, style, direction)
}
NodeShape::Asymmetric => {
draw_asymmetric(canvas, x, y, width, height, label_lines, style, direction)
}
NodeShape::Parallelogram => draw_parallelogram(
canvas,
x,
y,
width,
height,
label_lines,
style,
direction,
true,
),
NodeShape::ParallelogramAlt => draw_parallelogram(
canvas,
x,
y,
width,
height,
label_lines,
style,
direction,
false,
),
NodeShape::Trapezoid => draw_trapezoid(
canvas,
x,
y,
width,
height,
label_lines,
style,
direction,
true,
),
NodeShape::TrapezoidAlt => draw_trapezoid(
canvas,
x,
y,
width,
height,
label_lines,
style,
direction,
false,
),
}
}
#[allow(clippy::too_many_arguments)]
fn draw_boxlike(
canvas: &mut Canvas,
x: usize,
y: usize,
width: usize,
height: usize,
label_lines: &[String],
top_left: char,
top_right: char,
bottom_left: char,
bottom_right: char,
top_h: char,
bottom_h: char,
left_side: char,
right_side: char,
style: &StyleChars,
direction: Direction,
) {
let height = height.max(3);
let bottom_y = y + height - 1;
let mut bt_preferred_down_arm: Option<usize> = None;
if direction == Direction::BT {
let center_x = x + width / 2;
let mut candidates: Vec<usize> = Vec::new();
for i in 1..width.saturating_sub(1) {
let pos_x = x + i;
let above = if y > 0 { canvas.get(pos_x, y - 1) } else { ' ' };
let above2 = if y > 1 { canvas.get(pos_x, y - 2) } else { ' ' };
let above_is_vertical = is_vertical(above, style) || is_arrow(above);
let above_is_corner_down = above == style.corner_dr || above == style.corner_dl;
let above_is_junction = is_junction(above, style);
let above2_is_vertical =
is_vertical(above2, style) || is_arrow(above2) || is_junction(above2, style);
let has_down_arm = above_is_vertical
|| ((above_is_corner_down || above_is_junction) && above2_is_vertical);
if has_down_arm {
candidates.push(pos_x);
}
}
if let Some(best) = candidates.into_iter().min_by_key(|pos| {
let dist = (*pos).abs_diff(center_x);
(dist, *pos)
}) {
bt_preferred_down_arm = Some(best);
}
}
canvas.set(x, y, top_left);
for i in 1..width.saturating_sub(1) {
let pos_x = x + i;
let c = if direction == Direction::BT {
let above = if y > 0 { canvas.get(pos_x, y - 1) } else { ' ' };
let above2 = if y > 1 { canvas.get(pos_x, y - 2) } else { ' ' };
let above_is_vertical = is_vertical(above, style) || is_arrow(above);
let above_is_corner_down = above == style.corner_dr || above == style.corner_dl;
let above_is_junction = is_junction(above, style);
let above2_is_vertical =
is_vertical(above2, style) || is_arrow(above2) || is_junction(above2, style);
let has_down_arm = above_is_vertical
|| ((above_is_corner_down || above_is_junction) && above2_is_vertical);
if has_down_arm && bt_preferred_down_arm == Some(pos_x) {
style.junction_up
} else {
top_h
}
} else {
top_h
};
canvas.set(pos_x, y, c);
}
canvas.set(x + width.saturating_sub(1), y, top_right);
let inner_height = height.saturating_sub(2);
let label_start_y = y + 1 + inner_height.saturating_sub(label_lines.len()) / 2;
let label_area_width = width.saturating_sub(4);
for j in 0..inner_height {
let row_y = y + 1 + j;
canvas.set(x, row_y, left_side);
for i in 1..width.saturating_sub(1) {
canvas.set(x + i, row_y, ' ');
}
canvas.set(x + width.saturating_sub(1), row_y, right_side);
}
for (idx, line) in label_lines.iter().enumerate() {
let row_y = label_start_y + idx;
if row_y < y + 1 || row_y >= bottom_y {
continue;
}
let padded_label = format!(" {:^w$} ", line, w = label_area_width);
for (i, c) in padded_label
.chars()
.take(width.saturating_sub(2))
.enumerate()
{
canvas.set(x + 1 + i, row_y, c);
}
}
canvas.set(x, bottom_y, bottom_left);
for i in 1..width.saturating_sub(1) {
let pos_x = x + i;
let c = if matches!(direction, Direction::TD | Direction::TB) {
let below = canvas.get(pos_x, bottom_y + 1);
let has_up_arm = is_vertical(below, style)
|| is_junction(below, style)
|| below == style.corner_ur || below == style.corner_ul; if has_up_arm {
style.junction_down
} else {
bottom_h
}
} else {
bottom_h
};
canvas.set(pos_x, bottom_y, c);
}
canvas.set(x + width.saturating_sub(1), bottom_y, bottom_right);
}
#[allow(clippy::too_many_arguments)]
fn draw_rectangle(
canvas: &mut Canvas,
x: usize,
y: usize,
width: usize,
height: usize,
label_lines: &[String],
style: &StyleChars,
direction: Direction,
) {
draw_boxlike(
canvas,
x,
y,
width,
height,
label_lines,
style.tl,
style.tr,
style.bl,
style.br,
style.h,
style.h,
style.v,
style.v,
style,
direction,
);
}
#[allow(clippy::too_many_arguments)]
fn draw_rounded(
canvas: &mut Canvas,
x: usize,
y: usize,
width: usize,
height: usize,
label_lines: &[String],
style: &StyleChars,
direction: Direction,
) {
let (tl, tr, bl, br) = if style.tl == '┌' {
('╭', '╮', '╰', '╯')
} else {
('(', ')', '(', ')')
};
draw_boxlike(
canvas,
x,
y,
width,
height,
label_lines,
tl,
tr,
bl,
br,
style.h,
style.h,
style.v,
style.v,
style,
direction,
);
}
fn draw_diamond(
canvas: &mut Canvas,
x: usize,
y: usize,
width: usize,
label: &str,
style: &StyleChars,
) {
let center = x + width / 2;
let is_unicode = style.tl == '┌';
let point_char = if is_unicode { '◇' } else { 'v' };
canvas.set(center, y, if is_unicode { '◇' } else { '^' });
canvas.set(x, y + 1, '<');
let padded_label = format!(" {:^width$} ", label, width = width - 4);
for (i, c) in padded_label.chars().take(width - 2).enumerate() {
canvas.set(x + 1 + i, y + 1, c);
}
canvas.set(x + width - 1, y + 1, '>');
let below = canvas.get(center, y + 3);
let bottom_char = if is_vertical(below, style) || is_junction(below, style) {
style.junction_down
} else {
point_char
};
canvas.set(center, y + 2, bottom_char);
for i in 1..center - x {
canvas.set(x + i, y + 2, ' ');
canvas.set(center + i, y + 2, ' ');
}
}
fn draw_circle(
canvas: &mut Canvas,
x: usize,
y: usize,
width: usize,
label: &str,
style: &StyleChars,
) {
let is_unicode = style.tl == '┌';
let (tl, tr, bl, br, h) = if is_unicode {
('╭', '╮', '╰', '╯', '─')
} else {
('/', '\\', '\\', '/', '-')
};
canvas.set(x, y, tl);
for i in 1..width - 1 {
canvas.set(x + i, y, h);
}
canvas.set(x + width - 1, y, tr);
canvas.set(x, y + 1, '(');
let padded_label = format!(" {:^width$} ", label, width = width - 4);
for (i, c) in padded_label.chars().take(width - 2).enumerate() {
canvas.set(x + 1 + i, y + 1, c);
}
canvas.set(x + width - 1, y + 1, ')');
canvas.set(x, y + 2, bl);
for i in 1..width - 1 {
let pos_x = x + i;
let below = canvas.get(pos_x, y + 3);
let c = if is_vertical(below, style) || is_junction(below, style) {
style.junction_down
} else {
h
};
canvas.set(pos_x, y + 2, c);
}
canvas.set(x + width - 1, y + 2, br);
}
fn draw_double_circle(
canvas: &mut Canvas,
x: usize,
y: usize,
width: usize,
label: &str,
style: &StyleChars,
) {
let width = width.max(7); let is_unicode = style.tl == '┌';
let (tl, tr, bl, br, h) = if is_unicode {
('╭', '╮', '╰', '╯', '─')
} else {
('/', '\\', '\\', '/', '-')
};
canvas.set(x, y, tl);
for i in 1..width - 1 {
canvas.set(x + i, y, h);
}
canvas.set(x + width - 1, y, tr);
canvas.set(x, y + 1, '(');
canvas.set(x + 1, y + 1, '(');
let content_width = width.saturating_sub(6);
let padded_label = format!(" {:^width$} ", label, width = content_width);
for (i, c) in padded_label.chars().take(width - 4).enumerate() {
canvas.set(x + 2 + i, y + 1, c);
}
canvas.set(x + width - 2, y + 1, ')');
canvas.set(x + width - 1, y + 1, ')');
canvas.set(x, y + 2, bl);
for i in 1..width - 1 {
let pos_x = x + i;
let below = canvas.get(pos_x, y + 3);
let c = if is_vertical(below, style) || is_junction(below, style) {
style.junction_down
} else {
h
};
canvas.set(pos_x, y + 2, c);
}
canvas.set(x + width - 1, y + 2, br);
}
#[allow(clippy::too_many_arguments)]
fn draw_stadium(
canvas: &mut Canvas,
x: usize,
y: usize,
width: usize,
height: usize,
label_lines: &[String],
style: &StyleChars,
direction: Direction,
) {
draw_boxlike(
canvas,
x,
y,
width,
height,
label_lines,
style.tl,
style.tr,
style.bl,
style.br,
style.h,
style.h,
'(',
')',
style,
direction,
);
}
#[allow(clippy::too_many_arguments)]
fn draw_hexagon(
canvas: &mut Canvas,
x: usize,
y: usize,
width: usize,
height: usize,
label_lines: &[String],
style: &StyleChars,
direction: Direction,
) {
draw_boxlike(
canvas,
x,
y,
width,
height,
label_lines,
'/',
'\\',
'\\',
'/',
style.h,
style.h,
'<',
'>',
style,
direction,
);
}
#[allow(clippy::too_many_arguments)]
fn draw_database(
canvas: &mut Canvas,
x: usize,
y: usize,
width: usize,
height: usize,
label_lines: &[String],
style: &StyleChars,
direction: Direction,
) {
let is_unicode = style.tl == '┌';
let h = if is_unicode { '─' } else { '-' };
draw_boxlike(
canvas,
x,
y,
width,
height,
label_lines,
'/',
'\\',
'\\',
'/',
h,
h,
style.v,
style.v,
style,
direction,
);
}
#[allow(clippy::too_many_arguments)]
fn draw_subroutine(
canvas: &mut Canvas,
x: usize,
y: usize,
width: usize,
height: usize,
label_lines: &[String],
style: &StyleChars,
direction: Direction,
) {
let dv = if style.tl == '┌' { '║' } else { '|' };
draw_boxlike(
canvas,
x,
y,
width,
height,
label_lines,
style.tl,
style.tr,
style.bl,
style.br,
style.h,
style.h,
dv,
dv,
style,
direction,
);
}
#[allow(clippy::too_many_arguments)]
fn draw_asymmetric(
canvas: &mut Canvas,
x: usize,
y: usize,
width: usize,
height: usize,
label_lines: &[String],
style: &StyleChars,
direction: Direction,
) {
draw_boxlike(
canvas,
x,
y,
width,
height,
label_lines,
'>',
style.tr,
'>',
style.br,
style.h,
style.h,
' ',
style.v,
style,
direction,
);
}
#[allow(clippy::too_many_arguments)]
fn draw_parallelogram(
canvas: &mut Canvas,
x: usize,
y: usize,
width: usize,
height: usize,
label_lines: &[String],
style: &StyleChars,
direction: Direction,
lean_right: bool,
) {
let is_unicode = style.tl == '┌';
let (fwd, back) = if is_unicode {
('╱', '╲')
} else {
('/', '\\')
};
let corner = if lean_right { fwd } else { back };
draw_boxlike(
canvas,
x,
y,
width,
height,
label_lines,
corner,
corner,
corner,
corner,
style.h,
style.h,
style.v,
style.v,
style,
direction,
);
}
#[allow(clippy::too_many_arguments)]
fn draw_trapezoid(
canvas: &mut Canvas,
x: usize,
y: usize,
width: usize,
height: usize,
label_lines: &[String],
style: &StyleChars,
direction: Direction,
wider_top: bool,
) {
let is_unicode = style.tl == '┌';
let (fwd, back) = if is_unicode {
('╱', '╲')
} else {
('/', '\\')
};
let (tl, tr, bl, br) = if wider_top {
(fwd, back, back, fwd)
} else {
(back, fwd, fwd, back)
};
draw_boxlike(
canvas,
x,
y,
width,
height,
label_lines,
tl,
tr,
bl,
br,
style.h,
style.h,
style.v,
style.v,
style,
direction,
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::{Direction, NodeShape, Rectangle};
use crate::render::canvas::Canvas;
use crate::style::{ASCII_CHARS, UNICODE_CHARS};
fn mk_canvas(w: usize, h: usize) -> Canvas {
Canvas::new(w, h)
}
fn lines(s: &str) -> Vec<String> {
vec![s.to_string()]
}
#[test]
fn subgraph_draws_corners_ascii() {
let mut c = mk_canvas(10, 6);
let r = Rectangle::new(0, 0, 10, 6);
draw_subgraph(&mut c, &r, None, &ASCII_CHARS, Direction::TD);
assert_eq!(c.get(0, 0), '+');
assert_eq!(c.get(9, 0), '+');
assert_eq!(c.get(0, 5), '+');
assert_eq!(c.get(9, 5), '+');
}
#[test]
fn subgraph_draws_corners_unicode() {
let mut c = mk_canvas(10, 6);
let r = Rectangle::new(0, 0, 10, 6);
draw_subgraph(&mut c, &r, None, &UNICODE_CHARS, Direction::TD);
assert_eq!(c.get(0, 0), '┌');
assert_eq!(c.get(9, 0), '┐');
assert_eq!(c.get(0, 5), '└');
assert_eq!(c.get(9, 5), '┘');
}
#[test]
fn subgraph_invalid_rect_is_noop() {
let mut c = mk_canvas(10, 6);
let r = Rectangle::new(0, 0, 0, 6); draw_subgraph(&mut c, &r, None, &ASCII_CHARS, Direction::TD);
assert_eq!(c.get(0, 0), ' ');
}
#[test]
fn subgraph_title_appears_in_td() {
let mut c = mk_canvas(20, 5);
let r = Rectangle::new(0, 0, 20, 5);
draw_subgraph(&mut c, &r, Some("Grp"), &ASCII_CHARS, Direction::TD);
let row: String = (0..20).map(|x| c.get(x, 1)).collect();
assert!(row.contains("Grp"), "title not found in row: {row:?}");
}
#[test]
fn subgraph_title_too_long_is_skipped() {
let mut c = mk_canvas(8, 4);
let r = Rectangle::new(0, 0, 8, 4);
draw_subgraph(
&mut c,
&r,
Some("VeryLongTitle"),
&ASCII_CHARS,
Direction::TD,
);
assert_eq!(c.get(0, 0), '+');
let row: String = (1..7).map(|x| c.get(x, 0)).collect();
assert!(!row.contains('['), "unexpected title bracket in: {row:?}");
}
#[test]
fn draw_node_rectangle_corners_ascii() {
let mut c = mk_canvas(12, 5);
draw_node(
&mut c,
0,
0,
12,
5,
&lines("hi"),
NodeShape::Rectangle,
&ASCII_CHARS,
Direction::TD,
);
assert_eq!(c.get(0, 0), '+');
assert_eq!(c.get(11, 0), '+');
assert_eq!(c.get(0, 4), '+');
assert_eq!(c.get(11, 4), '+');
}
#[test]
fn draw_node_rounded_corners_ascii() {
let mut c = mk_canvas(12, 5);
draw_node(
&mut c,
0,
0,
12,
5,
&lines("hi"),
NodeShape::Rounded,
&ASCII_CHARS,
Direction::TD,
);
assert_eq!(c.get(0, 0), '(');
assert_eq!(c.get(11, 0), ')');
assert_eq!(c.get(0, 4), '(');
assert_eq!(c.get(11, 4), ')');
}
#[test]
fn draw_node_rectangle_label_written() {
let mut c = mk_canvas(12, 3);
draw_node(
&mut c,
0,
0,
12,
3,
&lines("hi"),
NodeShape::Rectangle,
&ASCII_CHARS,
Direction::TD,
);
let row: String = (0..12).map(|x| c.get(x, 1)).collect();
assert!(
row.contains("hi"),
"label not found in interior row: {row:?}"
);
}
#[test]
fn draw_node_all_shapes_no_panic() {
let shapes = [
NodeShape::Rectangle,
NodeShape::Rounded,
NodeShape::Diamond,
NodeShape::Circle,
NodeShape::DoubleCircle,
NodeShape::Stadium,
NodeShape::Hexagon,
NodeShape::Database,
NodeShape::Subroutine,
NodeShape::Asymmetric,
NodeShape::Parallelogram,
NodeShape::ParallelogramAlt,
NodeShape::Trapezoid,
NodeShape::TrapezoidAlt,
];
for shape in shapes {
let mut c = mk_canvas(20, 7);
draw_node(
&mut c,
0,
0,
20,
7,
&lines("test"),
shape,
&UNICODE_CHARS,
Direction::TD,
);
let non_space = (0..20).any(|x| (0..7).any(|y| c.get(x, y) != ' '));
assert!(non_space, "shape {shape:?} produced blank canvas");
}
}
#[test]
fn boxlike_td_junction_placed_on_bottom_border() {
let mut c = mk_canvas(14, 7);
let style = &UNICODE_CHARS;
c.set(7, 3, style.edge_v);
draw_node(
&mut c,
0,
0,
14,
3,
&lines("A"),
NodeShape::Rectangle,
style,
Direction::TD,
);
let ch = c.get(7, 2);
assert_eq!(
ch, style.junction_down,
"expected junction_down at bottom border col 7, got {ch:?}"
);
}
#[test]
fn boxlike_td_no_junction_without_down_arm() {
let mut c = mk_canvas(14, 3);
let style = &UNICODE_CHARS;
draw_node(
&mut c,
0,
0,
14,
3,
&lines("A"),
NodeShape::Rectangle,
style,
Direction::TD,
);
let ch = c.get(7, 2);
assert_eq!(ch, style.h, "expected plain h-line, got {ch:?}");
}
#[test]
fn boxlike_bt_junction_placed_on_top_border() {
let mut c = mk_canvas(14, 7);
let style = &UNICODE_CHARS;
c.set(7, 1, style.edge_v);
draw_node(
&mut c,
0,
2,
14,
3,
&lines("A"),
NodeShape::Rectangle,
style,
Direction::BT,
);
let ch = c.get(7, 2);
assert_eq!(ch, style.junction_up, "expected junction_up, got {ch:?}");
}
#[test]
fn boxlike_bt_no_junction_without_arm_above() {
let mut c = mk_canvas(14, 7);
let style = &UNICODE_CHARS;
draw_node(
&mut c,
0,
2,
14,
3,
&lines("A"),
NodeShape::Rectangle,
style,
Direction::BT,
);
let ch = c.get(7, 2);
assert_eq!(ch, style.h, "expected plain h-line, got {ch:?}");
}
#[test]
fn trapezoid_variants_differ_at_top_left_corner() {
let mut c1 = mk_canvas(16, 5);
let mut c2 = mk_canvas(16, 5);
draw_node(
&mut c1,
0,
0,
16,
5,
&lines("x"),
NodeShape::Trapezoid,
&ASCII_CHARS,
Direction::TD,
);
draw_node(
&mut c2,
0,
0,
16,
5,
&lines("x"),
NodeShape::TrapezoidAlt,
&ASCII_CHARS,
Direction::TD,
);
let top1 = c1.get(0, 0);
let top2 = c2.get(0, 0);
assert_ne!(
top1, top2,
"Trapezoid variants should differ at top-left corner"
);
}
#[test]
fn parallelogram_variants_differ_at_top_left_corner() {
let mut c1 = mk_canvas(16, 5);
let mut c2 = mk_canvas(16, 5);
draw_node(
&mut c1,
0,
0,
16,
5,
&lines("x"),
NodeShape::Parallelogram,
&ASCII_CHARS,
Direction::TD,
);
draw_node(
&mut c2,
0,
0,
16,
5,
&lines("x"),
NodeShape::ParallelogramAlt,
&ASCII_CHARS,
Direction::TD,
);
let tl1 = c1.get(0, 0);
let tl2 = c2.get(0, 0);
assert_ne!(
tl1, tl2,
"Parallelogram variants should differ at top-left corner"
);
}
}