Skip to main content

blockworx/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, PinSide, 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    /// The block side (East/West) `anchor`'s pin sits on — i.e. the direction its
265    /// stub, and a route leaving it, extend. Used to point the drag-to-route hint.
266    pub fn anchor_side(&self, anchor: PinId) -> Option<PinSide> {
267        Some(slot(self.held_pin(anchor)?).side)
268    }
269
270    pub fn route_label_at_pos(
271        &self,
272        pos: Pos2,
273        painter: &Style<'_, impl Renderer>,
274    ) -> Option<(RouteId, RouteLabelId)> {
275        self.route_candidates(Rect::from_min_max(pos, pos))
276            .into_iter()
277            .find_map(|(route_id, wire)| {
278                hit_text_anchor(
279                    &wire.route.name,
280                    &wire.labels,
281                    self.route_geometry(route_id)?,
282                    pos,
283                    painter,
284                )
285                .map(|label_id| (route_id, label_id))
286            })
287    }
288
289    pub fn route_at_pos(&self, pos: Pos2) -> Option<RouteId> {
290        self.route_candidates(Rect::from_min_max(pos, pos))
291            .into_iter()
292            .filter_map(|(route_id, _)| {
293                self.route_geometry(route_id)?
294                    .hovered_edge_distance(pos)
295                    .map(|d| (route_id, d))
296            })
297            .min_by(|(_, a), (_, b)| a.total_cmp(b))
298            .map(|(route_id, _)| route_id)
299    }
300
301    /// The first pin whose `hit_rect` contains `pos`, scanning pin-bearing
302    /// candidates in layer order. `hit_rect` yields the already-padded rect for
303    /// the part of the pin being tested, or `None` for a pin that doesn't draw
304    /// that part.
305    fn pin_part_at_pos(
306        &self,
307        pos: Pos2,
308        hit_rect: impl Fn(&ShapeRef<'_>, PinId, &Pin) -> Option<Rect>,
309    ) -> Option<(PinId, PinLocation)> {
310        for (_, shape) in self.hit_candidates(Rect::from_min_max(pos, pos)) {
311            let mut found: Option<(PinId, PinLocation)> = None;
312            shape.with_pins(|pid, pin| {
313                if found.is_none()
314                    && hit_rect(&shape, pid, pin).is_some_and(|rect| rect.contains(pos))
315                {
316                    let slot = slot(pin);
317                    found = Some((
318                        pid,
319                        PinLocation {
320                            side: slot.side,
321                            offset: slot.offset as f32 * PIN_PITCH,
322                        },
323                    ));
324                }
325            });
326            if found.is_some() {
327                return found;
328            }
329        }
330        None
331    }
332
333    pub fn pin_text_at_pos(
334        &self,
335        pos: Pos2,
336        painter: &Style<'_, impl Renderer>,
337    ) -> Option<(PinId, PinLocation)> {
338        self.pin_part_at_pos(pos, |shape, pid, _| {
339            Some(shape.pin_text_rect(pid, painter)?.expand(LABEL_HIT_PAD))
340        })
341    }
342
343    /// Like [`Self::pin_text_at_pos`], but hit-tests each pin's `type` label
344    /// (the smaller second line) instead of its `name`.
345    pub fn pin_type_at_pos(
346        &self,
347        pos: Pos2,
348        painter: &Style<'_, impl Renderer>,
349    ) -> Option<(PinId, PinLocation)> {
350        self.pin_part_at_pos(pos, |shape, pid, _| {
351            Some(shape.pin_type_rect(pid, painter)?.expand(LABEL_HIT_PAD))
352        })
353    }
354
355    /// Like [`Self::pin_text_at_pos`], but hit-tests each pin's stub (the red
356    /// line) instead of its `name`. Used by tools that act on the stub.
357    pub fn pin_stub_at_pos(&self, pos: Pos2) -> Option<(PinId, PinLocation)> {
358        self.pin_part_at_pos(pos, |shape, pid, _| shape.pin_stub_rect(pid))
359    }
360
361    /// Like [`Self::pin_text_at_pos`], but hit-tests each pin's `tag` label
362    /// (drawn above the stub) instead of its `name`. A hidden tag draws nothing,
363    /// so it is not hit-testable. A shown but empty tag is hit-tested against its
364    /// "+tag" placeholder extent so the prompt is double-clickable to start
365    /// editing.
366    pub fn pin_tag_at_pos(
367        &self,
368        pos: Pos2,
369        painter: &Style<'_, impl Renderer>,
370    ) -> Option<(PinId, PinLocation)> {
371        self.pin_part_at_pos(pos, |shape, pid, pin| {
372            if pin.tag_hidden {
373                return None;
374            }
375            let tag = pin.tag.clone();
376            let text = if tag.is_empty() {
377                crate::render::ADD_TAG_PLACEHOLDER
378            } else {
379                &tag
380            };
381            Some(
382                shape
383                    .tag_text_rect_for(pid, text, painter)?
384                    .expand(LABEL_HIT_PAD),
385            )
386        })
387    }
388
389    /// Find a titled shape whose title text bbox contains `pos`. Blocks and
390    /// areas have titles; areas (the top layer) are tested first. Ports and
391    /// text boxes have no title and are skipped.
392    pub fn title_at_pos(&self, pos: Pos2, painter: &Style<'_, impl Renderer>) -> Option<ShapeId> {
393        for (id, shape) in self
394            .areas()
395            .chain(self.hit_candidates(Rect::from_min_max(pos, pos)))
396        {
397            if let Some(title) = shape.title() {
398                let text_width = painter.text_size(title.name, &painter.theme().title_font).x;
399                let title_width = (text_width + 10.0).max(20.0);
400                // The clamped position — where the title actually draws (a
401                // resize can leave a stored offset outside the block).
402                let (title_pos, title_align) = crate::render::clamped_block_title_position(
403                    shape.gui_rect(),
404                    &title,
405                    text_width,
406                );
407                let bbox = title_align.anchor_size(title_pos, vec2(title_width, TITLE_TEXT_SIZE));
408                if bbox.expand(LABEL_HIT_PAD).contains(pos) {
409                    return Some(id);
410                }
411            }
412        }
413        None
414    }
415
416    /// Find a titled shape whose title anchor handle is near `pos`. Blocks and
417    /// areas have title anchors; areas (the top layer) are tested first.
418    pub fn title_anchor_at_pos(&self, pos: Pos2) -> Option<ShapeId> {
419        for (id, shape) in self
420            .areas()
421            .chain(self.hit_candidates(Rect::from_min_max(pos, pos)))
422        {
423            if let Some(anchor) = shape.title_anchor()
424                && pos.distance(anchor) < MOVE_HOVER_DISTANCE
425            {
426                return Some(id);
427            }
428        }
429        None
430    }
431
432    /// Find a block whose type-label text bbox contains `pos`. An empty type is
433    /// hit-tested against its "Add type" placeholder extent so the prompt is
434    /// double-clickable to start editing.
435    pub fn type_at_pos(&self, pos: Pos2, painter: &Style<'_, impl Renderer>) -> Option<BlockId> {
436        for (shape_id, shape) in self.hit_candidates(Rect::from_min_max(pos, pos)) {
437            let ShapeId::Rect(id) = shape_id else {
438                continue;
439            };
440            let ShapeRef::Block(block) = shape else {
441                continue;
442            };
443            if let Some(type_label) = block.type_label() {
444                let text = if type_label.name.is_empty() {
445                    crate::render::ADD_TYPE_PLACEHOLDER
446                } else {
447                    type_label.name
448                };
449                let text_width = painter.text_size(text, &painter.theme().type_font).x;
450                let width = (text_width + 10.0).max(20.0);
451                // Match the draw: a committed type clamps into the block, the
452                // empty-slot placeholder draws unclamped.
453                let (pos_anchor, align) = if type_label.name.is_empty() {
454                    crate::render::block_type_position(block.gui_rect(), &type_label)
455                } else {
456                    crate::render::clamped_block_type_position(
457                        block.gui_rect(),
458                        &type_label,
459                        text_width,
460                    )
461                };
462                let bbox = align.anchor_size(pos_anchor, vec2(width, TITLE_TEXT_SIZE));
463                if bbox.expand(LABEL_HIT_PAD).contains(pos) {
464                    return Some(id);
465                }
466            }
467        }
468        None
469    }
470
471    /// Resolve a pin to its world-space position in this scope: the tip of
472    /// the port body when the pin is the scope's own boundary, the tip of
473    /// its stub on the child block otherwise — the same split
474    /// [`Drawing::pin_shape`] resolves everywhere else.
475    pub fn anchor(&self, anchor: PinId) -> Option<Pos2> {
476        let shape = self.shape(self.pin_shape(anchor)?)?;
477        shape.anchor_point_with_rect(shape.gui_rect(), anchor)
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use crate::path::Scope;
485    use crate::{
486        canvas::Painter,
487        theme::Theme,
488        widget::test_fixtures::{self as fx, Scene},
489    };
490    use blockworx_doc::fixtures::{block_id, image_id, pin_id};
491    use blockworx_geom::pos2;
492
493    /// Run `check` against a `Drawing` over `scene` inside a headless egui pass,
494    /// with a painter the label hit-tests can measure text with.
495    fn with_drawing(scene: &mut Scene, check: impl Fn(&Drawing<'_>, &Style<'_, Painter>)) {
496        let ctx = egui::Context::default();
497        // The label hit-tests measure text in `CANVAS_FAMILY`, which nothing
498        // binds until the app's fonts are installed on the following pass.
499        ctx.set_fonts(crate::canvas::build_fonts(
500            blockworx_paint::FontChoice::default(),
501        ));
502        ctx.run_ui(egui::RawInput::default(), |_| {})
503            .drop_without_applying_deltas();
504        ctx.run_ui(egui::RawInput::default(), |ui| {
505            let drawing = scene.drawing();
506            let theme = Theme::default();
507            let mut painter = Painter::headless(ui.painter().clone(), theme.palette().clone());
508            let style = Style::new(&theme, &mut painter);
509            check(&drawing, &style);
510        })
511        .drop_without_applying_deltas();
512    }
513
514    /// A stale title offset (a resize can shrink the block under a dragged
515    /// title) draws clamped back inside the block; the hit test must find the
516    /// title where it draws, not at the stale offset.
517    #[test]
518    fn a_clamped_title_is_hit_where_it_draws() {
519        use crate::grid::{GRID_SIZE, TITLE_TEXT_SIZE};
520        let id = block_id(1);
521        let mut scene = Scene::new(vec![
522            fx::block_in(
523                1,
524                Scope::Root,
525                Rect::from_min_max(pos2(300.0, 300.0), pos2(300.0 + 4.0 * GRID_SIZE, 420.0)),
526            ),
527            fx::titled(1, "a rather long title"),
528            fx::title_offset(1, 500.0),
529        ]);
530        with_drawing(&mut scene, |drawing, style| {
531            let (drawn_center, stale_center) = {
532                let shape = drawing.shape(ShapeId::Rect(id)).unwrap();
533                let title = shape.title().unwrap();
534                let text_width = style.text_size(title.name, &style.theme().title_font).x;
535                let (drawn, align) = crate::render::clamped_block_title_position(
536                    shape.gui_rect(),
537                    &title,
538                    text_width,
539                );
540                let (stale, _) = crate::render::block_title_position(shape.gui_rect(), &title);
541                // Precondition: the stale offset genuinely diverges from the
542                // drawn position, or this test distinguishes nothing.
543                assert!(
544                    (stale.x - drawn.x).abs() > 4.0 * GRID_SIZE,
545                    "stale {stale:?} vs drawn {drawn:?}"
546                );
547                (
548                    align
549                        .anchor_size(drawn, vec2(text_width, TITLE_TEXT_SIZE))
550                        .center(),
551                    align
552                        .anchor_size(stale, vec2(text_width, TITLE_TEXT_SIZE))
553                        .center(),
554                )
555            };
556            assert_eq!(
557                drawing.title_at_pos(drawn_center, style),
558                Some(ShapeId::Rect(id)),
559                "the drawn title must be hittable"
560            );
561            assert_eq!(
562                drawing.title_at_pos(stale_center, style),
563                None,
564                "the stale position draws nothing and must not hit"
565            );
566        });
567    }
568
569    /// One 300×300 block titled "b", typed "T", carrying a west pin named "in"
570    /// with a type and a visible tag — so its title, its pin's label cluster,
571    /// and its body all overlap somewhere.
572    fn overlapping_targets() -> Scene {
573        Scene::new(vec![
574            fx::block_in(
575                1,
576                Scope::Root,
577                Rect::from_min_max(pos2(300.0, 300.0), pos2(600.0, 600.0)),
578            ),
579            fx::titled(1, "b"),
580            fx::typed(1, "T"),
581            fx::pin(3, 1, PinSide::West, 0),
582            fx::pin_typed(3, "u8"),
583            fx::pin_tagged(3, "T1"),
584            fx::pin_tag_shown(3),
585        ])
586    }
587
588    #[test]
589    fn a_title_wins_over_the_block_body_beneath_it() {
590        let rid = block_id(1);
591        with_drawing(&mut overlapping_targets(), |data, painter| {
592            let title = data
593                .shape(ShapeId::Rect(rid))
594                .unwrap()
595                .title_anchor()
596                .unwrap();
597            assert!(
598                data.shape_at_pos(title).is_some(),
599                "the probe must also be over the body, or the test proves nothing"
600            );
601            assert!(matches!(
602                data.resolve_at_pos(title, painter),
603                Some(HitTarget::Title(ShapeId::Rect(hit))) if hit == rid
604            ));
605        });
606    }
607
608    #[test]
609    fn a_pin_name_wins_over_the_block_body_beneath_it() {
610        with_drawing(&mut overlapping_targets(), |data, painter| {
611            let block = data.shape(ShapeId::Rect(block_id(1))).unwrap();
612            let probe = block.pin_text_rect(pin_id(3), painter).unwrap().center();
613            assert!(data.shape_at_pos(probe).is_some());
614            assert!(matches!(
615                data.resolve_at_pos(probe, painter),
616                Some(HitTarget::Pin {
617                    part: PinPart::Name,
618                    ..
619                })
620            ));
621        });
622    }
623
624    #[test]
625    fn a_pin_tag_wins_over_the_stub_it_is_drawn_over() {
626        with_drawing(&mut overlapping_targets(), |data, painter| {
627            let block = data.shape(ShapeId::Rect(block_id(1))).unwrap();
628            let tag = block.tag_text_rect_for(pin_id(3), "T1", painter).unwrap();
629            let stub = block.pin_stub_rect(pin_id(3)).unwrap();
630            let probe = tag.intersect(stub).center();
631            assert!(
632                tag.intersects(stub),
633                "the tag must overlap the stub, or the test proves nothing"
634            );
635            assert!(matches!(
636                data.resolve_at_pos(probe, painter),
637                Some(HitTarget::Pin {
638                    part: PinPart::Tag,
639                    ..
640                })
641            ));
642        });
643    }
644
645    #[test]
646    fn a_bare_stub_still_resolves_to_the_stub() {
647        with_drawing(&mut overlapping_targets(), |data, painter| {
648            let block = data.shape(ShapeId::Rect(block_id(1))).unwrap();
649            let stub = block.pin_stub_rect(pin_id(3)).unwrap();
650            let probe = pos2(stub.center().x, stub.bottom() - 1.0);
651            assert!(matches!(
652                data.resolve_at_pos(probe, painter),
653                Some(HitTarget::Pin {
654                    part: PinPart::Stub,
655                    ..
656                })
657            ));
658        });
659    }
660
661    #[test]
662    fn a_body_hit_with_nothing_over_it_resolves_to_the_shape() {
663        let rid = block_id(1);
664        with_drawing(&mut overlapping_targets(), |data, painter| {
665            let probe = data.shape(ShapeId::Rect(rid)).unwrap().gui_rect().center();
666            assert!(matches!(
667                data.resolve_at_pos(probe, painter),
668                Some(HitTarget::Shape(ShapeId::Rect(hit))) if hit == rid
669            ));
670        });
671    }
672
673    // A port's name is drawn inside its body, and double-clicking it is how a
674    // port is renamed — so the label must win over the body it sits on.
675    #[test]
676    fn a_port_name_wins_over_the_port_body_beneath_it() {
677        let port = pin_id(4);
678        let mut scene = Scene::new(vec![fx::pin_at(
679            4,
680            Scope::Root,
681            "Port 4",
682            fx::slot(PinSide::West, 0),
683            Rect::from_min_max(pos2(0.0, 0.0), pos2(60.0, 30.0)),
684        )]);
685        with_drawing(&mut scene, |data, painter| {
686            let probe = data
687                .shape(ShapeId::Port(port))
688                .unwrap()
689                .pin_text_rect(port, painter)
690                .unwrap()
691                .center();
692            assert_eq!(data.port_at_pos(probe), Some(port));
693            assert!(matches!(
694                data.resolve_at_pos(probe, painter),
695                Some(HitTarget::Pin {
696                    anchor,
697                    part: PinPart::Name,
698                    ..
699                }) if anchor == port
700            ));
701        });
702    }
703
704    #[test]
705    fn empty_canvas_resolves_to_nothing() {
706        with_drawing(&mut overlapping_targets(), |data, painter| {
707            assert!(data.resolve_at_pos(pos2(-500.0, -500.0), painter).is_none());
708        });
709    }
710
711    #[test]
712    fn an_image_over_a_block_is_hit_first() {
713        // Images are an annotation layer above the blocks, so a click on an image
714        // sitting over a block selects the image, not the block underneath.
715        let sid = image_id(5);
716        let mut scene = Scene::new(vec![
717            fx::asset().1,
718            fx::block(1, 0.0), // (0,0)..(45,60)
719            fx::image(
720                5,
721                Scope::Root,
722                Rect::from_min_max(pos2(10.0, 10.0), pos2(30.0, 30.0)),
723            ),
724        ]);
725        let drawing = scene.drawing();
726        let center = drawing
727            .shape(ShapeId::Image(sid))
728            .unwrap()
729            .gui_rect()
730            .center();
731        assert!(
732            drawing
733                .shape(ShapeId::Rect(block_id(1)))
734                .unwrap()
735                .gui_rect()
736                .contains(center),
737            "the probe must be over the block too, or the test proves nothing"
738        );
739        assert_eq!(
740            drawing.shape_at_pos(center),
741            Some(ShapeId::Image(sid)),
742            "the image wins the hit over the block beneath it"
743        );
744    }
745
746    #[test]
747    fn an_icon_is_selectable_like_any_shape() {
748        // An icon is a first-class shape: clicking it selects it directly (no
749        // need to select its block first). It is the foreground layer, so it is
750        // hit-tested before the block beneath it, and the indexed and linear
751        // paths must agree — `icon_at_pos` is index-independent.
752        use crate::widget::spatial::SpatialIndex;
753        let a = block_id(1);
754        let icon_box = Rect::from_min_max(pos2(8.0, 8.0), pos2(32.0, 32.0));
755        let mut scene = Scene::new(vec![
756            fx::asset().1,
757            fx::block(1, 0.0),
758            fx::icon(1, icon_box),
759        ]);
760        let probe = icon_box.center();
761
762        let linear = scene.drawing().shape_at_pos(probe);
763        let index = SpatialIndex::from_drawing(&scene.drawing());
764        let Scene {
765            doc,
766            index: doc_index,
767            presentation,
768            gesture,
769            path,
770            ..
771        } = &mut scene;
772        let indexed =
773            Drawing::new_indexed(doc_index.view(doc), path, &index, presentation, gesture)
774                .shape_at_pos(probe);
775        assert_eq!(
776            linear,
777            Some(ShapeId::Icon(a)),
778            "clicking an icon selects it"
779        );
780        assert_eq!(indexed, linear, "indexed and linear hit-tests must agree");
781    }
782
783    #[test]
784    fn pins_in_rect_collects_only_enclosed_child_block_pins() {
785        let (a, pa) = (block_id(1), pin_id(3));
786        let mut scene = Scene::new(vec![fx::block(1, 0.0), fx::pin(3, 1, PinSide::East, 0)]);
787        let drawing = scene.drawing();
788
789        let stub = drawing
790            .shape(ShapeId::Rect(a))
791            .unwrap()
792            .pin_stub_rect(pa)
793            .unwrap();
794        assert_eq!(drawing.pins_in_rect(stub.expand(5.0)), vec![pa]);
795        let far = Rect::from_min_size(pos2(1000.0, 1000.0), vec2(20.0, 20.0));
796        assert!(drawing.pins_in_rect(far).is_empty());
797    }
798
799    // A marquee that only *touches* a pin's stub (without enclosing it) still
800    // selects the pin — `pins_in_rect` tests `intersects`, not `contains_rect`.
801    #[test]
802    fn pins_in_rect_selects_a_pin_whose_stub_is_merely_touched() {
803        let (a, pa) = (block_id(1), pin_id(3));
804        let mut scene = Scene::new(vec![fx::block(1, 0.0), fx::pin(3, 1, PinSide::East, 0)]);
805        let drawing = scene.drawing();
806        let stub = drawing
807            .shape(ShapeId::Rect(a))
808            .unwrap()
809            .pin_stub_rect(pa)
810            .unwrap();
811        // A thin sliver over the stub's outer edge: overlaps but cannot enclose.
812        let touch = Rect::from_min_max(
813            pos2(stub.right() - 1.0, stub.top()),
814            pos2(stub.right() + 5.0, stub.bottom()),
815        );
816        assert!(
817            !touch.contains_rect(stub),
818            "marquee must not enclose the stub"
819        );
820        assert_eq!(drawing.pins_in_rect(touch), vec![pa]);
821    }
822
823    // The route tool snaps to a pin from anywhere near its stub or its end, with
824    // a margin wider than the old `HIT_RADIUS`.
825    #[test]
826    fn anchor_at_pos_snaps_from_near_the_stub_or_a_widened_end() {
827        let (a, pa) = (block_id(1), pin_id(3));
828        let mut scene = Scene::new(vec![fx::block(1, 0.0), fx::pin(3, 1, PinSide::East, 0)]);
829        let drawing = scene.drawing();
830        let (stub_center, head) = {
831            let s = drawing.shape(ShapeId::Rect(a)).unwrap();
832            (
833                s.pin_stub_rect(pa).unwrap().center(),
834                s.anchor_point_with_rect(s.gui_rect(), pa).unwrap(),
835            )
836        };
837        // Anywhere along the stub registers.
838        assert_eq!(drawing.anchor_at_pos(stub_center), Some(pa));
839        // Past the old 9px HIT_RADIUS but within ROUTE_HIT_MARGIN of the end.
840        let near = head + vec2(12.0, 0.0);
841        assert!(head.distance(near) > crate::grid::HIT_RADIUS.get());
842        assert!(head.distance(near) < ROUTE_HIT_MARGIN.get());
843        assert_eq!(drawing.anchor_at_pos(near), Some(pa));
844        // Far away: nothing.
845        assert!(drawing.anchor_at_pos(pos2(500.0, 500.0)).is_none());
846    }
847}