magical markdown slides
at 9f8b184e4eefac55aaae12c0697e8ee9e6e1eebf 465 lines 18 kB view raw
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}