Skip to content

Commit 3cdfad4

Browse files
authored
Merge pull request #12 from bash/xparsecolor
Improve and Fix Incorrect Color Parsing
2 parents 1ba319a + d0ab91c commit 3cdfad4

File tree

5 files changed

+203
-58
lines changed

5 files changed

+203
-58
lines changed

doc/terminal-survey.md

+6-2
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,15 @@ A list of terminals that were tested for support of DA1 (`CSI c`) and `OSC 10` /
3737

3838
<br>
3939

40-
**ℹ️ Note:**
40+
**ℹ️ Note 1:**
4141
Some Linux terminals are omitted since they all use the `vte` library behind the scenes. \
4242
Here's a non-exhaustive list: GNOME Terminal, (GNOME) Console, MATE Terminal, XFCE Terminal, (GNOME) Builder, (elementary) Terminal, LXTerminal, Guake.
4343

44-
[^1]: The response does not use the `XParseColor` format but rather a CSS-like hex code (e.g. `#AAAAAA`).
44+
**ℹ️ Note 2:**
45+
If not otherwise noted, the terminals respond using the `rgb:r(rrr)/g(ggg)/b(bbbb)` format.
46+
See [Color Strings](https://www.x.org/releases/current/doc/libX11/libX11/libX11.html#Color_Strings) for details on what is theoretically possible.
47+
48+
[^1]: Responds using the `#r(rrr)g(ggg)b(bbb)` format.
4549

4650
[Contour]: https://contour-terminal.org/
4751
[QTerminal]: https://github.com/lxqt/qterminal

src/color.rs

-55
Original file line numberDiff line numberDiff line change
@@ -46,47 +46,6 @@ impl From<rgb::RGB16> for Color {
4646
}
4747
}
4848

49-
impl Color {
50-
/// Parses an X11 color (see `man xparsecolor`).
51-
#[cfg(unix)]
52-
pub(crate) fn parse_x11(input: &str) -> Option<Self> {
53-
let raw_parts = input.strip_prefix("rgb:")?;
54-
let mut parts = raw_parts.split('/');
55-
let r = parse_channel(parts.next()?)?;
56-
let g = parse_channel(parts.next()?)?;
57-
let b = parse_channel(parts.next()?)?;
58-
Some(Color { r, g, b })
59-
}
60-
61-
// Some terminals (only Terminology found so far) respond with a
62-
// CSS-like hex color code.
63-
#[cfg(unix)]
64-
pub(crate) fn parse_css_like(input: &str) -> Option<Self> {
65-
let raw_parts = input.strip_prefix('#')?;
66-
let len = raw_parts.len();
67-
if len == 6 {
68-
let r = parse_channel(&raw_parts[..2])?;
69-
let g = parse_channel(&raw_parts[2..4])?;
70-
let b = parse_channel(&raw_parts[4..])?;
71-
Some(Color { r, g, b })
72-
} else {
73-
None
74-
}
75-
}
76-
}
77-
78-
#[cfg(unix)]
79-
fn parse_channel(input: &str) -> Option<u16> {
80-
let len = input.len();
81-
// From the xparsecolor man page:
82-
// h indicates the value scaled in 4 bits,
83-
// hh the value scaled in 8 bits,
84-
// hhh the value scaled in 12 bits, and
85-
// hhhh the value scaled in 16 bits, respectively.
86-
let shift = (1..=4).contains(&len).then_some(16 - 4 * len as u16)?;
87-
Some(u16::from_str_radix(input, 16).ok()? << shift)
88-
}
89-
9049
// Implementation of determining the perceived lightness
9150
// follows this excellent answer: https://stackoverflow.com/a/56678483
9251

@@ -116,7 +75,6 @@ fn luminance_to_perceived_lightness(luminance: f64) -> u8 {
11675
}
11776

11877
#[cfg(test)]
119-
#[allow(clippy::unwrap_used)]
12078
mod tests {
12179
use super::*;
12280

@@ -135,17 +93,4 @@ mod tests {
13593
};
13694
assert_eq!(100, white.perceived_lightness())
13795
}
138-
139-
#[test]
140-
#[cfg(unix)]
141-
fn parses_css_like_color() {
142-
assert_eq!(
143-
Color {
144-
r: 171 << 8,
145-
g: 205 << 8,
146-
b: 239 << 8
147-
},
148-
Color::parse_css_like("#ABCDEF").unwrap()
149-
)
150-
}
15196
}

src/lib.rs

+2
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ use thiserror::Error;
8888

8989
mod color;
9090
mod os;
91+
#[cfg(unix)]
92+
mod xparsecolor;
9193

9294
#[cfg(unix)]
9395
mod xterm;

src/xparsecolor.rs

+193
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
use crate::Color;
2+
3+
/// Parses a color value that follows the `XParseColor` format.
4+
/// See https://www.x.org/releases/current/doc/libX11/libX11/libX11.html#Color_Strings
5+
/// for a reference of what `XParseColor` supports.
6+
///
7+
/// Not all formats are supported, just the ones that are returned
8+
/// by the tested terminals. Feel free to open a PR if you encounter
9+
/// a terminal that returns a different format.
10+
pub(crate) fn xparsecolor(input: &str) -> Option<Color> {
11+
if let Some(stripped) = input.strip_prefix('#') {
12+
parse_sharp(stripped)
13+
} else if let Some(stripped) = input.strip_prefix("rgb:") {
14+
parse_rgb(stripped)
15+
} else {
16+
None
17+
}
18+
}
19+
20+
/// From the `xparsecolor` man page:
21+
/// > For backward compatibility, an older syntax for RGB Device is supported,
22+
/// > but its continued use is not encouraged. The syntax is an initial sharp sign character
23+
/// > followed by a numeric specification, in one of the following formats:
24+
/// >
25+
/// > The R, G, and B represent single hexadecimal digits.
26+
/// > When fewer than 16 bits each are specified, they represent the most significant bits of the value
27+
/// > (unlike the `rgb:` syntax, in which values are scaled).
28+
/// > For example, the string `#3a7` is the same as `#3000a0007000`.
29+
fn parse_sharp(input: &str) -> Option<Color> {
30+
const NUM_COMPONENTS: usize = 3;
31+
let len = input.len();
32+
if len % NUM_COMPONENTS == 0 && len <= NUM_COMPONENTS * 4 {
33+
let chunk_size = input.len() / NUM_COMPONENTS;
34+
let r = parse_channel_shifted(&input[0..chunk_size])?;
35+
let g = parse_channel_shifted(&input[chunk_size..chunk_size * 2])?;
36+
let b = parse_channel_shifted(&input[chunk_size * 2..])?;
37+
Some(Color { r, g, b })
38+
} else {
39+
None
40+
}
41+
}
42+
43+
fn parse_channel_shifted(input: &str) -> Option<u16> {
44+
let value = u16::from_str_radix(input, 16).ok()?;
45+
Some(value << ((4 - input.len()) * 4))
46+
}
47+
48+
/// From the `xparsecolor` man page:
49+
/// > An RGB Device specification is identified by the prefix `rgb:` and conforms to the following syntax:
50+
/// > ```text
51+
/// > rgb:<red>/<green>/<blue>
52+
/// >
53+
/// > <red>, <green>, <blue> := h | hh | hhh | hhhh
54+
/// > h := single hexadecimal digits (case insignificant)
55+
/// > ```
56+
/// > Note that *h* indicates the value scaled in 4 bits,
57+
/// > *hh* the value scaled in 8 bits, *hhh* the value scaled in 12 bits,
58+
/// > and *hhhh* the value scaled in 16 bits, respectively.
59+
fn parse_rgb(input: &str) -> Option<Color> {
60+
let mut parts = input.split('/');
61+
let r = parse_channel_scaled(parts.next()?)?;
62+
let g = parse_channel_scaled(parts.next()?)?;
63+
let b = parse_channel_scaled(parts.next()?)?;
64+
if parts.next().is_none() {
65+
Some(Color { r, g, b })
66+
} else {
67+
None
68+
}
69+
}
70+
71+
fn parse_channel_scaled(input: &str) -> Option<u16> {
72+
let len = input.len();
73+
if (1..=4).contains(&len) {
74+
let max = u32::pow(16, len as u32) - 1;
75+
let value = u32::from_str_radix(input, 16).ok()?;
76+
Some((u16::MAX as u32 * value / max) as u16)
77+
} else {
78+
None
79+
}
80+
}
81+
82+
#[cfg(test)]
83+
mod tests {
84+
use super::*;
85+
86+
// Tests adapted from alacritty/vte:
87+
// https://github.com/alacritty/vte/blob/ed51aa19b7ad060f62a75ec55ebb802ced850b1a/src/ansi.rs#L2134
88+
#[test]
89+
fn parses_valid_rgb_color() {
90+
assert_eq!(
91+
xparsecolor("rgb:f/e/d"),
92+
Some(Color {
93+
r: 0xffff,
94+
g: 0xeeee,
95+
b: 0xdddd,
96+
})
97+
);
98+
assert_eq!(
99+
xparsecolor("rgb:11/aa/ff"),
100+
Some(Color {
101+
r: 0x1111,
102+
g: 0xaaaa,
103+
b: 0xffff
104+
})
105+
);
106+
assert_eq!(
107+
xparsecolor("rgb:f/ed1/cb23"),
108+
Some(Color {
109+
r: 0xffff,
110+
g: 0xed1d,
111+
b: 0xcb23,
112+
})
113+
);
114+
assert_eq!(
115+
xparsecolor("rgb:ffff/0/0"),
116+
Some(Color {
117+
r: 0xffff,
118+
g: 0x0,
119+
b: 0x0
120+
})
121+
);
122+
}
123+
124+
#[test]
125+
fn fails_for_invalid_rgb_color() {
126+
assert!(xparsecolor("rgb:").is_none()); // Empty
127+
assert!(xparsecolor("rgb:f/f").is_none()); // Not enough channels
128+
assert!(xparsecolor("rgb:f/f/f/f").is_none()); // Too many channels
129+
assert!(xparsecolor("rgb:f//f").is_none()); // Empty channel
130+
assert!(xparsecolor("rgb:ffff/ffff/fffff").is_none()); // Too many digits for one channel
131+
}
132+
133+
// Tests adapted from alacritty/vte:
134+
// https://github.com/alacritty/vte/blob/ed51aa19b7ad060f62a75ec55ebb802ced850b1a/src/ansi.rs#L2142
135+
#[test]
136+
fn parses_valid_sharp_color() {
137+
assert_eq!(
138+
xparsecolor("#1af"),
139+
Some(Color {
140+
r: 0x1000,
141+
g: 0xa000,
142+
b: 0xf000,
143+
})
144+
);
145+
assert_eq!(
146+
xparsecolor("#1AF"),
147+
Some(Color {
148+
r: 0x1000,
149+
g: 0xa000,
150+
b: 0xf000,
151+
})
152+
);
153+
assert_eq!(
154+
xparsecolor("#11aaff"),
155+
Some(Color {
156+
r: 0x1100,
157+
g: 0xaa00,
158+
b: 0xff00
159+
})
160+
);
161+
assert_eq!(
162+
xparsecolor("#110aa0ff0"),
163+
Some(Color {
164+
r: 0x1100,
165+
g: 0xaa00,
166+
b: 0xff00
167+
})
168+
);
169+
assert_eq!(
170+
xparsecolor("#1100aa00ff00"),
171+
Some(Color {
172+
r: 0x1100,
173+
g: 0xaa00,
174+
b: 0xff00
175+
})
176+
);
177+
assert_eq!(
178+
xparsecolor("#123456789ABC"),
179+
Some(Color {
180+
r: 0x1234,
181+
g: 0x5678,
182+
b: 0x9ABC
183+
})
184+
);
185+
}
186+
187+
#[test]
188+
fn fails_for_invalid_sharp_color() {
189+
assert!(xparsecolor("#").is_none()); // Empty
190+
assert!(xparsecolor("#1234").is_none()); // Not divisible by three
191+
assert!(xparsecolor("#123456789ABCDEF").is_none()); // Too many components
192+
}
193+
}

src/xterm.rs

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use self::io_utils::{read_until2, TermReader};
2+
use crate::xparsecolor::xparsecolor;
23
use crate::{Color, ColorScheme, Error, QueryOptions, Result};
34
use std::env;
45
use std::io::{self, BufRead, BufReader, Write as _};
@@ -74,7 +75,7 @@ fn parse_response(response: String, prefix: &str) -> Result<Color> {
7475
.strip_suffix('\x07')
7576
.or(response.strip_suffix("\x1b\\"))
7677
})
77-
.and_then(|c| Color::parse_x11(c).or_else(|| Color::parse_css_like(c)))
78+
.and_then(xparsecolor)
7879
.ok_or_else(|| Error::Parse(response))
7980
}
8081

0 commit comments

Comments
 (0)