native_theme/watch/mod.rs
1//! Runtime theme change watching.
2//!
3//! This module provides the public API for monitoring OS theme changes at
4//! runtime. Call [`on_theme_change()`](crate::watch::on_theme_change) with a callback to start watching;
5//! the returned [`ThemeSubscription`](crate::watch::ThemeSubscription) keeps the watcher alive via RAII semantics
6//! -- dropping it stops the watcher and joins the background thread.
7//!
8//! # RAII ownership model
9//!
10//! [`ThemeSubscription`](crate::watch::ThemeSubscription) is an RAII guard. Dropping it stops the watcher and
11//! joins the background thread. You **must** bind it to a variable -- if
12//! you discard the return value, the watcher is dropped immediately and
13//! no events are ever delivered.
14//!
15//! # Shutdown mechanism
16//!
17//! When a `ThemeSubscription` is dropped, shutdown proceeds in three phases:
18//!
19//! 1. **Platform-specific wakeup** -- if a platform shutdown closure was
20//! registered (see constructor below), it runs first. This wakes
21//! the background thread's event loop so it can observe the disconnect.
22//! 2. **Channel disconnect** -- the shutdown channel sender is dropped,
23//! causing the receiver in the background thread to see `Disconnected`
24//! on its next `recv()` or `try_recv()`.
25//! 3. **Thread join** -- `JoinHandle::join()` blocks until the background
26//! thread exits, ensuring clean shutdown before the guard is gone.
27//!
28//! # Constructor
29//!
30//! There is a single `pub(crate)` constructor:
31//!
32//! - [`ThemeSubscription::new(tx, handle, platform_shutdown)`] -- the optional
33//! `platform_shutdown` closure wakes the background thread's event loop on
34//! platforms where dropping the channel sender alone is not sufficient
35//! (`CFRunLoop::stop` on macOS, `PostThreadMessageW(WM_QUIT)` on Windows).
36//! Pass `None` on Linux where inotify/D-Bus poll the channel directly.
37//!
38//! # Signal-only events
39//!
40//! [`ThemeChangeEvent`](crate::watch::ThemeChangeEvent) carries no theme data. When you receive an event,
41//! re-run [`SystemTheme::from_system()`](crate::SystemTheme::from_system)
42//! to get the updated theme.
43//!
44//! # Example
45//!
46//! ```no_run
47//! use std::sync::mpsc;
48//!
49//! let (tx, rx) = mpsc::channel();
50//! let _watcher = native_theme::watch::on_theme_change(move |event| {
51//! let _ = tx.send(event);
52//! })?;
53//!
54//! // On your UI thread:
55//! // if let Ok(event) = rx.try_recv() {
56//! // let theme = native_theme::SystemTheme::from_system()?;
57//! // // re-apply theme ...
58//! // }
59//! # Ok::<(), native_theme::error::Error>(())
60//! ```
61
62#[cfg(all(feature = "watch", feature = "kde", target_os = "linux"))]
63mod kde;
64
65#[cfg(all(feature = "watch", feature = "portal", target_os = "linux"))]
66mod gnome;
67
68#[cfg(all(feature = "watch", feature = "macos", target_os = "macos"))]
69mod macos;
70
71#[cfg(all(feature = "watch", feature = "windows", target_os = "windows"))]
72mod windows;
73
74use std::sync::mpsc;
75use std::thread::JoinHandle;
76
77/// A signal that the OS theme has changed.
78///
79/// This enum carries no theme data -- it is a notification only.
80/// When you receive an event, call
81/// [`SystemTheme::from_system()`](crate::SystemTheme::from_system)
82/// to read the updated theme.
83///
84/// # Non-exhaustive
85///
86/// Future versions may add new variants. Always include a wildcard arm:
87///
88/// ```ignore
89/// match event {
90/// ThemeChangeEvent::Changed => { /* ... */ }
91/// _ => { /* handle future variants */ }
92/// }
93/// ```
94#[derive(Debug, Clone, PartialEq, Eq)]
95#[non_exhaustive]
96pub enum ThemeChangeEvent {
97 /// The OS theme changed.
98 Changed,
99}
100
101/// RAII guard that keeps a theme watcher alive.
102///
103/// Holds a background thread and a shutdown channel. When dropped, the
104/// channel sender is dropped (signaling shutdown via disconnection) and
105/// the background thread is joined.
106///
107/// Note: `ThemeSubscription` is `Send` but not `Sync`. The background thread
108/// handle and shutdown channel are not safe to share across threads, but the
109/// guard can be moved to a different thread if needed.
110///
111/// # Important
112///
113/// You **must** bind this to a variable. If you discard it, the watcher
114/// stops immediately:
115///
116/// ```ignore
117/// // WRONG -- watcher is dropped immediately:
118/// on_theme_change(|e| println!("{e:?}"));
119///
120/// // RIGHT -- watcher lives as long as `_watcher`:
121/// let _watcher = on_theme_change(|e| println!("{e:?}")).unwrap();
122/// ```
123#[must_use]
124pub struct ThemeSubscription {
125 shutdown_tx: Option<mpsc::Sender<()>>,
126 thread: Option<JoinHandle<()>>,
127 platform_shutdown: Option<Box<dyn FnOnce() + Send>>,
128}
129
130impl std::fmt::Debug for ThemeSubscription {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 f.debug_struct("ThemeSubscription")
133 .field("shutdown_tx", &self.shutdown_tx)
134 .field("thread", &self.thread)
135 .field(
136 "platform_shutdown",
137 &self.platform_shutdown.as_ref().map(|_| "..."),
138 )
139 .finish()
140 }
141}
142
143impl ThemeSubscription {
144 /// Create a new `ThemeSubscription` from a shutdown channel, thread handle,
145 /// and optional platform-specific shutdown action.
146 ///
147 /// The `platform_shutdown` closure (if `Some`) is called **before** the
148 /// channel is dropped during `Drop`, allowing platform backends to wake
149 /// their blocked event loops so the thread can observe the disconnect.
150 /// Pass `None` on Linux where the channel disconnect alone suffices.
151 ///
152 /// Compiled where a backend exists to call it, and for tests.
153 #[cfg(any(
154 test,
155 all(target_os = "linux", any(feature = "kde", feature = "portal")),
156 all(target_os = "macos", feature = "macos"),
157 all(target_os = "windows", feature = "windows"),
158 ))]
159 pub(crate) fn new(
160 shutdown_tx: mpsc::Sender<()>,
161 thread: JoinHandle<()>,
162 platform_shutdown: Option<Box<dyn FnOnce() + Send>>,
163 ) -> Self {
164 Self {
165 shutdown_tx: Some(shutdown_tx),
166 thread: Some(thread),
167 platform_shutdown,
168 }
169 }
170}
171
172impl Drop for ThemeSubscription {
173 fn drop(&mut self) {
174 // Run the platform-specific shutdown action first (e.g. CFRunLoop::stop
175 // on macOS, PostThreadMessageW WM_QUIT on Windows) to wake the blocked
176 // event loop so it can observe the channel disconnect.
177 if let Some(shutdown_fn) = self.platform_shutdown.take() {
178 shutdown_fn();
179 }
180 // Drop the sender to signal shutdown (receiver sees Disconnected).
181 drop(self.shutdown_tx.take());
182 // Join the background thread so it finishes cleanly.
183 if let Some(handle) = self.thread.take() {
184 let _ = handle.join();
185 }
186 }
187}
188
189/// Start watching for OS theme changes.
190///
191/// The `callback` is invoked on a **background thread** whenever the OS
192/// theme changes. To marshal events to your UI thread, send them through
193/// a channel:
194///
195/// ```no_run
196/// use std::sync::mpsc;
197/// use native_theme::watch::ThemeChangeEvent;
198///
199/// let (tx, rx) = mpsc::channel();
200/// let _watcher = native_theme::watch::on_theme_change(move |event| {
201/// let _ = tx.send(event);
202/// })?;
203///
204/// // On UI thread:
205/// // match rx.try_recv() {
206/// // Ok(ThemeChangeEvent::Changed) => { /* re-read theme */ }
207/// // _ => {}
208/// // }
209/// # Ok::<(), native_theme::error::Error>(())
210/// ```
211///
212/// # Errors
213///
214/// Returns [`Error::WatchUnavailable`](crate::Error::WatchUnavailable) if no
215/// platform-specific backend is available for the current desktop
216/// environment or platform.
217pub fn on_theme_change(
218 callback: impl Fn(ThemeChangeEvent) + Send + 'static,
219) -> crate::Result<ThemeSubscription> {
220 #[cfg(target_os = "linux")]
221 {
222 #[cfg(not(any(feature = "kde", feature = "portal")))]
223 let _ = callback;
224 let de = crate::detect_linux_desktop();
225 match de {
226 #[cfg(feature = "kde")]
227 crate::LinuxDesktop::Kde => kde::watch_kde(callback),
228
229 #[cfg(feature = "portal")]
230 crate::LinuxDesktop::Gnome | crate::LinuxDesktop::Budgie => {
231 gnome::watch_gnome(callback)
232 }
233
234 _ => Err(crate::Error::WatchUnavailable {
235 reason: "theme watching not supported for this desktop environment",
236 }),
237 }
238 }
239
240 #[cfg(target_os = "macos")]
241 {
242 #[cfg(feature = "macos")]
243 {
244 return macos::watch_macos(callback);
245 }
246 #[cfg(not(feature = "macos"))]
247 {
248 let _ = callback;
249 return Err(crate::Error::WatchUnavailable {
250 reason: "enable the 'macos' feature for macOS theme watching",
251 });
252 }
253 }
254
255 #[cfg(target_os = "windows")]
256 {
257 #[cfg(feature = "windows")]
258 {
259 return windows::watch_windows(callback);
260 }
261 #[cfg(not(feature = "windows"))]
262 {
263 let _ = callback;
264 return Err(crate::Error::WatchUnavailable {
265 reason: "enable the 'windows' feature for Windows theme watching",
266 });
267 }
268 }
269
270 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
271 {
272 let _ = callback;
273 Err(crate::Error::PlatformUnsupported {
274 platform: "unsupported",
275 })
276 }
277}
278
279#[cfg(test)]
280#[allow(clippy::unwrap_used)]
281mod tests {
282 use super::*;
283
284 #[test]
285 fn theme_change_event_is_debug_clone_eq() {
286 let event = ThemeChangeEvent::Changed;
287 let cloned = event.clone();
288 assert_eq!(event, cloned);
289 // Debug
290 let debug_str = format!("{:?}", event);
291 assert!(debug_str.contains("Changed"));
292 }
293
294 /// On unsupported platforms, on_theme_change() returns PlatformUnsupported.
295 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
296 #[test]
297 fn on_theme_change_returns_unsupported() {
298 let result = on_theme_change(|_| {});
299 assert!(result.is_err());
300 let err = result.unwrap_err();
301 assert!(
302 matches!(&err, crate::Error::PlatformUnsupported { .. }),
303 "expected PlatformUnsupported, got: {err:?}"
304 );
305 }
306
307 /// On Linux, on_theme_change() dispatches based on the detected DE.
308 /// In CI (no DE running), XDG_CURRENT_DESKTOP is usually empty/Unknown,
309 /// so we get WatchUnavailable. On a real DE it may succeed or return
310 /// ReaderFailed. All outcomes are valid.
311 #[cfg(target_os = "linux")]
312 #[test]
313 fn on_theme_change_dispatches_or_returns_error() {
314 let result = on_theme_change(|_| {});
315 assert!(
316 matches!(
317 &result,
318 Ok(_)
319 | Err(crate::Error::WatchUnavailable { .. })
320 | Err(crate::Error::PlatformUnsupported { .. })
321 | Err(crate::Error::ReaderFailed { .. })
322 ),
323 "unexpected result: {result:?}"
324 );
325 }
326
327 #[test]
328 fn theme_subscription_drop_signals_shutdown() {
329 use std::sync::mpsc;
330 use std::thread;
331
332 let (tx, rx) = mpsc::channel();
333 let thread_handle = thread::spawn(move || {
334 // Block until shutdown signal (channel disconnected)
335 let _ = rx.recv();
336 });
337
338 let watcher = ThemeSubscription::new(tx, thread_handle, None);
339 // Drop the watcher -- should signal shutdown and join thread
340 drop(watcher);
341 // If we get here, the thread was joined successfully (did not hang)
342 }
343
344 #[test]
345 fn theme_subscription_is_send() {
346 fn assert_send<T: Send>() {}
347 assert_send::<ThemeSubscription>();
348 }
349}