Skip to main content

bevy_react/protocol/
outbound.rs

1//! Everything Bevy sends to JS: [`UiEvent`] and the [`Outbound`] envelope.
2
3use serde::{Deserialize, Serialize};
4
5use super::NodeId;
6
7/// An interaction event sent from Bevy back into JS, where the reconciler
8/// dispatches it to the matching React handler.
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
10#[serde(rename_all = "camelCase")]
11pub struct UiEvent {
12    pub id: NodeId,
13    /// `"click"`, a pointer kind (`"pointerDown"` / `"pointerMove"` /
14    /// `"pointerUp"` / `"pointerEnter"` / `"pointerLeave"`), `"scroll"`,
15    /// `"wheel"`, a `canvas`'s `"resize"`, or one of an `editableText`'s
16    /// `"change"` / `"select"` / `"focus"` / `"blur"` events.
17    pub kind: String,
18    /// Cursor x within the node, normalized to `0..1` (left→right). Present only
19    /// for pointer events; `None` for `"click"`.
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub x: Option<f32>,
22    /// Cursor y within the node, normalized to `0..1` (top→bottom). Present only
23    /// for pointer events; `None` for `"click"`.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub y: Option<f32>,
26    /// Absolute cursor x in window logical pixels (left→right, top-left origin).
27    /// Present only for pointer events; lets a handler drag a node across the
28    /// screen (the normalized `x`/`y` are clamped to the node and can't).
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub client_x: Option<f32>,
31    /// Absolute cursor y in window logical pixels (top→bottom). Present only for
32    /// pointer events; see [`client_x`](Self::client_x).
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub client_y: Option<f32>,
35    /// Which mouse button fired, in DOM `MouseEvent.button` numbering:
36    /// `0` left/primary, `1` middle/auxiliary, `2` right/secondary. Present for
37    /// `"pointerDown"`/`"pointerMove"`/`"pointerUp"`; absent for `"click"`
38    /// (primary-only, like DOM `click`) and hover/scroll/text events.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub button: Option<u8>,
41    /// The new text of an `editableText`. Present only for `"change"` events.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub value: Option<String>,
44    /// Selection anchor, a UTF-8 **byte** offset. Present only for `"select"`.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub selection_start: Option<usize>,
47    /// Selection focus, a UTF-8 **byte** offset. Present only for `"select"`.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub selection_end: Option<usize>,
50    /// `"forward"` (anchor ≤ focus), `"backward"`, or `"none"` (collapsed).
51    /// Present only for `"select"`.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub selection_direction: Option<String>,
54    /// Whether an IME composition is in progress. Present on an `editableText`'s
55    /// `"change"` / `"select"` events.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub composing: Option<bool>,
58    /// Vertical scroll offset (logical px) → `ScrollPosition.y`. Present only for
59    /// `"scroll"` events.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub scroll_top: Option<f32>,
62    /// Horizontal scroll offset (logical px) → `ScrollPosition.x`. Present only for
63    /// `"scroll"` events.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub scroll_left: Option<f32>,
66    /// Raw horizontal wheel delta (the frame's accumulated scroll). Present only
67    /// for `"wheel"` events; interpret with [`delta_mode`](Self::delta_mode).
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub delta_x: Option<f32>,
70    /// Raw vertical wheel delta. Present only for `"wheel"` events; positive is a
71    /// wheel-down / scroll-forward gesture, matching DOM `WheelEvent.deltaY`.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub delta_y: Option<f32>,
74    /// How to read the wheel deltas: `"line"` (mouse notches — scale by your own
75    /// per-line distance) or `"pixel"` (trackpad — already in pixels). Mirrors
76    /// DOM `WheelEvent.deltaMode`. Present only for `"wheel"` events.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub delta_mode: Option<String>,
79    /// New logical (CSS px) width of a `canvas`'s laid-out box. Present only for
80    /// `"resize"` events, which fire on first layout (0 → W×H) and whenever the
81    /// physical pixel size changes (including a DPR change at constant logical
82    /// size). The surface was cleared — redraw.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub width: Option<f32>,
85    /// New logical height of a `canvas`'s laid-out box. Present only for
86    /// `"resize"` events; see [`width`](Self::width).
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub height: Option<f32>,
89}
90
91/// Everything that flows Bevy -> JS over the single outbound channel. Internally
92/// tagged (`t`) so `serde_v8` produces a plain JS object the JS event loop can
93/// `switch` on. Each variant serializes to a map, as internal tagging requires.
94#[derive(Debug, Clone, Serialize)]
95#[serde(tag = "t", rename_all = "camelCase")]
96pub enum Outbound {
97    /// A UI interaction on a reconciler node (the original click path).
98    UiEvent { event: UiEvent },
99    /// A named Bevy -> React app event (e.g. `"user.disconnected"`). `value` is
100    /// the payload, pre-serialized so this channel stays a single concrete type.
101    Event {
102        name: String,
103        value: serde_json::Value,
104    },
105    /// A reply to a React -> Bevy request, correlated by the request `id`.
106    Response { id: u64, result: ResponseResult },
107    /// A token-tagged animation driver settled: `finished` is `true` on natural
108    /// completion, `false` on interruption. `token` correlates the JS completion
109    /// callback registered when the driver was assigned.
110    AnimationFinished {
111        id: crate::animations::SharedId,
112        token: u64,
113        finished: bool,
114    },
115    /// Hot-reload sentinel: make the JS event loop exit so the runtime rebuilds.
116    Reload,
117}
118
119/// The outcome of a React -> Bevy request. Internally tagged (`status`) so JS
120/// reads `result.status === "ok"`. The error is a message, surfaced to JS as a
121/// rejected promise — the typed success value is the only thing in the schema.
122#[derive(Debug, Clone, Serialize)]
123#[serde(tag = "status", rename_all = "camelCase")]
124pub enum ResponseResult {
125    Ok { value: serde_json::Value },
126    Err { message: String },
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    /// A `change` event serializes its new text as camelCase `value`, while the
134    /// pointer-only fields stay omitted.
135    #[test]
136    fn serializes_change_event_with_value() {
137        let ev = UiEvent {
138            id: 7,
139            kind: "change".into(),
140            value: Some("hello".into()),
141            ..Default::default()
142        };
143        let v = serde_json::to_value(&ev).expect("serializable");
144        assert_eq!(v["kind"], "change");
145        assert_eq!(v["value"], "hello");
146        assert!(v.get("clientX").is_none(), "pointer fields omitted");
147        assert!(v.get("button").is_none(), "button omitted on text events");
148    }
149
150    /// A pointer event carries the DOM button number; button-less events omit it
151    /// entirely (see the `serializes_change_event_with_value` assertion above).
152    #[test]
153    fn serializes_pointer_event_with_button() {
154        let ev = UiEvent {
155            id: 3,
156            kind: "pointerDown".into(),
157            button: Some(2),
158            ..Default::default()
159        };
160        let v = serde_json::to_value(&ev).expect("serializable");
161        assert_eq!(v["kind"], "pointerDown");
162        assert_eq!(v["button"], 2);
163    }
164
165    /// A `"resize"` UI event serializes its logical size and omits every other
166    /// optional field.
167    #[test]
168    fn serializes_resize_ui_event() {
169        let v = serde_json::to_value(Outbound::UiEvent {
170            event: UiEvent {
171                id: 5,
172                kind: "resize".into(),
173                width: Some(300.0),
174                height: Some(150.0),
175                ..Default::default()
176            },
177        })
178        .unwrap();
179        assert_eq!(v["t"], "uiEvent");
180        let ev = &v["event"];
181        assert_eq!(ev["id"], 5);
182        assert_eq!(ev["kind"], "resize");
183        assert_eq!(ev["width"], 300.0);
184        assert_eq!(ev["height"], 150.0);
185        assert!(ev.get("x").is_none() && ev.get("scrollTop").is_none());
186    }
187}