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 such a theme to re-run — every
216 /// `ComponentStyleSlots` slot is `None` in a raw-token theme and in the
217 /// IntUI preset, and each
218 /// widget builds its `Recipe*Style` lazily at its own build site, in
219 /// `teksilo-widgets`, from `ctx.theme().input`. Changing the tokens here is
220 /// therefore sufficient; the widgets read the new values on their next
221 /// build.
222 ///
223 /// A slot an app has installed itself is `Some(..)` and is preserved as it
224 /// is, so a custom Tier-3 style keeps whatever dimensions it was written
225 /// with. That is intentional (a hand-written style owns its own metrics)
226 /// but it means a custom style is not density-aware unless its author made
227 /// it so.
228 ///
229 /// A theme that carries a [`DensityProjection`] — every shipped preset that
230 /// installs slots — takes that function's answer instead, so its own chrome
231 /// does follow the density.
232 ///
233 /// Use `WidgetTree::set_input_density` rather than calling this and
234 /// `set_theme` by hand: a density change must rebuild, not merely relayout.
235 pub fn with_density(&self, density: TargetDensity) -> Theme {
236 if let Some(DensityProjection(project)) = self.extension::<DensityProjection>().copied() {
237 return project(self, density);
238 }
239 Theme {
240 input: InputTokens::for_density(density),
241 ..self.clone()
242 }
243 }
244
245 /// Look up a typed theme extension. See [`ThemeExtensions`].
246 pub fn extension<T: std::any::Any + Send + Sync>(&self) -> Option<&T> {
247 self.extensions.get::<T>()
248 }
249
250 /// Attach a typed extension and return self for chaining. See
251 /// [`ThemeExtensions`].
252 pub fn with_extension<T: std::any::Any + Send + Sync>(mut self, value: T) -> Self {
253 self.extensions.insert(value);
254 self
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use crate::presets::intui;
262 use teksilo_tokens::TargetDensity;
263
264 /// The whole point of `#[serde(default)]` on `input`: a theme serialized
265 /// before the field existed must still load. The fixture is built the only
266 /// way that cannot go stale — serialize a real theme, then delete the key.
267 #[test]
268 fn a_theme_without_an_input_key_still_deserializes() {
269 let mut value: serde_json::Value =
270 serde_json::to_value(intui::light()).expect("theme serializes");
271 assert!(
272 value
273 .as_object_mut()
274 .expect("theme is a JSON object")
275 .remove("input")
276 .is_some(),
277 "the fixture must actually have had an `input` key to remove"
278 );
279
280 let restored: Theme = serde_json::from_value(value).expect("pre-programme theme loads");
281 assert_eq!(restored.input, teksilo_tokens::InputTokens::default());
282 assert_eq!(restored.input.density, TargetDensity::Compact);
283 assert_eq!(restored.colors, intui::light().colors);
284 }
285
286 /// `with_density` swaps the token group and nothing else.
287 #[test]
288 fn with_density_projects_only_the_input_tokens() {
289 let base = intui::light();
290 let touch = base.with_density(TargetDensity::Touch);
291
292 assert_eq!(touch.input.density, TargetDensity::Touch);
293 assert_eq!(touch.input.target_size, 44.0);
294 // Everything else rides across verbatim.
295 assert_eq!(touch.id, base.id);
296 assert_eq!(touch.appearance, base.appearance);
297 assert_eq!(touch.colors, base.colors);
298 assert_eq!(touch.typography, base.typography);
299 assert_eq!(touch.shape, base.shape);
300 assert_eq!(touch.motion, base.motion);
301 }
302
303 /// A theme built by a preset is Compact, i.e. today's behaviour.
304 #[test]
305 fn presets_start_compact() {
306 assert_eq!(intui::light().input, teksilo_tokens::InputTokens::default());
307 assert_eq!(intui::dark().input.density, TargetDensity::Compact);
308 }
309
310 /// An app-installed Tier-3 style slot survives a density projection —
311 /// documented behaviour (a hand-written style owns its own metrics), not
312 /// an accident of `..self.clone()`.
313 #[test]
314 fn with_density_preserves_installed_style_slots() {
315 #[derive(Debug)]
316 struct TestButtonStyle;
317 impl crate::styles::ButtonStyle for TestButtonStyle {
318 fn make_body(
319 &self,
320 _cfg: &crate::styles::ButtonStyleConfig,
321 ctx: &mut crate::BuildContext,
322 ) -> crate::WidgetId {
323 ctx.add(crate::test_widgets::FillWidget::new())
324 }
325 }
326
327 let mut base = intui::light();
328 base.style_slots.button = Some(std::rc::Rc::new(TestButtonStyle));
329 let touch = base.with_density(TargetDensity::Touch);
330 assert!(touch.style_slots.button.is_some());
331 assert_eq!(touch.input.density, TargetDensity::Touch);
332 }
333}