rae 0.1.13

Renderer-neutral Rust design catalog for desktop widgets, layouts, sanitized output, and GLSL shader primitives.
Documentation
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,
        }
    }
}

/// Hit-testable rectangular region. Constructors and `HitMap::push` sanitize identifiers.
#[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(&region.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);
    }
}