Skip to main content

gpui_kit_material_color/dynamic/
color.rs

1// Copyright 2022 Google LLC
2// SPDX-License-Identifier: Apache-2.0
3// Ported to Rust from material-color-utilities (typescript/dynamiccolor:
4// dynamic_color.ts, contrast_curve.ts, tone_delta_pair.ts), modified for this
5// crate: colors are an enum resolved through match tables instead of objects of
6// closures, and the tone delegates cover the phone platform only.
7// See licenses/material-color-utilities.txt.
8
9//! Dynamic colors: roles whose tone follows mode, contrast and background.
10use 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/// A role of a [`DynamicScheme`].
21#[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    /// Every role.
87    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    /// Upstream's snake_case name of the role.
153    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/// A value that changes with the contrast level, usually the contrast a color
220/// needs against its background.
221#[derive(Clone, Copy, Debug, PartialEq)]
222pub struct ContrastCurve {
223    /// The value at contrast level -1.
224    pub low: f64,
225    /// The value at contrast level 0.
226    pub normal: f64,
227    /// The value at contrast level 0.5.
228    pub medium: f64,
229    /// The value at contrast level 1.
230    pub high: f64,
231}
232
233impl ContrastCurve {
234    /// A curve from its values at contrast levels -1, 0, 0.5 and 1.
235    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    /// The value at a contrast level, interpolating between the stops.
245    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/// How the tones of a pair relate; `a` is described compared with `b`.
261#[derive(Clone, Copy, Debug, PartialEq, Eq)]
262pub(super) enum TonePolarity {
263    Darker,
264    Lighter,
265    Nearer,
266    RelativeLighter,
267}
268
269/// How to fulfill a tone delta constraint.
270#[derive(Clone, Copy, Debug, PartialEq, Eq)]
271pub(super) enum DeltaConstraint {
272    Exact,
273    Farther,
274}
275
276/// A required tone distance between two roles.
277#[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/// A role's constraints under one scheme. Its initial tone is separate
288/// (`spec_*::tone`) because computing it can be expensive and can read other
289/// roles.
290#[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
355/// A tone: its role, the scheme's mode and contrast (fixed roles resolve under
356/// a light, standard copy of the scheme), and whether it is the initial tone.
357type MemoKey = (DynamicRole, bool, u64, bool);
358
359thread_local! {
360    // Resolving roles one at a time re-solves shared tones many times over:
361    // measured at a 162 ms median for a 2025 vibrant scheme on an Apple M2.
362    // The owner is `with_tone_memo`: the memo lives for one call over one
363    // scheme, so it never has anything to invalidate.
364    static TONE_MEMO: RefCell<Option<HashMap<MemoKey, f64>>> = const { RefCell::new(None) };
365}
366
367/// Run `f`, sharing resolved tones between the roles it reads. Every role read
368/// inside must belong to one scheme.
369pub(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    // Not borrowed while computing: tones read other tones.
394    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
403/// The foreground tone nearest `ratio` against `bg_tone`.
404pub(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        // Where the initial ratio is high and neither direction reaches it,
411        // staying light avoids a momentary black at some contrast levels.
412        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
427/// White reads best on tones below about 60.
428fn 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
458/// A tone satisfying `desired` against two backgrounds.
459fn 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        // 1st round: solve to the minimum of each.
502        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            // 2nd round: expand farther to match delta.
521            f_tone = clamp_double(0., 100., n_tone + delta * expansion);
522            if (f_tone - n_tone) * expansion < delta {
523                // 3rd round: contract nearer to match delta.
524                n_tone = clamp_double(0., 100., f_tone - delta * expansion);
525            }
526        }
527        // Avoid the 50-59 awkward zone.
528        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
571/// Fixed-dim accents keep the tones the awkward-zone rule would move.
572fn 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}