bevy-react 0.1.2

Drive bevy_ui from a React app over an embedded V8 runtime.
Documentation
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
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
//! The Bevy-side endpoint of the Rust<->JS boundary: channel handles plus the
//! id<->entity bookkeeping the reconciler ops are applied against.

use bevy::platform::collections::{HashMap, HashSet};
use bevy::prelude::*;
use bevy::text::{LetterSpacing, LineHeight};
use crossbeam_channel::Receiver;

use crate::protocol::{NodeId, Op, Outbound, Style};

/// The text appearance a `<text>` element/span carries, kept so inheriting child
/// runs (bare strings) can copy it on append without an ECS query (Bevy commands
/// are deferred within an op batch, so the parent's components aren't visible yet).
pub type ResolvedTextStyle = (TextColor, TextFont, LineHeight, LetterSpacing);

/// Carries batches of reconciler ops from the JS thread to Bevy.
pub type OpReceiver = Receiver<Vec<Op>>;
/// Carries everything Bevy sends to the JS side — UI events, app events, and
/// request responses — over one channel (sync `send`, no runtime needed).
///
/// The transport differs by target. On native, the JS thread parks on an async
/// recv, so this is a tokio `UnboundedSender`. On web there is no separate thread
/// (React runs in the page's own engine), so a crossbeam `Sender` drained per
/// frame is enough. Both expose the same `send(msg) -> Result<…>`, so every
/// producer ([`event`](crate::event), [`request`](crate::request)) is target-agnostic.
#[cfg(not(target_arch = "wasm32"))]
pub type OutboundSender = tokio::sync::mpsc::UnboundedSender<Outbound>;
#[cfg(target_arch = "wasm32")]
pub type OutboundSender = crossbeam_channel::Sender<Outbound>;

/// Component stamped on every entity the reconciler creates, recording the JS
/// node id so interaction events can be reported back with the right identity.
#[derive(Component, Debug, Clone, Copy)]
pub struct RNode(pub NodeId);

/// Base + hover + press styles kept on an element that declares `hoverStyle`
/// and/or `pressStyle`. The interaction system re-applies the merged style as
/// the node's `Interaction` changes, entirely on the Bevy side (no round-trip
/// to JS). Absent on elements without variants — they style as before.
#[derive(Component, Debug, Clone, Default)]
pub struct StyleVariants {
    pub base: Option<Style>,
    pub hover: Option<Style>,
    pub press: Option<Style>,
    pub focus: Option<Style>,
}

/// Whether a node with a `focusStyle` [`StyleVariants::focus`] is currently
/// focused. A *mutated* bool (never inserted/removed while the node lives) so the
/// interaction-style system re-merges on `Changed<FocusState>` when focus toggles.
/// Set by the focus observers; read by `apply_interaction_styles`.
#[derive(Component, Debug, Clone, Copy, Default)]
pub struct FocusState(pub bool);

/// Records which pointer handlers a node declared in JS, so the drag-capture
/// system knows whether to emit `pointerDown` / `pointerMove` / `pointerUp` for
/// it, and the hover system whether to emit `pointerEnter` / `pointerLeave`.
/// Stamped (or removed) alongside the node's `Interaction` +
/// `RelativeCursorPosition` whenever any `onPointer*` handler is present.
#[derive(Component, Debug, Clone, Copy, Default)]
pub struct PointerHandlers {
    pub down: bool,
    pub moved: bool,
    pub up: bool,
    pub enter: bool,
    pub leave: bool,
}

/// Whether a node with an `onPointerEnter`/`onPointerLeave` handler currently has
/// the pointer inside it (its `Interaction` is not `None`). Kept so the hover
/// system can emit `pointerEnter`/`pointerLeave` only on the boundary crossing —
/// not on the `Hovered`↔`Pressed` transition of a click. A *component* (rather
/// than a side-table) so it despawns with the node. Stamped alongside
/// `PointerHandlers` when either handler is present; absent otherwise.
#[derive(Component, Debug, Clone, Copy, Default)]
pub struct HoverState(pub bool);

/// Marks a node that declared an `onScroll` handler, so the read-back system
/// (`collect_scroll_events`) reports its `ScrollPosition` changes. The marker is
/// what keeps that query cheap: `ScrollPosition` is a required component of every
/// `Node`, so a bare `Changed<ScrollPosition>` query would fire for every node on
/// its mount frame — scoping to `With<ScrollListener>` walks only onScroll nodes.
/// Inserted/removed alongside the node as its `onScroll` handler comes and goes,
/// mirroring how `Interaction` gates `onClick`.
#[derive(Component, Debug, Clone, Copy, Default)]
pub struct ScrollListener;

/// Marks a node that declared an `onWheel` handler, so
/// [`crate::scroll::collect_wheel_events`] reports raw wheel deltas over it. The
/// marker scopes the wheel hit-test to opted-in nodes; unlike [`ScrollListener`]
/// it works on *any* node (no `overflow: scroll` needed). Inserted/removed as the
/// handler comes and goes, mirroring `ScrollListener`.
#[derive(Component, Debug, Clone, Copy, Default)]
pub struct WheelListener;

/// Per-node wheel step: logical pixels scrolled per mouse-wheel "line", overriding
/// the default. Read by `scroll::apply_scroll`; absent → the default `LINE_HEIGHT`.
/// Stamped from the `scrollStep` prop; only affects `MouseScrollUnit::Line` wheels
/// (trackpads report `Pixel` deltas, which are used raw).
#[derive(Component, Debug, Clone, Copy)]
pub struct ScrollStep(pub f32);

/// The last physical size reported to JS as a `"resize"` event for a `<canvas>`
/// (`collect_canvas_resize_events`). `ComputedNode` is rewritten by layout far
/// more often than the size actually changes, so this compare is the real
/// filter. Starts `(0, 0)`, so the first layout fires an event. A component
/// (not a `Local` map) so a despawn cleans it up automatically.
#[derive(Component, Debug, Clone, Copy, Default)]
pub struct CanvasSizeTracker(pub (u32, u32));

/// A standalone clone of the outbound sender, inserted in [`Plugin::build`] so
/// the request dispatcher and the [`ReactEvents`](crate::ReactEvents) system
/// param can push to JS without depending on [`JsBridge`], which only exists
/// after `Startup`.
#[derive(Resource, Clone)]
pub struct OutboundResource(pub OutboundSender);

/// What kind of `TextSpan`-backed run a node inside a `<text>` element is.
/// Tracked in [`JsBridge::spans`]; a node not in that map isn't a span at all.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpanKind {
    /// A bare-string run with no style of its own: it inherits (and must be
    /// re-sent) its parent `<text>`'s resolved style.
    RawInherited,
    /// A nested/inline `<text>` span carrying its own style, which must NOT
    /// inherit its parent's.
    InlineStyled,
}

/// The Bevy resource holding the live boundary state.
#[derive(Resource)]
pub struct JsBridge {
    /// Incoming op batches from the reconciler.
    pub ops_rx: OpReceiver,
    /// Outgoing UI events to the reconciler (wrapped in [`Outbound::UiEvent`]).
    pub outbound_tx: OutboundSender,
    /// Maps reconciler node ids to their spawned entities.
    pub nodes: HashMap<NodeId, Entity>,
    /// The last applied props per node (event-like fields stripped — see
    /// [`crate::protocol::Props::split_events`]). Every [`crate::protocol::Op::Update`]
    /// merges its delta into this retained state, so the apply path always works
    /// from the full merged props even though only the changed fields crossed
    /// the boundary. Seeded on create.
    /// Boxed: `Props` is several KB by value (four inline `Style`s), and the
    /// update path moves entries out of and back into the map per op.
    pub props_cache: HashMap<NodeId, Box<crate::protocol::Props>>,
    /// Resolved text style of each `<text>` element/span, for span inheritance.
    pub text_styles: HashMap<NodeId, ResolvedTextStyle>,
    /// Node ids whose text content lives in a `TextSpan` component (vs a `Text`),
    /// so `Op::UpdateText` updates the right component, and what [`SpanKind`]
    /// each one is. Nodes absent from the map hold a plain `Text` (or no text).
    pub spans: HashMap<NodeId, SpanKind>,
    /// Node ids that are `editableText` inputs, so an `Update` knows to push a
    /// diverging `value` into the live `EditableText` buffer.
    pub editable_inputs: HashSet<NodeId>,
    /// Node ids that are `<surface>` detached roots. Their subtree renders into an
    /// offscreen image via a dedicated UI camera, so they must NOT be parented into
    /// the on-screen Bevy hierarchy: child-attach ops skip Bevy parenting for these.
    pub surfaces: HashSet<NodeId>,
    /// The last text value emitted to JS for each `editableText`, used to dedup
    /// `TextEditChange` (which also fires on cursor moves) into real `"change"`s.
    pub editable_values: HashMap<NodeId, String>,
    /// The last selection (anchor, focus) byte offsets emitted to JS per
    /// `editableText`, used to dedup `"select"` events. Pre-seeded by a controlled
    /// selection write so its echoed `TextEditChange` doesn't re-emit.
    pub editable_selections: HashMap<NodeId, (usize, usize)>,
    /// `editableText` ids with an `onSelect` handler. Selection moves are very
    /// high-frequency, so `"select"` is only emitted for nodes in this set.
    pub editable_select_handlers: HashSet<NodeId>,
    /// `editableText` ids with an `onFocus`/`onBlur` handler — gates `"focus"`/
    /// `"blur"` emission the same way.
    pub editable_focus_handlers: HashSet<NodeId>,
    /// Controlled selection (anchor, focus) byte offsets awaiting application to
    /// the live `EditableText`, drained by `apply_pending_selections`.
    pub editable_pending_selection: HashMap<NodeId, (usize, usize)>,
    /// The last `ScrollPosition` emitted to JS (or written by a controlled
    /// `scrollTop`/`scrollLeft`) per node. Dedups `"scroll"` events and breaks the
    /// controlled-component echo loop: a programmatic write-back equal to this is
    /// not re-emitted. Only nodes with an `onScroll` handler appear here.
    pub scroll_positions: HashMap<NodeId, Vec2>,
    /// Authoritative ordered children per parent (incl. `ROOT_ID`), stored as a
    /// doubly-linked sibling list (per-child links here, per-parent ends in
    /// [`Self::child_list`]) so detach/append/insert are O(1) regardless of sibling
    /// count. Bevy's `Children` component can't be read mid-batch — `Commands`
    /// hierarchy ops are deferred to the next sync point — so this mirror is the
    /// source of truth for child order; `apply_js_ops` syncs it into the ECS with one
    /// `replace_children` per structurally-changed parent per batch.
    pub siblings: HashMap<NodeId, SiblingLinks>,
    /// Ends of each parent's ordered child list (entry present iff non-empty).
    pub child_list: HashMap<NodeId, ChildList>,
    /// Reverse lookup (child → its current parent) so a re-parent or reorder can detach
    /// the child from its old parent's ordered list before re-inserting it.
    pub parent_of: HashMap<NodeId, NodeId>,
    /// React-tree parentage of `<surface>` detached roots: surface id → its React
    /// parent id, plus the reverse (parent → its surface children). A surface is kept
    /// OUT of `siblings`/`parent_of` (it's not a Bevy child, and counting it would
    /// skew sibling ordering), so its structural position lives here instead. This
    /// lets `Op::Remove` of an *ancestor* despawn the detached surface — which Bevy's
    /// recursive despawn can't reach, since the surface has no `ChildOf`.
    pub surface_parent: HashMap<NodeId, NodeId>,
    pub child_surfaces: HashMap<NodeId, Vec<NodeId>>,
}

/// Doubly-linked sibling entry (present iff the node is attached to a parent).
#[derive(Clone, Copy, Default)]
pub struct SiblingLinks {
    prev: Option<NodeId>,
    next: Option<NodeId>,
}

/// Ends of a parent's ordered child list.
#[derive(Clone, Copy)]
pub struct ChildList {
    head: NodeId,
    tail: NodeId,
}

impl JsBridge {
    pub fn new(ops_rx: OpReceiver, outbound_tx: OutboundSender, root: Entity) -> Self {
        let mut nodes = HashMap::new();
        // ROOT_ID (0) always resolves to the UI root entity.
        nodes.insert(crate::protocol::ROOT_ID, root);
        Self {
            ops_rx,
            outbound_tx,
            nodes,
            props_cache: HashMap::new(),
            text_styles: HashMap::new(),
            spans: HashMap::new(),
            editable_inputs: HashSet::new(),
            surfaces: HashSet::new(),
            editable_values: HashMap::new(),
            editable_selections: HashMap::new(),
            editable_select_handlers: HashSet::new(),
            editable_focus_handlers: HashSet::new(),
            editable_pending_selection: HashMap::new(),
            scroll_positions: HashMap::new(),
            siblings: HashMap::new(),
            child_list: HashMap::new(),
            parent_of: HashMap::new(),
            surface_parent: HashMap::new(),
            child_surfaces: HashMap::new(),
        }
    }

    /// Record a `<surface>`'s React parent (detaching it from any previous one first),
    /// so a later removal of an ancestor can find and despawn this detached root.
    pub fn attach_surface(&mut self, surface: NodeId, parent: NodeId) {
        self.detach_surface(surface);
        self.surface_parent.insert(surface, parent);
        self.child_surfaces.entry(parent).or_default().push(surface);
    }

    /// Unlink `surface` from its current React parent's surface list (if any). Called
    /// before a re-`Append`/`Insert` (a reorder/re-parent) and on removal.
    pub fn detach_surface(&mut self, surface: NodeId) {
        if let Some(parent) = self.surface_parent.remove(&surface)
            && let Some(list) = self.child_surfaces.get_mut(&parent)
        {
            list.retain(|&id| id != surface);
        }
    }

    /// Every detached surface id structurally **under** `node` — its surface children,
    /// recursively through normal descendants (the sibling lists) and nested surfaces —
    /// removing their parentage bookkeeping as it goes. Does NOT include `node` itself
    /// (a surface removed directly is handled by its own `Remove`). Used so `Op::Remove`
    /// despawns surfaces that Bevy's recursive despawn of `node` can't reach.
    pub fn surfaces_under(&mut self, node: NodeId) -> Vec<NodeId> {
        let mut out = Vec::new();
        self.collect_surfaces(node, &mut out);
        out
    }

    fn collect_surfaces(&mut self, node: NodeId, out: &mut Vec<NodeId>) {
        if let Some(surfaces) = self.child_surfaces.remove(&node) {
            for surface in surfaces {
                self.surface_parent.remove(&surface);
                out.push(surface);
                // A surface can itself host nested surfaces.
                self.collect_surfaces(surface, out);
            }
        }
        let kids: Vec<NodeId> = self.children_of(node).collect();
        for kid in kids {
            self.collect_surfaces(kid, out);
        }
    }

    /// Unlink `child` from its current parent's ordered children list (if any). Called
    /// before an `Append`/`Insert` so a reorder or re-parent doesn't leave a stale
    /// duplicate in the shadow tree, and on removal. O(1): patches the doubly-linked
    /// neighbors and the parent's list ends.
    pub fn detach(&mut self, child: NodeId) {
        let Some(parent) = self.parent_of.remove(&child) else {
            return;
        };
        let Some(links) = self.siblings.remove(&child) else {
            return;
        };
        if let Some(prev) = links.prev
            && let Some(l) = self.siblings.get_mut(&prev)
        {
            l.next = links.next;
        }
        if let Some(next) = links.next
            && let Some(l) = self.siblings.get_mut(&next)
        {
            l.prev = links.prev;
        }
        if let Some(list) = self.child_list.get_mut(&parent) {
            match (links.prev, links.next) {
                // Only child: the parent's list is now empty.
                (None, None) => {
                    self.child_list.remove(&parent);
                }
                (None, Some(next)) => list.head = next,
                (Some(prev), None) => list.tail = prev,
                (Some(_), Some(_)) => {}
            }
        }
    }

    /// Attach `child` as `parent`'s last child (detaching it from any previous
    /// position first). O(1).
    pub fn append_child(&mut self, parent: NodeId, child: NodeId) {
        self.detach(child);
        self.parent_of.insert(child, parent);
        match self.child_list.get_mut(&parent) {
            Some(list) => {
                let old_tail = list.tail;
                if let Some(l) = self.siblings.get_mut(&old_tail) {
                    l.next = Some(child);
                }
                self.siblings.insert(
                    child,
                    SiblingLinks {
                        prev: Some(old_tail),
                        next: None,
                    },
                );
                list.tail = child;
            }
            None => {
                self.siblings.insert(child, SiblingLinks::default());
                self.child_list.insert(
                    parent,
                    ChildList {
                        head: child,
                        tail: child,
                    },
                );
            }
        }
    }

    /// Attach `child` immediately before `before` under `parent` (detaching `child`
    /// from any previous position first). Falls back to appending when `before` is not
    /// currently a child of `parent` — the same fallback the old index-based path had
    /// (`position(..).unwrap_or(len)`), and the path taken when `before` is a
    /// `<surface>` (which never enters the sibling list). O(1).
    pub fn insert_before(&mut self, parent: NodeId, child: NodeId, before: NodeId) {
        self.detach(child);
        if self.parent_of.get(&before) != Some(&parent) {
            self.append_child(parent, child);
            return;
        }
        self.parent_of.insert(child, parent);
        let before_links = self
            .siblings
            .get_mut(&before)
            .expect("attached child has sibling links");
        let prev = before_links.prev;
        before_links.prev = Some(child);
        self.siblings.insert(
            child,
            SiblingLinks {
                prev,
                next: Some(before),
            },
        );
        match prev {
            Some(p) => {
                if let Some(l) = self.siblings.get_mut(&p) {
                    l.next = Some(child);
                }
            }
            None => {
                if let Some(list) = self.child_list.get_mut(&parent) {
                    list.head = child;
                }
            }
        }
    }

    /// Iterate `parent`'s children in order (walks the sibling links).
    pub fn children_of(&self, parent: NodeId) -> impl Iterator<Item = NodeId> + '_ {
        let mut cursor = self.child_list.get(&parent).map(|l| l.head);
        std::iter::from_fn(move || {
            let id = cursor?;
            cursor = self.siblings.get(&id).and_then(|l| l.next);
            Some(id)
        })
    }

    /// Drop all per-node side-table data for a single node id. Covers the `NodeId`-keyed
    /// data tables only — NOT the structural `siblings`/`child_list`/`parent_of` maps
    /// (handled by `forget_subtree`/`detach`) nor the surface parentage maps `surface_parent`/
    /// `child_surfaces` (handled by `attach_surface`/`detach_surface`/`surfaces_under`).
    fn forget_node_data(&mut self, id: NodeId) {
        self.nodes.remove(&id);
        self.props_cache.remove(&id);
        self.text_styles.remove(&id);
        self.spans.remove(&id);
        self.editable_inputs.remove(&id);
        self.surfaces.remove(&id);
        self.editable_values.remove(&id);
        self.editable_selections.remove(&id);
        self.editable_select_handlers.remove(&id);
        self.editable_focus_handlers.remove(&id);
        self.editable_pending_selection.remove(&id);
        self.scroll_positions.remove(&id);
    }

    /// Drop `child` and its whole subtree from the shadow tree. React emits a `Remove`
    /// only for the root of a removed subtree (Bevy despawns the descendants
    /// recursively), so we recurse to keep the structural maps bounded and to prune
    /// every node's per-node side-table data (via `forget_node_data`) — otherwise
    /// descendant ids would linger as stale entity handles until the next `Op::Reset`.
    /// Does not unlink the root from its parent's ordered list; call `detach` for that.
    pub fn forget_subtree(&mut self, child: NodeId) {
        self.forget_node_data(child);
        let grandkids: Vec<NodeId> = self.children_of(child).collect();
        self.child_list.remove(&child);
        for grandkid in grandkids {
            self.parent_of.remove(&grandkid);
            self.siblings.remove(&grandkid);
            self.forget_subtree(grandkid);
        }
    }
}