1use crate::ast::*;
4use crate::theme::{LifelineStyle, ParticipantShape, Theme};
5use std::collections::HashMap;
6use std::fmt::Write;
7
8#[derive(Debug, Clone)]
10pub struct Config {
11 pub padding: f64,
13 pub left_margin: f64,
15 pub right_margin: f64,
17 pub participant_gap: f64,
19 pub header_height: f64,
21 pub row_height: f64,
23 pub participant_width: f64,
25 pub font_size: f64,
27 pub activation_width: f64,
29 pub note_padding: f64,
31 pub block_margin: f64,
33 pub title_height: f64,
35 pub theme: Theme,
37}
38
39impl Default for Config {
40 fn default() -> Self {
41 Self {
42 padding: 10.5,
43 left_margin: 40.0,
44 right_margin: 40.0,
45 participant_gap: 150.0,
46 header_height: 40.0,
47 row_height: 32.0,
48 participant_width: 90.0,
49 font_size: 12.0,
50 activation_width: 10.0,
51 note_padding: 6.0,
52 block_margin: 5.0,
53 title_height: 50.0,
54 theme: Theme::default(),
55 }
56 }
57}
58
59impl Config {
60 pub fn with_theme(mut self, theme: Theme) -> Self {
62 self.theme = theme;
63 self
64 }
65}
66
67#[derive(Debug, Clone)]
69struct BlockBackground {
70 x: f64,
71 y: f64,
72 width: f64,
73 height: f64,
74}
75
76#[derive(Debug, Clone)]
78struct BlockLabel {
79 x1: f64,
80 start_y: f64,
81 end_y: f64,
82 x2: f64,
83 kind: String,
84 label: String,
85 else_y: Option<f64>,
86}
87
88#[derive(Debug, Clone)]
89struct LabelBox {
90 x_min: f64,
91 x_max: f64,
92 y_min: f64,
93 y_max: f64,
94}
95
96struct RenderState {
98 config: Config,
99 participants: Vec<Participant>,
100 participant_x: HashMap<String, f64>,
101 participant_widths: HashMap<String, f64>,
102 current_y: f64,
103 activations: HashMap<String, Vec<(f64, Option<f64>)>>,
104 autonumber: Option<u32>,
105 destroyed: HashMap<String, f64>,
106 has_title: bool,
107 total_width: f64,
108 block_backgrounds: Vec<BlockBackground>,
110 block_labels: Vec<BlockLabel>,
112 footer_style: FooterStyle,
114 else_return_pending: Vec<bool>,
116 serial_first_row_pending: Vec<bool>,
118 parallel_depth: usize,
120 message_label_boxes: Vec<LabelBox>,
122}
123
124const TEXT_WIDTH_PADDING: f64 = 41.0;
125const TEXT_WIDTH_SCALE: f64 = 1.3;
126const MESSAGE_WIDTH_PADDING: f64 = 8.0;
127const MESSAGE_WIDTH_SCALE: f64 = 0.9;
128const DELAY_UNIT: f64 = 18.0;
129const BLOCK_LABEL_HEIGHT: f64 = 24.0;
130const BLOCK_FOOTER_PADDING_LEVEL1: f64 = 1.5;
131const BLOCK_FOOTER_PADDING_DEEP: f64 = 1.5;
132const BLOCK_FOOTER_PADDING_TOP_FACTOR: f64 = 1.8;
133const BLOCK_ELSE_SPACING_LEVEL1: f64 = 1.1875;
134const BLOCK_ELSE_SPACING_DEEP: f64 = 1.15625;
135const BLOCK_ELSE_TOP_SPACING_FACTOR: f64 = 1.15625;
136const BLOCK_NESTED_HEADER_ADJUST: f64 = 18.0;
137const BLOCK_NESTED_FRAME_SHIFT: f64 = 18.0;
138const PARALLEL_BLOCK_GAP: f64 = 22.0;
139const MESSAGE_SPACING_MULT: f64 = 0.5625;
140const SELF_MESSAGE_MIN_SPACING: f64 = 78.0;
141const SELF_MESSAGE_GAP: f64 = 16.0;
142const CREATE_MESSAGE_SPACING: f64 = 41.0;
143const DESTROY_SPACING: f64 = 15.0;
144const NOTE_PADDING: f64 = 9.5;
145const NOTE_LINE_HEIGHT_EXTRA: f64 = 6.0;
146const NOTE_MARGIN: f64 = 24.0;
147const STATE_LINE_HEIGHT_EXTRA: f64 = 11.0;
148const REF_LINE_HEIGHT_EXTRA: f64 = 16.333333;
149const ELSE_RETURN_GAP: f64 = 1.0;
150const SERIAL_FIRST_ROW_GAP: f64 = 0.0;
151const SERIAL_FIRST_ROW_PARALLEL_GAP: f64 = 1.0;
152const SERIAL_SELF_MESSAGE_ADJUST: f64 = 1.0;
153const ACTIVATION_START_GAP: f64 = 0.0;
154const ACTIVATION_CHAIN_GAP: f64 = 1.0;
155const REF_EXTRA_GAP: f64 = 2.5;
156const SELF_MESSAGE_ACTIVE_ADJUST: f64 = 1.0;
157const STATE_EXTRA_GAP: f64 = 0.0;
158const MESSAGE_LABEL_COLLISION_PADDING: f64 = 2.0;
159const MESSAGE_LABEL_COLLISION_STEP_RATIO: f64 = 0.9;
160const MESSAGE_LABEL_ASCENT_FACTOR: f64 = 0.8;
161const MESSAGE_LABEL_DESCENT_FACTOR: f64 = 0.2;
162
163fn block_header_space(config: &Config, depth: usize) -> f64 {
164 let base = config.row_height + BLOCK_LABEL_HEIGHT;
165 if depth == 0 {
166 base
167 } else {
168 (base - BLOCK_NESTED_HEADER_ADJUST).max(BLOCK_LABEL_HEIGHT)
169 }
170}
171
172fn block_frame_shift(depth: usize) -> f64 {
173 if depth == 0 {
174 0.0
175 } else if depth == 1 {
176 BLOCK_NESTED_FRAME_SHIFT
177 } else {
178 BLOCK_NESTED_FRAME_SHIFT
179 }
180}
181
182fn block_footer_padding(config: &Config, depth: usize) -> f64 {
183 let factor = if depth == 0 {
184 BLOCK_FOOTER_PADDING_TOP_FACTOR
185 } else if depth == 1 {
186 BLOCK_FOOTER_PADDING_LEVEL1
187 } else {
188 BLOCK_FOOTER_PADDING_DEEP
189 };
190 config.row_height * factor
191}
192
193fn block_else_spacing(config: &Config, depth: usize) -> f64 {
194 if depth == 0 {
195 config.row_height * BLOCK_ELSE_TOP_SPACING_FACTOR
196 } else if depth == 1 {
197 config.row_height * BLOCK_ELSE_SPACING_LEVEL1
198 } else {
199 config.row_height * BLOCK_ELSE_SPACING_DEEP
200 }
201}
202
203fn message_spacing_line_height(config: &Config) -> f64 {
204 config.row_height * MESSAGE_SPACING_MULT
205}
206
207fn self_message_spacing(config: &Config, lines: usize) -> f64 {
208 let line_height = config.font_size + 4.0;
209 let text_block_height = lines as f64 * line_height;
210 let loop_height = (text_block_height + 10.0).max(25.0);
211 let base = loop_height + SELF_MESSAGE_GAP;
212 if lines >= 3 {
213 base.max(SELF_MESSAGE_MIN_SPACING)
214 } else {
215 base
216 }
217}
218
219fn note_line_height(config: &Config) -> f64 {
220 config.font_size + NOTE_LINE_HEIGHT_EXTRA
221}
222
223fn note_padding(_config: &Config) -> f64 {
224 NOTE_PADDING
225}
226
227fn item_pre_gap(config: &Config) -> f64 {
228 config.font_size + 1.0
229}
230
231fn item_pre_shift(config: &Config) -> f64 {
232 (config.row_height - item_pre_gap(config)).max(0.0)
233}
234
235fn label_boxes_overlap(x_min: f64, x_max: f64, y_min: f64, y_max: f64, other: &LabelBox) -> bool {
236 let x_overlap = x_max >= other.x_min - MESSAGE_LABEL_COLLISION_PADDING
237 && x_min <= other.x_max + MESSAGE_LABEL_COLLISION_PADDING;
238 let y_overlap = y_max >= other.y_min - MESSAGE_LABEL_COLLISION_PADDING
239 && y_min <= other.y_max + MESSAGE_LABEL_COLLISION_PADDING;
240 x_overlap && y_overlap
241}
242
243fn actor_footer_extra(_participants: &[Participant], _config: &Config) -> f64 {
244 0.0
246}
247
248fn serial_first_row_gap(parallel_depth: usize) -> f64 {
249 if parallel_depth > 0 {
250 SERIAL_FIRST_ROW_PARALLEL_GAP
251 } else {
252 SERIAL_FIRST_ROW_GAP
253 }
254}
255
256fn state_line_height(config: &Config) -> f64 {
257 config.font_size + STATE_LINE_HEIGHT_EXTRA
258}
259
260fn ref_line_height(config: &Config) -> f64 {
261 config.font_size + REF_LINE_HEIGHT_EXTRA
262}
263
264const ARROWHEAD_SIZE: f64 = 10.0;
266
267fn arrowhead_points(x: f64, y: f64, direction: f64) -> String {
269 let size = ARROWHEAD_SIZE;
270 let half_width = size * 0.35;
271
272 let tip_x = x;
274 let tip_y = y;
275
276 let back_x = x - size * direction.cos();
278 let back_y = y - size * direction.sin();
279
280 let perp_x = -direction.sin() * half_width;
282 let perp_y = direction.cos() * half_width;
283
284 format!(
285 "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
286 back_x + perp_x,
287 back_y + perp_y,
288 tip_x,
289 tip_y,
290 back_x - perp_x,
291 back_y - perp_y
292 )
293}
294
295fn arrow_direction(x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
297 (y2 - y1).atan2(x2 - x1)
298}
299
300fn block_has_frame(kind: &BlockKind) -> bool {
301 !matches!(kind, BlockKind::Parallel | BlockKind::Serial)
302}
303
304fn block_is_parallel(kind: &BlockKind) -> bool {
305 matches!(kind, BlockKind::Parallel)
306}
307
308fn parallel_needs_gap(items: &[Item]) -> bool {
309 items.iter().any(|item| matches!(item, Item::Block { .. }))
310}
311
312fn text_char_weight(c: char) -> f64 {
313 if c.is_ascii() {
314 if c.is_uppercase() {
315 0.7
316 } else {
317 0.5
318 }
319 } else {
320 1.0 }
322}
323
324fn max_weighted_line(text: &str) -> f64 {
325 text.split("\\n")
326 .map(|line| line.chars().map(text_char_weight).sum::<f64>())
327 .fold(0.0_f64, |a, b| a.max(b))
328}
329
330fn estimate_text_width(text: &str, font_size: f64) -> f64 {
332 let weighted = max_weighted_line(text);
333 weighted * font_size * TEXT_WIDTH_SCALE + TEXT_WIDTH_PADDING
334}
335
336fn estimate_message_width(text: &str, font_size: f64) -> f64 {
337 let weighted = max_weighted_line(text);
338 weighted * font_size * MESSAGE_WIDTH_SCALE + MESSAGE_WIDTH_PADDING
339}
340
341fn block_tab_width(kind: &str) -> f64 {
342 (kind.chars().count() as f64 * 12.0 + 21.0).max(57.0)
343}
344
345fn calculate_participant_gaps(
347 participants: &[Participant],
348 items: &[Item],
349 config: &Config,
350) -> Vec<f64> {
351 if participants.len() <= 1 {
352 return vec![];
353 }
354
355 let mut participant_index: HashMap<String, usize> = HashMap::new();
357 for (i, p) in participants.iter().enumerate() {
358 participant_index.insert(p.id().to_string(), i);
359 }
360
361 let min_gap = config.participant_gap * 0.6; let mut gaps: Vec<f64> = vec![min_gap; participants.len() - 1];
364
365 fn process_items(
367 items: &[Item],
368 participant_index: &HashMap<String, usize>,
369 gaps: &mut Vec<f64>,
370 config: &Config,
371 ) {
372 for item in items {
373 match item {
374 Item::Message { from, to, text, .. } => {
375 if let (Some(&from_idx), Some(&to_idx)) =
376 (participant_index.get(from), participant_index.get(to))
377 {
378 if from_idx != to_idx {
379 let (min_idx, max_idx) = if from_idx < to_idx {
380 (from_idx, to_idx)
381 } else {
382 (to_idx, from_idx)
383 };
384
385 let text_width = estimate_message_width(text, config.font_size);
386
387 let gap_count = (max_idx - min_idx) as f64;
389 let needed_gap =
390 text_width / gap_count + config.participant_width * 0.3;
391
392 for gap_idx in min_idx..max_idx {
394 if needed_gap > gaps[gap_idx] {
395 gaps[gap_idx] = needed_gap;
396 }
397 }
398 }
399 }
400 }
401 Item::Block {
402 items, else_items, ..
403 } => {
404 process_items(items, participant_index, gaps, config);
405 if let Some(else_items) = else_items {
406 process_items(else_items, participant_index, gaps, config);
407 }
408 }
409 _ => {}
410 }
411 }
412 }
413
414 process_items(items, &participant_index, &mut gaps, config);
415
416 for i in 0..participants.len() - 1 {
418 let name1_width = estimate_message_width(&participants[i].name, config.font_size);
419 let name2_width = estimate_message_width(&participants[i + 1].name, config.font_size);
420 let needed_for_names = (name1_width + name2_width) / 2.0 + 20.0;
421 if needed_for_names > gaps[i] {
422 gaps[i] = needed_for_names;
423 }
424 }
425
426 let max_gap = config.participant_gap * 3.0;
428 for gap in &mut gaps {
429 if *gap > max_gap {
430 *gap = max_gap;
431 }
432 }
433
434 gaps
435}
436
437impl RenderState {
438 fn new(
439 config: Config,
440 participants: Vec<Participant>,
441 items: &[Item],
442 has_title: bool,
443 footer_style: FooterStyle,
444 ) -> Self {
445 let mut config = config;
446 let line_height = config.font_size + 2.0;
447 let mut required_header_height = config.header_height;
448 for p in &participants {
449 let lines = p.name.split("\\n").count();
450 let total_height = lines as f64 * line_height;
451 let needed = match p.kind {
452 ParticipantKind::Participant => total_height + 10.0,
453 ParticipantKind::Actor => {
454 let figure_height = 38.0;
456 let name_height = lines as f64 * line_height;
457 figure_height + name_height + 15.0
458 }
459 };
460 if needed > required_header_height {
461 required_header_height = needed;
462 }
463 }
464 if required_header_height > config.header_height {
465 config.header_height = required_header_height;
466 }
467 let mut participant_widths: HashMap<String, f64> = HashMap::new();
469 let min_width = config.participant_width;
470
471 for p in &participants {
472 let text_width = estimate_text_width(&p.name, config.font_size);
473 let width = (text_width + 8.0).max(min_width);
474 participant_widths.insert(p.id().to_string(), width);
475 }
476
477 let gaps = calculate_participant_gaps(&participants, items, &config);
478
479 let left_margin = config.left_margin;
481 let right_margin = config.right_margin;
483
484 let mut participant_x = HashMap::new();
485 let first_width = participants
486 .first()
487 .map(|p| *participant_widths.get(p.id()).unwrap_or(&min_width))
488 .unwrap_or(min_width);
489 let mut current_x = config.padding + left_margin + first_width / 2.0;
490
491 for (i, p) in participants.iter().enumerate() {
492 participant_x.insert(p.id().to_string(), current_x);
493 if i < gaps.len() {
494 let current_width = *participant_widths.get(p.id()).unwrap_or(&min_width);
495 let next_width = participants
496 .get(i + 1)
497 .map(|np| *participant_widths.get(np.id()).unwrap_or(&min_width))
498 .unwrap_or(min_width);
499 let actual_gap = gaps[i].max((current_width + next_width) / 2.0 + 30.0);
501 current_x += actual_gap;
502 }
503 }
504
505 let last_width = participants
506 .last()
507 .map(|p| *participant_widths.get(p.id()).unwrap_or(&min_width))
508 .unwrap_or(min_width);
509 let total_width = current_x + last_width / 2.0 + right_margin + config.padding;
510
511 Self {
512 config,
513 participants,
514 participant_x,
515 participant_widths,
516 current_y: 0.0,
517 activations: HashMap::new(),
518 autonumber: None,
519 destroyed: HashMap::new(),
520 has_title,
521 total_width,
522 block_backgrounds: Vec::new(),
523 block_labels: Vec::new(),
524 footer_style,
525 else_return_pending: Vec::new(),
526 serial_first_row_pending: Vec::new(),
527 parallel_depth: 0,
528 message_label_boxes: Vec::new(),
529 }
530 }
531
532 fn get_participant_width(&self, name: &str) -> f64 {
533 *self
534 .participant_widths
535 .get(name)
536 .unwrap_or(&self.config.participant_width)
537 }
538
539 fn get_x(&self, name: &str) -> f64 {
540 *self.participant_x.get(name).unwrap_or(&0.0)
541 }
542
543 fn push_else_return_pending(&mut self) {
544 self.else_return_pending.push(true);
545 }
546
547 fn pop_else_return_pending(&mut self) {
548 self.else_return_pending.pop();
549 }
550
551 fn apply_else_return_gap(&mut self, arrow: &Arrow) {
552 if let Some(pending) = self.else_return_pending.last_mut() {
553 if *pending && matches!(arrow.line, LineStyle::Dashed) {
554 self.current_y += ELSE_RETURN_GAP;
555 *pending = false;
556 }
557 }
558 }
559
560 fn push_serial_first_row_pending(&mut self) {
561 self.serial_first_row_pending.push(true);
562 }
563
564 fn pop_serial_first_row_pending(&mut self) {
565 self.serial_first_row_pending.pop();
566 }
567
568 fn in_serial_block(&self) -> bool {
569 !self.serial_first_row_pending.is_empty()
570 }
571
572 fn apply_serial_first_row_gap(&mut self) {
573 if let Some(pending) = self.serial_first_row_pending.last_mut() {
574 if *pending {
575 self.current_y += serial_first_row_gap(self.parallel_depth);
576 *pending = false;
577 }
578 }
579 }
580
581 fn reserve_message_label(
582 &mut self,
583 x_min: f64,
584 x_max: f64,
585 mut y_min: f64,
586 mut y_max: f64,
587 step: f64,
588 ) -> f64 {
589 let mut offset = 0.0;
590 let mut attempts = 0;
591 while self
592 .message_label_boxes
593 .iter()
594 .any(|b| label_boxes_overlap(x_min, x_max, y_min, y_max, b))
595 && attempts < 20
596 {
597 y_min += step;
598 y_max += step;
599 offset += step;
600 attempts += 1;
601 }
602 self.message_label_boxes.push(LabelBox {
603 x_min,
604 x_max,
605 y_min,
606 y_max,
607 });
608 offset
609 }
610
611 fn push_parallel(&mut self) {
612 self.parallel_depth += 1;
613 }
614
615 fn pop_parallel(&mut self) {
616 if self.parallel_depth > 0 {
617 self.parallel_depth -= 1;
618 }
619 }
620
621 fn active_activation_count(&self) -> usize {
622 self.activations
623 .values()
624 .map(|acts| acts.iter().filter(|(_, end)| end.is_none()).count())
625 .sum()
626 }
627
628 fn diagram_width(&self) -> f64 {
629 self.total_width
630 }
631
632 fn leftmost_x(&self) -> f64 {
634 self.participants
635 .first()
636 .map(|p| self.get_x(p.id()))
637 .unwrap_or(self.config.padding)
638 }
639
640 fn rightmost_x(&self) -> f64 {
642 self.participants
643 .last()
644 .map(|p| self.get_x(p.id()))
645 .unwrap_or(self.total_width - self.config.padding)
646 }
647
648 fn block_left(&self) -> f64 {
650 let leftmost_width = self
651 .participants
652 .first()
653 .map(|p| self.get_participant_width(p.id()))
654 .unwrap_or(self.config.participant_width);
655 self.leftmost_x() - leftmost_width / 2.0 - self.config.block_margin
656 }
657
658 fn block_right(&self) -> f64 {
660 let rightmost_width = self
661 .participants
662 .last()
663 .map(|p| self.get_participant_width(p.id()))
664 .unwrap_or(self.config.participant_width);
665 self.rightmost_x() + rightmost_width / 2.0 + self.config.block_margin
666 }
667
668 fn header_top(&self) -> f64 {
669 if self.has_title {
670 self.config.padding + self.config.title_height
671 } else {
672 self.config.padding
673 }
674 }
675
676 fn content_start(&self) -> f64 {
677 self.header_top() + self.config.header_height + self.config.row_height
678 }
679
680 fn next_number(&mut self) -> Option<u32> {
681 self.autonumber.map(|n| {
682 self.autonumber = Some(n + 1);
683 n
684 })
685 }
686
687 fn add_block_background(&mut self, x: f64, y: f64, width: f64, height: f64) {
689 self.block_backgrounds.push(BlockBackground {
690 x,
691 y,
692 width,
693 height,
694 });
695 }
696
697 fn add_block_label(
699 &mut self,
700 x1: f64,
701 start_y: f64,
702 end_y: f64,
703 x2: f64,
704 kind: &str,
705 label: &str,
706 else_y: Option<f64>,
707 ) {
708 self.block_labels.push(BlockLabel {
709 x1,
710 start_y,
711 end_y,
712 x2,
713 kind: kind.to_string(),
714 label: label.to_string(),
715 else_y,
716 });
717 }
718}
719
720fn find_involved_participants(items: &[Item], state: &RenderState) -> Option<(f64, f64, bool)> {
722 let mut min_left: Option<f64> = None;
723 let mut max_right: Option<f64> = None;
724 let leftmost_id = state.participants.first().map(|p| p.id()).unwrap_or("");
725 let mut includes_leftmost = false;
726
727 fn update_bounds(
728 participant: &str,
729 state: &RenderState,
730 min_left: &mut Option<f64>,
731 max_right: &mut Option<f64>,
732 includes_leftmost: &mut bool,
733 leftmost_id: &str,
734 ) {
735 let x = state.get_x(participant);
736 if x > 0.0 {
737 let width = state.get_participant_width(participant);
738 let left = x - width / 2.0;
739 let right = x + width / 2.0;
740 *min_left = Some(min_left.map_or(left, |m| m.min(left)));
741 *max_right = Some(max_right.map_or(right, |m| m.max(right)));
742 if participant == leftmost_id {
743 *includes_leftmost = true;
744 }
745 }
746 }
747
748 fn process_items(
749 items: &[Item],
750 state: &RenderState,
751 min_left: &mut Option<f64>,
752 max_right: &mut Option<f64>,
753 includes_leftmost: &mut bool,
754 leftmost_id: &str,
755 ) {
756 for item in items {
757 match item {
758 Item::Message { from, to, .. } => {
759 update_bounds(
760 from,
761 state,
762 min_left,
763 max_right,
764 includes_leftmost,
765 leftmost_id,
766 );
767 update_bounds(
768 to,
769 state,
770 min_left,
771 max_right,
772 includes_leftmost,
773 leftmost_id,
774 );
775 }
776 Item::Note { participants, .. } => {
777 for p in participants {
778 update_bounds(
779 p,
780 state,
781 min_left,
782 max_right,
783 includes_leftmost,
784 leftmost_id,
785 );
786 }
787 }
788 Item::Block {
789 items, else_items, ..
790 } => {
791 process_items(
792 items,
793 state,
794 min_left,
795 max_right,
796 includes_leftmost,
797 leftmost_id,
798 );
799 if let Some(else_items) = else_items {
800 process_items(
801 else_items,
802 state,
803 min_left,
804 max_right,
805 includes_leftmost,
806 leftmost_id,
807 );
808 }
809 }
810 Item::Activate { participant }
811 | Item::Deactivate { participant }
812 | Item::Destroy { participant } => {
813 update_bounds(
814 participant,
815 state,
816 min_left,
817 max_right,
818 includes_leftmost,
819 leftmost_id,
820 );
821 }
822 _ => {}
823 }
824 }
825 }
826
827 process_items(
828 items,
829 state,
830 &mut min_left,
831 &mut max_right,
832 &mut includes_leftmost,
833 leftmost_id,
834 );
835
836 match (min_left, max_right) {
837 (Some(min), Some(max)) => Some((min, max, includes_leftmost)),
838 _ => None,
839 }
840}
841
842fn calculate_block_bounds_with_label(
844 items: &[Item],
845 else_items: Option<&[Item]>,
846 label: &str,
847 kind: &str,
848 depth: usize,
849 state: &RenderState,
850) -> (f64, f64) {
851 let mut all_items: Vec<&Item> = items.iter().collect();
852 if let Some(else_items) = else_items {
853 all_items.extend(else_items.iter());
854 }
855
856 let items_slice: Vec<Item> = all_items.into_iter().cloned().collect();
858
859 let (base_x1, base_x2, includes_leftmost) =
860 if let Some((min_left, max_right, includes_leftmost)) =
861 find_involved_participants(&items_slice, state)
862 {
863 let margin = state.config.block_margin;
864 (min_left - margin, max_right + margin, includes_leftmost)
865 } else {
866 (state.block_left(), state.block_right(), false)
868 };
869
870 let pentagon_width = block_tab_width(kind);
873 let label_font_size = state.config.font_size - 1.0;
874 let label_padding_x = 6.0;
875 let condition_width = if label.is_empty() {
876 0.0
877 } else {
878 let condition_text = format!("[{}]", label);
879 let base_width =
880 (estimate_text_width(&condition_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
881 base_width + label_padding_x * 2.0
882 };
883 let min_label_width = pentagon_width + 8.0 + condition_width + 20.0; let current_width = base_x2 - base_x1;
887 let (mut x1, mut x2) = if current_width < min_label_width {
888 (base_x1, base_x1 + min_label_width)
890 } else {
891 (base_x1, base_x2)
892 };
893
894 let nested_padding = depth as f64 * 20.0;
896 if nested_padding > 0.0 {
897 let available = x2 - x1;
898 let max_padding = ((available - min_label_width) / 2.0).max(0.0);
899 let inset = nested_padding.min(max_padding);
900 x1 += inset;
901 x2 -= inset;
902 }
903
904 if depth == 0 && includes_leftmost {
905 x1 = x1.min(state.config.padding);
906 }
907
908 (x1, x2)
909}
910
911fn collect_block_backgrounds(
913 state: &mut RenderState,
914 items: &[Item],
915 depth: usize,
916 active_activation_count: &mut usize,
917) {
918 for item in items {
919 match item {
920 Item::Message {
921 text,
922 from,
923 to,
924 arrow,
925 activate,
926 deactivate,
927 create,
928 ..
929 } => {
930 state.apply_else_return_gap(arrow);
931 let chain_gap = if *activate && depth == 0 && *active_activation_count == 1 {
932 ACTIVATION_CHAIN_GAP
933 } else {
934 0.0
935 };
936 let is_self = from == to;
937 let lines: Vec<&str> = text.split("\\n").collect();
938 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
939
940 if is_self {
941 let mut spacing = self_message_spacing(&state.config, lines.len());
942 if state.in_serial_block() {
943 spacing -= SERIAL_SELF_MESSAGE_ADJUST;
944 }
945 if *active_activation_count > 0 {
946 spacing -= SELF_MESSAGE_ACTIVE_ADJUST;
947 }
948 state.current_y += spacing;
949 } else {
950 let spacing_line_height = message_spacing_line_height(&state.config);
951 let extra_height = if lines.len() > 1 {
952 (lines.len() - 1) as f64 * spacing_line_height
953 } else {
954 0.0
955 };
956 if lines.len() > 1 {
957 state.current_y += extra_height;
958 }
959 state.current_y += state.config.row_height + delay_offset;
960 }
961
962 if *create {
963 state.current_y += CREATE_MESSAGE_SPACING;
964 }
965
966 state.apply_serial_first_row_gap();
967
968 if *activate && depth == 0 {
969 state.current_y += ACTIVATION_START_GAP;
970 }
971 if chain_gap > 0.0 {
972 state.current_y += chain_gap;
973 }
974 if *activate {
975 *active_activation_count += 1;
976 }
977 if *deactivate && *active_activation_count > 0 {
978 *active_activation_count -= 1;
979 }
980 }
981 Item::Note { text, .. } => {
982 let lines: Vec<&str> = text.split("\\n").collect();
983 let line_height = note_line_height(&state.config);
984 let note_height =
985 note_padding(&state.config) * 2.0 + lines.len() as f64 * line_height;
986 state.current_y += note_height.max(state.config.row_height) + NOTE_MARGIN;
987 }
988 Item::State { text, .. } => {
989 let lines: Vec<&str> = text.split("\\n").collect();
990 let line_height = state_line_height(&state.config);
991 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
992 state.current_y += box_height + item_pre_gap(&state.config) + STATE_EXTRA_GAP;
993 }
994 Item::Ref { text, .. } => {
995 let lines: Vec<&str> = text.split("\\n").collect();
996 let line_height = ref_line_height(&state.config);
997 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
998 state.current_y += box_height + item_pre_gap(&state.config) + REF_EXTRA_GAP;
999 }
1000 Item::Description { text } => {
1001 let lines: Vec<&str> = text.split("\\n").collect();
1002 let line_height = state.config.font_size + 4.0;
1003 state.current_y += lines.len() as f64 * line_height + 10.0;
1004 }
1005 Item::Destroy { .. } => {
1006 state.current_y += DESTROY_SPACING;
1007 }
1008 Item::Activate { .. } => {
1009 *active_activation_count += 1;
1010 }
1011 Item::Deactivate { .. } => {
1012 if *active_activation_count > 0 {
1013 *active_activation_count -= 1;
1014 }
1015 }
1016 Item::Block {
1017 kind,
1018 label,
1019 items,
1020 else_items,
1021 } => {
1022 if block_is_parallel(kind) {
1023 state.push_parallel();
1024 let start_y = state.current_y;
1025 let mut max_end_y = start_y;
1026 let start_activation_count = *active_activation_count;
1027 for item in items {
1028 state.current_y = start_y;
1029 *active_activation_count = start_activation_count;
1030 collect_block_backgrounds(
1031 state,
1032 std::slice::from_ref(item),
1033 depth,
1034 active_activation_count,
1035 );
1036 if state.current_y > max_end_y {
1037 max_end_y = state.current_y;
1038 }
1039 }
1040 *active_activation_count = start_activation_count;
1041 let gap = if parallel_needs_gap(items) {
1042 PARALLEL_BLOCK_GAP
1043 } else {
1044 0.0
1045 };
1046 state.current_y = max_end_y + gap;
1047 state.pop_parallel();
1048 continue;
1049 }
1050
1051 if matches!(kind, BlockKind::Serial) {
1052 state.push_serial_first_row_pending();
1053 collect_block_backgrounds(state, items, depth, active_activation_count);
1054 if let Some(else_items) = else_items {
1055 collect_block_backgrounds(
1056 state,
1057 else_items,
1058 depth,
1059 active_activation_count,
1060 );
1061 }
1062 state.pop_serial_first_row_pending();
1063 continue;
1064 }
1065
1066 if !block_has_frame(kind) {
1067 collect_block_backgrounds(state, items, depth, active_activation_count);
1068 if let Some(else_items) = else_items {
1069 collect_block_backgrounds(
1070 state,
1071 else_items,
1072 depth,
1073 active_activation_count,
1074 );
1075 }
1076 continue;
1077 }
1078
1079 let start_y = state.current_y;
1080 let frame_shift = block_frame_shift(depth);
1081 let frame_start_y = start_y - frame_shift;
1082
1083 let (x1, x2) = calculate_block_bounds_with_label(
1085 items,
1086 else_items.as_deref(),
1087 label,
1088 kind.as_str(),
1089 depth,
1090 state,
1091 );
1092
1093 state.current_y += block_header_space(&state.config, depth);
1094 collect_block_backgrounds(state, items, depth + 1, active_activation_count);
1095
1096 let else_y = if else_items.is_some() {
1097 Some(state.current_y)
1098 } else {
1099 None
1100 };
1101
1102 if let Some(else_items) = else_items {
1103 state.push_else_return_pending();
1104 state.current_y += block_else_spacing(&state.config, depth);
1105 collect_block_backgrounds(
1106 state,
1107 else_items,
1108 depth + 1,
1109 active_activation_count,
1110 );
1111 state.pop_else_return_pending();
1112 }
1113
1114 let end_y = state.current_y - state.config.row_height
1115 + block_footer_padding(&state.config, depth);
1116 let frame_end_y = end_y - frame_shift;
1117 state.current_y = end_y + state.config.row_height * 1.0;
1118
1119 state.add_block_background(x1, frame_start_y, x2 - x1, frame_end_y - frame_start_y);
1121 state.add_block_label(
1123 x1,
1124 frame_start_y,
1125 frame_end_y,
1126 x2,
1127 kind.as_str(),
1128 label,
1129 else_y,
1130 );
1131 }
1132 _ => {}
1133 }
1134 }
1135}
1136
1137fn render_block_backgrounds(svg: &mut String, state: &RenderState) {
1139 let theme = &state.config.theme;
1140 for bg in &state.block_backgrounds {
1141 writeln!(
1142 svg,
1143 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="none"/>"##,
1144 x = bg.x,
1145 y = bg.y,
1146 w = bg.width,
1147 h = bg.height,
1148 fill = theme.block_fill
1149 )
1150 .unwrap();
1151 }
1152}
1153
1154fn render_block_labels(svg: &mut String, state: &RenderState) {
1157 let theme = &state.config.theme;
1158
1159 for bl in &state.block_labels {
1160 let x1 = bl.x1;
1161 let x2 = bl.x2;
1162 let start_y = bl.start_y;
1163 let end_y = bl.end_y;
1164
1165 writeln!(
1167 svg,
1168 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="block"/>"#,
1169 x = x1,
1170 y = start_y,
1171 w = x2 - x1,
1172 h = end_y - start_y
1173 )
1174 .unwrap();
1175
1176 let label_text = &bl.kind;
1178 let label_width = block_tab_width(label_text);
1179 let label_height = BLOCK_LABEL_HEIGHT;
1180 let label_text_offset = 16.0;
1181 let notch_size = 5.0;
1182 let label_font_size = state.config.font_size - 1.0;
1183 let label_padding_x = 6.0;
1184
1185 let pentagon_path = format!(
1187 "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x3} {y3} L {x1} {y3} Z",
1188 x1 = x1,
1189 y1 = start_y,
1190 x2 = x1 + label_width,
1191 y2 = start_y + label_height - notch_size,
1192 x3 = x1 + label_width - notch_size,
1193 y3 = start_y + label_height
1194 );
1195
1196 writeln!(
1197 svg,
1198 r##"<path d="{path}" fill="{fill}" stroke="{stroke}"/>"##,
1199 path = pentagon_path,
1200 fill = theme.block_label_fill,
1201 stroke = theme.block_stroke
1202 )
1203 .unwrap();
1204
1205 writeln!(
1207 svg,
1208 r#"<text x="{x}" y="{y}" class="block-label">{kind}</text>"#,
1209 x = x1 + 5.0,
1210 y = start_y + label_text_offset,
1211 kind = label_text
1212 )
1213 .unwrap();
1214
1215 if !bl.label.is_empty() {
1217 let condition_text = format!("[{}]", bl.label);
1218 let text_x = x1 + label_width + 8.0;
1219 let text_y = start_y + label_text_offset;
1220 let base_width = (estimate_text_width(&condition_text, label_font_size)
1221 - TEXT_WIDTH_PADDING)
1222 .max(0.0);
1223 let bg_width = base_width + label_padding_x * 2.0;
1224
1225 writeln!(
1226 svg,
1227 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="{stroke}" stroke-width="1"/>"##,
1228 x = text_x - label_padding_x,
1229 y = start_y,
1230 w = bg_width,
1231 h = label_height,
1232 fill = theme.block_label_fill,
1233 stroke = theme.block_stroke
1234 )
1235 .unwrap();
1236
1237 writeln!(
1238 svg,
1239 r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1240 x = text_x,
1241 y = text_y,
1242 label = escape_xml(&condition_text)
1243 )
1244 .unwrap();
1245 }
1246
1247 if let Some(else_y) = bl.else_y {
1249 writeln!(
1250 svg,
1251 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-dasharray="5,3"/>"##,
1252 x1 = x1,
1253 y = else_y,
1254 x2 = x2,
1255 c = theme.block_stroke
1256 )
1257 .unwrap();
1258
1259 let else_text = "[else]";
1260 let else_text_x = x1 + 4.0;
1261 let else_base_width =
1262 (estimate_text_width(else_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
1263 let else_bg_width = else_base_width + label_padding_x * 2.0;
1264 let else_rect_y = else_y - label_height;
1265 let else_text_y = else_rect_y + label_text_offset;
1266
1267 writeln!(
1268 svg,
1269 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="{stroke}" stroke-width="1"/>"##,
1270 x = else_text_x - label_padding_x,
1271 y = else_rect_y,
1272 w = else_bg_width,
1273 h = label_height,
1274 fill = theme.block_label_fill,
1275 stroke = theme.block_stroke
1276 )
1277 .unwrap();
1278
1279 writeln!(
1280 svg,
1281 r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1282 x = else_text_x,
1283 y = else_text_y,
1284 label = else_text
1285 )
1286 .unwrap();
1287 }
1288 }
1289}
1290
1291pub fn render(diagram: &Diagram) -> String {
1293 render_with_config(diagram, Config::default())
1294}
1295
1296pub fn render_with_config(diagram: &Diagram, config: Config) -> String {
1298 let participants = diagram.participants();
1299 let has_title = diagram.title.is_some();
1300 let footer_style = diagram.options.footer;
1301 let mut state = RenderState::new(
1302 config,
1303 participants,
1304 &diagram.items,
1305 has_title,
1306 footer_style,
1307 );
1308 let mut svg = String::new();
1309
1310 let content_height = calculate_height(&diagram.items, &state.config, 0);
1312 let title_space = if has_title {
1313 state.config.title_height
1314 } else {
1315 0.0
1316 };
1317 let footer_space = match footer_style {
1318 FooterStyle::Box => state.config.header_height,
1319 FooterStyle::Bar | FooterStyle::None => 0.0,
1320 };
1321 let footer_label_extra = match footer_style {
1322 FooterStyle::Box => actor_footer_extra(&state.participants, &state.config),
1323 FooterStyle::Bar | FooterStyle::None => 0.0,
1324 };
1325 let footer_margin = state.config.row_height; let base_total_height = state.config.padding * 2.0
1327 + title_space
1328 + state.config.header_height
1329 + content_height
1330 + footer_margin
1331 + footer_space;
1332 let total_height = base_total_height + footer_label_extra;
1333 let total_width = state.diagram_width();
1334
1335 writeln!(
1337 &mut svg,
1338 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}">"#,
1339 w = total_width,
1340 h = total_height
1341 )
1342 .unwrap();
1343
1344 let theme = &state.config.theme;
1346 let lifeline_dash = match theme.lifeline_style {
1347 LifelineStyle::Dashed => "stroke-dasharray: 5,5;",
1348 LifelineStyle::Solid => "",
1349 };
1350
1351 svg.push_str("<defs>\n");
1352 svg.push_str("<style>\n");
1353 writeln!(
1354 &mut svg,
1355 ".participant {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1356 fill = theme.participant_fill,
1357 stroke = theme.participant_stroke
1358 )
1359 .unwrap();
1360 writeln!(
1361 &mut svg,
1362 ".participant-text {{ font-family: {f}; font-size: {s}px; text-anchor: middle; dominant-baseline: middle; fill: {c}; }}",
1363 f = theme.font_family,
1364 s = state.config.font_size,
1365 c = theme.participant_text
1366 )
1367 .unwrap();
1368 writeln!(
1369 &mut svg,
1370 ".lifeline {{ stroke: {c}; stroke-width: 1; {dash} }}",
1371 c = theme.lifeline_color,
1372 dash = lifeline_dash
1373 )
1374 .unwrap();
1375 writeln!(
1376 &mut svg,
1377 ".message {{ stroke: {c}; stroke-width: 1.5; fill: none; }}",
1378 c = theme.message_color
1379 )
1380 .unwrap();
1381 writeln!(
1382 &mut svg,
1383 ".message-dashed {{ stroke: {c}; stroke-width: 1.5; fill: none; stroke-dasharray: 5,3; }}",
1384 c = theme.message_color
1385 )
1386 .unwrap();
1387 writeln!(
1388 &mut svg,
1389 ".message-text {{ font-family: {f}; font-size: {s}px; fill: {c}; stroke: none; }}",
1390 f = theme.font_family,
1391 s = state.config.font_size,
1392 c = theme.message_text_color
1393 )
1394 .unwrap();
1395 writeln!(
1396 &mut svg,
1397 ".note {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1398 fill = theme.note_fill,
1399 stroke = theme.note_stroke
1400 )
1401 .unwrap();
1402 writeln!(
1403 &mut svg,
1404 ".note-text {{ font-family: {f}; font-size: {s}px; fill: {c}; }}",
1405 f = theme.font_family,
1406 s = state.config.font_size - 1.0,
1407 c = theme.note_text_color
1408 )
1409 .unwrap();
1410 writeln!(
1411 &mut svg,
1412 ".block {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1413 c = theme.block_stroke
1414 )
1415 .unwrap();
1416 writeln!(
1417 &mut svg,
1418 ".block-label {{ font-family: {f}; font-size: {s}px; font-weight: bold; fill: {c}; }}",
1419 f = theme.font_family,
1420 s = state.config.font_size - 1.0,
1421 c = theme.message_text_color
1422 )
1423 .unwrap();
1424 writeln!(
1425 &mut svg,
1426 ".activation {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1427 fill = theme.activation_fill,
1428 stroke = theme.activation_stroke
1429 )
1430 .unwrap();
1431 writeln!(
1432 &mut svg,
1433 ".actor-head {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1434 fill = theme.actor_fill,
1435 stroke = theme.actor_stroke
1436 )
1437 .unwrap();
1438 writeln!(
1439 &mut svg,
1440 ".actor-body {{ stroke: {c}; stroke-width: 2; fill: none; }}",
1441 c = theme.actor_stroke
1442 )
1443 .unwrap();
1444 writeln!(
1445 &mut svg,
1446 ".title {{ font-family: {f}; font-size: {s}px; font-weight: bold; text-anchor: middle; fill: {c}; }}",
1447 f = theme.font_family,
1448 s = state.config.font_size + 4.0,
1449 c = theme.message_text_color
1450 )
1451 .unwrap();
1452 writeln!(
1454 &mut svg,
1455 ".arrowhead {{ fill: {c}; stroke: none; }}",
1456 c = theme.message_color
1457 )
1458 .unwrap();
1459 writeln!(
1460 &mut svg,
1461 ".arrowhead-open {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1462 c = theme.message_color
1463 )
1464 .unwrap();
1465 svg.push_str("</style>\n");
1466 svg.push_str("</defs>\n");
1467
1468 writeln!(
1470 &mut svg,
1471 r##"<rect width="100%" height="100%" fill="{bg}"/>"##,
1472 bg = theme.background
1473 )
1474 .unwrap();
1475
1476 if let Some(title) = &diagram.title {
1478 let title_y = state.config.padding + state.config.font_size + 10.0;
1479 writeln!(
1480 &mut svg,
1481 r#"<text x="{x}" y="{y}" class="title">{t}</text>"#,
1482 x = total_width / 2.0,
1483 y = title_y,
1484 t = escape_xml(title)
1485 )
1486 .unwrap();
1487 }
1488
1489 let header_y = state.header_top();
1491 let footer_y = match footer_style {
1492 FooterStyle::Box => base_total_height - state.config.padding - state.config.header_height,
1493 FooterStyle::Bar | FooterStyle::None => total_height - state.config.padding,
1494 };
1495
1496 state.current_y = state.content_start();
1498 let mut active_activation_count = 0;
1499 collect_block_backgrounds(&mut state, &diagram.items, 0, &mut active_activation_count);
1500
1501 render_block_backgrounds(&mut svg, &state);
1503
1504 state.current_y = state.content_start();
1506
1507 let lifeline_start = header_y + state.config.header_height;
1509 let lifeline_end = footer_y;
1510
1511 for p in &state.participants {
1512 let x = state.get_x(p.id());
1513 writeln!(
1514 &mut svg,
1515 r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="lifeline"/>"#,
1516 x = x,
1517 y1 = lifeline_start,
1518 y2 = lifeline_end
1519 )
1520 .unwrap();
1521 }
1522
1523 render_participant_headers(&mut svg, &state, header_y);
1525
1526 state.current_y = state.content_start();
1528 render_items(&mut svg, &mut state, &diagram.items, 0);
1529
1530 render_activations(&mut svg, &mut state, footer_y);
1532
1533 render_block_labels(&mut svg, &state);
1535
1536 match state.footer_style {
1538 FooterStyle::Box => {
1539 render_participant_headers(&mut svg, &state, footer_y);
1540 }
1541 FooterStyle::Bar => {
1542 let left = state.leftmost_x()
1544 - state.get_participant_width(
1545 state.participants.first().map(|p| p.id()).unwrap_or(""),
1546 ) / 2.0;
1547 let right = state.rightmost_x()
1548 + state
1549 .get_participant_width(state.participants.last().map(|p| p.id()).unwrap_or(""))
1550 / 2.0;
1551 writeln!(
1552 &mut svg,
1553 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-width="1"/>"##,
1554 x1 = left,
1555 y = footer_y,
1556 x2 = right,
1557 c = state.config.theme.lifeline_color
1558 )
1559 .unwrap();
1560 }
1561 FooterStyle::None => {
1562 }
1564 }
1565
1566 svg.push_str("</svg>\n");
1567 svg
1568}
1569
1570fn calculate_height(items: &[Item], config: &Config, depth: usize) -> f64 {
1571 fn inner(
1572 items: &[Item],
1573 config: &Config,
1574 depth: usize,
1575 else_pending: &mut Vec<bool>,
1576 serial_pending: &mut Vec<bool>,
1577 active_activation_count: &mut usize,
1578 parallel_depth: &mut usize,
1579 ) -> f64 {
1580 let mut height = 0.0;
1581 let line_height = config.font_size + 4.0;
1582 for item in items {
1583 match item {
1584 Item::Message {
1585 from,
1586 to,
1587 text,
1588 arrow,
1589 create,
1590 activate,
1591 deactivate,
1592 ..
1593 } => {
1594 if let Some(pending) = else_pending.last_mut() {
1595 if *pending && matches!(arrow.line, LineStyle::Dashed) {
1596 height += ELSE_RETURN_GAP;
1597 *pending = false;
1598 }
1599 }
1600 let chain_gap = if *activate && depth == 0 && *active_activation_count == 1 {
1601 ACTIVATION_CHAIN_GAP
1602 } else {
1603 0.0
1604 };
1605 let is_self = from == to;
1606 let lines = text.split("\\n").count();
1607 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
1608 if is_self {
1609 let mut spacing = self_message_spacing(config, lines);
1610 if !serial_pending.is_empty() {
1611 spacing -= SERIAL_SELF_MESSAGE_ADJUST;
1612 }
1613 if *active_activation_count > 0 {
1614 spacing -= SELF_MESSAGE_ACTIVE_ADJUST;
1615 }
1616 height += spacing;
1617 } else {
1618 let spacing_line_height = message_spacing_line_height(config);
1619 height += config.row_height
1620 + (lines.saturating_sub(1)) as f64 * spacing_line_height
1621 + delay_offset;
1622 }
1623 if *create {
1624 height += CREATE_MESSAGE_SPACING;
1625 }
1626 if let Some(pending) = serial_pending.last_mut() {
1627 if *pending {
1628 height += serial_first_row_gap(*parallel_depth);
1629 *pending = false;
1630 }
1631 }
1632 if *activate && depth == 0 {
1633 height += ACTIVATION_START_GAP;
1634 }
1635 height += chain_gap;
1636 if *activate {
1637 *active_activation_count += 1;
1638 }
1639 if *deactivate && *active_activation_count > 0 {
1640 *active_activation_count -= 1;
1641 }
1642 }
1643 Item::Note { text, .. } => {
1644 let lines = text.split("\\n").count();
1645 let note_height =
1646 note_padding(config) * 2.0 + lines as f64 * note_line_height(config);
1647 height += note_height.max(config.row_height) + NOTE_MARGIN;
1648 }
1649 Item::State { text, .. } => {
1650 let lines = text.split("\\n").count();
1651 let box_height =
1652 config.note_padding * 2.0 + lines as f64 * state_line_height(config);
1653 height += box_height + item_pre_gap(config) + STATE_EXTRA_GAP;
1654 }
1655 Item::Ref { text, .. } => {
1656 let lines = text.split("\\n").count();
1657 let box_height =
1658 config.note_padding * 2.0 + lines as f64 * ref_line_height(config);
1659 height += box_height + item_pre_gap(config) + REF_EXTRA_GAP;
1660 }
1661 Item::Description { text } => {
1662 let lines = text.split("\\n").count();
1663 height += lines as f64 * line_height + 10.0;
1664 }
1665 Item::Block {
1666 kind,
1667 items,
1668 else_items,
1669 ..
1670 } => {
1671 if block_is_parallel(kind) {
1672 let mut max_branch_height = 0.0;
1673 let base_activation_count = *active_activation_count;
1674 *parallel_depth += 1;
1675 for item in items {
1676 *active_activation_count = base_activation_count;
1677 let branch_height = inner(
1678 std::slice::from_ref(item),
1679 config,
1680 depth,
1681 else_pending,
1682 serial_pending,
1683 active_activation_count,
1684 parallel_depth,
1685 );
1686 if branch_height > max_branch_height {
1687 max_branch_height = branch_height;
1688 }
1689 }
1690 *active_activation_count = base_activation_count;
1691 if *parallel_depth > 0 {
1692 *parallel_depth -= 1;
1693 }
1694 let gap = if parallel_needs_gap(items) {
1695 PARALLEL_BLOCK_GAP
1696 } else {
1697 0.0
1698 };
1699 height += max_branch_height + gap;
1700 continue;
1701 }
1702
1703 if matches!(kind, BlockKind::Serial) {
1704 serial_pending.push(true);
1705 height += inner(
1706 items,
1707 config,
1708 depth,
1709 else_pending,
1710 serial_pending,
1711 active_activation_count,
1712 parallel_depth,
1713 );
1714 if let Some(else_items) = else_items {
1715 height += inner(
1716 else_items,
1717 config,
1718 depth,
1719 else_pending,
1720 serial_pending,
1721 active_activation_count,
1722 parallel_depth,
1723 );
1724 }
1725 serial_pending.pop();
1726 } else if !block_has_frame(kind) {
1727 height += inner(
1728 items,
1729 config,
1730 depth,
1731 else_pending,
1732 serial_pending,
1733 active_activation_count,
1734 parallel_depth,
1735 );
1736 if let Some(else_items) = else_items {
1737 height += inner(
1738 else_items,
1739 config,
1740 depth,
1741 else_pending,
1742 serial_pending,
1743 active_activation_count,
1744 parallel_depth,
1745 );
1746 }
1747 } else {
1748 height += block_header_space(config, depth);
1749 height += inner(
1750 items,
1751 config,
1752 depth + 1,
1753 else_pending,
1754 serial_pending,
1755 active_activation_count,
1756 parallel_depth,
1757 );
1758 if let Some(else_items) = else_items {
1759 else_pending.push(true);
1760 height += block_else_spacing(config, depth);
1761 height += inner(
1762 else_items,
1763 config,
1764 depth + 1,
1765 else_pending,
1766 serial_pending,
1767 active_activation_count,
1768 parallel_depth,
1769 );
1770 else_pending.pop();
1771 }
1772 height += block_footer_padding(config, depth) + config.row_height * 1.0
1773 - config.row_height;
1774 }
1775 }
1776 Item::Activate { .. } => {
1777 *active_activation_count += 1;
1778 }
1779 Item::Deactivate { .. } => {
1780 if *active_activation_count > 0 {
1781 *active_activation_count -= 1;
1782 }
1783 }
1784 Item::Destroy { .. } => {
1785 height += DESTROY_SPACING;
1786 }
1787 Item::ParticipantDecl { .. } => {}
1788 Item::Autonumber { .. } => {}
1789 Item::DiagramOption { .. } => {} }
1791 }
1792 height
1793 }
1794
1795 let mut else_pending = Vec::new();
1796 let mut serial_pending = Vec::new();
1797 let mut active_activation_count = 0;
1798 let mut parallel_depth = 0;
1799 inner(
1800 items,
1801 config,
1802 depth,
1803 &mut else_pending,
1804 &mut serial_pending,
1805 &mut active_activation_count,
1806 &mut parallel_depth,
1807 )
1808}
1809
1810fn render_participant_headers(svg: &mut String, state: &RenderState, y: f64) {
1811 let shape = state.config.theme.participant_shape;
1812
1813 for p in &state.participants {
1814 let x = state.get_x(p.id());
1815 let p_width = state.get_participant_width(p.id());
1816 let box_x = x - p_width / 2.0;
1817
1818 match p.kind {
1819 ParticipantKind::Participant => {
1820 match shape {
1822 ParticipantShape::Rectangle => {
1823 writeln!(
1824 svg,
1825 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="participant"/>"#,
1826 x = box_x,
1827 y = y,
1828 w = p_width,
1829 h = state.config.header_height
1830 )
1831 .unwrap();
1832 }
1833 ParticipantShape::RoundedRect => {
1834 writeln!(
1835 svg,
1836 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" class="participant"/>"#,
1837 x = box_x,
1838 y = y,
1839 w = p_width,
1840 h = state.config.header_height
1841 )
1842 .unwrap();
1843 }
1844 ParticipantShape::Circle => {
1845 let rx = p_width / 2.0 - 5.0;
1847 let ry = state.config.header_height / 2.0 - 2.0;
1848 writeln!(
1849 svg,
1850 r#"<ellipse cx="{cx}" cy="{cy}" rx="{rx}" ry="{ry}" class="participant"/>"#,
1851 cx = x,
1852 cy = y + state.config.header_height / 2.0,
1853 rx = rx,
1854 ry = ry
1855 )
1856 .unwrap();
1857 }
1858 }
1859 let lines: Vec<&str> = p.name.split("\\n").collect();
1861 if lines.len() == 1 {
1862 writeln!(
1863 svg,
1864 r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
1865 x = x,
1866 y = y + state.config.header_height / 2.0 + 5.0,
1867 name = escape_xml(&p.name)
1868 )
1869 .unwrap();
1870 } else {
1871 let line_height = state.config.font_size + 2.0;
1872 let total_height = lines.len() as f64 * line_height;
1873 let start_y = y + state.config.header_height / 2.0 - total_height / 2.0
1874 + line_height * 0.8;
1875 write!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
1876 for (i, line) in lines.iter().enumerate() {
1877 let dy = if i == 0 { start_y } else { line_height };
1878 if i == 0 {
1879 writeln!(
1880 svg,
1881 r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
1882 x = x,
1883 y = dy,
1884 text = escape_xml(line)
1885 )
1886 .unwrap();
1887 } else {
1888 writeln!(
1889 svg,
1890 r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
1891 x = x,
1892 dy = dy,
1893 text = escape_xml(line)
1894 )
1895 .unwrap();
1896 }
1897 }
1898 writeln!(svg, "</text>").unwrap();
1899 }
1900 }
1901 ParticipantKind::Actor => {
1902 let head_r = 8.0;
1904 let body_len = 12.0;
1905 let arm_len = 10.0;
1906 let leg_len = 10.0;
1907 let figure_height = 38.0; let fig_top = y + 8.0;
1911 let fig_center_y = fig_top + head_r + body_len / 2.0;
1912 let arm_y = fig_center_y + 2.0;
1913
1914 writeln!(
1916 svg,
1917 r#"<circle cx="{x}" cy="{cy}" r="{r}" class="actor-head"/>"#,
1918 x = x,
1919 cy = fig_center_y - body_len / 2.0 - head_r,
1920 r = head_r
1921 )
1922 .unwrap();
1923 writeln!(
1925 svg,
1926 r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="actor-body"/>"#,
1927 x = x,
1928 y1 = fig_center_y - body_len / 2.0,
1929 y2 = fig_center_y + body_len / 2.0
1930 )
1931 .unwrap();
1932 writeln!(
1934 svg,
1935 r#"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="actor-body"/>"#,
1936 x1 = x - arm_len,
1937 y = arm_y,
1938 x2 = x + arm_len
1939 )
1940 .unwrap();
1941 writeln!(
1943 svg,
1944 r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
1945 x = x,
1946 y1 = fig_center_y + body_len / 2.0,
1947 x2 = x - leg_len * 0.6,
1948 y2 = fig_center_y + body_len / 2.0 + leg_len
1949 )
1950 .unwrap();
1951 writeln!(
1953 svg,
1954 r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
1955 x = x,
1956 y1 = fig_center_y + body_len / 2.0,
1957 x2 = x + leg_len * 0.6,
1958 y2 = fig_center_y + body_len / 2.0 + leg_len
1959 )
1960 .unwrap();
1961 let name_lines: Vec<&str> = p.name.split("\\n").collect();
1963 let name_start_y = fig_top + figure_height + 5.0;
1964 if name_lines.len() == 1 {
1965 writeln!(
1966 svg,
1967 r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
1968 x = x,
1969 y = name_start_y + state.config.font_size,
1970 name = escape_xml(&p.name)
1971 )
1972 .unwrap();
1973 } else {
1974 let line_height = state.config.font_size + 2.0;
1976 writeln!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
1977 for (i, line) in name_lines.iter().enumerate() {
1978 if i == 0 {
1979 writeln!(
1980 svg,
1981 r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
1982 x = x,
1983 y = name_start_y + state.config.font_size,
1984 text = escape_xml(line)
1985 )
1986 .unwrap();
1987 } else {
1988 writeln!(
1989 svg,
1990 r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
1991 x = x,
1992 dy = line_height,
1993 text = escape_xml(line)
1994 )
1995 .unwrap();
1996 }
1997 }
1998 writeln!(svg, "</text>").unwrap();
1999 }
2000 }
2001 }
2002 }
2003}
2004
2005fn render_items(svg: &mut String, state: &mut RenderState, items: &[Item], depth: usize) {
2006 for item in items {
2007 match item {
2008 Item::Message {
2009 from,
2010 to,
2011 text,
2012 arrow,
2013 activate,
2014 deactivate,
2015 create,
2016 ..
2017 } => {
2018 render_message(
2019 svg,
2020 state,
2021 from,
2022 to,
2023 text,
2024 arrow,
2025 *activate,
2026 *deactivate,
2027 *create,
2028 depth,
2029 );
2030 }
2031 Item::Note {
2032 position,
2033 participants,
2034 text,
2035 } => {
2036 render_note(svg, state, position, participants, text);
2037 }
2038 Item::Block {
2039 kind,
2040 label,
2041 items,
2042 else_items,
2043 } => {
2044 render_block(svg, state, kind, label, items, else_items.as_deref(), depth);
2045 }
2046 Item::Activate { participant } => {
2047 let y = state.current_y;
2048 state
2049 .activations
2050 .entry(participant.clone())
2051 .or_default()
2052 .push((y, None));
2053 }
2054 Item::Deactivate { participant } => {
2055 if let Some(acts) = state.activations.get_mut(participant) {
2056 if let Some(act) = acts.last_mut() {
2057 if act.1.is_none() {
2058 act.1 = Some(state.current_y);
2059 }
2060 }
2061 }
2062 }
2063 Item::Destroy { participant } => {
2064 state.destroyed.insert(participant.clone(), state.current_y);
2065 let x = state.get_x(participant);
2067 let y = state.current_y;
2068 let size = 12.0;
2069 let theme = &state.config.theme;
2070 writeln!(
2071 svg,
2072 r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="2"/>"#,
2073 x1 = x - size,
2074 y1 = y - size,
2075 x2 = x + size,
2076 y2 = y + size,
2077 stroke = theme.message_color
2078 )
2079 .unwrap();
2080 writeln!(
2081 svg,
2082 r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="2"/>"#,
2083 x1 = x + size,
2084 y1 = y - size,
2085 x2 = x - size,
2086 y2 = y + size,
2087 stroke = theme.message_color
2088 )
2089 .unwrap();
2090 state.current_y += DESTROY_SPACING;
2091 }
2092 Item::Autonumber { enabled, start } => {
2093 if *enabled {
2094 state.autonumber = Some(start.unwrap_or(1));
2095 } else {
2096 state.autonumber = None;
2097 }
2098 }
2099 Item::ParticipantDecl { .. } => {
2100 }
2102 Item::State { participants, text } => {
2103 render_state(svg, state, participants, text);
2104 }
2105 Item::Ref {
2106 participants,
2107 text,
2108 input_from,
2109 input_label,
2110 output_to,
2111 output_label,
2112 } => {
2113 render_ref(
2114 svg,
2115 state,
2116 participants,
2117 text,
2118 input_from.as_deref(),
2119 input_label.as_deref(),
2120 output_to.as_deref(),
2121 output_label.as_deref(),
2122 );
2123 }
2124 Item::DiagramOption { .. } => {
2125 }
2127 Item::Description { text } => {
2128 render_description(svg, state, text);
2129 }
2130 }
2131 }
2132}
2133
2134fn render_message(
2135 svg: &mut String,
2136 state: &mut RenderState,
2137 from: &str,
2138 to: &str,
2139 text: &str,
2140 arrow: &Arrow,
2141 activate: bool,
2142 deactivate: bool,
2143 create: bool,
2144 depth: usize,
2145) {
2146 let x1 = state.get_x(from);
2147 let x2 = state.get_x(to);
2148
2149 state.apply_else_return_gap(arrow);
2150 let active_count = state.active_activation_count();
2151 let chain_gap = if activate && depth == 0 && active_count == 1 {
2152 ACTIVATION_CHAIN_GAP
2153 } else {
2154 0.0
2155 };
2156
2157 let is_self = from == to;
2158 let line_class = match arrow.line {
2159 LineStyle::Solid => "message",
2160 LineStyle::Dashed => "message-dashed",
2161 };
2162 let is_filled = matches!(arrow.head, ArrowHead::Filled);
2163
2164 let num_prefix = state
2166 .next_number()
2167 .map(|n| format!("{}. ", n))
2168 .unwrap_or_default();
2169
2170 let display_text = format!("{}{}", num_prefix, text);
2172 let lines: Vec<&str> = display_text.split("\\n").collect();
2173 let line_height = state.config.font_size + 4.0;
2174 let extra_height = if !is_self && lines.len() > 1 {
2175 let spacing_line_height = message_spacing_line_height(&state.config);
2176 (lines.len() - 1) as f64 * spacing_line_height
2177 } else {
2178 0.0
2179 };
2180
2181 if !is_self && lines.len() > 1 {
2183 state.current_y += extra_height;
2184 }
2185
2186 let y = state.current_y;
2187 let has_label_text = lines.iter().any(|line| !line.trim().is_empty());
2188
2189 writeln!(svg, r#"<g class="message">"#).unwrap();
2191
2192 if is_self {
2193 let loop_width = 40.0;
2195 let text_block_height = lines.len() as f64 * line_height;
2196 let loop_height = (text_block_height + 10.0).max(25.0);
2197 let arrow_end_x = x1;
2198 let arrow_end_y = y + loop_height;
2199 let direction = std::f64::consts::PI;
2201 let arrow_points = arrowhead_points(arrow_end_x, arrow_end_y, direction);
2202
2203 writeln!(
2204 svg,
2205 r#" <path d="M {x1} {y} L {x2} {y} L {x2} {y2} L {arrow_x} {y2}" class="{cls}"/>"#,
2206 x1 = x1,
2207 y = y,
2208 x2 = x1 + loop_width,
2209 y2 = y + loop_height,
2210 arrow_x = arrow_end_x + ARROWHEAD_SIZE,
2211 cls = line_class
2212 )
2213 .unwrap();
2214
2215 if is_filled {
2217 writeln!(
2218 svg,
2219 r#" <polygon points="{points}" class="arrowhead"/>"#,
2220 points = arrow_points
2221 )
2222 .unwrap();
2223 } else {
2224 writeln!(
2225 svg,
2226 r#" <polyline points="{points}" class="arrowhead-open"/>"#,
2227 points = arrow_points
2228 )
2229 .unwrap();
2230 }
2231
2232 let text_x = x1 + loop_width + 5.0;
2234 let max_width = lines
2235 .iter()
2236 .map(|line| estimate_message_width(line, state.config.font_size))
2237 .fold(0.0, f64::max);
2238 let top_line_y = y + 4.0 + 0.5 * line_height;
2239 let bottom_line_y = y + 4.0 + (lines.len() as f64 - 0.5) * line_height;
2240 let label_y_min = top_line_y - line_height * MESSAGE_LABEL_ASCENT_FACTOR;
2241 let label_y_max = bottom_line_y + line_height * MESSAGE_LABEL_DESCENT_FACTOR;
2242 let label_x_min = text_x;
2243 let label_x_max = text_x + max_width;
2244 let label_offset = if has_label_text {
2245 let step = line_height * MESSAGE_LABEL_COLLISION_STEP_RATIO;
2246 state.reserve_message_label(label_x_min, label_x_max, label_y_min, label_y_max, step)
2247 } else {
2248 0.0
2249 };
2250 for (i, line) in lines.iter().enumerate() {
2251 let line_y = y + 4.0 + (i as f64 + 0.5) * line_height + label_offset;
2252 writeln!(
2253 svg,
2254 r#" <text x="{x}" y="{y}" class="message-text">{t}</text>"#,
2255 x = text_x,
2256 y = line_y,
2257 t = escape_xml(line)
2258 )
2259 .unwrap();
2260 }
2261
2262 writeln!(svg, r#"</g>"#).unwrap();
2264
2265 let mut spacing = self_message_spacing(&state.config, lines.len());
2266 if state.in_serial_block() {
2267 spacing -= SERIAL_SELF_MESSAGE_ADJUST;
2268 }
2269 if active_count > 0 {
2270 spacing -= SELF_MESSAGE_ACTIVE_ADJUST;
2271 }
2272 state.current_y += spacing;
2273 } else {
2274 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
2276 let y2 = y + delay_offset;
2277
2278 let text_x = (x1 + x2) / 2.0;
2279 let text_y = (y + y2) / 2.0 - 8.0;
2280
2281 let direction = arrow_direction(x1, y, x2, y2);
2283 let arrow_points = arrowhead_points(x2, y2, direction);
2284
2285 let line_end_x = x2 - ARROWHEAD_SIZE * direction.cos();
2287 let line_end_y = y2 - ARROWHEAD_SIZE * direction.sin();
2288
2289 writeln!(
2291 svg,
2292 r#" <line x1="{x1}" y1="{y1}" x2="{lx2}" y2="{ly2}" class="{cls}"/>"#,
2293 x1 = x1,
2294 y1 = y,
2295 lx2 = line_end_x,
2296 ly2 = line_end_y,
2297 cls = line_class
2298 )
2299 .unwrap();
2300
2301 if is_filled {
2303 writeln!(
2304 svg,
2305 r#" <polygon points="{points}" class="arrowhead"/>"#,
2306 points = arrow_points
2307 )
2308 .unwrap();
2309 } else {
2310 writeln!(
2311 svg,
2312 r#" <polyline points="{points}" class="arrowhead-open"/>"#,
2313 points = arrow_points
2314 )
2315 .unwrap();
2316 }
2317
2318 let max_width = lines
2320 .iter()
2321 .map(|line| estimate_message_width(line, state.config.font_size))
2322 .fold(0.0, f64::max);
2323 let top_line_y = text_y - (lines.len() as f64 - 1.0) * line_height;
2324 let bottom_line_y = text_y;
2325 let label_offset = if has_label_text {
2326 let label_y_min = top_line_y - line_height * MESSAGE_LABEL_ASCENT_FACTOR;
2327 let label_y_max = bottom_line_y + line_height * MESSAGE_LABEL_DESCENT_FACTOR;
2328 let label_x_min = text_x - max_width / 2.0;
2329 let label_x_max = text_x + max_width / 2.0;
2330 let step = line_height * MESSAGE_LABEL_COLLISION_STEP_RATIO;
2331 state.reserve_message_label(label_x_min, label_x_max, label_y_min, label_y_max, step)
2332 } else {
2333 0.0
2334 };
2335 for (i, line) in lines.iter().enumerate() {
2336 let line_y = text_y - (lines.len() - 1 - i) as f64 * line_height + label_offset;
2337 writeln!(
2338 svg,
2339 r#" <text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"#,
2340 x = text_x,
2341 y = line_y,
2342 t = escape_xml(line)
2343 )
2344 .unwrap();
2345 }
2346
2347 writeln!(svg, r#"</g>"#).unwrap();
2349
2350 state.current_y += state.config.row_height + delay_offset;
2352 }
2353
2354 if create {
2355 state.current_y += CREATE_MESSAGE_SPACING;
2356 }
2357
2358 state.apply_serial_first_row_gap();
2359
2360 if activate && depth == 0 {
2361 state.current_y += ACTIVATION_START_GAP;
2362 }
2363 if chain_gap > 0.0 {
2364 state.current_y += chain_gap;
2365 }
2366
2367 if activate {
2369 state
2370 .activations
2371 .entry(to.to_string())
2372 .or_default()
2373 .push((y, None));
2374 }
2375 if deactivate {
2376 if let Some(acts) = state.activations.get_mut(from) {
2377 if let Some(act) = acts.last_mut() {
2378 if act.1.is_none() {
2379 act.1 = Some(y);
2380 }
2381 }
2382 }
2383 }
2384}
2385
2386fn render_note(
2387 svg: &mut String,
2388 state: &mut RenderState,
2389 position: &NotePosition,
2390 participants: &[String],
2391 text: &str,
2392) {
2393 let lines: Vec<&str> = text.split("\\n").collect();
2394 let line_height = note_line_height(&state.config);
2395 let padding = note_padding(&state.config);
2396 let note_height = padding * 2.0 + lines.len() as f64 * line_height;
2397
2398 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(10);
2401 let content_width = (max_line_len as f64 * 10.0 + padding * 2.0).max(80.0);
2402
2403 let (x, note_width, text_anchor) = match position {
2404 NotePosition::Left => {
2405 let px = state.get_x(&participants[0]);
2406 let p_width = state.get_participant_width(&participants[0]);
2407 let w = content_width.min(300.0);
2408 let x = (px - p_width / 2.0 - w - 10.0).max(state.config.padding);
2410 (x, w, "start")
2411 }
2412 NotePosition::Right => {
2413 let px = state.get_x(&participants[0]);
2414 let p_width = state.get_participant_width(&participants[0]);
2415 let w = content_width.min(300.0);
2416 (px + p_width / 2.0 + 10.0, w, "start")
2417 }
2418 NotePosition::Over => {
2419 if participants.len() == 1 {
2420 let px = state.get_x(&participants[0]);
2421 let w = content_width;
2423 let x = (px - w / 2.0).max(state.config.padding);
2425 (x, w, "middle")
2426 } else {
2427 let x1 = state.get_x(&participants[0]);
2429 let x2 = state.get_x(participants.last().unwrap());
2430 let p1_width = state.get_participant_width(&participants[0]);
2431 let p2_width = state.get_participant_width(participants.last().unwrap());
2432 let span_width = (x2 - x1).abs() + (p1_width + p2_width) / 2.0 * 0.8;
2433 let w = span_width.max(content_width);
2434 let center = (x1 + x2) / 2.0;
2435 let x = (center - w / 2.0).max(state.config.padding);
2437 (x, w, "middle")
2438 }
2439 }
2440 };
2441
2442 let y = state.current_y;
2443 let fold_size = 8.0; let note_path = format!(
2448 "M {x} {y} L {x2} {y} L {x3} {y2} L {x3} {y3} L {x} {y3} Z",
2449 x = x,
2450 y = y,
2451 x2 = x + note_width - fold_size,
2452 x3 = x + note_width,
2453 y2 = y + fold_size,
2454 y3 = y + note_height
2455 );
2456
2457 writeln!(svg, r#"<path d="{path}" class="note"/>"#, path = note_path).unwrap();
2458
2459 let theme = &state.config.theme;
2461 let fold_path = format!(
2463 "M {x1} {y1} L {x2} {y2} L {x1} {y2} Z",
2464 x1 = x + note_width - fold_size,
2465 y1 = y,
2466 x2 = x + note_width,
2467 y2 = y + fold_size
2468 );
2469
2470 writeln!(
2471 svg,
2472 r##"<path d="{path}" fill="#e0e0a0" stroke="{stroke}" stroke-width="1"/>"##,
2473 path = fold_path,
2474 stroke = theme.note_stroke
2475 )
2476 .unwrap();
2477
2478 let text_x = match text_anchor {
2480 "middle" => x + note_width / 2.0,
2481 "start" => x + padding,
2482 _ => x + note_width - padding,
2483 };
2484
2485 for (i, line) in lines.iter().enumerate() {
2486 let text_y = y + padding + (i as f64 + 0.8) * line_height;
2487 writeln!(
2488 svg,
2489 r#"<text x="{x}" y="{y}" class="note-text" text-anchor="{anchor}">{t}</text>"#,
2490 x = text_x,
2491 y = text_y,
2492 anchor = if *position == NotePosition::Over {
2493 "middle"
2494 } else {
2495 "start"
2496 },
2497 t = escape_xml(line)
2498 )
2499 .unwrap();
2500 }
2501
2502 state.current_y += note_height.max(state.config.row_height) + NOTE_MARGIN;
2504}
2505
2506fn render_state(svg: &mut String, state: &mut RenderState, participants: &[String], text: &str) {
2508 let theme = &state.config.theme;
2509 let lines: Vec<&str> = text.split("\\n").collect();
2510 let line_height = state_line_height(&state.config);
2511 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
2512
2513 let (x, box_width) = if participants.len() == 1 {
2515 let px = state.get_x(&participants[0]);
2516 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(8);
2517 let w = (max_line_len as f64 * 8.0 + state.config.note_padding * 2.0).max(60.0);
2518 (px - w / 2.0, w)
2519 } else {
2520 let x1 = state.get_x(&participants[0]);
2521 let x2 = state.get_x(participants.last().unwrap());
2522 let span_width = (x2 - x1).abs() + state.config.participant_width * 0.6;
2523 let center = (x1 + x2) / 2.0;
2524 (center - span_width / 2.0, span_width)
2525 };
2526
2527 let shift = item_pre_shift(&state.config);
2528 let y = (state.current_y - shift).max(state.content_start());
2529
2530 writeln!(
2532 svg,
2533 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
2534 x = x,
2535 y = y,
2536 w = box_width,
2537 h = box_height,
2538 fill = theme.state_fill,
2539 stroke = theme.state_stroke
2540 )
2541 .unwrap();
2542
2543 let text_x = x + box_width / 2.0;
2545 for (i, line) in lines.iter().enumerate() {
2546 let text_y = y + state.config.note_padding + (i as f64 + 0.8) * line_height;
2547 writeln!(
2548 svg,
2549 r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
2550 x = text_x,
2551 y = text_y,
2552 fill = theme.state_text_color,
2553 font = theme.font_family,
2554 size = state.config.font_size,
2555 t = escape_xml(line)
2556 )
2557 .unwrap();
2558 }
2559
2560 state.current_y = y + box_height + state.config.row_height + REF_EXTRA_GAP;
2561}
2562
2563fn render_ref(
2565 svg: &mut String,
2566 state: &mut RenderState,
2567 participants: &[String],
2568 text: &str,
2569 input_from: Option<&str>,
2570 input_label: Option<&str>,
2571 output_to: Option<&str>,
2572 output_label: Option<&str>,
2573) {
2574 let theme = &state.config.theme;
2575 let lines: Vec<&str> = text.split("\\n").collect();
2576 let line_height = ref_line_height(&state.config);
2577 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
2578 let notch_size = 10.0;
2579
2580 let (x, box_width) = if participants.len() == 1 {
2582 let px = state.get_x(&participants[0]);
2583 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(15);
2584 let w = (max_line_len as f64 * 8.0 + state.config.note_padding * 2.0 + notch_size * 2.0)
2585 .max(100.0);
2586 (px - w / 2.0, w)
2587 } else {
2588 let x1 = state.get_x(&participants[0]);
2589 let x2 = state.get_x(participants.last().unwrap());
2590 let span_width = (x2 - x1).abs() + state.config.participant_width * 0.8;
2591 let center = (x1 + x2) / 2.0;
2592 (center - span_width / 2.0, span_width)
2593 };
2594
2595 let shift = item_pre_shift(&state.config);
2596 let y = (state.current_y - shift).max(state.content_start());
2597 let input_offset = state.config.note_padding + state.config.font_size + 1.0;
2598 let output_padding = state.config.note_padding + 3.0;
2599
2600 if let Some(from) = input_from {
2602 let from_x = state.get_x(from);
2603 let to_x = x; let arrow_y = y + input_offset;
2605
2606 let direction = arrow_direction(from_x, arrow_y, to_x, arrow_y);
2608 let arrow_points = arrowhead_points(to_x, arrow_y, direction);
2609 let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
2610
2611 writeln!(
2613 svg,
2614 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message"/>"##,
2615 x1 = from_x,
2616 y = arrow_y,
2617 x2 = line_end_x
2618 )
2619 .unwrap();
2620
2621 writeln!(
2623 svg,
2624 r#"<polygon points="{points}" class="arrowhead"/>"#,
2625 points = arrow_points
2626 )
2627 .unwrap();
2628
2629 if let Some(label) = input_label {
2631 let text_x = (from_x + to_x) / 2.0;
2632 writeln!(
2633 svg,
2634 r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
2635 x = text_x,
2636 y = arrow_y - 8.0,
2637 t = escape_xml(label)
2638 )
2639 .unwrap();
2640 }
2641 }
2642
2643 let ref_path = format!(
2646 "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x1} {y2} L {x3} {y3} Z",
2647 x1 = x + notch_size,
2648 y1 = y,
2649 x2 = x + box_width,
2650 y2 = y + box_height,
2651 x3 = x,
2652 y3 = y + box_height / 2.0
2653 );
2654
2655 writeln!(
2656 svg,
2657 r##"<path d="{path}" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
2658 path = ref_path,
2659 fill = theme.ref_fill,
2660 stroke = theme.ref_stroke
2661 )
2662 .unwrap();
2663
2664 writeln!(
2666 svg,
2667 r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-weight="bold">ref</text>"##,
2668 x = x + notch_size + 4.0,
2669 y = y + state.config.font_size,
2670 fill = theme.ref_text_color,
2671 font = theme.font_family,
2672 size = state.config.font_size - 2.0
2673 )
2674 .unwrap();
2675
2676 let text_x = x + box_width / 2.0;
2678 for (i, line) in lines.iter().enumerate() {
2679 let text_y = y + state.config.note_padding + (i as f64 + 0.8) * line_height;
2680 writeln!(
2681 svg,
2682 r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
2683 x = text_x,
2684 y = text_y,
2685 fill = theme.ref_text_color,
2686 font = theme.font_family,
2687 size = state.config.font_size,
2688 t = escape_xml(line)
2689 )
2690 .unwrap();
2691 }
2692
2693 if let Some(to) = output_to {
2695 let from_x = x + box_width; let to_x = state.get_x(to);
2697 let arrow_y = y + box_height - output_padding;
2698
2699 let direction = arrow_direction(from_x, arrow_y, to_x, arrow_y);
2701 let arrow_points = arrowhead_points(to_x, arrow_y, direction);
2702 let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
2703
2704 writeln!(
2706 svg,
2707 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message-dashed"/>"##,
2708 x1 = from_x,
2709 y = arrow_y,
2710 x2 = line_end_x
2711 )
2712 .unwrap();
2713
2714 writeln!(
2716 svg,
2717 r#"<polygon points="{points}" class="arrowhead"/>"#,
2718 points = arrow_points
2719 )
2720 .unwrap();
2721
2722 if let Some(label) = output_label {
2724 let text_x = (from_x + to_x) / 2.0;
2725 writeln!(
2726 svg,
2727 r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
2728 x = text_x,
2729 y = arrow_y - 8.0,
2730 t = escape_xml(label)
2731 )
2732 .unwrap();
2733 }
2734 }
2735
2736 state.current_y = y + box_height + state.config.row_height + STATE_EXTRA_GAP;
2737}
2738
2739fn render_description(svg: &mut String, state: &mut RenderState, text: &str) {
2741 let theme = &state.config.theme;
2742 let lines: Vec<&str> = text.split("\\n").collect();
2743 let line_height = state.config.font_size + 4.0;
2744
2745 let x = state.config.padding + 10.0;
2747 let y = state.current_y;
2748
2749 for (i, line) in lines.iter().enumerate() {
2750 let text_y = y + (i as f64 + 0.8) * line_height;
2751 writeln!(
2752 svg,
2753 r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-style="italic">{t}</text>"##,
2754 x = x,
2755 y = text_y,
2756 fill = theme.description_text_color,
2757 font = theme.font_family,
2758 size = state.config.font_size - 1.0,
2759 t = escape_xml(line)
2760 )
2761 .unwrap();
2762 }
2763
2764 state.current_y += lines.len() as f64 * line_height + 10.0;
2765}
2766
2767fn render_block(
2768 svg: &mut String,
2769 state: &mut RenderState,
2770 kind: &BlockKind,
2771 _label: &str,
2772 items: &[Item],
2773 else_items: Option<&[Item]>,
2774 depth: usize,
2775) {
2776 if block_is_parallel(kind) {
2777 state.push_parallel();
2778 let start_y = state.current_y;
2779 let mut max_end_y = start_y;
2780 for item in items {
2781 state.current_y = start_y;
2782 render_items(svg, state, std::slice::from_ref(item), depth);
2783 if state.current_y > max_end_y {
2784 max_end_y = state.current_y;
2785 }
2786 }
2787 let gap = if parallel_needs_gap(items) {
2788 PARALLEL_BLOCK_GAP
2789 } else {
2790 0.0
2791 };
2792 state.current_y = max_end_y + gap;
2793 state.pop_parallel();
2794 return;
2795 }
2796
2797 if matches!(kind, BlockKind::Serial) {
2798 state.push_serial_first_row_pending();
2799 render_items(svg, state, items, depth);
2800 if let Some(else_items) = else_items {
2801 render_items(svg, state, else_items, depth);
2802 }
2803 state.pop_serial_first_row_pending();
2804 return;
2805 }
2806
2807 if !block_has_frame(kind) {
2808 render_items(svg, state, items, depth);
2809 if let Some(else_items) = else_items {
2810 render_items(svg, state, else_items, depth);
2811 }
2812 return;
2813 }
2814
2815 state.current_y += block_header_space(&state.config, depth);
2820
2821 render_items(svg, state, items, depth + 1);
2823
2824 if let Some(else_items) = else_items {
2826 state.push_else_return_pending();
2827 state.current_y += block_else_spacing(&state.config, depth);
2828 render_items(svg, state, else_items, depth + 1);
2829 state.pop_else_return_pending();
2830 }
2831
2832 let end_y =
2833 state.current_y - state.config.row_height + block_footer_padding(&state.config, depth);
2834
2835 state.current_y = end_y + state.config.row_height * 1.0;
2837
2838 }
2841
2842fn render_activations(svg: &mut String, state: &mut RenderState, footer_y: f64) {
2843 for (participant, activations) in &state.activations {
2844 let x = state.get_x(participant);
2845 let box_x = x - state.config.activation_width / 2.0;
2846
2847 for (start_y, end_y) in activations {
2848 let end = end_y.unwrap_or(footer_y);
2850 let height = end - start_y;
2851
2852 if height > 0.0 {
2853 writeln!(
2854 svg,
2855 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="activation"/>"#,
2856 x = box_x,
2857 y = start_y,
2858 w = state.config.activation_width,
2859 h = height
2860 )
2861 .unwrap();
2862 }
2863 }
2864 }
2865}
2866
2867fn escape_xml(s: &str) -> String {
2868 s.replace('&', "&")
2869 .replace('<', "<")
2870 .replace('>', ">")
2871 .replace('"', """)
2872 .replace('\'', "'")
2873}
2874
2875#[cfg(test)]
2876mod tests {
2877 use super::*;
2878 use crate::parser::parse;
2879
2880 #[test]
2881 fn test_render_simple() {
2882 let diagram = parse("Alice->Bob: Hello").unwrap();
2883 let svg = render(&diagram);
2884 assert!(svg.contains("<svg"));
2885 assert!(svg.contains("Alice"));
2886 assert!(svg.contains("Bob"));
2887 assert!(svg.contains("Hello"));
2888 }
2889
2890 #[test]
2891 fn test_render_with_note() {
2892 let diagram = parse("Alice->Bob: Hello\nnote over Alice: Thinking").unwrap();
2893 let svg = render(&diagram);
2894 assert!(svg.contains("Thinking"));
2895 }
2896}