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