1use egui::Color32;
4
5use super::color::{Oklch, to_color32};
6use super::{Contrast, Tint};
7
8#[derive(Clone, Copy, PartialEq, Eq, Debug)]
10pub enum Tone {
11 Sunken,
13 Base,
15 Raised,
17 Hover,
19 Pressed,
21 Border,
23 Muted,
25 Text,
27 Strong,
29}
30
31impl Tone {
32 pub const ALL: [Tone; 9] = [
34 Tone::Sunken,
35 Tone::Base,
36 Tone::Raised,
37 Tone::Hover,
38 Tone::Pressed,
39 Tone::Border,
40 Tone::Muted,
41 Tone::Text,
42 Tone::Strong,
43 ];
44
45 fn chroma_scale(self) -> f32 {
47 match self {
48 Tone::Sunken | Tone::Base => 0.2,
49 Tone::Raised => 0.35,
50 Tone::Hover => 0.45,
51 Tone::Pressed => 0.6,
52 Tone::Border => 0.4,
53 Tone::Muted | Tone::Text | Tone::Strong => 1.0,
54 }
55 }
56}
57
58#[derive(Clone, Copy, PartialEq, Eq, Debug)]
60#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
61pub enum Swatch {
62 Neutral,
64 Accent,
66 Red,
67 Orange,
68 Yellow,
69 Green,
70 Cyan,
71 Blue,
72 Purple,
73}
74
75impl Swatch {
76 pub const ALL: [Swatch; 9] = [
78 Swatch::Neutral,
79 Swatch::Accent,
80 Swatch::Red,
81 Swatch::Orange,
82 Swatch::Yellow,
83 Swatch::Green,
84 Swatch::Cyan,
85 Swatch::Blue,
86 Swatch::Purple,
87 ];
88 pub const SUCCESS: Swatch = Swatch::Green;
90 pub const WARNING: Swatch = Swatch::Orange;
92 pub const DANGER: Swatch = Swatch::Red;
94 pub const INFO: Swatch = Swatch::Blue;
96
97 pub(crate) fn chroma_hue(self) -> (f32, f32) {
99 match self {
100 Swatch::Neutral => (0.0, 0.0),
101 Swatch::Accent | Swatch::Blue => (0.15, 255.0),
102 Swatch::Red => (0.17, 25.0),
103 Swatch::Orange => (0.15, 55.0),
104 Swatch::Yellow => (0.14, 95.0),
105 Swatch::Green => (0.14, 145.0),
106 Swatch::Cyan => (0.12, 200.0),
107 Swatch::Purple => (0.15, 305.0),
108 }
109 }
110}
111
112fn resolve(tint: Tint, accent: (f32, f32)) -> (f32, f32) {
116 let (chroma, hue) = match tint {
117 Tint::Swatch(Swatch::Accent) => return accent,
118 Tint::Swatch(s) => return s.chroma_hue(),
119 Tint::Custom { chroma, hue } => (chroma, hue),
120 };
121 if !(chroma.is_finite() && hue.is_finite()) {
122 return Swatch::Blue.chroma_hue();
123 }
124 (chroma.clamp(0.0, Tint::MAX_CHROMA), hue.rem_euclid(360.0))
125}
126
127fn ladder(dark: bool, contrast: Contrast) -> [f32; 9] {
129 match (dark, contrast) {
130 (true, Contrast::Low) => [0.18, 0.23, 0.29, 0.34, 0.39, 0.42, 0.66, 0.80, 0.88],
131 (true, Contrast::Medium) => [0.16, 0.21, 0.27, 0.32, 0.38, 0.44, 0.72, 0.88, 0.96],
132 (true, Contrast::High) => [0.10, 0.14, 0.22, 0.28, 0.34, 0.50, 0.82, 0.96, 0.995],
133 (false, Contrast::Low) => [0.985, 0.96, 0.91, 0.87, 0.83, 0.78, 0.58, 0.42, 0.32],
134 (false, Contrast::Medium) => [0.995, 0.97, 0.92, 0.88, 0.84, 0.78, 0.50, 0.30, 0.20],
135 (false, Contrast::High) => [1.0, 0.99, 0.93, 0.89, 0.85, 0.70, 0.40, 0.16, 0.06],
136 }
137}
138
139fn cell(ladder: [f32; 9], tone: Tone, (c, h): (f32, f32)) -> Color32 {
141 to_color32(Oklch {
142 l: ladder[tone as usize],
143 c: c * tone.chroma_scale(),
144 h,
145 })
146}
147
148#[derive(Clone, PartialEq, Debug)]
150pub struct Theme {
151 dark: bool,
152 ladder: [f32; 9],
153 accent: (f32, f32),
155 cells: [[Color32; 9]; 9],
156}
157
158impl Theme {
159 pub fn new(dark: bool, contrast: Contrast, accent: Tint) -> Theme {
162 let ladder = ladder(dark, contrast);
163 let accent = resolve(accent, Swatch::Blue.chroma_hue());
164 let cells = std::array::from_fn(|t| {
165 std::array::from_fn(|s| {
166 cell(
167 ladder,
168 Tone::ALL[t],
169 resolve(Tint::Swatch(Swatch::ALL[s]), accent),
170 )
171 })
172 });
173 Theme {
174 dark,
175 ladder,
176 accent,
177 cells,
178 }
179 }
180
181 pub(crate) fn chroma_hue(&self, tint: Tint) -> (f32, f32) {
184 resolve(tint, self.accent)
185 }
186
187 pub fn tint(&self, tone: Tone, tint: Tint) -> Color32 {
189 match tint {
190 Tint::Swatch(s) => self.color(tone, s),
191 Tint::Custom { .. } => cell(self.ladder, tone, resolve(tint, self.accent)),
192 }
193 }
194
195 pub fn is_dark(&self) -> bool {
197 self.dark
198 }
199
200 pub fn color(&self, tone: Tone, swatch: Swatch) -> Color32 {
202 self.cells[tone as usize][swatch as usize]
203 }
204
205 pub fn text(&self, swatch: Swatch) -> Color32 {
207 self.color(Tone::Text, swatch)
208 }
209
210 pub fn fill(&self, swatch: Swatch) -> Color32 {
212 self.color(Tone::Raised, swatch)
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use crate::theme::color::{contrast_ratio, from_color32};
220 use crate::theme::{Contrast, Tint};
221
222 const CONTRASTS: [Contrast; 3] = [Contrast::Low, Contrast::Medium, Contrast::High];
223
224 fn targets(contrast: Contrast) -> (f32, f32) {
226 match contrast {
227 Contrast::Low => (4.5, 3.0),
228 Contrast::Medium => (7.0, 4.5),
229 Contrast::High => (12.0, 7.0),
230 }
231 }
232
233 fn themes(accent: Tint) -> impl Iterator<Item = (Theme, Contrast)> {
234 [false, true]
235 .into_iter()
236 .flat_map(move |dark| CONTRASTS.map(|c| (Theme::new(dark, c, accent), c)))
237 }
238
239 fn on_base(t: &Theme, c: egui::Color32) -> f32 {
240 contrast_ratio(c, t.color(Tone::Base, Swatch::Neutral))
241 }
242
243 #[test]
244 fn every_ladder_meets_its_targets() {
245 for (t, contrast) in themes(Tint::Swatch(Swatch::Blue)) {
246 let (text, muted) = targets(contrast);
247 let name = format!("dark {} {contrast:?}", t.is_dark());
248 let r = on_base(&t, t.color(Tone::Text, Swatch::Neutral));
249 assert!(r >= text, "{name}: text {r}");
250 let r = on_base(&t, t.color(Tone::Muted, Swatch::Neutral));
251 assert!(r >= muted, "{name}: muted {r}");
252 for s in Swatch::ALL {
253 let r = on_base(&t, t.text(s));
254 assert!(r >= muted, "{name}: {s:?} {r}");
255 }
256 }
257 }
258
259 #[test]
260 fn ladders_are_monotonic() {
261 for (t, contrast) in themes(Tint::Swatch(Swatch::Blue)) {
262 let l: Vec<f32> = Tone::ALL
263 .iter()
264 .map(|&tone| from_color32(t.color(tone, Swatch::Neutral)).l)
265 .collect();
266 for w in l.windows(2) {
267 let rising = w[1] > w[0];
268 assert_eq!(
269 rising,
270 t.is_dark(),
271 "dark {} {contrast:?}: {l:?}",
272 t.is_dark()
273 );
274 }
275 }
276 }
277
278 #[test]
279 fn accent_extremes_meet_the_swatch_target() {
280 for hue in (0..360).step_by(10) {
281 let accent = Tint::Custom {
282 chroma: Tint::MAX_CHROMA,
283 hue: hue as f32,
284 };
285 for (t, contrast) in themes(accent) {
286 let r = on_base(&t, t.text(Swatch::Accent));
287 assert!(
288 r >= targets(contrast).1,
289 "hue {hue}, dark {} {contrast:?}: {r}",
290 t.is_dark()
291 );
292 }
293 }
294 }
295
296 #[test]
297 fn grey_accent_is_neutral() {
298 for hue in [0.0, 123.0, 300.0] {
299 let t = Theme::new(true, Contrast::Medium, Tint::Custom { chroma: 0.0, hue });
300 for tone in Tone::ALL {
301 assert_eq!(
302 t.color(tone, Swatch::Accent),
303 t.color(tone, Swatch::Neutral)
304 );
305 }
306 }
307 }
308
309 #[test]
310 fn hue_wraps() {
311 for (hue, same) in [(360.0, 0.0), (-30.0, 330.0), (720.0, 0.0)] {
312 let a = Theme::new(false, Contrast::Medium, Tint::Custom { chroma: 0.15, hue });
313 let b = Theme::new(
314 false,
315 Contrast::Medium,
316 Tint::Custom {
317 chroma: 0.15,
318 hue: same,
319 },
320 );
321 for tone in Tone::ALL {
322 assert_eq!(
323 a.color(tone, Swatch::Accent),
324 b.color(tone, Swatch::Accent),
325 "{hue}"
326 );
327 }
328 }
329 }
330
331 #[test]
332 fn aliases() {
333 assert_eq!(Swatch::SUCCESS, Swatch::Green);
334 assert_eq!(Swatch::WARNING, Swatch::Orange);
335 assert_eq!(Swatch::DANGER, Swatch::Red);
336 assert_eq!(Swatch::INFO, Swatch::Blue);
337 }
338
339 fn medium(dark: bool) -> Theme {
340 Theme::new(dark, Contrast::Medium, Tint::Swatch(Swatch::Blue))
341 }
342
343 #[test]
344 fn tint_of_a_swatch_is_its_cell() {
345 for dark in [true, false] {
346 let t = medium(dark);
347 for tone in Tone::ALL {
348 for s in Swatch::ALL {
349 assert_eq!(t.tint(tone, Tint::Swatch(s)), t.color(tone, s));
350 }
351 }
352 }
353 }
354
355 #[test]
356 fn custom_hue_wraps_and_grey_is_neutral() {
357 let t = medium(true);
358 let custom = |chroma, hue| Tint::Custom { chroma, hue };
359 for tone in Tone::ALL {
360 assert_eq!(
361 t.tint(tone, custom(0.15, -30.0)),
362 t.tint(tone, custom(0.15, 330.0))
363 );
364 assert_eq!(
365 t.tint(tone, custom(0.0, 77.0)),
366 t.color(tone, Swatch::Neutral)
367 );
368 }
369 }
370
371 #[test]
372 fn non_finite_reads_as_default_accent() {
373 let t = medium(false);
374 for tint in [
375 Tint::Custom {
376 chroma: f32::NAN,
377 hue: 10.0,
378 },
379 Tint::Custom {
380 chroma: 0.1,
381 hue: f32::INFINITY,
382 },
383 ] {
384 for tone in Tone::ALL {
385 assert_eq!(t.tint(tone, tint), t.tint(tone, Tint::Swatch(Swatch::Blue)));
386 }
387 }
388 let nan_accent = Theme::new(
389 false,
390 Contrast::Medium,
391 Tint::Custom {
392 chroma: f32::NAN,
393 hue: 0.0,
394 },
395 );
396 assert_eq!(
397 nan_accent.color(Tone::Text, Swatch::Accent),
398 t.color(Tone::Text, Swatch::Blue)
399 );
400 }
401
402 #[test]
403 fn accent_swatch_accent_is_blue() {
404 let t = Theme::new(true, Contrast::Medium, Tint::Swatch(Swatch::Accent));
405 for tone in Tone::ALL {
406 assert_eq!(t.color(tone, Swatch::Accent), t.color(tone, Swatch::Blue));
407 }
408 }
409}