use crate::{sanitize::sanitize_str, Insets, Rect, Vec2};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum CursorKind {
#[default]
Default,
Pointer,
Text,
Grab,
ResizeHorizontal,
ResizeVertical,
ResizeDiagonalDown,
ResizeDiagonalUp,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum ResizeHandle {
Left,
Right,
Top,
Bottom,
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
impl ResizeHandle {
pub fn cursor(self) -> CursorKind {
match self {
Self::Left | Self::Right => CursorKind::ResizeHorizontal,
Self::Top | Self::Bottom => CursorKind::ResizeVertical,
Self::TopLeft | Self::BottomRight => CursorKind::ResizeDiagonalDown,
Self::TopRight | Self::BottomLeft => CursorKind::ResizeDiagonalUp,
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct HitRegion {
pub id: String,
pub rect: Rect,
pub z_index: i16,
pub disabled: bool,
pub cursor: CursorKind,
}
impl HitRegion {
pub fn new(id: impl Into<String>, rect: Rect) -> Self {
Self {
id: sanitize_str(&id.into(), 120),
rect,
z_index: 0,
disabled: false,
cursor: CursorKind::Default,
}
}
pub fn with_z_index(mut self, z_index: i16) -> Self {
self.z_index = z_index;
self
}
pub fn with_cursor(mut self, cursor: CursorKind) -> Self {
self.cursor = cursor;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn sanitized_id(&self) -> String {
sanitize_str(&self.id, 120)
}
pub fn contains(&self, point: Vec2) -> bool {
!self.disabled
&& self.rect.is_finite()
&& !self.rect.is_empty()
&& self.rect.contains(point.x, point.y)
}
}
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct HitMap {
pub regions: Vec<HitRegion>,
}
impl HitMap {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, region: HitRegion) {
let mut region = region;
region.id = sanitize_str(®ion.id, 120);
self.regions.push(region);
}
pub fn hit_test(&self, point: Vec2) -> Option<&HitRegion> {
self.regions
.iter()
.enumerate()
.filter(|(_, region)| region.contains(point))
.max_by_key(|(index, region)| (region.z_index, *index))
.map(|(_, region)| region)
}
pub fn all_at(&self, point: Vec2) -> Vec<&HitRegion> {
let mut hits = self
.regions
.iter()
.enumerate()
.filter(|(_, region)| region.contains(point))
.collect::<Vec<_>>();
hits.sort_by_key(|(index, region)| (region.z_index, *index));
hits.reverse();
hits.into_iter().map(|(_, region)| region).collect()
}
}
pub fn local_point(rect: Rect, point: Vec2) -> Option<Vec2> {
(rect.is_finite() && !rect.is_empty() && rect.contains(point.x, point.y))
.then(|| Vec2::new(point.x - rect.x, point.y - rect.y))
}
pub fn hit_resize_handle(rect: Rect, point: Vec2, threshold: f32) -> Option<ResizeHandle> {
if !rect.is_finite() || rect.is_empty() || !point.x.is_finite() || !point.y.is_finite() {
return None;
}
let threshold = if threshold.is_finite() && threshold >= 0.0 {
threshold
} else {
return None;
};
let outer = rect.inset(Insets {
top: -threshold,
right: -threshold,
bottom: -threshold,
left: -threshold,
});
if !outer.contains(point.x, point.y) {
return None;
}
let near_left = (point.x - rect.x).abs() <= threshold;
let near_right = (point.x - rect.right()).abs() <= threshold;
let near_top = (point.y - rect.y).abs() <= threshold;
let near_bottom = (point.y - rect.bottom()).abs() <= threshold;
match (near_left, near_right, near_top, near_bottom) {
(true, _, true, _) => Some(ResizeHandle::TopLeft),
(_, true, true, _) => Some(ResizeHandle::TopRight),
(true, _, _, true) => Some(ResizeHandle::BottomLeft),
(_, true, _, true) => Some(ResizeHandle::BottomRight),
(true, _, _, _) => Some(ResizeHandle::Left),
(_, true, _, _) => Some(ResizeHandle::Right),
(_, _, true, _) => Some(ResizeHandle::Top),
(_, _, _, true) => Some(ResizeHandle::Bottom),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hit_map_returns_topmost_enabled_region() {
let mut map = HitMap::new();
map.push(HitRegion::new("base", Rect::new(0.0, 0.0, 100.0, 100.0)));
map.push(HitRegion::new("top", Rect::new(20.0, 20.0, 40.0, 40.0)).with_z_index(10));
assert_eq!(map.hit_test(Vec2::new(24.0, 24.0)).unwrap().id, "top");
}
#[test]
fn hit_region_new_sanitizes_identifier() {
let region = HitRegion::new("pane\u{200f}\x1b[31m", Rect::ZERO);
assert_eq!(region.id, "pane");
}
#[test]
fn hit_map_push_sanitizes_public_region_identifier() {
let mut map = HitMap::new();
map.push(HitRegion {
id: "pane\u{202e}\x1b[31m".to_string(),
rect: Rect::new(0.0, 0.0, 10.0, 10.0),
z_index: 0,
disabled: false,
cursor: CursorKind::Default,
});
assert_eq!(map.hit_test(Vec2::new(1.0, 1.0)).unwrap().id, "pane");
assert_eq!(map.regions[0].sanitized_id(), "pane");
}
#[test]
fn equal_z_hit_order_prefers_later_regions() {
let mut map = HitMap::new();
map.push(HitRegion::new("first", Rect::new(0.0, 0.0, 20.0, 20.0)));
map.push(HitRegion::new("second", Rect::new(0.0, 0.0, 20.0, 20.0)));
assert_eq!(map.hit_test(Vec2::new(1.0, 1.0)).unwrap().id, "second");
assert_eq!(map.all_at(Vec2::new(1.0, 1.0))[0].id, "second");
}
#[test]
fn malformed_hit_rects_do_not_capture_pointer_events() {
let rect = Rect::new(0.0, 0.0, f32::INFINITY, 20.0);
let region = HitRegion::new("bad", rect).with_z_index(i16::MAX);
assert!(!region.contains(Vec2::new(1.0, 1.0)));
assert_eq!(local_point(rect, Vec2::new(1.0, 1.0)), None);
assert_eq!(hit_resize_handle(rect, Vec2::new(1.0, 1.0), 4.0), None);
}
#[test]
fn resize_handle_ignores_non_finite_or_negative_thresholds() {
let rect = Rect::new(10.0, 20.0, 100.0, 80.0);
assert_eq!(
hit_resize_handle(rect, Vec2::new(11.0, 21.0), f32::NAN),
None
);
assert_eq!(hit_resize_handle(rect, Vec2::new(11.0, 21.0), -4.0), None);
assert_eq!(hit_resize_handle(rect, Vec2::new(10.0, 20.0), -4.0), None);
}
#[test]
fn resize_handle_detects_corners_and_edges() {
let rect = Rect::new(10.0, 20.0, 100.0, 80.0);
assert_eq!(
hit_resize_handle(rect, Vec2::new(11.0, 21.0), 4.0),
Some(ResizeHandle::TopLeft)
);
assert_eq!(
hit_resize_handle(rect, Vec2::new(110.0, 55.0), 4.0),
Some(ResizeHandle::Right)
);
assert_eq!(hit_resize_handle(rect, Vec2::new(60.0, 60.0), 4.0), None);
}
}