Skip to main content

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/// Selects `default` only when no mode is active yet. Called at app start and after a hot reload so a
47/// selection restored by the rsx hot-reload bridge is not clobbered by the default.
48pub fn init_mode(default: impl Into<String>) {
49    let already_set = ACTIVE_MODE.with(|s| s.peek().is_some());
50    if !already_set {
51        set_mode(default);
52    }
53}
54
55/// Reactive read of the active mode id — subscribes the caller so a label re-renders on switch. `None` before
56/// any mode is set.
57pub fn use_mode() -> Option<String> {
58    ACTIVE_MODE.with(|s| s.get())
59}
60
61/// Non-reactive read of the active mode id, for the hot-reload snapshot bridge.
62pub fn active_mode() -> Option<String> {
63    ACTIVE_MODE.with(|s| s.peek())
64}
65
66thread_local! {
67    // The (light, dark) mode-id pair, so is_dark/set_dark/toggle_dark can switch schemes without the app
68    // hardcoding which registered modes are the light/dark ones. ManuallyDrop for the same dlclose-safety
69    // reason as MODES/ACTIVE_MODE above. None until set_light_dark is called.
70    static SCHEME_PAIR: ManuallyDrop<RefCell<Option<(String, String)>>> =
71        ManuallyDrop::new(RefCell::new(None));
72}
73
74/// Designates which two registered modes form the light/dark pair. A thin, optional convention over the open
75/// mode registry: it does not replace named modes (a third mode like `"pastel"` stays valid) — it only tells
76/// `is_dark`/`set_dark`/`toggle_dark` which ids to flip between. Both ids should also be registered via
77/// [`register_mode`]. Does not itself change the active mode.
78pub fn set_light_dark(light: impl Into<String>, dark: impl Into<String>) {
79    SCHEME_PAIR.with(|p| *p.borrow_mut() = Some((light.into(), dark.into())));
80}
81
82/// Reactive: `true` when the active mode is the designated dark mode. `false` when it is the light mode, no
83/// pair has been set, or a third (unpaired) mode is active. Read this for a sun/moon toggle's on/off state.
84pub fn is_dark() -> bool {
85    let active = use_mode();
86    SCHEME_PAIR.with(|p| {
87        p.borrow()
88            .as_ref()
89            .is_some_and(|(_, dark)| active.as_deref() == Some(dark.as_str()))
90    })
91}
92
93/// Selects the designated dark (`on = true`) or light (`on = false`) mode. No-op if no pair has been set.
94pub fn set_dark(on: bool) {
95    let target = SCHEME_PAIR.with(|p| {
96        p.borrow()
97            .as_ref()
98            .map(|(light, dark)| if on { dark.clone() } else { light.clone() })
99    });
100    if let Some(target) = target {
101        set_mode(target);
102    }
103}
104
105/// Flips between the designated light and dark modes. Reads the current scheme non-reactively so it is safe
106/// to call from an event handler.
107pub fn toggle_dark() {
108    let currently_dark = SCHEME_PAIR.with(|p| {
109        p.borrow()
110            .as_ref()
111            .is_some_and(|(_, dark)| active_mode().as_deref() == Some(dark.as_str()))
112    });
113    set_dark(!currently_dark);
114}
115
116thread_local! {
117    // OS light/dark preference, fed by set_system_dark from the platform layer and read reactively by the
118    // follow_system effect. ManuallyDrop for the same dlclose-safety reason as the signals above.
119    static SYSTEM_DARK: ManuallyDrop<RwSignal<bool>> = ManuallyDrop::new(signal(false));
120    // Keeps the follow_system effect alive for the app's lifetime; replaced (old dropped) on re-call, since a
121    // hot reload re-runs the app's setup.
122    static FOLLOW: ManuallyDrop<RefCell<Option<reactive_core::Effect>>> =
123        ManuallyDrop::new(RefCell::new(None));
124}
125
126/// Reports the OS light/dark preference into the reactive graph. Called by the runner at window creation and
127/// whenever the OS scheme changes; drives [`follow_system`].
128pub fn set_system_dark(dark: bool) {
129    SYSTEM_DARK.with(|s| s.set(dark));
130}
131
132/// Drives the active mode from the OS light/dark preference — light → `light`, dark → `dark` — updating live
133/// as the OS scheme changes. Installs a reactive effect (kept alive internally) and designates the pair so
134/// [`is_dark`]/[`toggle_dark`] stay consistent. Re-calling replaces the effect (hot reload re-runs setup). A
135/// manual [`set_mode`] still wins until the next OS change re-drives it.
136pub fn follow_system(light: impl Into<String>, dark: impl Into<String>) {
137    let light = light.into();
138    let dark = dark.into();
139    set_light_dark(light.clone(), dark.clone());
140    let eff = reactive_core::effect(move || {
141        let want = if SYSTEM_DARK.with(|s| s.get()) {
142            &dark
143        } else {
144            &light
145        };
146        set_mode(want.clone());
147    });
148    FOLLOW.with(|f| *f.borrow_mut() = Some(eff));
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use std::cell::Cell;
155
156    // Thread-locals persist across tests sharing a runner thread; each test resets to a known-empty state.
157    fn reset() {
158        ACTIVE_MODE.with(|s| s.set(None));
159        MODES.with(|m| m.borrow_mut().clear());
160        SCHEME_PAIR.with(|p| *p.borrow_mut() = None);
161        // Drop any prior follow_system effect first, so it stops reacting to SYSTEM_DARK in later tests.
162        FOLLOW.with(|f| *f.borrow_mut() = None);
163        SYSTEM_DARK.with(|s| s.set(false));
164    }
165
166    #[test]
167    fn set_mode_runs_apply_and_publishes_id() {
168        reset();
169        let hits = Rc::new(Cell::new(0));
170        let h = hits.clone();
171        register_mode("dark", move || h.set(h.get() + 1));
172        set_mode("dark");
173        assert_eq!(hits.get(), 1, "apply closure ran once");
174        assert_eq!(active_mode().as_deref(), Some("dark"));
175    }
176
177    #[test]
178    fn set_mode_publishes_even_without_registration() {
179        reset();
180        set_mode("unregistered");
181        assert_eq!(active_mode().as_deref(), Some("unregistered"));
182    }
183
184    #[test]
185    fn init_mode_does_not_clobber_existing_selection() {
186        reset();
187        set_mode("midnight");
188        init_mode("modern");
189        assert_eq!(
190            active_mode().as_deref(),
191            Some("midnight"),
192            "init must keep a selection already made (e.g. restored across hot reload)"
193        );
194    }
195
196    #[test]
197    fn init_mode_applies_default_when_empty() {
198        reset();
199        init_mode("modern");
200        assert_eq!(active_mode().as_deref(), Some("modern"));
201    }
202
203    #[test]
204    fn set_and_toggle_dark_switch_between_the_pair() {
205        reset();
206        register_mode("day", || {});
207        register_mode("night", || {});
208        set_light_dark("day", "night");
209
210        set_dark(true);
211        assert_eq!(active_mode().as_deref(), Some("night"));
212        set_dark(false);
213        assert_eq!(active_mode().as_deref(), Some("day"));
214
215        toggle_dark();
216        assert_eq!(active_mode().as_deref(), Some("night"));
217        toggle_dark();
218        assert_eq!(active_mode().as_deref(), Some("day"));
219    }
220
221    #[test]
222    fn follow_system_drives_mode_from_os_scheme() {
223        reset();
224        register_mode("day", || {});
225        register_mode("night", || {});
226        follow_system("day", "night");
227        assert_eq!(
228            active_mode().as_deref(),
229            Some("day"),
230            "effect runs once with default SYSTEM_DARK=false → light"
231        );
232        set_system_dark(true);
233        assert_eq!(active_mode().as_deref(), Some("night"));
234        set_system_dark(false);
235        assert_eq!(active_mode().as_deref(), Some("day"));
236    }
237
238    #[test]
239    fn is_dark_false_for_unpaired_third_mode() {
240        reset();
241        set_light_dark("day", "night");
242        set_mode("pastel");
243        assert!(
244            !is_dark(),
245            "a third mode outside the pair is neither dark nor light"
246        );
247    }
248
249    #[test]
250    fn dark_helpers_are_noops_without_a_pair() {
251        reset();
252        set_dark(true);
253        toggle_dark();
254        assert_eq!(active_mode(), None, "no pair set → nothing to switch to");
255    }
256
257    #[test]
258    fn use_mode_is_reactive() {
259        reset();
260        let seen = Rc::new(RefCell::new(Vec::<Option<String>>::new()));
261        let s = seen.clone();
262        let _e = reactive_core::effect(move || s.borrow_mut().push(use_mode()));
263        set_mode("a");
264        set_mode("b");
265        let got = seen.borrow().clone();
266        assert_eq!(
267            got,
268            vec![None, Some("a".into()), Some("b".into())],
269            "effect re-ran on each mode switch"
270        );
271    }
272}