Skip to main content

cranpose_services/
theme.rs

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