use unicode_width::UnicodeWidthStr;
use crate::sequence::{
AutonumberState, 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) {
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_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 height = BOX_HEIGHT + body_rows + note_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;
let mut autonumber = AutonumberState::Off;
let mut autonumber_cursor = 0usize;
let mut message_arrow_rows: Vec<usize> = Vec::with_capacity(num_messages);
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);
for (msg_idx, msg) in diag.messages.iter().enumerate() {
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);
}
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);
}
}
}
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), "");
}
}