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}