1use 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 pub muted: Color,
26 pub muted_text: Color,
28 pub selection_background: Color,
29 pub selection_foreground: Color,
30 pub error: Color,
32 pub success: Color,
34 pub warning: Color,
36 pub accent: Color,
38 pub chrome: Color,
40 pub highlight: Color,
42 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 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 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 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 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 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 highlight: rgb(0xBD, 0x93, 0xF9),
165 notice: rgb(0xFF, 0x79, 0xC6),
166 }
167 }
168
169 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 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 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 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 pub fn base(&self) -> Style {
256 Style::new().fg(self.foreground)
257 }
258
259 pub fn header(&self) -> Style {
261 Style::new().fg(self.accent).add_modifier(Modifier::BOLD)
262 }
263
264 pub fn accent_style(&self) -> Style {
267 Style::new().fg(self.accent).add_modifier(Modifier::BOLD)
268 }
269
270 pub fn success(&self) -> Style {
272 Style::new().fg(self.success)
273 }
274
275 pub fn warning(&self) -> Style {
277 Style::new().fg(self.warning)
278 }
279
280 pub fn error(&self) -> Style {
282 Style::new().fg(self.error)
283 }
284
285 pub fn hint(&self) -> Style {
288 Style::new().fg(self.muted_text)
289 }
290
291 pub fn bar(&self) -> Style {
294 Style::new().fg(self.selection_foreground).bg(self.selection_background)
295 }
296
297 pub fn chrome_bar(&self) -> Style {
299 Style::new().bg(self.muted)
300 }
301
302 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 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}