magical markdown slides
1use owo_colors::{OwoColorize, Style};
2use terminal_colorsaurus::{QueryOptions, background_color};
3
4/// Detects if the terminal background is dark.
5///
6/// Uses [terminal_colorsaurus] to query the terminal background color.
7/// Defaults to true (dark) if detection fails.
8pub fn detect_is_dark() -> bool {
9 match background_color(QueryOptions::default()) {
10 Ok(color) => {
11 let r = color.r as f32 / 255.0;
12 let g = color.g as f32 / 255.0;
13 let b = color.b as f32 / 255.0;
14 let luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
15 luminance < 0.5
16 }
17 Err(_) => true,
18 }
19}
20
21/// Color theme abstraction for slides with owo-colors with semantic roles for consistent theming across the application.
22///
23/// Avoids dynamic dispatch by using compile-time color assignments.
24#[derive(Debug, Clone)]
25pub struct ThemeColors {
26 pub heading: Style,
27 pub body: Style,
28 pub accent: Style,
29 pub code: Style,
30 pub dimmed: Style,
31 pub code_fence: Style,
32 pub rule: Style,
33 pub list_marker: Style,
34 pub blockquote_border: Style,
35 pub table_border: Style,
36}
37
38impl Default for ThemeColors {
39 fn default() -> Self {
40 Self::basic(detect_is_dark())
41 }
42}
43
44impl ThemeColors {
45 /// Apply heading style to text
46 pub fn heading<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
47 text.style(self.heading)
48 }
49
50 /// Apply body style to text
51 pub fn body<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
52 text.style(self.body)
53 }
54
55 /// Apply accent style to text
56 pub fn accent<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
57 text.style(self.accent)
58 }
59
60 /// Apply code style to text
61 pub fn code<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
62 text.style(self.code)
63 }
64
65 /// Apply dimmed style to text
66 pub fn dimmed<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
67 text.style(self.dimmed)
68 }
69
70 /// Apply code fence style to text
71 pub fn code_fence<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
72 text.style(self.code_fence)
73 }
74
75 /// Apply horizontal rule style to text
76 pub fn rule<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
77 text.style(self.rule)
78 }
79
80 /// Apply list marker style to text
81 pub fn list_marker<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
82 text.style(self.list_marker)
83 }
84
85 /// Apply blockquote border style to text
86 pub fn blockquote_border<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
87 text.style(self.blockquote_border)
88 }
89
90 /// Apply table border style to text
91 pub fn table_border<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
92 text.style(self.table_border)
93 }
94
95 /// Create an oxocarbon-based theme.
96 pub fn basic(is_dark: bool) -> Self {
97 if is_dark {
98 Self {
99 // green
100 heading: Style::new().truecolor(66, 190, 101).bold(),
101 body: Style::new().truecolor(242, 244, 248),
102 // pink
103 accent: Style::new().truecolor(238, 83, 150),
104 // blue
105 code: Style::new().truecolor(51, 177, 255),
106 // gray
107 dimmed: Style::new().truecolor(82, 82, 82),
108 code_fence: Style::new().truecolor(82, 82, 82),
109 rule: Style::new().truecolor(82, 82, 82),
110 // light blue
111 list_marker: Style::new().truecolor(120, 169, 255),
112 blockquote_border: Style::new().truecolor(82, 82, 82),
113 table_border: Style::new().truecolor(82, 82, 82),
114 }
115 } else {
116 // Oxocarbon Light variant
117 Self {
118 // green
119 heading: Style::new().truecolor(66, 190, 101).bold(),
120 body: Style::new().truecolor(57, 57, 57),
121 // orange
122 accent: Style::new().truecolor(255, 111, 0),
123 // blue
124 code: Style::new().truecolor(15, 98, 254),
125 // dark gray
126 dimmed: Style::new().truecolor(22, 22, 22),
127 code_fence: Style::new().truecolor(22, 22, 22),
128 rule: Style::new().truecolor(22, 22, 22),
129 // pink
130 list_marker: Style::new().truecolor(238, 83, 150),
131 blockquote_border: Style::new().truecolor(22, 22, 22),
132 table_border: Style::new().truecolor(22, 22, 22),
133 }
134 }
135 }
136
137 /// Create a Monokai-inspired theme.
138 ///
139 /// Dark variant uses classic Monokai colors optimized for dark backgrounds.
140 /// Light variant uses adjusted colors optimized for light backgrounds.
141 pub fn monokai(is_dark: bool) -> Self {
142 if is_dark {
143 Self {
144 heading: Style::new().truecolor(249, 38, 114).bold(), // pink
145 body: Style::new().truecolor(248, 248, 242), // off-white
146 accent: Style::new().truecolor(230, 219, 116), // yellow
147 code: Style::new().truecolor(166, 226, 46), // green
148 dimmed: Style::new().truecolor(117, 113, 94), // brown-gray
149 code_fence: Style::new().truecolor(117, 113, 94),
150 rule: Style::new().truecolor(117, 113, 94),
151 list_marker: Style::new().truecolor(230, 219, 116),
152 blockquote_border: Style::new().truecolor(117, 113, 94),
153 table_border: Style::new().truecolor(117, 113, 94),
154 }
155 } else {
156 Self {
157 heading: Style::new().truecolor(200, 30, 90).bold(), // darker pink
158 body: Style::new().truecolor(39, 40, 34), // dark gray
159 accent: Style::new().truecolor(180, 170, 80), // darker yellow
160 code: Style::new().truecolor(100, 150, 30), // darker green
161 dimmed: Style::new().truecolor(150, 150, 150), // light gray
162 code_fence: Style::new().truecolor(150, 150, 150),
163 rule: Style::new().truecolor(150, 150, 150),
164 list_marker: Style::new().truecolor(180, 170, 80),
165 blockquote_border: Style::new().truecolor(150, 150, 150),
166 table_border: Style::new().truecolor(150, 150, 150),
167 }
168 }
169 }
170
171 /// Create a Dracula-inspired theme.
172 ///
173 /// Dark variant uses classic Dracula colors optimized for dark backgrounds.
174 /// Light variant uses adjusted colors optimized for light backgrounds.
175 pub fn dracula(is_dark: bool) -> Self {
176 if is_dark {
177 Self {
178 heading: Style::new().truecolor(255, 121, 198).bold(), // pink
179 body: Style::new().truecolor(248, 248, 242),
180 accent: Style::new().truecolor(139, 233, 253), // cyan
181 code: Style::new().truecolor(80, 250, 123), // green
182 dimmed: Style::new().truecolor(98, 114, 164),
183 code_fence: Style::new().truecolor(98, 114, 164),
184 rule: Style::new().truecolor(98, 114, 164),
185 list_marker: Style::new().truecolor(241, 250, 140), // yellow
186 blockquote_border: Style::new().truecolor(98, 114, 164),
187 table_border: Style::new().truecolor(98, 114, 164),
188 }
189 } else {
190 Self {
191 heading: Style::new().truecolor(200, 80, 160).bold(), // darker pink
192 body: Style::new().truecolor(40, 42, 54),
193 accent: Style::new().truecolor(80, 150, 180), // darker cyan
194 code: Style::new().truecolor(50, 160, 80), // darker green
195 dimmed: Style::new().truecolor(150, 150, 150), // light gray
196 code_fence: Style::new().truecolor(150, 150, 150),
197 rule: Style::new().truecolor(150, 150, 150),
198 list_marker: Style::new().truecolor(180, 170, 90), // darker yellow
199 blockquote_border: Style::new().truecolor(150, 150, 150),
200 table_border: Style::new().truecolor(150, 150, 150),
201 }
202 }
203 }
204
205 /// Create a Solarized theme.
206 ///
207 /// Uses Ethan Schoonover's Solarized color palette.
208 pub fn solarized(is_dark: bool) -> Self {
209 if is_dark {
210 Self {
211 heading: Style::new().truecolor(38, 139, 210).bold(),
212 body: Style::new().truecolor(131, 148, 150),
213 accent: Style::new().truecolor(42, 161, 152),
214 code: Style::new().truecolor(133, 153, 0),
215 dimmed: Style::new().truecolor(88, 110, 117),
216 code_fence: Style::new().truecolor(88, 110, 117),
217 rule: Style::new().truecolor(88, 110, 117),
218 list_marker: Style::new().truecolor(181, 137, 0),
219 blockquote_border: Style::new().truecolor(88, 110, 117),
220 table_border: Style::new().truecolor(88, 110, 117),
221 }
222 } else {
223 Self {
224 heading: Style::new().truecolor(38, 139, 210).bold(),
225 body: Style::new().truecolor(101, 123, 131),
226 accent: Style::new().truecolor(42, 161, 152),
227 code: Style::new().truecolor(133, 153, 0),
228 dimmed: Style::new().truecolor(147, 161, 161),
229 code_fence: Style::new().truecolor(147, 161, 161),
230 rule: Style::new().truecolor(147, 161, 161),
231 list_marker: Style::new().truecolor(181, 137, 0),
232 blockquote_border: Style::new().truecolor(147, 161, 161),
233 table_border: Style::new().truecolor(147, 161, 161),
234 }
235 }
236 }
237
238 /// Create a Nord theme instance
239 pub fn nord(is_dark: bool) -> Self {
240 if is_dark {
241 Self {
242 heading: Style::new().truecolor(136, 192, 208).bold(), // nord8 - light blue
243 body: Style::new().truecolor(216, 222, 233), // nord4
244 accent: Style::new().truecolor(143, 188, 187), // nord7 - teal
245 code: Style::new().truecolor(163, 190, 140), // nord14 - green
246 dimmed: Style::new().truecolor(76, 86, 106), // nord3
247 code_fence: Style::new().truecolor(76, 86, 106),
248 rule: Style::new().truecolor(76, 86, 106),
249 list_marker: Style::new().truecolor(235, 203, 139), // nord13 - yellow
250 blockquote_border: Style::new().truecolor(76, 86, 106),
251 table_border: Style::new().truecolor(76, 86, 106),
252 }
253 } else {
254 Self {
255 heading: Style::new().truecolor(94, 129, 172).bold(), // darker blue
256 body: Style::new().truecolor(46, 52, 64),
257 accent: Style::new().truecolor(136, 192, 208), // blue
258 code: Style::new().truecolor(163, 190, 140), // green
259 dimmed: Style::new().truecolor(143, 157, 175),
260 code_fence: Style::new().truecolor(143, 157, 175),
261 rule: Style::new().truecolor(143, 157, 175),
262 list_marker: Style::new().truecolor(235, 203, 139), // yellow
263 blockquote_border: Style::new().truecolor(143, 157, 175),
264 table_border: Style::new().truecolor(143, 157, 175),
265 }
266 }
267 }
268}
269
270/// Theme registry for loading themes by name with automatic light/dark variant selection.
271pub struct ThemeRegistry;
272
273impl ThemeRegistry {
274 /// Get a theme by name with automatic variant detection or explicit override.
275 pub fn get(name: &str) -> ThemeColors {
276 let (theme_name, explicit_variant) = if let Some((scheme, variant)) = name.split_once(':') {
277 let is_dark = match variant.to_lowercase().as_str() {
278 "light" => false,
279 "dark" => true,
280 _ => detect_is_dark(),
281 };
282 (scheme, Some(is_dark))
283 } else {
284 (name, None)
285 };
286
287 let is_dark = explicit_variant.unwrap_or_else(detect_is_dark);
288
289 match theme_name.to_lowercase().as_str() {
290 "basic" => ThemeColors::basic(is_dark),
291 "monokai" => ThemeColors::monokai(is_dark),
292 "dracula" => ThemeColors::dracula(is_dark),
293 "solarized" => ThemeColors::solarized(is_dark),
294 "nord" => ThemeColors::nord(is_dark),
295 _ => ThemeColors::basic(is_dark),
296 }
297 }
298
299 /// List all available theme scheme names.
300 pub fn available_themes() -> Vec<&'static str> {
301 vec!["basic", "monokai", "dracula", "solarized", "nord"]
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn theme_colors_default() {
311 let theme = ThemeColors::default();
312 let text = "Test";
313 let heading = theme.heading(&text);
314 assert!(heading.to_string().contains("Test"));
315 }
316
317 #[test]
318 fn theme_colors_apply_styles() {
319 let theme = ThemeColors::default();
320
321 assert!(theme.heading(&"Heading").to_string().contains("Heading"));
322 assert!(theme.body(&"Body").to_string().contains("Body"));
323 assert!(theme.accent(&"Accent").to_string().contains("Accent"));
324 assert!(theme.code(&"Code").to_string().contains("Code"));
325 assert!(theme.dimmed(&"Dimmed").to_string().contains("Dimmed"));
326 }
327
328 #[test]
329 fn theme_basic_dark_variant() {
330 let theme = ThemeColors::basic(true);
331 let text = "Test";
332 let styled = theme.heading(&text);
333 assert!(styled.to_string().contains("Test"));
334 }
335
336 #[test]
337 fn theme_basic_light_variant() {
338 let theme = ThemeColors::basic(false);
339 let text = "Test";
340 let styled = theme.heading(&text);
341 assert!(styled.to_string().contains("Test"));
342 }
343
344 #[test]
345 fn theme_monokai_variants() {
346 let dark = ThemeColors::monokai(true);
347 let light = ThemeColors::monokai(false);
348 assert!(dark.heading(&"Test").to_string().contains("Test"));
349 assert!(light.heading(&"Test").to_string().contains("Test"));
350 }
351
352 #[test]
353 fn theme_dracula_variants() {
354 let dark = ThemeColors::dracula(true);
355 let light = ThemeColors::dracula(false);
356 assert!(dark.heading(&"Test").to_string().contains("Test"));
357 assert!(light.heading(&"Test").to_string().contains("Test"));
358 }
359
360 #[test]
361 fn theme_solarized_variants() {
362 let dark = ThemeColors::solarized(true);
363 let light = ThemeColors::solarized(false);
364 assert!(dark.heading(&"Test").to_string().contains("Test"));
365 assert!(light.heading(&"Test").to_string().contains("Test"));
366 }
367
368 #[test]
369 fn theme_nord_variants() {
370 let dark = ThemeColors::nord(true);
371 let light = ThemeColors::nord(false);
372 assert!(dark.heading(&"Test").to_string().contains("Test"));
373 assert!(light.heading(&"Test").to_string().contains("Test"));
374 }
375
376 #[test]
377 fn theme_registry_get_basic() {
378 let theme = ThemeRegistry::get("basic");
379 let text = "Test";
380 let styled = theme.heading(&text);
381 assert!(styled.to_string().contains("Test"));
382 }
383
384 #[test]
385 fn theme_registry_explicit_variant_dark() {
386 let theme = ThemeRegistry::get("basic:dark");
387 let text = "Test";
388 let styled = theme.heading(&text);
389 assert!(styled.to_string().contains("Test"));
390 }
391
392 #[test]
393 fn theme_registry_explicit_variant_light() {
394 let theme = ThemeRegistry::get("solarized:light");
395 let text = "Test";
396 let styled = theme.heading(&text);
397 assert!(styled.to_string().contains("Test"));
398 }
399
400 #[test]
401 fn theme_registry_get_monokai() {
402 let theme = ThemeRegistry::get("monokai");
403 let text = "Test";
404 let styled = theme.heading(&text);
405 assert!(styled.to_string().contains("Test"));
406 }
407
408 #[test]
409 fn theme_registry_get_dracula() {
410 let theme = ThemeRegistry::get("dracula");
411 let text = "Test";
412 let styled = theme.heading(&text);
413 assert!(styled.to_string().contains("Test"));
414 }
415
416 #[test]
417 fn theme_registry_get_solarized() {
418 let theme = ThemeRegistry::get("solarized");
419 let text = "Test";
420 let styled = theme.heading(&text);
421 assert!(styled.to_string().contains("Test"));
422 }
423
424 #[test]
425 fn theme_registry_get_nord() {
426 let theme = ThemeRegistry::get("nord");
427 let text = "Test";
428 let styled = theme.heading(&text);
429 assert!(styled.to_string().contains("Test"));
430 }
431
432 #[test]
433 fn theme_registry_get_unknown_fallback() {
434 let theme = ThemeRegistry::get("nonexistent");
435 let text = "Test";
436 let styled = theme.heading(&text);
437 assert!(styled.to_string().contains("Test"));
438 }
439
440 #[test]
441 fn theme_registry_case_insensitive() {
442 let theme1 = ThemeRegistry::get("BASIC");
443 let theme2 = ThemeRegistry::get("basic");
444 let text = "Test";
445 assert!(theme1.heading(&text).to_string().contains("Test"));
446 assert!(theme2.heading(&text).to_string().contains("Test"));
447 }
448
449 #[test]
450 fn theme_registry_available_themes() {
451 let themes = ThemeRegistry::available_themes();
452 assert!(themes.contains(&"basic"));
453 assert!(themes.contains(&"monokai"));
454 assert!(themes.contains(&"dracula"));
455 assert!(themes.contains(&"solarized"));
456 assert!(themes.contains(&"nord"));
457 assert_eq!(themes.len(), 5);
458 }
459
460 #[test]
461 fn detect_is_dark_returns_bool() {
462 let result = detect_is_dark();
463 assert!(result == true || result == false);
464 }
465}