telar_theme_core/mode.rs
1//! Named theme modes on top of the low-level [`crate::set_theme`] store.
2//!
3//! An app registers each variant once (`register_mode`) and switches by id (`set_mode`) instead of
4//! scattering `set_theme(...)` calls across the setup closure and every switch button. The
5//! active id lives in a reactive signal, so a label like `"Active · {mode}"` re-renders on switch without a
6//! hand-written memo, and the id is what the rsx crate bridges through hot-reload snapshot/restore so the
7//! selected variant survives a dylib swap.
8
9use std::cell::RefCell;
10use std::collections::HashMap;
11use std::mem::ManuallyDrop;
12use std::rc::Rc;
13
14use reactive_core::{RwSignal, signal};
15
16// Installs a concrete theme (typically via `set_theme`). Type-erased so variants of any concrete
17// theme type register under one string-keyed table.
18type ApplyMode = Rc<dyn Fn()>;
19
20thread_local! {
21 // ManuallyDrop mirrors THEME/WIDGET_THEME in context.rs: no TLS destructor is registered, so unmapping the
22 // dylib on dlclose stays safe. Cleanup happens via reset_runtime() dropping the whole Runtime.
23 static ACTIVE_MODE: ManuallyDrop<RwSignal<Option<String>>> = ManuallyDrop::new(signal(None));
24 static MODES: ManuallyDrop<RefCell<HashMap<String, ApplyMode>>> =
25 ManuallyDrop::new(RefCell::new(HashMap::new()));
26}
27
28/// Registers a named mode. `apply` installs the concrete theme when the mode is selected. Re-registering an
29/// id replaces its closure, which is expected: hot reload re-runs the app's setup and re-registers every mode.
30pub fn register_mode(id: impl Into<String>, apply: impl Fn() + 'static) {
31 MODES.with(|m| m.borrow_mut().insert(id.into(), Rc::new(apply)));
32}
33
34/// Selects a mode: runs its registered `apply` closure (if one is registered) and publishes the id to the
35/// reactive active-mode signal. Setting an unregistered id still updates the signal, so an app may drive the
36/// theme from its own effect on [`use_mode`] instead of registering closures.
37pub fn set_mode(id: impl Into<String>) {
38 let id = id.into();
39 let apply = MODES.with(|m| m.borrow().get(&id).cloned());
40 if let Some(apply) = apply {
41 apply();
42 }
43 ACTIVE_MODE.with(|s| s.set(Some(id)));
44}
45
46/// Reactive read of the active mode id — subscribes the caller so a label re-renders on switch. `None` before
47/// any mode is set.
48fn use_mode() -> Option<String> {
49 ACTIVE_MODE.with(|s| s.get())
50}
51
52/// Non-reactive read of the active mode id, for the hot-reload snapshot bridge.
53pub fn active_mode() -> Option<String> {
54 ACTIVE_MODE.with(|s| s.peek())
55}
56
57thread_local! {
58 // The (light, dark) mode-id pair, so is_dark can tell which registered mode is the dark one without the
59 // app hardcoding it. ManuallyDrop for the same dlclose-safety reason as MODES/ACTIVE_MODE above. None
60 // until set_light_dark is called.
61 static SCHEME_PAIR: ManuallyDrop<RefCell<Option<(String, String)>>> =
62 ManuallyDrop::new(RefCell::new(None));
63}
64
65/// Designates which two registered modes form the light/dark pair, so [`is_dark`] can tell which one is
66/// currently active. Called by [`follow_system`]; both ids should also be registered via [`register_mode`].
67/// Does not itself change the active mode.
68fn set_light_dark(light: impl Into<String>, dark: impl Into<String>) {
69 SCHEME_PAIR.with(|p| *p.borrow_mut() = Some((light.into(), dark.into())));
70}
71
72/// Reactive: `true` when the active mode is the designated dark mode. `false` when it is the light mode, no
73/// pair has been set, or a third (unpaired) mode is active. Backs [`ThemeTokens`](crate::ThemeTokens)'s
74/// mode-following `ink`/`surface` defaults.
75pub(crate) fn is_dark() -> bool {
76 let active = use_mode();
77 SCHEME_PAIR.with(|p| {
78 p.borrow()
79 .as_ref()
80 .is_some_and(|(_, dark)| active.as_deref() == Some(dark.as_str()))
81 })
82}
83
84thread_local! {
85 // OS light/dark preference, fed by set_system_dark from the platform layer and read reactively by the
86 // follow_system effect. ManuallyDrop for the same dlclose-safety reason as the signals above.
87 static SYSTEM_DARK: ManuallyDrop<RwSignal<bool>> = ManuallyDrop::new(signal(false));
88 // Keeps the follow_system effect alive for the app's lifetime; replaced (old dropped) on re-call, since a
89 // hot reload re-runs the app's setup.
90 static FOLLOW: ManuallyDrop<RefCell<Option<reactive_core::Effect>>> =
91 ManuallyDrop::new(RefCell::new(None));
92}
93
94/// Reports the OS light/dark preference into the reactive graph. Called by the runner at window creation and
95/// whenever the OS scheme changes; drives [`follow_system`].
96pub fn set_system_dark(dark: bool) {
97 SYSTEM_DARK.with(|s| s.set(dark));
98}
99
100/// Drives the active mode from the OS light/dark preference — light → `light`, dark → `dark` — updating live
101/// as the OS scheme changes. Installs a reactive effect (kept alive internally) and designates the pair so
102/// [`is_dark`] stays consistent. Re-calling replaces the effect (hot reload re-runs setup). A manual
103/// [`set_mode`] still wins until the next OS change re-drives it.
104pub fn follow_system(light: impl Into<String>, dark: impl Into<String>) {
105 let light = light.into();
106 let dark = dark.into();
107 set_light_dark(light.clone(), dark.clone());
108 let eff = reactive_core::effect(move || {
109 let want = if SYSTEM_DARK.with(|s| s.get()) {
110 &dark
111 } else {
112 &light
113 };
114 set_mode(want.clone());
115 });
116 FOLLOW.with(|f| *f.borrow_mut() = Some(eff));
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use std::cell::Cell;
123
124 // Thread-locals persist across tests sharing a runner thread; each test resets to a known-empty state.
125 fn reset() {
126 ACTIVE_MODE.with(|s| s.set(None));
127 MODES.with(|m| m.borrow_mut().clear());
128 SCHEME_PAIR.with(|p| *p.borrow_mut() = None);
129 // Drop any prior follow_system effect first, so it stops reacting to SYSTEM_DARK in later tests.
130 FOLLOW.with(|f| *f.borrow_mut() = None);
131 SYSTEM_DARK.with(|s| s.set(false));
132 }
133
134 #[test]
135 fn set_mode_runs_apply_and_publishes_id() {
136 reset();
137 let hits = Rc::new(Cell::new(0));
138 let h = hits.clone();
139 register_mode("dark", move || h.set(h.get() + 1));
140 set_mode("dark");
141 assert_eq!(hits.get(), 1, "apply closure ran once");
142 assert_eq!(active_mode().as_deref(), Some("dark"));
143 }
144
145 #[test]
146 fn set_mode_publishes_even_without_registration() {
147 reset();
148 set_mode("unregistered");
149 assert_eq!(active_mode().as_deref(), Some("unregistered"));
150 }
151
152 #[test]
153 fn follow_system_drives_mode_from_os_scheme() {
154 reset();
155 register_mode("day", || {});
156 register_mode("night", || {});
157 follow_system("day", "night");
158 assert_eq!(
159 active_mode().as_deref(),
160 Some("day"),
161 "effect runs once with default SYSTEM_DARK=false → light"
162 );
163 set_system_dark(true);
164 assert_eq!(active_mode().as_deref(), Some("night"));
165 set_system_dark(false);
166 assert_eq!(active_mode().as_deref(), Some("day"));
167 }
168
169 #[test]
170 fn is_dark_false_for_unpaired_third_mode() {
171 reset();
172 set_light_dark("day", "night");
173 set_mode("pastel");
174 assert!(
175 !is_dark(),
176 "a third mode outside the pair is neither dark nor light"
177 );
178 }
179
180 #[test]
181 fn use_mode_is_reactive() {
182 reset();
183 let seen = Rc::new(RefCell::new(Vec::<Option<String>>::new()));
184 let s = seen.clone();
185 let _e = reactive_core::effect(move || s.borrow_mut().push(use_mode()));
186 set_mode("a");
187 set_mode("b");
188 let got = seen.borrow().clone();
189 assert_eq!(
190 got,
191 vec![None, Some("a".into()), Some("b".into())],
192 "effect re-ran on each mode switch"
193 );
194 }
195}