haz_cli/output/palette.rs
1//! Deterministic per-task colour assignment plus the small ANSI
2//! escape-code helpers the presenter writes.
3//!
4//! Each task identity (`<project>:<task>` byte string) maps to a
5//! 24-bit RGB triple via:
6//!
7//! 1. An FNV-1a 64-bit hash of the identity string. Stable across
8//! runs and across processes (no per-process randomisation, no
9//! transient `std::hash::RandomState`).
10//! 2. The hash, taken modulo 360, becomes the HSL hue in degrees.
11//! Fixed saturation (0.70) and lightness (0.62) keep every
12//! rendered colour bright enough to read on a dark terminal
13//! background and dim enough to read on a light one.
14//! 3. The HSL triple converts to RGB via the textbook
15//! chroma-and-match formula.
16//!
17//! The presenter writes raw ANSI escape sequences directly: the
18//! truecolor SGR (`ESC [ 38 ; 2 ; r ; g ; b m`) for foreground
19//! colour, plus the reset (`ESC [ 0 m`). No external crate. The
20//! file's public surface is the [`task_color`] entry point plus
21//! the [`Rgb`] colour value and the [`ansi`] sub-module that
22//! produces the escape bytes.
23
24use haz_domain::task_id::TaskId;
25
26/// 24-bit colour value the presenter writes into the truecolor
27/// SGR sequence.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29pub struct Rgb {
30 /// Red channel, 0..=255.
31 pub r: u8,
32 /// Green channel, 0..=255.
33 pub g: u8,
34 /// Blue channel, 0..=255.
35 pub b: u8,
36}
37
38impl Rgb {
39 /// Construct an [`Rgb`] from the three channels.
40 #[must_use]
41 pub const fn new(r: u8, g: u8, b: u8) -> Self {
42 Self { r, g, b }
43 }
44}
45
46/// FNV-1a offset basis for the 64-bit variant.
47const FNV1A_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
48/// FNV-1a prime for the 64-bit variant.
49const FNV1A_PRIME: u64 = 0x0000_0100_0000_01b3;
50
51/// FNV-1a hash of `bytes`. Stable across runs and processes; no
52/// per-process randomisation.
53#[must_use]
54fn fnv1a_64(bytes: &[u8]) -> u64 {
55 let mut hash = FNV1A_OFFSET_BASIS;
56 for byte in bytes {
57 hash ^= u64::from(*byte);
58 hash = hash.wrapping_mul(FNV1A_PRIME);
59 }
60 hash
61}
62
63/// Convert an HSL triple to an RGB triple.
64///
65/// `hue_degrees` is wrapped into `[0, 360)`. `saturation` and
66/// `lightness` are clamped into `[0, 1]` because callers in this
67/// module pin them at compile time, but the clamps mean a stray
68/// out-of-range constant cannot panic.
69#[must_use]
70fn hsl_to_rgb(hue_degrees: f32, saturation: f32, lightness: f32) -> Rgb {
71 let saturation = saturation.clamp(0.0, 1.0);
72 let lightness = lightness.clamp(0.0, 1.0);
73 let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation;
74 let hue_sector = hue_degrees.rem_euclid(360.0) / 60.0;
75 let secondary = chroma * (1.0 - (hue_sector.rem_euclid(2.0) - 1.0).abs());
76 // `hue_sector` is in [0, 6) by construction. The six branches
77 // map each half-open unit interval to its primary-channel
78 // assignment per the standard HSL-to-RGB derivation. Branching
79 // on a float (rather than casting the floor to an integer)
80 // sidesteps the truncation lint without changing the result.
81 let (r1, g1, b1) = if hue_sector < 1.0 {
82 (chroma, secondary, 0.0)
83 } else if hue_sector < 2.0 {
84 (secondary, chroma, 0.0)
85 } else if hue_sector < 3.0 {
86 (0.0, chroma, secondary)
87 } else if hue_sector < 4.0 {
88 (0.0, secondary, chroma)
89 } else if hue_sector < 5.0 {
90 (secondary, 0.0, chroma)
91 } else {
92 (chroma, 0.0, secondary)
93 };
94 let match_value = lightness - chroma / 2.0;
95 Rgb::new(
96 channel_to_byte(r1 + match_value),
97 channel_to_byte(g1 + match_value),
98 channel_to_byte(b1 + match_value),
99 )
100}
101
102fn channel_to_byte(value: f32) -> u8 {
103 let scaled = (value.clamp(0.0, 1.0) * 255.0).round();
104 // `scaled` is in [0.0, 255.0] by construction (clamp + scale +
105 // round). The saturating `f32 as u8` cast added in Rust 1.45
106 // additionally maps any NaN / out-of-range input to a safe
107 // byte, so the conversion is total.
108 #[expect(
109 clippy::cast_possible_truncation,
110 clippy::cast_sign_loss,
111 reason = "scaled is bounded by the preceding clamp + scale + round"
112 )]
113 let byte = scaled as u8;
114 byte
115}
116
117/// Saturation pinned for every task colour. Chosen so colours are
118/// vivid on both light and dark terminal backgrounds without
119/// overpowering the surrounding text.
120const TASK_SATURATION: f32 = 0.70;
121/// Lightness pinned for every task colour. Same rationale as
122/// `TASK_SATURATION`: bright enough on a dark terminal, dim enough
123/// on a light one.
124const TASK_LIGHTNESS: f32 = 0.62;
125
126/// Return the deterministic RGB colour for `task`.
127///
128/// The mapping is a pure function of the task's `<project>:<task>`
129/// display form: identical workspaces produce identical colours
130/// across runs and across machines. Distinct task identities give
131/// well-distributed hues; collisions exist (two distinct identities
132/// MAY hash to the same hue degree) but are rare in practice and
133/// never cause a correctness issue, only a presentation one.
134#[must_use]
135pub fn task_color(task: &TaskId) -> Rgb {
136 let display = format!("{task}");
137 let hash = fnv1a_64(display.as_bytes());
138 // `hash % 360` fits in 9 bits; the widening through `u16` is
139 // lossless and `f32::from(u16)` is precision-preserving for
140 // every value below 2^24, well above 360.
141 let hue_bucket = u16::try_from(hash % 360).unwrap_or(0);
142 let hue = f32::from(hue_bucket);
143 hsl_to_rgb(hue, TASK_SATURATION, TASK_LIGHTNESS)
144}
145
146/// ANSI escape-sequence helpers the presenter writes when colour
147/// is enabled.
148///
149/// Truecolor (24-bit) foreground / background SGRs are widely
150/// supported across modern terminals (xterm, alacritty, kitty,
151/// wezterm, iTerm2, Windows Terminal, GNOME Terminal). A terminal
152/// that lacks truecolor support either ignores the SGR and renders
153/// the text uncoloured or falls back to an approximate indexed
154/// colour; both degraded modes are acceptable. Older limited-
155/// colour terminals are not the target.
156pub mod ansi {
157 use super::Rgb;
158
159 /// ANSI escape: reset all SGR attributes.
160 pub const RESET: &str = "\x1b[0m";
161 /// ANSI escape: bold (intensity) on.
162 pub const BOLD: &str = "\x1b[1m";
163 /// ANSI escape: dim (low intensity) on.
164 pub const DIM: &str = "\x1b[2m";
165
166 /// 8-colour palette indices that map to a status-bearing
167 /// meaning the presenter reuses across the summary line and
168 /// the run-level epilogue.
169 pub const FG_GREEN: &str = "\x1b[32m";
170 pub const FG_YELLOW: &str = "\x1b[33m";
171 pub const FG_RED: &str = "\x1b[31m";
172
173 /// Truecolor foreground SGR for `rgb`. Wraps the channels into
174 /// `\x1b[38;2;R;G;Bm`.
175 #[must_use]
176 pub fn fg(rgb: Rgb) -> String {
177 format!("\x1b[38;2;{};{};{}m", rgb.r, rgb.g, rgb.b)
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use std::str::FromStr;
184
185 use haz_domain::name::{ProjectName, TaskName};
186
187 use super::*;
188
189 fn task_id_for(project: &str, task: &str) -> TaskId {
190 TaskId {
191 project: ProjectName::from_str(project).unwrap(),
192 task: TaskName::from_str(task).unwrap(),
193 }
194 }
195
196 #[test]
197 fn fnv1a_is_deterministic() {
198 assert_eq!(fnv1a_64(b"abc"), fnv1a_64(b"abc"));
199 assert_ne!(fnv1a_64(b"abc"), fnv1a_64(b"abd"));
200 }
201
202 #[test]
203 fn task_color_is_stable_across_calls() {
204 let task = task_id_for("lib", "build");
205 assert_eq!(task_color(&task), task_color(&task));
206 }
207
208 #[test]
209 fn task_color_differs_for_distinct_tasks() {
210 // A handful of identities; at least one pair MUST differ.
211 // The chance of all four hashing to the same hue degree is
212 // negligible; the assertion catches a hash function that
213 // collapses everything to zero.
214 let a = task_color(&task_id_for("lib", "build"));
215 let b = task_color(&task_id_for("lib", "test"));
216 let c = task_color(&task_id_for("web", "bundle"));
217 let d = task_color(&task_id_for("api", "check"));
218 assert!(a != b || a != c || a != d);
219 }
220
221 #[test]
222 fn hsl_to_rgb_known_points() {
223 // Hue 0 + lightness 0.5 + saturation 1.0 = pure red.
224 assert_eq!(hsl_to_rgb(0.0, 1.0, 0.5), Rgb::new(255, 0, 0));
225 // Hue 120 = pure green.
226 assert_eq!(hsl_to_rgb(120.0, 1.0, 0.5), Rgb::new(0, 255, 0));
227 // Hue 240 = pure blue.
228 assert_eq!(hsl_to_rgb(240.0, 1.0, 0.5), Rgb::new(0, 0, 255));
229 // Saturation 0 = greyscale.
230 let grey = hsl_to_rgb(0.0, 0.0, 0.5);
231 assert_eq!(grey.r, grey.g);
232 assert_eq!(grey.g, grey.b);
233 }
234
235 #[test]
236 fn ansi_fg_renders_truecolor_sgr() {
237 let escape = ansi::fg(Rgb::new(10, 20, 30));
238 assert_eq!(escape, "\x1b[38;2;10;20;30m");
239 }
240}