1#![forbid(unsafe_code)]
2
3use crate::frame::{HitData, HitId, HitRegion};
30use ahash::AHashMap;
31use ftui_core::geometry::Rect;
32
33#[derive(Debug, Clone)]
39pub struct SpatialHitConfig {
40 pub cell_size: u16,
43
44 pub bucket_warn_threshold: usize,
46
47 pub track_cache_stats: bool,
49}
50
51impl Default for SpatialHitConfig {
52 fn default() -> Self {
53 Self {
54 cell_size: 8,
55 bucket_warn_threshold: 64,
56 track_cache_stats: false,
57 }
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct HitEntry {
68 pub id: HitId,
70 pub rect: Rect,
72 pub region: HitRegion,
74 pub data: HitData,
76 pub z_order: u16,
78 order: u32,
80}
81
82impl HitEntry {
83 pub fn new(
85 id: HitId,
86 rect: Rect,
87 region: HitRegion,
88 data: HitData,
89 z_order: u16,
90 order: u32,
91 ) -> Self {
92 Self {
93 id,
94 rect,
95 region,
96 data,
97 z_order,
98 order,
99 }
100 }
101
102 #[inline]
104 pub fn contains(&self, x: u16, y: u16) -> bool {
105 x >= self.rect.x
106 && x < self.rect.x.saturating_add(self.rect.width)
107 && y >= self.rect.y
108 && y < self.rect.y.saturating_add(self.rect.height)
109 }
110
111 #[inline]
113 fn cmp_z_order(&self, other: &Self) -> std::cmp::Ordering {
114 match self.z_order.cmp(&other.z_order) {
115 std::cmp::Ordering::Equal => self.order.cmp(&other.order),
116 ord => ord,
117 }
118 }
119}
120
121#[derive(Debug, Clone, Default)]
127struct Bucket {
128 entries: Vec<u32>,
130}
131
132impl Bucket {
133 #[inline]
135 fn push(&mut self, entry_idx: u32) {
136 self.entries.push(entry_idx);
137 }
138
139 #[inline]
141 fn clear(&mut self) {
142 self.entries.clear();
143 }
144}
145
146#[derive(Debug, Clone, Copy, Default)]
152struct HoverCache {
153 pos: (u16, u16),
155 result: Option<u32>,
157 valid: bool,
159}
160
161#[derive(Debug, Clone, Default)]
167struct DirtyTracker {
168 dirty_rects: Vec<Rect>,
170 full_rebuild: bool,
172}
173
174impl DirtyTracker {
175 fn mark_dirty(&mut self, rect: Rect) {
177 if !self.full_rebuild {
178 self.dirty_rects.push(rect);
179 }
180 }
181
182 fn mark_full_rebuild(&mut self) {
184 self.full_rebuild = true;
185 self.dirty_rects.clear();
186 }
187
188 fn clear(&mut self) {
190 self.dirty_rects.clear();
191 self.full_rebuild = false;
192 }
193
194 fn is_dirty(&self, x: u16, y: u16) -> bool {
196 if self.full_rebuild {
197 return true;
198 }
199 for rect in &self.dirty_rects {
200 if x >= rect.x
201 && x < rect.x.saturating_add(rect.width)
202 && y >= rect.y
203 && y < rect.y.saturating_add(rect.height)
204 {
205 return true;
206 }
207 }
208 false
209 }
210}
211
212#[derive(Debug, Clone, Copy, Default)]
218pub struct CacheStats {
219 pub hits: u64,
221 pub misses: u64,
223 pub rebuilds: u64,
225}
226
227impl CacheStats {
228 #[must_use]
230 pub fn hit_rate(&self) -> f32 {
231 let total = self.hits + self.misses;
232 if total == 0 {
233 0.0
234 } else {
235 (self.hits as f32 / total as f32) * 100.0
236 }
237 }
238}
239
240#[derive(Debug)]
249pub struct SpatialHitIndex {
250 config: SpatialHitConfig,
251
252 width: u16,
254 height: u16,
255
256 grid_width: u16,
258 grid_height: u16,
259
260 entries: Vec<HitEntry>,
262
263 buckets: Vec<Bucket>,
265
266 next_order: u32,
268
269 cache: HoverCache,
271
272 dirty: DirtyTracker,
274
275 stats: CacheStats,
277
278 id_to_entry: AHashMap<HitId, u32>,
280}
281
282impl SpatialHitIndex {
283 pub fn new(width: u16, height: u16, config: SpatialHitConfig) -> Self {
285 let cell_size = config.cell_size.max(1);
286 let grid_width = (width.saturating_add(cell_size - 1)) / cell_size;
287 let grid_height = (height.saturating_add(cell_size - 1)) / cell_size;
288 let bucket_count = grid_width as usize * grid_height as usize;
289
290 Self {
291 config,
292 width,
293 height,
294 grid_width,
295 grid_height,
296 entries: Vec::with_capacity(256),
297 buckets: vec![Bucket::default(); bucket_count],
298 next_order: 0,
299 cache: HoverCache::default(),
300 dirty: DirtyTracker::default(),
301 stats: CacheStats::default(),
302 id_to_entry: AHashMap::with_capacity(256),
303 }
304 }
305
306 pub fn with_defaults(width: u16, height: u16) -> Self {
308 Self::new(width, height, SpatialHitConfig::default())
309 }
310
311 pub fn register(
321 &mut self,
322 id: HitId,
323 rect: Rect,
324 region: HitRegion,
325 data: HitData,
326 z_order: u16,
327 ) {
328 let entry_idx = self.entries.len() as u32;
330 let entry = HitEntry::new(id, rect, region, data, z_order, self.next_order);
331 self.next_order = self.next_order.wrapping_add(1);
332
333 self.entries.push(entry);
334 self.id_to_entry.insert(id, entry_idx);
335
336 self.add_to_buckets(entry_idx, rect);
338
339 self.dirty.mark_dirty(rect);
341 if self.cache.valid && self.dirty.is_dirty(self.cache.pos.0, self.cache.pos.1) {
342 self.cache.valid = false;
343 }
344 }
345
346 pub fn register_simple(&mut self, id: HitId, rect: Rect, region: HitRegion, data: HitData) {
348 self.register(id, rect, region, data, 0);
349 }
350
351 pub fn update(&mut self, id: HitId, new_rect: Rect) -> bool {
355 let Some(&entry_idx) = self.id_to_entry.get(&id) else {
356 return false;
357 };
358
359 let old_rect = self.entries[entry_idx as usize].rect;
360
361 self.dirty.mark_dirty(old_rect);
363 self.dirty.mark_dirty(new_rect);
364
365 self.entries[entry_idx as usize].rect = new_rect;
367
368 self.rebuild_buckets();
371
372 self.cache.valid = false;
374
375 true
376 }
377
378 pub fn remove(&mut self, id: HitId) -> bool {
382 let Some(&entry_idx) = self.id_to_entry.get(&id) else {
383 return false;
384 };
385
386 let rect = self.entries[entry_idx as usize].rect;
387 self.dirty.mark_dirty(rect);
388
389 self.entries[entry_idx as usize].id = HitId::default();
391 self.id_to_entry.remove(&id);
392
393 self.rebuild_buckets();
395 self.cache.valid = false;
396
397 true
398 }
399
400 #[must_use]
409 pub fn hit_test(&mut self, x: u16, y: u16) -> Option<(HitId, HitRegion, HitData)> {
410 if x >= self.width || y >= self.height {
412 return None;
413 }
414
415 if self.cache.valid && self.cache.pos == (x, y) {
417 if self.config.track_cache_stats {
418 self.stats.hits += 1;
419 }
420 return self.cache.result.map(|idx| {
421 let e = &self.entries[idx as usize];
422 (e.id, e.region, e.data)
423 });
424 }
425
426 if self.config.track_cache_stats {
427 self.stats.misses += 1;
428 }
429
430 let bucket_idx = self.bucket_index(x, y);
432 let bucket = &self.buckets[bucket_idx];
433
434 let mut best: Option<&HitEntry> = None;
436 let mut best_idx: Option<u32> = None;
437
438 for &entry_idx in &bucket.entries {
439 let entry = &self.entries[entry_idx as usize];
440
441 if entry.id == HitId::default() {
443 continue;
444 }
445
446 if entry.contains(x, y) {
448 match best {
450 None => {
451 best = Some(entry);
452 best_idx = Some(entry_idx);
453 }
454 Some(current_best) if entry.cmp_z_order(current_best).is_gt() => {
455 best = Some(entry);
456 best_idx = Some(entry_idx);
457 }
458 _ => {}
459 }
460 }
461 }
462
463 self.cache.pos = (x, y);
465 self.cache.result = best_idx;
466 self.cache.valid = true;
467 self.dirty.clear();
469
470 best.map(|e| (e.id, e.region, e.data))
471 }
472
473 #[must_use]
475 pub fn hit_test_readonly(&self, x: u16, y: u16) -> Option<(HitId, HitRegion, HitData)> {
476 if x >= self.width || y >= self.height {
477 return None;
478 }
479
480 let bucket_idx = self.bucket_index(x, y);
481 let bucket = &self.buckets[bucket_idx];
482
483 let mut best: Option<&HitEntry> = None;
484
485 for &entry_idx in &bucket.entries {
486 let entry = &self.entries[entry_idx as usize];
487 if entry.id == HitId::default() {
488 continue;
489 }
490 if entry.contains(x, y) {
491 match best {
492 None => best = Some(entry),
493 Some(current_best) if entry.cmp_z_order(current_best).is_gt() => {
494 best = Some(entry)
495 }
496 _ => {}
497 }
498 }
499 }
500
501 best.map(|e| (e.id, e.region, e.data))
502 }
503
504 pub fn clear(&mut self) {
506 self.entries.clear();
507 self.id_to_entry.clear();
508 for bucket in &mut self.buckets {
509 bucket.clear();
510 }
511 self.next_order = 0;
512 self.cache.valid = false;
513 self.dirty.clear();
514 }
515
516 #[must_use]
518 pub fn stats(&self) -> CacheStats {
519 self.stats
520 }
521
522 pub fn reset_stats(&mut self) {
524 self.stats = CacheStats::default();
525 }
526
527 #[inline]
529 #[must_use]
530 pub fn len(&self) -> usize {
531 self.id_to_entry.len()
532 }
533
534 #[inline]
536 #[must_use]
537 pub fn is_empty(&self) -> bool {
538 self.id_to_entry.is_empty()
539 }
540
541 pub fn invalidate_region(&mut self, rect: Rect) {
543 self.dirty.mark_dirty(rect);
544 if self.cache.valid && self.dirty.is_dirty(self.cache.pos.0, self.cache.pos.1) {
545 self.cache.valid = false;
546 }
547 }
548
549 pub fn invalidate_all(&mut self) {
551 self.cache.valid = false;
552 self.dirty.mark_full_rebuild();
553 }
554
555 #[inline]
561 fn bucket_index(&self, x: u16, y: u16) -> usize {
562 let cell_size = self.config.cell_size;
563 let bx = x / cell_size;
564 let by = y / cell_size;
565 by as usize * self.grid_width as usize + bx as usize
566 }
567
568 fn bucket_range(&self, rect: Rect) -> (u16, u16, u16, u16) {
570 let cell_size = self.config.cell_size;
571 let bx_start = rect.x / cell_size;
572 let by_start = rect.y / cell_size;
573 let bx_end = rect.x.saturating_add(rect.width.saturating_sub(1)) / cell_size;
574 let by_end = rect.y.saturating_add(rect.height.saturating_sub(1)) / cell_size;
575 (
576 bx_start.min(self.grid_width.saturating_sub(1)),
577 by_start.min(self.grid_height.saturating_sub(1)),
578 bx_end.min(self.grid_width.saturating_sub(1)),
579 by_end.min(self.grid_height.saturating_sub(1)),
580 )
581 }
582
583 fn add_to_buckets(&mut self, entry_idx: u32, rect: Rect) {
585 if rect.width == 0 || rect.height == 0 {
586 return;
587 }
588
589 let (bx_start, by_start, bx_end, by_end) = self.bucket_range(rect);
590
591 for by in by_start..=by_end {
592 for bx in bx_start..=bx_end {
593 let bucket_idx = by as usize * self.grid_width as usize + bx as usize;
594 if bucket_idx < self.buckets.len() {
595 self.buckets[bucket_idx].push(entry_idx);
596
597 if self.buckets[bucket_idx].entries.len() > self.config.bucket_warn_threshold {
599 }
601 }
602 }
603 }
604 }
605
606 fn rebuild_buckets(&mut self) {
608 for bucket in &mut self.buckets {
610 bucket.clear();
611 }
612
613 let mut valid_idx = 0;
615 for i in 0..self.entries.len() {
616 if self.entries[i].id != HitId::default() {
617 if i != valid_idx {
618 self.entries[valid_idx] = self.entries[i];
619 }
620 valid_idx += 1;
621 }
622 }
623 self.entries.truncate(valid_idx);
624
625 self.id_to_entry.clear();
627 for (idx, entry) in self.entries.iter().enumerate() {
628 self.id_to_entry.insert(entry.id, idx as u32);
629 }
630
631 for idx in 0..self.entries.len() {
634 let rect = self.entries[idx].rect;
635 self.add_to_buckets_internal(idx as u32, rect);
636 }
637
638 self.dirty.clear();
639 self.stats.rebuilds += 1;
640 }
641
642 fn add_to_buckets_internal(&mut self, entry_idx: u32, rect: Rect) {
644 if rect.width == 0 || rect.height == 0 {
645 return;
646 }
647
648 let (bx_start, by_start, bx_end, by_end) = self.bucket_range(rect);
649
650 for by in by_start..=by_end {
651 for bx in bx_start..=bx_end {
652 let bucket_idx = by as usize * self.grid_width as usize + bx as usize;
653 if bucket_idx < self.buckets.len() {
654 self.buckets[bucket_idx].push(entry_idx);
655 }
656 }
657 }
658 }
659}
660
661#[cfg(test)]
666mod tests {
667 use super::*;
668
669 fn index() -> SpatialHitIndex {
670 SpatialHitIndex::with_defaults(80, 24)
671 }
672
673 #[test]
676 fn initial_state_empty() {
677 let idx = index();
678 assert!(idx.is_empty());
679 assert_eq!(idx.len(), 0);
680 }
681
682 #[test]
683 fn register_and_hit_test() {
684 let mut idx = index();
685 idx.register_simple(
686 HitId::new(1),
687 Rect::new(10, 5, 20, 3),
688 HitRegion::Button,
689 42,
690 );
691
692 let result = idx.hit_test(15, 6);
694 assert_eq!(result, Some((HitId::new(1), HitRegion::Button, 42)));
695
696 assert!(idx.hit_test(5, 5).is_none());
698 assert!(idx.hit_test(35, 5).is_none());
699 }
700
701 #[test]
702 fn z_order_topmost_wins() {
703 let mut idx = index();
704
705 idx.register(
707 HitId::new(1),
708 Rect::new(0, 0, 10, 10),
709 HitRegion::Content,
710 1,
711 0, );
713 idx.register(
714 HitId::new(2),
715 Rect::new(5, 5, 10, 10),
716 HitRegion::Border,
717 2,
718 1, );
720
721 let result = idx.hit_test(7, 7);
723 assert_eq!(result, Some((HitId::new(2), HitRegion::Border, 2)));
724
725 let result = idx.hit_test(2, 2);
727 assert_eq!(result, Some((HitId::new(1), HitRegion::Content, 1)));
728 }
729
730 #[test]
731 fn same_z_order_later_wins() {
732 let mut idx = index();
733
734 idx.register(
736 HitId::new(1),
737 Rect::new(0, 0, 10, 10),
738 HitRegion::Content,
739 1,
740 0,
741 );
742 idx.register(
743 HitId::new(2),
744 Rect::new(5, 5, 10, 10),
745 HitRegion::Border,
746 2,
747 0,
748 );
749
750 let result = idx.hit_test(7, 7);
752 assert_eq!(result, Some((HitId::new(2), HitRegion::Border, 2)));
753 }
754
755 #[test]
756 fn hit_test_border_inclusive() {
757 let mut idx = index();
758 idx.register_simple(
759 HitId::new(1),
760 Rect::new(10, 10, 5, 5),
761 HitRegion::Content,
762 0,
763 );
764
765 assert!(idx.hit_test(10, 10).is_some()); assert!(idx.hit_test(14, 10).is_some()); assert!(idx.hit_test(10, 14).is_some()); assert!(idx.hit_test(14, 14).is_some()); assert!(idx.hit_test(15, 10).is_none()); assert!(idx.hit_test(10, 15).is_none()); assert!(idx.hit_test(9, 10).is_none()); assert!(idx.hit_test(10, 9).is_none()); }
777
778 #[test]
779 fn update_widget_rect() {
780 let mut idx = index();
781 idx.register_simple(
782 HitId::new(1),
783 Rect::new(0, 0, 10, 10),
784 HitRegion::Content,
785 0,
786 );
787
788 assert!(idx.hit_test(5, 5).is_some());
790
791 let updated = idx.update(HitId::new(1), Rect::new(50, 10, 10, 10));
793 assert!(updated);
794
795 assert!(idx.hit_test(5, 5).is_none());
797
798 assert!(idx.hit_test(55, 15).is_some());
800 }
801
802 #[test]
803 fn remove_widget() {
804 let mut idx = index();
805 idx.register_simple(
806 HitId::new(1),
807 Rect::new(0, 0, 10, 10),
808 HitRegion::Content,
809 0,
810 );
811
812 assert!(idx.hit_test(5, 5).is_some());
813
814 let removed = idx.remove(HitId::new(1));
815 assert!(removed);
816
817 assert!(idx.hit_test(5, 5).is_none());
818 assert!(idx.is_empty());
819 }
820
821 #[test]
822 fn clear_all() {
823 let mut idx = index();
824 idx.register_simple(
825 HitId::new(1),
826 Rect::new(0, 0, 10, 10),
827 HitRegion::Content,
828 0,
829 );
830 idx.register_simple(
831 HitId::new(2),
832 Rect::new(20, 20, 10, 10),
833 HitRegion::Button,
834 1,
835 );
836
837 assert_eq!(idx.len(), 2);
838
839 idx.clear();
840
841 assert!(idx.is_empty());
842 assert!(idx.hit_test(5, 5).is_none());
843 assert!(idx.hit_test(25, 25).is_none());
844 }
845
846 #[test]
849 fn cache_hit_on_same_position() {
850 let mut idx = SpatialHitIndex::new(
851 80,
852 24,
853 SpatialHitConfig {
854 track_cache_stats: true,
855 ..Default::default()
856 },
857 );
858 idx.register_simple(
859 HitId::new(1),
860 Rect::new(0, 0, 10, 10),
861 HitRegion::Content,
862 0,
863 );
864
865 let _ = idx.hit_test(5, 5);
867 assert_eq!(idx.stats().misses, 1);
868 assert_eq!(idx.stats().hits, 0);
869
870 let _ = idx.hit_test(5, 5);
872 assert_eq!(idx.stats().hits, 1);
873
874 let _ = idx.hit_test(7, 7);
876 assert_eq!(idx.stats().misses, 2);
877 }
878
879 #[test]
880 fn cache_invalidated_on_register() {
881 let mut idx = SpatialHitIndex::new(
882 80,
883 24,
884 SpatialHitConfig {
885 track_cache_stats: true,
886 ..Default::default()
887 },
888 );
889 idx.register_simple(
890 HitId::new(1),
891 Rect::new(0, 0, 10, 10),
892 HitRegion::Content,
893 0,
894 );
895
896 let _ = idx.hit_test(5, 5);
898
899 idx.register_simple(HitId::new(2), Rect::new(0, 0, 10, 10), HitRegion::Button, 1);
901
902 let hits_before = idx.stats().hits;
904 let _ = idx.hit_test(5, 5);
905 assert_eq!(idx.stats().hits, hits_before);
907 }
908
909 #[test]
912 fn property_random_layout_correctness() {
913 let mut idx = index();
914 let widgets = vec![
915 (HitId::new(1), Rect::new(0, 0, 20, 10), 0u16),
916 (HitId::new(2), Rect::new(10, 5, 20, 10), 1),
917 (HitId::new(3), Rect::new(25, 0, 15, 15), 2),
918 ];
919
920 for (id, rect, z) in &widgets {
921 idx.register(*id, *rect, HitRegion::Content, id.id() as u64, *z);
922 }
923
924 for x in 0..60 {
926 for y in 0..20 {
927 let indexed_result = idx.hit_test_readonly(x, y);
928
929 let mut best: Option<(HitId, u16)> = None;
931 for (id, rect, z) in &widgets {
932 if x >= rect.x
933 && x < rect.x + rect.width
934 && y >= rect.y
935 && y < rect.y + rect.height
936 {
937 match best {
938 None => best = Some((*id, *z)),
939 Some((_, best_z)) if *z > best_z => best = Some((*id, *z)),
940 _ => {}
941 }
942 }
943 }
944
945 let expected_id = best.map(|(id, _)| id);
946 let indexed_id = indexed_result.map(|(id, _, _)| id);
947
948 assert_eq!(
949 indexed_id, expected_id,
950 "Mismatch at ({}, {}): indexed={:?}, expected={:?}",
951 x, y, indexed_id, expected_id
952 );
953 }
954 }
955 }
956
957 #[test]
960 fn out_of_bounds_returns_none() {
961 let mut idx = index();
962 idx.register_simple(
963 HitId::new(1),
964 Rect::new(0, 0, 10, 10),
965 HitRegion::Content,
966 0,
967 );
968
969 assert!(idx.hit_test(100, 100).is_none());
970 assert!(idx.hit_test(80, 0).is_none());
971 assert!(idx.hit_test(0, 24).is_none());
972 }
973
974 #[test]
975 fn zero_size_rect_ignored() {
976 let mut idx = index();
977 idx.register_simple(
978 HitId::new(1),
979 Rect::new(10, 10, 0, 0),
980 HitRegion::Content,
981 0,
982 );
983
984 assert!(idx.hit_test(10, 10).is_none());
986 }
987
988 #[test]
989 fn large_rect_spans_many_buckets() {
990 let mut idx = index();
991 idx.register_simple(
993 HitId::new(1),
994 Rect::new(0, 0, 80, 24),
995 HitRegion::Content,
996 0,
997 );
998
999 assert!(idx.hit_test(0, 0).is_some());
1001 assert!(idx.hit_test(40, 12).is_some());
1002 assert!(idx.hit_test(79, 23).is_some());
1003 }
1004
1005 #[test]
1006 fn update_nonexistent_returns_false() {
1007 let mut idx = index();
1008 let result = idx.update(HitId::new(999), Rect::new(0, 0, 10, 10));
1009 assert!(!result);
1010 }
1011
1012 #[test]
1013 fn remove_nonexistent_returns_false() {
1014 let mut idx = index();
1015 let result = idx.remove(HitId::new(999));
1016 assert!(!result);
1017 }
1018
1019 #[test]
1020 fn stats_hit_rate() {
1021 let mut stats = CacheStats::default();
1022 assert_eq!(stats.hit_rate(), 0.0);
1023
1024 stats.hits = 75;
1025 stats.misses = 25;
1026 assert!((stats.hit_rate() - 75.0).abs() < 0.01);
1027 }
1028
1029 #[test]
1030 fn config_defaults() {
1031 let config = SpatialHitConfig::default();
1032 assert_eq!(config.cell_size, 8);
1033 assert_eq!(config.bucket_warn_threshold, 64);
1034 assert!(!config.track_cache_stats);
1035 }
1036
1037 #[test]
1038 fn invalidate_region() {
1039 let mut idx = index();
1040 idx.register_simple(
1041 HitId::new(1),
1042 Rect::new(0, 0, 10, 10),
1043 HitRegion::Content,
1044 0,
1045 );
1046
1047 let _ = idx.hit_test(5, 5);
1049 assert!(idx.cache.valid);
1050
1051 idx.invalidate_region(Rect::new(0, 0, 10, 10));
1053 assert!(!idx.cache.valid);
1054 }
1055
1056 #[test]
1057 fn invalidate_all() {
1058 let mut idx = index();
1059 idx.register_simple(
1060 HitId::new(1),
1061 Rect::new(0, 0, 10, 10),
1062 HitRegion::Content,
1063 0,
1064 );
1065
1066 let _ = idx.hit_test(5, 5);
1067 assert!(idx.cache.valid);
1068
1069 idx.invalidate_all();
1070 assert!(!idx.cache.valid);
1071 }
1072
1073 #[test]
1074 fn three_overlapping_widgets_z_order() {
1075 let mut idx = index();
1076 idx.register(
1077 HitId::new(1),
1078 Rect::new(0, 0, 20, 20),
1079 HitRegion::Content,
1080 10,
1081 0,
1082 );
1083 idx.register(
1084 HitId::new(2),
1085 Rect::new(5, 5, 15, 15),
1086 HitRegion::Border,
1087 20,
1088 2,
1089 );
1090 idx.register(
1091 HitId::new(3),
1092 Rect::new(8, 8, 10, 10),
1093 HitRegion::Button,
1094 30,
1095 1,
1096 );
1097 let result = idx.hit_test(10, 10);
1099 assert_eq!(result, Some((HitId::new(2), HitRegion::Border, 20)));
1100 }
1101
1102 #[test]
1103 fn hit_test_readonly_matches_mutable() {
1104 let mut idx = index();
1105 idx.register_simple(
1106 HitId::new(1),
1107 Rect::new(5, 5, 10, 10),
1108 HitRegion::Content,
1109 0,
1110 );
1111 let mutable_result = idx.hit_test(8, 8);
1112 let readonly_result = idx.hit_test_readonly(8, 8);
1113 assert_eq!(mutable_result, readonly_result);
1114 }
1115
1116 #[test]
1117 fn single_pixel_widget() {
1118 let mut idx = index();
1119 idx.register_simple(HitId::new(1), Rect::new(5, 5, 1, 1), HitRegion::Button, 0);
1120 assert!(idx.hit_test(5, 5).is_some());
1121 assert!(idx.hit_test(6, 5).is_none());
1122 assert!(idx.hit_test(5, 6).is_none());
1123 }
1124
1125 #[test]
1126 fn clear_on_empty_is_idempotent() {
1127 let mut idx = index();
1128 idx.clear();
1129 assert!(idx.is_empty());
1130 idx.clear();
1131 assert!(idx.is_empty());
1132 }
1133
1134 #[test]
1135 fn register_remove_register_cycle() {
1136 let mut idx = index();
1137 idx.register_simple(
1138 HitId::new(1),
1139 Rect::new(0, 0, 10, 10),
1140 HitRegion::Content,
1141 0,
1142 );
1143 assert_eq!(idx.len(), 1);
1144 idx.remove(HitId::new(1));
1145 assert_eq!(idx.len(), 0);
1146 idx.register_simple(HitId::new(1), Rect::new(20, 20, 5, 5), HitRegion::Border, 0);
1147 assert_eq!(idx.len(), 1);
1148 assert!(idx.hit_test(22, 22).is_some());
1150 assert!(idx.hit_test(5, 5).is_none());
1151 }
1152
1153 #[test]
1154 fn invalidate_non_overlapping_region_preserves_cache() {
1155 let mut idx = index();
1156 idx.register_simple(
1157 HitId::new(1),
1158 Rect::new(0, 0, 10, 10),
1159 HitRegion::Content,
1160 0,
1161 );
1162 let _ = idx.hit_test(5, 5);
1163 assert!(idx.cache.valid);
1164 idx.invalidate_region(Rect::new(50, 50, 10, 10));
1166 assert!(idx.cache.valid);
1167 }
1168
1169 #[test]
1170 fn hit_entry_contains() {
1171 let entry = HitEntry::new(
1172 HitId::new(1),
1173 Rect::new(10, 10, 20, 20),
1174 HitRegion::Content,
1175 0,
1176 0,
1177 0,
1178 );
1179 assert!(entry.contains(15, 15));
1180 assert!(entry.contains(10, 10));
1181 assert!(!entry.contains(9, 10));
1182 assert!(!entry.contains(30, 30));
1183 }
1184
1185 #[test]
1186 fn reset_stats_clears_counters() {
1187 let mut idx = SpatialHitIndex::new(
1188 80,
1189 24,
1190 SpatialHitConfig {
1191 cell_size: 8,
1192 bucket_warn_threshold: 64,
1193 track_cache_stats: true,
1194 },
1195 );
1196 idx.register_simple(
1197 HitId::new(1),
1198 Rect::new(0, 0, 10, 10),
1199 HitRegion::Content,
1200 0,
1201 );
1202 let _ = idx.hit_test(5, 5);
1203 let _ = idx.hit_test(5, 5); let stats = idx.stats();
1205 assert!(stats.hits > 0 || stats.misses > 0);
1206 idx.reset_stats();
1207 let stats = idx.stats();
1208 assert_eq!(stats.hits, 0);
1209 assert_eq!(stats.misses, 0);
1210 }
1211
1212 #[test]
1219 fn config_debug_clone() {
1220 let config = SpatialHitConfig::default();
1221 let dbg = format!("{:?}", config);
1222 assert!(dbg.contains("SpatialHitConfig"), "Debug: {dbg}");
1223 let cloned = config.clone();
1224 assert_eq!(cloned.cell_size, 8);
1225 }
1226
1227 #[test]
1230 fn hit_entry_debug_clone_copy_eq() {
1231 let entry = HitEntry::new(
1232 HitId::new(1),
1233 Rect::new(0, 0, 10, 10),
1234 HitRegion::Content,
1235 42,
1236 5,
1237 0,
1238 );
1239 let dbg = format!("{:?}", entry);
1240 assert!(dbg.contains("HitEntry"), "Debug: {dbg}");
1241 let copied = entry; assert_eq!(entry, copied);
1243 let cloned: HitEntry = entry; assert_eq!(entry, cloned);
1245 }
1246
1247 #[test]
1248 fn hit_entry_ne() {
1249 let a = HitEntry::new(
1250 HitId::new(1),
1251 Rect::new(0, 0, 10, 10),
1252 HitRegion::Content,
1253 0,
1254 0,
1255 0,
1256 );
1257 let b = HitEntry::new(
1258 HitId::new(2),
1259 Rect::new(0, 0, 10, 10),
1260 HitRegion::Content,
1261 0,
1262 0,
1263 0,
1264 );
1265 assert_ne!(a, b);
1266 }
1267
1268 #[test]
1269 fn hit_entry_contains_zero_width() {
1270 let entry = HitEntry::new(
1271 HitId::new(1),
1272 Rect::new(10, 10, 0, 5),
1273 HitRegion::Content,
1274 0,
1275 0,
1276 0,
1277 );
1278 assert!(!entry.contains(10, 10));
1280 }
1281
1282 #[test]
1283 fn hit_entry_contains_zero_height() {
1284 let entry = HitEntry::new(
1285 HitId::new(1),
1286 Rect::new(10, 10, 5, 0),
1287 HitRegion::Content,
1288 0,
1289 0,
1290 0,
1291 );
1292 assert!(!entry.contains(10, 10));
1293 }
1294
1295 #[test]
1296 fn hit_entry_contains_at_saturating_boundary() {
1297 let entry = HitEntry::new(
1299 HitId::new(1),
1300 Rect::new(u16::MAX - 5, u16::MAX - 5, 10, 10),
1301 HitRegion::Content,
1302 0,
1303 0,
1304 0,
1305 );
1306 assert!(entry.contains(u16::MAX - 5, u16::MAX - 5));
1309 assert!(entry.contains(u16::MAX - 1, u16::MAX - 1));
1310 assert!(!entry.contains(u16::MAX, u16::MAX));
1311 }
1312
1313 #[test]
1316 fn cache_stats_default() {
1317 let stats = CacheStats::default();
1318 assert_eq!(stats.hits, 0);
1319 assert_eq!(stats.misses, 0);
1320 assert_eq!(stats.rebuilds, 0);
1321 assert_eq!(stats.hit_rate(), 0.0);
1322 }
1323
1324 #[test]
1325 fn cache_stats_debug_copy() {
1326 let stats = CacheStats {
1327 hits: 10,
1328 misses: 5,
1329 rebuilds: 1,
1330 };
1331 let dbg = format!("{:?}", stats);
1332 assert!(dbg.contains("CacheStats"), "Debug: {dbg}");
1333 let copy = stats; assert_eq!(copy.hits, stats.hits);
1335 }
1336
1337 #[test]
1338 fn cache_stats_100_percent_hit_rate() {
1339 let stats = CacheStats {
1340 hits: 100,
1341 misses: 0,
1342 rebuilds: 0,
1343 };
1344 assert!((stats.hit_rate() - 100.0).abs() < 0.01);
1345 }
1346
1347 #[test]
1348 fn cache_stats_0_percent_hit_rate() {
1349 let stats = CacheStats {
1350 hits: 0,
1351 misses: 100,
1352 rebuilds: 0,
1353 };
1354 assert!((stats.hit_rate()).abs() < 0.01);
1355 }
1356
1357 #[test]
1360 fn new_with_cell_size_zero_clamped_to_one() {
1361 let config = SpatialHitConfig {
1362 cell_size: 0,
1363 ..Default::default()
1364 };
1365 let idx = SpatialHitIndex::new(80, 24, config);
1366 assert_eq!(idx.grid_width, 80);
1368 assert_eq!(idx.grid_height, 24);
1369 assert!(idx.is_empty());
1370 }
1371
1372 #[test]
1373 fn new_with_cell_size_one() {
1374 let config = SpatialHitConfig {
1375 cell_size: 1,
1376 ..Default::default()
1377 };
1378 let idx = SpatialHitIndex::new(10, 5, config);
1379 assert_eq!(idx.grid_width, 10);
1381 assert_eq!(idx.grid_height, 5);
1382 }
1383
1384 #[test]
1385 fn new_with_large_cell_size() {
1386 let config = SpatialHitConfig {
1387 cell_size: 100,
1388 ..Default::default()
1389 };
1390 let idx = SpatialHitIndex::new(80, 24, config);
1391 assert_eq!(idx.grid_width, 1);
1393 assert_eq!(idx.grid_height, 1);
1394 }
1395
1396 #[test]
1397 fn new_zero_dimensions() {
1398 let idx = SpatialHitIndex::with_defaults(0, 0);
1399 assert!(idx.is_empty());
1400 assert!(idx.hit_test_readonly(0, 0).is_none());
1402 }
1403
1404 #[test]
1405 fn with_defaults_uses_default_config() {
1406 let idx = SpatialHitIndex::with_defaults(80, 24);
1407 assert_eq!(idx.config.cell_size, 8);
1408 assert_eq!(idx.config.bucket_warn_threshold, 64);
1409 assert!(!idx.config.track_cache_stats);
1410 }
1411
1412 #[test]
1413 fn index_debug_format() {
1414 let idx = SpatialHitIndex::with_defaults(10, 10);
1415 let dbg = format!("{:?}", idx);
1416 assert!(dbg.contains("SpatialHitIndex"), "Debug: {dbg}");
1417 }
1418
1419 #[test]
1422 fn register_zero_width_rect_not_in_buckets() {
1423 let mut idx = index();
1424 idx.register_simple(HitId::new(1), Rect::new(5, 5, 0, 10), HitRegion::Content, 0);
1425 assert_eq!(idx.len(), 1);
1427 assert!(idx.hit_test(5, 5).is_none());
1428 }
1429
1430 #[test]
1431 fn register_zero_height_rect_not_in_buckets() {
1432 let mut idx = index();
1433 idx.register_simple(HitId::new(1), Rect::new(5, 5, 10, 0), HitRegion::Content, 0);
1434 assert_eq!(idx.len(), 1);
1435 assert!(idx.hit_test(5, 5).is_none());
1436 }
1437
1438 #[test]
1439 fn register_rect_extending_past_screen() {
1440 let mut idx = index();
1441 idx.register_simple(
1443 HitId::new(1),
1444 Rect::new(70, 20, 20, 10),
1445 HitRegion::Content,
1446 0,
1447 );
1448 assert!(idx.hit_test(75, 22).is_some());
1450 assert!(idx.hit_test(85, 25).is_none());
1452 }
1453
1454 #[test]
1455 fn register_many_widgets() {
1456 let mut idx = index();
1457 for i in 0..100u32 {
1458 let x = (i % 8) as u16 * 10;
1459 let y = (i / 8) as u16 * 3;
1460 idx.register_simple(
1461 HitId::new(i + 1),
1462 Rect::new(x, y, 5, 2),
1463 HitRegion::Content,
1464 i as u64,
1465 );
1466 }
1467 assert_eq!(idx.len(), 100);
1468 let result = idx.hit_test(2, 1);
1470 assert!(result.is_some());
1471 }
1472
1473 #[test]
1474 fn register_simple_uses_z_order_zero() {
1475 let mut idx = index();
1476 idx.register_simple(
1477 HitId::new(1),
1478 Rect::new(0, 0, 10, 10),
1479 HitRegion::Content,
1480 0,
1481 );
1482 idx.register(
1484 HitId::new(2),
1485 Rect::new(0, 0, 10, 10),
1486 HitRegion::Border,
1487 0,
1488 1,
1489 );
1490 let result = idx.hit_test(5, 5);
1492 assert_eq!(result, Some((HitId::new(2), HitRegion::Border, 0)));
1493 }
1494
1495 #[test]
1498 fn update_to_zero_size_rect() {
1499 let mut idx = index();
1500 idx.register_simple(
1501 HitId::new(1),
1502 Rect::new(0, 0, 10, 10),
1503 HitRegion::Content,
1504 0,
1505 );
1506 assert!(idx.hit_test(5, 5).is_some());
1507
1508 idx.update(HitId::new(1), Rect::new(0, 0, 0, 0));
1509 assert!(idx.hit_test(0, 0).is_none());
1511 }
1512
1513 #[test]
1514 fn update_shrinks_widget() {
1515 let mut idx = index();
1516 idx.register_simple(
1517 HitId::new(1),
1518 Rect::new(0, 0, 20, 20),
1519 HitRegion::Content,
1520 0,
1521 );
1522 assert!(idx.hit_test(15, 15).is_some());
1523
1524 idx.update(HitId::new(1), Rect::new(0, 0, 5, 5));
1525 assert!(idx.hit_test(15, 15).is_none());
1526 assert!(idx.hit_test(2, 2).is_some());
1527 }
1528
1529 #[test]
1532 fn remove_middle_entry_compacts() {
1533 let mut idx = index();
1534 idx.register_simple(HitId::new(1), Rect::new(0, 0, 5, 5), HitRegion::Content, 10);
1535 idx.register_simple(
1536 HitId::new(2),
1537 Rect::new(10, 0, 5, 5),
1538 HitRegion::Content,
1539 20,
1540 );
1541 idx.register_simple(
1542 HitId::new(3),
1543 Rect::new(20, 0, 5, 5),
1544 HitRegion::Content,
1545 30,
1546 );
1547 assert_eq!(idx.len(), 3);
1548
1549 idx.remove(HitId::new(2));
1550 assert_eq!(idx.len(), 2);
1551
1552 let r1 = idx.hit_test(2, 2);
1554 assert_eq!(r1, Some((HitId::new(1), HitRegion::Content, 10)));
1555 let r3 = idx.hit_test(22, 2);
1556 assert_eq!(r3, Some((HitId::new(3), HitRegion::Content, 30)));
1557 }
1558
1559 #[test]
1560 fn double_remove_returns_false() {
1561 let mut idx = index();
1562 idx.register_simple(
1563 HitId::new(1),
1564 Rect::new(0, 0, 10, 10),
1565 HitRegion::Content,
1566 0,
1567 );
1568 assert!(idx.remove(HitId::new(1)));
1569 assert!(!idx.remove(HitId::new(1)));
1570 }
1571
1572 #[test]
1575 fn hit_test_at_exact_screen_boundary() {
1576 let mut idx = index(); idx.register_simple(
1578 HitId::new(1),
1579 Rect::new(70, 20, 10, 4),
1580 HitRegion::Content,
1581 0,
1582 );
1583 assert!(idx.hit_test(79, 23).is_some());
1585 assert!(idx.hit_test(80, 23).is_none());
1587 assert!(idx.hit_test(79, 24).is_none());
1588 }
1589
1590 #[test]
1591 fn hit_test_at_grid_cell_boundaries() {
1592 let mut idx = index(); idx.register_simple(
1594 HitId::new(1),
1595 Rect::new(6, 6, 4, 4), HitRegion::Content,
1597 0,
1598 );
1599 assert!(idx.hit_test(7, 7).is_some());
1601 assert!(idx.hit_test(8, 8).is_some());
1603 assert!(idx.hit_test(9, 9).is_some());
1605 assert!(idx.hit_test(10, 10).is_none());
1607 }
1608
1609 #[test]
1610 fn hit_test_readonly_out_of_bounds() {
1611 let idx = index();
1612 assert!(idx.hit_test_readonly(80, 0).is_none());
1613 assert!(idx.hit_test_readonly(0, 24).is_none());
1614 assert!(idx.hit_test_readonly(u16::MAX, u16::MAX).is_none());
1615 }
1616
1617 #[test]
1618 fn hit_test_readonly_skips_removed() {
1619 let mut idx = index();
1620 idx.register_simple(
1621 HitId::new(1),
1622 Rect::new(0, 0, 10, 10),
1623 HitRegion::Content,
1624 0,
1625 );
1626 idx.register_simple(HitId::new(2), Rect::new(0, 0, 10, 10), HitRegion::Border, 1);
1627 idx.remove(HitId::new(2));
1628 let result = idx.hit_test_readonly(5, 5);
1630 assert_eq!(result, Some((HitId::new(1), HitRegion::Content, 0)));
1631 }
1632
1633 #[test]
1636 fn cache_updates_on_different_positions() {
1637 let mut idx = SpatialHitIndex::new(
1638 80,
1639 24,
1640 SpatialHitConfig {
1641 track_cache_stats: true,
1642 ..Default::default()
1643 },
1644 );
1645 idx.register_simple(
1646 HitId::new(1),
1647 Rect::new(0, 0, 40, 12),
1648 HitRegion::Content,
1649 1,
1650 );
1651 idx.register_simple(
1652 HitId::new(2),
1653 Rect::new(40, 12, 40, 12),
1654 HitRegion::Border,
1655 2,
1656 );
1657
1658 let r1 = idx.hit_test(5, 5);
1660 assert_eq!(r1, Some((HitId::new(1), HitRegion::Content, 1)));
1661 assert_eq!(idx.stats().misses, 1);
1662
1663 let r2 = idx.hit_test(50, 15);
1665 assert_eq!(r2, Some((HitId::new(2), HitRegion::Border, 2)));
1666 assert_eq!(idx.stats().misses, 2);
1667
1668 let _ = idx.hit_test(5, 5);
1670 assert_eq!(idx.stats().misses, 3);
1671 }
1672
1673 #[test]
1674 fn cache_invalidated_by_invalidate_all_then_same_position() {
1675 let mut idx = SpatialHitIndex::new(
1676 80,
1677 24,
1678 SpatialHitConfig {
1679 track_cache_stats: true,
1680 ..Default::default()
1681 },
1682 );
1683 idx.register_simple(
1684 HitId::new(1),
1685 Rect::new(0, 0, 10, 10),
1686 HitRegion::Content,
1687 0,
1688 );
1689
1690 let _ = idx.hit_test(5, 5);
1692 assert_eq!(idx.stats().misses, 1);
1693 assert_eq!(idx.stats().hits, 0);
1694
1695 idx.invalidate_all();
1697 let _ = idx.hit_test(5, 5);
1698 assert_eq!(idx.stats().misses, 2);
1700 }
1701
1702 #[test]
1703 fn cache_not_updated_by_readonly() {
1704 let mut idx = SpatialHitIndex::new(
1705 80,
1706 24,
1707 SpatialHitConfig {
1708 track_cache_stats: true,
1709 ..Default::default()
1710 },
1711 );
1712 idx.register_simple(
1713 HitId::new(1),
1714 Rect::new(0, 0, 10, 10),
1715 HitRegion::Content,
1716 0,
1717 );
1718
1719 let _ = idx.hit_test_readonly(5, 5);
1721 assert_eq!(idx.stats().hits, 0);
1722 assert_eq!(idx.stats().misses, 0);
1723
1724 let _ = idx.hit_test(5, 5);
1726 assert_eq!(idx.stats().misses, 1);
1727 }
1728
1729 #[test]
1732 fn invalidate_region_zero_size() {
1733 let mut idx = index();
1734 idx.register_simple(
1735 HitId::new(1),
1736 Rect::new(0, 0, 10, 10),
1737 HitRegion::Content,
1738 0,
1739 );
1740 let _ = idx.hit_test(5, 5);
1741 assert!(idx.cache.valid);
1742
1743 idx.invalidate_region(Rect::new(5, 5, 0, 0));
1745 assert!(idx.cache.valid);
1746 }
1747
1748 #[test]
1749 fn invalidate_region_outside_screen() {
1750 let mut idx = index();
1751 idx.register_simple(
1752 HitId::new(1),
1753 Rect::new(0, 0, 10, 10),
1754 HitRegion::Content,
1755 0,
1756 );
1757 let _ = idx.hit_test(5, 5);
1758 assert!(idx.cache.valid);
1759
1760 idx.invalidate_region(Rect::new(100, 100, 10, 10));
1762 assert!(idx.cache.valid);
1764 }
1765
1766 #[test]
1769 fn rebuild_counted_in_stats() {
1770 let mut idx = SpatialHitIndex::new(
1771 80,
1772 24,
1773 SpatialHitConfig {
1774 track_cache_stats: true,
1775 ..Default::default()
1776 },
1777 );
1778 idx.register_simple(
1779 HitId::new(1),
1780 Rect::new(0, 0, 10, 10),
1781 HitRegion::Content,
1782 0,
1783 );
1784 assert_eq!(idx.stats().rebuilds, 0);
1785
1786 idx.update(HitId::new(1), Rect::new(10, 10, 5, 5));
1788 assert_eq!(idx.stats().rebuilds, 1);
1789
1790 idx.remove(HitId::new(1));
1792 assert_eq!(idx.stats().rebuilds, 2);
1793 }
1794
1795 #[test]
1798 fn register_hit_update_hit_remove_clear() {
1799 let mut idx = index();
1800
1801 idx.register_simple(
1803 HitId::new(1),
1804 Rect::new(0, 0, 10, 10),
1805 HitRegion::Content,
1806 0,
1807 );
1808 assert_eq!(idx.len(), 1);
1809
1810 assert!(idx.hit_test(5, 5).is_some());
1812
1813 idx.update(HitId::new(1), Rect::new(20, 20, 10, 10));
1815 assert!(idx.hit_test(5, 5).is_none());
1816 assert!(idx.hit_test(25, 22).is_some());
1817
1818 idx.remove(HitId::new(1));
1820 assert!(idx.is_empty());
1821 assert!(idx.hit_test(25, 22).is_none());
1822
1823 idx.register_simple(HitId::new(2), Rect::new(0, 0, 5, 5), HitRegion::Button, 99);
1825 assert_eq!(idx.len(), 1);
1826 let r = idx.hit_test(2, 2);
1827 assert_eq!(r, Some((HitId::new(2), HitRegion::Button, 99)));
1828
1829 idx.clear();
1831 assert!(idx.is_empty());
1832 assert!(idx.hit_test(2, 2).is_none());
1833 }
1834
1835 #[test]
1838 fn z_order_tie_broken_by_registration_order() {
1839 let mut idx = index();
1840 idx.register(
1842 HitId::new(1),
1843 Rect::new(0, 0, 10, 10),
1844 HitRegion::Content,
1845 10,
1846 5,
1847 );
1848 idx.register(
1849 HitId::new(2),
1850 Rect::new(0, 0, 10, 10),
1851 HitRegion::Border,
1852 20,
1853 5,
1854 );
1855 idx.register(
1856 HitId::new(3),
1857 Rect::new(0, 0, 10, 10),
1858 HitRegion::Button,
1859 30,
1860 5,
1861 );
1862
1863 let result = idx.hit_test(5, 5);
1865 assert_eq!(result, Some((HitId::new(3), HitRegion::Button, 30)));
1866 }
1867
1868 #[test]
1869 fn z_order_higher_z_beats_later_registration() {
1870 let mut idx = index();
1871 idx.register(
1873 HitId::new(1),
1874 Rect::new(0, 0, 10, 10),
1875 HitRegion::Content,
1876 10,
1877 10,
1878 );
1879 idx.register(
1881 HitId::new(2),
1882 Rect::new(0, 0, 10, 10),
1883 HitRegion::Border,
1884 20,
1885 5,
1886 );
1887
1888 let result = idx.hit_test(5, 5);
1890 assert_eq!(result, Some((HitId::new(1), HitRegion::Content, 10)));
1891 }
1892
1893 #[test]
1896 fn all_hit_region_variants_returned() {
1897 let mut idx = index();
1898 let regions = [
1899 (1, HitRegion::Content),
1900 (2, HitRegion::Border),
1901 (3, HitRegion::Scrollbar),
1902 (4, HitRegion::Handle),
1903 (5, HitRegion::Button),
1904 (6, HitRegion::Link),
1905 (7, HitRegion::Custom(42)),
1906 ];
1907 for (i, (id, region)) in regions.iter().enumerate() {
1908 let x = (i as u16) * 10;
1909 idx.register_simple(HitId::new(*id), Rect::new(x, 0, 5, 5), *region, *id as u64);
1910 }
1911 for (i, (id, region)) in regions.iter().enumerate() {
1912 let x = (i as u16) * 10 + 2;
1913 let result = idx.hit_test(x, 2);
1914 assert_eq!(
1915 result,
1916 Some((HitId::new(*id), *region, *id as u64)),
1917 "Failed for region {:?}",
1918 region
1919 );
1920 }
1921 }
1922
1923 #[test]
1926 fn single_cell_screen() {
1927 let mut idx = SpatialHitIndex::with_defaults(1, 1);
1928 idx.register_simple(HitId::new(1), Rect::new(0, 0, 1, 1), HitRegion::Content, 0);
1929 assert!(idx.hit_test(0, 0).is_some());
1930 assert!(idx.hit_test(1, 0).is_none());
1931 }
1932
1933 #[test]
1936 fn hit_test_readonly_equivalent_to_mutable_for_grid() {
1937 let mut idx = index();
1938 idx.register(
1939 HitId::new(1),
1940 Rect::new(0, 0, 40, 12),
1941 HitRegion::Content,
1942 1,
1943 0,
1944 );
1945 idx.register(
1946 HitId::new(2),
1947 Rect::new(30, 8, 20, 10),
1948 HitRegion::Border,
1949 2,
1950 1,
1951 );
1952 idx.register(
1953 HitId::new(3),
1954 Rect::new(60, 0, 20, 24),
1955 HitRegion::Button,
1956 3,
1957 2,
1958 );
1959
1960 for x in (0..80).step_by(5) {
1962 for y in (0..24).step_by(3) {
1963 let ro = idx.hit_test_readonly(x, y);
1964 let expected_id = ro.map(|(id, _, _)| id);
1965 let ro2 = idx.hit_test_readonly(x, y);
1968 assert_eq!(ro, ro2, "Readonly inconsistency at ({x}, {y})");
1969 let mut_result = idx.hit_test(x, y);
1971 let mut_id = mut_result.map(|(id, _, _)| id);
1972 assert_eq!(
1973 expected_id, mut_id,
1974 "Mutable/readonly mismatch at ({x}, {y})"
1975 );
1976 }
1977 }
1978 }
1979}