Skip to main content

plushie_core/protocol/
outgoing.rs

1//! Outgoing wire messages: events and response types.
2//!
3//! [`OutgoingEvent`] is the main event struct emitted by the renderer.
4//! Response types ([`EffectResponse`], [`QueryResponse`], etc.) are
5//! serialized in reply to incoming messages.
6
7use serde::Serialize;
8use serde_json::Value;
9
10/// Hint for the renderer's event coalescing system.
11///
12/// Set by event constructors or widget authors via
13/// [`OutgoingEvent::with_coalesce`]. The renderer uses this to decide
14/// whether and how to buffer events during rate-limited delivery. Not
15/// serialized to the wire (renderer-internal metadata).
16///
17/// # For widget authors
18///
19/// Set on events returned from `handle_message()`:
20///
21/// ```ignore
22/// let event = OutgoingEvent::widget_event("cursor_pos", node_id, value)
23///     .with_coalesce(CoalesceHint::Replace);
24/// ```
25///
26/// Events without a hint are always delivered immediately (never
27/// rate-limited), regardless of `event_rate` or `default_event_rate`.
28#[derive(Debug, Clone, PartialEq)]
29pub enum CoalesceHint {
30    /// Keep the latest event, discard intermediates.
31    /// Use for: position reports, state snapshots, progress values --
32    /// anything where only the most recent value matters.
33    Replace,
34    /// Sum the named `value` fields across coalesced events.
35    /// Other fields keep the latest event's values.
36    /// Use for: scroll deltas, velocity changes, counters, anything
37    /// where intermediate values carry magnitude that would be lost
38    /// if only the latest were kept.
39    Accumulate(Vec<String>),
40}
41
42/// An event written to stdout by the renderer.
43///
44/// All events share a flat struct with optional fields. There are two
45/// constructor patterns:
46///
47/// - **Widget events** (click, input, toggle, etc.) use `id` to identify
48///   the source widget. Built via the internal `bare()` constructor.
49/// - **Subscription events** (key_press, cursor_moved, window_opened,
50///   etc.) use `tag` to identify the subscription that requested them.
51///   Built via the internal `tagged()` constructor. The `id` field is empty.
52///
53/// Widget authors emit custom events via
54/// [`widget_event`](Self::widget_event).
55#[derive(Debug, Serialize)]
56pub struct OutgoingEvent {
57    /// Always `"event"`.
58    #[serde(rename = "type")]
59    pub message_type: &'static str,
60    /// Session that produced this event.
61    pub session: String,
62    /// Event type (e.g. `"click"`, `"key_press"`, `"window_opened"`).
63    pub family: String,
64    /// Source widget node ID (widget events) or empty (subscription events).
65    pub id: String,
66    /// Primary value payload (e.g. input text, slider value, selected option,
67    /// or structured data for pointer/window/IME events).
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub value: Option<Value>,
70    /// Subscription tag identifying which subscription requested this event.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub tag: Option<String>,
73    /// Keyboard modifier state at the time of the event.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub modifiers: Option<KeyModifiers>,
76    /// Whether the event was captured (consumed) by an iced widget before
77    /// reaching the subscription listener. Present on keyboard, mouse,
78    /// touch, and IME events; absent on widget-level events.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub captured: Option<bool>,
81    /// Coalescing hint for rate-limited delivery.
82    /// Not serialized to the wire (renderer-internal metadata).
83    ///
84    /// Kept private so nothing outside this crate can serialize or copy
85    /// the field into a typed wire message by accident. Set via
86    /// [`with_coalesce`](Self::with_coalesce), observe via
87    /// [`coalesce_hint`](Self::coalesce_hint).
88    #[serde(skip)]
89    pub(crate) coalesce: Option<CoalesceHint>,
90}
91
92impl OutgoingEvent {
93    /// Mark the event with its capture status.
94    pub fn with_captured(mut self, captured: bool) -> Self {
95        self.captured = Some(captured);
96        self
97    }
98
99    /// Set the session ID for this event.
100    pub fn with_session(mut self, session: impl Into<String>) -> Self {
101        self.session = session.into();
102        self
103    }
104
105    /// Declare that this event can be coalesced during rate-limited
106    /// delivery. Without this hint, the event is always delivered
107    /// immediately regardless of rate settings.
108    pub fn with_coalesce(mut self, hint: CoalesceHint) -> Self {
109        self.coalesce = Some(hint);
110        self
111    }
112
113    /// Current coalesce hint, if any.
114    ///
115    /// Exposed for the renderer's event buffering pipeline; not part of
116    /// the wire protocol.
117    pub fn coalesce_hint(&self) -> Option<&CoalesceHint> {
118        self.coalesce.as_ref()
119    }
120
121    /// Consume the coalesce hint (renderer-internal).
122    pub fn take_coalesce(&mut self) -> Option<CoalesceHint> {
123        self.coalesce.take()
124    }
125
126    /// Set the primary `value` field on this event.
127    ///
128    /// For built-in widget events, `value` carries the widget's primary
129    /// datum (input text, slider position, selected option). Widget
130    /// code wrapping built-in widgets can use this to emit events compatible
131    /// with the built-in shape:
132    ///
133    /// ```ignore
134    /// OutgoingEvent::generic("input", id, value)
135    ///     .with_value(serde_json::Value::String(text))
136    /// ```
137    pub fn with_value(mut self, value: Value) -> Self {
138        self.value = Some(value);
139        self
140    }
141}
142
143/// Serializable representation of keyboard modifiers.
144///
145/// All fields default to `false`, so partial JSON like `{"shift": true}`
146/// deserializes correctly with unset modifiers left as `false`.
147#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, serde::Deserialize)]
148pub struct KeyModifiers {
149    #[serde(default)]
150    /// Whether Shift is held.
151    pub shift: bool,
152    #[serde(default)]
153    /// Whether Control is held.
154    pub ctrl: bool,
155    #[serde(default)]
156    /// Whether Alt is held.
157    pub alt: bool,
158    #[serde(default)]
159    /// Whether the Super/Command key is held.
160    pub logo: bool,
161    #[serde(default)]
162    /// Whether the Command key is held (macOS).
163    pub command: bool,
164}
165
166// ---------------------------------------------------------------------------
167// Widget events (click, input, toggle, slide, select, submit)
168// ---------------------------------------------------------------------------
169
170impl OutgoingEvent {
171    /// Helper to build a bare event with only the common fields.
172    fn bare(family: impl Into<String>, id: impl Into<String>) -> Self {
173        Self {
174            message_type: "event",
175            session: String::new(),
176            family: family.into(),
177            id: id.into(),
178            value: None,
179            tag: None,
180            modifiers: None,
181            captured: None,
182            coalesce: None,
183        }
184    }
185
186    /// Helper to build a subscription-tagged event with no widget id.
187    pub fn tagged(family: impl Into<String>, tag: impl Into<String>) -> Self {
188        Self {
189            message_type: "event",
190            session: String::new(),
191            family: family.into(),
192            id: String::new(),
193            value: None,
194            tag: Some(tag.into()),
195            modifiers: None,
196            captured: None,
197            coalesce: None,
198        }
199    }
200
201    /// Generic widget event with a family string and optional value payload.
202    /// Used for built-in renderer events such as on_open, on_close, and sort.
203    pub fn generic(family: impl Into<String>, id: impl Into<String>, value: Option<Value>) -> Self {
204        Self {
205            value,
206            ..Self::bare(family, id)
207        }
208    }
209
210    /// Convenience constructor for custom widget-emitted events.
211    ///
212    /// Custom widget families must not reuse built-in family strings such
213    /// as `"click"` or `"select"`, because SDK event parsing reserves those
214    /// names for built-in events.
215    pub fn widget_event(
216        family: impl Into<String>,
217        id: impl Into<String>,
218        value: Option<Value>,
219    ) -> Self {
220        let family = family.into();
221        crate::EventType::assert_custom_family(&family);
222        Self::generic(family, id, value)
223    }
224
225    /// Set or construct `click`.
226    pub fn click(id: impl Into<String>) -> Self {
227        Self::bare("click", id)
228    }
229
230    /// Set or construct `input`.
231    pub fn input(id: impl Into<String>, value: impl Into<String>) -> Self {
232        Self {
233            value: Some(Value::String(value.into())),
234            ..Self::bare("input", id)
235        }
236    }
237
238    /// Set or construct `submit`.
239    pub fn submit(id: impl Into<String>, value: impl Into<String>) -> Self {
240        Self {
241            value: Some(Value::String(value.into())),
242            ..Self::bare("submit", id)
243        }
244    }
245
246    /// Set or construct `toggle`.
247    pub fn toggle(id: impl Into<String>, checked: bool) -> Self {
248        Self {
249            value: Some(Value::Bool(checked)),
250            ..Self::bare("toggle", id)
251        }
252    }
253
254    /// Set or construct `slide`.
255    pub fn slide(id: impl Into<String>, value: f64) -> Self {
256        Self {
257            value: Some(serde_json::json!(sanitize_f64(value))),
258            coalesce: Some(CoalesceHint::Replace),
259            ..Self::bare("slide", id)
260        }
261    }
262
263    /// Set or construct `slide_release`.
264    pub fn slide_release(id: impl Into<String>, value: f64) -> Self {
265        Self {
266            value: Some(serde_json::json!(sanitize_f64(value))),
267            ..Self::bare("slide_release", id)
268        }
269    }
270
271    /// Set or construct `select`.
272    pub fn select(id: impl Into<String>, value: impl Into<String>) -> Self {
273        Self {
274            value: Some(Value::String(value.into())),
275            ..Self::bare("select", id)
276        }
277    }
278
279    // -----------------------------------------------------------------------
280    // Keyboard events
281    //
282    // key_press and key_release constructors that depend on iced types
283    // (KeyEventData) are defined in plushie-widget-sdk, not here.
284    // -----------------------------------------------------------------------
285
286    /// Set or construct `modifiers_changed`.
287    pub fn modifiers_changed(tag: impl Into<String>, modifiers: KeyModifiers) -> Self {
288        Self {
289            modifiers: Some(modifiers),
290            coalesce: Some(CoalesceHint::Replace),
291            ..Self::tagged("modifiers_changed", tag)
292        }
293    }
294
295    // -----------------------------------------------------------------------
296    // Mouse events
297    // -----------------------------------------------------------------------
298
299    /// Set or construct `cursor_moved`.
300    pub fn cursor_moved(tag: impl Into<String>, x: f32, y: f32) -> Self {
301        Self {
302            value: Some(serde_json::json!({"x": sanitize_f32(x), "y": sanitize_f32(y)})),
303            coalesce: Some(CoalesceHint::Replace),
304            ..Self::tagged("cursor_moved", tag)
305        }
306    }
307
308    /// Set or construct `cursor_entered`.
309    pub fn cursor_entered(tag: impl Into<String>) -> Self {
310        Self::tagged("cursor_entered", tag)
311    }
312
313    /// Set or construct `cursor_left`.
314    pub fn cursor_left(tag: impl Into<String>) -> Self {
315        Self::tagged("cursor_left", tag)
316    }
317
318    /// Set or construct `button_pressed`.
319    pub fn button_pressed(tag: impl Into<String>, button: impl Into<String>) -> Self {
320        Self {
321            value: Some(Value::String(button.into())),
322            ..Self::tagged("button_pressed", tag)
323        }
324    }
325
326    /// Set or construct `button_released`.
327    pub fn button_released(tag: impl Into<String>, button: impl Into<String>) -> Self {
328        Self {
329            value: Some(Value::String(button.into())),
330            ..Self::tagged("button_released", tag)
331        }
332    }
333
334    /// Set or construct `wheel_scrolled`.
335    pub fn wheel_scrolled(tag: impl Into<String>, delta_x: f32, delta_y: f32, unit: &str) -> Self {
336        Self {
337            value: Some(serde_json::json!({
338                "delta_x": sanitize_f32(delta_x),
339                "delta_y": sanitize_f32(delta_y),
340                "unit": unit,
341            })),
342            coalesce: Some(CoalesceHint::Accumulate(vec![
343                "delta_x".into(),
344                "delta_y".into(),
345            ])),
346            ..Self::tagged("wheel_scrolled", tag)
347        }
348    }
349
350    // -----------------------------------------------------------------------
351    // Touch events
352    // -----------------------------------------------------------------------
353
354    fn touch_event(family: &str, tag: impl Into<String>, finger_id: u64, x: f32, y: f32) -> Self {
355        Self {
356            value: Some(serde_json::json!({
357                "id": finger_id,
358                "x": sanitize_f32(x),
359                "y": sanitize_f32(y),
360            })),
361            ..Self::tagged(family, tag)
362        }
363    }
364
365    /// Set or construct `finger_pressed`.
366    pub fn finger_pressed(tag: impl Into<String>, finger_id: u64, x: f32, y: f32) -> Self {
367        Self::touch_event("finger_pressed", tag, finger_id, x, y)
368    }
369
370    /// Set or construct `finger_moved`.
371    pub fn finger_moved(tag: impl Into<String>, finger_id: u64, x: f32, y: f32) -> Self {
372        Self {
373            coalesce: Some(CoalesceHint::Replace),
374            ..Self::touch_event("finger_moved", tag, finger_id, x, y)
375        }
376    }
377
378    /// Set or construct `finger_lifted`.
379    pub fn finger_lifted(tag: impl Into<String>, finger_id: u64, x: f32, y: f32) -> Self {
380        Self::touch_event("finger_lifted", tag, finger_id, x, y)
381    }
382
383    /// Set or construct `finger_lost`.
384    pub fn finger_lost(tag: impl Into<String>, finger_id: u64, x: f32, y: f32) -> Self {
385        Self::touch_event("finger_lost", tag, finger_id, x, y)
386    }
387
388    // -----------------------------------------------------------------------
389    // IME events
390    // -----------------------------------------------------------------------
391
392    /// Set or construct `ime_opened`.
393    pub fn ime_opened(tag: impl Into<String>) -> Self {
394        Self::tagged("ime_opened", tag)
395    }
396
397    /// Set or construct `ime_preedit`.
398    pub fn ime_preedit(
399        tag: impl Into<String>,
400        text: impl Into<String>,
401        cursor: Option<std::ops::Range<usize>>,
402    ) -> Self {
403        let cursor_val = cursor
404            .map(|r| serde_json::json!({"start": r.start, "end": r.end}))
405            .unwrap_or(serde_json::Value::Null);
406        Self {
407            value: Some(serde_json::json!({"text": text.into(), "cursor": cursor_val})),
408            ..Self::tagged("ime_preedit", tag)
409        }
410    }
411
412    /// Set or construct `ime_commit`.
413    pub fn ime_commit(tag: impl Into<String>, text: impl Into<String>) -> Self {
414        Self {
415            value: Some(serde_json::json!({"text": text.into()})),
416            ..Self::tagged("ime_commit", tag)
417        }
418    }
419
420    /// Set or construct `ime_closed`.
421    pub fn ime_closed(tag: impl Into<String>) -> Self {
422        Self::tagged("ime_closed", tag)
423    }
424
425    // -----------------------------------------------------------------------
426    // Window lifecycle events
427    // -----------------------------------------------------------------------
428
429    /// Set or construct `window_opened`.
430    pub fn window_opened(
431        tag: impl Into<String>,
432        window_id: impl Into<String>,
433        position: Option<(f32, f32)>,
434        width: f32,
435        height: f32,
436        scale_factor: f32,
437    ) -> Self {
438        // x/y mirror window_moved/window_resized: top-level fields, absent
439        // when the platform did not report a position.
440        let mut value = serde_json::json!({
441            "window_id": window_id.into(),
442            "width": sanitize_f32(width),
443            "height": sanitize_f32(height),
444            "scale_factor": sanitize_f32(scale_factor),
445        });
446        if let Some((x, y)) = position {
447            value["x"] = serde_json::json!(sanitize_f32(x));
448            value["y"] = serde_json::json!(sanitize_f32(y));
449        }
450        Self {
451            value: Some(value),
452            ..Self::tagged("window_opened", tag)
453        }
454    }
455
456    /// Window event carrying only a window_id in its value payload.
457    fn window_event(family: &str, tag: impl Into<String>, window_id: impl Into<String>) -> Self {
458        Self {
459            value: Some(serde_json::json!({"window_id": window_id.into()})),
460            ..Self::tagged(family, tag)
461        }
462    }
463
464    /// Set or construct `window_closed`.
465    pub fn window_closed(tag: impl Into<String>, window_id: impl Into<String>) -> Self {
466        Self::window_event("window_closed", tag, window_id)
467    }
468
469    /// Set or construct `window_close_requested`.
470    pub fn window_close_requested(tag: impl Into<String>, window_id: impl Into<String>) -> Self {
471        Self::window_event("window_close_requested", tag, window_id)
472    }
473
474    /// Set or construct `window_moved`.
475    pub fn window_moved(
476        tag: impl Into<String>,
477        window_id: impl Into<String>,
478        x: f32,
479        y: f32,
480    ) -> Self {
481        Self {
482            value: Some(serde_json::json!({
483                "window_id": window_id.into(),
484                "x": sanitize_f32(x),
485                "y": sanitize_f32(y),
486            })),
487            ..Self::tagged("window_moved", tag)
488        }
489    }
490
491    /// Set or construct `window_resized`.
492    pub fn window_resized(
493        tag: impl Into<String>,
494        window_id: impl Into<String>,
495        width: f32,
496        height: f32,
497    ) -> Self {
498        Self {
499            value: Some(serde_json::json!({
500                "window_id": window_id.into(),
501                "width": sanitize_f32(width),
502                "height": sanitize_f32(height),
503            })),
504            ..Self::tagged("window_resized", tag)
505        }
506    }
507
508    /// Set or construct `window_focused`.
509    pub fn window_focused(tag: impl Into<String>, window_id: impl Into<String>) -> Self {
510        Self::window_event("window_focused", tag, window_id)
511    }
512
513    /// Set or construct `window_unfocused`.
514    pub fn window_unfocused(tag: impl Into<String>, window_id: impl Into<String>) -> Self {
515        Self::window_event("window_unfocused", tag, window_id)
516    }
517
518    /// Set or construct `window_rescaled`.
519    pub fn window_rescaled(
520        tag: impl Into<String>,
521        window_id: impl Into<String>,
522        scale_factor: f32,
523    ) -> Self {
524        Self {
525            value: Some(serde_json::json!({
526                "window_id": window_id.into(),
527                "scale_factor": sanitize_f32(scale_factor),
528            })),
529            ..Self::tagged("window_rescaled", tag)
530        }
531    }
532
533    /// Set or construct `file_hovered`.
534    pub fn file_hovered(
535        tag: impl Into<String>,
536        window_id: impl Into<String>,
537        path: impl Into<String>,
538    ) -> Self {
539        Self {
540            value: Some(serde_json::json!({
541                "window_id": window_id.into(),
542                "path": path.into(),
543            })),
544            ..Self::tagged("file_hovered", tag)
545        }
546    }
547
548    /// Set or construct `file_dropped`.
549    pub fn file_dropped(
550        tag: impl Into<String>,
551        window_id: impl Into<String>,
552        path: impl Into<String>,
553    ) -> Self {
554        Self {
555            value: Some(serde_json::json!({
556                "window_id": window_id.into(),
557                "path": path.into(),
558            })),
559            ..Self::tagged("file_dropped", tag)
560        }
561    }
562
563    /// Set or construct `files_hovered_left`.
564    pub fn files_hovered_left(tag: impl Into<String>, window_id: impl Into<String>) -> Self {
565        Self::window_event("files_hovered_left", tag, window_id)
566    }
567
568    // -----------------------------------------------------------------------
569    // Animation / theme / system events
570    // -----------------------------------------------------------------------
571
572    /// Set or construct `animation_frame`.
573    pub fn animation_frame(tag: impl Into<String>, timestamp_millis: u64) -> Self {
574        Self {
575            value: Some(serde_json::json!({"timestamp": timestamp_millis})),
576            coalesce: Some(CoalesceHint::Replace),
577            ..Self::tagged("animation_frame", tag)
578        }
579    }
580
581    /// Set or construct `theme_changed`.
582    pub fn theme_changed(tag: impl Into<String>, mode: impl Into<String>) -> Self {
583        Self {
584            value: Some(Value::String(mode.into())),
585            coalesce: Some(CoalesceHint::Replace),
586            ..Self::tagged("theme_changed", tag)
587        }
588    }
589
590    /// Renderer-side validation diagnostic.
591    ///
592    /// The `id` field on the event envelope is set to `canvas_id` for
593    /// consistency with other canvas events. The `value` payload carries
594    /// the full diagnostic detail including the optional `element_id`.
595    pub fn diagnostic(
596        canvas_id: impl Into<String>,
597        element_id: Option<String>,
598        level: &str,
599        code: &str,
600        message: &str,
601    ) -> Self {
602        Self {
603            value: Some(serde_json::json!({
604                "level": level,
605                "element_id": element_id,
606                "code": code,
607                "message": message,
608            })),
609            ..Self::bare("diagnostic", canvas_id)
610        }
611    }
612
613    // -----------------------------------------------------------------------
614    // PaneGrid events
615    // -----------------------------------------------------------------------
616
617    /// Set or construct `pane_resized`.
618    pub fn pane_resized(id: impl Into<String>, split: impl Into<String>, ratio: f32) -> Self {
619        Self {
620            value: Some(serde_json::json!({"split": split.into(), "ratio": sanitize_f32(ratio)})),
621            coalesce: Some(CoalesceHint::Replace),
622            ..Self::bare("pane_resized", id)
623        }
624    }
625
626    /// Set or construct `pane_dragged`.
627    pub fn pane_dragged(
628        id: impl Into<String>,
629        kind: &str,
630        pane: impl Into<String>,
631        target: Option<String>,
632        region: Option<&str>,
633        edge: Option<&str>,
634    ) -> Self {
635        let mut val = serde_json::json!({"action": kind, "pane": pane.into()});
636        if let Some(t) = target {
637            val["target"] = serde_json::json!(t);
638        }
639        if let Some(r) = region {
640            val["region"] = serde_json::json!(r);
641        }
642        if let Some(e) = edge {
643            val["edge"] = serde_json::json!(e);
644        }
645        Self {
646            value: Some(val),
647            ..Self::bare("pane_dragged", id)
648        }
649    }
650
651    /// Set or construct `pane_clicked`.
652    pub fn pane_clicked(id: impl Into<String>, pane: impl Into<String>) -> Self {
653        Self {
654            value: Some(serde_json::json!({"pane": pane.into()})),
655            ..Self::bare("pane_clicked", id)
656        }
657    }
658
659    /// Set or construct `pane_focus_cycle`.
660    pub fn pane_focus_cycle(id: impl Into<String>, pane: impl Into<String>) -> Self {
661        Self {
662            value: Some(serde_json::json!({"pane": pane.into()})),
663            ..Self::bare("pane_focus_cycle", id)
664        }
665    }
666
667    // -----------------------------------------------------------------------
668    // TextInput paste event
669    // -----------------------------------------------------------------------
670
671    /// Set or construct `paste`.
672    pub fn paste(id: impl Into<String>, text: impl Into<String>) -> Self {
673        Self {
674            value: Some(Value::String(text.into())),
675            ..Self::bare("paste", id)
676        }
677    }
678
679    // -----------------------------------------------------------------------
680    // Scripting key events (no full KeyEventData available)
681    // -----------------------------------------------------------------------
682
683    /// Key press event from scripting (no full KeyEventData).
684    ///
685    /// Produces the same event shape as real key_press events: `key` in
686    /// `value.key`, modifiers in the top-level `modifiers` field. Missing
687    /// modifier fields default to `false`.
688    pub fn scripting_key_press(key: impl Into<String>, modifiers_json: Value) -> Self {
689        let mods: KeyModifiers =
690            serde_json::from_value(modifiers_json).unwrap_or(KeyModifiers::default());
691        Self {
692            modifiers: Some(mods),
693            value: Some(serde_json::json!({"key": key.into()})),
694            ..Self::bare("key_press", String::new())
695        }
696    }
697
698    /// Key release event from scripting (no full KeyEventData).
699    ///
700    /// Produces the same event shape as real key_release events: `key` in
701    /// `value.key`, modifiers in the top-level `modifiers` field. Missing
702    /// modifier fields default to `false`.
703    pub fn scripting_key_release(key: impl Into<String>, modifiers_json: Value) -> Self {
704        let mods: KeyModifiers =
705            serde_json::from_value(modifiers_json).unwrap_or(KeyModifiers::default());
706        Self {
707            modifiers: Some(mods),
708            value: Some(serde_json::json!({"key": key.into()})),
709            ..Self::bare("key_release", String::new())
710        }
711    }
712
713    /// Cursor moved event from scripting.
714    ///
715    /// Uses `f32` to match the real `cursor_moved` event shape (see
716    /// [`Self::cursor_moved`]). Scripting has no precision requirement f64
717    /// meets but f32 doesn't.
718    pub fn scripting_cursor_moved(x: f32, y: f32) -> Self {
719        Self {
720            value: Some(serde_json::json!({
721                "x": sanitize_f32(x),
722                "y": sanitize_f32(y),
723            })),
724            ..Self::bare("cursor_moved", String::new())
725        }
726    }
727
728    /// Scroll event from scripting.
729    ///
730    /// Uses `f32` to match the real `wheel_scrolled` event shape (see
731    /// [`Self::wheel_scrolled`]).
732    pub fn scripting_scroll(delta_x: f32, delta_y: f32) -> Self {
733        Self {
734            value: Some(serde_json::json!({
735                "delta_x": sanitize_f32(delta_x),
736                "delta_y": sanitize_f32(delta_y),
737                "unit": "pixel",
738            })),
739            ..Self::bare("wheel_scrolled", String::new())
740        }
741    }
742
743    // -----------------------------------------------------------------------
744    // ComboBox option hovered event
745    // -----------------------------------------------------------------------
746
747    /// Set or construct `option_hovered`.
748    pub fn option_hovered(id: impl Into<String>, value: impl Into<String>) -> Self {
749        Self {
750            value: Some(Value::String(value.into())),
751            ..Self::bare("option_hovered", id)
752        }
753    }
754
755    // -----------------------------------------------------------------------
756    // Scrollable events
757    // -----------------------------------------------------------------------
758
759    #[allow(clippy::too_many_arguments)]
760    /// Set or construct `scroll`.
761    pub fn scroll(
762        id: impl Into<String>,
763        abs_x: f32,
764        abs_y: f32,
765        rel_x: f32,
766        rel_y: f32,
767        bounds_w: f32,
768        bounds_h: f32,
769        content_w: f32,
770        content_h: f32,
771    ) -> Self {
772        Self {
773            value: Some(serde_json::json!({
774                "absolute_x": sanitize_f32(abs_x), "absolute_y": sanitize_f32(abs_y),
775                "relative_x": sanitize_f32(rel_x), "relative_y": sanitize_f32(rel_y),
776                "bounds_width": sanitize_f32(bounds_w), "bounds_height": sanitize_f32(bounds_h),
777                "content_width": sanitize_f32(content_w), "content_height": sanitize_f32(content_h),
778            })),
779            coalesce: Some(CoalesceHint::Replace),
780            ..Self::bare("scrolled", id)
781        }
782    }
783
784    // -----------------------------------------------------------------------
785    // Unified pointer events
786    //
787    // These constructors produce events with unified families ("press",
788    // "release", "move", "scroll", "enter", "exit", "double_click",
789    // "resize") that carry pointer_type, finger ID, coordinates, and
790    // modifier state.
791    // -----------------------------------------------------------------------
792
793    /// Build a modifiers object for inclusion in pointer event values.
794    fn modifiers_data(modifiers: &KeyModifiers) -> serde_json::Value {
795        serde_json::json!({
796            "shift": modifiers.shift,
797            "ctrl": modifiers.ctrl,
798            "alt": modifiers.alt,
799            "logo": modifiers.logo,
800            "command": modifiers.command,
801        })
802    }
803
804    /// Unified pointer press event.
805    ///
806    /// `pointer_type`: `"mouse"`, `"touch"`, or `"pen"`.
807    /// `finger`: finger ID when `pointer_type` is `"touch"`, `None` otherwise.
808    pub fn pointer_press(
809        id: impl Into<String>,
810        x: f32,
811        y: f32,
812        button: &str,
813        pointer_type: &str,
814        finger: Option<u64>,
815        modifiers: KeyModifiers,
816    ) -> Self {
817        let mut val = serde_json::json!({
818            "x": sanitize_f32(x),
819            "y": sanitize_f32(y),
820            "button": button,
821            "pointer": pointer_type,
822            "modifiers": Self::modifiers_data(&modifiers),
823        });
824        if let Some(f) = finger {
825            val["finger"] = serde_json::json!(f);
826        }
827        Self {
828            value: Some(val),
829            ..Self::bare("press", id)
830        }
831    }
832
833    /// Unified pointer release event.
834    pub fn pointer_release(
835        id: impl Into<String>,
836        x: f32,
837        y: f32,
838        button: &str,
839        pointer_type: &str,
840        finger: Option<u64>,
841        modifiers: KeyModifiers,
842    ) -> Self {
843        let mut val = serde_json::json!({
844            "x": sanitize_f32(x),
845            "y": sanitize_f32(y),
846            "button": button,
847            "pointer": pointer_type,
848            "modifiers": Self::modifiers_data(&modifiers),
849        });
850        if let Some(f) = finger {
851            val["finger"] = serde_json::json!(f);
852        }
853        Self {
854            value: Some(val),
855            ..Self::bare("release", id)
856        }
857    }
858
859    /// Unified pointer move event (coalesceable).
860    pub fn pointer_move(
861        id: impl Into<String>,
862        x: f32,
863        y: f32,
864        pointer_type: &str,
865        finger: Option<u64>,
866        modifiers: KeyModifiers,
867    ) -> Self {
868        let mut val = serde_json::json!({
869            "x": sanitize_f32(x),
870            "y": sanitize_f32(y),
871            "pointer": pointer_type,
872            "modifiers": Self::modifiers_data(&modifiers),
873        });
874        if let Some(f) = finger {
875            val["finger"] = serde_json::json!(f);
876        }
877        Self {
878            value: Some(val),
879            coalesce: Some(CoalesceHint::Replace),
880            ..Self::bare("move", id)
881        }
882    }
883
884    /// Unified pointer scroll event (coalesceable, accumulates deltas).
885    pub fn pointer_scroll(
886        id: impl Into<String>,
887        x: f32,
888        y: f32,
889        delta_x: f32,
890        delta_y: f32,
891        pointer_type: &str,
892        modifiers: KeyModifiers,
893    ) -> Self {
894        Self {
895            value: Some(serde_json::json!({
896                "x": sanitize_f32(x),
897                "y": sanitize_f32(y),
898                "delta_x": sanitize_f32(delta_x),
899                "delta_y": sanitize_f32(delta_y),
900                "pointer": pointer_type,
901                "modifiers": Self::modifiers_data(&modifiers),
902            })),
903            coalesce: Some(CoalesceHint::Accumulate(vec![
904                "delta_x".into(),
905                "delta_y".into(),
906            ])),
907            ..Self::bare("scroll", id)
908        }
909    }
910
911    /// Unified pointer enter event (no data payload).
912    pub fn pointer_enter(id: impl Into<String>) -> Self {
913        Self::bare("enter", id)
914    }
915
916    /// Unified pointer exit event (no data payload).
917    pub fn pointer_exit(id: impl Into<String>) -> Self {
918        Self::bare("exit", id)
919    }
920
921    /// Unified pointer double-click event.
922    pub fn pointer_double_click(
923        id: impl Into<String>,
924        x: f32,
925        y: f32,
926        pointer_type: &str,
927        modifiers: KeyModifiers,
928    ) -> Self {
929        Self {
930            value: Some(serde_json::json!({
931                "x": sanitize_f32(x),
932                "y": sanitize_f32(y),
933                "pointer": pointer_type,
934                "modifiers": Self::modifiers_data(&modifiers),
935            })),
936            ..Self::bare("double_click", id)
937        }
938    }
939
940    /// Unified resize event (for sensor widgets).
941    pub fn resize(id: impl Into<String>, width: f32, height: f32) -> Self {
942        Self {
943            value: Some(serde_json::json!({
944                "width": sanitize_f32(width),
945                "height": sanitize_f32(height),
946            })),
947            coalesce: Some(CoalesceHint::Replace),
948            ..Self::bare("resize", id)
949        }
950    }
951}
952
953// ---------------------------------------------------------------------------
954// Helpers
955// ---------------------------------------------------------------------------
956
957/// Map an `f32` to a JSON value: finite values become a `Number`,
958/// non-finite values become `Null`. Aligns with the wire codec, which
959/// also rewrites non-finite to `null` after the event constructors run.
960/// Earlier code substituted `0.0`, which silently invented a finite
961/// position whenever upstream produced `NaN`/`inf`.
962fn sanitize_f32(v: f32) -> Value {
963    if v.is_finite() {
964        serde_json::json!(v)
965    } else {
966        log::warn!("non-finite f32 ({v}) replaced with null in outgoing event");
967        Value::Null
968    }
969}
970
971/// `f64` counterpart of [`sanitize_f32`]. Non-finite values become
972/// JSON `null` rather than `0.0`.
973fn sanitize_f64(v: f64) -> Value {
974    if v.is_finite() {
975        serde_json::json!(v)
976    } else {
977        log::warn!("non-finite f64 ({v}) replaced with null in outgoing event");
978        Value::Null
979    }
980}
981
982// ---------------------------------------------------------------------------
983// Response types (serialized to stdout in reply to incoming messages)
984// ---------------------------------------------------------------------------
985
986// ---------------------------------------------------------------------------
987// Diagnostic (renderer -> host)
988// ---------------------------------------------------------------------------
989
990/// Severity level for an outgoing diagnostic.
991///
992/// Wire form is a snake_case string (`"info"`, `"warn"`, `"error"`).
993#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
994#[serde(rename_all = "snake_case")]
995pub enum DiagnosticLevel {
996    /// Informational message; not a problem.
997    Info,
998    /// Something irregular but recoverable.
999    Warn,
1000    /// A serious failure that needs host attention, such as malformed
1001    /// input, a protocol fault, or widget code panicking.
1002    Error,
1003}
1004
1005impl std::fmt::Display for DiagnosticLevel {
1006    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1007        match self {
1008            Self::Info => f.write_str("info"),
1009            Self::Warn => f.write_str("warn"),
1010            Self::Error => f.write_str("error"),
1011        }
1012    }
1013}
1014
1015/// A structured diagnostic sent from the renderer to the host.
1016///
1017/// Wire form:
1018///
1019/// ```json
1020/// {
1021///   "type": "diagnostic",
1022///   "session": "s1",
1023///   "level": "warn",
1024///   "diagnostic": {"kind": "font_family_not_found", "family": "Inter"}
1025/// }
1026/// ```
1027///
1028/// The `diagnostic` field is a [`plushie_core::Diagnostic`]
1029/// serialised via its existing `#[serde(tag = "kind")]` representation,
1030/// so hosts can decode directly into the typed enum (or pattern-match
1031/// on the `kind` discriminant) without bespoke parsing.
1032#[derive(Debug, Clone, Serialize)]
1033pub struct DiagnosticMessage {
1034    /// Always `"diagnostic"`.
1035    #[serde(rename = "type")]
1036    pub message_type: &'static str,
1037    /// Session that produced this diagnostic.
1038    pub session: String,
1039    /// Severity.
1040    pub level: DiagnosticLevel,
1041    /// The typed diagnostic payload.
1042    pub diagnostic: crate::Diagnostic,
1043}
1044
1045impl DiagnosticMessage {
1046    /// Construct a new value with empty session.
1047    pub fn new(level: DiagnosticLevel, diagnostic: crate::Diagnostic) -> Self {
1048        Self {
1049            message_type: "diagnostic",
1050            session: String::new(),
1051            level,
1052            diagnostic,
1053        }
1054    }
1055
1056    /// Set the session ID for this diagnostic.
1057    pub fn with_session(mut self, session: impl Into<String>) -> Self {
1058        self.session = session.into();
1059        self
1060    }
1061}
1062
1063/// Response to an effect request, written to stdout as JSONL.
1064#[derive(Debug, Serialize)]
1065pub struct EffectResponse {
1066    #[serde(rename = "type")]
1067    /// Message type.
1068    pub message_type: &'static str,
1069    /// Session.
1070    pub session: String,
1071    /// Target widget ID.
1072    pub id: String,
1073    /// Status.
1074    pub status: &'static str,
1075    #[serde(skip_serializing_if = "Option::is_none")]
1076    /// Operation result payload.
1077    pub result: Option<Value>,
1078    #[serde(skip_serializing_if = "Option::is_none")]
1079    /// Error payload.
1080    pub error: Option<String>,
1081}
1082
1083impl EffectResponse {
1084    /// The effect completed successfully with the given result.
1085    pub fn ok(id: String, result: Value) -> Self {
1086        Self {
1087            message_type: "effect_response",
1088            session: String::new(),
1089            id,
1090            status: "ok",
1091            result: Some(result),
1092            error: None,
1093        }
1094    }
1095
1096    /// The effect failed with the given reason.
1097    pub fn error(id: String, reason: String) -> Self {
1098        Self {
1099            message_type: "effect_response",
1100            session: String::new(),
1101            id,
1102            status: "error",
1103            result: None,
1104            error: Some(reason),
1105        }
1106    }
1107
1108    /// The requested effect kind is not supported by this backend.
1109    /// Distinct from `error`: unsupported means the renderer can't
1110    /// handle this effect at all (e.g. file dialogs in headless mode),
1111    /// not that it tried and failed. The SDK uses this to trigger
1112    /// registered effect stubs or propagate to the app.
1113    pub fn unsupported(id: String) -> Self {
1114        Self {
1115            message_type: "effect_response",
1116            session: String::new(),
1117            id,
1118            status: "unsupported",
1119            result: None,
1120            error: None,
1121        }
1122    }
1123
1124    /// The user cancelled the operation (e.g. closed a file dialog).
1125    /// Distinct from `error`: cancellation is a normal user action,
1126    /// not a failure.
1127    pub fn cancelled(id: String) -> Self {
1128        Self {
1129            message_type: "effect_response",
1130            session: String::new(),
1131            id,
1132            status: "cancelled",
1133            result: None,
1134            error: None,
1135        }
1136    }
1137
1138    /// Set the session ID for this response.
1139    pub fn with_session(mut self, session: impl Into<String>) -> Self {
1140        self.session = session.into();
1141        self
1142    }
1143}
1144
1145/// Acknowledgement that an effect stub was registered or unregistered.
1146/// Sent back to the SDK so it can wait for confirmation before
1147/// proceeding (no timing assumptions about message ordering).
1148#[derive(Debug, Serialize)]
1149pub struct EffectStubAck {
1150    #[serde(rename = "type")]
1151    /// Message type.
1152    pub message_type: &'static str,
1153    /// Session.
1154    pub session: String,
1155    /// Event kind string used on the wire.
1156    pub kind: String,
1157    /// Stub registration state.
1158    pub status: &'static str,
1159}
1160
1161impl EffectStubAck {
1162    /// Set or construct `registered`.
1163    pub fn registered(kind: String) -> Self {
1164        Self {
1165            message_type: "effect_stub_register_ack",
1166            session: String::new(),
1167            kind,
1168            status: "registered",
1169        }
1170    }
1171
1172    /// Construct a failed registration acknowledgement.
1173    pub fn register_error(kind: String) -> Self {
1174        Self {
1175            message_type: "effect_stub_register_ack",
1176            session: String::new(),
1177            kind,
1178            status: "error",
1179        }
1180    }
1181
1182    /// Set or construct `unregistered`.
1183    pub fn unregistered(kind: String) -> Self {
1184        Self {
1185            message_type: "effect_stub_unregister_ack",
1186            session: String::new(),
1187            kind,
1188            status: "unregistered",
1189        }
1190    }
1191
1192    /// Construct a failed unregistration acknowledgement.
1193    pub fn unregister_error(kind: String) -> Self {
1194        Self {
1195            message_type: "effect_stub_unregister_ack",
1196            session: String::new(),
1197            kind,
1198            status: "error",
1199        }
1200    }
1201
1202    /// Return a new value with the session set.
1203    pub fn with_session(mut self, session: impl Into<String>) -> Self {
1204        self.session = session.into();
1205        self
1206    }
1207}
1208
1209/// Response to a Query message.
1210#[derive(Debug, Serialize)]
1211pub struct QueryResponse {
1212    #[serde(rename = "type")]
1213    /// Message type.
1214    pub message_type: &'static str,
1215    /// Session.
1216    pub session: String,
1217    /// Target widget ID.
1218    pub id: String,
1219    /// Target identifier.
1220    pub target: String,
1221    /// Raw bytes (pixels, font, etc.).
1222    pub data: Value,
1223}
1224
1225impl QueryResponse {
1226    /// Construct a new value.
1227    pub fn new(id: String, target: String, data: Value) -> Self {
1228        Self {
1229            message_type: "query_response",
1230            session: String::new(),
1231            id,
1232            target,
1233            data,
1234        }
1235    }
1236
1237    /// Set the session ID for this response.
1238    pub fn with_session(mut self, session: impl Into<String>) -> Self {
1239        self.session = session.into();
1240        self
1241    }
1242}
1243
1244/// Response to an Interact message.
1245#[derive(Debug, Serialize)]
1246pub struct InteractResponse {
1247    #[serde(rename = "type")]
1248    /// Message type.
1249    pub message_type: &'static str,
1250    /// Session.
1251    pub session: String,
1252    /// Target widget ID.
1253    pub id: String,
1254    /// Events.
1255    pub events: Vec<OutgoingEvent>,
1256}
1257
1258impl InteractResponse {
1259    /// Construct a new value.
1260    pub fn new(id: String, events: Vec<OutgoingEvent>) -> Self {
1261        Self {
1262            message_type: "interact_response",
1263            session: String::new(),
1264            id,
1265            events,
1266        }
1267    }
1268
1269    /// Set the session ID for this response and all contained events.
1270    pub fn with_session(mut self, session: impl Into<String>) -> Self {
1271        let session = session.into();
1272        for event in &mut self.events {
1273            event.session.clone_from(&session);
1274        }
1275        self.session = session;
1276        self
1277    }
1278}
1279
1280/// Response to a TreeHash message.
1281///
1282/// Tree hashes capture structural tree data (hash of JSON tree). No pixel data.
1283/// For pixel data, see the `screenshot_response` message type.
1284#[derive(Debug, Serialize)]
1285#[allow(dead_code)]
1286pub struct TreeHashResponse {
1287    #[serde(rename = "type")]
1288    /// Message type.
1289    pub message_type: &'static str,
1290    /// Session.
1291    pub session: String,
1292    /// Target widget ID.
1293    pub id: String,
1294    /// Identifier string.
1295    pub name: String,
1296    /// Hash.
1297    pub hash: String,
1298}
1299
1300#[allow(dead_code)]
1301impl TreeHashResponse {
1302    /// Construct a new value.
1303    pub fn new(id: String, name: String, hash: String) -> Self {
1304        Self {
1305            message_type: "tree_hash_response",
1306            session: String::new(),
1307            id,
1308            name,
1309            hash,
1310        }
1311    }
1312
1313    /// Set the session ID for this response.
1314    pub fn with_session(mut self, session: impl Into<String>) -> Self {
1315        self.session = session.into();
1316        self
1317    }
1318}
1319
1320/// Response to a Screenshot message.
1321///
1322/// The structured fields are serialized here. The optional `rgba` payload is
1323/// injected by the transport encoder so JSON can use base64 while MessagePack
1324/// can use native binary.
1325#[derive(Debug, Serialize)]
1326pub struct ScreenshotResponse {
1327    #[serde(rename = "type")]
1328    /// Message type.
1329    pub message_type: &'static str,
1330    /// Session.
1331    pub session: String,
1332    /// Target widget ID.
1333    pub id: String,
1334    /// Identifier string.
1335    pub name: String,
1336    /// SHA-256 hex hash of RGBA data.
1337    pub hash: String,
1338    /// Rendered width in pixels.
1339    pub width: u32,
1340    /// Rendered height in pixels.
1341    pub height: u32,
1342}
1343
1344impl ScreenshotResponse {
1345    /// Construct a new value.
1346    pub fn new(id: String, name: String, hash: String, width: u32, height: u32) -> Self {
1347        Self {
1348            message_type: "screenshot_response",
1349            session: String::new(),
1350            id,
1351            name,
1352            hash,
1353            width,
1354            height,
1355        }
1356    }
1357
1358    /// Set the session ID for this response.
1359    pub fn with_session(mut self, session: impl Into<String>) -> Self {
1360        self.session = session.into();
1361        self
1362    }
1363}
1364
1365/// Response to a Reset message.
1366#[derive(Debug, Serialize)]
1367pub struct ResetResponse {
1368    #[serde(rename = "type")]
1369    /// Message type.
1370    pub message_type: &'static str,
1371    /// Session.
1372    pub session: String,
1373    /// Target widget ID.
1374    pub id: String,
1375    /// Status.
1376    pub status: &'static str,
1377}
1378
1379impl ResetResponse {
1380    /// Set or construct `ok`.
1381    pub fn ok(id: String) -> Self {
1382        Self {
1383            message_type: "reset_response",
1384            session: String::new(),
1385            id,
1386            status: "ok",
1387        }
1388    }
1389
1390    /// Set the session ID for this response.
1391    pub fn with_session(mut self, session: impl Into<String>) -> Self {
1392        self.session = session.into();
1393        self
1394    }
1395}
1396
1397#[cfg(test)]
1398mod tests {
1399    use super::*;
1400    use serde_json::json;
1401
1402    #[test]
1403    fn effect_stub_register_ack_includes_status() {
1404        let ack = EffectStubAck::registered("file_open".to_string()).with_session("s1");
1405
1406        assert_eq!(
1407            serde_json::to_value(ack).unwrap(),
1408            json!({
1409                "type": "effect_stub_register_ack",
1410                "session": "s1",
1411                "kind": "file_open",
1412                "status": "registered",
1413            })
1414        );
1415    }
1416
1417    #[test]
1418    fn effect_stub_register_error_ack_includes_status() {
1419        let ack = EffectStubAck::register_error("not_real".to_string()).with_session("s1");
1420
1421        assert_eq!(
1422            serde_json::to_value(ack).unwrap(),
1423            json!({
1424                "type": "effect_stub_register_ack",
1425                "session": "s1",
1426                "kind": "not_real",
1427                "status": "error",
1428            })
1429        );
1430    }
1431
1432    #[test]
1433    fn effect_stub_unregister_ack_includes_status() {
1434        let ack = EffectStubAck::unregistered("file_open".to_string()).with_session("s1");
1435
1436        assert_eq!(
1437            serde_json::to_value(ack).unwrap(),
1438            json!({
1439                "type": "effect_stub_unregister_ack",
1440                "session": "s1",
1441                "kind": "file_open",
1442                "status": "unregistered",
1443            })
1444        );
1445    }
1446
1447    #[test]
1448    fn effect_stub_unregister_error_ack_includes_status() {
1449        let ack = EffectStubAck::unregister_error("not_real".to_string()).with_session("s1");
1450
1451        assert_eq!(
1452            serde_json::to_value(ack).unwrap(),
1453            json!({
1454                "type": "effect_stub_unregister_ack",
1455                "session": "s1",
1456                "kind": "not_real",
1457                "status": "error",
1458            })
1459        );
1460    }
1461
1462    #[test]
1463    fn screenshot_response_serializes_structured_fields() {
1464        let response = ScreenshotResponse::new(
1465            "sc1".to_string(),
1466            "homepage".to_string(),
1467            "d4e5f6".to_string(),
1468            1024,
1469            768,
1470        )
1471        .with_session("s1");
1472
1473        assert_eq!(
1474            serde_json::to_value(response).unwrap(),
1475            json!({
1476                "type": "screenshot_response",
1477                "session": "s1",
1478                "id": "sc1",
1479                "name": "homepage",
1480                "hash": "d4e5f6",
1481                "width": 1024,
1482                "height": 768,
1483            })
1484        );
1485    }
1486
1487    #[test]
1488    fn animation_frame_serializes_timestamp_object() {
1489        let event = OutgoingEvent::animation_frame("anim", 16_000).with_session("s1");
1490
1491        assert_eq!(
1492            serde_json::to_value(event).unwrap(),
1493            json!({
1494                "type": "event",
1495                "session": "s1",
1496                "family": "animation_frame",
1497                "id": "",
1498                "tag": "anim",
1499                "value": {
1500                    "timestamp": 16_000,
1501                },
1502            })
1503        );
1504    }
1505
1506    #[test]
1507    fn widget_event_accepts_custom_family() {
1508        let event =
1509            OutgoingEvent::widget_event("star_rating:select", "rating", Some(json!({"value": 5})));
1510
1511        assert_eq!(event.family, "star_rating:select");
1512        assert_eq!(event.id, "rating");
1513    }
1514
1515    #[test]
1516    #[should_panic(
1517        expected = "custom event family \"click\" collides with a built-in event family"
1518    )]
1519    fn widget_event_rejects_builtin_family() {
1520        let _ = OutgoingEvent::widget_event("click", "button", None);
1521    }
1522
1523    #[test]
1524    fn generic_allows_builtin_renderer_events() {
1525        let event = OutgoingEvent::generic("click", "button", None);
1526
1527        assert_eq!(event.family, "click");
1528        assert_eq!(event.id, "button");
1529    }
1530
1531    // -----------------------------------------------------------------------
1532    // Subscription event constructors
1533    //
1534    // These constructors all flow through `Self::tagged()` and carry a
1535    // `tag` instead of an `id`. The wire shape and the coalesce hint
1536    // are part of the contract; both are pinned here.
1537    // -----------------------------------------------------------------------
1538
1539    #[test]
1540    fn cursor_moved_serializes_with_position_value_and_replace_hint() {
1541        let event = OutgoingEvent::cursor_moved("mouse", 10.0, 20.0).with_session("s1");
1542        assert!(matches!(event.coalesce_hint(), Some(CoalesceHint::Replace)));
1543
1544        assert_eq!(
1545            serde_json::to_value(event).unwrap(),
1546            json!({
1547                "type": "event",
1548                "session": "s1",
1549                "family": "cursor_moved",
1550                "id": "",
1551                "tag": "mouse",
1552                "value": {"x": 10.0, "y": 20.0},
1553            })
1554        );
1555    }
1556
1557    #[test]
1558    fn cursor_entered_left_serialize_without_value() {
1559        let entered = OutgoingEvent::cursor_entered("mouse").with_session("s1");
1560        let left = OutgoingEvent::cursor_left("mouse").with_session("s1");
1561        for (event, family) in [(entered, "cursor_entered"), (left, "cursor_left")] {
1562            let val = serde_json::to_value(event).unwrap();
1563            assert_eq!(val["family"], family);
1564            assert_eq!(val["tag"], "mouse");
1565            // Constructors without a payload omit the `value` field.
1566            assert!(val.get("value").is_none(), "{family} should omit value");
1567        }
1568    }
1569
1570    #[test]
1571    fn button_pressed_released_carry_button_string() {
1572        let pressed = OutgoingEvent::button_pressed("mouse", "left");
1573        assert_eq!(
1574            serde_json::to_value(pressed).unwrap()["value"],
1575            json!("left"),
1576        );
1577        let released = OutgoingEvent::button_released("mouse", "right");
1578        assert_eq!(
1579            serde_json::to_value(released).unwrap()["family"],
1580            "button_released",
1581        );
1582    }
1583
1584    #[test]
1585    fn wheel_scrolled_serializes_with_accumulate_hint() {
1586        let event = OutgoingEvent::wheel_scrolled("mouse", 1.0, 2.0, "pixel");
1587        match event.coalesce_hint() {
1588            Some(CoalesceHint::Accumulate(fields)) => {
1589                assert_eq!(fields, &vec!["delta_x".to_string(), "delta_y".to_string()]);
1590            }
1591            other => panic!("expected Accumulate hint, got {other:?}"),
1592        }
1593
1594        let val = serde_json::to_value(event).unwrap();
1595        assert_eq!(val["family"], "wheel_scrolled");
1596        assert_eq!(val["value"]["delta_x"], 1.0);
1597        assert_eq!(val["value"]["delta_y"], 2.0);
1598        assert_eq!(val["value"]["unit"], "pixel");
1599    }
1600
1601    #[test]
1602    fn modifiers_changed_carries_modifiers_with_replace_hint() {
1603        let event = OutgoingEvent::modifiers_changed(
1604            "kbd",
1605            KeyModifiers {
1606                shift: true,
1607                ctrl: true,
1608                alt: false,
1609                logo: false,
1610                command: false,
1611            },
1612        );
1613        assert!(matches!(event.coalesce_hint(), Some(CoalesceHint::Replace)));
1614        let val = serde_json::to_value(event).unwrap();
1615        assert_eq!(val["family"], "modifiers_changed");
1616        assert_eq!(val["modifiers"]["shift"], true);
1617        assert_eq!(val["modifiers"]["ctrl"], true);
1618        assert_eq!(val["modifiers"]["alt"], false);
1619    }
1620
1621    #[test]
1622    fn theme_changed_serializes_with_string_mode_and_replace_hint() {
1623        let event = OutgoingEvent::theme_changed("theme", "dark");
1624        assert!(matches!(event.coalesce_hint(), Some(CoalesceHint::Replace)));
1625        let val = serde_json::to_value(event).unwrap();
1626        assert_eq!(val["family"], "theme_changed");
1627        assert_eq!(val["value"], "dark");
1628    }
1629
1630    #[test]
1631    fn window_event_constructors_share_window_event_shape() {
1632        for (event, family) in [
1633            (OutgoingEvent::window_closed("win", "main"), "window_closed"),
1634            (
1635                OutgoingEvent::window_close_requested("win", "main"),
1636                "window_close_requested",
1637            ),
1638            (
1639                OutgoingEvent::window_focused("win", "main"),
1640                "window_focused",
1641            ),
1642            (
1643                OutgoingEvent::window_unfocused("win", "main"),
1644                "window_unfocused",
1645            ),
1646            (
1647                OutgoingEvent::files_hovered_left("win", "main"),
1648                "files_hovered_left",
1649            ),
1650        ] {
1651            let val = serde_json::to_value(event).unwrap();
1652            assert_eq!(val["family"], family);
1653            assert_eq!(val["tag"], "win");
1654            assert_eq!(val["value"]["window_id"], "main", "for {family}");
1655        }
1656    }
1657
1658    #[test]
1659    fn window_opened_includes_position_when_provided() {
1660        let with_pos =
1661            OutgoingEvent::window_opened("win", "main", Some((50.0, 75.0)), 800.0, 600.0, 2.0);
1662        let val = serde_json::to_value(with_pos).unwrap();
1663        assert_eq!(val["family"], "window_opened");
1664        assert_eq!(val["value"]["window_id"], "main");
1665        assert_eq!(val["value"]["width"], 800.0);
1666        assert_eq!(val["value"]["height"], 600.0);
1667        assert_eq!(val["value"]["scale_factor"], 2.0);
1668        assert_eq!(val["value"]["x"], 50.0);
1669        assert_eq!(val["value"]["y"], 75.0);
1670    }
1671
1672    #[test]
1673    fn window_opened_omits_position_when_absent() {
1674        // Top-level x/y are absent rather than zero when the platform
1675        // didn't report a position; mirrors the documented contract.
1676        let without_pos = OutgoingEvent::window_opened("win", "main", None, 800.0, 600.0, 1.0);
1677        let val = serde_json::to_value(without_pos).unwrap();
1678        assert!(val["value"].get("x").is_none());
1679        assert!(val["value"].get("y").is_none());
1680    }
1681
1682    // -----------------------------------------------------------------------
1683    // Touch event constructors
1684    // -----------------------------------------------------------------------
1685
1686    #[test]
1687    fn finger_event_constructors_share_payload_shape() {
1688        for (event, family, expects_replace) in [
1689            (
1690                OutgoingEvent::finger_pressed("touch", 7, 1.0, 2.0),
1691                "finger_pressed",
1692                false,
1693            ),
1694            (
1695                OutgoingEvent::finger_moved("touch", 7, 1.0, 2.0),
1696                "finger_moved",
1697                true,
1698            ),
1699            (
1700                OutgoingEvent::finger_lifted("touch", 7, 1.0, 2.0),
1701                "finger_lifted",
1702                false,
1703            ),
1704            (
1705                OutgoingEvent::finger_lost("touch", 7, 1.0, 2.0),
1706                "finger_lost",
1707                false,
1708            ),
1709        ] {
1710            let has_replace = matches!(event.coalesce_hint(), Some(CoalesceHint::Replace));
1711            assert_eq!(
1712                has_replace, expects_replace,
1713                "{family} coalesce hint mismatch (only finger_moved should coalesce)"
1714            );
1715            let val = serde_json::to_value(event).unwrap();
1716            assert_eq!(val["family"], family);
1717            assert_eq!(val["tag"], "touch");
1718            assert_eq!(val["value"]["id"], 7);
1719            assert_eq!(val["value"]["x"], 1.0);
1720            assert_eq!(val["value"]["y"], 2.0);
1721        }
1722    }
1723
1724    // -----------------------------------------------------------------------
1725    // Coalesce-hint behavior
1726    //
1727    // The action plan calls out CoalesceHint::Accumulate as
1728    // load-bearing (delta_x/delta_y in wheel_scrolled, pointer_scroll).
1729    // The hint itself is metadata; the renderer's emitter is what
1730    // sums the deltas. This test pins the emitter-input contract: the
1731    // hint is `Accumulate(["delta_x", "delta_y"])` for both
1732    // wheel_scrolled and pointer_scroll.
1733    // -----------------------------------------------------------------------
1734
1735    #[test]
1736    fn pointer_scroll_carries_accumulate_hint_for_deltas() {
1737        let event = OutgoingEvent::pointer_scroll(
1738            "scroller",
1739            5.0,
1740            10.0,
1741            1.5,
1742            -2.5,
1743            "mouse",
1744            KeyModifiers::default(),
1745        );
1746        match event.coalesce_hint() {
1747            Some(CoalesceHint::Accumulate(fields)) => {
1748                let mut sorted: Vec<&str> = fields.iter().map(String::as_str).collect();
1749                sorted.sort();
1750                assert_eq!(sorted, vec!["delta_x", "delta_y"]);
1751            }
1752            other => panic!("expected Accumulate hint, got {other:?}"),
1753        }
1754    }
1755
1756    #[test]
1757    fn coalesce_hint_persists_after_with_session() {
1758        // `with_session` rebuilds the struct via the builder pattern;
1759        // verify the hint isn't dropped on the way through.
1760        let event = OutgoingEvent::cursor_moved("mouse", 0.0, 0.0).with_session("s1");
1761        assert!(matches!(event.coalesce_hint(), Some(CoalesceHint::Replace)));
1762    }
1763
1764    #[test]
1765    fn take_coalesce_consumes_hint_only_once() {
1766        let mut event = OutgoingEvent::cursor_moved("mouse", 0.0, 0.0);
1767        assert!(event.take_coalesce().is_some());
1768        // Second take returns None; the hint is already consumed by
1769        // the renderer's coalesce pipeline.
1770        assert!(event.take_coalesce().is_none());
1771    }
1772
1773    #[test]
1774    fn coalesce_hint_field_is_skipped_on_serialization() {
1775        // The hint is renderer-internal metadata; it must not leak
1776        // onto the wire. Pin the contract explicitly here so a future
1777        // serde tweak can't quietly start exposing it.
1778        let event = OutgoingEvent::cursor_moved("mouse", 0.0, 0.0);
1779        let val = serde_json::to_value(event).unwrap();
1780        assert!(val.get("coalesce").is_none());
1781        let object = val.as_object().unwrap();
1782        assert!(!object.contains_key("coalesce_hint"));
1783    }
1784}