Skip to main content

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}