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