Skip to main content

dendryform_core/
theme.rs

1//! Theme system — colors, fonts, spacing, and the built-in dark theme.
2
3use std::collections::HashMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::color::Color;
8
9/// A set of color values for a single named color in the palette.
10///
11/// Each color has a primary accent (hex), a dim background tint (CSS rgba),
12/// and a hover border color (hex, typically a lighter or shifted variant).
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub struct ColorSet {
16    /// Primary accent color (hex, e.g. `"#4fc3f7"`).
17    accent: String,
18    /// Dim background tint (CSS color string, e.g. `"rgba(79, 195, 247, 0.10)"`).
19    dim: String,
20    /// Hover border color (hex).
21    hover_border: String,
22}
23
24impl ColorSet {
25    /// Creates a new color set.
26    pub fn new(accent: &str, dim: &str, hover_border: &str) -> Self {
27        Self {
28            accent: accent.to_owned(),
29            dim: dim.to_owned(),
30            hover_border: hover_border.to_owned(),
31        }
32    }
33
34    /// Returns the primary accent color (hex).
35    pub fn accent(&self) -> &str {
36        &self.accent
37    }
38
39    /// Returns the dim background tint (CSS color string).
40    pub fn dim(&self) -> &str {
41        &self.dim
42    }
43
44    /// Returns the hover border color (hex).
45    pub fn hover_border(&self) -> &str {
46        &self.hover_border
47    }
48}
49
50/// The color palette — maps each [`Color`] to its [`ColorSet`].
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[serde(transparent)]
53pub struct ThemePalette(HashMap<Color, ColorSet>);
54
55impl ThemePalette {
56    /// Creates a new palette from a map.
57    pub fn new(entries: HashMap<Color, ColorSet>) -> Self {
58        Self(entries)
59    }
60
61    /// Returns the color set for a given color, if defined.
62    pub fn get(&self, color: Color) -> Option<&ColorSet> {
63        self.0.get(&color)
64    }
65
66    /// Inserts or replaces a color set.
67    pub fn insert(&mut self, color: Color, set: ColorSet) {
68        self.0.insert(color, set);
69    }
70
71    /// Returns an iterator over all palette entries.
72    pub fn iter(&self) -> impl Iterator<Item = (&Color, &ColorSet)> {
73        self.0.iter()
74    }
75
76    /// Merges another palette on top of this one (overrides win).
77    pub fn merge(&mut self, overrides: ThemePalette) {
78        for (color, set) in overrides.0 {
79            self.0.insert(color, set);
80        }
81    }
82}
83
84/// Background color configuration.
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub struct Backgrounds {
88    /// Primary page background (hex).
89    page: String,
90    /// Card background (hex).
91    card: String,
92    /// Card hover background (hex).
93    card_hover: String,
94}
95
96impl Backgrounds {
97    /// Creates a new background configuration.
98    pub fn new(page: &str, card: &str, card_hover: &str) -> Self {
99        Self {
100            page: page.to_owned(),
101            card: card.to_owned(),
102            card_hover: card_hover.to_owned(),
103        }
104    }
105
106    /// Returns the page background color.
107    pub fn page(&self) -> &str {
108        &self.page
109    }
110
111    /// Returns the card background color.
112    pub fn card(&self) -> &str {
113        &self.card
114    }
115
116    /// Returns the card hover background color.
117    pub fn card_hover(&self) -> &str {
118        &self.card_hover
119    }
120}
121
122/// Text color configuration.
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124#[serde(rename_all = "snake_case")]
125pub struct TextColors {
126    /// Primary text color (hex).
127    primary: String,
128    /// Dimmed text color (hex).
129    dim: String,
130    /// Bright/emphasized text color (hex).
131    bright: String,
132}
133
134impl TextColors {
135    /// Creates a new text color configuration.
136    pub fn new(primary: &str, dim: &str, bright: &str) -> Self {
137        Self {
138            primary: primary.to_owned(),
139            dim: dim.to_owned(),
140            bright: bright.to_owned(),
141        }
142    }
143
144    /// Returns the primary text color.
145    pub fn primary(&self) -> &str {
146        &self.primary
147    }
148
149    /// Returns the dimmed text color.
150    pub fn dim(&self) -> &str {
151        &self.dim
152    }
153
154    /// Returns the bright text color.
155    pub fn bright(&self) -> &str {
156        &self.bright
157    }
158}
159
160/// Border color configuration.
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub struct Borders {
164    /// Default border color (hex).
165    normal: String,
166    /// Highlighted/accent border color (hex).
167    highlight: String,
168}
169
170impl Borders {
171    /// Creates a new border configuration.
172    pub fn new(normal: &str, highlight: &str) -> Self {
173        Self {
174            normal: normal.to_owned(),
175            highlight: highlight.to_owned(),
176        }
177    }
178
179    /// Returns the normal border color.
180    pub fn normal(&self) -> &str {
181        &self.normal
182    }
183
184    /// Returns the highlight border color.
185    pub fn highlight(&self) -> &str {
186        &self.highlight
187    }
188}
189
190/// Font configuration.
191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192#[serde(rename_all = "snake_case")]
193pub struct Fonts {
194    /// Display / heading font family (e.g. `"JetBrains Mono"`).
195    display: String,
196    /// Body / text font family (e.g. `"DM Sans"`).
197    body: String,
198}
199
200impl Fonts {
201    /// Creates a new font configuration.
202    pub fn new(display: &str, body: &str) -> Self {
203        Self {
204            display: display.to_owned(),
205            body: body.to_owned(),
206        }
207    }
208
209    /// Returns the display font family.
210    pub fn display(&self) -> &str {
211        &self.display
212    }
213
214    /// Returns the body font family.
215    pub fn body(&self) -> &str {
216        &self.body
217    }
218}
219
220/// Spacing and shape configuration.
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
222#[serde(rename_all = "snake_case")]
223pub struct Spacing {
224    /// Standard border radius in pixels.
225    radius: u32,
226    /// Small border radius in pixels.
227    radius_sm: u32,
228    /// Box shadow CSS value.
229    shadow: String,
230    /// Canvas max-width in pixels.
231    canvas_width: u32,
232}
233
234impl Spacing {
235    /// Creates a new spacing configuration.
236    pub fn new(radius: u32, radius_sm: u32, shadow: &str, canvas_width: u32) -> Self {
237        Self {
238            radius,
239            radius_sm,
240            shadow: shadow.to_owned(),
241            canvas_width,
242        }
243    }
244
245    /// Returns the standard border radius.
246    pub fn radius(&self) -> u32 {
247        self.radius
248    }
249
250    /// Returns the small border radius.
251    pub fn radius_sm(&self) -> u32 {
252        self.radius_sm
253    }
254
255    /// Returns the box shadow CSS value.
256    pub fn shadow(&self) -> &str {
257        &self.shadow
258    }
259
260    /// Returns the canvas max-width in pixels.
261    pub fn canvas_width(&self) -> u32 {
262        self.canvas_width
263    }
264}
265
266/// A complete theme for rendering diagrams.
267///
268/// Contains all visual configuration: color palette, background/text/border
269/// colors, fonts, spacing, and animation settings.
270#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
271#[serde(rename_all = "snake_case")]
272pub struct Theme {
273    /// Theme name (e.g. `"dark"`).
274    name: String,
275    /// Color palette mapping named colors to CSS values.
276    palette: ThemePalette,
277    /// Background colors.
278    backgrounds: Backgrounds,
279    /// Text colors.
280    text: TextColors,
281    /// Border colors.
282    borders: Borders,
283    /// Font families.
284    fonts: Fonts,
285    /// Spacing and shape values.
286    spacing: Spacing,
287    /// Whether to enable CSS animations.
288    animate: bool,
289}
290
291impl Theme {
292    /// Returns the theme name.
293    pub fn name(&self) -> &str {
294        &self.name
295    }
296
297    /// Returns the color palette.
298    pub fn palette(&self) -> &ThemePalette {
299        &self.palette
300    }
301
302    /// Returns the background colors.
303    pub fn backgrounds(&self) -> &Backgrounds {
304        &self.backgrounds
305    }
306
307    /// Returns the text colors.
308    pub fn text(&self) -> &TextColors {
309        &self.text
310    }
311
312    /// Returns the border colors.
313    pub fn borders(&self) -> &Borders {
314        &self.borders
315    }
316
317    /// Returns the font families.
318    pub fn fonts(&self) -> &Fonts {
319        &self.fonts
320    }
321
322    /// Returns the spacing configuration.
323    pub fn spacing(&self) -> &Spacing {
324        &self.spacing
325    }
326
327    /// Returns whether animations are enabled.
328    pub fn animate(&self) -> bool {
329        self.animate
330    }
331
332    /// Returns the built-in dark theme with exact Taproot color values.
333    pub fn dark() -> Self {
334        let mut palette = HashMap::new();
335        palette.insert(
336            Color::Blue,
337            ColorSet::new("#4fc3f7", "rgba(79, 195, 247, 0.10)", "#4fc3f7"),
338        );
339        palette.insert(
340            Color::Green,
341            ColorSet::new("#3ddc84", "rgba(61, 220, 132, 0.12)", "#3ddc84"),
342        );
343        palette.insert(
344            Color::Amber,
345            ColorSet::new("#ffb74d", "rgba(255, 183, 77, 0.10)", "#ffb74d"),
346        );
347        palette.insert(
348            Color::Purple,
349            ColorSet::new("#b39ddb", "rgba(179, 157, 219, 0.10)", "#b39ddb"),
350        );
351        palette.insert(
352            Color::Red,
353            ColorSet::new("#ef5350", "rgba(239, 83, 80, 0.10)", "#ef5350"),
354        );
355        palette.insert(
356            Color::Teal,
357            ColorSet::new("#4dd0e1", "rgba(77, 208, 225, 0.10)", "#4dd0e1"),
358        );
359
360        Self {
361            name: "dark".to_owned(),
362            palette: ThemePalette::new(palette),
363            backgrounds: Backgrounds::new("#0a0e14", "#111820", "#161e29"),
364            text: TextColors::new("#c4cdd9", "#5a6a7a", "#e8edf3"),
365            borders: Borders::new("#1e2a3a", "#2a3f5a"),
366            fonts: Fonts::new("JetBrains Mono", "DM Sans"),
367            spacing: Spacing::new(10, 6, "0 2px 20px rgba(0,0,0,0.3)", 1100),
368            animate: true,
369        }
370    }
371
372    /// Merges another theme on top of this one.
373    ///
374    /// Override values replace base values. Palette entries are merged
375    /// individually (only overridden colors are replaced).
376    pub fn merge(mut self, overrides: ThemeOverrides) -> Self {
377        if let Some(palette) = overrides.palette {
378            self.palette.merge(palette);
379        }
380        if let Some(backgrounds) = overrides.backgrounds {
381            self.backgrounds = backgrounds;
382        }
383        if let Some(text) = overrides.text {
384            self.text = text;
385        }
386        if let Some(borders) = overrides.borders {
387            self.borders = borders;
388        }
389        if let Some(fonts) = overrides.fonts {
390            self.fonts = fonts;
391        }
392        if let Some(spacing) = overrides.spacing {
393            self.spacing = spacing;
394        }
395        if let Some(animate) = overrides.animate {
396            self.animate = animate;
397        }
398        self
399    }
400}
401
402/// Partial theme overrides for merging on top of a base theme.
403///
404/// All fields are optional — only specified fields override the base.
405#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
406#[serde(rename_all = "snake_case")]
407pub struct ThemeOverrides {
408    /// Override palette entries.
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub palette: Option<ThemePalette>,
411    /// Override background colors.
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub backgrounds: Option<Backgrounds>,
414    /// Override text colors.
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub text: Option<TextColors>,
417    /// Override border colors.
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub borders: Option<Borders>,
420    /// Override font families.
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub fonts: Option<Fonts>,
423    /// Override spacing configuration.
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub spacing: Option<Spacing>,
426    /// Override animation setting.
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub animate: Option<bool>,
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn test_dark_theme_name() {
437        let theme = Theme::dark();
438        assert_eq!(theme.name(), "dark");
439    }
440
441    #[test]
442    fn test_dark_theme_palette_has_all_colors() {
443        let theme = Theme::dark();
444        assert!(theme.palette().get(Color::Blue).is_some());
445        assert!(theme.palette().get(Color::Green).is_some());
446        assert!(theme.palette().get(Color::Amber).is_some());
447        assert!(theme.palette().get(Color::Purple).is_some());
448        assert!(theme.palette().get(Color::Red).is_some());
449        assert!(theme.palette().get(Color::Teal).is_some());
450    }
451
452    #[test]
453    fn test_dark_theme_exact_blue_values() {
454        let theme = Theme::dark();
455        let blue = theme.palette().get(Color::Blue).unwrap();
456        assert_eq!(blue.accent(), "#4fc3f7");
457        assert_eq!(blue.dim(), "rgba(79, 195, 247, 0.10)");
458    }
459
460    #[test]
461    fn test_dark_theme_exact_green_values() {
462        let theme = Theme::dark();
463        let green = theme.palette().get(Color::Green).unwrap();
464        assert_eq!(green.accent(), "#3ddc84");
465        assert_eq!(green.dim(), "rgba(61, 220, 132, 0.12)");
466    }
467
468    #[test]
469    fn test_dark_theme_backgrounds() {
470        let theme = Theme::dark();
471        assert_eq!(theme.backgrounds().page(), "#0a0e14");
472        assert_eq!(theme.backgrounds().card(), "#111820");
473        assert_eq!(theme.backgrounds().card_hover(), "#161e29");
474    }
475
476    #[test]
477    fn test_dark_theme_text_colors() {
478        let theme = Theme::dark();
479        assert_eq!(theme.text().primary(), "#c4cdd9");
480        assert_eq!(theme.text().dim(), "#5a6a7a");
481        assert_eq!(theme.text().bright(), "#e8edf3");
482    }
483
484    #[test]
485    fn test_dark_theme_borders() {
486        let theme = Theme::dark();
487        assert_eq!(theme.borders().normal(), "#1e2a3a");
488        assert_eq!(theme.borders().highlight(), "#2a3f5a");
489    }
490
491    #[test]
492    fn test_dark_theme_fonts() {
493        let theme = Theme::dark();
494        assert_eq!(theme.fonts().display(), "JetBrains Mono");
495        assert_eq!(theme.fonts().body(), "DM Sans");
496    }
497
498    #[test]
499    fn test_dark_theme_spacing() {
500        let theme = Theme::dark();
501        assert_eq!(theme.spacing().radius(), 10);
502        assert_eq!(theme.spacing().radius_sm(), 6);
503        assert_eq!(theme.spacing().canvas_width(), 1100);
504    }
505
506    #[test]
507    fn test_dark_theme_animate() {
508        let theme = Theme::dark();
509        assert!(theme.animate());
510    }
511
512    #[test]
513    fn test_merge_overrides_palette() {
514        let base = Theme::dark();
515        let mut override_palette = HashMap::new();
516        override_palette.insert(
517            Color::Blue,
518            ColorSet::new("#0000ff", "rgba(0,0,255,0.1)", "#0000ff"),
519        );
520        let overrides = ThemeOverrides {
521            palette: Some(ThemePalette::new(override_palette)),
522            ..Default::default()
523        };
524        let merged = base.merge(overrides);
525        assert_eq!(
526            merged.palette().get(Color::Blue).unwrap().accent(),
527            "#0000ff"
528        );
529        // Green should be unchanged.
530        assert_eq!(
531            merged.palette().get(Color::Green).unwrap().accent(),
532            "#3ddc84"
533        );
534    }
535
536    #[test]
537    fn test_merge_overrides_animate() {
538        let base = Theme::dark();
539        let overrides = ThemeOverrides {
540            animate: Some(false),
541            ..Default::default()
542        };
543        let merged = base.merge(overrides);
544        assert!(!merged.animate());
545        // Everything else unchanged.
546        assert_eq!(merged.name(), "dark");
547        assert_eq!(merged.backgrounds().page(), "#0a0e14");
548    }
549
550    #[test]
551    fn test_merge_overrides_backgrounds() {
552        let base = Theme::dark();
553        let overrides = ThemeOverrides {
554            backgrounds: Some(Backgrounds::new("#000000", "#111111", "#222222")),
555            ..Default::default()
556        };
557        let merged = base.merge(overrides);
558        assert_eq!(merged.backgrounds().page(), "#000000");
559        assert_eq!(merged.backgrounds().card(), "#111111");
560    }
561
562    #[test]
563    fn test_serde_round_trip_theme() {
564        let theme = Theme::dark();
565        let json = serde_json::to_string_pretty(&theme).unwrap();
566        let deserialized: Theme = serde_json::from_str(&json).unwrap();
567        assert_eq!(theme, deserialized);
568    }
569
570    #[test]
571    fn test_serde_round_trip_overrides() {
572        let overrides = ThemeOverrides {
573            animate: Some(false),
574            backgrounds: Some(Backgrounds::new("#000", "#111", "#222")),
575            ..Default::default()
576        };
577        let json = serde_json::to_string_pretty(&overrides).unwrap();
578        let deserialized: ThemeOverrides = serde_json::from_str(&json).unwrap();
579        assert_eq!(overrides, deserialized);
580    }
581
582    #[test]
583    fn test_serde_overrides_empty_fields_omitted() {
584        let overrides = ThemeOverrides {
585            animate: Some(true),
586            ..Default::default()
587        };
588        let json = serde_json::to_string(&overrides).unwrap();
589        assert!(!json.contains("palette"));
590        assert!(!json.contains("backgrounds"));
591        assert!(json.contains("animate"));
592    }
593
594    #[test]
595    fn test_yaml_round_trip_theme() {
596        let theme = Theme::dark();
597        let yaml = serde_yml::to_string(&theme).unwrap();
598        let deserialized: Theme = serde_yml::from_str(&yaml).unwrap();
599        assert_eq!(theme, deserialized);
600    }
601
602    #[test]
603    fn test_colorset_accessors() {
604        let cs = ColorSet::new("#abc", "rgba(0,0,0,0.5)", "#def");
605        assert_eq!(cs.accent(), "#abc");
606        assert_eq!(cs.dim(), "rgba(0,0,0,0.5)");
607        assert_eq!(cs.hover_border(), "#def");
608    }
609
610    #[test]
611    fn test_palette_insert_and_get() {
612        let mut palette = ThemePalette::new(HashMap::new());
613        assert!(palette.get(Color::Red).is_none());
614        palette.insert(
615            Color::Red,
616            ColorSet::new("#f00", "rgba(255,0,0,0.1)", "#f00"),
617        );
618        assert_eq!(palette.get(Color::Red).unwrap().accent(), "#f00");
619    }
620
621    #[test]
622    fn test_palette_merge() {
623        let mut base = ThemePalette::new(HashMap::new());
624        base.insert(Color::Red, ColorSet::new("#f00", "r", "#f00"));
625        base.insert(Color::Blue, ColorSet::new("#00f", "b", "#00f"));
626
627        let mut overrides = ThemePalette::new(HashMap::new());
628        overrides.insert(Color::Red, ColorSet::new("#ff0000", "rr", "#ff0000"));
629
630        base.merge(overrides);
631        assert_eq!(base.get(Color::Red).unwrap().accent(), "#ff0000");
632        assert_eq!(base.get(Color::Blue).unwrap().accent(), "#00f");
633    }
634
635    #[test]
636    fn test_dark_yml_matches_dark_constructor() {
637        let yaml = include_str!("../../../themes/dark.yml");
638        let from_file: Theme = serde_yml::from_str(yaml).unwrap();
639        let from_code = Theme::dark();
640        assert_eq!(from_file, from_code);
641    }
642
643    #[test]
644    fn test_merge_overrides_text() {
645        let base = Theme::dark();
646        let overrides = ThemeOverrides {
647            text: Some(TextColors::new("#aaa", "#bbb", "#ccc")),
648            ..Default::default()
649        };
650        let merged = base.merge(overrides);
651        assert_eq!(merged.text().primary(), "#aaa");
652        assert_eq!(merged.text().dim(), "#bbb");
653        assert_eq!(merged.text().bright(), "#ccc");
654    }
655
656    #[test]
657    fn test_merge_overrides_borders() {
658        let base = Theme::dark();
659        let overrides = ThemeOverrides {
660            borders: Some(Borders::new("#111", "#222")),
661            ..Default::default()
662        };
663        let merged = base.merge(overrides);
664        assert_eq!(merged.borders().normal(), "#111");
665        assert_eq!(merged.borders().highlight(), "#222");
666    }
667
668    #[test]
669    fn test_merge_overrides_fonts() {
670        let base = Theme::dark();
671        let overrides = ThemeOverrides {
672            fonts: Some(Fonts::new("Fira Code", "Inter")),
673            ..Default::default()
674        };
675        let merged = base.merge(overrides);
676        assert_eq!(merged.fonts().display(), "Fira Code");
677        assert_eq!(merged.fonts().body(), "Inter");
678    }
679
680    #[test]
681    fn test_merge_overrides_spacing() {
682        let base = Theme::dark();
683        let overrides = ThemeOverrides {
684            spacing: Some(Spacing::new(12, 8, "none", 900)),
685            ..Default::default()
686        };
687        let merged = base.merge(overrides);
688        assert_eq!(merged.spacing().radius(), 12);
689        assert_eq!(merged.spacing().radius_sm(), 8);
690        assert_eq!(merged.spacing().shadow(), "none");
691        assert_eq!(merged.spacing().canvas_width(), 900);
692    }
693
694    #[test]
695    fn test_palette_iter() {
696        let theme = Theme::dark();
697        let count = theme.palette().iter().count();
698        assert_eq!(count, 6); // Blue, Green, Amber, Purple, Red, Teal
699    }
700
701    #[test]
702    fn test_dark_theme_exact_amber_values() {
703        let theme = Theme::dark();
704        let amber = theme.palette().get(Color::Amber).unwrap();
705        assert_eq!(amber.accent(), "#ffb74d");
706        assert_eq!(amber.dim(), "rgba(255, 183, 77, 0.10)");
707        assert_eq!(amber.hover_border(), "#ffb74d");
708    }
709
710    #[test]
711    fn test_dark_theme_exact_purple_values() {
712        let theme = Theme::dark();
713        let purple = theme.palette().get(Color::Purple).unwrap();
714        assert_eq!(purple.accent(), "#b39ddb");
715    }
716
717    #[test]
718    fn test_dark_theme_exact_red_values() {
719        let theme = Theme::dark();
720        let red = theme.palette().get(Color::Red).unwrap();
721        assert_eq!(red.accent(), "#ef5350");
722    }
723
724    #[test]
725    fn test_dark_theme_exact_teal_values() {
726        let theme = Theme::dark();
727        let teal = theme.palette().get(Color::Teal).unwrap();
728        assert_eq!(teal.accent(), "#4dd0e1");
729    }
730}