Skip to main content

cranpose_services/
theme.rs

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