Skip to main content

rustial_engine/
interaction.rs

1//! Canonical interaction event and target types for Rustial.
2//!
3//! This module defines the stable engine-owned interaction surface that later
4//! runtime systems and renderer integrations build on top of. The types here do
5//! not perform hit-testing by themselves; instead they normalize user-facing
6//! interaction concepts around existing pick/query results such as [`PickHit`].
7//!
8//! # Scope of the `v1.0` interaction types
9//!
10//! The types in this module provide:
11//!
12//! - event kinds (`click`, `mouseenter`, `mouseleave`, ...)
13//! - pointer metadata (mouse, touch, pen)
14//! - keyboard-modifier snapshots
15//! - stable target identity derived from [`PickHit`]
16//! - event payloads that carry screen position, resolved geo position, and the
17//!   top hit at dispatch time
18//!
19//! Later roadmap phases can add an interaction manager and subscription model on
20//! top of these types without changing their core semantics.
21
22use crate::camera_projection::CameraProjection;
23use crate::picking::{HitCategory, HitProvenance, PickHit};
24use rustial_math::{GeoCoord, TileId};
25
26/// Logical screen-space position in pixels relative to the viewport origin.
27#[derive(Debug, Clone, Copy, PartialEq, Default)]
28pub struct ScreenPoint {
29    /// X coordinate in logical pixels (`0 = left`).
30    pub x: f64,
31    /// Y coordinate in logical pixels (`0 = top`).
32    pub y: f64,
33}
34
35impl ScreenPoint {
36    /// Create a new screen-space point.
37    pub const fn new(x: f64, y: f64) -> Self {
38        Self { x, y }
39    }
40}
41
42/// Input device class that produced an interaction.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
44pub enum PointerKind {
45    /// Mouse or trackpad cursor interaction.
46    #[default]
47    Mouse,
48    /// Touch interaction.
49    Touch,
50    /// Stylus or tablet pen interaction.
51    Pen,
52    /// Unknown or host-defined pointer class.
53    Unknown,
54}
55
56/// Pointer button snapshot for button-aware interaction events.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58pub enum InteractionButton {
59    /// Primary / left button.
60    Primary,
61    /// Secondary / right button.
62    Secondary,
63    /// Auxiliary / middle button.
64    Auxiliary,
65    /// Any other host-defined button index.
66    Other(u16),
67}
68
69/// Keyboard-modifier snapshot carried with an interaction event.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
71pub struct InteractionModifiers {
72    /// Whether shift was pressed.
73    pub shift: bool,
74    /// Whether control was pressed.
75    pub ctrl: bool,
76    /// Whether alt was pressed.
77    pub alt: bool,
78    /// Whether meta / command / windows key was pressed.
79    pub meta: bool,
80}
81
82impl InteractionModifiers {
83    /// Create a new modifier snapshot.
84    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    /// Whether any modifier key is active.
94    pub const fn any(self) -> bool {
95        self.shift || self.ctrl || self.alt || self.meta
96    }
97}
98
99/// Canonical interaction event kind.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
101pub enum InteractionEventKind {
102    /// Cursor entered a target.
103    MouseEnter,
104    /// Cursor left a target.
105    MouseLeave,
106    /// Cursor moved within the viewport.
107    MouseMove,
108    /// Cursor moved over a target.
109    MouseOver,
110    /// Cursor moved out of a target.
111    MouseOut,
112    /// Pointer or mouse button pressed.
113    MouseDown,
114    /// Pointer or mouse button released.
115    MouseUp,
116    /// Click or tap activation.
117    Click,
118    /// Double-click activation.
119    DoubleClick,
120    /// Secondary-click / context-menu activation.
121    ContextMenu,
122    /// Touch sequence started.
123    TouchStart,
124    /// Touch sequence moved.
125    TouchMove,
126    /// Touch sequence ended.
127    TouchEnd,
128    /// Touch sequence was canceled.
129    TouchCancel,
130}
131
132impl InteractionEventKind {
133    /// Return the canonical string name used by web-map interaction APIs.
134    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    /// Return the canonical internal event kind used for hover transitions.
154    ///
155    /// `mouseover` and `mouseout` map to the stricter `mouseenter` and
156    /// `mouseleave` forms so later runtime code can normalize alias handling.
157    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    /// Whether this event kind represents hover-related pointer movement.
166    pub const fn is_hover_event(self) -> bool {
167        matches!(
168            self,
169            Self::MouseEnter | Self::MouseLeave | Self::MouseMove | Self::MouseOver | Self::MouseOut
170        )
171    }
172}
173
174/// Broad interaction target class.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
176pub enum InteractionTargetKind {
177    /// Terrain surface target.
178    Terrain,
179    /// Vector feature target.
180    Feature,
181    /// Placed symbol target.
182    Symbol,
183    /// 3D model target.
184    Model,
185}
186
187/// Stable identity and provenance for an interaction target.
188#[derive(Debug, Clone, PartialEq, Eq, Hash)]
189pub struct InteractionTarget {
190    /// Broad target class.
191    pub kind: InteractionTargetKind,
192    /// How the hit was resolved.
193    pub provenance: HitProvenance,
194    /// Style layer id or runtime layer name that produced the target.
195    pub layer_id: Option<String>,
196    /// Style source id, when known.
197    pub source_id: Option<String>,
198    /// Style source-layer id, when known.
199    pub source_layer: Option<String>,
200    /// Tile that supplied the feature, when known.
201    pub source_tile: Option<TileId>,
202    /// Stable feature id within the source.
203    pub feature_id: Option<String>,
204    /// Source-local feature index.
205    pub feature_index: Option<usize>,
206    /// Whether the hit came from a placed symbol collision box.
207    pub from_symbol: bool,
208}
209
210impl InteractionTarget {
211    /// Build an interaction target identity from a pick hit.
212    pub fn from_pick_hit(hit: &PickHit) -> Self {
213        Self {
214            kind: InteractionTargetKind::from_hit_category(hit.category),
215            provenance: hit.provenance,
216            layer_id: hit.layer_id.clone(),
217            source_id: hit.source_id.clone(),
218            source_layer: hit.source_layer.clone(),
219            source_tile: hit.source_tile,
220            feature_id: hit.feature_id.clone(),
221            feature_index: hit.feature_index,
222            from_symbol: hit.from_symbol,
223        }
224    }
225
226    /// Whether the target resolves to a source feature identity.
227    pub fn is_feature_backed(&self) -> bool {
228        self.source_id.is_some() && self.feature_id.is_some()
229    }
230}
231
232impl InteractionTargetKind {
233    /// Derive an interaction target kind from a pick-hit category.
234    pub const fn from_hit_category(category: HitCategory) -> Self {
235        match category {
236            HitCategory::Terrain => Self::Terrain,
237            HitCategory::Feature => Self::Feature,
238            HitCategory::Symbol => Self::Symbol,
239            HitCategory::Model => Self::Model,
240        }
241    }
242}
243
244/// Canonical interaction event payload.
245#[derive(Debug, Clone)]
246pub struct InteractionEvent {
247    /// Event kind.
248    pub kind: InteractionEventKind,
249    /// Input device class that produced the event.
250    pub pointer_kind: PointerKind,
251    /// Logical screen-space event location.
252    pub screen_point: ScreenPoint,
253    /// Resolved geographic coordinate, when available.
254    pub query_coord: Option<GeoCoord>,
255    /// Camera projection active at dispatch time.
256    pub projection: Option<CameraProjection>,
257    /// Pressed button for button-aware events.
258    pub button: Option<InteractionButton>,
259    /// Keyboard-modifier snapshot at dispatch time.
260    pub modifiers: InteractionModifiers,
261    /// Current target of the interaction, when any.
262    pub target: Option<InteractionTarget>,
263    /// Related target for enter/leave-style transitions, when any.
264    pub related_target: Option<InteractionTarget>,
265    /// Top-priority hit associated with the interaction, when any.
266    pub hit: Option<PickHit>,
267}
268
269impl InteractionEvent {
270    /// Create a new interaction event with no target or resolved hit attached.
271    pub fn new(kind: InteractionEventKind, pointer_kind: PointerKind, screen_point: ScreenPoint) -> Self {
272        Self {
273            kind,
274            pointer_kind,
275            screen_point,
276            query_coord: None,
277            projection: None,
278            button: None,
279            modifiers: InteractionModifiers::default(),
280            target: None,
281            related_target: None,
282            hit: None,
283        }
284    }
285
286    /// Attach a resolved geographic query coordinate.
287    pub fn with_query_coord(mut self, query_coord: GeoCoord) -> Self {
288        self.query_coord = Some(query_coord);
289        self
290    }
291
292    /// Attach the active camera projection.
293    pub fn with_projection(mut self, projection: CameraProjection) -> Self {
294        self.projection = Some(projection);
295        self
296    }
297
298    /// Attach button metadata.
299    pub fn with_button(mut self, button: InteractionButton) -> Self {
300        self.button = Some(button);
301        self
302    }
303
304    /// Attach keyboard modifiers.
305    pub fn with_modifiers(mut self, modifiers: InteractionModifiers) -> Self {
306        self.modifiers = modifiers;
307        self
308    }
309
310    /// Attach a top-priority pick hit and derive the current interaction target.
311    pub fn with_hit(mut self, hit: PickHit) -> Self {
312        self.target = Some(InteractionTarget::from_pick_hit(&hit));
313        self.hit = Some(hit);
314        self
315    }
316
317    /// Attach a related target for enter/leave-style transitions.
318    pub fn with_related_target(mut self, related_target: InteractionTarget) -> Self {
319        self.related_target = Some(related_target);
320        self
321    }
322
323    /// Whether this event currently targets something queryable.
324    pub fn has_target(&self) -> bool {
325        self.target.is_some()
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::picking::PickHit;
333    use std::collections::HashMap;
334
335    #[test]
336    fn mouseover_and_mouseout_canonicalize_to_enter_leave() {
337        assert_eq!(
338            InteractionEventKind::MouseOver.canonical(),
339            InteractionEventKind::MouseEnter
340        );
341        assert_eq!(
342            InteractionEventKind::MouseOut.canonical(),
343            InteractionEventKind::MouseLeave
344        );
345    }
346
347    #[test]
348    fn interaction_target_kind_tracks_pick_hit_category() {
349        let hit = PickHit {
350            category: HitCategory::Symbol,
351            provenance: HitProvenance::GeometricApproximation,
352            layer_id: Some("places".into()),
353            source_id: Some("composite".into()),
354            source_layer: Some("place_label".into()),
355            source_tile: None,
356            feature_id: Some("42".into()),
357            feature_index: Some(3),
358            geometry: None,
359            properties: HashMap::new(),
360            state: HashMap::new(),
361            distance_meters: 0.0,
362            hit_coord: None,
363            layer_priority: 0,
364            from_symbol: true,
365        };
366
367        let target = InteractionTarget::from_pick_hit(&hit);
368        assert_eq!(target.kind, InteractionTargetKind::Symbol);
369        assert!(target.is_feature_backed());
370        assert!(target.from_symbol);
371    }
372
373    #[test]
374    fn interaction_event_with_hit_populates_target() {
375        let hit = PickHit::terrain_surface(GeoCoord::from_lat_lon(10.0, 20.0), Some(25.0));
376        let event = InteractionEvent::new(
377            InteractionEventKind::MouseMove,
378            PointerKind::Mouse,
379            ScreenPoint::new(10.0, 20.0),
380        )
381        .with_hit(hit);
382
383        assert!(event.has_target());
384        assert_eq!(
385            event.target.as_ref().map(|target| target.kind),
386            Some(InteractionTargetKind::Terrain)
387        );
388        assert_eq!(event.kind.as_str(), "mousemove");
389    }
390
391    #[test]
392    fn modifiers_any_detects_active_modifier() {
393        assert!(!InteractionModifiers::default().any());
394        assert!(InteractionModifiers::new(false, true, false, false).any());
395    }
396}