Skip to main content

plushie_renderer_lib/
widget_ops.rs

1//! Widget operations: focus, scroll, cursor, pane grid, font loading,
2//! tree hash queries, image management. Dispatched from the widget SDK's
3//! `Dispatch::WidgetOp` core effect via the `op` string and JSON
4//! `payload`.
5
6use iced::Task;
7
8use plushie_widget_sdk::protocol::OutgoingEvent;
9use plushie_widget_sdk::runtime::Message;
10
11use crate::App;
12
13use crate::constants::{MAX_FONT_BYTES, MAX_LOADED_FONTS};
14
15// ---------------------------------------------------------------------------
16// Widget operations (impl App)
17// ---------------------------------------------------------------------------
18
19impl App {
20    /// Dispatch a widget operation by name. Called when Core produces a
21    /// `WidgetOp` effect. Returns an iced `Task` for operations that
22    /// need async completion (focus, scroll, font load).
23    pub fn handle_widget_op(&mut self, op: &str, payload: &serde_json::Value) -> Task<Message> {
24        let get_target = || {
25            payload
26                .get("target")
27                .and_then(|v| v.as_str())
28                .unwrap_or_default()
29                .to_string()
30        };
31
32        match op {
33            "focus" => {
34                let target = get_target();
35                // Canvas element focus (target contains "/"): route through
36                // the registry so CanvasWidget sets pending focus.
37                if target.contains('/') {
38                    self.registry.handle_widget_op(&target, "focus", payload);
39                    // Use the registry's prefix walk to find the canvas widget ID.
40                    let canvas_id = self
41                        .registry
42                        .get_for_node_id(&target)
43                        .map(|(_, matched)| matched.to_string())
44                        .unwrap_or(target);
45                    iced::widget::operation::focus::<Message>(iced::widget::Id::from(canvas_id))
46                } else {
47                    iced::widget::operation::focus::<Message>(iced::widget::Id::from(target))
48                }
49            }
50            "focus_next" => iced::widget::operation::focus_next(),
51            "focus_previous" => iced::widget::operation::focus_previous(),
52            "focus_next_within" => {
53                let scope = payload
54                    .get("scope")
55                    .and_then(|v| v.as_str())
56                    .unwrap_or_default()
57                    .to_string();
58                iced::widget::operation::focus_next_within(iced::widget::Id::from(scope))
59            }
60            "focus_previous_within" => {
61                let scope = payload
62                    .get("scope")
63                    .and_then(|v| v.as_str())
64                    .unwrap_or_default()
65                    .to_string();
66                iced::widget::operation::focus_previous_within(iced::widget::Id::from(scope))
67            }
68            "scroll_to" => {
69                let target = get_target();
70                let offset_x = payload
71                    .get("offset_x")
72                    .and_then(|v| v.as_f64())
73                    .map(|v| v as f32);
74                let offset_y = payload
75                    .get("offset_y")
76                    .and_then(|v| v.as_f64())
77                    .map(|v| v as f32);
78                iced::widget::operation::scroll_to(
79                    iced::widget::Id::from(target),
80                    iced::widget::operation::AbsoluteOffset {
81                        x: offset_x.unwrap_or(0.0),
82                        y: offset_y.unwrap_or(0.0),
83                    },
84                )
85            }
86            "scroll_by" => {
87                let target = get_target();
88                let offset_x = payload
89                    .get("offset_x")
90                    .and_then(|v| v.as_f64())
91                    .unwrap_or(0.0) as f32;
92                let offset_y = payload
93                    .get("offset_y")
94                    .and_then(|v| v.as_f64())
95                    .unwrap_or(0.0) as f32;
96                iced::widget::operation::scroll_by(
97                    iced::widget::Id::from(target),
98                    iced::widget::operation::AbsoluteOffset {
99                        x: offset_x,
100                        y: offset_y,
101                    },
102                )
103            }
104            "snap_to" => {
105                let target = get_target();
106                let x = payload.get("x").and_then(|v| v.as_f64()).map(|v| v as f32);
107                let y = payload.get("y").and_then(|v| v.as_f64()).map(|v| v as f32);
108                iced::widget::operation::snap_to(
109                    iced::widget::Id::from(target),
110                    iced::widget::operation::RelativeOffset { x, y },
111                )
112            }
113            "snap_to_end" => {
114                let target = get_target();
115                iced::widget::operation::snap_to_end(iced::widget::Id::from(target))
116            }
117            "select_all" => {
118                iced::widget::operation::select_all(iced::widget::Id::from(get_target()))
119            }
120            "select_range" => {
121                let target = get_target();
122                let start = payload.get("start").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
123                let end = payload.get("end").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
124                iced::widget::operation::select_range(iced::widget::Id::from(target), start, end)
125            }
126            "move_cursor_to_front" => {
127                iced::widget::operation::move_cursor_to_front(iced::widget::Id::from(get_target()))
128            }
129            "move_cursor_to_end" => {
130                iced::widget::operation::move_cursor_to_end(iced::widget::Id::from(get_target()))
131            }
132            "move_cursor_to" => {
133                let target = get_target();
134                let position = payload
135                    .get("position")
136                    .and_then(|v| v.as_u64())
137                    .unwrap_or(0) as usize;
138                iced::widget::operation::move_cursor_to(iced::widget::Id::from(target), position)
139            }
140            "announce" => {
141                // `politeness` is read off the wire for forward-compat
142                // (the SDK always sends it) but the fork currently
143                // treats all announcements as assertive. When the
144                // fork grows a polite variant this branch can route
145                // explicitly; until then the politeness hint is
146                // logged for visibility but otherwise ignored.
147                let text = payload
148                    .get("text")
149                    .and_then(|v| v.as_str())
150                    .unwrap_or_default()
151                    .to_string();
152                if let Some(p) = payload.get("politeness").and_then(|v| v.as_str()) {
153                    log::trace!("announce politeness={p} (renderer routes to assertive)");
154                }
155                iced::announce(text)
156            }
157            "exit" => iced::exit(),
158            // -- PaneGrid operations --
159            // Routed through the registry to PaneGridWidget::handle_widget_op.
160            "pane_split" | "pane_close" | "pane_swap" | "pane_maximize" | "pane_restore" => {
161                let target = get_target();
162                self.registry.handle_widget_op(&target, op, payload);
163                Task::none()
164            }
165            "find_focused" => {
166                let tag = payload
167                    .get("tag")
168                    .and_then(|v| v.as_str())
169                    .unwrap_or("find_focused")
170                    .to_string();
171                let sink = self.emitter.sink();
172                iced::widget::operation::find_focused().map(move |maybe_id| {
173                    let focused = maybe_id.map(|id| id.to_string());
174                    // sink lock is the innermost; no nested locks.
175                    let mut guard = sink.lock();
176                    if let Err(e) = guard.emit_query_response(
177                        "find_focused",
178                        &tag,
179                        &serde_json::json!({"focused": focused}),
180                    ) {
181                        log::error!("write error: {e}");
182                    }
183                    Message::NoOp
184                })
185            }
186            // Load a font from base64-encoded data at runtime. Supports
187            // TrueType (.ttf), OpenType (.otf), and TrueType Collections
188            // (.ttc). Variable fonts are supported. Format detection is
189            // handled by fontdb (via cosmic-text), so no explicit format
190            // field is needed.
191            "load_font" => {
192                let family = payload
193                    .get("family")
194                    .and_then(|v| v.as_str())
195                    .unwrap_or_default();
196                let data = payload
197                    .get("data")
198                    .and_then(crate::settings::decode_font_data)
199                    .unwrap_or_default();
200                if family.is_empty() {
201                    log::warn!("load_font: missing family name");
202                    Task::none()
203                } else if data.is_empty() {
204                    log::warn!("load_font: no font data provided for family {family}");
205                    Task::none()
206                } else if data.len() > MAX_FONT_BYTES {
207                    log::warn!(
208                        "load_font: font data for {family} ({} bytes) exceeds {} byte limit, rejecting",
209                        data.len(),
210                        MAX_FONT_BYTES
211                    );
212                    Task::none()
213                } else if !crate::constants::try_reserve_font_slot() {
214                    log::warn!(
215                        "load_font: already loaded {MAX_LOADED_FONTS} fonts, \
216                         rejecting to prevent unbounded memory growth"
217                    );
218                    Task::none()
219                } else {
220                    plushie_widget_sdk::fonts::register_loaded_family(family);
221                    let family_for_log = family.to_string();
222                    iced::font::load(data).map(move |result| {
223                        match result {
224                            Ok(()) => log::info!("font {family_for_log} loaded successfully"),
225                            Err(e) => {
226                                log::error!("font {family_for_log} load failed: {e:?}")
227                            }
228                        }
229                        Message::NoOp
230                    })
231                }
232            }
233            "tree_hash" => {
234                let tag = payload
235                    .get("tag")
236                    .and_then(|v| v.as_str())
237                    .unwrap_or("tree_hash")
238                    .to_string();
239                let hash = self.core.tree_hash();
240                if let Err(e) = self.emitter.emit_query_response(
241                    "tree_hash",
242                    &tag,
243                    &serde_json::json!({"hash": hash}),
244                ) {
245                    log::error!("write error: {e}");
246                    return iced::exit();
247                }
248                Task::none()
249            }
250            "list_images" => {
251                let tag = payload
252                    .get("tag")
253                    .and_then(|v| v.as_str())
254                    .unwrap_or("list_images")
255                    .to_string();
256                let handles: Vec<String> = self.image_registry.handle_names();
257                if let Err(e) = self.emitter.emit_query_response(
258                    "list_images",
259                    &tag,
260                    &serde_json::json!({"handles": handles}),
261                ) {
262                    log::error!("write error: {e}");
263                    return iced::exit();
264                }
265                Task::none()
266            }
267            "clear_images" => {
268                self.image_registry.clear();
269                Task::none()
270            }
271            other => {
272                log::warn!("unknown widget_op: {other}");
273                Task::none()
274            }
275        }
276    }
277
278    // -----------------------------------------------------------------------
279    // Image operations
280    // -----------------------------------------------------------------------
281
282    /// Apply an image operation (create, update, remove) to the
283    /// in-memory image registry. Emits an error event on failure.
284    pub fn handle_image_op(
285        &mut self,
286        op: &str,
287        handle: &str,
288        data: Option<Vec<u8>>,
289        pixels: Option<Vec<u8>>,
290        width: Option<u32>,
291        height: Option<u32>,
292    ) {
293        if let Err(error) = self
294            .image_registry
295            .apply_op(op, handle, data, pixels, width, height)
296        {
297            // Best-effort error notification. If stdout is broken the
298            // next synchronous write in update() will exit cleanly.
299            if let Err(e) = self.emitter.emit_event(OutgoingEvent::generic(
300                "image_error".to_string(),
301                handle.to_string(),
302                Some(serde_json::json!({ "error": error })),
303            )) {
304                log::error!("write error: {e}");
305            }
306        }
307    }
308}
309
310// ---------------------------------------------------------------------------
311// PaneGrid helpers
312// ---------------------------------------------------------------------------