Skip to main content

standout_render/style/
color.rs

1//! Color value parsing for stylesheets.
2//!
3//! Supports multiple color formats:
4//!
5//! - Named colors: `red`, `green`, `blue`, etc. (16 ANSI colors)
6//! - Bright variants: `bright_red`, `bright_green`, etc.
7//! - 256-color palette: `0` through `255`
8//! - RGB hex: `"#ff6b35"` or `"#fff"` (3 or 6 digit)
9//! - RGB tuple: `[255, 107, 53]`
10//! - Cube coordinates: `cube(60%, 20%, 0%)` (theme-relative color)
11//!
12//! # Example
13//!
14//! ```rust
15//! use standout_render::style::ColorDef;
16//!
17//! // Parse from YAML values
18//! let red = ColorDef::parse_value(&serde_yaml::Value::String("red".into())).unwrap();
19//! let hex = ColorDef::parse_value(&serde_yaml::Value::String("#ff6b35".into())).unwrap();
20//! let palette = ColorDef::parse_value(&serde_yaml::Value::Number(208.into())).unwrap();
21//! let rgb = ColorDef::parse_value(&serde_yaml::Value::Sequence(vec![
22//!     serde_yaml::Value::Number(255.into()),
23//!     serde_yaml::Value::Number(107.into()),
24//!     serde_yaml::Value::Number(53.into()),
25//! ])).unwrap();
26//!
27//! // Parse cube coordinate
28//! let cube = ColorDef::parse_string("cube(60%, 20%, 0%)").unwrap();
29//! ```
30
31use console::Color;
32
33use crate::colorspace::{CubeCoord, ThemePalette};
34
35/// Parsed color definition from stylesheet.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum ColorDef {
38    /// Named ANSI color.
39    Named(Color),
40    /// 256-color palette index.
41    Color256(u8),
42    /// True color RGB.
43    Rgb(u8, u8, u8),
44    /// Theme-relative cube coordinate, resolved via [`ThemePalette`] at style build time.
45    Cube(CubeCoord),
46}
47
48impl ColorDef {
49    /// Parses a color definition from a YAML value.
50    ///
51    /// Supports:
52    /// - Strings: named colors, bright variants, hex codes
53    /// - Numbers: 256-color palette indices
54    /// - Sequences: RGB tuples `[r, g, b]`
55    pub fn parse_value(value: &serde_yaml::Value) -> Result<Self, String> {
56        match value {
57            serde_yaml::Value::String(s) => Self::parse_string(s),
58            serde_yaml::Value::Number(n) => {
59                let index = n
60                    .as_u64()
61                    .ok_or_else(|| format!("Invalid color palette index: {}", n))?;
62                if index > 255 {
63                    return Err(format!(
64                        "Color palette index {} out of range (0-255)",
65                        index
66                    ));
67                }
68                Ok(ColorDef::Color256(index as u8))
69            }
70            serde_yaml::Value::Sequence(seq) => Self::parse_rgb_tuple(seq),
71            _ => Err(format!("Invalid color value: {:?}", value)),
72        }
73    }
74
75    /// Parses a color from a string value.
76    ///
77    /// Supports:
78    /// - Named colors: `red`, `green`, `blue`, etc.
79    /// - Bright variants: `bright_red`, `bright_green`, etc.
80    /// - Hex codes: `#ff6b35` or `#fff`
81    /// - Cube coordinates: `cube(60%, 20%, 0%)`
82    pub fn parse_string(s: &str) -> Result<Self, String> {
83        let s = s.trim();
84
85        // Check for cube() function
86        if s.starts_with("cube(") && s.ends_with(')') {
87            return Self::parse_cube(s);
88        }
89
90        // Check for hex color
91        if let Some(hex) = s.strip_prefix('#') {
92            return Self::parse_hex(hex);
93        }
94
95        // Check for named color
96        Self::parse_named(s)
97    }
98
99    /// Parses a `cube(r%, g%, b%)` color specification.
100    ///
101    /// Each component is a percentage (0–100). The `%` suffix is optional.
102    fn parse_cube(s: &str) -> Result<Self, String> {
103        let inner = &s[5..s.len() - 1]; // strip "cube(" and ")"
104        let parts: Vec<&str> = inner.split(',').map(|p| p.trim()).collect();
105        if parts.len() != 3 {
106            return Err(format!(
107                "cube() requires exactly 3 components, got {}",
108                parts.len()
109            ));
110        }
111
112        let mut values = [0.0f64; 3];
113        for (i, part) in parts.iter().enumerate() {
114            let num_str = part.strip_suffix('%').unwrap_or(part).trim();
115            values[i] = num_str
116                .parse::<f64>()
117                .map_err(|_| format!("Invalid cube component '{}': expected a number", part))?;
118        }
119
120        let coord = CubeCoord::from_percentages(values[0], values[1], values[2])?;
121        Ok(ColorDef::Cube(coord))
122    }
123
124    /// Parses a hex color code (without the # prefix).
125    fn parse_hex(hex: &str) -> Result<Self, String> {
126        match hex.len() {
127            // 3-digit hex: #rgb -> #rrggbb
128            3 => {
129                let r = u8::from_str_radix(&hex[0..1], 16)
130                    .map_err(|_| format!("Invalid hex: {}", hex))?
131                    * 17;
132                let g = u8::from_str_radix(&hex[1..2], 16)
133                    .map_err(|_| format!("Invalid hex: {}", hex))?
134                    * 17;
135                let b = u8::from_str_radix(&hex[2..3], 16)
136                    .map_err(|_| format!("Invalid hex: {}", hex))?
137                    * 17;
138                Ok(ColorDef::Rgb(r, g, b))
139            }
140            // 6-digit hex: #rrggbb
141            6 => {
142                let r = u8::from_str_radix(&hex[0..2], 16)
143                    .map_err(|_| format!("Invalid hex: {}", hex))?;
144                let g = u8::from_str_radix(&hex[2..4], 16)
145                    .map_err(|_| format!("Invalid hex: {}", hex))?;
146                let b = u8::from_str_radix(&hex[4..6], 16)
147                    .map_err(|_| format!("Invalid hex: {}", hex))?;
148                Ok(ColorDef::Rgb(r, g, b))
149            }
150            _ => Err(format!(
151                "Invalid hex color: #{} (must be 3 or 6 digits)",
152                hex
153            )),
154        }
155    }
156
157    /// Parses a named color (including bright variants).
158    fn parse_named(name: &str) -> Result<Self, String> {
159        let name_lower = name.to_lowercase();
160
161        // Check for bright_ prefix
162        if let Some(base) = name_lower.strip_prefix("bright_") {
163            return Self::parse_bright_color(base);
164        }
165
166        // Standard colors
167        let color = match name_lower.as_str() {
168            "black" => Color::Black,
169            "red" => Color::Red,
170            "green" => Color::Green,
171            "yellow" => Color::Yellow,
172            "blue" => Color::Blue,
173            "magenta" => Color::Magenta,
174            "cyan" => Color::Cyan,
175            "white" => Color::White,
176            // Also accept gray/grey as aliases
177            "gray" | "grey" => Color::White,
178            _ => return Err(format!("Unknown color name: {}", name)),
179        };
180
181        Ok(ColorDef::Named(color))
182    }
183
184    /// Parses a bright color variant.
185    fn parse_bright_color(base: &str) -> Result<Self, String> {
186        // console crate uses Color256 for bright colors (indices 8-15)
187        let index = match base {
188            "black" => 8,
189            "red" => 9,
190            "green" => 10,
191            "yellow" => 11,
192            "blue" => 12,
193            "magenta" => 13,
194            "cyan" => 14,
195            "white" => 15,
196            _ => return Err(format!("Unknown bright color: bright_{}", base)),
197        };
198
199        Ok(ColorDef::Color256(index))
200    }
201
202    /// Parses an RGB tuple from a YAML sequence.
203    fn parse_rgb_tuple(seq: &[serde_yaml::Value]) -> Result<Self, String> {
204        if seq.len() != 3 {
205            return Err(format!(
206                "RGB tuple must have exactly 3 values, got {}",
207                seq.len()
208            ));
209        }
210
211        let mut components = [0u8; 3];
212        for (i, val) in seq.iter().enumerate() {
213            let n = val
214                .as_u64()
215                .ok_or_else(|| format!("RGB component {} is not a number", i))?;
216            if n > 255 {
217                return Err(format!("RGB component {} out of range (0-255): {}", i, n));
218            }
219            components[i] = n as u8;
220        }
221
222        Ok(ColorDef::Rgb(components[0], components[1], components[2]))
223    }
224
225    /// Converts this color definition to a `console::Color`.
226    ///
227    /// For [`Cube`](ColorDef::Cube) colors, a [`ThemePalette`] is required to resolve
228    /// the cube coordinate to an actual RGB value. If no palette is provided,
229    /// the default xterm palette is used.
230    pub fn to_console_color(&self, palette: Option<&ThemePalette>) -> Color {
231        match self {
232            ColorDef::Named(c) => *c,
233            ColorDef::Color256(n) => Color::Color256(*n),
234            ColorDef::Rgb(r, g, b) => Color::Color256(crate::rgb_to_ansi256((*r, *g, *b))),
235            ColorDef::Cube(coord) => {
236                let p;
237                let palette = match palette {
238                    Some(pal) => pal,
239                    None => {
240                        p = ThemePalette::default_xterm();
241                        &p
242                    }
243                };
244                let rgb = palette.resolve(coord);
245                Color::Color256(crate::rgb_to_ansi256((rgb.0, rgb.1, rgb.2)))
246            }
247        }
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use serde_yaml::Value;
255
256    // =========================================================================
257    // Named color tests
258    // =========================================================================
259
260    #[test]
261    fn test_parse_named_colors() {
262        assert_eq!(
263            ColorDef::parse_string("red").unwrap(),
264            ColorDef::Named(Color::Red)
265        );
266        assert_eq!(
267            ColorDef::parse_string("green").unwrap(),
268            ColorDef::Named(Color::Green)
269        );
270        assert_eq!(
271            ColorDef::parse_string("blue").unwrap(),
272            ColorDef::Named(Color::Blue)
273        );
274        assert_eq!(
275            ColorDef::parse_string("yellow").unwrap(),
276            ColorDef::Named(Color::Yellow)
277        );
278        assert_eq!(
279            ColorDef::parse_string("magenta").unwrap(),
280            ColorDef::Named(Color::Magenta)
281        );
282        assert_eq!(
283            ColorDef::parse_string("cyan").unwrap(),
284            ColorDef::Named(Color::Cyan)
285        );
286        assert_eq!(
287            ColorDef::parse_string("white").unwrap(),
288            ColorDef::Named(Color::White)
289        );
290        assert_eq!(
291            ColorDef::parse_string("black").unwrap(),
292            ColorDef::Named(Color::Black)
293        );
294    }
295
296    #[test]
297    fn test_parse_named_colors_case_insensitive() {
298        assert_eq!(
299            ColorDef::parse_string("RED").unwrap(),
300            ColorDef::Named(Color::Red)
301        );
302        assert_eq!(
303            ColorDef::parse_string("Red").unwrap(),
304            ColorDef::Named(Color::Red)
305        );
306    }
307
308    #[test]
309    fn test_parse_gray_aliases() {
310        assert_eq!(
311            ColorDef::parse_string("gray").unwrap(),
312            ColorDef::Named(Color::White)
313        );
314        assert_eq!(
315            ColorDef::parse_string("grey").unwrap(),
316            ColorDef::Named(Color::White)
317        );
318    }
319
320    #[test]
321    fn test_parse_unknown_color() {
322        assert!(ColorDef::parse_string("purple").is_err());
323        assert!(ColorDef::parse_string("orange").is_err());
324    }
325
326    // =========================================================================
327    // Bright color tests
328    // =========================================================================
329
330    #[test]
331    fn test_parse_bright_colors() {
332        assert_eq!(
333            ColorDef::parse_string("bright_red").unwrap(),
334            ColorDef::Color256(9)
335        );
336        assert_eq!(
337            ColorDef::parse_string("bright_green").unwrap(),
338            ColorDef::Color256(10)
339        );
340        assert_eq!(
341            ColorDef::parse_string("bright_blue").unwrap(),
342            ColorDef::Color256(12)
343        );
344        assert_eq!(
345            ColorDef::parse_string("bright_black").unwrap(),
346            ColorDef::Color256(8)
347        );
348        assert_eq!(
349            ColorDef::parse_string("bright_white").unwrap(),
350            ColorDef::Color256(15)
351        );
352    }
353
354    #[test]
355    fn test_parse_unknown_bright_color() {
356        assert!(ColorDef::parse_string("bright_purple").is_err());
357    }
358
359    // =========================================================================
360    // Hex color tests
361    // =========================================================================
362
363    #[test]
364    fn test_parse_hex_6_digit() {
365        assert_eq!(
366            ColorDef::parse_string("#ff6b35").unwrap(),
367            ColorDef::Rgb(255, 107, 53)
368        );
369        assert_eq!(
370            ColorDef::parse_string("#000000").unwrap(),
371            ColorDef::Rgb(0, 0, 0)
372        );
373        assert_eq!(
374            ColorDef::parse_string("#ffffff").unwrap(),
375            ColorDef::Rgb(255, 255, 255)
376        );
377    }
378
379    #[test]
380    fn test_parse_hex_3_digit() {
381        assert_eq!(
382            ColorDef::parse_string("#fff").unwrap(),
383            ColorDef::Rgb(255, 255, 255)
384        );
385        assert_eq!(
386            ColorDef::parse_string("#000").unwrap(),
387            ColorDef::Rgb(0, 0, 0)
388        );
389        assert_eq!(
390            ColorDef::parse_string("#f80").unwrap(),
391            ColorDef::Rgb(255, 136, 0)
392        );
393    }
394
395    #[test]
396    fn test_parse_hex_case_insensitive() {
397        assert_eq!(
398            ColorDef::parse_string("#FF6B35").unwrap(),
399            ColorDef::Rgb(255, 107, 53)
400        );
401        assert_eq!(
402            ColorDef::parse_string("#FFF").unwrap(),
403            ColorDef::Rgb(255, 255, 255)
404        );
405    }
406
407    #[test]
408    fn test_parse_hex_invalid() {
409        assert!(ColorDef::parse_string("#ff").is_err());
410        assert!(ColorDef::parse_string("#ffff").is_err());
411        assert!(ColorDef::parse_string("#gggggg").is_err());
412    }
413
414    // =========================================================================
415    // YAML value tests
416    // =========================================================================
417
418    #[test]
419    fn test_parse_value_string() {
420        let val = Value::String("red".into());
421        assert_eq!(
422            ColorDef::parse_value(&val).unwrap(),
423            ColorDef::Named(Color::Red)
424        );
425    }
426
427    #[test]
428    fn test_parse_value_number() {
429        let val = Value::Number(208.into());
430        assert_eq!(
431            ColorDef::parse_value(&val).unwrap(),
432            ColorDef::Color256(208)
433        );
434    }
435
436    #[test]
437    fn test_parse_value_number_out_of_range() {
438        let val = Value::Number(256.into());
439        assert!(ColorDef::parse_value(&val).is_err());
440    }
441
442    #[test]
443    fn test_parse_value_sequence() {
444        let val = Value::Sequence(vec![
445            Value::Number(255.into()),
446            Value::Number(107.into()),
447            Value::Number(53.into()),
448        ]);
449        assert_eq!(
450            ColorDef::parse_value(&val).unwrap(),
451            ColorDef::Rgb(255, 107, 53)
452        );
453    }
454
455    #[test]
456    fn test_parse_value_sequence_wrong_length() {
457        let val = Value::Sequence(vec![Value::Number(255.into()), Value::Number(107.into())]);
458        assert!(ColorDef::parse_value(&val).is_err());
459    }
460
461    #[test]
462    fn test_parse_value_sequence_out_of_range() {
463        let val = Value::Sequence(vec![
464            Value::Number(256.into()),
465            Value::Number(107.into()),
466            Value::Number(53.into()),
467        ]);
468        assert!(ColorDef::parse_value(&val).is_err());
469    }
470
471    // =========================================================================
472    // to_console_color tests
473    // =========================================================================
474
475    #[test]
476    fn test_to_console_color_named() {
477        let c = ColorDef::Named(Color::Red);
478        assert_eq!(c.to_console_color(None), Color::Red);
479    }
480
481    #[test]
482    fn test_to_console_color_256() {
483        let c = ColorDef::Color256(208);
484        assert_eq!(c.to_console_color(None), Color::Color256(208));
485    }
486
487    #[test]
488    fn test_to_console_color_rgb() {
489        let c = ColorDef::Rgb(255, 107, 53);
490        // RGB gets converted to 256 color via rgb_to_ansi256
491        if let Color::Color256(_) = c.to_console_color(None) {
492            // OK - it converted
493        } else {
494            panic!("Expected Color256");
495        }
496    }
497
498    // =========================================================================
499    // Cube color tests
500    // =========================================================================
501
502    #[test]
503    fn test_parse_cube_percentages() {
504        let c = ColorDef::parse_string("cube(60%, 20%, 0%)").unwrap();
505        match c {
506            ColorDef::Cube(coord) => {
507                assert!((coord.r - 0.6).abs() < 0.001);
508                assert!((coord.g - 0.2).abs() < 0.001);
509                assert!((coord.b - 0.0).abs() < 0.001);
510            }
511            _ => panic!("Expected Cube"),
512        }
513    }
514
515    #[test]
516    fn test_parse_cube_without_percent_sign() {
517        let c = ColorDef::parse_string("cube(100, 50, 0)").unwrap();
518        match c {
519            ColorDef::Cube(coord) => {
520                assert!((coord.r - 1.0).abs() < 0.001);
521                assert!((coord.g - 0.5).abs() < 0.001);
522                assert!((coord.b - 0.0).abs() < 0.001);
523            }
524            _ => panic!("Expected Cube"),
525        }
526    }
527
528    #[test]
529    fn test_parse_cube_corners() {
530        // Origin
531        let c = ColorDef::parse_string("cube(0%, 0%, 0%)").unwrap();
532        assert!(matches!(c, ColorDef::Cube(_)));
533
534        // Opposite corner
535        let c = ColorDef::parse_string("cube(100%, 100%, 100%)").unwrap();
536        assert!(matches!(c, ColorDef::Cube(_)));
537    }
538
539    #[test]
540    fn test_parse_cube_out_of_range() {
541        assert!(ColorDef::parse_string("cube(101%, 0%, 0%)").is_err());
542        assert!(ColorDef::parse_string("cube(-1%, 0%, 0%)").is_err());
543    }
544
545    #[test]
546    fn test_parse_cube_wrong_arg_count() {
547        assert!(ColorDef::parse_string("cube(60%, 20%)").is_err());
548        assert!(ColorDef::parse_string("cube(60%, 20%, 0%, 10%)").is_err());
549    }
550
551    #[test]
552    fn test_parse_cube_invalid_number() {
553        assert!(ColorDef::parse_string("cube(abc, 20%, 0%)").is_err());
554    }
555
556    #[test]
557    fn test_to_console_color_cube() {
558        use crate::colorspace::CubeCoord;
559        let coord = CubeCoord::from_percentages(60.0, 20.0, 0.0).unwrap();
560        let c = ColorDef::Cube(coord);
561        // Should resolve without panic
562        if let Color::Color256(_) = c.to_console_color(None) {
563            // OK
564        } else {
565            panic!("Expected Color256 from cube resolution");
566        }
567    }
568
569    #[test]
570    fn test_to_console_color_cube_with_palette() {
571        use crate::colorspace::{CubeCoord, Rgb, ThemePalette};
572        let palette = ThemePalette::new([
573            Rgb(40, 40, 40),
574            Rgb(204, 36, 29),
575            Rgb(152, 151, 26),
576            Rgb(215, 153, 33),
577            Rgb(69, 133, 136),
578            Rgb(177, 98, 134),
579            Rgb(104, 157, 106),
580            Rgb(168, 153, 132),
581        ]);
582        let coord = CubeCoord::from_percentages(0.0, 0.0, 0.0).unwrap();
583        let c = ColorDef::Cube(coord);
584        // Origin should resolve to bg (anchors[0])
585        if let Color::Color256(_) = c.to_console_color(Some(&palette)) {
586            // OK
587        } else {
588            panic!("Expected Color256");
589        }
590    }
591
592    #[test]
593    fn test_parse_value_cube_string() {
594        let val = Value::String("cube(50%, 50%, 50%)".into());
595        let c = ColorDef::parse_value(&val).unwrap();
596        assert!(matches!(c, ColorDef::Cube(_)));
597    }
598}