Skip to main content

iced_cube/
theme.rs

1//! Design tokens and the built-in light and dark themes.
2//!
3//! Every component resolves its colours through [`Tokens::of`], so any
4//! [`iced::Theme`] works. [`light`] and [`dark`] are tuned for the library,
5//! and [`Config`] builds a theme with your own colours on top of either.
6
7use std::borrow::Cow;
8use std::sync::OnceLock;
9
10use iced::font::Weight;
11use iced::theme::Palette;
12use iced::theme::palette::{Extended, Pair, Secondary, deviate};
13use iced::{Color, Font, Theme, color};
14
15static BASE_FONT: OnceLock<Font> = OnceLock::new();
16
17/// Registers the font components use for emphasised text, such as card and
18/// toast titles. Pass the same font you give iced's `default_font`, before
19/// the first view. Returns `false` if a font was already set.
20///
21/// Without it, emphasised text uses iced's generic sans-serif family, which
22/// the system resolves and may differ from your default font.
23pub fn set_font(font: Font) -> bool {
24    BASE_FONT.set(font).is_ok()
25}
26
27/// The registered base font, or iced's default when none is set.
28pub fn font() -> Font {
29    BASE_FONT.get().copied().unwrap_or(Font::DEFAULT)
30}
31
32/// The base font at semibold weight.
33pub fn semibold() -> Font {
34    Font {
35        weight: Weight::Semibold,
36        ..font()
37    }
38}
39
40/// Corner radii in logical pixels.
41pub mod radius {
42    pub const SM: f32 = 4.0;
43    pub const MD: f32 = 6.0;
44    pub const LG: f32 = 8.0;
45    pub const FULL: f32 = 9999.0;
46}
47
48/// Spacing scale in logical pixels.
49pub mod space {
50    pub const XS: f32 = 4.0;
51    pub const SM: f32 = 8.0;
52    pub const MD: f32 = 12.0;
53    pub const LG: f32 = 16.0;
54    pub const XL: f32 = 24.0;
55}
56
57/// Text sizes in logical pixels.
58pub mod text_size {
59    pub const XS: f32 = 12.0;
60    pub const SM: f32 = 14.0;
61    pub const MD: f32 = 16.0;
62    pub const LG: f32 = 18.0;
63}
64
65/// How far the popover surface moves towards the foreground in dark
66/// themes. Floating surfaces sit just above cards.
67const POPOVER_LIFT: f32 = 0.06;
68
69/// Semantic colours resolved from a theme.
70#[derive(Debug, Clone, Copy, PartialEq)]
71pub struct Tokens {
72    pub background: Color,
73    pub foreground: Color,
74    /// Floating surfaces: menus, popovers, lists and dialogs. The page
75    /// background in light themes, lifted towards the foreground in dark
76    /// ones so a menu over a card still reads as raised.
77    pub popover: Color,
78    pub muted: Color,
79    pub muted_foreground: Color,
80    pub accent: Color,
81    pub accent_foreground: Color,
82    pub border: Color,
83    pub ring: Color,
84    pub primary: Color,
85    pub primary_foreground: Color,
86    pub secondary: Color,
87    pub secondary_foreground: Color,
88    pub destructive: Color,
89    pub destructive_foreground: Color,
90    pub success: Color,
91    pub warning: Color,
92    pub is_dark: bool,
93}
94
95impl Tokens {
96    /// Resolves the tokens for any iced theme.
97    pub fn of(theme: &Theme) -> Self {
98        let ext = theme.extended_palette();
99        let background = ext.background.base.color;
100        let foreground = ext.background.base.text;
101
102        let popover = if ext.is_dark {
103            mix(background, foreground, POPOVER_LIFT)
104        } else {
105            background
106        };
107
108        Self {
109            background,
110            foreground,
111            popover,
112            muted: ext.background.weak.color,
113            muted_foreground: mix(foreground, background, 0.4),
114            accent: ext.secondary.weak.color,
115            accent_foreground: on(ext.secondary.weak.color),
116            border: ext.background.strong.color,
117            ring: Color {
118                a: 0.5,
119                ..ext.primary.base.color
120            },
121            primary: ext.primary.base.color,
122            primary_foreground: on(ext.primary.base.color),
123            secondary: ext.secondary.base.color,
124            secondary_foreground: on(ext.secondary.base.color),
125            destructive: ext.danger.base.color,
126            destructive_foreground: on(ext.danger.base.color),
127            success: ext.success.base.color,
128            warning: ext.warning.base.color,
129            is_dark: ext.is_dark,
130        }
131    }
132
133    /// Background of a disabled text field, select or combobox.
134    pub fn disabled_field(&self) -> Color {
135        mix(self.background, self.muted, 0.5)
136    }
137}
138
139/// The library's light theme.
140pub fn light() -> Theme {
141    Config::light().build()
142}
143
144/// The library's dark theme.
145pub fn dark() -> Theme {
146    Config::dark().build()
147}
148
149/// Builds a [`Theme`] from your own colours, starting from the light or
150/// dark defaults. Text on coloured surfaces, such as a primary button's
151/// label, is picked automatically with [`on`].
152///
153/// ```
154/// use iced::color;
155/// use iced_cube::theme::{Config, Tokens};
156///
157/// let theme = Config::light()
158///     .name("Brand")
159///     .primary(color!(0x4f46e5))
160///     .accent(color!(0xeef2ff))
161///     .build();
162///
163/// assert_eq!(Tokens::of(&theme).primary, color!(0x4f46e5));
164/// ```
165#[derive(Debug, Clone, PartialEq)]
166pub struct Config {
167    name: Cow<'static, str>,
168    background: Color,
169    foreground: Color,
170    primary: Color,
171    secondary: Option<Color>,
172    accent: Option<Color>,
173    destructive: Color,
174    success: Color,
175    warning: Color,
176    border: Option<Color>,
177}
178
179impl Config {
180    /// The colours of [`light`].
181    pub fn light() -> Self {
182        Self {
183            name: Cow::Borrowed("Cube Light"),
184            background: color!(0xffffff),
185            foreground: color!(0x09090b),
186            primary: color!(0x18181b),
187            secondary: None,
188            accent: None,
189            destructive: color!(0xdc2626),
190            success: color!(0x16a34a),
191            warning: color!(0xd97706),
192            border: None,
193        }
194    }
195
196    /// The colours of [`dark`].
197    pub fn dark() -> Self {
198        Self {
199            name: Cow::Borrowed("Cube Dark"),
200            background: color!(0x09090b),
201            foreground: color!(0xfafafa),
202            primary: color!(0xfafafa),
203            secondary: None,
204            accent: None,
205            destructive: color!(0xdc2626),
206            success: color!(0x22c55e),
207            warning: color!(0xf59e0b),
208            border: None,
209        }
210    }
211
212    /// The theme's name, as shown by iced's theme pickers.
213    pub fn name(mut self, name: impl Into<Cow<'static, str>>) -> Self {
214        self.name = name.into();
215        self
216    }
217
218    /// Filled buttons, checked controls, sliders and focus rings.
219    pub fn primary(mut self, colour: Color) -> Self {
220        self.primary = colour;
221        self
222    }
223
224    /// Secondary buttons and badges. Defaults to a shade of the background.
225    pub fn secondary(mut self, colour: Color) -> Self {
226        self.secondary = Some(colour);
227        self
228    }
229
230    /// Hover and highlight surfaces, such as the selected option in a
231    /// menu. Defaults to a shade of the background.
232    pub fn accent(mut self, colour: Color) -> Self {
233        self.accent = Some(colour);
234        self
235    }
236
237    /// Destructive buttons, badges and errors.
238    pub fn destructive(mut self, colour: Color) -> Self {
239        self.destructive = colour;
240        self
241    }
242
243    pub fn success(mut self, colour: Color) -> Self {
244        self.success = colour;
245        self
246    }
247
248    pub fn warning(mut self, colour: Color) -> Self {
249        self.warning = colour;
250        self
251    }
252
253    /// The window background. Whether the theme counts as dark follows
254    /// from this colour.
255    pub fn background(mut self, colour: Color) -> Self {
256        self.background = colour;
257        self
258    }
259
260    /// Body text.
261    pub fn foreground(mut self, colour: Color) -> Self {
262        self.foreground = colour;
263        self
264    }
265
266    /// Borders of inputs, cards and separators. Defaults to a shade of the
267    /// background.
268    pub fn border(mut self, colour: Color) -> Self {
269        self.border = Some(colour);
270        self
271    }
272
273    /// The base iced palette.
274    pub fn palette(&self) -> Palette {
275        Palette {
276            background: self.background,
277            text: self.foreground,
278            primary: self.primary,
279            success: self.success,
280            warning: self.warning,
281            danger: self.destructive,
282        }
283    }
284
285    /// The extended iced palette that [`Tokens::of`] reads. Secondary goes
286    /// in `secondary.base`, accent in `secondary.weak` and border in
287    /// `background.strong`.
288    pub fn extended(&self) -> Extended {
289        let mut extended = Extended::generate(self.palette());
290        let shade = extended.background.weak.color;
291        let secondary = self.secondary.unwrap_or(shade);
292        let accent = self.accent.unwrap_or(shade);
293        let strong = deviate(secondary, 0.1);
294
295        extended.secondary = Secondary {
296            base: Pair::new(secondary, on(secondary)),
297            weak: Pair::new(accent, on(accent)),
298            strong: Pair::new(strong, on(strong)),
299        };
300        if let Some(border) = self.border {
301            extended.background.strong = Pair::new(border, self.foreground);
302        }
303        extended
304    }
305
306    /// Builds the iced theme.
307    pub fn build(&self) -> Theme {
308        let extended = self.extended();
309        Theme::custom_with_fn(self.name.clone(), self.palette(), move |_| extended)
310    }
311}
312
313/// Linearly blends `a` towards `b` by `t` (0.0 is `a`, 1.0 is `b`).
314pub fn mix(a: Color, b: Color, t: f32) -> Color {
315    let t = t.clamp(0.0, 1.0);
316    Color {
317        r: a.r + (b.r - a.r) * t,
318        g: a.g + (b.g - a.g) * t,
319        b: a.b + (b.b - a.b) * t,
320        a: a.a + (b.a - a.a) * t,
321    }
322}
323
324/// Near-black or near-white, whichever reads better on `background`.
325pub fn on(background: Color) -> Color {
326    let luminance = 0.2126 * background.r + 0.7152 * background.g + 0.0722 * background.b;
327    if luminance > 0.55 {
328        color!(0x09090b)
329    } else {
330        color!(0xfafafa)
331    }
332}
333
334/// Returns `color` with its alpha multiplied by `alpha`.
335pub fn fade(color: Color, alpha: f32) -> Color {
336    Color {
337        a: color.a * alpha.clamp(0.0, 1.0),
338        ..color
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn semibold_keeps_the_base_family() {
348        let font = semibold();
349        assert_eq!(font.weight, Weight::Semibold);
350        assert_eq!(font.family, super::font().family);
351    }
352
353    #[test]
354    fn mix_endpoints_return_inputs() {
355        let a = Color::from_rgb(0.0, 0.2, 0.4);
356        let b = Color::from_rgb(1.0, 0.8, 0.6);
357        assert_eq!(mix(a, b, 0.0), a);
358        assert_eq!(mix(a, b, 1.0), b);
359    }
360
361    #[test]
362    fn mix_clamps_out_of_range_factor() {
363        let a = Color::BLACK;
364        let b = Color::WHITE;
365        assert_eq!(mix(a, b, -1.0), a);
366        assert_eq!(mix(a, b, 2.0), b);
367    }
368
369    #[test]
370    fn fade_scales_alpha_only() {
371        let faded = fade(Color::from_rgba(0.1, 0.2, 0.3, 0.8), 0.5);
372        assert_eq!((faded.r, faded.g, faded.b), (0.1, 0.2, 0.3));
373        assert!((faded.a - 0.4).abs() < f32::EPSILON);
374    }
375
376    #[test]
377    fn on_picks_dark_text_for_light_backgrounds() {
378        assert_eq!(on(Color::WHITE), color!(0x09090b));
379        assert_eq!(on(Color::BLACK), color!(0xfafafa));
380        assert_eq!(on(color!(0xdc2626)), color!(0xfafafa));
381    }
382
383    #[test]
384    fn popover_is_the_background_in_light_and_lifted_in_dark() {
385        let light = Tokens::of(&light());
386        assert_eq!(light.popover, light.background);
387
388        let dark = Tokens::of(&dark());
389        let card = mix(dark.background, dark.foreground, 0.04);
390        let brightness = |colour: Color| colour.r + colour.g + colour.b;
391        assert!(brightness(dark.popover) > brightness(card));
392        assert!(brightness(dark.popover) < brightness(dark.foreground));
393    }
394
395    #[test]
396    fn light_and_dark_report_their_mode() {
397        assert!(!Tokens::of(&light()).is_dark);
398        assert!(Tokens::of(&dark()).is_dark);
399    }
400
401    #[test]
402    fn primary_foreground_contrasts_with_primary() {
403        for theme in [light(), dark()] {
404            let tokens = Tokens::of(&theme);
405            assert_ne!(tokens.primary, tokens.primary_foreground);
406            assert_ne!(tokens.background, tokens.foreground);
407        }
408    }
409
410    #[test]
411    fn defaults_match_the_original_palettes() {
412        let light = Tokens::of(&light());
413        assert_eq!(light.background, color!(0xffffff));
414        assert_eq!(light.foreground, color!(0x09090b));
415        assert_eq!(light.primary, color!(0x18181b));
416        assert_eq!(light.destructive, color!(0xdc2626));
417        assert_eq!(light.secondary, light.muted);
418        assert_eq!(light.accent, light.muted);
419        assert_eq!(light.secondary_foreground, light.foreground);
420        assert_eq!(light.accent_foreground, light.foreground);
421
422        let dark = Tokens::of(&dark());
423        assert_eq!(dark.background, color!(0x09090b));
424        assert_eq!(dark.primary, color!(0xfafafa));
425        assert_eq!(dark.success, color!(0x22c55e));
426        assert_eq!(dark.secondary, dark.muted);
427        assert_eq!(dark.secondary_foreground, dark.foreground);
428        assert_eq!(dark.accent_foreground, dark.foreground);
429    }
430
431    #[test]
432    fn defaults_keep_the_generated_border() {
433        for config in [Config::light(), Config::dark()] {
434            let generated = Extended::generate(config.palette());
435            let tokens = Tokens::of(&config.build());
436            assert_eq!(tokens.border, generated.background.strong.color);
437            assert_eq!(tokens.muted, generated.background.weak.color);
438        }
439    }
440
441    #[test]
442    fn config_colours_reach_tokens() {
443        let theme = Config::light()
444            .name("Brand")
445            .primary(color!(0x4f46e5))
446            .secondary(color!(0xe0e7ff))
447            .accent(color!(0xfef3c7))
448            .destructive(color!(0xbe123c))
449            .success(color!(0x059669))
450            .warning(color!(0xca8a04))
451            .background(color!(0xfafaf9))
452            .foreground(color!(0x1c1917))
453            .border(color!(0xd6d3d1))
454            .build();
455        let tokens = Tokens::of(&theme);
456
457        assert_eq!(theme.to_string(), "Brand");
458        assert_eq!(tokens.primary, color!(0x4f46e5));
459        assert_eq!(tokens.primary_foreground, on(color!(0x4f46e5)));
460        assert_eq!(tokens.secondary, color!(0xe0e7ff));
461        assert_eq!(tokens.secondary_foreground, on(color!(0xe0e7ff)));
462        assert_eq!(tokens.accent, color!(0xfef3c7));
463        assert_eq!(tokens.destructive, color!(0xbe123c));
464        assert_eq!(tokens.success, color!(0x059669));
465        assert_eq!(tokens.warning, color!(0xca8a04));
466        assert_eq!(tokens.background, color!(0xfafaf9));
467        assert_eq!(tokens.foreground, color!(0x1c1917));
468        assert_eq!(tokens.border, color!(0xd6d3d1));
469    }
470
471    #[test]
472    fn unset_surfaces_follow_a_new_background() {
473        let background = color!(0xf5f5f4);
474        let tokens = Tokens::of(&Config::light().background(background).build());
475        let shade = Extended::generate(Palette {
476            background,
477            ..Config::light().palette()
478        })
479        .background
480        .weak
481        .color;
482        assert_eq!(tokens.secondary, shade);
483        assert_eq!(tokens.accent, shade);
484    }
485
486    #[test]
487    fn dark_mode_follows_the_background() {
488        assert!(!Tokens::of(&Config::light().build()).is_dark);
489        assert!(Tokens::of(&Config::dark().build()).is_dark);
490        let night = Config::light()
491            .background(color!(0x0f172a))
492            .foreground(color!(0xf8fafc))
493            .build();
494        assert!(Tokens::of(&night).is_dark);
495        let paper = Config::dark()
496            .background(color!(0xfffbeb))
497            .foreground(color!(0x292524))
498            .build();
499        assert!(!Tokens::of(&paper).is_dark);
500    }
501
502    #[test]
503    fn tokens_resolve_for_builtin_iced_themes() {
504        for theme in Theme::ALL {
505            let tokens = Tokens::of(theme);
506            assert_ne!(tokens.background, tokens.foreground, "{theme}");
507        }
508    }
509}