teksilo_core/styles/theme.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The complete theme aggregator.
5//!
6//! `Theme` lives in `teksilo-core` (not `teksilo-tokens`) so the per-widget
7//! style trait protocols and the typed `Rc<dyn FooStyle>` slots in
8//! [`ComponentStyleSlots`] can sit on the same struct without forcing a
9//! dependency cycle. See the `docs/styling-system.md` reference for the
10//! four-tier ladder this type anchors.
11//!
12//! Construct via a preset — there is no `Theme::default()` /
13//! `Theme::*_default()`. Apps explicitly pick one:
14//!
15//! ```
16//! use teksilo_core::presets::intui;
17//! let theme = intui::light();
18//! ```
19//!
20//! `appearance` is required and drives shadow density, OS-theme
21//! matching, and asset variant selection. `extensions` is a typed
22//! registry for app-attached extras; see [`ThemeExtensions`].
23
24use std::borrow::Cow;
25
26use serde::{Deserialize, Serialize};
27
28use teksilo_tokens::{
29 ColorTokens, InputTokens, LayoutTokens, MotionTokens, ShapeTokens, TargetDensity,
30 TypographyTokens,
31};
32
33use crate::styles::component_style_slots::ComponentStyleSlots;
34use crate::styles::theme_appearance::ThemeAppearance;
35use crate::styles::theme_extension::ThemeExtensions;
36
37/// Stable identity for a [`Theme`], independent of its token values.
38///
39/// Two themes that share a `ThemeId` are "the same theme" even after a
40/// token tweak, and two distinct themes are always distinguishable even
41/// if they happen to share an appearance (Light/Dark). This is what lets
42/// UI like `ThemeSwitcher` reliably match the active theme back to a list
43/// entry, where `appearance` alone would be ambiguous.
44///
45/// Preset constructors stamp a `family.variant` id (e.g. `"intui.light"`,
46/// `"fluent.dark"`). OS-driven themes (follow-system / native) carry the
47/// id `"system"`. A theme built from raw tokens defaults to `"custom"`.
48#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
49pub struct ThemeId(Cow<'static, str>);
50
51impl ThemeId {
52 /// Construct from a static string (`ThemeId::new("intui.light")`) or an
53 /// owned `String` for app-supplied custom themes.
54 pub fn new(id: impl Into<Cow<'static, str>>) -> Self {
55 Self(id.into())
56 }
57
58 /// The id as a string slice.
59 pub fn as_str(&self) -> &str {
60 &self.0
61 }
62}
63
64impl Default for ThemeId {
65 fn default() -> Self {
66 Self(Cow::Borrowed("custom"))
67 }
68}
69
70impl std::fmt::Display for ThemeId {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 f.write_str(&self.0)
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct Theme {
78 /// Stable identity of this theme — see [`ThemeId`]. Serde-defaulted so
79 /// older serialized themes (which predate the field) still deserialize.
80 #[serde(default)]
81 pub id: ThemeId,
82 pub appearance: ThemeAppearance,
83 pub colors: ColorTokens,
84 pub layout: LayoutTokens,
85 pub typography: TypographyTokens,
86 pub shape: ShapeTokens,
87 pub motion: MotionTokens,
88 /// Input and density tokens — target sizes, gesture slop, scroll physics
89 /// and the touch kill switch. See [`InputTokens`] and
90 /// `docs/density-and-targets.md`.
91 ///
92 /// Serde-defaulted for the same reason [`Theme::id`](Self::id) is: `Theme`
93 /// derives `Deserialize`, and a theme serialized before this field existed
94 /// must still load. The default is the Compact ladder — today's behaviour.
95 #[serde(default)]
96 pub input: InputTokens,
97 /// Typed `Rc<dyn FooStyle>` slot bag for theme-wide style
98 /// installations. `None` per slot means "use the widget's local
99 /// `Recipe*Style` default"; apps install per-theme overrides via
100 /// `theme.style_slots.button = Some(Rc::new(MyButton))`. Per-call
101 /// `.style(...)` on a widget always wins over the slot.
102 #[serde(skip, default)]
103 pub style_slots: ComponentStyleSlots,
104 #[serde(skip, default)]
105 pub extensions: ThemeExtensions,
106}
107
108/// How a theme re-derives itself for another [`TargetDensity`], registered as
109/// a [`ThemeExtensions`] entry.
110///
111/// Absent — the default, and what every raw-token theme gets — means
112/// [`Theme::with_density`] swaps [`Theme::input`] and changes nothing else.
113/// That is right for a theme whose `style_slots` are all `None`, because each
114/// widget then builds its own `Recipe*Style` from `ctx.theme().input` at its
115/// next build.
116///
117/// A **preset that installs Tier-3 slots of its own** (Fluent, macOS,
118/// Material 3) needs more: its slots are `Some(..)`, so they would ride across
119/// a density switch still carrying the dimensions they were built with, and a
120/// Fluent button would stay 32 dp tall under Touch. Such a preset registers
121/// this — a function taking the *current* theme, so it can recover the palette
122/// it was built from (`theme.extension::<FluentPalette>()`) and rebuild only
123/// the slots it owns, leaving colours, id, other extensions and any slot the
124/// app installed itself alone.
125///
126/// It lives in the extension registry rather than as a `Theme` field for the
127/// reason the registry exists: it is optional, typed, non-serializable state
128/// that only some themes carry.
129#[derive(Clone, Copy)]
130pub struct DensityProjection(pub fn(&Theme, TargetDensity) -> Theme);
131
132impl std::fmt::Debug for DensityProjection {
133 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134 f.write_str("DensityProjection(<fn>)")
135 }
136}
137
138impl Theme {
139 /// Build a Theme from raw token data. Most apps go through a
140 /// preset constructor (e.g. `teksilo_core::presets::intui::light`)
141 /// rather than calling this directly — presets aggregate the
142 /// matching `Recipe*Style` defaults under the same call.
143 pub fn new(
144 appearance: ThemeAppearance,
145 colors: ColorTokens,
146 layout: LayoutTokens,
147 typography: TypographyTokens,
148 shape: ShapeTokens,
149 motion: MotionTokens,
150 input: InputTokens,
151 ) -> Self {
152 Self {
153 id: ThemeId::default(),
154 appearance,
155 colors,
156 layout,
157 typography,
158 shape,
159 motion,
160 input,
161 style_slots: ComponentStyleSlots::default(),
162 extensions: ThemeExtensions::new(),
163 }
164 }
165
166 /// Register how this theme re-derives itself for another density. Preset
167 /// constructors call this; see [`DensityProjection`].
168 pub fn with_density_projection(self, project: fn(&Theme, TargetDensity) -> Theme) -> Self {
169 self.with_extension(DensityProjection(project))
170 }
171
172 /// Set this theme's [`ThemeId`] and return self for chaining. Used by
173 /// preset constructors and apps building custom themes.
174 pub fn with_id(mut self, id: impl Into<Cow<'static, str>>) -> Self {
175 self.id = ThemeId::new(id);
176 self
177 }
178
179 /// Whether this theme paints on a dark background. Convenience for
180 /// `theme.appearance.is_dark()`.
181 pub fn is_dark(&self) -> bool {
182 self.appearance.is_dark()
183 }
184
185 /// A copy of this theme projected for an **inactive window** — the accent
186 /// family and focus indicators desaturated toward graphite (see
187 /// [`ColorTokens::for_inactive_window`](teksilo_tokens::ColorTokens::for_inactive_window)).
188 /// The paint walker swaps this in when the host window loses focus, so every
189 /// accent-coloured control greys out with no per-widget code. Only the
190 /// colours change; typography / layout / shape / motion are untouched, so
191 /// this never affects layout.
192 pub fn for_inactive_window(&self) -> Theme {
193 Theme {
194 colors: self.colors.for_inactive_window(),
195 ..self.clone()
196 }
197 }
198
199 /// Project into a high-contrast variant (WCAG 1.4.6 Enhanced / EN 301 549
200 /// §11.7), applied at paint time when the OS "increase contrast" preference
201 /// is set. See
202 /// [`ColorTokens::for_high_contrast`](teksilo_tokens::ColorTokens::for_high_contrast).
203 pub fn for_high_contrast(&self) -> Theme {
204 Theme {
205 colors: self.colors.for_high_contrast(),
206 ..self.clone()
207 }
208 }
209
210 /// A copy of this theme projected onto another [`TargetDensity`].
211 ///
212 /// This is a **token projection only**: it replaces [`Self::input`] with
213 /// [`InputTokens::for_density`] and carries `style_slots` and `extensions`
214 /// across verbatim. It deliberately does *not* re-run any recipe
215 /// constructor, because there is nothing in a theme to re-run — every
216 /// `ComponentStyleSlots` slot is `None` in the shipped presets, and each
217 /// widget builds its `Recipe*Style` lazily at its own build site, in
218 /// `teksilo-widgets`, from `ctx.theme().input`. Changing the tokens here is
219 /// therefore sufficient; the widgets read the new values on their next
220 /// build.
221 ///
222 /// A slot an app has installed itself is `Some(..)` and is preserved as it
223 /// is, so a custom Tier-3 style keeps whatever dimensions it was written
224 /// with. That is intentional (a hand-written style owns its own metrics)
225 /// but it means a custom style is not density-aware unless its author made
226 /// it so.
227 ///
228 /// A theme that carries a [`DensityProjection`] — every shipped preset that
229 /// installs slots — takes that function's answer instead, so its own chrome
230 /// does follow the density.
231 ///
232 /// Use `WidgetTree::set_input_density` rather than calling this and
233 /// `set_theme` by hand: a density change must rebuild, not merely relayout.
234 pub fn with_density(&self, density: TargetDensity) -> Theme {
235 if let Some(DensityProjection(project)) = self.extension::<DensityProjection>().copied() {
236 return project(self, density);
237 }
238 Theme {
239 input: InputTokens::for_density(density),
240 ..self.clone()
241 }
242 }
243
244 /// Look up a typed theme extension. See [`ThemeExtensions`].
245 pub fn extension<T: std::any::Any + Send + Sync>(&self) -> Option<&T> {
246 self.extensions.get::<T>()
247 }
248
249 /// Attach a typed extension and return self for chaining. See
250 /// [`ThemeExtensions`].
251 pub fn with_extension<T: std::any::Any + Send + Sync>(mut self, value: T) -> Self {
252 self.extensions.insert(value);
253 self
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use crate::presets::intui;
261 use teksilo_tokens::TargetDensity;
262
263 /// The whole point of `#[serde(default)]` on `input`: a theme serialized
264 /// before the field existed must still load. The fixture is built the only
265 /// way that cannot go stale — serialize a real theme, then delete the key.
266 #[test]
267 fn a_theme_without_an_input_key_still_deserializes() {
268 let mut value: serde_json::Value =
269 serde_json::to_value(intui::light()).expect("theme serializes");
270 assert!(
271 value
272 .as_object_mut()
273 .expect("theme is a JSON object")
274 .remove("input")
275 .is_some(),
276 "the fixture must actually have had an `input` key to remove"
277 );
278
279 let restored: Theme = serde_json::from_value(value).expect("pre-programme theme loads");
280 assert_eq!(restored.input, teksilo_tokens::InputTokens::default());
281 assert_eq!(restored.input.density, TargetDensity::Compact);
282 assert_eq!(restored.colors, intui::light().colors);
283 }
284
285 /// `with_density` swaps the token group and nothing else.
286 #[test]
287 fn with_density_projects_only_the_input_tokens() {
288 let base = intui::light();
289 let touch = base.with_density(TargetDensity::Touch);
290
291 assert_eq!(touch.input.density, TargetDensity::Touch);
292 assert_eq!(touch.input.target_size, 44.0);
293 // Everything else rides across verbatim.
294 assert_eq!(touch.id, base.id);
295 assert_eq!(touch.appearance, base.appearance);
296 assert_eq!(touch.colors, base.colors);
297 assert_eq!(touch.typography, base.typography);
298 assert_eq!(touch.shape, base.shape);
299 assert_eq!(touch.motion, base.motion);
300 }
301
302 /// A theme built by a preset is Compact, i.e. today's behaviour.
303 #[test]
304 fn presets_start_compact() {
305 assert_eq!(intui::light().input, teksilo_tokens::InputTokens::default());
306 assert_eq!(intui::dark().input.density, TargetDensity::Compact);
307 }
308
309 /// An app-installed Tier-3 style slot survives a density projection —
310 /// documented behaviour (a hand-written style owns its own metrics), not
311 /// an accident of `..self.clone()`.
312 #[test]
313 fn with_density_preserves_installed_style_slots() {
314 #[derive(Debug)]
315 struct TestButtonStyle;
316 impl crate::styles::ButtonStyle for TestButtonStyle {
317 fn make_body(
318 &self,
319 _cfg: &crate::styles::ButtonStyleConfig,
320 ctx: &mut crate::BuildContext,
321 ) -> crate::WidgetId {
322 ctx.add(crate::test_widgets::FillWidget::new())
323 }
324 }
325
326 let mut base = intui::light();
327 base.style_slots.button = Some(std::rc::Rc::new(TestButtonStyle));
328 let touch = base.with_density(TargetDensity::Touch);
329 assert!(touch.style_slots.button.is_some());
330 assert_eq!(touch.input.density, TargetDensity::Touch);
331 }
332}