1use super::contrast;
11use super::hct::Hct;
12use super::palette::TonalPalette;
13use super::scheme::{DynamicScheme, SpecVersion};
14use super::spec_2021;
15use super::spec_2025;
16use super::utils::{clamp_double, lerp};
17use std::cell::RefCell;
18use std::collections::HashMap;
19
20#[allow(missing_docs, reason = "each is the Material role of the same name")]
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
23pub enum DynamicRole {
24 PrimaryPaletteKeyColor,
25 SecondaryPaletteKeyColor,
26 TertiaryPaletteKeyColor,
27 NeutralPaletteKeyColor,
28 NeutralVariantPaletteKeyColor,
29 ErrorPaletteKeyColor,
30 Background,
31 OnBackground,
32 Surface,
33 SurfaceDim,
34 SurfaceBright,
35 SurfaceContainerLowest,
36 SurfaceContainerLow,
37 SurfaceContainer,
38 SurfaceContainerHigh,
39 SurfaceContainerHighest,
40 OnSurface,
41 SurfaceVariant,
42 OnSurfaceVariant,
43 InverseSurface,
44 InverseOnSurface,
45 Outline,
46 OutlineVariant,
47 Shadow,
48 Scrim,
49 SurfaceTint,
50 Primary,
51 PrimaryDim,
52 OnPrimary,
53 PrimaryContainer,
54 OnPrimaryContainer,
55 InversePrimary,
56 Secondary,
57 SecondaryDim,
58 OnSecondary,
59 SecondaryContainer,
60 OnSecondaryContainer,
61 Tertiary,
62 TertiaryDim,
63 OnTertiary,
64 TertiaryContainer,
65 OnTertiaryContainer,
66 Error,
67 ErrorDim,
68 OnError,
69 ErrorContainer,
70 OnErrorContainer,
71 PrimaryFixed,
72 PrimaryFixedDim,
73 OnPrimaryFixed,
74 OnPrimaryFixedVariant,
75 SecondaryFixed,
76 SecondaryFixedDim,
77 OnSecondaryFixed,
78 OnSecondaryFixedVariant,
79 TertiaryFixed,
80 TertiaryFixedDim,
81 OnTertiaryFixed,
82 OnTertiaryFixedVariant,
83}
84
85impl DynamicRole {
86 pub const ALL: [Self; 59] = {
88 use DynamicRole::*;
89 [
90 PrimaryPaletteKeyColor,
91 SecondaryPaletteKeyColor,
92 TertiaryPaletteKeyColor,
93 NeutralPaletteKeyColor,
94 NeutralVariantPaletteKeyColor,
95 ErrorPaletteKeyColor,
96 Background,
97 OnBackground,
98 Surface,
99 SurfaceDim,
100 SurfaceBright,
101 SurfaceContainerLowest,
102 SurfaceContainerLow,
103 SurfaceContainer,
104 SurfaceContainerHigh,
105 SurfaceContainerHighest,
106 OnSurface,
107 SurfaceVariant,
108 OnSurfaceVariant,
109 InverseSurface,
110 InverseOnSurface,
111 Outline,
112 OutlineVariant,
113 Shadow,
114 Scrim,
115 SurfaceTint,
116 Primary,
117 PrimaryDim,
118 OnPrimary,
119 PrimaryContainer,
120 OnPrimaryContainer,
121 InversePrimary,
122 Secondary,
123 SecondaryDim,
124 OnSecondary,
125 SecondaryContainer,
126 OnSecondaryContainer,
127 Tertiary,
128 TertiaryDim,
129 OnTertiary,
130 TertiaryContainer,
131 OnTertiaryContainer,
132 Error,
133 ErrorDim,
134 OnError,
135 ErrorContainer,
136 OnErrorContainer,
137 PrimaryFixed,
138 PrimaryFixedDim,
139 OnPrimaryFixed,
140 OnPrimaryFixedVariant,
141 SecondaryFixed,
142 SecondaryFixedDim,
143 OnSecondaryFixed,
144 OnSecondaryFixedVariant,
145 TertiaryFixed,
146 TertiaryFixedDim,
147 OnTertiaryFixed,
148 OnTertiaryFixedVariant,
149 ]
150 };
151
152 pub const fn name(self) -> &'static str {
154 use DynamicRole::*;
155 match self {
156 PrimaryPaletteKeyColor => "primary_palette_key_color",
157 SecondaryPaletteKeyColor => "secondary_palette_key_color",
158 TertiaryPaletteKeyColor => "tertiary_palette_key_color",
159 NeutralPaletteKeyColor => "neutral_palette_key_color",
160 NeutralVariantPaletteKeyColor => "neutral_variant_palette_key_color",
161 ErrorPaletteKeyColor => "error_palette_key_color",
162 Background => "background",
163 OnBackground => "on_background",
164 Surface => "surface",
165 SurfaceDim => "surface_dim",
166 SurfaceBright => "surface_bright",
167 SurfaceContainerLowest => "surface_container_lowest",
168 SurfaceContainerLow => "surface_container_low",
169 SurfaceContainer => "surface_container",
170 SurfaceContainerHigh => "surface_container_high",
171 SurfaceContainerHighest => "surface_container_highest",
172 OnSurface => "on_surface",
173 SurfaceVariant => "surface_variant",
174 OnSurfaceVariant => "on_surface_variant",
175 InverseSurface => "inverse_surface",
176 InverseOnSurface => "inverse_on_surface",
177 Outline => "outline",
178 OutlineVariant => "outline_variant",
179 Shadow => "shadow",
180 Scrim => "scrim",
181 SurfaceTint => "surface_tint",
182 Primary => "primary",
183 PrimaryDim => "primary_dim",
184 OnPrimary => "on_primary",
185 PrimaryContainer => "primary_container",
186 OnPrimaryContainer => "on_primary_container",
187 InversePrimary => "inverse_primary",
188 Secondary => "secondary",
189 SecondaryDim => "secondary_dim",
190 OnSecondary => "on_secondary",
191 SecondaryContainer => "secondary_container",
192 OnSecondaryContainer => "on_secondary_container",
193 Tertiary => "tertiary",
194 TertiaryDim => "tertiary_dim",
195 OnTertiary => "on_tertiary",
196 TertiaryContainer => "tertiary_container",
197 OnTertiaryContainer => "on_tertiary_container",
198 Error => "error",
199 ErrorDim => "error_dim",
200 OnError => "on_error",
201 ErrorContainer => "error_container",
202 OnErrorContainer => "on_error_container",
203 PrimaryFixed => "primary_fixed",
204 PrimaryFixedDim => "primary_fixed_dim",
205 OnPrimaryFixed => "on_primary_fixed",
206 OnPrimaryFixedVariant => "on_primary_fixed_variant",
207 SecondaryFixed => "secondary_fixed",
208 SecondaryFixedDim => "secondary_fixed_dim",
209 OnSecondaryFixed => "on_secondary_fixed",
210 OnSecondaryFixedVariant => "on_secondary_fixed_variant",
211 TertiaryFixed => "tertiary_fixed",
212 TertiaryFixedDim => "tertiary_fixed_dim",
213 OnTertiaryFixed => "on_tertiary_fixed",
214 OnTertiaryFixedVariant => "on_tertiary_fixed_variant",
215 }
216 }
217}
218
219#[derive(Clone, Copy, Debug, PartialEq)]
222pub struct ContrastCurve {
223 pub low: f64,
225 pub normal: f64,
227 pub medium: f64,
229 pub high: f64,
231}
232
233impl ContrastCurve {
234 pub const fn new(low: f64, normal: f64, medium: f64, high: f64) -> Self {
236 Self {
237 low,
238 normal,
239 medium,
240 high,
241 }
242 }
243
244 pub fn get(&self, level: f64) -> f64 {
246 if level <= -1. {
247 self.low
248 } else if level < 0. {
249 lerp(self.low, self.normal, level + 1.)
250 } else if level < 0.5 {
251 lerp(self.normal, self.medium, level / 0.5)
252 } else if level < 1. {
253 lerp(self.medium, self.high, (level - 0.5) / 0.5)
254 } else {
255 self.high
256 }
257 }
258}
259
260#[derive(Clone, Copy, Debug, PartialEq, Eq)]
262pub(super) enum TonePolarity {
263 Darker,
264 Lighter,
265 Nearer,
266 RelativeLighter,
267}
268
269#[derive(Clone, Copy, Debug, PartialEq, Eq)]
271pub(super) enum DeltaConstraint {
272 Exact,
273 Farther,
274}
275
276#[derive(Clone, Copy, Debug, PartialEq)]
278pub(super) struct ToneDeltaPair {
279 pub a: DynamicRole,
280 pub b: DynamicRole,
281 pub delta: f64,
282 pub polarity: TonePolarity,
283 pub stay_together: bool,
284 pub constraint: DeltaConstraint,
285}
286
287#[derive(Clone, Copy, Debug)]
291pub(super) struct Def {
292 pub palette: TonalPalette,
293 pub is_background: bool,
294 pub chroma_multiplier: f64,
295 pub background: Option<DynamicRole>,
296 pub second_background: Option<DynamicRole>,
297 pub curve: Option<ContrastCurve>,
298 pub pair: Option<ToneDeltaPair>,
299}
300
301impl Def {
302 pub fn new(palette: TonalPalette) -> Self {
303 Self {
304 palette,
305 is_background: false,
306 chroma_multiplier: 1.,
307 background: None,
308 second_background: None,
309 curve: None,
310 pair: None,
311 }
312 }
313
314 pub fn is_background(mut self) -> Self {
315 self.is_background = true;
316 self
317 }
318
319 pub fn multiplier(mut self, multiplier: f64) -> Self {
320 self.chroma_multiplier = multiplier;
321 self
322 }
323
324 pub fn on(mut self, background: DynamicRole, curve: Option<ContrastCurve>) -> Self {
325 self.background = Some(background);
326 self.curve = curve;
327 self
328 }
329
330 pub fn second(mut self, background: DynamicRole) -> Self {
331 self.second_background = Some(background);
332 self
333 }
334
335 pub fn pair(mut self, pair: ToneDeltaPair) -> Self {
336 self.pair = Some(pair);
337 self
338 }
339}
340
341fn def(s: &DynamicScheme, role: DynamicRole) -> Def {
342 match s.spec {
343 SpecVersion::Spec2021 => spec_2021::def(s, role),
344 SpecVersion::Spec2025 => spec_2025::def(s, role),
345 }
346}
347
348fn initial_tone(s: &DynamicScheme, role: DynamicRole) -> f64 {
349 memoized(s, role, true, || match s.spec {
350 SpecVersion::Spec2021 => spec_2021::tone(s, role),
351 SpecVersion::Spec2025 => spec_2025::tone(s, role),
352 })
353}
354
355type MemoKey = (DynamicRole, bool, u64, bool);
358
359thread_local! {
360 static TONE_MEMO: RefCell<Option<HashMap<MemoKey, f64>>> = const { RefCell::new(None) };
365}
366
367pub(super) fn with_tone_memo<R>(f: impl FnOnce() -> R) -> R {
370 struct Clear;
371 impl Drop for Clear {
372 fn drop(&mut self) {
373 TONE_MEMO.with_borrow_mut(|memo| *memo = None);
374 }
375 }
376 TONE_MEMO.with_borrow_mut(|memo| *memo = Some(HashMap::with_capacity(128)));
377 let _clear = Clear;
378 f()
379}
380
381fn memoized(
382 s: &DynamicScheme,
383 role: DynamicRole,
384 initial: bool,
385 compute: impl FnOnce() -> f64,
386) -> f64 {
387 let key = (role, s.is_dark, s.contrast_level.to_bits(), initial);
388 if let Some(tone) =
389 TONE_MEMO.with_borrow(|memo| memo.as_ref().and_then(|memo| memo.get(&key).copied()))
390 {
391 return tone;
392 }
393 let tone = compute();
395 TONE_MEMO.with_borrow_mut(|memo| {
396 if let Some(memo) = memo {
397 memo.insert(key, tone);
398 }
399 });
400 tone
401}
402
403pub(super) fn foreground_tone(bg_tone: f64, ratio: f64) -> f64 {
405 let lighter_tone = contrast::lighter_unsafe(bg_tone, ratio);
406 let darker_tone = contrast::darker_unsafe(bg_tone, ratio);
407 let lighter_ratio = contrast::ratio_of_tones(lighter_tone, bg_tone);
408 let darker_ratio = contrast::ratio_of_tones(darker_tone, bg_tone);
409 if tone_prefers_light_foreground(bg_tone) {
410 let negligible = (lighter_ratio - darker_ratio).abs() < 0.1
413 && lighter_ratio < ratio
414 && darker_ratio < ratio;
415 if lighter_ratio >= ratio || lighter_ratio >= darker_ratio || negligible {
416 lighter_tone
417 } else {
418 darker_tone
419 }
420 } else if darker_ratio >= ratio || darker_ratio >= lighter_ratio {
421 darker_tone
422 } else {
423 lighter_tone
424 }
425}
426
427fn tone_prefers_light_foreground(tone: f64) -> bool {
429 tone.round() < 60.
430}
431
432pub(super) fn hct(s: &DynamicScheme, role: DynamicRole) -> Hct {
433 let d = def(s, role);
434 let tone = tone(s, role);
435 match s.spec {
436 SpecVersion::Spec2021 => d.palette.hct(tone),
437 SpecVersion::Spec2025 => {
438 if d.chroma_multiplier == 1. {
439 return d.palette.hct(tone);
440 }
441 let chroma = d.palette.chroma() * d.chroma_multiplier;
442 if tone == 99. && Hct::is_yellow(d.palette.hue()) {
443 TonalPalette::from_hue_and_chroma(d.palette.hue(), chroma).hct(tone)
444 } else {
445 Hct::from_hct(d.palette.hue(), chroma, tone)
446 }
447 }
448 }
449}
450
451pub(super) fn tone(s: &DynamicScheme, role: DynamicRole) -> f64 {
452 memoized(s, role, false, || match s.spec {
453 SpecVersion::Spec2021 => tone_2021(s, role),
454 SpecVersion::Spec2025 => tone_2025(s, role),
455 })
456}
457
458fn dual_background_tone(
460 s: &DynamicScheme,
461 answer: f64,
462 first: DynamicRole,
463 second: DynamicRole,
464 desired: f64,
465) -> f64 {
466 let (tone_1, tone_2) = (tone(s, first), tone(s, second));
467 let (upper, lower) = (tone_1.max(tone_2), tone_1.min(tone_2));
468 if contrast::ratio_of_tones(upper, answer) >= desired
469 && contrast::ratio_of_tones(lower, answer) >= desired
470 {
471 return answer;
472 }
473 let light = contrast::lighter(upper, desired);
474 let dark = contrast::darker(lower, desired);
475 if tone_prefers_light_foreground(tone_1) || tone_prefers_light_foreground(tone_2) {
476 return light.unwrap_or(100.);
477 }
478 match (light, dark) {
479 (Some(only), None) | (None, Some(only)) => only,
480 (_, dark) => dark.unwrap_or(0.),
481 }
482}
483
484fn tone_2021(s: &DynamicScheme, role: DynamicRole) -> f64 {
485 let d = def(s, role);
486 let decreasing = s.contrast_level < 0.;
487 if let Some(pair) = d.pair {
488 let a_is_nearer = pair.polarity == TonePolarity::Nearer
489 || (pair.polarity == TonePolarity::Lighter && !s.is_dark)
490 || (pair.polarity == TonePolarity::Darker && s.is_dark);
491 let (nearer, farther) = if a_is_nearer {
492 (pair.a, pair.b)
493 } else {
494 (pair.b, pair.a)
495 };
496 let am_nearer = role == nearer;
497 let expansion = if s.is_dark { 1. } else { -1. };
498 let delta = pair.delta;
499 let mut n_tone = initial_tone(s, nearer);
500 let mut f_tone = initial_tone(s, farther);
501 if let (Some(bg), Some(n_curve), Some(f_curve)) =
503 (d.background, def(s, nearer).curve, def(s, farther).curve)
504 {
505 let bg_tone = tone(s, bg);
506 let n_contrast = n_curve.get(s.contrast_level);
507 let f_contrast = f_curve.get(s.contrast_level);
508 if contrast::ratio_of_tones(bg_tone, n_tone) < n_contrast {
509 n_tone = foreground_tone(bg_tone, n_contrast);
510 }
511 if contrast::ratio_of_tones(bg_tone, f_tone) < f_contrast {
512 f_tone = foreground_tone(bg_tone, f_contrast);
513 }
514 if decreasing {
515 n_tone = foreground_tone(bg_tone, n_contrast);
516 f_tone = foreground_tone(bg_tone, f_contrast);
517 }
518 }
519 if (f_tone - n_tone) * expansion < delta {
520 f_tone = clamp_double(0., 100., n_tone + delta * expansion);
522 if (f_tone - n_tone) * expansion < delta {
523 n_tone = clamp_double(0., 100., f_tone - delta * expansion);
525 }
526 }
527 let move_both = |n: &mut f64, f: &mut f64| {
529 if expansion > 0. {
530 *n = 60.;
531 *f = f.max(*n + delta * expansion);
532 } else {
533 *n = 49.;
534 *f = f.min(*n + delta * expansion);
535 }
536 };
537 if (50. ..60.).contains(&n_tone) {
538 move_both(&mut n_tone, &mut f_tone);
539 } else if (50. ..60.).contains(&f_tone) {
540 if pair.stay_together {
541 move_both(&mut n_tone, &mut f_tone);
542 } else {
543 f_tone = if expansion > 0. { 60. } else { 49. };
544 }
545 }
546 return if am_nearer { n_tone } else { f_tone };
547 }
548
549 let mut answer = initial_tone(s, role);
550 let (Some(bg), Some(curve)) = (d.background, d.curve) else {
551 return answer;
552 };
553 let bg_tone = tone(s, bg);
554 let desired = curve.get(s.contrast_level);
555 if contrast::ratio_of_tones(bg_tone, answer) < desired || decreasing {
556 answer = foreground_tone(bg_tone, desired);
557 }
558 if d.is_background && (50. ..60.).contains(&answer) {
559 answer = if contrast::ratio_of_tones(49., bg_tone) >= desired {
560 49.
561 } else {
562 60.
563 };
564 }
565 match d.second_background {
566 Some(second) => dual_background_tone(s, answer, bg, second, desired),
567 None => answer,
568 }
569}
570
571fn is_fixed_dim(role: DynamicRole) -> bool {
573 matches!(
574 role,
575 DynamicRole::PrimaryFixedDim
576 | DynamicRole::SecondaryFixedDim
577 | DynamicRole::TertiaryFixedDim
578 )
579}
580
581fn avoid_awkward_tones(tone: f64) -> f64 {
582 if tone >= 57. {
583 clamp_double(65., 100., tone)
584 } else {
585 clamp_double(0., 49., tone)
586 }
587}
588
589fn tone_2025(s: &DynamicScheme, role: DynamicRole) -> f64 {
590 let d = def(s, role);
591 if let Some(pair) = d.pair {
592 let absolute = match pair.polarity {
593 TonePolarity::Darker => -pair.delta,
594 TonePolarity::RelativeLighter if s.is_dark => -pair.delta,
595 _ => pair.delta,
596 };
597 let am_a = role == pair.a;
598 let (self_role, ref_role) = if am_a {
599 (pair.a, pair.b)
600 } else {
601 (pair.b, pair.a)
602 };
603 let mut self_tone = initial_tone(s, self_role);
604 let ref_tone = tone(s, ref_role);
605 let relative = absolute * if am_a { 1. } else { -1. };
606 match pair.constraint {
607 DeltaConstraint::Exact => {
608 self_tone = clamp_double(0., 100., ref_tone + relative);
609 }
610 DeltaConstraint::Farther => {
611 self_tone = if relative > 0. {
612 clamp_double(ref_tone + relative, 100., self_tone)
613 } else {
614 clamp_double(0., ref_tone + relative, self_tone)
615 };
616 }
617 }
618 if let (Some(bg), Some(curve)) = (d.background, d.curve) {
619 let bg_tone = tone(s, bg);
620 let wanted = curve.get(s.contrast_level);
621 if contrast::ratio_of_tones(bg_tone, self_tone) < wanted || s.contrast_level < 0. {
622 self_tone = foreground_tone(bg_tone, wanted);
623 }
624 }
625 if d.is_background && !is_fixed_dim(role) {
626 self_tone = avoid_awkward_tones(self_tone);
627 }
628 return self_tone;
629 }
630
631 let mut answer = initial_tone(s, role);
632 let (Some(bg), Some(curve)) = (d.background, d.curve) else {
633 return answer;
634 };
635 let bg_tone = tone(s, bg);
636 let desired = curve.get(s.contrast_level);
637 if contrast::ratio_of_tones(bg_tone, answer) < desired || s.contrast_level < 0. {
638 answer = foreground_tone(bg_tone, desired);
639 }
640 if d.is_background && !is_fixed_dim(role) {
641 answer = avoid_awkward_tones(answer);
642 }
643 match d.second_background {
644 Some(second) => dual_background_tone(s, answer, bg, second, desired),
645 None => answer,
646 }
647}