Skip to main content

blockworx_editor/widget/
hit_target.rs

1//! The canvas hit-test z-order, encoded once.
2//!
3//! Every pointer gesture — hover, click, double-click, drag — asks
4//! [`Drawing::resolve_at_pos`] what is under the cursor and reacts to the answer.
5//! Tools keep their reactions (which tool to switch to); none of them re-derives
6//! *what was hit*, so hover, click and drag can never disagree about it.
7//!
8//! Two orders meet here. *Across kinds* the priority is editorial — labels
9//! beat the bodies they sit on, a port beats a route crossing it — and
10//! [`Drawing::resolve_at_pos`] is the only place it is written down.
11//! *Within* a kind there is no separate hit order at all: hit order is draw
12//! order back to front, so the shape painted last is tested first
13//! ([`Drawing::hit_candidates`], the document's one `chronological` order
14//! reversed). Every fine-phase test lives here too, so the priorities and
15//! the tests they order can be read together.
16
17use crate::{
18    grid::{MOVE_HOVER_DISTANCE, PIN_PITCH, RESIZE_SHIM, ROUTE_HIT_MARGIN, TITLE_TEXT_SIZE},
19    shape::{
20        BaseShape, PinLocation, ShapeId, ShapeRef,
21        pin::{Pin, slot},
22    },
23    theme::Style,
24    widget::{auto_route::hit_text_anchor, drawing::Drawing},
25};
26use blockworx_doc::id::{AreaId, BlockId, ImageId, PinId, RouteId, RouteLabelId};
27use blockworx_geom::{Pos2, Rect, vec2};
28use blockworx_paint::Renderer;
29
30/// Which part of a pin was hit. The stub is last so a tag drawn over it (the
31/// tag rises only a few pixels above the stub line) stays double-clickable.
32#[derive(Clone, Copy, PartialEq, Eq, Debug)]
33pub enum PinPart {
34    Name,
35    Type,
36    Tag,
37    Stub,
38}
39
40/// What sits at a point on the canvas, resolved top-down.
41#[derive(Clone, Copy, Debug)]
42pub enum HitTarget {
43    /// A shape's title text.
44    Title(ShapeId),
45    /// A block's type label (or its "Add type" placeholder).
46    BlockType(BlockId),
47    /// A port's body, away from its own labels.
48    Port(PinId),
49    /// One part of a pin — on a child block or on a port (where the pin
50    /// stands for the port itself).
51    Pin {
52        anchor: PinId,
53        location: PinLocation,
54        part: PinPart,
55    },
56    RouteLabel {
57        route: RouteId,
58        label: RouteLabelId,
59    },
60    Route(RouteId),
61    /// A shape body: block, port, area, image, icon, or text box.
62    Shape(ShapeId),
63}
64
65impl Drawing<'_> {
66    /// What `pos` lands on, in priority order: labels first (title, block type,
67    /// then a pin's name / type / tag / stub), then a port body, then routes,
68    /// then any other shape body. `None` is empty canvas. A port is resolved
69    /// ahead of the routes so grabbing one always moves the port, never a route
70    /// crossing it; the rest of its body arrives as a plain [`HitTarget::Shape`].
71    pub fn resolve_at_pos(
72        &self,
73        pos: Pos2,
74        painter: &Style<'_, impl Renderer>,
75    ) -> Option<HitTarget> {
76        if let Some(shape) = self.title_at_pos(pos, painter) {
77            return Some(HitTarget::Title(shape));
78        }
79        if let Some(rect) = self.type_at_pos(pos, painter) {
80            return Some(HitTarget::BlockType(rect));
81        }
82        let as_part = |part| {
83            move |(anchor, location)| HitTarget::Pin {
84                anchor,
85                location,
86                part,
87            }
88        };
89        if let Some(pin) = self
90            .pin_text_at_pos(pos, painter)
91            .map(as_part(PinPart::Name))
92            .or_else(|| {
93                self.pin_type_at_pos(pos, painter)
94                    .map(as_part(PinPart::Type))
95            })
96            .or_else(|| self.pin_tag_at_pos(pos, painter).map(as_part(PinPart::Tag)))
97            .or_else(|| self.pin_stub_at_pos(pos).map(as_part(PinPart::Stub)))
98        {
99            return Some(pin);
100        }
101        if let Some(port) = self.port_at_pos(pos) {
102            return Some(HitTarget::Port(port));
103        }
104        if let Some((route, label)) = self.route_label_at_pos(pos, painter) {
105            return Some(HitTarget::RouteLabel { route, label });
106        }
107        if let Some(route) = self.route_at_pos(pos) {
108            return Some(HitTarget::Route(route));
109        }
110        self.shape_at_pos(pos).map(HitTarget::Shape)
111    }
112}
113
114/// Slack added around a drawn label's text extent when hit-testing it, so small
115/// text is comfortably clickable.
116const LABEL_HIT_PAD: f32 = 4.0;
117
118impl Drawing<'_> {
119    pub fn port_at_pos(&self, pos: Pos2) -> Option<PinId> {
120        self.ports_layer()
121            .filter(|(_, shape)| shape.gui_rect().contains(pos))
122            .filter_map(|(id, _)| match id {
123                ShapeId::Port(pid) => Some(pid),
124                _ => None,
125            })
126            .last()
127    }
128
129    /// Collect the child-block pins whose stub falls fully inside `marquee`.
130    /// Only pins on child blocks are returned; boundary ports are
131    /// marquee-selected as whole shapes, not as pins.
132    pub fn pins_in_rect(&self, marquee: Rect) -> Vec<PinId> {
133        let mut pins = Vec::new();
134        for (id, shape) in self.shape_candidates(marquee) {
135            if !id.is_block() {
136                continue;
137            }
138            shape.with_pins(|pid, _| {
139                if let Some(stub) = shape.pin_stub_rect(pid)
140                    && marquee.intersects(stub)
141                {
142                    pins.push(pid);
143                }
144            });
145        }
146        pins
147    }
148
149    /// Find any shape at `pos`, honoring the paint layers top-down. Icons are the
150    /// foreground layer, tested first. Areas are next — but by their *border*
151    /// (and title), not their interior, so a click inside one falls through.
152    /// Images are the next annotation layer (above the blocks), tested by their
153    /// whole rect so an image drawn over a block is still selectable. Blocks,
154    /// ports, and text boxes come last.
155    pub fn shape_at_pos(&self, pos: Pos2) -> Option<ShapeId> {
156        self.icon_at_pos(pos)
157            .map(ShapeId::Icon)
158            .or_else(|| self.area_at_pos(pos).map(ShapeId::Area))
159            .or_else(|| self.image_at_pos(pos).map(ShapeId::Image))
160            .or_else(|| {
161                self.hit_candidates(Rect::from_min_max(pos, pos))
162                    .into_iter()
163                    .find_map(|(id, s)| match id {
164                        // Already resolved above; skip so an image never shadows a
165                        // block here in the wrong order.
166                        ShapeId::Image(_) => None,
167                        _ => s.gui_rect().contains(pos).then_some(id),
168                    })
169            })
170    }
171
172    /// Find the top-most free-floating image whose rect contains `pos`. Images
173    /// are an annotation layer above the blocks, so they are hit-tested before
174    /// blocks/ports (see [`Self::shape_at_pos`]). Later images (drawn on top) win
175    /// ties.
176    pub fn image_at_pos(&self, pos: Pos2) -> Option<ImageId> {
177        self.images_layer()
178            .filter(|(_, shape)| shape.gui_rect().contains(pos))
179            .filter_map(|(id, _)| match id {
180                ShapeId::Image(iid) => Some(iid),
181                _ => None,
182            })
183            .last()
184    }
185
186    /// The block whose icon contains `pos`, if any. Icons are the foreground
187    /// layer (drawn above everything), so they are hit-tested before any other
188    /// shape (see [`Self::shape_at_pos`]). Later child blocks (drawn on top) win
189    /// ties.
190    pub fn icon_at_pos(&self, pos: Pos2) -> Option<BlockId> {
191        self.icons()
192            .filter(|(_, shape)| shape.gui_rect().contains(pos))
193            .filter_map(|(id, _)| match id {
194                ShapeId::Icon(bid) => Some(bid),
195                _ => None,
196            })
197            .last()
198    }
199
200    /// Find an area whose *border* (within [`RESIZE_SHIM`] of the outline) or
201    /// title text contains `pos`. The interior is deliberately not hit-testable
202    /// so clicks pass through to shapes beneath the area. Later areas (drawn
203    /// on top) win ties.
204    pub fn area_at_pos(&self, pos: Pos2) -> Option<AreaId> {
205        let mut hit = None;
206        for (id, area) in self.areas() {
207            let ShapeId::Area(cid) = id else { continue };
208            let rect = area.gui_rect();
209            let on_border =
210                rect.expand(RESIZE_SHIM).contains(pos) && !rect.shrink(RESIZE_SHIM).contains(pos);
211            let on_title = area
212                .title_anchor()
213                .is_some_and(|a| pos.distance(a) < MOVE_HOVER_DISTANCE);
214            if on_border || on_title {
215                hit = Some(cid);
216            }
217        }
218        hit
219    }
220
221    pub fn anchor_at_pos(&self, pos: Pos2) -> Option<PinId> {
222        for (_, shape) in self.hit_candidates(Rect::from_min_max(pos, pos)) {
223            let rect = shape.gui_rect();
224            let mut found: Option<PinId> = None;
225            shape.with_pins(|pin_id, _pin| {
226                if found.is_some() {
227                    return;
228                }
229                // Snap to a pin anywhere near its end or along its stub, so the
230                // route tool registers without precise aim at the connection point.
231                let near_end = shape
232                    .anchor_point_with_rect(rect, pin_id)
233                    .is_some_and(|end| end.distance(pos) < ROUTE_HIT_MARGIN.get());
234                let near_stub = shape
235                    .pin_stub_rect(pin_id)
236                    .is_some_and(|stub| stub.expand(ROUTE_HIT_MARGIN.get()).contains(pos));
237                if near_end || near_stub {
238                    found = Some(pin_id);
239                }
240            });
241            if found.is_some() {
242                return found;
243            }
244        }
245        None
246    }
247
248    /// Every pin/port anchor in the current view paired with its stub-end point.
249    /// The all-anchors generalization of [`Self::anchor_at_pos`], used to drive the
250    /// route-start/end hover targets.
251    pub fn anchor_targets(&self) -> Vec<(PinId, Pos2)> {
252        let mut out = Vec::new();
253        for (_, shape) in self.shapes() {
254            let rect = shape.gui_rect();
255            shape.with_pins(|pin_id, _pin| {
256                if let Some(end) = shape.anchor_point_with_rect(rect, pin_id) {
257                    out.push((pin_id, end));
258                }
259            });
260        }
261        out
262    }
263
264    pub fn route_label_at_pos(
265        &self,
266        pos: Pos2,
267        painter: &Style<'_, impl Renderer>,
268    ) -> Option<(RouteId, RouteLabelId)> {
269        self.route_candidates(Rect::from_min_max(pos, pos))
270            .into_iter()
271            .find_map(|(route_id, wire)| {
272                hit_text_anchor(
273                    &wire.route.name,
274                    &wire.labels,
275                    self.route_geometry(route_id)?,
276                    pos,
277                    painter,
278                )
279                .map(|label_id| (route_id, label_id))
280            })
281    }
282
283    pub fn route_at_pos(&self, pos: Pos2) -> Option<RouteId> {
284        self.route_candidates(Rect::from_min_max(pos, pos))
285            .into_iter()
286            .filter_map(|(route_id, _)| {
287                self.route_geometry(route_id)?
288                    .hovered_edge_distance(pos)
289                    .map(|d| (route_id, d))
290            })
291            .min_by(|(_, a), (_, b)| a.total_cmp(b))
292            .map(|(route_id, _)| route_id)
293    }
294
295    /// The first pin whose `hit_rect` contains `pos`, scanning pin-bearing
296    /// candidates in layer order. `hit_rect` yields the already-padded rect for
297    /// the part of the pin being tested, or `None` for a pin that doesn't draw
298    /// that part.
299    fn pin_part_at_pos(
300        &self,
301        pos: Pos2,
302        hit_rect: impl Fn(&ShapeRef<'_>, PinId, &Pin) -> Option<Rect>,
303    ) -> Option<(PinId, PinLocation)> {
304        for (_, shape) in self.hit_candidates(Rect::from_min_max(pos, pos)) {
305            let mut found: Option<(PinId, PinLocation)> = None;
306            shape.with_pins(|pid, pin| {
307                if found.is_none()
308                    && hit_rect(&shape, pid, pin).is_some_and(|rect| rect.contains(pos))
309                {
310                    let slot = slot(pin);
311                    found = Some((
312                        pid,
313                        PinLocation {
314                            side: slot.side,
315                            offset: slot.offset as f32 * PIN_PITCH,
316                        },
317                    ));
318                }
319            });
320            if found.is_some() {
321                return found;
322            }
323        }
324        None
325    }
326
327    pub fn pin_text_at_pos(
328        &self,
329        pos: Pos2,
330        painter: &Style<'_, impl Renderer>,
331    ) -> Option<(PinId, PinLocation)> {
332        self.pin_part_at_pos(pos, |shape, pid, _| {
333            Some(shape.pin_text_rect(pid, painter)?.expand(LABEL_HIT_PAD))
334        })
335    }
336
337    /// Like [`Self::pin_text_at_pos`], but hit-tests each pin's `type` label
338    /// (the smaller second line) instead of its `name`.
339    pub fn pin_type_at_pos(
340        &self,
341        pos: Pos2,
342        painter: &Style<'_, impl Renderer>,
343    ) -> Option<(PinId, PinLocation)> {
344        self.pin_part_at_pos(pos, |shape, pid, _| {
345            Some(shape.pin_type_rect(pid, painter)?.expand(LABEL_HIT_PAD))
346        })
347    }
348
349    /// Like [`Self::pin_text_at_pos`], but hit-tests each pin's stub (the red
350    /// line) instead of its `name`. Used by tools that act on the stub.
351    pub fn pin_stub_at_pos(&self, pos: Pos2) -> Option<(PinId, PinLocation)> {
352        self.pin_part_at_pos(pos, |shape, pid, _| shape.pin_stub_rect(pid))
353    }
354
355    /// Like [`Self::pin_text_at_pos`], but hit-tests each pin's `tag` label
356    /// (drawn above the stub) instead of its `name`. A hidden tag draws nothing,
357    /// so it is not hit-testable. A shown but empty tag is hit-tested against its
358    /// "+tag" placeholder extent so the prompt is double-clickable to start
359    /// editing.
360    pub fn pin_tag_at_pos(
361        &self,
362        pos: Pos2,
363        painter: &Style<'_, impl Renderer>,
364    ) -> Option<(PinId, PinLocation)> {
365        self.pin_part_at_pos(pos, |shape, pid, pin| {
366            if pin.tag_hidden {
367                return None;
368            }
369            let tag = pin.tag.clone();
370            let text = if tag.is_empty() {
371                crate::render::ADD_TAG_PLACEHOLDER
372            } else {
373                &tag
374            };
375            Some(
376                shape
377                    .tag_text_rect_for(pid, text, painter)?
378                    .expand(LABEL_HIT_PAD),
379            )
380        })
381    }
382
383    /// Find a titled shape whose title text bbox contains `pos`. Blocks and
384    /// areas have titles; areas (the top layer) are tested first. Ports and
385    /// text boxes have no title and are skipped.
386    pub fn title_at_pos(&self, pos: Pos2, painter: &Style<'_, impl Renderer>) -> Option<ShapeId> {
387        for (id, shape) in self
388            .areas()
389            .chain(self.hit_candidates(Rect::from_min_max(pos, pos)))
390        {
391            if let Some(title) = shape.title() {
392                let text_width = painter.text_size(title.name, &painter.theme().title_font).x;
393                let title_width = (text_width + 10.0).max(20.0);
394                // The clamped position — where the title actually draws (a
395                // resize can leave a stored offset outside the block).
396                let (title_pos, title_align) = crate::render::clamped_block_title_position(
397                    shape.gui_rect(),
398                    &title,
399                    text_width,
400                );
401                let bbox = title_align.anchor_size(title_pos, vec2(title_width, TITLE_TEXT_SIZE));
402                if bbox.expand(LABEL_HIT_PAD).contains(pos) {
403                    return Some(id);
404                }
405            }
406        }
407        None
408    }
409
410    /// Find a titled shape whose title anchor handle is near `pos`. Blocks and
411    /// areas have title anchors; areas (the top layer) are tested first.
412    pub fn title_anchor_at_pos(&self, pos: Pos2) -> Option<ShapeId> {
413        for (id, shape) in self
414            .areas()
415            .chain(self.hit_candidates(Rect::from_min_max(pos, pos)))
416        {
417            if let Some(anchor) = shape.title_anchor()
418                && pos.distance(anchor) < MOVE_HOVER_DISTANCE
419            {
420                return Some(id);
421            }
422        }
423        None
424    }
425
426    /// Find a block whose type-label text bbox contains `pos`. An empty type is
427    /// hit-tested against its "Add type" placeholder extent so the prompt is
428    /// double-clickable to start editing.
429    pub fn type_at_pos(&self, pos: Pos2, painter: &Style<'_, impl Renderer>) -> Option<BlockId> {
430        for (shape_id, shape) in self.hit_candidates(Rect::from_min_max(pos, pos)) {
431            let ShapeId::Rect(id) = shape_id else {
432                continue;
433            };
434            let ShapeRef::Block(block) = shape else {
435                continue;
436            };
437            if let Some(type_label) = block.type_label() {
438                let text = if type_label.name.is_empty() {
439                    crate::render::ADD_TYPE_PLACEHOLDER
440                } else {
441                    type_label.name
442                };
443                let text_width = painter.text_size(text, &painter.theme().type_font).x;
444                let width = (text_width + 10.0).max(20.0);
445                // Match the draw: a committed type clamps into the block, the
446                // empty-slot placeholder draws unclamped.
447                let (pos_anchor, align) = if type_label.name.is_empty() {
448                    crate::render::block_type_position(block.gui_rect(), &type_label)
449                } else {
450                    crate::render::clamped_block_type_position(
451                        block.gui_rect(),
452                        &type_label,
453                        text_width,
454                    )
455                };
456                let bbox = align.anchor_size(pos_anchor, vec2(width, TITLE_TEXT_SIZE));
457                if bbox.expand(LABEL_HIT_PAD).contains(pos) {
458                    return Some(id);
459                }
460            }
461        }
462        None
463    }
464
465    /// Resolve a pin to its world-space position in this scope: the tip of
466    /// the port body when the pin is the scope's own boundary, the tip of
467    /// its stub on the child block otherwise — the same split
468    /// [`Drawing::pin_shape`] resolves everywhere else.
469    pub fn anchor(&self, anchor: PinId) -> Option<Pos2> {
470        let shape = self.shape(self.pin_shape(anchor)?)?;
471        shape.anchor_point_with_rect(shape.gui_rect(), anchor)
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use crate::path::Scope;
479    use crate::shape::pin::PinSide;
480    use crate::{
481        theme::Theme,
482        widget::test_fixtures::{self as fx, Scene},
483    };
484    use blockworx_doc::fixtures::{block_id, image_id, pin_id};
485    use blockworx_geom::pos2;
486    use blockworx_paint::FontChoice;
487    use blockworx_text::measure::{Headless, Measured};
488
489    /// Run `check` against a `Drawing` over `scene` with a painter the label
490    /// hit-tests can measure real text with.
491    fn with_drawing(scene: &mut Scene, check: impl Fn(&Drawing<'_>, &Style<'_, Headless<'_>>)) {
492        let theme = Theme::default();
493        let canvas = Measured::new(FontChoice::default(), theme.palette().clone());
494        canvas.frame(|painter| {
495            let drawing = scene.drawing();
496            let style = Style::new(&theme, painter);
497            check(&drawing, &style);
498        });
499    }
500
501    /// A stale title offset (a resize can shrink the block under a dragged
502    /// title) draws clamped back inside the block; the hit test must find the
503    /// title where it draws, not at the stale offset.
504    #[test]
505    fn a_clamped_title_is_hit_where_it_draws() {
506        use crate::grid::{GRID_SIZE, TITLE_TEXT_SIZE};
507        let id = block_id(1);
508        let mut scene = Scene::new(vec![
509            fx::block_in(
510                1,
511                Scope::Root,
512                Rect::from_min_max(pos2(300.0, 300.0), pos2(300.0 + 4.0 * GRID_SIZE, 420.0)),
513            ),
514            fx::titled(1, "a rather long title"),
515            fx::title_offset(1, 500.0),
516        ]);
517        with_drawing(&mut scene, |drawing, style| {
518            let (drawn_center, stale_center) = {
519                let shape = drawing.shape(ShapeId::Rect(id)).unwrap();
520                let title = shape.title().unwrap();
521                let text_width = style.text_size(title.name, &style.theme().title_font).x;
522                let (drawn, align) = crate::render::clamped_block_title_position(
523                    shape.gui_rect(),
524                    &title,
525                    text_width,
526                );
527                let (stale, _) = crate::render::block_title_position(shape.gui_rect(), &title);
528                // Precondition: the stale offset genuinely diverges from the
529                // drawn position, or this test distinguishes nothing.
530                assert!(
531                    (stale.x - drawn.x).abs() > 4.0 * GRID_SIZE,
532                    "stale {stale:?} vs drawn {drawn:?}"
533                );
534                (
535                    align
536                        .anchor_size(drawn, vec2(text_width, TITLE_TEXT_SIZE))
537                        .center(),
538                    align
539                        .anchor_size(stale, vec2(text_width, TITLE_TEXT_SIZE))
540                        .center(),
541                )
542            };
543            assert_eq!(
544                drawing.title_at_pos(drawn_center, style),
545                Some(ShapeId::Rect(id)),
546                "the drawn title must be hittable"
547            );
548            assert_eq!(
549                drawing.title_at_pos(stale_center, style),
550                None,
551                "the stale position draws nothing and must not hit"
552            );
553        });
554    }
555
556    /// One 300×300 block titled "b", typed "T", carrying a west pin named "in"
557    /// with a type and a visible tag — so its title, its pin's label cluster,
558    /// and its body all overlap somewhere.
559    fn overlapping_targets() -> Scene {
560        Scene::new(vec![
561            fx::block_in(
562                1,
563                Scope::Root,
564                Rect::from_min_max(pos2(300.0, 300.0), pos2(600.0, 600.0)),
565            ),
566            fx::titled(1, "b"),
567            fx::typed(1, "T"),
568            fx::pin(3, 1, PinSide::West, 0),
569            fx::pin_typed(3, "u8"),
570            fx::pin_tagged(3, "T1"),
571            fx::pin_tag_shown(3),
572        ])
573    }
574
575    #[test]
576    fn a_title_wins_over_the_block_body_beneath_it() {
577        let rid = block_id(1);
578        with_drawing(&mut overlapping_targets(), |data, painter| {
579            let title = data
580                .shape(ShapeId::Rect(rid))
581                .unwrap()
582                .title_anchor()
583                .unwrap();
584            assert!(
585                data.shape_at_pos(title).is_some(),
586                "the probe must also be over the body, or the test proves nothing"
587            );
588            assert!(matches!(
589                data.resolve_at_pos(title, painter),
590                Some(HitTarget::Title(ShapeId::Rect(hit))) if hit == rid
591            ));
592        });
593    }
594
595    #[test]
596    fn a_pin_name_wins_over_the_block_body_beneath_it() {
597        with_drawing(&mut overlapping_targets(), |data, painter| {
598            let block = data.shape(ShapeId::Rect(block_id(1))).unwrap();
599            let probe = block.pin_text_rect(pin_id(3), painter).unwrap().center();
600            assert!(data.shape_at_pos(probe).is_some());
601            assert!(matches!(
602                data.resolve_at_pos(probe, painter),
603                Some(HitTarget::Pin {
604                    part: PinPart::Name,
605                    ..
606                })
607            ));
608        });
609    }
610
611    #[test]
612    fn a_pin_tag_wins_over_the_stub_it_is_drawn_over() {
613        with_drawing(&mut overlapping_targets(), |data, painter| {
614            let block = data.shape(ShapeId::Rect(block_id(1))).unwrap();
615            let tag = block.tag_text_rect_for(pin_id(3), "T1", painter).unwrap();
616            let stub = block.pin_stub_rect(pin_id(3)).unwrap();
617            let probe = tag.intersect(stub).center();
618            assert!(
619                tag.intersects(stub),
620                "the tag must overlap the stub, or the test proves nothing"
621            );
622            assert!(matches!(
623                data.resolve_at_pos(probe, painter),
624                Some(HitTarget::Pin {
625                    part: PinPart::Tag,
626                    ..
627                })
628            ));
629        });
630    }
631
632    #[test]
633    fn a_bare_stub_still_resolves_to_the_stub() {
634        with_drawing(&mut overlapping_targets(), |data, painter| {
635            let block = data.shape(ShapeId::Rect(block_id(1))).unwrap();
636            let stub = block.pin_stub_rect(pin_id(3)).unwrap();
637            let probe = pos2(stub.center().x, stub.bottom() - 1.0);
638            assert!(matches!(
639                data.resolve_at_pos(probe, painter),
640                Some(HitTarget::Pin {
641                    part: PinPart::Stub,
642                    ..
643                })
644            ));
645        });
646    }
647
648    #[test]
649    fn a_body_hit_with_nothing_over_it_resolves_to_the_shape() {
650        let rid = block_id(1);
651        with_drawing(&mut overlapping_targets(), |data, painter| {
652            let probe = data.shape(ShapeId::Rect(rid)).unwrap().gui_rect().center();
653            assert!(matches!(
654                data.resolve_at_pos(probe, painter),
655                Some(HitTarget::Shape(ShapeId::Rect(hit))) if hit == rid
656            ));
657        });
658    }
659
660    // A port's name is drawn inside its body, and double-clicking it is how a
661    // port is renamed — so the label must win over the body it sits on.
662    #[test]
663    fn a_port_name_wins_over_the_port_body_beneath_it() {
664        let port = pin_id(4);
665        let mut scene = Scene::new(vec![fx::pin_at(
666            4,
667            Scope::Root,
668            "Port 4",
669            fx::slot(PinSide::West, 0),
670            Rect::from_min_max(pos2(0.0, 0.0), pos2(60.0, 30.0)),
671        )]);
672        with_drawing(&mut scene, |data, painter| {
673            let probe = data
674                .shape(ShapeId::Port(port))
675                .unwrap()
676                .pin_text_rect(port, painter)
677                .unwrap()
678                .center();
679            assert_eq!(data.port_at_pos(probe), Some(port));
680            assert!(matches!(
681                data.resolve_at_pos(probe, painter),
682                Some(HitTarget::Pin {
683                    anchor,
684                    part: PinPart::Name,
685                    ..
686                }) if anchor == port
687            ));
688        });
689    }
690
691    #[test]
692    fn empty_canvas_resolves_to_nothing() {
693        with_drawing(&mut overlapping_targets(), |data, painter| {
694            assert!(data.resolve_at_pos(pos2(-500.0, -500.0), painter).is_none());
695        });
696    }
697
698    #[test]
699    fn an_image_over_a_block_is_hit_first() {
700        // Images are an annotation layer above the blocks, so a click on an image
701        // sitting over a block selects the image, not the block underneath.
702        let sid = image_id(5);
703        let mut scene = Scene::new(vec![
704            fx::asset().1,
705            fx::block(1, 0.0), // (0,0)..(45,60)
706            fx::image(
707                5,
708                Scope::Root,
709                Rect::from_min_max(pos2(10.0, 10.0), pos2(30.0, 30.0)),
710            ),
711        ]);
712        let drawing = scene.drawing();
713        let center = drawing
714            .shape(ShapeId::Image(sid))
715            .unwrap()
716            .gui_rect()
717            .center();
718        assert!(
719            drawing
720                .shape(ShapeId::Rect(block_id(1)))
721                .unwrap()
722                .gui_rect()
723                .contains(center),
724            "the probe must be over the block too, or the test proves nothing"
725        );
726        assert_eq!(
727            drawing.shape_at_pos(center),
728            Some(ShapeId::Image(sid)),
729            "the image wins the hit over the block beneath it"
730        );
731    }
732
733    #[test]
734    fn an_icon_is_selectable_like_any_shape() {
735        // An icon is a first-class shape: clicking it selects it directly (no
736        // need to select its block first). It is the foreground layer, so it is
737        // hit-tested before the block beneath it, and the indexed and linear
738        // paths must agree — `icon_at_pos` is index-independent.
739        use crate::widget::spatial::SpatialIndex;
740        let a = block_id(1);
741        let icon_box = Rect::from_min_max(pos2(8.0, 8.0), pos2(32.0, 32.0));
742        let mut scene = Scene::new(vec![
743            fx::asset().1,
744            fx::block(1, 0.0),
745            fx::icon(1, icon_box),
746        ]);
747        let probe = icon_box.center();
748
749        let linear = scene.drawing().shape_at_pos(probe);
750        let index = SpatialIndex::from_drawing(&scene.drawing());
751        let Scene {
752            doc,
753            index: doc_index,
754            presentation,
755            gesture,
756            path,
757            ..
758        } = &mut scene;
759        let indexed =
760            Drawing::new_indexed(doc_index.view(doc), path, &index, presentation, gesture)
761                .shape_at_pos(probe);
762        assert_eq!(
763            linear,
764            Some(ShapeId::Icon(a)),
765            "clicking an icon selects it"
766        );
767        assert_eq!(indexed, linear, "indexed and linear hit-tests must agree");
768    }
769
770    #[test]
771    fn pins_in_rect_collects_only_enclosed_child_block_pins() {
772        let (a, pa) = (block_id(1), pin_id(3));
773        let mut scene = Scene::new(vec![fx::block(1, 0.0), fx::pin(3, 1, PinSide::East, 0)]);
774        let drawing = scene.drawing();
775
776        let stub = drawing
777            .shape(ShapeId::Rect(a))
778            .unwrap()
779            .pin_stub_rect(pa)
780            .unwrap();
781        assert_eq!(drawing.pins_in_rect(stub.expand(5.0)), vec![pa]);
782        let far = Rect::from_min_size(pos2(1000.0, 1000.0), vec2(20.0, 20.0));
783        assert!(drawing.pins_in_rect(far).is_empty());
784    }
785
786    // A marquee that only *touches* a pin's stub (without enclosing it) still
787    // selects the pin — `pins_in_rect` tests `intersects`, not `contains_rect`.
788    #[test]
789    fn pins_in_rect_selects_a_pin_whose_stub_is_merely_touched() {
790        let (a, pa) = (block_id(1), pin_id(3));
791        let mut scene = Scene::new(vec![fx::block(1, 0.0), fx::pin(3, 1, PinSide::East, 0)]);
792        let drawing = scene.drawing();
793        let stub = drawing
794            .shape(ShapeId::Rect(a))
795            .unwrap()
796            .pin_stub_rect(pa)
797            .unwrap();
798        // A thin sliver over the stub's outer edge: overlaps but cannot enclose.
799        let touch = Rect::from_min_max(
800            pos2(stub.right() - 1.0, stub.top()),
801            pos2(stub.right() + 5.0, stub.bottom()),
802        );
803        assert!(
804            !touch.contains_rect(stub),
805            "marquee must not enclose the stub"
806        );
807        assert_eq!(drawing.pins_in_rect(touch), vec![pa]);
808    }
809
810    // The route tool snaps to a pin from anywhere near its stub or its end, with
811    // a margin wider than the old `HIT_RADIUS`.
812    #[test]
813    fn anchor_at_pos_snaps_from_near_the_stub_or_a_widened_end() {
814        let (a, pa) = (block_id(1), pin_id(3));
815        let mut scene = Scene::new(vec![fx::block(1, 0.0), fx::pin(3, 1, PinSide::East, 0)]);
816        let drawing = scene.drawing();
817        let (stub_center, head) = {
818            let s = drawing.shape(ShapeId::Rect(a)).unwrap();
819            (
820                s.pin_stub_rect(pa).unwrap().center(),
821                s.anchor_point_with_rect(s.gui_rect(), pa).unwrap(),
822            )
823        };
824        // Anywhere along the stub registers.
825        assert_eq!(drawing.anchor_at_pos(stub_center), Some(pa));
826        // Past the old 9px HIT_RADIUS but within ROUTE_HIT_MARGIN of the end.
827        let near = head + vec2(12.0, 0.0);
828        assert!(head.distance(near) > crate::grid::HIT_RADIUS.get());
829        assert!(head.distance(near) < ROUTE_HIT_MARGIN.get());
830        assert_eq!(drawing.anchor_at_pos(near), Some(pa));
831        // Far away: nothing.
832        assert!(drawing.anchor_at_pos(pos2(500.0, 500.0)).is_none());
833    }
834}