Skip to main content

jag_draw/
hit_test.rs

1use crate::display_list::{Command, DisplayList};
2use crate::scene::*;
3
4/// Result of a hit test for a single topmost element.
5#[derive(Clone, Debug)]
6pub struct HitResult {
7    /// Increasing draw-order identifier within the display list.
8    pub id: usize,
9    /// Z layering value; larger is visually on top.
10    pub z: i32,
11    /// Kind of primitive that was hit.
12    pub kind: HitKind,
13    /// Geometry of the hit shape.
14    pub shape: HitShape,
15    /// The local-to-world transform at draw time.
16    pub transform: Transform2D,
17    /// If this hit corresponds to a hit region, returns the user-specified region id.
18    pub region_id: Option<u32>,
19    /// Local point within the hit item's local space (after inverse transform), relative to the shape's origin.
20    pub local_pos: Option<[f32; 2]>,
21    /// Normalized coordinates within the shape's bounding box when applicable ([0,1] range).
22    pub local_uv: Option<[f32; 2]>,
23}
24
25#[derive(Clone, Debug, Copy, PartialEq, Eq)]
26pub enum HitKind {
27    Rect,
28    RoundedRect,
29    Ellipse,
30    Text,
31    StrokeRect,
32    StrokeRoundedRect,
33    Path,
34    BoxShadow,
35    HitRegion,
36    Hyperlink,
37}
38
39/// Public geometry snapshot of a hit element.
40#[derive(Clone, Debug, PartialEq)]
41pub enum HitShape {
42    Rect(Rect),
43    RoundedRect(RoundedRect),
44    Ellipse { center: [f32; 2], radii: [f32; 2] },
45    StrokeRect { rect: Rect, width: f32 },
46    StrokeRoundedRect { rrect: RoundedRect, width: f32 },
47    PathBBox { rect: Rect },
48    Text,
49    BoxShadow { rrect: RoundedRect },
50    Hyperlink { rect: Rect, url: String },
51}
52
53/// Preprocessed hit-test item built from a display list command.
54#[derive(Clone, Debug)]
55struct HitItem {
56    id: usize,
57    z: i32,
58    kind: HitKind,
59    transform: Transform2D,
60    data: HitData,
61    clips: Vec<ClipEntry>,
62    region_id: Option<u32>,
63}
64
65#[derive(Clone, Debug)]
66enum HitData {
67    Rect(Rect),
68    RoundedRect(RoundedRect),
69    Ellipse { center: [f32; 2], radii: [f32; 2] },
70    StrokeRect { rect: Rect, width: f32 },
71    StrokeRoundedRect { rrect: RoundedRect, width: f32 },
72    PathBBox(Rect),
73    Text(TextRun),
74    BoxShadow { rrect: RoundedRect },
75    Hyperlink { rect: Rect, url: String },
76}
77
78#[derive(Clone, Debug)]
79struct ClipEntry {
80    rect: Rect,
81    transform: Transform2D,
82}
83
84/// Spatial index for hit testing. Currently implemented as a flat list while
85/// preserving z-order and clip stacks. Can be upgraded to a tree later.
86#[derive(Default, Clone)]
87pub struct HitIndex {
88    items: Vec<HitItem>,
89}
90
91impl HitIndex {
92    /// Build a hit-test index from the display list. Records each drawable
93    /// element with its transform and the active clip stack at that point.
94    pub fn build(list: &DisplayList) -> Self {
95        let mut items = Vec::new();
96        let mut clips: Vec<ClipEntry> = Vec::new();
97        let mut tstack: Vec<Transform2D> = vec![Transform2D::identity()];
98        let mut next_id: usize = 0;
99
100        for cmd in &list.commands {
101            match cmd {
102                Command::PushClip(ClipRect(rect)) => {
103                    clips.push(ClipEntry {
104                        rect: *rect,
105                        transform: *tstack.last().unwrap(),
106                    });
107                }
108                Command::PopClip => {
109                    let _ = clips.pop();
110                }
111                Command::PushTransform(t) => {
112                    tstack.push(*t);
113                }
114                Command::PopTransform => {
115                    let _ = tstack.pop();
116                }
117                Command::DrawRect {
118                    rect, z, transform, ..
119                } => {
120                    items.push(HitItem {
121                        id: next_id,
122                        z: *z,
123                        kind: HitKind::Rect,
124                        transform: *transform,
125                        data: HitData::Rect(*rect),
126                        clips: clips.clone(),
127                        region_id: None,
128                    });
129                    next_id += 1;
130                }
131                Command::DrawRoundedRect {
132                    rrect,
133                    z,
134                    transform,
135                    ..
136                } => {
137                    items.push(HitItem {
138                        id: next_id,
139                        z: *z,
140                        kind: HitKind::RoundedRect,
141                        transform: *transform,
142                        data: HitData::RoundedRect(*rrect),
143                        clips: clips.clone(),
144                        region_id: None,
145                    });
146                    next_id += 1;
147                }
148                Command::DrawEllipse {
149                    center,
150                    radii,
151                    z,
152                    transform,
153                    ..
154                } => {
155                    items.push(HitItem {
156                        id: next_id,
157                        z: *z,
158                        kind: HitKind::Ellipse,
159                        transform: *transform,
160                        data: HitData::Ellipse {
161                            center: *center,
162                            radii: *radii,
163                        },
164                        clips: clips.clone(),
165                        region_id: None,
166                    });
167                    next_id += 1;
168                }
169                Command::StrokeRect {
170                    rect,
171                    stroke,
172                    z,
173                    transform,
174                    ..
175                } => {
176                    items.push(HitItem {
177                        id: next_id,
178                        z: *z,
179                        kind: HitKind::StrokeRect,
180                        transform: *transform,
181                        data: HitData::StrokeRect {
182                            rect: *rect,
183                            width: stroke.width,
184                        },
185                        clips: clips.clone(),
186                        region_id: None,
187                    });
188                    next_id += 1;
189                }
190                Command::StrokeRoundedRect {
191                    rrect,
192                    stroke,
193                    z,
194                    transform,
195                    ..
196                } => {
197                    items.push(HitItem {
198                        id: next_id,
199                        z: *z,
200                        kind: HitKind::StrokeRoundedRect,
201                        transform: *transform,
202                        data: HitData::StrokeRoundedRect {
203                            rrect: *rrect,
204                            width: stroke.width,
205                        },
206                        clips: clips.clone(),
207                        region_id: None,
208                    });
209                    next_id += 1;
210                }
211                Command::DrawText {
212                    run, z, transform, ..
213                } => {
214                    items.push(HitItem {
215                        id: next_id,
216                        z: *z,
217                        kind: HitKind::Text,
218                        transform: *transform,
219                        data: HitData::Text(run.clone()),
220                        clips: clips.clone(),
221                        region_id: None,
222                    });
223                    next_id += 1;
224                }
225                Command::DrawHyperlink {
226                    hyperlink,
227                    z,
228                    transform,
229                    ..
230                } => {
231                    // Keep hyperlinks above generic per-node hit regions (commonly z + 10)
232                    // so inline links remain hoverable/clickable inside text containers.
233                    let hit_z = z.saturating_add(11);
234                    let text_width = hyperlink.measured_width.unwrap_or_else(|| {
235                        // Fallback heuristic (slightly generous). Include a small
236                        // boost for heavier font weights.
237                        let weight_boost = ((hyperlink.weight - 400.0).max(0.0) / 500.0) * 0.08;
238                        let char_width = hyperlink.size * (0.55 + weight_boost);
239                        hyperlink.text.chars().count() as f32 * char_width
240                    });
241                    let text_height = hyperlink.size * 1.2; // Include descenders
242
243                    let rect = Rect {
244                        x: hyperlink.pos[0],
245                        y: hyperlink.pos[1] - hyperlink.size, // Baseline to top
246                        w: text_width,
247                        h: text_height,
248                    };
249
250                    items.push(HitItem {
251                        id: next_id,
252                        z: hit_z,
253                        kind: HitKind::Hyperlink,
254                        transform: *transform,
255                        data: HitData::Hyperlink {
256                            rect,
257                            url: hyperlink.url.clone(),
258                        },
259                        clips: clips.clone(),
260                        region_id: None,
261                    });
262                    next_id += 1;
263                }
264                Command::FillPath { .. } => {
265                    // Coarse bbox hit: compute path bounding box (approximate using control/end points).
266                    if let Command::FillPath {
267                        path, z, transform, ..
268                    } = cmd
269                    {
270                        if let Some(rect) = bbox_for_path(path) {
271                            items.push(HitItem {
272                                id: next_id,
273                                z: *z,
274                                kind: HitKind::Path,
275                                transform: *transform,
276                                data: HitData::PathBBox(rect),
277                                clips: clips.clone(),
278                                region_id: None,
279                            });
280                            next_id += 1;
281                        }
282                    }
283                }
284                Command::StrokePath {
285                    path, z, transform, ..
286                } => {
287                    if let Some(mut rect) = bbox_for_path(path) {
288                        // Expand bbox by half of stroke width conservatively
289                        if let Command::StrokePath { stroke, .. } = cmd {
290                            let w = stroke.width.max(0.0) * 0.5;
291                            rect.x -= w;
292                            rect.y -= w;
293                            rect.w += w * 2.0;
294                            rect.h += w * 2.0;
295                        }
296                        items.push(HitItem {
297                            id: next_id,
298                            z: *z,
299                            kind: HitKind::Path,
300                            transform: *transform,
301                            data: HitData::PathBBox(rect),
302                            clips: clips.clone(),
303                            region_id: None,
304                        });
305                        next_id += 1;
306                    }
307                }
308                Command::BoxShadow {
309                    rrect,
310                    z,
311                    transform,
312                    ..
313                } => {
314                    items.push(HitItem {
315                        id: next_id,
316                        z: *z,
317                        kind: HitKind::BoxShadow,
318                        transform: *transform,
319                        data: HitData::BoxShadow { rrect: *rrect },
320                        clips: clips.clone(),
321                        region_id: None,
322                    });
323                    next_id += 1;
324                }
325                Command::HitRegionRect {
326                    id,
327                    rect,
328                    z,
329                    transform,
330                } => {
331                    items.push(HitItem {
332                        id: next_id,
333                        z: *z,
334                        kind: HitKind::HitRegion,
335                        transform: *transform,
336                        data: HitData::Rect(*rect),
337                        clips: clips.clone(),
338                        region_id: Some(*id),
339                    });
340                    next_id += 1;
341                }
342                Command::HitRegionRoundedRect {
343                    id,
344                    rrect,
345                    z,
346                    transform,
347                } => {
348                    items.push(HitItem {
349                        id: next_id,
350                        z: *z,
351                        kind: HitKind::HitRegion,
352                        transform: *transform,
353                        data: HitData::RoundedRect(*rrect),
354                        clips: clips.clone(),
355                        region_id: Some(*id),
356                    });
357                    next_id += 1;
358                }
359                Command::HitRegionEllipse {
360                    id,
361                    center,
362                    radii,
363                    z,
364                    transform,
365                } => {
366                    items.push(HitItem {
367                        id: next_id,
368                        z: *z,
369                        kind: HitKind::HitRegion,
370                        transform: *transform,
371                        data: HitData::Ellipse {
372                            center: *center,
373                            radii: *radii,
374                        },
375                        clips: clips.clone(),
376                        region_id: Some(*id),
377                    });
378                    next_id += 1;
379                }
380                // Image, SVG, and external texture draws currently do not participate in hit testing.
381                Command::DrawImage { .. } => {}
382                Command::DrawSvg { .. } => {}
383                Command::DrawExternalTexture { .. } => {}
384                Command::PushOpacity(_) => {}
385                Command::PopOpacity => {}
386            }
387        }
388
389        // Always include a root viewport hit region to capture scene-surface hits
390        // Use minimal z so it doesn't occlude content.
391        let root_rect = Rect {
392            x: 0.0,
393            y: 0.0,
394            w: list.viewport.width as f32,
395            h: list.viewport.height as f32,
396        };
397        items.push(HitItem {
398            id: next_id,
399            z: i32::MIN,
400            kind: HitKind::HitRegion,
401            transform: Transform2D::identity(),
402            data: HitData::Rect(root_rect),
403            clips: Vec::new(),
404            region_id: Some(u32::MAX),
405        });
406
407        Self { items }
408    }
409
410    /// Return the topmost element at the given device-space position.
411    pub fn topmost_at(&self, pos: [f32; 2]) -> Option<HitResult> {
412        let mut best: Option<HitItem> = None;
413        for it in &self.items {
414            if !passes_clip(it, pos) {
415                continue;
416            }
417            if hit_item_contains(it, pos) {
418                best = match best {
419                    None => Some(it.clone()),
420                    Some(ref cur) => {
421                        if it.z > cur.z || (it.z == cur.z && it.id > cur.id) {
422                            Some(it.clone())
423                        } else {
424                            Some(cur.clone())
425                        }
426                    }
427                };
428            }
429        }
430        best.map(|it| {
431            let (local_pos, local_uv) = compute_locals(&it, pos);
432            HitResult {
433                id: it.id,
434                z: it.z,
435                kind: it.kind,
436                shape: match &it.data {
437                    HitData::Rect(r) => HitShape::Rect(*r),
438                    HitData::RoundedRect(rr) => HitShape::RoundedRect(*rr),
439                    HitData::Ellipse { center, radii } => HitShape::Ellipse {
440                        center: *center,
441                        radii: *radii,
442                    },
443                    HitData::StrokeRect { rect, width } => HitShape::StrokeRect {
444                        rect: *rect,
445                        width: *width,
446                    },
447                    HitData::StrokeRoundedRect { rrect, width } => HitShape::StrokeRoundedRect {
448                        rrect: *rrect,
449                        width: *width,
450                    },
451                    HitData::PathBBox(r) => HitShape::PathBBox { rect: *r },
452                    HitData::Text(_) => HitShape::Text,
453                    HitData::BoxShadow { rrect } => HitShape::BoxShadow { rrect: *rrect },
454                    HitData::Hyperlink { rect, url } => HitShape::Hyperlink {
455                        rect: *rect,
456                        url: url.clone(),
457                    },
458                },
459                transform: it.transform,
460                region_id: it.region_id,
461                local_pos,
462                local_uv,
463            }
464        })
465    }
466}
467
468fn bbox_for_path(path: &Path) -> Option<Rect> {
469    let mut minx = f32::INFINITY;
470    let mut miny = f32::INFINITY;
471    let mut maxx = f32::NEG_INFINITY;
472    let mut maxy = f32::NEG_INFINITY;
473    let mut any = false;
474    for cmd in &path.cmds {
475        match *cmd {
476            PathCmd::MoveTo(p) | PathCmd::LineTo(p) => {
477                minx = minx.min(p[0]);
478                miny = miny.min(p[1]);
479                maxx = maxx.max(p[0]);
480                maxy = maxy.max(p[1]);
481                any = true;
482            }
483            PathCmd::QuadTo(c, p) => {
484                for q in [c, p] {
485                    minx = minx.min(q[0]);
486                    miny = miny.min(q[1]);
487                    maxx = maxx.max(q[0]);
488                    maxy = maxy.max(q[1]);
489                }
490                any = true;
491            }
492            PathCmd::CubicTo(c1, c2, p) => {
493                for q in [c1, c2, p] {
494                    minx = minx.min(q[0]);
495                    miny = miny.min(q[1]);
496                    maxx = maxx.max(q[0]);
497                    maxy = maxy.max(q[1]);
498                }
499                any = true;
500            }
501            PathCmd::Close => {}
502        }
503    }
504    if any {
505        Some(Rect {
506            x: minx,
507            y: miny,
508            w: (maxx - minx).max(0.0),
509            h: (maxy - miny).max(0.0),
510        })
511    } else {
512        None
513    }
514}
515
516fn passes_clip(item: &HitItem, world: [f32; 2]) -> bool {
517    for c in &item.clips {
518        if !point_in_rect_local(world, &c.transform, c.rect) {
519            return false;
520        }
521    }
522    true
523}
524
525fn hit_item_contains(item: &HitItem, world: [f32; 2]) -> bool {
526    match &item.data {
527        HitData::Rect(r) => point_in_rect_local(world, &item.transform, *r),
528        HitData::RoundedRect(r) => point_in_rounded_rect_local(world, &item.transform, *r),
529        HitData::Ellipse { center, radii } => {
530            point_in_ellipse_local(world, &item.transform, *center, *radii)
531        }
532        HitData::StrokeRect { rect, width } => {
533            point_in_stroke_rect_local(world, &item.transform, *rect, *width)
534        }
535        HitData::StrokeRoundedRect { rrect, width } => {
536            point_in_stroke_rounded_rect_local(world, &item.transform, *rrect, *width)
537        }
538        HitData::PathBBox(rect) => point_in_rect_local(world, &item.transform, *rect),
539        HitData::Text(_run) => false, // Text hit testing not yet implemented
540        HitData::BoxShadow { rrect } => point_in_rounded_rect_local(world, &item.transform, *rrect),
541        HitData::Hyperlink { rect, .. } => point_in_rect_local(world, &item.transform, *rect),
542    }
543}
544
545fn point_in_rect_local(world: [f32; 2], transform: &Transform2D, rect: Rect) -> bool {
546    if let Some(p) = transform.inverse_apply(world) {
547        p[0] >= rect.x && p[1] >= rect.y && p[0] <= rect.x + rect.w && p[1] <= rect.y + rect.h
548    } else {
549        false
550    }
551}
552
553fn point_in_rounded_rect_local(
554    world: [f32; 2],
555    transform: &Transform2D,
556    rrect: RoundedRect,
557) -> bool {
558    if let Some(p) = transform.inverse_apply(world) {
559        let Rect { x, y, w, h } = rrect.rect;
560        let tl = rrect.radii.tl.min(w * 0.5).min(h * 0.5);
561        let tr = rrect.radii.tr.min(w * 0.5).min(h * 0.5);
562        let br = rrect.radii.br.min(w * 0.5).min(h * 0.5);
563        let bl = rrect.radii.bl.min(w * 0.5).min(h * 0.5);
564        let px = p[0] - x;
565        let py = p[1] - y;
566        if px < 0.0 || py < 0.0 || px > w || py > h {
567            return false;
568        }
569        // Top-left
570        if px < tl && py < tl {
571            let dx = tl - px;
572            let dy = tl - py;
573            return dx * dx + dy * dy <= tl * tl + 1e-5;
574        }
575        // Top-right
576        if px > w - tr && py < tr {
577            let dx = px - (w - tr);
578            let dy = tr - py;
579            return dx * dx + dy * dy <= tr * tr + 1e-5;
580        }
581        // Bottom-right
582        if px > w - br && py > h - br {
583            let dx = px - (w - br);
584            let dy = py - (h - br);
585            return dx * dx + dy * dy <= br * br + 1e-5;
586        }
587        // Bottom-left
588        if px < bl && py > h - bl {
589            let dx = bl - px;
590            let dy = py - (h - bl);
591            return dx * dx + dy * dy <= bl * bl + 1e-5;
592        }
593        true
594    } else {
595        false
596    }
597}
598
599fn point_in_ellipse_local(
600    world: [f32; 2],
601    transform: &Transform2D,
602    center: [f32; 2],
603    radii: [f32; 2],
604) -> bool {
605    if let Some(p) = transform.inverse_apply(world) {
606        let dx = (p[0] - center[0]) / radii[0].max(1e-6);
607        let dy = (p[1] - center[1]) / radii[1].max(1e-6);
608        dx * dx + dy * dy <= 1.0 + 1e-5
609    } else {
610        false
611    }
612}
613
614fn point_in_stroke_rect_local(
615    world: [f32; 2],
616    transform: &Transform2D,
617    rect: Rect,
618    width: f32,
619) -> bool {
620    if let Some(p) = transform.inverse_apply(world) {
621        let outer = Rect {
622            x: rect.x - width * 0.5,
623            y: rect.y - width * 0.5,
624            w: rect.w + width,
625            h: rect.h + width,
626        };
627        let inner = Rect {
628            x: rect.x + width * 0.5,
629            y: rect.y + width * 0.5,
630            w: (rect.w - width).max(0.0),
631            h: (rect.h - width).max(0.0),
632        };
633        let in_outer = p[0] >= outer.x
634            && p[1] >= outer.y
635            && p[0] <= outer.x + outer.w
636            && p[1] <= outer.y + outer.h;
637        let in_inner = p[0] >= inner.x
638            && p[1] >= inner.y
639            && p[0] <= inner.x + inner.w
640            && p[1] <= inner.y + inner.h;
641        in_outer && !in_inner
642    } else {
643        false
644    }
645}
646
647fn point_in_stroke_rounded_rect_local(
648    world: [f32; 2],
649    transform: &Transform2D,
650    rrect: RoundedRect,
651    width: f32,
652) -> bool {
653    // Approximate by testing ring between rrect and inset rrect.
654    if let Some(p) = transform.inverse_apply(world) {
655        // Outer check
656        let outer_hit = point_in_rounded_rect_untransformed(p, rrect);
657        if !outer_hit {
658            return false;
659        }
660        // Inner check (inset by stroke width)
661        let inset = width.max(0.0) * 0.5;
662        let inner = RoundedRect {
663            rect: Rect {
664                x: rrect.rect.x + inset,
665                y: rrect.rect.y + inset,
666                w: (rrect.rect.w - width).max(0.0),
667                h: (rrect.rect.h - width).max(0.0),
668            },
669            radii: RoundedRadii {
670                tl: (rrect.radii.tl - inset).max(0.0),
671                tr: (rrect.radii.tr - inset).max(0.0),
672                br: (rrect.radii.br - inset).max(0.0),
673                bl: (rrect.radii.bl - inset).max(0.0),
674            },
675        };
676        let inner_hit = point_in_rounded_rect_untransformed(p, inner);
677        return outer_hit && !inner_hit;
678    }
679    false
680}
681
682fn point_in_rounded_rect_untransformed(p: [f32; 2], rrect: RoundedRect) -> bool {
683    let Rect { x, y, w, h } = rrect.rect;
684    let tl = rrect.radii.tl.min(w * 0.5).min(h * 0.5);
685    let tr = rrect.radii.tr.min(w * 0.5).min(h * 0.5);
686    let br = rrect.radii.br.min(w * 0.5).min(h * 0.5);
687    let bl = rrect.radii.bl.min(w * 0.5).min(h * 0.5);
688    let px = p[0] - x;
689    let py = p[1] - y;
690    if px < 0.0 || py < 0.0 || px > w || py > h {
691        return false;
692    }
693    if px < tl && py < tl {
694        let dx = tl - px;
695        let dy = tl - py;
696        return dx * dx + dy * dy <= tl * tl + 1e-5;
697    }
698    if px > w - tr && py < tr {
699        let dx = px - (w - tr);
700        let dy = tr - py;
701        return dx * dx + dy * dy <= tr * tr + 1e-5;
702    }
703    if px > w - br && py > h - br {
704        let dx = px - (w - br);
705        let dy = py - (h - br);
706        return dx * dx + dy * dy <= br * br + 1e-5;
707    }
708    if px < bl && py > h - bl {
709        let dx = bl - px;
710        let dy = py - (h - bl);
711        return dx * dx + dy * dy <= bl * bl + 1e-5;
712    }
713    true
714}
715
716// --- Transform helpers ---
717impl Transform2D {
718    /// Apply the transform to a point (x, y).
719    pub fn apply(&self, p: [f32; 2]) -> [f32; 2] {
720        let a = self.m[0];
721        let b = self.m[1];
722        let c = self.m[2];
723        let d = self.m[3];
724        let e = self.m[4];
725        let f = self.m[5];
726        [a * p[0] + c * p[1] + e, b * p[0] + d * p[1] + f]
727    }
728
729    /// Apply the inverse transform to a world-space point. Returns None if non-invertible.
730    pub fn inverse_apply(&self, p: [f32; 2]) -> Option<[f32; 2]> {
731        let a = self.m[0];
732        let b = self.m[1];
733        let c = self.m[2];
734        let d = self.m[3];
735        let e = self.m[4];
736        let f = self.m[5];
737        let det = a * d - b * c;
738        if det.abs() < 1e-12 {
739            return None;
740        }
741        let inv_det = 1.0 / det;
742        let ia = d * inv_det;
743        let ib = -b * inv_det;
744        let ic = -c * inv_det;
745        let id = a * inv_det;
746        // Inverse translation = -inv_linear * [e, f]
747        let ie = -(ia * e + ic * f);
748        let iff = -(ib * e + id * f);
749        Some([ia * p[0] + ic * p[1] + ie, ib * p[0] + id * p[1] + iff])
750    }
751}
752
753/// Convenience: perform a one-shot hit test on the display list without keeping an index.
754pub fn hit_test(list: &DisplayList, pos: [f32; 2]) -> Option<HitResult> {
755    HitIndex::build(list).topmost_at(pos)
756}
757
758fn compute_locals(item: &HitItem, world: [f32; 2]) -> (Option<[f32; 2]>, Option<[f32; 2]>) {
759    let p = match item.transform.inverse_apply(world) {
760        Some(p) => p,
761        None => return (None, None),
762    };
763    match &item.data {
764        HitData::Rect(r) => {
765            let local = [p[0] - r.x, p[1] - r.y];
766            let uv = [
767                if r.w.abs() > 1e-6 {
768                    (local[0] / r.w).clamp(0.0, 1.0)
769                } else {
770                    0.0
771                },
772                if r.h.abs() > 1e-6 {
773                    (local[1] / r.h).clamp(0.0, 1.0)
774                } else {
775                    0.0
776                },
777            ];
778            (Some(local), Some(uv))
779        }
780        HitData::RoundedRect(rr) => {
781            let r = rr.rect;
782            let local = [p[0] - r.x, p[1] - r.y];
783            let uv = [
784                if r.w.abs() > 1e-6 {
785                    (local[0] / r.w).clamp(0.0, 1.0)
786                } else {
787                    0.0
788                },
789                if r.h.abs() > 1e-6 {
790                    (local[1] / r.h).clamp(0.0, 1.0)
791                } else {
792                    0.0
793                },
794            ];
795            (Some(local), Some(uv))
796        }
797        HitData::Ellipse { center, radii } => {
798            let local = [p[0] - center[0], p[1] - center[1]];
799            let uv = [
800                0.5 + if radii[0].abs() > 1e-6 {
801                    local[0] / (2.0 * radii[0])
802                } else {
803                    0.0
804                },
805                0.5 + if radii[1].abs() > 1e-6 {
806                    local[1] / (2.0 * radii[1])
807                } else {
808                    0.0
809                },
810            ];
811            (Some(local), Some(uv))
812        }
813        HitData::StrokeRect { rect, .. } => {
814            let local = [p[0] - rect.x, p[1] - rect.y];
815            (Some(local), None)
816        }
817        HitData::StrokeRoundedRect { rrect, .. } => {
818            let r = rrect.rect;
819            let local = [p[0] - r.x, p[1] - r.y];
820            (Some(local), None)
821        }
822        HitData::Text(_) => (None, None),
823        HitData::PathBBox(r) => {
824            let local = [p[0] - r.x, p[1] - r.y];
825            let uv = [
826                if r.w.abs() > 1e-6 {
827                    (local[0] / r.w).clamp(0.0, 1.0)
828                } else {
829                    0.0
830                },
831                if r.h.abs() > 1e-6 {
832                    (local[1] / r.h).clamp(0.0, 1.0)
833                } else {
834                    0.0
835                },
836            ];
837            (Some(local), Some(uv))
838        }
839        HitData::BoxShadow { rrect } => {
840            let r = rrect.rect;
841            let local = [p[0] - r.x, p[1] - r.y];
842            let uv = [
843                if r.w.abs() > 1e-6 {
844                    (local[0] / r.w).clamp(0.0, 1.0)
845                } else {
846                    0.0
847                },
848                if r.h.abs() > 1e-6 {
849                    (local[1] / r.h).clamp(0.0, 1.0)
850                } else {
851                    0.0
852                },
853            ];
854            (Some(local), Some(uv))
855        }
856        HitData::Hyperlink { rect, .. } => {
857            let local = [p[0] - rect.x, p[1] - rect.y];
858            let uv = [
859                if rect.w.abs() > 1e-6 {
860                    (local[0] / rect.w).clamp(0.0, 1.0)
861                } else {
862                    0.0
863                },
864                if rect.h.abs() > 1e-6 {
865                    (local[1] / rect.h).clamp(0.0, 1.0)
866                } else {
867                    0.0
868                },
869            ];
870            (Some(local), Some(uv))
871        }
872    }
873}
874
875#[cfg(test)]
876mod tests {
877    use super::*;
878    use crate::{Command, DisplayList, Viewport};
879
880    #[test]
881    fn inline_hyperlink_hit_wins_over_overlapping_text_region() {
882        let mut list = DisplayList {
883            viewport: Viewport {
884                width: 400,
885                height: 120,
886            },
887            commands: Vec::new(),
888        };
889
890        let hyperlink = Hyperlink {
891            text: "default blue underlined link".to_string(),
892            pos: [20.0, 36.0], // baseline
893            size: 16.0,
894            color: ColorLinPremul::from_srgba_u8([0x00, 0x66, 0xcc, 0xff]),
895            url: "https://example.com".to_string(),
896            weight: 400.0,
897            measured_width: Some(180.0),
898            underline: true,
899            underline_color: None,
900            family: None,
901            style: FontStyle::Normal,
902        };
903
904        // Inline hyperlink draw.
905        list.commands.push(Command::DrawHyperlink {
906            hyperlink,
907            z: 0,
908            transform: Transform2D::identity(),
909            id: 1,
910        });
911
912        // Simulate overlapping paragraph text hit region at z + 10.
913        list.commands.push(Command::HitRegionRect {
914            id: 4242,
915            rect: Rect {
916                x: 10.0,
917                y: 10.0,
918                w: 300.0,
919                h: 40.0,
920            },
921            z: 10,
922            transform: Transform2D::identity(),
923        });
924
925        let hit = HitIndex::build(&list)
926            .topmost_at([80.0, 28.0])
927            .expect("expected a hit over inline hyperlink");
928        assert_eq!(hit.kind, HitKind::Hyperlink);
929        match hit.shape {
930            HitShape::Hyperlink { url, .. } => assert_eq!(url, "https://example.com"),
931            other => panic!("expected hyperlink hit shape, got {other:?}"),
932        }
933    }
934}