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    pub fn to_color(&self) -> Color {
131        match self {
132            ColorValue::Rgb { r, g, b } => Color::Rgb(*r, *g, *b),
133            ColorValue::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    pub fn light() -> Self {
157        Self {
158            name: "Light".to_string(),
159            colors: ThemeColors {
160                background: ColorValue::Rgb {
161                    r: 250,
162                    g: 250,
163                    b: 250,
164                },
165                foreground: ColorValue::Rgb {
166                    r: 30,
167                    g: 30,
168                    b: 30,
169                },
170
171                border: ColorValue::Named("gray".to_string()),
172                border_focused: ColorValue::Named("blue".to_string()),
173                header: ColorValue::Named("blue".to_string()),
174                status_bar: ColorValue::Named("white".to_string()),
175
176                text_primary: ColorValue::Named("black".to_string()),
177                text_secondary: ColorValue::Named("dark_gray".to_string()),
178                text_disabled: ColorValue::Named("gray".to_string()),
179                text_highlight: ColorValue::Named("magenta".to_string()),
180
181                user_message: ColorValue::Named("blue".to_string()),
182                assistant_message: ColorValue::Named("green".to_string()),
183                system_message: ColorValue::Named("yellow".to_string()),
184                user_message_background: ColorValue::Rgb {
185                    r: 230,
186                    g: 230,
187                    b: 230,
188                },
189
190                code_background: ColorValue::Rgb {
191                    r: 240,
192                    g: 240,
193                    b: 240,
194                },
195                code_foreground: ColorValue::Named("dark_gray".to_string()),
196                code_keyword: ColorValue::Named("magenta".to_string()),
197                code_string: ColorValue::Named("green".to_string()),
198                code_comment: ColorValue::Named("gray".to_string()),
199
200                mode_normal: ColorValue::Named("green".to_string()),
201                mode_accept_edits: ColorValue::Named("yellow".to_string()),
202                mode_plan: ColorValue::Named("blue".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    pub fn dark() -> Self {
237        Self {
238            name: "Dark".to_string(),
239            colors: ThemeColors {
240                background: ColorValue::Rgb {
241                    r: 20,
242                    g: 20,
243                    b: 20,
244                },
245                foreground: ColorValue::Rgb {
246                    r: 230,
247                    g: 230,
248                    b: 230,
249                },
250
251                border: ColorValue::Named("dark_gray".to_string()),
252                border_focused: ColorValue::Named("cyan".to_string()),
253                header: ColorValue::Named("cyan".to_string()),
254                status_bar: ColorValue::Named("black".to_string()),
255
256                text_primary: ColorValue::Named("white".to_string()),
257                text_secondary: ColorValue::Named("gray".to_string()),
258                text_disabled: ColorValue::Named("dark_gray".to_string()),
259                text_highlight: ColorValue::Named("yellow".to_string()),
260
261                user_message: ColorValue::Named("blue".to_string()),
262                assistant_message: ColorValue::Named("green".to_string()),
263                system_message: ColorValue::Named("yellow".to_string()),
264                user_message_background: ColorValue::Rgb {
265                    r: 54,
266                    g: 54,
267                    b: 54,
268                },
269
270                code_background: ColorValue::Rgb {
271                    r: 40,
272                    g: 40,
273                    b: 40,
274                },
275                code_foreground: ColorValue::Named("gray".to_string()),
276                code_keyword: ColorValue::Named("magenta".to_string()),
277                code_string: ColorValue::Named("green".to_string()),
278                code_comment: ColorValue::Named("dark_gray".to_string()),
279
280                mode_normal: ColorValue::Named("green".to_string()),
281                mode_accept_edits: ColorValue::Named("yellow".to_string()),
282                mode_plan: ColorValue::Named("blue".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    pub fn plain() -> Self {
304        fn d() -> ColorValue {
305            ColorValue::Named("default".to_string())
306        }
307        Self {
308            name: "Plain".to_string(),
309            colors: ThemeColors {
310                background: d(),
311                foreground: d(),
312                border: d(),
313                border_focused: d(),
314                header: d(),
315                status_bar: d(),
316                text_primary: d(),
317                text_secondary: d(),
318                text_disabled: d(),
319                text_highlight: d(),
320                user_message: d(),
321                assistant_message: d(),
322                system_message: d(),
323                user_message_background: d(),
324                code_background: d(),
325                code_foreground: d(),
326                code_keyword: d(),
327                code_string: d(),
328                code_comment: d(),
329                mode_normal: d(),
330                mode_accept_edits: d(),
331                mode_plan: 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}