use unicode_width::UnicodeWidthStr;
use crate::sequence::{
AutonumberState, Block, BlockKind, MessageStyle, NoteAnchor, ParticipantGroup, SequenceDiagram,
};
use crate::types::Rgb;
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 = 4;
const ARROW_RIGHT: char = '▸';
const ARROW_LEFT: char = '◂';
const H_SOLID: char = '─';
const H_DASH: char = '┄';
const LIFELINE: char = '┆';
const ACTIVATION_BAR: char = '█';
const ACTIVATION_BAR_WIDTH: usize = 2;
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 max_line_width(text: &str) -> usize {
text.lines().map(|l| l.width()).max().unwrap_or(0)
}
fn wrap_note_text(text: &str, budget: usize) -> String {
if budget == 0 {
return text.to_string();
}
let mut out = String::with_capacity(text.len());
for (seg_idx, segment) in text.split('\n').enumerate() {
if seg_idx > 0 {
out.push('\n');
}
if segment.width() <= budget {
out.push_str(segment);
continue;
}
let mut current_w = 0usize;
let mut first_word = true;
for word in segment.split_ascii_whitespace() {
let w = word.width();
if first_word {
out.push_str(word);
current_w = w;
first_word = false;
} else if current_w + 1 + w <= budget {
out.push(' ');
out.push_str(word);
current_w += 1 + w;
} else {
out.push('\n');
out.push_str(word);
current_w = w;
}
}
if first_word {
out.push_str(segment);
}
}
out
}
fn note_budget(
anchor: &NoteAnchor,
layouts: &[ParticipantLayout],
diag: &SequenceDiagram,
) -> usize {
const RIGHT_OF_BUDGET: usize = 30;
const OVER_SINGLE_BUDGET: usize = 40;
const BOX_OVERHEAD: usize = 4;
match anchor {
NoteAnchor::LeftOf(id) => {
let Some(i) = diag.participant_index(id) else {
return OVER_SINGLE_BUDGET;
};
let right_edge = layouts[i].center.saturating_sub(2);
right_edge.saturating_sub(BOX_OVERHEAD)
}
NoteAnchor::RightOf(_) => RIGHT_OF_BUDGET,
NoteAnchor::Over(_) => OVER_SINGLE_BUDGET,
NoteAnchor::OverPair(a, b) => {
let Some(i) = diag.participant_index(a) else {
return OVER_SINGLE_BUDGET;
};
let Some(j) = diag.participant_index(b) else {
return OVER_SINGLE_BUDGET;
};
let (lo, hi) = if i <= j { (i, j) } else { (j, i) };
let span_w = layouts[hi].center.saturating_sub(layouts[lo].center) + 1;
span_w.saturating_sub(BOX_OVERHEAD).max(OVER_SINGLE_BUDGET)
}
}
}
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) + 3;
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, right, '│');
if !text.is_empty() {
canvas.put_str(row + 1, cx + 1, text);
}
canvas.put(row + 2, cx, '├');
if style.has_arrow() {
canvas.put(row + 2, cx + 1, ARROW_LEFT);
} else {
canvas.put(row + 2, cx + 1, h_char);
}
for c in (cx + 2)..right {
canvas.put(row + 2, c, h_char);
}
canvas.put(row + 2, right, '┘');
}
fn draw_participant_group_frame(
canvas: &mut Canvas,
grp: &ParticipantGroup,
layouts: &[ParticipantLayout],
top_row: usize,
bottom_row: usize,
) {
if grp.members.is_empty() {
return;
}
let lo_idx = *grp.members.iter().min().expect("members non-empty");
let hi_idx = *grp.members.iter().max().expect("members non-empty");
if lo_idx >= layouts.len() || hi_idx >= layouts.len() {
return;
}
let left = layouts[lo_idx]
.center
.saturating_sub(layouts[lo_idx].box_width / 2 + 1);
let right = layouts[hi_idx].center + layouts[hi_idx].box_width / 2 + 1;
if right <= left {
return;
}
canvas.put(top_row, left, '┌');
for c in (left + 1)..right {
canvas.put(top_row, c, '─');
}
canvas.put(top_row, right, '┐');
if !grp.label.is_empty() {
let tag = format!("[{}]", grp.label);
canvas.put_str(top_row, left + 2, &tag);
}
if bottom_row != top_row {
canvas.put(bottom_row, left, '└');
for c in (left + 1)..right {
canvas.put(bottom_row, c, '─');
}
canvas.put(bottom_row, right, '┘');
}
}
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_wrapped: Vec<String> = diag
.notes
.iter()
.map(|note| {
let budget = note_budget(¬e.anchor, &layouts, diag);
wrap_note_text(¬e.text, budget)
})
.collect();
let note_rows: usize = note_wrapped
.iter()
.map(|t| t.lines().count().max(1) + 3)
.sum();
let block_rows: usize = diag
.blocks
.iter()
.map(|b| 4 + 2 * b.branches.len().saturating_sub(1))
.sum();
let group_frame_rows: usize = if diag.participant_groups.is_empty() {
0
} else {
1
};
let height = group_frame_rows
+ BOX_HEIGHT
+ body_rows
+ note_rows
+ block_rows
+ BOX_HEIGHT
+ group_frame_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 participant_width = last.center + last.box_width / 2 + 2 + self_msg_extra;
let note_required_width: usize = diag
.notes
.iter()
.zip(note_wrapped.iter())
.filter_map(|(note, wrapped)| {
let text_w = max_line_width(wrapped);
let (_l, r) = note_columns(¬e.anchor, &layouts, diag, text_w)?;
Some(r + 2)
})
.max()
.unwrap_or(0);
let width = participant_width.max(note_required_width);
let mut canvas = Canvas::new(width, height);
let header_top = group_frame_rows;
let footer_top = height - BOX_HEIGHT - group_frame_rows;
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, header_top);
draw_participant_box(&mut canvas, cx, w, &p.label, footer_top);
}
if group_frame_rows > 0 {
let group_top_row = 0usize;
let group_bottom_row = footer_top + BOX_HEIGHT;
for grp in &diag.participant_groups {
draw_participant_group_frame(
&mut canvas,
grp,
&layouts,
group_top_row,
group_bottom_row,
);
}
}
let lifeline_start = header_top + 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 = header_top + 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, wrapped) in diag
.notes
.iter()
.zip(note_wrapped.iter())
.filter(|(n, _)| n.after_message == at)
{
let text_w = max_line_width(wrapped);
if let Some((l, r)) = note_columns(¬e.anchor, &layouts, diag, text_w) {
draw_note_box(canvas, l, r, *arrow_row, wrapped);
*arrow_row += wrapped.lines().count().max(1) + 3;
}
}
};
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,
);
let act_ranges: Vec<(usize, usize, usize)> = diag
.activations
.iter()
.filter_map(|act| {
let pi = diag.participant_index(&act.participant)?;
let arrow_r0 = message_arrow_rows
.get(act.start_message)
.copied()
.unwrap_or(lifeline_start + 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(lifeline_start);
let (lo, hi) = if r0 <= r1 { (r0, r1) } else { (r1, r0) };
Some((lo, hi, pi))
})
.collect();
for (i, act) in diag.activations.iter().enumerate() {
let Some(pi) = diag.participant_index(&act.participant) else {
continue;
};
let cx = layouts[pi].center;
let (lo, hi, _) = act_ranges[i];
let depth = act_ranges
.iter()
.enumerate()
.filter(|&(j, &(other_lo, other_hi, other_pi))| {
j != i
&& other_pi == pi
&& other_lo <= lo
&& other_hi >= hi
&& (other_lo, other_hi) != (lo, hi)
})
.count();
let col_offset = depth * (ACTIVATION_BAR_WIDTH + 1);
for r in lo..=hi {
for dx in 0..ACTIVATION_BAR_WIDTH {
let col = cx + col_offset + dx;
if col >= canvas.width {
break;
}
let cell = canvas.grid[r][col];
if cell == LIFELINE || cell == ' ' {
canvas.put(r, col, ACTIVATION_BAR);
}
}
}
}
let mut frame_interiors: Vec<(usize, usize, usize, usize)> = Vec::new();
let mut rect_interiors: Vec<(usize, usize, usize, usize, char)> = Vec::new();
let mut label_rows: std::collections::HashSet<usize> = std::collections::HashSet::new();
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;
}
if let BlockKind::Rect { rgb, alpha } = b.kind {
let glyph = rect_shade_glyph(rgb, alpha);
rect_interiors.push((top, bottom, left, right, glyph));
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();
label_rows.insert(top);
label_rows.insert(bottom);
for &(dr, _) in &branches {
label_rows.insert(dr);
}
draw_block_frame(
&mut canvas,
top,
bottom,
left,
right,
kind_label,
opener_label,
&branches,
);
frame_interiors.push((top, bottom, left, right));
}
for (top, bottom, left, right) in frame_interiors {
for r in (top + 1)..bottom {
if label_rows.contains(&r) {
continue;
}
for c in (left + 1)..right {
if canvas.grid[r][c] == ' ' {
canvas.put(r, c, '\u{2591}');
}
}
}
}
for (top, bottom, left, right, glyph) in rect_interiors {
for r in (top + 1)..bottom {
for c in (left + 1)..right {
let cell = canvas.grid[r][c];
let should_replace = cell == ' '
|| (cell == '\u{2591}' && (glyph == '\u{2592}' || glyph == '\u{2593}'))
|| (cell == '\u{2592}' && glyph == '\u{2593}');
if should_replace {
canvas.put(r, c, glyph);
}
}
}
}
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",
BlockKind::Rect { .. } => "",
}
}
fn rect_shade_glyph(rgb: Rgb, alpha: Option<u8>) -> char {
let Rgb(r, g, b) = rgb;
let luminance = 0.299 * f32::from(r) + 0.587 * f32::from(g) + 0.114 * f32::from(b);
let alpha_norm = alpha.map_or(1.0_f32, |a| f32::from(a) / 255.0);
let intensity = (255.0 - luminance) * alpha_norm;
if intensity < 60.0 {
'\u{2591}' } else if intensity < 130.0 {
'\u{2592}' } else {
'\u{2593}' }
}
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), "");
}
}