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}