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//!   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}