telar_theme_core/context.rs
1use std::mem::ManuallyDrop;
2use std::rc::Rc;
3
4use geometry_core::Color;
5use reactive_core::{RwSignal, signal};
6
7// Flexible theme contract: users define their own tokens with whatever names they want. `as_any` is the only requirement so `use_theme` can downcast back to the concrete type.
8pub trait Theme: 'static {
9 fn as_any(&self) -> &dyn std::any::Any;
10}
11
12/// Opt-in semantic-token contract the built-in component catalogue reads through, so a component can resolve a
13/// token without knowing the concrete theme type.
14///
15/// **Every method carries a default**, which makes `impl ThemeTokens for MyTheme {}` valid and each token an
16/// independent opt-in: a theme answers the questions it cares about and lets the catalogue keep its own answer
17/// for the rest. This trait is deliberately not where a theme's vocabulary lives — that belongs to the theme's
18/// own type, reachable in full through [`use_theme`]. What is here is only the subset a component written
19/// without knowledge of that type has to be able to ask for.
20///
21/// The metric tokens are *bases*, not a size scale. A catalogue component derives its own proportions from
22/// [`font_size`](Self::font_size) rather than asking for a named role, because naming the roles would decide for
23/// every application which roles may exist. One number scales the type; the component keeps its own ratios.
24pub trait ThemeTokens: 'static {
25 fn primary(&self) -> Color {
26 Color::rgba(0.24, 0.47, 0.98, 1.0)
27 }
28 fn on_primary(&self) -> Color {
29 Color::rgba(1.0, 1.0, 1.0, 1.0)
30 }
31
32 /// Base corner radius in px. A component rounds by this, or by a step of the scale below where its shape
33 /// asks for one (a pill is not a card).
34 fn radius(&self) -> f32 {
35 4.0
36 }
37
38 /// The steps either side of [`radius`](Self::radius), so a theme owns **how round everything is** instead
39 /// of each component keeping its own literal.
40 ///
41 /// This is the axis an application actually restyles, and a scale of three steps derived from one base is
42 /// what a design system needs to be reachable from outside. A component that hardcodes
43 /// `BorderRadius::all(8.0)` is not themeable at all — the caller can change the base radius and watch
44 /// nothing move — and the fix is not a prop per component but a token they all read.
45 ///
46 /// A theme that wants a flat scale returns the same number from all three; one that wants a rounder
47 /// language moves the base and the steps follow.
48 fn radius_sm(&self) -> f32 {
49 self.radius() * 0.6
50 }
51 fn radius_md(&self) -> f32 {
52 self.radius() * 0.8
53 }
54 fn radius_lg(&self) -> f32 {
55 self.radius()
56 }
57 /// Base gap between adjacent things in px, and the unit a component derives its own padding from.
58 fn spacing(&self) -> f32 {
59 8.0
60 }
61 /// Base body text size in px. Every catalogue component scales its own text off this, so changing it scales
62 /// the whole type ramp.
63 fn font_size(&self) -> f32 {
64 14.0
65 }
66 /// Default size of a standalone icon in px.
67 fn icon_size(&self) -> f32 {
68 16.0
69 }
70
71 /// The steps either side of [`spacing`](Self::spacing), so a theme owns **how much air everything has**
72 /// instead of each component keeping its own literal.
73 ///
74 /// Note the base sits in the *middle* here, where the radius base is the largest step: "how round is the
75 /// biggest thing" and "what is the default gap" are different questions, and a scale that pretended
76 /// otherwise would make every component either cramped or airy the moment a theme moved one number.
77 ///
78 /// A theme that wants a flat rhythm returns the same number from all four; one that wants a roomier
79 /// language moves the base and the steps follow.
80 fn spacing_sm(&self) -> f32 {
81 self.spacing() * 0.5
82 }
83 fn spacing_md(&self) -> f32 {
84 self.spacing()
85 }
86 fn spacing_lg(&self) -> f32 {
87 self.spacing() * 1.5
88 }
89 fn spacing_xl(&self) -> f32 {
90 self.spacing() * 2.0
91 }
92
93 fn muted(&self) -> Color {
94 Color::rgba(0.5, 0.5, 0.6, 0.6)
95 }
96 fn scrollbar(&self) -> Color {
97 Color::rgba(0.5, 0.5, 0.6, 0.6)
98 }
99
100 /// Primary text ink for component labels/titles/values. A theme should override it, but the default
101 /// follows the active light/dark mode rather than assuming light: a theme that overrides `surface` and
102 /// forgets `ink` used to paint near-black text on its own dark panel.
103 fn ink(&self) -> Color {
104 if crate::mode::is_dark() {
105 Color::rgba(0.98, 0.98, 1.0, 1.0)
106 } else {
107 Color::rgba(0.15, 0.15, 0.2, 1.0)
108 }
109 }
110 /// The background a floating panel sits on — a menu, a dropdown, a dialog. Opaque by default, because the
111 /// thing it covers must not read through it, and mode-following for the same reason as [`ink`](Self::ink).
112 fn surface(&self) -> Color {
113 if crate::mode::is_dark() {
114 Color::rgba(0.09, 0.09, 0.11, 1.0)
115 } else {
116 Color::rgba(1.0, 1.0, 1.0, 1.0)
117 }
118 }
119 /// A quiet, low-contrast surface tone for chip/tag backgrounds. Defaults to a faint neutral wash.
120 fn surface_alt(&self) -> Color {
121 Color::rgba(0.5, 0.5, 0.55, 0.1)
122 }
123 /// Hairline border/divider tone. Defaults to a faint neutral.
124 fn border(&self) -> Color {
125 Color::rgba(0.5, 0.5, 0.55, 0.35)
126 }
127
128 /// Semantic status colours. Defaults are conventional hues; a theme should override to match its palette.
129 fn success(&self) -> Color {
130 Color::rgba(0.4, 0.7, 0.4, 1.0)
131 }
132 fn warning(&self) -> Color {
133 Color::rgba(0.9, 0.75, 0.4, 1.0)
134 }
135 fn error(&self) -> Color {
136 Color::rgba(0.8, 0.35, 0.4, 1.0)
137 }
138 fn info(&self) -> Color {
139 Color::rgba(0.4, 0.6, 0.8, 1.0)
140 }
141
142 /// Three progressively stronger highlight/elevation tints for hover, selection, and pressed states.
143 /// Defaults to faint neutral washes a theme can override with palette-specific tones.
144 fn highlight_low(&self) -> Color {
145 Color::rgba(0.5, 0.5, 0.55, 0.06)
146 }
147 fn highlight_med(&self) -> Color {
148 Color::rgba(0.5, 0.5, 0.55, 0.12)
149 }
150 fn highlight_high(&self) -> Color {
151 Color::rgba(0.5, 0.5, 0.55, 0.20)
152 }
153}
154
155thread_local! {
156 // ManuallyDrop suppresses RwSignal's Drop impl so no TLS destructor is registered. Cleanup happens via reset_runtime() which drops the entire Runtime (and its signals slab).
157 static THEME: ManuallyDrop<RwSignal<Option<Rc<dyn Theme>>>> =
158 ManuallyDrop::new(signal(None));
159 static THEME_TOKENS: ManuallyDrop<RwSignal<Option<Rc<dyn ThemeTokens>>>> =
160 ManuallyDrop::new(signal(None));
161}
162
163pub fn set_theme<T: Theme + ThemeTokens + Clone + 'static>(theme: T) {
164 let theme = Rc::new(theme);
165 let as_theme: Rc<dyn Theme> = theme.clone();
166 let as_tokens: Rc<dyn ThemeTokens> = theme;
167 THEME.with(|s| s.set(Some(as_theme)));
168 THEME_TOKENS.with(|s| s.set(Some(as_tokens)));
169}
170
171pub fn use_theme<T: Theme + Clone + 'static>() -> T {
172 THEME.with(|s| {
173 let theme = s.get().unwrap_or_else(|| {
174 panic!(
175 "use_theme::<{}> called but no theme has been set; call set_theme first",
176 std::any::type_name::<T>()
177 )
178 });
179 theme
180 .as_any()
181 .downcast_ref::<T>()
182 .unwrap_or_else(|| {
183 panic!(
184 "use_theme::<{}> called but a theme of a different type is set",
185 std::any::type_name::<T>()
186 )
187 })
188 .clone()
189 })
190}
191
192pub fn use_theme_tokens() -> Option<Rc<dyn ThemeTokens>> {
193 THEME_TOKENS.with(|s| s.get())
194}