1use 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#[derive(Component, Debug, Default, Clone, Copy, PartialEq)]
38pub struct SvgUserPos(pub Option<Vec2>);
39
40#[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 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 let hit = hovering
94 .and_then(|id| shape_hits.hits.get(&id))
95 .filter(|h| h.shape == entity);
96 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
120fn 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 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 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 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 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 #[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 #[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 #[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 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 #[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 #[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}