Skip to main content

gpui_kit_material_color/dynamic/
scheme.rs

1// Copyright 2022 Google LLC
2// SPDX-License-Identifier: Apache-2.0
3// Ported to Rust from material-color-utilities
4// (typescript/dynamiccolor/dynamic_scheme.ts and variant.ts), modified for this
5// crate: only the phone platform, which is what desktop and web use; no CMF
6// variant; a single source color. See licenses/material-color-utilities.txt.
7
8//! A seed, a style, a mode and a contrast level, resolved to tonal palettes.
9use super::color::{self, DynamicRole};
10use super::hct::Hct;
11use super::palette::TonalPalette;
12use super::temperature::TemperatureCache;
13use super::utils::sanitize_degrees_double;
14use super::{dislike, utils::clamp_double};
15
16/// The style of a generated scheme.
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
19#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
20pub enum Variant {
21    /// Grayscale; the seed only gives a trace of hue to nothing.
22    Monochrome,
23    /// Nearly grayscale, with a hint of the seed.
24    Neutral,
25    /// Pastel tokens at low chroma: Material's default.
26    #[default]
27    TonalSpot,
28    /// Maximum chroma of the seed's hue.
29    Vibrant,
30    /// Hues rotated away from the seed for a playful result.
31    Expressive,
32    /// Places the seed in the primary container, tertiary its complement.
33    Fidelity,
34    /// Places the seed in the primary container, tertiary an analogue.
35    Content,
36    /// A playful theme in which the seed's hue does not appear in surfaces.
37    Rainbow,
38    /// A playful theme in which the seed's hue does not appear in accents.
39    FruitSalad,
40}
41
42impl Variant {
43    /// Every variant, in upstream's order.
44    pub const ALL: [Self; 9] = [
45        Self::Monochrome,
46        Self::Neutral,
47        Self::TonalSpot,
48        Self::Vibrant,
49        Self::Expressive,
50        Self::Fidelity,
51        Self::Content,
52        Self::Rainbow,
53        Self::FruitSalad,
54    ];
55
56    /// Whether the 2025 color spec defines this variant. Upstream resolves
57    /// the others with the 2021 spec under any requested version.
58    pub const fn has_2025_spec(self) -> bool {
59        matches!(
60            self,
61            Self::Expressive | Self::Vibrant | Self::TonalSpot | Self::Neutral
62        )
63    }
64}
65
66/// The version of Material's color spec.
67///
68/// Upstream's 2026 spec only defines its CMF variant and resolves every variant
69/// here as 2025, so it is not a separate version in this port.
70#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
71#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
72pub enum SpecVersion {
73    /// The original Material You spec: upstream's default.
74    #[default]
75    #[cfg_attr(feature = "serde", serde(rename = "2021"))]
76    Spec2021,
77    /// The Material 3 Expressive spec.
78    #[cfg_attr(feature = "serde", serde(rename = "2025"))]
79    Spec2025,
80}
81
82/// The state a [`DynamicRole`] resolves against.
83///
84/// ```
85/// use gpui_kit_material_color::dynamic::{
86///     DynamicRole, DynamicScheme, Hct, SpecVersion, Variant,
87/// };
88///
89/// let scheme = DynamicScheme::new(
90///     Hct::from_argb(0xff6750a4),
91///     Variant::TonalSpot,
92///     false,
93///     0.0,
94///     SpecVersion::Spec2021,
95/// );
96/// assert_eq!(scheme.argb(DynamicRole::Primary), 0xff65558f);
97/// ```
98#[derive(Clone, Copy, Debug, PartialEq)]
99pub struct DynamicScheme {
100    /// The seed.
101    pub source: Hct,
102    /// The style.
103    pub variant: Variant,
104    /// -1 is minimum contrast, 0 the design as specified, 0.5 medium, 1 maximum.
105    pub contrast_level: f64,
106    /// Dark mode.
107    pub is_dark: bool,
108    /// The spec actually in force, after [`Variant::has_2025_spec`].
109    pub spec: SpecVersion,
110    /// Usually colorful.
111    pub primary_palette: TonalPalette,
112    /// Usually less colorful.
113    pub secondary_palette: TonalPalette,
114    /// Usually a different hue from primary.
115    pub tertiary_palette: TonalPalette,
116    /// Usually not colorful: surfaces.
117    pub neutral_palette: TonalPalette,
118    /// Slightly more colorful than neutral: outlines and variants.
119    pub neutral_variant_palette: TonalPalette,
120    /// Reddish.
121    pub error_palette: TonalPalette,
122}
123
124impl DynamicScheme {
125    /// Resolve the palettes of a seed. A non-finite contrast level counts as 0;
126    /// others are limited to -1..=1.
127    pub fn new(
128        source: Hct,
129        variant: Variant,
130        is_dark: bool,
131        contrast_level: f64,
132        spec: SpecVersion,
133    ) -> Self {
134        let spec = if variant.has_2025_spec() {
135            spec
136        } else {
137            SpecVersion::Spec2021
138        };
139        let contrast_level = if contrast_level.is_finite() {
140            clamp_double(-1., 1., contrast_level)
141        } else {
142            0.
143        };
144        let palettes = match spec {
145            SpecVersion::Spec2021 => palettes_2021(variant, source),
146            SpecVersion::Spec2025 => palettes_2025(variant, source, is_dark),
147        };
148        Self {
149            source,
150            variant,
151            contrast_level,
152            is_dark,
153            spec,
154            primary_palette: palettes[0],
155            secondary_palette: palettes[1],
156            tertiary_palette: palettes[2],
157            neutral_palette: palettes[3],
158            neutral_variant_palette: palettes[4],
159            error_palette: palettes[5],
160        }
161    }
162
163    /// The hue in `hues` at the range of `breakpoints` holding the source's
164    /// hue, or the source's hue when no range does.
165    pub fn piecewise_hue(source: Hct, breakpoints: &[f64], hues: &[f64]) -> f64 {
166        let size = breakpoints.len().saturating_sub(1).min(hues.len());
167        for i in 0..size {
168            if source.hue() >= breakpoints[i] && source.hue() < breakpoints[i + 1] {
169                return sanitize_degrees_double(hues[i]);
170            }
171        }
172        source.hue()
173    }
174
175    /// The source's hue rotated by the entry of `rotations` at the range of
176    /// `breakpoints` holding it.
177    pub fn rotated_hue(source: Hct, breakpoints: &[f64], rotations: &[f64]) -> f64 {
178        let rotation = if breakpoints.len().saturating_sub(1).min(rotations.len()) == 0 {
179            0.
180        } else {
181            Self::piecewise_hue(source, breakpoints, rotations)
182        };
183        sanitize_degrees_double(source.hue() + rotation)
184    }
185
186    /// The ARGB color of a role.
187    pub fn argb(&self, role: DynamicRole) -> u32 {
188        self.hct(role).argb()
189    }
190
191    /// The HCT color of a role.
192    pub fn hct(&self, role: DynamicRole) -> Hct {
193        color::hct(self, role)
194    }
195
196    /// The tone of a role. This resolves contrast constraints; for the color
197    /// use [`Self::hct`].
198    pub fn tone(&self, role: DynamicRole) -> f64 {
199        color::tone(self, role)
200    }
201}
202
203fn hue_chroma(hue: f64, chroma: f64) -> TonalPalette {
204    TonalPalette::from_hue_and_chroma(hue, chroma)
205}
206
207const DEFAULT_ERROR: (f64, f64) = (25., 84.);
208
209fn palettes_2021(variant: Variant, source: Hct) -> [TonalPalette; 6] {
210    [
211        primary_2021(variant, source),
212        secondary_2021(variant, source),
213        tertiary_2021(variant, source),
214        neutral_2021(variant, source),
215        neutral_variant_2021(variant, source),
216        hue_chroma(DEFAULT_ERROR.0, DEFAULT_ERROR.1),
217    ]
218}
219
220fn primary_2021(variant: Variant, source: Hct) -> TonalPalette {
221    let hue = source.hue();
222    match variant {
223        Variant::Content | Variant::Fidelity => hue_chroma(hue, source.chroma()),
224        Variant::FruitSalad => hue_chroma(sanitize_degrees_double(hue - 50.), 48.),
225        Variant::Monochrome => hue_chroma(hue, 0.),
226        Variant::Neutral => hue_chroma(hue, 12.),
227        Variant::Rainbow => hue_chroma(hue, 48.),
228        Variant::TonalSpot => hue_chroma(hue, 36.),
229        Variant::Expressive => hue_chroma(sanitize_degrees_double(hue + 240.), 40.),
230        Variant::Vibrant => hue_chroma(hue, 200.),
231    }
232}
233
234fn secondary_2021(variant: Variant, source: Hct) -> TonalPalette {
235    let hue = source.hue();
236    match variant {
237        Variant::Content | Variant::Fidelity => {
238            hue_chroma(hue, (source.chroma() - 32.).max(source.chroma() * 0.5))
239        }
240        Variant::FruitSalad => hue_chroma(sanitize_degrees_double(hue - 50.), 36.),
241        Variant::Monochrome => hue_chroma(hue, 0.),
242        Variant::Neutral => hue_chroma(hue, 8.),
243        Variant::Rainbow | Variant::TonalSpot => hue_chroma(hue, 16.),
244        Variant::Expressive => hue_chroma(
245            DynamicScheme::rotated_hue(
246                source,
247                &[0., 21., 51., 121., 151., 191., 271., 321., 360.],
248                &[45., 95., 45., 20., 45., 90., 45., 45., 45.],
249            ),
250            24.,
251        ),
252        Variant::Vibrant => hue_chroma(
253            DynamicScheme::rotated_hue(
254                source,
255                &[0., 41., 61., 101., 131., 181., 251., 301., 360.],
256                &[18., 15., 10., 12., 15., 18., 15., 12., 12.],
257            ),
258            24.,
259        ),
260    }
261}
262
263fn tertiary_2021(variant: Variant, source: Hct) -> TonalPalette {
264    let hue = source.hue();
265    match variant {
266        Variant::Content => TonalPalette::from_hct(dislike::fix_if_disliked(
267            TemperatureCache::new(source).analogous(3, 6)[2],
268        )),
269        Variant::Fidelity => TonalPalette::from_hct(dislike::fix_if_disliked(
270            TemperatureCache::new(source).complement(),
271        )),
272        Variant::FruitSalad => hue_chroma(hue, 36.),
273        Variant::Monochrome => hue_chroma(hue, 0.),
274        Variant::Neutral => hue_chroma(hue, 16.),
275        Variant::Rainbow | Variant::TonalSpot => {
276            hue_chroma(sanitize_degrees_double(hue + 60.), 24.)
277        }
278        Variant::Expressive => hue_chroma(
279            DynamicScheme::rotated_hue(
280                source,
281                &[0., 21., 51., 121., 151., 191., 271., 321., 360.],
282                &[120., 120., 20., 45., 20., 15., 20., 120., 120.],
283            ),
284            32.,
285        ),
286        Variant::Vibrant => hue_chroma(
287            DynamicScheme::rotated_hue(
288                source,
289                &[0., 41., 61., 101., 131., 181., 251., 301., 360.],
290                &[35., 30., 20., 25., 30., 35., 30., 25., 25.],
291            ),
292            32.,
293        ),
294    }
295}
296
297fn neutral_2021(variant: Variant, source: Hct) -> TonalPalette {
298    let hue = source.hue();
299    match variant {
300        Variant::Content | Variant::Fidelity => hue_chroma(hue, source.chroma() / 8.),
301        Variant::FruitSalad => hue_chroma(hue, 10.),
302        Variant::Monochrome | Variant::Rainbow => hue_chroma(hue, 0.),
303        Variant::Neutral => hue_chroma(hue, 2.),
304        Variant::TonalSpot => hue_chroma(hue, 6.),
305        Variant::Expressive => hue_chroma(sanitize_degrees_double(hue + 15.), 8.),
306        Variant::Vibrant => hue_chroma(hue, 10.),
307    }
308}
309
310fn neutral_variant_2021(variant: Variant, source: Hct) -> TonalPalette {
311    let hue = source.hue();
312    match variant {
313        Variant::Content | Variant::Fidelity => hue_chroma(hue, source.chroma() / 8. + 4.),
314        Variant::FruitSalad => hue_chroma(hue, 16.),
315        Variant::Monochrome | Variant::Rainbow => hue_chroma(hue, 0.),
316        Variant::Neutral => hue_chroma(hue, 2.),
317        Variant::TonalSpot => hue_chroma(hue, 8.),
318        Variant::Expressive => hue_chroma(sanitize_degrees_double(hue + 15.), 12.),
319        Variant::Vibrant => hue_chroma(hue, 12.),
320    }
321}
322
323fn expressive_neutral_hue(source: Hct) -> f64 {
324    DynamicScheme::rotated_hue(
325        source,
326        &[0., 71., 124., 253., 278., 300., 360.],
327        &[10., 0., 10., 0., 10., 0.],
328    )
329}
330
331fn expressive_neutral_chroma(source: Hct, is_dark: bool) -> f64 {
332    if is_dark {
333        if Hct::is_yellow(expressive_neutral_hue(source)) {
334            6.
335        } else {
336            14.
337        }
338    } else {
339        18.
340    }
341}
342
343fn vibrant_neutral_hue(source: Hct) -> f64 {
344    DynamicScheme::rotated_hue(
345        source,
346        &[0., 38., 105., 140., 333., 360.],
347        &[-14., 10., -14., 10., -14.],
348    )
349}
350
351const VIBRANT_NEUTRAL_CHROMA: f64 = 28.;
352
353// Only called for the four variants of `Variant::has_2025_spec`; the others
354// resolve with `palettes_2021`.
355fn palettes_2025(variant: Variant, source: Hct, is_dark: bool) -> [TonalPalette; 6] {
356    let hue = source.hue();
357    let error_hue = DynamicScheme::piecewise_hue(
358        source,
359        &[0., 3., 13., 23., 33., 43., 153., 273., 360.],
360        &[12., 22., 32., 12., 22., 32., 22., 12.],
361    );
362    match variant {
363        Variant::Neutral => {
364            let blue = Hct::is_blue(hue);
365            [
366                hue_chroma(hue, if blue { 12. } else { 8. }),
367                hue_chroma(hue, if blue { 6. } else { 4. }),
368                hue_chroma(
369                    DynamicScheme::rotated_hue(
370                        source,
371                        &[0., 38., 105., 161., 204., 278., 333., 360.],
372                        &[-32., 26., 10., -39., 24., -15., -32.],
373                    ),
374                    20.,
375                ),
376                hue_chroma(hue, 1.4),
377                hue_chroma(hue, 1.4 * 2.2),
378                hue_chroma(error_hue, 50.),
379            ]
380        }
381        Variant::TonalSpot => [
382            hue_chroma(hue, if is_dark { 26. } else { 32. }),
383            hue_chroma(hue, 16.),
384            hue_chroma(
385                DynamicScheme::rotated_hue(
386                    source,
387                    &[0., 20., 71., 161., 333., 360.],
388                    &[-40., 48., -32., 40., -32.],
389                ),
390                28.,
391            ),
392            hue_chroma(hue, 5.),
393            hue_chroma(hue, 5. * 1.7),
394            hue_chroma(error_hue, 60.),
395        ],
396        Variant::Expressive => {
397            let neutral_hue = expressive_neutral_hue(source);
398            let neutral_chroma = expressive_neutral_chroma(source, is_dark);
399            [
400                hue_chroma(hue, if is_dark { 36. } else { 48. }),
401                hue_chroma(
402                    DynamicScheme::rotated_hue(
403                        source,
404                        &[0., 105., 140., 204., 253., 278., 300., 333., 360.],
405                        &[-160., 155., -100., 96., -96., -156., -165., -160.],
406                    ),
407                    if is_dark { 16. } else { 24. },
408                ),
409                hue_chroma(
410                    DynamicScheme::rotated_hue(
411                        source,
412                        &[0., 105., 140., 204., 253., 278., 300., 333., 360.],
413                        &[-165., 160., -105., 101., -101., -160., -170., -165.],
414                    ),
415                    48.,
416                ),
417                hue_chroma(neutral_hue, neutral_chroma),
418                hue_chroma(
419                    neutral_hue,
420                    neutral_chroma
421                        * if (105. ..125.).contains(&neutral_hue) {
422                            1.6
423                        } else {
424                            2.3
425                        },
426                ),
427                hue_chroma(error_hue, 64.),
428            ]
429        }
430        Variant::Vibrant => {
431            let neutral_hue = vibrant_neutral_hue(source);
432            [
433                hue_chroma(hue, 74.),
434                hue_chroma(neutral_hue, 56.),
435                hue_chroma(
436                    DynamicScheme::rotated_hue(
437                        source,
438                        &[0., 38., 71., 105., 140., 161., 253., 333., 360.],
439                        &[-72., 35., 24., -24., 62., 50., 62., -72.],
440                    ),
441                    56.,
442                ),
443                hue_chroma(neutral_hue, VIBRANT_NEUTRAL_CHROMA),
444                hue_chroma(neutral_hue, VIBRANT_NEUTRAL_CHROMA * 1.29),
445                hue_chroma(error_hue, 80.),
446            ]
447        }
448        _ => palettes_2021(variant, source),
449    }
450}