Skip to main content

rpi_tui/
mermaid.rs

1//! Compact Mermaid rendering for terminal TUI.
2//!
3//! Terminals do not have a portable Mermaid canvas. This component renders the
4//! useful structure of common flowcharts and sequence diagrams as aligned
5//! Unicode text, and falls back to a readable source panel for unsupported
6//! Mermaid constructs.
7
8use std::any::Any;
9use std::collections::HashMap;
10
11use super::component::Component;
12use super::theme::theme;
13use super::utils::wrap_text_with_ansi;
14
15pub struct Mermaid {
16    source: String,
17    padding_x: usize,
18}
19
20impl Mermaid {
21    pub fn new(source: impl Into<String>) -> Self {
22        Self {
23            source: source.into(),
24            padding_x: 1,
25        }
26    }
27
28    fn body_lines(&self, width: usize) -> Vec<String> {
29        let source = self.source.trim();
30        let mut lines = Vec::new();
31        let first = source.lines().next().unwrap_or_default().trim();
32        let is_sequence = first.eq_ignore_ascii_case("sequencediagram");
33        let is_flow = first.eq_ignore_ascii_case("graph")
34            || first.to_ascii_lowercase().starts_with("graph ")
35            || first.eq_ignore_ascii_case("flowchart")
36            || first.to_ascii_lowercase().starts_with("flowchart ");
37
38        if is_sequence {
39            lines.extend(self.render_sequence(source));
40        } else if is_flow {
41            lines.extend(self.render_flow(source));
42        }
43
44        if lines.is_empty() {
45            lines = source
46                .lines()
47                .skip_while(|line| {
48                    let trimmed = line.trim().to_ascii_lowercase();
49                    trimmed == "mermaid"
50                        || trimmed == "graph"
51                        || trimmed.starts_with("graph ")
52                        || trimmed == "flowchart"
53                        || trimmed.starts_with("flowchart ")
54                        || trimmed == "sequencediagram"
55                })
56                .map(|line| format!("  {line}"))
57                .collect();
58        }
59
60        let max_width = width.saturating_sub(self.padding_x * 2).max(1);
61        lines
62            .into_iter()
63            .flat_map(|line| wrap_text_with_ansi(&line, max_width))
64            .collect()
65    }
66
67    fn render_flow(&self, source: &str) -> Vec<String> {
68        let edges: Vec<_> = source
69            .lines()
70            .skip(1)
71            .map(str::trim)
72            .filter(|line| {
73                !line.is_empty()
74                    && !line.starts_with("%%")
75                    && !line.starts_with("class")
76                    && !line.starts_with("style")
77                    && !line.starts_with("linkStyle")
78            })
79            .filter_map(parse_flow_edge)
80            .collect();
81
82        // Mermaid lets later edges refer to a node by its identifier alone
83        // (`B --> C` after `A --> B[Ready]`). Preserve the readable node label
84        // across those references instead of showing the implementation ID.
85        let mut labels = HashMap::new();
86        for (left, _, _, right) in &edges {
87            for raw_node in [*left, *right] {
88                let id = node_id(raw_node);
89                let label = node_label(raw_node);
90                if !id.is_empty() && !label.is_empty() && id != label {
91                    labels.insert(id, label);
92                }
93            }
94        }
95
96        edges
97            .into_iter()
98            .filter_map(|(left, arrow, edge_label, right)| {
99                let left = labels
100                    .get(&node_id(left))
101                    .cloned()
102                    .unwrap_or_else(|| node_label(left));
103                let right = labels
104                    .get(&node_id(right))
105                    .cloned()
106                    .unwrap_or_else(|| node_label(right));
107                if left.is_empty() || right.is_empty() {
108                    return None;
109                }
110                let label_suffix = edge_label
111                    .filter(|label| !label.is_empty())
112                    .map(|label| format!(" ({label})"))
113                    .unwrap_or_default();
114                Some(format!("  {left} {arrow}{label_suffix} {right}"))
115            })
116            .collect()
117    }
118
119    fn render_sequence(&self, source: &str) -> Vec<String> {
120        let mut out = Vec::new();
121        for raw in source.lines().skip(1) {
122            let line = raw.trim();
123            if line.is_empty() || line.starts_with("%%") || line.starts_with("participant ") {
124                continue;
125            }
126            let Some((left, rest)) = line.split_once("->>") else {
127                continue;
128            };
129            let Some((right, message)) = rest.split_once(':') else {
130                continue;
131            };
132            out.push(format!(
133                "  {} ──▶ {}: {}",
134                left.trim(),
135                right.trim(),
136                message.trim()
137            ));
138        }
139        out
140    }
141}
142
143/// Parse a common Mermaid flowchart edge. Mermaid permits edge labels in
144/// `A -->|yes| B` form and several connector styles; terminal rendering keeps
145/// the graph readable as one labelled edge per row.
146fn parse_flow_edge(line: &str) -> Option<(&str, &'static str, Option<String>, &str)> {
147    let (connector, arrow) = if line.contains("-.->") {
148        ("-.->", "╌╌▶")
149    } else if line.contains("==>") {
150        ("==>", "══▶")
151    } else if line.contains("-->") {
152        ("-->", "──▶")
153    } else if line.contains("---") {
154        ("---", "───")
155    } else {
156        return None;
157    };
158    let (left, raw_right) = line.split_once(connector)?;
159    let raw_right = raw_right.trim();
160    // Standard Mermaid syntax: `A -->|approved| B`.
161    if let Some(rest) = raw_right.strip_prefix('|') {
162        let (label, right) = rest.split_once('|')?;
163        return Some((
164            left,
165            arrow,
166            Some(label.trim().trim_matches('"').to_string()),
167            right,
168        ));
169    }
170    // Retain compatibility with the original compact renderer's `B: label`
171    // notation as it is useful in hand-authored terminal diagrams.
172    if let Some((right, label)) = raw_right.split_once(':') {
173        if !label.trim().is_empty() {
174            return Some((
175                left,
176                arrow,
177                Some(label.trim().trim_matches('"').to_string()),
178                right,
179            ));
180        }
181    }
182    Some((left, arrow, None, raw_right))
183}
184
185/// Mermaid node identifier, before any shape delimiter. A bare node is both
186/// its identifier and label.
187fn node_id(raw: &str) -> String {
188    raw.trim()
189        .trim_matches(';')
190        .trim()
191        .split(['(', '[', '{'])
192        .next()
193        .unwrap_or_default()
194        .trim()
195        .to_string()
196}
197
198fn node_label(raw: &str) -> String {
199    let raw = raw.trim().trim_matches(';').trim();
200    let Some(open) = raw.find(['(', '[', '{']) else {
201        return raw.to_string();
202    };
203    let close = match raw.as_bytes().get(open) {
204        Some(b'(') => ')',
205        Some(b'[') => ']',
206        Some(b'{') => '}',
207        _ => return raw.to_string(),
208    };
209    // `A((Start))` and `A([Start])` use nested delimiters. Use the final
210    // matching delimiter and remove presentation-only wrapper punctuation.
211    raw[open + 1..]
212        .rfind(close)
213        .map(|end| {
214            raw[open + 1..open + 1 + end]
215                .trim()
216                .trim_matches(|c| matches!(c, '"' | '(' | ')' | '[' | ']' | '{' | '}'))
217                .trim()
218                .to_string()
219        })
220        .filter(|label| !label.is_empty())
221        .unwrap_or_else(|| raw.to_string())
222}
223
224impl Component for Mermaid {
225    fn render(&self, width: usize) -> Vec<String> {
226        let colors = theme().colors;
227        let panel_width = width.max(1).saturating_sub(self.padding_x * 2).max(1);
228        let mut lines = Vec::new();
229        lines.push(format!(
230            "{}{}",
231            " ".repeat(self.padding_x),
232            colors.md_code_block_border.fg("mermaid")
233        ));
234        lines.extend(
235            self.body_lines(panel_width)
236                .into_iter()
237                .map(|line| format!("{}{}", " ".repeat(self.padding_x), line)),
238        );
239        lines
240    }
241
242    fn invalidate(&self) {}
243
244    fn as_any(&self) -> &dyn Any {
245        self
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::strip_ansi;
253
254    #[test]
255    fn renders_flowchart_edges_as_terminal_arrows() {
256        let output = strip_ansi(
257            &Mermaid::new("flowchart LR\nA[Start] --> B{Done?}")
258                .render(60)
259                .join("\n"),
260        );
261        assert!(output.contains("Start ──▶ Done?"));
262    }
263
264    #[test]
265    fn renders_labelled_and_styled_flowchart_edges() {
266        let output = strip_ansi(
267            &Mermaid::new(
268                "flowchart TD\nA((Start)) -->|continue| B{Ready?}\nB -.->|no| C[Retry]\nB ==> D([Done])",
269            )
270            .render(60)
271            .join("\n"),
272        );
273        assert!(output.contains("Start ──▶ (continue) Ready?"), "{output}");
274        assert!(output.contains("Ready? ╌╌▶ (no) Retry"), "{output}");
275        assert!(output.contains("Ready? ══▶ Done"), "{output}");
276    }
277
278    #[test]
279    fn long_flowchart_edges_wrap_instead_of_eliding() {
280        let output = strip_ansi(
281            &Mermaid::new(
282                "flowchart LR\nA[Very long source node] --> B[Very long destination node]",
283            )
284            .render(20)
285            .join("\n"),
286        );
287        let compact: String = output.chars().filter(|ch| !ch.is_whitespace()).collect();
288        assert!(compact.contains("Verylongsourcenode"), "{output}");
289        assert!(compact.contains("Verylongdestinationnode"), "{output}");
290    }
291
292    #[test]
293    fn renders_sequence_messages() {
294        let output = strip_ansi(
295            &Mermaid::new("sequenceDiagram\nAlice->>Bob: Hello")
296                .render(60)
297                .join("\n"),
298        );
299        assert!(output.contains("Alice ──▶ Bob: Hello"));
300    }
301
302    #[test]
303    fn falls_back_to_source_for_unknown_diagrams() {
304        let output = strip_ansi(&Mermaid::new("pie\n  \"A\" : 50").render(60).join("\n"));
305        assert!(output.contains("\"A\" : 50"));
306    }
307}