osd_core/
renderer.rs

1//! SVG renderer for sequence diagrams
2
3use crate::ast::*;
4use crate::theme::{LifelineStyle, ParticipantShape, Theme};
5use std::collections::HashMap;
6use std::fmt::Write;
7
8/// Rendering configuration
9#[derive(Debug, Clone)]
10pub struct Config {
11    /// Padding around the diagram
12    pub padding: f64,
13    /// Left margin for diagram content
14    pub left_margin: f64,
15    /// Right margin for diagram content
16    pub right_margin: f64,
17    /// Space between participants
18    pub participant_gap: f64,
19    /// Height of participant header/footer box
20    pub header_height: f64,
21    /// Height of each row (message, note, etc.)
22    pub row_height: f64,
23    /// Width of participant box
24    pub participant_width: f64,
25    /// Font size
26    pub font_size: f64,
27    /// Activation box width
28    pub activation_width: f64,
29    /// Note padding
30    pub note_padding: f64,
31    /// Block margin
32    pub block_margin: f64,
33    /// Title height (when title exists)
34    pub title_height: f64,
35    /// Theme for styling
36    pub theme: Theme,
37}
38
39impl Default for Config {
40    fn default() -> Self {
41        // Values calibrated to match websequencediagrams.com output exactly
42        // Reference: WSD Ultimate Stress Test produces:
43        //   - First participant box: x=78.5, y=110.5
44        //   - Box height: 46px (1 line), 108px (2+ lines)
45        //   - Box width: dynamic based on text
46        Self {
47            padding: 10.5,           // WSD: padding from SVG edge
48            left_margin: 66.0,       // WSD: Mobile lifeline at 124.5, box left edge at 78.5
49            right_margin: 10.0,      // WSD: minimal right margin, dynamically expanded for notes
50            participant_gap: 85.0,   // WSD: minimum gap for participants with no messages between
51            header_height: 46.0,     // WSD: participant box height = 46px (single line)
52            row_height: 32.0,        // WSD: actual row height = 32px
53            participant_width: 92.0, // WSD: minimum participant width = 92px
54            font_size: 14.0,         // WSD: uses 14px font
55            activation_width: 8.0,   // WSD: narrower activation bars
56            note_padding: 6.0,
57            block_margin: 5.0,
58            title_height: 100.0,     // WSD: title + space before participant boxes (y=110.5)
59            theme: Theme::default(),
60        }
61    }
62}
63
64impl Config {
65    /// Set the theme
66    pub fn with_theme(mut self, theme: Theme) -> Self {
67        self.theme = theme;
68        self
69    }
70}
71
72/// Block background info for deferred rendering
73#[derive(Debug, Clone)]
74struct BlockBackground {
75    x: f64,
76    y: f64,
77    width: f64,
78    height: f64,
79}
80
81/// Block label info for deferred rendering (rendered above activations/lifelines)
82#[derive(Debug, Clone)]
83struct BlockLabel {
84    x1: f64,
85    start_y: f64,
86    end_y: f64,
87    x2: f64,
88    kind: String,
89    label: String,
90    /// Y coordinates and labels for each else section
91    else_sections: Vec<(f64, Option<String>)>,
92}
93
94#[derive(Debug, Clone)]
95struct LabelBox {
96    x_min: f64,
97    x_max: f64,
98    y_min: f64,
99    y_max: f64,
100}
101
102/// Render state
103struct RenderState {
104    config: Config,
105    participants: Vec<Participant>,
106    participant_x: HashMap<String, f64>,
107    participant_widths: HashMap<String, f64>,
108    current_y: f64,
109    activations: HashMap<String, Vec<(f64, Option<f64>)>>,
110    autonumber: Option<u32>,
111    destroyed: HashMap<String, f64>,
112    has_title: bool,
113    total_width: f64,
114    /// Collected block backgrounds for deferred rendering
115    block_backgrounds: Vec<BlockBackground>,
116    /// Collected block labels for deferred rendering (above activations/lifelines)
117    block_labels: Vec<BlockLabel>,
118    /// Footer style from diagram options
119    footer_style: FooterStyle,
120    /// Tracks whether the first return message in an else branch needs extra spacing
121    else_return_pending: Vec<bool>,
122    /// Tracks whether a serial block needs extra spacing after its first row
123    serial_first_row_pending: Vec<bool>,
124    /// Tracks nested parallel depth for serial row spacing
125    parallel_depth: usize,
126    /// Tracks message label bounding boxes to avoid overlap
127    message_label_boxes: Vec<LabelBox>,
128    /// Collected destroy X marks for deferred rendering (above activations)
129    destroy_marks: Vec<(f64, f64)>,
130}
131
132// ============================================
133// Text width calculation
134// ============================================
135const TEXT_WIDTH_PADDING: f64 = 41.0;
136const TEXT_WIDTH_SCALE: f64 = 1.3;
137const MESSAGE_WIDTH_PADDING: f64 = 4.0;
138const MESSAGE_WIDTH_SCALE: f64 = 0.82;
139
140// ============================================
141// Group Spacing (unified element spacing)
142// ============================================
143// All element spacing is controlled by this single function.
144// "Group" = visual unit (text + arrow/loop/box)
145// Spacing = gap between visual bottom of one group and visual top of next group
146fn group_spacing(config: &Config) -> f64 {
147    config.row_height  // 32px by default
148}
149
150// ============================================
151// Message
152// ============================================
153const MESSAGE_TEXT_ABOVE_ARROW: f64 = 6.0;       // Text is rendered 6px above arrow
154const DELAY_UNIT: f64 = 18.0;                    // Pixels per delay unit
155
156// ============================================
157// Block (alt, opt, loop, etc.)
158// ============================================
159const BLOCK_LABEL_HEIGHT: f64 = 22.0;            // Pentagon label height
160const ELEMENT_PADDING: f64 = 8.0;                // Unified internal padding for all elements
161
162// ============================================
163// Note
164// ============================================
165const NOTE_MARGIN: f64 = 10.0;                   // Margin between note and lifeline
166const NOTE_FOLD_SIZE: f64 = 8.0;                 // Corner fold size
167const NOTE_LINE_HEIGHT: f64 = 17.0;              // Line height (font 13px + 4px)
168const NOTE_MIN_WIDTH: f64 = 50.0;                // Minimum width
169
170// ============================================
171// State box
172// ============================================
173const STATE_LINE_HEIGHT_EXTRA: f64 = 11.0;       // Extra line height for state
174const STATE_PADDING: f64 = 12.0;                 // Padding inside state box (left/right/top/bottom)
175
176// ============================================
177// Ref box
178// ============================================
179const REF_LINE_HEIGHT_EXTRA: f64 = 16.0;         // Extra line height for ref
180
181// ============================================
182// Message label collision avoidance
183// ============================================
184const MESSAGE_LABEL_COLLISION_PADDING: f64 = 2.0;
185const MESSAGE_LABEL_COLLISION_STEP_RATIO: f64 = 0.9;
186const MESSAGE_LABEL_ASCENT_FACTOR: f64 = 0.8;
187const MESSAGE_LABEL_DESCENT_FACTOR: f64 = 0.2;
188
189fn block_header_space(config: &Config, _depth: usize) -> f64 {
190    // Pentagon height + group spacing + text offset absorption
191    BLOCK_LABEL_HEIGHT + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
192}
193
194fn block_frame_shift(_depth: usize) -> f64 {
195    0.0
196}
197
198fn block_footer_padding(_config: &Config, _depth: usize) -> f64 {
199    ELEMENT_PADDING
200}
201
202fn block_else_before(_config: &Config, _depth: usize) -> f64 {
203    ELEMENT_PADDING
204}
205
206fn block_else_after(config: &Config, _depth: usize) -> f64 {
207    // Space after else line to match alt header spacing
208    // Else label text is rendered at else_y + 16 (label_text_offset)
209    // Need space for label text height + group spacing + message text offset
210    16.0 + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
211}
212
213fn self_message_spacing(config: &Config, lines: usize) -> f64 {
214    let line_height = config.font_size + 4.0;
215    let text_block_height = lines as f64 * line_height;
216    let loop_height = text_block_height.max(25.0);
217    // Group height (loop) + group spacing + next message text offset
218    // Next message text is rendered MESSAGE_TEXT_ABOVE_ARROW above arrow,
219    // so we add that offset to maintain consistent visual spacing
220    loop_height + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
221}
222
223fn note_line_height(_config: &Config) -> f64 {
224    // Simplified: fixed value (font 13px + padding 4px = 17px)
225    NOTE_LINE_HEIGHT
226}
227
228fn note_padding(_config: &Config) -> f64 {
229    ELEMENT_PADDING
230}
231
232fn item_pre_gap(config: &Config) -> f64 {
233    config.font_size + 1.0
234}
235
236fn item_pre_shift(config: &Config) -> f64 {
237    (config.row_height - item_pre_gap(config)).max(0.0)
238}
239
240fn label_boxes_overlap(x_min: f64, x_max: f64, y_min: f64, y_max: f64, other: &LabelBox) -> bool {
241    let x_overlap = x_max >= other.x_min - MESSAGE_LABEL_COLLISION_PADDING
242        && x_min <= other.x_max + MESSAGE_LABEL_COLLISION_PADDING;
243    let y_overlap = y_max >= other.y_min - MESSAGE_LABEL_COLLISION_PADDING
244        && y_min <= other.y_max + MESSAGE_LABEL_COLLISION_PADDING;
245    x_overlap && y_overlap
246}
247
248fn actor_footer_extra(_participants: &[Participant], _config: &Config) -> f64 {
249    // Actor names are now rendered within the header, so no extra footer space needed
250    0.0
251}
252
253fn serial_first_row_gap(_parallel_depth: usize) -> f64 {
254    // Simplified: always 0 (no fine-tuning needed)
255    0.0
256}
257
258fn state_line_height(config: &Config) -> f64 {
259    config.font_size + STATE_LINE_HEIGHT_EXTRA
260}
261
262fn ref_line_height(config: &Config) -> f64 {
263    config.font_size + REF_LINE_HEIGHT_EXTRA
264}
265
266// ============================================
267// Common Y advancement functions
268// These are the single source of truth for Y position calculations.
269// Used by: collect_block_backgrounds, calculate_height, render_*
270// ============================================
271
272/// Calculate Y advancement for a regular (non-self) message
273fn regular_message_y_advance(config: &Config, line_count: usize, delay_offset: f64) -> f64 {
274    // Use actual line_height (font_size + 4) to match text rendering
275    let line_height = config.font_size + 4.0;
276    let extra_height = if line_count > 1 {
277        // Extra lines + MESSAGE_TEXT_ABOVE_ARROW offset (text is rendered above arrow)
278        (line_count - 1) as f64 * line_height + MESSAGE_TEXT_ABOVE_ARROW
279    } else {
280        0.0
281    };
282    // Group spacing + multiline extra + delay
283    // For regular messages, text is above arrow on both sides, so offsets cancel out
284    group_spacing(config) + extra_height + delay_offset
285}
286
287/// Calculate Y advancement for a self-message
288fn self_message_y_advance(config: &Config, line_count: usize) -> f64 {
289    self_message_spacing(config, line_count)
290}
291
292/// Calculate Y advancement for a note
293fn note_y_advance(config: &Config, line_count: usize) -> f64 {
294    let note_height = note_padding(config) * 2.0 + line_count as f64 * note_line_height(config);
295    // Group height (note box) + group spacing + next message text offset
296    note_height.max(group_spacing(config)) + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
297}
298
299/// Calculate Y advancement for a state box
300fn state_y_advance(config: &Config, line_count: usize) -> f64 {
301    let box_height = STATE_PADDING * 2.0 + line_count as f64 * state_line_height(config);
302    // Group height (state box) + group spacing
303    // Note: Don't add MESSAGE_TEXT_ABOVE_ARROW here because states use item_pre_shift
304    // which already accounts for visual alignment with messages
305    box_height + group_spacing(config)
306}
307
308/// Calculate Y advancement for a ref box
309fn ref_y_advance(config: &Config, line_count: usize) -> f64 {
310    // Include label tab height + content area
311    let box_height = BLOCK_LABEL_HEIGHT + ELEMENT_PADDING * 2.0 + line_count as f64 * ref_line_height(config);
312    // Group height (ref box) + group spacing + next message text offset
313    box_height + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
314}
315
316/// Calculate Y advancement for a description
317fn description_y_advance(config: &Config, line_count: usize) -> f64 {
318    let line_height = config.font_size + 4.0;
319    // Group height (text block) + group spacing + next message text offset
320    line_count as f64 * line_height + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
321}
322
323/// Calculate Y advancement for a block end (footer + row margin)
324fn block_end_y_advance(config: &Config, depth: usize) -> f64 {
325    block_footer_padding(config, depth) + group_spacing(config)
326}
327
328/// Arrowhead size constant
329const ARROWHEAD_SIZE: f64 = 10.0;
330
331/// Generate arrowhead polygon points for a given end position and direction
332fn arrowhead_points(x: f64, y: f64, direction: f64) -> String {
333    let size = ARROWHEAD_SIZE;
334    let half_width = size * 0.35;
335
336    // Tip of the arrow
337    let tip_x = x;
338    let tip_y = y;
339
340    // Back points of the arrow (rotated by direction)
341    let back_x = x - size * direction.cos();
342    let back_y = y - size * direction.sin();
343
344    // Perpendicular offset for the two back points
345    let perp_x = -direction.sin() * half_width;
346    let perp_y = direction.cos() * half_width;
347
348    format!(
349        "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
350        back_x + perp_x,
351        back_y + perp_y,
352        tip_x,
353        tip_y,
354        back_x - perp_x,
355        back_y - perp_y
356    )
357}
358
359/// Calculate direction angle from (x1, y1) to (x2, y2)
360fn arrow_direction(x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
361    (y2 - y1).atan2(x2 - x1)
362}
363
364fn block_has_frame(kind: &BlockKind) -> bool {
365    !matches!(kind, BlockKind::Parallel | BlockKind::Serial)
366}
367
368fn block_is_parallel(kind: &BlockKind) -> bool {
369    matches!(kind, BlockKind::Parallel)
370}
371
372fn parallel_needs_gap(items: &[Item]) -> bool {
373    items.iter().any(|item| matches!(item, Item::Block { .. }))
374}
375
376fn text_char_weight(c: char) -> f64 {
377    if c.is_ascii() {
378        if c.is_uppercase() {
379            0.7
380        } else {
381            0.5
382        }
383    } else {
384        1.0 // CJK and other characters are wider
385    }
386}
387
388/// Character width for participant box calculation (WSD proportional font metrics)
389/// Based on analysis of WSD SVG glyph definitions and actual output comparison
390fn participant_char_width(c: char) -> f64 {
391    match c {
392        // Very wide: W, M, m, w, @
393        'W' | 'w' => 14.0,
394        'M' | 'm' => 12.5,
395        '@' | '%' => 14.0,
396        // Wide uppercase
397        'A' | 'B' | 'C' | 'D' | 'E' | 'G' | 'H' | 'K' | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'X' | 'Y' | 'Z' => 12.0,
398        // Narrow uppercase
399        'F' | 'I' | 'J' | 'L' => 7.0,
400        // Wide lowercase
401        'o' | 'e' | 'a' | 'n' | 'u' | 'v' | 'x' | 'z' | 'b' | 'd' | 'g' | 'h' | 'k' | 'p' | 'q' | 's' | 'c' | 'y' => 8.5,
402        // Narrow lowercase
403        'i' | 'j' | 'l' => 4.0,
404        't' | 'f' | 'r' => 6.0,
405        // Punctuation and special chars (WSD uses wider glyphs for these)
406        ':' => 6.5,
407        '-' | '_' => 7.0,
408        '[' | ']' | '(' | ')' | '{' | '}' => 7.0,
409        '.' | ',' | '\'' | '`' | ';' => 4.0,
410        ' ' => 5.0,
411        // Numbers
412        '0'..='9' => 9.0,
413        // Default for other ASCII
414        _ if c.is_ascii() => 8.5,
415        // CJK and other characters
416        _ => 14.0,
417    }
418}
419
420/// Calculate participant box width based on WSD proportional font metrics
421fn calculate_participant_width(name: &str, min_width: f64) -> f64 {
422    let lines: Vec<&str> = name.split("\\n").collect();
423    let max_line_width = lines
424        .iter()
425        .map(|line| line.chars().map(participant_char_width).sum::<f64>())
426        .fold(0.0_f64, |a, b| a.max(b));
427
428    // WSD uses consistent padding for all participant boxes
429    let padding = 50.0;
430
431    (max_line_width + padding).max(min_width)
432}
433
434fn max_weighted_line(text: &str) -> f64 {
435    text.split("\\n")
436        .map(|line| line.chars().map(text_char_weight).sum::<f64>())
437        .fold(0.0_f64, |a, b| a.max(b))
438}
439
440/// Estimate text width in pixels (rough approximation)
441fn estimate_text_width(text: &str, font_size: f64) -> f64 {
442    let weighted = max_weighted_line(text);
443    weighted * font_size * TEXT_WIDTH_SCALE + TEXT_WIDTH_PADDING
444}
445
446fn estimate_message_width(text: &str, font_size: f64) -> f64 {
447    let weighted = max_weighted_line(text);
448    weighted * font_size * MESSAGE_WIDTH_SCALE + MESSAGE_WIDTH_PADDING
449}
450
451fn block_tab_width(kind: &str) -> f64 {
452    (kind.chars().count() as f64 * 12.0 + 21.0).max(57.0)
453}
454
455/// Calculate note width based on text content (using note font size of 13px)
456fn calculate_note_width(text: &str, _config: &Config) -> f64 {
457    // Use NOTE_LINE_HEIGHT - 4.0 as the effective font size (13px)
458    let note_font_size = NOTE_LINE_HEIGHT - 4.0;
459    let text_width = estimate_text_width(text, note_font_size);
460    (ELEMENT_PADDING * 2.0 + text_width).max(NOTE_MIN_WIDTH)
461}
462
463/// Calculate required right margin based on right-side notes on the rightmost participant only
464fn calculate_right_margin(
465    participants: &[Participant],
466    items: &[Item],
467    config: &Config,
468) -> f64 {
469    let rightmost_id = match participants.last() {
470        Some(p) => p.id().to_string(),
471        None => return config.right_margin,
472    };
473    let mut max_right_note_width: f64 = 0.0;
474
475    fn process_items_for_right_notes(
476        items: &[Item],
477        rightmost_id: &str,
478        max_width: &mut f64,
479        config: &Config,
480    ) {
481        for item in items {
482            match item {
483                Item::Note {
484                    position: NotePosition::Right,
485                    participants,
486                    text,
487                } => {
488                    // Only consider notes on the rightmost participant
489                    if participants.first().map(|s| s.as_str()) == Some(rightmost_id) {
490                        let note_width = calculate_note_width(text, config);
491                        if note_width > *max_width {
492                            *max_width = note_width;
493                        }
494                    }
495                }
496                Item::Block {
497                    items, else_sections, ..
498                } => {
499                    process_items_for_right_notes(items, rightmost_id, max_width, config);
500                    for section in else_sections {
501                        process_items_for_right_notes(&section.items, rightmost_id, max_width, config);
502                    }
503                }
504                _ => {}
505            }
506        }
507    }
508
509    process_items_for_right_notes(items, &rightmost_id, &mut max_right_note_width, config);
510
511    // right_margin needs to accommodate: NOTE_MARGIN + note_width
512    if max_right_note_width > 0.0 {
513        (max_right_note_width + NOTE_MARGIN).max(config.right_margin)
514    } else {
515        config.right_margin
516    }
517}
518
519/// Calculate required left margin based on left-side notes and self-message text
520fn calculate_left_margin(
521    participants: &[Participant],
522    items: &[Item],
523    config: &Config,
524) -> f64 {
525    let leftmost_id = match participants.first() {
526        Some(p) => p.id().to_string(),
527        None => return config.padding,
528    };
529    let mut max_left_note_width: f64 = 0.0;
530    let mut max_self_msg_text_width: f64 = 0.0;
531
532    fn process_items_for_left_margin(
533        items: &[Item],
534        leftmost_id: &str,
535        max_note_width: &mut f64,
536        max_self_msg_width: &mut f64,
537        config: &Config,
538    ) {
539        for item in items {
540            match item {
541                Item::Note {
542                    position: NotePosition::Left,
543                    participants,
544                    text,
545                } => {
546                    // Only consider notes on the leftmost participant
547                    if participants.first().map(|s| s.as_str()) == Some(leftmost_id) {
548                        let note_width = calculate_note_width(text, config);
549                        if note_width > *max_note_width {
550                            *max_note_width = note_width;
551                        }
552                    }
553                }
554                Item::Message { from, to, text, .. } => {
555                    // Self-message text extends to the left
556                    if from == to && from == leftmost_id {
557                        let text_width = estimate_message_width(text, config.font_size);
558                        if text_width > *max_self_msg_width {
559                            *max_self_msg_width = text_width;
560                        }
561                    }
562                }
563                Item::Block {
564                    items, else_sections, ..
565                } => {
566                    process_items_for_left_margin(items, leftmost_id, max_note_width, max_self_msg_width, config);
567                    for section in else_sections {
568                        process_items_for_left_margin(&section.items, leftmost_id, max_note_width, max_self_msg_width, config);
569                    }
570                }
571                _ => {}
572            }
573        }
574    }
575
576    process_items_for_left_margin(items, &leftmost_id, &mut max_left_note_width, &mut max_self_msg_text_width, config);
577
578    // left_margin needs to accommodate both note width and self-message text width
579    let note_margin = if max_left_note_width > 0.0 {
580        max_left_note_width + NOTE_MARGIN
581    } else {
582        0.0
583    };
584    // Self-message text: text is right-aligned at (participant_x - 5), extending left
585    // text_left_edge = left_margin + participant_width/2 - 5 - text_width
586    //                = left_margin + 46 - 5 - text_width = left_margin + 41 - text_width
587    // For text_left_edge >= padding: left_margin >= padding + text_width - 41
588    let self_msg_margin = if max_self_msg_text_width > 0.0 {
589        (max_self_msg_text_width - 41.0 + config.padding).max(0.0)
590    } else {
591        0.0
592    };
593
594    note_margin.max(self_msg_margin).max(config.padding)
595}
596
597/// Calculate dynamic gaps between participants based on message text lengths
598fn calculate_participant_gaps(
599    participants: &[Participant],
600    items: &[Item],
601    config: &Config,
602) -> Vec<f64> {
603    if participants.len() <= 1 {
604        return vec![];
605    }
606
607    // Create a map from participant id to index
608    let mut participant_index: HashMap<String, usize> = HashMap::new();
609    for (i, p) in participants.iter().enumerate() {
610        participant_index.insert(p.id().to_string(), i);
611    }
612
613    // Initialize gaps with WSD-compatible minimum gap
614    // WSD uses ~59px center-to-center for simple diagrams
615    let min_gap = config.participant_gap;
616    let mut gaps: Vec<f64> = vec![min_gap; participants.len() - 1];
617
618    // Calculate max text width for each adjacent pair
619    fn process_items(
620        items: &[Item],
621        participant_index: &HashMap<String, usize>,
622        gaps: &mut Vec<f64>,
623        config: &Config,
624    ) {
625        for item in items {
626            match item {
627                Item::Message { from, to, text, arrow, .. } => {
628                    if let (Some(&from_idx), Some(&to_idx)) =
629                        (participant_index.get(from), participant_index.get(to))
630                    {
631                        if from_idx != to_idx {
632                            let (min_idx, max_idx) = if from_idx < to_idx {
633                                (from_idx, to_idx)
634                            } else {
635                                (to_idx, from_idx)
636                            };
637
638                            let text_width = estimate_message_width(text, config.font_size);
639
640                            // WSD: delay messages need extra horizontal space for diagonal lines
641                            // Delay coefficient 86.4 for WSD gap matching (645px for delay(7))
642                            let delay_extra = arrow.delay.map(|d| d as f64 * 86.4).unwrap_or(0.0);
643
644                            // Ensure message text fits between participants with padding
645                            // Account for activation bars (8px width on each side) and text centering
646                            let gap_count = (max_idx - min_idx) as f64;
647                            let activation_space = 40.0; // Space for activation bars on both sides
648                            let text_padding = 40.0; // Padding on each side of text
649                            let needed_gap = if gap_count == 1.0 {
650                                // Adjacent: text width plus padding plus activation bar space
651                                text_width + text_padding + activation_space + delay_extra
652                            } else {
653                                // Non-adjacent: distribute evenly with margin
654                                (text_width + text_padding + activation_space) / gap_count + delay_extra
655                            };
656
657                            // Update gaps between the participants
658                            for gap_idx in min_idx..max_idx {
659                                if needed_gap > gaps[gap_idx] {
660                                    gaps[gap_idx] = needed_gap;
661                                }
662                            }
663                        }
664                    }
665                }
666                Item::Note {
667                    position,
668                    participants: note_participants,
669                    text,
670                } => {
671                    // Calculate note width
672                    let note_width = calculate_note_width(text, config);
673
674                    if let Some(participant) = note_participants.first() {
675                        if let Some(&idx) = participant_index.get(participant) {
676                            match position {
677                                NotePosition::Left => {
678                                    // Left note: needs space between left neighbor
679                                    if idx > 0 {
680                                        // Need gap for note width + margins
681                                        let needed_gap = note_width + NOTE_MARGIN * 2.0;
682                                        if needed_gap > gaps[idx - 1] {
683                                            gaps[idx - 1] = needed_gap;
684                                        }
685                                    }
686                                }
687                                NotePosition::Right => {
688                                    // Right note: needs space between right neighbor
689                                    if idx < gaps.len() {
690                                        let needed_gap = note_width + NOTE_MARGIN * 2.0;
691                                        if needed_gap > gaps[idx] {
692                                            gaps[idx] = needed_gap;
693                                        }
694                                    }
695                                }
696                                NotePosition::Over => {
697                                    // Over note: only process if spanning multiple participants
698                                    // Single participant case is fine as long as width doesn't exceed
699                                }
700                            }
701                        }
702                    }
703                }
704                Item::Block {
705                    items, else_sections, ..
706                } => {
707                    process_items(items, participant_index, gaps, config);
708                    for section in else_sections {
709                        process_items(&section.items, participant_index, gaps, config);
710                    }
711                }
712                _ => {}
713            }
714        }
715    }
716
717    process_items(items, &participant_index, &mut gaps, config);
718
719    // WSD: participant name lengths don't directly increase gaps
720    // The participant box widths (already calculated elsewhere) handle this
721    // No additional gap increase needed for names
722
723    // Cap maximum gap (WSD allows up to ~645px for long messages)
724    let max_gap = 645.0;
725    for gap in &mut gaps {
726        if *gap > max_gap {
727            *gap = max_gap;
728        }
729    }
730
731    gaps
732}
733
734impl RenderState {
735    fn new(
736        config: Config,
737        participants: Vec<Participant>,
738        items: &[Item],
739        has_title: bool,
740        footer_style: FooterStyle,
741    ) -> Self {
742        let mut config = config;
743        // WSD header height calculation:
744        // - 1 line: 46px
745        // - 2+ lines: 108px (WSD caps at 108px regardless of line count)
746        // - Actor: ~108px for 2-line names
747        let mut required_header_height = config.header_height;
748        for p in &participants {
749            let lines = p.name.split("\\n").count();
750            let needed = match p.kind {
751                ParticipantKind::Participant => {
752                    // WSD: 46px for 1 line, 108px for 2+ lines (capped)
753                    if lines <= 1 {
754                        46.0
755                    } else {
756                        108.0 // WSD uses fixed 108px for multi-line
757                    }
758                }
759                ParticipantKind::Actor => {
760                    // WSD: Actor has stick figure + name below
761                    // ~85px for 1-line, ~108px for 2+ lines
762                    if lines <= 1 {
763                        85.0
764                    } else {
765                        108.0
766                    }
767                }
768            };
769            if needed > required_header_height {
770                required_header_height = needed;
771            }
772        }
773        if required_header_height > config.header_height {
774            config.header_height = required_header_height;
775        }
776        // Calculate individual participant widths based on their names
777        // Using WSD proportional font metrics for accurate box widths
778        let mut participant_widths: HashMap<String, f64> = HashMap::new();
779        let min_width = config.participant_width;
780
781        for p in &participants {
782            let width = calculate_participant_width(&p.name, min_width);
783            participant_widths.insert(p.id().to_string(), width);
784        }
785
786        let gaps = calculate_participant_gaps(&participants, items, &config);
787
788        // Left margin for notes on leftmost participant (dynamic)
789        let left_margin = calculate_left_margin(&participants, items, &config);
790        // Right margin for self-loops and notes on rightmost participant (dynamic)
791        let right_margin = calculate_right_margin(&participants, items, &config);
792
793        let mut participant_x = HashMap::new();
794        let first_width = participants
795            .first()
796            .map(|p| *participant_widths.get(p.id()).unwrap_or(&min_width))
797            .unwrap_or(min_width);
798        let mut current_x = config.padding + left_margin + first_width / 2.0;
799
800        for (i, p) in participants.iter().enumerate() {
801            participant_x.insert(p.id().to_string(), current_x);
802            if i < gaps.len() {
803                let current_width = *participant_widths.get(p.id()).unwrap_or(&min_width);
804                let next_p = participants.get(i + 1);
805                let next_width = next_p
806                    .map(|np| *participant_widths.get(np.id()).unwrap_or(&min_width))
807                    .unwrap_or(min_width);
808
809                // WSD: Actor doesn't have a header box, so it takes less horizontal space
810                // Reduce gap when current or next participant is an Actor
811                let current_is_actor = p.kind == ParticipantKind::Actor;
812                let next_is_actor = next_p.map(|np| np.kind == ParticipantKind::Actor).unwrap_or(false);
813
814                // Note: Actor gap reduction disabled - it changes total width
815                // WSD and OSD have different actor placement algorithms
816                let actor_gap_reduction = 0.0;
817                let _ = (current_is_actor, next_is_actor); // suppress warnings
818
819                // WSD: edge-to-edge gap varies by message density
820                // Variable edge padding: more messages = more edge padding
821                let calculated_gap = gaps[i] - actor_gap_reduction;
822
823                // Determine edge padding based on message density and participant types
824                // WSD uses variable edge padding based on content
825                let half_widths = (current_width + next_width) / 2.0;
826                let neither_is_actor = !current_is_actor && !next_is_actor;
827
828                let either_is_actor = current_is_actor || next_is_actor;
829                let edge_padding = if calculated_gap > 500.0 {
830                    // Very high (delay messages): minimal extra padding
831                    10.0
832                } else if either_is_actor && calculated_gap > 130.0 {
833                    // Actor-adjacent gaps: WSD uses tighter spacing around actors
834                    33.0
835                } else if neither_is_actor && half_widths > 155.0 && calculated_gap > 130.0 {
836                    // Two large normal boxes with medium traffic: extra padding
837                    90.0
838                } else if calculated_gap > 130.0 {
839                    // Medium-high traffic: WSD uses ~49px for these gaps
840                    49.0
841                } else if calculated_gap > config.participant_gap {
842                    // Medium traffic: moderate padding
843                    25.0
844                } else {
845                    // Low traffic: edge_padding depends on individual participant widths
846                    let max_width = current_width.max(next_width);
847                    let min_width_val = current_width.min(next_width);
848                    let width_diff = max_width - min_width_val;
849
850                    if max_width > 160.0 && min_width_val > 160.0 {
851                        // Both participants are very wide (>160): small positive padding
852                        // WSD UserDB→Cache: both 161.2, gap=163, ep≈1.8
853                        1.8
854                    } else if max_width > 160.0 && min_width_val > 140.0 {
855                        // One very wide, one large: negative padding
856                        // WSD ML→Notify: max=161.2, min=149.6, gap=148.5, ep≈-7
857                        -7.0
858                    } else if max_width > 160.0 && min_width_val < 110.0 {
859                        // One very wide, one small: large positive padding
860                        // WSD Cache→Kafka: max=161.2, min=103.2, gap=143.5, ep≈11.3
861                        11.3
862                    } else if max_width > 160.0 && width_diff > 45.0 {
863                        // One very wide, one medium-small: negative padding
864                        // WSD Notify→Payment: max=161.2, min=114.8, diff=46.4, gap=132, ep≈-6
865                        -6.0
866                    } else if min_width_val < 115.0 {
867                        // One small participant: moderate padding
868                        // WSD Kafka→ML, Payment→Worker
869                        10.0
870                    } else {
871                        // Medium participants: moderate padding
872                        11.0
873                    }
874                };
875
876                let min_center_gap = (current_width + next_width) / 2.0 + edge_padding - actor_gap_reduction;
877                let actual_gap = calculated_gap.max(min_center_gap).max(60.0);
878                current_x += actual_gap;
879            }
880        }
881
882        let last_width = participants
883            .last()
884            .map(|p| *participant_widths.get(p.id()).unwrap_or(&min_width))
885            .unwrap_or(min_width);
886        let total_width = current_x + last_width / 2.0 + right_margin + config.padding;
887
888        Self {
889            config,
890            participants,
891            participant_x,
892            participant_widths,
893            current_y: 0.0,
894            activations: HashMap::new(),
895            autonumber: None,
896            destroyed: HashMap::new(),
897            has_title,
898            total_width,
899            block_backgrounds: Vec::new(),
900            block_labels: Vec::new(),
901            footer_style,
902            else_return_pending: Vec::new(),
903            serial_first_row_pending: Vec::new(),
904            parallel_depth: 0,
905            message_label_boxes: Vec::new(),
906            destroy_marks: Vec::new(),
907        }
908    }
909
910    fn get_participant_width(&self, name: &str) -> f64 {
911        *self
912            .participant_widths
913            .get(name)
914            .unwrap_or(&self.config.participant_width)
915    }
916
917    fn get_x(&self, name: &str) -> f64 {
918        // Handle boundary markers for gate/found/lost messages
919        if name == "[" {
920            return self.config.padding;
921        }
922        if name == "]" {
923            return self.total_width - self.config.padding;
924        }
925        *self.participant_x.get(name).unwrap_or(&0.0)
926    }
927
928    fn push_else_return_pending(&mut self) {
929        self.else_return_pending.push(true);
930    }
931
932    fn pop_else_return_pending(&mut self) {
933        self.else_return_pending.pop();
934    }
935
936    fn apply_else_return_gap(&mut self, arrow: &Arrow) {
937        if let Some(pending) = self.else_return_pending.last_mut() {
938            if *pending && matches!(arrow.line, LineStyle::Dashed) {
939                // Simplified: removed fine-tuning after else return
940                *pending = false;
941            }
942        }
943    }
944
945    fn push_serial_first_row_pending(&mut self) {
946        self.serial_first_row_pending.push(true);
947    }
948
949    fn pop_serial_first_row_pending(&mut self) {
950        self.serial_first_row_pending.pop();
951    }
952
953    fn apply_serial_first_row_gap(&mut self) {
954        if let Some(pending) = self.serial_first_row_pending.last_mut() {
955            if *pending {
956                self.current_y += serial_first_row_gap(self.parallel_depth);
957                *pending = false;
958            }
959        }
960    }
961
962    fn reserve_message_label(
963        &mut self,
964        x_min: f64,
965        x_max: f64,
966        mut y_min: f64,
967        mut y_max: f64,
968        step: f64,
969    ) -> f64 {
970        // Only check collision with boxes that could actually overlap
971        // Skip boxes whose y_max is significantly above our y_min (they're in previous rows)
972        let relevance_threshold = step * 2.0;
973        let relevant_boxes: Vec<&LabelBox> = self
974            .message_label_boxes
975            .iter()
976            .filter(|b| b.y_max + relevance_threshold >= y_min)
977            .collect();
978
979        let mut offset = 0.0;
980        let mut attempts = 0;
981        while relevant_boxes
982            .iter()
983            .any(|b| label_boxes_overlap(x_min, x_max, y_min, y_max, b))
984            && attempts < 20
985        {
986            y_min += step;
987            y_max += step;
988            offset += step;
989            attempts += 1;
990        }
991        self.message_label_boxes.push(LabelBox {
992            x_min,
993            x_max,
994            y_min,
995            y_max,
996        });
997        offset
998    }
999
1000    fn push_parallel(&mut self) {
1001        self.parallel_depth += 1;
1002    }
1003
1004    fn pop_parallel(&mut self) {
1005        if self.parallel_depth > 0 {
1006            self.parallel_depth -= 1;
1007        }
1008    }
1009
1010    /// Check if a participant has an active activation at the given Y position
1011    fn is_participant_active_at(&self, participant: &str, y: f64) -> bool {
1012        if let Some(acts) = self.activations.get(participant) {
1013            acts.iter().any(|(start_y, end_y)| {
1014                *start_y <= y && end_y.map_or(true, |end| y <= end)
1015            })
1016        } else {
1017            false
1018        }
1019    }
1020
1021    /// Get arrow start X position, accounting for activation bar
1022    fn get_arrow_start_x(&self, participant: &str, y: f64, going_right: bool) -> f64 {
1023        let x = self.get_x(participant);
1024        if self.is_participant_active_at(participant, y) {
1025            let half_width = self.config.activation_width / 2.0;
1026            if going_right {
1027                x + half_width // Arrow starts from right edge of activation bar
1028            } else {
1029                x - half_width // Arrow starts from left edge of activation bar
1030            }
1031        } else {
1032            x
1033        }
1034    }
1035
1036    /// Get arrow end X position, accounting for activation bar
1037    fn get_arrow_end_x(&self, participant: &str, y: f64, coming_from_right: bool) -> f64 {
1038        let x = self.get_x(participant);
1039        if self.is_participant_active_at(participant, y) {
1040            let half_width = self.config.activation_width / 2.0;
1041            if coming_from_right {
1042                x + half_width // Arrow ends at right edge of activation bar
1043            } else {
1044                x - half_width // Arrow ends at left edge of activation bar
1045            }
1046        } else {
1047            x
1048        }
1049    }
1050
1051    fn diagram_width(&self) -> f64 {
1052        // Consider block labels that may extend beyond participant-based width
1053        let max_block_x2 = self
1054            .block_labels
1055            .iter()
1056            .map(|bl| bl.x2)
1057            .fold(0.0f64, |a, b| a.max(b));
1058        // Add right margin for blocks
1059        let block_width = if max_block_x2 > 0.0 {
1060            max_block_x2 + self.config.padding
1061        } else {
1062            0.0
1063        };
1064        self.total_width.max(block_width)
1065    }
1066
1067    /// Get the x position of the leftmost participant
1068    fn leftmost_x(&self) -> f64 {
1069        self.participants
1070            .first()
1071            .map(|p| self.get_x(p.id()))
1072            .unwrap_or(self.config.padding)
1073    }
1074
1075    /// Get the x position of the rightmost participant
1076    fn rightmost_x(&self) -> f64 {
1077        self.participants
1078            .last()
1079            .map(|p| self.get_x(p.id()))
1080            .unwrap_or(self.total_width - self.config.padding)
1081    }
1082
1083    /// Get block left boundary (based on leftmost participant)
1084    fn block_left(&self) -> f64 {
1085        let leftmost_width = self
1086            .participants
1087            .first()
1088            .map(|p| self.get_participant_width(p.id()))
1089            .unwrap_or(self.config.participant_width);
1090        self.leftmost_x() - leftmost_width / 2.0 - self.config.block_margin
1091    }
1092
1093    /// Get block right boundary (based on rightmost participant)
1094    fn block_right(&self) -> f64 {
1095        let rightmost_width = self
1096            .participants
1097            .last()
1098            .map(|p| self.get_participant_width(p.id()))
1099            .unwrap_or(self.config.participant_width);
1100        self.rightmost_x() + rightmost_width / 2.0 + self.config.block_margin
1101    }
1102
1103    fn header_top(&self) -> f64 {
1104        if self.has_title {
1105            self.config.padding + self.config.title_height
1106        } else {
1107            self.config.padding
1108        }
1109    }
1110
1111    fn content_start(&self) -> f64 {
1112        // WSD first message Y: 250.5
1113        // header_top (110.5) + header_height (108) + row_height (32) = 250.5
1114        self.header_top() + self.config.header_height + self.config.row_height
1115    }
1116
1117    fn next_number(&mut self) -> Option<u32> {
1118        self.autonumber.map(|n| {
1119            self.autonumber = Some(n + 1);
1120            n
1121        })
1122    }
1123
1124    /// Add a block background to be rendered later
1125    fn add_block_background(&mut self, x: f64, y: f64, width: f64, height: f64) {
1126        self.block_backgrounds.push(BlockBackground {
1127            x,
1128            y,
1129            width,
1130            height,
1131        });
1132    }
1133
1134    /// Add a block label to be rendered later (above activations/lifelines)
1135    fn add_block_label(
1136        &mut self,
1137        x1: f64,
1138        start_y: f64,
1139        end_y: f64,
1140        x2: f64,
1141        kind: &str,
1142        label: &str,
1143        else_sections: Vec<(f64, Option<String>)>,
1144    ) {
1145        self.block_labels.push(BlockLabel {
1146            x1,
1147            start_y,
1148            end_y,
1149            x2,
1150            kind: kind.to_string(),
1151            label: label.to_string(),
1152            else_sections,
1153        });
1154    }
1155}
1156
1157/// Find participants involved in a list of items (returns min/max edges and whether leftmost is included)
1158fn find_involved_participants(items: &[Item], state: &RenderState) -> Option<(f64, f64, bool)> {
1159    let mut min_left: Option<f64> = None;
1160    let mut max_right: Option<f64> = None;
1161    let leftmost_id = state.participants.first().map(|p| p.id()).unwrap_or("");
1162    let mut includes_leftmost = false;
1163
1164    fn update_bounds(
1165        participant: &str,
1166        state: &RenderState,
1167        min_left: &mut Option<f64>,
1168        max_right: &mut Option<f64>,
1169        includes_leftmost: &mut bool,
1170        leftmost_id: &str,
1171    ) {
1172        let x = state.get_x(participant);
1173        if x > 0.0 {
1174            let width = state.get_participant_width(participant);
1175            let left = x - width / 2.0;
1176            let right = x + width / 2.0;
1177            *min_left = Some(min_left.map_or(left, |m| m.min(left)));
1178            *max_right = Some(max_right.map_or(right, |m| m.max(right)));
1179            if participant == leftmost_id {
1180                *includes_leftmost = true;
1181            }
1182        }
1183    }
1184
1185    fn process_items(
1186        items: &[Item],
1187        state: &RenderState,
1188        min_left: &mut Option<f64>,
1189        max_right: &mut Option<f64>,
1190        includes_leftmost: &mut bool,
1191        leftmost_id: &str,
1192    ) {
1193        for item in items {
1194            match item {
1195                Item::Message { from, to, .. } => {
1196                    update_bounds(
1197                        from,
1198                        state,
1199                        min_left,
1200                        max_right,
1201                        includes_leftmost,
1202                        leftmost_id,
1203                    );
1204                    update_bounds(
1205                        to,
1206                        state,
1207                        min_left,
1208                        max_right,
1209                        includes_leftmost,
1210                        leftmost_id,
1211                    );
1212                }
1213                Item::Note { participants, .. } => {
1214                    for p in participants {
1215                        update_bounds(
1216                            p,
1217                            state,
1218                            min_left,
1219                            max_right,
1220                            includes_leftmost,
1221                            leftmost_id,
1222                        );
1223                    }
1224                }
1225                Item::Block {
1226                    items, else_sections, ..
1227                } => {
1228                    process_items(
1229                        items,
1230                        state,
1231                        min_left,
1232                        max_right,
1233                        includes_leftmost,
1234                        leftmost_id,
1235                    );
1236                    for section in else_sections {
1237                        process_items(
1238                            &section.items,
1239                            state,
1240                            min_left,
1241                            max_right,
1242                            includes_leftmost,
1243                            leftmost_id,
1244                        );
1245                    }
1246                }
1247                Item::Activate { participant }
1248                | Item::Deactivate { participant }
1249                | Item::Destroy { participant } => {
1250                    update_bounds(
1251                        participant,
1252                        state,
1253                        min_left,
1254                        max_right,
1255                        includes_leftmost,
1256                        leftmost_id,
1257                    );
1258                }
1259                _ => {}
1260            }
1261        }
1262    }
1263
1264    process_items(
1265        items,
1266        state,
1267        &mut min_left,
1268        &mut max_right,
1269        &mut includes_leftmost,
1270        leftmost_id,
1271    );
1272
1273    match (min_left, max_right) {
1274        (Some(min), Some(max)) => Some((min, max, includes_leftmost)),
1275        _ => None,
1276    }
1277}
1278
1279/// Padding for nested blocks inside parent
1280const NESTED_BLOCK_INSET: f64 = 5.0;
1281
1282/// Calculate block x boundaries based on involved participants and label length
1283fn calculate_block_bounds_with_label(
1284    items: &[Item],
1285    else_sections: &[crate::ast::ElseSection],
1286    label: &str,
1287    kind: &str,
1288    _depth: usize,
1289    state: &RenderState,
1290    parent_bounds: Option<(f64, f64)>,
1291) -> (f64, f64) {
1292    let mut all_items: Vec<&Item> = items.iter().collect();
1293    for section in else_sections {
1294        all_items.extend(section.items.iter());
1295    }
1296
1297    // Convert Vec<&Item> to slice for find_involved_participants
1298    let items_slice: Vec<Item> = all_items.into_iter().cloned().collect();
1299
1300    // If we have parent bounds, apply nested inset as constraint
1301    let (constraint_x1, constraint_x2) = if let Some((px1, px2)) = parent_bounds {
1302        (px1 + NESTED_BLOCK_INSET, px2 - NESTED_BLOCK_INSET)
1303    } else {
1304        (state.block_left(), state.block_right())
1305    };
1306
1307    let (base_x1, base_x2) =
1308        if let Some((min_left, max_right, _includes_leftmost)) =
1309            find_involved_participants(&items_slice, state)
1310        {
1311            let margin = state.config.block_margin;
1312            // Apply parent constraint: nested block must be within parent bounds
1313            let x1 = (min_left - margin).max(constraint_x1);
1314            let x2 = (max_right + margin).min(constraint_x2);
1315            (x1, x2)
1316        } else {
1317            // Fallback to constrained width if no participants found
1318            (constraint_x1, constraint_x2)
1319        };
1320
1321    // Calculate minimum width needed for label
1322    // Pentagon width + gap + condition label width + right margin
1323    let pentagon_width = block_tab_width(kind);
1324    let label_font_size = state.config.font_size - 1.0;
1325    let label_padding_x = 6.0;
1326    let condition_width = if label.is_empty() {
1327        0.0
1328    } else {
1329        let condition_text = format!("[{}]", label);
1330        let base_width =
1331            (estimate_text_width(&condition_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
1332        base_width + label_padding_x * 2.0
1333    };
1334
1335    // Calculate max else label width (else labels start at same X as condition label)
1336    let mut max_else_label_width = 0.0f64;
1337    for section in else_sections {
1338        if let Some(el) = &section.label {
1339            if !el.is_empty() {
1340                let else_text = format!("[{}]", el);
1341                let base_width =
1342                    (estimate_text_width(&else_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
1343                let width = base_width + label_padding_x * 2.0;
1344                max_else_label_width = max_else_label_width.max(width);
1345            }
1346        }
1347    }
1348
1349    // Use the wider of all labels
1350    let max_label_content_width = condition_width.max(max_else_label_width);
1351    let min_label_width = pentagon_width + 8.0 + max_label_content_width + 20.0; // Extra right margin
1352
1353    // Calculate available width within parent constraint
1354    let available_width = constraint_x2 - constraint_x1;
1355
1356    // Ensure block is wide enough for the label, but don't exceed parent bounds
1357    let current_width = base_x2 - base_x1;
1358    let (x1, x2) = if current_width < min_label_width {
1359        // Extend the right side to accommodate the label, but respect parent bounds
1360        let desired_x2 = base_x1 + min_label_width;
1361        if desired_x2 <= constraint_x2 {
1362            (base_x1, desired_x2)
1363        } else if min_label_width <= available_width {
1364            // Shift left to fit within parent
1365            (constraint_x2 - min_label_width, constraint_x2)
1366        } else {
1367            // Label is wider than available space, use full parent width
1368            (constraint_x1, constraint_x2)
1369        }
1370    } else {
1371        (base_x1, base_x2)
1372    };
1373
1374    (x1, x2)
1375}
1376
1377/// Pre-calculate block backgrounds by doing a dry run
1378fn collect_block_backgrounds(
1379    state: &mut RenderState,
1380    items: &[Item],
1381    depth: usize,
1382    active_activation_count: &mut usize,
1383    parent_bounds: Option<(f64, f64)>,
1384) {
1385    for item in items {
1386        match item {
1387            Item::Message {
1388                text,
1389                from,
1390                to,
1391                arrow,
1392                activate,
1393                deactivate,
1394                create,
1395                ..
1396            } => {
1397                state.apply_else_return_gap(arrow);
1398                let is_self = from == to;
1399                let line_count = text.split("\\n").count();
1400                let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
1401
1402                if is_self {
1403                    state.current_y += self_message_y_advance(&state.config, line_count);
1404                } else {
1405                    state.current_y += regular_message_y_advance(&state.config, line_count, delay_offset);
1406                }
1407
1408                if *create {
1409                    state.current_y += state.config.row_height;
1410                }
1411
1412                state.apply_serial_first_row_gap();
1413                if *activate {
1414                    *active_activation_count += 1;
1415                }
1416                if *deactivate && *active_activation_count > 0 {
1417                    *active_activation_count -= 1;
1418                }
1419            }
1420            Item::Note { text, .. } => {
1421                let line_count = text.split("\\n").count();
1422                state.current_y += note_y_advance(&state.config, line_count);
1423            }
1424            Item::State { text, .. } => {
1425                let line_count = text.split("\\n").count();
1426                state.current_y += state_y_advance(&state.config, line_count);
1427            }
1428            Item::Ref { text, .. } => {
1429                let line_count = text.split("\\n").count();
1430                state.current_y += ref_y_advance(&state.config, line_count);
1431            }
1432            Item::Description { text } => {
1433                let line_count = text.split("\\n").count();
1434                state.current_y += description_y_advance(&state.config, line_count);
1435            }
1436            Item::Destroy { .. } => {
1437                state.current_y += state.config.row_height;
1438            }
1439            Item::Activate { .. } => {
1440                *active_activation_count += 1;
1441            }
1442            Item::Deactivate { .. } => {
1443                if *active_activation_count > 0 {
1444                    *active_activation_count -= 1;
1445                }
1446            }
1447            Item::Block {
1448                kind,
1449                label,
1450                items,
1451                else_sections,
1452            } => {
1453                if block_is_parallel(kind) {
1454                    state.push_parallel();
1455                    let start_y = state.current_y;
1456                    let mut max_end_y = start_y;
1457                    let start_activation_count = *active_activation_count;
1458                    for item in items {
1459                        state.current_y = start_y;
1460                        *active_activation_count = start_activation_count;
1461                        collect_block_backgrounds(
1462                            state,
1463                            std::slice::from_ref(item),
1464                            depth,
1465                            active_activation_count,
1466                            parent_bounds,
1467                        );
1468                        if state.current_y > max_end_y {
1469                            max_end_y = state.current_y;
1470                        }
1471                    }
1472                    *active_activation_count = start_activation_count;
1473                    let gap = if parallel_needs_gap(items) {
1474                        state.config.row_height
1475                    } else {
1476                        0.0
1477                    };
1478                    state.current_y = max_end_y + gap;
1479                    state.pop_parallel();
1480                    continue;
1481                }
1482
1483                if matches!(kind, BlockKind::Serial) {
1484                    state.push_serial_first_row_pending();
1485                    collect_block_backgrounds(state, items, depth, active_activation_count, parent_bounds);
1486                    for section in else_sections {
1487                        collect_block_backgrounds(
1488                            state,
1489                            &section.items,
1490                            depth,
1491                            active_activation_count,
1492                            parent_bounds,
1493                        );
1494                    }
1495                    state.pop_serial_first_row_pending();
1496                    continue;
1497                }
1498
1499                if !block_has_frame(kind) {
1500                    collect_block_backgrounds(state, items, depth, active_activation_count, parent_bounds);
1501                    for section in else_sections {
1502                        collect_block_backgrounds(
1503                            state,
1504                            &section.items,
1505                            depth,
1506                            active_activation_count,
1507                            parent_bounds,
1508                        );
1509                    }
1510                    continue;
1511                }
1512
1513                let start_y = state.current_y;
1514                let frame_shift = block_frame_shift(depth);
1515                let frame_start_y = start_y - frame_shift;
1516
1517                // Calculate bounds based on involved participants and label width
1518                let (x1, x2) = calculate_block_bounds_with_label(
1519                    items,
1520                    else_sections,
1521                    label,
1522                    kind.as_str(),
1523                    depth,
1524                    state,
1525                    parent_bounds,
1526                );
1527
1528                state.current_y += block_header_space(&state.config, depth);
1529                // Pass current block bounds as parent bounds to nested blocks
1530                collect_block_backgrounds(state, items, depth + 1, active_activation_count, Some((x1, x2)));
1531
1532                // Process each else section
1533                let mut else_section_info: Vec<(f64, Option<String>)> = Vec::new();
1534                for section in else_sections {
1535                    // Add padding before else line
1536                    state.current_y += block_else_before(&state.config, depth);
1537                    let else_y = state.current_y;
1538                    else_section_info.push((else_y, section.label.clone()));
1539
1540                    state.push_else_return_pending();
1541                    // Add padding after else line
1542                    state.current_y += block_else_after(&state.config, depth);
1543                    // Pass current block bounds as parent bounds to nested blocks
1544                    collect_block_backgrounds(
1545                        state,
1546                        &section.items,
1547                        depth + 1,
1548                        active_activation_count,
1549                        Some((x1, x2)),
1550                    );
1551                    state.pop_else_return_pending();
1552                }
1553
1554                // Block bottom = current Y + footer padding
1555                // (prevent messages from overflowing block)
1556                let end_y = state.current_y + block_footer_padding(&state.config, depth);
1557                let frame_end_y = end_y - frame_shift;
1558                state.current_y = end_y + state.config.row_height;
1559
1560                // Collect this block's background
1561                state.add_block_background(x1, frame_start_y, x2 - x1, frame_end_y - frame_start_y);
1562                // Collect this block's label for rendering above activations/lifelines
1563                state.add_block_label(
1564                    x1,
1565                    frame_start_y,
1566                    frame_end_y,
1567                    x2,
1568                    kind.as_str(),
1569                    label,
1570                    else_section_info,
1571                );
1572            }
1573            _ => {}
1574        }
1575    }
1576}
1577
1578/// Render all collected block backgrounds
1579fn render_block_backgrounds(svg: &mut String, state: &RenderState) {
1580    let theme = &state.config.theme;
1581    for bg in &state.block_backgrounds {
1582        writeln!(
1583            svg,
1584            r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="none"/>"##,
1585            x = bg.x,
1586            y = bg.y,
1587            w = bg.width,
1588            h = bg.height,
1589            fill = theme.block_fill
1590        )
1591        .unwrap();
1592    }
1593}
1594
1595/// Render all collected block labels (frame, pentagon, condition text, else divider)
1596/// This is called AFTER activations are drawn so labels appear on top
1597fn render_block_labels(svg: &mut String, state: &RenderState) {
1598    let theme = &state.config.theme;
1599
1600    for bl in &state.block_labels {
1601        let x1 = bl.x1;
1602        let x2 = bl.x2;
1603        let start_y = bl.start_y;
1604        let end_y = bl.end_y;
1605
1606        // Draw block frame
1607        writeln!(
1608            svg,
1609            r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="block"/>"#,
1610            x = x1,
1611            y = start_y,
1612            w = x2 - x1,
1613            h = end_y - start_y
1614        )
1615        .unwrap();
1616
1617        // Pentagon/tab-shaped label (WSD style)
1618        let label_text = &bl.kind;
1619        let label_width = block_tab_width(label_text);
1620        let label_height = BLOCK_LABEL_HEIGHT;
1621        let label_text_offset = 16.0;
1622        let notch_size = 5.0;
1623
1624        // Pentagon path
1625        let pentagon_path = format!(
1626            "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x3} {y3} L {x1} {y3} Z",
1627            x1 = x1,
1628            y1 = start_y,
1629            x2 = x1 + label_width,
1630            y2 = start_y + label_height - notch_size,
1631            x3 = x1 + label_width - notch_size,
1632            y3 = start_y + label_height
1633        );
1634
1635        writeln!(
1636            svg,
1637            r##"<path d="{path}" fill="{fill}" stroke="{stroke}"/>"##,
1638            path = pentagon_path,
1639            fill = theme.block_label_fill,
1640            stroke = theme.block_stroke
1641        )
1642        .unwrap();
1643
1644        // Block type label text
1645        writeln!(
1646            svg,
1647            r#"<text x="{x}" y="{y}" class="block-label">{kind}</text>"#,
1648            x = x1 + 5.0,
1649            y = start_y + label_text_offset,
1650            kind = label_text
1651        )
1652        .unwrap();
1653
1654        // Condition label (text only, no background per WSD style)
1655        if !bl.label.is_empty() {
1656            let condition_text = format!("[{}]", bl.label);
1657            let text_x = x1 + label_width + 8.0;
1658            let text_y = start_y + label_text_offset;
1659
1660            writeln!(
1661                svg,
1662                r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1663                x = text_x,
1664                y = text_y,
1665                label = escape_xml(&condition_text)
1666            )
1667            .unwrap();
1668        }
1669
1670        // Else separators (dashed line + else label text for each else section)
1671        for (else_y, else_label_opt) in &bl.else_sections {
1672            // Draw dashed line
1673            writeln!(
1674                svg,
1675                r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-dasharray="5,3"/>"##,
1676                x1 = x1,
1677                y = else_y,
1678                x2 = x2,
1679                c = theme.block_stroke
1680            )
1681            .unwrap();
1682
1683            // Draw else label text at same X position as alt condition label
1684            if let Some(else_label_text) = else_label_opt {
1685                let condition_text = format!("[{}]", else_label_text);
1686                let text_x = x1 + label_width + 8.0; // Same X as alt's [Success case]
1687                let text_y = else_y + label_text_offset; // Below the dashed line
1688
1689                writeln!(
1690                    svg,
1691                    r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1692                    x = text_x,
1693                    y = text_y,
1694                    label = escape_xml(&condition_text)
1695                )
1696                .unwrap();
1697            }
1698        }
1699    }
1700}
1701
1702/// Render a diagram to SVG
1703pub fn render(diagram: &Diagram) -> String {
1704    render_with_config(diagram, Config::default())
1705}
1706
1707/// Render a diagram to SVG with custom config
1708pub fn render_with_config(diagram: &Diagram, config: Config) -> String {
1709    let participants = diagram.participants();
1710    let has_title = diagram.title.is_some();
1711    let footer_style = diagram.options.footer;
1712    let mut state = RenderState::new(
1713        config,
1714        participants,
1715        &diagram.items,
1716        has_title,
1717        footer_style,
1718    );
1719    let mut svg = String::new();
1720
1721    // Pre-calculate height
1722    let content_height = calculate_height(&diagram.items, &state.config, 0);
1723    let title_space = if has_title {
1724        state.config.title_height
1725    } else {
1726        0.0
1727    };
1728    let footer_space = match footer_style {
1729        FooterStyle::Box => state.config.header_height,
1730        FooterStyle::Bar | FooterStyle::None => 0.0,
1731    };
1732    let footer_label_extra = match footer_style {
1733        FooterStyle::Box => actor_footer_extra(&state.participants, &state.config),
1734        FooterStyle::Bar | FooterStyle::None => 0.0,
1735    };
1736    let footer_margin = state.config.row_height; // Space between content and footer
1737    let base_total_height = state.config.padding * 2.0
1738        + title_space
1739        + state.config.header_height
1740        + content_height
1741        + footer_margin
1742        + footer_space;
1743    let total_height = base_total_height + footer_label_extra;
1744
1745    // Pre-calculate block backgrounds to determine max width (before SVG header)
1746    state.current_y = state.content_start();
1747    let mut active_activation_count = 0;
1748    collect_block_backgrounds(&mut state, &diagram.items, 0, &mut active_activation_count, None);
1749
1750    let total_width = state.diagram_width();
1751
1752    // SVG header
1753    writeln!(
1754        &mut svg,
1755        r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}">"#,
1756        w = total_width,
1757        h = total_height
1758    )
1759    .unwrap();
1760
1761    // Styles based on theme
1762    let theme = &state.config.theme;
1763    let lifeline_dash = match theme.lifeline_style {
1764        LifelineStyle::Dashed => "stroke-dasharray: 5,5;",
1765        LifelineStyle::Solid => "",
1766    };
1767
1768    svg.push_str("<defs>\n");
1769    svg.push_str("<style>\n");
1770    writeln!(
1771        &mut svg,
1772        ".participant {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1773        fill = theme.participant_fill,
1774        stroke = theme.participant_stroke
1775    )
1776    .unwrap();
1777    writeln!(
1778        &mut svg,
1779        ".participant-text {{ font-family: {f}; font-size: {s}px; text-anchor: middle; dominant-baseline: middle; fill: {c}; }}",
1780        f = theme.font_family,
1781        s = state.config.font_size,
1782        c = theme.participant_text
1783    )
1784    .unwrap();
1785    writeln!(
1786        &mut svg,
1787        ".lifeline {{ stroke: {c}; stroke-width: 1; {dash} }}",
1788        c = theme.lifeline_color,
1789        dash = lifeline_dash
1790    )
1791    .unwrap();
1792    writeln!(
1793        &mut svg,
1794        ".message {{ stroke: {c}; stroke-width: 1.5; fill: none; }}",
1795        c = theme.message_color
1796    )
1797    .unwrap();
1798    writeln!(
1799        &mut svg,
1800        ".message-dashed {{ stroke: {c}; stroke-width: 1.5; fill: none; stroke-dasharray: 5,3; }}",
1801        c = theme.message_color
1802    )
1803    .unwrap();
1804    writeln!(
1805        &mut svg,
1806        ".message-text {{ font-family: {f}; font-size: {s}px; fill: {c}; stroke: none; }}",
1807        f = theme.font_family,
1808        s = state.config.font_size,
1809        c = theme.message_text_color
1810    )
1811    .unwrap();
1812    writeln!(
1813        &mut svg,
1814        ".note {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1815        fill = theme.note_fill,
1816        stroke = theme.note_stroke
1817    )
1818    .unwrap();
1819    writeln!(
1820        &mut svg,
1821        ".note-text {{ font-family: {f}; font-size: {s}px; fill: {c}; }}",
1822        f = theme.font_family,
1823        s = state.config.font_size - 1.0,
1824        c = theme.note_text_color
1825    )
1826    .unwrap();
1827    writeln!(
1828        &mut svg,
1829        ".block {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1830        c = theme.block_stroke
1831    )
1832    .unwrap();
1833    writeln!(
1834        &mut svg,
1835        ".block-label {{ font-family: {f}; font-size: {s}px; font-weight: bold; fill: {c}; }}",
1836        f = theme.font_family,
1837        s = state.config.font_size - 1.0,
1838        c = theme.message_text_color
1839    )
1840    .unwrap();
1841    writeln!(
1842        &mut svg,
1843        ".activation {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1844        fill = theme.activation_fill,
1845        stroke = theme.activation_stroke
1846    )
1847    .unwrap();
1848    writeln!(
1849        &mut svg,
1850        ".actor-head {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1851        fill = theme.actor_fill,
1852        stroke = theme.actor_stroke
1853    )
1854    .unwrap();
1855    writeln!(
1856        &mut svg,
1857        ".actor-body {{ stroke: {c}; stroke-width: 2; fill: none; }}",
1858        c = theme.actor_stroke
1859    )
1860    .unwrap();
1861    writeln!(
1862        &mut svg,
1863        ".title {{ font-family: {f}; font-size: {s}px; font-weight: bold; text-anchor: middle; fill: {c}; }}",
1864        f = theme.font_family,
1865        s = state.config.font_size + 4.0,
1866        c = theme.message_text_color
1867    )
1868    .unwrap();
1869    // Arrowhead styles
1870    writeln!(
1871        &mut svg,
1872        ".arrowhead {{ fill: {c}; stroke: none; }}",
1873        c = theme.message_color
1874    )
1875    .unwrap();
1876    writeln!(
1877        &mut svg,
1878        ".arrowhead-open {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1879        c = theme.message_color
1880    )
1881    .unwrap();
1882    svg.push_str("</style>\n");
1883    svg.push_str("</defs>\n");
1884
1885    // Background with theme color
1886    writeln!(
1887        &mut svg,
1888        r##"<rect width="100%" height="100%" fill="{bg}"/>"##,
1889        bg = theme.background
1890    )
1891    .unwrap();
1892
1893    // Title
1894    if let Some(title) = &diagram.title {
1895        let title_y = state.config.padding + state.config.font_size + 7.36; // WSD: 31.86
1896        writeln!(
1897            &mut svg,
1898            r#"<text x="{x}" y="{y}" class="title">{t}</text>"#,
1899            x = total_width / 2.0,
1900            y = title_y,
1901            t = escape_xml(title)
1902        )
1903        .unwrap();
1904    }
1905
1906    // Calculate footer position
1907    let header_y = state.header_top();
1908    let footer_y = match footer_style {
1909        FooterStyle::Box => base_total_height - state.config.padding - state.config.header_height,
1910        FooterStyle::Bar | FooterStyle::None => total_height - state.config.padding,
1911    };
1912
1913    // Draw block backgrounds FIRST (behind lifelines)
1914    // (block_backgrounds already calculated above for width calculation)
1915    render_block_backgrounds(&mut svg, &state);
1916
1917    // Reset current_y for actual rendering
1918    state.current_y = state.content_start();
1919
1920    // Pre-calculate destroyed participants for lifeline rendering
1921    let destroyed_map = collect_destroyed(&diagram.items, &state.config, state.content_start());
1922
1923    // Draw lifelines (behind messages but above block backgrounds)
1924    let lifeline_start = header_y + state.config.header_height;
1925    let lifeline_end = footer_y;
1926
1927    for p in &state.participants {
1928        let x = state.get_x(p.id());
1929        // If participant is destroyed, end lifeline at destroy position
1930        let end_y = destroyed_map.get(p.id()).copied().unwrap_or(lifeline_end);
1931        writeln!(
1932            &mut svg,
1933            r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="lifeline"/>"#,
1934            x = x,
1935            y1 = lifeline_start,
1936            y2 = end_y
1937        )
1938        .unwrap();
1939    }
1940
1941    // Draw participant headers
1942    render_participant_headers(&mut svg, &state, header_y);
1943
1944    // Render items
1945    state.current_y = state.content_start();
1946    render_items(&mut svg, &mut state, &diagram.items, 0);
1947
1948    // Draw activation bars
1949    render_activations(&mut svg, &mut state, footer_y);
1950
1951    // Draw destroy X marks AFTER activations so they appear on top
1952    render_destroy_marks(&mut svg, &state);
1953
1954    // Draw block labels AFTER activations so they appear on top
1955    render_block_labels(&mut svg, &state);
1956
1957    // Draw participant footers based on footer style option
1958    // Note: destroyed participants should NOT have footers
1959    match state.footer_style {
1960        FooterStyle::Box => {
1961            render_participant_footers(&mut svg, &state, footer_y);
1962        }
1963        FooterStyle::Bar => {
1964            // Draw simple horizontal line across all participants
1965            let left = state.leftmost_x()
1966                - state.get_participant_width(
1967                    state.participants.first().map(|p| p.id()).unwrap_or(""),
1968                ) / 2.0;
1969            let right = state.rightmost_x()
1970                + state
1971                    .get_participant_width(state.participants.last().map(|p| p.id()).unwrap_or(""))
1972                    / 2.0;
1973            writeln!(
1974                &mut svg,
1975                r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-width="1"/>"##,
1976                x1 = left,
1977                y = footer_y,
1978                x2 = right,
1979                c = state.config.theme.lifeline_color
1980            )
1981            .unwrap();
1982        }
1983        FooterStyle::None => {
1984            // No footer at all
1985        }
1986    }
1987
1988    svg.push_str("</svg>\n");
1989    svg
1990}
1991
1992fn calculate_height(items: &[Item], config: &Config, depth: usize) -> f64 {
1993    fn inner(
1994        items: &[Item],
1995        config: &Config,
1996        depth: usize,
1997        else_pending: &mut Vec<bool>,
1998        serial_pending: &mut Vec<bool>,
1999        active_activation_count: &mut usize,
2000        parallel_depth: &mut usize,
2001    ) -> f64 {
2002        let mut height = 0.0;
2003        for item in items {
2004            match item {
2005                Item::Message {
2006                    from,
2007                    to,
2008                    text,
2009                    arrow,
2010                    create,
2011                    activate,
2012                    deactivate,
2013                    ..
2014                } => {
2015                    if let Some(pending) = else_pending.last_mut() {
2016                        if *pending && matches!(arrow.line, LineStyle::Dashed) {
2017                            *pending = false;
2018                        }
2019                    }
2020                    let is_self = from == to;
2021                    let line_count = text.split("\\n").count();
2022                    let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
2023                    if is_self {
2024                        height += self_message_y_advance(config, line_count);
2025                    } else {
2026                        height += regular_message_y_advance(config, line_count, delay_offset);
2027                    }
2028                    if *create {
2029                        height += config.row_height;
2030                    }
2031                    if let Some(pending) = serial_pending.last_mut() {
2032                        if *pending {
2033                            height += serial_first_row_gap(*parallel_depth);
2034                            *pending = false;
2035                        }
2036                    }
2037                    if *activate {
2038                        *active_activation_count += 1;
2039                    }
2040                    if *deactivate && *active_activation_count > 0 {
2041                        *active_activation_count -= 1;
2042                    }
2043                }
2044                Item::Note { text, .. } => {
2045                    let line_count = text.split("\\n").count();
2046                    height += note_y_advance(config, line_count);
2047                }
2048                Item::State { text, .. } => {
2049                    let line_count = text.split("\\n").count();
2050                    height += state_y_advance(config, line_count);
2051                }
2052                Item::Ref { text, .. } => {
2053                    let line_count = text.split("\\n").count();
2054                    height += ref_y_advance(config, line_count);
2055                }
2056                Item::Description { text } => {
2057                    let line_count = text.split("\\n").count();
2058                    height += description_y_advance(config, line_count);
2059                }
2060                Item::Block {
2061                    kind,
2062                    items,
2063                    else_sections,
2064                    ..
2065                } => {
2066                    if block_is_parallel(kind) {
2067                        let mut max_branch_height = 0.0;
2068                        let base_activation_count = *active_activation_count;
2069                        *parallel_depth += 1;
2070                        for item in items {
2071                            *active_activation_count = base_activation_count;
2072                            let branch_height = inner(
2073                                std::slice::from_ref(item),
2074                                config,
2075                                depth,
2076                                else_pending,
2077                                serial_pending,
2078                                active_activation_count,
2079                                parallel_depth,
2080                            );
2081                            if branch_height > max_branch_height {
2082                                max_branch_height = branch_height;
2083                            }
2084                        }
2085                        *active_activation_count = base_activation_count;
2086                        if *parallel_depth > 0 {
2087                            *parallel_depth -= 1;
2088                        }
2089                        let gap = if parallel_needs_gap(items) {
2090                            config.row_height
2091                        } else {
2092                            0.0
2093                        };
2094                        height += max_branch_height + gap;
2095                        continue;
2096                    }
2097
2098                    if matches!(kind, BlockKind::Serial) {
2099                        serial_pending.push(true);
2100                        height += inner(
2101                            items,
2102                            config,
2103                            depth,
2104                            else_pending,
2105                            serial_pending,
2106                            active_activation_count,
2107                            parallel_depth,
2108                        );
2109                        for else_section in else_sections {
2110                            height += inner(
2111                                &else_section.items,
2112                                config,
2113                                depth,
2114                                else_pending,
2115                                serial_pending,
2116                                active_activation_count,
2117                                parallel_depth,
2118                            );
2119                        }
2120                        serial_pending.pop();
2121                    } else if !block_has_frame(kind) {
2122                        height += inner(
2123                            items,
2124                            config,
2125                            depth,
2126                            else_pending,
2127                            serial_pending,
2128                            active_activation_count,
2129                            parallel_depth,
2130                        );
2131                        for else_section in else_sections {
2132                            height += inner(
2133                                &else_section.items,
2134                                config,
2135                                depth,
2136                                else_pending,
2137                                serial_pending,
2138                                active_activation_count,
2139                                parallel_depth,
2140                            );
2141                        }
2142                    } else {
2143                        height += block_header_space(config, depth);
2144                        height += inner(
2145                            items,
2146                            config,
2147                            depth + 1,
2148                            else_pending,
2149                            serial_pending,
2150                            active_activation_count,
2151                            parallel_depth,
2152                        );
2153                        for else_section in else_sections {
2154                            else_pending.push(true);
2155                            // Padding before and after else line
2156                            height += block_else_before(config, depth) + block_else_after(config, depth);
2157                            height += inner(
2158                                &else_section.items,
2159                                config,
2160                                depth + 1,
2161                                else_pending,
2162                                serial_pending,
2163                                active_activation_count,
2164                                parallel_depth,
2165                            );
2166                            else_pending.pop();
2167                        }
2168                        // Block bottom and trailing margin
2169                        height += block_end_y_advance(config, depth);
2170                    }
2171                }
2172                Item::Activate { .. } => {
2173                    *active_activation_count += 1;
2174                }
2175                Item::Deactivate { .. } => {
2176                    if *active_activation_count > 0 {
2177                        *active_activation_count -= 1;
2178                    }
2179                }
2180                Item::Destroy { .. } => {
2181                    height += config.row_height;
2182                }
2183                Item::ParticipantDecl { .. } => {}
2184                Item::Autonumber { .. } => {}
2185                Item::DiagramOption { .. } => {} // Options don't take space
2186            }
2187        }
2188        height
2189    }
2190
2191    let mut else_pending = Vec::new();
2192    let mut serial_pending = Vec::new();
2193    let mut active_activation_count = 0;
2194    let mut parallel_depth = 0;
2195    inner(
2196        items,
2197        config,
2198        depth,
2199        &mut else_pending,
2200        &mut serial_pending,
2201        &mut active_activation_count,
2202        &mut parallel_depth,
2203    )
2204}
2205
2206/// Pre-calculate destroyed participants and their Y positions
2207/// This is needed because lifelines are drawn before render_items processes Item::Destroy
2208fn collect_destroyed(items: &[Item], config: &Config, start_y: f64) -> HashMap<String, f64> {
2209    fn inner(
2210        items: &[Item],
2211        config: &Config,
2212        current_y: &mut f64,
2213        destroyed: &mut HashMap<String, f64>,
2214    ) {
2215        for item in items {
2216            match item {
2217                Item::Message { from, to, text, arrow, .. } => {
2218                    let is_self = from == to;
2219                    let line_count = text.split("\\n").count();
2220                    let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
2221                    if is_self {
2222                        *current_y += self_message_y_advance(config, line_count) + delay_offset;
2223                    } else {
2224                        *current_y += regular_message_y_advance(config, line_count, delay_offset);
2225                    }
2226                }
2227                Item::Note { text, .. } => {
2228                    let line_count = text.split("\\n").count();
2229                    *current_y += note_y_advance(config, line_count);
2230                }
2231                Item::State { text, .. } => {
2232                    let line_count = text.split("\\n").count();
2233                    *current_y += state_y_advance(config, line_count);
2234                }
2235                Item::Ref { .. } => {
2236                    *current_y += ref_y_advance(config, 1);
2237                }
2238                Item::Description { text, .. } => {
2239                    let line_count = text.split("\\n").count();
2240                    *current_y += description_y_advance(config, line_count);
2241                }
2242                Item::Destroy { participant } => {
2243                    // destroy_y is at the previous message position
2244                    let destroy_y = *current_y - config.row_height;
2245                    destroyed.insert(participant.clone(), destroy_y);
2246                    *current_y += config.row_height;
2247                }
2248                Item::Block { items, else_sections, kind, .. } => {
2249                    if block_has_frame(kind) {
2250                        *current_y += block_header_space(config, 0);
2251                    }
2252                    inner(items, config, current_y, destroyed);
2253                    for else_section in else_sections {
2254                        if block_has_frame(kind) {
2255                            *current_y += block_else_before(config, 0) + block_else_after(config, 0);
2256                        }
2257                        inner(&else_section.items, config, current_y, destroyed);
2258                    }
2259                    if block_has_frame(kind) {
2260                        *current_y += block_end_y_advance(config, 0);
2261                    }
2262                }
2263                Item::Activate { .. } | Item::Deactivate { .. } | Item::Autonumber { .. } | Item::ParticipantDecl { .. } | Item::DiagramOption { .. } => {}
2264            }
2265        }
2266    }
2267
2268    let mut destroyed = HashMap::new();
2269    let mut current_y = start_y;
2270    inner(items, config, &mut current_y, &mut destroyed);
2271    destroyed
2272}
2273
2274fn render_participant_headers(svg: &mut String, state: &RenderState, y: f64) {
2275    let shape = state.config.theme.participant_shape;
2276
2277    for p in &state.participants {
2278        let x = state.get_x(p.id());
2279        let p_width = state.get_participant_width(p.id());
2280        let box_x = x - p_width / 2.0;
2281
2282        match p.kind {
2283            ParticipantKind::Participant => {
2284                // Draw shape based on theme
2285                match shape {
2286                    ParticipantShape::Rectangle => {
2287                        writeln!(
2288                            svg,
2289                            r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="participant"/>"#,
2290                            x = box_x,
2291                            y = y,
2292                            w = p_width,
2293                            h = state.config.header_height
2294                        )
2295                        .unwrap();
2296                    }
2297                    ParticipantShape::RoundedRect => {
2298                        writeln!(
2299                            svg,
2300                            r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" class="participant"/>"#,
2301                            x = box_x,
2302                            y = y,
2303                            w = p_width,
2304                            h = state.config.header_height
2305                        )
2306                        .unwrap();
2307                    }
2308                    ParticipantShape::Circle => {
2309                        // Draw ellipse/circle that fits in the header area
2310                        let rx = p_width / 2.0 - 5.0;
2311                        let ry = state.config.header_height / 2.0 - 2.0;
2312                        writeln!(
2313                            svg,
2314                            r#"<ellipse cx="{cx}" cy="{cy}" rx="{rx}" ry="{ry}" class="participant"/>"#,
2315                            cx = x,
2316                            cy = y + state.config.header_height / 2.0,
2317                            rx = rx,
2318                            ry = ry
2319                        )
2320                        .unwrap();
2321                    }
2322                }
2323                // Name centered in box (handle multiline with \n)
2324                let lines: Vec<&str> = p.name.split("\\n").collect();
2325                if lines.len() == 1 {
2326                    writeln!(
2327                        svg,
2328                        r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2329                        x = x,
2330                        y = y + state.config.header_height / 2.0,
2331                        name = escape_xml(&p.name)
2332                    )
2333                    .unwrap();
2334                } else {
2335                    let line_height = state.config.font_size + 2.0;
2336                    let total_height = lines.len() as f64 * line_height;
2337                    let start_y = y + state.config.header_height / 2.0 - total_height / 2.0
2338                        + line_height * 0.8;
2339                    write!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2340                    for (i, line) in lines.iter().enumerate() {
2341                        let dy = if i == 0 { start_y } else { line_height };
2342                        if i == 0 {
2343                            writeln!(
2344                                svg,
2345                                r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2346                                x = x,
2347                                y = dy,
2348                                text = escape_xml(line)
2349                            )
2350                            .unwrap();
2351                        } else {
2352                            writeln!(
2353                                svg,
2354                                r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2355                                x = x,
2356                                dy = dy,
2357                                text = escape_xml(line)
2358                            )
2359                            .unwrap();
2360                        }
2361                    }
2362                    writeln!(svg, "</text>").unwrap();
2363                }
2364            }
2365            ParticipantKind::Actor => {
2366                // Stick figure at top of header area, name below within header
2367                let head_r = 8.0;
2368                let body_len = 12.0;
2369                let arm_len = 10.0;
2370                let leg_len = 10.0;
2371                let figure_height = 38.0; // head(16) + body(12) + legs(10)
2372
2373                // Position figure at top with small margin
2374                let fig_top = y + 8.0;
2375                let fig_center_y = fig_top + head_r + body_len / 2.0;
2376                let arm_y = fig_center_y + 2.0;
2377
2378                // Head
2379                writeln!(
2380                    svg,
2381                    r#"<circle cx="{x}" cy="{cy}" r="{r}" class="actor-head"/>"#,
2382                    x = x,
2383                    cy = fig_center_y - body_len / 2.0 - head_r,
2384                    r = head_r
2385                )
2386                .unwrap();
2387                // Body
2388                writeln!(
2389                    svg,
2390                    r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="actor-body"/>"#,
2391                    x = x,
2392                    y1 = fig_center_y - body_len / 2.0,
2393                    y2 = fig_center_y + body_len / 2.0
2394                )
2395                .unwrap();
2396                // Arms
2397                writeln!(
2398                    svg,
2399                    r#"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="actor-body"/>"#,
2400                    x1 = x - arm_len,
2401                    y = arm_y,
2402                    x2 = x + arm_len
2403                )
2404                .unwrap();
2405                // Left leg
2406                writeln!(
2407                    svg,
2408                    r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2409                    x = x,
2410                    y1 = fig_center_y + body_len / 2.0,
2411                    x2 = x - leg_len * 0.6,
2412                    y2 = fig_center_y + body_len / 2.0 + leg_len
2413                )
2414                .unwrap();
2415                // Right leg
2416                writeln!(
2417                    svg,
2418                    r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2419                    x = x,
2420                    y1 = fig_center_y + body_len / 2.0,
2421                    x2 = x + leg_len * 0.6,
2422                    y2 = fig_center_y + body_len / 2.0 + leg_len
2423                )
2424                .unwrap();
2425                // Name below figure (within header)
2426                let name_lines: Vec<&str> = p.name.split("\\n").collect();
2427                let name_start_y = fig_top + figure_height + 5.0;
2428                if name_lines.len() == 1 {
2429                    writeln!(
2430                        svg,
2431                        r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2432                        x = x,
2433                        y = name_start_y + state.config.font_size,
2434                        name = escape_xml(&p.name)
2435                    )
2436                    .unwrap();
2437                } else {
2438                    // Multiline actor name using tspan
2439                    let line_height = state.config.font_size + 2.0;
2440                    writeln!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2441                    for (i, line) in name_lines.iter().enumerate() {
2442                        if i == 0 {
2443                            writeln!(
2444                                svg,
2445                                r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2446                                x = x,
2447                                y = name_start_y + state.config.font_size,
2448                                text = escape_xml(line)
2449                            )
2450                            .unwrap();
2451                        } else {
2452                            writeln!(
2453                                svg,
2454                                r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2455                                x = x,
2456                                dy = line_height,
2457                                text = escape_xml(line)
2458                            )
2459                            .unwrap();
2460                        }
2461                    }
2462                    writeln!(svg, "</text>").unwrap();
2463                }
2464            }
2465        }
2466    }
2467}
2468
2469/// Render participant footers, skipping destroyed participants
2470fn render_participant_footers(svg: &mut String, state: &RenderState, y: f64) {
2471    let shape = state.config.theme.participant_shape;
2472
2473    for p in &state.participants {
2474        // Skip destroyed participants - they should not have footers
2475        if state.destroyed.contains_key(p.id()) {
2476            continue;
2477        }
2478
2479        let x = state.get_x(p.id());
2480        let p_width = state.get_participant_width(p.id());
2481        let box_x = x - p_width / 2.0;
2482
2483        match p.kind {
2484            ParticipantKind::Participant => {
2485                // Draw shape based on theme
2486                match shape {
2487                    ParticipantShape::Rectangle => {
2488                        writeln!(
2489                            svg,
2490                            r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="participant"/>"#,
2491                            x = box_x,
2492                            y = y,
2493                            w = p_width,
2494                            h = state.config.header_height
2495                        )
2496                        .unwrap();
2497                    }
2498                    ParticipantShape::RoundedRect => {
2499                        writeln!(
2500                            svg,
2501                            r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" class="participant"/>"#,
2502                            x = box_x,
2503                            y = y,
2504                            w = p_width,
2505                            h = state.config.header_height
2506                        )
2507                        .unwrap();
2508                    }
2509                    ParticipantShape::Circle => {
2510                        let r = state.config.header_height / 2.0;
2511                        writeln!(
2512                            svg,
2513                            r#"<ellipse cx="{cx}" cy="{cy}" rx="{rx}" ry="{ry}" class="participant"/>"#,
2514                            cx = x,
2515                            cy = y + r,
2516                            rx = p_width / 2.0,
2517                            ry = r
2518                        )
2519                        .unwrap();
2520                    }
2521                }
2522                // Draw participant name
2523                let lines: Vec<&str> = p.name.split("\\n").collect();
2524                let line_height = state.config.font_size + 2.0;
2525                let total_text_height = lines.len() as f64 * line_height;
2526                let text_start_y = y + (state.config.header_height - total_text_height) / 2.0 + state.config.font_size;
2527
2528                if lines.len() == 1 {
2529                    writeln!(
2530                        svg,
2531                        r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2532                        x = x,
2533                        y = text_start_y,
2534                        name = escape_xml(&p.name)
2535                    )
2536                    .unwrap();
2537                } else {
2538                    writeln!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2539                    for (i, line) in lines.iter().enumerate() {
2540                        if i == 0 {
2541                            writeln!(
2542                                svg,
2543                                r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2544                                x = x,
2545                                y = text_start_y,
2546                                text = escape_xml(line)
2547                            )
2548                            .unwrap();
2549                        } else {
2550                            writeln!(
2551                                svg,
2552                                r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2553                                x = x,
2554                                dy = line_height,
2555                                text = escape_xml(line)
2556                            )
2557                            .unwrap();
2558                        }
2559                    }
2560                    writeln!(svg, "</text>").unwrap();
2561                }
2562            }
2563            ParticipantKind::Actor => {
2564                // For actors, draw stick figure as footer too
2565                let head_r = 8.0;
2566                let body_len = 12.0;
2567                let arm_len = 10.0;
2568                let leg_len = 10.0;
2569                let figure_height = 38.0;
2570                let fig_top = y + 8.0;
2571                let fig_center_y = fig_top + head_r + body_len / 2.0;
2572                let arm_y = fig_center_y + 2.0;
2573
2574                // Head
2575                writeln!(
2576                    svg,
2577                    r#"<circle cx="{x}" cy="{cy}" r="{r}" class="actor-head"/>"#,
2578                    x = x,
2579                    cy = fig_center_y - body_len / 2.0 - head_r,
2580                    r = head_r
2581                )
2582                .unwrap();
2583                // Body
2584                writeln!(
2585                    svg,
2586                    r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="actor-body"/>"#,
2587                    x = x,
2588                    y1 = fig_center_y - body_len / 2.0,
2589                    y2 = fig_center_y + body_len / 2.0
2590                )
2591                .unwrap();
2592                // Arms
2593                writeln!(
2594                    svg,
2595                    r#"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="actor-body"/>"#,
2596                    x1 = x - arm_len,
2597                    y = arm_y,
2598                    x2 = x + arm_len
2599                )
2600                .unwrap();
2601                // Left leg
2602                writeln!(
2603                    svg,
2604                    r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2605                    x = x,
2606                    y1 = fig_center_y + body_len / 2.0,
2607                    x2 = x - leg_len * 0.6,
2608                    y2 = fig_center_y + body_len / 2.0 + leg_len
2609                )
2610                .unwrap();
2611                // Right leg
2612                writeln!(
2613                    svg,
2614                    r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2615                    x = x,
2616                    y1 = fig_center_y + body_len / 2.0,
2617                    x2 = x + leg_len * 0.6,
2618                    y2 = fig_center_y + body_len / 2.0 + leg_len
2619                )
2620                .unwrap();
2621                // Name below figure
2622                let name_lines: Vec<&str> = p.name.split("\\n").collect();
2623                let name_start_y = fig_top + figure_height + 5.0;
2624                if name_lines.len() == 1 {
2625                    writeln!(
2626                        svg,
2627                        r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2628                        x = x,
2629                        y = name_start_y + state.config.font_size,
2630                        name = escape_xml(&p.name)
2631                    )
2632                    .unwrap();
2633                } else {
2634                    let line_height = state.config.font_size + 2.0;
2635                    writeln!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2636                    for (i, line) in name_lines.iter().enumerate() {
2637                        if i == 0 {
2638                            writeln!(
2639                                svg,
2640                                r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2641                                x = x,
2642                                y = name_start_y + state.config.font_size,
2643                                text = escape_xml(line)
2644                            )
2645                            .unwrap();
2646                        } else {
2647                            writeln!(
2648                                svg,
2649                                r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2650                                x = x,
2651                                dy = line_height,
2652                                text = escape_xml(line)
2653                            )
2654                            .unwrap();
2655                        }
2656                    }
2657                    writeln!(svg, "</text>").unwrap();
2658                }
2659            }
2660        }
2661    }
2662}
2663
2664fn render_items(svg: &mut String, state: &mut RenderState, items: &[Item], depth: usize) {
2665    for item in items {
2666        match item {
2667            Item::Message {
2668                from,
2669                to,
2670                text,
2671                arrow,
2672                activate,
2673                deactivate,
2674                create,
2675                ..
2676            } => {
2677                render_message(
2678                    svg,
2679                    state,
2680                    from,
2681                    to,
2682                    text,
2683                    arrow,
2684                    *activate,
2685                    *deactivate,
2686                    *create,
2687                    depth,
2688                );
2689            }
2690            Item::Note {
2691                position,
2692                participants,
2693                text,
2694            } => {
2695                render_note(svg, state, position, participants, text);
2696            }
2697            Item::Block {
2698                kind,
2699                label,
2700                items,
2701                else_sections,
2702            } => {
2703                render_block(svg, state, kind, label, items, else_sections, depth);
2704            }
2705            Item::Activate { participant } => {
2706                let y = state.current_y;
2707                state
2708                    .activations
2709                    .entry(participant.clone())
2710                    .or_default()
2711                    .push((y, None));
2712            }
2713            Item::Deactivate { participant } => {
2714                if let Some(acts) = state.activations.get_mut(participant) {
2715                    if let Some(act) = acts.last_mut() {
2716                        if act.1.is_none() {
2717                            act.1 = Some(state.current_y);
2718                        }
2719                    }
2720                }
2721            }
2722            Item::Destroy { participant } => {
2723                // X mark should be at the previous message's arrow position (WSD compatible)
2724                // After a message, current_y is incremented by row_height, so we subtract it back
2725                let destroy_y = state.current_y - state.config.row_height;
2726                state.destroyed.insert(participant.clone(), destroy_y);
2727
2728                // Close all open activations for this participant at destroy_y
2729                if let Some(acts) = state.activations.get_mut(participant) {
2730                    for act in acts.iter_mut() {
2731                        if act.1.is_none() {
2732                            act.1 = Some(destroy_y);
2733                        }
2734                    }
2735                }
2736                // Collect X mark position for deferred rendering (above activations)
2737                let x = state.get_x(participant);
2738                state.destroy_marks.push((x, destroy_y));
2739                state.current_y += state.config.row_height;
2740            }
2741            Item::Autonumber { enabled, start } => {
2742                if *enabled {
2743                    state.autonumber = Some(start.unwrap_or(1));
2744                } else {
2745                    state.autonumber = None;
2746                }
2747            }
2748            Item::ParticipantDecl { .. } => {
2749                // Already processed
2750            }
2751            Item::State { participants, text } => {
2752                render_state(svg, state, participants, text);
2753            }
2754            Item::Ref {
2755                participants,
2756                text,
2757                input_from,
2758                input_label,
2759                output_to,
2760                output_label,
2761            } => {
2762                render_ref(
2763                    svg,
2764                    state,
2765                    participants,
2766                    text,
2767                    input_from.as_deref(),
2768                    input_label.as_deref(),
2769                    output_to.as_deref(),
2770                    output_label.as_deref(),
2771                );
2772            }
2773            Item::DiagramOption { .. } => {
2774                // Options are processed at render start, not during item rendering
2775            }
2776            Item::Description { text } => {
2777                render_description(svg, state, text);
2778            }
2779        }
2780    }
2781}
2782
2783fn render_message(
2784    svg: &mut String,
2785    state: &mut RenderState,
2786    from: &str,
2787    to: &str,
2788    text: &str,
2789    arrow: &Arrow,
2790    activate: bool,
2791    deactivate: bool,
2792    create: bool,
2793    _depth: usize,
2794) {
2795    // Get base lifeline positions (used for text centering and direction calculation)
2796    let base_x1 = state.get_x(from);
2797    let base_x2 = state.get_x(to);
2798
2799    state.apply_else_return_gap(arrow);
2800
2801    let is_self = from == to;
2802    let line_class = match arrow.line {
2803        LineStyle::Solid => "message",
2804        LineStyle::Dashed => "message-dashed",
2805    };
2806    let is_filled = matches!(arrow.head, ArrowHead::Filled);
2807
2808    // Get autonumber prefix
2809    let num_prefix = state
2810        .next_number()
2811        .map(|n| format!("{}. ", n))
2812        .unwrap_or_default();
2813
2814    // Calculate text lines and height
2815    let display_text = format!("{}{}", num_prefix, text);
2816    let lines: Vec<&str> = display_text.split("\\n").collect();
2817    let line_height = state.config.font_size + 4.0;
2818    let extra_height = if !is_self && lines.len() > 1 {
2819        // Use actual line_height (not spacing_line_height) to match text rendering
2820        // Extra lines + MESSAGE_TEXT_ABOVE_ARROW offset (text is rendered above arrow)
2821        (lines.len() - 1) as f64 * line_height + MESSAGE_TEXT_ABOVE_ARROW
2822    } else {
2823        0.0
2824    };
2825
2826    // Add space BEFORE the message for multiline text (text is rendered above arrow)
2827    if !is_self && lines.len() > 1 {
2828        state.current_y += extra_height;
2829    }
2830
2831    let y = state.current_y;
2832    let has_label_text = lines.iter().any(|line| !line.trim().is_empty());
2833
2834    // Calculate activation-aware arrow endpoints
2835    let going_right = base_x2 > base_x1;
2836    let x1 = state.get_arrow_start_x(from, y, going_right);
2837    let x2 = state.get_arrow_end_x(to, y, !going_right);
2838
2839    // Open message group
2840    writeln!(svg, r#"<g class="message">"#).unwrap();
2841
2842    if is_self {
2843        // Self message - loop back
2844        // For self-messages, always use right edge of activation bar (loop extends to the right)
2845        // Check if already active OR if this message starts a new activation
2846        let is_active = state.is_participant_active_at(from, y) || activate;
2847        let activation_offset = if is_active {
2848            state.config.activation_width / 2.0
2849        } else {
2850            0.0
2851        };
2852        let loop_anchor_x = base_x1 + activation_offset;
2853
2854        let loop_width = 40.0;
2855        let text_block_height = lines.len() as f64 * line_height;
2856        // WSD: loop height equals text block height, no extra padding
2857        let loop_height = text_block_height.max(25.0);
2858        let arrow_end_x = loop_anchor_x;
2859        let arrow_end_y = y + loop_height;
2860        // Arrowhead points left (PI radians)
2861        let direction = std::f64::consts::PI;
2862        let arrow_points = arrowhead_points(arrow_end_x, arrow_end_y, direction);
2863
2864        writeln!(
2865            svg,
2866            r#"  <path d="M {x1} {y} L {x2} {y} L {x2} {y2} L {arrow_x} {y2}" class="{cls}"/>"#,
2867            x1 = loop_anchor_x,
2868            y = y,
2869            x2 = loop_anchor_x + loop_width,
2870            y2 = y + loop_height,
2871            arrow_x = arrow_end_x + ARROWHEAD_SIZE,
2872            cls = line_class
2873        )
2874        .unwrap();
2875
2876        // Draw arrowhead as polygon or polyline
2877        if is_filled {
2878            writeln!(
2879                svg,
2880                r#"  <polygon points="{points}" class="arrowhead"/>"#,
2881                points = arrow_points
2882            )
2883            .unwrap();
2884        } else {
2885            writeln!(
2886                svg,
2887                r#"  <polyline points="{points}" class="arrowhead-open"/>"#,
2888                points = arrow_points
2889            )
2890            .unwrap();
2891        }
2892
2893        // Text - multiline support
2894        // Self-message text is positioned to the LEFT of the activation bar with right-alignment
2895        // Add padding from activation bar if present
2896        let text_padding = if is_active { 8.0 } else { 5.0 };
2897        let text_x = base_x1 - activation_offset - text_padding;
2898        for (i, line) in lines.iter().enumerate() {
2899            let line_y = y + 4.0 + (i as f64 + 0.5) * line_height;
2900            writeln!(
2901                svg,
2902                r#"  <text x="{x}" y="{y}" class="message-text" text-anchor="end">{t}</text>"#,
2903                x = text_x,
2904                y = line_y,
2905                t = escape_xml(line)
2906            )
2907            .unwrap();
2908        }
2909
2910        // Close message group
2911        writeln!(svg, r#"</g>"#).unwrap();
2912
2913        let spacing = self_message_spacing(&state.config, lines.len());
2914        state.current_y += spacing;
2915    } else {
2916        // Regular message - check for delay
2917        let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
2918        let y2 = y + delay_offset;
2919
2920        // Text is centered between lifelines (not activation bar edges)
2921        let text_x = (base_x1 + base_x2) / 2.0;
2922        let text_y = (y + y2) / 2.0 - 6.0;  // WSD: label slightly above arrow
2923
2924        // Calculate arrowhead direction and shorten line to not overlap with arrowhead
2925        let direction = arrow_direction(x1, y, x2, y2);
2926        let arrow_points = arrowhead_points(x2, y2, direction);
2927
2928        // Shorten the line so it doesn't overlap with the arrowhead
2929        let line_end_x = x2 - ARROWHEAD_SIZE * direction.cos();
2930        let line_end_y = y2 - ARROWHEAD_SIZE * direction.sin();
2931
2932        // Draw arrow line (slanted if delay)
2933        writeln!(
2934            svg,
2935            r#"  <line x1="{x1}" y1="{y1}" x2="{lx2}" y2="{ly2}" class="{cls}"/>"#,
2936            x1 = x1,
2937            y1 = y,
2938            lx2 = line_end_x,
2939            ly2 = line_end_y,
2940            cls = line_class
2941        )
2942        .unwrap();
2943
2944        // Draw arrowhead as polygon or polyline
2945        if is_filled {
2946            writeln!(
2947                svg,
2948                r#"  <polygon points="{points}" class="arrowhead"/>"#,
2949                points = arrow_points
2950            )
2951            .unwrap();
2952        } else {
2953            writeln!(
2954                svg,
2955                r#"  <polyline points="{points}" class="arrowhead-open"/>"#,
2956                points = arrow_points
2957            )
2958            .unwrap();
2959        }
2960
2961        // Text with multiline support (positioned at midpoint of slanted line)
2962        let max_width = lines
2963            .iter()
2964            .map(|line| estimate_message_width(line, state.config.font_size))
2965            .fold(0.0, f64::max);
2966        let top_line_y = text_y - (lines.len() as f64 - 1.0) * line_height;
2967        let bottom_line_y = text_y;
2968        let label_offset = if has_label_text {
2969            let label_y_min = top_line_y - line_height * MESSAGE_LABEL_ASCENT_FACTOR;
2970            let label_y_max = bottom_line_y + line_height * MESSAGE_LABEL_DESCENT_FACTOR;
2971            let label_x_min = text_x - max_width / 2.0;
2972            let label_x_max = text_x + max_width / 2.0;
2973            let step = line_height * MESSAGE_LABEL_COLLISION_STEP_RATIO;
2974            let raw_offset = state.reserve_message_label(label_x_min, label_x_max, label_y_min, label_y_max, step);
2975            // Limit offset to prevent text from going below the arrow
2976            let max_offset = y - MESSAGE_TEXT_ABOVE_ARROW - bottom_line_y;
2977            raw_offset.min(max_offset.max(0.0))
2978        } else {
2979            0.0
2980        };
2981        // Calculate rotation angle for delayed messages (slanted arrow)
2982        let rotation = if delay_offset > 0.0 {
2983            let dx = x2 - x1;
2984            let dy = delay_offset;
2985            let angle_rad = dy.atan2(dx.abs());
2986            let angle_deg = angle_rad.to_degrees();
2987            // Rotate in the direction of the arrow
2988            if dx < 0.0 { -angle_deg } else { angle_deg }
2989        } else {
2990            0.0
2991        };
2992
2993        for (i, line) in lines.iter().enumerate() {
2994            let line_y = text_y - (lines.len() - 1 - i) as f64 * line_height + label_offset;
2995            if rotation.abs() > 0.1 {
2996                // Apply rotation transform for delayed messages
2997                writeln!(
2998                    svg,
2999                    r#"  <text x="{x}" y="{y}" class="message-text" text-anchor="middle" transform="rotate({rot},{cx},{cy})">{t}</text>"#,
3000                    x = text_x,
3001                    y = line_y,
3002                    rot = rotation,
3003                    cx = text_x,
3004                    cy = line_y,
3005                    t = escape_xml(line)
3006                )
3007                .unwrap();
3008            } else {
3009                writeln!(
3010                    svg,
3011                    r#"  <text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"#,
3012                    x = text_x,
3013                    y = line_y,
3014                    t = escape_xml(line)
3015                )
3016                .unwrap();
3017            }
3018        }
3019
3020        // Close message group
3021        writeln!(svg, r#"</g>"#).unwrap();
3022
3023        // Add row_height plus delay offset
3024        state.current_y += state.config.row_height + delay_offset;
3025    }
3026
3027    if create {
3028        state.current_y += state.config.row_height;
3029    }
3030
3031    state.apply_serial_first_row_gap();
3032
3033    // Handle activation
3034    if activate {
3035        state
3036            .activations
3037            .entry(to.to_string())
3038            .or_default()
3039            .push((y, None));
3040    }
3041    if deactivate {
3042        if let Some(acts) = state.activations.get_mut(from) {
3043            if let Some(act) = acts.last_mut() {
3044                if act.1.is_none() {
3045                    act.1 = Some(y);
3046                }
3047            }
3048        }
3049    }
3050}
3051
3052fn render_note(
3053    svg: &mut String,
3054    state: &mut RenderState,
3055    position: &NotePosition,
3056    participants: &[String],
3057    text: &str,
3058) {
3059    let lines: Vec<&str> = text.split("\\n").collect();
3060    let line_height = note_line_height(&state.config);
3061
3062    // Calculate note size (same padding on all sides)
3063    // Use estimate_text_width to properly handle CJK characters
3064    let note_font_size = NOTE_LINE_HEIGHT - 4.0; // 13px
3065    let text_width = estimate_text_width(text, note_font_size);
3066    let content_width = (ELEMENT_PADDING * 2.0 + text_width).max(NOTE_MIN_WIDTH);
3067    let note_height = ELEMENT_PADDING * 2.0 + lines.len() as f64 * line_height;
3068
3069    let (x, note_width, text_anchor) = match position {
3070        NotePosition::Left => {
3071            let px = state.get_x(&participants[0]);
3072            // Note right edge = px - NOTE_MARGIN
3073            let x = (px - NOTE_MARGIN - content_width).max(state.config.padding);
3074            (x, content_width, "start")
3075        }
3076        NotePosition::Right => {
3077            let px = state.get_x(&participants[0]);
3078            // Note left edge = px + NOTE_MARGIN
3079            (px + NOTE_MARGIN, content_width, "start")
3080        }
3081        NotePosition::Over => {
3082            if participants.len() == 1 {
3083                let px = state.get_x(&participants[0]);
3084                // Center on lifeline
3085                let x = (px - content_width / 2.0).max(state.config.padding);
3086                (x, content_width, "middle")
3087            } else {
3088                // Span multiple participants
3089                // Use min/max to handle any order of participants in the syntax
3090                let x1 = state.get_x(&participants[0]);
3091                let x2 = state.get_x(participants.last().unwrap());
3092                let left_x = x1.min(x2);
3093                let right_x = x1.max(x2);
3094                let span_width = (right_x - left_x) + NOTE_MARGIN * 2.0;
3095                let w = span_width.max(content_width);
3096                let x = (left_x - NOTE_MARGIN).max(state.config.padding);
3097                (x, w, "middle")
3098            }
3099        }
3100    };
3101
3102    let y = state.current_y;
3103    let fold_size = NOTE_FOLD_SIZE;
3104
3105    // Note background with dog-ear (folded corner) effect
3106    // Path: start at top-left, go right (leaving space for fold), diagonal fold, down, left, up
3107    let note_path = format!(
3108        "M {x} {y} L {x2} {y} L {x3} {y2} L {x3} {y3} L {x} {y3} Z",
3109        x = x,
3110        y = y,
3111        x2 = x + note_width - fold_size,
3112        x3 = x + note_width,
3113        y2 = y + fold_size,
3114        y3 = y + note_height
3115    );
3116
3117    writeln!(svg, r#"<path d="{path}" class="note"/>"#, path = note_path).unwrap();
3118
3119    // Draw the fold triangle (represents the folded corner)
3120    let theme = &state.config.theme;
3121    // Triangle: from fold start, to diagonal corner, to bottom of fold
3122    let fold_path = format!(
3123        "M {x1} {y1} L {x2} {y2} L {x1} {y2} Z",
3124        x1 = x + note_width - fold_size,
3125        y1 = y,
3126        x2 = x + note_width,
3127        y2 = y + fold_size
3128    );
3129
3130    writeln!(
3131        svg,
3132        r##"<path d="{path}" fill="none" stroke="{stroke}" stroke-width="1"/>"##,
3133        path = fold_path,
3134        stroke = theme.note_stroke
3135    )
3136    .unwrap();
3137
3138    // Text position (same padding on all sides)
3139    let text_x = match text_anchor {
3140        "middle" => x + note_width / 2.0,
3141        _ => x + ELEMENT_PADDING,
3142    };
3143    let text_anchor_attr = if *position == NotePosition::Over { "middle" } else { "start" };
3144
3145    for (i, line) in lines.iter().enumerate() {
3146        let text_y = y + ELEMENT_PADDING + (i as f64 + 0.8) * line_height;
3147        writeln!(
3148            svg,
3149            r#"<text x="{x}" y="{y}" class="note-text" text-anchor="{anchor}">{t}</text>"#,
3150            x = text_x,
3151            y = text_y,
3152            anchor = text_anchor_attr,
3153            t = escape_xml(line)
3154        )
3155        .unwrap();
3156    }
3157
3158    // Add spacing between elements
3159    state.current_y += note_y_advance(&state.config, lines.len());
3160}
3161
3162/// Render a state box (rounded rectangle)
3163fn render_state(svg: &mut String, state: &mut RenderState, participants: &[String], text: &str) {
3164    let theme = &state.config.theme;
3165    let lines: Vec<&str> = text.split("\\n").collect();
3166    let line_height = state_line_height(&state.config);
3167    let box_height = STATE_PADDING * 2.0 + lines.len() as f64 * line_height;
3168
3169    // Calculate box position and width
3170    let (x, box_width) = if participants.len() == 1 {
3171        let px = state.get_x(&participants[0]);
3172        let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(8);
3173        let w = (max_line_len as f64 * 8.0 + STATE_PADDING * 2.0).max(60.0);
3174        (px - w / 2.0, w)
3175    } else {
3176        let x1 = state.get_x(&participants[0]);
3177        let x2 = state.get_x(participants.last().unwrap());
3178        let span_width = (x2 - x1).abs() + state.config.participant_width * 0.6;
3179        let center = (x1 + x2) / 2.0;
3180        (center - span_width / 2.0, span_width)
3181    };
3182
3183    // Don't use item_pre_shift for states - render at current_y for consistent spacing
3184    let y = state.current_y.max(state.content_start());
3185
3186    // Draw rounded rectangle
3187    writeln!(
3188        svg,
3189        r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
3190        x = x,
3191        y = y,
3192        w = box_width,
3193        h = box_height,
3194        fill = theme.state_fill,
3195        stroke = theme.state_stroke
3196    )
3197    .unwrap();
3198
3199    // Draw text
3200    let text_x = x + box_width / 2.0;
3201    for (i, line) in lines.iter().enumerate() {
3202        let text_y = y + STATE_PADDING + (i as f64 + 0.8) * line_height;
3203        writeln!(
3204            svg,
3205            r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
3206            x = text_x,
3207            y = text_y,
3208            fill = theme.state_text_color,
3209            font = theme.font_family,
3210            size = state.config.font_size,
3211            t = escape_xml(line)
3212        )
3213        .unwrap();
3214    }
3215
3216    // Advance Y using the same calculation as state_y_advance for consistency
3217    let line_count = lines.len();
3218    state.current_y += state_y_advance(&state.config, line_count);
3219}
3220
3221/// Render a ref box (block-like style with pentagon tab)
3222fn render_ref(
3223    svg: &mut String,
3224    state: &mut RenderState,
3225    participants: &[String],
3226    text: &str,
3227    input_from: Option<&str>,
3228    input_label: Option<&str>,
3229    output_to: Option<&str>,
3230    output_label: Option<&str>,
3231) {
3232    let theme = &state.config.theme;
3233    let lines: Vec<&str> = text.split("\\n").collect();
3234    let line_height = ref_line_height(&state.config);
3235    // Add extra height for the label tab
3236    let label_height = BLOCK_LABEL_HEIGHT;
3237    let box_height = label_height + state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
3238
3239    // Calculate box position and width (span across participants)
3240    let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(15);
3241    let text_width = max_line_len as f64 * 8.0 + state.config.note_padding * 4.0;
3242
3243    let (x, box_width) = if participants.len() == 1 {
3244        let px = state.get_x(&participants[0]);
3245        let w = (text_width + 40.0).max(100.0);
3246        (px - w / 2.0, w)
3247    } else {
3248        let x1 = state.get_x(&participants[0]);
3249        let x2 = state.get_x(participants.last().unwrap());
3250        let span_width = (x2 - x1).abs() + state.config.participant_width * 0.8;
3251        // Ensure box is wide enough for text with proper margins
3252        let final_width = span_width.max(text_width);
3253        let center = (x1 + x2) / 2.0;
3254        (center - final_width / 2.0, final_width)
3255    };
3256
3257    let shift = item_pre_shift(&state.config);
3258    let y = (state.current_y - shift).max(state.content_start());
3259    let input_arrow_y = y + label_height / 2.0 + 4.0;
3260    let output_arrow_y = y + box_height - label_height / 2.0 - 4.0;
3261
3262    // Draw input signal arrow if present
3263    if let Some(from) = input_from {
3264        let from_x = state.get_x(from);
3265        // Use the edge of the ref box that is closer to the sender
3266        let to_x = if from_x < x + box_width / 2.0 {
3267            x // Left edge if sender is on the left
3268        } else {
3269            x + box_width // Right edge if sender is on the right
3270        };
3271
3272        // Calculate arrowhead
3273        let direction = arrow_direction(from_x, input_arrow_y, to_x, input_arrow_y);
3274        let arrow_points = arrowhead_points(to_x, input_arrow_y, direction);
3275        let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
3276
3277        // Draw arrow line
3278        writeln!(
3279            svg,
3280            r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message"/>"##,
3281            x1 = from_x,
3282            y = input_arrow_y,
3283            x2 = line_end_x
3284        )
3285        .unwrap();
3286
3287        // Draw arrowhead
3288        writeln!(
3289            svg,
3290            r#"<polygon points="{points}" class="arrowhead"/>"#,
3291            points = arrow_points
3292        )
3293        .unwrap();
3294
3295        // Draw label if present
3296        if let Some(label) = input_label {
3297            let text_x = (from_x + to_x) / 2.0;
3298            writeln!(
3299                svg,
3300                r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
3301                x = text_x,
3302                y = input_arrow_y - 8.0,
3303                t = escape_xml(label)
3304            )
3305            .unwrap();
3306        }
3307    }
3308
3309    // Draw block-style rectangle frame
3310    writeln!(
3311        svg,
3312        r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="block"/>"#,
3313        x = x,
3314        y = y,
3315        w = box_width,
3316        h = box_height
3317    )
3318    .unwrap();
3319
3320    // Pentagon/tab-shaped label (same style as alt/opt/loop blocks)
3321    let label_text = "ref";
3322    let tab_width = block_tab_width(label_text);
3323    let notch_size = 5.0;
3324    let label_text_offset = 16.0;
3325
3326    let pentagon_path = format!(
3327        "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x3} {y3} L {x1} {y3} Z",
3328        x1 = x,
3329        y1 = y,
3330        x2 = x + tab_width,
3331        y2 = y + label_height - notch_size,
3332        x3 = x + tab_width - notch_size,
3333        y3 = y + label_height
3334    );
3335
3336    writeln!(
3337        svg,
3338        r##"<path d="{path}" fill="{fill}" stroke="{stroke}"/>"##,
3339        path = pentagon_path,
3340        fill = theme.block_label_fill,
3341        stroke = theme.block_stroke
3342    )
3343    .unwrap();
3344
3345    // "ref" label text
3346    writeln!(
3347        svg,
3348        r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
3349        x = x + 5.0,
3350        y = y + label_text_offset,
3351        label = label_text
3352    )
3353    .unwrap();
3354
3355    // Draw text centered below the label tab
3356    let text_x = x + box_width / 2.0;
3357    for (i, line) in lines.iter().enumerate() {
3358        let text_y = y + label_height + state.config.note_padding + (i as f64 + 0.5) * line_height;
3359        writeln!(
3360            svg,
3361            r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
3362            x = text_x,
3363            y = text_y,
3364            fill = theme.ref_text_color,
3365            font = theme.font_family,
3366            size = state.config.font_size,
3367            t = escape_xml(line)
3368        )
3369        .unwrap();
3370    }
3371
3372    // Draw output signal arrow if present
3373    if let Some(to) = output_to {
3374        let to_x = state.get_x(to);
3375        // Use the edge of the ref box that is closer to the recipient
3376        let from_x = if to_x < x + box_width / 2.0 {
3377            x // Left edge if recipient is on the left
3378        } else {
3379            x + box_width // Right edge if recipient is on the right
3380        };
3381
3382        // Calculate arrowhead
3383        let direction = arrow_direction(from_x, output_arrow_y, to_x, output_arrow_y);
3384        let arrow_points = arrowhead_points(to_x, output_arrow_y, direction);
3385        let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
3386
3387        // Draw dashed arrow line (response style)
3388        writeln!(
3389            svg,
3390            r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message-dashed"/>"##,
3391            x1 = from_x,
3392            y = output_arrow_y,
3393            x2 = line_end_x
3394        )
3395        .unwrap();
3396
3397        // Draw arrowhead
3398        writeln!(
3399            svg,
3400            r#"<polygon points="{points}" class="arrowhead"/>"#,
3401            points = arrow_points
3402        )
3403        .unwrap();
3404
3405        // Draw label if present
3406        if let Some(label) = output_label {
3407            let text_x = (from_x + to_x) / 2.0;
3408            writeln!(
3409                svg,
3410                r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
3411                x = text_x,
3412                y = output_arrow_y - 8.0,
3413                t = escape_xml(label)
3414            )
3415            .unwrap();
3416        }
3417    }
3418
3419    // Advance Y using the same calculation as ref_y_advance for consistency
3420    let line_count = lines.len();
3421    state.current_y += ref_y_advance(&state.config, line_count);
3422}
3423
3424/// Render a description (extended text explanation)
3425fn render_description(svg: &mut String, state: &mut RenderState, text: &str) {
3426    let theme = &state.config.theme;
3427    let lines: Vec<&str> = text.split("\\n").collect();
3428    let line_height = state.config.font_size + 4.0;
3429
3430    // Draw text on the left side of the diagram
3431    let x = state.config.padding + 10.0;
3432    let y = state.current_y;
3433
3434    for (i, line) in lines.iter().enumerate() {
3435        let text_y = y + (i as f64 + 0.8) * line_height;
3436        writeln!(
3437            svg,
3438            r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-style="italic">{t}</text>"##,
3439            x = x,
3440            y = text_y,
3441            fill = theme.description_text_color,
3442            font = theme.font_family,
3443            size = state.config.font_size - 1.0,
3444            t = escape_xml(line)
3445        )
3446        .unwrap();
3447    }
3448
3449    state.current_y += description_y_advance(&state.config, lines.len());
3450}
3451
3452fn render_block(
3453    svg: &mut String,
3454    state: &mut RenderState,
3455    kind: &BlockKind,
3456    _label: &str,
3457    items: &[Item],
3458    else_sections: &[crate::ast::ElseSection],
3459    depth: usize,
3460) {
3461    if block_is_parallel(kind) {
3462        state.push_parallel();
3463        let start_y = state.current_y;
3464        let mut max_end_y = start_y;
3465        for item in items {
3466            state.current_y = start_y;
3467            render_items(svg, state, std::slice::from_ref(item), depth);
3468            if state.current_y > max_end_y {
3469                max_end_y = state.current_y;
3470            }
3471        }
3472        let gap = if parallel_needs_gap(items) {
3473            state.config.row_height
3474        } else {
3475            0.0
3476        };
3477        state.current_y = max_end_y + gap;
3478        state.pop_parallel();
3479        return;
3480    }
3481
3482    if matches!(kind, BlockKind::Serial) {
3483        state.push_serial_first_row_pending();
3484        render_items(svg, state, items, depth);
3485        for else_section in else_sections {
3486            render_items(svg, state, &else_section.items, depth);
3487        }
3488        state.pop_serial_first_row_pending();
3489        return;
3490    }
3491
3492    if !block_has_frame(kind) {
3493        render_items(svg, state, items, depth);
3494        for else_section in else_sections {
3495            render_items(svg, state, &else_section.items, depth);
3496        }
3497        return;
3498    }
3499
3500    // Note: Block frame, labels, and else separators are rendered by render_block_labels()
3501    // This function only handles Y position tracking and rendering of inner items
3502    // svg is still used for rendering inner items via render_items()
3503
3504    state.current_y += block_header_space(&state.config, depth);
3505
3506    // Render items
3507    render_items(svg, state, items, depth + 1);
3508
3509    // Render else sections if present
3510    for else_section in else_sections {
3511        state.push_else_return_pending();
3512        // Padding before else line (same as collect_block_backgrounds)
3513        state.current_y += block_else_before(&state.config, depth);
3514        // Padding after else line
3515        state.current_y += block_else_after(&state.config, depth);
3516        render_items(svg, state, &else_section.items, depth + 1);
3517        state.pop_else_return_pending();
3518    }
3519
3520    // Block bottom = current Y + footer padding
3521    // (ensures messages don't overflow outside the block)
3522    let end_y = state.current_y + block_footer_padding(&state.config, depth);
3523
3524    // Set current_y to end of block + margin
3525    state.current_y = end_y + state.config.row_height;
3526
3527    // Block frame, labels, and else separators are rendered later by render_block_labels()
3528    // which is called after activations are drawn, so labels appear on top
3529}
3530
3531fn render_activations(svg: &mut String, state: &mut RenderState, footer_y: f64) {
3532    for (participant, activations) in &state.activations {
3533        let x = state.get_x(participant);
3534        let box_x = x - state.config.activation_width / 2.0;
3535
3536        for (start_y, end_y) in activations {
3537            // If no end_y, extend to footer
3538            let end = end_y.unwrap_or(footer_y);
3539            let height = end - start_y;
3540
3541            if height > 0.0 {
3542                writeln!(
3543                    svg,
3544                    r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="activation"/>"#,
3545                    x = box_x,
3546                    y = start_y,
3547                    w = state.config.activation_width,
3548                    h = height
3549                )
3550                .unwrap();
3551            }
3552        }
3553    }
3554}
3555
3556/// Render destroy X marks (drawn after activations so they appear on top)
3557fn render_destroy_marks(svg: &mut String, state: &RenderState) {
3558    let size = 15.0; // X mark size
3559    for (x, y) in &state.destroy_marks {
3560        // Draw red X mark
3561        writeln!(
3562            svg,
3563            r##"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="#cc0000" stroke-width="2"/>"##,
3564            x1 = x - size,
3565            y1 = y - size,
3566            x2 = x + size,
3567            y2 = y + size
3568        )
3569        .unwrap();
3570        writeln!(
3571            svg,
3572            r##"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="#cc0000" stroke-width="2"/>"##,
3573            x1 = x + size,
3574            y1 = y - size,
3575            x2 = x - size,
3576            y2 = y + size
3577        )
3578        .unwrap();
3579    }
3580}
3581
3582fn escape_xml(s: &str) -> String {
3583    s.replace('&', "&amp;")
3584        .replace('<', "&lt;")
3585        .replace('>', "&gt;")
3586        .replace('"', "&quot;")
3587        .replace('\'', "&apos;")
3588}
3589
3590#[cfg(test)]
3591mod tests {
3592    use super::*;
3593    use crate::parser::parse;
3594
3595    #[test]
3596    fn test_render_simple() {
3597        let diagram = parse("Alice->Bob: Hello").unwrap();
3598        let svg = render(&diagram);
3599        assert!(svg.contains("<svg"));
3600        assert!(svg.contains("Alice"));
3601        assert!(svg.contains("Bob"));
3602        assert!(svg.contains("Hello"));
3603    }
3604
3605    #[test]
3606    fn test_render_with_note() {
3607        let diagram = parse("Alice->Bob: Hello\nnote over Alice: Thinking").unwrap();
3608        let svg = render(&diagram);
3609        assert!(svg.contains("Thinking"));
3610    }
3611}