quvyta-framework 0.1.14

A Rust framework for building terminal applications
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
//! Colours: parsing, blending, contrast and perceptual distance, and reduction to the
//! 256- and 16-colour palettes for terminals without 24-bit colour.

use std::fmt;

/// A 24-bit sRGB colour.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Rgb {
    /// Red channel.
    pub r: u8,
    /// Green channel.
    pub g: u8,
    /// Blue channel.
    pub b: u8,
}

impl Rgb {
    /// Creates a colour from its channels.
    #[must_use]
    pub const fn new(r: u8, g: u8, b: u8) -> Self {
        Self { r, g, b }
    }

    /// Parses `#RRGGBB` or `#RGB`, in either letter case.
    #[must_use]
    pub fn parse_hex(text: &str) -> Option<Self> {
        let hex = text.strip_prefix('#')?;
        if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
            return None;
        }
        let channel = |s: &str| u8::from_str_radix(s, 16).ok();
        match hex.len() {
            6 => Some(Self::new(channel(&hex[0..2])?, channel(&hex[2..4])?, channel(&hex[4..6])?)),
            3 => {
                let short = |i: usize| channel(&hex[i..=i]).map(|v| v * 17);
                Some(Self::new(short(0)?, short(1)?, short(2)?))
            }
            _ => None,
        }
    }

    /// Blends towards `other`: `t = 0` gives `self`, `t = 1` gives `other`.
    /// `t` is clamped to `0..=1`.
    #[must_use]
    pub fn mix(self, other: Self, t: f32) -> Self {
        let t = t.clamp(0.0, 1.0);
        let blend = |a: u8, b: u8| {
            let value = f32::from(a) + (f32::from(b) - f32::from(a)) * t;
            // `value` stays within 0..=255 because `t` is clamped.
            value.round().clamp(0.0, 255.0) as u8
        };
        Self::new(blend(self.r, other.r), blend(self.g, other.g), blend(self.b, other.b))
    }

    /// WCAG relative luminance, from 0 (black) to 1 (white).
    #[must_use]
    pub fn relative_luminance(self) -> f64 {
        let [r, g, b] = self.linear();
        0.2126 * r + 0.7152 * g + 0.0722 * b
    }

    /// WCAG contrast ratio between two colours, from 1 to 21.
    #[must_use]
    pub fn contrast_ratio(self, other: Self) -> f64 {
        let (a, b) = (self.relative_luminance(), other.relative_luminance());
        let (light, dark) = if a >= b { (a, b) } else { (b, a) };
        (light + 0.05) / (dark + 0.05)
    }

    /// The colour in the OKLab perceptual space as `[L, a, b]`.
    #[must_use]
    pub fn oklab(self) -> [f64; 3] {
        let [r, g, b] = self.linear();
        let l = 0.412_221_470_8 * r + 0.536_332_536_3 * g + 0.051_445_992_9 * b;
        let m = 0.211_903_498_2 * r + 0.680_699_545_1 * g + 0.107_396_956_6 * b;
        let s = 0.088_302_461_9 * r + 0.281_718_837_6 * g + 0.629_978_700_5 * b;
        let (l, m, s) = (l.cbrt(), m.cbrt(), s.cbrt());
        [
            0.210_454_255_3 * l + 0.793_617_785_0 * m - 0.004_072_046_8 * s,
            1.977_998_495_1 * l - 2.428_592_205_0 * m + 0.450_593_709_9 * s,
            0.025_904_037_1 * l + 0.782_771_766_2 * m - 0.808_675_766_0 * s,
        ]
    }

    /// Euclidean distance in OKLab. Around 0.02 is barely visible; 0.10 reads as a clearly
    /// different colour.
    #[must_use]
    pub fn perceptual_distance(self, other: Self) -> f64 {
        let [l1, a1, b1] = self.oklab();
        let [l2, a2, b2] = other.oklab();
        ((l1 - l2).powi(2) + (a1 - a2).powi(2) + (b1 - b2).powi(2)).sqrt()
    }

    /// Nearest entry of the xterm 256-colour palette (indices 16..=255).
    #[must_use]
    pub fn to_ansi256(self) -> u8 {
        const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
        let nearest_level = |v: u8| (0u8..6).min_by_key(|&i| v.abs_diff(LEVELS[usize::from(i)])).unwrap_or(0);
        let (ri, gi, bi) = (nearest_level(self.r), nearest_level(self.g), nearest_level(self.b));
        let cube = Self::new(LEVELS[usize::from(ri)], LEVELS[usize::from(gi)], LEVELS[usize::from(bi)]);
        let cube_index = 16 + 36 * ri + 6 * gi + bi;

        let average = (u16::from(self.r) + u16::from(self.g) + u16::from(self.b)) / 3;
        // Grey ramp values are 8, 18, ..., 238.
        let step = (average.saturating_sub(3) / 10).min(23) as u8;
        let grey_value = 8 + 10 * step;
        let grey = Self::new(grey_value, grey_value, grey_value);
        let grey_index = 232 + step;

        if self.squared_distance(grey) < self.squared_distance(cube) { grey_index } else { cube_index }
    }

    /// Nearest of the 16 standard terminal colours (xterm defaults).
    #[must_use]
    pub fn to_ansi16(self) -> u8 {
        const PALETTE: [Rgb; 16] = [
            Rgb::new(0, 0, 0),
            Rgb::new(205, 0, 0),
            Rgb::new(0, 205, 0),
            Rgb::new(205, 205, 0),
            Rgb::new(0, 0, 238),
            Rgb::new(205, 0, 205),
            Rgb::new(0, 205, 205),
            Rgb::new(229, 229, 229),
            Rgb::new(127, 127, 127),
            Rgb::new(255, 0, 0),
            Rgb::new(0, 255, 0),
            Rgb::new(255, 255, 0),
            Rgb::new(92, 92, 255),
            Rgb::new(255, 0, 255),
            Rgb::new(0, 255, 255),
            Rgb::new(255, 255, 255),
        ];
        (0u8..16).min_by_key(|&i| self.squared_distance(PALETTE[usize::from(i)])).unwrap_or(0)
    }

    fn linear(self) -> [f64; 3] {
        let channel = |v: u8| {
            let c = f64::from(v) / 255.0;
            if c <= 0.040_45 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
        };
        [channel(self.r), channel(self.g), channel(self.b)]
    }

    fn squared_distance(self, other: Self) -> u32 {
        let d = |a: u8, b: u8| u32::from(a.abs_diff(b)).pow(2);
        d(self.r, other.r) + d(self.g, other.g) + d(self.b, other.b)
    }
}

impl fmt::Display for Rgb {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
    }
}

/// OKLab distance under which a floating surface melts into the ground around it. Every built-in
/// theme's `overlay` sits just past it from `canvas`, so menus over the screen keep their tone,
/// and well within it from `surface` and `raised`, so menus over panels are lifted.
pub(crate) const APART: f64 = 0.05;

/// The furthest a floating surface is moved towards a theme colour, so a lift never turns the
/// surface into a different colour.
pub(crate) const LIFT_CAP: f32 = 0.3;

/// The steps a lift is searched in.
const LIFT_STEP: f32 = 0.01;

/// How a floating surface's backgrounds are moved so it stands apart from the ground around it:
/// every background is blended towards `towards` by `amount`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Lift {
    /// The theme colour the backgrounds move towards.
    pub(crate) towards: Rgb,
    /// How far they move, 0 to [`LIFT_CAP`].
    pub(crate) amount: f32,
}

impl Lift {
    /// `color` lifted.
    pub(crate) fn apply(self, color: Rgb) -> Rgb {
        color.mix(self.towards, self.amount)
    }
}

/// The smallest lift that takes `surface` at least [`APART`] from every colour in `grounds`,
/// trying each of `towards` (theme colours, in order of preference) up to [`LIFT_CAP`]. The
/// direction that needs less wins; an earlier one wins a tie. `None` when the surface already
/// stands apart, or when no lift within the cap moves it any further from the nearest ground;
/// when none reaches [`APART`], the lift that gets furthest does.
pub(crate) fn lift_apart(surface: Rgb, grounds: &[Rgb], towards: &[Rgb]) -> Option<Lift> {
    let grounds: Vec<[f64; 3]> = grounds.iter().map(|ground| ground.oklab()).collect();
    let clearance = |color: Rgb| {
        let [l, a, b] = color.oklab();
        grounds
            .iter()
            .map(|[gl, ga, gb]| ((l - gl).powi(2) + (a - ga).powi(2) + (b - gb).powi(2)).sqrt())
            .fold(f64::INFINITY, f64::min)
    };
    let resting = clearance(surface);
    if resting >= APART {
        return None;
    }
    // (lift, clearance): the first lift that clears, else the one that gets furthest.
    let mut cleared: Option<Lift> = None;
    let mut furthest: Option<(Lift, f64)> = None;
    let steps = (LIFT_CAP / LIFT_STEP).round() as u16;
    for &target in towards {
        for step in 1..=steps {
            let amount = f32::from(step) * LIFT_STEP;
            if cleared.is_some_and(|lift| lift.amount <= amount) {
                break;
            }
            let lift = Lift { towards: target, amount };
            let reach = clearance(lift.apply(surface));
            if reach >= APART {
                cleared = Some(lift);
                break;
            }
            if furthest.is_none_or(|(_, best)| reach > best) {
                furthest = Some((lift, reach));
            }
        }
    }
    cleared.or_else(|| furthest.filter(|(_, reach)| *reach > resting).map(|(lift, _)| lift))
}

/// How many colours the terminal can show.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorDepth {
    /// 24-bit colour.
    TrueColor,
    /// The xterm 256-colour palette.
    Ansi256,
    /// The 16 standard colours.
    Ansi16,
}

impl ColorDepth {
    /// Detects the colour depth from environment variables.
    ///
    /// `env` looks a variable up; pass `|name| std::env::var(name).ok()` in applications and a
    /// fixed map in tests.
    #[must_use]
    pub fn detect(env: impl Fn(&str) -> Option<String>) -> Self {
        let lower = |name: &str| env(name).map(|v| v.to_lowercase());
        if let Some(value) = lower("COLORTERM")
            && (value.contains("truecolor") || value.contains("24bit"))
        {
            return Self::TrueColor;
        }
        if env("WT_SESSION").is_some() {
            return Self::TrueColor;
        }
        if let Some(program) = lower("TERM_PROGRAM")
            && ["iterm", "wezterm", "vscode", "ghostty"].iter().any(|p| program.contains(p))
        {
            return Self::TrueColor;
        }
        match lower("TERM") {
            Some(term) if term.contains("direct") => Self::TrueColor,
            Some(term) if term.contains("256color") => Self::Ansi256,
            Some(term) if term == "dumb" || term == "linux" || term.is_empty() => Self::Ansi16,
            _ => Self::Ansi256,
        }
    }

    /// Whether a terminal of this depth shows `a` and `b` as two different colours.
    pub(crate) fn tells_apart(self, a: Rgb, b: Rgb) -> bool {
        match self {
            Self::TrueColor => a != b,
            Self::Ansi256 => a.to_ansi256() != b.to_ansi256(),
            Self::Ansi16 => a.to_ansi16() != b.to_ansi16(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashMap;

    fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
        let map: HashMap<String, String> = pairs.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect();
        move |name| map.get(name).cloned()
    }

    #[test]
    fn parses_long_and_short_hex() {
        assert_eq!(Rgb::parse_hex("#0B1118"), Some(Rgb::new(11, 17, 24)));
        assert_eq!(Rgb::parse_hex("#fff"), Some(Rgb::new(255, 255, 255)));
        assert_eq!(Rgb::parse_hex("0B1118"), None);
        assert_eq!(Rgb::parse_hex("#38BDZ8"), None);
        assert_eq!(Rgb::parse_hex("#12345"), None);
        assert_eq!(Rgb::new(11, 17, 24).to_string(), "#0b1118");
    }

    #[test]
    fn mix_blends_linearly_and_clamps() {
        let black = Rgb::new(0, 0, 0);
        let white = Rgb::new(255, 255, 255);
        assert_eq!(black.mix(white, 0.0), black);
        assert_eq!(black.mix(white, 1.0), white);
        assert_eq!(black.mix(white, 0.5), Rgb::new(128, 128, 128));
        assert_eq!(black.mix(white, 7.0), white);
    }

    #[test]
    fn contrast_matches_wcag_extremes() {
        let black = Rgb::new(0, 0, 0);
        let white = Rgb::new(255, 255, 255);
        assert!((black.contrast_ratio(white) - 21.0).abs() < 1e-9);
        assert!((white.contrast_ratio(white) - 1.0).abs() < 1e-9);
    }

    #[test]
    fn oklab_of_white_is_unit_lightness() {
        let [l, a, b] = Rgb::new(255, 255, 255).oklab();
        assert!((l - 1.0).abs() < 1e-3 && a.abs() < 1e-3 && b.abs() < 1e-3);
        let red = Rgb::new(255, 0, 0);
        assert!(red.perceptual_distance(red) < 1e-12);
        assert!(red.perceptual_distance(Rgb::new(0, 0, 255)) > 0.3);
    }

    #[test]
    fn reduces_to_256_palette() {
        assert_eq!(Rgb::new(255, 0, 0).to_ansi256(), 196);
        assert_eq!(Rgb::new(128, 128, 128).to_ansi256(), 244);
        assert_eq!(Rgb::new(0, 0, 0).to_ansi256(), 16);
    }

    #[test]
    fn reduces_to_16_palette() {
        assert_eq!(Rgb::new(10, 10, 12).to_ansi16(), 0);
        assert_eq!(Rgb::new(250, 250, 250).to_ansi16(), 15);
        assert_eq!(Rgb::new(240, 20, 20).to_ansi16(), 9);
    }

    const DARK_TEXT: Rgb = Rgb::new(245, 245, 247);
    const DARK_CANVAS: Rgb = Rgb::new(12, 12, 14);

    #[test]
    fn a_surface_on_its_own_tone_is_lifted_apart() {
        let ground = Rgb::new(29, 29, 35);
        let lift = lift_apart(ground, &[ground], &[DARK_TEXT, DARK_CANVAS]).expect("the same tone is lifted");
        let lifted = lift.apply(ground);
        assert!(lifted.perceptual_distance(ground) >= APART);
        assert!(lifted.relative_luminance() > ground.relative_luminance(), "a dark theme lifts lighter");
        assert!(lift.amount <= LIFT_CAP);
    }

    #[test]
    fn a_close_tone_is_lifted_by_the_smallest_step_that_clears() {
        let (surface, ground) = (Rgb::new(24, 24, 29), Rgb::new(19, 19, 23));
        let lift = lift_apart(surface, &[ground], &[DARK_TEXT, DARK_CANVAS]).expect("a close tone is lifted");
        assert!(lift.apply(surface).perceptual_distance(ground) >= APART);
        let smaller = Lift { amount: lift.amount - LIFT_STEP, ..lift };
        assert!(smaller.apply(surface).perceptual_distance(ground) < APART, "no smaller step clears");
    }

    #[test]
    fn a_far_tone_is_left_alone() {
        let (surface, ground) = (Rgb::new(24, 24, 29), Rgb::new(12, 12, 14));
        assert!(surface.perceptual_distance(ground) >= APART);
        assert_eq!(lift_apart(surface, &[ground], &[DARK_TEXT, DARK_CANVAS]), None);
        assert_eq!(lift_apart(surface, &[], &[DARK_TEXT, DARK_CANVAS]), None, "nothing around, nothing to do");
    }

    #[test]
    fn a_light_theme_lifts_darker() {
        let (text, canvas) = (Rgb::new(24, 24, 27), Rgb::new(250, 250, 250));
        let ground = Rgb::new(238, 238, 240);
        let lift = lift_apart(ground, &[ground], &[text, canvas]).expect("lifted");
        let lifted = lift.apply(ground);
        assert!(lifted.relative_luminance() < ground.relative_luminance());
        assert!(lifted.perceptual_distance(ground) >= APART);
    }

    #[test]
    fn grey_stays_grey() {
        let (ground, text, canvas) = (Rgb::new(29, 29, 29), Rgb::new(245, 245, 245), Rgb::new(12, 12, 12));
        let lifted = lift_apart(ground, &[ground], &[text, canvas]).expect("lifted").apply(ground);
        assert!(lifted.r == lifted.g && lifted.g == lifted.b, "{lifted} is not a grey");
    }

    #[test]
    fn every_ground_is_cleared_and_the_nearer_direction_wins() {
        // A darker and a lighter ground on either side: lifting away from both needs a step
        // past the lighter one.
        let surface = Rgb::new(120, 120, 120);
        let grounds = [Rgb::new(116, 116, 116), Rgb::new(130, 130, 130)];
        let lift = lift_apart(surface, &grounds, &[DARK_TEXT, Rgb::new(0, 0, 0)]).expect("lifted");
        let lifted = lift.apply(surface);
        assert!(grounds.iter().all(|ground| lifted.perceptual_distance(*ground) >= APART));
        // Towards black is shorter here: the lighter ground sits in the way of the text.
        assert_eq!(lift.towards, Rgb::new(0, 0, 0));
    }

    #[test]
    fn a_lift_never_passes_the_cap() {
        // Towards a colour barely different from the ground, nothing clears; the furthest step
        // within the cap is taken.
        let ground = Rgb::new(100, 100, 100);
        let lift = lift_apart(ground, &[ground], &[Rgb::new(112, 112, 112)]).expect("the furthest lift");
        assert!((lift.amount - LIFT_CAP).abs() < 1e-6);
        assert!(lift.apply(ground).perceptual_distance(ground) < APART);
        assert_eq!(lift_apart(ground, &[ground], &[ground]), None, "a lift that gets nowhere is none");
    }

    #[test]
    fn detects_color_depth() {
        assert_eq!(ColorDepth::detect(env(&[("COLORTERM", "truecolor")])), ColorDepth::TrueColor);
        assert_eq!(ColorDepth::detect(env(&[("TERM", "xterm-256color")])), ColorDepth::Ansi256);
        assert_eq!(ColorDepth::detect(env(&[("TERM", "linux")])), ColorDepth::Ansi16);
        assert_eq!(ColorDepth::detect(env(&[("TERM", "xterm-direct")])), ColorDepth::TrueColor);
        assert_eq!(ColorDepth::detect(env(&[])), ColorDepth::Ansi256);
    }
}