Skip to main content

rdi_core/
controller.rs

1//! Public [`DesktopController`] — the top-level entry point for the crate.
2//!
3//! A `DesktopController` owns exactly one worker thread and one
4//! [`DesktopBackend`]. All public methods forward
5//! their requests to that worker and (for synchronous calls) block until
6//! it replies.
7
8use std::sync::Mutex;
9use std::thread::{self, JoinHandle};
10
11use crossbeam_channel::{bounded, unbounded, Sender};
12
13use crate::backend::DesktopBackend;
14use crate::engine::{worker_main, WorkerMsg};
15use crate::handle::{AnimationHandle, PreObservers};
16use crate::spec::{AnimationOptions, IconAnimationSpec};
17use crate::{DesktopError, IconId, IconSnapshot, MonitorInfo, OverlayRenderOptions, Point, SnapshotFrame};
18
19/// Top-level entry point.
20///
21/// Cheap `Clone` is deliberately **not** implemented; sharing across
22/// threads is done via `Arc<DesktopController>` on the caller side. The
23/// worker thread is joined on `Drop`.
24pub struct DesktopController {
25    tx: Sender<WorkerMsg>,
26    worker: Mutex<Option<JoinHandle<()>>>,
27}
28
29/// One-shot reservation of a prepared animation on its controller's worker.
30/// Dropping it before start cancels without moving desktop icons.
31pub struct PreparedAnimation {
32    trigger: Sender<crate::engine::StartMode>,
33    handle: AnimationHandle,
34}
35
36impl PreparedAnimation {
37    /// Consume the reservation and start its visual clock on the worker.
38    pub fn start(self) -> Result<AnimationHandle, DesktopError> {
39        self.trigger.send(crate::engine::StartMode::Animation).map_err(|_| DesktopError::WorkerCrashed("preparation expired".into()))?;
40        Ok(self.handle)
41    }
42
43    pub fn open_timeline(self) -> Result<crate::TimelineSession, DesktopError> {
44        let (session, runtime) = crate::TimelineSession::pair(self.handle.clone());
45        let (ready, response) = bounded(1);
46        self.trigger.send(crate::engine::StartMode::Timeline { runtime, ready })
47            .map_err(|_| DesktopError::BackendUnavailable("preparation expired".into()))?;
48        response.recv().map_err(|_| DesktopError::BackendUnavailable(
49            format!("timeline opening failed: {:?}", self.handle.wait())
50        ))?;
51        Ok(session)
52    }
53
54    /// Discard GPU resources and wait for the reservation to be released.
55    pub fn cancel(self) {
56        let _ = self.trigger.send(crate::engine::StartMode::Cancel);
57        self.handle.wait();
58    }
59}
60
61impl DesktopController {
62    /// Spawn a worker thread that owns `backend` and start serving
63    /// requests.
64    pub fn new<B: DesktopBackend>(backend: B) -> Result<Self, DesktopError> {
65        Self::from_boxed(Box::new(backend))
66    }
67
68    /// Same as [`Self::new`] but takes an already-boxed backend, useful
69    /// when the backend type is only known dynamically.
70    pub fn from_boxed(backend: Box<dyn DesktopBackend>) -> Result<Self, DesktopError> {
71        let (tx, rx) = unbounded::<WorkerMsg>();
72        let tx_for_worker = tx.clone();
73        let worker = thread::Builder::new()
74            .name("rdi-worker".into())
75            .spawn(move || worker_main(tx_for_worker, rx, backend))
76            .map_err(|e| {
77                DesktopError::BackendUnavailable(format!("failed to spawn worker thread: {e}"))
78            })?;
79        Ok(Self {
80            tx,
81            worker: Mutex::new(Some(worker)),
82        })
83    }
84
85    // ---- simple sync operations -----------------------------------------
86
87    /// Snapshot every icon currently on the desktop.
88    pub fn list_icons(&self) -> Result<Vec<IconSnapshot>, DesktopError> {
89        let (rtx, rrx) = bounded(1);
90        self.tx
91            .send(WorkerMsg::ListIcons { resp: rtx })
92            .map_err(|_| DesktopError::WorkerCrashed("channel closed".into()))?;
93        rrx.recv()
94            .map_err(|_| DesktopError::WorkerCrashed("worker dropped response".into()))?
95    }
96
97    /// Read the raw desktop folder-view flag word.
98    pub fn get_flags(&self) -> Result<u32, DesktopError> {
99        let (rtx, rrx) = bounded(1);
100        self.tx
101            .send(WorkerMsg::GetFlags { resp: rtx })
102            .map_err(|_| DesktopError::WorkerCrashed("channel closed".into()))?;
103        rrx.recv()
104            .map_err(|_| DesktopError::WorkerCrashed("worker dropped response".into()))?
105    }
106
107    /// Perform a masked update of the desktop folder-view flags.
108    ///
109    /// `new_flags = (old_flags & !mask) | (values & mask)`.
110    ///
111    /// Returns [`DesktopError::AnimationBusy`] while an animation is in
112    /// flight — a mid-animation flag write can un-hide the real icons
113    /// behind the overlay. Use [`AnimationOptions::before_flags`] /
114    /// [`after_flags`](AnimationOptions::after_flags) to bracket an
115    /// animation with flag changes.
116    pub fn apply_flags(&self, mask: u32, values: u32) -> Result<(), DesktopError> {
117        let (rtx, rrx) = bounded(1);
118        self.tx
119            .send(WorkerMsg::ApplyFlags {
120                mask,
121                values,
122                resp: rtx,
123            })
124            .map_err(|_| DesktopError::WorkerCrashed("channel closed".into()))?;
125        rrx.recv()
126            .map_err(|_| DesktopError::WorkerCrashed("worker dropped response".into()))?
127    }
128
129    /// OR-set semantics — matches the legacy `set_desktop_flags(flags)`.
130    #[inline]
131    pub fn set_flags(&self, flags: u32) -> Result<(), DesktopError> {
132        self.apply_flags(flags, 0xFFFF_FFFF)
133    }
134
135    /// AND-clear semantics — matches the legacy `unset_desktop_flags(flags)`.
136    #[inline]
137    pub fn unset_flags(&self, flags: u32) -> Result<(), DesktopError> {
138        self.apply_flags(flags, 0)
139    }
140
141    /// XOR-toggle semantics — matches the legacy `switch_desktop_flags(flags)`.
142    pub fn toggle_flags(&self, flags: u32) -> Result<(), DesktopError> {
143        let current = self.get_flags()?;
144        self.apply_flags(flags, current ^ flags)
145    }
146
147    /// Exactly-set semantics — matches the legacy `exactly_set_desktop_flags(flags)`.
148    pub fn set_flags_exactly(&self, flags: u32) -> Result<(), DesktopError> {
149        self.apply_flags(crate::engine::build_true_mask(flags), flags)
150    }
151
152    /// Instantly move a batch of icons to the specified positions
153    /// (no animation). Returns the ids the backend could not resolve.
154    ///
155    /// Returns [`DesktopError::AnimationBusy`] while an animation is in
156    /// flight — the positions would be overwritten by the animation's
157    /// final commit. Stop the animation first.
158    pub fn set_positions(
159        &self,
160        moves: Vec<(IconId, Point)>,
161    ) -> Result<Vec<IconId>, DesktopError> {
162        let (rtx, rrx) = bounded(1);
163        self.tx
164            .send(WorkerMsg::SetPositions { moves, resp: rtx })
165            .map_err(|_| DesktopError::WorkerCrashed("channel closed".into()))?;
166        rrx.recv()
167            .map_err(|_| DesktopError::WorkerCrashed("worker dropped response".into()))?
168    }
169
170    /// Enumerate every connected display in virtual-screen
171    /// coordinates.
172    ///
173    /// The returned [`MonitorInfo`] entries share the same coordinate
174    /// system as icon positions, so a caller can decide "put this icon
175    /// on the second monitor" by checking `monitor.bounds` and building
176    /// a `Point` inside those bounds.
177    pub fn list_monitors(&self) -> Result<Vec<MonitorInfo>, DesktopError> {
178        let (rtx, rrx) = bounded(1);
179        self.tx
180            .send(WorkerMsg::ListMonitors { resp: rtx })
181            .map_err(|_| DesktopError::WorkerCrashed("channel closed".into()))?;
182        rrx.recv()
183            .map_err(|_| DesktopError::WorkerCrashed("worker dropped response".into()))?
184    }
185
186    /// Read current screen and icon-grid metrics, including during playback.
187    pub fn desktop_info(&self) -> Result<crate::DesktopInfo, DesktopError> {
188        let (response, receiver) = bounded(1);
189        self.tx.send(WorkerMsg::DesktopInfo { resp: response })
190            .map_err(|_| DesktopError::WorkerCrashed("channel closed".into()))?;
191        receiver.recv()
192            .map_err(|_| DesktopError::WorkerCrashed("worker dropped response".into()))?
193    }
194
195    /// Render one frame of the overlay off-screen and return the raw
196    /// pixel buffer + dimensions. Never touches an on-screen window,
197    /// DirectComposition, the real desktop, or the display mode.
198    ///
199    /// `positions` are in **overlay-local pixel coordinates** (top-left
200    /// origin). `dpi_scale` is the scale factor to render at
201    /// (`1.0` → 96 DPI, `2.5` → 240 DPI).
202    ///
203    /// The backend must have been populated with icon metadata by a
204    /// prior [`Self::list_icons`] call for icon bitmaps + labels to
205    /// appear; ids missing from the backend's caches render as
206    /// placeholder tiles.
207    ///
208    /// Returns [`DesktopError::OverlayUnavailable`] on backends that
209    /// do not implement rendering (the cross-platform stub, the
210    /// test fake, or platforms other than Windows).
211    pub fn render_overlay_snapshot(
212        &self,
213        width_px: u32,
214        height_px: u32,
215        dpi_scale: f32,
216        positions: Vec<(IconId, Point)>,
217        render_options: OverlayRenderOptions,
218    ) -> Result<SnapshotFrame, DesktopError> {
219        let (rtx, rrx) = bounded(1);
220        self.tx
221            .send(WorkerMsg::RenderOverlaySnapshot {
222                width_px,
223                height_px,
224                dpi_scale,
225                positions,
226                render_options,
227                resp: rtx,
228            })
229            .map_err(|_| DesktopError::WorkerCrashed("channel closed".into()))?;
230        rrx.recv()
231            .map_err(|_| DesktopError::WorkerCrashed("worker dropped response".into()))?
232    }
233
234    // ---- animation ------------------------------------------------------
235
236    pub fn prepare_scene(&self, scene: crate::Scene) -> Result<crate::RenderSession, DesktopError> {
237        let (resp, response) = bounded(1);
238        self.tx.send(WorkerMsg::PrepareScene { scene, resp })
239            .map_err(|_| DesktopError::WorkerCrashed("channel closed".into()))?;
240        response.recv().map_err(|_| DesktopError::WorkerCrashed("worker dropped response".into()))?
241    }
242
243    /// Build overlay resources now without displaying or moving anything.
244    /// The snapshot is fixed at preparation; reprepare after desktop changes.
245    pub fn prepare(
246        &self,
247        specs: Vec<IconAnimationSpec>,
248        options: AnimationOptions,
249    ) -> Result<PreparedAnimation, DesktopError> {
250        let (trigger, start) = bounded(1);
251        let (ready, readiness) = bounded(1);
252        let (resp, response) = bounded(1);
253        self.tx.send(WorkerMsg::StartAnimation {
254            specs, options, observers: PreObservers::default(), resp,
255            preparation: Some((ready, start)),
256        }).map_err(|_| DesktopError::WorkerCrashed("channel closed".into()))?;
257        let handle = response.recv().map_err(|_| DesktopError::WorkerCrashed("worker dropped response".into()))??;
258        if readiness.recv().is_err() {
259            return Err(DesktopError::BackendUnavailable(format!("preparation failed: {:?}", handle.wait())));
260        }
261        Ok(PreparedAnimation { trigger, handle })
262    }
263
264    /// Start an animation.
265    ///
266    /// Returns immediately with a handle. Use [`AnimationHandle::wait`]
267    /// to block until completion, or the various `on_*` observers for
268    /// non-blocking notification.
269    ///
270    /// **Observer race warning:** callbacks attached via
271    /// [`AnimationHandle::on_start`] / `on_tick` / `on_icon_complete` /
272    /// `on_finish` are inherently racy — the worker may already be
273    /// ticking (and firing events) by the time your registration lands.
274    /// If precise counts matter, use
275    /// [`Self::animate_with_observers`] instead, which installs the
276    /// callbacks *before* the worker enters its tick loop.
277    pub fn animate(
278        &self,
279        specs: Vec<IconAnimationSpec>,
280        options: AnimationOptions,
281    ) -> Result<AnimationHandle, DesktopError> {
282        self.animate_with_observers(specs, options, PreObservers::default())
283    }
284
285    /// Same as [`Self::animate`] but atomically pre-attaches a bundle of
286    /// observers, guaranteeing that every event fired by the worker is
287    /// delivered to those callbacks.
288    pub fn animate_with_observers(
289        &self,
290        specs: Vec<IconAnimationSpec>,
291        options: AnimationOptions,
292        observers: PreObservers,
293    ) -> Result<AnimationHandle, DesktopError> {
294        let (rtx, rrx) = bounded(1);
295        self.tx
296            .send(WorkerMsg::StartAnimation {
297                specs,
298                options,
299                observers,
300                resp: rtx,
301                preparation: None,
302            })
303            .map_err(|_| DesktopError::WorkerCrashed("channel closed".into()))?;
304        rrx.recv()
305            .map_err(|_| DesktopError::WorkerCrashed("worker dropped response".into()))?
306    }
307
308    // ---- lifecycle ------------------------------------------------------
309
310    /// Stop the worker thread and block until it has exited.
311    ///
312    /// Idempotent, and called by [`Drop`]. Exposed separately so
313    /// embedders that must not block on the calling thread's own
314    /// resources — notably PyO3, which runs `Drop` with the GIL held
315    /// while the worker may be waiting to *acquire* the GIL inside an
316    /// observer callback — can perform the join at a point of their
317    /// choosing.
318    pub fn shutdown(&self) {
319        let _ = self.tx.send(WorkerMsg::Shutdown);
320        if let Ok(mut guard) = self.worker.lock() {
321            if let Some(handle) = guard.take() {
322                let _ = handle.join();
323            }
324        }
325    }
326}
327
328impl Drop for DesktopController {
329    fn drop(&mut self) {
330        self.shutdown();
331    }
332}