standout_render/style/
parser.rs1use std::collections::HashMap;
28
29use console::Style;
30
31use super::super::theme::ColorMode;
32use super::definition::StyleDefinition;
33use super::error::StylesheetError;
34use super::value::StyleValue;
35
36#[derive(Debug, Clone)]
57pub struct ThemeVariants {
58 base: HashMap<String, Style>,
60
61 light: HashMap<String, Style>,
63
64 dark: HashMap<String, Style>,
66
67 aliases: HashMap<String, String>,
69}
70
71impl ThemeVariants {
72 pub fn new() -> Self {
74 Self {
75 base: HashMap::new(),
76 light: HashMap::new(),
77 dark: HashMap::new(),
78 aliases: HashMap::new(),
79 }
80 }
81
82 pub fn resolve(&self, mode: Option<ColorMode>) -> HashMap<String, StyleValue> {
91 let mut result = HashMap::new();
92
93 for (name, target) in &self.aliases {
95 result.insert(name.clone(), StyleValue::Alias(target.clone()));
96 }
97
98 let mode_styles = match mode {
100 Some(ColorMode::Light) => &self.light,
101 Some(ColorMode::Dark) => &self.dark,
102 None => &HashMap::new(), };
104
105 for (name, style) in &self.base {
106 let style = mode_styles.get(name).unwrap_or(style);
108 result.insert(name.clone(), StyleValue::Concrete(style.clone()));
109 }
110
111 result
112 }
113
114 pub fn base(&self) -> &HashMap<String, Style> {
116 &self.base
117 }
118
119 pub fn light(&self) -> &HashMap<String, Style> {
121 &self.light
122 }
123
124 pub fn dark(&self) -> &HashMap<String, Style> {
126 &self.dark
127 }
128
129 pub fn aliases(&self) -> &HashMap<String, String> {
131 &self.aliases
132 }
133
134 pub fn is_empty(&self) -> bool {
136 self.base.is_empty() && self.aliases.is_empty()
137 }
138
139 pub fn len(&self) -> usize {
141 self.base.len() + self.aliases.len()
142 }
143}
144
145impl Default for ThemeVariants {
146 fn default() -> Self {
147 Self::new()
148 }
149}
150
151pub fn parse_stylesheet(yaml: &str) -> Result<ThemeVariants, StylesheetError> {
194 let root: serde_yaml::Value =
196 serde_yaml::from_str(yaml).map_err(|e| StylesheetError::Parse {
197 path: None,
198 message: e.to_string(),
199 })?;
200
201 let mapping = root.as_mapping().ok_or_else(|| StylesheetError::Parse {
202 path: None,
203 message: "Stylesheet must be a YAML mapping".to_string(),
204 })?;
205
206 let mut definitions: HashMap<String, StyleDefinition> = HashMap::new();
208
209 for (key, value) in mapping {
210 let name = key.as_str().ok_or_else(|| StylesheetError::Parse {
211 path: None,
212 message: format!("Style name must be a string, got {:?}", key),
213 })?;
214
215 let def = StyleDefinition::parse(value, name)?;
216 definitions.insert(name.to_string(), def);
217 }
218
219 build_variants(&definitions)
221}
222
223pub(crate) fn build_variants(
225 definitions: &HashMap<String, StyleDefinition>,
226) -> Result<ThemeVariants, StylesheetError> {
227 let mut variants = ThemeVariants::new();
228
229 for (name, def) in definitions {
230 match def {
231 StyleDefinition::Alias(target) => {
232 variants.aliases.insert(name.clone(), target.clone());
233 }
234 StyleDefinition::Attributes { base, light, dark } => {
235 let base_style = base.to_style();
237 variants.base.insert(name.clone(), base_style);
238
239 if let Some(light_attrs) = light {
241 let merged = base.merge(light_attrs);
242 variants.light.insert(name.clone(), merged.to_style());
243 }
244
245 if let Some(dark_attrs) = dark {
247 let merged = base.merge(dark_attrs);
248 variants.dark.insert(name.clone(), merged.to_style());
249 }
250 }
251 }
252 }
253
254 Ok(variants)
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
266 fn test_parse_empty_stylesheet() {
267 let yaml = "{}";
268 let variants = parse_stylesheet(yaml).unwrap();
269 assert!(variants.is_empty());
270 }
271
272 #[test]
273 fn test_parse_simple_style() {
274 let yaml = r#"
275 header:
276 fg: cyan
277 bold: true
278 "#;
279 let variants = parse_stylesheet(yaml).unwrap();
280
281 assert_eq!(variants.len(), 1);
282 assert!(variants.base().contains_key("header"));
283 assert!(variants.light().is_empty());
284 assert!(variants.dark().is_empty());
285 }
286
287 #[test]
288 fn test_parse_shorthand_style() {
289 let yaml = r#"
290 bold_text: bold
291 accent: cyan
292 warning: "yellow italic"
293 "#;
294 let variants = parse_stylesheet(yaml).unwrap();
295
296 assert_eq!(variants.base().len(), 3);
297 assert!(variants.base().contains_key("bold_text"));
298 assert!(variants.base().contains_key("accent"));
299 assert!(variants.base().contains_key("warning"));
300 }
301
302 #[test]
303 fn test_parse_alias() {
304 let yaml = r#"
305 muted:
306 dim: true
307 disabled: muted
308 "#;
309 let variants = parse_stylesheet(yaml).unwrap();
310
311 assert_eq!(variants.base().len(), 1);
312 assert_eq!(variants.aliases().len(), 1);
313 assert_eq!(
314 variants.aliases().get("disabled"),
315 Some(&"muted".to_string())
316 );
317 }
318
319 #[test]
320 fn test_parse_adaptive_style() {
321 let yaml = r#"
322 footer:
323 fg: gray
324 bold: true
325 light:
326 fg: black
327 dark:
328 fg: white
329 "#;
330 let variants = parse_stylesheet(yaml).unwrap();
331
332 assert!(variants.base().contains_key("footer"));
333 assert!(variants.light().contains_key("footer"));
334 assert!(variants.dark().contains_key("footer"));
335 }
336
337 #[test]
338 fn test_parse_light_only() {
339 let yaml = r#"
340 panel:
341 bg: gray
342 light:
343 bg: white
344 "#;
345 let variants = parse_stylesheet(yaml).unwrap();
346
347 assert!(variants.base().contains_key("panel"));
348 assert!(variants.light().contains_key("panel"));
349 assert!(!variants.dark().contains_key("panel"));
350 }
351
352 #[test]
353 fn test_parse_dark_only() {
354 let yaml = r#"
355 panel:
356 bg: gray
357 dark:
358 bg: black
359 "#;
360 let variants = parse_stylesheet(yaml).unwrap();
361
362 assert!(variants.base().contains_key("panel"));
363 assert!(!variants.light().contains_key("panel"));
364 assert!(variants.dark().contains_key("panel"));
365 }
366
367 #[test]
372 fn test_resolve_no_mode() {
373 let yaml = r#"
374 header:
375 fg: cyan
376 footer:
377 fg: gray
378 light:
379 fg: black
380 dark:
381 fg: white
382 "#;
383 let variants = parse_stylesheet(yaml).unwrap();
384 let resolved = variants.resolve(None);
385
386 assert!(matches!(
388 resolved.get("header"),
389 Some(StyleValue::Concrete(_))
390 ));
391 assert!(matches!(
392 resolved.get("footer"),
393 Some(StyleValue::Concrete(_))
394 ));
395 }
396
397 #[test]
398 fn test_resolve_light_mode() {
399 let yaml = r#"
400 footer:
401 fg: gray
402 light:
403 fg: black
404 dark:
405 fg: white
406 "#;
407 let variants = parse_stylesheet(yaml).unwrap();
408 let resolved = variants.resolve(Some(ColorMode::Light));
409
410 assert!(matches!(
412 resolved.get("footer"),
413 Some(StyleValue::Concrete(_))
414 ));
415 }
416
417 #[test]
418 fn test_resolve_dark_mode() {
419 let yaml = r#"
420 footer:
421 fg: gray
422 light:
423 fg: black
424 dark:
425 fg: white
426 "#;
427 let variants = parse_stylesheet(yaml).unwrap();
428 let resolved = variants.resolve(Some(ColorMode::Dark));
429
430 assert!(matches!(
432 resolved.get("footer"),
433 Some(StyleValue::Concrete(_))
434 ));
435 }
436
437 #[test]
438 fn test_resolve_preserves_aliases() {
439 let yaml = r#"
440 muted:
441 dim: true
442 disabled: muted
443 "#;
444 let variants = parse_stylesheet(yaml).unwrap();
445 let resolved = variants.resolve(Some(ColorMode::Light));
446
447 assert!(matches!(
449 resolved.get("muted"),
450 Some(StyleValue::Concrete(_))
451 ));
452 assert!(matches!(resolved.get("disabled"), Some(StyleValue::Alias(t)) if t == "muted"));
454 }
455
456 #[test]
457 fn test_resolve_non_adaptive_uses_base() {
458 let yaml = r#"
459 header:
460 fg: cyan
461 bold: true
462 "#;
463 let variants = parse_stylesheet(yaml).unwrap();
464
465 let light = variants.resolve(Some(ColorMode::Light));
467 assert!(matches!(light.get("header"), Some(StyleValue::Concrete(_))));
468
469 let dark = variants.resolve(Some(ColorMode::Dark));
471 assert!(matches!(dark.get("header"), Some(StyleValue::Concrete(_))));
472
473 let none = variants.resolve(None);
475 assert!(matches!(none.get("header"), Some(StyleValue::Concrete(_))));
476 }
477
478 #[test]
483 fn test_parse_invalid_yaml() {
484 let yaml = "not: [valid: yaml";
485 let result = parse_stylesheet(yaml);
486 assert!(matches!(result, Err(StylesheetError::Parse { .. })));
487 }
488
489 #[test]
490 fn test_parse_non_mapping_root() {
491 let yaml = "- item1\n- item2";
492 let result = parse_stylesheet(yaml);
493 assert!(matches!(result, Err(StylesheetError::Parse { .. })));
494 }
495
496 #[test]
497 fn test_parse_invalid_color() {
498 let yaml = r#"
499 bad:
500 fg: not_a_color
501 "#;
502 let result = parse_stylesheet(yaml);
503 assert!(result.is_err());
504 }
505
506 #[test]
507 fn test_parse_unknown_attribute() {
508 let yaml = r#"
509 bad:
510 unknown: true
511 "#;
512 let result = parse_stylesheet(yaml);
513 assert!(matches!(
514 result,
515 Err(StylesheetError::UnknownAttribute { .. })
516 ));
517 }
518
519 #[test]
524 fn test_parse_complete_stylesheet() {
525 let yaml = r##"
526 # Visual layer
527 muted:
528 dim: true
529
530 accent:
531 fg: cyan
532 bold: true
533
534 # Adaptive styles
535 background:
536 light:
537 bg: "#f8f8f8"
538 dark:
539 bg: "#1e1e1e"
540
541 text:
542 light:
543 fg: "#333333"
544 dark:
545 fg: "#d4d4d4"
546
547 border:
548 dim: true
549 light:
550 fg: "#cccccc"
551 dark:
552 fg: "#444444"
553
554 # Semantic layer - aliases
555 header: accent
556 footer: muted
557 timestamp: muted
558 title: accent
559 error: red
560 success: green
561 warning: "yellow bold"
562 "##;
563
564 let variants = parse_stylesheet(yaml).unwrap();
565
566 assert_eq!(variants.base().len(), 8);
570 assert_eq!(variants.aliases().len(), 4);
571
572 assert!(variants.light().contains_key("background"));
574 assert!(variants.light().contains_key("text"));
575 assert!(variants.light().contains_key("border"));
576 assert!(variants.dark().contains_key("background"));
577 assert!(variants.dark().contains_key("text"));
578 assert!(variants.dark().contains_key("border"));
579
580 assert_eq!(
582 variants.aliases().get("header"),
583 Some(&"accent".to_string())
584 );
585 assert_eq!(variants.aliases().get("footer"), Some(&"muted".to_string()));
586 }
587}