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;