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