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::{ColorTokens, LayoutTokens, MotionTokens, ShapeTokens, TypographyTokens};
29
30use crate::styles::component_style_slots::ComponentStyleSlots;
31use crate::styles::theme_appearance::ThemeAppearance;
32use crate::styles::theme_extension::ThemeExtensions;
33
34/// Stable identity for a [`Theme`], independent of its token values.
35///
36/// Two themes that share a `ThemeId` are "the same theme" even after a
37/// token tweak, and two distinct themes are always distinguishable even
38/// if they happen to share an appearance (Light/Dark). This is what lets
39/// UI like `ThemeSwitcher` reliably match the active theme back to a list
40/// entry, where `appearance` alone would be ambiguous.
41///
42/// Preset constructors stamp a `family.variant` id (e.g. `"intui.light"`,
43/// `"fluent.dark"`). OS-driven themes (follow-system / native) carry the
44/// id `"system"`. A theme built from raw tokens defaults to `"custom"`.
45#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
46pub struct ThemeId(Cow<'static, str>);
47
48impl ThemeId {
49    /// Construct from a static string (`ThemeId::new("intui.light")`) or an
50    /// owned `String` for app-supplied custom themes.
51    pub fn new(id: impl Into<Cow<'static, str>>) -> Self {
52        Self(id.into())
53    }
54
55    /// The id as a string slice.
56    pub fn as_str(&self) -> &str {
57        &self.0
58    }
59}
60
61impl Default for ThemeId {
62    fn default() -> Self {
63        Self(Cow::Borrowed("custom"))
64    }
65}
66
67impl std::fmt::Display for ThemeId {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        f.write_str(&self.0)
70    }
71}
72
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct Theme {
75    /// Stable identity of this theme — see [`ThemeId`]. Serde-defaulted so
76    /// older serialized themes (which predate the field) still deserialize.
77    #[serde(default)]
78    pub id: ThemeId,
79    pub appearance: ThemeAppearance,
80    pub colors: ColorTokens,
81    pub layout: LayoutTokens,
82    pub typography: TypographyTokens,
83    pub shape: ShapeTokens,
84    pub motion: MotionTokens,
85    /// Typed `Rc<dyn FooStyle>` slot bag for theme-wide style
86    /// installations. `None` per slot means "use the widget's local
87    /// `Recipe*Style` default"; apps install per-theme overrides via
88    /// `theme.style_slots.button = Some(Rc::new(MyButton))`. Per-call
89    /// `.style(...)` on a widget always wins over the slot.
90    #[serde(skip, default)]
91    pub style_slots: ComponentStyleSlots,
92    #[serde(skip, default)]
93    pub extensions: ThemeExtensions,
94}
95
96impl Theme {
97    /// Build a Theme from raw token data. Most apps go through a
98    /// preset constructor (e.g. `teksilo_core::presets::intui::light`)
99    /// rather than calling this directly — presets aggregate the
100    /// matching `Recipe*Style` defaults under the same call.
101    pub fn new(
102        appearance: ThemeAppearance,
103        colors: ColorTokens,
104        layout: LayoutTokens,
105        typography: TypographyTokens,
106        shape: ShapeTokens,
107        motion: MotionTokens,
108    ) -> Self {
109        Self {
110            id: ThemeId::default(),
111            appearance,
112            colors,
113            layout,
114            typography,
115            shape,
116            motion,
117            style_slots: ComponentStyleSlots::default(),
118            extensions: ThemeExtensions::new(),
119        }
120    }
121
122    /// Set this theme's [`ThemeId`] and return self for chaining. Used by
123    /// preset constructors and apps building custom themes.
124    pub fn with_id(mut self, id: impl Into<Cow<'static, str>>) -> Self {
125        self.id = ThemeId::new(id);
126        self
127    }
128
129    /// Whether this theme paints on a dark background. Convenience for
130    /// `theme.appearance.is_dark()`.
131    pub fn is_dark(&self) -> bool {
132        self.appearance.is_dark()
133    }
134
135    /// A copy of this theme projected for an **inactive window** — the accent
136    /// family and focus indicators desaturated toward graphite (see
137    /// [`ColorTokens::for_inactive_window`](teksilo_tokens::ColorTokens::for_inactive_window)).
138    /// The paint walker swaps this in when the host window loses focus, so every
139    /// accent-coloured control greys out with no per-widget code. Only the
140    /// colours change; typography / layout / shape / motion are untouched, so
141    /// this never affects layout.
142    pub fn for_inactive_window(&self) -> Theme {
143        Theme {
144            colors: self.colors.for_inactive_window(),
145            ..self.clone()
146        }
147    }
148
149    /// Project into a high-contrast variant (WCAG 1.4.6 Enhanced / EN 301 549
150    /// §11.7), applied at paint time when the OS "increase contrast" preference
151    /// is set. See
152    /// [`ColorTokens::for_high_contrast`](teksilo_tokens::ColorTokens::for_high_contrast).
153    pub fn for_high_contrast(&self) -> Theme {
154        Theme {
155            colors: self.colors.for_high_contrast(),
156            ..self.clone()
157        }
158    }
159
160    /// Look up a typed theme extension. See [`ThemeExtensions`].
161    pub fn extension<T: std::any::Any + Send + Sync>(&self) -> Option<&T> {
162        self.extensions.get::<T>()
163    }
164
165    /// Attach a typed extension and return self for chaining. See
166    /// [`ThemeExtensions`].
167    pub fn with_extension<T: std::any::Any + Send + Sync>(mut self, value: T) -> Self {
168        self.extensions.insert(value);
169        self
170    }
171}