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 fn muted(&self) -> Color {
72 Color::rgba(0.5, 0.5, 0.6, 0.6)
73 }
74 fn scrollbar(&self) -> Color {
75 Color::rgba(0.5, 0.5, 0.6, 0.6)
76 }
77
78 /// Primary text ink for component labels/titles/values. A theme should override it, but the default
79 /// follows the active light/dark mode rather than assuming light: a theme that overrides `surface` and
80 /// forgets `ink` used to paint near-black text on its own dark panel.
81 fn ink(&self) -> Color {
82 if crate::mode::is_dark() {
83 Color::rgba(0.98, 0.98, 1.0, 1.0)
84 } else {
85 Color::rgba(0.15, 0.15, 0.2, 1.0)
86 }
87 }
88 /// The background a floating panel sits on — a menu, a dropdown, a dialog. Opaque by default, because the
89 /// thing it covers must not read through it, and mode-following for the same reason as [`ink`](Self::ink).
90 fn surface(&self) -> Color {
91 if crate::mode::is_dark() {
92 Color::rgba(0.09, 0.09, 0.11, 1.0)
93 } else {
94 Color::rgba(1.0, 1.0, 1.0, 1.0)
95 }
96 }
97 /// A quiet, low-contrast surface tone for chip/tag backgrounds. Defaults to a faint neutral wash.
98 fn surface_alt(&self) -> Color {
99 Color::rgba(0.5, 0.5, 0.55, 0.1)
100 }
101 /// Hairline border/divider tone. Defaults to a faint neutral.
102 fn border(&self) -> Color {
103 Color::rgba(0.5, 0.5, 0.55, 0.35)
104 }
105
106 /// Semantic status colours. Defaults are conventional hues; a theme should override to match its palette.
107 fn success(&self) -> Color {
108 Color::rgba(0.4, 0.7, 0.4, 1.0)
109 }
110 fn warning(&self) -> Color {
111 Color::rgba(0.9, 0.75, 0.4, 1.0)
112 }
113 fn error(&self) -> Color {
114 Color::rgba(0.8, 0.35, 0.4, 1.0)
115 }
116 fn info(&self) -> Color {
117 Color::rgba(0.4, 0.6, 0.8, 1.0)
118 }
119
120 /// Three progressively stronger highlight/elevation tints for hover, selection, and pressed states.
121 /// Defaults to faint neutral washes a theme can override with palette-specific tones.
122 fn highlight_low(&self) -> Color {
123 Color::rgba(0.5, 0.5, 0.55, 0.06)
124 }
125 fn highlight_med(&self) -> Color {
126 Color::rgba(0.5, 0.5, 0.55, 0.12)
127 }
128 fn highlight_high(&self) -> Color {
129 Color::rgba(0.5, 0.5, 0.55, 0.20)
130 }
131}
132
133thread_local! {
134 // 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).
135 static THEME: ManuallyDrop<RwSignal<Option<Rc<dyn Theme>>>> =
136 ManuallyDrop::new(signal(None));
137 static THEME_TOKENS: ManuallyDrop<RwSignal<Option<Rc<dyn ThemeTokens>>>> =
138 ManuallyDrop::new(signal(None));
139}
140
141pub fn set_theme<T: Theme + ThemeTokens + Clone + 'static>(theme: T) {
142 let theme = Rc::new(theme);
143 let as_theme: Rc<dyn Theme> = theme.clone();
144 let as_tokens: Rc<dyn ThemeTokens> = theme;
145 THEME.with(|s| s.set(Some(as_theme)));
146 THEME_TOKENS.with(|s| s.set(Some(as_tokens)));
147}
148
149pub fn use_theme<T: Theme + Clone + 'static>() -> T {
150 THEME.with(|s| {
151 let theme = s.get().unwrap_or_else(|| {
152 panic!(
153 "use_theme::<{}> called but no theme has been set; call set_theme first",
154 std::any::type_name::<T>()
155 )
156 });
157 theme
158 .as_any()
159 .downcast_ref::<T>()
160 .unwrap_or_else(|| {
161 panic!(
162 "use_theme::<{}> called but a theme of a different type is set",
163 std::any::type_name::<T>()
164 )
165 })
166 .clone()
167 })
168}
169
170pub fn use_theme_tokens() -> Option<Rc<dyn ThemeTokens>> {
171 THEME_TOKENS.with(|s| s.get())
172}