Skip to main content

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}