bevy-react 0.3.0

Drive bevy_ui from a React app over an embedded V8 runtime.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
//! The `cursor` style prop: drives the window's mouse cursor from the node the
//! pointer is over.
//!
//! `bevy_ui` has no per-node cursor mechanism — `bevy_winit` only reads
//! [`CursorIcon`] from the window entity. So a node's `cursor` style is stamped
//! as a [`NodeCursor`] component (in [`crate::ui_map::apply_style_masked`]) and
//! [`drive_cursor_icon`] each frame picks the node under the pointer and writes the
//! chosen cursor onto the primary window's [`CursorIcon`].
//!
//! Two pointer worlds feed the one window cursor, folded into a single decision so
//! they never clobber each other:
//! - **Main-window UI**: a geometric hit-test over the `UiStack`, topmost first, no
//!   `Interaction` needed — so a plain `<node style={{ cursor }}>` works without
//!   opting into the pointer machinery. Mirrors [`crate::scroll::collect_wheel_events`].
//! - **`<surface>` UI**: an offscreen subtree hit-tested in the *texture's* space via
//!   the in-world virtual pointer, so the window hit-test can't see it. Instead we
//!   read the picking [`HoverMap`] for the [`SurfaceVirtualPointer`] — the same
//!   authoritative hover state surface hover styling rides — take its topmost node,
//!   and climb to the nearest cursor-bearing ancestor.
//!
//! Surface takes precedence: if the virtual pointer is over a surface cursor node,
//! that wins; otherwise the main-window hit-test decides; otherwise the default
//! arrow. Nodes without a `NodeCursor` are transparent to both scans, so a child
//! inherits its nearest cursor-bearing ancestor (CSS-like).

use crate::surface::SurfaceVirtualPointer;
use bevy::picking::hover::HoverMap;
use bevy::picking::pointer::PointerId;
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use bevy::ui::{ComputedNode, UiGlobalTransform, UiStack};
use bevy::window::{CursorIcon, CustomCursor, CustomCursorImage, PrimaryWindow, SystemCursorIcon};

/// The cursor a node requests while the pointer is over it — the raw `cursor` style
/// name (a system keyword or a custom-cursor name), resolved at drive time by
/// [`resolve_cursor`]. Read by [`drive_cursor_icon`]; absent → the node contributes
/// no cursor.
#[derive(Component, Debug, Clone)]
pub struct NodeCursor(pub String);

/// Named custom image cursors, registered upfront via `ReactUiPlugin::cursor` and
/// loaded to handles in the plugin's `Startup` `setup` (mirroring the
/// [`Fonts`](crate::Fonts) registry). Keyed by the name React selects with
/// `style={{ cursor: name }}`; the app owns the asset, React references it by name.
#[derive(Resource, Default)]
pub struct CustomCursors(pub HashMap<String, CustomCursorImage>);

/// Resolve a `cursor` name to the [`CursorIcon`] to write on the window. The
/// [`CustomCursors`] registry is checked **first**, so a custom cursor registered under
/// a system-keyword name (e.g. `"pointer"`) *overrides* that built-in; otherwise the
/// name is matched against the system keywords, and finally an unknown name warns and
/// falls back to the default arrow (mirroring an unknown `fontFamily`).
fn resolve_cursor(name: &str, custom: &CustomCursors) -> CursorIcon {
    if let Some(image) = custom.0.get(name) {
        CursorIcon::Custom(CustomCursor::Image(image.clone()))
    } else if let Some(icon) = system_cursor_keyword(name) {
        CursorIcon::from(icon)
    } else {
        let msg = format!("unknown cursor {name:?}");
        warn!(target: "bevy_react", "{msg}");
        crate::diag::report("cursor", name, &msg);
        CursorIcon::from(SystemCursorIcon::Default)
    }
}

/// Map a CSS `cursor` keyword (camelCase or CSS kebab-case) to a built-in
/// [`SystemCursorIcon`], or `None` if the string is not a reserved keyword (→ an
/// unregistered custom name). The full winit set.
fn system_cursor_keyword(s: &str) -> Option<SystemCursorIcon> {
    use SystemCursorIcon::*;
    Some(match s {
        "default" | "auto" => Default,
        "contextMenu" | "context-menu" => ContextMenu,
        "help" => Help,
        "pointer" => Pointer,
        "progress" => Progress,
        "wait" => Wait,
        "cell" => Cell,
        "crosshair" => Crosshair,
        "text" => Text,
        "verticalText" | "vertical-text" => VerticalText,
        "alias" => Alias,
        "copy" => Copy,
        "move" => Move,
        "noDrop" | "no-drop" => NoDrop,
        "notAllowed" | "not-allowed" => NotAllowed,
        "grab" => Grab,
        "grabbing" => Grabbing,
        "eResize" | "e-resize" => EResize,
        "nResize" | "n-resize" => NResize,
        "neResize" | "ne-resize" => NeResize,
        "nwResize" | "nw-resize" => NwResize,
        "sResize" | "s-resize" => SResize,
        "seResize" | "se-resize" => SeResize,
        "swResize" | "sw-resize" => SwResize,
        "wResize" | "w-resize" => WResize,
        "ewResize" | "ew-resize" => EwResize,
        "nsResize" | "ns-resize" => NsResize,
        "neswResize" | "nesw-resize" => NeswResize,
        "nwseResize" | "nwse-resize" => NwseResize,
        "colResize" | "col-resize" => ColResize,
        "rowResize" | "row-resize" => RowResize,
        "allScroll" | "all-scroll" => AllScroll,
        "zoomIn" | "zoom-in" => ZoomIn,
        "zoomOut" | "zoom-out" => ZoomOut,
        _ => return None,
    })
}

/// Set the primary window's [`CursorIcon`] to the cursor of the node under the
/// pointer (main-window or `<surface>` UI). When the pointer is over no cursor-bearing
/// node, it falls back to the `"default"` cursor — which resolves like any other name,
/// so a custom cursor registered under `"default"` becomes the app-wide base cursor
/// (and with none registered, the system default arrow).
///
/// Writes only on change, so a steady hover doesn't re-trigger `bevy_winit`'s
/// `update_cursors` every frame. Runs after `apply_js_ops` so a freshly-stamped
/// `NodeCursor` and this frame's `ComputedNode` are visible.
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub fn drive_cursor_icon(
    mut commands: Commands,
    windows: Query<(Entity, &Window, Option<&CursorIcon>), With<PrimaryWindow>>,
    ui_stack: Res<UiStack>,
    windowed: Query<(&ComputedNode, &UiGlobalTransform, &NodeCursor)>,
    hover_map: Option<Res<HoverMap>>,
    surface_pointer: Option<Res<SurfaceVirtualPointer>>,
    transform3d_pointer: Option<Res<crate::layer::pick3d::Transform3dPointer>>,
    membership: Option<Res<crate::layer::LayerMembership>>,
    matrices: Query<&crate::layer::transform3d::LayerTransform3dMatrix>,
    custom_cursors: Res<CustomCursors>,
    node_cursors: Query<&NodeCursor>,
    child_of: Query<&ChildOf>,
    rnodes: Query<&crate::bridge::RNode>,
) {
    let Ok((window_entity, window, current)) = windows.single() else {
        return;
    };

    // Members of visually-transformed layers render elsewhere than layout put
    // them: the window scan must skip them (their rects are stale), and the
    // transform3d virtual pointer's hover — which follows the visual — takes
    // over, exactly like the surface pointer does for texture-space UI.
    let transformed_members = membership
        .as_deref()
        .map(|membership| crate::layer::pick3d::visually_transformed_members(membership, &matrices))
        .unwrap_or_default();

    // Surface UI wins: the in-world virtual pointer is the only thing that knows the
    // pointer is over an offscreen subtree (the window hit-test can't — its geometry
    // is in texture space). Then the transform3d remap, then the main-window scan.
    let named = surface_pointer
        .as_deref()
        .zip(hover_map.as_deref())
        .and_then(|(pointer, hover_map)| {
            surface_cursor_for(pointer.id, hover_map, &node_cursors, &child_of)
        })
        .or_else(|| {
            transform3d_pointer
                .as_deref()
                .zip(hover_map.as_deref())
                .and_then(|(pointer, hover_map)| {
                    surface_cursor_for(pointer.id, hover_map, &node_cursors, &child_of)
                })
        })
        .or_else(|| window_cursor(window, &ui_stack, &windowed, &transformed_members));

    // No cursor-bearing node under the pointer → the app default. Routing it through
    // `resolve_cursor("default", …)` (rather than a hardcoded arrow) lets a custom
    // cursor registered as `"default"` become the app-wide base cursor.
    let desired = {
        // Attribute an unknown-cursor warning to the node that carried the
        // `cursor` style, so devtools can flag its row.
        let _diag = named
            .as_ref()
            .and_then(|(_, e)| rnodes.get(*e).ok())
            .map(|r| crate::diag::node_scope(r.0));
        resolve_cursor(
            named.as_ref().map(|(n, _)| n.as_str()).unwrap_or("default"),
            &custom_cursors,
        )
    };
    if current != Some(&desired) {
        commands.entity(window_entity).insert(desired);
    }
}

/// The cursor of the topmost main-window `NodeCursor` node under the window pointer
/// (name + the entity that carried it), or `None` if the pointer is over none (or
/// its position is unknown).
fn window_cursor(
    window: &Window,
    ui_stack: &UiStack,
    windowed: &Query<(&ComputedNode, &UiGlobalTransform, &NodeCursor)>,
    skip: &bevy::platform::collections::HashSet<Entity>,
) -> Option<(String, Entity)> {
    // `ComputedNode`/`UiGlobalTransform` are physical; the window cursor is logical
    // (see the note in `crate::scroll::apply_scroll`). Match them for the hit-test.
    let cursor = window.cursor_position()? * window.scale_factor();
    // `uinodes` is back-to-front, so reversed is topmost-first: the first
    // cursor-bearing node whose rect contains the pointer wins. `skip` holds
    // members of visually-transformed layers — their layout rects are stale
    // (the transform3d virtual pointer's hover covers them instead).
    ui_stack.uinodes.iter().rev().find_map(|&entity| {
        if skip.contains(&entity) {
            return None;
        }
        let (computed, transform, node_cursor) = windowed.get(entity).ok()?;
        computed
            .contains_point(*transform, cursor)
            .then(|| (node_cursor.0.clone(), entity))
    })
}

/// The cursor of the topmost `<surface>` node under the virtual pointer, climbing to
/// the nearest cursor-bearing ancestor. `None` when the pointer is over no surface
/// node, or that node (and its ancestors) declare no cursor. Split from the system so
/// it can be unit-tested without constructing a [`SurfaceVirtualPointer`] (its fields
/// are crate-private to the surface crate).
fn surface_cursor_for(
    pointer_id: PointerId,
    hover_map: &HoverMap,
    node_cursors: &Query<&NodeCursor>,
    child_of: &Query<&ChildOf>,
) -> Option<(String, Entity)> {
    // Topmost hovered node = smallest picking depth (the UI backend assigns depth 0 to
    // the front-most node, increasing downward through the stack).
    let (&top, _) = hover_map
        .get(&pointer_id)?
        .iter()
        .min_by(|a, b| a.1.depth.total_cmp(&b.1.depth))?;
    let owner = crate::reconcile::climb(top, child_of, |e| node_cursors.contains(e))?;
    node_cursors.get(owner).ok().map(|c| (c.0.clone(), owner))
}

#[cfg(test)]
mod tests {
    use super::*;
    use bevy::ecs::entity::EntityHashMap;
    use bevy::ecs::system::RunSystemOnce;
    use bevy::picking::backend::HitData;

    /// Spawn `n` overlapping 200×100 cursor nodes centered at (300, 200), stacked
    /// in the given order (last = topmost), run `drive_cursor_icon` for a pointer at
    /// `cursor`, and return the window's resulting `CursorIcon` (if any was written).
    fn run(cursor: Option<Vec2>, stack: &[&str]) -> Option<CursorIcon> {
        let mut world = World::new();

        let mut window = Window::default();
        window.set_physical_cursor_position(cursor.map(|c| c.as_dvec2()));
        let window_entity = world.spawn((window, PrimaryWindow)).id();

        let nodes: Vec<Entity> = stack
            .iter()
            .map(|&name| {
                world
                    .spawn((
                        ComputedNode {
                            size: Vec2::new(200.0, 100.0),
                            inverse_scale_factor: 1.0,
                            ..default()
                        },
                        UiGlobalTransform::from_translation(Vec2::new(300.0, 200.0)),
                        NodeCursor(name.to_string()),
                    ))
                    .id()
            })
            .collect();
        world.insert_resource(UiStack {
            uinodes: nodes,
            partition: Vec::new(),
        });
        world.init_resource::<CustomCursors>();

        world.run_system_once(drive_cursor_icon).unwrap();
        world.entity(window_entity).get::<CursorIcon>().cloned()
    }

    #[test]
    fn topmost_node_under_pointer_wins() {
        // Two overlapping nodes; the second (topmost) is `pointer` and claims the cursor.
        let icon = run(Some(Vec2::new(300.0, 200.0)), &["grab", "pointer"]);
        assert_eq!(icon, Some(CursorIcon::from(SystemCursorIcon::Pointer)));
    }

    #[test]
    fn single_node_sets_its_cursor() {
        let icon = run(Some(Vec2::new(300.0, 200.0)), &["text"]);
        assert_eq!(icon, Some(CursorIcon::from(SystemCursorIcon::Text)));
    }

    #[test]
    fn pointer_off_all_nodes_resets_to_default() {
        // In-window but outside the node's rect (x:200..400, y:150..250) → default arrow.
        let icon = run(Some(Vec2::new(50.0, 50.0)), &["pointer"]);
        assert_eq!(icon, Some(CursorIcon::from(SystemCursorIcon::Default)));
    }

    #[test]
    fn no_cursor_position_resets_to_default() {
        let icon = run(None, &["pointer"]);
        assert_eq!(icon, Some(CursorIcon::from(SystemCursorIcon::Default)));
    }

    /// A custom cursor registered under `"default"` becomes the app-wide base cursor:
    /// with no cursor-bearing node under the pointer, the window shows it (not the
    /// hardcoded system arrow).
    #[test]
    fn registered_default_becomes_app_cursor() {
        let mut world = World::new();
        let mut window = Window::default();
        window.set_physical_cursor_position(Some(Vec2::new(10.0, 10.0).as_dvec2()));
        let window_entity = world.spawn((window, PrimaryWindow)).id();
        // No nodes → nothing supplies a cursor, so the app default is used.
        world.insert_resource(UiStack {
            uinodes: Vec::new(),
            partition: Vec::new(),
        });

        let arrow = CustomCursorImage {
            handle: Handle::default(),
            hotspot: (0, 0),
            ..default()
        };
        let mut registry = CustomCursors::default();
        registry.0.insert("default".into(), arrow.clone());
        world.insert_resource(registry);

        world.run_system_once(drive_cursor_icon).unwrap();
        assert_eq!(
            world.entity(window_entity).get::<CursorIcon>().cloned(),
            Some(CursorIcon::Custom(CustomCursor::Image(arrow))),
        );
    }

    /// A surface leaf hovered by the virtual pointer inherits its ancestor's cursor
    /// (climb), and the topmost (smallest-depth) hovered node wins.
    #[test]
    fn surface_pointer_uses_hovered_node_cursor() {
        let mut world = World::new();
        // A `<button>`-like parent with a cursor; a childless leaf (e.g. its `<text>`)
        // with none. The virtual pointer hovers the leaf.
        let parent = world.spawn(NodeCursor("pointer".to_string())).id();
        let leaf = world.spawn(ChildOf(parent)).id();
        // A second, deeper node with a different cursor that must lose on depth.
        let behind = world.spawn(NodeCursor("grab".to_string())).id();

        let pointer_id = PointerId::Mouse;
        let mut hovered = EntityHashMap::default();
        hovered.insert(leaf, HitData::new(Entity::PLACEHOLDER, 0.0, None, None));
        hovered.insert(behind, HitData::new(Entity::PLACEHOLDER, 1.0, None, None));
        let mut hover_map = HoverMap::default();
        hover_map.insert(pointer_id, hovered);
        world.insert_resource(hover_map);

        let hit = world
            .run_system_once(
                move |hm: Res<HoverMap>, ncs: Query<&NodeCursor>, co: Query<&ChildOf>| {
                    surface_cursor_for(pointer_id, &hm, &ncs, &co)
                },
            )
            .unwrap();
        assert_eq!(hit, Some(("pointer".to_string(), parent)));
    }

    /// A hovered surface node with no cursor on itself or any ancestor contributes
    /// nothing (so the main-window hit-test / default takes over).
    #[test]
    fn surface_pointer_without_cursor_yields_none() {
        let mut world = World::new();
        let bare = world.spawn_empty().id();
        let pointer_id = PointerId::Mouse;
        let mut hovered = EntityHashMap::default();
        hovered.insert(bare, HitData::new(Entity::PLACEHOLDER, 0.0, None, None));
        let mut hover_map = HoverMap::default();
        hover_map.insert(pointer_id, hovered);
        world.insert_resource(hover_map);

        let hit = world
            .run_system_once(
                move |hm: Res<HoverMap>, ncs: Query<&NodeCursor>, co: Query<&ChildOf>| {
                    surface_cursor_for(pointer_id, &hm, &ncs, &co)
                },
            )
            .unwrap();
        assert_eq!(hit, None);
    }

    /// Resolution order: the registry wins first (so a custom cursor named after a
    /// system keyword *overrides* it), then system keywords, then a warn + default.
    #[test]
    fn custom_cursor_overrides_and_resolves() {
        let mut registry = CustomCursors::default();
        let hand = CustomCursorImage {
            handle: Handle::default(),
            hotspot: (4, 2),
            ..default()
        };
        registry.0.insert("hand".into(), hand.clone());
        // Register a custom cursor under a SYSTEM keyword name to prove override.
        registry.0.insert("pointer".into(), hand.clone());

        // Registered custom name → custom.
        assert_eq!(
            resolve_cursor("hand", &registry),
            CursorIcon::Custom(CustomCursor::Image(hand.clone())),
        );
        // "pointer" is registered → the custom image overrides the system pointer.
        assert_eq!(
            resolve_cursor("pointer", &registry),
            CursorIcon::Custom(CustomCursor::Image(hand)),
        );
        // A system keyword that isn't registered → the built-in cursor.
        assert_eq!(
            resolve_cursor("text", &registry),
            CursorIcon::from(SystemCursorIcon::Text),
        );
        // Neither registered nor a keyword → warn + default arrow.
        assert_eq!(
            resolve_cursor("missing", &registry),
            CursorIcon::from(SystemCursorIcon::Default),
        );
    }
}