Skip to main content

toddy_core/protocol/
incoming.rs

1//! Incoming wire messages from the host process.
2
3use serde::{Deserialize, Deserializer};
4use serde_json::Value;
5
6use super::types::{PatchOp, TreeNode};
7
8/// Messages sent from the host to the renderer over stdin.
9#[derive(Debug, Clone, Deserialize)]
10#[serde(tag = "type", rename_all = "snake_case")]
11pub enum IncomingMessage {
12    /// Replace the entire UI tree with a new snapshot.
13    Snapshot { tree: TreeNode },
14    /// Apply incremental changes to the retained UI tree.
15    Patch { ops: Vec<PatchOp> },
16    /// Request a platform effect (file dialog, clipboard, notification).
17    Effect {
18        id: String,
19        kind: String,
20        payload: Value,
21    },
22    /// Perform a widget operation (focus, scroll, select, etc.).
23    WidgetOp {
24        op: String,
25        #[serde(default)]
26        payload: Value,
27    },
28    /// Subscribe to a runtime event source (keyboard, mouse, window, etc.).
29    Subscribe { kind: String, tag: String },
30    /// Unsubscribe from a runtime event source.
31    Unsubscribe { kind: String },
32    /// Perform a window operation (resize, move, close, etc.).
33    WindowOp {
34        op: String,
35        window_id: String,
36        #[serde(default)]
37        settings: Value,
38    },
39    /// Apply or update renderer settings.
40    Settings { settings: Value },
41    /// Query the current tree or find a widget.
42    Query {
43        id: String,
44        target: String,
45        #[serde(default)]
46        selector: Value,
47    },
48    /// Interact with a widget (click, type, etc.)
49    Interact {
50        id: String,
51        action: String,
52        #[serde(default)]
53        selector: Value,
54        #[serde(default)]
55        payload: Value,
56    },
57    /// Capture a structural tree hash (hash of JSON tree).
58    // Used by the binary crate's headless and test modes. Appears dead
59    // from toddy-core's perspective because the usage is in toddy/.
60    #[allow(dead_code)]
61    TreeHash { id: String, name: String },
62    /// Capture a pixel screenshot (GPU-rendered RGBA data).
63    #[allow(dead_code)]
64    Screenshot {
65        id: String,
66        name: String,
67        #[serde(default)]
68        width: Option<u32>,
69        #[serde(default)]
70        height: Option<u32>,
71    },
72    /// Reset the app state.
73    Reset { id: String },
74    /// Image operation (create, update, delete in-memory image handles).
75    ///
76    /// Binary fields (`data`, `pixels`) accept either raw bytes (from msgpack)
77    /// or base64-encoded strings (from JSON). The custom deserializer handles both.
78    ImageOp {
79        op: String,
80        handle: String,
81        #[serde(default, deserialize_with = "deserialize_binary_field")]
82        data: Option<Vec<u8>>,
83        #[serde(default, deserialize_with = "deserialize_binary_field")]
84        pixels: Option<Vec<u8>>,
85        #[serde(default)]
86        width: Option<u32>,
87        #[serde(default)]
88        height: Option<u32>,
89    },
90    /// A single extension command pushed to a native extension widget.
91    /// Bypasses the normal tree update / diff / patch cycle.
92    ExtensionCommand {
93        node_id: String,
94        op: String,
95        #[serde(default)]
96        payload: Value,
97    },
98    /// A batch of extension commands processed in one cycle.
99    ExtensionCommands { commands: Vec<ExtensionCommandItem> },
100    /// Advance the animation clock by one frame (headless/test mode).
101    /// Emits an `animation_frame` event if `on_animation_frame` is subscribed.
102    AdvanceFrame { timestamp: u64 },
103}
104
105/// A single item within an `ExtensionCommands` batch.
106#[derive(Debug, Clone, Deserialize)]
107pub struct ExtensionCommandItem {
108    pub node_id: String,
109    pub op: String,
110    #[serde(default)]
111    pub payload: Value,
112}
113
114// ---------------------------------------------------------------------------
115// Binary field deserialization (handles both raw bytes and base64 strings)
116// ---------------------------------------------------------------------------
117
118/// Deserializes a binary field that may arrive as:
119/// - Raw bytes (msgpack binary type, via rmpv path)
120/// - Base64-encoded string (JSON path)
121/// - null / absent (returns None)
122///
123/// When the codec's rmpv-based decode extracts binary fields and injects them
124/// as `serde_json::Value::Array` of u8 values, serde picks them up as Vec<u8>.
125/// When the field arrives as a base64 string (JSON mode), we decode it here.
126fn deserialize_binary_field<'de, D>(deserializer: D) -> Result<Option<Vec<u8>>, D::Error>
127where
128    D: Deserializer<'de>,
129{
130    use serde::de::Error;
131
132    let val: Option<Value> = Option::deserialize(deserializer)?;
133    match val {
134        None => Ok(None),
135        Some(Value::Null) => Ok(None),
136        // Base64 string (JSON mode)
137        Some(Value::String(s)) => {
138            use base64::Engine as _;
139            base64::engine::general_purpose::STANDARD
140                .decode(&s)
141                .map(Some)
142                .map_err(|e| D::Error::custom(format!("base64 decode: {e}")))
143        }
144        // Array of u8 values (injected by rmpv binary extraction)
145        Some(Value::Array(arr)) => {
146            let bytes: Result<Vec<u8>, _> = arr
147                .into_iter()
148                .map(|v| {
149                    v.as_u64()
150                        .and_then(|n| u8::try_from(n).ok())
151                        .ok_or_else(|| D::Error::custom("expected u8 in binary array"))
152                })
153                .collect();
154            bytes.map(Some)
155        }
156        Some(other) => Err(D::Error::custom(format!(
157            "expected string, array, or null for binary field, got {other}"
158        ))),
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use serde_json::json;
166
167    // -----------------------------------------------------------------------
168    // IncomingMessage deserialization
169    // -----------------------------------------------------------------------
170
171    #[test]
172    fn deserialize_snapshot() {
173        let json =
174            r#"{"type":"snapshot","tree":{"id":"root","type":"column","props":{},"children":[]}}"#;
175        let msg: IncomingMessage = serde_json::from_str(json).unwrap();
176        match msg {
177            IncomingMessage::Snapshot { tree } => {
178                assert_eq!(tree.id, "root");
179                assert_eq!(tree.type_name, "column");
180            }
181            _ => panic!("expected Snapshot"),
182        }
183    }
184
185    #[test]
186    fn deserialize_snapshot_nested_tree() {
187        let msg: IncomingMessage = serde_json::from_value(json!({
188            "type": "snapshot",
189            "tree": {
190                "id": "root",
191                "type": "column",
192                "props": { "spacing": 10 },
193                "children": [{
194                    "id": "c1",
195                    "type": "text",
196                    "props": { "content": "hello" },
197                    "children": []
198                }]
199            }
200        }))
201        .unwrap();
202        match msg {
203            IncomingMessage::Snapshot { tree } => {
204                assert_eq!(tree.children.len(), 1);
205                assert_eq!(tree.children[0].id, "c1");
206                assert_eq!(tree.children[0].type_name, "text");
207                assert_eq!(tree.props["spacing"], 10);
208            }
209            _ => panic!("expected Snapshot"),
210        }
211    }
212
213    #[test]
214    fn deserialize_patch_replace_node() {
215        let msg: IncomingMessage = serde_json::from_value(json!({
216            "type": "patch",
217            "ops": [{
218                "op": "replace_node",
219                "path": [0],
220                "node": {
221                    "id": "x",
222                    "type": "text",
223                    "props": {},
224                    "children": []
225                }
226            }]
227        }))
228        .unwrap();
229        match msg {
230            IncomingMessage::Patch { ops } => {
231                assert_eq!(ops.len(), 1);
232                assert_eq!(ops[0].op, "replace_node");
233                assert_eq!(ops[0].path, vec![0]);
234                assert!(ops[0].rest.get("node").is_some());
235            }
236            _ => panic!("expected Patch"),
237        }
238    }
239
240    #[test]
241    fn deserialize_patch_multiple_ops() {
242        let msg: IncomingMessage = serde_json::from_value(json!({
243            "type": "patch",
244            "ops": [
245                { "op": "update_props", "path": [0], "props": { "color": "red" } },
246                { "op": "remove_child", "path": [], "index": 2 }
247            ]
248        }))
249        .unwrap();
250        match msg {
251            IncomingMessage::Patch { ops } => {
252                assert_eq!(ops.len(), 2);
253                assert_eq!(ops[0].op, "update_props");
254                assert_eq!(ops[1].op, "remove_child");
255            }
256            _ => panic!("expected Patch"),
257        }
258    }
259
260    #[test]
261    fn deserialize_effect() {
262        let json = r#"{"type":"effect","id":"e1","kind":"clipboard_read","payload":{}}"#;
263        let msg: IncomingMessage = serde_json::from_str(json).unwrap();
264        match msg {
265            IncomingMessage::Effect { id, kind, payload } => {
266                assert_eq!(id, "e1");
267                assert_eq!(kind, "clipboard_read");
268                assert!(payload.is_object());
269            }
270            _ => panic!("expected Effect"),
271        }
272    }
273
274    #[test]
275    fn deserialize_effect_with_payload() {
276        let msg: IncomingMessage = serde_json::from_value(json!({
277            "type": "effect",
278            "id": "e2",
279            "kind": "clipboard_write",
280            "payload": { "text": "copied" }
281        }))
282        .unwrap();
283        match msg {
284            IncomingMessage::Effect { id, kind, payload } => {
285                assert_eq!(id, "e2");
286                assert_eq!(kind, "clipboard_write");
287                assert_eq!(payload["text"], "copied");
288            }
289            _ => panic!("expected Effect"),
290        }
291    }
292
293    #[test]
294    fn deserialize_widget_op() {
295        let json = r#"{"type":"widget_op","op":"focus","payload":{"target":"input1"}}"#;
296        let msg: IncomingMessage = serde_json::from_str(json).unwrap();
297        match msg {
298            IncomingMessage::WidgetOp { op, payload } => {
299                assert_eq!(op, "focus");
300                assert_eq!(payload["target"], "input1");
301            }
302            _ => panic!("expected WidgetOp"),
303        }
304    }
305
306    #[test]
307    fn deserialize_widget_op_no_payload() {
308        let json = r#"{"type":"widget_op","op":"blur"}"#;
309        let msg: IncomingMessage = serde_json::from_str(json).unwrap();
310        match msg {
311            IncomingMessage::WidgetOp { op, payload } => {
312                assert_eq!(op, "blur");
313                assert!(payload.is_null());
314            }
315            _ => panic!("expected WidgetOp"),
316        }
317    }
318
319    #[test]
320    fn deserialize_subscribe() {
321        let json = r#"{"type":"subscribe","kind":"on_key_press","tag":"keys"}"#;
322        let msg: IncomingMessage = serde_json::from_str(json).unwrap();
323        match msg {
324            IncomingMessage::Subscribe { kind, tag } => {
325                assert_eq!(kind, "on_key_press");
326                assert_eq!(tag, "keys");
327            }
328            _ => panic!("expected Subscribe"),
329        }
330    }
331
332    #[test]
333    fn deserialize_unsubscribe() {
334        let json = r#"{"type":"unsubscribe","kind":"on_key_press"}"#;
335        let msg: IncomingMessage = serde_json::from_str(json).unwrap();
336        match msg {
337            IncomingMessage::Unsubscribe { kind } => {
338                assert_eq!(kind, "on_key_press");
339            }
340            _ => panic!("expected Unsubscribe"),
341        }
342    }
343
344    #[test]
345    fn deserialize_settings() {
346        let json = r#"{"type":"settings","settings":{"default_text_size":18}}"#;
347        let msg: IncomingMessage = serde_json::from_str(json).unwrap();
348        match msg {
349            IncomingMessage::Settings { settings } => {
350                assert_eq!(settings["default_text_size"], 18);
351            }
352            _ => panic!("expected Settings"),
353        }
354    }
355
356    #[test]
357    fn deserialize_window_op() {
358        let msg: IncomingMessage = serde_json::from_value(json!({
359            "type": "window_op",
360            "op": "resize",
361            "window_id": "main",
362            "settings": { "width": 800, "height": 600 }
363        }))
364        .unwrap();
365        match msg {
366            IncomingMessage::WindowOp {
367                op,
368                window_id,
369                settings,
370            } => {
371                assert_eq!(op, "resize");
372                assert_eq!(window_id, "main");
373                assert_eq!(settings["width"], 800);
374                assert_eq!(settings["height"], 600);
375            }
376            _ => panic!("expected WindowOp"),
377        }
378    }
379
380    #[test]
381    fn deserialize_window_op_no_settings() {
382        let json = r#"{"type":"window_op","op":"close","window_id":"popup"}"#;
383        let msg: IncomingMessage = serde_json::from_str(json).unwrap();
384        match msg {
385            IncomingMessage::WindowOp {
386                op,
387                window_id,
388                settings,
389            } => {
390                assert_eq!(op, "close");
391                assert_eq!(window_id, "popup");
392                assert!(settings.is_null());
393            }
394            _ => panic!("expected WindowOp"),
395        }
396    }
397
398    #[test]
399    fn deserialize_malformed_json_missing_field() {
400        let json = r#"{"type":"snapshot"}"#;
401        let result = serde_json::from_str::<IncomingMessage>(json);
402        assert!(result.is_err());
403    }
404
405    #[test]
406    fn deserialize_unknown_type_tag() {
407        let json = r#"{"type":"bogus_message","data":42}"#;
408        let result = serde_json::from_str::<IncomingMessage>(json);
409        assert!(result.is_err());
410    }
411
412    #[test]
413    fn deserialize_invalid_json_syntax() {
414        let json = r#"{"type":"snapshot",,,}"#;
415        let result = serde_json::from_str::<IncomingMessage>(json);
416        assert!(result.is_err());
417    }
418
419    // -----------------------------------------------------------------------
420    // ExtensionCommand deserialization
421    // -----------------------------------------------------------------------
422
423    #[test]
424    fn extension_command_deserializes() {
425        let msg: IncomingMessage = serde_json::from_value(json!({
426            "type": "extension_command",
427            "node_id": "term-1",
428            "op": "write",
429            "payload": { "data": "hello" }
430        }))
431        .unwrap();
432        match msg {
433            IncomingMessage::ExtensionCommand {
434                node_id,
435                op,
436                payload,
437            } => {
438                assert_eq!(node_id, "term-1");
439                assert_eq!(op, "write");
440                assert_eq!(payload["data"], "hello");
441            }
442            _ => panic!("wrong variant"),
443        }
444    }
445
446    #[test]
447    fn extension_commands_deserializes() {
448        let msg: IncomingMessage = serde_json::from_value(json!({
449            "type": "extension_commands",
450            "commands": [
451                { "node_id": "term-1", "op": "write", "payload": { "data": "a" } },
452                { "node_id": "log-1", "op": "append", "payload": { "line": "x" } }
453            ]
454        }))
455        .unwrap();
456        match msg {
457            IncomingMessage::ExtensionCommands { commands } => {
458                assert_eq!(commands.len(), 2);
459                assert_eq!(commands[0].node_id, "term-1");
460                assert_eq!(commands[1].op, "append");
461            }
462            _ => panic!("wrong variant"),
463        }
464    }
465
466    #[test]
467    fn extension_command_with_default_payload() {
468        let json = r#"{"type":"extension_command","node_id":"ext-1","op":"reset"}"#;
469        let msg: IncomingMessage = serde_json::from_str(json).unwrap();
470        match msg {
471            IncomingMessage::ExtensionCommand { payload, .. } => {
472                assert!(payload.is_null());
473            }
474            _ => panic!("wrong variant"),
475        }
476    }
477}