Skip to main content

concinnity_core/components/
hit_region.rs

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