1use crate::display_list::{Command, DisplayList};
2use crate::scene::*;
3
4#[derive(Clone, Debug)]
6pub struct HitResult {
7 pub id: usize,
9 pub z: i32,
11 pub kind: HitKind,
13 pub shape: HitShape,
15 pub transform: Transform2D,
17 pub region_id: Option<u32>,
19 pub local_pos: Option<[f32; 2]>,
21 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#[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#[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#[derive(Default, Clone)]
87pub struct HitIndex {
88 items: Vec<HitItem>,
89}
90
91impl HitIndex {
92 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 let hit_z = z.saturating_add(11);
234 let text_width = hyperlink.measured_width.unwrap_or_else(|| {
235 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; let rect = Rect {
244 x: hyperlink.pos[0],
245 y: hyperlink.pos[1] - hyperlink.size, 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 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 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 Command::DrawImage { .. } => {}
382 Command::DrawSvg { .. } => {}
383 Command::DrawExternalTexture { .. } => {}
384 Command::PushOpacity(_) => {}
385 Command::PopOpacity => {}
386 }
387 }
388
389 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 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, 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 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 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 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 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 if let Some(p) = transform.inverse_apply(world) {
655 let outer_hit = point_in_rounded_rect_untransformed(p, rrect);
657 if !outer_hit {
658 return false;
659 }
660 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
716impl Transform2D {
718 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 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 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
753pub 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], 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 list.commands.push(Command::DrawHyperlink {
906 hyperlink,
907 z: 0,
908 transform: Transform2D::identity(),
909 id: 1,
910 });
911
912 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}