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 /// Where the pointer is on the screen, in logical pixels, when the
201 /// platform told the shell where the window drawing this event's root
202 /// sits. `None` on platforms without window positions and for events a
203 /// test dispatches without one. A gesture that crosses windows, such as
204 /// a tab torn out of one window and dropped on another, compares this
205 /// with the windows' positions instead of translating `global_position`
206 /// itself.
207 pub screen_position: Option<Point>,
208 /// Scroll delta in logical pixels.
209 ///
210 /// This is non-zero for [`PointerEventKind::Scroll`] events and zero for
211 /// button/move events.
212 pub scroll_delta: Point,
213 pub buttons: PointerButtons,
214 /// Platform timestamp of the input sample in milliseconds, when available.
215 ///
216 /// The time base is platform specific (e.g. Android's uptime clock); only
217 /// differences between events of the same gesture are meaningful. Gesture
218 /// velocity trackers must prefer this over the delivery time because
219 /// platforms like Android deliver input batched/frame-aligned: several
220 /// samples arrive back-to-back and delivery-time stamping makes computed
221 /// velocities wildly wrong.
222 pub time_ms: Option<i64>,
223 /// Timestamp in the animation frame-clock domain at input dispatch.
224 /// Unlike `time_ms`, this has the same origin as frame callbacks and can
225 /// anchor input-driven animations without a wall/platform clock conversion.
226 pub animation_time_nanos: Option<u64>,
227 /// Multiplicative zoom factor for [`PointerEventKind::Zoom`] events
228 /// (`> 1.0` zooms in, `< 1.0` zooms out). `1.0` for all other events.
229 pub zoom_delta: f32,
230 /// The kind of device that produced this event (touch, mouse, stylus), when
231 /// the platform reports it. Defaults to [`PointerSource::Unknown`].
232 pub source: PointerSource,
233 /// Keyboard modifiers held at the time of this sample, when the platform
234 /// can report them.
235 ///
236 /// `None` means the platform never told the shell what the keyboard state
237 /// was — touch-only Android/iOS input has no channel for it today — and is
238 /// deliberately distinct from `Some(Modifiers::NONE)`, which means the
239 /// platform looked and nothing was held. An app that wants shift/ctrl-click
240 /// multi-select reads this field directly; it must not treat `None` as
241 /// "nothing held" or it silently drops the gesture on the platforms that
242 /// cannot yet report it instead of visibly doing nothing.
243 pub modifiers: Option<Modifiers>,
244 consumed: Rc<Cell<bool>>,
245 deferred_post_dispatch: DeferredPostDispatch,
246}
247
248impl PointerEvent {
249 pub fn new(kind: PointerEventKind, position: Point, global_position: Point) -> Self {
250 Self {
251 id: 0,
252 kind,
253 phase: match kind {
254 PointerEventKind::Down => PointerPhase::Start,
255 PointerEventKind::Move | PointerEventKind::Enter | PointerEventKind::Exit => {
256 PointerPhase::Move
257 }
258 PointerEventKind::Up => PointerPhase::End,
259 PointerEventKind::Cancel => PointerPhase::Cancel,
260 PointerEventKind::Scroll
261 | PointerEventKind::Zoom
262 | PointerEventKind::RotaryScrollPre
263 | PointerEventKind::RotaryScroll => PointerPhase::Move,
264 },
265 position,
266 global_position,
267 screen_position: None,
268 scroll_delta: Point { x: 0.0, y: 0.0 },
269 buttons: PointerButtons::NONE,
270 time_ms: None,
271 animation_time_nanos: None,
272 zoom_delta: 1.0,
273 source: PointerSource::Unknown,
274 modifiers: None,
275 consumed: Rc::new(Cell::new(false)),
276 deferred_post_dispatch: DeferredPostDispatch {
277 action: Rc::new(RefCell::new(None)),
278 },
279 }
280 }
281
282 /// Set the pointer id for this event (`0` is the primary pointer).
283 pub fn with_id(mut self, id: PointerId) -> Self {
284 self.id = id;
285 self
286 }
287
288 /// Set the multiplicative zoom factor for a [`PointerEventKind::Zoom`] event.
289 pub fn with_zoom_delta(mut self, zoom_delta: f32) -> Self {
290 self.zoom_delta = zoom_delta;
291 self
292 }
293
294 /// Set the scroll delta for this event.
295 pub fn with_scroll_delta(mut self, scroll_delta: Point) -> Self {
296 self.scroll_delta = scroll_delta;
297 self
298 }
299
300 /// Set the platform timestamp (milliseconds) for this event.
301 pub fn with_time_ms(mut self, time_ms: Option<i64>) -> Self {
302 self.time_ms = time_ms;
303 self
304 }
305
306 /// Set the timestamp in the animation frame-clock domain.
307 pub fn with_animation_time_nanos(mut self, time_nanos: u64) -> Self {
308 self.animation_time_nanos = Some(time_nanos);
309 self
310 }
311
312 /// Set where the pointer is on the screen, when the platform knows.
313 pub fn with_screen_position(mut self, screen_position: Option<Point>) -> Self {
314 self.screen_position = screen_position;
315 self
316 }
317
318 /// Where the pointer is in the steadiest frame the platform offers: on
319 /// the screen where it reports window positions, and in the composition
320 /// where it does not.
321 ///
322 /// Two events compared this way say how far the hand travelled even when
323 /// the window under it travelled too, which is what a drag threshold has
324 /// to measure: a press that drags a borderless window never moves within
325 /// that window, because the window follows it.
326 pub fn travelled_to(&self) -> Point {
327 self.screen_position.unwrap_or(self.global_position)
328 }
329
330 /// Set the buttons state for this event
331 pub fn with_buttons(mut self, buttons: PointerButtons) -> Self {
332 self.buttons = buttons;
333 self
334 }
335
336 /// Set the device source (touch/mouse/stylus) for this event.
337 pub fn with_source(mut self, source: PointerSource) -> Self {
338 self.source = source;
339 self
340 }
341
342 /// Set the keyboard modifiers held during this event, when the platform
343 /// can report them. See the [`modifiers`](Self::modifiers) field docs for
344 /// why this takes a concrete [`Modifiers`] rather than an `Option`: the
345 /// `None` case is the *absence* of a call to this builder, not a value it
346 /// produces.
347 pub fn with_modifiers(mut self, modifiers: Modifiers) -> Self {
348 self.modifiers = Some(modifiers);
349 self
350 }
351
352 /// Builds a rotary pointer event for one dispatch pass.
353 ///
354 /// `kind` must be [`PointerEventKind::RotaryScrollPre`] (capture) or
355 /// [`PointerEventKind::RotaryScroll`] (bubble). The rotary payload rides on
356 /// the existing `scroll_delta`/`time_ms` fields so rotary reuses the
357 /// pointer dispatch path without widening [`PointerEvent`].
358 pub fn rotary(kind: PointerEventKind, rotary: RotaryScrollEvent, position: Point) -> Self {
359 debug_assert!(
360 kind.is_rotary(),
361 "PointerEvent::rotary requires a rotary event kind"
362 );
363 Self::new(kind, position, position)
364 .with_scroll_delta(Point {
365 x: rotary.horizontal_scroll_pixels,
366 y: rotary.vertical_scroll_pixels,
367 })
368 .with_time_ms(Some(rotary.uptime_millis as i64))
369 }
370
371 /// Reads this event back as a [`RotaryScrollEvent`], or `None` when it is
372 /// not a rotary event.
373 ///
374 /// Copies three scalars out of the event; it never allocates.
375 pub fn rotary_scroll_event(&self) -> Option<RotaryScrollEvent> {
376 if !self.kind.is_rotary() {
377 return None;
378 }
379 Some(RotaryScrollEvent {
380 vertical_scroll_pixels: self.scroll_delta.y,
381 horizontal_scroll_pixels: self.scroll_delta.x,
382 uptime_millis: self.time_ms.unwrap_or(0).max(0) as u64,
383 })
384 }
385
386 /// Mark this event as consumed, preventing other handlers from processing it.
387 ///
388 /// Example: Scroll gestures consume events once dragging starts to prevent
389 /// child buttons from firing clicks.
390 pub fn consume(&self) {
391 self.consumed.set(true);
392 }
393
394 /// Check if this event has been consumed by another handler.
395 ///
396 /// Handlers should check this before processing events. For example,
397 /// clickable should not fire if the event was consumed by a scroll gesture.
398 pub fn is_consumed(&self) -> bool {
399 self.consumed.get()
400 }
401
402 pub fn defer_post_dispatch_action<F>(&self, action: F)
403 where
404 F: FnOnce() -> bool + 'static,
405 {
406 *self.deferred_post_dispatch.action.borrow_mut() = Some(Box::new(action));
407 }
408
409 pub fn finish_post_dispatch(&self) {
410 if self.is_consumed() {
411 self.deferred_post_dispatch.action.borrow_mut().take();
412 return;
413 }
414
415 let Some(action) = self.deferred_post_dispatch.action.borrow_mut().take() else {
416 return;
417 };
418 if action() {
419 self.consume();
420 }
421 }
422
423 /// Creates a copy of this event with a new local position, sharing the consumption state.
424 pub fn copy_with_local_position(&self, position: Point) -> Self {
425 Self {
426 id: self.id,
427 kind: self.kind,
428 phase: self.phase,
429 position,
430 global_position: self.global_position,
431 screen_position: self.screen_position,
432 scroll_delta: self.scroll_delta,
433 buttons: self.buttons,
434 time_ms: self.time_ms,
435 animation_time_nanos: self.animation_time_nanos,
436 zoom_delta: self.zoom_delta,
437 source: self.source,
438 modifiers: self.modifiers,
439 consumed: self.consumed.clone(),
440 deferred_post_dispatch: self.deferred_post_dispatch.clone(),
441 }
442 }
443}
444
445#[cfg(test)]
446#[path = "tests/types_tests.rs"]
447mod tests;