uncurses/color/mod.rs
1//! Terminal color values, palettes, and capability profiles.
2//!
3//! ## Color values
4//!
5//! [`Color`] covers three palette spaces in one enum: the sixteen named ANSI
6//! colors ([`Color::Green`], [`Color::BrightBlue`], and so on),
7//! [`Color::Indexed`] for the xterm 256-color palette, and [`Color::Rgb`] for
8//! 24-bit true color. All of them convert to RGB with [`Color::to_rgb`], which
9//! makes palette colors usable with true-color helpers such as
10//! [`Color::to_hex`] and [`Color::to_hsl`].
11//!
12//! ## Named colors
13//!
14//! The sixteen named variants are the standard ANSI palette: `Black` through
15//! `White` are normal intensity (palette indices `0..=7`) and `BrightBlack`
16//! through `BrightWhite` are the bright/high-intensity variants (`8..=15`).
17//! [`Color::named_index`] returns that `0..=15` index for a named color and
18//! `None` for [`Color::Indexed`]/[`Color::Rgb`]; [`Color::from_named`]
19//! goes the other way.
20//!
21//! ## Hex and HSL helpers
22//!
23//! [`Color::hex`] accepts optional-`#` three-, six-, or eight-digit hex input
24//! and returns [`Color::Rgb`]. Eight-digit input parses and ignores the final
25//! alpha byte because terminal colors carry no alpha channel. [`Color::hsl`]
26//! wraps hue into `0..360`, clamps saturation/lightness into `0.0..=1.0`, and
27//! rounds computed RGB channels to the nearest byte.
28//!
29//! [`Color::to_hex`] and [`Color::to_hsl`] first resolve named and indexed
30//! colors through the xterm palette. Gray HSL colors report hue `0.0` because
31//! the hue is undefined when saturation is zero.
32//!
33//! ## Profile downsampling
34//!
35//! [`Profile`] describes the color capability of an output stream. Converting a
36//! color through a profile preserves true color when possible, quantizes to the
37//! nearest supported palette for `Ansi256`/`Ansi`, and drops color entirely for
38//! `Ascii`/`Disabled`.
39//!
40//! ```text
41//! Color::Rgb / Indexed / named
42//! │
43//! ├─ Profile::TrueColor ──► original color
44//! ├─ Profile::Ansi256 ──► nearest xterm index
45//! ├─ Profile::Ansi ──► nearest named color
46//! └─ Ascii / Disabled ──► None
47//! ```
48//!
49//! ```rust,ignore
50//! use uncurses::color::{Color, Profile};
51//!
52//! let orange = Color::Rgb(255, 128, 0);
53//! let green = Color::Green;
54//!
55//! assert_eq!(Profile::TrueColor.convert(orange), Some(orange));
56//! assert!(matches!(Profile::Ansi256.convert(orange), Some(Color::Indexed(_))));
57//! assert_eq!(Profile::Disabled.convert(green), None);
58//! ```
59
60mod convert;
61mod profile;
62
63pub use profile::Profile;
64
65/// A terminal color in one of the supported palette spaces.
66///
67/// The sixteen named variants are the standard ANSI palette. Use
68/// [`Color::Rgb`] when exact 24-bit color should be preserved on true-color
69/// terminals and [`Color::Indexed`] when targeting a specific xterm 256-color
70/// palette entry. A [`Profile`] can downsample any variant to the capability
71/// of a particular output stream.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
73pub enum Color {
74 /// Black (palette `0`, foreground `30`, background `40`).
75 Black,
76 /// Red (palette `1`, foreground `31`, background `41`).
77 Red,
78 /// Green (palette `2`, foreground `32`, background `42`).
79 Green,
80 /// Yellow (palette `3`, foreground `33`, background `43`).
81 Yellow,
82 /// Blue (palette `4`, foreground `34`, background `44`).
83 Blue,
84 /// Magenta (palette `5`, foreground `35`, background `45`).
85 Magenta,
86 /// Cyan (palette `6`, foreground `36`, background `46`).
87 Cyan,
88 /// White (palette `7`, foreground `37`, background `47`).
89 White,
90 /// Bright black (palette `8`, foreground `90`, background `100`).
91 BrightBlack,
92 /// Bright red (palette `9`, foreground `91`, background `101`).
93 BrightRed,
94 /// Bright green (palette `10`, foreground `92`, background `102`).
95 BrightGreen,
96 /// Bright yellow (palette `11`, foreground `93`, background `103`).
97 BrightYellow,
98 /// Bright blue (palette `12`, foreground `94`, background `104`).
99 BrightBlue,
100 /// Bright magenta (palette `13`, foreground `95`, background `105`).
101 BrightMagenta,
102 /// Bright cyan (palette `14`, foreground `96`, background `106`).
103 BrightCyan,
104 /// Bright white (palette `15`, foreground `97`, background `107`).
105 BrightWhite,
106 /// Extended xterm 256-color palette index (`0..=255`).
107 Indexed(u8),
108 /// 24-bit true color as red, green, and blue bytes.
109 Rgb(u8, u8, u8),
110}
111
112impl Color {
113 /// Construct a 24-bit RGB color.
114 ///
115 /// Returns [`Color::Rgb`] with the supplied red, green, and blue bytes.
116 /// This is a `const` convenience constructor for code that prefers named
117 /// parameters over the enum variant.
118 pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
119 Self::Rgb(r, g, b)
120 }
121
122 /// Return the `0..=15` ANSI palette index for a named color.
123 ///
124 /// Returns `Some(0..=15)` for the sixteen named variants and `None` for
125 /// [`Color::Indexed`] and [`Color::Rgb`]. The index is also the xterm
126 /// 256-color palette index used when resolving a named color through
127 /// [`Color::to_rgb`].
128 pub const fn named_index(self) -> Option<u8> {
129 Some(match self {
130 Color::Black => 0,
131 Color::Red => 1,
132 Color::Green => 2,
133 Color::Yellow => 3,
134 Color::Blue => 4,
135 Color::Magenta => 5,
136 Color::Cyan => 6,
137 Color::White => 7,
138 Color::BrightBlack => 8,
139 Color::BrightRed => 9,
140 Color::BrightGreen => 10,
141 Color::BrightYellow => 11,
142 Color::BrightBlue => 12,
143 Color::BrightMagenta => 13,
144 Color::BrightCyan => 14,
145 Color::BrightWhite => 15,
146 Color::Indexed(_) | Color::Rgb(..) => return None,
147 })
148 }
149
150 /// Convert a `0..=15` ANSI palette index into the matching named color.
151 ///
152 /// Returns `Some` for values in `0..=15` and `None` for any higher value.
153 pub const fn from_named(v: u8) -> Option<Self> {
154 Some(match v {
155 0 => Color::Black,
156 1 => Color::Red,
157 2 => Color::Green,
158 3 => Color::Yellow,
159 4 => Color::Blue,
160 5 => Color::Magenta,
161 6 => Color::Cyan,
162 7 => Color::White,
163 8 => Color::BrightBlack,
164 9 => Color::BrightRed,
165 10 => Color::BrightGreen,
166 11 => Color::BrightYellow,
167 12 => Color::BrightBlue,
168 13 => Color::BrightMagenta,
169 14 => Color::BrightCyan,
170 15 => Color::BrightWhite,
171 _ => return None,
172 })
173 }
174
175 /// Return whether this is one of the sixteen named ANSI colors.
176 pub const fn is_named(self) -> bool {
177 self.named_index().is_some()
178 }
179
180 /// Return whether this is a bright/high-intensity named color.
181 ///
182 /// Bright colors are the named variants with palette indices `8..=15` and
183 /// encode as foreground SGR `90..=97` or background SGR `100..=107`.
184 /// [`Color::Indexed`] and [`Color::Rgb`] are never bright.
185 pub const fn is_named_bright(self) -> bool {
186 matches!(self.named_index(), Some(8..=15))
187 }
188
189 /// Convert this color to `(red, green, blue)` bytes.
190 ///
191 /// [`Color::Rgb`] returns its stored components unchanged. Named colors and
192 /// [`Color::Indexed`] are resolved through the xterm 256-color palette,
193 /// where named colors use their `0..=15` palette index.
194 pub fn to_rgb(self) -> (u8, u8, u8) {
195 match self {
196 Color::Rgb(r, g, b) => (r, g, b),
197 Color::Indexed(idx) => indexed_to_rgb(idx),
198 other => indexed_to_rgb(other.named_index().unwrap_or(0)),
199 }
200 }
201
202 /// Format this color as a lower-case `#rrggbb` hex string.
203 ///
204 /// Palette and indexed colors are resolved with [`Color::to_rgb`] first,
205 /// so the result is always the six-digit true-color form. The returned
206 /// string never includes alpha.
207 pub fn to_hex(self) -> String {
208 let (r, g, b) = self.to_rgb();
209 format!("#{r:02x}{g:02x}{b:02x}")
210 }
211
212 /// Convert this color to HSL components.
213 ///
214 /// Returns `(h, s, l)` with hue in degrees (`0.0..360.0`) and saturation
215 /// and lightness in `0.0..=1.0`. Palette and indexed colors are resolved to
216 /// RGB first. If the color is gray (`r == g == b`), saturation is `0.0` and
217 /// hue is reported as `0.0`.
218 pub fn to_hsl(self) -> (f32, f32, f32) {
219 let (r, g, b) = self.to_rgb();
220 let r = f32::from(r) / 255.0;
221 let g = f32::from(g) / 255.0;
222 let b = f32::from(b) / 255.0;
223 let max = r.max(g).max(b);
224 let min = r.min(g).min(b);
225 let delta = max - min;
226 let l = (max + min) / 2.0;
227 if delta == 0.0 {
228 return (0.0, 0.0, l);
229 }
230 let s = delta / (1.0 - (2.0 * l - 1.0).abs());
231 let h = if max == r {
232 60.0 * ((g - b) / delta).rem_euclid(6.0)
233 } else if max == g {
234 60.0 * ((b - r) / delta + 2.0)
235 } else {
236 60.0 * ((r - g) / delta + 4.0)
237 };
238 (h, s, l)
239 }
240
241 /// Parse a hex color string into [`Color::Rgb`].
242 ///
243 /// The leading `#` is optional. Accepted forms are:
244 ///
245 /// - `rgb` / `#rgb`: shorthand, each nibble is doubled (`f` becomes `ff`).
246 /// - `rrggbb` / `#rrggbb`: one byte per channel.
247 /// - `rrggbbaa` / `#rrggbbaa`: a trailing alpha byte is parsed for
248 /// validity but ignored.
249 ///
250 /// Returns `None` for any other length or for non-hexadecimal input. The
251 /// function does not panic.
252 pub fn hex(s: &str) -> Option<Color> {
253 let s = s.strip_prefix('#').unwrap_or(s);
254 let bytes = s.as_bytes();
255 let (r, g, b) = match bytes.len() {
256 3 => {
257 let r = hex_nibble(bytes[0])?;
258 let g = hex_nibble(bytes[1])?;
259 let b = hex_nibble(bytes[2])?;
260 (r * 17, g * 17, b * 17)
261 }
262 6 | 8 => {
263 let r = hex_byte(bytes[0], bytes[1])?;
264 let g = hex_byte(bytes[2], bytes[3])?;
265 let b = hex_byte(bytes[4], bytes[5])?;
266 // For the 8-digit form, validate the trailing alpha pair so
267 // non-hex input is rejected, then discard it: terminal colors
268 // carry no alpha channel.
269 if bytes.len() == 8 {
270 hex_byte(bytes[6], bytes[7])?;
271 }
272 (r, g, b)
273 }
274 _ => return None,
275 };
276 Some(Color::Rgb(r, g, b))
277 }
278
279 /// Construct [`Color::Rgb`] from HSL.
280 ///
281 /// `h` is hue in degrees and is wrapped into `0.0..360.0` with
282 /// `f32::rem_euclid`. `s` (saturation) and `l` (lightness) are clamped to
283 /// `0.0..=1.0`. The intermediate RGB values are rounded to the nearest byte
284 /// and clamped to `0..=255`.
285 pub fn hsl(h: f32, s: f32, l: f32) -> Color {
286 let h = h.rem_euclid(360.0);
287 let s = s.clamp(0.0, 1.0);
288 let l = l.clamp(0.0, 1.0);
289 let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
290 let hp = h / 60.0;
291 let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
292 let (r1, g1, b1) = match hp as u32 {
293 0 => (c, x, 0.0),
294 1 => (x, c, 0.0),
295 2 => (0.0, c, x),
296 3 => (0.0, x, c),
297 4 => (x, 0.0, c),
298 _ => (c, 0.0, x),
299 };
300 let m = l - c / 2.0;
301 let to = |v: f32| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8;
302 Color::Rgb(to(r1), to(g1), to(b1))
303 }
304}
305
306/// Parse one ASCII hex digit into its `0..=15` value.
307fn hex_nibble(b: u8) -> Option<u8> {
308 (b as char).to_digit(16).map(|d| d as u8)
309}
310
311/// Parse two ASCII hex digits into a byte.
312fn hex_byte(hi: u8, lo: u8) -> Option<u8> {
313 Some(hex_nibble(hi)? * 16 + hex_nibble(lo)?)
314}
315
316/// The standard xterm 256-color palette RGB values.
317const XTERM_COLORS: [(u8, u8, u8); 256] = {
318 let mut table = [(0u8, 0u8, 0u8); 256];
319
320 // 0-15: Standard colors (approximate)
321 table[0] = (0, 0, 0);
322 table[1] = (128, 0, 0);
323 table[2] = (0, 128, 0);
324 table[3] = (128, 128, 0);
325 table[4] = (0, 0, 128);
326 table[5] = (128, 0, 128);
327 table[6] = (0, 128, 128);
328 table[7] = (192, 192, 192);
329 table[8] = (128, 128, 128);
330 table[9] = (255, 0, 0);
331 table[10] = (0, 255, 0);
332 table[11] = (255, 255, 0);
333 table[12] = (0, 0, 255);
334 table[13] = (255, 0, 255);
335 table[14] = (0, 255, 255);
336 table[15] = (255, 255, 255);
337
338 // 16-231: 6x6x6 color cube
339 let cube_values: [u8; 6] = [0, 0x5f, 0x87, 0xaf, 0xd7, 0xff];
340 let mut i = 16usize;
341 let mut r = 0usize;
342 while r < 6 {
343 let mut g = 0usize;
344 while g < 6 {
345 let mut b = 0usize;
346 while b < 6 {
347 table[i] = (cube_values[r], cube_values[g], cube_values[b]);
348 i += 1;
349 b += 1;
350 }
351 g += 1;
352 }
353 r += 1;
354 }
355
356 // 232-255: Grayscale ramp
357 let mut i = 232usize;
358 while i < 256 {
359 let v = (8 + 10 * (i - 232)) as u8;
360 table[i] = (v, v, v);
361 i += 1;
362 }
363
364 table
365};
366
367/// Look up the RGB values for an xterm 256-color palette index.
368///
369/// Indices `0..=15` are the standard ANSI colors, `16..=231` are the 6×6×6
370/// color cube, and `232..=255` are the grayscale ramp. Every `u8` is a valid
371/// palette index, so this function does not fail or panic.
372pub(crate) fn indexed_to_rgb(idx: u8) -> (u8, u8, u8) {
373 XTERM_COLORS[idx as usize]
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 #[test]
381 fn named_index_round_trips() {
382 assert_eq!(Color::Black.named_index(), Some(0));
383 assert_eq!(Color::BrightWhite.named_index(), Some(15));
384 assert_eq!(Color::Indexed(42).named_index(), None);
385 assert_eq!(Color::Rgb(1, 2, 3).named_index(), None);
386 assert_eq!(Color::from_named(0), Some(Color::Black));
387 assert_eq!(Color::from_named(15), Some(Color::BrightWhite));
388 assert_eq!(Color::from_named(16), None);
389 assert!(Color::Green.is_named());
390 assert!(!Color::Indexed(2).is_named());
391 assert!(Color::BrightRed.is_named_bright());
392 assert!(!Color::Red.is_named_bright());
393 }
394
395 #[test]
396 fn test_color_to_rgb() {
397 assert_eq!(Color::Rgb(255, 128, 0).to_rgb(), (255, 128, 0));
398 assert_eq!(Color::Black.to_rgb(), (0, 0, 0));
399 }
400
401 #[test]
402 fn test_xterm_cube_color() {
403 // Index 196 = pure red in the 6x6x6 cube (5,0,0)
404 assert_eq!(indexed_to_rgb(196), (255, 0, 0));
405 }
406
407 #[test]
408 fn test_xterm_grayscale() {
409 assert_eq!(indexed_to_rgb(232), (8, 8, 8));
410 assert_eq!(indexed_to_rgb(255), (238, 238, 238));
411 }
412
413 #[test]
414 fn test_hex_forms() {
415 assert_eq!(Color::hex("#fff"), Some(Color::Rgb(255, 255, 255)));
416 assert_eq!(Color::hex("fff"), Some(Color::Rgb(255, 255, 255)));
417 assert_eq!(Color::hex("#abc"), Some(Color::Rgb(0xaa, 0xbb, 0xcc)));
418 assert_eq!(Color::hex("#ff8800"), Some(Color::Rgb(255, 136, 0)));
419 // 8-digit form: the trailing alpha pair is parsed but ignored.
420 assert_eq!(Color::hex("#ff880042"), Some(Color::Rgb(255, 136, 0)));
421 }
422
423 #[test]
424 fn test_hex_rejects_bad_input() {
425 assert_eq!(Color::hex("#ff"), None); // wrong length
426 assert_eq!(Color::hex("#fffff"), None); // wrong length
427 assert_eq!(Color::hex("#gggggg"), None); // non-hex digits
428 assert_eq!(Color::hex("#ff8800zz"), None); // non-hex alpha pair
429 assert_eq!(Color::hex(""), None);
430 }
431
432 #[test]
433 fn test_hsl() {
434 assert_eq!(Color::hsl(0.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
435 assert_eq!(Color::hsl(120.0, 1.0, 0.5), Color::Rgb(0, 255, 0));
436 assert_eq!(Color::hsl(240.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
437 // Hue wraps and full lightness is white regardless of hue.
438 assert_eq!(Color::hsl(360.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
439 assert_eq!(Color::hsl(123.0, 0.5, 1.0), Color::Rgb(255, 255, 255));
440 assert_eq!(Color::hsl(200.0, 0.7, 0.0), Color::Rgb(0, 0, 0));
441 }
442
443 #[test]
444 fn test_to_hex() {
445 assert_eq!(Color::Rgb(255, 136, 0).to_hex(), "#ff8800");
446 assert_eq!(Color::Rgb(0, 0, 0).to_hex(), "#000000");
447 assert_eq!(Color::Black.to_hex(), "#000000");
448 // Round-trips with `hex`.
449 assert_eq!(
450 Color::hex(&Color::Rgb(18, 52, 86).to_hex()),
451 Some(Color::Rgb(18, 52, 86))
452 );
453 }
454
455 #[test]
456 fn test_to_hsl() {
457 let approx = |a: f32, b: f32| (a - b).abs() < 0.01;
458 let (h, s, l) = Color::Rgb(255, 0, 0).to_hsl();
459 assert!(approx(h, 0.0) && approx(s, 1.0) && approx(l, 0.5));
460 let (h, s, l) = Color::Rgb(0, 255, 0).to_hsl();
461 assert!(approx(h, 120.0) && approx(s, 1.0) && approx(l, 0.5));
462 let (h, s, l) = Color::Rgb(0, 0, 255).to_hsl();
463 assert!(approx(h, 240.0) && approx(s, 1.0) && approx(l, 0.5));
464 // Gray: undefined hue reported as 0, zero saturation.
465 let (_, s, l) = Color::Rgb(128, 128, 128).to_hsl();
466 assert!(approx(s, 0.0) && approx(l, 128.0 / 255.0));
467 // hsl -> rgb -> hsl round-trip stays close.
468 let (h, s, l) = Color::hsl(210.0, 0.6, 0.45).to_hsl();
469 assert!(approx(h, 210.0) && approx(s, 0.6) && approx(l, 0.45));
470 }
471
472 #[test]
473 fn color_into_option() {
474 let c: Option<Color> = Color::Red.into();
475 assert_eq!(c, Some(Color::Red));
476 }
477}