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/// 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}