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