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 multiple of it where its shape asks for
33    /// one (a pill is not a card).
34    fn radius(&self) -> f32 {
35        4.0
36    }
37    /// Base gap between adjacent things in px, and the unit a component derives its own padding from.
38    fn spacing(&self) -> f32 {
39        8.0
40    }
41    /// Base body text size in px. Every catalogue component scales its own text off this, so changing it scales
42    /// the whole type ramp.
43    fn font_size(&self) -> f32 {
44        14.0
45    }
46    /// Default size of a standalone icon in px.
47    fn icon_size(&self) -> f32 {
48        16.0
49    }
50
51    fn muted(&self) -> Color {
52        Color::rgba(0.5, 0.5, 0.6, 0.6)
53    }
54    fn scrollbar(&self) -> Color {
55        Color::rgba(0.5, 0.5, 0.6, 0.6)
56    }
57
58    /// Primary text ink for component labels/titles/values. Defaults to a near-black; a theme should override
59    /// it (e.g. a dark theme returns a light ink) so component text stays legible on its surface.
60    fn ink(&self) -> Color {
61        Color::rgba(0.15, 0.15, 0.2, 1.0)
62    }
63    /// The background a floating panel sits on — a menu, a dropdown, a dialog. Opaque by default, because the
64    /// thing it covers must not read through it.
65    fn surface(&self) -> Color {
66        Color::rgba(1.0, 1.0, 1.0, 1.0)
67    }
68    /// A quiet, low-contrast surface tone for chip/tag backgrounds. Defaults to a faint neutral wash.
69    fn surface_alt(&self) -> Color {
70        Color::rgba(0.5, 0.5, 0.55, 0.1)
71    }
72    /// Hairline border/divider tone. Defaults to a faint neutral.
73    fn border(&self) -> Color {
74        Color::rgba(0.5, 0.5, 0.55, 0.35)
75    }
76
77    /// Semantic status colours. Defaults are conventional hues; a theme should override to match its palette.
78    fn success(&self) -> Color {
79        Color::rgba(0.4, 0.7, 0.4, 1.0)
80    }
81    fn warning(&self) -> Color {
82        Color::rgba(0.9, 0.75, 0.4, 1.0)
83    }
84    fn error(&self) -> Color {
85        Color::rgba(0.8, 0.35, 0.4, 1.0)
86    }
87    fn info(&self) -> Color {
88        Color::rgba(0.4, 0.6, 0.8, 1.0)
89    }
90
91    /// Three progressively stronger highlight/elevation tints for hover, selection, and pressed states.
92    /// Defaults to faint neutral washes a theme can override with palette-specific tones.
93    fn highlight_low(&self) -> Color {
94        Color::rgba(0.5, 0.5, 0.55, 0.06)
95    }
96    fn highlight_med(&self) -> Color {
97        Color::rgba(0.5, 0.5, 0.55, 0.12)
98    }
99    fn highlight_high(&self) -> Color {
100        Color::rgba(0.5, 0.5, 0.55, 0.20)
101    }
102}
103
104thread_local! {
105    // 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).
106    static THEME: ManuallyDrop<RwSignal<Option<Rc<dyn Theme>>>> =
107        ManuallyDrop::new(signal(None));
108    static THEME_TOKENS: ManuallyDrop<RwSignal<Option<Rc<dyn ThemeTokens>>>> =
109        ManuallyDrop::new(signal(None));
110}
111
112pub fn set_theme<T: Theme + ThemeTokens + Clone + 'static>(theme: T) {
113    let theme = Rc::new(theme);
114    let as_theme: Rc<dyn Theme> = theme.clone();
115    let as_tokens: Rc<dyn ThemeTokens> = theme;
116    THEME.with(|s| s.set(Some(as_theme)));
117    THEME_TOKENS.with(|s| s.set(Some(as_tokens)));
118}
119
120pub fn use_theme<T: Theme + Clone + 'static>() -> T {
121    THEME.with(|s| {
122        let theme = s.get().unwrap_or_else(|| {
123            panic!(
124                "use_theme::<{}> called but no theme has been set; call set_theme first",
125                std::any::type_name::<T>()
126            )
127        });
128        theme
129            .as_any()
130            .downcast_ref::<T>()
131            .unwrap_or_else(|| {
132                panic!(
133                    "use_theme::<{}> called but a theme of a different type is set",
134                    std::any::type_name::<T>()
135                )
136            })
137            .clone()
138    })
139}
140
141pub fn use_theme_tokens() -> Option<Rc<dyn ThemeTokens>> {
142    THEME_TOKENS.with(|s| s.get())
143}