use unicode_width::UnicodeWidthStr;
use crate::sequence::{
AutonumberState, Block, BlockKind, MessageStyle, NoteAnchor, NoteEvent, 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 = '┆';
const ACTIVATION_BAR: 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,
top_row: usize,
) {
let left = cx.saturating_sub(box_width / 2);
let right = left + box_width - 1; let label_row = top_row + 1;
let bottom_row = top_row + 2;
canvas.put(top_row, left, '┌');
for c in (left + 1)..right {
canvas.put(top_row, c, '─');
}
canvas.put(top_row, 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(label_row, left, '│');
canvas.put_str(label_row, label_start, label);
canvas.put(label_row, right, '│');
canvas.put(bottom_row, left, '└');
for c in (left + 1)..right {
canvas.put(bottom_row, c, '─');
}
canvas.put(bottom_row, right, '┘');
}
fn draw_note_box(canvas: &mut Canvas, left: usize, right: usize, row: usize, text: &str) {
if right < left {
return;
}
let lines: Vec<&str> = text.lines().collect();
let height = lines.len() + 2;
canvas.put(row, left, '╭');
for c in (left + 1)..right {
canvas.put(row, c, '─');
}
canvas.put(row, right, '╮');
let inner_left = left + 2; for (i, line) in lines.iter().enumerate() {
let r = row + 1 + i;
canvas.put(r, left, '│');
for c in (left + 1)..right {
canvas.put(r, c, ' ');
}
canvas.put(r, right, '│');
canvas.put_str(r, inner_left, line);
}
let bottom = row + height - 1;
canvas.put(bottom, left, '╰');
for c in (left + 1)..right {
canvas.put(bottom, c, '─');
}
canvas.put(bottom, right, '╯');
}
fn note_columns(
anchor: &NoteAnchor,
layouts: &[ParticipantLayout],
diag: &SequenceDiagram,
text_w: usize,
) -> Option<(usize, usize)> {
let box_w = text_w + 4;
match anchor {
NoteAnchor::LeftOf(id) => {
let i = diag.participant_index(id)?;
let right = layouts[i].center.saturating_sub(2);
let left = right.saturating_sub(box_w.saturating_sub(1));
Some((left, right))
}
NoteAnchor::RightOf(id) => {
let i = diag.participant_index(id)?;
let left = layouts[i].center + 2;
Some((left, left + box_w - 1))
}
NoteAnchor::Over(id) => {
let i = diag.participant_index(id)?;
let center = layouts[i].center;
let left = center.saturating_sub(box_w / 2);
Some((left, left + box_w - 1))
}
NoteAnchor::OverPair(a, b) => {
let i = diag.participant_index(a)?;
let j = diag.participant_index(b)?;
let (lo, hi) = if i <= j { (i, j) } else { (j, i) };
let span_left = layouts[lo].center;
let span_right = layouts[hi].center;
let span_w = span_right - span_left + 1;
let needed_w = box_w.max(span_w + 2);
let centre = (span_left + span_right) / 2;
let left = centre.saturating_sub(needed_w / 2);
Some((left, left + needed_w - 1))
}
}
}
fn note_height(note: &NoteEvent) -> usize {
note.text.lines().count().max(1) + 3
}
fn max_line_width(text: &str) -> usize {
text.lines().map(|l| l.width()).max().unwrap_or(0)
}
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 note_rows: usize = diag.notes.iter().map(note_height).sum();
let block_rows: usize = diag
.blocks
.iter()
.map(|b| 4 + 2 * b.branches.len().saturating_sub(1))
.sum();
let height = BOX_HEIGHT + body_rows + note_rows + block_rows + BOX_HEIGHT;
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);
let footer_top = height - BOX_HEIGHT;
for (i, p) in diag.participants.iter().enumerate() {
let cx = layouts[i].center;
let w = layouts[i].box_width;
draw_participant_box(&mut canvas, cx, w, &p.label, 0);
draw_participant_box(&mut canvas, cx, w, &p.label, footer_top);
}
let lifeline_start = BOX_HEIGHT; let lifeline_end = footer_top.saturating_sub(1);
for layout in &layouts {
draw_lifeline(&mut canvas, layout.center, lifeline_start, lifeline_end);
}
let mut arrow_row = BOX_HEIGHT + 1;
let mut autonumber = AutonumberState::Off;
let mut autonumber_cursor = 0usize;
let mut message_arrow_rows: Vec<usize> = Vec::with_capacity(num_messages);
let num_blocks = diag.blocks.len();
let mut block_top_rows: Vec<usize> = vec![0; num_blocks];
let mut block_bottom_rows: Vec<usize> = vec![0; num_blocks];
let mut branch_divider_rows: Vec<Vec<usize>> = diag
.blocks
.iter()
.map(|b| vec![0usize; b.branches.len()])
.collect();
let apply_block_events = |arrow_row: &mut usize,
pos: usize,
top_rows: &mut [usize],
bottom_rows: &mut [usize],
dividers: &mut [Vec<usize>]| {
for (i, b) in diag.blocks.iter().enumerate() {
if pos > 0 && b.end_message + 1 == pos {
bottom_rows[i] = *arrow_row;
*arrow_row += 2;
}
}
for (i, b) in diag.blocks.iter().enumerate().rev() {
if b.start_message == pos {
top_rows[i] = *arrow_row;
*arrow_row += 2;
}
}
for (i, b) in diag.blocks.iter().enumerate() {
for (j, branch) in b.branches.iter().enumerate().skip(1) {
if branch.start_message == pos {
dividers[i][j] = *arrow_row;
*arrow_row += 2;
}
}
}
};
let render_notes_at = |canvas: &mut Canvas, arrow_row: &mut usize, at: usize| {
for note in diag.notes.iter().filter(|n| n.after_message == at) {
let text_w = max_line_width(¬e.text);
if let Some((l, r)) = note_columns(¬e.anchor, &layouts, diag, text_w) {
draw_note_box(canvas, l, r, *arrow_row, ¬e.text);
*arrow_row += note_height(note);
}
}
};
render_notes_at(&mut canvas, &mut arrow_row, 0);
apply_block_events(
&mut arrow_row,
0,
&mut block_top_rows,
&mut block_bottom_rows,
&mut branch_divider_rows,
);
for (msg_idx, msg) in diag.messages.iter().enumerate() {
if msg_idx > 0 {
apply_block_events(
&mut arrow_row,
msg_idx,
&mut block_top_rows,
&mut block_bottom_rows,
&mut branch_divider_rows,
);
}
while autonumber_cursor < diag.autonumber_changes.len()
&& diag.autonumber_changes[autonumber_cursor].at_message <= msg_idx
{
autonumber = diag.autonumber_changes[autonumber_cursor].state;
autonumber_cursor += 1;
}
let label_owned;
let label: &str = match autonumber {
AutonumberState::On { next_value } => {
label_owned = if msg.text.is_empty() {
format!("[{next_value}]")
} else {
format!("[{next_value}] {}", msg.text)
};
autonumber = AutonumberState::On {
next_value: next_value + 1,
};
&label_owned
}
AutonumberState::Off => &msg.text,
};
let Some(si) = diag.participant_index(&msg.from) else {
continue;
};
let Some(ti) = diag.participant_index(&msg.to) else {
continue;
};
message_arrow_rows.push(arrow_row);
if si == ti {
draw_self_message(
&mut canvas,
layouts[si].center,
arrow_row,
label,
msg.style,
);
arrow_row += SELF_MSG_ROW_H;
} else {
draw_message(
&mut canvas,
layouts[si].center,
layouts[ti].center,
arrow_row,
label,
msg.style,
);
arrow_row += EVENT_ROW_H;
}
render_notes_at(&mut canvas, &mut arrow_row, msg_idx + 1);
}
apply_block_events(
&mut arrow_row,
num_messages,
&mut block_top_rows,
&mut block_bottom_rows,
&mut branch_divider_rows,
);
for act in &diag.activations {
let Some(pi) = diag.participant_index(&act.participant) else {
continue;
};
let cx = layouts[pi].center;
let arrow_r0 = message_arrow_rows
.get(act.start_message)
.copied()
.unwrap_or(BOX_HEIGHT + 1);
let r1 = message_arrow_rows
.get(act.end_message)
.copied()
.unwrap_or_else(|| height.saturating_sub(2));
let r0 = arrow_r0.saturating_sub(1).max(BOX_HEIGHT);
let (lo, hi) = if r0 <= r1 { (r0, r1) } else { (r1, r0) };
for r in lo..=hi {
let cell = canvas.grid[r][cx];
if cell == LIFELINE || cell == ' ' {
canvas.put(r, cx, ACTIVATION_BAR);
}
}
}
for (i, b) in diag.blocks.iter().enumerate() {
if b.start_message > b.end_message
|| message_arrow_rows.get(b.start_message).is_none()
{
continue;
}
let Some((natural_left, natural_right)) =
block_column_range(b, diag, &layouts)
else {
continue;
};
let depth = block_depth(i, &diag.blocks);
let max_inset = (natural_right - natural_left) / 4;
let inset = depth.min(max_inset);
let left = natural_left.saturating_add(inset);
let right = natural_right.saturating_sub(inset);
let top = block_top_rows[i];
let bottom = block_bottom_rows[i];
if top == 0 || bottom == 0 || top >= bottom {
continue;
}
let kind_label = block_kind_label(b.kind);
let opener_label = b.branches.first().map(|br| br.label.as_str()).unwrap_or("");
let branches: Vec<(usize, &str)> = b
.branches
.iter()
.enumerate()
.skip(1)
.map(|(j, branch)| (branch_divider_rows[i][j], branch.label.as_str()))
.filter(|(row, _)| *row != 0)
.collect();
draw_block_frame(
&mut canvas,
top,
bottom,
left,
right,
kind_label,
opener_label,
&branches,
);
}
canvas.into_string()
}
fn block_depth(idx: usize, blocks: &[Block]) -> usize {
let me = &blocks[idx];
blocks
.iter()
.enumerate()
.filter(|(j, b)| {
*j != idx
&& b.start_message <= me.start_message
&& b.end_message >= me.end_message
&& (b.start_message < me.start_message
|| b.end_message > me.end_message)
})
.count()
}
fn block_column_range(
block: &Block,
diag: &SequenceDiagram,
layouts: &[ParticipantLayout],
) -> Option<(usize, usize)> {
let mut min_idx: Option<usize> = None;
let mut max_idx: Option<usize> = None;
for msg in &diag.messages[block.start_message..=block.end_message] {
for id in [&msg.from, &msg.to] {
if let Some(p) = diag.participant_index(id) {
min_idx = Some(min_idx.map_or(p, |m| m.min(p)));
max_idx = Some(max_idx.map_or(p, |m| m.max(p)));
}
}
}
let lo = min_idx?;
let hi = max_idx?;
let left = layouts[lo].center.saturating_sub(layouts[lo].box_width / 2 + 1);
let right = layouts[hi].center + layouts[hi].box_width / 2 + 1;
Some((left, right))
}
fn block_kind_label(kind: BlockKind) -> &'static str {
match kind {
BlockKind::Loop => "loop",
BlockKind::Alt => "alt",
BlockKind::Opt => "opt",
BlockKind::Par => "par",
BlockKind::Critical => "critical",
BlockKind::Break => "break",
}
}
fn draw_tag(canvas: &mut Canvas, row: usize, anchor_left: usize, label: &str) -> usize {
if label.is_empty() {
return anchor_left + 2;
}
let col = anchor_left + 2;
let tag = format!("[{label}]");
let width = tag.chars().count();
canvas.put_str(row, col, &tag);
col + width
}
#[allow(clippy::too_many_arguments)]
fn draw_block_frame(
canvas: &mut Canvas,
top: usize,
bottom: usize,
left: usize,
right: usize,
kind: &str,
opener_label: &str,
branches: &[(usize, &str)],
) {
if right <= left || bottom <= top {
return;
}
let paintable = |ch: char| -> bool {
ch == ' ' || ch == LIFELINE || ch == ACTIVATION_BAR
};
if paintable(canvas.grid[top][left]) {
canvas.put(top, left, '╔');
}
for c in (left + 1)..right {
if paintable(canvas.grid[top][c]) {
canvas.put(top, c, '═');
}
}
if paintable(canvas.grid[top][right]) {
canvas.put(top, right, '╗');
}
let after_kind = draw_tag(canvas, top, left, kind);
if !opener_label.is_empty() {
draw_tag(canvas, top, after_kind, opener_label);
}
let divider_row_set: std::collections::HashSet<usize> =
branches.iter().map(|(r, _)| *r).collect();
for &(divider_row, branch_label) in branches {
if divider_row <= top || divider_row >= bottom {
continue;
}
canvas.put(divider_row, left, '╠');
canvas.put(divider_row, right, '╣');
for c in (left + 1)..right {
if paintable(canvas.grid[divider_row][c]) {
canvas.put(divider_row, c, '┄');
}
}
draw_tag(canvas, divider_row, left, branch_label);
}
for r in (top + 1)..bottom {
if divider_row_set.contains(&r) {
continue;
}
if paintable(canvas.grid[r][left]) {
canvas.put(r, left, '║');
}
if paintable(canvas.grid[r][right]) {
canvas.put(r, right, '║');
}
}
if paintable(canvas.grid[bottom][left]) {
canvas.put(bottom, left, '╚');
}
for c in (left + 1)..right {
if paintable(canvas.grid[bottom][c]) {
canvas.put(bottom, c, '═');
}
}
if paintable(canvas.grid[bottom][right]) {
canvas.put(bottom, right, '╝');
}
}
#[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), "");
}
}