Skip to main content

cranpose_foundation/nodes/input/
types.rs

1use std::{
2    cell::{Cell, RefCell},
3    rc::Rc,
4};
5
6use cranpose_ui_graphics::Point;
7
8use super::rotary::RotaryScrollEvent;
9
10pub type PointerId = u64;
11
12type PostDispatchAction = Box<dyn FnOnce() -> bool>;
13
14#[derive(Clone)]
15struct DeferredPostDispatch {
16    action: Rc<RefCell<Option<PostDispatchAction>>>,
17}
18
19impl std::fmt::Debug for DeferredPostDispatch {
20    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        formatter
22            .debug_struct("DeferredPostDispatch")
23            .field("is_pending", &self.action.borrow().is_some())
24            .finish()
25    }
26}
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum PointerPhase {
30    Start,
31    Move,
32    End,
33    Cancel,
34}
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum PointerEventKind {
38    Down,
39    Move,
40    Up,
41    Cancel,
42    Scroll,
43    /// Discrete zoom step (desktop ctrl+wheel, browser pinch-trackpad).
44    /// The multiplicative factor is carried in [`PointerEvent::zoom_delta`].
45    Zoom,
46    /// Rotary scroll (Wear OS crown / rotating bezel) during the **capture**
47    /// pass, which runs root-to-focused so ancestors can intercept the event
48    /// before the focused node sees it.
49    ///
50    /// The scroll amounts are carried in [`PointerEvent::scroll_delta`] (`y` =
51    /// vertical pixels, `x` = horizontal pixels) and the rotary uptime in
52    /// [`PointerEvent::time_ms`]; use
53    /// [`PointerEvent::rotary_scroll_event`] to read them back as a
54    /// [`RotaryScrollEvent`]. Mirrors Compose's `onPreRotaryScrollEvent`.
55    RotaryScrollPre,
56    /// Rotary scroll during the **bubble** pass, which runs focused-to-root.
57    /// Mirrors Compose's `onRotaryScrollEvent`.
58    RotaryScroll,
59    Enter,
60    Exit,
61}
62
63impl PointerEventKind {
64    /// Returns true for the two rotary passes.
65    pub fn is_rotary(self) -> bool {
66        matches!(self, Self::RotaryScrollPre | Self::RotaryScroll)
67    }
68}
69
70/// The kind of physical device that produced a pointer event.
71///
72/// Threaded from the platform layer (Android `MotionEvent` tool type, winit
73/// `PointerSource`/`ButtonSource`, web `PointerEvent.pointerType`) so input
74/// consumers can preserve device-specific gesture details while keeping shared
75/// direct-manipulation behavior source-independent.
76#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
77pub enum PointerSource {
78    /// A mouse or other indirect precise pointer (desktop, web `"mouse"`).
79    Mouse,
80    /// A finger on a touchscreen (Android finger, winit touch, web `"touch"`).
81    Touch,
82    /// A stylus/pen (Android stylus/eraser, winit tablet tool, web `"pen"`).
83    Stylus,
84    /// The platform did not report a device type.
85    #[default]
86    Unknown,
87}
88
89impl PointerSource {
90    /// Whether this source is a direct-touch device (finger or stylus), used
91    /// for contact-specific release and velocity semantics.
92    pub fn is_touch_like(self) -> bool {
93        matches!(self, PointerSource::Touch | PointerSource::Stylus)
94    }
95}
96
97#[repr(u8)]
98#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
99pub enum PointerButton {
100    Primary = 0,
101    Secondary = 1,
102    Middle = 2,
103    Back = 3,
104    Forward = 4,
105}
106
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108pub struct PointerButtons(u8);
109
110impl PointerButtons {
111    pub const NONE: Self = Self(0);
112
113    pub fn new() -> Self {
114        Self::NONE
115    }
116
117    pub fn with(mut self, button: PointerButton) -> Self {
118        self.insert(button);
119        self
120    }
121
122    pub fn insert(&mut self, button: PointerButton) {
123        self.0 |= 1 << (button as u8);
124    }
125
126    pub fn remove(&mut self, button: PointerButton) {
127        self.0 &= !(1 << (button as u8));
128    }
129
130    pub fn contains(&self, button: PointerButton) -> bool {
131        (self.0 & (1 << (button as u8))) != 0
132    }
133}
134
135impl Default for PointerButtons {
136    fn default() -> Self {
137        Self::NONE
138    }
139}
140
141/// Keyboard modifier keys held during an input sample.
142///
143/// Lives here (rather than up in `cranpose-ui`, where the keyboard `KeyEvent`
144/// type lives) because [`PointerEvent`] needs it too and `cranpose-foundation`
145/// sits below `cranpose-ui` in the dependency graph — this is the one crate
146/// both a key event and a pointer event can share it from. `cranpose-ui`
147/// re-exports this type rather than defining its own, so there is exactly one
148/// `Modifiers` in the framework.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
150pub struct Modifiers {
151    /// Shift key is pressed.
152    pub shift: bool,
153    /// Control key is pressed (Cmd on macOS).
154    pub ctrl: bool,
155    /// Alt key is pressed (Option on macOS).
156    pub alt: bool,
157    /// Meta/Super key is pressed (Windows key, Cmd on macOS).
158    pub meta: bool,
159}
160
161impl Modifiers {
162    /// No modifiers pressed.
163    pub const NONE: Modifiers = Modifiers {
164        shift: false,
165        ctrl: false,
166        alt: false,
167        meta: false,
168    };
169
170    /// Returns true if any modifier is pressed.
171    pub fn any(&self) -> bool {
172        self.shift || self.ctrl || self.alt || self.meta
173    }
174
175    /// Returns true if Ctrl (or Cmd on macOS) is pressed.
176    pub fn command_or_ctrl(&self) -> bool {
177        #[cfg(target_os = "macos")]
178        {
179            self.meta
180        }
181        #[cfg(not(target_os = "macos"))]
182        {
183            self.ctrl
184        }
185    }
186}
187
188/// Pointer event with consumption tracking for gesture disambiguation.
189///
190/// Events can be consumed by handlers (e.g., scroll) to prevent other handlers
191/// (e.g., clicks) from receiving them. This enables proper gesture disambiguation
192/// matching Jetpack Compose's event consumption pattern.
193#[derive(Clone, Debug)]
194pub struct PointerEvent {
195    pub id: PointerId,
196    pub kind: PointerEventKind,
197    pub phase: PointerPhase,
198    pub position: Point,
199    pub global_position: Point,
200    /// Scroll delta in logical pixels.
201    ///
202    /// This is non-zero for [`PointerEventKind::Scroll`] events and zero for
203    /// button/move events.
204    pub scroll_delta: Point,
205    pub buttons: PointerButtons,
206    /// Platform timestamp of the input sample in milliseconds, when available.
207    ///
208    /// The time base is platform specific (e.g. Android's uptime clock); only
209    /// differences between events of the same gesture are meaningful. Gesture
210    /// velocity trackers must prefer this over the delivery time because
211    /// platforms like Android deliver input batched/frame-aligned: several
212    /// samples arrive back-to-back and delivery-time stamping makes computed
213    /// velocities wildly wrong.
214    pub time_ms: Option<i64>,
215    /// Timestamp in the animation frame-clock domain at input dispatch.
216    /// Unlike `time_ms`, this has the same origin as frame callbacks and can
217    /// anchor input-driven animations without a wall/platform clock conversion.
218    pub animation_time_nanos: Option<u64>,
219    /// Multiplicative zoom factor for [`PointerEventKind::Zoom`] events
220    /// (`> 1.0` zooms in, `< 1.0` zooms out). `1.0` for all other events.
221    pub zoom_delta: f32,
222    /// The kind of device that produced this event (touch, mouse, stylus), when
223    /// the platform reports it. Defaults to [`PointerSource::Unknown`].
224    pub source: PointerSource,
225    /// Keyboard modifiers held at the time of this sample, when the platform
226    /// can report them.
227    ///
228    /// `None` means the platform never told the shell what the keyboard state
229    /// was — touch-only Android/iOS input has no channel for it today — and is
230    /// deliberately distinct from `Some(Modifiers::NONE)`, which means the
231    /// platform looked and nothing was held. An app that wants shift/ctrl-click
232    /// multi-select reads this field directly; it must not treat `None` as
233    /// "nothing held" or it silently drops the gesture on the platforms that
234    /// cannot yet report it instead of visibly doing nothing.
235    pub modifiers: Option<Modifiers>,
236    /// Tracks whether this event has been consumed by a handler.
237    /// Shared via `Rc<Cell>` so consumption can be tracked across copies.
238    consumed: Rc<Cell<bool>>,
239    deferred_post_dispatch: DeferredPostDispatch,
240}
241
242impl PointerEvent {
243    pub fn new(kind: PointerEventKind, position: Point, global_position: Point) -> Self {
244        Self {
245            id: 0,
246            kind,
247            phase: match kind {
248                PointerEventKind::Down => PointerPhase::Start,
249                PointerEventKind::Move | PointerEventKind::Enter | PointerEventKind::Exit => {
250                    PointerPhase::Move
251                }
252                PointerEventKind::Up => PointerPhase::End,
253                PointerEventKind::Cancel => PointerPhase::Cancel,
254                PointerEventKind::Scroll
255                | PointerEventKind::Zoom
256                | PointerEventKind::RotaryScrollPre
257                | PointerEventKind::RotaryScroll => PointerPhase::Move,
258            },
259            position,
260            global_position,
261            scroll_delta: Point { x: 0.0, y: 0.0 },
262            buttons: PointerButtons::NONE,
263            time_ms: None,
264            animation_time_nanos: None,
265            zoom_delta: 1.0,
266            source: PointerSource::Unknown,
267            modifiers: None,
268            consumed: Rc::new(Cell::new(false)),
269            deferred_post_dispatch: DeferredPostDispatch {
270                action: Rc::new(RefCell::new(None)),
271            },
272        }
273    }
274
275    /// Set the pointer id for this event (`0` is the primary pointer).
276    pub fn with_id(mut self, id: PointerId) -> Self {
277        self.id = id;
278        self
279    }
280
281    /// Set the multiplicative zoom factor for a [`PointerEventKind::Zoom`] event.
282    pub fn with_zoom_delta(mut self, zoom_delta: f32) -> Self {
283        self.zoom_delta = zoom_delta;
284        self
285    }
286
287    /// Set the scroll delta for this event.
288    pub fn with_scroll_delta(mut self, scroll_delta: Point) -> Self {
289        self.scroll_delta = scroll_delta;
290        self
291    }
292
293    /// Set the platform timestamp (milliseconds) for this event.
294    pub fn with_time_ms(mut self, time_ms: Option<i64>) -> Self {
295        self.time_ms = time_ms;
296        self
297    }
298
299    /// Set the timestamp in the animation frame-clock domain.
300    pub fn with_animation_time_nanos(mut self, time_nanos: u64) -> Self {
301        self.animation_time_nanos = Some(time_nanos);
302        self
303    }
304
305    /// Set the buttons state for this event
306    pub fn with_buttons(mut self, buttons: PointerButtons) -> Self {
307        self.buttons = buttons;
308        self
309    }
310
311    /// Set the device source (touch/mouse/stylus) for this event.
312    pub fn with_source(mut self, source: PointerSource) -> Self {
313        self.source = source;
314        self
315    }
316
317    /// Set the keyboard modifiers held during this event, when the platform
318    /// can report them. See the [`modifiers`](Self::modifiers) field docs for
319    /// why this takes a concrete [`Modifiers`] rather than an `Option`: the
320    /// `None` case is the *absence* of a call to this builder, not a value it
321    /// produces.
322    pub fn with_modifiers(mut self, modifiers: Modifiers) -> Self {
323        self.modifiers = Some(modifiers);
324        self
325    }
326
327    /// Builds a rotary pointer event for one dispatch pass.
328    ///
329    /// `kind` must be [`PointerEventKind::RotaryScrollPre`] (capture) or
330    /// [`PointerEventKind::RotaryScroll`] (bubble). The rotary payload rides on
331    /// the existing `scroll_delta`/`time_ms` fields so rotary reuses the
332    /// pointer dispatch path without widening [`PointerEvent`].
333    pub fn rotary(kind: PointerEventKind, rotary: RotaryScrollEvent, position: Point) -> Self {
334        debug_assert!(
335            kind.is_rotary(),
336            "PointerEvent::rotary requires a rotary event kind"
337        );
338        Self::new(kind, position, position)
339            .with_scroll_delta(Point {
340                x: rotary.horizontal_scroll_pixels,
341                y: rotary.vertical_scroll_pixels,
342            })
343            .with_time_ms(Some(rotary.uptime_millis as i64))
344    }
345
346    /// Reads this event back as a [`RotaryScrollEvent`], or `None` when it is
347    /// not a rotary event.
348    ///
349    /// Copies three scalars out of the event; it never allocates.
350    pub fn rotary_scroll_event(&self) -> Option<RotaryScrollEvent> {
351        if !self.kind.is_rotary() {
352            return None;
353        }
354        Some(RotaryScrollEvent {
355            vertical_scroll_pixels: self.scroll_delta.y,
356            horizontal_scroll_pixels: self.scroll_delta.x,
357            uptime_millis: self.time_ms.unwrap_or(0).max(0) as u64,
358        })
359    }
360
361    /// Mark this event as consumed, preventing other handlers from processing it.
362    ///
363    /// Example: Scroll gestures consume events once dragging starts to prevent
364    /// child buttons from firing clicks.
365    pub fn consume(&self) {
366        self.consumed.set(true);
367    }
368
369    /// Check if this event has been consumed by another handler.
370    ///
371    /// Handlers should check this before processing events. For example,
372    /// clickable should not fire if the event was consumed by a scroll gesture.
373    pub fn is_consumed(&self) -> bool {
374        self.consumed.get()
375    }
376
377    pub fn defer_post_dispatch_action<F>(&self, action: F)
378    where
379        F: FnOnce() -> bool + 'static,
380    {
381        *self.deferred_post_dispatch.action.borrow_mut() = Some(Box::new(action));
382    }
383
384    pub fn finish_post_dispatch(&self) {
385        if self.is_consumed() {
386            self.deferred_post_dispatch.action.borrow_mut().take();
387            return;
388        }
389
390        let Some(action) = self.deferred_post_dispatch.action.borrow_mut().take() else {
391            return;
392        };
393        if action() {
394            self.consume();
395        }
396    }
397
398    /// Creates a copy of this event with a new local position, sharing the consumption state.
399    pub fn copy_with_local_position(&self, position: Point) -> Self {
400        Self {
401            id: self.id,
402            kind: self.kind,
403            phase: self.phase,
404            position,
405            global_position: self.global_position,
406            scroll_delta: self.scroll_delta,
407            buttons: self.buttons,
408            time_ms: self.time_ms,
409            animation_time_nanos: self.animation_time_nanos,
410            zoom_delta: self.zoom_delta,
411            source: self.source,
412            modifiers: self.modifiers,
413            consumed: self.consumed.clone(),
414            deferred_post_dispatch: self.deferred_post_dispatch.clone(),
415        }
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    fn point(x: f32, y: f32) -> Point {
424        Point { x, y }
425    }
426
427    #[test]
428    fn pointer_event_clones_share_consumed_state() {
429        let event = PointerEvent::new(PointerEventKind::Move, point(1.0, 2.0), point(3.0, 4.0));
430        let cloned = event.clone();
431        assert!(!event.is_consumed());
432        assert!(!cloned.is_consumed());
433
434        cloned.consume();
435
436        assert!(event.is_consumed());
437        assert!(cloned.is_consumed());
438    }
439
440    #[test]
441    fn pointer_event_source_defaults_unknown_and_threads_through_copy() {
442        let event = PointerEvent::new(PointerEventKind::Down, point(1.0, 1.0), point(1.0, 1.0));
443        assert_eq!(event.source, PointerSource::Unknown);
444        assert!(!PointerSource::Unknown.is_touch_like());
445
446        let touch = event.with_source(PointerSource::Touch);
447        assert_eq!(touch.source, PointerSource::Touch);
448        assert!(PointerSource::Touch.is_touch_like());
449        assert!(PointerSource::Stylus.is_touch_like());
450        assert!(!PointerSource::Mouse.is_touch_like());
451
452        // Local-position copies (used during hit-test dispatch) keep the source.
453        let local = touch.copy_with_local_position(point(5.0, 5.0));
454        assert_eq!(local.source, PointerSource::Touch);
455    }
456
457    #[test]
458    fn modifiers_any_is_true_when_any_field_is_set() {
459        assert!(!Modifiers::NONE.any());
460        assert!(!Modifiers::default().any());
461        assert!(
462            Modifiers {
463                shift: true,
464                ..Modifiers::NONE
465            }
466            .any()
467        );
468    }
469
470    #[test]
471    fn modifiers_command_or_ctrl_reads_the_platform_appropriate_key() {
472        let ctrl_only = Modifiers {
473            ctrl: true,
474            ..Modifiers::NONE
475        };
476        let meta_only = Modifiers {
477            meta: true,
478            ..Modifiers::NONE
479        };
480
481        #[cfg(target_os = "macos")]
482        {
483            assert!(!ctrl_only.command_or_ctrl());
484            assert!(meta_only.command_or_ctrl());
485        }
486        #[cfg(not(target_os = "macos"))]
487        {
488            assert!(ctrl_only.command_or_ctrl());
489            assert!(!meta_only.command_or_ctrl());
490        }
491        assert!(!Modifiers::NONE.command_or_ctrl());
492    }
493
494    #[test]
495    fn pointer_event_modifiers_default_to_unreported_and_thread_through_copy() {
496        let event = PointerEvent::new(PointerEventKind::Down, point(1.0, 1.0), point(1.0, 1.0));
497        // Unreported (None) must stay visibly distinct from "reported, none
498        // held" (Some(Modifiers::NONE)) -- see the field doc on why.
499        assert_eq!(event.modifiers, None);
500
501        let shift = event.with_modifiers(Modifiers {
502            shift: true,
503            ..Modifiers::NONE
504        });
505        assert_eq!(
506            shift.modifiers,
507            Some(Modifiers {
508                shift: true,
509                ..Modifiers::NONE
510            })
511        );
512
513        // Local-position copies (used during hit-test dispatch) keep the
514        // modifiers, exactly like they keep the source.
515        let local = shift.copy_with_local_position(point(5.0, 5.0));
516        assert_eq!(local.modifiers, shift.modifiers);
517    }
518
519    #[test]
520    fn rotary_payload_round_trips_through_pointer_event() {
521        let rotary = RotaryScrollEvent::new(-64.0, 12.0, 1_234);
522        let event = PointerEvent::rotary(PointerEventKind::RotaryScroll, rotary, point(5.0, 6.0));
523
524        assert_eq!(event.phase, PointerPhase::Move);
525        assert_eq!(event.scroll_delta, point(12.0, -64.0));
526        assert_eq!(event.time_ms, Some(1_234));
527        assert_eq!(event.rotary_scroll_event(), Some(rotary));
528    }
529
530    #[test]
531    fn rotary_payload_survives_local_position_copies() {
532        // Dispatch localizes the event per node; the rotary payload must
533        // survive that copy or handlers deeper in the chain see zeros.
534        let rotary = RotaryScrollEvent::new(-8.0, 0.0, 7);
535        let event =
536            PointerEvent::rotary(PointerEventKind::RotaryScrollPre, rotary, point(0.0, 0.0));
537
538        let local = event.copy_with_local_position(point(3.0, 4.0));
539
540        assert_eq!(local.rotary_scroll_event(), Some(rotary));
541    }
542
543    #[test]
544    fn non_rotary_events_have_no_rotary_payload() {
545        let scroll = PointerEvent::new(PointerEventKind::Scroll, point(0.0, 0.0), point(0.0, 0.0))
546            .with_scroll_delta(point(1.0, 2.0));
547
548        assert_eq!(scroll.rotary_scroll_event(), None);
549        assert!(!PointerEventKind::Scroll.is_rotary());
550        assert!(PointerEventKind::RotaryScroll.is_rotary());
551        assert!(PointerEventKind::RotaryScrollPre.is_rotary());
552    }
553
554    #[test]
555    fn pointer_event_copy_with_local_position_preserves_consumption_state() {
556        let event = PointerEvent::new(PointerEventKind::Down, point(4.0, 5.0), point(4.0, 5.0))
557            .with_time_ms(Some(123))
558            .with_animation_time_nanos(456_000_000);
559        let local = event.copy_with_local_position(point(1.0, 1.0));
560
561        assert_eq!(local.position, point(1.0, 1.0));
562        assert_eq!(local.global_position, event.global_position);
563        assert_eq!(local.time_ms, Some(123));
564        assert_eq!(local.animation_time_nanos, Some(456_000_000));
565        assert!(!local.is_consumed());
566
567        event.consume();
568
569        assert!(local.is_consumed());
570    }
571
572    #[test]
573    fn deferred_post_dispatch_action_consumes_when_it_applies() {
574        let event = PointerEvent::new(PointerEventKind::Move, point(0.0, 0.0), point(0.0, 0.0));
575        event.defer_post_dispatch_action(|| true);
576
577        event.finish_post_dispatch();
578
579        assert!(event.is_consumed());
580    }
581
582    #[test]
583    fn deferred_post_dispatch_action_is_replaced_and_discarded_when_consumed() {
584        let event = PointerEvent::new(PointerEventKind::Move, point(0.0, 0.0), point(0.0, 0.0));
585        let first_called = Rc::new(Cell::new(false));
586        let second_called = Rc::new(Cell::new(false));
587        let first_called_in_action = first_called.clone();
588        let second_called_in_action = second_called.clone();
589        event.defer_post_dispatch_action(move || {
590            first_called_in_action.set(true);
591            true
592        });
593        event.defer_post_dispatch_action(move || {
594            second_called_in_action.set(true);
595            true
596        });
597        event.consume();
598
599        event.finish_post_dispatch();
600
601        assert!(!first_called.get());
602        assert!(!second_called.get());
603        assert!(event.is_consumed());
604    }
605}