Skip to main content

concinnity_asset/
hit_region.rs

1// Screen-space hit-region schema.
2
3use crate::{AssetId, SpriteFit, de_opt_asset_ref};
4use alloc::string::String;
5
6/// A responsive invisible rectangular region in screen space.
7///
8/// When clicked, fires an `action`. When hovered, it optionally restyles a
9/// referenced [TextLabel](#textlabel) (colour and/or scale).
10///
11/// The cursor must be free (not captured for camera control) for events to fire.
12///
13/// ```rust
14/// # use concinnity_asset::HitRegion;
15/// HitRegion {
16///     x: 430.0,
17///     y: 330.0,
18///     width: 220.0,
19///     height: 40.0,
20///     hover_color: Some([1.0, 0.85, 0.3]),
21///     hover_scale: Some(1.08),
22///     ..Default::default()
23/// };
24/// ```
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26#[serde(default)]
27pub struct HitRegion {
28    /// Left edge of the region in window pixels.
29    pub x: f32,
30    /// Top edge of the region in window pixels.
31    pub y: f32,
32    /// Width of the region in window pixels.
33    pub width: f32,
34    /// Height of the region in window pixels.
35    pub height: f32,
36    /// A [TextLabel](#textlabel) to style on hover. `None` = no label effect.
37    #[serde(deserialize_with = "de_opt_asset_ref")]
38    pub label: Option<AssetId>,
39    /// RGB colour applied to the label while hovered. `None` = no change.
40    pub hover_color: Option<[f32; 3]>,
41    /// Scale applied to the label while hovered. None = no change.
42    pub hover_scale: Option<f32>,
43    /// Action to fire on click. Recognised forms:
44    /// `"scene:<name>"`, `"quit"`, `"screen:show:<name>"`, `"screen:hide"`,
45    /// `"screen:toggle:<name>"`.
46    pub action: String,
47    /// The [Sprite](#sprite) a [Slider](#slider) drag region moves along its
48    /// track. `None` for ordinary regions. Set automatically when a `Slider`
49    /// expands; you don't set this directly.
50    #[serde(default, deserialize_with = "de_opt_asset_ref")]
51    pub drag_handle: Option<AssetId>,
52    /// [Screen](#screen) this region belongs to. Resolved automatically from the
53    /// naming convention (a region named `<screen>_*` belongs to screen
54    /// `<screen>`); you don't set this directly. While a screen is active,
55    /// only the top capturing screen's regions fire; with no screen active,
56    /// only screen-less regions fire.
57    #[serde(default, deserialize_with = "de_opt_asset_ref")]
58    pub screen: Option<AssetId>,
59    /// Whether this region is inert. A disabled region never hovers or fires.
60    /// Set by the engine at runtime (e.g. a settings row whose feature the GPU
61    /// cannot provide is disabled and grayed out); you don't set this directly.
62    #[serde(default)]
63    pub disabled: bool,
64    /// When set, this region tracks its referenced [`label`](#hitregion): it
65    /// follows the label's vertical position (so a menu the engine lays out at
66    /// runtime keeps its buttons clickable) and is inert while the label's text
67    /// is empty (so a hidden menu entry does not catch clicks). Requires
68    /// `label`.
69    #[serde(default)]
70    pub follow_label: bool,
71    /// How a screen-owned region maps from the reference canvas to the window
72    /// when their aspect ratios differ (matches [Sprite](#sprite)'s `fit`).
73    /// `Bottom` keeps a region aligned with bottom-anchored furniture it
74    /// covers. A region spanning the whole reference canvas always covers the
75    /// full window regardless of `fit`.
76    #[serde(default)]
77    pub fit: SpriteFit,
78}
79
80impl Default for HitRegion {
81    fn default() -> Self {
82        Self {
83            x: 0.0,
84            y: 0.0,
85            width: 100.0,
86            height: 40.0,
87            label: None,
88            hover_color: None,
89            hover_scale: None,
90            action: String::new(),
91            drag_handle: None,
92            screen: None,
93            disabled: false,
94            follow_label: false,
95            fit: SpriteFit::Fit,
96        }
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn a_blank_region_is_an_enabled_button_sized_rectangle() {
106        let h = HitRegion::default();
107        assert_eq!((h.x, h.y), (0.0, 0.0));
108        assert_eq!((h.width, h.height), (100.0, 40.0));
109        assert!(!h.disabled);
110        assert!(!h.follow_label);
111        assert_eq!(h.fit, SpriteFit::Fit);
112        assert!(h.action.is_empty());
113        // Hover styling is opt-in: unset means "do not restyle on hover".
114        assert_eq!(h.hover_color, None);
115        assert_eq!(h.hover_scale, None);
116        assert!(h.label.is_none());
117        assert!(h.drag_handle.is_none());
118        assert!(h.screen.is_none());
119    }
120
121    #[test]
122    fn an_authored_region_parses_and_round_trips_through_postcard() {
123        crate::test_support::install_resolvers();
124        let h: HitRegion = serde_json::from_str(
125            r#"{"x":10,"y":20,"width":200,"height":48,"label":"play_label","action":"start",
126                "hover_color":[1,0.85,0.3],"hover_scale":1.1,"drag_handle":"grip",
127                "screen":"menu","disabled":true,"follow_label":true,"fit":"cover"}"#,
128        )
129        .unwrap();
130        assert_eq!(h.label, Some(AssetId(10)));
131        assert_eq!(h.drag_handle, Some(AssetId(4)));
132        assert_eq!(h.screen, Some(AssetId(4)));
133        assert_eq!(h.action, "start");
134        assert_eq!(h.hover_scale, Some(1.1));
135        assert_eq!(h.fit, SpriteFit::Cover);
136        assert!(h.disabled);
137        assert!(h.follow_label);
138
139        let bytes = postcard::to_allocvec(&h).unwrap();
140        let back: HitRegion = postcard::from_bytes(&bytes).unwrap();
141        assert_eq!(back.hover_color, Some([1.0, 0.85, 0.3]));
142        assert_eq!((back.width, back.height), (200.0, 48.0));
143        assert_eq!(back.label, Some(AssetId(10)));
144        assert_eq!(back.fit, SpriteFit::Cover);
145    }
146}