Skip to main content

cranpose_services/
theme.rs

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