Skip to main content

standout_render/style/
attributes.rs

1//! Style attribute types and merging logic.
2//!
3//! This module defines [`StyleAttributes`], the intermediate representation of
4//! style properties parsed from YAML before conversion to `console::Style`.
5//!
6//! # Attribute Merging
7//!
8//! When resolving adaptive styles, mode-specific attributes merge onto base:
9//!
10//! ```yaml
11//! footer:
12//!   fg: gray        # Base
13//!   bold: true      # Shared
14//!   light:
15//!     fg: black     # Override in light mode
16//!   dark:
17//!     fg: white     # Override in dark mode
18//! ```
19//!
20//! The merge uses `Option<T>` semantics: `Some` values in the override replace
21//! base values, `None` values preserve the base.
22
23use console::Style;
24
25use crate::colorspace::ThemePalette;
26
27use super::color::ColorDef;
28use super::error::StylesheetError;
29
30/// Parsed style attributes from YAML.
31///
32/// All fields are optional to support both full definitions and partial overrides.
33/// When merging, `Some` values override, `None` values preserve the base.
34#[derive(Debug, Clone, Default, PartialEq, Eq)]
35pub struct StyleAttributes {
36    /// Foreground (text) color.
37    pub fg: Option<ColorDef>,
38    /// Background color.
39    pub bg: Option<ColorDef>,
40    /// Bold text.
41    pub bold: Option<bool>,
42    /// Dimmed/faded text.
43    pub dim: Option<bool>,
44    /// Italic text.
45    pub italic: Option<bool>,
46    /// Underlined text.
47    pub underline: Option<bool>,
48    /// Blinking text (limited terminal support).
49    pub blink: Option<bool>,
50    /// Swap fg/bg colors.
51    pub reverse: Option<bool>,
52    /// Hidden text.
53    pub hidden: Option<bool>,
54    /// Strikethrough text.
55    pub strikethrough: Option<bool>,
56}
57
58impl StyleAttributes {
59    /// Creates empty attributes (all None).
60    pub fn new() -> Self {
61        Self::default()
62    }
63
64    /// Parses attributes from a YAML mapping.
65    ///
66    /// Ignores `light` and `dark` keys (handled separately by the parser).
67    pub fn parse_mapping(
68        map: &serde_yaml::Mapping,
69        style_name: &str,
70    ) -> Result<Self, StylesheetError> {
71        let mut attrs = StyleAttributes::new();
72
73        for (key, value) in map {
74            let key_str = key
75                .as_str()
76                .ok_or_else(|| StylesheetError::InvalidDefinition {
77                    style: style_name.to_string(),
78                    message: format!("Non-string key in style definition: {:?}", key),
79                    path: None,
80                })?;
81
82            // Skip mode sections (handled by parent parser)
83            if key_str == "light" || key_str == "dark" {
84                continue;
85            }
86
87            attrs.set_attribute(key_str, value, style_name)?;
88        }
89
90        Ok(attrs)
91    }
92
93    /// Sets a single attribute from a YAML value.
94    fn set_attribute(
95        &mut self,
96        name: &str,
97        value: &serde_yaml::Value,
98        style_name: &str,
99    ) -> Result<(), StylesheetError> {
100        match name {
101            "fg" => {
102                self.fg = Some(ColorDef::parse_value(value).map_err(|e| {
103                    StylesheetError::InvalidColor {
104                        style: style_name.to_string(),
105                        value: e,
106                        path: None,
107                    }
108                })?);
109            }
110            "bg" => {
111                self.bg = Some(ColorDef::parse_value(value).map_err(|e| {
112                    StylesheetError::InvalidColor {
113                        style: style_name.to_string(),
114                        value: e,
115                        path: None,
116                    }
117                })?);
118            }
119            "bold" => {
120                self.bold = Some(parse_bool(value, name, style_name)?);
121            }
122            "dim" => {
123                self.dim = Some(parse_bool(value, name, style_name)?);
124            }
125            "italic" => {
126                self.italic = Some(parse_bool(value, name, style_name)?);
127            }
128            "underline" => {
129                self.underline = Some(parse_bool(value, name, style_name)?);
130            }
131            "blink" => {
132                self.blink = Some(parse_bool(value, name, style_name)?);
133            }
134            "reverse" => {
135                self.reverse = Some(parse_bool(value, name, style_name)?);
136            }
137            "hidden" => {
138                self.hidden = Some(parse_bool(value, name, style_name)?);
139            }
140            "strikethrough" => {
141                self.strikethrough = Some(parse_bool(value, name, style_name)?);
142            }
143            _ => {
144                return Err(StylesheetError::UnknownAttribute {
145                    style: style_name.to_string(),
146                    attribute: name.to_string(),
147                    path: None,
148                });
149            }
150        }
151
152        Ok(())
153    }
154
155    /// Merges another set of attributes onto this one.
156    ///
157    /// `Some` values in `other` override values in `self`.
158    /// `None` values in `other` preserve values in `self`.
159    ///
160    /// Returns a new `StyleAttributes` with the merged result.
161    pub fn merge(&self, other: &StyleAttributes) -> StyleAttributes {
162        StyleAttributes {
163            fg: other.fg.clone().or_else(|| self.fg.clone()),
164            bg: other.bg.clone().or_else(|| self.bg.clone()),
165            bold: other.bold.or(self.bold),
166            dim: other.dim.or(self.dim),
167            italic: other.italic.or(self.italic),
168            underline: other.underline.or(self.underline),
169            blink: other.blink.or(self.blink),
170            reverse: other.reverse.or(self.reverse),
171            hidden: other.hidden.or(self.hidden),
172            strikethrough: other.strikethrough.or(self.strikethrough),
173        }
174    }
175
176    /// Returns true if all attributes are None.
177    pub fn is_empty(&self) -> bool {
178        self.fg.is_none()
179            && self.bg.is_none()
180            && self.bold.is_none()
181            && self.dim.is_none()
182            && self.italic.is_none()
183            && self.underline.is_none()
184            && self.blink.is_none()
185            && self.reverse.is_none()
186            && self.hidden.is_none()
187            && self.strikethrough.is_none()
188    }
189
190    /// Converts these attributes to a `console::Style`.
191    ///
192    /// The optional [`ThemePalette`] is used to resolve [`ColorDef::Cube`] colors.
193    pub fn to_style(&self, palette: Option<&ThemePalette>) -> Style {
194        let mut style = Style::new();
195
196        if let Some(ref fg) = self.fg {
197            style = style.fg(fg.to_console_color(palette));
198        }
199        if let Some(ref bg) = self.bg {
200            style = style.bg(bg.to_console_color(palette));
201        }
202        if self.bold == Some(true) {
203            style = style.bold();
204        }
205        if self.dim == Some(true) {
206            style = style.dim();
207        }
208        if self.italic == Some(true) {
209            style = style.italic();
210        }
211        if self.underline == Some(true) {
212            style = style.underlined();
213        }
214        if self.blink == Some(true) {
215            style = style.blink();
216        }
217        if self.reverse == Some(true) {
218            style = style.reverse();
219        }
220        if self.hidden == Some(true) {
221            style = style.hidden();
222        }
223        if self.strikethrough == Some(true) {
224            style = style.strikethrough();
225        }
226
227        style
228    }
229}
230
231/// Parses a boolean value from YAML.
232fn parse_bool(
233    value: &serde_yaml::Value,
234    attr: &str,
235    style_name: &str,
236) -> Result<bool, StylesheetError> {
237    value
238        .as_bool()
239        .ok_or_else(|| StylesheetError::InvalidDefinition {
240            style: style_name.to_string(),
241            message: format!("'{}' must be a boolean, got {:?}", attr, value),
242            path: None,
243        })
244}
245
246/// Parses a shorthand string into attributes.
247///
248/// Shorthand format: space-separated attribute names and/or a color.
249///
250/// Examples:
251/// - `"bold"` → bold: true
252/// - `"cyan"` → fg: cyan
253/// - `"cyan bold"` → fg: cyan, bold: true
254/// - `"yellow italic"` → fg: yellow, italic: true
255pub fn parse_shorthand(s: &str, style_name: &str) -> Result<StyleAttributes, StylesheetError> {
256    let mut attrs = StyleAttributes::new();
257
258    // Split by comma or whitespace
259    let parts: Vec<&str> = s
260        .split(|c: char| c == ',' || c.is_whitespace())
261        .filter(|s| !s.is_empty())
262        .collect();
263
264    for part in parts {
265        match part.to_lowercase().as_str() {
266            "bold" => attrs.bold = Some(true),
267            "dim" => attrs.dim = Some(true),
268            "italic" => attrs.italic = Some(true),
269            "underline" => attrs.underline = Some(true),
270            "blink" => attrs.blink = Some(true),
271            "reverse" => attrs.reverse = Some(true),
272            "hidden" => attrs.hidden = Some(true),
273            "strikethrough" => attrs.strikethrough = Some(true),
274            // If not a known attribute, try as a color
275            _ => {
276                if attrs.fg.is_some() {
277                    return Err(StylesheetError::InvalidShorthand {
278                        style: style_name.to_string(),
279                        value: format!(
280                            "Multiple colors in shorthand: already have fg, got '{}'",
281                            part
282                        ),
283                        path: None,
284                    });
285                }
286                attrs.fg = Some(ColorDef::parse_string(part).map_err(|e| {
287                    StylesheetError::InvalidShorthand {
288                        style: style_name.to_string(),
289                        value: e,
290                        path: None,
291                    }
292                })?);
293            }
294        }
295    }
296
297    if attrs.is_empty() {
298        return Err(StylesheetError::InvalidShorthand {
299            style: style_name.to_string(),
300            value: format!("Empty or invalid shorthand: '{}'", s),
301            path: None,
302        });
303    }
304
305    Ok(attrs)
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use console::Color;
312    use serde_yaml::{Mapping, Value};
313
314    // =========================================================================
315    // StyleAttributes::parse_mapping tests
316    // =========================================================================
317
318    #[test]
319    fn test_parse_mapping_fg_only() {
320        let mut map = Mapping::new();
321        map.insert(Value::String("fg".into()), Value::String("red".into()));
322
323        let attrs = StyleAttributes::parse_mapping(&map, "test").unwrap();
324        assert_eq!(attrs.fg, Some(ColorDef::Named(Color::Red)));
325        assert!(attrs.bg.is_none());
326        assert!(attrs.bold.is_none());
327    }
328
329    #[test]
330    fn test_parse_mapping_full() {
331        let mut map = Mapping::new();
332        map.insert(Value::String("fg".into()), Value::String("cyan".into()));
333        map.insert(Value::String("bg".into()), Value::String("black".into()));
334        map.insert(Value::String("bold".into()), Value::Bool(true));
335        map.insert(Value::String("dim".into()), Value::Bool(false));
336        map.insert(Value::String("italic".into()), Value::Bool(true));
337
338        let attrs = StyleAttributes::parse_mapping(&map, "test").unwrap();
339        assert_eq!(attrs.fg, Some(ColorDef::Named(Color::Cyan)));
340        assert_eq!(attrs.bg, Some(ColorDef::Named(Color::Black)));
341        assert_eq!(attrs.bold, Some(true));
342        assert_eq!(attrs.dim, Some(false));
343        assert_eq!(attrs.italic, Some(true));
344    }
345
346    #[test]
347    fn test_parse_mapping_ignores_light_dark() {
348        let mut map = Mapping::new();
349        map.insert(Value::String("fg".into()), Value::String("red".into()));
350        map.insert(
351            Value::String("light".into()),
352            Value::Mapping(Mapping::new()),
353        );
354        map.insert(Value::String("dark".into()), Value::Mapping(Mapping::new()));
355
356        let attrs = StyleAttributes::parse_mapping(&map, "test").unwrap();
357        assert_eq!(attrs.fg, Some(ColorDef::Named(Color::Red)));
358        // light and dark should be ignored, not cause errors
359    }
360
361    #[test]
362    fn test_parse_mapping_unknown_attribute() {
363        let mut map = Mapping::new();
364        map.insert(
365            Value::String("unknown".into()),
366            Value::String("value".into()),
367        );
368
369        let result = StyleAttributes::parse_mapping(&map, "test");
370        assert!(matches!(
371            result,
372            Err(StylesheetError::UnknownAttribute { attribute, .. }) if attribute == "unknown"
373        ));
374    }
375
376    #[test]
377    fn test_parse_mapping_hex_color() {
378        let mut map = Mapping::new();
379        map.insert(Value::String("fg".into()), Value::String("#ff6b35".into()));
380
381        let attrs = StyleAttributes::parse_mapping(&map, "test").unwrap();
382        assert_eq!(attrs.fg, Some(ColorDef::Rgb(255, 107, 53)));
383    }
384
385    // =========================================================================
386    // StyleAttributes::merge tests
387    // =========================================================================
388
389    #[test]
390    fn test_merge_empty_onto_full() {
391        let base = StyleAttributes {
392            fg: Some(ColorDef::Named(Color::Red)),
393            bold: Some(true),
394            ..Default::default()
395        };
396        let empty = StyleAttributes::new();
397
398        let merged = base.merge(&empty);
399        assert_eq!(merged.fg, Some(ColorDef::Named(Color::Red)));
400        assert_eq!(merged.bold, Some(true));
401    }
402
403    #[test]
404    fn test_merge_full_onto_empty() {
405        let empty = StyleAttributes::new();
406        let full = StyleAttributes {
407            fg: Some(ColorDef::Named(Color::Blue)),
408            italic: Some(true),
409            ..Default::default()
410        };
411
412        let merged = empty.merge(&full);
413        assert_eq!(merged.fg, Some(ColorDef::Named(Color::Blue)));
414        assert_eq!(merged.italic, Some(true));
415    }
416
417    #[test]
418    fn test_merge_override() {
419        let base = StyleAttributes {
420            fg: Some(ColorDef::Named(Color::Red)),
421            bold: Some(true),
422            ..Default::default()
423        };
424        let override_attrs = StyleAttributes {
425            fg: Some(ColorDef::Named(Color::Blue)),
426            ..Default::default()
427        };
428
429        let merged = base.merge(&override_attrs);
430        // fg overridden
431        assert_eq!(merged.fg, Some(ColorDef::Named(Color::Blue)));
432        // bold preserved
433        assert_eq!(merged.bold, Some(true));
434    }
435
436    #[test]
437    fn test_merge_preserves_unset() {
438        let base = StyleAttributes {
439            fg: Some(ColorDef::Named(Color::Red)),
440            bg: Some(ColorDef::Named(Color::White)),
441            bold: Some(true),
442            dim: Some(true),
443            ..Default::default()
444        };
445        let override_attrs = StyleAttributes {
446            fg: Some(ColorDef::Named(Color::Blue)),
447            bold: Some(false),
448            ..Default::default()
449        };
450
451        let merged = base.merge(&override_attrs);
452        assert_eq!(merged.fg, Some(ColorDef::Named(Color::Blue))); // overridden
453        assert_eq!(merged.bg, Some(ColorDef::Named(Color::White))); // preserved
454        assert_eq!(merged.bold, Some(false)); // overridden
455        assert_eq!(merged.dim, Some(true)); // preserved
456    }
457
458    // =========================================================================
459    // StyleAttributes::to_style tests
460    // =========================================================================
461
462    #[test]
463    fn test_to_style_empty() {
464        let attrs = StyleAttributes::new();
465        let style = attrs.to_style(None);
466        // Empty style - hard to test directly, but should not panic
467        let _ = style.apply_to("test");
468    }
469
470    #[test]
471    fn test_to_style_with_attributes() {
472        let attrs = StyleAttributes {
473            fg: Some(ColorDef::Named(Color::Red)),
474            bold: Some(true),
475            italic: Some(true),
476            ..Default::default()
477        };
478        let style = attrs.to_style(None).force_styling(true);
479        let output = style.apply_to("test").to_string();
480        // Should contain ANSI codes
481        assert!(output.contains("\x1b["));
482        assert!(output.contains("test"));
483    }
484
485    // =========================================================================
486    // parse_shorthand tests
487    // =========================================================================
488
489    #[test]
490    fn test_parse_shorthand_single_attribute() {
491        let attrs = parse_shorthand("bold", "test").unwrap();
492        assert_eq!(attrs.bold, Some(true));
493        assert!(attrs.fg.is_none());
494    }
495
496    #[test]
497    fn test_parse_shorthand_single_color() {
498        let attrs = parse_shorthand("cyan", "test").unwrap();
499        assert_eq!(attrs.fg, Some(ColorDef::Named(Color::Cyan)));
500        assert!(attrs.bold.is_none());
501    }
502
503    #[test]
504    fn test_parse_shorthand_color_and_attribute() {
505        let attrs = parse_shorthand("cyan bold", "test").unwrap();
506        assert_eq!(attrs.fg, Some(ColorDef::Named(Color::Cyan)));
507        assert_eq!(attrs.bold, Some(true));
508    }
509
510    #[test]
511    fn test_parse_shorthand_multiple_attributes() {
512        let attrs = parse_shorthand("bold italic underline", "test").unwrap();
513        assert_eq!(attrs.bold, Some(true));
514        assert_eq!(attrs.italic, Some(true));
515        assert_eq!(attrs.underline, Some(true));
516        assert!(attrs.fg.is_none());
517    }
518
519    #[test]
520    fn test_parse_shorthand_color_with_multiple_attributes() {
521        let attrs = parse_shorthand("yellow bold italic", "test").unwrap();
522        assert_eq!(attrs.fg, Some(ColorDef::Named(Color::Yellow)));
523        assert_eq!(attrs.bold, Some(true));
524        assert_eq!(attrs.italic, Some(true));
525    }
526
527    #[test]
528    fn test_parse_shorthand_multiple_colors_error() {
529        let result = parse_shorthand("red blue", "test");
530        assert!(matches!(
531            result,
532            Err(StylesheetError::InvalidShorthand { .. })
533        ));
534    }
535
536    #[test]
537    fn test_parse_shorthand_empty_error() {
538        let result = parse_shorthand("", "test");
539        assert!(matches!(
540            result,
541            Err(StylesheetError::InvalidShorthand { .. })
542        ));
543    }
544
545    #[test]
546    fn test_parse_shorthand_invalid_token_error() {
547        let result = parse_shorthand("boldx", "test");
548        assert!(matches!(
549            result,
550            Err(StylesheetError::InvalidShorthand { .. })
551        ));
552    }
553
554    #[test]
555    fn test_parse_shorthand_case_insensitive() {
556        let attrs = parse_shorthand("BOLD ITALIC", "test").unwrap();
557        assert_eq!(attrs.bold, Some(true));
558        assert_eq!(attrs.italic, Some(true));
559    }
560
561    #[test]
562    fn test_parse_shorthand_comma_separated() {
563        let attrs = parse_shorthand("bold, italic, cyan", "test").unwrap();
564        assert_eq!(attrs.bold, Some(true));
565        assert_eq!(attrs.italic, Some(true));
566        assert_eq!(attrs.fg, Some(ColorDef::Named(Color::Cyan)));
567    }
568
569    #[test]
570    fn test_parse_shorthand_mixed_separators() {
571        let attrs = parse_shorthand("bold, italic underline", "test").unwrap();
572        assert_eq!(attrs.bold, Some(true));
573        assert_eq!(attrs.italic, Some(true));
574        assert_eq!(attrs.underline, Some(true));
575    }
576
577    // =========================================================================
578    // StyleAttributes::is_empty tests
579    // =========================================================================
580
581    #[test]
582    fn test_is_empty_true() {
583        assert!(StyleAttributes::new().is_empty());
584    }
585
586    #[test]
587    fn test_is_empty_false() {
588        let attrs = StyleAttributes {
589            bold: Some(true),
590            ..Default::default()
591        };
592        assert!(!attrs.is_empty());
593    }
594}