Skip to main content

cranpose_services/
theme.rs

1use std::cell::{Cell, RefCell};
2
3use cranpose_core::{CompositionLocal, CompositionLocalProvider, compositionLocalOf};
4use cranpose_macros::composable;
5
6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7pub enum SystemTheme {
8    Light,
9    Dark,
10}
11
12thread_local! {
13    static PLATFORM_SYSTEM_THEME: Cell<Option<SystemTheme>> = const { Cell::new(None) };
14}
15
16/// Installs the platform-reported system theme. Platform backends call this at
17/// startup and whenever the OS reports a change (then force a root render so
18/// composition observes it).
19pub fn set_platform_system_theme(theme: SystemTheme) {
20    PLATFORM_SYSTEM_THEME.with(|cell| cell.set(Some(theme)));
21}
22
23/// Removes any platform-reported theme (tests and teardown).
24pub fn clear_platform_system_theme() {
25    PLATFORM_SYSTEM_THEME.with(|cell| cell.set(None));
26}
27
28pub fn default_system_theme() -> SystemTheme {
29    if let Some(theme) = PLATFORM_SYSTEM_THEME.with(Cell::get) {
30        return theme;
31    }
32    detected_system_theme()
33}
34
35fn detected_system_theme() -> SystemTheme {
36    thread_local! {
37        static DETECTED: Cell<Option<SystemTheme>> = const { Cell::new(None) };
38    }
39    DETECTED.with(|cell| {
40        if let Some(theme) = cell.get() {
41            return theme;
42        }
43        let theme = detect_system_theme_uncached();
44        cell.set(Some(theme));
45        theme
46    })
47}
48
49fn detect_system_theme_uncached() -> SystemTheme {
50    #[cfg(all(
51        not(target_arch = "wasm32"),
52        not(target_os = "android"),
53        not(target_os = "ios"),
54        feature = "system-theme"
55    ))]
56    {
57        detect_native_system_theme().unwrap_or(SystemTheme::Light)
58    }
59
60    #[cfg(all(target_arch = "wasm32", feature = "system-theme-web"))]
61    {
62        web_sys::window()
63            .and_then(|window| {
64                window
65                    .match_media("(prefers-color-scheme: dark)")
66                    .ok()
67                    .flatten()
68            })
69            .map_or(SystemTheme::Light, |query| {
70                if query.matches() {
71                    SystemTheme::Dark
72                } else {
73                    SystemTheme::Light
74                }
75            })
76    }
77
78    #[cfg(any(
79        target_os = "android",
80        target_os = "ios",
81        all(
82            not(target_arch = "wasm32"),
83            not(target_os = "android"),
84            not(target_os = "ios"),
85            not(feature = "system-theme")
86        ),
87        all(target_arch = "wasm32", not(feature = "system-theme-web"))
88    ))]
89    {
90        SystemTheme::Light
91    }
92}
93
94#[cfg(all(
95    not(target_arch = "wasm32"),
96    not(target_os = "android"),
97    not(target_os = "ios"),
98    feature = "system-theme"
99))]
100fn detect_native_system_theme() -> Option<SystemTheme> {
101    detect_env_theme().or_else(detect_platform_theme)
102}
103
104#[cfg(all(
105    not(target_arch = "wasm32"),
106    not(target_os = "android"),
107    not(target_os = "ios"),
108    feature = "system-theme"
109))]
110fn detect_env_theme() -> Option<SystemTheme> {
111    ["GTK_THEME", "QT_STYLE_OVERRIDE", "XDG_CURRENT_DESKTOP"]
112        .into_iter()
113        .filter_map(|key| std::env::var(key).ok())
114        .find_map(|value| theme_from_text(&value))
115}
116
117#[cfg(all(
118    target_os = "linux",
119    not(target_arch = "wasm32"),
120    feature = "system-theme"
121))]
122fn detect_platform_theme() -> Option<SystemTheme> {
123    command_stdout(
124        "gsettings",
125        &["get", "org.gnome.desktop.interface", "color-scheme"],
126    )
127    .and_then(|value| theme_from_text(&value))
128    .or_else(|| {
129        command_stdout(
130            "gsettings",
131            &["get", "org.gnome.desktop.interface", "gtk-theme"],
132        )
133        .and_then(|value| theme_from_text(&value))
134    })
135    .or_else(|| {
136        command_stdout(
137            "kreadconfig6",
138            &["--group", "General", "--key", "ColorScheme"],
139        )
140        .and_then(|value| theme_from_text(&value))
141    })
142    .or_else(|| {
143        command_stdout(
144            "kreadconfig5",
145            &["--group", "General", "--key", "ColorScheme"],
146        )
147        .and_then(|value| theme_from_text(&value))
148    })
149}
150
151#[cfg(all(target_os = "macos", feature = "system-theme"))]
152fn detect_platform_theme() -> Option<SystemTheme> {
153    command_stdout("defaults", &["read", "-g", "AppleInterfaceStyle"])
154        .and_then(|value| theme_from_text(&value))
155}
156
157#[cfg(all(target_os = "windows", feature = "system-theme"))]
158fn detect_platform_theme() -> Option<SystemTheme> {
159    command_stdout(
160        "reg",
161        &[
162            "query",
163            r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
164            "/v",
165            "AppsUseLightTheme",
166        ],
167    )
168    .and_then(|value| theme_from_windows_registry(&value))
169}
170
171#[cfg(all(
172    not(target_os = "linux"),
173    not(target_os = "macos"),
174    not(target_os = "windows"),
175    not(target_arch = "wasm32"),
176    not(target_os = "android"),
177    not(target_os = "ios"),
178    feature = "system-theme"
179))]
180fn detect_platform_theme() -> Option<SystemTheme> {
181    None
182}
183
184#[cfg(all(
185    not(target_arch = "wasm32"),
186    not(target_os = "android"),
187    not(target_os = "ios"),
188    feature = "system-theme"
189))]
190fn command_stdout(program: &str, args: &[&str]) -> Option<String> {
191    let output = crate::windowless_command(program)
192        .args(args)
193        .output()
194        .ok()?;
195    if !output.status.success() {
196        return None;
197    }
198    String::from_utf8(output.stdout).ok()
199}
200
201#[cfg(all(
202    not(target_arch = "wasm32"),
203    not(target_os = "android"),
204    not(target_os = "ios"),
205    feature = "system-theme"
206))]
207fn theme_from_text(value: &str) -> Option<SystemTheme> {
208    let value = value.to_ascii_lowercase();
209    if value.contains("dark") {
210        Some(SystemTheme::Dark)
211    } else if value.contains("light") || value.contains("default") {
212        Some(SystemTheme::Light)
213    } else {
214        None
215    }
216}
217
218#[cfg(all(target_os = "windows", feature = "system-theme"))]
219fn theme_from_windows_registry(value: &str) -> Option<SystemTheme> {
220    value.lines().find_map(|line| {
221        if !line.contains("AppsUseLightTheme") {
222            return None;
223        }
224        if line.contains("0x0") {
225            Some(SystemTheme::Dark)
226        } else if line.contains("0x1") {
227            Some(SystemTheme::Light)
228        } else {
229            None
230        }
231    })
232}
233
234pub fn local_system_theme() -> CompositionLocal<SystemTheme> {
235    thread_local! {
236        static LOCAL_SYSTEM_THEME: RefCell<Option<CompositionLocal<SystemTheme>>> = const { RefCell::new(None) };
237    }
238
239    LOCAL_SYSTEM_THEME.with(|cell| {
240        let mut local = cell.borrow_mut();
241        local
242            .get_or_insert_with(|| compositionLocalOf(default_system_theme))
243            .clone()
244    })
245}
246
247#[composable]
248pub fn ProvideSystemTheme(theme: SystemTheme, content: impl FnOnce()) {
249    let local = local_system_theme();
250    CompositionLocalProvider(vec![local.provides(theme)], move || {
251        content();
252    });
253}
254
255#[composable]
256pub fn isSystemInDarkTheme() -> bool {
257    matches!(local_system_theme().current(), SystemTheme::Dark)
258}
259
260#[cfg(test)]
261mod tests {
262    use std::{cell::RefCell, rc::Rc};
263
264    use cranpose_core::CompositionLocalProvider;
265
266    use super::*;
267    use crate::run_test_composition;
268
269    #[test]
270    fn default_system_theme_returns_supported_variant() {
271        assert!(matches!(
272            default_system_theme(),
273            SystemTheme::Light | SystemTheme::Dark
274        ));
275    }
276
277    #[test]
278    fn platform_pushed_theme_wins_over_detection() {
279        clear_platform_system_theme();
280        set_platform_system_theme(SystemTheme::Dark);
281        assert_eq!(default_system_theme(), SystemTheme::Dark);
282        set_platform_system_theme(SystemTheme::Light);
283        assert_eq!(default_system_theme(), SystemTheme::Light);
284        clear_platform_system_theme();
285    }
286
287    #[test]
288    fn local_system_theme_can_be_overridden() {
289        let local = local_system_theme();
290        let captured = Rc::new(RefCell::new(None));
291
292        {
293            let captured = Rc::clone(&captured);
294            let local_for_provider = local.clone();
295            run_test_composition(move || {
296                let captured = Rc::clone(&captured);
297                let local = local.clone();
298                CompositionLocalProvider(
299                    vec![local_for_provider.provides(SystemTheme::Dark)],
300                    move || {
301                        *captured.borrow_mut() = Some(local.current());
302                    },
303                );
304            });
305        }
306
307        assert_eq!(*captured.borrow(), Some(SystemTheme::Dark));
308    }
309
310    #[test]
311    fn provide_system_theme_sets_current_theme() {
312        let local = local_system_theme();
313        let captured = Rc::new(RefCell::new(None));
314
315        {
316            let captured = Rc::clone(&captured);
317            run_test_composition(move || {
318                let captured = Rc::clone(&captured);
319                let local = local.clone();
320                ProvideSystemTheme(SystemTheme::Dark, move || {
321                    *captured.borrow_mut() = Some(local.current());
322                });
323            });
324        }
325
326        assert_eq!(*captured.borrow(), Some(SystemTheme::Dark));
327    }
328
329    #[test]
330    fn is_system_in_dark_theme_reads_current_theme() {
331        let captured = Rc::new(RefCell::new(None));
332
333        {
334            let captured = Rc::clone(&captured);
335            run_test_composition(move || {
336                let captured = Rc::clone(&captured);
337                ProvideSystemTheme(SystemTheme::Dark, move || {
338                    *captured.borrow_mut() = Some(isSystemInDarkTheme());
339                });
340            });
341        }
342
343        assert_eq!(*captured.borrow(), Some(true));
344    }
345
346    #[cfg(all(
347        not(target_arch = "wasm32"),
348        not(target_os = "android"),
349        not(target_os = "ios"),
350        feature = "system-theme"
351    ))]
352    #[test]
353    fn theme_from_text_reads_common_native_values() {
354        assert_eq!(theme_from_text("'prefer-dark'"), Some(SystemTheme::Dark));
355        assert_eq!(theme_from_text("Breeze Light"), Some(SystemTheme::Light));
356        assert_eq!(theme_from_text("Adwaita"), None);
357    }
358}