Skip to main content

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}