1use crate::camera_projection::CameraProjection;
23use crate::picking::{HitCategory, HitProvenance, PickHit};
24use rustial_math::{GeoCoord, TileId};
25
26#[derive(Debug, Clone, Copy, PartialEq, Default)]
28pub struct ScreenPoint {
29 pub x: f64,
31 pub y: f64,
33}
34
35impl ScreenPoint {
36 pub const fn new(x: f64, y: f64) -> Self {
38 Self { x, y }
39 }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
44pub enum PointerKind {
45 #[default]
47 Mouse,
48 Touch,
50 Pen,
52 Unknown,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58pub enum InteractionButton {
59 Primary,
61 Secondary,
63 Auxiliary,
65 Other(u16),
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
71pub struct InteractionModifiers {
72 pub shift: bool,
74 pub ctrl: bool,
76 pub alt: bool,
78 pub meta: bool,
80}
81
82impl InteractionModifiers {
83 pub const fn new(shift: bool, ctrl: bool, alt: bool, meta: bool) -> Self {
85 Self {
86 shift,
87 ctrl,
88 alt,
89 meta,
90 }
91 }
92
93 pub const fn any(self) -> bool {
95 self.shift || self.ctrl || self.alt || self.meta
96 }
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
101pub enum InteractionEventKind {
102 MouseEnter,
104 MouseLeave,
106 MouseMove,
108 MouseOver,
110 MouseOut,
112 MouseDown,
114 MouseUp,
116 Click,
118 DoubleClick,
120 ContextMenu,
122 TouchStart,
124 TouchMove,
126 TouchEnd,
128 TouchCancel,
130}
131
132impl InteractionEventKind {
133 pub const fn as_str(self) -> &'static str {
135 match self {
136 Self::MouseEnter => "mouseenter",
137 Self::MouseLeave => "mouseleave",
138 Self::MouseMove => "mousemove",
139 Self::MouseOver => "mouseover",
140 Self::MouseOut => "mouseout",
141 Self::MouseDown => "mousedown",
142 Self::MouseUp => "mouseup",
143 Self::Click => "click",
144 Self::DoubleClick => "dblclick",
145 Self::ContextMenu => "contextmenu",
146 Self::TouchStart => "touchstart",
147 Self::TouchMove => "touchmove",
148 Self::TouchEnd => "touchend",
149 Self::TouchCancel => "touchcancel",
150 }
151 }
152
153 pub const fn canonical(self) -> Self {
158 match self {
159 Self::MouseOver => Self::MouseEnter,
160 Self::MouseOut => Self::MouseLeave,
161 other => other,
162 }
163 }
164
165 pub const fn is_hover_event(self) -> bool {
167 matches!(
168 self,
169 Self::MouseEnter
170 | Self::MouseLeave
171 | Self::MouseMove
172 | Self::MouseOver
173 | Self::MouseOut
174 )
175 }
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
180pub enum InteractionTargetKind {
181 Terrain,
183 Feature,
185 Symbol,
187 Model,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Hash)]
193pub struct InteractionTarget {
194 pub kind: InteractionTargetKind,
196 pub provenance: HitProvenance,
198 pub layer_id: Option<String>,
200 pub source_id: Option<String>,
202 pub source_layer: Option<String>,
204 pub source_tile: Option<TileId>,
206 pub feature_id: Option<String>,
208 pub feature_index: Option<usize>,
210 pub from_symbol: bool,
212}
213
214impl InteractionTarget {
215 pub fn from_pick_hit(hit: &PickHit) -> Self {
217 Self {
218 kind: InteractionTargetKind::from_hit_category(hit.category),
219 provenance: hit.provenance,
220 layer_id: hit.layer_id.clone(),
221 source_id: hit.source_id.clone(),
222 source_layer: hit.source_layer.clone(),
223 source_tile: hit.source_tile,
224 feature_id: hit.feature_id.clone(),
225 feature_index: hit.feature_index,
226 from_symbol: hit.from_symbol,
227 }
228 }
229
230 pub fn is_feature_backed(&self) -> bool {
232 self.source_id.is_some() && self.feature_id.is_some()
233 }
234}
235
236impl InteractionTargetKind {
237 pub const fn from_hit_category(category: HitCategory) -> Self {
239 match category {
240 HitCategory::Terrain => Self::Terrain,
241 HitCategory::Feature => Self::Feature,
242 HitCategory::Symbol => Self::Symbol,
243 HitCategory::Model => Self::Model,
244 }
245 }
246}
247
248#[derive(Debug, Clone)]
250pub struct InteractionEvent {
251 pub kind: InteractionEventKind,
253 pub pointer_kind: PointerKind,
255 pub screen_point: ScreenPoint,
257 pub query_coord: Option<GeoCoord>,
259 pub projection: Option<CameraProjection>,
261 pub button: Option<InteractionButton>,
263 pub modifiers: InteractionModifiers,
265 pub target: Option<InteractionTarget>,
267 pub related_target: Option<InteractionTarget>,
269 pub hit: Option<PickHit>,
271}
272
273impl InteractionEvent {
274 pub fn new(
276 kind: InteractionEventKind,
277 pointer_kind: PointerKind,
278 screen_point: ScreenPoint,
279 ) -> Self {
280 Self {
281 kind,
282 pointer_kind,
283 screen_point,
284 query_coord: None,
285 projection: None,
286 button: None,
287 modifiers: InteractionModifiers::default(),
288 target: None,
289 related_target: None,
290 hit: None,
291 }
292 }
293
294 pub fn with_query_coord(mut self, query_coord: GeoCoord) -> Self {
296 self.query_coord = Some(query_coord);
297 self
298 }
299
300 pub fn with_projection(mut self, projection: CameraProjection) -> Self {
302 self.projection = Some(projection);
303 self
304 }
305
306 pub fn with_button(mut self, button: InteractionButton) -> Self {
308 self.button = Some(button);
309 self
310 }
311
312 pub fn with_modifiers(mut self, modifiers: InteractionModifiers) -> Self {
314 self.modifiers = modifiers;
315 self
316 }
317
318 pub fn with_hit(mut self, hit: PickHit) -> Self {
320 self.target = Some(InteractionTarget::from_pick_hit(&hit));
321 self.hit = Some(hit);
322 self
323 }
324
325 pub fn with_related_target(mut self, related_target: InteractionTarget) -> Self {
327 self.related_target = Some(related_target);
328 self
329 }
330
331 pub fn has_target(&self) -> bool {
333 self.target.is_some()
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use crate::picking::PickHit;
341 use std::collections::HashMap;
342
343 #[test]
344 fn mouseover_and_mouseout_canonicalize_to_enter_leave() {
345 assert_eq!(
346 InteractionEventKind::MouseOver.canonical(),
347 InteractionEventKind::MouseEnter
348 );
349 assert_eq!(
350 InteractionEventKind::MouseOut.canonical(),
351 InteractionEventKind::MouseLeave
352 );
353 }
354
355 #[test]
356 fn interaction_target_kind_tracks_pick_hit_category() {
357 let hit = PickHit {
358 category: HitCategory::Symbol,
359 provenance: HitProvenance::GeometricApproximation,
360 layer_id: Some("places".into()),
361 source_id: Some("composite".into()),
362 source_layer: Some("place_label".into()),
363 source_tile: None,
364 feature_id: Some("42".into()),
365 feature_index: Some(3),
366 geometry: None,
367 properties: HashMap::new(),
368 state: HashMap::new(),
369 distance_meters: 0.0,
370 hit_coord: None,
371 layer_priority: 0,
372 from_symbol: true,
373 };
374
375 let target = InteractionTarget::from_pick_hit(&hit);
376 assert_eq!(target.kind, InteractionTargetKind::Symbol);
377 assert!(target.is_feature_backed());
378 assert!(target.from_symbol);
379 }
380
381 #[test]
382 fn interaction_event_with_hit_populates_target() {
383 let hit = PickHit::terrain_surface(GeoCoord::from_lat_lon(10.0, 20.0), Some(25.0));
384 let event = InteractionEvent::new(
385 InteractionEventKind::MouseMove,
386 PointerKind::Mouse,
387 ScreenPoint::new(10.0, 20.0),
388 )
389 .with_hit(hit);
390
391 assert!(event.has_target());
392 assert_eq!(
393 event.target.as_ref().map(|target| target.kind),
394 Some(InteractionTargetKind::Terrain)
395 );
396 assert_eq!(event.kind.as_str(), "mousemove");
397 }
398
399 #[test]
400 fn modifiers_any_detects_active_modifier() {
401 assert!(!InteractionModifiers::default().any());
402 assert!(InteractionModifiers::new(false, true, false, false).any());
403 }
404}