git_iris/theme/
color.rs

1//! Theme color type - format-agnostic RGB color representation.
2
3use std::fmt;
4use std::str::FromStr;
5
6/// A theme color in RGB format.
7///
8/// This is the canonical color representation used throughout the theme system.
9/// Adapters convert this to framework-specific types (Ratatui Color, colored tuples).
10#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
11pub struct ThemeColor {
12    pub r: u8,
13    pub g: u8,
14    pub b: u8,
15}
16
17impl ThemeColor {
18    /// Create a new theme color from RGB values.
19    #[must_use]
20    pub const fn new(r: u8, g: u8, b: u8) -> Self {
21        Self { r, g, b }
22    }
23
24    /// Create a theme color from a hex string (with or without #).
25    ///
26    /// # Errors
27    /// Returns an error if the hex string is invalid.
28    pub fn from_hex(hex: &str) -> Result<Self, ColorParseError> {
29        let hex = hex.trim_start_matches('#');
30
31        if hex.len() != 6 {
32            return Err(ColorParseError::InvalidLength(hex.len()));
33        }
34
35        let r = u8::from_str_radix(&hex[0..2], 16)
36            .map_err(|_| ColorParseError::InvalidHex(hex.to_string()))?;
37        let g = u8::from_str_radix(&hex[2..4], 16)
38            .map_err(|_| ColorParseError::InvalidHex(hex.to_string()))?;
39        let b = u8::from_str_radix(&hex[4..6], 16)
40            .map_err(|_| ColorParseError::InvalidHex(hex.to_string()))?;
41
42        Ok(Self { r, g, b })
43    }
44
45    /// Convert to hex string with # prefix.
46    #[must_use]
47    pub fn to_hex(&self) -> String {
48        format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
49    }
50
51    /// Convert to RGB tuple for use with colored crate.
52    #[must_use]
53    pub const fn to_rgb_tuple(&self) -> (u8, u8, u8) {
54        (self.r, self.g, self.b)
55    }
56
57    /// Linearly interpolate between two colors.
58    ///
59    /// `t` should be in range 0.0..=1.0 where 0.0 returns `self` and 1.0 returns `other`.
60    #[must_use]
61    #[allow(
62        clippy::cast_possible_truncation,
63        clippy::cast_sign_loss,
64        clippy::as_conversions
65    )]
66    pub fn lerp(&self, other: &Self, t: f32) -> Self {
67        let t = t.clamp(0.0, 1.0);
68        Self {
69            r: (f32::from(self.r) + (f32::from(other.r) - f32::from(self.r)) * t) as u8,
70            g: (f32::from(self.g) + (f32::from(other.g) - f32::from(self.g)) * t) as u8,
71            b: (f32::from(self.b) + (f32::from(other.b) - f32::from(self.b)) * t) as u8,
72        }
73    }
74
75    /// The fallback color used when a token cannot be resolved.
76    /// A neutral gray that works on both light and dark backgrounds.
77    pub const FALLBACK: Self = Self::new(128, 128, 128);
78}
79
80impl Default for ThemeColor {
81    fn default() -> Self {
82        Self::FALLBACK
83    }
84}
85
86impl fmt::Display for ThemeColor {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        write!(f, "{}", self.to_hex())
89    }
90}
91
92impl FromStr for ThemeColor {
93    type Err = ColorParseError;
94
95    fn from_str(s: &str) -> Result<Self, Self::Err> {
96        Self::from_hex(s)
97    }
98}
99
100/// Errors that can occur when parsing a color.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum ColorParseError {
103    /// Hex string has wrong length (expected 6 characters without #).
104    InvalidLength(usize),
105    /// Hex string contains invalid characters.
106    InvalidHex(String),
107}
108
109impl fmt::Display for ColorParseError {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        match self {
112            Self::InvalidLength(len) => {
113                write!(f, "invalid hex color length: {len} (expected 6)")
114            }
115            Self::InvalidHex(hex) => {
116                write!(f, "invalid hex color: {hex}")
117            }
118        }
119    }
120}
121
122impl std::error::Error for ColorParseError {}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn test_from_hex() {
130        assert_eq!(
131            ThemeColor::from_hex("#e135ff").unwrap(),
132            ThemeColor::new(225, 53, 255)
133        );
134        assert_eq!(
135            ThemeColor::from_hex("80ffea").unwrap(),
136            ThemeColor::new(128, 255, 234)
137        );
138    }
139
140    #[test]
141    fn test_to_hex() {
142        assert_eq!(ThemeColor::new(225, 53, 255).to_hex(), "#e135ff");
143    }
144
145    #[test]
146    fn test_lerp() {
147        let black = ThemeColor::new(0, 0, 0);
148        let white = ThemeColor::new(255, 255, 255);
149        let mid = black.lerp(&white, 0.5);
150        assert_eq!(mid, ThemeColor::new(127, 127, 127));
151    }
152}