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