use unicode_width::UnicodeWidthStr;
use crate::sequence::{MessageStyle, SequenceDiagram};
const BOX_PAD: usize = 2;
const BOX_HEIGHT: usize = 3;
const MIN_GAP: usize = 2;
const LABEL_PADDING: usize = 2;
const EVENT_ROW_H: usize = 2;
const SELF_MSG_ROW_H: usize = 3;
const ARROW_RIGHT: char = '▸';
const ARROW_LEFT: char = '◂';
const H_SOLID: char = '─';
const H_DASH: char = '┄';
const LIFELINE: char = '┆';
struct Canvas {
grid: Vec<Vec<char>>,
width: usize,
height: usize,
}
impl Canvas {
fn new(width: usize, height: usize) -> Self {
Self {
grid: vec![vec![' '; width]; height],
width,
height,
}
}
fn put(&mut self, row: usize, col: usize, ch: char) {
if row < self.height && col < self.width {
self.grid[row][col] = ch;
}
}
fn put_str(&mut self, row: usize, col: usize, s: &str) {
let mut c = col;
for ch in s.chars() {
if c >= self.width {
break;
}
self.put(row, c, ch);
c += unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
}
}
fn into_string(self) -> String {
self.grid
.iter()
.map(|row| {
let s: String = row.iter().collect();
s.trim_end().to_string()
})
.collect::<Vec<_>>()
.join("\n")
}
}
struct ParticipantLayout {
center: usize,
box_width: usize,
}
fn compute_layout(diag: &SequenceDiagram) -> Vec<ParticipantLayout> {
let n = diag.participants.len();
if n == 0 {
return Vec::new();
}
let box_widths: Vec<usize> = diag
.participants
.iter()
.map(|p| {
let label_w = p.label.width();
(label_w + 2 * BOX_PAD + 2).max(8)
})
.collect();
let mut gap_mins = vec![MIN_GAP; n.saturating_sub(1)];
for msg in &diag.messages {
let Some(si) = diag.participant_index(&msg.from) else {
continue;
};
let Some(ti) = diag.participant_index(&msg.to) else {
continue;
};
if si == ti {
continue; }
let lo = si.min(ti);
let hi = si.max(ti);
let spans = hi - lo;
let label_need = msg.text.width() + LABEL_PADDING;
let per_gap = label_need.div_ceil(spans);
for slot in gap_mins.iter_mut().take(hi).skip(lo) {
*slot = (*slot).max(per_gap);
}
}
let left_margin = box_widths[0] / 2 + 1;
let mut layouts = Vec::with_capacity(n);
let mut prev_center = left_margin;
for i in 0..n {
let center = if i == 0 {
left_margin
} else {
prev_center
+ box_widths[i - 1] / 2
+ gap_mins[i - 1]
+ box_widths[i] / 2
};
layouts.push(ParticipantLayout {
center,
box_width: box_widths[i],
});
prev_center = center;
}
layouts
}
fn draw_participant_box(canvas: &mut Canvas, cx: usize, box_width: usize, label: &str) {
let left = cx.saturating_sub(box_width / 2);
let right = left + box_width - 1;
canvas.put(0, left, '┌');
for c in (left + 1)..right {
canvas.put(0, c, '─');
}
canvas.put(0, right, '┐');
let label_w = label.width();
let inner_w = box_width.saturating_sub(2); let label_start = left + 1 + (inner_w.saturating_sub(label_w)) / 2;
canvas.put(1, left, '│');
canvas.put_str(1, label_start, label);
canvas.put(1, right, '│');
canvas.put(2, left, '└');
for c in (left + 1)..right {
canvas.put(2, c, '─');
}
canvas.put(2, right, '┘');
}
fn draw_lifeline(canvas: &mut Canvas, cx: usize, start: usize, end: usize) {
for r in start..=end {
if canvas.grid[r][cx] == ' ' {
canvas.put(r, cx, LIFELINE);
}
}
}
fn draw_message(
canvas: &mut Canvas,
src_cx: usize,
tgt_cx: usize,
row: usize,
text: &str,
style: MessageStyle,
) {
let going_right = tgt_cx > src_cx;
let left = src_cx.min(tgt_cx);
let right = src_cx.max(tgt_cx);
let h_char = if style.is_dashed() { H_DASH } else { H_SOLID };
for c in (left + 1)..right {
canvas.put(row, c, h_char);
}
if style.has_arrow() {
if going_right {
canvas.put(row, left, h_char); canvas.put(row, right, ARROW_RIGHT);
} else {
canvas.put(row, left, ARROW_LEFT);
canvas.put(row, right, h_char);
}
} else {
canvas.put(row, left, h_char);
canvas.put(row, right, h_char);
}
if !text.is_empty() && row > 0 {
let label_col = left + 2;
canvas.put_str(row - 1, label_col, text);
}
}
fn draw_self_message(canvas: &mut Canvas, cx: usize, row: usize, text: &str, style: MessageStyle) {
let h_char = if style.is_dashed() { H_DASH } else { H_SOLID };
let loop_w = text.width().max(4) + 4;
let right = cx + loop_w;
canvas.put(row, cx, '├');
for c in (cx + 1)..right {
canvas.put(row, c, h_char);
}
canvas.put(row, right, '┐');
canvas.put(row + 1, cx, '├');
if style.has_arrow() {
canvas.put(row + 1, cx + 1, ARROW_LEFT);
} else {
canvas.put(row + 1, cx + 1, h_char);
}
for c in (cx + 2)..right {
canvas.put(row + 1, c, h_char);
}
canvas.put(row + 1, right, '┘');
if !text.is_empty() && row > 0 {
canvas.put_str(row - 1, cx + 2, text);
}
}
pub fn render(diag: &SequenceDiagram) -> String {
let n = diag.participants.len();
if n == 0 {
return String::new();
}
let layouts = compute_layout(diag);
let num_messages = diag.messages.len();
let body_rows = if num_messages == 0 {
2 } else {
let self_msg_count = diag
.messages
.iter()
.filter(|m| m.from == m.to)
.count();
let regular_count = num_messages - self_msg_count;
1 + regular_count * EVENT_ROW_H + self_msg_count * SELF_MSG_ROW_H
};
let height = BOX_HEIGHT + body_rows;
let last = &layouts[n - 1];
let self_msg_extra = diag
.messages
.iter()
.filter(|m| {
diag.participant_index(&m.from) == diag.participant_index(&m.to)
&& diag.participant_index(&m.from) == Some(n - 1)
})
.map(|m| m.text.width() + 6)
.max()
.unwrap_or(0);
let width = last.center + last.box_width / 2 + 2 + self_msg_extra;
let mut canvas = Canvas::new(width, height);
for (i, p) in diag.participants.iter().enumerate() {
draw_participant_box(
&mut canvas,
layouts[i].center,
layouts[i].box_width,
&p.label,
);
}
let lifeline_start = BOX_HEIGHT; let lifeline_end = height - 1;
for layout in &layouts {
draw_lifeline(&mut canvas, layout.center, lifeline_start, lifeline_end);
}
let mut arrow_row = BOX_HEIGHT + 1;
for msg in &diag.messages {
let Some(si) = diag.participant_index(&msg.from) else {
continue;
};
let Some(ti) = diag.participant_index(&msg.to) else {
continue;
};
if si == ti {
draw_self_message(
&mut canvas,
layouts[si].center,
arrow_row,
&msg.text,
msg.style,
);
arrow_row += SELF_MSG_ROW_H;
} else {
draw_message(
&mut canvas,
layouts[si].center,
layouts[ti].center,
arrow_row,
&msg.text,
msg.style,
);
arrow_row += EVENT_ROW_H;
}
}
canvas.into_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::sequence::parse;
#[test]
fn render_produces_participant_boxes() {
let diag = parse("sequenceDiagram\nparticipant A as Alice\nparticipant B as Bob").unwrap();
let out = render(&diag);
assert!(out.contains("Alice"), "missing Alice in:\n{out}");
assert!(out.contains("Bob"), "missing Bob in:\n{out}");
assert!(out.contains('┌'), "no box corner in:\n{out}");
}
#[test]
fn render_draws_lifelines() {
let diag = parse("sequenceDiagram\nA->>B: hi").unwrap();
let out = render(&diag);
assert!(out.contains(LIFELINE), "no lifeline char in:\n{out}");
}
#[test]
fn render_solid_arrow() {
let diag = parse("sequenceDiagram\nA->>B: go").unwrap();
let out = render(&diag);
assert!(out.contains(ARROW_RIGHT), "no solid arrowhead in:\n{out}");
}
#[test]
fn render_dashed_arrow() {
let diag = parse("sequenceDiagram\nA-->>B: back").unwrap();
let out = render(&diag);
assert!(out.contains(H_DASH), "no dashed glyph in:\n{out}");
}
#[test]
fn render_message_text_appears() {
let diag = parse("sequenceDiagram\nA->>B: Hello Bob").unwrap();
let out = render(&diag);
assert!(out.contains("Hello Bob"), "missing message text in:\n{out}");
}
#[test]
fn render_message_order_top_to_bottom() {
let diag = parse("sequenceDiagram\nA->>B: first\nB->>A: second").unwrap();
let out = render(&diag);
let first_row = out
.lines()
.position(|l| l.contains("first"))
.expect("'first' not found");
let second_row = out
.lines()
.position(|l| l.contains("second"))
.expect("'second' not found");
assert!(
first_row < second_row,
"'first' should appear above 'second':\n{out}"
);
}
#[test]
fn render_empty_diagram_is_empty_string() {
let diag = crate::sequence::SequenceDiagram::default();
assert_eq!(render(&diag), "");
}
}