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 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 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 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 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 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 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 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 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 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 pub fn base(&self) -> Style {
217 Style::new().fg(self.foreground)
218 }
219
220 pub fn header(&self) -> Style {
222 Style::new().fg(self.cyan).add_modifier(Modifier::BOLD)
223 }
224
225 pub fn accent(&self) -> Style {
228 Style::new().fg(self.cyan).add_modifier(Modifier::BOLD)
229 }
230
231 pub fn success(&self) -> Style {
233 Style::new().fg(self.green)
234 }
235
236 pub fn warning(&self) -> Style {
238 Style::new().fg(self.yellow)
239 }
240
241 pub fn error(&self) -> Style {
243 Style::new().fg(self.red)
244 }
245
246 pub fn hint(&self) -> Style {
249 Style::new().fg(self.muted_text)
250 }
251
252 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 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 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}