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