Skip to main content

cranpose_services/
theme.rs

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