Skip to main content

qframe/theme/
mod.rs

1//! Themes: colour tokens, motion timing, typography roles and CSS-like style rules.
2//!
3//! ```toml
4//! [meta]
5//! name = "Nordic"
6//! extends = "monochrome"
7//!
8//! [colors]
9//! accent = "#38BDF8"
10//!
11//! [style."button.primary"]
12//! bg = "$accent"
13//! fg = "$ink"
14//!
15//! [style."button:focus"]
16//! bg = "pulse($accent, $accent-2)"
17//! ```
18//!
19//! A theme only writes what differs from the theme it extends. Style rules match
20//! `widget.variant:state` selectors; more specific rules win, and at equal specificity the
21//! later rule wins, with rules of the extended theme counted first.
22
23mod cache;
24mod motion;
25mod paint;
26mod registry;
27mod selector;
28mod source;
29mod style;
30mod validate;
31
32use std::collections::BTreeMap;
33use std::sync::Arc;
34
35pub use motion::Motion;
36pub(crate) use motion::{MOTION_KEYS, parse_duration};
37pub(crate) use paint::Expr;
38pub use paint::Paint;
39pub use registry::{Resolved, ThemeRegistry};
40pub use selector::{Selector, State};
41pub use style::{PropValue, StyleProps, WORD_PROPS};
42
43use crate::animation::CellAnimation;
44use crate::color::Rgb;
45use crate::icons::IconGlyphs;
46use cache::StyleCache;
47
48/// Colour tokens every resolved theme defines.
49pub const REQUIRED_COLORS: [&str; 20] = [
50    "canvas", "surface", "raised", "active", "overlay", "accent", "accent-2", "text", "dim", "muted", "ink", "success",
51    "warning", "danger", "info", "series-1", "series-2", "series-3", "series-4", "series-5",
52];
53
54/// How many categorical series tones a theme carries: `series-1` to `series-5`.
55///
56/// The set is deliberately small. Five tones a reader can tell apart are worth more than a dozen
57/// they cannot, and a chart with more than five series is usually a table wearing a chart's
58/// clothes.
59pub const SERIES_COLORS: usize = 5;
60
61/// A fully resolved theme, ready to style widgets.
62#[derive(Debug, Clone, PartialEq)]
63pub struct Theme {
64    id: String,
65    name: String,
66    colors: BTreeMap<String, Rgb>,
67    motion: Motion,
68    typography: BTreeMap<String, StyleProps>,
69    rules: Vec<(Selector, StyleProps)>,
70    icon_set: String,
71    icons: BTreeMap<String, IconGlyphs>,
72    animations: BTreeMap<String, Arc<CellAnimation>>,
73    /// Styles already layered, so a widget painted every frame does not match every rule again.
74    cache: StyleCache,
75}
76
77impl Theme {
78    /// The theme id, which is its file stem.
79    #[must_use]
80    pub fn id(&self) -> &str {
81        &self.id
82    }
83
84    /// The display name from `[meta] name`.
85    #[must_use]
86    pub fn name(&self) -> &str {
87        &self.name
88    }
89
90    /// A colour token such as `"accent"`.
91    #[must_use]
92    pub fn color(&self, token: &str) -> Option<Rgb> {
93        self.colors.get(token).copied()
94    }
95
96    /// Resolves a colour written the way theme files write it (`"$danger"`, `"#38BDF8"`,
97    /// `"mix($accent, $danger, 50%)"`) against this theme's tokens.
98    ///
99    /// # Errors
100    ///
101    /// A message explaining why `expression` is not a single colour: it does not parse, names an
102    /// unknown token, or uses `pulse()`, which breathes and so has no single colour.
103    pub fn solid(&self, expression: &str) -> Result<Rgb, String> {
104        paint::Expr::parse(expression)?.solid(&self.colors)
105    }
106
107    /// The tone of the `index`-th series of a chart, counted from zero.
108    ///
109    /// A theme carries [`SERIES_COLORS`] series tones, so the tones wrap around: series five takes
110    /// the tone of series zero. Wrapping is why a chart must name its series with a
111    /// [`Legend`](crate::widgets::Legend) instead of leaving the meaning in the colour, and why a
112    /// chart that needs more than five kinds is better off grouping the small ones together.
113    #[must_use]
114    pub fn series_color(&self, index: usize) -> Rgb {
115        let token = format!("series-{}", index % SERIES_COLORS + 1);
116        self.color(&token).unwrap_or_else(|| self.colors.get("accent").copied().unwrap_or(Rgb::new(0, 0, 0)))
117    }
118
119    /// Every colour token, sorted by name.
120    pub fn colors(&self) -> impl Iterator<Item = (&str, Rgb)> {
121        self.colors.iter().map(|(name, color)| (name.as_str(), *color))
122    }
123
124    /// Motion timing.
125    #[must_use]
126    pub fn motion(&self) -> Motion {
127        self.motion
128    }
129
130    /// A typography role such as `"title"`.
131    #[must_use]
132    pub fn typography(&self, role: &str) -> Option<&StyleProps> {
133        self.typography.get(role)
134    }
135
136    /// The icon set this theme uses.
137    #[must_use]
138    pub fn icon_set(&self) -> &str {
139        &self.icon_set
140    }
141
142    /// Icons this theme overrides on top of its icon set.
143    #[must_use]
144    pub fn icon_overrides(&self) -> &BTreeMap<String, IconGlyphs> {
145        &self.icons
146    }
147
148    /// Animations this theme defines or replaces on top of its icon set's.
149    #[must_use]
150    pub fn animation_overrides(&self) -> &BTreeMap<String, Arc<CellAnimation>> {
151        &self.animations
152    }
153
154    /// The style of `widget` drawn with `variant` in `states`: every matching rule layered
155    /// from least to most specific.
156    ///
157    /// The result is remembered for the lifetime of the theme, so asking again (as widgets do
158    /// every frame) is a lookup, and the returned properties share their storage.
159    #[must_use]
160    pub fn style(&self, widget: &str, variant: Option<&str>, states: &[State]) -> StyleProps {
161        self.cache.get_or_insert(widget, variant, states, || self.layer_rules(widget, variant, states))
162    }
163
164    /// Layers every rule matching `widget`, `variant` and `states`, least specific first.
165    fn layer_rules(&self, widget: &str, variant: Option<&str>, states: &[State]) -> StyleProps {
166        let mut matching: Vec<(usize, &(Selector, StyleProps))> = self
167            .rules
168            .iter()
169            .enumerate()
170            .filter(|(_, (selector, _))| selector.matches(widget, variant, states))
171            .collect();
172        matching.sort_by_key(|(order, (selector, _))| (selector.specificity(), *order));
173        let mut props = StyleProps::default();
174        for (_, (_, rule)) in matching {
175            props.overlay(rule);
176        }
177        props
178    }
179}