Skip to main content

vtcode_commons/
colors.rs

1#![expect(
2    clippy::string_slice,
3    clippy::cast_possible_truncation,
4    reason = "Hex input is validated to six ASCII digits and RGB interpolation is clamped to byte range."
5)]
6
7//! Color utilities for VT Code
8//!
9//! This module provides color manipulation capabilities using anstyle,
10//! which offers low-level ANSI styling with RGB and 256-color support.
11
12use anstyle::{AnsiColor, Color, Effects, RgbColor, Style};
13
14/// Create an RGB color from hex string
15pub fn color_from_hex(hex: &str) -> Option<Color> {
16    let hex = hex.trim_start_matches('#');
17    if hex.len() != 6 {
18        return None;
19    }
20
21    let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
22    let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
23    let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
24
25    Some(Color::Rgb(RgbColor(r, g, b)))
26}
27
28/// Blend two RGB colors
29#[allow(
30    clippy::cast_sign_loss,
31    reason = "Intentional compatibility, platform, or test-only suppression."
32)]
33pub fn blend_colors(color1: &Color, color2: &Color, ratio: f32) -> Option<Color> {
34    let rgb1 = color_to_rgb(color1)?;
35    let rgb2 = color_to_rgb(color2)?;
36
37    let r = (rgb1.r() as f32 * (1.0 - ratio) + rgb2.r() as f32 * ratio) as u8;
38    let g = (rgb1.g() as f32 * (1.0 - ratio) + rgb2.g() as f32 * ratio) as u8;
39    let b = (rgb1.b() as f32 * (1.0 - ratio) + rgb2.b() as f32 * ratio) as u8;
40
41    Some(Color::Rgb(RgbColor(r, g, b)))
42}
43
44/// Convert an ANSI color to RGB, if possible
45fn color_to_rgb(color: &Color) -> Option<RgbColor> {
46    match color {
47        Color::Rgb(rgb) => Some(*rgb),
48        Color::Ansi(ansi_color) => ansi_to_rgb(*ansi_color),
49        Color::Ansi256(ansi256_color) => ansi256_to_rgb(*ansi256_color),
50    }
51}
52
53/// Convert an ANSI color to RGB approximation
54fn ansi_to_rgb(ansi_color: AnsiColor) -> Option<RgbColor> {
55    match ansi_color {
56        AnsiColor::Black => Some(RgbColor(0, 0, 0)),
57        AnsiColor::Red => Some(RgbColor(170, 0, 0)),
58        AnsiColor::Green => Some(RgbColor(0, 170, 0)),
59        AnsiColor::Yellow => Some(RgbColor(170, 85, 0)),
60        AnsiColor::Blue => Some(RgbColor(0, 0, 170)),
61        AnsiColor::Magenta => Some(RgbColor(170, 0, 170)),
62        AnsiColor::Cyan => Some(RgbColor(0, 170, 170)),
63        AnsiColor::White => Some(RgbColor(170, 170, 170)),
64        AnsiColor::BrightBlack => Some(RgbColor(85, 85, 85)),
65        AnsiColor::BrightRed => Some(RgbColor(255, 85, 85)),
66        AnsiColor::BrightGreen => Some(RgbColor(85, 255, 85)),
67        AnsiColor::BrightYellow => Some(RgbColor(255, 255, 85)),
68        AnsiColor::BrightBlue => Some(RgbColor(85, 85, 255)),
69        AnsiColor::BrightMagenta => Some(RgbColor(255, 85, 255)),
70        AnsiColor::BrightCyan => Some(RgbColor(85, 255, 255)),
71        AnsiColor::BrightWhite => Some(RgbColor(255, 255, 255)),
72    }
73}
74
75/// Convert an ANSI256 color to RGB approximation
76fn ansi256_to_rgb(ansi256_color: anstyle::Ansi256Color) -> Option<RgbColor> {
77    let code = ansi256_color.0;
78    match code {
79        0 => Some(RgbColor(0, 0, 0)),
80        1 => Some(RgbColor(170, 0, 0)),
81        2 => Some(RgbColor(0, 170, 0)),
82        3 => Some(RgbColor(170, 85, 0)),
83        4 => Some(RgbColor(0, 0, 170)),
84        5 => Some(RgbColor(170, 0, 170)),
85        6 => Some(RgbColor(0, 170, 170)),
86        7 => Some(RgbColor(170, 170, 170)),
87        8 => Some(RgbColor(85, 85, 85)),
88        9 => Some(RgbColor(255, 85, 85)),
89        10 => Some(RgbColor(85, 255, 85)),
90        11 => Some(RgbColor(255, 255, 85)),
91        12 => Some(RgbColor(85, 85, 255)),
92        13 => Some(RgbColor(255, 85, 255)),
93        14 => Some(RgbColor(85, 255, 255)),
94        15 => Some(RgbColor(255, 255, 255)),
95        n if (16..=231).contains(&n) => {
96            let adjusted = n - 16;
97            let r = adjusted / 36;
98            let g = (adjusted % 36) / 6;
99            let b = adjusted % 6;
100            let scale = |x: u8| -> u8 { if x == 0 { 0 } else { 55 + x * 40 } };
101            Some(RgbColor(scale(r), scale(g), scale(b)))
102        }
103        n if n >= 232 => {
104            let gray = 8 + (n - 232) * 10;
105            Some(RgbColor(gray, gray, gray))
106        }
107        _ => Some(RgbColor(128, 128, 128)),
108    }
109}
110
111/// Determine if a color is light (for contrast calculations)
112pub fn is_light_color(color: &Color) -> bool {
113    let rgb = color_to_rgb(color);
114    if let Some(RgbColor(r, g, b)) = rgb {
115        let luminance = (0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32) / 255.0;
116        luminance > 0.5
117    } else {
118        false
119    }
120}
121
122/// Get a contrasting color (black or white) for better readability
123pub fn contrasting_color(color: &Color) -> Color {
124    if is_light_color(color) {
125        Color::Ansi(AnsiColor::Black)
126    } else {
127        Color::Ansi(AnsiColor::White)
128    }
129}
130
131/// Create a desaturated version of a color
132#[allow(
133    clippy::cast_sign_loss,
134    reason = "Intentional compatibility, platform, or test-only suppression."
135)]
136pub fn desaturate_color(color: &Color, amount: f32) -> Option<Color> {
137    let rgb = color_to_rgb(color)?;
138    let r = rgb.r() as f32;
139    let g = rgb.g() as f32;
140    let b = rgb.b() as f32;
141    let gray = 0.299 * r + 0.587 * g + 0.114 * b;
142    let r_new = r * (1.0 - amount) + gray * amount;
143    let g_new = g * (1.0 - amount) + gray * amount;
144    let b_new = b * (1.0 - amount) + gray * amount;
145    Some(Color::Rgb(RgbColor(r_new as u8, g_new as u8, b_new as u8)))
146}
147
148fn styled(text: &str, style: Style) -> String {
149    format!("{}{}{}", style.render(), text, style.render_reset())
150}
151
152/// Style wrapper for console::style compatibility
153pub fn style(text: impl std::fmt::Display) -> StyledString {
154    StyledString { text: text.to_string(), style: Style::new() }
155}
156
157pub struct StyledString {
158    text: String,
159    style: Style,
160}
161
162impl StyledString {
163    pub fn red(mut self) -> Self {
164        self.style = self.style.fg_color(Some(Color::Ansi(AnsiColor::Red)));
165        self
166    }
167
168    pub fn green(mut self) -> Self {
169        self.style = self.style.fg_color(Some(Color::Ansi(AnsiColor::Green)));
170        self
171    }
172
173    pub fn blue(mut self) -> Self {
174        self.style = self.style.fg_color(Some(Color::Ansi(AnsiColor::Blue)));
175        self
176    }
177
178    pub fn yellow(mut self) -> Self {
179        self.style = self.style.fg_color(Some(Color::Ansi(AnsiColor::Yellow)));
180        self
181    }
182
183    pub fn cyan(mut self) -> Self {
184        self.style = self.style.fg_color(Some(Color::Ansi(AnsiColor::Cyan)));
185        self
186    }
187
188    pub fn magenta(mut self) -> Self {
189        self.style = self.style.fg_color(Some(Color::Ansi(AnsiColor::Magenta)));
190        self
191    }
192
193    pub fn bold(mut self) -> Self {
194        self.style = self.style.effects(self.style.get_effects() | Effects::BOLD);
195        self
196    }
197
198    fn dimmed(mut self) -> Self {
199        self.style = self.style.effects(self.style.get_effects() | Effects::DIMMED);
200        self
201    }
202
203    pub fn dim(self) -> Self {
204        self.dimmed()
205    }
206
207    pub fn on_black(mut self) -> Self {
208        self.style = self.style.bg_color(Some(Color::Ansi(AnsiColor::Black)));
209        self
210    }
211}
212
213impl std::fmt::Display for StyledString {
214    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215        write!(f, "{}{}{}", self.style.render(), self.text, self.style.render_reset())
216    }
217}
218
219/// Apply red color to text
220pub fn red(text: &str) -> String {
221    styled(text, Style::new().fg_color(Some(Color::Ansi(AnsiColor::Red))))
222}
223
224/// Apply green color to text
225pub fn green(text: &str) -> String {
226    styled(text, Style::new().fg_color(Some(Color::Ansi(AnsiColor::Green))))
227}
228
229/// Apply blue color to text
230pub fn blue(text: &str) -> String {
231    styled(text, Style::new().fg_color(Some(Color::Ansi(AnsiColor::Blue))))
232}
233
234/// Apply yellow color to text
235pub fn yellow(text: &str) -> String {
236    styled(text, Style::new().fg_color(Some(Color::Ansi(AnsiColor::Yellow))))
237}
238
239/// Apply purple color to text
240pub fn purple(text: &str) -> String {
241    styled(text, Style::new().fg_color(Some(Color::Ansi(AnsiColor::Magenta))))
242}
243
244/// Apply cyan color to text
245pub fn cyan(text: &str) -> String {
246    styled(text, Style::new().fg_color(Some(Color::Ansi(AnsiColor::Cyan))))
247}
248
249/// Apply white color to text
250pub fn white(text: &str) -> String {
251    styled(text, Style::new().fg_color(Some(Color::Ansi(AnsiColor::White))))
252}
253
254/// Apply black color to text
255pub fn black(text: &str) -> String {
256    styled(text, Style::new().fg_color(Some(Color::Ansi(AnsiColor::Black))))
257}
258
259/// Apply bold styling to text
260pub fn bold(text: &str) -> String {
261    styled(text, Style::new().effects(Effects::BOLD))
262}
263
264/// Apply italic styling to text
265pub fn italic(text: &str) -> String {
266    styled(text, Style::new().effects(Effects::ITALIC))
267}
268
269/// Apply underline styling to text
270pub fn underline(text: &str) -> String {
271    styled(text, Style::new().effects(Effects::UNDERLINE))
272}
273
274/// Apply dimmed styling to text
275pub fn dimmed(text: &str) -> String {
276    styled(text, Style::new().effects(Effects::DIMMED))
277}
278
279/// Apply blinking styling to text
280pub fn blink(text: &str) -> String {
281    styled(text, Style::new().effects(Effects::BLINK))
282}
283
284/// Apply reversed styling to text
285pub fn reversed(text: &str) -> String {
286    styled(text, Style::new().effects(Effects::INVERT))
287}
288
289/// Apply strikethrough styling to text
290pub fn strikethrough(text: &str) -> String {
291    styled(text, Style::new().effects(Effects::STRIKETHROUGH))
292}
293
294/// Apply custom RGB color to text
295pub fn rgb(text: &str, r: u8, g: u8, b: u8) -> String {
296    styled(text, Style::new().fg_color(Some(Color::Rgb(RgbColor(r, g, b)))))
297}
298
299/// Combine multiple color and style operations
300pub fn custom_style(text: &str, styles: &[&str]) -> String {
301    let mut style = Style::new();
302
303    for style_str in styles {
304        match *style_str {
305            "red" => style = style.fg_color(Some(Color::Ansi(AnsiColor::Red))),
306            "green" => style = style.fg_color(Some(Color::Ansi(AnsiColor::Green))),
307            "blue" => style = style.fg_color(Some(Color::Ansi(AnsiColor::Blue))),
308            "yellow" => style = style.fg_color(Some(Color::Ansi(AnsiColor::Yellow))),
309            "purple" => style = style.fg_color(Some(Color::Ansi(AnsiColor::Magenta))),
310            "cyan" => style = style.fg_color(Some(Color::Ansi(AnsiColor::Cyan))),
311            "white" => style = style.fg_color(Some(Color::Ansi(AnsiColor::White))),
312            "black" => style = style.fg_color(Some(Color::Ansi(AnsiColor::Black))),
313            "bold" => style = style.effects(style.get_effects() | Effects::BOLD),
314            "italic" => style = style.effects(style.get_effects() | Effects::ITALIC),
315            "underline" => style = style.effects(style.get_effects() | Effects::UNDERLINE),
316            "dimmed" => style = style.effects(style.get_effects() | Effects::DIMMED),
317            "blink" => style = style.effects(style.get_effects() | Effects::BLINK),
318            "reversed" => style = style.effects(style.get_effects() | Effects::INVERT),
319            "strikethrough" => style = style.effects(style.get_effects() | Effects::STRIKETHROUGH),
320            _ => {}
321        }
322    }
323
324    styled(text, style)
325}