Skip to main content

cranpose_foundation/nodes/input/
types.rs

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