Skip to main content

compose_rust/
palette.rs

1//! An application's own colours.
2//!
3//! A theme picks a design system and a scheme, and until this existed that was the whole of
4//! what an application could say about colour. The only way to paint a brand colour was a
5//! literal on every node, and a literal is a colour the design system never sees: the
6//! pressed layer, the disabled fade, the tint of glass and the switch to dark all carried on
7//! without it.
8//!
9//! A [`Palette`] replaces the value of colour roles, one role and one scheme at a time, and
10//! nothing else. The shape, type, spacing, elevation, motion and component rules stay the
11//! design system's, so the same brand orange is a large rounded ripple under Material 3 and
12//! a glass capsule under Liquid Glass. What crosses the boundary is a short list of role
13//! values, never a token table.
14//!
15//! Both schemes travel at once. When the platform turns dark the Renderer chooses from what
16//! it already holds, and the Host hears nothing.
17
18use crate::contrast::{DIFF_ROLES, MIN_SEPARATION, SYNTAX_ROLES, apart, contrast};
19use crate::schema::{COLOR_ROLE_COUNT, Color, ColorRole, ColorScheme, DesignSystem};
20use crate::tokens::table;
21
22/// Bytes of one palette entry on the wire: the role, the scheme, and the colour.
23pub const PALETTE_ENTRY_LEN: usize = 8;
24
25/// The colours one application gives some of the roles, per scheme.
26///
27/// A role the application did not give keeps the design system's value, separately in each
28/// scheme: giving `Primary` for light only leaves dark `Primary` to the system.
29///
30/// Built in a `const`, so a theme that carries it stays `Copy` and can be a `const` too:
31///
32/// ```
33/// use compose_rust::{Color, ColorRole, DesignSystem, Palette, Theme};
34///
35/// const EMBER: Palette = Palette::new()
36///     .with(ColorRole::Primary, Color::rgb(0xE8590C), Color::rgb(0xFF8A4C))
37///     .with(ColorRole::OnPrimary, Color::rgb(0xFFFFFF), Color::rgb(0x2B0E00))
38///     .with(ColorRole::Background, Color::rgb(0xFFFBF8), Color::rgb(0x141110));
39///
40/// const THEME: Theme = Theme::adaptive(DesignSystem::Material3).with_palette(&EMBER);
41/// ```
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
43pub struct Palette {
44    light: [Option<Color>; COLOR_ROLE_COUNT],
45    dark: [Option<Color>; COLOR_ROLE_COUNT],
46}
47
48impl Default for Palette {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54/// One role's colour in one scheme, as it travels.
55#[derive(Clone, Copy, Debug, Eq, PartialEq)]
56pub struct PaletteEntry {
57    pub role: ColorRole,
58    /// `Light` or `Dark`. Never `FollowSystem`, which is a way of choosing, not a scheme.
59    pub scheme: ColorScheme,
60    pub color: Color,
61}
62
63impl Palette {
64    /// A palette that changes nothing.
65    pub const fn new() -> Self {
66        Self {
67            light: [None; COLOR_ROLE_COUNT],
68            dark: [None; COLOR_ROLE_COUNT],
69        }
70    }
71
72    /// Gives a role its colour in both schemes.
73    ///
74    /// Two values rather than one, because one colour used for both is almost always wrong
75    /// in one of them: a deep orange chosen for a light page sinks into a dark one.
76    pub const fn with(mut self, role: ColorRole, light: Color, dark: Color) -> Self {
77        self.light[role as usize - 1] = Some(light);
78        self.dark[role as usize - 1] = Some(dark);
79        self
80    }
81
82    /// Gives a role its colour in the light scheme only. Dark keeps the system's.
83    pub const fn with_light(mut self, role: ColorRole, light: Color) -> Self {
84        self.light[role as usize - 1] = Some(light);
85        self
86    }
87
88    /// Gives a role its colour in the dark scheme only. Light keeps the system's.
89    pub const fn with_dark(mut self, role: ColorRole, dark: Color) -> Self {
90        self.dark[role as usize - 1] = Some(dark);
91        self
92    }
93
94    /// The colour this palette gives a role in a scheme, or None where it leaves the
95    /// system's. `FollowSystem` is not a scheme and has no answer.
96    pub const fn get(&self, role: ColorRole, scheme: ColorScheme) -> Option<Color> {
97        match scheme {
98            ColorScheme::Light => self.light[role as usize - 1],
99            ColorScheme::Dark => self.dark[role as usize - 1],
100            ColorScheme::FollowSystem => None,
101        }
102    }
103
104    /// Every value this palette gives, in role order with light before dark.
105    pub fn entries(&self) -> impl Iterator<Item = PaletteEntry> + '_ {
106        crate::schema::COLOR_ROLE_SCHEMA
107            .iter()
108            .flat_map(move |variant| {
109                let role = ColorRole::try_from(variant.tag).expect("a role from the role schema");
110                [ColorScheme::Light, ColorScheme::Dark]
111                    .into_iter()
112                    .filter_map(move |scheme| {
113                        self.get(role, scheme).map(|color| PaletteEntry {
114                            role,
115                            scheme,
116                            color,
117                        })
118                    })
119            })
120    }
121
122    /// How many values this palette gives.
123    pub fn len(&self) -> usize {
124        self.light.iter().chain(self.dark.iter()).flatten().count()
125    }
126
127    pub fn is_empty(&self) -> bool {
128        self.len() == 0
129    }
130
131    /// Writes the entries as the wire carries them, eight bytes each.
132    pub fn write_into(&self, out: &mut Vec<u8>) {
133        for entry in self.entries() {
134            out.extend_from_slice(&(entry.role as u16).to_le_bytes());
135            out.extend_from_slice(&(entry.scheme as u16).to_le_bytes());
136            out.extend_from_slice(&entry.color.to_argb().to_le_bytes());
137        }
138    }
139
140    /// Every role's colour in one scheme of one design system, with this palette on top.
141    ///
142    /// This is the Renderer's resolution, written out where a test can reach it. A role
143    /// the palette gives is that value. A role it does not give is the system's, except
144    /// for one case: a fill the palette gives whose ink it does not. The system's ink is
145    /// kept if it still holds the contrast that pair is held to on the new fill, and
146    /// otherwise the ink is white or black, whichever holds more. Orange takes the system's
147    /// white where white reads on it, and a yellow brand takes black.
148    ///
149    /// Only that direction. An ink given without its fill was meant, and if it does not
150    /// read the thing to correct is the palette.
151    pub fn resolve(&self, system: DesignSystem, scheme: ColorScheme) -> [Color; COLOR_ROLE_COUNT] {
152        let base = table(system);
153        let mut out = [Color::default(); COLOR_ROLE_COUNT];
154        for (index, slot) in out.iter_mut().enumerate() {
155            let role = base.colors[index].role;
156            *slot = self
157                .get(role, scheme)
158                .unwrap_or_else(|| base.color(role, scheme));
159        }
160        for (fill, ink, required) in INK_PAIRS {
161            if self.get(fill, scheme).is_none() || self.get(ink, scheme).is_some() {
162                continue;
163            }
164            out[ink as usize - 1] =
165                ink_for(out[fill as usize - 1], out[ink as usize - 1], required);
166        }
167        out
168    }
169
170    /// The pairs this palette, laid over a design system's table, leaves short of the
171    /// contrast or the separation they are held to. Empty where nothing is.
172    ///
173    /// The bounds are the ones the system tables themselves are tested against: accent
174    /// inks 3:1 on their fills, reading inks and container inks 4.5:1, every syntax ink
175    /// 4.5:1 on the code panel, each diff ink 4.5:1 on its own line background and the body
176    /// ink 4.5:1 on both, and a container, a line background or a word background that has
177    /// to be told apart from what is behind it actually is. An application calls this from
178    /// its own tests, once for each of the seven systems.
179    pub fn check(&self, system: DesignSystem) -> Vec<PaletteViolation> {
180        let mut found = Vec::new();
181        for scheme in [ColorScheme::Light, ColorScheme::Dark] {
182            let colors = self.resolve(system, scheme);
183            let color = |role: ColorRole| colors[role as usize - 1];
184            let mut contrast_pair = |ink: ColorRole, fill: ColorRole, required: f64| {
185                let ratio = contrast(color(ink), color(fill));
186                if ratio < required {
187                    found.push(PaletteViolation::Contrast {
188                        scheme,
189                        ink,
190                        fill,
191                        ratio,
192                        required,
193                    });
194                }
195            };
196            for (fill, ink, required) in INK_PAIRS {
197                contrast_pair(ink, fill, required);
198            }
199            contrast_pair(ColorRole::OnSurface, ColorRole::SurfaceContainer, 4.5);
200            for ink in SYNTAX_ROLES {
201                contrast_pair(ink, ColorRole::SurfaceContainer, 4.5);
202            }
203            for (ink, line, _) in DIFF_ROLES {
204                contrast_pair(ink, line, 4.5);
205                contrast_pair(ColorRole::OnSurface, line, 4.5);
206            }
207            let mut apart_pair = |first: ColorRole, second: ColorRole| {
208                let distance = apart(color(first), color(second));
209                if distance < MIN_SEPARATION {
210                    found.push(PaletteViolation::Indistinct {
211                        scheme,
212                        first,
213                        second,
214                        distance,
215                    });
216                }
217            };
218            for container in [
219                ColorRole::SurfaceContainer,
220                ColorRole::PrimaryContainer,
221                ColorRole::SecondaryContainer,
222                ColorRole::TertiaryContainer,
223            ] {
224                apart_pair(container, ColorRole::Background);
225            }
226            for (_, line, word) in DIFF_ROLES {
227                apart_pair(word, line);
228            }
229        }
230        found
231    }
232}
233
234/// A pair a palette leaves short, as [`Palette::check`] reports it.
235#[derive(Clone, Copy, Debug, PartialEq)]
236pub enum PaletteViolation {
237    /// `ink` on `fill` measures `ratio`, under the `required` bound.
238    Contrast {
239        scheme: ColorScheme,
240        ink: ColorRole,
241        fill: ColorRole,
242        ratio: f64,
243        required: f64,
244    },
245    /// Two colours that have to be told apart are `distance` apart, summed over the three
246    /// eight bit channels.
247    Indistinct {
248        scheme: ColorScheme,
249        first: ColorRole,
250        second: ColorRole,
251        distance: u32,
252    },
253}
254
255impl std::fmt::Display for PaletteViolation {
256    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257        match self {
258            Self::Contrast {
259                scheme,
260                ink,
261                fill,
262                ratio,
263                required,
264            } => write!(
265                formatter,
266                "{scheme:?}: {ink:?} on {fill:?} is {ratio:.2}:1, under the {required}:1 it needs to be read"
267            ),
268            Self::Indistinct {
269                scheme,
270                first,
271                second,
272                distance,
273            } => write!(
274                formatter,
275                "{scheme:?}: {first:?} and {second:?} are {distance} apart, so one cannot be seen on the other"
276            ),
277        }
278    }
279}
280
281/// Every fill whose ink the Renderer may choose, with the contrast that pair is held to.
282///
283/// Accents hold 3:1, the bound a label on a control is held to. Containers and the reading
284/// surfaces hold 4.5:1, because a paragraph lands on them.
285pub const INK_PAIRS: [(ColorRole, ColorRole, f64); 10] = [
286    (ColorRole::Primary, ColorRole::OnPrimary, 3.0),
287    (ColorRole::Secondary, ColorRole::OnSecondary, 3.0),
288    (ColorRole::Tertiary, ColorRole::OnTertiary, 3.0),
289    (ColorRole::Error, ColorRole::OnError, 3.0),
290    (
291        ColorRole::PrimaryContainer,
292        ColorRole::OnPrimaryContainer,
293        4.5,
294    ),
295    (
296        ColorRole::SecondaryContainer,
297        ColorRole::OnSecondaryContainer,
298        4.5,
299    ),
300    (
301        ColorRole::TertiaryContainer,
302        ColorRole::OnTertiaryContainer,
303        4.5,
304    ),
305    (ColorRole::Surface, ColorRole::OnSurface, 4.5),
306    (ColorRole::SurfaceVariant, ColorRole::OnSurfaceVariant, 4.5),
307    (ColorRole::Background, ColorRole::OnBackground, 4.5),
308];
309
310/// The ink for a fill the application changed and whose ink it left alone.
311pub fn ink_for(fill: Color, system_ink: Color, required: f64) -> Color {
312    if contrast(system_ink, fill) >= required {
313        return system_ink;
314    }
315    let white = Color::rgb(0xffffff);
316    let black = Color::rgb(0x000000);
317    if contrast(white, fill) >= contrast(black, fill) {
318        white
319    } else {
320        black
321    }
322}
323
324/// What is wrong with one entry of a palette that arrived over the wire.
325///
326/// Each is reported, and the entry is left out. The rest of the palette still applies:
327/// throwing the whole theme away for one bad entry would open the application with no
328/// colours at all.
329#[derive(Clone, Copy, Debug, Eq, PartialEq)]
330pub enum PaletteProblem {
331    /// The palette is not a whole number of entries. The trailing bytes are ignored.
332    Length {
333        len: usize,
334    },
335    UnknownRole {
336        index: usize,
337        tag: u16,
338    },
339    /// A scheme that is neither light nor dark, `FollowSystem` included.
340    InvalidScheme {
341        index: usize,
342        scheme: u16,
343    },
344    /// The same role and scheme a second time. The first one stands.
345    Duplicate {
346        index: usize,
347        role: ColorRole,
348        scheme: ColorScheme,
349    },
350}
351
352/// Reads a palette off the wire, keeping every entry that is sound.
353pub fn decode_palette(bytes: &[u8]) -> (Palette, Vec<PaletteProblem>) {
354    let mut palette = Palette::new();
355    let mut problems = Vec::new();
356    if bytes.len() % PALETTE_ENTRY_LEN != 0 {
357        problems.push(PaletteProblem::Length { len: bytes.len() });
358    }
359    for (index, entry) in bytes.chunks_exact(PALETTE_ENTRY_LEN).enumerate() {
360        let tag = u16::from_le_bytes([entry[0], entry[1]]);
361        let scheme_tag = u16::from_le_bytes([entry[2], entry[3]]);
362        let argb = u32::from_le_bytes([entry[4], entry[5], entry[6], entry[7]]);
363        let Ok(role) = ColorRole::try_from(tag) else {
364            problems.push(PaletteProblem::UnknownRole { index, tag });
365            continue;
366        };
367        let scheme = match ColorScheme::try_from(scheme_tag) {
368            Ok(scheme @ (ColorScheme::Light | ColorScheme::Dark)) => scheme,
369            _ => {
370                problems.push(PaletteProblem::InvalidScheme {
371                    index,
372                    scheme: scheme_tag,
373                });
374                continue;
375            }
376        };
377        if palette.get(role, scheme).is_some() {
378            problems.push(PaletteProblem::Duplicate {
379                index,
380                role,
381                scheme,
382            });
383            continue;
384        }
385        let color = Color::argb(argb);
386        palette = match scheme {
387            ColorScheme::Light => palette.with_light(role, color),
388            _ => palette.with_dark(role, color),
389        };
390    }
391    (palette, problems)
392}