Skip to main content

pounce_common/
style.rs

1//! Tiger / rust / warm branded color palette for POUNCE output
2//! (pounce#71).
3//!
4//! This module is **pure** — every function maps values to
5//! [`anstyle`] colors/styles with no I/O and no global state, so the
6//! whole palette is unit-testable without a TTY. Terminal-capability
7//! detection ([`truecolor_enabled`], [`color_enabled_stdout`]) reads
8//! the environment but never emits anything; the actual color
9//! *stripping* for the iteration table is delegated to
10//! `anstream::AutoStream` at the print site.
11//!
12//! Two orthogonal channels drive the iteration table:
13//!
14//! * **Background** marks *restoration* lines, keyed off the
15//!   per-iteration `alpha_primal_char` tag — `'s'` soft-stay → tan,
16//!   `'S'` soft-exit → amber, `'R'` (and the dedicated restoration
17//!   phase's `r`-suffixed rows) → deep rust.
18//! * **Foreground** is a smooth gradient driven by the primal step
19//!   length `alpha ∈ [0, 1]`. On normal lines it runs black (α = 1,
20//!   full Newton step) → red (α → 0, stalling). On restoration lines
21//!   the ramp shifts to cream → bright-yellow so the text stays
22//!   legible on the dark background.
23
24use anstyle::{Ansi256Color, Color, RgbColor, Style};
25
26// ---- Palette constants (tiger / rust / warm) ----
27
28/// Background for hard restoration (`'R'` + dedicated resto-phase rows).
29pub const RUST_DEEP: RgbColor = RgbColor(0x6e, 0x26, 0x0e);
30/// Background for soft-restoration "exit" (`'S'`).
31pub const AMBER: RgbColor = RgbColor(0xb5, 0x6a, 0x12);
32/// Background for soft-restoration "stay" (`'s'`).
33pub const TAN: RgbColor = RgbColor(0x8a, 0x6d, 0x3b);
34/// Accent used for `WARN`-level logs and banners.
35pub const TIGER_ORANGE: RgbColor = RgbColor(0xe8, 0x7a, 0x1e);
36/// Foreground on restoration lines at α = 1 (full step).
37pub const CREAM: RgbColor = RgbColor(0xf5, 0xe6, 0xc8);
38/// Foreground on restoration lines at α → 0 (stalling).
39pub const BRIGHT_YEL: RgbColor = RgbColor(0xff, 0xe0, 0x3a);
40/// Foreground on normal lines at α → 0 (stalling) — the "hot" red.
41pub const ALPHA_HOT: RgbColor = RgbColor(0xcc, 0x22, 0x00);
42/// Foreground on normal lines at α = 1 (full Newton step).
43pub const ALPHA_COOL: RgbColor = RgbColor(0x00, 0x00, 0x00);
44
45// ---- Restoration kind ↔ color ----
46
47/// `true` when `c` denotes a restoration line (`'s'`, `'S'`, `'R'`).
48/// `'t'`/`'T'` (tiny step) are deliberately excluded — that stalling
49/// condition is conveyed by the foreground gradient, not a background.
50pub fn is_resto_char(c: char) -> bool {
51    matches!(c, 's' | 'S' | 'R')
52}
53
54/// Map the iteration's `alpha_primal_char` to its restoration
55/// background, or `None` for a normal (non-restoration) line.
56pub fn resto_background_rgb(c: char) -> Option<RgbColor> {
57    match c {
58        's' => Some(TAN),
59        'S' => Some(AMBER),
60        'R' => Some(RUST_DEEP),
61        _ => None,
62    }
63}
64
65/// Human-readable restoration kind for the structured
66/// `pounce::iteration` tracing event.
67pub fn resto_kind_str(c: char) -> &'static str {
68    match c {
69        's' => "soft_stay",
70        'S' => "soft_exit",
71        'R' => "hard",
72        _ => "none",
73    }
74}
75
76// ---- Alpha gradient ----
77
78/// Linear interpolation of one 8-bit channel by `t ∈ [0, 1]`.
79fn lerp_u8(a: u8, b: u8, t: f64) -> u8 {
80    let v = a as f64 + (b as f64 - a as f64) * t;
81    v.round().clamp(0.0, 255.0) as u8
82}
83
84/// The raw RGB foreground for primal step length `alpha`. `in_resto`
85/// selects the cream→bright-yellow ramp instead of the black→red one.
86///
87/// `alpha` is clamped to `[0, 1]`; non-finite input is treated as a
88/// full step (`alpha = 1`). The interpolation parameter is `1 - alpha`
89/// so that α = 1 is "cool" (black / cream) and α → 0 is "hot"
90/// (red / bright-yellow).
91pub fn alpha_gradient_rgb(alpha: f64, in_resto: bool) -> RgbColor {
92    let alpha = if alpha.is_finite() {
93        alpha.clamp(0.0, 1.0)
94    } else {
95        1.0
96    };
97    let t = 1.0 - alpha;
98    let (cool, hot) = if in_resto {
99        (CREAM, BRIGHT_YEL)
100    } else {
101        (ALPHA_COOL, ALPHA_HOT)
102    };
103    RgbColor(
104        lerp_u8(cool.0, hot.0, t),
105        lerp_u8(cool.1, hot.1, t),
106        lerp_u8(cool.2, hot.2, t),
107    )
108}
109
110// ---- Truecolor → 256-color downgrade ----
111
112/// Snap an 8-bit value to its nearest index on the xterm 6×6×6 color
113/// cube's per-channel step ladder `[0, 95, 135, 175, 215, 255]`.
114///
115/// Returns the 0-based level *index* (0..=5), not the step value itself —
116/// `nearest_ansi256` combines three such indices into the cube offset
117/// `16 + 36*r + 6*g + b`.
118fn cube_level(v: u8) -> u8 {
119    const STEPS: [u8; 6] = [0, 95, 135, 175, 215, 255];
120    let mut best = 0u8;
121    let mut best_d = u16::MAX;
122    for (i, &s) in STEPS.iter().enumerate() {
123        let d = (v as i16 - s as i16).unsigned_abs();
124        if d < best_d {
125            best_d = d;
126            best = i as u8;
127        }
128    }
129    best
130}
131
132/// Nearest xterm-256 cube color to an RGB triple. Used as the graceful
133/// fallback on terminals that advertise ANSI color but not truecolor.
134pub fn nearest_ansi256(c: RgbColor) -> Ansi256Color {
135    let r = cube_level(c.0);
136    let g = cube_level(c.1);
137    let b = cube_level(c.2);
138    Ansi256Color(16 + 36 * r + 6 * g + b)
139}
140
141/// Wrap an RGB color as an [`anstyle::Color`], downgrading to the
142/// nearest 256-color when the terminal lacks truecolor support.
143pub fn downgrade(c: RgbColor, truecolor: bool) -> Color {
144    if truecolor {
145        Color::Rgb(c)
146    } else {
147        Color::Ansi256(nearest_ansi256(c))
148    }
149}
150
151// ---- Composed iteration-row style ----
152
153/// Build the [`Style`] for one iteration-table row: foreground from the
154/// alpha gradient, optional background from the restoration kind.
155/// Honors the detected truecolor capability so the same call yields
156/// RGB on capable terminals and a 256-color approximation elsewhere.
157pub fn iteration_row_style(alpha_primal: f64, alpha_char: char) -> Style {
158    iteration_row_style_with(alpha_primal, alpha_char, truecolor_enabled())
159}
160
161/// [`iteration_row_style`] with the truecolor decision injected — the
162/// unit-test seam (no environment reads).
163pub fn iteration_row_style_with(alpha_primal: f64, alpha_char: char, truecolor: bool) -> Style {
164    let in_resto = is_resto_char(alpha_char);
165    let fg = downgrade(alpha_gradient_rgb(alpha_primal, in_resto), truecolor);
166    let mut style = Style::new().fg_color(Some(fg));
167    if let Some(bg) = resto_background_rgb(alpha_char) {
168        style = style.bg_color(Some(downgrade(bg, truecolor)));
169    }
170    style
171}
172
173// ---- Terminal-capability detection ----
174
175/// `true` when the terminal advertises 24-bit truecolor (`COLORTERM`).
176pub fn truecolor_enabled() -> bool {
177    anstyle_query::truecolor()
178}
179
180/// `true` when colored output should be emitted to stdout: stdout is a
181/// terminal and the user has not opted out via `NO_COLOR` (unless
182/// `CLICOLOR_FORCE` overrides). Stream-based call sites should prefer
183/// `anstream::AutoStream`, which applies the same policy while
184/// stripping escapes from redirected output; this helper is for code
185/// that must branch without a stream handle.
186pub fn color_enabled_stdout() -> bool {
187    use std::io::IsTerminal;
188    if anstyle_query::clicolor_force() {
189        return true;
190    }
191    if anstyle_query::no_color() {
192        return false;
193    }
194    std::io::stdout().is_terminal()
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn resto_background_maps_three_kinds() {
203        assert_eq!(resto_background_rgb('s'), Some(TAN));
204        assert_eq!(resto_background_rgb('S'), Some(AMBER));
205        assert_eq!(resto_background_rgb('R'), Some(RUST_DEEP));
206        // Non-restoration tags (including tiny-step) get no background.
207        for c in [' ', 'f', 'h', 'w', 'W', 't', 'T'] {
208            assert_eq!(resto_background_rgb(c), None, "char {c:?}");
209        }
210    }
211
212    #[test]
213    fn is_resto_char_only_for_s_caps_r() {
214        for c in ['s', 'S', 'R'] {
215            assert!(is_resto_char(c), "char {c:?}");
216        }
217        for c in [' ', 'f', 'h', 'w', 'W', 't', 'T'] {
218            assert!(!is_resto_char(c), "char {c:?}");
219        }
220    }
221
222    #[test]
223    fn alpha_gradient_normal_endpoints() {
224        // Full step → black; stalled → hot red.
225        assert_eq!(alpha_gradient_rgb(1.0, false), ALPHA_COOL);
226        assert_eq!(alpha_gradient_rgb(0.0, false), ALPHA_HOT);
227    }
228
229    #[test]
230    fn alpha_gradient_resto_endpoints() {
231        assert_eq!(alpha_gradient_rgb(1.0, true), CREAM);
232        assert_eq!(alpha_gradient_rgb(0.0, true), BRIGHT_YEL);
233    }
234
235    #[test]
236    fn alpha_gradient_is_monotonic_toward_hot() {
237        // As alpha shrinks the red channel must not decrease (normal
238        // ramp drives from black 0x00 → 0xcc).
239        let mut prev = alpha_gradient_rgb(1.0, false).0;
240        for step in 1..=10 {
241            let a = 1.0 - step as f64 / 10.0;
242            let r = alpha_gradient_rgb(a, false).0;
243            assert!(r >= prev, "alpha={a} red went backwards {prev}->{r}");
244            prev = r;
245        }
246        assert_eq!(prev, ALPHA_HOT.0);
247    }
248
249    #[test]
250    fn alpha_gradient_clamps_and_handles_nonfinite() {
251        assert_eq!(alpha_gradient_rgb(2.0, false), ALPHA_COOL);
252        assert_eq!(alpha_gradient_rgb(-1.0, false), ALPHA_HOT);
253        // NaN is treated as a full step (no false stalling alarm).
254        assert_eq!(alpha_gradient_rgb(f64::NAN, false), ALPHA_COOL);
255    }
256
257    #[test]
258    fn downgrade_picks_rgb_or_256() {
259        assert_eq!(downgrade(RUST_DEEP, true), Color::Rgb(RUST_DEEP));
260        // 256-color path yields a cube index, never an RGB color.
261        match downgrade(RUST_DEEP, false) {
262            Color::Ansi256(_) => {}
263            other => panic!("expected Ansi256, got {other:?}"),
264        }
265    }
266
267    #[test]
268    fn nearest_ansi256_snaps_pure_colors() {
269        // Pure white → cube corner 231 (16 + 36*5 + 6*5 + 5).
270        assert_eq!(
271            nearest_ansi256(RgbColor(0xff, 0xff, 0xff)),
272            Ansi256Color(231)
273        );
274        // Pure black → cube origin 16.
275        assert_eq!(
276            nearest_ansi256(RgbColor(0x00, 0x00, 0x00)),
277            Ansi256Color(16)
278        );
279    }
280
281    #[test]
282    fn iteration_row_style_composes_fg_and_bg() {
283        // Restoration row: both fg gradient and bg present.
284        let s = iteration_row_style_with(0.5, 'R', true);
285        assert!(s.get_fg_color().is_some());
286        assert_eq!(s.get_bg_color(), Some(Color::Rgb(RUST_DEEP)));
287        // Normal row: fg only, no background.
288        let n = iteration_row_style_with(1.0, ' ', true);
289        assert_eq!(n.get_fg_color(), Some(Color::Rgb(ALPHA_COOL)));
290        assert_eq!(n.get_bg_color(), None);
291    }
292}