Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
1327 lines
44 KiB
Rust
1327 lines
44 KiB
Rust
use crate::error::MermaidError;
|
|
use crate::theme::MermaidTheme;
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
const SEQUENCE_EVENT_ROW_HEIGHT: f64 = 37.0;
|
|
const SEQUENCE_FRAGMENT_HEADER_HEIGHT: f64 = 28.0;
|
|
const SEQUENCE_FRAGMENT_FOOTER_HEIGHT: f64 = 12.0;
|
|
const SEQUENCE_FRAGMENT_INSET_X: f64 = 18.0;
|
|
const SEQUENCE_FRAGMENT_MARGIN_X: f64 = 20.0;
|
|
const SEQUENCE_FRAGMENT_TAB_PADDING_X: f64 = 10.0;
|
|
const SEQUENCE_FRAGMENT_STROKE: &str = "#d7c8f8";
|
|
const SEQUENCE_FRAGMENT_TEXT: &str = "#4c3f6f";
|
|
const SEQUENCE_ACTIVATION_W: f64 = 8.0;
|
|
const SEQUENCE_ACTIVATION_NEST_OFFSET_X: f64 = 4.0;
|
|
|
|
pub fn render_sequence_diagram_to_svg(
|
|
mermaid_source: &str,
|
|
theme: &MermaidTheme,
|
|
) -> Result<String, MermaidError> {
|
|
let diagram = parse_sequence_diagram(mermaid_source)?;
|
|
|
|
let title_h = if diagram.title.is_some() {
|
|
26.0_f64
|
|
} else {
|
|
0.0
|
|
};
|
|
let header_y = 16.0_f64 + title_h;
|
|
let box_margin = 10.0_f64;
|
|
let edge_pad = 10.0_f64;
|
|
|
|
let box_w = diagram
|
|
.participants
|
|
.iter()
|
|
.map(|p| estimate_label_box_width(&p.label))
|
|
.fold(100.0_f64, f64::max);
|
|
let left_margin = box_w / 2.0 + edge_pad;
|
|
|
|
let n = diagram.participants.len().max(1);
|
|
let pair_spacings = compute_sequence_pair_spacings(&diagram, box_w);
|
|
let mut participant_xs = Vec::with_capacity(n);
|
|
let mut next_x = left_margin;
|
|
participant_xs.push(next_x);
|
|
for spacing in pair_spacings {
|
|
next_x += spacing;
|
|
participant_xs.push(next_x);
|
|
}
|
|
let mut width =
|
|
(participant_xs.last().copied().unwrap_or(left_margin) + box_w / 2.0 + edge_pad).max(360.0);
|
|
width = width.max(required_sequence_width(
|
|
&diagram,
|
|
&participant_xs,
|
|
box_w,
|
|
edge_pad,
|
|
));
|
|
if let Some(title) = &diagram.title {
|
|
width = width.max(estimate_sequence_text_width(title) * 1.25 + edge_pad * 2.0);
|
|
}
|
|
let max_label_lines = diagram
|
|
.participants
|
|
.iter()
|
|
.map(|p| p.label.split("<br/>").count())
|
|
.max()
|
|
.unwrap_or(1);
|
|
let header_h = if max_label_lines > 1 {
|
|
44.0_f64
|
|
} else {
|
|
32.0_f64
|
|
};
|
|
|
|
let events_top = header_y + header_h + 28.0;
|
|
let (event_y_positions, fragment_layouts, activation_layouts, content_bottom) =
|
|
layout_sequence_events(&diagram.events, events_top);
|
|
let footer_h = header_h;
|
|
let footer_y = content_bottom + box_margin;
|
|
let height = (footer_y + footer_h + header_y).max(220.0);
|
|
|
|
let mut x_for: BTreeMap<&str, f64> = BTreeMap::new();
|
|
for (participant, x) in diagram.participants.iter().zip(participant_xs.iter()) {
|
|
x_for.insert(participant.id.as_str(), *x);
|
|
}
|
|
|
|
let mut svg = String::new();
|
|
|
|
svg.push_str(&format!(
|
|
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {width} {height}\">"
|
|
));
|
|
svg.push_str(&format!(
|
|
"<rect x=\"0\" y=\"0\" width=\"{width}\" height=\"{height}\" fill=\"{}\"/>",
|
|
theme.background
|
|
));
|
|
|
|
if let Some(title) = &diagram.title {
|
|
svg.push_str(&format!(
|
|
"<text x=\"{tx:.3}\" y=\"20\" text-anchor=\"middle\" font-family=\"Trebuchet MS,Verdana,Arial,sans-serif\" font-size=\"13\" font-weight=\"bold\" fill=\"{}\">{}</text>",
|
|
theme.text_color,
|
|
escape_xml(title),
|
|
tx = width / 2.0,
|
|
));
|
|
}
|
|
|
|
svg.push_str(&format!(
|
|
"<defs>\
|
|
<marker id=\"seq_arrow\" markerWidth=\"8\" markerHeight=\"8\" refX=\"7\" refY=\"4\" orient=\"auto\"><path d=\"M0,0 L8,4 L0,8 Z\" fill=\"{ec}\"/></marker>\
|
|
<marker id=\"seq_arrow_rev\" markerWidth=\"8\" markerHeight=\"8\" refX=\"1\" refY=\"4\" orient=\"auto\"><path d=\"M8,0 L0,4 L8,8 Z\" fill=\"{ec}\"/></marker>\
|
|
<marker id=\"seq_cross\" markerWidth=\"11\" markerHeight=\"11\" refX=\"5\" refY=\"5\" orient=\"auto\"><path d=\"M1,1 L9,9 M9,1 L1,9\" stroke=\"{ec}\" stroke-width=\"1.5\" fill=\"none\"/></marker>\
|
|
<marker id=\"seq_open\" markerWidth=\"10\" markerHeight=\"10\" refX=\"7\" refY=\"4\" orient=\"auto\"><path d=\"M0,0 L8,4 L0,8\" stroke=\"{ec}\" stroke-width=\"1.2\" fill=\"none\"/></marker>\
|
|
</defs>",
|
|
ec = theme.edge_color
|
|
));
|
|
|
|
for participant in &diagram.participants {
|
|
let x = x_for
|
|
.get(participant.id.as_str())
|
|
.copied()
|
|
.unwrap_or(left_margin);
|
|
let box_x = x - box_w / 2.0;
|
|
|
|
svg.push_str(&format!(
|
|
"<rect x=\"{box_x:.3}\" y=\"{header_y:.3}\" width=\"{box_w:.3}\" height=\"{header_h:.3}\" rx=\"3\" ry=\"3\" fill=\"{}\" stroke=\"{}\" stroke-width=\"1\"/>",
|
|
theme.node_fill, theme.node_stroke
|
|
));
|
|
svg.push_str(&render_participant_label(
|
|
x,
|
|
header_y + header_h / 2.0,
|
|
&participant.label,
|
|
&theme.text_color,
|
|
));
|
|
|
|
let y0 = header_y + header_h;
|
|
let y1 = footer_y;
|
|
svg.push_str(&format!(
|
|
"<line x1=\"{x:.3}\" y1=\"{y0:.3}\" x2=\"{x:.3}\" y2=\"{y1:.3}\" stroke=\"{}\" stroke-width=\"0.5\"/>",
|
|
theme.edge_color
|
|
));
|
|
|
|
svg.push_str(&format!(
|
|
"<rect x=\"{box_x:.3}\" y=\"{footer_y:.3}\" width=\"{box_w:.3}\" height=\"{footer_h:.3}\" rx=\"3\" ry=\"3\" fill=\"{}\" stroke=\"{}\" stroke-width=\"1\"/>",
|
|
theme.node_fill, theme.node_stroke
|
|
));
|
|
svg.push_str(&render_participant_label(
|
|
x,
|
|
footer_y + footer_h / 2.0,
|
|
&participant.label,
|
|
&theme.text_color,
|
|
));
|
|
}
|
|
|
|
for bar in &activation_layouts {
|
|
let Some(x) = x_for.get(bar.participant.as_str()).copied() else {
|
|
continue;
|
|
};
|
|
let bx =
|
|
x - SEQUENCE_ACTIVATION_W / 2.0 + bar.depth as f64 * SEQUENCE_ACTIVATION_NEST_OFFSET_X;
|
|
svg.push_str(&format!(
|
|
"<rect x=\"{bx:.3}\" y=\"{y:.3}\" width=\"{SEQUENCE_ACTIVATION_W:.3}\" height=\"{h:.3}\" fill=\"{}\" stroke=\"{}\" stroke-width=\"0.8\"/>",
|
|
theme.node_fill,
|
|
theme.node_stroke,
|
|
y = bar.start_y,
|
|
h = bar.end_y - bar.start_y,
|
|
));
|
|
}
|
|
|
|
if let Some((min_participant_x, max_participant_x)) = participant_span(&diagram, &x_for) {
|
|
for fragment in &fragment_layouts {
|
|
render_fragment(
|
|
&mut svg,
|
|
fragment,
|
|
min_participant_x,
|
|
max_participant_x,
|
|
theme,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (idx, ev) in diagram.events.iter().enumerate() {
|
|
let Some(y) = event_y_positions.get(idx).copied().flatten() else {
|
|
continue;
|
|
};
|
|
|
|
match ev {
|
|
SequenceEvent::Message {
|
|
from,
|
|
to,
|
|
text,
|
|
dashed,
|
|
head,
|
|
bidirectional,
|
|
} => {
|
|
let x1 = x_for.get(from.as_str()).copied().unwrap_or(left_margin);
|
|
let x2 = x_for.get(to.as_str()).copied().unwrap_or(left_margin);
|
|
|
|
let dash = if *dashed {
|
|
" stroke-dasharray=\"5,4\""
|
|
} else {
|
|
""
|
|
};
|
|
let marker_end = match head {
|
|
SequenceArrowHead::Filled => " marker-end=\"url(#seq_arrow)\"",
|
|
SequenceArrowHead::Cross => " marker-end=\"url(#seq_cross)\"",
|
|
SequenceArrowHead::Open => " marker-end=\"url(#seq_open)\"",
|
|
SequenceArrowHead::None => "",
|
|
};
|
|
let marker_start = if *bidirectional {
|
|
" marker-start=\"url(#seq_arrow_rev)\""
|
|
} else {
|
|
""
|
|
};
|
|
|
|
if (x1 - x2).abs() < 1.0 {
|
|
let loop_w = 26.0_f64;
|
|
let loop_h = SEQUENCE_EVENT_ROW_HEIGHT * 0.42;
|
|
let xr = x1 + loop_w;
|
|
let ye = y + loop_h;
|
|
svg.push_str(&format!(
|
|
"<path d=\"M {x1:.3},{y:.3} C {xr:.3},{y:.3} {xr:.3},{ye:.3} {x1:.3},{ye:.3}\" stroke=\"{}\" stroke-width=\"1.5\" fill=\"none\"{dash}{marker_end}/>",
|
|
theme.edge_color
|
|
));
|
|
if !text.is_empty() {
|
|
svg.push_str(&format!(
|
|
"<text x=\"{tx:.3}\" y=\"{ty:.3}\" text-anchor=\"start\" font-family=\"Trebuchet MS,Verdana,Arial,sans-serif\" font-size=\"11\" fill=\"{}\">{}</text>",
|
|
theme.text_color,
|
|
escape_xml(text),
|
|
tx = x1 + 4.0,
|
|
ty = y - 6.0
|
|
));
|
|
}
|
|
} else {
|
|
svg.push_str(&format!(
|
|
"<line x1=\"{x1:.3}\" y1=\"{y:.3}\" x2=\"{x2:.3}\" y2=\"{y:.3}\" stroke=\"{}\" stroke-width=\"1.5\"{marker_end}{marker_start}{dash}/>",
|
|
theme.edge_color
|
|
));
|
|
if !text.is_empty() {
|
|
let mx = (x1 + x2) / 2.0;
|
|
svg.push_str(&format!(
|
|
"<text x=\"{mx:.3}\" y=\"{ty:.3}\" text-anchor=\"middle\" font-family=\"Trebuchet MS,Verdana,Arial,sans-serif\" font-size=\"11\" fill=\"{}\">{}</text>",
|
|
theme.text_color,
|
|
escape_xml(text),
|
|
ty = y - 6.0
|
|
));
|
|
}
|
|
}
|
|
}
|
|
SequenceEvent::Note { placement, text } => match placement {
|
|
SequenceNotePlacement::Over { from, to } => {
|
|
let x1 = x_for.get(from.as_str()).copied().unwrap_or(left_margin);
|
|
let x2 = x_for.get(to.as_str()).copied().unwrap_or(left_margin);
|
|
|
|
let (lx, rx) = if x1 <= x2 { (x1, x2) } else { (x2, x1) };
|
|
let pad = 50.0_f64;
|
|
let note_x = (lx - pad).max(8.0);
|
|
let note_w = (rx - lx + pad * 2.0).max(120.0);
|
|
render_note(&mut svg, note_x, y, note_w, text, theme);
|
|
}
|
|
SequenceNotePlacement::Beside { participant, side } => {
|
|
let participant_x = x_for
|
|
.get(participant.as_str())
|
|
.copied()
|
|
.unwrap_or(left_margin);
|
|
let note_w = estimate_note_width(text).min(380.0);
|
|
let note_x = match side {
|
|
SequenceNoteSide::Left => (participant_x - note_w - 12.0).max(8.0),
|
|
SequenceNoteSide::Right => {
|
|
(participant_x + 12.0).min((width - note_w - 8.0).max(8.0))
|
|
}
|
|
};
|
|
render_note(&mut svg, note_x, y, note_w, text, theme);
|
|
}
|
|
},
|
|
SequenceEvent::Activate { .. }
|
|
| SequenceEvent::Deactivate { .. }
|
|
| SequenceEvent::FragmentStart { .. }
|
|
| SequenceEvent::FragmentElse { .. }
|
|
| SequenceEvent::FragmentEnd => {}
|
|
}
|
|
}
|
|
|
|
svg.push_str("</svg>");
|
|
Ok(svg)
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct SequenceParticipant {
|
|
id: String,
|
|
label: String,
|
|
aliases: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct SequenceDiagram {
|
|
participants: Vec<SequenceParticipant>,
|
|
events: Vec<SequenceEvent>,
|
|
title: Option<String>,
|
|
}
|
|
|
|
impl SequenceParticipant {
|
|
fn matches(&self, reference: &str) -> bool {
|
|
self.id == reference
|
|
|| self.label == reference
|
|
|| self.aliases.iter().any(|alias| alias == reference)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
enum SequenceFragmentKind {
|
|
Alt,
|
|
Loop,
|
|
Opt,
|
|
Par,
|
|
Critical,
|
|
Break,
|
|
Rect,
|
|
}
|
|
|
|
impl SequenceFragmentKind {
|
|
fn tab_label(self) -> Option<&'static str> {
|
|
match self {
|
|
Self::Alt => Some("alt"),
|
|
Self::Loop => Some("loop"),
|
|
Self::Opt => Some("opt"),
|
|
Self::Par => Some("par"),
|
|
Self::Critical => Some("critical"),
|
|
Self::Break => Some("break"),
|
|
Self::Rect => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum SequenceNoteSide {
|
|
Left,
|
|
Right,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum SequenceNotePlacement {
|
|
Over {
|
|
from: String,
|
|
to: String,
|
|
},
|
|
Beside {
|
|
participant: String,
|
|
side: SequenceNoteSide,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum SequenceArrowHead {
|
|
Filled,
|
|
Cross,
|
|
Open,
|
|
None,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum SequenceEvent {
|
|
Message {
|
|
from: String,
|
|
to: String,
|
|
text: String,
|
|
dashed: bool,
|
|
head: SequenceArrowHead,
|
|
bidirectional: bool,
|
|
},
|
|
Note {
|
|
placement: SequenceNotePlacement,
|
|
text: String,
|
|
},
|
|
Activate {
|
|
participant: String,
|
|
},
|
|
Deactivate {
|
|
participant: String,
|
|
},
|
|
FragmentStart {
|
|
kind: SequenceFragmentKind,
|
|
label: String,
|
|
},
|
|
FragmentElse {
|
|
label: String,
|
|
},
|
|
FragmentEnd,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct SequenceFragmentLayout {
|
|
kind: SequenceFragmentKind,
|
|
label: String,
|
|
depth: usize,
|
|
start_y: f64,
|
|
end_y: f64,
|
|
else_markers: Vec<SequenceElseMarker>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct SequenceElseMarker {
|
|
separator_y: f64,
|
|
label: String,
|
|
}
|
|
|
|
struct OpenSequenceFragment {
|
|
kind: SequenceFragmentKind,
|
|
label: String,
|
|
depth: usize,
|
|
start_y: f64,
|
|
else_markers: Vec<SequenceElseMarker>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct SequenceActivationLayout {
|
|
participant: String,
|
|
depth: usize,
|
|
start_y: f64,
|
|
end_y: f64,
|
|
}
|
|
|
|
struct AutoNumber {
|
|
active: bool,
|
|
next: u64,
|
|
step: u64,
|
|
}
|
|
|
|
impl Default for AutoNumber {
|
|
fn default() -> Self {
|
|
Self {
|
|
active: false,
|
|
next: 1,
|
|
step: 1,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AutoNumber {
|
|
fn apply(&mut self, rest: &str) {
|
|
let mut tokens = rest.split_whitespace();
|
|
match tokens.next() {
|
|
None => self.active = true,
|
|
Some(t) if t.eq_ignore_ascii_case("off") => self.active = false,
|
|
Some(t) => {
|
|
self.active = true;
|
|
if let Ok(start) = t.parse::<u64>() {
|
|
self.next = start;
|
|
}
|
|
if let Some(step) = tokens.next().and_then(|s| s.parse::<u64>().ok()) {
|
|
self.step = step;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn number(&mut self, text: String) -> String {
|
|
if !self.active {
|
|
return text;
|
|
}
|
|
let n = self.next;
|
|
self.next = self.next.saturating_add(self.step);
|
|
if text.is_empty() {
|
|
format!("{n}.")
|
|
} else {
|
|
format!("{n}. {text}")
|
|
}
|
|
}
|
|
}
|
|
|
|
fn strip_keyword_ci<'a>(line: &'a str, kw: &str) -> Option<&'a str> {
|
|
let head = line.get(..kw.len())?;
|
|
if !head.eq_ignore_ascii_case(kw) {
|
|
return None;
|
|
}
|
|
let rest = &line[kw.len()..];
|
|
if rest.is_empty() {
|
|
return Some(rest);
|
|
}
|
|
if !rest.starts_with([' ', '\t']) {
|
|
return None;
|
|
}
|
|
Some(rest.trim_start())
|
|
}
|
|
|
|
fn parse_sequence_diagram(input: &str) -> Result<SequenceDiagram, MermaidError> {
|
|
let lines: Vec<&str> = input.lines().collect();
|
|
|
|
let mut i = 0_usize;
|
|
while i < lines.len() {
|
|
let line = lines[i].trim();
|
|
if line.is_empty() || line.starts_with("%%") {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if line.split_whitespace().next() == Some("sequenceDiagram") {
|
|
i += 1;
|
|
break;
|
|
}
|
|
|
|
return Err(MermaidError::ParseError {
|
|
line: i + 1,
|
|
message: "Expected 'sequenceDiagram' declaration".to_string(),
|
|
});
|
|
}
|
|
|
|
let mut participants: Vec<SequenceParticipant> = Vec::new();
|
|
let mut events: Vec<SequenceEvent> = Vec::new();
|
|
let mut title: Option<String> = None;
|
|
let mut autonumber = AutoNumber::default();
|
|
let mut in_box = false;
|
|
let mut open_fragment_depth: usize = 0;
|
|
let mut in_acc_block = false;
|
|
|
|
while i < lines.len() {
|
|
let raw = lines[i];
|
|
let line = raw.trim();
|
|
let line_no = i + 1;
|
|
i += 1;
|
|
|
|
if line.is_empty() || line.starts_with("%%") {
|
|
continue;
|
|
}
|
|
|
|
if in_acc_block {
|
|
if line.contains('}') {
|
|
in_acc_block = false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if let Some(rest) =
|
|
strip_keyword_ci(line, "participant").or_else(|| strip_keyword_ci(line, "actor"))
|
|
{
|
|
let participant = parse_participant_declaration(rest, line_no)?;
|
|
register_participant(&mut participants, participant);
|
|
continue;
|
|
}
|
|
|
|
if let Some(rest) = strip_keyword_ci(line, "create") {
|
|
let decl = strip_keyword_ci(rest, "participant")
|
|
.or_else(|| strip_keyword_ci(rest, "actor"))
|
|
.unwrap_or(rest);
|
|
let participant = parse_participant_declaration(decl, line_no)?;
|
|
register_participant(&mut participants, participant);
|
|
continue;
|
|
}
|
|
if let Some(rest) = strip_keyword_ci(line, "destroy") {
|
|
if !rest.is_empty() {
|
|
resolve_participant_ref(&mut participants, rest);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if strip_keyword_ci(line, "box").is_some() {
|
|
in_box = true;
|
|
continue;
|
|
}
|
|
if in_box && open_fragment_depth == 0 && line.eq_ignore_ascii_case("end") {
|
|
in_box = false;
|
|
continue;
|
|
}
|
|
|
|
if let Some(rest) = strip_keyword_ci(line, "note") {
|
|
events.push(parse_note_line(rest, line, line_no, &mut participants)?);
|
|
continue;
|
|
}
|
|
|
|
if let Some(rest) = strip_keyword_ci(line, "activate") {
|
|
if !rest.is_empty() {
|
|
let participant = resolve_participant_ref(&mut participants, rest);
|
|
events.push(SequenceEvent::Activate { participant });
|
|
continue;
|
|
}
|
|
}
|
|
if let Some(rest) = strip_keyword_ci(line, "deactivate") {
|
|
if !rest.is_empty() {
|
|
let participant = resolve_participant_ref(&mut participants, rest);
|
|
events.push(SequenceEvent::Deactivate { participant });
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if let Some(rest) = strip_keyword_ci(line, "autonumber") {
|
|
autonumber.apply(rest);
|
|
continue;
|
|
}
|
|
|
|
if let Some(rest) = strip_keyword_ci(line, "title") {
|
|
let rest = rest.trim_start_matches(':').trim();
|
|
if !rest.is_empty() {
|
|
title = Some(decode_sequence_text(rest));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
let lower = line.to_ascii_lowercase();
|
|
if lower.starts_with("acctitle") || lower.starts_with("accdescr") {
|
|
if lower.contains('{') && !lower.contains('}') {
|
|
in_acc_block = true;
|
|
}
|
|
continue;
|
|
}
|
|
if lower.starts_with("links ")
|
|
|| lower.starts_with("link ")
|
|
|| lower.starts_with("properties ")
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if let Some(msg) = parse_message_line(line) {
|
|
let from = resolve_participant_ref(&mut participants, &msg.from);
|
|
let to = resolve_participant_ref(&mut participants, &msg.to);
|
|
|
|
let activate = if msg.activate_target {
|
|
Some(to.clone())
|
|
} else {
|
|
None
|
|
};
|
|
let deactivate = if msg.deactivate_source {
|
|
Some(from.clone())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
events.push(SequenceEvent::Message {
|
|
from,
|
|
to,
|
|
text: autonumber.number(msg.text),
|
|
dashed: msg.dashed,
|
|
head: msg.head,
|
|
bidirectional: msg.bidirectional,
|
|
});
|
|
if let Some(participant) = activate {
|
|
events.push(SequenceEvent::Activate { participant });
|
|
}
|
|
if let Some(participant) = deactivate {
|
|
events.push(SequenceEvent::Deactivate { participant });
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if let Some(fragment) = parse_fragment_line(line) {
|
|
match &fragment {
|
|
SequenceEvent::FragmentStart { .. } => open_fragment_depth += 1,
|
|
SequenceEvent::FragmentEnd => {
|
|
open_fragment_depth = open_fragment_depth.saturating_sub(1)
|
|
}
|
|
_ => {}
|
|
}
|
|
events.push(fragment);
|
|
continue;
|
|
}
|
|
|
|
return Err(MermaidError::ParseError {
|
|
line: line_no,
|
|
message: format!("Unrecognized sequenceDiagram line: {line}"),
|
|
});
|
|
}
|
|
|
|
if participants.is_empty() {
|
|
participants.push(SequenceParticipant {
|
|
id: "Participant".to_string(),
|
|
label: "Participant".to_string(),
|
|
aliases: Vec::new(),
|
|
});
|
|
}
|
|
|
|
Ok(SequenceDiagram {
|
|
participants,
|
|
events,
|
|
title,
|
|
})
|
|
}
|
|
|
|
fn parse_note_line(
|
|
rest: &str,
|
|
line: &str,
|
|
line_no: usize,
|
|
participants: &mut Vec<SequenceParticipant>,
|
|
) -> Result<SequenceEvent, MermaidError> {
|
|
let invalid = || MermaidError::ParseError {
|
|
line: line_no,
|
|
message: format!("Invalid Note syntax: {line}"),
|
|
};
|
|
|
|
let (side, rest) = if let Some(rest) = strip_keyword_ci(rest, "over") {
|
|
(None, rest)
|
|
} else if let Some(rest) = strip_keyword_ci(rest, "right") {
|
|
(
|
|
Some(SequenceNoteSide::Right),
|
|
strip_keyword_ci(rest, "of").ok_or_else(invalid)?,
|
|
)
|
|
} else if let Some(rest) = strip_keyword_ci(rest, "left") {
|
|
(
|
|
Some(SequenceNoteSide::Left),
|
|
strip_keyword_ci(rest, "of").ok_or_else(invalid)?,
|
|
)
|
|
} else {
|
|
return Err(invalid());
|
|
};
|
|
|
|
let (who, text) = rest.split_once(':').ok_or_else(invalid)?;
|
|
let who = who.trim();
|
|
let text = decode_sequence_text(text.trim());
|
|
|
|
let placement = match side {
|
|
Some(side) => SequenceNotePlacement::Beside {
|
|
participant: resolve_participant_ref(participants, who),
|
|
side,
|
|
},
|
|
None => {
|
|
let (from, to) = match who.split_once(',') {
|
|
Some((a, b)) => (
|
|
resolve_participant_ref(participants, a.trim()),
|
|
resolve_participant_ref(participants, b.trim()),
|
|
),
|
|
None => {
|
|
let participant = resolve_participant_ref(participants, who);
|
|
(participant.clone(), participant)
|
|
}
|
|
};
|
|
SequenceNotePlacement::Over { from, to }
|
|
}
|
|
};
|
|
|
|
Ok(SequenceEvent::Note { placement, text })
|
|
}
|
|
|
|
struct ParsedMessage {
|
|
from: String,
|
|
to: String,
|
|
text: String,
|
|
dashed: bool,
|
|
head: SequenceArrowHead,
|
|
bidirectional: bool,
|
|
activate_target: bool,
|
|
deactivate_source: bool,
|
|
}
|
|
|
|
fn parse_message_line(line: &str) -> Option<ParsedMessage> {
|
|
let (head_raw, text) = line
|
|
.split_once(':')
|
|
.map_or((line, ""), |(a, b)| (a, b.trim()));
|
|
|
|
let head_raw = head_raw.trim();
|
|
const ARROWS: &[(&str, bool, SequenceArrowHead, bool)] = &[
|
|
("<<-->>", true, SequenceArrowHead::Filled, true),
|
|
("<<->>", false, SequenceArrowHead::Filled, true),
|
|
("-->>", true, SequenceArrowHead::Filled, false),
|
|
("->>", false, SequenceArrowHead::Filled, false),
|
|
("--x", true, SequenceArrowHead::Cross, false),
|
|
("-x", false, SequenceArrowHead::Cross, false),
|
|
("--)", true, SequenceArrowHead::Open, false),
|
|
("-)", false, SequenceArrowHead::Open, false),
|
|
("-->", true, SequenceArrowHead::None, false),
|
|
("->", false, SequenceArrowHead::None, false),
|
|
];
|
|
let (op, dashed, head, bidirectional) = ARROWS
|
|
.iter()
|
|
.copied()
|
|
.find(|(op, ..)| head_raw.contains(op))?;
|
|
|
|
let (from_raw, to_raw) = head_raw.split_once(op)?;
|
|
let from = from_raw.trim().to_string();
|
|
let mut to_raw = to_raw.trim();
|
|
let mut activate_target = false;
|
|
let mut deactivate_source = false;
|
|
if let Some(stripped) = to_raw.strip_prefix('+') {
|
|
activate_target = true;
|
|
to_raw = stripped;
|
|
} else if let Some(stripped) = to_raw.strip_prefix('-') {
|
|
deactivate_source = true;
|
|
to_raw = stripped;
|
|
}
|
|
|
|
Some(ParsedMessage {
|
|
from,
|
|
to: to_raw.trim().to_string(),
|
|
text: text.to_string(),
|
|
dashed,
|
|
head,
|
|
bidirectional,
|
|
activate_target,
|
|
deactivate_source,
|
|
})
|
|
}
|
|
|
|
fn parse_fragment_line(line: &str) -> Option<SequenceEvent> {
|
|
let trimmed = line.trim();
|
|
|
|
const STARTS: &[(&str, SequenceFragmentKind)] = &[
|
|
("alt", SequenceFragmentKind::Alt),
|
|
("loop", SequenceFragmentKind::Loop),
|
|
("opt", SequenceFragmentKind::Opt),
|
|
("par", SequenceFragmentKind::Par),
|
|
("critical", SequenceFragmentKind::Critical),
|
|
("break", SequenceFragmentKind::Break),
|
|
("rect", SequenceFragmentKind::Rect),
|
|
];
|
|
for (kw, kind) in STARTS.iter().copied() {
|
|
if let Some(label) = strip_keyword_ci(trimmed, kw) {
|
|
return Some(SequenceEvent::FragmentStart {
|
|
kind,
|
|
label: label.to_string(),
|
|
});
|
|
}
|
|
}
|
|
|
|
for kw in ["else", "and", "option"] {
|
|
if let Some(label) = strip_keyword_ci(trimmed, kw) {
|
|
return Some(SequenceEvent::FragmentElse {
|
|
label: label.to_string(),
|
|
});
|
|
}
|
|
}
|
|
|
|
if trimmed.eq_ignore_ascii_case("end") {
|
|
return Some(SequenceEvent::FragmentEnd);
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn parse_participant_declaration(
|
|
raw: &str,
|
|
line_no: usize,
|
|
) -> Result<SequenceParticipant, MermaidError> {
|
|
let raw = raw.trim();
|
|
if raw.is_empty() {
|
|
return Err(MermaidError::ParseError {
|
|
line: line_no,
|
|
message: "Expected participant name".to_string(),
|
|
});
|
|
}
|
|
|
|
let (id, label, aliases) = match raw.split_once(" as ") {
|
|
Some((lhs, rhs)) => {
|
|
let lhs = normalize_participant_token(lhs);
|
|
let rhs = normalize_participant_token(rhs);
|
|
if lhs.is_empty() || rhs.is_empty() {
|
|
return Err(MermaidError::ParseError {
|
|
line: line_no,
|
|
message: "Expected participant name".to_string(),
|
|
});
|
|
}
|
|
let (id, label) = choose_participant_id_and_label(&lhs, &rhs);
|
|
let mut aliases = Vec::new();
|
|
push_unique_alias(&mut aliases, lhs);
|
|
push_unique_alias(&mut aliases, rhs);
|
|
(id, label, aliases)
|
|
}
|
|
None => {
|
|
let name = normalize_participant_token(raw);
|
|
if name.is_empty() {
|
|
return Err(MermaidError::ParseError {
|
|
line: line_no,
|
|
message: "Expected participant name".to_string(),
|
|
});
|
|
}
|
|
(name.clone(), name, Vec::new())
|
|
}
|
|
};
|
|
|
|
Ok(SequenceParticipant { id, label, aliases })
|
|
}
|
|
|
|
fn choose_participant_id_and_label(lhs: &str, rhs: &str) -> (String, String) {
|
|
let lhs_has_whitespace = lhs.chars().any(char::is_whitespace);
|
|
let rhs_has_whitespace = rhs.chars().any(char::is_whitespace);
|
|
|
|
match (lhs_has_whitespace, rhs_has_whitespace) {
|
|
(true, false) => (rhs.to_string(), lhs.to_string()),
|
|
(false, true) => (lhs.to_string(), rhs.to_string()),
|
|
_ if lhs.len() <= rhs.len() => (lhs.to_string(), rhs.to_string()),
|
|
_ => (rhs.to_string(), lhs.to_string()),
|
|
}
|
|
}
|
|
|
|
fn normalize_participant_token(token: &str) -> String {
|
|
token
|
|
.trim()
|
|
.trim_matches(|ch| matches!(ch, '"' | '\''))
|
|
.trim()
|
|
.to_string()
|
|
}
|
|
|
|
fn register_participant(list: &mut Vec<SequenceParticipant>, participant: SequenceParticipant) {
|
|
let match_index = list.iter().position(|existing| {
|
|
existing.matches(&participant.id)
|
|
|| existing.matches(&participant.label)
|
|
|| participant
|
|
.aliases
|
|
.iter()
|
|
.any(|alias| existing.matches(alias))
|
|
});
|
|
|
|
match match_index {
|
|
Some(index) => {
|
|
let existing = &mut list[index];
|
|
if existing.label == existing.id && participant.label != participant.id {
|
|
existing.label = participant.label.clone();
|
|
}
|
|
push_unique_alias(&mut existing.aliases, participant.id.clone());
|
|
push_unique_alias(&mut existing.aliases, participant.label.clone());
|
|
for alias in participant.aliases {
|
|
push_unique_alias(&mut existing.aliases, alias);
|
|
}
|
|
}
|
|
None => list.push(participant),
|
|
}
|
|
}
|
|
|
|
fn resolve_participant_ref(list: &mut Vec<SequenceParticipant>, reference: &str) -> String {
|
|
let reference = normalize_participant_token(reference);
|
|
|
|
if let Some(participant) = list
|
|
.iter()
|
|
.find(|participant| participant.matches(&reference))
|
|
{
|
|
return participant.id.clone();
|
|
}
|
|
|
|
let participant = SequenceParticipant {
|
|
id: reference.clone(),
|
|
label: reference,
|
|
aliases: Vec::new(),
|
|
};
|
|
let id = participant.id.clone();
|
|
list.push(participant);
|
|
id
|
|
}
|
|
|
|
fn push_unique_alias(aliases: &mut Vec<String>, value: String) {
|
|
if !value.is_empty() && !aliases.iter().any(|alias| alias == &value) {
|
|
aliases.push(value);
|
|
}
|
|
}
|
|
|
|
fn participant_span(diagram: &SequenceDiagram, x_for: &BTreeMap<&str, f64>) -> Option<(f64, f64)> {
|
|
let mut xs = diagram
|
|
.participants
|
|
.iter()
|
|
.filter_map(|participant| x_for.get(participant.id.as_str()).copied());
|
|
let first = xs.next()?;
|
|
let mut min_x = first;
|
|
let mut max_x = first;
|
|
for x in xs {
|
|
min_x = min_x.min(x);
|
|
max_x = max_x.max(x);
|
|
}
|
|
Some((min_x, max_x))
|
|
}
|
|
|
|
fn layout_sequence_events(
|
|
events: &[SequenceEvent],
|
|
events_top: f64,
|
|
) -> (
|
|
Vec<Option<f64>>,
|
|
Vec<SequenceFragmentLayout>,
|
|
Vec<SequenceActivationLayout>,
|
|
f64,
|
|
) {
|
|
let mut event_y_positions = vec![None; events.len()];
|
|
let mut fragment_layouts = Vec::new();
|
|
let mut open_fragments: Vec<OpenSequenceFragment> = Vec::new();
|
|
let mut cursor_y = events_top;
|
|
let mut last_row_center: Option<f64> = None;
|
|
|
|
for (idx, event) in events.iter().enumerate() {
|
|
match event {
|
|
SequenceEvent::Message { .. } | SequenceEvent::Note { .. } => {
|
|
let center = cursor_y + SEQUENCE_EVENT_ROW_HEIGHT / 2.0;
|
|
event_y_positions[idx] = Some(center);
|
|
last_row_center = Some(center);
|
|
cursor_y += SEQUENCE_EVENT_ROW_HEIGHT;
|
|
}
|
|
SequenceEvent::Activate { .. } | SequenceEvent::Deactivate { .. } => {
|
|
event_y_positions[idx] = Some(last_row_center.unwrap_or(cursor_y));
|
|
}
|
|
SequenceEvent::FragmentStart { kind, label } => {
|
|
open_fragments.push(OpenSequenceFragment {
|
|
kind: *kind,
|
|
label: label.clone(),
|
|
depth: open_fragments.len(),
|
|
start_y: cursor_y,
|
|
else_markers: Vec::new(),
|
|
});
|
|
cursor_y += SEQUENCE_FRAGMENT_HEADER_HEIGHT;
|
|
}
|
|
SequenceEvent::FragmentElse { label } => {
|
|
if let Some(fragment) = open_fragments.last_mut() {
|
|
fragment.else_markers.push(SequenceElseMarker {
|
|
separator_y: cursor_y,
|
|
label: label.clone(),
|
|
});
|
|
}
|
|
cursor_y += SEQUENCE_FRAGMENT_HEADER_HEIGHT;
|
|
}
|
|
SequenceEvent::FragmentEnd => {
|
|
if let Some(fragment) = open_fragments.pop() {
|
|
fragment_layouts.push(SequenceFragmentLayout {
|
|
kind: fragment.kind,
|
|
label: fragment.label,
|
|
depth: fragment.depth,
|
|
start_y: fragment.start_y,
|
|
end_y: cursor_y + SEQUENCE_FRAGMENT_FOOTER_HEIGHT,
|
|
else_markers: fragment.else_markers,
|
|
});
|
|
cursor_y += SEQUENCE_FRAGMENT_FOOTER_HEIGHT;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
while let Some(fragment) = open_fragments.pop() {
|
|
fragment_layouts.push(SequenceFragmentLayout {
|
|
kind: fragment.kind,
|
|
label: fragment.label,
|
|
depth: fragment.depth,
|
|
start_y: fragment.start_y,
|
|
end_y: cursor_y + SEQUENCE_FRAGMENT_FOOTER_HEIGHT,
|
|
else_markers: fragment.else_markers,
|
|
});
|
|
cursor_y += SEQUENCE_FRAGMENT_FOOTER_HEIGHT;
|
|
}
|
|
|
|
fragment_layouts.sort_by(|a, b| {
|
|
a.depth
|
|
.cmp(&b.depth)
|
|
.then_with(|| a.start_y.total_cmp(&b.start_y))
|
|
});
|
|
|
|
let activations = compute_activation_layouts(events, &event_y_positions, cursor_y);
|
|
(event_y_positions, fragment_layouts, activations, cursor_y)
|
|
}
|
|
|
|
fn compute_activation_layouts(
|
|
events: &[SequenceEvent],
|
|
event_y_positions: &[Option<f64>],
|
|
content_bottom: f64,
|
|
) -> Vec<SequenceActivationLayout> {
|
|
const MIN_BAR_H: f64 = 6.0;
|
|
|
|
let mut open: Vec<(String, f64, usize)> = Vec::new();
|
|
let mut bars = Vec::new();
|
|
for (idx, event) in events.iter().enumerate() {
|
|
let y = event_y_positions.get(idx).copied().flatten();
|
|
match event {
|
|
SequenceEvent::Activate { participant } => {
|
|
let depth = open.iter().filter(|(p, ..)| p == participant).count();
|
|
open.push((participant.clone(), y.unwrap_or(content_bottom), depth));
|
|
}
|
|
SequenceEvent::Deactivate { participant } => {
|
|
if let Some(pos) = open.iter().rposition(|(p, ..)| p == participant) {
|
|
let (participant, start_y, depth) = open.remove(pos);
|
|
let end_y = y.unwrap_or(content_bottom);
|
|
bars.push(SequenceActivationLayout {
|
|
participant,
|
|
depth,
|
|
start_y,
|
|
end_y: end_y.max(start_y + MIN_BAR_H),
|
|
});
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
for (participant, start_y, depth) in open {
|
|
bars.push(SequenceActivationLayout {
|
|
participant,
|
|
depth,
|
|
start_y,
|
|
end_y: content_bottom.max(start_y + MIN_BAR_H),
|
|
});
|
|
}
|
|
bars.sort_by(|a, b| {
|
|
a.depth
|
|
.cmp(&b.depth)
|
|
.then_with(|| a.start_y.total_cmp(&b.start_y))
|
|
});
|
|
bars
|
|
}
|
|
|
|
fn render_fragment(
|
|
svg: &mut String,
|
|
fragment: &SequenceFragmentLayout,
|
|
min_participant_x: f64,
|
|
max_participant_x: f64,
|
|
theme: &MermaidTheme,
|
|
) {
|
|
let inset = fragment.depth as f64 * SEQUENCE_FRAGMENT_INSET_X;
|
|
let x = min_participant_x - SEQUENCE_FRAGMENT_MARGIN_X + inset;
|
|
let width =
|
|
(max_participant_x - min_participant_x) + SEQUENCE_FRAGMENT_MARGIN_X * 2.0 - inset * 2.0;
|
|
let height = (fragment.end_y - fragment.start_y).max(SEQUENCE_FRAGMENT_HEADER_HEIGHT);
|
|
let y = fragment.start_y;
|
|
|
|
svg.push_str(&format!(
|
|
"<rect x=\"{x:.3}\" y=\"{y:.3}\" width=\"{width:.3}\" height=\"{height:.3}\" fill=\"none\" stroke=\"{SEQUENCE_FRAGMENT_STROKE}\" stroke-width=\"1\" stroke-dasharray=\"3,3\"/>"
|
|
));
|
|
|
|
if let Some(tab_text) = fragment.kind.tab_label() {
|
|
let tab_text_width = crate::text_wrap::display_width_units(tab_text) * 6.5;
|
|
let tab_width = tab_text_width + SEQUENCE_FRAGMENT_TAB_PADDING_X * 2.0 + 10.0;
|
|
let tab_height = 18.0;
|
|
let tab_x = x + 8.0;
|
|
let tab_y = y + 4.0;
|
|
let tab_body_width = (tab_width - 10.0).max(10.0);
|
|
|
|
svg.push_str(&format!(
|
|
"<path d=\"M {tab_x:.3},{tab_y:.3} h {tab_body_width:.3} l 10,0 l 0,10 l -10,8 h -{tab_body_width:.3} z\" fill=\"{}\" stroke=\"{SEQUENCE_FRAGMENT_STROKE}\" stroke-width=\"1\"/>",
|
|
theme.node_fill
|
|
));
|
|
svg.push_str(&format!(
|
|
"<text x=\"{text_x:.3}\" y=\"{text_y:.3}\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"Trebuchet MS,Verdana,Arial,sans-serif\" font-size=\"11\" fill=\"{SEQUENCE_FRAGMENT_TEXT}\">{}</text>",
|
|
escape_xml(tab_text),
|
|
text_x = tab_x + tab_body_width / 2.0,
|
|
text_y = tab_y + tab_height / 2.0
|
|
));
|
|
|
|
if !fragment.label.is_empty() {
|
|
let label_x = (tab_x + tab_body_width + 18.0).min(x + width - 8.0);
|
|
svg.push_str(&format!(
|
|
"<text x=\"{text_x:.3}\" y=\"{text_y:.3}\" text-anchor=\"start\" dominant-baseline=\"central\" font-family=\"Trebuchet MS,Verdana,Arial,sans-serif\" font-size=\"11\" fill=\"{}\">[{}]</text>",
|
|
theme.text_color,
|
|
escape_xml(&fragment.label),
|
|
text_x = label_x,
|
|
text_y = y + SEQUENCE_FRAGMENT_HEADER_HEIGHT / 2.0
|
|
));
|
|
}
|
|
}
|
|
|
|
for else_marker in &fragment.else_markers {
|
|
svg.push_str(&format!(
|
|
"<line x1=\"{x:.3}\" y1=\"{y:.3}\" x2=\"{x2:.3}\" y2=\"{y:.3}\" stroke=\"{SEQUENCE_FRAGMENT_STROKE}\" stroke-width=\"1\" stroke-dasharray=\"3,3\"/>",
|
|
x = x,
|
|
y = else_marker.separator_y,
|
|
x2 = x + width
|
|
));
|
|
if !else_marker.label.is_empty() {
|
|
svg.push_str(&format!(
|
|
"<text x=\"{text_x:.3}\" y=\"{text_y:.3}\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"Trebuchet MS,Verdana,Arial,sans-serif\" font-size=\"11\" fill=\"{}\">[{}]</text>",
|
|
theme.text_color,
|
|
escape_xml(&else_marker.label),
|
|
text_x = x + width / 2.0,
|
|
text_y = else_marker.separator_y + SEQUENCE_FRAGMENT_HEADER_HEIGHT / 2.0
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_note(
|
|
svg: &mut String,
|
|
note_x: f64,
|
|
center_y: f64,
|
|
note_w: f64,
|
|
text: &str,
|
|
theme: &MermaidTheme,
|
|
) {
|
|
let note_h = 26.0_f64;
|
|
svg.push_str(&format!(
|
|
"<rect x=\"{note_x:.3}\" y=\"{ny:.3}\" width=\"{note_w:.3}\" height=\"{note_h:.3}\" rx=\"4\" ry=\"4\" fill=\"#fff2b0\" stroke=\"{}\" stroke-width=\"1\"/>",
|
|
theme.edge_color,
|
|
ny = center_y - note_h / 2.0
|
|
));
|
|
svg.push_str(&format!(
|
|
"<text x=\"{x:.3}\" y=\"{center_y:.3}\" text-anchor=\"middle\" dominant-baseline=\"middle\" font-family=\"Trebuchet MS,Verdana,Arial,sans-serif\" font-size=\"11\" fill=\"#333333\">{}</text>",
|
|
escape_xml(text),
|
|
x = note_x + note_w / 2.0
|
|
));
|
|
}
|
|
|
|
fn estimate_note_width(text: &str) -> f64 {
|
|
(crate::text_wrap::display_width_units(text) * 6.5 + 18.0).max(120.0)
|
|
}
|
|
fn compute_sequence_pair_spacings(diagram: &SequenceDiagram, box_w: f64) -> Vec<f64> {
|
|
let mut pair_spacings = vec![box_w + 30.0; diagram.participants.len().saturating_sub(1)];
|
|
for event in &diagram.events {
|
|
let SequenceEvent::Message { from, to, text, .. } = event else {
|
|
continue;
|
|
};
|
|
let (Some(from_idx), Some(to_idx)) = (
|
|
participant_index(diagram, from),
|
|
participant_index(diagram, to),
|
|
) else {
|
|
continue;
|
|
};
|
|
let a = from_idx.min(to_idx);
|
|
let b = from_idx.max(to_idx);
|
|
if a == b {
|
|
continue;
|
|
}
|
|
let total_gap = (estimate_sequence_text_width(text) * 0.95).max(box_w + 30.0);
|
|
let per_gap = total_gap / (b - a) as f64;
|
|
for spacing in &mut pair_spacings[a..b] {
|
|
*spacing = spacing.max(per_gap);
|
|
}
|
|
}
|
|
pair_spacings
|
|
}
|
|
|
|
fn required_sequence_width(
|
|
diagram: &SequenceDiagram,
|
|
participant_xs: &[f64],
|
|
box_w: f64,
|
|
edge_pad: f64,
|
|
) -> f64 {
|
|
let mut width = participant_xs.last().copied().unwrap_or(0.0) + box_w / 2.0 + edge_pad;
|
|
for event in &diagram.events {
|
|
match event {
|
|
SequenceEvent::Message { from, to, text, .. } => {
|
|
let (Some(from_idx), Some(to_idx)) = (
|
|
participant_index(diagram, from),
|
|
participant_index(diagram, to),
|
|
) else {
|
|
continue;
|
|
};
|
|
let x1 = participant_xs.get(from_idx).copied().unwrap_or(0.0);
|
|
let x2 = participant_xs.get(to_idx).copied().unwrap_or(0.0);
|
|
if from_idx == to_idx {
|
|
width = width.max(x1 + 50.0 + edge_pad);
|
|
continue;
|
|
}
|
|
let mx = (x1 + x2) / 2.0;
|
|
width = width.max(mx + estimate_sequence_text_width(text) / 2.0 + edge_pad);
|
|
}
|
|
SequenceEvent::Note { placement, text } => match placement {
|
|
SequenceNotePlacement::Over { from, to } => {
|
|
let (Some(from_idx), Some(to_idx)) = (
|
|
participant_index(diagram, from),
|
|
participant_index(diagram, to),
|
|
) else {
|
|
continue;
|
|
};
|
|
let x1 = participant_xs.get(from_idx).copied().unwrap_or(0.0);
|
|
let x2 = participant_xs.get(to_idx).copied().unwrap_or(0.0);
|
|
let (lx, rx) = if x1 <= x2 { (x1, x2) } else { (x2, x1) };
|
|
let note_x = (lx - 50.0).max(8.0);
|
|
let note_w = (rx - lx + 100.0).max(120.0);
|
|
width = width.max(note_x + note_w + edge_pad);
|
|
}
|
|
SequenceNotePlacement::Beside { participant, side } => {
|
|
if !matches!(side, SequenceNoteSide::Right) {
|
|
continue;
|
|
}
|
|
let Some(idx) = participant_index(diagram, participant) else {
|
|
continue;
|
|
};
|
|
let x = participant_xs.get(idx).copied().unwrap_or(0.0);
|
|
width = width.max(x + 12.0 + estimate_note_width(text).min(380.0) + edge_pad);
|
|
}
|
|
},
|
|
SequenceEvent::Activate { .. }
|
|
| SequenceEvent::Deactivate { .. }
|
|
| SequenceEvent::FragmentStart { .. }
|
|
| SequenceEvent::FragmentElse { .. }
|
|
| SequenceEvent::FragmentEnd => {}
|
|
}
|
|
}
|
|
width
|
|
}
|
|
|
|
fn participant_index(diagram: &SequenceDiagram, id: &str) -> Option<usize> {
|
|
diagram
|
|
.participants
|
|
.iter()
|
|
.position(|participant| participant.id == id)
|
|
}
|
|
|
|
fn estimate_sequence_text_width(text: &str) -> f64 {
|
|
crate::text_wrap::display_width_units(text) * 6.5
|
|
}
|
|
|
|
fn estimate_label_box_width(label: &str) -> f64 {
|
|
let char_w = 7.2_f64;
|
|
let padding = 16.0_f64;
|
|
let text_w = label
|
|
.split("<br/>")
|
|
.map(|line| crate::text_wrap::display_width_units(line) * char_w)
|
|
.fold(0.0_f64, f64::max);
|
|
(text_w + padding).max(100.0)
|
|
}
|
|
|
|
fn render_participant_label(x: f64, cy: f64, label: &str, color: &str) -> String {
|
|
let font = "Trebuchet MS,Verdana,Arial,sans-serif";
|
|
let size = 12_f64;
|
|
let lines: Vec<&str> = label.split("<br/>").collect();
|
|
|
|
if lines.len() == 1 {
|
|
return format!(
|
|
"<text x=\"{x:.3}\" y=\"{cy:.3}\" text-anchor=\"middle\" dominant-baseline=\"middle\" font-family=\"{font}\" font-size=\"{size}\" fill=\"{color}\">{}</text>",
|
|
escape_xml(lines[0])
|
|
);
|
|
}
|
|
|
|
let line_h = 15.0_f64;
|
|
let total_h = line_h * lines.len() as f64;
|
|
let y0 = cy - total_h / 2.0 + line_h / 2.0;
|
|
|
|
let mut out = format!(
|
|
"<text x=\"{x:.3}\" text-anchor=\"middle\" font-family=\"{font}\" font-size=\"{size}\" fill=\"{color}\">"
|
|
);
|
|
for (i, line_text) in lines.iter().enumerate() {
|
|
if i == 0 {
|
|
out.push_str(&format!(
|
|
"<tspan x=\"{x:.3}\" y=\"{y0:.3}\">{}</tspan>",
|
|
escape_xml(line_text)
|
|
));
|
|
} else {
|
|
out.push_str(&format!(
|
|
"<tspan x=\"{x:.3}\" dy=\"{line_h:.3}\">{}</tspan>",
|
|
escape_xml(line_text)
|
|
));
|
|
}
|
|
}
|
|
out.push_str("</text>");
|
|
out
|
|
}
|
|
|
|
fn decode_sequence_text(s: &str) -> String {
|
|
s.replace("#59;", ";")
|
|
}
|
|
|
|
fn escape_xml(s: &str) -> String {
|
|
s.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
.replace('\'', "'")
|
|
}
|