magical markdown slides
1use owo_colors::{OwoColorize, Style};
2use serde::Deserialize;
3use terminal_colorsaurus::{QueryOptions, background_color};
4
5/// Parses a hex color string to RGB values.
6///
7/// Supports both `#RRGGBB` and `RRGGBB` formats.
8fn parse_hex_color(hex: &str) -> Option<(u8, u8, u8)> {
9 let hex = hex.trim_start_matches('#');
10
11 if hex.len() != 6 {
12 return None;
13 }
14
15 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
16 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
17 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
18
19 Some((r, g, b))
20}
21
22/// Base16 color scheme specification.
23///
24/// Defines a standard 16-color palette that can be mapped to semantic theme roles.
25#[derive(Debug, Clone, Deserialize)]
26struct Base16Scheme {
27 #[allow(dead_code)]
28 system: String,
29 #[allow(dead_code)]
30 name: String,
31 #[allow(dead_code)]
32 author: String,
33 #[allow(dead_code)]
34 variant: String,
35 palette: Base16Palette,
36}
37
38/// Base16 color palette with 16 standardized color slots.
39///
40/// Each base color serves a semantic purpose in the base16 specification:
41/// - base00-03: Background shades (darkest to lighter)
42/// - base04-07: Foreground shades (darker to lightest)
43/// - base08-0F: Accent colors (red, orange, yellow, green, cyan, blue, magenta, brown)
44#[derive(Debug, Clone, Deserialize)]
45#[allow(dead_code)]
46struct Base16Palette {
47 base00: String,
48 base01: String,
49 base02: String,
50 base03: String,
51 base04: String,
52 base05: String,
53 base06: String,
54 base07: String,
55 base08: String,
56 base09: String,
57 #[serde(rename = "base0A")]
58 base0a: String,
59 #[serde(rename = "base0B")]
60 base0b: String,
61 #[serde(rename = "base0C")]
62 base0c: String,
63 #[serde(rename = "base0D")]
64 base0d: String,
65 #[serde(rename = "base0E")]
66 base0e: String,
67 #[serde(rename = "base0F")]
68 base0f: String,
69}
70
71static CATPPUCCIN_LATTE: &str = include_str!("themes/catppuccin-latte.yml");
72static CATPPUCCIN_MOCHA: &str = include_str!("themes/catppuccin-mocha.yml");
73static GRUVBOX_MATERIAL_DARK: &str = include_str!("themes/gruvbox-material-dark-medium.yml");
74static GRUVBOX_MATERIAL_LIGHT: &str = include_str!("themes/gruvbox-material-light-medium.yml");
75static NORD_LIGHT: &str = include_str!("themes/nord-light.yml");
76static NORD: &str = include_str!("themes/nord.yml");
77static OXOCARBON_DARK: &str = include_str!("themes/oxocarbon-dark.yml");
78static OXOCARBON_LIGHT: &str = include_str!("themes/oxocarbon-light.yml");
79static SOLARIZED_DARK: &str = include_str!("themes/solarized-dark.yml");
80static SOLARIZED_LIGHT: &str = include_str!("themes/solarized-light.yml");
81
82/// RGB color value for use with both owo-colors and ratatui
83#[derive(Debug, Clone, Copy)]
84pub struct Color {
85 pub r: u8,
86 pub g: u8,
87 pub b: u8,
88}
89
90impl Color {
91 pub const fn new(r: u8, g: u8, b: u8) -> Self {
92 Self { r, g, b }
93 }
94
95 /// Apply this color to text using owo-colors
96 pub fn to_owo_color<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
97 text.style(self.into())
98 }
99}
100
101impl From<Color> for Style {
102 fn from(color: Color) -> Self {
103 Style::new().truecolor(color.r, color.g, color.b)
104 }
105}
106
107impl From<&Color> for Style {
108 fn from(color: &Color) -> Self {
109 Style::new().truecolor(color.r, color.g, color.b)
110 }
111}
112
113/// Detects if the terminal background is dark.
114///
115/// Uses [terminal_colorsaurus] to query the terminal background color.
116/// Defaults to true (dark) if detection fails.
117pub fn detect_is_dark() -> bool {
118 match background_color(QueryOptions::default()) {
119 Ok(color) => {
120 let r = color.r as f32 / 255.0;
121 let g = color.g as f32 / 255.0;
122 let b = color.b as f32 / 255.0;
123 let luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
124 luminance < 0.5
125 }
126 Err(_) => true,
127 }
128}
129
130/// Color theme abstraction for slides with semantic roles for consistent theming across the application.
131///
132/// Stores RGB colors that can be converted to both owo-colors Style (for terminal output)
133/// and ratatui Color (for TUI rendering) via Into implementations.
134#[derive(Debug, Clone)]
135pub struct ThemeColors {
136 pub heading: Color,
137 pub heading_bold: bool,
138 pub body: Color,
139 pub accent: Color,
140 pub code: Color,
141 pub dimmed: Color,
142 pub code_fence: Color,
143 pub rule: Color,
144 pub list_marker: Color,
145 pub blockquote_border: Color,
146 pub table_border: Color,
147 pub emphasis: Color,
148 pub strong: Color,
149 pub link: Color,
150 pub inline_code_bg: Color,
151 pub ui_border: Color,
152 pub ui_title: Color,
153 pub ui_text: Color,
154 pub ui_background: Color,
155}
156
157impl Default for ThemeColors {
158 fn default() -> Self {
159 let is_dark = detect_is_dark();
160 let theme_name = if is_dark { "nord" } else { "nord-light" };
161 ThemeRegistry::get(theme_name)
162 }
163}
164
165impl ThemeColors {
166 /// Create a ThemeColors from a base16 color scheme.
167 ///
168 /// Maps base16 colors to semantic theme roles following base16 styling guidelines:
169 ///
170 /// Content colors:
171 /// - base05: body text (main foreground)
172 /// - base0D: headings (blue - classes/functions)
173 /// - base0E: strong emphasis (magenta/purple - keywords)
174 /// - base0B: code blocks (green - strings)
175 /// - base03: dimmed/borders (comment color)
176 /// - base0A: list markers (yellow - classes/constants)
177 /// - base09: emphasis/italics (orange - integers/constants)
178 /// - base0C: links (cyan - support/regex)
179 /// - base08: accents (red - variables/tags)
180 /// - base02: inline code background (selection background)
181 ///
182 /// UI chrome colors:
183 /// - base00: UI background (darkest background)
184 /// - base04: UI borders (dim foreground)
185 /// - base06: UI titles (bright foreground)
186 /// - base07: UI text (brightest foreground)
187 fn from_base16(scheme: &Base16Scheme) -> Option<Self> {
188 let palette = &scheme.palette;
189
190 let heading = parse_hex_color(&palette.base0d)?;
191 let body = parse_hex_color(&palette.base05)?;
192 let accent = parse_hex_color(&palette.base08)?;
193 let code = parse_hex_color(&palette.base0b)?;
194 let dimmed = parse_hex_color(&palette.base03)?;
195 let code_fence = dimmed;
196 let rule = dimmed;
197 let list_marker = parse_hex_color(&palette.base0a)?;
198 let blockquote_border = dimmed;
199 let table_border = dimmed;
200 let emphasis = parse_hex_color(&palette.base09)?;
201 let strong = parse_hex_color(&palette.base0e)?;
202 let link = parse_hex_color(&palette.base0c)?;
203 let inline_code_bg = parse_hex_color(&palette.base02)?;
204 let ui_background = parse_hex_color(&palette.base00)?;
205 let ui_border = parse_hex_color(&palette.base04)?;
206 let ui_title = parse_hex_color(&palette.base06)?;
207 let ui_text = parse_hex_color(&palette.base07)?;
208
209 Some(Self {
210 heading: Color::new(heading.0, heading.1, heading.2),
211 heading_bold: true,
212 body: Color::new(body.0, body.1, body.2),
213 accent: Color::new(accent.0, accent.1, accent.2),
214 code: Color::new(code.0, code.1, code.2),
215 dimmed: Color::new(dimmed.0, dimmed.1, dimmed.2),
216 code_fence: Color::new(code_fence.0, code_fence.1, code_fence.2),
217 rule: Color::new(rule.0, rule.1, rule.2),
218 list_marker: Color::new(list_marker.0, list_marker.1, list_marker.2),
219 blockquote_border: Color::new(blockquote_border.0, blockquote_border.1, blockquote_border.2),
220 table_border: Color::new(table_border.0, table_border.1, table_border.2),
221 emphasis: Color::new(emphasis.0, emphasis.1, emphasis.2),
222 strong: Color::new(strong.0, strong.1, strong.2),
223 link: Color::new(link.0, link.1, link.2),
224 inline_code_bg: Color::new(inline_code_bg.0, inline_code_bg.1, inline_code_bg.2),
225 ui_border: Color::new(ui_border.0, ui_border.1, ui_border.2),
226 ui_title: Color::new(ui_title.0, ui_title.1, ui_title.2),
227 ui_text: Color::new(ui_text.0, ui_text.1, ui_text.2),
228 ui_background: Color::new(ui_background.0, ui_background.1, ui_background.2),
229 })
230 }
231
232 /// Apply heading style to text
233 pub fn heading<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
234 let mut style: Style = (&self.heading).into();
235 if self.heading_bold {
236 style = style.bold();
237 }
238 text.style(style)
239 }
240
241 /// Apply body style to text
242 pub fn body<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
243 text.style((&self.body).into())
244 }
245
246 /// Apply accent style to text
247 pub fn accent<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
248 text.style((&self.accent).into())
249 }
250
251 /// Apply code style to text
252 pub fn code<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
253 text.style((&self.code).into())
254 }
255
256 /// Apply dimmed style to text
257 pub fn dimmed<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
258 text.style((&self.dimmed).into())
259 }
260
261 /// Apply code fence style to text
262 pub fn code_fence<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
263 text.style((&self.code_fence).into())
264 }
265
266 /// Apply horizontal rule style to text
267 pub fn rule<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
268 text.style((&self.rule).into())
269 }
270
271 /// Apply list marker style to text
272 pub fn list_marker<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
273 text.style((&self.list_marker).into())
274 }
275
276 /// Apply blockquote border style to text
277 pub fn blockquote_border<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
278 text.style((&self.blockquote_border).into())
279 }
280
281 /// Apply table border style to text
282 pub fn table_border<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
283 text.style((&self.table_border).into())
284 }
285
286 /// Apply emphasis (italic) style to text
287 pub fn emphasis<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
288 text.style((&self.emphasis).into())
289 }
290
291 /// Apply strong (bold) style to text
292 pub fn strong<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
293 let style: Style = (&self.strong).into();
294 text.style(style.bold())
295 }
296
297 /// Apply link style to text
298 pub fn link<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
299 text.style((&self.link).into())
300 }
301
302 /// Apply inline code background style to text
303 pub fn inline_code_bg<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> {
304 text.style((&self.inline_code_bg).into())
305 }
306}
307
308/// Theme registry for loading prebuilt base16 themes from YAML files.
309///
310/// Themes are embedded at compile time using include_str! for zero runtime I/O.
311/// Supports all base16 color schemes in the themes directory.
312pub struct ThemeRegistry;
313
314impl ThemeRegistry {
315 /// Get a theme by name.
316 ///
317 /// Loads and parses the corresponding YAML theme file embedded at compile time.
318 /// Falls back to Nord theme if the requested theme is not found or parsing fails.
319 pub fn get(name: &str) -> ThemeColors {
320 let yaml = match name.to_lowercase().as_str() {
321 "catppuccin-latte" => CATPPUCCIN_LATTE,
322 "catppuccin-mocha" => CATPPUCCIN_MOCHA,
323 "gruvbox-material-dark" => GRUVBOX_MATERIAL_DARK,
324 "gruvbox-material-light" => GRUVBOX_MATERIAL_LIGHT,
325 "nord-light" => NORD_LIGHT,
326 "nord" => NORD,
327 "oxocarbon-dark" => OXOCARBON_DARK,
328 "oxocarbon-light" => OXOCARBON_LIGHT,
329 "solarized-dark" => SOLARIZED_DARK,
330 "solarized-light" => SOLARIZED_LIGHT,
331 _ => NORD,
332 };
333
334 serde_yml::from_str::<Base16Scheme>(yaml)
335 .ok()
336 .and_then(|scheme| ThemeColors::from_base16(&scheme))
337 .unwrap_or_else(|| {
338 serde_yml::from_str::<Base16Scheme>(NORD)
339 .ok()
340 .and_then(|scheme| ThemeColors::from_base16(&scheme))
341 .expect("Failed to parse fallback Nord theme")
342 })
343 }
344
345 /// List all available theme names.
346 pub fn available_themes() -> Vec<&'static str> {
347 vec![
348 "catppuccin-latte",
349 "catppuccin-mocha",
350 "gruvbox-material-dark",
351 "gruvbox-material-light",
352 "nord-light",
353 "nord",
354 "oxocarbon-dark",
355 "oxocarbon-light",
356 "solarized-dark",
357 "solarized-light",
358 ]
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 #[test]
367 fn parse_hex_color_with_hash() {
368 let result = parse_hex_color("#FF8040");
369 assert_eq!(result, Some((255, 128, 64)));
370 }
371
372 #[test]
373 fn parse_hex_color_without_hash() {
374 let result = parse_hex_color("FF8040");
375 assert_eq!(result, Some((255, 128, 64)));
376 }
377
378 #[test]
379 fn parse_hex_color_lowercase() {
380 let result = parse_hex_color("#ff8040");
381 assert_eq!(result, Some((255, 128, 64)));
382 }
383
384 #[test]
385 fn parse_hex_color_invalid_length() {
386 assert_eq!(parse_hex_color("#FFF"), None);
387 assert_eq!(parse_hex_color("#FFFFFFF"), None);
388 }
389
390 #[test]
391 fn parse_hex_color_invalid_chars() {
392 assert_eq!(parse_hex_color("#GGGGGG"), None);
393 assert_eq!(parse_hex_color("#XYZ123"), None);
394 }
395
396 #[test]
397 fn color_new() {
398 let color = Color::new(255, 128, 64);
399 assert_eq!(color.r, 255);
400 assert_eq!(color.g, 128);
401 assert_eq!(color.b, 64);
402 }
403
404 #[test]
405 fn color_into_style() {
406 let color = Color::new(100, 150, 200);
407 let style: Style = color.into();
408 let text = "Test";
409 let styled = text.style(style);
410 assert!(styled.to_string().contains("Test"));
411 }
412
413 #[test]
414 fn color_ref_into_style() {
415 let color = Color::new(100, 150, 200);
416 let style: Style = (&color).into();
417 let text = "Test";
418 let styled = text.style(style);
419 assert!(styled.to_string().contains("Test"));
420 }
421
422 #[test]
423 fn base16_scheme_deserializes() {
424 let yaml = r##"
425system: "base16"
426name: "Test Theme"
427author: "Test Author"
428variant: "dark"
429palette:
430 base00: "#000000"
431 base01: "#111111"
432 base02: "#222222"
433 base03: "#333333"
434 base04: "#444444"
435 base05: "#555555"
436 base06: "#666666"
437 base07: "#777777"
438 base08: "#888888"
439 base09: "#999999"
440 base0A: "#aaaaaa"
441 base0B: "#bbbbbb"
442 base0C: "#cccccc"
443 base0D: "#dddddd"
444 base0E: "#eeeeee"
445 base0F: "#ffffff"
446"##;
447 let scheme: Result<Base16Scheme, _> = serde_yml::from_str(yaml);
448 assert!(scheme.is_ok());
449 }
450
451 #[test]
452 fn theme_colors_from_base16() {
453 let yaml = r##"
454system: "base16"
455name: "Test Theme"
456author: "Test Author"
457variant: "dark"
458palette:
459 base00: "#000000"
460 base01: "#111111"
461 base02: "#222222"
462 base03: "#333333"
463 base04: "#444444"
464 base05: "#555555"
465 base06: "#666666"
466 base07: "#777777"
467 base08: "#ff0000"
468 base09: "#ff7f00"
469 base0A: "#ffff00"
470 base0B: "#00ff00"
471 base0C: "#00ffff"
472 base0D: "#0000ff"
473 base0E: "#ff00ff"
474 base0F: "#ffffff"
475"##;
476 let scheme: Base16Scheme = serde_yml::from_str(yaml).unwrap();
477 let theme = ThemeColors::from_base16(&scheme);
478 assert!(theme.is_some());
479
480 let theme = theme.unwrap();
481 assert_eq!(theme.body.r, 85); // base05 - #555555
482 assert_eq!(theme.heading.r, 0); // base0D - #0000ff
483 assert_eq!(theme.code.r, 0); // base0B - #00ff00
484 assert_eq!(theme.accent.r, 255); // base08 - #ff0000
485 assert_eq!(theme.emphasis.r, 255); // base09 - #ff7f00
486 assert_eq!(theme.strong.r, 255); // base0E - #ff00ff
487 assert_eq!(theme.link.r, 0); // base0C - #00ffff
488 assert_eq!(theme.inline_code_bg.r, 34); // base02 - #222222
489 assert_eq!(theme.ui_background.r, 0); // base00 - #000000
490 assert_eq!(theme.ui_border.r, 68); // base04 - #444444
491 assert_eq!(theme.ui_title.r, 102); // base06 - #666666
492 assert_eq!(theme.ui_text.r, 119); // base07 - #777777
493 }
494
495 #[test]
496 fn theme_colors_default() {
497 let theme = ThemeColors::default();
498 let text = "Test";
499 let heading = theme.heading(&text);
500 assert!(heading.to_string().contains("Test"));
501 }
502
503 #[test]
504 fn theme_colors_apply_styles() {
505 let theme = ThemeColors::default();
506
507 assert!(theme.heading(&"Heading").to_string().contains("Heading"));
508 assert!(theme.body(&"Body").to_string().contains("Body"));
509 assert!(theme.accent(&"Accent").to_string().contains("Accent"));
510 assert!(theme.code(&"Code").to_string().contains("Code"));
511 assert!(theme.dimmed(&"Dimmed").to_string().contains("Dimmed"));
512 }
513
514 #[test]
515 fn theme_registry_get_nord() {
516 let theme = ThemeRegistry::get("nord");
517 let text = "Test";
518 let styled = theme.heading(&text);
519 assert!(styled.to_string().contains("Test"));
520 assert_eq!(theme.heading.r, 129);
521 assert_eq!(theme.heading.g, 161);
522 assert_eq!(theme.heading.b, 193);
523 }
524
525 #[test]
526 fn theme_registry_get_nord_light() {
527 let theme = ThemeRegistry::get("nord-light");
528 let text = "Test";
529 let styled = theme.heading(&text);
530 assert!(styled.to_string().contains("Test"));
531 }
532
533 #[test]
534 fn theme_registry_get_catppuccin_mocha() {
535 let theme = ThemeRegistry::get("catppuccin-mocha");
536 let text = "Test";
537 let styled = theme.heading(&text);
538 assert!(styled.to_string().contains("Test"));
539 }
540
541 #[test]
542 fn theme_registry_get_catppuccin_latte() {
543 let theme = ThemeRegistry::get("catppuccin-latte");
544 let text = "Test";
545 let styled = theme.heading(&text);
546 assert!(styled.to_string().contains("Test"));
547 }
548
549 #[test]
550 fn theme_registry_get_gruvbox_dark() {
551 let theme = ThemeRegistry::get("gruvbox-material-dark");
552 let text = "Test";
553 let styled = theme.heading(&text);
554 assert!(styled.to_string().contains("Test"));
555 }
556
557 #[test]
558 fn theme_registry_get_gruvbox_light() {
559 let theme = ThemeRegistry::get("gruvbox-material-light");
560 let text = "Test";
561 let styled = theme.heading(&text);
562 assert!(styled.to_string().contains("Test"));
563 }
564
565 #[test]
566 fn theme_registry_get_oxocarbon_dark() {
567 let theme = ThemeRegistry::get("oxocarbon-dark");
568 let text = "Test";
569 let styled = theme.heading(&text);
570 assert!(styled.to_string().contains("Test"));
571 }
572
573 #[test]
574 fn theme_registry_get_oxocarbon_light() {
575 let theme = ThemeRegistry::get("oxocarbon-light");
576 let text = "Test";
577 let styled = theme.heading(&text);
578 assert!(styled.to_string().contains("Test"));
579 }
580
581 #[test]
582 fn theme_registry_get_solarized_dark() {
583 let theme = ThemeRegistry::get("solarized-dark");
584 let text = "Test";
585 let styled = theme.heading(&text);
586 assert!(styled.to_string().contains("Test"));
587 }
588
589 #[test]
590 fn theme_registry_get_solarized_light() {
591 let theme = ThemeRegistry::get("solarized-light");
592 let text = "Test";
593 let styled = theme.heading(&text);
594 assert!(styled.to_string().contains("Test"));
595 }
596
597 #[test]
598 fn theme_registry_get_unknown_fallback() {
599 let theme = ThemeRegistry::get("nonexistent");
600 let text = "Test";
601 let styled = theme.heading(&text);
602 assert!(styled.to_string().contains("Test"));
603 }
604
605 #[test]
606 fn theme_registry_case_insensitive() {
607 let theme1 = ThemeRegistry::get("NORD");
608 let theme2 = ThemeRegistry::get("nord");
609 let text = "Test";
610 assert!(theme1.heading(&text).to_string().contains("Test"));
611 assert!(theme2.heading(&text).to_string().contains("Test"));
612 }
613
614 #[test]
615 fn theme_registry_available_themes() {
616 let themes = ThemeRegistry::available_themes();
617 assert!(themes.contains(&"nord"));
618 assert!(themes.contains(&"nord-light"));
619 assert!(themes.contains(&"catppuccin-mocha"));
620 assert!(themes.contains(&"catppuccin-latte"));
621 assert!(themes.contains(&"gruvbox-material-dark"));
622 assert!(themes.contains(&"gruvbox-material-light"));
623 assert!(themes.contains(&"oxocarbon-dark"));
624 assert!(themes.contains(&"oxocarbon-light"));
625 assert!(themes.contains(&"solarized-dark"));
626 assert!(themes.contains(&"solarized-light"));
627 assert_eq!(themes.len(), 10);
628 }
629
630 #[test]
631 fn detect_is_dark_returns_bool() {
632 let result = detect_is_dark();
633 assert!(result || !result);
634 }
635
636 #[test]
637 fn theme_colors_all_semantic_roles() {
638 let theme = ThemeColors::default();
639
640 assert!(theme.heading(&"Test").to_string().contains("Test"));
641 assert!(theme.body(&"Test").to_string().contains("Test"));
642 assert!(theme.accent(&"Test").to_string().contains("Test"));
643 assert!(theme.code(&"Test").to_string().contains("Test"));
644 assert!(theme.dimmed(&"Test").to_string().contains("Test"));
645 assert!(theme.code_fence(&"Test").to_string().contains("Test"));
646 assert!(theme.rule(&"Test").to_string().contains("Test"));
647 assert!(theme.list_marker(&"Test").to_string().contains("Test"));
648 assert!(theme.blockquote_border(&"Test").to_string().contains("Test"));
649 assert!(theme.table_border(&"Test").to_string().contains("Test"));
650 assert!(theme.emphasis(&"Test").to_string().contains("Test"));
651 assert!(theme.strong(&"Test").to_string().contains("Test"));
652 assert!(theme.link(&"Test").to_string().contains("Test"));
653 assert!(theme.inline_code_bg(&"Test").to_string().contains("Test"));
654
655 // UI colors don't need style methods, just verify they exist
656 let _ = theme.ui_border;
657 let _ = theme.ui_title;
658 let _ = theme.ui_text;
659 let _ = theme.ui_background;
660 }
661
662 #[test]
663 fn all_embedded_themes_parse() {
664 for theme_name in ThemeRegistry::available_themes() {
665 let theme = ThemeRegistry::get(theme_name);
666 let styled = theme.heading(&"Test");
667 assert!(
668 styled.to_string().contains("Test"),
669 "Theme '{}' failed to parse or apply styles",
670 theme_name
671 );
672 }
673 }
674}