Skip to main content

plushie_renderer_lib/
execute.rs

1//! Typed command execution for RendererOp.
2//!
3//! [`App::execute`] dispatches typed [`RendererOp`] variants directly
4//! to iced operations. This is the primary entry point for direct mode
5//! (zero serialization). Wire mode currently uses
6//! [`App::apply`](crate::apply) with `IncomingMessage`.
7
8use iced::Task;
9
10use plushie_core::ops::*;
11use plushie_widget_sdk::runtime::Message;
12
13use crate::App;
14
15impl App {
16    /// Execute a typed renderer operation.
17    ///
18    /// Returns an iced Task for operations that need async completion
19    /// (focus, scroll, effects, window queries).
20    pub fn execute(&mut self, op: RendererOp) -> Task<Message> {
21        use iced::widget::operation;
22
23        match op {
24            // -- Widget-targeted command (unified) --
25            RendererOp::Command {
26                ref id,
27                ref family,
28                ref value,
29            } => self.execute_command(id, family, value),
30            RendererOp::Commands(commands) => {
31                // Atomic batch: buffer outgoing events so observers
32                // see a single consistent state after all commands
33                // commit.
34                self.emitter.begin_batch();
35                let tasks: Vec<_> = commands
36                    .iter()
37                    .map(|cmd| self.execute_command(&cmd.id, &cmd.family, &cmd.value))
38                    .collect();
39                self.emitter.end_batch();
40                Task::batch(tasks)
41            }
42
43            // -- Global focus (no target widget) --
44            RendererOp::FocusNext => operation::focus_next(),
45            RendererOp::FocusPrevious => operation::focus_previous(),
46            RendererOp::FocusNextWithin { scope } => {
47                operation::focus_next_within(iced::advanced::widget::Id::from(scope))
48            }
49            RendererOp::FocusPreviousWithin { scope } => {
50                operation::focus_previous_within(iced::advanced::widget::Id::from(scope))
51            }
52
53            // -- Accessibility --
54            //
55            // Politeness is carried on the wire but collapsed to the
56            // fork's assertive `announce` for now. Per-politeness
57            // routing requires fork-level additions; the SDK-level
58            // API is future-proofed so app code can specify politeness
59            // today and the renderer picks it up when the fork grows
60            // the `announce_polite` variant.
61            RendererOp::Announce { text, .. } => iced::announce(text),
62
63            // -- Window operations --
64            RendererOp::Window(op) => self.dispatch_window_op(op),
65            RendererOp::WindowQuery(query) => self.dispatch_window_query(query),
66
67            // -- System --
68            RendererOp::SystemOp(op) => self.dispatch_system_op(op),
69            RendererOp::SystemQuery(query) => self.dispatch_system_query(query),
70
71            // -- Effects --
72            RendererOp::Effect { tag, request, .. } => {
73                if self.effect_handler.is_async(&request) {
74                    let future = self.effect_handler.handle_async(tag, request);
75                    let sink = self.emitter.sink();
76                    Task::perform(future, move |response| {
77                        // sink lock is the innermost; no nested locks
78                        // here, and iced's async continuation must
79                        // keep it that way.
80                        let mut guard = sink.lock();
81                        if let Err(e) = guard.emit_effect_response(response) {
82                            log::error!("effect response write error: {e}");
83                        }
84                        Message::NoOp
85                    })
86                } else if let Some(response) = self.effect_handler.handle_sync(&tag, &request) {
87                    if let Err(e) = self.emitter.emit_effect_response(response) {
88                        log::error!("effect response write error: {e}");
89                        return iced::exit();
90                    }
91                    Task::none()
92                } else {
93                    Task::none()
94                }
95            }
96
97            // -- Images --
98            RendererOp::Image(op) => self.execute_image_op(op),
99
100            // -- Font loading --
101            RendererOp::LoadFont { family, bytes } => {
102                plushie_widget_sdk::fonts::register_loaded_family(&family);
103                iced::font::load(bytes).map(|_| Message::NoOp)
104            }
105
106            // -- Subscriptions --
107            RendererOp::Subscribe {
108                kind,
109                tag,
110                max_rate,
111                window_id,
112            } => {
113                use plushie_widget_sdk::protocol::IncomingMessage;
114                self.core.apply(IncomingMessage::Subscribe {
115                    kind,
116                    tag,
117                    window_id,
118                    max_rate,
119                });
120                self.sync_subscription_rates();
121                self.cleanup_subscription_rates();
122                Task::none()
123            }
124            RendererOp::Unsubscribe { kind, tag } => {
125                use plushie_widget_sdk::protocol::IncomingMessage;
126                self.core.apply(IncomingMessage::Unsubscribe {
127                    kind,
128                    tag: Some(tag),
129                });
130                self.sync_subscription_rates();
131                self.cleanup_subscription_rates();
132                Task::none()
133            }
134
135            // -- Testing / debugging --
136            RendererOp::TreeHash { tag } => {
137                self.handle_widget_op("tree_hash", &serde_json::json!({"target": tag}))
138            }
139            RendererOp::FindFocused { tag } => {
140                self.handle_widget_op("find_focused", &serde_json::json!({"target": tag}))
141            }
142            RendererOp::AdvanceFrame { timestamp } => self.handle_widget_op(
143                "advance_frame",
144                &serde_json::json!({"timestamp": timestamp}),
145            ),
146            _ => Task::none(),
147        }
148    }
149
150    fn execute_image_op(&mut self, op: ImageOp) -> Task<Message> {
151        match op {
152            ImageOp::Create { handle, data } => {
153                self.handle_image_op("create_image", &handle, Some(data), None, None, None);
154                Task::none()
155            }
156            ImageOp::CreateRaw {
157                handle,
158                width,
159                height,
160                pixels,
161            } => {
162                self.handle_image_op(
163                    "create_image",
164                    &handle,
165                    None,
166                    Some(pixels),
167                    Some(width),
168                    Some(height),
169                );
170                Task::none()
171            }
172            ImageOp::Update { handle, data } => {
173                self.handle_image_op("update_image", &handle, Some(data), None, None, None);
174                Task::none()
175            }
176            ImageOp::UpdateRaw {
177                handle,
178                width,
179                height,
180                pixels,
181            } => {
182                self.handle_image_op(
183                    "update_image",
184                    &handle,
185                    None,
186                    Some(pixels),
187                    Some(width),
188                    Some(height),
189                );
190                Task::none()
191            }
192            ImageOp::Delete(handle) => {
193                self.handle_image_op("delete_image", &handle, None, None, None, None);
194                Task::none()
195            }
196            ImageOp::List { tag } => {
197                self.handle_widget_op("list_images", &serde_json::json!({"tag": tag}))
198            }
199            ImageOp::Clear => self.handle_widget_op("clear_images", &serde_json::json!({})),
200            _ => Task::none(),
201        }
202    }
203
204    /// Dispatch a widget-targeted command by family.
205    ///
206    /// Built-in operations (focus, scroll, text cursor) return iced Tasks.
207    /// Everything else routes to the widget registry.
208    pub(crate) fn execute_command(
209        &mut self,
210        id: &str,
211        family: &str,
212        value: &serde_json::Value,
213    ) -> Task<Message> {
214        use iced::widget::Id as WId;
215        use iced::widget::operation;
216
217        match family {
218            "focus" => {
219                if id.contains('/') {
220                    self.registry
221                        .handle_widget_op(id, "focus", &serde_json::json!({}));
222                    let canvas_id = self
223                        .registry
224                        .get_for_node_id(id)
225                        .map(|(_, matched)| matched.to_string())
226                        .unwrap_or_else(|| id.to_string());
227                    operation::focus::<Message>(WId::from(canvas_id))
228                } else {
229                    operation::focus::<Message>(WId::from(id.to_string()))
230                }
231            }
232            "select_all" => operation::select_all(WId::from(id.to_string())),
233            "move_cursor_to_front" => operation::move_cursor_to_front(WId::from(id.to_string())),
234            "move_cursor_to_end" => operation::move_cursor_to_end(WId::from(id.to_string())),
235            "move_cursor_to" => {
236                let pos = value.as_u64().unwrap_or(0) as usize;
237                operation::move_cursor_to(WId::from(id.to_string()), pos)
238            }
239            "select_range" => {
240                let start = value.get("start_pos").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
241                let end = value.get("end_pos").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
242                operation::select_range(WId::from(id.to_string()), start, end)
243            }
244            "scroll_to" => {
245                let x = value.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
246                let y = value.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
247                operation::scroll_to(
248                    WId::from(id.to_string()),
249                    operation::AbsoluteOffset { x, y },
250                )
251            }
252            "scroll_by" => {
253                let x = value.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
254                let y = value.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
255                operation::scroll_by(
256                    WId::from(id.to_string()),
257                    operation::AbsoluteOffset { x, y },
258                )
259            }
260            "snap_to" => {
261                let x = value.get("x").and_then(|v| v.as_f64()).map(|v| v as f32);
262                let y = value.get("y").and_then(|v| v.as_f64()).map(|v| v as f32);
263                operation::snap_to(
264                    WId::from(id.to_string()),
265                    operation::RelativeOffset { x, y },
266                )
267            }
268            "snap_to_end" => operation::snap_to_end(WId::from(id.to_string())),
269            // Everything else routes to the widget registry (native widgets,
270            // pane grid ops, etc.)
271            _ => {
272                self.registry.handle_widget_op(id, family, value);
273                Task::none()
274            }
275        }
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use std::future::Future;
283    use std::pin::Pin;
284    use std::sync::Arc;
285
286    use parking_lot::Mutex;
287    use plushie_core::ops::EffectRequest;
288    use plushie_widget_sdk::protocol::{DiagnosticMessage, EffectResponse, OutgoingEvent};
289
290    struct NullEffectHandler;
291
292    impl crate::effects::EffectHandler for NullEffectHandler {
293        fn handle_sync(&self, _: &str, _: &EffectRequest) -> Option<EffectResponse> {
294            None
295        }
296
297        fn handle_async(
298            &self,
299            _: String,
300            _: EffectRequest,
301        ) -> Pin<Box<dyn Future<Output = EffectResponse> + Send>> {
302            Box::pin(async { unreachable!() })
303        }
304
305        fn is_async(&self, _: &EffectRequest) -> bool {
306            false
307        }
308    }
309
310    struct NullSink;
311
312    impl crate::emitters::EventSink for NullSink {
313        fn emit_event(&mut self, _: OutgoingEvent) -> std::io::Result<()> {
314            Ok(())
315        }
316
317        fn emit_effect_response(&mut self, _: EffectResponse) -> std::io::Result<()> {
318            Ok(())
319        }
320
321        fn emit_query_response(
322            &mut self,
323            _: &str,
324            _: &str,
325            _: &serde_json::Value,
326        ) -> std::io::Result<()> {
327            Ok(())
328        }
329
330        fn emit_screenshot_response(
331            &mut self,
332            _: &str,
333            _: &str,
334            _: &str,
335            _: u32,
336            _: u32,
337            _: &[u8],
338        ) -> std::io::Result<()> {
339            Ok(())
340        }
341
342        fn emit_hello(
343            &mut self,
344            _: &str,
345            _: &str,
346            _: &[&str],
347            _: &[&str],
348            _: &str,
349        ) -> std::io::Result<()> {
350            Ok(())
351        }
352
353        fn emit_diagnostic(&mut self, _: DiagnosticMessage) -> std::io::Result<()> {
354            Ok(())
355        }
356
357        fn write_raw(&mut self, _: &[u8]) -> std::io::Result<()> {
358            Ok(())
359        }
360    }
361
362    fn test_app() -> App {
363        let sink = Arc::new(Mutex::new(
364            Box::new(NullSink) as Box<dyn crate::emitters::EventSink>
365        ));
366        App::new(
367            plushie_widget_sdk::registry::WidgetRegistry::new(),
368            Box::new(NullEffectHandler),
369            sink,
370        )
371    }
372
373    #[test]
374    fn execute_subscribe_updates_emitter_rate() {
375        let mut app = test_app();
376
377        let _ = app.execute(RendererOp::Subscribe {
378            kind: "on_pointer_move".to_string(),
379            tag: "on_pointer_move".to_string(),
380            max_rate: Some(30),
381            window_id: None,
382        });
383
384        assert_eq!(
385            app.emitter.subscription_rate_for("on_pointer_move"),
386            Some(30)
387        );
388    }
389
390    #[test]
391    fn execute_unsubscribe_removes_emitter_rate() {
392        let mut app = test_app();
393
394        let _ = app.execute(RendererOp::Subscribe {
395            kind: "on_pointer_move".to_string(),
396            tag: "on_pointer_move".to_string(),
397            max_rate: Some(30),
398            window_id: None,
399        });
400        let _ = app.execute(RendererOp::Unsubscribe {
401            kind: "on_pointer_move".to_string(),
402            tag: "on_pointer_move".to_string(),
403        });
404
405        assert_eq!(app.emitter.subscription_rate_for("on_pointer_move"), None);
406    }
407
408    #[test]
409    fn execute_subscribe_without_rate_removes_existing_rate() {
410        let mut app = test_app();
411
412        let _ = app.execute(RendererOp::Subscribe {
413            kind: "on_pointer_move".to_string(),
414            tag: "on_pointer_move".to_string(),
415            max_rate: Some(30),
416            window_id: None,
417        });
418        let _ = app.execute(RendererOp::Subscribe {
419            kind: "on_pointer_move".to_string(),
420            tag: "on_pointer_move".to_string(),
421            max_rate: None,
422            window_id: None,
423        });
424
425        assert_eq!(app.emitter.subscription_rate_for("on_pointer_move"), None);
426    }
427}