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`],
14//! [`Theme::solarized_dark`], and [`Theme::vesper`] — all ported from each
15//! project's own canonical 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    /// Errors, validation failures, `last_error`.
31    pub error: Color,
32    /// Booted-simulator dot, confirmation affordances.
33    pub success: Color,
34    /// In-flight / transient status messages.
35    pub warning: Color,
36    /// Popup borders, command-palette chrome, runtime-group headers.
37    pub accent: Color,
38    /// Header text, readout numbers, chrome accents.
39    pub chrome: Color,
40    /// Spare accent (blue slot in the original palette).
41    pub highlight: Color,
42    /// Spare accent (magenta slot in the original palette).
43    pub notice: Color,
44}
45
46impl Theme {
47    pub fn from_name(name: ThemeName) -> Self {
48        match name {
49            ThemeName::DefaultPlus => Self::default_plus(),
50            ThemeName::Ansi => Self::ansi(),
51            ThemeName::TokyoNight => Self::tokyo_night(),
52            ThemeName::Nord => Self::nord(),
53            ThemeName::Dracula => Self::dracula(),
54            ThemeName::Gruvbox => Self::gruvbox(),
55            ThemeName::CatppuccinMocha => Self::catppuccin_mocha(),
56            ThemeName::SolarizedDark => Self::solarized_dark(),
57            ThemeName::Vesper => Self::vesper(),
58        }
59    }
60
61    /// The [Default+](https://github.com/otaviocc/default-plus) colorscheme,
62    /// values taken verbatim from that repo's `palette.yaml` (`base` +
63    /// `accent`).
64    pub fn default_plus() -> Self {
65        Self {
66            background: rgb(0x1E, 0x1E, 0x1E),
67            foreground: rgb(0xFF, 0xFF, 0xFF),
68            muted: rgb(0x4D, 0x4D, 0x4D),
69            muted_text: rgb(0x8E, 0x8E, 0x8E),
70            selection_background: rgb(0x54, 0x55, 0x4A),
71            selection_foreground: rgb(0xFF, 0xFF, 0xFF),
72            error: rgb(0xFC, 0x46, 0x51),
73            success: rgb(0x2E, 0xA8, 0x5B),
74            warning: rgb(0xFF, 0xE7, 0x6D),
75            accent: rgb(0x56, 0xD0, 0xB3),
76            chrome: rgb(0x56, 0xD0, 0xB3),
77            highlight: rgb(0x35, 0xB0, 0xD8),
78            notice: rgb(0xF2, 0x24, 0x8C),
79        }
80    }
81
82    /// The terminal's own 16-color scheme. Correct on a background whose
83    /// depth/appearance nothing here has detected, at the cost of not
84    /// matching Default+ exactly on every terminal — see vigia's `theme.rs`
85    /// for the same reasoning about why an ANSI-named palette is the safe
86    /// fallback rather than the default.
87    pub fn ansi() -> Self {
88        Self {
89            background: Color::Reset,
90            foreground: Color::Reset,
91            muted: Color::DarkGray,
92            muted_text: Color::DarkGray,
93            selection_background: Color::DarkGray,
94            selection_foreground: Color::White,
95            error: Color::Red,
96            success: Color::Green,
97            warning: Color::Yellow,
98            accent: Color::Cyan,
99            chrome: Color::Cyan,
100            highlight: Color::Blue,
101            notice: Color::Magenta,
102        }
103    }
104
105    /// [Tokyo Night](https://github.com/folke/tokyonight.nvim) (the "Night"
106    /// variant), values taken from that project's canonical Lua palette.
107    pub fn tokyo_night() -> Self {
108        Self {
109            background: rgb(0x1A, 0x1B, 0x26),
110            foreground: rgb(0xC0, 0xCA, 0xF5),
111            muted: rgb(0x56, 0x5F, 0x89),
112            muted_text: rgb(0x56, 0x5F, 0x89),
113            selection_background: rgb(0x29, 0x2E, 0x42),
114            selection_foreground: rgb(0xC0, 0xCA, 0xF5),
115            error: rgb(0xF7, 0x76, 0x8E),
116            success: rgb(0x9E, 0xCE, 0x6A),
117            warning: rgb(0xE0, 0xAF, 0x68),
118            accent: rgb(0x7D, 0xCF, 0xFF),
119            chrome: rgb(0x7A, 0xA2, 0xF7),
120            highlight: rgb(0x7A, 0xA2, 0xF7),
121            notice: rgb(0xBB, 0x9A, 0xF7),
122        }
123    }
124
125    /// [Nord](https://www.nordtheme.com), values taken from the official
126    /// palette (`nord0`-`nord15`). Uses `nord8` ("frost", light blue-cyan)
127    /// as the accent rather than `nord7`, matching how most terminal ports
128    /// pick the brightest frost tone for emphasis.
129    pub fn nord() -> Self {
130        Self {
131            background: rgb(0x2E, 0x34, 0x40),
132            foreground: rgb(0xD8, 0xDE, 0xE9),
133            muted: rgb(0x4C, 0x56, 0x6A),
134            muted_text: rgb(0x4C, 0x56, 0x6A),
135            selection_background: rgb(0x43, 0x4C, 0x5E),
136            selection_foreground: rgb(0xEC, 0xEF, 0xF4),
137            error: rgb(0xBF, 0x61, 0x6A),
138            success: rgb(0xA3, 0xBE, 0x8C),
139            warning: rgb(0xEB, 0xCB, 0x8B),
140            accent: rgb(0x88, 0xC0, 0xD0),
141            chrome: rgb(0x88, 0xC0, 0xD0),
142            highlight: rgb(0x81, 0xA1, 0xC1),
143            notice: rgb(0xB4, 0x8E, 0xAD),
144        }
145    }
146
147    /// [Dracula](https://draculatheme.com), values taken from the official
148    /// spec. Dracula has no distinct "blue" — its own ANSI spec maps the
149    /// blue slot to the purple hex, which this mirrors.
150    pub fn dracula() -> Self {
151        Self {
152            background: rgb(0x28, 0x2A, 0x36),
153            foreground: rgb(0xF8, 0xF8, 0xF2),
154            muted: rgb(0x62, 0x72, 0xA4),
155            muted_text: rgb(0x62, 0x72, 0xA4),
156            selection_background: rgb(0x44, 0x47, 0x5A),
157            selection_foreground: rgb(0xF8, 0xF8, 0xF2),
158            error: rgb(0xFF, 0x55, 0x55),
159            success: rgb(0x50, 0xFA, 0x7B),
160            warning: rgb(0xF1, 0xFA, 0x8C),
161            accent: rgb(0x8B, 0xE9, 0xFD),
162            chrome: rgb(0xBD, 0x93, 0xF9),
163            // Dracula's own ANSI spec maps "blue" to the purple hex.
164            highlight: rgb(0xBD, 0x93, 0xF9),
165            notice: rgb(0xFF, 0x79, 0xC6),
166        }
167    }
168
169    /// [Gruvbox](https://github.com/morhetz/gruvbox) dark, "bright" accent
170    /// set (gruvbox's neutral tones are deliberately desaturated/earthy;
171    /// the bright set reads better as foreground text on its own dark
172    /// background, which is how most terminal ports use it for ANSI 8-15).
173    pub fn gruvbox() -> Self {
174        Self {
175            background: rgb(0x28, 0x28, 0x28),
176            foreground: rgb(0xEB, 0xDB, 0xB2),
177            muted: rgb(0x92, 0x83, 0x74),
178            muted_text: rgb(0x92, 0x83, 0x74),
179            selection_background: rgb(0x50, 0x49, 0x45),
180            selection_foreground: rgb(0xFB, 0xF1, 0xC7),
181            error: rgb(0xFB, 0x49, 0x34),
182            success: rgb(0xB8, 0xBB, 0x26),
183            warning: rgb(0xFA, 0xBD, 0x2F),
184            accent: rgb(0x8E, 0xC0, 0x7C),
185            chrome: rgb(0x8E, 0xC0, 0x7C),
186            highlight: rgb(0x83, 0xA5, 0x98),
187            notice: rgb(0xD3, 0x86, 0x9B),
188        }
189    }
190
191    /// [Catppuccin](https://catppuccin.com) Mocha, values taken from the
192    /// official palette.
193    pub fn catppuccin_mocha() -> Self {
194        Self {
195            background: rgb(0x1E, 0x1E, 0x2E),
196            foreground: rgb(0xCD, 0xD6, 0xF4),
197            muted: rgb(0x6C, 0x70, 0x86),
198            muted_text: rgb(0xA6, 0xAD, 0xC8),
199            selection_background: rgb(0x45, 0x47, 0x5A),
200            selection_foreground: rgb(0xCD, 0xD6, 0xF4),
201            error: rgb(0xF3, 0x8B, 0xA8),
202            success: rgb(0xA6, 0xE3, 0xA1),
203            warning: rgb(0xF9, 0xE2, 0xAF),
204            accent: rgb(0x94, 0xE2, 0xD5),
205            chrome: rgb(0x89, 0xB4, 0xFA),
206            highlight: rgb(0x89, 0xB4, 0xFA),
207            notice: rgb(0xCB, 0xA6, 0xF7),
208        }
209    }
210
211    /// [Solarized](https://ethanschoonover.com/solarized/) Dark, values
212    /// taken from the official base16 spec (`base03`-`base3`).
213    pub fn solarized_dark() -> Self {
214        Self {
215            background: rgb(0x00, 0x2B, 0x36),
216            foreground: rgb(0x83, 0x94, 0x96),
217            muted: rgb(0x58, 0x6E, 0x75),
218            muted_text: rgb(0x58, 0x6E, 0x75),
219            selection_background: rgb(0x07, 0x36, 0x42),
220            selection_foreground: rgb(0x93, 0xA1, 0xA1),
221            error: rgb(0xDC, 0x32, 0x2F),
222            success: rgb(0x85, 0x99, 0x00),
223            warning: rgb(0xB5, 0x89, 0x00),
224            accent: rgb(0x2A, 0xA1, 0x98),
225            chrome: rgb(0x26, 0x8B, 0xD2),
226            highlight: rgb(0x26, 0x8B, 0xD2),
227            notice: rgb(0xD3, 0x36, 0x82),
228        }
229    }
230
231    /// [Vesper](https://github.com/raunofreiberg/vesper) — an ultra-muted,
232    /// near-monochromatic palette with warm pastel accents. Values taken
233    /// from the canonical VS Code theme.
234    pub fn vesper() -> Self {
235        Self {
236            background: rgb(0x10, 0x10, 0x10),
237            foreground: rgb(0xFF, 0xFF, 0xFF),
238            muted: rgb(0x50, 0x50, 0x50),
239            muted_text: rgb(0x7E, 0x7E, 0x7E),
240            selection_background: rgb(0x23, 0x23, 0x23),
241            selection_foreground: rgb(0xFF, 0xFF, 0xFF),
242            error: rgb(0xFF, 0x80, 0x80),
243            success: rgb(0x90, 0xB9, 0x9F),
244            warning: rgb(0xFF, 0xC7, 0x99),
245            accent: rgb(0xFF, 0xC7, 0x99),
246            chrome: rgb(0xF5, 0x91, 0xB2),
247            highlight: rgb(0xF5, 0x91, 0xB2),
248            notice: rgb(0xEC, 0xAA, 0xD6),
249        }
250    }
251
252    // MARK: - Semantic accessors
253
254    /// Regular body text.
255    pub fn base(&self) -> Style {
256        Style::new().fg(self.foreground)
257    }
258
259    /// The title bar / breadcrumb chrome.
260    pub fn header(&self) -> Style {
261        Style::new().fg(self.accent).add_modifier(Modifier::BOLD)
262    }
263
264    /// Runtime group headers, the command-palette border — anything drawing
265    /// attention without signaling success/warning/error.
266    pub fn accent_style(&self) -> Style {
267        Style::new().fg(self.accent).add_modifier(Modifier::BOLD)
268    }
269
270    /// A booted simulator's indicator dot, confirmation affordances.
271    pub fn success(&self) -> Style {
272        Style::new().fg(self.success)
273    }
274
275    /// In-flight/transient status messages, confirm-prompt banners.
276    pub fn warning(&self) -> Style {
277        Style::new().fg(self.warning)
278    }
279
280    /// `last_error`, validation failures.
281    pub fn error(&self) -> Style {
282        Style::new().fg(self.error)
283    }
284
285    /// Footer key hints, "press any key to close", the ghost autocomplete
286    /// suffix, a shutdown simulator's indicator dot.
287    pub fn hint(&self) -> Style {
288        Style::new().fg(self.muted_text)
289    }
290
291    /// The main list's selected row — the same filled-bar look for every
292    /// highlighted row across the UI.
293    pub fn bar(&self) -> Style {
294        Style::new().fg(self.selection_foreground).bg(self.selection_background)
295    }
296
297    /// Full-row background wash for header and status bar.
298    pub fn chrome_bar(&self) -> Style {
299        Style::new().bg(self.muted)
300    }
301
302    /// Horizontal rule (hairline) between bars and content.
303    pub fn rule(&self) -> Style {
304        Style::new().fg(self.muted)
305    }
306}
307
308impl Default for Theme {
309    fn default() -> Self {
310        Self::from_name(ThemeName::default())
311    }
312}
313
314const fn rgb(r: u8, g: u8, b: u8) -> Color {
315    Color::Rgb(r, g, b)
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn default_theme_is_default_plus() {
324        assert_eq!(Theme::default(), Theme::default_plus());
325    }
326
327    #[test]
328    fn from_name_selects_the_matching_built_in_for_every_theme() {
329        assert_eq!(Theme::from_name(ThemeName::DefaultPlus), Theme::default_plus());
330        assert_eq!(Theme::from_name(ThemeName::Ansi), Theme::ansi());
331        assert_eq!(Theme::from_name(ThemeName::TokyoNight), Theme::tokyo_night());
332        assert_eq!(Theme::from_name(ThemeName::Nord), Theme::nord());
333        assert_eq!(Theme::from_name(ThemeName::Dracula), Theme::dracula());
334        assert_eq!(Theme::from_name(ThemeName::Gruvbox), Theme::gruvbox());
335        assert_eq!(Theme::from_name(ThemeName::CatppuccinMocha), Theme::catppuccin_mocha());
336        assert_eq!(Theme::from_name(ThemeName::SolarizedDark), Theme::solarized_dark());
337        assert_eq!(Theme::from_name(ThemeName::Vesper), Theme::vesper());
338    }
339
340    #[test]
341    fn every_built_in_theme_is_reachable_from_its_theme_name() {
342        for name in ThemeName::ALL {
343            let _ = Theme::from_name(name);
344        }
345    }
346
347    #[test]
348    fn tokyo_night_matches_the_canonical_hexes() {
349        let theme = Theme::tokyo_night();
350        assert_eq!(theme.background, Color::Rgb(0x1A, 0x1B, 0x26));
351        assert_eq!(theme.foreground, Color::Rgb(0xC0, 0xCA, 0xF5));
352        assert_eq!(theme.highlight, Color::Rgb(0x7A, 0xA2, 0xF7));
353        assert_eq!(theme.error, Color::Rgb(0xF7, 0x76, 0x8E));
354    }
355
356    #[test]
357    fn nord_matches_the_canonical_hexes() {
358        let theme = Theme::nord();
359        assert_eq!(theme.background, Color::Rgb(0x2E, 0x34, 0x40));
360        assert_eq!(theme.foreground, Color::Rgb(0xD8, 0xDE, 0xE9));
361        assert_eq!(theme.accent, Color::Rgb(0x88, 0xC0, 0xD0));
362        assert_eq!(theme.error, Color::Rgb(0xBF, 0x61, 0x6A));
363    }
364
365    #[test]
366    fn dracula_matches_the_canonical_hexes_and_highlight_borrows_purple() {
367        let theme = Theme::dracula();
368        assert_eq!(theme.background, Color::Rgb(0x28, 0x2A, 0x36));
369        assert_eq!(theme.foreground, Color::Rgb(0xF8, 0xF8, 0xF2));
370        assert_eq!(theme.accent, Color::Rgb(0x8B, 0xE9, 0xFD));
371        // Dracula's own ANSI spec maps "blue" to the purple hex.
372        assert_eq!(theme.highlight, Color::Rgb(0xBD, 0x93, 0xF9));
373    }
374
375    #[test]
376    fn gruvbox_matches_the_canonical_hexes() {
377        let theme = Theme::gruvbox();
378        assert_eq!(theme.background, Color::Rgb(0x28, 0x28, 0x28));
379        assert_eq!(theme.foreground, Color::Rgb(0xEB, 0xDB, 0xB2));
380        assert_eq!(theme.warning, Color::Rgb(0xFA, 0xBD, 0x2F));
381    }
382
383    #[test]
384    fn catppuccin_mocha_matches_the_canonical_hexes() {
385        let theme = Theme::catppuccin_mocha();
386        assert_eq!(theme.background, Color::Rgb(0x1E, 0x1E, 0x2E));
387        assert_eq!(theme.foreground, Color::Rgb(0xCD, 0xD6, 0xF4));
388        assert_eq!(theme.highlight, Color::Rgb(0x89, 0xB4, 0xFA));
389        assert_eq!(theme.notice, Color::Rgb(0xCB, 0xA6, 0xF7));
390    }
391
392    #[test]
393    fn vesper_matches_the_canonical_hexes() {
394        let theme = Theme::vesper();
395        assert_eq!(theme.background, Color::Rgb(0x10, 0x10, 0x10));
396        assert_eq!(theme.foreground, Color::Rgb(0xFF, 0xFF, 0xFF));
397        assert_eq!(theme.muted, Color::Rgb(0x50, 0x50, 0x50));
398        assert_eq!(theme.error, Color::Rgb(0xFF, 0x80, 0x80));
399        assert_eq!(theme.accent, Color::Rgb(0xFF, 0xC7, 0x99));
400        assert_eq!(theme.chrome, Color::Rgb(0xF5, 0x91, 0xB2));
401    }
402
403    #[test]
404    fn solarized_dark_matches_the_canonical_hexes() {
405        let theme = Theme::solarized_dark();
406        assert_eq!(theme.background, Color::Rgb(0x00, 0x2B, 0x36));
407        assert_eq!(theme.foreground, Color::Rgb(0x83, 0x94, 0x96));
408        assert_eq!(theme.highlight, Color::Rgb(0x26, 0x8B, 0xD2));
409        assert_eq!(theme.warning, Color::Rgb(0xB5, 0x89, 0x00));
410    }
411
412    #[test]
413    fn every_built_in_theme_is_visually_distinct() {
414        let themes = ThemeName::ALL.map(Theme::from_name);
415        for (i, a) in themes.iter().enumerate() {
416            for b in &themes[i + 1..] {
417                assert_ne!(a, b, "two built-in themes should never be identical");
418            }
419        }
420    }
421
422    #[test]
423    fn default_plus_matches_the_canonical_palette_hexes() {
424        let theme = Theme::default_plus();
425        assert_eq!(theme.background, Color::Rgb(0x1E, 0x1E, 0x1E));
426        assert_eq!(theme.selection_background, Color::Rgb(0x54, 0x55, 0x4A));
427        assert_eq!(theme.success, Color::Rgb(0x2E, 0xA8, 0x5B));
428        assert_eq!(theme.error, Color::Rgb(0xFC, 0x46, 0x51));
429    }
430
431    #[test]
432    fn ansi_theme_uses_named_terminal_colors_not_truecolor() {
433        let theme = Theme::ansi();
434        assert_eq!(theme.error, Color::Red);
435        assert_eq!(theme.accent, Color::Cyan);
436    }
437
438    #[test]
439    fn bar_style_pairs_selection_background_and_foreground() {
440        let theme = Theme::default_plus();
441        let style = theme.bar();
442        assert_eq!(style.bg, Some(theme.selection_background));
443        assert_eq!(style.fg, Some(theme.selection_foreground));
444    }
445
446    #[test]
447    fn chrome_bar_uses_muted_as_background() {
448        let theme = Theme::default_plus();
449        let style = theme.chrome_bar();
450        assert_eq!(style.bg, Some(theme.muted));
451        assert_eq!(style.fg, None);
452    }
453
454    #[test]
455    fn rule_uses_muted_as_foreground() {
456        let theme = Theme::default_plus();
457        let style = theme.rule();
458        assert_eq!(style.fg, Some(theme.muted));
459        assert_eq!(style.bg, None);
460    }
461}