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}