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}