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}