1#![forbid(unsafe_code)]
2
3use crate::frame::{HitData, HitId, HitRegion};
30use ftui_core::geometry::Rect;
31use std::collections::HashMap;
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 pub fn hit_rate(&self) -> f32 {
230 let total = self.hits + self.misses;
231 if total == 0 {
232 0.0
233 } else {
234 (self.hits as f32 / total as f32) * 100.0
235 }
236 }
237}
238
239#[derive(Debug)]
248pub struct SpatialHitIndex {
249 config: SpatialHitConfig,
250
251 width: u16,
253 height: u16,
254
255 grid_width: u16,
257 grid_height: u16,
258
259 entries: Vec<HitEntry>,
261
262 buckets: Vec<Bucket>,
264
265 next_order: u32,
267
268 cache: HoverCache,
270
271 dirty: DirtyTracker,
273
274 stats: CacheStats,
276
277 id_to_entry: HashMap<HitId, u32>,
279}
280
281impl SpatialHitIndex {
282 pub fn new(width: u16, height: u16, config: SpatialHitConfig) -> Self {
284 let cell_size = config.cell_size.max(1);
285 let grid_width = (width.saturating_add(cell_size - 1)) / cell_size;
286 let grid_height = (height.saturating_add(cell_size - 1)) / cell_size;
287 let bucket_count = grid_width as usize * grid_height as usize;
288
289 Self {
290 config,
291 width,
292 height,
293 grid_width,
294 grid_height,
295 entries: Vec::with_capacity(256),
296 buckets: vec![Bucket::default(); bucket_count],
297 next_order: 0,
298 cache: HoverCache::default(),
299 dirty: DirtyTracker::default(),
300 stats: CacheStats::default(),
301 id_to_entry: HashMap::with_capacity(256),
302 }
303 }
304
305 pub fn with_defaults(width: u16, height: u16) -> Self {
307 Self::new(width, height, SpatialHitConfig::default())
308 }
309
310 pub fn register(
320 &mut self,
321 id: HitId,
322 rect: Rect,
323 region: HitRegion,
324 data: HitData,
325 z_order: u16,
326 ) {
327 let entry_idx = self.entries.len() as u32;
329 let entry = HitEntry::new(id, rect, region, data, z_order, self.next_order);
330 self.next_order = self.next_order.wrapping_add(1);
331
332 self.entries.push(entry);
333 self.id_to_entry.insert(id, entry_idx);
334
335 self.add_to_buckets(entry_idx, rect);
337
338 self.dirty.mark_dirty(rect);
340 if self.cache.valid && self.dirty.is_dirty(self.cache.pos.0, self.cache.pos.1) {
341 self.cache.valid = false;
342 }
343 }
344
345 pub fn register_simple(&mut self, id: HitId, rect: Rect, region: HitRegion, data: HitData) {
347 self.register(id, rect, region, data, 0);
348 }
349
350 pub fn update(&mut self, id: HitId, new_rect: Rect) -> bool {
354 let Some(&entry_idx) = self.id_to_entry.get(&id) else {
355 return false;
356 };
357
358 let old_rect = self.entries[entry_idx as usize].rect;
359
360 self.dirty.mark_dirty(old_rect);
362 self.dirty.mark_dirty(new_rect);
363
364 self.entries[entry_idx as usize].rect = new_rect;
366
367 self.rebuild_buckets();
370
371 self.cache.valid = false;
373
374 true
375 }
376
377 pub fn remove(&mut self, id: HitId) -> bool {
381 let Some(&entry_idx) = self.id_to_entry.get(&id) else {
382 return false;
383 };
384
385 let rect = self.entries[entry_idx as usize].rect;
386 self.dirty.mark_dirty(rect);
387
388 self.entries[entry_idx as usize].id = HitId::default();
390 self.id_to_entry.remove(&id);
391
392 self.rebuild_buckets();
394 self.cache.valid = false;
395
396 true
397 }
398
399 pub fn hit_test(&mut self, x: u16, y: u16) -> Option<(HitId, HitRegion, HitData)> {
408 if x >= self.width || y >= self.height {
410 return None;
411 }
412
413 if self.cache.valid && self.cache.pos == (x, y) {
415 if self.config.track_cache_stats {
416 self.stats.hits += 1;
417 }
418 return self.cache.result.map(|idx| {
419 let e = &self.entries[idx as usize];
420 (e.id, e.region, e.data)
421 });
422 }
423
424 if self.config.track_cache_stats {
425 self.stats.misses += 1;
426 }
427
428 let bucket_idx = self.bucket_index(x, y);
430 let bucket = &self.buckets[bucket_idx];
431
432 let mut best: Option<&HitEntry> = None;
434 let mut best_idx: Option<u32> = None;
435
436 for &entry_idx in &bucket.entries {
437 let entry = &self.entries[entry_idx as usize];
438
439 if entry.id == HitId::default() {
441 continue;
442 }
443
444 if entry.contains(x, y) {
446 match best {
448 None => {
449 best = Some(entry);
450 best_idx = Some(entry_idx);
451 }
452 Some(current_best) if entry.cmp_z_order(current_best).is_gt() => {
453 best = Some(entry);
454 best_idx = Some(entry_idx);
455 }
456 _ => {}
457 }
458 }
459 }
460
461 self.cache.pos = (x, y);
463 self.cache.result = best_idx;
464 self.cache.valid = true;
465
466 best.map(|e| (e.id, e.region, e.data))
467 }
468
469 pub fn hit_test_readonly(&self, x: u16, y: u16) -> Option<(HitId, HitRegion, HitData)> {
471 if x >= self.width || y >= self.height {
472 return None;
473 }
474
475 let bucket_idx = self.bucket_index(x, y);
476 let bucket = &self.buckets[bucket_idx];
477
478 let mut best: Option<&HitEntry> = None;
479
480 for &entry_idx in &bucket.entries {
481 let entry = &self.entries[entry_idx as usize];
482 if entry.id == HitId::default() {
483 continue;
484 }
485 if entry.contains(x, y) {
486 match best {
487 None => best = Some(entry),
488 Some(current_best) if entry.cmp_z_order(current_best).is_gt() => {
489 best = Some(entry)
490 }
491 _ => {}
492 }
493 }
494 }
495
496 best.map(|e| (e.id, e.region, e.data))
497 }
498
499 pub fn clear(&mut self) {
501 self.entries.clear();
502 self.id_to_entry.clear();
503 for bucket in &mut self.buckets {
504 bucket.clear();
505 }
506 self.next_order = 0;
507 self.cache.valid = false;
508 self.dirty.clear();
509 }
510
511 pub fn stats(&self) -> CacheStats {
513 self.stats
514 }
515
516 pub fn reset_stats(&mut self) {
518 self.stats = CacheStats::default();
519 }
520
521 pub fn len(&self) -> usize {
523 self.id_to_entry.len()
524 }
525
526 pub fn is_empty(&self) -> bool {
528 self.id_to_entry.is_empty()
529 }
530
531 pub fn invalidate_region(&mut self, rect: Rect) {
533 self.dirty.mark_dirty(rect);
534 if self.cache.valid && self.dirty.is_dirty(self.cache.pos.0, self.cache.pos.1) {
535 self.cache.valid = false;
536 }
537 }
538
539 pub fn invalidate_all(&mut self) {
541 self.cache.valid = false;
542 self.dirty.mark_full_rebuild();
543 }
544
545 #[inline]
551 fn bucket_index(&self, x: u16, y: u16) -> usize {
552 let cell_size = self.config.cell_size;
553 let bx = x / cell_size;
554 let by = y / cell_size;
555 by as usize * self.grid_width as usize + bx as usize
556 }
557
558 fn bucket_range(&self, rect: Rect) -> (u16, u16, u16, u16) {
560 let cell_size = self.config.cell_size;
561 let bx_start = rect.x / cell_size;
562 let by_start = rect.y / cell_size;
563 let bx_end = rect.x.saturating_add(rect.width.saturating_sub(1)) / cell_size;
564 let by_end = rect.y.saturating_add(rect.height.saturating_sub(1)) / cell_size;
565 (
566 bx_start.min(self.grid_width.saturating_sub(1)),
567 by_start.min(self.grid_height.saturating_sub(1)),
568 bx_end.min(self.grid_width.saturating_sub(1)),
569 by_end.min(self.grid_height.saturating_sub(1)),
570 )
571 }
572
573 fn add_to_buckets(&mut self, entry_idx: u32, rect: Rect) {
575 if rect.width == 0 || rect.height == 0 {
576 return;
577 }
578
579 let (bx_start, by_start, bx_end, by_end) = self.bucket_range(rect);
580
581 for by in by_start..=by_end {
582 for bx in bx_start..=bx_end {
583 let bucket_idx = by as usize * self.grid_width as usize + bx as usize;
584 if bucket_idx < self.buckets.len() {
585 self.buckets[bucket_idx].push(entry_idx);
586
587 if self.buckets[bucket_idx].entries.len() > self.config.bucket_warn_threshold {
589 }
591 }
592 }
593 }
594 }
595
596 fn rebuild_buckets(&mut self) {
598 for bucket in &mut self.buckets {
600 bucket.clear();
601 }
602
603 let mut valid_idx = 0;
605 for i in 0..self.entries.len() {
606 if self.entries[i].id != HitId::default() {
607 if i != valid_idx {
608 self.entries[valid_idx] = self.entries[i];
609 }
610 valid_idx += 1;
611 }
612 }
613 self.entries.truncate(valid_idx);
614
615 self.id_to_entry.clear();
617 for (idx, entry) in self.entries.iter().enumerate() {
618 self.id_to_entry.insert(entry.id, idx as u32);
619 }
620
621 let entry_rects: Vec<(u32, Rect)> = self
623 .entries
624 .iter()
625 .enumerate()
626 .map(|(idx, e)| (idx as u32, e.rect))
627 .collect();
628 for (idx, rect) in entry_rects {
629 self.add_to_buckets_internal(idx, rect);
630 }
631
632 self.dirty.clear();
633 self.stats.rebuilds += 1;
634 }
635
636 fn add_to_buckets_internal(&mut self, entry_idx: u32, rect: Rect) {
638 if rect.width == 0 || rect.height == 0 {
639 return;
640 }
641
642 let (bx_start, by_start, bx_end, by_end) = self.bucket_range(rect);
643
644 for by in by_start..=by_end {
645 for bx in bx_start..=bx_end {
646 let bucket_idx = by as usize * self.grid_width as usize + bx as usize;
647 if bucket_idx < self.buckets.len() {
648 self.buckets[bucket_idx].push(entry_idx);
649 }
650 }
651 }
652 }
653}
654
655#[cfg(test)]
660mod tests {
661 use super::*;
662
663 fn index() -> SpatialHitIndex {
664 SpatialHitIndex::with_defaults(80, 24)
665 }
666
667 #[test]
670 fn initial_state_empty() {
671 let idx = index();
672 assert!(idx.is_empty());
673 assert_eq!(idx.len(), 0);
674 }
675
676 #[test]
677 fn register_and_hit_test() {
678 let mut idx = index();
679 idx.register_simple(
680 HitId::new(1),
681 Rect::new(10, 5, 20, 3),
682 HitRegion::Button,
683 42,
684 );
685
686 let result = idx.hit_test(15, 6);
688 assert_eq!(result, Some((HitId::new(1), HitRegion::Button, 42)));
689
690 assert!(idx.hit_test(5, 5).is_none());
692 assert!(idx.hit_test(35, 5).is_none());
693 }
694
695 #[test]
696 fn z_order_topmost_wins() {
697 let mut idx = index();
698
699 idx.register(
701 HitId::new(1),
702 Rect::new(0, 0, 10, 10),
703 HitRegion::Content,
704 1,
705 0, );
707 idx.register(
708 HitId::new(2),
709 Rect::new(5, 5, 10, 10),
710 HitRegion::Border,
711 2,
712 1, );
714
715 let result = idx.hit_test(7, 7);
717 assert_eq!(result, Some((HitId::new(2), HitRegion::Border, 2)));
718
719 let result = idx.hit_test(2, 2);
721 assert_eq!(result, Some((HitId::new(1), HitRegion::Content, 1)));
722 }
723
724 #[test]
725 fn same_z_order_later_wins() {
726 let mut idx = index();
727
728 idx.register(
730 HitId::new(1),
731 Rect::new(0, 0, 10, 10),
732 HitRegion::Content,
733 1,
734 0,
735 );
736 idx.register(
737 HitId::new(2),
738 Rect::new(5, 5, 10, 10),
739 HitRegion::Border,
740 2,
741 0,
742 );
743
744 let result = idx.hit_test(7, 7);
746 assert_eq!(result, Some((HitId::new(2), HitRegion::Border, 2)));
747 }
748
749 #[test]
750 fn hit_test_border_inclusive() {
751 let mut idx = index();
752 idx.register_simple(
753 HitId::new(1),
754 Rect::new(10, 10, 5, 5),
755 HitRegion::Content,
756 0,
757 );
758
759 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()); }
771
772 #[test]
773 fn update_widget_rect() {
774 let mut idx = index();
775 idx.register_simple(
776 HitId::new(1),
777 Rect::new(0, 0, 10, 10),
778 HitRegion::Content,
779 0,
780 );
781
782 assert!(idx.hit_test(5, 5).is_some());
784
785 let updated = idx.update(HitId::new(1), Rect::new(50, 10, 10, 10));
787 assert!(updated);
788
789 assert!(idx.hit_test(5, 5).is_none());
791
792 assert!(idx.hit_test(55, 15).is_some());
794 }
795
796 #[test]
797 fn remove_widget() {
798 let mut idx = index();
799 idx.register_simple(
800 HitId::new(1),
801 Rect::new(0, 0, 10, 10),
802 HitRegion::Content,
803 0,
804 );
805
806 assert!(idx.hit_test(5, 5).is_some());
807
808 let removed = idx.remove(HitId::new(1));
809 assert!(removed);
810
811 assert!(idx.hit_test(5, 5).is_none());
812 assert!(idx.is_empty());
813 }
814
815 #[test]
816 fn clear_all() {
817 let mut idx = index();
818 idx.register_simple(
819 HitId::new(1),
820 Rect::new(0, 0, 10, 10),
821 HitRegion::Content,
822 0,
823 );
824 idx.register_simple(
825 HitId::new(2),
826 Rect::new(20, 20, 10, 10),
827 HitRegion::Button,
828 1,
829 );
830
831 assert_eq!(idx.len(), 2);
832
833 idx.clear();
834
835 assert!(idx.is_empty());
836 assert!(idx.hit_test(5, 5).is_none());
837 assert!(idx.hit_test(25, 25).is_none());
838 }
839
840 #[test]
843 fn cache_hit_on_same_position() {
844 let mut idx = SpatialHitIndex::new(
845 80,
846 24,
847 SpatialHitConfig {
848 track_cache_stats: true,
849 ..Default::default()
850 },
851 );
852 idx.register_simple(
853 HitId::new(1),
854 Rect::new(0, 0, 10, 10),
855 HitRegion::Content,
856 0,
857 );
858
859 idx.hit_test(5, 5);
861 assert_eq!(idx.stats().misses, 1);
862 assert_eq!(idx.stats().hits, 0);
863
864 idx.hit_test(5, 5);
866 assert_eq!(idx.stats().hits, 1);
867
868 idx.hit_test(7, 7);
870 assert_eq!(idx.stats().misses, 2);
871 }
872
873 #[test]
874 fn cache_invalidated_on_register() {
875 let mut idx = SpatialHitIndex::new(
876 80,
877 24,
878 SpatialHitConfig {
879 track_cache_stats: true,
880 ..Default::default()
881 },
882 );
883 idx.register_simple(
884 HitId::new(1),
885 Rect::new(0, 0, 10, 10),
886 HitRegion::Content,
887 0,
888 );
889
890 idx.hit_test(5, 5);
892
893 idx.register_simple(HitId::new(2), Rect::new(0, 0, 10, 10), HitRegion::Button, 1);
895
896 let hits_before = idx.stats().hits;
898 idx.hit_test(5, 5);
899 assert_eq!(idx.stats().hits, hits_before);
901 }
902
903 #[test]
906 fn property_random_layout_correctness() {
907 let mut idx = index();
908 let widgets = vec![
909 (HitId::new(1), Rect::new(0, 0, 20, 10), 0u16),
910 (HitId::new(2), Rect::new(10, 5, 20, 10), 1),
911 (HitId::new(3), Rect::new(25, 0, 15, 15), 2),
912 ];
913
914 for (id, rect, z) in &widgets {
915 idx.register(*id, *rect, HitRegion::Content, id.id() as u64, *z);
916 }
917
918 for x in 0..60 {
920 for y in 0..20 {
921 let indexed_result = idx.hit_test_readonly(x, y);
922
923 let mut best: Option<(HitId, u16)> = None;
925 for (id, rect, z) in &widgets {
926 if x >= rect.x
927 && x < rect.x + rect.width
928 && y >= rect.y
929 && y < rect.y + rect.height
930 {
931 match best {
932 None => best = Some((*id, *z)),
933 Some((_, best_z)) if *z > best_z => best = Some((*id, *z)),
934 _ => {}
935 }
936 }
937 }
938
939 let expected_id = best.map(|(id, _)| id);
940 let indexed_id = indexed_result.map(|(id, _, _)| id);
941
942 assert_eq!(
943 indexed_id, expected_id,
944 "Mismatch at ({}, {}): indexed={:?}, expected={:?}",
945 x, y, indexed_id, expected_id
946 );
947 }
948 }
949 }
950
951 #[test]
954 fn out_of_bounds_returns_none() {
955 let mut idx = index();
956 idx.register_simple(
957 HitId::new(1),
958 Rect::new(0, 0, 10, 10),
959 HitRegion::Content,
960 0,
961 );
962
963 assert!(idx.hit_test(100, 100).is_none());
964 assert!(idx.hit_test(80, 0).is_none());
965 assert!(idx.hit_test(0, 24).is_none());
966 }
967
968 #[test]
969 fn zero_size_rect_ignored() {
970 let mut idx = index();
971 idx.register_simple(
972 HitId::new(1),
973 Rect::new(10, 10, 0, 0),
974 HitRegion::Content,
975 0,
976 );
977
978 assert!(idx.hit_test(10, 10).is_none());
980 }
981
982 #[test]
983 fn large_rect_spans_many_buckets() {
984 let mut idx = index();
985 idx.register_simple(
987 HitId::new(1),
988 Rect::new(0, 0, 80, 24),
989 HitRegion::Content,
990 0,
991 );
992
993 assert!(idx.hit_test(0, 0).is_some());
995 assert!(idx.hit_test(40, 12).is_some());
996 assert!(idx.hit_test(79, 23).is_some());
997 }
998
999 #[test]
1000 fn update_nonexistent_returns_false() {
1001 let mut idx = index();
1002 let result = idx.update(HitId::new(999), Rect::new(0, 0, 10, 10));
1003 assert!(!result);
1004 }
1005
1006 #[test]
1007 fn remove_nonexistent_returns_false() {
1008 let mut idx = index();
1009 let result = idx.remove(HitId::new(999));
1010 assert!(!result);
1011 }
1012
1013 #[test]
1014 fn stats_hit_rate() {
1015 let mut stats = CacheStats::default();
1016 assert_eq!(stats.hit_rate(), 0.0);
1017
1018 stats.hits = 75;
1019 stats.misses = 25;
1020 assert!((stats.hit_rate() - 75.0).abs() < 0.01);
1021 }
1022
1023 #[test]
1024 fn config_defaults() {
1025 let config = SpatialHitConfig::default();
1026 assert_eq!(config.cell_size, 8);
1027 assert_eq!(config.bucket_warn_threshold, 64);
1028 assert!(!config.track_cache_stats);
1029 }
1030
1031 #[test]
1032 fn invalidate_region() {
1033 let mut idx = index();
1034 idx.register_simple(
1035 HitId::new(1),
1036 Rect::new(0, 0, 10, 10),
1037 HitRegion::Content,
1038 0,
1039 );
1040
1041 idx.hit_test(5, 5);
1043 assert!(idx.cache.valid);
1044
1045 idx.invalidate_region(Rect::new(0, 0, 10, 10));
1047 assert!(!idx.cache.valid);
1048 }
1049
1050 #[test]
1051 fn invalidate_all() {
1052 let mut idx = index();
1053 idx.register_simple(
1054 HitId::new(1),
1055 Rect::new(0, 0, 10, 10),
1056 HitRegion::Content,
1057 0,
1058 );
1059
1060 idx.hit_test(5, 5);
1061 assert!(idx.cache.valid);
1062
1063 idx.invalidate_all();
1064 assert!(!idx.cache.valid);
1065 }
1066}