| 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 |