Skip to main content

qframe/
color.rs

1//! Colours: parsing, blending, contrast and perceptual distance, and reduction to the
2//! 256- and 16-colour palettes for terminals without 24-bit colour.
3
4use std::fmt;
5
6/// A 24-bit sRGB colour.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8pub struct Rgb {
9    /// Red channel.
10    pub r: u8,
11    /// Green channel.
12    pub g: u8,
13    /// Blue channel.
14    pub b: u8,
15}
16
17impl Rgb {
18    /// Creates a colour from its channels.
19    #[must_use]
20    pub const fn new(r: u8, g: u8, b: u8) -> Self {
21        Self { r, g, b }
22    }
23
24    /// Parses `#RRGGBB` or `#RGB`, in either letter case.
25    #[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    /// Blends towards `other`: `t = 0` gives `self`, `t = 1` gives `other`.
43    /// `t` is clamped to `0..=1`.
44    #[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` stays within 0..=255 because `t` is clamped.
50            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    /// WCAG relative luminance, from 0 (black) to 1 (white).
56    #[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    /// WCAG contrast ratio between two colours, from 1 to 21.
63    #[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    /// The colour in the OKLab perceptual space as `[L, a, b]`.
71    #[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    /// Euclidean distance in OKLab. Around 0.02 is barely visible; 0.10 reads as a clearly
86    /// different colour.
87    #[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    /// Nearest entry of the xterm 256-colour palette (indices 16..=255).
95    #[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        // Grey ramp values are 8, 18, ..., 238.
105        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    /// The entry of the xterm 256-colour palette this colour takes as text on `bg`.
114    ///
115    /// Mostly the nearest entry, as [`Rgb::to_ansi256`] gives it. Faint text, such as a page
116    /// dimmed behind a dialog, can land on an entry that no longer reads on its background's —
117    /// under 1.6:1 in the WCAG ratio, where a glyph starts to disappear; it then takes the entry
118    /// closest to it in OKLab that still keeps 1.6:1, so faint text stays faint rather than
119    /// vanishing. Text drawn in the very colour of its background is left as it is, since that
120    /// is a fill rather than something to read.
121    #[must_use]
122    pub fn to_ansi256_text(self, bg: Self) -> u8 {
123        let fg = self.to_ansi256();
124        let behind = Self::from_ansi256(bg.to_ansi256());
125        if self == bg || Self::from_ansi256(fg).contrast_ratio(behind) >= READABLE {
126            return fg;
127        }
128        (16..=255u8)
129            .filter(|&index| Self::from_ansi256(index).contrast_ratio(behind) >= READABLE)
130            .min_by(|&a, &b| {
131                let distance = |index| self.perceptual_distance(Self::from_ansi256(index));
132                distance(a).total_cmp(&distance(b))
133            })
134            .unwrap_or(fg)
135    }
136
137    /// Nearest of the 16 standard terminal colours (xterm defaults), by distance alone.
138    ///
139    /// This is the plain reduction: on a dark screen every surface tone lands on black here.
140    /// What a sixteen-colour frame actually shows is [`Rgb::to_ansi16_on`] for backgrounds and
141    /// [`Rgb::to_ansi16_text`] for text, which keep lifted surfaces and faint text apart from the
142    /// ground.
143    #[must_use]
144    pub fn to_ansi16(self) -> u8 {
145        (0u8..16).min_by_key(|&i| self.squared_distance(ANSI16[usize::from(i)])).unwrap_or(0)
146    }
147
148    /// The colour of entry `index` of the 16 standard terminal colours, as xterm shows them by
149    /// default; an index past 15 gives the last entry, white. A terminal may be themed
150    /// differently, so these are the colours a reduction can count on, not the ones a person
151    /// necessarily sees.
152    #[must_use]
153    pub fn from_ansi16(index: u8) -> Self {
154        ANSI16[usize::from(index.min(15))]
155    }
156
157    /// The colour of entry `index` of the xterm 256-colour palette as xterm shows it by default:
158    /// the 16 standard colours as [`Rgb::from_ansi16`] gives them, then the 6×6×6 colour cube,
159    /// then the 24 greys from 8 to 238.
160    #[must_use]
161    pub fn from_ansi256(index: u8) -> Self {
162        const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
163        match index {
164            0..=15 => Self::from_ansi16(index),
165            16..=231 => {
166                let cube = index - 16;
167                let level = |i: u8| LEVELS[usize::from(i)];
168                Self::new(level(cube / 36), level(cube / 6 % 6), level(cube % 6))
169            }
170            _ => {
171                let grey = 8 + 10 * (index - 232);
172                Self::new(grey, grey, grey)
173            }
174        }
175    }
176
177    /// The entry of the 16 standard colours this colour takes as a background on a screen whose
178    /// ground is `ground` (the theme's `canvas`).
179    ///
180    /// Mostly the nearest entry, as [`Rgb::to_ansi16`] gives it. The exception is a tone the eye
181    /// tells from the ground in full colour — at least 0.05 away in OKLab, the same distance
182    /// under which a floating surface melts into the ground — that would still land on the
183    /// ground's own entry: it moves one step along the grey ladder black, bright black, white,
184    /// bright white, away from the ground. On a dark theme the ground stays black and raised
185    /// surfaces, tab strips and dialogs become bright black; on a light theme they step down
186    /// from bright white to white. Panels a shade off the ground stay on it, as in full colour
187    /// they barely differ.
188    #[must_use]
189    pub fn to_ansi16_on(self, ground: Self) -> u8 {
190        let nearest = self.to_ansi16();
191        let base = ground.to_ansi16();
192        if nearest != base || self.perceptual_distance(ground) < APART {
193            return nearest;
194        }
195        let Some(rung) = GREYS.iter().position(|&grey| grey == base) else {
196            return nearest;
197        };
198        let step = if self.oklab()[0] > ground.oklab()[0] { rung.checked_add(1) } else { rung.checked_sub(1) };
199        step.and_then(|rung| GREYS.get(rung)).copied().unwrap_or(nearest)
200    }
201
202    /// The entry of the 16 standard colours this colour takes as text on `bg`, on a screen whose
203    /// ground is `ground`.
204    ///
205    /// Both colours are reduced as [`Rgb::to_ansi16_on`] does. When the text would then no longer
206    /// read on its background — under 1.6:1 in the WCAG ratio, which is where a glyph starts to
207    /// disappear — it takes the quietest grey that still keeps 3:1 against it: faint text stays
208    /// faint rather than vanishing. On bright black that is white, on black bright black. Text
209    /// drawn in the very colour of its background is left as it is, since that is a fill rather
210    /// than something to read.
211    #[must_use]
212    pub fn to_ansi16_text(self, bg: Self, ground: Self) -> u8 {
213        let fg = self.to_ansi16_on(ground);
214        let behind = bg.to_ansi16_on(ground);
215        if self == bg || Self::from_ansi16(fg).contrast_ratio(Self::from_ansi16(behind)) >= READABLE {
216            return fg;
217        }
218        let behind = Self::from_ansi16(behind);
219        GREYS
220            .iter()
221            .copied()
222            .map(|grey| (grey, Self::from_ansi16(grey).contrast_ratio(behind)))
223            .filter(|(_, ratio)| *ratio >= QUIET_READABLE)
224            .min_by(|(_, a), (_, b)| a.total_cmp(b))
225            .map_or(fg, |(grey, _)| grey)
226    }
227
228    fn linear(self) -> [f64; 3] {
229        let channel = |v: u8| {
230            let c = f64::from(v) / 255.0;
231            if c <= 0.040_45 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
232        };
233        [channel(self.r), channel(self.g), channel(self.b)]
234    }
235
236    fn squared_distance(self, other: Self) -> u32 {
237        let d = |a: u8, b: u8| u32::from(a.abs_diff(b)).pow(2);
238        d(self.r, other.r) + d(self.g, other.g) + d(self.b, other.b)
239    }
240}
241
242/// The 16 standard terminal colours as xterm shows them by default.
243const ANSI16: [Rgb; 16] = [
244    Rgb::new(0, 0, 0),
245    Rgb::new(205, 0, 0),
246    Rgb::new(0, 205, 0),
247    Rgb::new(205, 205, 0),
248    Rgb::new(0, 0, 238),
249    Rgb::new(205, 0, 205),
250    Rgb::new(0, 205, 205),
251    Rgb::new(229, 229, 229),
252    Rgb::new(127, 127, 127),
253    Rgb::new(255, 0, 0),
254    Rgb::new(0, 255, 0),
255    Rgb::new(255, 255, 0),
256    Rgb::new(92, 92, 255),
257    Rgb::new(255, 0, 255),
258    Rgb::new(0, 255, 255),
259    Rgb::new(255, 255, 255),
260];
261
262/// The greys of the 16 standard colours from dark to light: black, bright black, white, bright
263/// white.
264const GREYS: [u8; 4] = [0, 8, 7, 15];
265
266/// Contrast under which reduced text starts to disappear into its background.
267const READABLE: f64 = 1.6;
268
269/// Contrast the grey that replaces unreadable text keeps against its background.
270const QUIET_READABLE: f64 = 3.0;
271
272impl fmt::Display for Rgb {
273    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274        write!(f, "#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
275    }
276}
277
278/// OKLab distance under which a floating surface melts into the ground around it. Every built-in
279/// theme's `overlay` sits just past it from `canvas`, so menus over the screen keep their tone,
280/// and well within it from `surface` and `raised`, so menus over panels are lifted.
281pub(crate) const APART: f64 = 0.05;
282
283/// The furthest a floating surface is moved towards a theme colour, so a lift never turns the
284/// surface into a different colour.
285pub(crate) const LIFT_CAP: f32 = 0.3;
286
287/// The steps a lift is searched in.
288const LIFT_STEP: f32 = 0.01;
289
290/// How a floating surface's backgrounds are moved so it stands apart from the ground around it:
291/// every background is blended towards `towards` by `amount`.
292#[derive(Debug, Clone, Copy, PartialEq)]
293pub(crate) struct Lift {
294    /// The theme colour the backgrounds move towards.
295    pub(crate) towards: Rgb,
296    /// How far they move, 0 to [`LIFT_CAP`].
297    pub(crate) amount: f32,
298}
299
300impl Lift {
301    /// `color` lifted.
302    pub(crate) fn apply(self, color: Rgb) -> Rgb {
303        color.mix(self.towards, self.amount)
304    }
305}
306
307/// The smallest lift that takes `surface` at least [`APART`] from every colour in `grounds`,
308/// trying each of `towards` (theme colours, in order of preference) up to [`LIFT_CAP`]. The
309/// direction that needs less wins; an earlier one wins a tie. `None` when the surface already
310/// stands apart, or when no lift within the cap moves it any further from the nearest ground;
311/// when none reaches [`APART`], the lift that gets furthest does.
312pub(crate) fn lift_apart(surface: Rgb, grounds: &[Rgb], towards: &[Rgb]) -> Option<Lift> {
313    let grounds: Vec<[f64; 3]> = grounds.iter().map(|ground| ground.oklab()).collect();
314    let clearance = |color: Rgb| {
315        let [l, a, b] = color.oklab();
316        grounds
317            .iter()
318            .map(|[gl, ga, gb]| ((l - gl).powi(2) + (a - ga).powi(2) + (b - gb).powi(2)).sqrt())
319            .fold(f64::INFINITY, f64::min)
320    };
321    let resting = clearance(surface);
322    if resting >= APART {
323        return None;
324    }
325    // (lift, clearance): the first lift that clears, else the one that gets furthest.
326    let mut cleared: Option<Lift> = None;
327    let mut furthest: Option<(Lift, f64)> = None;
328    let steps = (LIFT_CAP / LIFT_STEP).round() as u16;
329    for &target in towards {
330        for step in 1..=steps {
331            let amount = f32::from(step) * LIFT_STEP;
332            if cleared.is_some_and(|lift| lift.amount <= amount) {
333                break;
334            }
335            let lift = Lift { towards: target, amount };
336            let reach = clearance(lift.apply(surface));
337            if reach >= APART {
338                cleared = Some(lift);
339                break;
340            }
341            if furthest.is_none_or(|(_, best)| reach > best) {
342                furthest = Some((lift, reach));
343            }
344        }
345    }
346    cleared.or_else(|| furthest.filter(|(_, reach)| *reach > resting).map(|(lift, _)| lift))
347}
348
349/// How many colours the terminal can show.
350#[derive(Debug, Clone, Copy, PartialEq, Eq)]
351pub enum ColorDepth {
352    /// 24-bit colour.
353    TrueColor,
354    /// The xterm 256-colour palette.
355    Ansi256,
356    /// The 16 standard colours.
357    Ansi16,
358}
359
360impl ColorDepth {
361    /// Detects the colour depth from environment variables.
362    ///
363    /// `env` looks a variable up; pass `|name| std::env::var(name).ok()` in applications and a
364    /// fixed map in tests.
365    #[must_use]
366    pub fn detect(env: impl Fn(&str) -> Option<String>) -> Self {
367        let lower = |name: &str| env(name).map(|v| v.to_lowercase());
368        if let Some(value) = lower("COLORTERM")
369            && (value.contains("truecolor") || value.contains("24bit"))
370        {
371            return Self::TrueColor;
372        }
373        if env("WT_SESSION").is_some() {
374            return Self::TrueColor;
375        }
376        if let Some(program) = lower("TERM_PROGRAM")
377            && ["iterm", "wezterm", "vscode", "ghostty"].iter().any(|p| program.contains(p))
378        {
379            return Self::TrueColor;
380        }
381        match lower("TERM") {
382            Some(term) if term.contains("direct") => Self::TrueColor,
383            Some(term) if term.contains("256color") => Self::Ansi256,
384            Some(term) if term == "dumb" || term == "linux" || term.is_empty() => Self::Ansi16,
385            _ => Self::Ansi256,
386        }
387    }
388
389    /// How a terminal of this depth shows `color` as a background on a screen whose ground is
390    /// `ground`: two colours with the same answer are one colour on screen.
391    pub(crate) fn shown(self, color: Rgb, ground: Rgb) -> u32 {
392        match self {
393            Self::TrueColor => u32::from(color.r) << 16 | u32::from(color.g) << 8 | u32::from(color.b),
394            Self::Ansi256 => u32::from(color.to_ansi256()),
395            Self::Ansi16 => u32::from(color.to_ansi16_on(ground)),
396        }
397    }
398
399    /// Whether a terminal of this depth shows `a` and `b` as two different colours on a screen
400    /// whose ground is `ground`.
401    pub(crate) fn tells_apart(self, a: Rgb, b: Rgb, ground: Rgb) -> bool {
402        self.shown(a, ground) != self.shown(b, ground)
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use std::collections::HashMap;
410
411    fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
412        let map: HashMap<String, String> = pairs.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect();
413        move |name| map.get(name).cloned()
414    }
415
416    #[test]
417    fn parses_long_and_short_hex() {
418        assert_eq!(Rgb::parse_hex("#0B1118"), Some(Rgb::new(11, 17, 24)));
419        assert_eq!(Rgb::parse_hex("#fff"), Some(Rgb::new(255, 255, 255)));
420        assert_eq!(Rgb::parse_hex("0B1118"), None);
421        assert_eq!(Rgb::parse_hex("#38BDZ8"), None);
422        assert_eq!(Rgb::parse_hex("#12345"), None);
423        assert_eq!(Rgb::new(11, 17, 24).to_string(), "#0b1118");
424    }
425
426    #[test]
427    fn mix_blends_linearly_and_clamps() {
428        let black = Rgb::new(0, 0, 0);
429        let white = Rgb::new(255, 255, 255);
430        assert_eq!(black.mix(white, 0.0), black);
431        assert_eq!(black.mix(white, 1.0), white);
432        assert_eq!(black.mix(white, 0.5), Rgb::new(128, 128, 128));
433        assert_eq!(black.mix(white, 7.0), white);
434    }
435
436    #[test]
437    fn contrast_matches_wcag_extremes() {
438        let black = Rgb::new(0, 0, 0);
439        let white = Rgb::new(255, 255, 255);
440        assert!((black.contrast_ratio(white) - 21.0).abs() < 1e-9);
441        assert!((white.contrast_ratio(white) - 1.0).abs() < 1e-9);
442    }
443
444    #[test]
445    fn oklab_of_white_is_unit_lightness() {
446        let [l, a, b] = Rgb::new(255, 255, 255).oklab();
447        assert!((l - 1.0).abs() < 1e-3 && a.abs() < 1e-3 && b.abs() < 1e-3);
448        let red = Rgb::new(255, 0, 0);
449        assert!(red.perceptual_distance(red) < 1e-12);
450        assert!(red.perceptual_distance(Rgb::new(0, 0, 255)) > 0.3);
451    }
452
453    #[test]
454    fn reduces_to_256_palette() {
455        assert_eq!(Rgb::new(255, 0, 0).to_ansi256(), 196);
456        assert_eq!(Rgb::new(128, 128, 128).to_ansi256(), 244);
457        assert_eq!(Rgb::new(0, 0, 0).to_ansi256(), 16);
458    }
459
460    #[test]
461    fn reduces_to_16_palette() {
462        assert_eq!(Rgb::new(10, 10, 12).to_ansi16(), 0);
463        assert_eq!(Rgb::new(250, 250, 250).to_ansi16(), 15);
464        assert_eq!(Rgb::new(240, 20, 20).to_ansi16(), 9);
465    }
466
467    /// The monochrome theme's canvas, surface, overlay, raised and active tones.
468    const LADDER: [Rgb; 5] =
469        [Rgb::new(12, 12, 14), Rgb::new(19, 19, 23), Rgb::new(24, 24, 29), Rgb::new(29, 29, 35), Rgb::new(40, 40, 47)];
470
471    #[test]
472    fn a_lifted_surface_takes_bright_black_on_a_dark_ground() {
473        let [canvas, surface, overlay, raised, active] = LADDER;
474        for tone in LADDER {
475            assert_eq!(tone.to_ansi16(), 0, "the plain reduction puts {tone} on black");
476        }
477        assert_eq!(canvas.to_ansi16_on(canvas), 0, "the ground stays black");
478        assert_eq!(surface.to_ansi16_on(canvas), 0, "a panel a shade off the ground stays on it");
479        for tone in [overlay, raised, active] {
480            assert_eq!(tone.to_ansi16_on(canvas), 8, "{tone} is lifted to bright black");
481        }
482        // A dialog's dimmed page: the ground under it stays black, its faint text does not.
483        let (dimmed_text, dimmed_ground) = (Rgb::new(49, 49, 55), Rgb::new(15, 15, 18));
484        assert_eq!(dimmed_ground.to_ansi16_on(canvas), 0);
485        assert_eq!(dimmed_text.to_ansi16_text(dimmed_ground, canvas), 8);
486        // Colours that already land apart from the ground keep their nearest entry.
487        assert_eq!(Rgb::new(240, 20, 20).to_ansi16_on(canvas), 9);
488        assert_eq!(Rgb::new(245, 245, 247).to_ansi16_on(canvas), 15);
489    }
490
491    #[test]
492    fn a_lifted_surface_steps_down_on_a_light_ground() {
493        let canvas = Rgb::new(250, 250, 250);
494        assert_eq!(canvas.to_ansi16_on(canvas), 15);
495        assert_eq!(Rgb::new(244, 244, 245).to_ansi16_on(canvas), 15, "a shade off the ground stays on it");
496        assert_eq!(Rgb::new(238, 238, 240).to_ansi16_on(canvas), 7, "a raised tone steps down to white");
497        let text = Rgb::new(24, 24, 27);
498        assert_eq!(text.to_ansi16_text(Rgb::new(238, 238, 240), canvas), 0, "dark text keeps its black");
499    }
500
501    #[test]
502    fn text_that_would_vanish_takes_the_quietest_readable_grey() {
503        let [canvas, _, _, raised, _] = LADDER;
504        let muted = Rgb::new(95, 95, 105);
505        assert_eq!(muted.to_ansi16_on(canvas), 8, "muted text alone is bright black");
506        assert_eq!(muted.to_ansi16_text(canvas, canvas), 8, "and reads so on the ground");
507        assert_eq!(muted.to_ansi16_text(raised, canvas), 7, "on bright black it steps up to white");
508        let accent = Rgb::new(129, 140, 248);
509        assert_eq!(accent.to_ansi16_on(canvas), 12);
510        assert_eq!(accent.to_ansi16_text(raised, canvas), 7, "a blue that melts into bright black turns white");
511        assert_eq!(raised.to_ansi16_text(raised, canvas), 8, "a fill in its own colour is left alone");
512        for bg in 0..16 {
513            let behind = Rgb::from_ansi16(bg);
514            let text = Rgb::new(behind.r ^ 1, behind.g, behind.b);
515            let shown = text.to_ansi16_text(behind, behind);
516            let ratio = Rgb::from_ansi16(shown).contrast_ratio(Rgb::from_ansi16(behind.to_ansi16_on(behind)));
517            assert!(ratio >= READABLE, "text on entry {bg} keeps {ratio:.2}:1");
518        }
519    }
520
521    #[test]
522    fn the_sixteen_colours_round_trip() {
523        for index in 0..16 {
524            assert_eq!(Rgb::from_ansi16(index).to_ansi16(), index);
525        }
526        assert_eq!(Rgb::from_ansi16(200), Rgb::new(255, 255, 255));
527    }
528
529    #[test]
530    fn the_256_colours_round_trip() {
531        for index in 16..=255 {
532            assert_eq!(Rgb::from_ansi256(index).to_ansi256(), index);
533        }
534        assert_eq!(Rgb::from_ansi256(9), Rgb::from_ansi16(9));
535        assert_eq!(Rgb::from_ansi256(196), Rgb::new(255, 0, 0));
536        assert_eq!(Rgb::from_ansi256(232), Rgb::new(8, 8, 8));
537        assert_eq!(Rgb::from_ansi256(255), Rgb::new(238, 238, 238));
538    }
539
540    #[test]
541    fn faint_text_in_256_colours_keeps_a_readable_entry_near_its_own() {
542        let ground = Rgb::new(15, 15, 18);
543        // Nearest, this faint grey lands on an entry under 1.6:1 against its ground's.
544        let faint = Rgb::new(46, 46, 52);
545        let behind = Rgb::from_ansi256(ground.to_ansi256());
546        assert!(Rgb::from_ansi256(faint.to_ansi256()).contrast_ratio(behind) < READABLE);
547        let shown = Rgb::from_ansi256(faint.to_ansi256_text(ground));
548        let ratio = shown.contrast_ratio(behind);
549        assert!(ratio >= READABLE, "{shown} keeps {ratio:.2}:1");
550        assert!(ratio < 2.0, "and stays faint: {shown} at {ratio:.2}:1");
551        // Text that reads keeps its nearest entry; a fill in its own colour is left alone.
552        let text = Rgb::new(245, 245, 247);
553        assert_eq!(text.to_ansi256_text(ground), text.to_ansi256());
554        assert_eq!(ground.to_ansi256_text(ground), ground.to_ansi256());
555    }
556
557    const DARK_TEXT: Rgb = Rgb::new(245, 245, 247);
558    const DARK_CANVAS: Rgb = Rgb::new(12, 12, 14);
559
560    #[test]
561    fn a_surface_on_its_own_tone_is_lifted_apart() {
562        let ground = Rgb::new(29, 29, 35);
563        let lift = lift_apart(ground, &[ground], &[DARK_TEXT, DARK_CANVAS]).expect("the same tone is lifted");
564        let lifted = lift.apply(ground);
565        assert!(lifted.perceptual_distance(ground) >= APART);
566        assert!(lifted.relative_luminance() > ground.relative_luminance(), "a dark theme lifts lighter");
567        assert!(lift.amount <= LIFT_CAP);
568    }
569
570    #[test]
571    fn a_close_tone_is_lifted_by_the_smallest_step_that_clears() {
572        let (surface, ground) = (Rgb::new(24, 24, 29), Rgb::new(19, 19, 23));
573        let lift = lift_apart(surface, &[ground], &[DARK_TEXT, DARK_CANVAS]).expect("a close tone is lifted");
574        assert!(lift.apply(surface).perceptual_distance(ground) >= APART);
575        let smaller = Lift { amount: lift.amount - LIFT_STEP, ..lift };
576        assert!(smaller.apply(surface).perceptual_distance(ground) < APART, "no smaller step clears");
577    }
578
579    #[test]
580    fn a_far_tone_is_left_alone() {
581        let (surface, ground) = (Rgb::new(24, 24, 29), Rgb::new(12, 12, 14));
582        assert!(surface.perceptual_distance(ground) >= APART);
583        assert_eq!(lift_apart(surface, &[ground], &[DARK_TEXT, DARK_CANVAS]), None);
584        assert_eq!(lift_apart(surface, &[], &[DARK_TEXT, DARK_CANVAS]), None, "nothing around, nothing to do");
585    }
586
587    #[test]
588    fn a_light_theme_lifts_darker() {
589        let (text, canvas) = (Rgb::new(24, 24, 27), Rgb::new(250, 250, 250));
590        let ground = Rgb::new(238, 238, 240);
591        let lift = lift_apart(ground, &[ground], &[text, canvas]).expect("lifted");
592        let lifted = lift.apply(ground);
593        assert!(lifted.relative_luminance() < ground.relative_luminance());
594        assert!(lifted.perceptual_distance(ground) >= APART);
595    }
596
597    #[test]
598    fn grey_stays_grey() {
599        let (ground, text, canvas) = (Rgb::new(29, 29, 29), Rgb::new(245, 245, 245), Rgb::new(12, 12, 12));
600        let lifted = lift_apart(ground, &[ground], &[text, canvas]).expect("lifted").apply(ground);
601        assert!(lifted.r == lifted.g && lifted.g == lifted.b, "{lifted} is not a grey");
602    }
603
604    #[test]
605    fn every_ground_is_cleared_and_the_nearer_direction_wins() {
606        // A darker and a lighter ground on either side: lifting away from both needs a step
607        // past the lighter one.
608        let surface = Rgb::new(120, 120, 120);
609        let grounds = [Rgb::new(116, 116, 116), Rgb::new(130, 130, 130)];
610        let lift = lift_apart(surface, &grounds, &[DARK_TEXT, Rgb::new(0, 0, 0)]).expect("lifted");
611        let lifted = lift.apply(surface);
612        assert!(grounds.iter().all(|ground| lifted.perceptual_distance(*ground) >= APART));
613        // Towards black is shorter here: the lighter ground sits in the way of the text.
614        assert_eq!(lift.towards, Rgb::new(0, 0, 0));
615    }
616
617    #[test]
618    fn a_lift_never_passes_the_cap() {
619        // Towards a colour barely different from the ground, nothing clears; the furthest step
620        // within the cap is taken.
621        let ground = Rgb::new(100, 100, 100);
622        let lift = lift_apart(ground, &[ground], &[Rgb::new(112, 112, 112)]).expect("the furthest lift");
623        assert!((lift.amount - LIFT_CAP).abs() < 1e-6);
624        assert!(lift.apply(ground).perceptual_distance(ground) < APART);
625        assert_eq!(lift_apart(ground, &[ground], &[ground]), None, "a lift that gets nowhere is none");
626    }
627
628    #[test]
629    fn detects_color_depth() {
630        assert_eq!(ColorDepth::detect(env(&[("COLORTERM", "truecolor")])), ColorDepth::TrueColor);
631        assert_eq!(ColorDepth::detect(env(&[("TERM", "xterm-256color")])), ColorDepth::Ansi256);
632        assert_eq!(ColorDepth::detect(env(&[("TERM", "linux")])), ColorDepth::Ansi16);
633        assert_eq!(ColorDepth::detect(env(&[("TERM", "xterm-direct")])), ColorDepth::TrueColor);
634        assert_eq!(ColorDepth::detect(env(&[])), ColorDepth::Ansi256);
635    }
636}