Skip to main content

bevy_react/
plugin.rs

1//! The public Bevy plugin: wires the JS thread, channels, UI root, and hot
2//! reload into a consumer's `App`.
3
4use std::path::PathBuf;
5
6use crate::animations::{
7    AnimationCommand, AnimationSet, AnimationSettled, ReactUiAnimationsPlugin,
8};
9use bevy::asset::embedded_asset;
10use bevy::prelude::*;
11use bevy::window::CustomCursorImage;
12
13use crate::bridge::{JsBridge, OpReceiver, OutboundResource, OutboundSender};
14use crate::event::ReactEventRegistry;
15use crate::host::{self, HostConfig, HostSenders};
16use crate::message::{ReactAppExt, ReactMessage, ReactRegistry};
17use crate::protocol::{Op, Outbound};
18use crate::reconcile::{
19    OpApplyStats, apply_interaction_styles, apply_js_ops, apply_pending_selections,
20    apply_surface_interaction_styles, collect_canvas_resize_events, collect_hover_events,
21    collect_pointer_events, collect_scroll_events, collect_surface_clicks,
22    collect_surface_hover_events, collect_surface_pointer_events, collect_ui_events,
23    on_focus_gained, on_focus_lost, on_text_edit_change, sync_editable_a11y,
24};
25use crate::request::{RawRequest, ReactRequestRegistry, RequestReceiver, dispatch_react_requests};
26
27/// Whether the React UI currently owns the mouse pointer. Refreshed every frame
28/// in [`PointerCaptureSet`]; world-input systems (a 3D camera controller, picking,
29/// …) should consult it and ignore the mouse when it reports captured, so a UI
30/// drag or click doesn't also drive the scene.
31///
32/// Order such a system after the set to read the current frame's state:
33/// ```no_run
34/// # use bevy::prelude::*;
35/// # use bevy_react::PointerCaptureSet;
36/// # fn orbit_camera() {}
37/// # let mut app = App::new();
38/// app.add_systems(Update, orbit_camera.after(PointerCaptureSet));
39/// ```
40#[derive(Resource, Default, Debug, Clone, Copy)]
41pub struct PointerCapture {
42    /// A bevy-react element is being dragged (an `onPointer*` press is in
43    /// progress). Stays true for the whole gesture — even after the cursor leaves
44    /// the element's bounds — until the button is released.
45    pub dragging: bool,
46    /// The pointer is over an interactive UI element (its `Interaction` is
47    /// `Hovered` or `Pressed`).
48    pub over_ui: bool,
49    /// The UI consumed this frame's wheel input: a scroll container actually
50    /// moved, an `onWheel` listener claimed the delta, or an overlay (devtools
51    /// pick mode) owns the pointer. Unlike `over_ui`, merely hovering
52    /// interactive UI does not set this — a world wheel-consumer (a zoom
53    /// camera) can gate on it alone to keep zooming over pass-through elements.
54    pub wheel_captured: bool,
55}
56
57impl PointerCapture {
58    /// The UI owns the current pointer input; world systems should ignore the
59    /// mouse. True while dragging a UI element, while over interactive UI, or
60    /// when the UI consumed this frame's wheel. Coarse by design — consumers
61    /// wanting finer behavior (e.g. a camera that zooms over pass-through UI)
62    /// read the individual fields instead.
63    pub fn is_captured(&self) -> bool {
64        self.dragging || self.over_ui || self.wheel_captured
65    }
66}
67
68/// System set in which [`PointerCapture`] is refreshed each frame. Order your
69/// world-input systems `.after(PointerCaptureSet)` so they see this frame's state.
70#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
71pub struct PointerCaptureSet;
72
73/// Adds a React-driven `bevy_ui` layer to a Bevy `App`.
74///
75/// Point it at a built JS bundle (see the `bevy-react` npm package). The plugin
76/// spawns the dedicated JS thread, applies the reconciler's ops to the ECS,
77/// reports interactions back to React, and — unless disabled — hot reloads the
78/// app when the bundle changes on disk. In dev builds it also enables the
79/// devtools inspector (toggled with `F12`; see [`Self::devtools`]).
80pub struct ReactUiPlugin {
81    bundle: PathBuf,
82    hot_reload: bool,
83    animations: bool,
84    default_font: Option<PathBuf>,
85    named_fonts: Vec<(String, PathBuf)>,
86    custom_cursors: Vec<(String, PathBuf, (u16, u16))>,
87    #[cfg(feature = "devtools")]
88    devtools: crate::devtools::DevtoolsConfig,
89}
90
91impl ReactUiPlugin {
92    /// Create the plugin for the given built app bundle (`app.js`). The build
93    /// emits a `vendor.js` beside it (react + the bevy-react runtime, loaded once);
94    /// both must exist. Hot reload (React Fast Refresh — edits preserve component
95    /// state) and the Reanimated-style animations engine are enabled by default.
96    ///
97    /// The plugin does **not** spawn a camera — `bevy_ui` needs one to render, so
98    /// your app must provide it (a `Camera2d`, or any camera that renders UI).
99    pub fn new(bundle: impl Into<PathBuf>) -> Self {
100        Self {
101            bundle: bundle.into(),
102            hot_reload: true,
103            animations: true,
104            default_font: None,
105            named_fonts: Vec::new(),
106            custom_cursors: Vec::new(),
107            #[cfg(feature = "devtools")]
108            devtools: Default::default(),
109        }
110    }
111
112    /// Configure the devtools inspector, on by default in dev builds (release
113    /// builds never run it, even when compiled in). Every
114    /// [`DevtoolsConfig`](crate::DevtoolsConfig) field has a default, so
115    /// override only what you need:
116    ///
117    /// ```no_run
118    /// # use bevy::prelude::*;
119    /// # use bevy_react::{DevtoolsConfig, ReactUiPlugin};
120    /// # let mut app = App::new();
121    /// app.add_plugins(ReactUiPlugin::new("ui/dist/app.js").devtools(DevtoolsConfig {
122    ///     toggle_key: KeyCode::F1,
123    ///     settings_path: Some(".config/devtools.json".into()),
124    ///     ..default()
125    /// }));
126    /// // or disable devtools entirely
127    /// app.add_plugins(ReactUiPlugin::new("ui/dist/app.js").devtools(DevtoolsConfig {
128    ///     enabled: false,
129    ///     ..default()
130    /// }));
131    /// ```
132    #[cfg(feature = "devtools")]
133    pub fn devtools(mut self, config: crate::devtools::DevtoolsConfig) -> Self {
134        self.devtools = config;
135        self
136    }
137
138    /// Enable/disable watching the bundle and hot reloading on change.
139    pub fn hot_reload(mut self, yes: bool) -> Self {
140        self.hot_reload = yes;
141        self
142    }
143
144    /// Enable/disable the bundled [`ReactUiAnimationsPlugin`] (the `Animated.node`
145    /// / shared-value engine). On by default; disable to drop it entirely — the
146    /// `op_animate` op stays registered but its commands are discarded.
147    pub fn with_animations(mut self, yes: bool) -> Self {
148        self.animations = yes;
149        self
150    }
151
152    /// Set the app-wide default font, loaded via the `AssetServer` (path relative
153    /// to your `AssetPlugin.file_path`, e.g. `"fonts/Roboto.ttf"`). Every `<text>`
154    /// run uses it unless its style names another family via [`Self::font`].
155    pub fn default_font(mut self, path: impl Into<PathBuf>) -> Self {
156        self.default_font = Some(path.into());
157        self
158    }
159
160    /// Register a named font family. React selects it per element with
161    /// `style={{ fontFamily: name }}`; the path is loaded via the `AssetServer`
162    /// (relative to your `AssetPlugin.file_path`).
163    pub fn font(mut self, name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
164        self.named_fonts.push((name.into(), path.into()));
165        self
166    }
167
168    /// Register a named custom **image** cursor. React selects it per element with
169    /// `style={{ cursor: name }}` (any name that isn't a built-in cursor keyword);
170    /// the image at `path` is loaded via the `AssetServer` (relative to your
171    /// `AssetPlugin.file_path`), and `hotspot` is the click-point pixel (top-left
172    /// origin) within it. The cursor analogue of [`Self::font`].
173    pub fn cursor(
174        mut self,
175        name: impl Into<String>,
176        path: impl Into<PathBuf>,
177        hotspot: (u16, u16),
178    ) -> Self {
179        self.custom_cursors
180            .push((name.into(), path.into(), hotspot));
181        self
182    }
183}
184
185/// Register the layer/filter shader assets: the importable filter-pass
186/// prelude (`#import bevy_react::filter`, see `layer/filter_prelude.wgsl`)
187/// plus the embedded pass shaders. `load_shader_library!` both embeds the
188/// prelude and loads it as a `Shader`, which registers its
189/// `#define_import_path` with the shader composer.
190///
191/// Split out of [`Plugin::build`]'s render-gated block so asset-capable tests
192/// (see `filters/test_util.rs`) can register the shaders without the render
193/// sub-app; callers must have `AssetPlugin` and the `Shader` asset set up.
194///
195/// Each shader lives next to its loading code — the built-in pass shaders
196/// beside `filters/builtin`'s `ReactFilter::shader` impls, the prelude and
197/// composite beside `layer/render.rs`'s pipelines — and the embedded paths
198/// mirror that layout (this macro roots them at THIS file's directory).
199pub(crate) fn register_layer_shader_assets(app: &mut App) {
200    bevy::shader::load_shader_library!(app, "layer/filter_prelude.wgsl");
201    embedded_asset!(app, "layer/composite.wgsl");
202    embedded_asset!(app, "layer/render/mip_blit.wgsl");
203    embedded_asset!(app, "layer/render/backdrop_blit.wgsl");
204    embedded_asset!(app, "filters/builtin/color_matrix.wgsl");
205    embedded_asset!(app, "filters/builtin/blur.wgsl");
206    embedded_asset!(app, "filters/builtin/bloom.wgsl");
207    embedded_asset!(app, "filters/builtin/chromatic_aberration.wgsl");
208}
209
210impl Plugin for ReactUiPlugin {
211    fn build(&self, app: &mut App) {
212        // Render-side wiring, gated on a render pipeline being present (the
213        // canonical `DefaultPlugins`-first setup), since `embedded_asset!` and the
214        // render sub-app need the asset + render infrastructure — a headless `App`
215        // with neither (e.g. wiring-only tests) simply skips it.
216        if app.is_plugin_added::<bevy::render::RenderPlugin>() {
217            // Layer compositing (see `crate::layer::render`): the capture
218            // pass + composite quad over stock `bevy_ui_render`, public
219            // seams only. Steal window: after Queue, before the stock sort;
220            // prepare after the stock prepares' PrepareBindGroups slot is
221            // irrelevant (disjoint items).
222            register_layer_shader_assets(app);
223            if let Some(render_app) = app.get_sub_app_mut(bevy::render::RenderApp) {
224                use crate::layer::render as lr;
225                use bevy::core_pipeline::schedule::{Core2d, Core2dSystems, Core3d, Core3dSystems};
226                use bevy::render::render_phase::{AddRenderCommand, sort_phase_system};
227                use bevy::render::render_resource::SpecializedRenderPipelines;
228                use bevy::render::{
229                    ExtractSchedule, GpuResourceAppExt, Render, RenderStartup, RenderSystems,
230                };
231                use bevy::ui_render::{
232                    RenderUiSystems, TransparentUi, extract_ui_camera_view, ui_pass,
233                };
234
235                render_app
236                    .init_resource::<lr::ExtractedUiLayers>()
237                    .init_resource::<lr::LayerAtlases>()
238                    .init_resource::<lr::LayerTextureStore>()
239                    .init_gpu_resource::<SpecializedRenderPipelines<lr::LayerCompositePipeline>>()
240                    .init_gpu_resource::<lr::LayerCompositeMeta>()
241                    .init_gpu_resource::<lr::transform3d::CompositeUniformsMeta>()
242                    .init_gpu_resource::<SpecializedRenderPipelines<lr::LayerFilterPipeline>>()
243                    .init_gpu_resource::<lr::LayerFilterMeta>()
244                    .init_gpu_resource::<SpecializedRenderPipelines<lr::mips::LayerBlitPipeline>>()
245                    .init_gpu_resource::<lr::mips::LayerMipMeta>()
246                    .init_gpu_resource::<SpecializedRenderPipelines<lr::backdrop::BackdropBlitPipeline>>()
247                    .init_gpu_resource::<lr::backdrop::BackdropMeta>()
248                    .add_render_command::<TransparentUi, lr::DrawLayerComposite>()
249                    .add_systems(
250                        RenderStartup,
251                        (
252                            lr::init_layer_composite_pipeline,
253                            lr::init_layer_filter_pipeline,
254                            lr::mips::init_layer_blit_pipeline,
255                            lr::backdrop::init_backdrop_blit_pipeline,
256                        ),
257                    )
258                    .init_resource::<lr::clip::SwappedClips>()
259                    .add_systems(
260                        ExtractSchedule,
261                        (
262                            lr::extract_ui_layers.after(extract_ui_camera_view),
263                            // The extract-window clip swap: members'
264                            // CalculatedClip carries INTERIOR clips for
265                            // exactly the span of the stock UI extraction
266                            // sets (the main world is exclusively borrowed
267                            // here, so nothing main-world can observe it).
268                            // Explicitly bracket every RenderUiSystems set —
269                            // there is no umbrella set to hang onto.
270                            lr::clip::swap_interior_clips_in
271                                .before(RenderUiSystems::ExtractCameraViews)
272                                .before(RenderUiSystems::ExtractBoxShadows)
273                                .before(RenderUiSystems::ExtractBackgrounds)
274                                .before(RenderUiSystems::ExtractImages)
275                                .before(RenderUiSystems::ExtractTextureSlice)
276                                .before(RenderUiSystems::ExtractBorders)
277                                .before(RenderUiSystems::ExtractViewportNodes)
278                                .before(RenderUiSystems::ExtractTextBackgrounds)
279                                .before(RenderUiSystems::ExtractTextShadows)
280                                .before(RenderUiSystems::ExtractText)
281                                .before(RenderUiSystems::ExtractCursor)
282                                .before(RenderUiSystems::ExtractDebug)
283                                .before(RenderUiSystems::ExtractGradient),
284                            lr::clip::swap_interior_clips_out
285                                .after(RenderUiSystems::ExtractCameraViews)
286                                .after(RenderUiSystems::ExtractBoxShadows)
287                                .after(RenderUiSystems::ExtractBackgrounds)
288                                .after(RenderUiSystems::ExtractImages)
289                                .after(RenderUiSystems::ExtractTextureSlice)
290                                .after(RenderUiSystems::ExtractBorders)
291                                .after(RenderUiSystems::ExtractViewportNodes)
292                                .after(RenderUiSystems::ExtractTextBackgrounds)
293                                .after(RenderUiSystems::ExtractTextShadows)
294                                .after(RenderUiSystems::ExtractText)
295                                .after(RenderUiSystems::ExtractCursor)
296                                .after(RenderUiSystems::ExtractDebug)
297                                .after(RenderUiSystems::ExtractGradient),
298                        ),
299                    )
300                    .add_systems(
301                        Render,
302                        (
303                            lr::redistribute_ui_layers
304                                .in_set(RenderSystems::PhaseSort)
305                                .before(sort_phase_system::<TransparentUi>),
306                            lr::prepare_layer_textures.in_set(RenderSystems::PrepareResources),
307                            // Filter staging: allocates/specializes everything
308                            // a filter pass needs; `ui_layer_capture_pass`
309                            // replays the staged runs after each layer's
310                            // capture.
311                            lr::prepare_layer_filters
312                                .in_set(RenderSystems::PrepareBindGroups)
313                                .after(lr::prepare_layer_textures)
314                                .before(lr::prepare_layer_composites),
315                            // Backdrop staging: snapshot blit + a second
316                            // filter run per backdrop layer; the composite
317                            // gate reads its `output_valid`.
318                            lr::backdrop::prepare_layer_backdrops
319                                .in_set(RenderSystems::PrepareBindGroups)
320                                .after(lr::prepare_layer_textures)
321                                .before(lr::prepare_layer_composites),
322                            // Mip staging: after filters (`output_valid` /
323                            // `output_index` decided — mips build on the
324                            // filter output), before composites (its
325                            // bind-group choice reads `mips_valid`).
326                            lr::mips::prepare_layer_mips
327                                .in_set(RenderSystems::PrepareBindGroups)
328                                .after(lr::prepare_layer_filters)
329                                .before(lr::prepare_layer_composites),
330                            lr::prepare_layer_composites.in_set(RenderSystems::PrepareBindGroups),
331                        ),
332                    )
333                    .add_systems(
334                        Core2d,
335                        lr::ui_layer_capture_pass
336                            .after(Core2dSystems::PostProcess)
337                            .before(ui_pass),
338                    )
339                    .add_systems(
340                        Core3d,
341                        lr::ui_layer_capture_pass
342                            .after(Core3dSystems::PostProcess)
343                            .before(ui_pass),
344                    );
345            }
346        }
347
348        // Channels: op batches, app messages, requests, and animation commands flow
349        // JS -> Bevy (crossbeam, same on every target). The Bevy -> JS direction (a
350        // single `Outbound` stream plus, on native, reload signals) is owned by the
351        // target's host, which returns its sender below.
352        // TODO(review): all of these are UNBOUNDED — there's no backpressure. A system that
353        // `events.send`s every frame while the JS side consumes slowly (or not at all) grows
354        // the outbound queue without bound. Consider bounded channels with an explicit
355        // drop/coalesce policy before this is "production".
356        let (ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
357        // Side channel of per-batch send instants (see `FlushStamps`): feeds the
358        // devtools "pre-apply" timing leg. Stays empty on web (no `Instant`).
359        let (flush_stamps_tx, flush_stamps_rx) =
360            crossbeam_channel::unbounded::<std::time::Instant>();
361        // Side channel of per-batch devtools-origin flags (see `FlushFlags`):
362        // lets applies be attributed to the panel vs the app on every target.
363        let (flush_devtools_tx, flush_devtools_rx) = crossbeam_channel::unbounded::<bool>();
364        let (emit_tx, emit_rx) = crossbeam_channel::unbounded::<ReactMessage>();
365        let (request_tx, request_rx) = crossbeam_channel::unbounded::<RawRequest>();
366        let (anim_tx, anim_rx) = crossbeam_channel::unbounded::<AnimationCommand>();
367
368        // Spawn/install the target's JS host: native runs an embedded V8 isolate on
369        // a dedicated thread (fed from disk, with hot reload); web runs React in the
370        // browser's own engine. The host owns the Bevy->JS transport and returns the
371        // sender every outbound producer writes to. It starts before `setup` builds
372        // the root, so the first ops simply queue until then.
373        let outbound_tx = host::spawn(
374            app,
375            HostConfig {
376                bundle: self.bundle.clone(),
377                hot_reload: self.hot_reload,
378            },
379            HostSenders {
380                ops: ops_tx,
381                flush_stamps: flush_stamps_tx,
382                flush_devtools: flush_devtools_tx,
383                emit: emit_tx,
384                request: request_tx,
385                anim: anim_tx,
386            },
387        );
388        app.insert_resource(crate::reconcile::FlushStamps(flush_stamps_rx));
389        app.insert_resource(crate::reconcile::FlushFlags(flush_devtools_rx));
390
391        app.insert_resource(BridgeChannels {
392            ops_rx: Some(ops_rx),
393            outbound_tx: outbound_tx.clone(),
394        })
395        // A standalone outbound handle for the request dispatcher and the
396        // `ReactEvents` param, available before `setup` builds `JsBridge`.
397        .insert_resource(OutboundResource(outbound_tx))
398        .insert_resource(EmitReceiver(emit_rx))
399        .insert_resource(RequestReceiver(request_rx))
400        .insert_resource(ReactUiConfig {
401            default_font: self.default_font.clone(),
402            named_fonts: self.named_fonts.clone(),
403            custom_cursors: self.custom_cursors.clone(),
404        })
405        .init_resource::<ReactRegistry>()
406        .init_resource::<ReactRequestRegistry>()
407        .init_resource::<ReactEventRegistry>()
408        .init_resource::<PointerCapture>()
409        .init_resource::<OpApplyStats>()
410        // Unconditional so wasm compiles; it just stays `None` there.
411        .init_resource::<crate::reconcile::FrameStamp>()
412        .init_resource::<crate::ui_map::AtlasLayoutCache>()
413        .init_resource::<crate::scrollbar::ScrollbarTracks>()
414        .init_resource::<Fonts>()
415        .init_resource::<crate::cursor::CustomCursors>()
416        // The offscreen render-target ("portal") registry and its shared blank
417        // placeholder texture, created before the first portal can mount.
418        .init_resource::<crate::portal::RenderTargets>()
419        .add_systems(Startup, crate::portal::init_portal_placeholder)
420        // The `<surface>` registry (UI subtrees rendered into offscreen textures)
421        // and its single virtual pointer for in-world clicks.
422        .init_resource::<crate::surface::Surfaces>()
423        .add_systems(Startup, crate::surface::init_surface_pointer)
424        .add_systems(Startup, crate::layer::pick3d::init_transform3d_pointer)
425        .add_systems(Startup, setup)
426        .add_systems(
427            PreUpdate,
428            (dispatch_react_messages, dispatch_react_requests),
429        )
430        // Drive the surface virtual pointer (cursor → mesh UV → image render
431        // target) before `bevy_picking` processes inputs, so the offscreen UI is
432        // hit-tested with this frame's cursor.
433        .add_systems(
434            PreUpdate,
435            crate::surface::drive_surface_pointer
436                .before(bevy::picking::PickingSystems::ProcessInput),
437        )
438        // Remap the window cursor into 3D-transformed layers (inverse
439        // homography → virtual pointer) before input processing, like the
440        // surface driver. Reads last frame's matrices — the composite quad
441        // the user sees is last frame's too, so picking matches the visual.
442        .add_systems(
443            PreUpdate,
444            crate::layer::pick3d::drive_transform3d_pointer
445                .before(bevy::picking::PickingSystems::ProcessInput),
446        )
447        // Re-filter picking hits against the render-grade inherited clip
448        // (`CalculatedClip`) — bevy_ui 0.19's own clip check misses hits on
449        // scrolled/clipped-away nodes whose direct parent doesn't clip (see
450        // `pick_clip` module docs). Between the backends and the hover-map
451        // update, so every downstream consumer sees only visible hits.
452        .add_systems(
453            PreUpdate,
454            crate::pick_clip::filter_clipped_pointer_hits
455                .after(bevy::picking::PickingSystems::Backend)
456                .before(bevy::picking::PickingSystems::Hover),
457        )
458        // Scope hits around the transformed-layer remap (stale layout rects
459        // for the mouse, layer-membership for the virtual pointer) — after
460        // the clip filter for a deterministic filter order.
461        .add_systems(
462            PreUpdate,
463            crate::layer::pick3d::suppress_transformed_layer_hits
464                .after(bevy::picking::PickingSystems::Backend)
465                .after(crate::pick_clip::filter_clipped_pointer_hits)
466                .before(bevy::picking::PickingSystems::Hover),
467        )
468        .add_systems(
469            Update,
470            (
471                apply_js_ops,
472                collect_ui_events,
473                // Forward window-global keystrokes to React as built-in named
474                // events (`onKeyDown`/`onKeyUp`). Node-independent: reads Bevy's
475                // `KeyboardInput` messages directly, no reconciler state needed.
476                crate::keyboard::collect_keyboard_events,
477                // Emit `pointerEnter`/`pointerLeave` from `Interaction` transitions
478                // (same signal as hover styling), for nodes with those handlers.
479                collect_hover_events,
480                collect_pointer_events.in_set(PointerCaptureSet),
481                // Wheel-scroll any `overflow: scroll` node under the cursor, and
482                // deliver raw wheel deltas to any `onWheel` node. Both in the same
483                // set, after `collect_pointer_events`, so their `PointerCapture::over_ui`
484                // claim survives (that system *assigns* `over_ui`) and world systems
485                // (ordered `.after(PointerCaptureSet)`) see it. (A sub-tuple: the outer
486                // tuple is at Bevy's arity limit.)
487                (
488                    crate::scroll::apply_scroll
489                        .in_set(PointerCaptureSet)
490                        .after(collect_pointer_events),
491                    crate::scroll::collect_wheel_events
492                        .in_set(PointerCaptureSet)
493                        .after(collect_pointer_events),
494                    // Hovering a scrollbar part claims the hover channel (the
495                    // widget has `Hovered`, not `Interaction`, so a press on it
496                    // would otherwise start a world grab); a thumb drag claims
497                    // the pointer outright and snaps any eased scroll state. In the set,
498                    // after `collect_pointer_events` so its `over_ui` claim survives.
499                    crate::scrollbar::bridge_scrollbar_capture
500                        .in_set(PointerCaptureSet)
501                        .after(collect_pointer_events),
502                ),
503                // Ease `ScrollPosition` toward the target the controlled write
504                // (`apply_js_ops`) and the wheel (`PointerCaptureSet`) set this frame.
505                // Runs after both so it eases toward the freshest target.
506                crate::transition::drive_scroll_transition
507                    .after(apply_js_ops)
508                    .after(PointerCaptureSet),
509                // Report scroll-offset changes (wheel, controlled write, or an eased
510                // frame) to JS for any node with an `onScroll` handler. After the ease
511                // so it sees the moved offset; after the op drain so a controlled write
512                // is already seeded into the dedup map (no echo).
513                collect_scroll_events
514                    .after(crate::transition::drive_scroll_transition)
515                    .after(PointerCaptureSet)
516                    .after(apply_js_ops),
517                // After the op drain so this frame's `StyleVariants` writes are
518                // visible; the ordering forces a command sync point first.
519                apply_interaction_styles.after(apply_js_ops),
520                // Ease `transform`/`opacity`/`backgroundColor` toward the target the
521                // style appliers just wrote. After `apply_interaction_styles` (and
522                // thus the op drain) so the eased value lands last and a coincident
523                // re-render's snap never wins.
524                crate::transition::drive_transitions.after(apply_interaction_styles),
525                // World-anchored overlays reposition after the op drain so they
526                // override this frame's static `left`/`top`.
527                crate::anchor::position_anchored_nodes.after(apply_js_ops),
528                // Repaint `<canvas>` textures after their surfaces/sizes update,
529                // and report layout resizes to JS (the surface just cleared — so
530                // the app / the runtime's declarative replay redraws). Both read
531                // last frame's `ComputedNode`. The cursor driver rides here too —
532                // it also reads last frame's `ComputedNode` after the op drain
533                // (freshly-stamped `NodeCursor`s visible), and this sub-tuple keeps
534                // the outer tuple under Bevy's arity limit.
535                (
536                    crate::canvas::update_canvas_surfaces.after(apply_js_ops),
537                    collect_canvas_resize_events.after(apply_js_ops),
538                    crate::cursor::drive_cursor_icon.after(apply_js_ops),
539                    // Spawn/teardown the Bevy scrollbar widget over each
540                    // `overflow: scroll` container that declared a `scrollbar`
541                    // style, then place its track over the container's edge. Both
542                    // after the op drain (fresh `ScrollbarConfig`s visible); place
543                    // after sync so the tracks exist. Bevy's `ScrollbarPlugin`
544                    // (in `DefaultPlugins`) drives the thumb in `PostUpdate`.
545                    crate::scrollbar::sync_scrollbars.after(apply_js_ops),
546                    crate::scrollbar::position_scrollbars
547                        .after(apply_js_ops)
548                        .after(crate::scrollbar::sync_scrollbars),
549                    // Paint each bar in its hover/pressed/base state (reads Bevy's
550                    // `Hovered` + `ScrollbarDragState`). After sync so the entities exist.
551                    crate::scrollbar::style_scrollbar_states
552                        .after(crate::scrollbar::sync_scrollbars),
553                ),
554                // Bind `<portal>` nodes to their render-target textures after the
555                // op drain (so a freshly-spawned portal binds the same frame), then
556                // drive resolution + the snapshot camera lifecycle.
557                crate::portal::bind_portals.after(apply_js_ops),
558                crate::portal::drive_render_targets.after(crate::portal::bind_portals),
559                // Bind `<surface>` roots to their offscreen UI cameras after the op
560                // drain (so a freshly-mounted surface binds the same frame), then
561                // drive the snapshot camera lifecycle.
562                crate::surface::bind_surfaces.after(apply_js_ops),
563                crate::surface::drive_surfaces.after(crate::surface::bind_surfaces),
564                // Surface interaction: turn the virtual pointer's picking events on
565                // the offscreen subtree into `onClick`/`onPointer*` + hover/press
566                // styling. The picking events are produced in `PreUpdate`, so these
567                // read this frame's events.
568                collect_surface_clicks,
569                collect_surface_pointer_events,
570                collect_surface_hover_events,
571                apply_surface_interaction_styles,
572            ),
573        );
574
575        // The built-in `filter` registry (blur + the color-matrix ops), beside
576        // the other name-keyed registries above. Registration is
577        // `AssetServer`-free — shaders load lazily inside each entry's resolve.
578        crate::filters::register_builtin_filters(app);
579
580        // The built-in `"resize"` event + `bevy.window.size()` request (see
581        // `crate::window`). A separate `add_systems` call — the Update tuple
582        // above is at Bevy's arity cap. `.after(apply_js_ops)` so the initial
583        // size goes out the same frame the first batch applies.
584        app.add_systems(
585            Update,
586            crate::window::send_resize_events.after(apply_js_ops),
587        );
588
589        // Layer promotion (see `crate::layer`). Main-world state registers
590        // unconditionally so headless wiring tests build; the render half is
591        // gated above with the rest of the render-only setup. A separate
592        // `add_systems` call — the Update tuple above is at the arity cap.
593        // Ordering: after the op drain (fresh props/dirty marks; the explicit
594        // constraint also forces a command sync so markers are visible), and
595        // before every later alpha writer this frame — the interaction
596        // restyle, transitions (ordered after it), and the animation appliers
597        // — so they all see the final promotion state (no cross-stage
598        // ping-pong).
599        app.init_resource::<crate::layer::LayerMembership>();
600        app.init_resource::<crate::layer::LayersRegistry>();
601        app.init_resource::<crate::layer::LayerContentDirt>();
602        app.init_resource::<crate::layer::LayerRepaintState>();
603        app.init_resource::<crate::layer::clip::LayerClips>();
604        app.add_systems(
605            Update,
606            (
607                crate::layer::evaluate_layer_promotions
608                    .after(apply_js_ops)
609                    .before(apply_interaction_styles)
610                    .before(AnimationSet::Apply),
611                // Async `<image>` texture arrivals have no other write site the
612                // layer cache could tap.
613                crate::layer::watch_layer_image_assets,
614                // Override `ui_focus_system`'s geometric (stale-rect) verdict
615                // for members of visually-transformed layers with the virtual
616                // pointer's, before styling/enter-leave/drag-position readers.
617                crate::layer::pick3d::correct_transformed_interactions
618                    .before(apply_interaction_styles)
619                    .before(collect_hover_events)
620                    .before(crate::reconcile::collect_pointer_events),
621            ),
622        );
623        // Resolve each promoted root's wire `filter` and `backdropFilter`
624        // chains into packed render passes — the two instances of
625        // `crate::filters::resolve_chains`. After the interaction restyle —
626        // the last input writer this frame (and, transitively, after the
627        // promotion evaluator, so `Added<PromotedLayer>` is visible) — and
628        // before the transition/animation appliers so filter-param writes
629        // land on an already-resolved chain. Its own `add_systems` call — the
630        // Update tuple above is at the arity cap.
631        app.add_systems(
632            Update,
633            (
634                crate::filters::resolve_chains::<
635                    crate::filters::FilterInput,
636                    crate::filters::ResolvedFilterChain,
637                >,
638                crate::filters::resolve_chains::<
639                    crate::filters::BackdropInput,
640                    crate::filters::ResolvedBackdropChain,
641                >,
642            )
643                .after(apply_interaction_styles)
644                .before(crate::transition::drive_transitions)
645                .before(AnimationSet::Apply),
646        );
647        // After bevy_ui layout so capture rects/membership are this frame's
648        // geometry (extraction reads them the same frame, post-PostUpdate).
649        app.add_systems(
650            PostUpdate,
651            (
652                crate::layer::sync_layer_geometry.after(bevy::ui::UiSystems::Layout),
653                // Membership + geometry hashes are this frame's, and bevy_ui's
654                // text systems (PostLayout) have re-shaped — turn the frame's
655                // dirt into per-layer repaint decisions for extraction.
656                crate::layer::resolve_layer_repaints
657                    .after(crate::layer::sync_layer_geometry)
658                    .after(bevy::ui::UiSystems::PostLayout),
659                // Derives each transformed layer's composite matrix from this
660                // frame's layout; pushes composite-only dirt the resolver
661                // drains, so it slots between geometry sync and the resolver.
662                crate::layer::transform3d::sync_transform3d_matrices
663                    .after(crate::layer::sync_layer_geometry)
664                    .before(crate::layer::resolve_layer_repaints),
665                // Interior/quad clip maps: needs this frame's membership
666                // (sync_layer_geometry) and bevy_ui's final CalculatedClip
667                // (PostLayout). Deliberately NOT feeding
668                // resolve_layer_repaints — clip changes never dirty a
669                // capture (scrolling must stay a cache hit).
670                crate::layer::clip::sync_layer_clips
671                    .after(crate::layer::sync_layer_geometry)
672                    .after(bevy::ui::UiSystems::PostLayout),
673                // Re-clamps deferred controlled-scroll requests (a pin to the
674                // bottom in the same commit that grew the content) against
675                // fresh geometry.
676                crate::scroll::settle_controlled_scroll.after(bevy::ui::UiSystems::Layout),
677            ),
678        );
679        app.add_react_request_handler(crate::window::handle_window_size_request);
680
681        // Frame-start stamp for the devtools frame-wait / pre-apply split
682        // (native only — no usable `Instant::now` on wasm). `First`: before
683        // any work the pre-apply leg should attribute.
684        #[cfg(not(target_arch = "wasm32"))]
685        app.add_systems(bevy::app::First, crate::reconcile::mark_frame_start);
686
687        // `editableText` edits arrive as Bevy's `TextEditChange` trigger; an observer
688        // turns real changes into `"change"` and selection moves into `"select"` UI
689        // events. Two more observers bridge focus gain/loss to `"focus"`/`"blur"`.
690        app.add_observer(on_text_edit_change);
691        app.add_observer(on_focus_gained);
692        app.add_observer(on_focus_lost);
693
694        // Controlled-selection writes and the a11y value sync run after Bevy's
695        // text-edit pass (`EditableTextSystems`) so they see this frame's applied
696        // edits and resolve byte offsets against the current text.
697        app.add_systems(
698            PostUpdate,
699            (apply_pending_selections, sync_editable_a11y).after(bevy::text::EditableTextSystems),
700        );
701
702        // The animations engine is a separate plugin (its crate can't depend on
703        // this one). We add it and, as the only crate that sees both sides, order
704        // its `Apply` set after `apply_js_ops` so per-frame animation writes win
705        // over this frame's static style. Disabled → `anim_rx` drops here and
706        // `op_animate` sends are discarded.
707        if self.animations {
708            app.add_plugins(ReactUiAnimationsPlugin::new(anim_rx))
709                .configure_sets(Update, AnimationSet::Apply.after(apply_js_ops))
710                // Completion callbacks: settlements the engine reports (once per
711                // token-tagged driver, not per frame) go out to JS.
712                .add_systems(Update, forward_animation_settled.after(AnimationSet::Tick));
713        }
714
715        // The devtools inspector rides along by default (see `Self::devtools`).
716        // Registration is the only gate needed here: the plugin itself registers
717        // nothing in `--release` builds.
718        #[cfg(feature = "devtools")]
719        if self.devtools.enabled {
720            app.add_plugins(crate::devtools::DevtoolsPlugin::new(self.devtools.clone()));
721        }
722    }
723}
724
725/// Forward the animation engine's [`AnimationSettled`] messages to JS as
726/// [`Outbound::AnimationFinished`], resolving each to its completion callback.
727/// The engine crate can't depend on this one, so the bridging happens here.
728fn forward_animation_settled(
729    mut settled: MessageReader<AnimationSettled>,
730    outbound: Res<OutboundResource>,
731) {
732    for s in settled.read() {
733        let _ = outbound.0.send(Outbound::AnimationFinished {
734            id: s.id,
735            token: s.token,
736            finished: s.finished,
737        });
738    }
739}
740
741/// Marker for the UI root entity (reconciler node id 0 / `ROOT_ID`).
742/// `pub(crate)`: devtools' reserve-space mode insets this root's margins to
743/// push the app UI aside while the panel is docked.
744#[derive(Component)]
745pub(crate) struct UiRoot;
746
747/// Plugin configuration read by the startup system.
748#[derive(Resource)]
749struct ReactUiConfig {
750    default_font: Option<PathBuf>,
751    named_fonts: Vec<(String, PathBuf)>,
752    custom_cursors: Vec<(String, PathBuf, (u16, u16))>,
753}
754
755/// Fonts loaded from the plugin config, resolved to handles at startup. The
756/// default backs every `<text>` run; named entries are selected per element via
757/// the `fontFamily` style prop. Empty (unconfigured) → Bevy's built-in font.
758#[derive(Resource, Default)]
759pub struct Fonts {
760    pub default: Option<Handle<Font>>,
761    pub named: std::collections::HashMap<String, Handle<Font>>,
762}
763
764/// Carries the Bevy-side channel ends from `build` into `setup`.
765#[derive(Resource)]
766struct BridgeChannels {
767    ops_rx: Option<OpReceiver>,
768    outbound_tx: OutboundSender,
769}
770
771/// Receives app messages emitted by the React app (`emit(name, value)`).
772#[derive(Resource)]
773struct EmitReceiver(crossbeam_channel::Receiver<ReactMessage>);
774
775/// The single consumption point for React-emitted messages. Drains the channel
776/// each frame and routes every message to its registered typed payload via
777/// [`ReactRegistry`], triggering it for observers. Runs in `PreUpdate` so the
778/// triggers land before consumer `Update` systems run the same frame.
779fn dispatch_react_messages(
780    rx: Res<EmitReceiver>,
781    registry: Res<ReactRegistry>,
782    mut commands: Commands,
783) {
784    while let Ok(msg) = rx.0.try_recv() {
785        registry.dispatch(msg, &mut commands);
786    }
787}
788
789fn setup(
790    mut commands: Commands,
791    mut channels: ResMut<BridgeChannels>,
792    config: Res<ReactUiConfig>,
793    assets: Res<AssetServer>,
794) {
795    // Load configured fonts into the `Fonts` resource before the first
796    // `apply_js_ops` (Update) creates any text.
797    commands.insert_resource(Fonts {
798        default: config.default_font.as_ref().map(|p| assets.load(p.clone())),
799        named: config
800            .named_fonts
801            .iter()
802            .map(|(name, path)| (name.clone(), assets.load(path.clone())))
803            .collect(),
804    });
805    // Likewise load configured custom image cursors into the registry `drive_cursor_icon`
806    // resolves a `cursor` name against.
807    commands.insert_resource(crate::cursor::CustomCursors(
808        config
809            .custom_cursors
810            .iter()
811            .map(|(name, path, hotspot)| {
812                (
813                    name.clone(),
814                    CustomCursorImage {
815                        handle: assets.load(path.clone()),
816                        hotspot: *hotspot,
817                        ..default()
818                    },
819                )
820            })
821            .collect(),
822    ));
823
824    // The root container: a full-window flex column the reconciler appends
825    // top-level children into (it is reconciler node id 0). Children stack from
826    // the top, horizontally centered.
827    let root = commands
828        .spawn((
829            Node {
830                width: Val::Percent(100.0),
831                height: Val::Percent(100.0),
832                flex_direction: FlexDirection::Column,
833                justify_content: JustifyContent::FlexStart,
834                align_items: AlignItems::Center,
835                row_gap: Val::Px(16.0),
836                ..default()
837            },
838            // Layout scaffolding only: without an explicit `Pickable` the UI
839            // picking backend treats the full-window root as a blocking hit,
840            // which would make hover queries (`HoverMap`) report "over UI"
841            // everywhere. Element nodes opt in per-`FocusPolicy` instead.
842            bevy::picking::Pickable {
843                should_block_lower: false,
844                ..default()
845            },
846            UiRoot,
847        ))
848        .id();
849
850    // The shared overlay container for world-anchored nodes (`<anchor>`).
851    // `position_anchored_nodes` reparents every anchored overlay under this so it lives
852    // in its own hierarchy and never inflates an app container's flex layout or
853    // scrollable `content_size`. Zero-size at the window origin (absolute, left/top 0)
854    // with default `Overflow::visible`, so it neither clips its children nor intercepts
855    // pointer input; anchored nodes position themselves relative to its (0,0) corner.
856    // Spawned as the root's first child so the app subtree (appended later via ops)
857    // renders above it — add a `GlobalZIndex` here to lift overlays above app content.
858    commands.spawn((
859        Node {
860            position_type: PositionType::Absolute,
861            left: Val::Px(0.0),
862            top: Val::Px(0.0),
863            width: Val::Px(0.0),
864            height: Val::Px(0.0),
865            ..default()
866        },
867        crate::anchor::AnchorLayer,
868        ChildOf(root),
869    ));
870
871    let ops_rx = channels.ops_rx.take().expect("setup runs once");
872    commands.insert_resource(JsBridge::new(ops_rx, channels.outbound_tx.clone(), root));
873}
874
875#[cfg(test)]
876mod tests {
877    use super::*;
878
879    /// `is_captured` must cover every capture channel: a UI drag, hovering
880    /// interactive UI, and a UI-consumed wheel each count as "the UI owns the
881    /// pointer" for consumers that don't need finer grain.
882    #[test]
883    fn is_captured_covers_all_channels() {
884        assert!(!PointerCapture::default().is_captured());
885        assert!(
886            PointerCapture {
887                dragging: true,
888                ..default()
889            }
890            .is_captured()
891        );
892        assert!(
893            PointerCapture {
894                over_ui: true,
895                ..default()
896            }
897            .is_captured()
898        );
899        assert!(
900            PointerCapture {
901                wheel_captured: true,
902                ..default()
903            }
904            .is_captured()
905        );
906    }
907}