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