Skip to main content

kazari/
caps.rs

1//! The capability WALL — made a type, not a runtime clamp.
2//!
3//! A [`Block`](crate::ir) carries **no** color depth, no width, no tty
4//! knowledge. It is a pure value. The one and only place the wall is
5//! consulted is at emit, through a [`Capability`] supplied by the
6//! [`RenderEnvironment`](crate::render::RenderEnvironment). So "assumed a
7//! tty / assumed truecolor on a pipe" has no code path — it is
8//! *unrepresentable*, because no block can render without a `Capability`,
9//! and the same block value degrades to every tier through one total
10//! morphism.
11//!
12//! The two quantizers ([`Rgb::to_ansi256`] / [`Rgb::to_ansi16`]) are the
13//! single genuinely-new subsystem (0-hit fleet grep): nothing in pleme-io
14//! degrades an RGB to 256/16 color. They quantize **nearest-in-LINEAR
15//! space** (reusing `irodori::Color::to_linear`) so the matte Nord
16//! relationships survive the downgrade instead of washing out.
17
18use anstyle::AnsiColor;
19
20/// The color-depth ladder — the wall as a sum type. Every emit is a total
21/// function over this; a new rung forces every block's matrix row.
22#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
23pub enum ColorLevel {
24    /// 24-bit `38;2;r;g;b` — a real Nord panel.
25    Truecolor,
26    /// 256-color `38;5;n` — quantized through the xterm cube + grey ramp.
27    Ansi256,
28    /// 16-color `30..37`/`90..97` — the terminal's own palette renders it.
29    Ansi16,
30    /// No SGR at all — a pipe, `NO_COLOR`, a dumb terminal. Zero escape bytes.
31    None,
32}
33
34impl ColorLevel {
35    /// Every rung, for the verification matrix (CLOSED-LOOP MASS-SYNTHESIS).
36    pub const ALL: [ColorLevel; 4] = [
37        ColorLevel::Truecolor,
38        ColorLevel::Ansi256,
39        ColorLevel::Ansi16,
40        ColorLevel::None,
41    ];
42
43    /// `true` when this rung emits at least some SGR.
44    pub const fn is_colored(self) -> bool {
45        !matches!(self, ColorLevel::None)
46    }
47}
48
49/// The background kazari is drawing over. Known only when kazari owns the
50/// surface (its own panel bg); over a foreign terminal it is `Assumed`, and
51/// the WCAG contrast floor is best-effort — a tier-honesty boundary the type
52/// surfaces rather than hides.
53#[derive(Clone, Copy, PartialEq, Eq, Debug)]
54pub enum Background {
55    Known(Rgb),
56    Assumed,
57}
58
59/// A plain 24-bit color. The kazari-owned RGB type; converts to/from
60/// `irodori::Color` (the palette source of truth) losslessly.
61#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
62pub struct Rgb {
63    pub r: u8,
64    pub g: u8,
65    pub b: u8,
66}
67
68impl Rgb {
69    #[must_use]
70    pub const fn new(r: u8, g: u8, b: u8) -> Self {
71        Self { r, g, b }
72    }
73
74    /// CONSUME irodori — no fork of the palette.
75    #[must_use]
76    pub fn from_color(c: irodori::Color) -> Self {
77        Self::new(c.r, c.g, c.b)
78    }
79
80    fn linear(self) -> [f32; 3] {
81        // Reuse irodori's sRGB→linear so quantization distance is
82        // perceptually honest, not naive-sRGB (the 2026-06-14 wash-out bug).
83        irodori::Color::new(self.r, self.g, self.b).to_linear()
84    }
85
86    /// Nearest xterm-256 index, chosen in linear space between the 6×6×6
87    /// color cube and the 24-step grey ramp.
88    #[must_use]
89    pub fn to_ansi256(self) -> u8 {
90        const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
91        let nearest_level = |c: u8| -> (usize, u8) {
92            let mut best = 0usize;
93            let mut best_d = u16::MAX;
94            for (i, &lv) in LEVELS.iter().enumerate() {
95                let d = (i16::from(c) - i16::from(lv)).unsigned_abs();
96                if d < best_d {
97                    best_d = d;
98                    best = i;
99                }
100            }
101            (best, LEVELS[best])
102        };
103        let (ri, _) = nearest_level(self.r);
104        let (gi, _) = nearest_level(self.g);
105        let (bi, _) = nearest_level(self.b);
106        let cube_idx = 16 + 36 * ri + 6 * gi + bi;
107        let cube_rgb = Rgb::new(LEVELS[ri], LEVELS[gi], LEVELS[bi]);
108
109        // Grey ramp candidate (232..=255): greys 8, 18, ... 238.
110        let grey_avg = ((u16::from(self.r) + u16::from(self.g) + u16::from(self.b)) / 3) as u8;
111        let gi2 = ((i16::from(grey_avg) - 8).clamp(0, 238) as f32 / 10.0).round() as i16;
112        let gi2 = gi2.clamp(0, 23) as u8;
113        let grey_val = 8 + gi2 * 10;
114        let grey_idx = 232 + gi2;
115        let grey_rgb = Rgb::new(grey_val, grey_val, grey_val);
116
117        // Pick whichever candidate is nearest in linear space.
118        if linear_dist(self, cube_rgb) <= linear_dist(self, grey_rgb) {
119            cube_idx as u8
120        } else {
121            grey_idx
122        }
123    }
124
125    /// Nearest of the 16 standard ANSI colors, in linear space. The
126    /// terminal's own palette renders the slot — on a Nord terminal, a Nord
127    /// slot looks Nord.
128    #[must_use]
129    pub fn to_ansi16(self) -> AnsiColor {
130        // Standard xterm 16-color reference RGBs.
131        const REF: [(Rgb, AnsiColor); 16] = [
132            (Rgb::new(0x00, 0x00, 0x00), AnsiColor::Black),
133            (Rgb::new(0x80, 0x00, 0x00), AnsiColor::Red),
134            (Rgb::new(0x00, 0x80, 0x00), AnsiColor::Green),
135            (Rgb::new(0x80, 0x80, 0x00), AnsiColor::Yellow),
136            (Rgb::new(0x00, 0x00, 0x80), AnsiColor::Blue),
137            (Rgb::new(0x80, 0x00, 0x80), AnsiColor::Magenta),
138            (Rgb::new(0x00, 0x80, 0x80), AnsiColor::Cyan),
139            (Rgb::new(0xc0, 0xc0, 0xc0), AnsiColor::White),
140            (Rgb::new(0x80, 0x80, 0x80), AnsiColor::BrightBlack),
141            (Rgb::new(0xff, 0x00, 0x00), AnsiColor::BrightRed),
142            (Rgb::new(0x00, 0xff, 0x00), AnsiColor::BrightGreen),
143            (Rgb::new(0xff, 0xff, 0x00), AnsiColor::BrightYellow),
144            (Rgb::new(0x00, 0x00, 0xff), AnsiColor::BrightBlue),
145            (Rgb::new(0xff, 0x00, 0xff), AnsiColor::BrightMagenta),
146            (Rgb::new(0x00, 0xff, 0xff), AnsiColor::BrightCyan),
147            (Rgb::new(0xff, 0xff, 0xff), AnsiColor::BrightWhite),
148        ];
149        let mut best = AnsiColor::White;
150        let mut best_d = f32::MAX;
151        for (rgb, ansi) in REF {
152            let d = linear_dist(self, rgb);
153            if d < best_d {
154                best_d = d;
155                best = ansi;
156            }
157        }
158        best
159    }
160}
161
162fn linear_dist(a: Rgb, b: Rgb) -> f32 {
163    let la = a.linear();
164    let lb = b.linear();
165    let dr = la[0] - lb[0];
166    let dg = la[1] - lb[1];
167    let db = la[2] - lb[2];
168    dr * dr + dg * dg + db * db
169}
170
171impl From<irodori::Color> for Rgb {
172    fn from(c: irodori::Color) -> Self {
173        Rgb::from_color(c)
174    }
175}
176
177/// The full capability wall — carried by the environment, never the block.
178#[derive(Clone, Copy, PartialEq, Eq, Debug)]
179pub struct Capability {
180    pub level: ColorLevel,
181    /// Terminal width in columns, clamped to a sane band.
182    pub width: u16,
183    pub is_tty: bool,
184    pub unicode: bool,
185    pub background: Background,
186}
187
188#[derive(Clone, Copy, PartialEq, Eq, Debug)]
189pub enum Stream {
190    Stdout,
191    Stderr,
192}
193
194impl Capability {
195    /// Probe the real environment through a sealed precedence fold
196    /// (mirrors `shikumi::resolve_progressive`): `CLICOLOR_FORCE` >
197    /// `NO_COLOR` > `!is_tty` > `COLORTERM` > `TERM` > none.
198    #[must_use]
199    pub fn probe() -> Self {
200        Self::probe_stream(Stream::Stdout)
201    }
202
203    #[must_use]
204    pub fn probe_stream(stream: Stream) -> Self {
205        use std::io::IsTerminal;
206        let is_tty = match stream {
207            Stream::Stdout => std::io::stdout().is_terminal(),
208            Stream::Stderr => std::io::stderr().is_terminal(),
209        };
210        let level = resolve_level(is_tty);
211        Self {
212            level,
213            width: probe_width(),
214            is_tty,
215            unicode: probe_unicode(),
216            background: Background::Assumed,
217        }
218    }
219
220    /// The pipe/`NO_COLOR`/test floor: zero SGR, ASCII, 80 columns.
221    #[must_use]
222    pub const fn plain() -> Self {
223        Self {
224            level: ColorLevel::None,
225            width: 80,
226            is_tty: false,
227            unicode: false,
228            background: Background::Assumed,
229        }
230    }
231
232    /// A fixed capability for tests/mocks at a chosen depth.
233    #[must_use]
234    pub const fn fixed(level: ColorLevel, width: u16, unicode: bool) -> Self {
235        Self {
236            level,
237            width,
238            is_tty: matches!(level, ColorLevel::Truecolor | ColorLevel::Ansi256 | ColorLevel::Ansi16),
239            unicode,
240            background: Background::Assumed,
241        }
242    }
243}
244
245fn env_set(name: &str) -> bool {
246    std::env::var_os(name).is_some_and(|v| !v.is_empty())
247}
248
249fn resolve_level(is_tty: bool) -> ColorLevel {
250    // CLICOLOR_FORCE overrides everything, even a pipe.
251    let forced = env_set("CLICOLOR_FORCE");
252    if env_set("NO_COLOR") && !forced {
253        return ColorLevel::None;
254    }
255    if !is_tty && !forced {
256        return ColorLevel::None;
257    }
258    if let Ok(ct) = std::env::var("COLORTERM") {
259        if ct.contains("truecolor") || ct.contains("24bit") {
260            return ColorLevel::Truecolor;
261        }
262    }
263    match std::env::var("TERM") {
264        Ok(t) if t.contains("256") => ColorLevel::Ansi256,
265        Ok(t) if t.is_empty() || t == "dumb" => {
266            if forced {
267                ColorLevel::Ansi16
268            } else {
269                ColorLevel::None
270            }
271        }
272        Ok(_) => ColorLevel::Ansi16,
273        Err(_) => {
274            if forced {
275                ColorLevel::Ansi16
276            } else {
277                ColorLevel::None
278            }
279        }
280    }
281}
282
283fn probe_width() -> u16 {
284    if let Some((terminal_size::Width(w), _)) = terminal_size::terminal_size() {
285        return w.clamp(20, 400);
286    }
287    if let Ok(cols) = std::env::var("COLUMNS") {
288        if let Ok(w) = cols.parse::<u16>() {
289            return w.clamp(20, 400);
290        }
291    }
292    80
293}
294
295fn probe_unicode() -> bool {
296    for key in ["LC_ALL", "LC_CTYPE", "LANG"] {
297        if let Ok(v) = std::env::var(key) {
298            let v = v.to_ascii_lowercase();
299            if v.contains("utf-8") || v.contains("utf8") {
300                return true;
301            }
302        }
303    }
304    // Default to unicode on a modern tty even without a locale hint.
305    false
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn ansi256_cube_anchors() {
314        assert_eq!(Rgb::new(0, 0, 0).to_ansi256(), 16, "black is cube 16");
315        assert_eq!(Rgb::new(255, 255, 255).to_ansi256(), 231, "white is cube 231");
316    }
317
318    #[test]
319    fn ansi256_grey_ramp() {
320        // A pure mid-grey lands on the 24-step ramp (232..=255), not the cube.
321        let g = Rgb::new(128, 128, 128).to_ansi256();
322        assert!((232..=255).contains(&g), "mid grey -> ramp: {g}");
323    }
324
325    #[test]
326    fn ansi16_anchors() {
327        assert_eq!(Rgb::new(0, 0, 0).to_ansi16(), AnsiColor::Black);
328        assert_eq!(Rgb::new(255, 255, 255).to_ansi16(), AnsiColor::BrightWhite);
329        assert_eq!(Rgb::new(255, 0, 0).to_ansi16(), AnsiColor::BrightRed);
330        assert_eq!(Rgb::new(128, 0, 0).to_ansi16(), AnsiColor::Red);
331    }
332
333    #[test]
334    fn from_irodori_is_lossless() {
335        // CONSUME, no fork: an irodori Nord color round-trips exactly.
336        let n11 = irodori::NORD.aurora[0]; // #BF616A
337        let rgb = Rgb::from_color(n11);
338        assert_eq!((rgb.r, rgb.g, rgb.b), (0xBF, 0x61, 0x6A));
339    }
340
341    #[test]
342    fn plain_capability_is_the_pipe_floor() {
343        let c = Capability::plain();
344        assert_eq!(c.level, ColorLevel::None);
345        assert!(!c.is_tty);
346    }
347}