1use std::cell::{Cell, RefCell};
2#[cfg(all(
3 not(target_arch = "wasm32"),
4 not(target_os = "android"),
5 not(target_os = "ios"),
6 feature = "system-theme"
7))]
8use std::process::Command;
9
10use cranpose_core::{CompositionLocal, CompositionLocalProvider, compositionLocalOf};
11use cranpose_macros::composable;
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 std::{cell::RefCell, rc::Rc};
275
276 use cranpose_core::CompositionLocalProvider;
277
278 use super::*;
279 use crate::run_test_composition;
280
281 #[test]
282 fn default_system_theme_returns_supported_variant() {
283 assert!(matches!(
284 default_system_theme(),
285 SystemTheme::Light | SystemTheme::Dark
286 ));
287 }
288
289 #[test]
290 fn platform_pushed_theme_wins_over_detection() {
291 clear_platform_system_theme();
292 set_platform_system_theme(SystemTheme::Dark);
293 assert_eq!(default_system_theme(), SystemTheme::Dark);
294 set_platform_system_theme(SystemTheme::Light);
295 assert_eq!(default_system_theme(), SystemTheme::Light);
296 clear_platform_system_theme();
297 }
298
299 #[test]
300 fn local_system_theme_can_be_overridden() {
301 let local = local_system_theme();
302 let captured = Rc::new(RefCell::new(None));
303
304 {
305 let captured = Rc::clone(&captured);
306 let local_for_provider = local.clone();
307 let local_for_read = local.clone();
308 run_test_composition(move || {
309 let captured = Rc::clone(&captured);
310 let local_for_read = local_for_read.clone();
311 CompositionLocalProvider(
312 vec![local_for_provider.provides(SystemTheme::Dark)],
313 move || {
314 *captured.borrow_mut() = Some(local_for_read.current());
315 },
316 );
317 });
318 }
319
320 assert_eq!(*captured.borrow(), Some(SystemTheme::Dark));
321 }
322
323 #[test]
324 fn provide_system_theme_sets_current_theme() {
325 let local = local_system_theme();
326 let captured = Rc::new(RefCell::new(None));
327
328 {
329 let captured = Rc::clone(&captured);
330 let local = local.clone();
331 run_test_composition(move || {
332 let captured = Rc::clone(&captured);
333 let local = local.clone();
334 ProvideSystemTheme(SystemTheme::Dark, move || {
335 *captured.borrow_mut() = Some(local.current());
336 });
337 });
338 }
339
340 assert_eq!(*captured.borrow(), Some(SystemTheme::Dark));
341 }
342
343 #[test]
344 fn is_system_in_dark_theme_reads_current_theme() {
345 let captured = Rc::new(RefCell::new(None));
346
347 {
348 let captured = Rc::clone(&captured);
349 run_test_composition(move || {
350 let captured = Rc::clone(&captured);
351 ProvideSystemTheme(SystemTheme::Dark, move || {
352 *captured.borrow_mut() = Some(isSystemInDarkTheme());
353 });
354 });
355 }
356
357 assert_eq!(*captured.borrow(), Some(true));
358 }
359
360 #[cfg(all(
361 not(target_arch = "wasm32"),
362 not(target_os = "android"),
363 not(target_os = "ios"),
364 feature = "system-theme"
365 ))]
366 #[test]
367 fn theme_from_text_reads_common_native_values() {
368 assert_eq!(theme_from_text("'prefer-dark'"), Some(SystemTheme::Dark));
369 assert_eq!(theme_from_text("Breeze Light"), Some(SystemTheme::Light));
370 assert_eq!(theme_from_text("Adwaita"), None);
371 }
372}