1use std::fmt;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8pub struct Rgb {
9 pub r: u8,
11 pub g: u8,
13 pub b: u8,
15}
16
17impl Rgb {
18 #[must_use]
20 pub const fn new(r: u8, g: u8, b: u8) -> Self {
21 Self { r, g, b }
22 }
23
24 #[must_use]
26 pub fn parse_hex(text: &str) -> Option<Self> {
27 let hex = text.strip_prefix('#')?;
28 if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
29 return None;
30 }
31 let channel = |s: &str| u8::from_str_radix(s, 16).ok();
32 match hex.len() {
33 6 => Some(Self::new(channel(&hex[0..2])?, channel(&hex[2..4])?, channel(&hex[4..6])?)),
34 3 => {
35 let short = |i: usize| channel(&hex[i..=i]).map(|v| v * 17);
36 Some(Self::new(short(0)?, short(1)?, short(2)?))
37 }
38 _ => None,
39 }
40 }
41
42 #[must_use]
45 pub fn mix(self, other: Self, t: f32) -> Self {
46 let t = t.clamp(0.0, 1.0);
47 let blend = |a: u8, b: u8| {
48 let value = f32::from(a) + (f32::from(b) - f32::from(a)) * t;
49 value.round().clamp(0.0, 255.0) as u8
51 };
52 Self::new(blend(self.r, other.r), blend(self.g, other.g), blend(self.b, other.b))
53 }
54
55 #[must_use]
57 pub fn relative_luminance(self) -> f64 {
58 let [r, g, b] = self.linear();
59 0.2126 * r + 0.7152 * g + 0.0722 * b
60 }
61
62 #[must_use]
64 pub fn contrast_ratio(self, other: Self) -> f64 {
65 let (a, b) = (self.relative_luminance(), other.relative_luminance());
66 let (light, dark) = if a >= b { (a, b) } else { (b, a) };
67 (light + 0.05) / (dark + 0.05)
68 }
69
70 #[must_use]
72 pub fn oklab(self) -> [f64; 3] {
73 let [r, g, b] = self.linear();
74 let l = 0.412_221_470_8 * r + 0.536_332_536_3 * g + 0.051_445_992_9 * b;
75 let m = 0.211_903_498_2 * r + 0.680_699_545_1 * g + 0.107_396_956_6 * b;
76 let s = 0.088_302_461_9 * r + 0.281_718_837_6 * g + 0.629_978_700_5 * b;
77 let (l, m, s) = (l.cbrt(), m.cbrt(), s.cbrt());
78 [
79 0.210_454_255_3 * l + 0.793_617_785_0 * m - 0.004_072_046_8 * s,
80 1.977_998_495_1 * l - 2.428_592_205_0 * m + 0.450_593_709_9 * s,
81 0.025_904_037_1 * l + 0.782_771_766_2 * m - 0.808_675_766_0 * s,
82 ]
83 }
84
85 #[must_use]
88 pub fn perceptual_distance(self, other: Self) -> f64 {
89 let [l1, a1, b1] = self.oklab();
90 let [l2, a2, b2] = other.oklab();
91 ((l1 - l2).powi(2) + (a1 - a2).powi(2) + (b1 - b2).powi(2)).sqrt()
92 }
93
94 #[must_use]
96 pub fn to_ansi256(self) -> u8 {
97 const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
98 let nearest_level = |v: u8| (0u8..6).min_by_key(|&i| v.abs_diff(LEVELS[usize::from(i)])).unwrap_or(0);
99 let (ri, gi, bi) = (nearest_level(self.r), nearest_level(self.g), nearest_level(self.b));
100 let cube = Self::new(LEVELS[usize::from(ri)], LEVELS[usize::from(gi)], LEVELS[usize::from(bi)]);
101 let cube_index = 16 + 36 * ri + 6 * gi + bi;
102
103 let average = (u16::from(self.r) + u16::from(self.g) + u16::from(self.b)) / 3;
104 let step = (average.saturating_sub(3) / 10).min(23) as u8;
106 let grey_value = 8 + 10 * step;
107 let grey = Self::new(grey_value, grey_value, grey_value);
108 let grey_index = 232 + step;
109
110 if self.squared_distance(grey) < self.squared_distance(cube) { grey_index } else { cube_index }
111 }
112
113 #[must_use]
115 pub fn to_ansi16(self) -> u8 {
116 const PALETTE: [Rgb; 16] = [
117 Rgb::new(0, 0, 0),
118 Rgb::new(205, 0, 0),
119 Rgb::new(0, 205, 0),
120 Rgb::new(205, 205, 0),
121 Rgb::new(0, 0, 238),
122 Rgb::new(205, 0, 205),
123 Rgb::new(0, 205, 205),
124 Rgb::new(229, 229, 229),
125 Rgb::new(127, 127, 127),
126 Rgb::new(255, 0, 0),
127 Rgb::new(0, 255, 0),
128 Rgb::new(255, 255, 0),
129 Rgb::new(92, 92, 255),
130 Rgb::new(255, 0, 255),
131 Rgb::new(0, 255, 255),
132 Rgb::new(255, 255, 255),
133 ];
134 (0u8..16).min_by_key(|&i| self.squared_distance(PALETTE[usize::from(i)])).unwrap_or(0)
135 }
136
137 fn linear(self) -> [f64; 3] {
138 let channel = |v: u8| {
139 let c = f64::from(v) / 255.0;
140 if c <= 0.040_45 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
141 };
142 [channel(self.r), channel(self.g), channel(self.b)]
143 }
144
145 fn squared_distance(self, other: Self) -> u32 {
146 let d = |a: u8, b: u8| u32::from(a.abs_diff(b)).pow(2);
147 d(self.r, other.r) + d(self.g, other.g) + d(self.b, other.b)
148 }
149}
150
151impl fmt::Display for Rgb {
152 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153 write!(f, "#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
154 }
155}
156
157pub(crate) const APART: f64 = 0.05;
161
162pub(crate) const LIFT_CAP: f32 = 0.3;
165
166const LIFT_STEP: f32 = 0.01;
168
169#[derive(Debug, Clone, Copy, PartialEq)]
172pub(crate) struct Lift {
173 pub(crate) towards: Rgb,
175 pub(crate) amount: f32,
177}
178
179impl Lift {
180 pub(crate) fn apply(self, color: Rgb) -> Rgb {
182 color.mix(self.towards, self.amount)
183 }
184}
185
186pub(crate) fn lift_apart(surface: Rgb, grounds: &[Rgb], towards: &[Rgb]) -> Option<Lift> {
192 let grounds: Vec<[f64; 3]> = grounds.iter().map(|ground| ground.oklab()).collect();
193 let clearance = |color: Rgb| {
194 let [l, a, b] = color.oklab();
195 grounds
196 .iter()
197 .map(|[gl, ga, gb]| ((l - gl).powi(2) + (a - ga).powi(2) + (b - gb).powi(2)).sqrt())
198 .fold(f64::INFINITY, f64::min)
199 };
200 let resting = clearance(surface);
201 if resting >= APART {
202 return None;
203 }
204 let mut cleared: Option<Lift> = None;
206 let mut furthest: Option<(Lift, f64)> = None;
207 let steps = (LIFT_CAP / LIFT_STEP).round() as u16;
208 for &target in towards {
209 for step in 1..=steps {
210 let amount = f32::from(step) * LIFT_STEP;
211 if cleared.is_some_and(|lift| lift.amount <= amount) {
212 break;
213 }
214 let lift = Lift { towards: target, amount };
215 let reach = clearance(lift.apply(surface));
216 if reach >= APART {
217 cleared = Some(lift);
218 break;
219 }
220 if furthest.is_none_or(|(_, best)| reach > best) {
221 furthest = Some((lift, reach));
222 }
223 }
224 }
225 cleared.or_else(|| furthest.filter(|(_, reach)| *reach > resting).map(|(lift, _)| lift))
226}
227
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230pub enum ColorDepth {
231 TrueColor,
233 Ansi256,
235 Ansi16,
237}
238
239impl ColorDepth {
240 #[must_use]
245 pub fn detect(env: impl Fn(&str) -> Option<String>) -> Self {
246 let lower = |name: &str| env(name).map(|v| v.to_lowercase());
247 if let Some(value) = lower("COLORTERM")
248 && (value.contains("truecolor") || value.contains("24bit"))
249 {
250 return Self::TrueColor;
251 }
252 if env("WT_SESSION").is_some() {
253 return Self::TrueColor;
254 }
255 if let Some(program) = lower("TERM_PROGRAM")
256 && ["iterm", "wezterm", "vscode", "ghostty"].iter().any(|p| program.contains(p))
257 {
258 return Self::TrueColor;
259 }
260 match lower("TERM") {
261 Some(term) if term.contains("direct") => Self::TrueColor,
262 Some(term) if term.contains("256color") => Self::Ansi256,
263 Some(term) if term == "dumb" || term == "linux" || term.is_empty() => Self::Ansi16,
264 _ => Self::Ansi256,
265 }
266 }
267
268 pub(crate) fn tells_apart(self, a: Rgb, b: Rgb) -> bool {
270 match self {
271 Self::TrueColor => a != b,
272 Self::Ansi256 => a.to_ansi256() != b.to_ansi256(),
273 Self::Ansi16 => a.to_ansi16() != b.to_ansi16(),
274 }
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use std::collections::HashMap;
282
283 fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
284 let map: HashMap<String, String> = pairs.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect();
285 move |name| map.get(name).cloned()
286 }
287
288 #[test]
289 fn parses_long_and_short_hex() {
290 assert_eq!(Rgb::parse_hex("#0B1118"), Some(Rgb::new(11, 17, 24)));
291 assert_eq!(Rgb::parse_hex("#fff"), Some(Rgb::new(255, 255, 255)));
292 assert_eq!(Rgb::parse_hex("0B1118"), None);
293 assert_eq!(Rgb::parse_hex("#38BDZ8"), None);
294 assert_eq!(Rgb::parse_hex("#12345"), None);
295 assert_eq!(Rgb::new(11, 17, 24).to_string(), "#0b1118");
296 }
297
298 #[test]
299 fn mix_blends_linearly_and_clamps() {
300 let black = Rgb::new(0, 0, 0);
301 let white = Rgb::new(255, 255, 255);
302 assert_eq!(black.mix(white, 0.0), black);
303 assert_eq!(black.mix(white, 1.0), white);
304 assert_eq!(black.mix(white, 0.5), Rgb::new(128, 128, 128));
305 assert_eq!(black.mix(white, 7.0), white);
306 }
307
308 #[test]
309 fn contrast_matches_wcag_extremes() {
310 let black = Rgb::new(0, 0, 0);
311 let white = Rgb::new(255, 255, 255);
312 assert!((black.contrast_ratio(white) - 21.0).abs() < 1e-9);
313 assert!((white.contrast_ratio(white) - 1.0).abs() < 1e-9);
314 }
315
316 #[test]
317 fn oklab_of_white_is_unit_lightness() {
318 let [l, a, b] = Rgb::new(255, 255, 255).oklab();
319 assert!((l - 1.0).abs() < 1e-3 && a.abs() < 1e-3 && b.abs() < 1e-3);
320 let red = Rgb::new(255, 0, 0);
321 assert!(red.perceptual_distance(red) < 1e-12);
322 assert!(red.perceptual_distance(Rgb::new(0, 0, 255)) > 0.3);
323 }
324
325 #[test]
326 fn reduces_to_256_palette() {
327 assert_eq!(Rgb::new(255, 0, 0).to_ansi256(), 196);
328 assert_eq!(Rgb::new(128, 128, 128).to_ansi256(), 244);
329 assert_eq!(Rgb::new(0, 0, 0).to_ansi256(), 16);
330 }
331
332 #[test]
333 fn reduces_to_16_palette() {
334 assert_eq!(Rgb::new(10, 10, 12).to_ansi16(), 0);
335 assert_eq!(Rgb::new(250, 250, 250).to_ansi16(), 15);
336 assert_eq!(Rgb::new(240, 20, 20).to_ansi16(), 9);
337 }
338
339 const DARK_TEXT: Rgb = Rgb::new(245, 245, 247);
340 const DARK_CANVAS: Rgb = Rgb::new(12, 12, 14);
341
342 #[test]
343 fn a_surface_on_its_own_tone_is_lifted_apart() {
344 let ground = Rgb::new(29, 29, 35);
345 let lift = lift_apart(ground, &[ground], &[DARK_TEXT, DARK_CANVAS]).expect("the same tone is lifted");
346 let lifted = lift.apply(ground);
347 assert!(lifted.perceptual_distance(ground) >= APART);
348 assert!(lifted.relative_luminance() > ground.relative_luminance(), "a dark theme lifts lighter");
349 assert!(lift.amount <= LIFT_CAP);
350 }
351
352 #[test]
353 fn a_close_tone_is_lifted_by_the_smallest_step_that_clears() {
354 let (surface, ground) = (Rgb::new(24, 24, 29), Rgb::new(19, 19, 23));
355 let lift = lift_apart(surface, &[ground], &[DARK_TEXT, DARK_CANVAS]).expect("a close tone is lifted");
356 assert!(lift.apply(surface).perceptual_distance(ground) >= APART);
357 let smaller = Lift { amount: lift.amount - LIFT_STEP, ..lift };
358 assert!(smaller.apply(surface).perceptual_distance(ground) < APART, "no smaller step clears");
359 }
360
361 #[test]
362 fn a_far_tone_is_left_alone() {
363 let (surface, ground) = (Rgb::new(24, 24, 29), Rgb::new(12, 12, 14));
364 assert!(surface.perceptual_distance(ground) >= APART);
365 assert_eq!(lift_apart(surface, &[ground], &[DARK_TEXT, DARK_CANVAS]), None);
366 assert_eq!(lift_apart(surface, &[], &[DARK_TEXT, DARK_CANVAS]), None, "nothing around, nothing to do");
367 }
368
369 #[test]
370 fn a_light_theme_lifts_darker() {
371 let (text, canvas) = (Rgb::new(24, 24, 27), Rgb::new(250, 250, 250));
372 let ground = Rgb::new(238, 238, 240);
373 let lift = lift_apart(ground, &[ground], &[text, canvas]).expect("lifted");
374 let lifted = lift.apply(ground);
375 assert!(lifted.relative_luminance() < ground.relative_luminance());
376 assert!(lifted.perceptual_distance(ground) >= APART);
377 }
378
379 #[test]
380 fn grey_stays_grey() {
381 let (ground, text, canvas) = (Rgb::new(29, 29, 29), Rgb::new(245, 245, 245), Rgb::new(12, 12, 12));
382 let lifted = lift_apart(ground, &[ground], &[text, canvas]).expect("lifted").apply(ground);
383 assert!(lifted.r == lifted.g && lifted.g == lifted.b, "{lifted} is not a grey");
384 }
385
386 #[test]
387 fn every_ground_is_cleared_and_the_nearer_direction_wins() {
388 let surface = Rgb::new(120, 120, 120);
391 let grounds = [Rgb::new(116, 116, 116), Rgb::new(130, 130, 130)];
392 let lift = lift_apart(surface, &grounds, &[DARK_TEXT, Rgb::new(0, 0, 0)]).expect("lifted");
393 let lifted = lift.apply(surface);
394 assert!(grounds.iter().all(|ground| lifted.perceptual_distance(*ground) >= APART));
395 assert_eq!(lift.towards, Rgb::new(0, 0, 0));
397 }
398
399 #[test]
400 fn a_lift_never_passes_the_cap() {
401 let ground = Rgb::new(100, 100, 100);
404 let lift = lift_apart(ground, &[ground], &[Rgb::new(112, 112, 112)]).expect("the furthest lift");
405 assert!((lift.amount - LIFT_CAP).abs() < 1e-6);
406 assert!(lift.apply(ground).perceptual_distance(ground) < APART);
407 assert_eq!(lift_apart(ground, &[ground], &[ground]), None, "a lift that gets nowhere is none");
408 }
409
410 #[test]
411 fn detects_color_depth() {
412 assert_eq!(ColorDepth::detect(env(&[("COLORTERM", "truecolor")])), ColorDepth::TrueColor);
413 assert_eq!(ColorDepth::detect(env(&[("TERM", "xterm-256color")])), ColorDepth::Ansi256);
414 assert_eq!(ColorDepth::detect(env(&[("TERM", "linux")])), ColorDepth::Ansi16);
415 assert_eq!(ColorDepth::detect(env(&[("TERM", "xterm-direct")])), ColorDepth::TrueColor);
416 assert_eq!(ColorDepth::detect(env(&[])), ColorDepth::Ansi256);
417 }
418}