Skip to main content

corundum_framework_gui/theme/
tint.rs

1//! [`Tint`]: a colour with no lightness of its own — a swatch of the theme's, or a hue and a
2//! chroma — and ways to pick one at random or from a name.
3
4use super::Swatch;
5
6use std::hash::{BuildHasher, Hasher};
7use std::sync::atomic::{AtomicU64, Ordering};
8
9/// A colour with no lightness of its own: where it is used decides that.
10#[derive(Clone, Copy, PartialEq, Debug)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
12pub enum Tint {
13    /// One of the theme's, by name: it follows the theme.
14    Swatch(Swatch),
15    /// An OKLCH chroma and hue (in degrees), as the disk of a
16    /// [`TintPicker`](super::TintPicker) gives them.
17    Custom { chroma: f32, hue: f32 },
18}
19
20impl Tint {
21    /// The most chroma a tint may have: the rim of the picker's disk.
22    pub const MAX_CHROMA: f32 = 0.25;
23
24    /// The point of the disk that `u1` and `u2` (each in `[0, 1)`) pick, evenly over its
25    /// area: chroma `√u1 · MAX_CHROMA`, hue `360 · u2`. Vivid tints are as common as the
26    /// rim is long, so greys are rare.
27    pub fn from_unit(u1: f32, u2: f32) -> Tint {
28        let unit = |u: f32| u.clamp(0.0, 1.0 - f32::EPSILON);
29        Tint::Custom {
30            chroma: unit(u1).sqrt() * Tint::MAX_CHROMA,
31            hue: unit(u2) * 360.0,
32        }
33    }
34
35    /// The tint `bytes` hash to — a label's name, say. The hash (FNV-1a, then
36    /// MurmurHash3's finaliser) is fixed, so the same bytes give the same tint on every
37    /// device and every version.
38    pub fn from_bytes(bytes: &[u8]) -> Tint {
39        Tint::from_u64(fmix64(fnv1a(bytes)))
40    }
41
42    /// A tint at random, evenly over the disk.
43    pub fn random() -> Tint {
44        static DRAWS: AtomicU64 = AtomicU64::new(0);
45        let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
46        hasher.write_u64(DRAWS.fetch_add(1, Ordering::Relaxed));
47        Tint::from_u64(hasher.finish())
48    }
49
50    /// The high 32 bits pick the radius, the low 32 the angle.
51    fn from_u64(x: u64) -> Tint {
52        let unit = |bits: u64| (bits as f64 / (1u64 << 32) as f64) as f32;
53        Tint::from_unit(unit(x >> 32), unit(x & 0xffff_ffff))
54    }
55}
56
57/// MurmurHash3's 64-bit finaliser. FNV-1a alone leaves the high bits of short, similar
58/// inputs ("label1", "label2", …) nearly the same, which bunches their tints together.
59fn fmix64(mut k: u64) -> u64 {
60    k ^= k >> 33;
61    k = k.wrapping_mul(0xff51_afd7_ed55_8ccd);
62    k ^= k >> 33;
63    k = k.wrapping_mul(0xc4ce_b9fe_1a85_ec53);
64    k ^ (k >> 33)
65}
66
67/// FNV-1a, 64 bits.
68pub(crate) fn fnv1a(bytes: &[u8]) -> u64 {
69    bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, &b| {
70        (hash ^ b as u64).wrapping_mul(0x0000_0100_0000_01b3)
71    })
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    fn custom(t: Tint) -> (f32, f32) {
79        match t {
80            Tint::Custom { chroma, hue } => (chroma, hue),
81            other => panic!("{other:?} is not Custom"),
82        }
83    }
84
85    #[test]
86    fn from_unit_maps_radius_and_angle() {
87        assert_eq!(custom(Tint::from_unit(0.0, 0.5)).0, 0.0);
88        let (chroma, hue) = custom(Tint::from_unit(0.999_999, 0.25));
89        assert!((chroma - Tint::MAX_CHROMA).abs() < 1e-3, "{chroma}");
90        assert!((hue - 90.0).abs() < 0.1, "{hue}");
91    }
92
93    #[test]
94    fn from_unit_is_area_uniform() {
95        let n = 100;
96        let mut inner = 0;
97        for i in 0..n {
98            for j in 0..n {
99                let u1 = (i as f32 + 0.5) / n as f32;
100                let u2 = (j as f32 + 0.5) / n as f32;
101                if custom(Tint::from_unit(u1, u2)).0 <= Tint::MAX_CHROMA / 2.0 {
102                    inner += 1;
103                }
104            }
105        }
106        let share = inner as f32 / (n * n) as f32;
107        assert!((0.24..=0.26).contains(&share), "{share}");
108    }
109
110    #[test]
111    fn fnv1a_known_values() {
112        assert_eq!(fnv1a(b""), 0xcbf2_9ce4_8422_2325);
113        assert_eq!(fnv1a(b"a"), 0xaf63_dc4c_8601_ec8c);
114    }
115
116    #[test]
117    fn from_bytes_is_fixed() {
118        let (chroma, hue) = custom(Tint::from_bytes(b"bug"));
119        assert!((chroma - PINNED_CHROMA).abs() < 1e-6, "{chroma}");
120        assert!((hue - PINNED_HUE).abs() < 1e-4, "{hue}");
121    }
122
123    /// `from_bytes(b"bug")` as first computed: a change here changes every label's colour.
124    const PINNED_CHROMA: f32 = 0.238_907_53;
125    const PINNED_HUE: f32 = 331.028_96;
126
127    #[test]
128    fn similar_names_spread_over_the_disk() {
129        // Evenly over the area, 9% fall within 0.3 of the radius.
130        let inner = (0..2000)
131            .map(|i| custom(Tint::from_bytes(format!("label{i}").as_bytes())).0)
132            .filter(|&c| c < 0.3 * Tint::MAX_CHROMA)
133            .count();
134        let share = inner as f32 / 2000.0;
135        assert!((0.06..=0.12).contains(&share), "{share}");
136    }
137
138    #[test]
139    fn from_bytes_differs() {
140        assert_ne!(Tint::from_bytes(b"bug"), Tint::from_bytes(b"feature"));
141    }
142
143    #[test]
144    fn random_is_in_range_and_varies() {
145        let draws: Vec<(f32, f32)> = (0..100).map(|_| custom(Tint::random())).collect();
146        for &(chroma, hue) in &draws {
147            assert!((0.0..=Tint::MAX_CHROMA).contains(&chroma), "{chroma}");
148            assert!((0.0..360.0).contains(&hue), "{hue}");
149        }
150        assert!(draws.iter().any(|d| *d != draws[0]));
151    }
152
153    #[cfg(feature = "serde")]
154    #[test]
155    fn tint_round_trips() {
156        for t in [
157            Tint::Swatch(Swatch::Red),
158            Tint::Custom {
159                chroma: 0.2,
160                hue: 140.0,
161            },
162        ] {
163            let json = serde_json::to_string(&t).unwrap();
164            assert_eq!(serde_json::from_str::<Tint>(&json).unwrap(), t, "{json}");
165        }
166    }
167}