Skip to main content

rusty_mermaid_core/
style.rs

1use crate::Color;
2
3/// Visual style for shapes and paths.
4#[derive(Debug, Clone, Default)]
5pub struct Style {
6    pub fill: Option<Color>,
7    pub stroke: Option<Color>,
8    pub stroke_width: Option<f64>,
9    pub stroke_dasharray: Option<Vec<f64>>,
10    pub opacity: Option<f64>,
11    pub css_classes: Vec<String>,
12}
13
14impl Style {
15    /// Resolve stroke color, falling back to theme default.
16    pub fn resolved_stroke(&self, theme: &Theme) -> Color {
17        self.stroke.unwrap_or(theme.edge_stroke)
18    }
19
20    /// Resolve stroke width, falling back to theme default.
21    pub fn resolved_stroke_width(&self, theme: &Theme) -> f64 {
22        self.stroke_width.unwrap_or(theme.default_stroke_width)
23    }
24
25    /// Returns true if either stroke color or width is explicitly set.
26    pub fn has_explicit_stroke(&self) -> bool {
27        self.stroke.is_some() || self.stroke_width.is_some()
28    }
29
30    /// Resolve stroke only if explicitly set (at least one of color/width).
31    /// Returns (color, width) with theme fallback for the unset field.
32    pub fn resolve_stroke_opt(&self, theme: &Theme) -> Option<(Color, f64)> {
33        if self.has_explicit_stroke() {
34            Some((
35                self.resolved_stroke(theme),
36                self.resolved_stroke_width(theme),
37            ))
38        } else {
39            None
40        }
41    }
42}
43
44/// Font weight for text rendering.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
46pub enum FontWeight {
47    #[default]
48    Normal,
49    Bold,
50}
51
52/// CSS font-family fallback stack for SVG rendering.
53pub use crate::font_fallback::SVG_FONT_FAMILY as DEFAULT_FONT_FAMILY;
54
55/// Diagram color theme. All rendering reads from this — no hardcoded values.
56#[derive(Debug, Clone)]
57pub struct Theme {
58    // -- Colors --
59    pub node_fill: Color,
60    pub node_stroke: Color,
61    pub node_text: Color,
62    pub edge_stroke: Color,
63    pub edge_label_text: Color,
64    pub edge_label_bg: Color,
65    pub start_fill: Color,
66    pub end_inner_fill: Color,
67    pub composite_fill: Color,
68    pub composite_stroke: Color,
69    pub composite_label: Color,
70    pub note_fill: Color,
71    pub note_stroke: Color,
72    pub note_text: Color,
73    pub subgraph_fill: Color,
74    pub subgraph_stroke: Color,
75    pub subgraph_label: Color,
76    pub divider_stroke: Color,
77    pub region_stroke: Color,
78    pub lifeline_stroke: Color,
79    pub activation_fill: Color,
80    pub activation_stroke: Color,
81    /// Grid lines, axis ticks, light structural lines.
82    pub grid_stroke: Color,
83    /// Secondary/muted text (bit numbers, sublabels).
84    pub muted_text: Color,
85    /// Face/icon fill for journey emojis.
86    pub face_fill: Color,
87    /// Detail strokes for face features, thin decorative elements.
88    pub detail_stroke: Color,
89    // -- Typography --
90    pub font_size_node: f64,
91    pub font_size_edge_label: f64,
92    pub font_size_label: f64,
93    pub font_size_small: f64,
94    pub font_size_tiny: f64,
95    pub font_size_title: f64,
96    // -- Stroke --
97    pub default_stroke_width: f64,
98    // -- Rendering --
99    /// Padding around the diagram (pixels on each side).
100    pub padding: f64,
101    /// Background color for raster/interactive backends.
102    pub background: Color,
103    /// Custom font bytes (TTF/OTF). When `None`, backends use embedded default.
104    pub custom_font: Option<Vec<u8>>,
105}
106
107impl Default for Theme {
108    fn default() -> Self {
109        Self::light()
110    }
111}
112
113impl Theme {
114    /// Mermaid.js-aligned light theme with lavender fills and purple borders.
115    pub fn light() -> Self {
116        Self {
117            node_fill: Color::rgba(236, 236, 255, 178), // lavender @ 70%
118            node_stroke: Color::rgb(147, 112, 219),     // #9370DB purple
119            node_text: Color::rgb(51, 51, 51),          // #333333
120            edge_stroke: Color::rgb(51, 51, 51),        // #333333
121            edge_label_text: Color::rgb(51, 51, 51),    // #333333
122            edge_label_bg: Color::rgba(245, 243, 255, 191), // frosted lavender @ 75%
123            start_fill: Color::rgb(51, 51, 51),         // #333333
124            end_inner_fill: Color::rgb(147, 112, 219),  // #9370DB purple
125            composite_fill: Color::rgba(255, 255, 255, 204), // white @ 80%
126            composite_stroke: Color::rgb(147, 112, 219), // #9370DB
127            composite_label: Color::rgb(51, 51, 51),
128            note_fill: Color::rgba(255, 248, 200, 178), // warm yellow @ 70%
129            note_stroke: Color::rgb(170, 170, 51),      // #aaaa33
130            note_text: Color::rgb(51, 51, 51),
131            subgraph_fill: Color::rgba(236, 242, 220, 153), // sage @ 60%
132            subgraph_stroke: Color::rgb(168, 174, 142),     // #a8ae8e muted olive
133            subgraph_label: Color::rgb(51, 51, 51),
134            divider_stroke: Color::rgb(128, 128, 128), // #808080
135            region_stroke: Color::rgb(128, 128, 128),  // #808080
136            lifeline_stroke: Color::rgb(175, 165, 200), // gray-lavender blend
137            activation_fill: Color::rgba(200, 190, 230, 180), // light lavender
138            activation_stroke: Color::rgb(153, 153, 153), // #999999
139            grid_stroke: Color::rgb(200, 200, 200),    // #c8c8c8 light gray
140            muted_text: Color::rgb(120, 120, 120),     // #787878
141            face_fill: Color::rgb(255, 248, 220),      // cream
142            detail_stroke: Color::rgb(80, 80, 80),     // #505050
143            font_size_node: 14.0,
144            font_size_edge_label: 12.0,
145            font_size_label: 13.0,
146            font_size_small: 11.0,
147            font_size_tiny: 9.0,
148            font_size_title: 16.0,
149            default_stroke_width: 1.5,
150            padding: 20.0,
151            background: Color::WHITE,
152            custom_font: None,
153        }
154    }
155
156    /// Dark theme for dark backgrounds.
157    pub fn dark() -> Self {
158        Self {
159            node_fill: Color::rgb(45, 45, 68),           // #2d2d44
160            node_stroke: Color::rgb(124, 111, 189),      // #7c6fbd
161            node_text: Color::rgb(205, 214, 244),        // #cdd6f4
162            edge_stroke: Color::rgb(166, 173, 200),      // #a6adc8
163            edge_label_text: Color::rgb(186, 194, 222),  // #bac2de
164            edge_label_bg: Color::rgba(30, 30, 46, 204), // dark semi-transparent
165            start_fill: Color::rgb(205, 214, 244),       // #cdd6f4
166            end_inner_fill: Color::rgb(124, 111, 189),   // #7c6fbd
167            composite_fill: Color::rgb(37, 37, 56),      // #252538
168            composite_stroke: Color::rgb(124, 111, 189),
169            composite_label: Color::rgb(186, 194, 222),
170            note_fill: Color::rgb(62, 60, 40), // dark yellow-brown
171            note_stroke: Color::rgb(170, 170, 51),
172            note_text: Color::rgb(205, 214, 244),
173            subgraph_fill: Color::rgb(40, 43, 35), // #282b23 dark sage
174            subgraph_stroke: Color::rgb(105, 112, 85), // #697055 muted dark olive
175            subgraph_label: Color::rgb(205, 214, 244),
176            divider_stroke: Color::rgb(88, 91, 112),
177            region_stroke: Color::rgb(88, 91, 112),
178            lifeline_stroke: Color::rgb(100, 95, 130), // muted purple-gray
179            activation_fill: Color::rgba(60, 55, 85, 180), // dark lavender
180            activation_stroke: Color::rgb(88, 91, 112), // #585b70
181            grid_stroke: Color::rgb(68, 71, 90),       // #44475a muted dark
182            muted_text: Color::rgb(147, 153, 178),     // #9399b2
183            face_fill: Color::rgb(62, 60, 40),         // dark warm
184            detail_stroke: Color::rgb(166, 173, 200),  // #a6adc8 light
185            font_size_node: 14.0,
186            font_size_edge_label: 12.0,
187            font_size_label: 13.0,
188            font_size_small: 11.0,
189            font_size_tiny: 9.0,
190            font_size_title: 16.0,
191            default_stroke_width: 1.5,
192            padding: 20.0,
193            background: Color::rgb(30, 30, 46), // #1e1e2e
194            custom_font: None,
195        }
196    }
197}
198
199/// Text styling properties.
200#[derive(Debug, Clone)]
201pub struct TextStyle {
202    pub font_size: f64,
203    pub font_family: String,
204    pub fill: Option<Color>,
205    pub font_weight: FontWeight,
206}
207
208impl Default for TextStyle {
209    fn default() -> Self {
210        Self {
211            font_size: 14.0,
212            font_family: String::from(DEFAULT_FONT_FAMILY),
213            fill: None,
214            font_weight: FontWeight::Normal,
215        }
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn style_default_is_empty() {
225        let s = Style::default();
226        assert!(s.fill.is_none());
227        assert!(s.stroke.is_none());
228        assert!(s.stroke_width.is_none());
229        assert!(s.stroke_dasharray.is_none());
230        assert!(s.opacity.is_none());
231        assert!(s.css_classes.is_empty());
232    }
233
234    #[test]
235    fn text_style_default() {
236        let ts = TextStyle::default();
237        assert!((ts.font_size - 14.0).abs() < f64::EPSILON);
238        assert_eq!(ts.font_family, DEFAULT_FONT_FAMILY);
239        assert!(ts.font_family.starts_with("'Intel One Mono'"));
240        assert!(ts.font_family.ends_with("monospace"));
241        assert!(ts.fill.is_none());
242        assert_eq!(ts.font_weight, FontWeight::Normal);
243    }
244
245    #[test]
246    fn style_with_dash_array() {
247        let s = Style {
248            stroke_dasharray: Some(vec![5.0, 3.0]),
249            ..Default::default()
250        };
251        assert_eq!(s.stroke_dasharray.as_ref().unwrap(), &[5.0, 3.0]);
252    }
253
254    #[test]
255    fn style_with_css_classes() {
256        let s = Style {
257            css_classes: vec!["node".into(), "highlighted".into()],
258            ..Default::default()
259        };
260        assert_eq!(s.css_classes.len(), 2);
261        assert_eq!(s.css_classes[0], "node");
262    }
263
264    #[test]
265    fn theme_default_is_light() {
266        let t = Theme::default();
267        assert_eq!(t.node_fill, Color::rgba(236, 236, 255, 178));
268        assert_eq!(t.node_stroke, Color::rgb(147, 112, 219));
269    }
270
271    #[test]
272    fn theme_dark_has_dark_fills() {
273        let t = Theme::dark();
274        assert!(t.node_fill.luminance() < 0.1);
275        assert!(t.node_text.luminance() > 0.5);
276    }
277
278    #[test]
279    fn theme_light_typography_and_stroke() {
280        let t = Theme::light();
281        assert!((t.font_size_node - 14.0).abs() < f64::EPSILON);
282        assert!((t.font_size_edge_label - 12.0).abs() < f64::EPSILON);
283        assert!((t.font_size_label - 13.0).abs() < f64::EPSILON);
284        assert!((t.font_size_small - 11.0).abs() < f64::EPSILON);
285        assert!((t.font_size_title - 16.0).abs() < f64::EPSILON);
286        assert!((t.default_stroke_width - 1.5).abs() < f64::EPSILON);
287    }
288
289    #[test]
290    fn theme_light_sequence_colors() {
291        let t = Theme::light();
292        assert_eq!(t.lifeline_stroke, Color::rgb(175, 165, 200));
293        assert_eq!(t.activation_fill, Color::rgba(200, 190, 230, 180));
294        assert_eq!(t.activation_stroke, Color::rgb(153, 153, 153));
295    }
296
297    #[test]
298    fn theme_dark_has_all_new_fields() {
299        let t = Theme::dark();
300        assert!((t.font_size_node - 14.0).abs() < f64::EPSILON);
301        assert!((t.default_stroke_width - 1.5).abs() < f64::EPSILON);
302        assert!(t.lifeline_stroke.luminance() < 0.3);
303        assert!(t.activation_fill.a < 255);
304    }
305
306    #[test]
307    fn text_style_custom() {
308        let ts = TextStyle {
309            font_size: 24.0,
310            font_family: String::from("monospace"),
311            fill: Some(Color::BLACK),
312            font_weight: FontWeight::Bold,
313        };
314        assert!((ts.font_size - 24.0).abs() < f64::EPSILON);
315        assert_eq!(ts.font_family, "monospace");
316        assert_eq!(ts.fill, Some(Color::BLACK));
317        assert_eq!(ts.font_weight, FontWeight::Bold);
318    }
319
320    // ── Style resolution tests (13.13) ──
321
322    #[test]
323    fn resolved_stroke_uses_explicit() {
324        let theme = Theme::light();
325        let s = Style {
326            stroke: Some(Color::rgb(255, 0, 0)),
327            ..Default::default()
328        };
329        assert_eq!(s.resolved_stroke(&theme), Color::rgb(255, 0, 0));
330    }
331
332    #[test]
333    fn resolved_stroke_falls_back_to_theme() {
334        let theme = Theme::light();
335        let s = Style::default();
336        assert_eq!(s.resolved_stroke(&theme), theme.edge_stroke);
337    }
338
339    #[test]
340    fn resolved_stroke_width_uses_explicit() {
341        let theme = Theme::light();
342        let s = Style {
343            stroke_width: Some(3.0),
344            ..Default::default()
345        };
346        assert!((s.resolved_stroke_width(&theme) - 3.0).abs() < f64::EPSILON);
347    }
348
349    #[test]
350    fn resolved_stroke_width_falls_back_to_theme() {
351        let theme = Theme::light();
352        let s = Style::default();
353        assert!(
354            (s.resolved_stroke_width(&theme) - theme.default_stroke_width).abs() < f64::EPSILON
355        );
356    }
357
358    #[test]
359    fn has_explicit_stroke_both_none() {
360        assert!(!Style::default().has_explicit_stroke());
361    }
362
363    #[test]
364    fn has_explicit_stroke_color_only() {
365        let s = Style {
366            stroke: Some(Color::BLACK),
367            ..Default::default()
368        };
369        assert!(s.has_explicit_stroke());
370    }
371
372    #[test]
373    fn resolve_stroke_opt_none_when_no_explicit() {
374        let theme = Theme::light();
375        assert!(Style::default().resolve_stroke_opt(&theme).is_none());
376    }
377
378    #[test]
379    fn resolve_stroke_opt_some_with_color_only() {
380        let theme = Theme::light();
381        let s = Style {
382            stroke: Some(Color::rgb(0, 128, 0)),
383            ..Default::default()
384        };
385        let (color, width) = s.resolve_stroke_opt(&theme).unwrap();
386        assert_eq!(color, Color::rgb(0, 128, 0));
387        assert!((width - theme.default_stroke_width).abs() < f64::EPSILON);
388    }
389
390    #[test]
391    fn resolve_stroke_opt_some_with_width_only() {
392        let theme = Theme::light();
393        let s = Style {
394            stroke_width: Some(5.0),
395            ..Default::default()
396        };
397        let (color, width) = s.resolve_stroke_opt(&theme).unwrap();
398        assert_eq!(color, theme.edge_stroke);
399        assert!((width - 5.0).abs() < f64::EPSILON);
400    }
401}