xterm_color/
lib.rs

1//! Parses the subset of X11 [Color Strings][x11] emitted by terminals in response to [`OSC` color queries][osc] (`OSC 10`, `OSC 11`, ...).
2//!
3//! [osc]: https://www.invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
4//! [x11]: https://www.x.org/releases/current/doc/libX11/libX11/libX11.html#Color_Strings
5//!
6//! ```
7//! use xterm_color::Color;
8//!
9//! assert_eq!(
10//!    Color::parse(b"rgb:11/aa/ff").unwrap(),
11//!    Color::rgb(0x1111, 0xaaaa, 0xffff)
12//! );
13//! ```
14
15use core::fmt;
16use std::error;
17use std::marker::PhantomData;
18use std::str::from_utf8;
19
20/// An RGB color with 16 bits per channel and an optional alpha channel.
21#[derive(Debug, Clone, Eq, PartialEq)]
22#[allow(clippy::exhaustive_structs)]
23pub struct Color {
24    /// Red
25    pub red: u16,
26    /// Green
27    pub green: u16,
28    /// Blue
29    pub blue: u16,
30    /// Alpha.
31    ///
32    /// Can almost always be ignored as it is rarely set to
33    /// something other than the default (`0xffff`).
34    pub alpha: u16,
35}
36
37impl Color {
38    /// Construct a new [`Color`] from (r, g, b) components, with the default alpha (`0xffff`).
39    pub const fn rgb(red: u16, green: u16, blue: u16) -> Self {
40        Self {
41            red,
42            green,
43            blue,
44            alpha: u16::MAX,
45        }
46    }
47
48    /// Parses the subset of X11 [Color Strings](https://www.x.org/releases/current/doc/libX11/libX11/libX11.html#Color_Strings)
49    /// emitted by terminals in response to `OSC` color queries (`OSC 10`, `OSC 11`, ...).
50    ///
51    /// This function is a rough analogue to `XParseColor`.
52    ///
53    /// ## Accepted Formats
54    /// * `#<red><green><blue>`
55    /// * `rgb:<red>/<green>/<blue>`
56    /// * `rgba:<red>/<green>/<blue>/<alpha>` (rxvt-unicode extension)
57    ///
58    /// where `<red>`, `<green>` and `<blue>` are hexadecimal numbers with 1-4 digits.
59    #[doc(alias = "XParseColor")]
60    pub fn parse(input: &[u8]) -> Result<Color, ColorParseError> {
61        xparsecolor(input).ok_or(ColorParseError(PhantomData))
62    }
63}
64
65/// Error which can be returned when parsing a color.
66#[derive(Debug, Clone)]
67pub struct ColorParseError(PhantomData<()>);
68
69impl error::Error for ColorParseError {}
70
71impl fmt::Display for ColorParseError {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        f.write_str("invalid color spec")
74    }
75}
76
77fn xparsecolor(input: &[u8]) -> Option<Color> {
78    if let Some(stripped) = input.strip_prefix(b"#") {
79        parse_sharp(from_utf8(stripped).ok()?)
80    } else if let Some(stripped) = input.strip_prefix(b"rgb:") {
81        parse_rgb(from_utf8(stripped).ok()?)
82    } else if let Some(stripped) = input.strip_prefix(b"rgba:") {
83        parse_rgba(from_utf8(stripped).ok()?)
84    } else {
85        None
86    }
87}
88
89/// From the `xparsecolor` man page:
90/// > For backward compatibility, an older syntax for RGB Device is supported,
91/// > but its continued use is not encouraged. The syntax is an initial sharp sign character
92/// > followed by a numeric specification, in one of the following formats:
93/// >
94/// > The R, G, and B represent single hexadecimal digits.
95/// > When fewer than 16 bits each are specified, they represent the most significant bits of the value
96/// > (unlike the `rgb:` syntax, in which values are scaled).
97/// > For example, the string `#3a7` is the same as `#3000a0007000`.
98fn parse_sharp(input: &str) -> Option<Color> {
99    const NUM_COMPONENTS: usize = 3;
100    let len = input.len();
101    if len % NUM_COMPONENTS == 0 && len <= NUM_COMPONENTS * 4 {
102        let chunk_size = input.len() / NUM_COMPONENTS;
103        let red = parse_channel_shifted(&input[0..chunk_size])?;
104        let green = parse_channel_shifted(&input[chunk_size..chunk_size * 2])?;
105        let blue = parse_channel_shifted(&input[chunk_size * 2..])?;
106        Some(Color::rgb(red, green, blue))
107    } else {
108        None
109    }
110}
111
112fn parse_channel_shifted(input: &str) -> Option<u16> {
113    let value = u16::from_str_radix(input, 16).ok()?;
114    Some(value << ((4 - input.len()) * 4))
115}
116
117/// From the `xparsecolor` man page:
118/// > An RGB Device specification is identified by the prefix `rgb:` and conforms to the following syntax:
119/// > ```text
120/// > rgb:<red>/<green>/<blue>
121/// >
122/// >     <red>, <green>, <blue> := h | hh | hhh | hhhh
123/// >     h := single hexadecimal digits (case insignificant)
124/// > ```
125/// > Note that *h* indicates the value scaled in 4 bits,
126/// > *hh* the value scaled in 8 bits, *hhh* the value scaled in 12 bits,
127/// > and *hhhh* the value scaled in 16 bits, respectively.
128fn parse_rgb(input: &str) -> Option<Color> {
129    let mut parts = input.split('/');
130    let red = parse_channel_scaled(parts.next()?)?;
131    let green = parse_channel_scaled(parts.next()?)?;
132    let blue = parse_channel_scaled(parts.next()?)?;
133    if parts.next().is_none() {
134        Some(Color::rgb(red, green, blue))
135    } else {
136        None
137    }
138}
139
140/// Some terminals such as urxvt (rxvt-unicode) optionally support
141/// an alpha channel and sometimes return colors in the format `rgba:<red>/<green>/<blue>/<alpha>`.
142///
143/// Dropping the alpha channel is a best-effort thing as
144/// the effective color (when combined with a background color)
145/// could have a completely different perceived lightness value.
146///
147/// Test with `urxvt -depth 32 -fg grey90 -bg rgba:0000/0000/4444/cccc`
148fn parse_rgba(input: &str) -> Option<Color> {
149    let mut parts = input.split('/');
150    let red = parse_channel_scaled(parts.next()?)?;
151    let green = parse_channel_scaled(parts.next()?)?;
152    let blue = parse_channel_scaled(parts.next()?)?;
153    let alpha = parse_channel_scaled(parts.next()?)?;
154    if parts.next().is_none() {
155        Some(Color {
156            red,
157            green,
158            blue,
159            alpha,
160        })
161    } else {
162        None
163    }
164}
165
166fn parse_channel_scaled(input: &str) -> Option<u16> {
167    let len = input.len();
168    if (1..=4).contains(&len) {
169        let max = u32::pow(16, len as u32) - 1;
170        let value = u32::from_str_radix(input, 16).ok()?;
171        Some((u16::MAX as u32 * value / max) as u16)
172    } else {
173        None
174    }
175}
176
177// Implementation of determining the perceived lightness
178// follows this excellent answer: https://stackoverflow.com/a/56678483
179impl Color {
180    /// Perceptual lightness (L*) as a value between 0.0 (black) and 1.0 (white)
181    /// where 0.5 is the perceptual middle gray.
182    ///
183    /// Note that the color's alpha is ignored.
184    pub fn perceived_lightness(&self) -> f32 {
185        luminance_to_perceived_lightness(self.luminance()) / 100.
186    }
187
188    /// Luminance (`Y`) calculated using the [CIE XYZ formula](https://en.wikipedia.org/wiki/Relative_luminance).
189    fn luminance(&self) -> f32 {
190        let r = gamma_function(f32::from(self.red) / f32::from(u16::MAX));
191        let g = gamma_function(f32::from(self.green) / f32::from(u16::MAX));
192        let b = gamma_function(f32::from(self.blue) / f32::from(u16::MAX));
193        0.2126 * r + 0.7152 * g + 0.0722 * b
194    }
195}
196
197/// Converts a non-linear sRGB value to a linear one via [gamma correction](https://en.wikipedia.org/wiki/Gamma_correction).
198// Taken from bevy_color: https://github.com/bevyengine/bevy/blob/0403948aa23a748abd2a2aac05eef1209d66674e/crates/bevy_color/src/srgba.rs#L211
199fn gamma_function(value: f32) -> f32 {
200    if value <= 0.0 {
201        return value;
202    }
203    if value <= 0.04045 {
204        value / 12.92 // linear falloff in dark values
205    } else {
206        ((value + 0.055) / 1.055).powf(2.4) // gamma curve in other area
207    }
208}
209
210/// Perceptual lightness (L*) calculated using the [CIEXYZ to CIELAB formula](https://en.wikipedia.org/wiki/CIELAB_color_space).
211fn luminance_to_perceived_lightness(luminance: f32) -> f32 {
212    if luminance <= 216. / 24389. {
213        luminance * (24389. / 27.)
214    } else {
215        luminance.cbrt() * 116. - 16.
216    }
217}
218
219#[cfg(doctest)]
220#[doc = include_str!("../readme.md")]
221pub mod readme_doctests {}
222
223#[cfg(test)]
224#[allow(clippy::unwrap_used)]
225mod tests {
226    use super::*;
227
228    // Tests adapted from alacritty/vte:
229    // https://github.com/alacritty/vte/blob/ed51aa19b7ad060f62a75ec55ebb802ced850b1a/src/ansi.rs#L2134
230    #[test]
231    fn parses_valid_rgb_color() {
232        assert_eq!(
233            Color::parse(b"rgb:f/e/d").unwrap(),
234            Color {
235                red: 0xffff,
236                green: 0xeeee,
237                blue: 0xdddd,
238                alpha: u16::MAX,
239            }
240        );
241        assert_eq!(
242            Color::parse(b"rgb:11/aa/ff").unwrap(),
243            Color {
244                red: 0x1111,
245                green: 0xaaaa,
246                blue: 0xffff,
247                alpha: u16::MAX,
248            }
249        );
250        assert_eq!(
251            Color::parse(b"rgb:f/ed1/cb23").unwrap(),
252            Color {
253                red: 0xffff,
254                green: 0xed1d,
255                blue: 0xcb23,
256                alpha: u16::MAX,
257            }
258        );
259        assert_eq!(
260            Color::parse(b"rgb:ffff/0/0").unwrap(),
261            Color {
262                red: 0xffff,
263                green: 0x0,
264                blue: 0x0,
265                alpha: u16::MAX,
266            }
267        );
268    }
269
270    #[test]
271    fn parses_valid_rgba_color() {
272        assert_eq!(
273            Color::parse(b"rgba:0000/0000/4443/cccc").unwrap(),
274            Color {
275                red: 0x0000,
276                green: 0x0000,
277                blue: 0x4443,
278                alpha: 0xcccc,
279            }
280        );
281    }
282
283    #[test]
284    fn fails_for_invalid_rgb_color() {
285        assert!(Color::parse(b"rgb:").is_err()); // Empty
286        assert!(Color::parse(b"rgb:f/f").is_err()); // Not enough channels
287        assert!(Color::parse(b"rgb:f/f/f/f").is_err()); // Too many channels
288        assert!(Color::parse(b"rgb:f//f").is_err()); // Empty channel
289        assert!(Color::parse(b"rgb:ffff/ffff/fffff").is_err()); // Too many digits for one channel
290    }
291
292    // Tests adapted from alacritty/vte:
293    // https://github.com/alacritty/vte/blob/ed51aa19b7ad060f62a75ec55ebb802ced850b1a/src/ansi.rs#L2142
294    #[test]
295    fn parses_valid_sharp_color() {
296        assert_eq!(
297            Color::parse(b"#1af").unwrap(),
298            Color {
299                red: 0x1000,
300                green: 0xa000,
301                blue: 0xf000,
302                alpha: u16::MAX,
303            }
304        );
305        assert_eq!(
306            Color::parse(b"#1AF").unwrap(),
307            Color {
308                red: 0x1000,
309                green: 0xa000,
310                blue: 0xf000,
311                alpha: u16::MAX,
312            }
313        );
314        assert_eq!(
315            Color::parse(b"#11aaff").unwrap(),
316            Color {
317                red: 0x1100,
318                green: 0xaa00,
319                blue: 0xff00,
320                alpha: u16::MAX,
321            }
322        );
323        assert_eq!(
324            Color::parse(b"#110aa0ff0").unwrap(),
325            Color {
326                red: 0x1100,
327                green: 0xaa00,
328                blue: 0xff00,
329                alpha: u16::MAX,
330            }
331        );
332        assert_eq!(
333            Color::parse(b"#1100aa00ff00").unwrap(),
334            Color {
335                red: 0x1100,
336                green: 0xaa00,
337                blue: 0xff00,
338                alpha: u16::MAX,
339            }
340        );
341        assert_eq!(
342            Color::parse(b"#123456789ABC").unwrap(),
343            Color {
344                red: 0x1234,
345                green: 0x5678,
346                blue: 0x9ABC,
347                alpha: u16::MAX,
348            }
349        );
350    }
351
352    #[test]
353    fn fails_for_invalid_sharp_color() {
354        assert!(Color::parse(b"#").is_err()); // Empty
355        assert!(Color::parse(b"#1234").is_err()); // Not divisible by three
356        assert!(Color::parse(b"#123456789ABCDEF").is_err()); // Too many components
357    }
358
359    #[test]
360    fn black_has_perceived_lightness_zero() {
361        let black = Color::rgb(0, 0, 0);
362        assert_eq!(0.0, black.perceived_lightness())
363    }
364
365    #[test]
366    fn white_has_perceived_lightness_one() {
367        let white = Color::rgb(u16::MAX, u16::MAX, u16::MAX);
368        assert_eq!(1.0, white.perceived_lightness())
369    }
370}