corundum_framework_gui/theme/
tint.rs1use super::Swatch;
5
6use std::hash::{BuildHasher, Hasher};
7use std::sync::atomic::{AtomicU64, Ordering};
8
9#[derive(Clone, Copy, PartialEq, Debug)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
12pub enum Tint {
13 Swatch(Swatch),
15 Custom { chroma: f32, hue: f32 },
18}
19
20impl Tint {
21 pub const MAX_CHROMA: f32 = 0.25;
23
24 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 pub fn from_bytes(bytes: &[u8]) -> Tint {
39 Tint::from_u64(fmix64(fnv1a(bytes)))
40 }
41
42 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 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
57fn 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
67pub(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 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 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}