#![forbid(unsafe_code)]
use crate::frame::{HitData, HitId, HitRegion};
use ahash::AHashMap;
use ftui_core::geometry::Rect;
#[derive(Debug, Clone)]
pub struct SpatialHitConfig {
pub cell_size: u16,
pub bucket_warn_threshold: usize,
pub track_cache_stats: bool,
}
impl Default for SpatialHitConfig {
fn default() -> Self {
Self {
cell_size: 8,
bucket_warn_threshold: 64,
track_cache_stats: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HitEntry {
pub id: HitId,
pub rect: Rect,
pub region: HitRegion,
pub data: HitData,
pub z_order: u16,
order: u32,
}
impl HitEntry {
pub fn new(
id: HitId,
rect: Rect,
region: HitRegion,
data: HitData,
z_order: u16,
order: u32,
) -> Self {
Self {
id,
rect,
region,
data,
z_order,
order,
}
}
#[inline]
pub fn contains(&self, x: u16, y: u16) -> bool {
x >= self.rect.x
&& x < self.rect.x.saturating_add(self.rect.width)
&& y >= self.rect.y
&& y < self.rect.y.saturating_add(self.rect.height)
}
#[inline]
fn cmp_z_order(&self, other: &Self) -> std::cmp::Ordering {
match self.z_order.cmp(&other.z_order) {
std::cmp::Ordering::Equal => self.order.cmp(&other.order),
ord => ord,
}
}
}
#[derive(Debug, Clone, Default)]
struct Bucket {
entries: Vec<u32>,
}
impl Bucket {
#[inline]
fn push(&mut self, entry_idx: u32) {
self.entries.push(entry_idx);
}
#[inline]
fn clear(&mut self) {
self.entries.clear();
}
}
#[derive(Debug, Clone, Copy, Default)]
struct HoverCache {
pos: (u16, u16),
result: Option<u32>,
valid: bool,
}
#[derive(Debug, Clone, Default)]
struct DirtyTracker {
dirty_rects: Vec<Rect>,
full_rebuild: bool,
}
impl DirtyTracker {
fn mark_dirty(&mut self, rect: Rect) {
if !self.full_rebuild {
self.dirty_rects.push(rect);
}
}
fn mark_full_rebuild(&mut self) {
self.full_rebuild = true;
self.dirty_rects.clear();
}
fn clear(&mut self) {
self.dirty_rects.clear();
self.full_rebuild = false;
}
fn is_dirty(&self, x: u16, y: u16) -> bool {
if self.full_rebuild {
return true;
}
for rect in &self.dirty_rects {
if x >= rect.x
&& x < rect.x.saturating_add(rect.width)
&& y >= rect.y
&& y < rect.y.saturating_add(rect.height)
{
return true;
}
}
false
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct CacheStats {
pub hits: u64,
pub misses: u64,
pub rebuilds: u64,
}
impl CacheStats {
#[must_use]
pub fn hit_rate(&self) -> f32 {
let total = self.hits + self.misses;
if total == 0 {
0.0
} else {
(self.hits as f32 / total as f32) * 100.0
}
}
}
#[derive(Debug)]
pub struct SpatialHitIndex {
config: SpatialHitConfig,
width: u16,
height: u16,
grid_width: u16,
grid_height: u16,
entries: Vec<HitEntry>,
buckets: Vec<Bucket>,
next_order: u32,
cache: HoverCache,
dirty: DirtyTracker,
stats: CacheStats,
id_to_entry: AHashMap<HitId, u32>,
}
impl SpatialHitIndex {
pub fn new(width: u16, height: u16, config: SpatialHitConfig) -> Self {
let cell_size = config.cell_size.max(1);
let grid_width = (width.saturating_add(cell_size - 1)) / cell_size;
let grid_height = (height.saturating_add(cell_size - 1)) / cell_size;
let bucket_count = grid_width as usize * grid_height as usize;
Self {
config,
width,
height,
grid_width,
grid_height,
entries: Vec::with_capacity(256),
buckets: vec![Bucket::default(); bucket_count],
next_order: 0,
cache: HoverCache::default(),
dirty: DirtyTracker::default(),
stats: CacheStats::default(),
id_to_entry: AHashMap::with_capacity(256),
}
}
pub fn with_defaults(width: u16, height: u16) -> Self {
Self::new(width, height, SpatialHitConfig::default())
}
pub fn register(
&mut self,
id: HitId,
rect: Rect,
region: HitRegion,
data: HitData,
z_order: u16,
) {
let entry_idx = self.entries.len() as u32;
let entry = HitEntry::new(id, rect, region, data, z_order, self.next_order);
self.next_order = self.next_order.wrapping_add(1);
self.entries.push(entry);
self.id_to_entry.insert(id, entry_idx);
self.add_to_buckets(entry_idx, rect);
self.dirty.mark_dirty(rect);
if self.cache.valid && self.dirty.is_dirty(self.cache.pos.0, self.cache.pos.1) {
self.cache.valid = false;
}
}
pub fn register_simple(&mut self, id: HitId, rect: Rect, region: HitRegion, data: HitData) {
self.register(id, rect, region, data, 0);
}
pub fn update(&mut self, id: HitId, new_rect: Rect) -> bool {
let Some(&entry_idx) = self.id_to_entry.get(&id) else {
return false;
};
let old_rect = self.entries[entry_idx as usize].rect;
self.dirty.mark_dirty(old_rect);
self.dirty.mark_dirty(new_rect);
self.entries[entry_idx as usize].rect = new_rect;
self.rebuild_buckets();
self.cache.valid = false;
true
}
pub fn remove(&mut self, id: HitId) -> bool {
let Some(&entry_idx) = self.id_to_entry.get(&id) else {
return false;
};
let rect = self.entries[entry_idx as usize].rect;
self.dirty.mark_dirty(rect);
self.entries[entry_idx as usize].id = HitId::default();
self.id_to_entry.remove(&id);
self.rebuild_buckets();
self.cache.valid = false;
true
}
#[must_use]
pub fn hit_test(&mut self, x: u16, y: u16) -> Option<(HitId, HitRegion, HitData)> {
if x >= self.width || y >= self.height {
return None;
}
if self.cache.valid && self.cache.pos == (x, y) {
if self.config.track_cache_stats {
self.stats.hits += 1;
}
return self.cache.result.map(|idx| {
let e = &self.entries[idx as usize];
(e.id, e.region, e.data)
});
}
if self.config.track_cache_stats {
self.stats.misses += 1;
}
let bucket_idx = self.bucket_index(x, y);
let bucket = &self.buckets[bucket_idx];
let mut best: Option<&HitEntry> = None;
let mut best_idx: Option<u32> = None;
for &entry_idx in &bucket.entries {
let entry = &self.entries[entry_idx as usize];
if entry.id == HitId::default() {
continue;
}
if entry.contains(x, y) {
match best {
None => {
best = Some(entry);
best_idx = Some(entry_idx);
}
Some(current_best) if entry.cmp_z_order(current_best).is_gt() => {
best = Some(entry);
best_idx = Some(entry_idx);
}
_ => {}
}
}
}
self.cache.pos = (x, y);
self.cache.result = best_idx;
self.cache.valid = true;
self.dirty.clear();
best.map(|e| (e.id, e.region, e.data))
}
#[must_use]
pub fn hit_test_readonly(&self, x: u16, y: u16) -> Option<(HitId, HitRegion, HitData)> {
if x >= self.width || y >= self.height {
return None;
}
let bucket_idx = self.bucket_index(x, y);
let bucket = &self.buckets[bucket_idx];
let mut best: Option<&HitEntry> = None;
for &entry_idx in &bucket.entries {
let entry = &self.entries[entry_idx as usize];
if entry.id == HitId::default() {
continue;
}
if entry.contains(x, y) {
match best {
None => best = Some(entry),
Some(current_best) if entry.cmp_z_order(current_best).is_gt() => {
best = Some(entry)
}
_ => {}
}
}
}
best.map(|e| (e.id, e.region, e.data))
}
pub fn clear(&mut self) {
self.entries.clear();
self.id_to_entry.clear();
for bucket in &mut self.buckets {
bucket.clear();
}
self.next_order = 0;
self.cache.valid = false;
self.dirty.clear();
}
#[must_use]
pub fn stats(&self) -> CacheStats {
self.stats
}
pub fn reset_stats(&mut self) {
self.stats = CacheStats::default();
}
#[inline]
#[must_use]
pub fn len(&self) -> usize {
self.id_to_entry.len()
}
#[inline]
#[must_use]
pub fn is_empty(&self) -> bool {
self.id_to_entry.is_empty()
}
pub fn invalidate_region(&mut self, rect: Rect) {
self.dirty.mark_dirty(rect);
if self.cache.valid && self.dirty.is_dirty(self.cache.pos.0, self.cache.pos.1) {
self.cache.valid = false;
}
}
pub fn invalidate_all(&mut self) {
self.cache.valid = false;
self.dirty.mark_full_rebuild();
}
#[inline]
fn bucket_index(&self, x: u16, y: u16) -> usize {
let cell_size = self.config.cell_size;
let bx = x / cell_size;
let by = y / cell_size;
by as usize * self.grid_width as usize + bx as usize
}
fn bucket_range(&self, rect: Rect) -> (u16, u16, u16, u16) {
let cell_size = self.config.cell_size;
let bx_start = rect.x / cell_size;
let by_start = rect.y / cell_size;
let bx_end = rect.x.saturating_add(rect.width.saturating_sub(1)) / cell_size;
let by_end = rect.y.saturating_add(rect.height.saturating_sub(1)) / cell_size;
(
bx_start.min(self.grid_width.saturating_sub(1)),
by_start.min(self.grid_height.saturating_sub(1)),
bx_end.min(self.grid_width.saturating_sub(1)),
by_end.min(self.grid_height.saturating_sub(1)),
)
}
fn add_to_buckets(&mut self, entry_idx: u32, rect: Rect) {
if rect.width == 0 || rect.height == 0 {
return;
}
let (bx_start, by_start, bx_end, by_end) = self.bucket_range(rect);
for by in by_start..=by_end {
for bx in bx_start..=bx_end {
let bucket_idx = by as usize * self.grid_width as usize + bx as usize;
if bucket_idx < self.buckets.len() {
self.buckets[bucket_idx].push(entry_idx);
if self.buckets[bucket_idx].entries.len() > self.config.bucket_warn_threshold {
}
}
}
}
}
fn rebuild_buckets(&mut self) {
for bucket in &mut self.buckets {
bucket.clear();
}
let mut valid_idx = 0;
for i in 0..self.entries.len() {
if self.entries[i].id != HitId::default() {
if i != valid_idx {
self.entries[valid_idx] = self.entries[i];
}
valid_idx += 1;
}
}
self.entries.truncate(valid_idx);
self.id_to_entry.clear();
for (idx, entry) in self.entries.iter().enumerate() {
self.id_to_entry.insert(entry.id, idx as u32);
}
for idx in 0..self.entries.len() {
let rect = self.entries[idx].rect;
self.add_to_buckets_internal(idx as u32, rect);
}
self.dirty.clear();
self.stats.rebuilds += 1;
}
fn add_to_buckets_internal(&mut self, entry_idx: u32, rect: Rect) {
if rect.width == 0 || rect.height == 0 {
return;
}
let (bx_start, by_start, bx_end, by_end) = self.bucket_range(rect);
for by in by_start..=by_end {
for bx in bx_start..=bx_end {
let bucket_idx = by as usize * self.grid_width as usize + bx as usize;
if bucket_idx < self.buckets.len() {
self.buckets[bucket_idx].push(entry_idx);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn index() -> SpatialHitIndex {
SpatialHitIndex::with_defaults(80, 24)
}
#[test]
fn initial_state_empty() {
let idx = index();
assert!(idx.is_empty());
assert_eq!(idx.len(), 0);
}
#[test]
fn register_and_hit_test() {
let mut idx = index();
idx.register_simple(
HitId::new(1),
Rect::new(10, 5, 20, 3),
HitRegion::Button,
42,
);
let result = idx.hit_test(15, 6);
assert_eq!(result, Some((HitId::new(1), HitRegion::Button, 42)));
assert!(idx.hit_test(5, 5).is_none());
assert!(idx.hit_test(35, 5).is_none());
}
#[test]
fn z_order_topmost_wins() {
let mut idx = index();
idx.register(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
1,
0, );
idx.register(
HitId::new(2),
Rect::new(5, 5, 10, 10),
HitRegion::Border,
2,
1, );
let result = idx.hit_test(7, 7);
assert_eq!(result, Some((HitId::new(2), HitRegion::Border, 2)));
let result = idx.hit_test(2, 2);
assert_eq!(result, Some((HitId::new(1), HitRegion::Content, 1)));
}
#[test]
fn same_z_order_later_wins() {
let mut idx = index();
idx.register(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
1,
0,
);
idx.register(
HitId::new(2),
Rect::new(5, 5, 10, 10),
HitRegion::Border,
2,
0,
);
let result = idx.hit_test(7, 7);
assert_eq!(result, Some((HitId::new(2), HitRegion::Border, 2)));
}
#[test]
fn hit_test_border_inclusive() {
let mut idx = index();
idx.register_simple(
HitId::new(1),
Rect::new(10, 10, 5, 5),
HitRegion::Content,
0,
);
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()); }
#[test]
fn update_widget_rect() {
let mut idx = index();
idx.register_simple(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
0,
);
assert!(idx.hit_test(5, 5).is_some());
let updated = idx.update(HitId::new(1), Rect::new(50, 10, 10, 10));
assert!(updated);
assert!(idx.hit_test(5, 5).is_none());
assert!(idx.hit_test(55, 15).is_some());
}
#[test]
fn remove_widget() {
let mut idx = index();
idx.register_simple(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
0,
);
assert!(idx.hit_test(5, 5).is_some());
let removed = idx.remove(HitId::new(1));
assert!(removed);
assert!(idx.hit_test(5, 5).is_none());
assert!(idx.is_empty());
}
#[test]
fn clear_all() {
let mut idx = index();
idx.register_simple(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
0,
);
idx.register_simple(
HitId::new(2),
Rect::new(20, 20, 10, 10),
HitRegion::Button,
1,
);
assert_eq!(idx.len(), 2);
idx.clear();
assert!(idx.is_empty());
assert!(idx.hit_test(5, 5).is_none());
assert!(idx.hit_test(25, 25).is_none());
}
#[test]
fn cache_hit_on_same_position() {
let mut idx = SpatialHitIndex::new(
80,
24,
SpatialHitConfig {
track_cache_stats: true,
..Default::default()
},
);
idx.register_simple(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
0,
);
let _ = idx.hit_test(5, 5);
assert_eq!(idx.stats().misses, 1);
assert_eq!(idx.stats().hits, 0);
let _ = idx.hit_test(5, 5);
assert_eq!(idx.stats().hits, 1);
let _ = idx.hit_test(7, 7);
assert_eq!(idx.stats().misses, 2);
}
#[test]
fn cache_invalidated_on_register() {
let mut idx = SpatialHitIndex::new(
80,
24,
SpatialHitConfig {
track_cache_stats: true,
..Default::default()
},
);
idx.register_simple(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
0,
);
let _ = idx.hit_test(5, 5);
idx.register_simple(HitId::new(2), Rect::new(0, 0, 10, 10), HitRegion::Button, 1);
let hits_before = idx.stats().hits;
let _ = idx.hit_test(5, 5);
assert_eq!(idx.stats().hits, hits_before);
}
#[test]
fn property_random_layout_correctness() {
let mut idx = index();
let widgets = vec![
(HitId::new(1), Rect::new(0, 0, 20, 10), 0u16),
(HitId::new(2), Rect::new(10, 5, 20, 10), 1),
(HitId::new(3), Rect::new(25, 0, 15, 15), 2),
];
for (id, rect, z) in &widgets {
idx.register(*id, *rect, HitRegion::Content, id.id() as u64, *z);
}
for x in 0..60 {
for y in 0..20 {
let indexed_result = idx.hit_test_readonly(x, y);
let mut best: Option<(HitId, u16)> = None;
for (id, rect, z) in &widgets {
if x >= rect.x
&& x < rect.x + rect.width
&& y >= rect.y
&& y < rect.y + rect.height
{
match best {
None => best = Some((*id, *z)),
Some((_, best_z)) if *z > best_z => best = Some((*id, *z)),
_ => {}
}
}
}
let expected_id = best.map(|(id, _)| id);
let indexed_id = indexed_result.map(|(id, _, _)| id);
assert_eq!(
indexed_id, expected_id,
"Mismatch at ({}, {}): indexed={:?}, expected={:?}",
x, y, indexed_id, expected_id
);
}
}
}
#[test]
fn out_of_bounds_returns_none() {
let mut idx = index();
idx.register_simple(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
0,
);
assert!(idx.hit_test(100, 100).is_none());
assert!(idx.hit_test(80, 0).is_none());
assert!(idx.hit_test(0, 24).is_none());
}
#[test]
fn zero_size_rect_ignored() {
let mut idx = index();
idx.register_simple(
HitId::new(1),
Rect::new(10, 10, 0, 0),
HitRegion::Content,
0,
);
assert!(idx.hit_test(10, 10).is_none());
}
#[test]
fn large_rect_spans_many_buckets() {
let mut idx = index();
idx.register_simple(
HitId::new(1),
Rect::new(0, 0, 80, 24),
HitRegion::Content,
0,
);
assert!(idx.hit_test(0, 0).is_some());
assert!(idx.hit_test(40, 12).is_some());
assert!(idx.hit_test(79, 23).is_some());
}
#[test]
fn update_nonexistent_returns_false() {
let mut idx = index();
let result = idx.update(HitId::new(999), Rect::new(0, 0, 10, 10));
assert!(!result);
}
#[test]
fn remove_nonexistent_returns_false() {
let mut idx = index();
let result = idx.remove(HitId::new(999));
assert!(!result);
}
#[test]
fn stats_hit_rate() {
let mut stats = CacheStats::default();
assert_eq!(stats.hit_rate(), 0.0);
stats.hits = 75;
stats.misses = 25;
assert!((stats.hit_rate() - 75.0).abs() < 0.01);
}
#[test]
fn config_defaults() {
let config = SpatialHitConfig::default();
assert_eq!(config.cell_size, 8);
assert_eq!(config.bucket_warn_threshold, 64);
assert!(!config.track_cache_stats);
}
#[test]
fn invalidate_region() {
let mut idx = index();
idx.register_simple(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
0,
);
let _ = idx.hit_test(5, 5);
assert!(idx.cache.valid);
idx.invalidate_region(Rect::new(0, 0, 10, 10));
assert!(!idx.cache.valid);
}
#[test]
fn invalidate_all() {
let mut idx = index();
idx.register_simple(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
0,
);
let _ = idx.hit_test(5, 5);
assert!(idx.cache.valid);
idx.invalidate_all();
assert!(!idx.cache.valid);
}
#[test]
fn three_overlapping_widgets_z_order() {
let mut idx = index();
idx.register(
HitId::new(1),
Rect::new(0, 0, 20, 20),
HitRegion::Content,
10,
0,
);
idx.register(
HitId::new(2),
Rect::new(5, 5, 15, 15),
HitRegion::Border,
20,
2,
);
idx.register(
HitId::new(3),
Rect::new(8, 8, 10, 10),
HitRegion::Button,
30,
1,
);
let result = idx.hit_test(10, 10);
assert_eq!(result, Some((HitId::new(2), HitRegion::Border, 20)));
}
#[test]
fn hit_test_readonly_matches_mutable() {
let mut idx = index();
idx.register_simple(
HitId::new(1),
Rect::new(5, 5, 10, 10),
HitRegion::Content,
0,
);
let mutable_result = idx.hit_test(8, 8);
let readonly_result = idx.hit_test_readonly(8, 8);
assert_eq!(mutable_result, readonly_result);
}
#[test]
fn single_pixel_widget() {
let mut idx = index();
idx.register_simple(HitId::new(1), Rect::new(5, 5, 1, 1), HitRegion::Button, 0);
assert!(idx.hit_test(5, 5).is_some());
assert!(idx.hit_test(6, 5).is_none());
assert!(idx.hit_test(5, 6).is_none());
}
#[test]
fn clear_on_empty_is_idempotent() {
let mut idx = index();
idx.clear();
assert!(idx.is_empty());
idx.clear();
assert!(idx.is_empty());
}
#[test]
fn register_remove_register_cycle() {
let mut idx = index();
idx.register_simple(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
0,
);
assert_eq!(idx.len(), 1);
idx.remove(HitId::new(1));
assert_eq!(idx.len(), 0);
idx.register_simple(HitId::new(1), Rect::new(20, 20, 5, 5), HitRegion::Border, 0);
assert_eq!(idx.len(), 1);
assert!(idx.hit_test(22, 22).is_some());
assert!(idx.hit_test(5, 5).is_none());
}
#[test]
fn invalidate_non_overlapping_region_preserves_cache() {
let mut idx = index();
idx.register_simple(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
0,
);
let _ = idx.hit_test(5, 5);
assert!(idx.cache.valid);
idx.invalidate_region(Rect::new(50, 50, 10, 10));
assert!(idx.cache.valid);
}
#[test]
fn hit_entry_contains() {
let entry = HitEntry::new(
HitId::new(1),
Rect::new(10, 10, 20, 20),
HitRegion::Content,
0,
0,
0,
);
assert!(entry.contains(15, 15));
assert!(entry.contains(10, 10));
assert!(!entry.contains(9, 10));
assert!(!entry.contains(30, 30));
}
#[test]
fn reset_stats_clears_counters() {
let mut idx = SpatialHitIndex::new(
80,
24,
SpatialHitConfig {
cell_size: 8,
bucket_warn_threshold: 64,
track_cache_stats: true,
},
);
idx.register_simple(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
0,
);
let _ = idx.hit_test(5, 5);
let _ = idx.hit_test(5, 5); let stats = idx.stats();
assert!(stats.hits > 0 || stats.misses > 0);
idx.reset_stats();
let stats = idx.stats();
assert_eq!(stats.hits, 0);
assert_eq!(stats.misses, 0);
}
#[test]
fn config_debug_clone() {
let config = SpatialHitConfig::default();
let dbg = format!("{:?}", config);
assert!(dbg.contains("SpatialHitConfig"), "Debug: {dbg}");
let cloned = config.clone();
assert_eq!(cloned.cell_size, 8);
}
#[test]
fn hit_entry_debug_clone_copy_eq() {
let entry = HitEntry::new(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
42,
5,
0,
);
let dbg = format!("{:?}", entry);
assert!(dbg.contains("HitEntry"), "Debug: {dbg}");
let copied = entry; assert_eq!(entry, copied);
let cloned: HitEntry = entry; assert_eq!(entry, cloned);
}
#[test]
fn hit_entry_ne() {
let a = HitEntry::new(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
0,
0,
0,
);
let b = HitEntry::new(
HitId::new(2),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
0,
0,
0,
);
assert_ne!(a, b);
}
#[test]
fn hit_entry_contains_zero_width() {
let entry = HitEntry::new(
HitId::new(1),
Rect::new(10, 10, 0, 5),
HitRegion::Content,
0,
0,
0,
);
assert!(!entry.contains(10, 10));
}
#[test]
fn hit_entry_contains_zero_height() {
let entry = HitEntry::new(
HitId::new(1),
Rect::new(10, 10, 5, 0),
HitRegion::Content,
0,
0,
0,
);
assert!(!entry.contains(10, 10));
}
#[test]
fn hit_entry_contains_at_saturating_boundary() {
let entry = HitEntry::new(
HitId::new(1),
Rect::new(u16::MAX - 5, u16::MAX - 5, 10, 10),
HitRegion::Content,
0,
0,
0,
);
assert!(entry.contains(u16::MAX - 5, u16::MAX - 5));
assert!(entry.contains(u16::MAX - 1, u16::MAX - 1));
assert!(!entry.contains(u16::MAX, u16::MAX));
}
#[test]
fn cache_stats_default() {
let stats = CacheStats::default();
assert_eq!(stats.hits, 0);
assert_eq!(stats.misses, 0);
assert_eq!(stats.rebuilds, 0);
assert_eq!(stats.hit_rate(), 0.0);
}
#[test]
fn cache_stats_debug_copy() {
let stats = CacheStats {
hits: 10,
misses: 5,
rebuilds: 1,
};
let dbg = format!("{:?}", stats);
assert!(dbg.contains("CacheStats"), "Debug: {dbg}");
let copy = stats; assert_eq!(copy.hits, stats.hits);
}
#[test]
fn cache_stats_100_percent_hit_rate() {
let stats = CacheStats {
hits: 100,
misses: 0,
rebuilds: 0,
};
assert!((stats.hit_rate() - 100.0).abs() < 0.01);
}
#[test]
fn cache_stats_0_percent_hit_rate() {
let stats = CacheStats {
hits: 0,
misses: 100,
rebuilds: 0,
};
assert!((stats.hit_rate()).abs() < 0.01);
}
#[test]
fn new_with_cell_size_zero_clamped_to_one() {
let config = SpatialHitConfig {
cell_size: 0,
..Default::default()
};
let idx = SpatialHitIndex::new(80, 24, config);
assert_eq!(idx.grid_width, 80);
assert_eq!(idx.grid_height, 24);
assert!(idx.is_empty());
}
#[test]
fn new_with_cell_size_one() {
let config = SpatialHitConfig {
cell_size: 1,
..Default::default()
};
let idx = SpatialHitIndex::new(10, 5, config);
assert_eq!(idx.grid_width, 10);
assert_eq!(idx.grid_height, 5);
}
#[test]
fn new_with_large_cell_size() {
let config = SpatialHitConfig {
cell_size: 100,
..Default::default()
};
let idx = SpatialHitIndex::new(80, 24, config);
assert_eq!(idx.grid_width, 1);
assert_eq!(idx.grid_height, 1);
}
#[test]
fn new_zero_dimensions() {
let idx = SpatialHitIndex::with_defaults(0, 0);
assert!(idx.is_empty());
assert!(idx.hit_test_readonly(0, 0).is_none());
}
#[test]
fn with_defaults_uses_default_config() {
let idx = SpatialHitIndex::with_defaults(80, 24);
assert_eq!(idx.config.cell_size, 8);
assert_eq!(idx.config.bucket_warn_threshold, 64);
assert!(!idx.config.track_cache_stats);
}
#[test]
fn index_debug_format() {
let idx = SpatialHitIndex::with_defaults(10, 10);
let dbg = format!("{:?}", idx);
assert!(dbg.contains("SpatialHitIndex"), "Debug: {dbg}");
}
#[test]
fn register_zero_width_rect_not_in_buckets() {
let mut idx = index();
idx.register_simple(HitId::new(1), Rect::new(5, 5, 0, 10), HitRegion::Content, 0);
assert_eq!(idx.len(), 1);
assert!(idx.hit_test(5, 5).is_none());
}
#[test]
fn register_zero_height_rect_not_in_buckets() {
let mut idx = index();
idx.register_simple(HitId::new(1), Rect::new(5, 5, 10, 0), HitRegion::Content, 0);
assert_eq!(idx.len(), 1);
assert!(idx.hit_test(5, 5).is_none());
}
#[test]
fn register_rect_extending_past_screen() {
let mut idx = index();
idx.register_simple(
HitId::new(1),
Rect::new(70, 20, 20, 10),
HitRegion::Content,
0,
);
assert!(idx.hit_test(75, 22).is_some());
assert!(idx.hit_test(85, 25).is_none());
}
#[test]
fn register_many_widgets() {
let mut idx = index();
for i in 0..100u32 {
let x = (i % 8) as u16 * 10;
let y = (i / 8) as u16 * 3;
idx.register_simple(
HitId::new(i + 1),
Rect::new(x, y, 5, 2),
HitRegion::Content,
i as u64,
);
}
assert_eq!(idx.len(), 100);
let result = idx.hit_test(2, 1);
assert!(result.is_some());
}
#[test]
fn register_simple_uses_z_order_zero() {
let mut idx = index();
idx.register_simple(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
0,
);
idx.register(
HitId::new(2),
Rect::new(0, 0, 10, 10),
HitRegion::Border,
0,
1,
);
let result = idx.hit_test(5, 5);
assert_eq!(result, Some((HitId::new(2), HitRegion::Border, 0)));
}
#[test]
fn update_to_zero_size_rect() {
let mut idx = index();
idx.register_simple(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
0,
);
assert!(idx.hit_test(5, 5).is_some());
idx.update(HitId::new(1), Rect::new(0, 0, 0, 0));
assert!(idx.hit_test(0, 0).is_none());
}
#[test]
fn update_shrinks_widget() {
let mut idx = index();
idx.register_simple(
HitId::new(1),
Rect::new(0, 0, 20, 20),
HitRegion::Content,
0,
);
assert!(idx.hit_test(15, 15).is_some());
idx.update(HitId::new(1), Rect::new(0, 0, 5, 5));
assert!(idx.hit_test(15, 15).is_none());
assert!(idx.hit_test(2, 2).is_some());
}
#[test]
fn remove_middle_entry_compacts() {
let mut idx = index();
idx.register_simple(HitId::new(1), Rect::new(0, 0, 5, 5), HitRegion::Content, 10);
idx.register_simple(
HitId::new(2),
Rect::new(10, 0, 5, 5),
HitRegion::Content,
20,
);
idx.register_simple(
HitId::new(3),
Rect::new(20, 0, 5, 5),
HitRegion::Content,
30,
);
assert_eq!(idx.len(), 3);
idx.remove(HitId::new(2));
assert_eq!(idx.len(), 2);
let r1 = idx.hit_test(2, 2);
assert_eq!(r1, Some((HitId::new(1), HitRegion::Content, 10)));
let r3 = idx.hit_test(22, 2);
assert_eq!(r3, Some((HitId::new(3), HitRegion::Content, 30)));
}
#[test]
fn double_remove_returns_false() {
let mut idx = index();
idx.register_simple(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
0,
);
assert!(idx.remove(HitId::new(1)));
assert!(!idx.remove(HitId::new(1)));
}
#[test]
fn hit_test_at_exact_screen_boundary() {
let mut idx = index(); idx.register_simple(
HitId::new(1),
Rect::new(70, 20, 10, 4),
HitRegion::Content,
0,
);
assert!(idx.hit_test(79, 23).is_some());
assert!(idx.hit_test(80, 23).is_none());
assert!(idx.hit_test(79, 24).is_none());
}
#[test]
fn hit_test_at_grid_cell_boundaries() {
let mut idx = index(); idx.register_simple(
HitId::new(1),
Rect::new(6, 6, 4, 4), HitRegion::Content,
0,
);
assert!(idx.hit_test(7, 7).is_some());
assert!(idx.hit_test(8, 8).is_some());
assert!(idx.hit_test(9, 9).is_some());
assert!(idx.hit_test(10, 10).is_none());
}
#[test]
fn hit_test_readonly_out_of_bounds() {
let idx = index();
assert!(idx.hit_test_readonly(80, 0).is_none());
assert!(idx.hit_test_readonly(0, 24).is_none());
assert!(idx.hit_test_readonly(u16::MAX, u16::MAX).is_none());
}
#[test]
fn hit_test_readonly_skips_removed() {
let mut idx = index();
idx.register_simple(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
0,
);
idx.register_simple(HitId::new(2), Rect::new(0, 0, 10, 10), HitRegion::Border, 1);
idx.remove(HitId::new(2));
let result = idx.hit_test_readonly(5, 5);
assert_eq!(result, Some((HitId::new(1), HitRegion::Content, 0)));
}
#[test]
fn cache_updates_on_different_positions() {
let mut idx = SpatialHitIndex::new(
80,
24,
SpatialHitConfig {
track_cache_stats: true,
..Default::default()
},
);
idx.register_simple(
HitId::new(1),
Rect::new(0, 0, 40, 12),
HitRegion::Content,
1,
);
idx.register_simple(
HitId::new(2),
Rect::new(40, 12, 40, 12),
HitRegion::Border,
2,
);
let r1 = idx.hit_test(5, 5);
assert_eq!(r1, Some((HitId::new(1), HitRegion::Content, 1)));
assert_eq!(idx.stats().misses, 1);
let r2 = idx.hit_test(50, 15);
assert_eq!(r2, Some((HitId::new(2), HitRegion::Border, 2)));
assert_eq!(idx.stats().misses, 2);
let _ = idx.hit_test(5, 5);
assert_eq!(idx.stats().misses, 3);
}
#[test]
fn cache_invalidated_by_invalidate_all_then_same_position() {
let mut idx = SpatialHitIndex::new(
80,
24,
SpatialHitConfig {
track_cache_stats: true,
..Default::default()
},
);
idx.register_simple(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
0,
);
let _ = idx.hit_test(5, 5);
assert_eq!(idx.stats().misses, 1);
assert_eq!(idx.stats().hits, 0);
idx.invalidate_all();
let _ = idx.hit_test(5, 5);
assert_eq!(idx.stats().misses, 2);
}
#[test]
fn cache_not_updated_by_readonly() {
let mut idx = SpatialHitIndex::new(
80,
24,
SpatialHitConfig {
track_cache_stats: true,
..Default::default()
},
);
idx.register_simple(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
0,
);
let _ = idx.hit_test_readonly(5, 5);
assert_eq!(idx.stats().hits, 0);
assert_eq!(idx.stats().misses, 0);
let _ = idx.hit_test(5, 5);
assert_eq!(idx.stats().misses, 1);
}
#[test]
fn invalidate_region_zero_size() {
let mut idx = index();
idx.register_simple(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
0,
);
let _ = idx.hit_test(5, 5);
assert!(idx.cache.valid);
idx.invalidate_region(Rect::new(5, 5, 0, 0));
assert!(idx.cache.valid);
}
#[test]
fn invalidate_region_outside_screen() {
let mut idx = index();
idx.register_simple(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
0,
);
let _ = idx.hit_test(5, 5);
assert!(idx.cache.valid);
idx.invalidate_region(Rect::new(100, 100, 10, 10));
assert!(idx.cache.valid);
}
#[test]
fn rebuild_counted_in_stats() {
let mut idx = SpatialHitIndex::new(
80,
24,
SpatialHitConfig {
track_cache_stats: true,
..Default::default()
},
);
idx.register_simple(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
0,
);
assert_eq!(idx.stats().rebuilds, 0);
idx.update(HitId::new(1), Rect::new(10, 10, 5, 5));
assert_eq!(idx.stats().rebuilds, 1);
idx.remove(HitId::new(1));
assert_eq!(idx.stats().rebuilds, 2);
}
#[test]
fn register_hit_update_hit_remove_clear() {
let mut idx = index();
idx.register_simple(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
0,
);
assert_eq!(idx.len(), 1);
assert!(idx.hit_test(5, 5).is_some());
idx.update(HitId::new(1), Rect::new(20, 20, 10, 10));
assert!(idx.hit_test(5, 5).is_none());
assert!(idx.hit_test(25, 22).is_some());
idx.remove(HitId::new(1));
assert!(idx.is_empty());
assert!(idx.hit_test(25, 22).is_none());
idx.register_simple(HitId::new(2), Rect::new(0, 0, 5, 5), HitRegion::Button, 99);
assert_eq!(idx.len(), 1);
let r = idx.hit_test(2, 2);
assert_eq!(r, Some((HitId::new(2), HitRegion::Button, 99)));
idx.clear();
assert!(idx.is_empty());
assert!(idx.hit_test(2, 2).is_none());
}
#[test]
fn z_order_tie_broken_by_registration_order() {
let mut idx = index();
idx.register(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
10,
5,
);
idx.register(
HitId::new(2),
Rect::new(0, 0, 10, 10),
HitRegion::Border,
20,
5,
);
idx.register(
HitId::new(3),
Rect::new(0, 0, 10, 10),
HitRegion::Button,
30,
5,
);
let result = idx.hit_test(5, 5);
assert_eq!(result, Some((HitId::new(3), HitRegion::Button, 30)));
}
#[test]
fn z_order_higher_z_beats_later_registration() {
let mut idx = index();
idx.register(
HitId::new(1),
Rect::new(0, 0, 10, 10),
HitRegion::Content,
10,
10,
);
idx.register(
HitId::new(2),
Rect::new(0, 0, 10, 10),
HitRegion::Border,
20,
5,
);
let result = idx.hit_test(5, 5);
assert_eq!(result, Some((HitId::new(1), HitRegion::Content, 10)));
}
#[test]
fn all_hit_region_variants_returned() {
let mut idx = index();
let regions = [
(1, HitRegion::Content),
(2, HitRegion::Border),
(3, HitRegion::Scrollbar),
(4, HitRegion::Handle),
(5, HitRegion::Button),
(6, HitRegion::Link),
(7, HitRegion::Custom(42)),
];
for (i, (id, region)) in regions.iter().enumerate() {
let x = (i as u16) * 10;
idx.register_simple(HitId::new(*id), Rect::new(x, 0, 5, 5), *region, *id as u64);
}
for (i, (id, region)) in regions.iter().enumerate() {
let x = (i as u16) * 10 + 2;
let result = idx.hit_test(x, 2);
assert_eq!(
result,
Some((HitId::new(*id), *region, *id as u64)),
"Failed for region {:?}",
region
);
}
}
#[test]
fn single_cell_screen() {
let mut idx = SpatialHitIndex::with_defaults(1, 1);
idx.register_simple(HitId::new(1), Rect::new(0, 0, 1, 1), HitRegion::Content, 0);
assert!(idx.hit_test(0, 0).is_some());
assert!(idx.hit_test(1, 0).is_none());
}
#[test]
fn hit_test_readonly_equivalent_to_mutable_for_grid() {
let mut idx = index();
idx.register(
HitId::new(1),
Rect::new(0, 0, 40, 12),
HitRegion::Content,
1,
0,
);
idx.register(
HitId::new(2),
Rect::new(30, 8, 20, 10),
HitRegion::Border,
2,
1,
);
idx.register(
HitId::new(3),
Rect::new(60, 0, 20, 24),
HitRegion::Button,
3,
2,
);
for x in (0..80).step_by(5) {
for y in (0..24).step_by(3) {
let ro = idx.hit_test_readonly(x, y);
let expected_id = ro.map(|(id, _, _)| id);
let ro2 = idx.hit_test_readonly(x, y);
assert_eq!(ro, ro2, "Readonly inconsistency at ({x}, {y})");
let mut_result = idx.hit_test(x, y);
let mut_id = mut_result.map(|(id, _, _)| id);
assert_eq!(
expected_id, mut_id,
"Mutable/readonly mismatch at ({x}, {y})"
);
}
}
}
}