Skip to main content

mermaid_cli/render/
theme.rs

1use ratatui::style::Color;
2use serde::{Deserialize, Serialize};
3
4/// Theme configuration for the TUI
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct Theme {
7    pub name: String,
8    pub colors: ThemeColors,
9}
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ThemeColors {
13    // Primary colors
14    pub background: ColorValue,
15    pub foreground: ColorValue,
16
17    // UI elements
18    pub border: ColorValue,
19    pub border_focused: ColorValue,
20    pub header: ColorValue,
21    pub status_bar: ColorValue,
22
23    // Text colors
24    pub text_primary: ColorValue,
25    pub text_secondary: ColorValue,
26    pub text_disabled: ColorValue,
27    pub text_highlight: ColorValue,
28
29    // Message colors
30    pub user_message: ColorValue,
31    pub assistant_message: ColorValue,
32    pub system_message: ColorValue,
33    /// Full-width background band behind the user's submitted prompt
34    /// (Claude-Code style). A clearly-visible neutral gray, a step above the
35    /// main background — not a blue tint.
36    pub user_message_background: ColorValue,
37
38    // Code highlighting
39    pub code_background: ColorValue,
40    pub code_foreground: ColorValue,
41    pub code_keyword: ColorValue,
42    pub code_string: ColorValue,
43    pub code_comment: ColorValue,
44
45    // Mode colors
46    pub mode_normal: ColorValue,
47    pub mode_accept_edits: ColorValue,
48    pub mode_bypass_all: ColorValue,
49
50    // Status colors
51    pub success: ColorValue,
52    pub warning: ColorValue,
53    pub error: ColorValue,
54    pub info: ColorValue,
55
56    /// Brand accent (aqua #22D3EE) — the `ask_user_question` modal's header chip
57    /// and border. Serde-defaulted so themes/configs predating it still load.
58    #[serde(default = "default_brand")]
59    pub brand: ColorValue,
60
61    /// Muted meta text (tool headers, byline timestamps) — deliberately a
62    /// specific mid-gray rather than the terminal's ANSI gray, which most
63    /// palettes render much brighter. Serde-defaulted like `brand`.
64    #[serde(default = "default_text_meta")]
65    pub text_meta: ColorValue,
66    /// Background band behind added diff lines.
67    #[serde(default = "default_diff_added_bg")]
68    pub diff_added_bg: ColorValue,
69    /// Background band behind removed diff lines.
70    #[serde(default = "default_diff_removed_bg")]
71    pub diff_removed_bg: ColorValue,
72    /// Highlight band behind queued (mid-run steering) messages in the
73    /// status area.
74    #[serde(default = "default_queued_bg")]
75    pub queued_bg: ColorValue,
76}
77
78/// Mermaid's aqua brand accent, used when a theme omits `brand`.
79fn default_brand() -> ColorValue {
80    ColorValue::Rgb {
81        r: 34,
82        g: 211,
83        b: 238,
84    }
85}
86
87/// Dark-theme values double as serde defaults so themes/configs predating
88/// these fields keep today's exact colors.
89fn default_text_meta() -> ColorValue {
90    ColorValue::Rgb {
91        r: 136,
92        g: 136,
93        b: 136,
94    }
95}
96
97fn default_diff_added_bg() -> ColorValue {
98    ColorValue::Rgb {
99        r: 20,
100        g: 50,
101        b: 20,
102    }
103}
104
105fn default_diff_removed_bg() -> ColorValue {
106    ColorValue::Rgb {
107        r: 60,
108        g: 20,
109        b: 20,
110    }
111}
112
113fn default_queued_bg() -> ColorValue {
114    ColorValue::Rgb {
115        r: 60,
116        g: 60,
117        b: 80,
118    }
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122#[serde(untagged)]
123pub enum ColorValue {
124    Rgb { r: u8, g: u8, b: u8 },
125    Named(String),
126}
127
128impl ColorValue {
129    #[must_use]
130    pub fn to_color(&self) -> Color {
131        match self {
132            Self::Rgb { r, g, b } => Color::Rgb(*r, *g, *b),
133            Self::Named(name) => match name.as_str() {
134                // The terminal's own default fg/bg — what `Theme::plain()`
135                // (NO_COLOR) is built from.
136                "default" => Color::Reset,
137                "black" => Color::Black,
138                "red" => Color::Red,
139                "green" => Color::Green,
140                "yellow" => Color::Yellow,
141                "blue" => Color::Blue,
142                "magenta" => Color::Magenta,
143                "cyan" => Color::Cyan,
144                "white" => Color::White,
145                "gray" | "grey" => Color::Gray,
146                "dark_gray" | "dark_grey" => Color::DarkGray,
147                _ => Color::White,
148            },
149        }
150    }
151}
152
153impl Theme {
154    /// Create a light theme. Selected by `ui.theme = "light"` in config.toml
155    /// or `/theme light` (see `render()`'s theme memo).
156    #[must_use]
157    pub fn light() -> Self {
158        Self {
159            name: "Light".to_string(),
160            colors: ThemeColors {
161                background: ColorValue::Rgb {
162                    r: 250,
163                    g: 250,
164                    b: 250,
165                },
166                foreground: ColorValue::Rgb {
167                    r: 30,
168                    g: 30,
169                    b: 30,
170                },
171
172                border: ColorValue::Named("gray".to_string()),
173                border_focused: ColorValue::Named("blue".to_string()),
174                header: ColorValue::Named("blue".to_string()),
175                status_bar: ColorValue::Named("white".to_string()),
176
177                text_primary: ColorValue::Named("black".to_string()),
178                text_secondary: ColorValue::Named("dark_gray".to_string()),
179                text_disabled: ColorValue::Named("gray".to_string()),
180                text_highlight: ColorValue::Named("magenta".to_string()),
181
182                user_message: ColorValue::Named("blue".to_string()),
183                assistant_message: ColorValue::Named("green".to_string()),
184                system_message: ColorValue::Named("yellow".to_string()),
185                user_message_background: ColorValue::Rgb {
186                    r: 230,
187                    g: 230,
188                    b: 230,
189                },
190
191                code_background: ColorValue::Rgb {
192                    r: 240,
193                    g: 240,
194                    b: 240,
195                },
196                code_foreground: ColorValue::Named("dark_gray".to_string()),
197                code_keyword: ColorValue::Named("magenta".to_string()),
198                code_string: ColorValue::Named("green".to_string()),
199                code_comment: ColorValue::Named("gray".to_string()),
200
201                mode_normal: ColorValue::Named("green".to_string()),
202                mode_accept_edits: ColorValue::Named("yellow".to_string()),
203                mode_bypass_all: ColorValue::Named("red".to_string()),
204
205                success: ColorValue::Named("green".to_string()),
206                warning: ColorValue::Named("yellow".to_string()),
207                error: ColorValue::Named("red".to_string()),
208                info: ColorValue::Named("blue".to_string()),
209
210                brand: default_brand(),
211                text_meta: ColorValue::Rgb {
212                    r: 110,
213                    g: 110,
214                    b: 110,
215                },
216                diff_added_bg: ColorValue::Rgb {
217                    r: 220,
218                    g: 245,
219                    b: 220,
220                },
221                diff_removed_bg: ColorValue::Rgb {
222                    r: 250,
223                    g: 225,
224                    b: 225,
225                },
226                queued_bg: ColorValue::Rgb {
227                    r: 225,
228                    g: 225,
229                    b: 240,
230                },
231            },
232        }
233    }
234
235    /// Create the default dark theme
236    #[must_use]
237    pub fn dark() -> Self {
238        Self {
239            name: "Dark".to_string(),
240            colors: ThemeColors {
241                background: ColorValue::Rgb {
242                    r: 20,
243                    g: 20,
244                    b: 20,
245                },
246                foreground: ColorValue::Rgb {
247                    r: 230,
248                    g: 230,
249                    b: 230,
250                },
251
252                border: ColorValue::Named("dark_gray".to_string()),
253                border_focused: ColorValue::Named("cyan".to_string()),
254                header: ColorValue::Named("cyan".to_string()),
255                status_bar: ColorValue::Named("black".to_string()),
256
257                text_primary: ColorValue::Named("white".to_string()),
258                text_secondary: ColorValue::Named("gray".to_string()),
259                text_disabled: ColorValue::Named("dark_gray".to_string()),
260                text_highlight: ColorValue::Named("yellow".to_string()),
261
262                user_message: ColorValue::Named("blue".to_string()),
263                assistant_message: ColorValue::Named("green".to_string()),
264                system_message: ColorValue::Named("yellow".to_string()),
265                user_message_background: ColorValue::Rgb {
266                    r: 54,
267                    g: 54,
268                    b: 54,
269                },
270
271                code_background: ColorValue::Rgb {
272                    r: 40,
273                    g: 40,
274                    b: 40,
275                },
276                code_foreground: ColorValue::Named("gray".to_string()),
277                code_keyword: ColorValue::Named("magenta".to_string()),
278                code_string: ColorValue::Named("green".to_string()),
279                code_comment: ColorValue::Named("dark_gray".to_string()),
280
281                mode_normal: ColorValue::Named("green".to_string()),
282                mode_accept_edits: ColorValue::Named("yellow".to_string()),
283                mode_bypass_all: ColorValue::Named("red".to_string()),
284
285                success: ColorValue::Named("green".to_string()),
286                warning: ColorValue::Named("yellow".to_string()),
287                error: ColorValue::Named("red".to_string()),
288                info: ColorValue::Named("cyan".to_string()),
289
290                brand: default_brand(),
291                text_meta: default_text_meta(),
292                diff_added_bg: default_diff_added_bg(),
293                diff_removed_bg: default_diff_removed_bg(),
294                queued_bg: default_queued_bg(),
295            },
296        }
297    }
298
299    /// Colorless theme for `NO_COLOR`: every slot is the terminal's own
300    /// default fg/bg (`Color::Reset`), so nothing emits a color at all.
301    /// Structure (glyphs, layout, bold/dim) is untouched — diffs still read
302    /// via their `+`/`-` prefixes.
303    #[must_use]
304    pub fn plain() -> Self {
305        fn d() -> ColorValue {
306            ColorValue::Named("default".to_string())
307        }
308        Self {
309            name: "Plain".to_string(),
310            colors: ThemeColors {
311                background: d(),
312                foreground: d(),
313                border: d(),
314                border_focused: d(),
315                header: d(),
316                status_bar: d(),
317                text_primary: d(),
318                text_secondary: d(),
319                text_disabled: d(),
320                text_highlight: d(),
321                user_message: d(),
322                assistant_message: d(),
323                system_message: d(),
324                user_message_background: d(),
325                code_background: d(),
326                code_foreground: d(),
327                code_keyword: d(),
328                code_string: d(),
329                code_comment: d(),
330                mode_normal: d(),
331                mode_accept_edits: d(),
332                mode_bypass_all: d(),
333                success: d(),
334                warning: d(),
335                error: d(),
336                info: d(),
337                brand: d(),
338                text_meta: d(),
339                diff_added_bg: d(),
340                diff_removed_bg: d(),
341                queued_bg: d(),
342            },
343        }
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn named_default_maps_to_reset() {
353        assert_eq!(
354            ColorValue::Named("default".to_string()).to_color(),
355            Color::Reset
356        );
357    }
358
359    #[test]
360    fn plain_theme_is_entirely_reset() {
361        // Every slot must resolve to the terminal's own default — a single
362        // colored slot would defeat NO_COLOR. Serializing the palette and
363        // scanning for any non-"default" value covers all fields without
364        // enumerating them (new fields are covered automatically).
365        let theme = Theme::plain();
366        let json = serde_json::to_value(&theme.colors).unwrap();
367        let obj = json.as_object().unwrap();
368        assert!(!obj.is_empty());
369        for (field, value) in obj {
370            assert_eq!(
371                value.as_str(),
372                Some("default"),
373                "plain theme leaks color through `{field}`: {value}"
374            );
375        }
376    }
377
378    #[test]
379    fn dark_and_light_populate_the_new_slots() {
380        for theme in [Theme::dark(), Theme::light()] {
381            assert_ne!(theme.colors.diff_added_bg.to_color(), Color::Reset);
382            assert_ne!(theme.colors.diff_removed_bg.to_color(), Color::Reset);
383            assert_ne!(theme.colors.queued_bg.to_color(), Color::Reset);
384            assert_ne!(theme.colors.text_meta.to_color(), Color::Reset);
385        }
386    }
387
388    #[test]
389    fn theme_colors_deserialize_defaults_new_fields() {
390        // A theme serialized before the new slots existed still loads, with
391        // the dark values as defaults.
392        let dark = Theme::dark();
393        let mut json = serde_json::to_value(&dark.colors).unwrap();
394        let obj = json.as_object_mut().unwrap();
395        for field in ["text_meta", "diff_added_bg", "diff_removed_bg", "queued_bg"] {
396            obj.remove(field);
397        }
398        let colors: ThemeColors = serde_json::from_value(json).unwrap();
399        assert_eq!(
400            colors.text_meta.to_color(),
401            dark.colors.text_meta.to_color()
402        );
403        assert_eq!(
404            colors.diff_added_bg.to_color(),
405            dark.colors.diff_added_bg.to_color()
406        );
407    }
408}