Skip to main content

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}