1use std::collections::HashMap;
28
29use console::Style;
30
31use crate::colorspace::ThemePalette;
32
33use super::super::theme::ColorMode;
34use super::definition::StyleDefinition;
35use super::error::StylesheetError;
36use super::value::StyleValue;
37
38#[derive(Debug, Clone)]
59pub struct ThemeVariants {
60 base: HashMap<String, Style>,
62
63 light: HashMap<String, Style>,
65
66 dark: HashMap<String, Style>,
68
69 aliases: HashMap<String, String>,
71}
72
73impl ThemeVariants {
74 pub fn new() -> Self {
76 Self {
77 base: HashMap::new(),
78 light: HashMap::new(),
79 dark: HashMap::new(),
80 aliases: HashMap::new(),
81 }
82 }
83
84 pub fn resolve(&self, mode: Option<ColorMode>) -> HashMap<String, StyleValue> {
93 let mut result = HashMap::new();
94
95 for (name, target) in &self.aliases {
97 result.insert(name.clone(), StyleValue::Alias(target.clone()));
98 }
99
100 let mode_styles = match mode {
102 Some(ColorMode::Light) => &self.light,
103 Some(ColorMode::Dark) => &self.dark,
104 None => &HashMap::new(), };
106
107 for (name, style) in &self.base {
108 let style = mode_styles.get(name).unwrap_or(style);
110 result.insert(name.clone(), StyleValue::Concrete(style.clone()));
111 }
112
113 result
114 }
115
116 pub fn base(&self) -> &HashMap<String, Style> {
118 &self.base
119 }
120
121 pub fn light(&self) -> &HashMap<String, Style> {
123 &self.light
124 }
125
126 pub fn dark(&self) -> &HashMap<String, Style> {
128 &self.dark
129 }
130
131 pub fn aliases(&self) -> &HashMap<String, String> {
133 &self.aliases
134 }
135
136 pub fn is_empty(&self) -> bool {
138 self.base.is_empty() && self.aliases.is_empty()
139 }
140
141 pub fn len(&self) -> usize {
143 self.base.len() + self.aliases.len()
144 }
145}
146
147impl Default for ThemeVariants {
148 fn default() -> Self {
149 Self::new()
150 }
151}
152
153pub fn parse_stylesheet(
196 yaml: &str,
197 palette: Option<&ThemePalette>,
198) -> Result<ThemeVariants, StylesheetError> {
199 let root: serde_yaml::Value =
201 serde_yaml::from_str(yaml).map_err(|e| StylesheetError::Parse {
202 path: None,
203 message: e.to_string(),
204 })?;
205
206 let mapping = root.as_mapping().ok_or_else(|| StylesheetError::Parse {
207 path: None,
208 message: "Stylesheet must be a YAML mapping".to_string(),
209 })?;
210
211 let mut definitions: HashMap<String, StyleDefinition> = HashMap::new();
213
214 for (key, value) in mapping {
215 let name = key.as_str().ok_or_else(|| StylesheetError::Parse {
216 path: None,
217 message: format!("Style name must be a string, got {:?}", key),
218 })?;
219
220 if name == "icons" {
222 continue;
223 }
224
225 let def = StyleDefinition::parse(value, name)?;
226 definitions.insert(name.to_string(), def);
227 }
228
229 build_variants(&definitions, palette)
231}
232
233pub(crate) fn build_variants(
235 definitions: &HashMap<String, StyleDefinition>,
236 palette: Option<&ThemePalette>,
237) -> Result<ThemeVariants, StylesheetError> {
238 let mut variants = ThemeVariants::new();
239
240 for (name, def) in definitions {
241 match def {
242 StyleDefinition::Alias(target) => {
243 variants.aliases.insert(name.clone(), target.clone());
244 }
245 StyleDefinition::Attributes { base, light, dark } => {
246 let base_style = base.to_style(palette);
248 variants.base.insert(name.clone(), base_style);
249
250 if let Some(light_attrs) = light {
252 let merged = base.merge(light_attrs);
253 variants
254 .light
255 .insert(name.clone(), merged.to_style(palette));
256 }
257
258 if let Some(dark_attrs) = dark {
260 let merged = base.merge(dark_attrs);
261 variants.dark.insert(name.clone(), merged.to_style(palette));
262 }
263 }
264 }
265 }
266
267 Ok(variants)
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 #[test]
279 fn test_parse_empty_stylesheet() {
280 let yaml = "{}";
281 let variants = parse_stylesheet(yaml, None).unwrap();
282 assert!(variants.is_empty());
283 }
284
285 #[test]
286 fn test_parse_simple_style() {
287 let yaml = r#"
288 header:
289 fg: cyan
290 bold: true
291 "#;
292 let variants = parse_stylesheet(yaml, None).unwrap();
293
294 assert_eq!(variants.len(), 1);
295 assert!(variants.base().contains_key("header"));
296 assert!(variants.light().is_empty());
297 assert!(variants.dark().is_empty());
298 }
299
300 #[test]
301 fn test_parse_shorthand_style() {
302 let yaml = r#"
303 bold_text: bold
304 accent: cyan
305 warning: "yellow italic"
306 "#;
307 let variants = parse_stylesheet(yaml, None).unwrap();
308
309 assert_eq!(variants.base().len(), 3);
310 assert!(variants.base().contains_key("bold_text"));
311 assert!(variants.base().contains_key("accent"));
312 assert!(variants.base().contains_key("warning"));
313 }
314
315 #[test]
316 fn test_parse_alias() {
317 let yaml = r#"
318 muted:
319 dim: true
320 disabled: muted
321 "#;
322 let variants = parse_stylesheet(yaml, None).unwrap();
323
324 assert_eq!(variants.base().len(), 1);
325 assert_eq!(variants.aliases().len(), 1);
326 assert_eq!(
327 variants.aliases().get("disabled"),
328 Some(&"muted".to_string())
329 );
330 }
331
332 #[test]
333 fn test_parse_adaptive_style() {
334 let yaml = r#"
335 footer:
336 fg: gray
337 bold: true
338 light:
339 fg: black
340 dark:
341 fg: white
342 "#;
343 let variants = parse_stylesheet(yaml, None).unwrap();
344
345 assert!(variants.base().contains_key("footer"));
346 assert!(variants.light().contains_key("footer"));
347 assert!(variants.dark().contains_key("footer"));
348 }
349
350 #[test]
351 fn test_parse_light_only() {
352 let yaml = r#"
353 panel:
354 bg: gray
355 light:
356 bg: white
357 "#;
358 let variants = parse_stylesheet(yaml, None).unwrap();
359
360 assert!(variants.base().contains_key("panel"));
361 assert!(variants.light().contains_key("panel"));
362 assert!(!variants.dark().contains_key("panel"));
363 }
364
365 #[test]
366 fn test_parse_dark_only() {
367 let yaml = r#"
368 panel:
369 bg: gray
370 dark:
371 bg: black
372 "#;
373 let variants = parse_stylesheet(yaml, None).unwrap();
374
375 assert!(variants.base().contains_key("panel"));
376 assert!(!variants.light().contains_key("panel"));
377 assert!(variants.dark().contains_key("panel"));
378 }
379
380 #[test]
385 fn test_resolve_no_mode() {
386 let yaml = r#"
387 header:
388 fg: cyan
389 footer:
390 fg: gray
391 light:
392 fg: black
393 dark:
394 fg: white
395 "#;
396 let variants = parse_stylesheet(yaml, None).unwrap();
397 let resolved = variants.resolve(None);
398
399 assert!(matches!(
401 resolved.get("header"),
402 Some(StyleValue::Concrete(_))
403 ));
404 assert!(matches!(
405 resolved.get("footer"),
406 Some(StyleValue::Concrete(_))
407 ));
408 }
409
410 #[test]
411 fn test_resolve_light_mode() {
412 let yaml = r#"
413 footer:
414 fg: gray
415 light:
416 fg: black
417 dark:
418 fg: white
419 "#;
420 let variants = parse_stylesheet(yaml, None).unwrap();
421 let resolved = variants.resolve(Some(ColorMode::Light));
422
423 assert!(matches!(
425 resolved.get("footer"),
426 Some(StyleValue::Concrete(_))
427 ));
428 }
429
430 #[test]
431 fn test_resolve_dark_mode() {
432 let yaml = r#"
433 footer:
434 fg: gray
435 light:
436 fg: black
437 dark:
438 fg: white
439 "#;
440 let variants = parse_stylesheet(yaml, None).unwrap();
441 let resolved = variants.resolve(Some(ColorMode::Dark));
442
443 assert!(matches!(
445 resolved.get("footer"),
446 Some(StyleValue::Concrete(_))
447 ));
448 }
449
450 #[test]
451 fn test_resolve_preserves_aliases() {
452 let yaml = r#"
453 muted:
454 dim: true
455 disabled: muted
456 "#;
457 let variants = parse_stylesheet(yaml, None).unwrap();
458 let resolved = variants.resolve(Some(ColorMode::Light));
459
460 assert!(matches!(
462 resolved.get("muted"),
463 Some(StyleValue::Concrete(_))
464 ));
465 assert!(matches!(resolved.get("disabled"), Some(StyleValue::Alias(t)) if t == "muted"));
467 }
468
469 #[test]
470 fn test_resolve_non_adaptive_uses_base() {
471 let yaml = r#"
472 header:
473 fg: cyan
474 bold: true
475 "#;
476 let variants = parse_stylesheet(yaml, None).unwrap();
477
478 let light = variants.resolve(Some(ColorMode::Light));
480 assert!(matches!(light.get("header"), Some(StyleValue::Concrete(_))));
481
482 let dark = variants.resolve(Some(ColorMode::Dark));
484 assert!(matches!(dark.get("header"), Some(StyleValue::Concrete(_))));
485
486 let none = variants.resolve(None);
488 assert!(matches!(none.get("header"), Some(StyleValue::Concrete(_))));
489 }
490
491 #[test]
496 fn test_parse_invalid_yaml() {
497 let yaml = "not: [valid: yaml";
498 let result = parse_stylesheet(yaml, None);
499 assert!(matches!(result, Err(StylesheetError::Parse { .. })));
500 }
501
502 #[test]
503 fn test_parse_non_mapping_root() {
504 let yaml = "- item1\n- item2";
505 let result = parse_stylesheet(yaml, None);
506 assert!(matches!(result, Err(StylesheetError::Parse { .. })));
507 }
508
509 #[test]
510 fn test_parse_invalid_color() {
511 let yaml = r#"
512 bad:
513 fg: not_a_color
514 "#;
515 let result = parse_stylesheet(yaml, None);
516 assert!(result.is_err());
517 }
518
519 #[test]
520 fn test_parse_unknown_attribute() {
521 let yaml = r#"
522 bad:
523 unknown: true
524 "#;
525 let result = parse_stylesheet(yaml, None);
526 assert!(matches!(
527 result,
528 Err(StylesheetError::UnknownAttribute { .. })
529 ));
530 }
531
532 #[test]
537 fn test_parse_complete_stylesheet() {
538 let yaml = r##"
539 # Visual layer
540 muted:
541 dim: true
542
543 accent:
544 fg: cyan
545 bold: true
546
547 # Adaptive styles
548 background:
549 light:
550 bg: "#f8f8f8"
551 dark:
552 bg: "#1e1e1e"
553
554 text:
555 light:
556 fg: "#333333"
557 dark:
558 fg: "#d4d4d4"
559
560 border:
561 dim: true
562 light:
563 fg: "#cccccc"
564 dark:
565 fg: "#444444"
566
567 # Semantic layer - aliases
568 header: accent
569 footer: muted
570 timestamp: muted
571 title: accent
572 error: red
573 success: green
574 warning: "yellow bold"
575 "##;
576
577 let variants = parse_stylesheet(yaml, None).unwrap();
578
579 assert_eq!(variants.base().len(), 8);
583 assert_eq!(variants.aliases().len(), 4);
584
585 assert!(variants.light().contains_key("background"));
587 assert!(variants.light().contains_key("text"));
588 assert!(variants.light().contains_key("border"));
589 assert!(variants.dark().contains_key("background"));
590 assert!(variants.dark().contains_key("text"));
591 assert!(variants.dark().contains_key("border"));
592
593 assert_eq!(
595 variants.aliases().get("header"),
596 Some(&"accent".to_string())
597 );
598 assert_eq!(variants.aliases().get("footer"), Some(&"muted".to_string()));
599 }
600
601 #[test]
606 fn test_parse_cube_color_in_stylesheet() {
607 let yaml = r#"
608 theme_accent:
609 fg: "cube(60%, 20%, 0%)"
610 bold: true
611 "#;
612 let variants = parse_stylesheet(yaml, None).unwrap();
613 assert!(variants.base().contains_key("theme_accent"));
614 }
615
616 #[test]
617 fn test_parse_cube_color_with_palette() {
618 use crate::colorspace::{Rgb, ThemePalette};
619
620 let palette = ThemePalette::new([
621 Rgb(40, 40, 40),
622 Rgb(204, 36, 29),
623 Rgb(152, 151, 26),
624 Rgb(215, 153, 33),
625 Rgb(69, 133, 136),
626 Rgb(177, 98, 134),
627 Rgb(104, 157, 106),
628 Rgb(168, 153, 132),
629 ]);
630
631 let yaml = r#"
632 warm:
633 fg: "cube(80%, 30%, 0%)"
634 cool:
635 fg: "cube(0%, 0%, 80%)"
636 "#;
637 let variants = parse_stylesheet(yaml, Some(&palette)).unwrap();
638 assert!(variants.base().contains_key("warm"));
639 assert!(variants.base().contains_key("cool"));
640 }
641
642 #[test]
643 fn test_parse_cube_color_adaptive() {
644 let yaml = r#"
645 panel:
646 fg: "cube(50%, 50%, 50%)"
647 light:
648 fg: "cube(20%, 20%, 20%)"
649 dark:
650 fg: "cube(80%, 80%, 80%)"
651 "#;
652 let variants = parse_stylesheet(yaml, None).unwrap();
653 assert!(variants.base().contains_key("panel"));
654 assert!(variants.light().contains_key("panel"));
655 assert!(variants.dark().contains_key("panel"));
656 }
657}