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