Skip to main content

gpui_kit_material_color/
scheme.rs

1//! Semantic color roles and their derivation from a source palette.
2//!
3//! Reference themes use the complete md3.io color role set. Compatibility
4//! constants map older names onto the same role storage; application-specific
5//! status containers and selection colors remain explicit extensions.
6use crate::{Color, PaletteId};
7
8macro_rules! roles {
9    ($($(#[$doc:meta])* $name:ident),+ $(,)?) => {
10        /// A semantic color role. Components paint with roles, never with a
11        /// palette's stored colors.
12        #[allow(non_camel_case_types, clippy::upper_case_acronyms)]
13        #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
14        #[repr(usize)]
15        pub enum ColorRole { $($(#[$doc])* $name),+ }
16
17        impl ColorRole {
18            /// Every role, in declaration order.
19            pub const ALL: &[ColorRole] = &[$(ColorRole::$name),+];
20        }
21    }
22}
23
24roles!(
25    ERROR,
26    ERROR_CONTAINER,
27    INFO,
28    INVERSE_ON_SURFACE,
29    INVERSE_PRIMARY,
30    INVERSE_SURFACE,
31    ON_ERROR,
32    ON_ERROR_CONTAINER,
33    ON_INFO,
34    ON_PRIMARY,
35    ON_PRIMARY_CONTAINER,
36    ON_PRIMARY_FIXED,
37    ON_PRIMARY_FIXED_VARIANT,
38    ON_SECONDARY,
39    ON_SECONDARY_CONTAINER,
40    ON_SECONDARY_FIXED,
41    ON_SECONDARY_FIXED_VARIANT,
42    ON_SUCCESS,
43    ON_SURFACE,
44    ON_SURFACE_VARIANT,
45    ON_TERTIARY,
46    ON_TERTIARY_CONTAINER,
47    ON_TERTIARY_FIXED,
48    ON_TERTIARY_FIXED_VARIANT,
49    ON_WARNING,
50    OUTLINE,
51    OUTLINE_VARIANT,
52    PRIMARY,
53    PRIMARY_CONTAINER,
54    PRIMARY_FIXED,
55    PRIMARY_FIXED_DIM,
56    SCRIM,
57    SECONDARY,
58    SECONDARY_CONTAINER,
59    SECONDARY_FIXED,
60    SECONDARY_FIXED_DIM,
61    SHADOW,
62    SUCCESS,
63    SURFACE,
64    SURFACE_BRIGHT,
65    SURFACE_CONTAINER,
66    SURFACE_CONTAINER_HIGH,
67    SURFACE_CONTAINER_HIGHEST,
68    SURFACE_CONTAINER_LOW,
69    SURFACE_CONTAINER_LOWEST,
70    SURFACE_DIM,
71    SURFACE_VARIANT,
72    TERTIARY,
73    TERTIARY_CONTAINER,
74    TERTIARY_FIXED,
75    TERTIARY_FIXED_DIM,
76    WARNING,
77    INSET,
78    FAINT,
79    CARD_LINE,
80    SUCCESS_BG,
81    WARNING_BG,
82    FIND_HIGHLIGHT,
83    FIND_CURRENT,
84    FIND_CURRENT_LINE,
85    SELECTION,
86    READING_SELECTION,
87);
88
89// Compatibility names share storage with their semantic M3 role. Application
90// extensions above remain independent; they are not claimed as M3 scheme roles.
91impl ColorRole {
92    /// Legacy name for [`ColorRole::SURFACE_CONTAINER`], the window ground.
93    #[deprecated(note = "use `ColorRole::SURFACE_CONTAINER`")]
94    pub const CANVAS: Self = Self::SURFACE_CONTAINER;
95    /// Legacy name for [`ColorRole::SURFACE_CONTAINER_HIGHEST`]. Despite the
96    /// name, this is the *highest* container, not
97    /// [`ColorRole::SURFACE_CONTAINER_HIGH`].
98    #[deprecated(note = "use `ColorRole::SURFACE_CONTAINER_HIGHEST`")]
99    pub const SURFACE_HIGH: Self = Self::SURFACE_CONTAINER_HIGHEST;
100    /// Legacy name for [`ColorRole::ON_SURFACE`], primary text and icons.
101    #[deprecated(note = "use `ColorRole::ON_SURFACE`")]
102    pub const INK: Self = Self::ON_SURFACE;
103    /// Legacy name for [`ColorRole::ON_SURFACE_VARIANT`], secondary text
104    /// and icons.
105    #[deprecated(note = "use `ColorRole::ON_SURFACE_VARIANT`")]
106    pub const GRAY: Self = Self::ON_SURFACE_VARIANT;
107    /// Legacy name for [`ColorRole::OUTLINE_VARIANT`], decorative dividers.
108    #[deprecated(note = "use `ColorRole::OUTLINE_VARIANT`")]
109    pub const HAIRLINE: Self = Self::OUTLINE_VARIANT;
110    /// Legacy name for [`ColorRole::OUTLINE`], control boundaries such as
111    /// outlined fields and unselected checkboxes.
112    #[deprecated(note = "use `ColorRole::OUTLINE`")]
113    pub const CONTROL: Self = Self::OUTLINE;
114    /// Legacy name for [`ColorRole::PRIMARY`].
115    #[deprecated(note = "use `ColorRole::PRIMARY`")]
116    pub const ACCENT: Self = Self::PRIMARY;
117    /// Legacy name for [`ColorRole::ERROR`].
118    #[deprecated(note = "use `ColorRole::ERROR`")]
119    pub const DANGER: Self = Self::ERROR;
120    /// Legacy name for [`ColorRole::ERROR_CONTAINER`].
121    #[deprecated(note = "use `ColorRole::ERROR_CONTAINER`")]
122    pub const DANGER_BG: Self = Self::ERROR_CONTAINER;
123}
124
125/// The number of roles in a [`Scheme`].
126pub const ROLE_COUNT: usize = ColorRole::ALL.len();
127
128/// One value for every [`ColorRole`].
129#[derive(Clone, Copy, Debug, PartialEq)]
130pub struct Scheme {
131    colors: [Color; ROLE_COUNT],
132    dark: bool,
133}
134
135impl Scheme {
136    /// Override one semantic role in an application-defined scheme.
137    pub fn with_role(mut self, role: ColorRole, color: Color) -> Self {
138        self.colors[role as usize] = color;
139        self
140    }
141
142    /// Derive every role from a built-in palette.
143    pub fn from_palette(id: PaletteId) -> Self {
144        let p = id.colors();
145        let dark = id.is_dark();
146        let hex = Color::hex;
147        let tint = |foreground: u32, amount| hex(p.surface).blend(hex(foreground), amount);
148        // The window ground is M3's surface container: the palette's canvas
149        // with a trace of its accent, so surface tiles read as a layer above it.
150        let canvas = hex(p.canvas).blend(hex(p.accent), if dark { 0.03 } else { 0.045 });
151        let colors = std::array::from_fn(|index| match ColorRole::ALL[index] {
152            ColorRole::SURFACE_CONTAINER => canvas,
153            // Dark palettes' surfaces sit only a step above their canvas; lift
154            // them as M3 lifts dark surface containers, so tiles and cards
155            // read as layers.
156            ColorRole::SURFACE => {
157                if dark {
158                    hex(p.surface).blend(hex(p.text), 0.045)
159                } else {
160                    hex(p.surface)
161                }
162            }
163            // Dark palettes raise surfaces by lightening them, so their
164            // highest container builds on the surface rather than the
165            // darker ground.
166            ColorRole::SURFACE_CONTAINER_HIGHEST => {
167                if dark {
168                    hex(p.surface).blend(hex(p.text), 0.13)
169                } else {
170                    canvas.blend(hex(p.text), 0.06)
171                }
172            }
173            ColorRole::INSET => hex(p.inset),
174            ColorRole::ON_SURFACE => hex(p.text),
175            ColorRole::ON_SURFACE_VARIANT => hex(p.muted),
176            ColorRole::FAINT => hex(p.subtle),
177            ColorRole::OUTLINE_VARIANT => hex(p.border),
178            // M3 cards are separated by tone rather than a full-strength outline.
179            ColorRole::CARD_LINE => hex(p.surface).blend(hex(p.border), 0.55),
180            ColorRole::OUTLINE => hex(p.control),
181            ColorRole::PRIMARY => hex(p.accent),
182            ColorRole::ON_PRIMARY => hex(p.on_accent),
183            // Container roles derive from each palette's own accent and text,
184            // so every preset keeps its identity.
185            ColorRole::SECONDARY_CONTAINER => tint(p.accent, if dark { 0.26 } else { 0.24 }),
186            ColorRole::ON_SECONDARY_CONTAINER => {
187                hex(p.text).blend(hex(p.accent), if dark { 0.30 } else { 0.40 })
188            }
189            ColorRole::SUCCESS => hex(p.success),
190            ColorRole::SUCCESS_BG => tint(p.success, 0.12),
191            ColorRole::WARNING => hex(p.warning),
192            ColorRole::WARNING_BG => tint(p.warning, 0.12),
193            ColorRole::ERROR => hex(p.danger),
194            ColorRole::ERROR_CONTAINER => tint(p.danger, 0.12),
195            ColorRole::SELECTION => hex(p.selection),
196            // Selected text: the accent laid over whatever surface holds the
197            // text, so it stays visible on tinted panels.
198            ColorRole::READING_SELECTION => hex(p.accent).alpha(if dark { 0.42 } else { 0.30 }),
199            ColorRole::FIND_HIGHLIGHT => tint(p.warning, 0.16),
200            ColorRole::FIND_CURRENT => tint(p.warning, 0.32),
201            ColorRole::FIND_CURRENT_LINE => hex(p.warning),
202            // Legacy presets predate full M3 schemes. These are adapters from
203            // their existing colors, not seed-generated HCT schemes.
204            ColorRole::PRIMARY_CONTAINER | ColorRole::TERTIARY_CONTAINER => tint(p.accent, 0.24),
205            ColorRole::ON_PRIMARY_CONTAINER | ColorRole::ON_TERTIARY_CONTAINER => hex(p.text),
206            ColorRole::SECONDARY | ColorRole::TERTIARY => hex(p.accent),
207            ColorRole::ON_SECONDARY | ColorRole::ON_TERTIARY => hex(p.on_accent),
208            ColorRole::PRIMARY_FIXED | ColorRole::SECONDARY_FIXED | ColorRole::TERTIARY_FIXED => {
209                hex(p.accent).blend(hex(0xffffff), 0.8)
210            }
211            ColorRole::PRIMARY_FIXED_DIM
212            | ColorRole::SECONDARY_FIXED_DIM
213            | ColorRole::TERTIARY_FIXED_DIM => hex(p.accent).blend(hex(0xffffff), 0.6),
214            ColorRole::ON_PRIMARY_FIXED
215            | ColorRole::ON_SECONDARY_FIXED
216            | ColorRole::ON_TERTIARY_FIXED => hex(p.accent).blend(hex(0), 0.8),
217            ColorRole::ON_PRIMARY_FIXED_VARIANT
218            | ColorRole::ON_SECONDARY_FIXED_VARIANT
219            | ColorRole::ON_TERTIARY_FIXED_VARIANT => hex(p.accent).blend(hex(0), 0.4),
220            ColorRole::SURFACE_DIM => hex(p.canvas),
221            ColorRole::SURFACE_BRIGHT => {
222                if dark {
223                    hex(p.surface).blend(hex(p.text), 0.18)
224                } else {
225                    hex(p.surface)
226                }
227            }
228            ColorRole::SURFACE_VARIANT | ColorRole::SURFACE_CONTAINER_HIGH => {
229                tint(p.text, if dark { 0.10 } else { 0.06 })
230            }
231            ColorRole::SURFACE_CONTAINER_LOWEST => {
232                if dark {
233                    hex(p.canvas)
234                } else {
235                    hex(p.surface)
236                }
237            }
238            ColorRole::SURFACE_CONTAINER_LOW => tint(p.text, 0.025),
239            ColorRole::INVERSE_SURFACE => hex(p.text),
240            ColorRole::INVERSE_ON_SURFACE => hex(p.surface),
241            ColorRole::INVERSE_PRIMARY => hex(p.accent).blend(hex(p.surface), 0.4),
242            ColorRole::ON_ERROR => hex(p.on_accent),
243            ColorRole::ON_ERROR_CONTAINER => hex(p.danger),
244            ColorRole::ON_SUCCESS | ColorRole::ON_WARNING => hex(p.on_accent),
245            ColorRole::INFO => hex(p.accent),
246            ColorRole::ON_INFO => hex(p.on_accent),
247            ColorRole::SHADOW | ColorRole::SCRIM => hex(0),
248        });
249        Self { colors, dark }
250    }
251
252    /// Construct an exact reference scheme and derive only the kit-specific
253    /// extensions (selection, find highlights, and status containers).
254    pub(crate) fn from_reference(colors: &[u32; 52], dark: bool) -> Self {
255        let mut scheme = Self {
256            colors: [Color::hex(0); ROLE_COUNT],
257            dark,
258        };
259        for (role, value) in crate::themes::REFERENCE_ROLES.iter().zip(colors) {
260            scheme.colors[*role as usize] = Color::hex(*value);
261        }
262        let surface = scheme.get(ColorRole::SURFACE);
263        let accent = scheme.get(ColorRole::PRIMARY);
264        let warning = scheme.get(ColorRole::WARNING);
265        scheme
266            .with_role(
267                ColorRole::INSET,
268                scheme.get(ColorRole::SURFACE_CONTAINER_LOWEST),
269            )
270            .with_role(ColorRole::FAINT, scheme.get(ColorRole::OUTLINE))
271            .with_role(ColorRole::CARD_LINE, scheme.get(ColorRole::OUTLINE_VARIANT))
272            .with_role(
273                ColorRole::SUCCESS_BG,
274                surface.blend(scheme.get(ColorRole::SUCCESS), 0.12),
275            )
276            .with_role(ColorRole::WARNING_BG, surface.blend(warning, 0.12))
277            .with_role(ColorRole::FIND_HIGHLIGHT, surface.blend(warning, 0.16))
278            .with_role(ColorRole::FIND_CURRENT, surface.blend(warning, 0.32))
279            .with_role(ColorRole::FIND_CURRENT_LINE, warning)
280            .with_role(
281                ColorRole::SELECTION,
282                scheme.get(ColorRole::PRIMARY_CONTAINER),
283            )
284            .with_role(
285                ColorRole::READING_SELECTION,
286                accent.alpha(if dark { 0.42 } else { 0.30 }),
287            )
288    }
289
290    pub fn get(&self, role: ColorRole) -> Color {
291        self.colors[role as usize]
292    }
293
294    /// Whether the scheme was derived from a dark palette.
295    pub fn is_dark(&self) -> bool {
296        self.dark
297    }
298
299    /// Every role interpolated toward `to`; used for palette transitions.
300    /// The result takes `to`'s darkness once it is more than halfway there.
301    pub fn blend(&self, to: &Scheme, t: f32) -> Scheme {
302        Scheme {
303            colors: std::array::from_fn(|i| self.colors[i].blend(to.colors[i], t)),
304            dark: if t < 0.5 { self.dark } else { to.dark },
305        }
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use crate::palette::ALL;
313
314    #[test]
315    fn layers_stay_distinct_and_text_reads_against_surfaces() {
316        for id in ALL {
317            let scheme = Scheme::from_palette(id);
318            assert_eq!(scheme.is_dark(), id.is_dark());
319            let (canvas, surface, high) = (
320                scheme.get(ColorRole::SURFACE_CONTAINER),
321                scheme.get(ColorRole::SURFACE),
322                scheme.get(ColorRole::SURFACE_CONTAINER_HIGHEST),
323            );
324            assert_ne!(canvas, surface, "{}", id.name());
325            assert_ne!(surface, high, "{}", id.name());
326            let ink = scheme.get(ColorRole::ON_SURFACE).luminance();
327            assert_eq!(
328                ink > surface.luminance(),
329                id.is_dark(),
330                "{}: dark palettes have light text",
331                id.name()
332            );
333        }
334    }
335
336    #[test]
337    fn blend_reaches_its_target() {
338        let paper = Scheme::from_palette(PaletteId::Paper);
339        let ink = Scheme::from_palette(PaletteId::Ink);
340        assert_eq!(paper.blend(&ink, 0.), paper);
341        assert_eq!(paper.blend(&ink, 1.), ink);
342        assert!(paper.blend(&ink, 0.6).is_dark());
343    }
344}
345
346#[cfg(test)]
347mod custom_scheme_tests {
348    use super::*;
349    #[test]
350    fn overriding_one_role_preserves_other_roles_and_appearance() {
351        let before = Scheme::from_palette(PaletteId::Paper);
352        let after = before.with_role(ColorRole::PRIMARY, Color::hex(0x0b57d0));
353        assert_eq!(after.get(ColorRole::PRIMARY), Color::hex(0x0b57d0));
354        for role in ColorRole::ALL.iter().filter(|r| **r != ColorRole::PRIMARY) {
355            assert_eq!(before.get(*role), after.get(*role));
356        }
357        assert_eq!(before.is_dark(), after.is_dark());
358    }
359}