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