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}