返回 CodeWhale
color.rs
1 //! Color depth, quantization and contrast.
2 //!
3 //! Extracted from the Codewhale engine's `crates/palette` (`adapt.rs` and
4 //! `contrast.rs` at `Hmbown/CodeWhale` `58b1dd3dd`; depth detection last
5 //! changed in `c6416b203`, "Honor NO_COLOR with colorless terminal output").
6 //! The engine's legacy-constant remapping is not carried over: components
7 //! here name roles, and [`crate::theme::Theme`] resolves a role for the
8 //! terminal's depth when it paints.
9
10 use ratatui::style::Color;
11
12 /// How many colors the terminal can show.
13 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14 pub enum ColorDepth {
15 /// `NO_COLOR` is set: the terminal owns foreground and background. Bold,
16 /// dim, underline and every glyph still render.
17 Monochrome,
18 /// 16 named colors, remapped by the user's terminal profile.
19 Ansi16,
20 /// The fixed xterm 256-color cube and gray ramp.
21 Ansi256,
22 /// 24-bit color.
23 TrueColor,
24 }
25
26 impl ColorDepth {
27 /// Detect the active terminal's depth from the environment.
28 #[must_use]
29 pub fn detect() -> Self {
30 Self::detect_with(|key| std::env::var_os(key))
31 }
32
33 /// Decide depth from an injected environment reader, so every branch is
34 /// testable without touching the process environment.
35 ///
36 /// Order: `NO_COLOR` (present and non-empty, per no-color.org) wins, then
37 /// `COLORTERM`, Windows Terminal, known truecolor `TERM_PROGRAM`s, and
38 /// finally `TERM`. An unknown `TERM` gets 256 colors, not 24-bit: older
39 /// remote terminals render truecolor backgrounds as bright blocks.
40 #[must_use]
41 pub fn detect_with(get: impl Fn(&str) -> Option<std::ffi::OsString>) -> Self {
42 if let Some(no_color) = get("NO_COLOR")
43 && !no_color.is_empty()
44 {
45 return Self::Monochrome;
46 }
47 if let Some(ct) = get("COLORTERM") {
48 let ct = ct.to_string_lossy().to_ascii_lowercase();
49 if ct.contains("truecolor") || ct.contains("24bit") {
50 return Self::TrueColor;
51 }
52 }
53 if get("WT_SESSION").is_some() {
54 return Self::TrueColor;
55 }
56 if let Some(term_program) = get("TERM_PROGRAM") {
57 let term_program = term_program.to_string_lossy().to_ascii_lowercase();
58 if ["iterm", "wezterm", "vscode", "warp"]
59 .iter()
60 .any(|name| term_program.contains(name))
61 {
62 return Self::TrueColor;
63 }
64 }
65 let term = get("TERM")
66 .map(|t| t.to_string_lossy().to_ascii_lowercase())
67 .unwrap_or_default();
68 if term.contains("truecolor") || term.contains("24bit") {
69 Self::TrueColor
70 } else if term.contains("256") {
71 Self::Ansi256
72 } else if term.is_empty() || term == "dumb" {
73 Self::Ansi16
74 } else {
75 Self::Ansi256
76 }
77 }
78 }
79
80 /// A 24-bit color from a `0xRRGGBB` token value.
81 #[must_use]
82 pub const fn rgb(hex: u32) -> Color {
83 Color::Rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
84 }
85
86 /// Mix two RGB colors at `alpha` (0.0 = `bg`, 1.0 = `fg`). A non-RGB input
87 /// returns `fg`: a named color has no meaningful blend.
88 #[must_use]
89 pub fn blend(fg: Color, bg: Color, alpha: f32) -> Color {
90 let alpha = alpha.clamp(0.0, 1.0);
91 match (fg, bg) {
92 (Color::Rgb(fr, fg_, fb), Color::Rgb(br, bg_, bb)) => {
93 let mix = |a: u8, b: u8| -> u8 {
94 let a = f32::from(a);
95 let b = f32::from(b);
96 (b + (a - b) * alpha).round().clamp(0.0, 255.0) as u8
97 };
98 Color::Rgb(mix(fr, br), mix(fg_, bg_), mix(fb, bb))
99 }
100 _ => fg,
101 }
102 }
103
104 /// Map an RGB triple to the nearest xterm 256-color index. Uses only the
105 /// stable 6x6x6 cube and gray ramp (16..=255), never the user-remapped 0..=15.
106 #[must_use]
107 pub fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
108 const CUBE_LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
109
110 fn nearest_cube_level(channel: u8) -> usize {
111 CUBE_LEVELS
112 .iter()
113 .enumerate()
114 .min_by_key(|(_, level)| channel.abs_diff(**level))
115 .map_or(0, |(idx, _)| idx)
116 }
117
118 fn dist_sq(a: (u8, u8, u8), b: (u8, u8, u8)) -> u32 {
119 let dr = i32::from(a.0) - i32::from(b.0);
120 let dg = i32::from(a.1) - i32::from(b.1);
121 let db = i32::from(a.2) - i32::from(b.2);
122 (dr * dr + dg * dg + db * db) as u32
123 }
124
125 let ri = nearest_cube_level(r);
126 let gi = nearest_cube_level(g);
127 let bi = nearest_cube_level(b);
128 let cube_rgb = (CUBE_LEVELS[ri], CUBE_LEVELS[gi], CUBE_LEVELS[bi]);
129 let cube_index = 16 + (36 * ri) as u8 + (6 * gi) as u8 + bi as u8;
130
131 let avg = ((u16::from(r) + u16::from(g) + u16::from(b)) / 3) as u8;
132 let gray_i = if avg <= 8 {
133 0
134 } else if avg >= 238 {
135 23
136 } else {
137 ((u16::from(avg) - 8 + 5) / 10).min(23) as u8
138 };
139 let gray = 8 + 10 * gray_i;
140 let gray_index = 232 + gray_i;
141
142 if dist_sq((r, g, b), (gray, gray, gray)) < dist_sq((r, g, b), cube_rgb) {
143 gray_index
144 } else {
145 cube_index
146 }
147 }
148
149 /// The RGB an xterm 256-color index renders as. Only 16..=255 are fixed by
150 /// the specification; 0..=15 belong to the user's profile.
151 #[must_use]
152 pub fn indexed_rgb(index: u8) -> Option<(u8, u8, u8)> {
153 const CUBE_LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
154 match index {
155 0..=15 => None,
156 16..=231 => {
157 let i = index - 16;
158 Some((
159 CUBE_LEVELS[usize::from(i / 36)],
160 CUBE_LEVELS[usize::from((i / 6) % 6)],
161 CUBE_LEVELS[usize::from(i % 6)],
162 ))
163 }
164 232..=255 => {
165 let v = 8 + 10 * (index - 232);
166 Some((v, v, v))
167 }
168 }
169 }
170
171 /// The RGB a color is *known* to render as, or `None` when the terminal owns
172 /// the decision (`Reset`, named colors, indices 0..=15).
173 #[must_use]
174 pub fn resolvable_rgb(color: Color) -> Option<(u8, u8, u8)> {
175 match color {
176 Color::Rgb(r, g, b) => Some((r, g, b)),
177 Color::Indexed(index) => indexed_rgb(index),
178 _ => None,
179 }
180 }
181
182 /// WCAG 2.x relative luminance in `0.0..=1.0`, or `None` for a color whose
183 /// RGB the terminal decides.
184 #[must_use]
185 pub fn relative_luminance(color: Color) -> Option<f32> {
186 let (r, g, b) = resolvable_rgb(color)?;
187 fn channel(value: u8) -> f32 {
188 let c = f32::from(value) / 255.0;
189 if c <= 0.039_28 {
190 c / 12.92
191 } else {
192 ((c + 0.055) / 1.055).powf(2.4)
193 }
194 }
195 Some(0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b))
196 }
197
198 /// WCAG contrast ratio in `1.0..=21.0`, or `None` if either side is
199 /// terminal-defined.
200 #[must_use]
201 pub fn contrast_ratio(fg: Color, bg: Color) -> Option<f32> {
202 let a = relative_luminance(fg)?;
203 let b = relative_luminance(bg)?;
204 let (hi, lo) = if a >= b { (a, b) } else { (b, a) };
205 Some((hi + 0.05) / (lo + 0.05))
206 }
207
208 /// `hex` moved `amount` of the way toward `toward`, then rescaled so its
209 /// WCAG relative luminance stays `hex`'s. Contrast depends only on
210 /// luminance, so every contrast pair audited for `hex` holds for the result
211 /// (within rounding, which the audits check). Colors are `0xRRGGBB`.
212 #[must_use]
213 pub fn tint_keeping_luminance(hex: u32, toward: u32, amount: f64) -> u32 {
214 fn split(v: u32) -> [f64; 3] {
215 [(v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff].map(|c| f64::from(c) / 255.0)
216 }
217 fn lin(c: f64) -> f64 {
218 if c <= 0.039_28 {
219 c / 12.92
220 } else {
221 ((c + 0.055) / 1.055).powf(2.4)
222 }
223 }
224 fn unlin(v: f64) -> u32 {
225 let v = v.clamp(0.0, 1.0);
226 let s = if v <= 0.003_130_8 {
227 v * 12.92
228 } else {
229 1.055 * v.powf(1.0 / 2.4) - 0.055
230 };
231 (s * 255.0).round().clamp(0.0, 255.0) as u32
232 }
233 let lum = |c: [f64; 3]| 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
234 let amount = amount.clamp(0.0, 1.0);
235 let from = split(hex);
236 let to = split(toward);
237 let mixed: [f64; 3] = std::array::from_fn(|i| {
238 let a = (from[i] * 255.0).round();
239 let b = (to[i] * 255.0).round();
240 lin((a + (b - a) * amount).round() / 255.0)
241 });
242 let want = lum(from.map(lin));
243 let have = lum(mixed);
244 let k = if have > 0.0 { want / have } else { 1.0 };
245 let [r, g, b] = mixed.map(|c| unlin(c * k));
246 (r << 16) | (g << 8) | b
247 }
248
249 #[cfg(test)]
250 mod tests {
251 use super::*;
252 use std::collections::HashMap;
253 use std::ffi::OsString;
254
255 fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<OsString> {
256 let map: HashMap<String, OsString> = pairs
257 .iter()
258 .map(|(k, v)| ((*k).to_string(), OsString::from(*v)))
259 .collect();
260 move |key| map.get(key).cloned()
261 }
262
263 #[test]
264 fn no_color_wins_over_every_other_signal() {
265 let get = env(&[("NO_COLOR", "1"), ("COLORTERM", "truecolor")]);
266 assert_eq!(ColorDepth::detect_with(get), ColorDepth::Monochrome);
267 // An empty NO_COLOR is not a request (no-color.org).
268 let get = env(&[("NO_COLOR", ""), ("COLORTERM", "truecolor")]);
269 assert_eq!(ColorDepth::detect_with(get), ColorDepth::TrueColor);
270 }
271
272 #[test]
273 fn term_fallbacks_are_conservative() {
274 assert_eq!(
275 ColorDepth::detect_with(env(&[("TERM", "xterm-256color")])),
276 ColorDepth::Ansi256
277 );
278 assert_eq!(ColorDepth::detect_with(env(&[])), ColorDepth::Ansi16);
279 assert_eq!(
280 ColorDepth::detect_with(env(&[("TERM", "dumb")])),
281 ColorDepth::Ansi16
282 );
283 assert_eq!(
284 ColorDepth::detect_with(env(&[("TERM", "screen")])),
285 ColorDepth::Ansi256
286 );
287 assert_eq!(
288 ColorDepth::detect_with(env(&[("TERM_PROGRAM", "WezTerm")])),
289 ColorDepth::TrueColor
290 );
291 }
292
293 #[test]
294 fn ansi256_round_trips_through_the_fixed_cube() {
295 for index in 16..=255u8 {
296 let (r, g, b) = indexed_rgb(index).unwrap();
297 let back = rgb_to_ansi256(r, g, b);
298 assert_eq!(indexed_rgb(back), Some((r, g, b)), "index {index}");
299 }
300 assert_eq!(indexed_rgb(7), None);
301 }
302
303 #[test]
304 fn contrast_matches_wcag_extremes() {
305 let ratio = contrast_ratio(rgb(0xffffff), rgb(0x000000)).unwrap();
306 assert!((ratio - 21.0).abs() < 0.01);
307 assert_eq!(contrast_ratio(Color::Reset, rgb(0)), None);
308 }
309 }
310
310 lines RUST