Skip to main content

bevy_react/svg/
interact.rs

1//! `Interaction` / `RelativeCursorPosition` / user-space cursor synthesis for
2//! JSX `<svg>` shape children.
3//!
4//! Shapes are **Node-less** entities, so `ui_focus_system` (which drives
5//! `Interaction` + `RelativeCursorPosition` geometrically for layout nodes)
6//! never sees them. [`sync_shape_interactions`] re-derives both from the
7//! picking pipeline instead — the [`HoverMap`] (which contains a shape only
8//! because [`super::pick::refine_svg_pointer_hits`] emitted a refined
9//! `PointerHits` for it) plus the pointer's [`PointerPress`] — mirroring
10//! [`crate::layer::pick3d::correct_transformed_interactions`]. The refined
11//! hit's user-space cursor lands in [`SvgUserPos`], which the event
12//! collectors ([`crate::reconcile`]'s pointer/hover systems) read to report
13//! `x`/`y` in SVG **user units** instead of the node-normalized values.
14//!
15//! Only handler-bearing shapes participate: `reconcile::svg_ops` stamps
16//! `Interaction` (+ `RelativeCursorPosition`/[`SvgUserPos`]) exactly when the
17//! shape declares `onClick`/`onPointer*`, and this system queries through
18//! `Interaction` — a handler-less shape is skipped entirely, so its hits fall
19//! through to the `<svg>` root's own handlers via the event collectors'
20//! `ChildOf` climb.
21
22use bevy::picking::hover::HoverMap;
23use bevy::picking::pointer::{PointerId, PointerPress};
24use bevy::prelude::*;
25use bevy::ui::{ComputedNode, RelativeCursorPosition};
26
27use super::paint::view_box_transform;
28use super::pick::{SvgPointerShapeHits, map_point};
29use super::{SvgShape, SvgSurface};
30
31/// The cursor in the enclosing `<svg>` root's **user space** while a pointer
32/// hovers this shape; `None` while not hovered. Written by
33/// [`sync_shape_interactions`]; stamped/removed alongside the shape's handler
34/// components (see `reconcile::svg_ops`). The position lives in an interior
35/// `Option` (rather than inserting/removing the component per hover flip) so
36/// a hover boundary never causes archetype churn.
37#[derive(Component, Debug, Default, Clone, Copy, PartialEq)]
38pub struct SvgUserPos(pub Option<Vec2>);
39
40/// Drive `Interaction`, `RelativeCursorPosition`, and [`SvgUserPos`] for
41/// handler-bearing SVG shapes from this frame's [`HoverMap`] + press state +
42/// refined shape hits. Runs before `apply_interaction_styles` /
43/// `collect_hover_events` / `collect_pointer_events` (the
44/// `correct_transformed_interactions` slot) so styling, enter/leave, and
45/// drag positions all see the synthesized state.
46///
47/// Leave contract (the pick3d one): pointer off the shape → `Interaction::None`,
48/// `cursor_over: false` + `normalized: None`, user pos `None` — nothing sticky.
49#[allow(clippy::type_complexity)]
50pub(crate) fn sync_shape_interactions(
51    hover_map: Option<Res<HoverMap>>,
52    pointers: Query<(&PointerId, &PointerPress)>,
53    shape_hits: Res<SvgPointerShapeHits>,
54    roots: Query<(&SvgSurface, &ComputedNode)>,
55    mut shapes: Query<
56        (
57            Entity,
58            &mut Interaction,
59            Option<&mut RelativeCursorPosition>,
60            Option<&mut SvgUserPos>,
61        ),
62        With<SvgShape>,
63    >,
64) {
65    for (entity, mut interaction, rel, user) in &mut shapes {
66        // Hovered under ANY pointer: the mouse, the surface virtual pointer,
67        // and the transform3d virtual pointer all deliver shapes into the
68        // hover map the same way (the D2 refinement rides each hit entry's
69        // own pointer id). When several pointers hover the same shape the
70        // `find` over the HashMap picks an arbitrary one — acceptable: hit
71        // suppression makes simultaneous hover on one shape practically
72        // unreachable (a virtual pointer only exists where the real one is
73        // suppressed).
74        let hovering = hover_map.as_ref().and_then(|map| {
75            map.iter()
76                .find(|(_, hits)| hits.contains_key(&entity))
77                .map(|(id, _)| *id)
78        });
79        let pressed = hovering.is_some_and(|id| {
80            pointers
81                .iter()
82                .any(|(pid, press)| *pid == id && press.is_primary_pressed())
83        });
84        let desired = match (hovering.is_some(), pressed) {
85            (true, true) => Interaction::Pressed,
86            (true, false) => Interaction::Hovered,
87            (false, _) => Interaction::None,
88        };
89        interaction.set_if_neq(desired);
90        // This frame's refined hit for the hovering pointer — when it is ours
91        // (two overlapping `<svg>` roots can hand the pointer's handoff entry
92        // to the OTHER root's topmost shape).
93        let hit = hovering
94            .and_then(|id| shape_hits.hits.get(&id))
95            .filter(|h| h.shape == entity);
96        // The relative cursor is normalized against the SVG ROOT's box (the
97        // shape has no box of its own), centered like `ui_focus_system`'s
98        // own write; `normalized_01` shifts it for the wire.
99        if let Some(mut rel) = rel {
100            let normalized = hit.and_then(|h| {
101                roots
102                    .get(h.root)
103                    .ok()
104                    .and_then(|(surface, node)| user_to_root_normalized(surface, node, h.user_pos))
105            });
106            let next = RelativeCursorPosition {
107                cursor_over: hovering.is_some(),
108                normalized,
109            };
110            if rel.cursor_over != next.cursor_over || rel.normalized != next.normalized {
111                *rel = next;
112            }
113        }
114        if let Some(mut user) = user {
115            user.set_if_neq(SvgUserPos(hit.map(|h| h.user_pos)));
116        }
117    }
118}
119
120/// Map an SVG-user-space point into the root's centered-normalized box
121/// coordinates (the `ComputedNode::normalize_point` convention: `(-0.5,-0.5)`
122/// top-left … `(0.5,0.5)` bottom-right) — the exact forward of
123/// [`super::pick::cursor_to_user_space`]'s inverse. `None` on a zero-sized
124/// box.
125fn user_to_root_normalized(
126    surface: &SvgSurface,
127    node: &ComputedNode,
128    user_pos: Vec2,
129) -> Option<Vec2> {
130    let (w, h) = crate::canvas::clamp_physical_size(node.size);
131    if w == 0 || h == 0 {
132        return None;
133    }
134    let scale_factor = super::node_scale_factor(node);
135    let local = map_point(
136        view_box_transform(surface.view_box.as_ref(), w, h, scale_factor),
137        user_pos,
138    );
139    Some(local / node.size - Vec2::splat(0.5))
140}
141
142#[cfg(test)]
143mod tests {
144    use bevy::ecs::entity::EntityHashMap;
145    use bevy::ecs::system::RunSystemOnce;
146    use bevy::picking::backend::HitData;
147    use bevy::reflect::structs::Struct;
148
149    use super::super::pick::SvgShapeHit;
150    use super::*;
151    use crate::svg::{ShapeAttrs, ShapeKind, ViewBox, st};
152
153    /// A world with one `<svg>` root (200×200 physical box, viewBox
154    /// `0 0 100 100`) and one handler-bearing circle shape carrying the full
155    /// synthesis component set. Returns `(world, root, shape)`.
156    fn shape_world() -> (World, Entity, Entity) {
157        let mut world = World::new();
158        world.init_resource::<SvgPointerShapeHits>();
159        let root = world
160            .spawn((
161                SvgSurface::jsx(Some(ViewBox {
162                    min: Vec2::ZERO,
163                    size: Vec2::splat(100.0),
164                })),
165                ComputedNode {
166                    size: Vec2::splat(200.0),
167                    ..Default::default()
168                },
169            ))
170            .id();
171        let shape = world
172            .spawn((
173                SvgShape {
174                    kind: ShapeKind::Circle,
175                    attrs: ShapeAttrs {
176                        cx: st(50.0),
177                        cy: st(50.0),
178                        r: st(40.0),
179                        ..Default::default()
180                    },
181                },
182                ChildOf(root),
183                Interaction::None,
184                RelativeCursorPosition::default(),
185                SvgUserPos::default(),
186            ))
187            .id();
188        (world, root, shape)
189    }
190
191    /// Put `entity` (alone) under `pointer` in the hover map.
192    fn hover(world: &mut World, pointer: PointerId, entity: Entity) {
193        let mut map = HoverMap::default();
194        let mut entry: EntityHashMap<HitData> = EntityHashMap::default();
195        entry.insert(entity, HitData::new(Entity::PLACEHOLDER, 0.0, None, None));
196        map.insert(pointer, entry);
197        world.insert_resource(map);
198    }
199
200    /// Record the D2 handoff entry for `pointer`.
201    fn set_hit(world: &mut World, pointer: PointerId, root: Entity, shape: Entity, user_pos: Vec2) {
202        world.resource_mut::<SvgPointerShapeHits>().hits.insert(
203            pointer,
204            SvgShapeHit {
205                root,
206                shape,
207                user_pos,
208                depth: 0.0,
209            },
210        );
211    }
212
213    /// A `PointerPress` with the primary button held. The real writer is
214    /// bevy_picking's `PointerInput::receive` (fields are private), so the
215    /// test drives the reflected field directly.
216    fn primary_pressed() -> PointerPress {
217        let mut press = PointerPress::default();
218        *press
219            .field_mut("primary")
220            .expect("PointerPress has a `primary` field")
221            .try_downcast_mut::<bool>()
222            .expect("primary is a bool") = true;
223        press
224    }
225
226    /// Hovered shape: `Interaction::Hovered`; `RelativeCursorPosition` is
227    /// over and normalized against the ROOT's box (user (25,50) in a
228    /// 100-unit viewBox on a 200px box → centered (-0.25, 0.0)); and
229    /// `SvgUserPos(Some(user_pos))`.
230    #[test]
231    fn hovered_shape_synthesizes_full_state() {
232        let (mut world, root, shape) = shape_world();
233        world.spawn((PointerId::Mouse, PointerPress::default()));
234        hover(&mut world, PointerId::Mouse, shape);
235        set_hit(
236            &mut world,
237            PointerId::Mouse,
238            root,
239            shape,
240            Vec2::new(25.0, 50.0),
241        );
242        world.run_system_once(sync_shape_interactions).unwrap();
243
244        assert_eq!(
245            *world.get::<Interaction>(shape).unwrap(),
246            Interaction::Hovered
247        );
248        let rel = world.get::<RelativeCursorPosition>(shape).unwrap();
249        assert!(rel.cursor_over, "hovered → cursor_over");
250        let n = rel.normalized.expect("a refined hit yields a position");
251        assert!(
252            n.distance(Vec2::new(-0.25, 0.0)) < 1e-4,
253            "user (25,50) normalizes against the ROOT box; got {n:?}"
254        );
255        assert_eq!(
256            world.get::<SvgUserPos>(shape).unwrap().0,
257            Some(Vec2::new(25.0, 50.0)),
258            "the user-space cursor is published while hovered"
259        );
260    }
261
262    /// The hovering pointer's primary button held → `Interaction::Pressed`.
263    #[test]
264    fn pressed_pointer_presses_shape() {
265        let (mut world, root, shape) = shape_world();
266        world.spawn((PointerId::Mouse, primary_pressed()));
267        hover(&mut world, PointerId::Mouse, shape);
268        set_hit(&mut world, PointerId::Mouse, root, shape, Vec2::splat(50.0));
269        world.run_system_once(sync_shape_interactions).unwrap();
270        assert_eq!(
271            *world.get::<Interaction>(shape).unwrap(),
272            Interaction::Pressed
273        );
274    }
275
276    /// The leave contract: pointer off the shape → `Interaction::None`,
277    /// `cursor_over: false` + `normalized: None`, user pos `None` — nothing
278    /// sticky from the hovered frame.
279    #[test]
280    fn pointer_leave_clears_everything() {
281        let (mut world, root, shape) = shape_world();
282        world.spawn((PointerId::Mouse, PointerPress::default()));
283        hover(&mut world, PointerId::Mouse, shape);
284        set_hit(&mut world, PointerId::Mouse, root, shape, Vec2::splat(50.0));
285        world.run_system_once(sync_shape_interactions).unwrap();
286        assert_eq!(
287            *world.get::<Interaction>(shape).unwrap(),
288            Interaction::Hovered
289        );
290
291        // The pointer moves off: empty hover map, handoff rebuilt empty.
292        world.insert_resource(HoverMap::default());
293        world.resource_mut::<SvgPointerShapeHits>().hits.clear();
294        world.run_system_once(sync_shape_interactions).unwrap();
295
296        assert_eq!(*world.get::<Interaction>(shape).unwrap(), Interaction::None);
297        let rel = world.get::<RelativeCursorPosition>(shape).unwrap();
298        assert!(!rel.cursor_over, "leave clears cursor_over");
299        assert_eq!(rel.normalized, None, "leave clears the position");
300        assert_eq!(
301            world.get::<SvgUserPos>(shape).unwrap().0,
302            None,
303            "leave clears the user-space cursor"
304        );
305    }
306
307    /// Handler-less shapes (no `Interaction` component) are skipped — the
308    /// system must not grow components on them; and a non-shape entity's
309    /// `Interaction` is untouched even while the hover map lists it.
310    #[test]
311    fn handlerless_shapes_and_non_shapes_untouched() {
312        let (mut world, root, _shape) = shape_world();
313        world.spawn((PointerId::Mouse, PointerPress::default()));
314        let bare = world
315            .spawn((
316                SvgShape {
317                    kind: ShapeKind::Rect,
318                    attrs: ShapeAttrs::default(),
319                },
320                ChildOf(root),
321            ))
322            .id();
323        let node = world.spawn(Interaction::Hovered).id();
324        hover(&mut world, PointerId::Mouse, bare);
325        world.run_system_once(sync_shape_interactions).unwrap();
326
327        assert!(
328            world.get::<Interaction>(bare).is_none(),
329            "a handler-less shape must not gain Interaction"
330        );
331        assert_eq!(
332            *world.get::<Interaction>(node).unwrap(),
333            Interaction::Hovered,
334            "non-shape entities are not this system's business"
335        );
336    }
337
338    /// An `onClick`-only shape carries `Interaction` but no
339    /// `RelativeCursorPosition` (the generic handler-stamping rule): the
340    /// optional fetches must tolerate that.
341    #[test]
342    fn click_only_shape_drives_interaction_alone() {
343        let (mut world, root, _full) = shape_world();
344        world.spawn((PointerId::Mouse, PointerPress::default()));
345        let click_only = world
346            .spawn((
347                SvgShape {
348                    kind: ShapeKind::Rect,
349                    attrs: ShapeAttrs::default(),
350                },
351                ChildOf(root),
352                Interaction::None,
353            ))
354            .id();
355        hover(&mut world, PointerId::Mouse, click_only);
356        set_hit(&mut world, PointerId::Mouse, root, click_only, Vec2::ONE);
357        world.run_system_once(sync_shape_interactions).unwrap();
358        assert_eq!(
359            *world.get::<Interaction>(click_only).unwrap(),
360            Interaction::Hovered
361        );
362    }
363}