Skip to main content

cranpose_services/
theme.rs

1use cranpose_core::{CompositionLocal, CompositionLocalProvider, compositionLocalOf};
2use cranpose_macros::composable;
3
4#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5pub enum SystemTheme {
6    Light,
7    Dark,
8}
9
10/// Installs the platform-reported system theme. Platform backends call this at
11/// startup and whenever the OS reports a change (then force a root render so
12/// composition observes it).
13pub fn set_platform_system_theme(theme: SystemTheme) {
14    crate::thread_state::with(|state| state.platform_system_theme.set(Some(theme)));
15}
16
17/// Removes any platform-reported theme (tests and teardown).
18pub fn clear_platform_system_theme() {
19    crate::thread_state::with(|state| state.platform_system_theme.set(None));
20}
21
22pub fn default_system_theme() -> SystemTheme {
23    crate::thread_state::with(|state| {
24        if let Some(theme) = state.platform_system_theme.get() {
25            return theme;
26        }
27        if let Some(theme) = state.detected_system_theme.get() {
28            return theme;
29        }
30        let theme = detect_system_theme_uncached();
31        state.detected_system_theme.set(Some(theme));
32        theme
33    })
34}
35
36fn detect_system_theme_uncached() -> SystemTheme {
37    #[cfg(all(
38        not(target_arch = "wasm32"),
39        not(target_os = "android"),
40        not(target_os = "ios"),
41        feature = "system-theme"
42    ))]
43    {
44        detect_native_system_theme().unwrap_or(SystemTheme::Light)
45    }
46
47    #[cfg(all(target_arch = "wasm32", feature = "system-theme-web"))]
48    {
49        web_sys::window()
50            .and_then(|window| {
51                window
52                    .match_media("(prefers-color-scheme: dark)")
53                    .ok()
54                    .flatten()
55            })
56            .map_or(SystemTheme::Light, |query| {
57                if query.matches() {
58                    SystemTheme::Dark
59                } else {
60                    SystemTheme::Light
61                }
62            })
63    }
64
65    #[cfg(any(
66        target_os = "android",
67        target_os = "ios",
68        all(
69            not(target_arch = "wasm32"),
70            not(target_os = "android"),
71            not(target_os = "ios"),
72            not(feature = "system-theme")
73        ),
74        all(target_arch = "wasm32", not(feature = "system-theme-web"))
75    ))]
76    {
77        SystemTheme::Light
78    }
79}
80
81#[cfg(all(
82    not(target_arch = "wasm32"),
83    not(target_os = "android"),
84    not(target_os = "ios"),
85    feature = "system-theme"
86))]
87fn detect_native_system_theme() -> Option<SystemTheme> {
88    detect_env_theme().or_else(detect_platform_theme)
89}
90
91#[cfg(all(
92    not(target_arch = "wasm32"),
93    not(target_os = "android"),
94    not(target_os = "ios"),
95    feature = "system-theme"
96))]
97fn detect_env_theme() -> Option<SystemTheme> {
98    ["GTK_THEME", "QT_STYLE_OVERRIDE", "XDG_CURRENT_DESKTOP"]
99        .into_iter()
100        .filter_map(|key| std::env::var(key).ok())
101        .find_map(|value| theme_from_text(&value))
102}
103
104#[cfg(all(
105    target_os = "linux",
106    not(target_arch = "wasm32"),
107    feature = "system-theme"
108))]
109fn detect_platform_theme() -> Option<SystemTheme> {
110    command_stdout(
111        "gsettings",
112        &["get", "org.gnome.desktop.interface", "color-scheme"],
113    )
114    .and_then(|value| theme_from_text(&value))
115    .or_else(|| {
116        command_stdout(
117            "gsettings",
118            &["get", "org.gnome.desktop.interface", "gtk-theme"],
119        )
120        .and_then(|value| theme_from_text(&value))
121    })
122    .or_else(|| {
123        command_stdout(
124            "kreadconfig6",
125            &["--group", "General", "--key", "ColorScheme"],
126        )
127        .and_then(|value| theme_from_text(&value))
128    })
129    .or_else(|| {
130        command_stdout(
131            "kreadconfig5",
132            &["--group", "General", "--key", "ColorScheme"],
133        )
134        .and_then(|value| theme_from_text(&value))
135    })
136}
137
138#[cfg(all(target_os = "macos", feature = "system-theme"))]
139fn detect_platform_theme() -> Option<SystemTheme> {
140    command_stdout("defaults", &["read", "-g", "AppleInterfaceStyle"])
141        .and_then(|value| theme_from_text(&value))
142}
143
144#[cfg(all(target_os = "windows", feature = "system-theme"))]
145fn detect_platform_theme() -> Option<SystemTheme> {
146    command_stdout(
147        "reg",
148        &[
149            "query",
150            r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
151            "/v",
152            "AppsUseLightTheme",
153        ],
154    )
155    .and_then(|value| theme_from_windows_registry(&value))
156}
157
158#[cfg(all(
159    not(target_os = "linux"),
160    not(target_os = "macos"),
161    not(target_os = "windows"),
162    not(target_arch = "wasm32"),
163    not(target_os = "android"),
164    not(target_os = "ios"),
165    feature = "system-theme"
166))]
167fn detect_platform_theme() -> Option<SystemTheme> {
168    None
169}
170
171#[cfg(all(
172    not(target_arch = "wasm32"),
173    not(target_os = "android"),
174    not(target_os = "ios"),
175    feature = "system-theme"
176))]
177fn command_stdout(program: &str, args: &[&str]) -> Option<String> {
178    let output = crate::windowless_command(program)
179        .args(args)
180        .output()
181        .ok()?;
182    if !output.status.success() {
183        return None;
184    }
185    String::from_utf8(output.stdout).ok()
186}
187
188#[cfg(all(
189    not(target_arch = "wasm32"),
190    not(target_os = "android"),
191    not(target_os = "ios"),
192    feature = "system-theme"
193))]
194fn theme_from_text(value: &str) -> Option<SystemTheme> {
195    let value = value.to_ascii_lowercase();
196    if value.contains("dark") {
197        Some(SystemTheme::Dark)
198    } else if value.contains("light") || value.contains("default") {
199        Some(SystemTheme::Light)
200    } else {
201        None
202    }
203}
204
205#[cfg(all(target_os = "windows", feature = "system-theme"))]
206fn theme_from_windows_registry(value: &str) -> Option<SystemTheme> {
207    value.lines().find_map(|line| {
208        if !line.contains("AppsUseLightTheme") {
209            return None;
210        }
211        if line.contains("0x0") {
212            Some(SystemTheme::Dark)
213        } else if line.contains("0x1") {
214            Some(SystemTheme::Light)
215        } else {
216            None
217        }
218    })
219}
220
221pub fn local_system_theme() -> CompositionLocal<SystemTheme> {
222    crate::composition_locals::cached_local(
223        |locals| &locals.theme,
224        || compositionLocalOf(default_system_theme),
225    )
226}
227
228#[composable]
229pub fn ProvideSystemTheme(theme: SystemTheme, content: impl FnOnce()) {
230    let local = local_system_theme();
231    CompositionLocalProvider([local.provides(theme)], move || {
232        content();
233    });
234}
235
236#[composable]
237pub fn isSystemInDarkTheme() -> bool {
238    matches!(local_system_theme().current(), SystemTheme::Dark)
239}
240
241#[cfg(test)]
242#[path = "tests/theme_tests.rs"]
243mod tests;