slt/style/color.rs
1/// Terminal color.
2///
3/// Covers the standard 16 named colors, 256-color palette indices, and
4/// 24-bit RGB true color. Use [`Color::Reset`] to restore the terminal's
5/// default foreground or background.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
7#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
8pub enum Color {
9 /// Reset to the terminal's default color.
10 Reset,
11 /// Standard black (color index 0).
12 Black,
13 /// Standard red (color index 1).
14 Red,
15 /// Standard green (color index 2).
16 Green,
17 /// Standard yellow (color index 3).
18 Yellow,
19 /// Standard blue (color index 4).
20 Blue,
21 /// Standard magenta (color index 5).
22 Magenta,
23 /// Standard cyan (color index 6).
24 Cyan,
25 /// Standard white (color index 7).
26 White,
27 /// Bright black / dark gray (color index 8).
28 DarkGray,
29 /// Bright red (color index 9).
30 LightRed,
31 /// Bright green (color index 10).
32 LightGreen,
33 /// Bright yellow (color index 11).
34 LightYellow,
35 /// Bright blue (color index 12).
36 LightBlue,
37 /// Bright magenta (color index 13).
38 LightMagenta,
39 /// Bright cyan (color index 14).
40 LightCyan,
41 /// Bright white (color index 15).
42 LightWhite,
43 /// 24-bit true color.
44 Rgb(u8, u8, u8),
45 /// 256-color palette index.
46 Indexed(u8),
47}
48
49impl Color {
50 /// Resolve to `(r, g, b)` for luminance and blending operations.
51 ///
52 /// Named colors map to their typical terminal palette values.
53 /// [`Color::Reset`] maps to black; [`Color::Indexed`] maps to the xterm-256 palette.
54 fn to_rgb(self) -> (u8, u8, u8) {
55 match self {
56 Color::Rgb(r, g, b) => (r, g, b),
57 Color::Black => (0, 0, 0),
58 Color::Red => (205, 49, 49),
59 Color::Green => (13, 188, 121),
60 Color::Yellow => (229, 229, 16),
61 Color::Blue => (36, 114, 200),
62 Color::Magenta => (188, 63, 188),
63 Color::Cyan => (17, 168, 205),
64 Color::White => (229, 229, 229),
65 Color::DarkGray => (128, 128, 128),
66 Color::LightRed => (255, 0, 0),
67 Color::LightGreen => (0, 255, 0),
68 Color::LightYellow => (255, 255, 0),
69 Color::LightBlue => (0, 0, 255),
70 Color::LightMagenta => (255, 0, 255),
71 Color::LightCyan => (0, 255, 255),
72 Color::LightWhite => (255, 255, 255),
73 Color::Reset => (0, 0, 0),
74 Color::Indexed(idx) => xterm256_to_rgb(idx),
75 }
76 }
77
78 /// Compute relative luminance using ITU-R BT.709 coefficients.
79 ///
80 /// Returns a value in `[0.0, 1.0]` where 0 is darkest and 1 is brightest.
81 /// Use this to determine whether text on a given background should be
82 /// light or dark.
83 ///
84 /// # Example
85 ///
86 /// ```
87 /// use slt::Color;
88 ///
89 /// let dark = Color::Rgb(30, 30, 46);
90 /// assert!(dark.luminance() < 0.15);
91 ///
92 /// let light = Color::Rgb(205, 214, 244);
93 /// assert!(light.luminance() > 0.6);
94 /// ```
95 pub fn luminance(self) -> f32 {
96 let (r, g, b) = self.to_rgb();
97 let rf = r as f32 / 255.0;
98 let gf = g as f32 / 255.0;
99 let bf = b as f32 / 255.0;
100 0.2126 * rf + 0.7152 * gf + 0.0722 * bf
101 }
102
103 /// Return a contrasting foreground color for the given background.
104 ///
105 /// Uses the BT.709 luminance threshold (0.5) to decide between white
106 /// and black text. For theme-aware contrast, prefer using this over
107 /// hardcoding `theme.bg` as the foreground.
108 ///
109 /// # Example
110 ///
111 /// ```
112 /// use slt::Color;
113 ///
114 /// let bg = Color::Rgb(189, 147, 249); // Dracula purple
115 /// let fg = Color::contrast_fg(bg);
116 /// // Purple is mid-bright → returns black for readable text
117 /// ```
118 pub fn contrast_fg(bg: Color) -> Color {
119 if bg.luminance() > 0.5 {
120 Color::Rgb(0, 0, 0)
121 } else {
122 Color::Rgb(255, 255, 255)
123 }
124 }
125
126 /// Blend this color over another with the given alpha.
127 ///
128 /// `alpha` is in `[0.0, 1.0]` where 0.0 returns `other` unchanged and
129 /// 1.0 returns `self` unchanged. Both colors are resolved to RGB.
130 ///
131 /// # Example
132 ///
133 /// ```
134 /// use slt::Color;
135 ///
136 /// let white = Color::Rgb(255, 255, 255);
137 /// let black = Color::Rgb(0, 0, 0);
138 /// let gray = white.blend(black, 0.5);
139 /// // ≈ Rgb(128, 128, 128)
140 /// ```
141 pub fn blend(self, other: Color, alpha: f32) -> Color {
142 let alpha = alpha.clamp(0.0, 1.0);
143 let (r1, g1, b1) = self.to_rgb();
144 let (r2, g2, b2) = other.to_rgb();
145 let r = (r1 as f32 * alpha + r2 as f32 * (1.0 - alpha)).round() as u8;
146 let g = (g1 as f32 * alpha + g2 as f32 * (1.0 - alpha)).round() as u8;
147 let b = (b1 as f32 * alpha + b2 as f32 * (1.0 - alpha)).round() as u8;
148 Color::Rgb(r, g, b)
149 }
150
151 /// Lighten this color by the given amount (0.0–1.0).
152 ///
153 /// Blends toward white. `amount = 0.0` returns the original color;
154 /// `amount = 1.0` returns white.
155 pub fn lighten(self, amount: f32) -> Color {
156 Color::Rgb(255, 255, 255).blend(self, 1.0 - amount.clamp(0.0, 1.0))
157 }
158
159 /// Darken this color by the given amount (0.0–1.0).
160 ///
161 /// Blends toward black. `amount = 0.0` returns the original color;
162 /// `amount = 1.0` returns black.
163 pub fn darken(self, amount: f32) -> Color {
164 Color::Rgb(0, 0, 0).blend(self, 1.0 - amount.clamp(0.0, 1.0))
165 }
166
167 /// Downsample this color to fit the given color depth.
168 ///
169 /// - `TrueColor`: returns self unchanged.
170 /// - `EightBit`: converts `Rgb` to the nearest `Indexed` color.
171 /// - `Basic`: converts `Rgb` and `Indexed` to the nearest named color.
172 ///
173 /// Named colors (`Red`, `Green`, etc.) and `Reset` pass through all depths.
174 pub fn downsampled(self, depth: ColorDepth) -> Color {
175 match depth {
176 ColorDepth::TrueColor => self,
177 ColorDepth::EightBit => match self {
178 Color::Rgb(r, g, b) => Color::Indexed(rgb_to_ansi256(r, g, b)),
179 other => other,
180 },
181 ColorDepth::Basic => match self {
182 Color::Rgb(r, g, b) => rgb_to_ansi16(r, g, b),
183 Color::Indexed(i) => {
184 let (r, g, b) = xterm256_to_rgb(i);
185 rgb_to_ansi16(r, g, b)
186 }
187 other => other,
188 },
189 }
190 }
191}
192
193/// Terminal color depth capability.
194///
195/// Determines the maximum number of colors a terminal can display.
196/// Use [`ColorDepth::detect`] for automatic detection via environment
197/// variables, or specify explicitly in [`crate::RunConfig`].
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
199#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
200pub enum ColorDepth {
201 /// 24-bit true color (16 million colors).
202 TrueColor,
203 /// 256-color palette (xterm-256color).
204 EightBit,
205 /// 16 basic ANSI colors.
206 Basic,
207}
208
209impl ColorDepth {
210 /// Detect the terminal's color depth from environment variables.
211 ///
212 /// Checks `$COLORTERM` for `truecolor`/`24bit`, then `$TERM` for
213 /// `256color`. Falls back to `Basic` (16 colors) if neither is set.
214 pub fn detect() -> Self {
215 if let Ok(ct) = std::env::var("COLORTERM") {
216 let ct = ct.to_lowercase();
217 if ct == "truecolor" || ct == "24bit" {
218 return Self::TrueColor;
219 }
220 }
221 if let Ok(term) = std::env::var("TERM") {
222 if term.contains("256color") {
223 return Self::EightBit;
224 }
225 }
226 Self::Basic
227 }
228}
229
230fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
231 if r == g && g == b {
232 if r < 8 {
233 return 16;
234 }
235 if r > 248 {
236 return 231;
237 }
238 return 232 + (((r as u16 - 8) * 24 / 240) as u8);
239 }
240
241 let ri = if r < 48 {
242 0
243 } else {
244 ((r as u16 - 35) / 40) as u8
245 };
246 let gi = if g < 48 {
247 0
248 } else {
249 ((g as u16 - 35) / 40) as u8
250 };
251 let bi = if b < 48 {
252 0
253 } else {
254 ((b as u16 - 35) / 40) as u8
255 };
256 16 + 36 * ri.min(5) + 6 * gi.min(5) + bi.min(5)
257}
258
259fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Color {
260 let lum =
261 0.2126 * (r as f32 / 255.0) + 0.7152 * (g as f32 / 255.0) + 0.0722 * (b as f32 / 255.0);
262
263 let max = r.max(g).max(b);
264 let min = r.min(g).min(b);
265 let saturation = if max == 0 {
266 0.0
267 } else {
268 (max - min) as f32 / max as f32
269 };
270
271 if saturation < 0.2 {
272 return if lum < 0.15 {
273 Color::Black
274 } else {
275 Color::White
276 };
277 }
278
279 let rf = r as f32;
280 let gf = g as f32;
281 let bf = b as f32;
282
283 if rf >= gf && rf >= bf {
284 if gf > bf * 1.5 {
285 Color::Yellow
286 } else if bf > gf * 1.5 {
287 Color::Magenta
288 } else {
289 Color::Red
290 }
291 } else if gf >= rf && gf >= bf {
292 if bf > rf * 1.5 {
293 Color::Cyan
294 } else {
295 Color::Green
296 }
297 } else if rf > gf * 1.5 {
298 Color::Magenta
299 } else if gf > rf * 1.5 {
300 Color::Cyan
301 } else {
302 Color::Blue
303 }
304}
305
306fn xterm256_to_rgb(idx: u8) -> (u8, u8, u8) {
307 match idx {
308 0 => (0, 0, 0),
309 1 => (128, 0, 0),
310 2 => (0, 128, 0),
311 3 => (128, 128, 0),
312 4 => (0, 0, 128),
313 5 => (128, 0, 128),
314 6 => (0, 128, 128),
315 7 => (192, 192, 192),
316 8 => (128, 128, 128),
317 9 => (255, 0, 0),
318 10 => (0, 255, 0),
319 11 => (255, 255, 0),
320 12 => (0, 0, 255),
321 13 => (255, 0, 255),
322 14 => (0, 255, 255),
323 15 => (255, 255, 255),
324 16..=231 => {
325 let n = idx - 16;
326 let b_idx = n % 6;
327 let g_idx = (n / 6) % 6;
328 let r_idx = n / 36;
329 let to_val = |i: u8| if i == 0 { 0u8 } else { 55 + 40 * i };
330 (to_val(r_idx), to_val(g_idx), to_val(b_idx))
331 }
332 232..=255 => {
333 let v = 8 + 10 * (idx - 232);
334 (v, v, v)
335 }
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[test]
344 fn blend_halfway_rounds_to_128() {
345 assert_eq!(
346 Color::Rgb(255, 255, 255).blend(Color::Rgb(0, 0, 0), 0.5),
347 Color::Rgb(128, 128, 128)
348 );
349 }
350}