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