Skip to main content

smudge_core/
theme.rs

1//! Theme tokens for colors and corner radii.
2//!
3//! A style can name a theme token instead of a literal value. The token
4//! resolves to a concrete [`Color`] or radius when the node paints, so
5//! switching the theme changes every node that uses a token and no other.
6//! [`Theme`] holds the values the tokens resolve against; an application
7//! picks the built-in light or dark theme with [`Theme::light`] and
8//! [`Theme::dark`], or fills one of its own.
9//!
10//! ```
11//! use smudge_core::geometry::Color;
12//! use smudge_core::theme::{ColorToken, Theme};
13//! use smudge_core::widget::panel;
14//!
15//! let card = panel().background(ColorToken::SURFACE);
16//! let theme = Theme::light();
17//! assert_eq!(theme.color(ColorToken::SURFACE), Color::WHITE);
18//! ```
19
20use std::cell::Cell;
21use std::collections::HashMap;
22
23use crate::geometry::Color;
24
25/// The color a missing token resolves to, so the mistake stays visible on
26/// screen even in a release build.
27const MISSING_COLOR: Color = Color::rgb(255, 0, 255);
28
29/// The radius a missing token resolves to.
30///
31/// Unlike the color fallback it is not visible on screen, since it paints
32/// square corners. The debug assertion is what reports a missing radius.
33const MISSING_RADIUS: f32 = 0.0;
34
35/// The name of a color in a [`Theme`].
36///
37/// A token is a name, not a value: the value comes from the theme that is
38/// current when the node paints. The built-in constants name the tokens every
39/// theme should define.
40#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
41pub struct ColorToken(
42    /// The token's name, which themes key their colors by.
43    pub &'static str,
44);
45
46impl ColorToken {
47    /// The color behind the content of a plain surface.
48    pub const SURFACE: ColorToken = ColorToken("surface");
49    /// The surface color while the pointer is over a control.
50    pub const SURFACE_HOVER: ColorToken = ColorToken("surface_hover");
51    /// The surface color while a press is held on a control.
52    pub const SURFACE_PRESSED: ColorToken = ColorToken("surface_pressed");
53    /// The color of primary text.
54    pub const TEXT: ColorToken = ColorToken("text");
55    /// The color of secondary text, such as captions and placeholders.
56    pub const TEXT_MUTED: ColorToken = ColorToken("text_muted");
57    /// The color of interactive accents, such as a primary button.
58    pub const ACCENT: ColorToken = ColorToken("accent");
59    /// The accent color while the pointer is over a control that uses it.
60    pub const ACCENT_HOVER: ColorToken = ColorToken("accent_hover");
61    /// The accent color while a press is held on a control that uses it.
62    pub const ACCENT_PRESSED: ColorToken = ColorToken("accent_pressed");
63    /// The color of text painted over [`ColorToken::ACCENT`].
64    pub const ACCENT_TEXT: ColorToken = ColorToken("accent_text");
65    /// The color of borders around controls.
66    pub const BORDER: ColorToken = ColorToken("border");
67    /// The color of the ring around the keyboard-focused control.
68    pub const FOCUS_RING: ColorToken = ColorToken("focus_ring");
69}
70
71/// The name of a corner radius in a [`Theme`].
72///
73/// A token is a name, not a value: the value comes from the theme that is
74/// current when the node paints.
75#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
76pub struct RadiusToken(
77    /// The token's name, which themes key their radii by.
78    pub &'static str,
79);
80
81impl RadiusToken {
82    /// A small radius, for chips and small controls.
83    pub const SMALL: RadiusToken = RadiusToken("small");
84    /// A medium radius, for buttons and input fields.
85    pub const MEDIUM: RadiusToken = RadiusToken("medium");
86    /// A large radius, for cards and dialogs.
87    pub const LARGE: RadiusToken = RadiusToken("large");
88}
89
90/// A color that is either a literal or a named theme token.
91///
92/// A literal paints the same under every theme. A token resolves against the
93/// theme that is current when the node paints, so switching the theme
94/// repaints it.
95#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
96pub enum ColorValue {
97    /// A fixed color.
98    Literal(Color),
99    /// A color looked up in the current theme.
100    Token(ColorToken),
101}
102
103impl From<Color> for ColorValue {
104    fn from(color: Color) -> ColorValue {
105        ColorValue::Literal(color)
106    }
107}
108
109impl From<ColorToken> for ColorValue {
110    fn from(token: ColorToken) -> ColorValue {
111        ColorValue::Token(token)
112    }
113}
114
115impl ColorValue {
116    /// Resolves the value to a color.
117    ///
118    /// A literal is itself. A token is looked up in `theme`.
119    ///
120    /// # Panics
121    ///
122    /// Panics in debug builds when the theme does not define the token.
123    /// A release build falls back to magenta, which shows the mistake on
124    /// screen.
125    pub fn resolve(&self, theme: &Theme) -> Color {
126        match self {
127            ColorValue::Literal(color) => *color,
128            ColorValue::Token(token) => theme.color(*token),
129        }
130    }
131}
132
133/// A corner radius that is either a literal or a named theme token.
134///
135/// A literal is the same under every theme. A token resolves against the
136/// theme that is current when the node paints, so switching the theme
137/// repaints it.
138#[derive(Copy, Clone, Debug, PartialEq)]
139pub enum RadiusValue {
140    /// A fixed radius in logical pixels.
141    Literal(f32),
142    /// A radius looked up in the current theme.
143    Token(RadiusToken),
144}
145
146impl From<f32> for RadiusValue {
147    fn from(radius: f32) -> RadiusValue {
148        RadiusValue::Literal(radius)
149    }
150}
151
152impl From<RadiusToken> for RadiusValue {
153    fn from(token: RadiusToken) -> RadiusValue {
154        RadiusValue::Token(token)
155    }
156}
157
158impl Default for RadiusValue {
159    /// A radius of zero, which gives square corners.
160    fn default() -> RadiusValue {
161        RadiusValue::Literal(0.0)
162    }
163}
164
165impl RadiusValue {
166    /// Resolves the value to a radius in logical pixels.
167    ///
168    /// A literal is itself. A token is looked up in `theme`.
169    ///
170    /// # Panics
171    ///
172    /// Panics in debug builds when the theme does not define the token.
173    /// A release build falls back to zero, which gives square corners.
174    pub fn resolve(&self, theme: &Theme) -> f32 {
175        match self {
176            RadiusValue::Literal(radius) => *radius,
177            RadiusValue::Token(token) => theme.radius(*token),
178        }
179    }
180}
181
182/// The values that theme tokens resolve against.
183///
184/// The maps are keyed by the token names, so an application can add its own
185/// tokens by inserting under names of its own. Built-in themes come from
186/// [`Theme::light`] and [`Theme::dark`].
187pub struct Theme {
188    /// The colors, keyed by [`ColorToken`] name.
189    pub colors: HashMap<&'static str, Color>,
190    /// The corner radii in logical pixels, keyed by [`RadiusToken`] name.
191    pub radii: HashMap<&'static str, f32>,
192    /// Whether a token lookup ran since the flag was last cleared. The paint
193    /// pass clears it before a widget paints and reads it after, so a node is
194    /// repainted on a theme switch exactly when its paint consults the theme.
195    consulted: Cell<bool>,
196}
197
198impl Clone for Theme {
199    fn clone(&self) -> Theme {
200        // The clone has not been consulted; only the theme a widget paints
201        // with earns the flag.
202        Theme {
203            colors: self.colors.clone(),
204            radii: self.radii.clone(),
205            consulted: Cell::new(false),
206        }
207    }
208}
209
210impl PartialEq for Theme {
211    fn eq(&self, other: &Theme) -> bool {
212        // Consultation says how the theme was used, not what it holds, so a
213        // theme switch ignores it.
214        self.colors == other.colors && self.radii == other.radii
215    }
216}
217
218impl std::fmt::Debug for Theme {
219    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220        f.debug_struct("Theme")
221            .field("colors", &self.colors)
222            .field("radii", &self.radii)
223            .finish()
224    }
225}
226
227impl Theme {
228    /// A light theme that defines every built-in token.
229    ///
230    /// ```
231    /// use smudge_core::theme::{ColorToken, Theme};
232    ///
233    /// let theme = Theme::light();
234    /// let surface = theme.color(ColorToken::SURFACE);
235    /// assert!(surface.is_opaque());
236    /// ```
237    pub fn light() -> Theme {
238        Theme::new(
239            [
240                (ColorToken::SURFACE, Color::WHITE),
241                (ColorToken::SURFACE_HOVER, Color::rgb(240, 240, 240)),
242                (ColorToken::SURFACE_PRESSED, Color::rgb(224, 224, 224)),
243                (ColorToken::TEXT, Color::rgb(26, 26, 26)),
244                (ColorToken::TEXT_MUTED, Color::rgb(102, 102, 102)),
245                (ColorToken::ACCENT, Color::rgb(0, 95, 204)),
246                (ColorToken::ACCENT_HOVER, Color::rgb(0, 84, 181)),
247                (ColorToken::ACCENT_PRESSED, Color::rgb(0, 71, 153)),
248                (ColorToken::ACCENT_TEXT, Color::WHITE),
249                (ColorToken::BORDER, Color::rgb(204, 204, 204)),
250                (ColorToken::FOCUS_RING, Color::rgb(0, 120, 215)),
251            ],
252            [
253                (RadiusToken::SMALL, 2.0),
254                (RadiusToken::MEDIUM, 4.0),
255                (RadiusToken::LARGE, 8.0),
256            ],
257        )
258    }
259
260    /// A dark theme that defines every built-in token.
261    pub fn dark() -> Theme {
262        Theme::new(
263            [
264                (ColorToken::SURFACE, Color::rgb(30, 30, 30)),
265                (ColorToken::SURFACE_HOVER, Color::rgb(42, 42, 42)),
266                (ColorToken::SURFACE_PRESSED, Color::rgb(56, 56, 56)),
267                (ColorToken::TEXT, Color::rgb(240, 240, 240)),
268                (ColorToken::TEXT_MUTED, Color::rgb(153, 153, 153)),
269                (ColorToken::ACCENT, Color::rgb(77, 163, 255)),
270                (ColorToken::ACCENT_HOVER, Color::rgb(110, 180, 255)),
271                (ColorToken::ACCENT_PRESSED, Color::rgb(140, 196, 255)),
272                (ColorToken::ACCENT_TEXT, Color::rgb(11, 11, 11)),
273                (ColorToken::BORDER, Color::rgb(68, 68, 68)),
274                (ColorToken::FOCUS_RING, Color::rgb(77, 163, 255)),
275            ],
276            [
277                (RadiusToken::SMALL, 2.0),
278                (RadiusToken::MEDIUM, 4.0),
279                (RadiusToken::LARGE, 8.0),
280            ],
281        )
282    }
283
284    /// Builds a theme from `colors` and `radii` in token order.
285    fn new<const C: usize, const R: usize>(
286        colors: [(ColorToken, Color); C],
287        radii: [(RadiusToken, f32); R],
288    ) -> Theme {
289        Theme {
290            colors: colors
291                .into_iter()
292                .map(|(token, color)| (token.0, color))
293                .collect(),
294            radii: radii
295                .into_iter()
296                .map(|(token, radius)| (token.0, radius))
297                .collect(),
298            consulted: Cell::new(false),
299        }
300    }
301
302    /// Returns the color `token` resolves to.
303    ///
304    /// # Panics
305    ///
306    /// Panics in debug builds when the theme does not define the token. A
307    /// release build falls back to magenta, which shows the mistake on
308    /// screen.
309    pub fn color(&self, token: ColorToken) -> Color {
310        self.consulted.set(true);
311        match self.colors.get(token.0) {
312            Some(color) => *color,
313            None => {
314                debug_assert!(
315                    false,
316                    "the theme defines no color token named {:?}",
317                    token.0
318                );
319                MISSING_COLOR
320            }
321        }
322    }
323
324    /// Returns the radius in logical pixels that `token` resolves to.
325    ///
326    /// # Panics
327    ///
328    /// Panics in debug builds when the theme does not define the token. A
329    /// release build falls back to zero, which gives square corners.
330    pub fn radius(&self, token: RadiusToken) -> f32 {
331        self.consulted.set(true);
332        match self.radii.get(token.0) {
333            Some(radius) => *radius,
334            None => {
335                debug_assert!(
336                    false,
337                    "the theme defines no radius token named {:?}",
338                    token.0
339                );
340                MISSING_RADIUS
341            }
342        }
343    }
344
345    /// Clears the consultation flag, before a widget paints.
346    pub(crate) fn reset_consulted(&self) {
347        self.consulted.set(false);
348    }
349
350    /// Returns whether a token lookup ran since the flag was last cleared.
351    pub(crate) fn consulted(&self) -> bool {
352        self.consulted.get()
353    }
354}
355
356#[cfg(test)]
357#[path = "theme_tests.rs"]
358mod tests;