Skip to main content

cranpose_services/
theme.rs

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