Skip to main content

holodeck_simctl_tui/
theme.rs

1//! Named semantic colors for the TUI, resolved once at startup from
2//! `Config::theme` (Rust analogue of vigia's `theme.rs`: a struct of named
3//! `Style`s with built-in palettes, rather than `Color` literals scattered
4//! through the view layer).
5//!
6//! The shipped default is [`Theme::default_plus`] — the
7//! [Default+](https://github.com/otaviocc/default-plus) colorscheme, ported
8//! here from its canonical `palette.yaml` (base colors + the 6 "hero"
9//! accents reused across every other Default+ port). [`Theme::ansi`] is kept
10//! as an alternative that inherits the reader's own terminal scheme instead
11//! of asserting truecolor. A handful of other well-known terminal/TUI
12//! themes are also built in: [`Theme::tokyo_night`], [`Theme::nord`],
13//! [`Theme::dracula`], [`Theme::gruvbox`], [`Theme::catppuccin_mocha`], and
14//! [`Theme::solarized_dark`] — all ported from each project's own canonical
15//! palette values.
16
17use holodeck_core::models::ThemeName;
18use ratatui::style::{Color, Modifier, Style};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct Theme {
22    pub background: Color,
23    pub foreground: Color,
24    /// Structural chrome: borders, unfocused/inactive elements.
25    pub muted: Color,
26    /// Secondary text: hints, footers, "press any key to close".
27    pub muted_text: Color,
28    pub selection_background: Color,
29    pub selection_foreground: Color,
30    pub red: Color,
31    pub green: Color,
32    pub yellow: Color,
33    pub blue: Color,
34    pub magenta: Color,
35    pub cyan: Color,
36}
37
38impl Theme {
39    pub fn from_name(name: ThemeName) -> Self {
40        match name {
41            ThemeName::DefaultPlus => Self::default_plus(),
42            ThemeName::Ansi => Self::ansi(),
43            ThemeName::TokyoNight => Self::tokyo_night(),
44            ThemeName::Nord => Self::nord(),
45            ThemeName::Dracula => Self::dracula(),
46            ThemeName::Gruvbox => Self::gruvbox(),
47            ThemeName::CatppuccinMocha => Self::catppuccin_mocha(),
48            ThemeName::SolarizedDark => Self::solarized_dark(),
49        }
50    }
51
52    /// The [Default+](https://github.com/otaviocc/default-plus) colorscheme,
53    /// values taken verbatim from that repo's `palette.yaml` (`base` +
54    /// `accent`).
55    pub fn default_plus() -> Self {
56        Self {
57            background: rgb(0x1E, 0x1E, 0x1E),
58            foreground: rgb(0xFF, 0xFF, 0xFF),
59            muted: rgb(0x4D, 0x4D, 0x4D),
60            muted_text: rgb(0x8E, 0x8E, 0x8E),
61            selection_background: rgb(0x54, 0x55, 0x4A),
62            selection_foreground: rgb(0xFF, 0xFF, 0xFF),
63            red: rgb(0xFC, 0x46, 0x51),
64            green: rgb(0x2E, 0xA8, 0x5B),
65            yellow: rgb(0xFF, 0xE7, 0x6D),
66            blue: rgb(0x35, 0xB0, 0xD8),
67            magenta: rgb(0xF2, 0x24, 0x8C),
68            cyan: rgb(0x56, 0xD0, 0xB3),
69        }
70    }
71
72    /// The terminal's own 16-color scheme. Correct on a background whose
73    /// depth/appearance nothing here has detected, at the cost of not
74    /// matching Default+ exactly on every terminal — see vigia's `theme.rs`
75    /// for the same reasoning about why an ANSI-named palette is the safe
76    /// fallback rather than the default.
77    pub fn ansi() -> Self {
78        Self {
79            background: Color::Reset,
80            foreground: Color::Reset,
81            muted: Color::DarkGray,
82            muted_text: Color::DarkGray,
83            selection_background: Color::DarkGray,
84            selection_foreground: Color::White,
85            red: Color::Red,
86            green: Color::Green,
87            yellow: Color::Yellow,
88            blue: Color::Blue,
89            magenta: Color::Magenta,
90            cyan: Color::Cyan,
91        }
92    }
93
94    /// [Tokyo Night](https://github.com/folke/tokyonight.nvim) (the "Night"
95    /// variant), values taken from that project's canonical Lua palette.
96    pub fn tokyo_night() -> Self {
97        Self {
98            background: rgb(0x1A, 0x1B, 0x26),
99            foreground: rgb(0xC0, 0xCA, 0xF5),
100            muted: rgb(0x56, 0x5F, 0x89),
101            muted_text: rgb(0x56, 0x5F, 0x89),
102            selection_background: rgb(0x29, 0x2E, 0x42),
103            selection_foreground: rgb(0xC0, 0xCA, 0xF5),
104            red: rgb(0xF7, 0x76, 0x8E),
105            green: rgb(0x9E, 0xCE, 0x6A),
106            yellow: rgb(0xE0, 0xAF, 0x68),
107            blue: rgb(0x7A, 0xA2, 0xF7),
108            magenta: rgb(0xBB, 0x9A, 0xF7),
109            cyan: rgb(0x7D, 0xCF, 0xFF),
110        }
111    }
112
113    /// [Nord](https://www.nordtheme.com), values taken from the official
114    /// palette (`nord0`-`nord15`). Uses `nord8` ("frost", light blue-cyan)
115    /// as the accent rather than `nord7`, matching how most terminal ports
116    /// pick the brightest frost tone for emphasis.
117    pub fn nord() -> Self {
118        Self {
119            background: rgb(0x2E, 0x34, 0x40),
120            foreground: rgb(0xD8, 0xDE, 0xE9),
121            muted: rgb(0x4C, 0x56, 0x6A),
122            muted_text: rgb(0x4C, 0x56, 0x6A),
123            selection_background: rgb(0x43, 0x4C, 0x5E),
124            selection_foreground: rgb(0xEC, 0xEF, 0xF4),
125            red: rgb(0xBF, 0x61, 0x6A),
126            green: rgb(0xA3, 0xBE, 0x8C),
127            yellow: rgb(0xEB, 0xCB, 0x8B),
128            blue: rgb(0x81, 0xA1, 0xC1),
129            magenta: rgb(0xB4, 0x8E, 0xAD),
130            cyan: rgb(0x88, 0xC0, 0xD0),
131        }
132    }
133
134    /// [Dracula](https://draculatheme.com), values taken from the official
135    /// spec. Dracula has no distinct "blue" — its own ANSI spec maps the
136    /// blue slot to the purple hex, which this mirrors.
137    pub fn dracula() -> Self {
138        Self {
139            background: rgb(0x28, 0x2A, 0x36),
140            foreground: rgb(0xF8, 0xF8, 0xF2),
141            muted: rgb(0x62, 0x72, 0xA4),
142            muted_text: rgb(0x62, 0x72, 0xA4),
143            selection_background: rgb(0x44, 0x47, 0x5A),
144            selection_foreground: rgb(0xF8, 0xF8, 0xF2),
145            red: rgb(0xFF, 0x55, 0x55),
146            green: rgb(0x50, 0xFA, 0x7B),
147            yellow: rgb(0xF1, 0xFA, 0x8C),
148            blue: rgb(0xBD, 0x93, 0xF9),
149            magenta: rgb(0xFF, 0x79, 0xC6),
150            cyan: rgb(0x8B, 0xE9, 0xFD),
151        }
152    }
153
154    /// [Gruvbox](https://github.com/morhetz/gruvbox) dark, "bright" accent
155    /// set (gruvbox's neutral tones are deliberately desaturated/earthy;
156    /// the bright set reads better as foreground text on its own dark
157    /// background, which is how most terminal ports use it for ANSI 8-15).
158    pub fn gruvbox() -> Self {
159        Self {
160            background: rgb(0x28, 0x28, 0x28),
161            foreground: rgb(0xEB, 0xDB, 0xB2),
162            muted: rgb(0x92, 0x83, 0x74),
163            muted_text: rgb(0x92, 0x83, 0x74),
164            selection_background: rgb(0x50, 0x49, 0x45),
165            selection_foreground: rgb(0xFB, 0xF1, 0xC7),
166            red: rgb(0xFB, 0x49, 0x34),
167            green: rgb(0xB8, 0xBB, 0x26),
168            yellow: rgb(0xFA, 0xBD, 0x2F),
169            blue: rgb(0x83, 0xA5, 0x98),
170            magenta: rgb(0xD3, 0x86, 0x9B),
171            cyan: rgb(0x8E, 0xC0, 0x7C),
172        }
173    }
174
175    /// [Catppuccin](https://catppuccin.com) Mocha, values taken from the
176    /// official palette.
177    pub fn catppuccin_mocha() -> Self {
178        Self {
179            background: rgb(0x1E, 0x1E, 0x2E),
180            foreground: rgb(0xCD, 0xD6, 0xF4),
181            muted: rgb(0x6C, 0x70, 0x86),
182            muted_text: rgb(0xA6, 0xAD, 0xC8),
183            selection_background: rgb(0x45, 0x47, 0x5A),
184            selection_foreground: rgb(0xCD, 0xD6, 0xF4),
185            red: rgb(0xF3, 0x8B, 0xA8),
186            green: rgb(0xA6, 0xE3, 0xA1),
187            yellow: rgb(0xF9, 0xE2, 0xAF),
188            blue: rgb(0x89, 0xB4, 0xFA),
189            magenta: rgb(0xCB, 0xA6, 0xF7),
190            cyan: rgb(0x94, 0xE2, 0xD5),
191        }
192    }
193
194    /// [Solarized](https://ethanschoonover.com/solarized/) Dark, values
195    /// taken from the official base16 spec (`base03`-`base3`).
196    pub fn solarized_dark() -> Self {
197        Self {
198            background: rgb(0x00, 0x2B, 0x36),
199            foreground: rgb(0x83, 0x94, 0x96),
200            muted: rgb(0x58, 0x6E, 0x75),
201            muted_text: rgb(0x58, 0x6E, 0x75),
202            selection_background: rgb(0x07, 0x36, 0x42),
203            selection_foreground: rgb(0x93, 0xA1, 0xA1),
204            red: rgb(0xDC, 0x32, 0x2F),
205            green: rgb(0x85, 0x99, 0x00),
206            yellow: rgb(0xB5, 0x89, 0x00),
207            blue: rgb(0x26, 0x8B, 0xD2),
208            magenta: rgb(0xD3, 0x36, 0x82),
209            cyan: rgb(0x2A, 0xA1, 0x98),
210        }
211    }
212
213    // MARK: - Semantic accessors
214
215    /// Regular body text.
216    pub fn base(&self) -> Style {
217        Style::new().fg(self.foreground)
218    }
219
220    /// The title bar / breadcrumb chrome.
221    pub fn header(&self) -> Style {
222        Style::new().fg(self.cyan).add_modifier(Modifier::BOLD)
223    }
224
225    /// Runtime group headers, the command-palette border — anything drawing
226    /// attention without signaling success/warning/error.
227    pub fn accent(&self) -> Style {
228        Style::new().fg(self.cyan).add_modifier(Modifier::BOLD)
229    }
230
231    /// A booted simulator's indicator dot, confirmation affordances.
232    pub fn success(&self) -> Style {
233        Style::new().fg(self.green)
234    }
235
236    /// In-flight/transient status messages, confirm-prompt banners.
237    pub fn warning(&self) -> Style {
238        Style::new().fg(self.yellow)
239    }
240
241    /// `last_error`, validation failures.
242    pub fn error(&self) -> Style {
243        Style::new().fg(self.red)
244    }
245
246    /// Footer key hints, "press any key to close", the ghost autocomplete
247    /// suffix, a shutdown simulator's indicator dot.
248    pub fn hint(&self) -> Style {
249        Style::new().fg(self.muted_text)
250    }
251
252    /// The main list's selected row, and the header/status/wizard-breadcrumb
253    /// bars — the same filled-bar look everywhere it appears.
254    pub fn bar(&self) -> Style {
255        Style::new().fg(self.selection_foreground).bg(self.selection_background)
256    }
257}
258
259impl Default for Theme {
260    fn default() -> Self {
261        Self::from_name(ThemeName::default())
262    }
263}
264
265const fn rgb(r: u8, g: u8, b: u8) -> Color {
266    Color::Rgb(r, g, b)
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn default_theme_is_default_plus() {
275        assert_eq!(Theme::default(), Theme::default_plus());
276    }
277
278    #[test]
279    fn from_name_selects_the_matching_built_in_for_every_theme() {
280        assert_eq!(Theme::from_name(ThemeName::DefaultPlus), Theme::default_plus());
281        assert_eq!(Theme::from_name(ThemeName::Ansi), Theme::ansi());
282        assert_eq!(Theme::from_name(ThemeName::TokyoNight), Theme::tokyo_night());
283        assert_eq!(Theme::from_name(ThemeName::Nord), Theme::nord());
284        assert_eq!(Theme::from_name(ThemeName::Dracula), Theme::dracula());
285        assert_eq!(Theme::from_name(ThemeName::Gruvbox), Theme::gruvbox());
286        assert_eq!(Theme::from_name(ThemeName::CatppuccinMocha), Theme::catppuccin_mocha());
287        assert_eq!(Theme::from_name(ThemeName::SolarizedDark), Theme::solarized_dark());
288    }
289
290    #[test]
291    fn every_built_in_theme_is_reachable_from_its_theme_name() {
292        // Guards against a theme being added to ThemeName::ALL without a
293        // matching arm in Theme::from_name (or vice versa).
294        for name in ThemeName::ALL {
295            let _ = Theme::from_name(name);
296        }
297    }
298
299    #[test]
300    fn tokyo_night_matches_the_canonical_hexes() {
301        let theme = Theme::tokyo_night();
302        assert_eq!(theme.background, Color::Rgb(0x1A, 0x1B, 0x26));
303        assert_eq!(theme.foreground, Color::Rgb(0xC0, 0xCA, 0xF5));
304        assert_eq!(theme.blue, Color::Rgb(0x7A, 0xA2, 0xF7));
305        assert_eq!(theme.red, Color::Rgb(0xF7, 0x76, 0x8E));
306    }
307
308    #[test]
309    fn nord_matches_the_canonical_hexes() {
310        let theme = Theme::nord();
311        assert_eq!(theme.background, Color::Rgb(0x2E, 0x34, 0x40));
312        assert_eq!(theme.foreground, Color::Rgb(0xD8, 0xDE, 0xE9));
313        assert_eq!(theme.cyan, Color::Rgb(0x88, 0xC0, 0xD0));
314        assert_eq!(theme.red, Color::Rgb(0xBF, 0x61, 0x6A));
315    }
316
317    #[test]
318    fn dracula_matches_the_canonical_hexes_and_blue_borrows_purple() {
319        let theme = Theme::dracula();
320        assert_eq!(theme.background, Color::Rgb(0x28, 0x2A, 0x36));
321        assert_eq!(theme.foreground, Color::Rgb(0xF8, 0xF8, 0xF2));
322        assert_eq!(theme.cyan, Color::Rgb(0x8B, 0xE9, 0xFD));
323        // Dracula's own ANSI spec maps "blue" to the purple hex.
324        assert_eq!(theme.blue, Color::Rgb(0xBD, 0x93, 0xF9));
325    }
326
327    #[test]
328    fn gruvbox_matches_the_canonical_hexes() {
329        let theme = Theme::gruvbox();
330        assert_eq!(theme.background, Color::Rgb(0x28, 0x28, 0x28));
331        assert_eq!(theme.foreground, Color::Rgb(0xEB, 0xDB, 0xB2));
332        assert_eq!(theme.yellow, Color::Rgb(0xFA, 0xBD, 0x2F));
333    }
334
335    #[test]
336    fn catppuccin_mocha_matches_the_canonical_hexes() {
337        let theme = Theme::catppuccin_mocha();
338        assert_eq!(theme.background, Color::Rgb(0x1E, 0x1E, 0x2E));
339        assert_eq!(theme.foreground, Color::Rgb(0xCD, 0xD6, 0xF4));
340        assert_eq!(theme.blue, Color::Rgb(0x89, 0xB4, 0xFA));
341        assert_eq!(theme.magenta, Color::Rgb(0xCB, 0xA6, 0xF7));
342    }
343
344    #[test]
345    fn solarized_dark_matches_the_canonical_hexes() {
346        let theme = Theme::solarized_dark();
347        assert_eq!(theme.background, Color::Rgb(0x00, 0x2B, 0x36));
348        assert_eq!(theme.foreground, Color::Rgb(0x83, 0x94, 0x96));
349        assert_eq!(theme.blue, Color::Rgb(0x26, 0x8B, 0xD2));
350        assert_eq!(theme.yellow, Color::Rgb(0xB5, 0x89, 0x00));
351    }
352
353    #[test]
354    fn every_built_in_theme_is_visually_distinct() {
355        let themes = ThemeName::ALL.map(Theme::from_name);
356        for (i, a) in themes.iter().enumerate() {
357            for b in &themes[i + 1..] {
358                assert_ne!(a, b, "two built-in themes should never be identical");
359            }
360        }
361    }
362
363    #[test]
364    fn default_plus_matches_the_canonical_palette_hexes() {
365        let theme = Theme::default_plus();
366        assert_eq!(theme.background, Color::Rgb(0x1E, 0x1E, 0x1E));
367        assert_eq!(theme.selection_background, Color::Rgb(0x54, 0x55, 0x4A));
368        assert_eq!(theme.green, Color::Rgb(0x2E, 0xA8, 0x5B));
369        assert_eq!(theme.red, Color::Rgb(0xFC, 0x46, 0x51));
370    }
371
372    #[test]
373    fn ansi_theme_uses_named_terminal_colors_not_truecolor() {
374        let theme = Theme::ansi();
375        assert_eq!(theme.red, Color::Red);
376        assert_eq!(theme.cyan, Color::Cyan);
377    }
378
379    #[test]
380    fn bar_style_pairs_selection_background_and_foreground() {
381        let theme = Theme::default_plus();
382        let style = theme.bar();
383        assert_eq!(style.bg, Some(theme.selection_background));
384        assert_eq!(style.fg, Some(theme.selection_foreground));
385    }
386}