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
16pub fn set_platform_system_theme(theme: SystemTheme) {
20 PLATFORM_SYSTEM_THEME.with(|cell| cell.set(Some(theme)));
21}
22
23pub 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}