Skip to main content

cranpose_foundation/nodes/input/
types.rs

1use super::rotary::RotaryScrollEvent;
2use cranpose_ui_graphics::Point;
3use std::cell::Cell;
4use std::rc::Rc;
5
6pub type PointerId = u64;
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum PointerPhase {
10    Start,
11    Move,
12    End,
13    Cancel,
14}
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum PointerEventKind {
18    Down,
19    Move,
20    Up,
21    Cancel,
22    Scroll,
23    /// Discrete zoom step (desktop ctrl+wheel, browser pinch-trackpad).
24    /// The multiplicative factor is carried in [`PointerEvent::zoom_delta`].
25    Zoom,
26    /// Rotary scroll (Wear OS crown / rotating bezel) during the **capture**
27    /// pass, which runs root-to-focused so ancestors can intercept the event
28    /// before the focused node sees it.
29    ///
30    /// The scroll amounts are carried in [`PointerEvent::scroll_delta`] (`y` =
31    /// vertical pixels, `x` = horizontal pixels) and the rotary uptime in
32    /// [`PointerEvent::time_ms`]; use
33    /// [`PointerEvent::rotary_scroll_event`] to read them back as a
34    /// [`RotaryScrollEvent`]. Mirrors Compose's `onPreRotaryScrollEvent`.
35    RotaryScrollPre,
36    /// Rotary scroll during the **bubble** pass, which runs focused-to-root.
37    /// Mirrors Compose's `onRotaryScrollEvent`.
38    RotaryScroll,
39    Enter,
40    Exit,
41}
42
43impl PointerEventKind {
44    /// Returns true for the two rotary passes.
45    pub fn is_rotary(self) -> bool {
46        matches!(self, Self::RotaryScrollPre | Self::RotaryScroll)
47    }
48}
49
50/// The kind of physical device that produced a pointer event.
51///
52/// Threaded from the platform layer (Android `MotionEvent` tool type, winit
53/// `PointerSource`/`ButtonSource`, web `PointerEvent.pointerType`) so input
54/// consumers can preserve device-specific gesture details while keeping shared
55/// direct-manipulation behavior source-independent.
56#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
57pub enum PointerSource {
58    /// A mouse or other indirect precise pointer (desktop, web `"mouse"`).
59    Mouse,
60    /// A finger on a touchscreen (Android finger, winit touch, web `"touch"`).
61    Touch,
62    /// A stylus/pen (Android stylus/eraser, winit tablet tool, web `"pen"`).
63    Stylus,
64    /// The platform did not report a device type.
65    #[default]
66    Unknown,
67}
68
69impl PointerSource {
70    /// Whether this source is a direct-touch device (finger or stylus), used
71    /// for contact-specific release and velocity semantics.
72    pub fn is_touch_like(self) -> bool {
73        matches!(self, PointerSource::Touch | PointerSource::Stylus)
74    }
75}
76
77#[repr(u8)]
78#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
79pub enum PointerButton {
80    Primary = 0,
81    Secondary = 1,
82    Middle = 2,
83    Back = 3,
84    Forward = 4,
85}
86
87#[derive(Clone, Copy, Debug, PartialEq, Eq)]
88pub struct PointerButtons(u8);
89
90impl PointerButtons {
91    pub const NONE: Self = Self(0);
92
93    pub fn new() -> Self {
94        Self::NONE
95    }
96
97    pub fn with(mut self, button: PointerButton) -> Self {
98        self.insert(button);
99        self
100    }
101
102    pub fn insert(&mut self, button: PointerButton) {
103        self.0 |= 1 << (button as u8);
104    }
105
106    pub fn remove(&mut self, button: PointerButton) {
107        self.0 &= !(1 << (button as u8));
108    }
109
110    pub fn contains(&self, button: PointerButton) -> bool {
111        (self.0 & (1 << (button as u8))) != 0
112    }
113}
114
115impl Default for PointerButtons {
116    fn default() -> Self {
117        Self::NONE
118    }
119}
120
121/// Pointer event with consumption tracking for gesture disambiguation.
122///
123/// Events can be consumed by handlers (e.g., scroll) to prevent other handlers
124/// (e.g., clicks) from receiving them. This enables proper gesture disambiguation
125/// matching Jetpack Compose's event consumption pattern.
126#[derive(Clone, Debug)]
127pub struct PointerEvent {
128    pub id: PointerId,
129    pub kind: PointerEventKind,
130    pub phase: PointerPhase,
131    pub position: Point,
132    pub global_position: Point,
133    /// Scroll delta in logical pixels.
134    ///
135    /// This is non-zero for [`PointerEventKind::Scroll`] events and zero for
136    /// button/move events.
137    pub scroll_delta: Point,
138    pub buttons: PointerButtons,
139    /// Platform timestamp of the input sample in milliseconds, when available.
140    ///
141    /// The time base is platform specific (e.g. Android's uptime clock); only
142    /// differences between events of the same gesture are meaningful. Gesture
143    /// velocity trackers must prefer this over the delivery time because
144    /// platforms like Android deliver input batched/frame-aligned: several
145    /// samples arrive back-to-back and delivery-time stamping makes computed
146    /// velocities wildly wrong.
147    pub time_ms: Option<i64>,
148    /// Timestamp in the animation frame-clock domain at input dispatch.
149    /// Unlike `time_ms`, this has the same origin as frame callbacks and can
150    /// anchor input-driven animations without a wall/platform clock conversion.
151    pub animation_time_nanos: Option<u64>,
152    /// Multiplicative zoom factor for [`PointerEventKind::Zoom`] events
153    /// (`> 1.0` zooms in, `< 1.0` zooms out). `1.0` for all other events.
154    pub zoom_delta: f32,
155    /// The kind of device that produced this event (touch, mouse, stylus), when
156    /// the platform reports it. Defaults to [`PointerSource::Unknown`].
157    pub source: PointerSource,
158    /// Tracks whether this event has been consumed by a handler.
159    /// Shared via Rc<Cell> so consumption can be tracked across copies.
160    consumed: Rc<Cell<bool>>,
161}
162
163impl PointerEvent {
164    pub fn new(kind: PointerEventKind, position: Point, global_position: Point) -> Self {
165        Self {
166            id: 0,
167            kind,
168            phase: match kind {
169                PointerEventKind::Down => PointerPhase::Start,
170                PointerEventKind::Move | PointerEventKind::Enter | PointerEventKind::Exit => {
171                    PointerPhase::Move
172                }
173                PointerEventKind::Up => PointerPhase::End,
174                PointerEventKind::Cancel => PointerPhase::Cancel,
175                PointerEventKind::Scroll
176                | PointerEventKind::Zoom
177                | PointerEventKind::RotaryScrollPre
178                | PointerEventKind::RotaryScroll => PointerPhase::Move,
179            },
180            position,
181            global_position,
182            scroll_delta: Point { x: 0.0, y: 0.0 },
183            buttons: PointerButtons::NONE,
184            time_ms: None,
185            animation_time_nanos: None,
186            zoom_delta: 1.0,
187            source: PointerSource::Unknown,
188            consumed: Rc::new(Cell::new(false)),
189        }
190    }
191
192    /// Set the pointer id for this event (`0` is the primary pointer).
193    pub fn with_id(mut self, id: PointerId) -> Self {
194        self.id = id;
195        self
196    }
197
198    /// Set the multiplicative zoom factor for a [`PointerEventKind::Zoom`] event.
199    pub fn with_zoom_delta(mut self, zoom_delta: f32) -> Self {
200        self.zoom_delta = zoom_delta;
201        self
202    }
203
204    /// Set the scroll delta for this event.
205    pub fn with_scroll_delta(mut self, scroll_delta: Point) -> Self {
206        self.scroll_delta = scroll_delta;
207        self
208    }
209
210    /// Set the platform timestamp (milliseconds) for this event.
211    pub fn with_time_ms(mut self, time_ms: Option<i64>) -> Self {
212        self.time_ms = time_ms;
213        self
214    }
215
216    /// Set the timestamp in the animation frame-clock domain.
217    pub fn with_animation_time_nanos(mut self, time_nanos: u64) -> Self {
218        self.animation_time_nanos = Some(time_nanos);
219        self
220    }
221
222    /// Set the buttons state for this event
223    pub fn with_buttons(mut self, buttons: PointerButtons) -> Self {
224        self.buttons = buttons;
225        self
226    }
227
228    /// Set the device source (touch/mouse/stylus) for this event.
229    pub fn with_source(mut self, source: PointerSource) -> Self {
230        self.source = source;
231        self
232    }
233
234    /// Builds a rotary pointer event for one dispatch pass.
235    ///
236    /// `kind` must be [`PointerEventKind::RotaryScrollPre`] (capture) or
237    /// [`PointerEventKind::RotaryScroll`] (bubble). The rotary payload rides on
238    /// the existing `scroll_delta`/`time_ms` fields so rotary reuses the
239    /// pointer dispatch path without widening [`PointerEvent`].
240    pub fn rotary(kind: PointerEventKind, rotary: RotaryScrollEvent, position: Point) -> Self {
241        debug_assert!(
242            kind.is_rotary(),
243            "PointerEvent::rotary requires a rotary event kind"
244        );
245        Self::new(kind, position, position)
246            .with_scroll_delta(Point {
247                x: rotary.horizontal_scroll_pixels,
248                y: rotary.vertical_scroll_pixels,
249            })
250            .with_time_ms(Some(rotary.uptime_millis as i64))
251    }
252
253    /// Reads this event back as a [`RotaryScrollEvent`], or `None` when it is
254    /// not a rotary event.
255    ///
256    /// Copies three scalars out of the event; it never allocates.
257    pub fn rotary_scroll_event(&self) -> Option<RotaryScrollEvent> {
258        if !self.kind.is_rotary() {
259            return None;
260        }
261        Some(RotaryScrollEvent {
262            vertical_scroll_pixels: self.scroll_delta.y,
263            horizontal_scroll_pixels: self.scroll_delta.x,
264            uptime_millis: self.time_ms.unwrap_or(0).max(0) as u64,
265        })
266    }
267
268    /// Mark this event as consumed, preventing other handlers from processing it.
269    ///
270    /// Example: Scroll gestures consume events once dragging starts to prevent
271    /// child buttons from firing clicks.
272    pub fn consume(&self) {
273        self.consumed.set(true);
274    }
275
276    /// Check if this event has been consumed by another handler.
277    ///
278    /// Handlers should check this before processing events. For example,
279    /// clickable should not fire if the event was consumed by a scroll gesture.
280    pub fn is_consumed(&self) -> bool {
281        self.consumed.get()
282    }
283
284    /// Creates a copy of this event with a new local position, sharing the consumption state.
285    pub fn copy_with_local_position(&self, position: Point) -> Self {
286        Self {
287            id: self.id,
288            kind: self.kind,
289            phase: self.phase,
290            position,
291            global_position: self.global_position,
292            scroll_delta: self.scroll_delta,
293            buttons: self.buttons,
294            time_ms: self.time_ms,
295            animation_time_nanos: self.animation_time_nanos,
296            zoom_delta: self.zoom_delta,
297            source: self.source,
298            consumed: self.consumed.clone(),
299        }
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    fn point(x: f32, y: f32) -> Point {
308        Point { x, y }
309    }
310
311    #[test]
312    fn pointer_event_clones_share_consumed_state() {
313        let event = PointerEvent::new(PointerEventKind::Move, point(1.0, 2.0), point(3.0, 4.0));
314        let cloned = event.clone();
315        assert!(!event.is_consumed());
316        assert!(!cloned.is_consumed());
317
318        cloned.consume();
319
320        assert!(event.is_consumed());
321        assert!(cloned.is_consumed());
322    }
323
324    #[test]
325    fn pointer_event_source_defaults_unknown_and_threads_through_copy() {
326        let event = PointerEvent::new(PointerEventKind::Down, point(1.0, 1.0), point(1.0, 1.0));
327        assert_eq!(event.source, PointerSource::Unknown);
328        assert!(!PointerSource::Unknown.is_touch_like());
329
330        let touch = event.with_source(PointerSource::Touch);
331        assert_eq!(touch.source, PointerSource::Touch);
332        assert!(PointerSource::Touch.is_touch_like());
333        assert!(PointerSource::Stylus.is_touch_like());
334        assert!(!PointerSource::Mouse.is_touch_like());
335
336        // Local-position copies (used during hit-test dispatch) keep the source.
337        let local = touch.copy_with_local_position(point(5.0, 5.0));
338        assert_eq!(local.source, PointerSource::Touch);
339    }
340
341    #[test]
342    fn rotary_payload_round_trips_through_pointer_event() {
343        let rotary = RotaryScrollEvent::new(-64.0, 12.0, 1_234);
344        let event = PointerEvent::rotary(PointerEventKind::RotaryScroll, rotary, point(5.0, 6.0));
345
346        assert_eq!(event.phase, PointerPhase::Move);
347        assert_eq!(event.scroll_delta, point(12.0, -64.0));
348        assert_eq!(event.time_ms, Some(1_234));
349        assert_eq!(event.rotary_scroll_event(), Some(rotary));
350    }
351
352    #[test]
353    fn rotary_payload_survives_local_position_copies() {
354        // Dispatch localizes the event per node; the rotary payload must
355        // survive that copy or handlers deeper in the chain see zeros.
356        let rotary = RotaryScrollEvent::new(-8.0, 0.0, 7);
357        let event =
358            PointerEvent::rotary(PointerEventKind::RotaryScrollPre, rotary, point(0.0, 0.0));
359
360        let local = event.copy_with_local_position(point(3.0, 4.0));
361
362        assert_eq!(local.rotary_scroll_event(), Some(rotary));
363    }
364
365    #[test]
366    fn non_rotary_events_have_no_rotary_payload() {
367        let scroll = PointerEvent::new(PointerEventKind::Scroll, point(0.0, 0.0), point(0.0, 0.0))
368            .with_scroll_delta(point(1.0, 2.0));
369
370        assert_eq!(scroll.rotary_scroll_event(), None);
371        assert!(!PointerEventKind::Scroll.is_rotary());
372        assert!(PointerEventKind::RotaryScroll.is_rotary());
373        assert!(PointerEventKind::RotaryScrollPre.is_rotary());
374    }
375
376    #[test]
377    fn pointer_event_copy_with_local_position_preserves_consumption_state() {
378        let event = PointerEvent::new(PointerEventKind::Down, point(4.0, 5.0), point(4.0, 5.0))
379            .with_time_ms(Some(123))
380            .with_animation_time_nanos(456_000_000);
381        let local = event.copy_with_local_position(point(1.0, 1.0));
382
383        assert_eq!(local.position, point(1.0, 1.0));
384        assert_eq!(local.global_position, event.global_position);
385        assert_eq!(local.time_ms, Some(123));
386        assert_eq!(local.animation_time_nanos, Some(456_000_000));
387        assert!(!local.is_consumed());
388
389        event.consume();
390
391        assert!(local.is_consumed());
392    }
393}