Skip to main content

cranpose_foundation/nodes/input/
rotary.rs

1//! Rotary input events (Wear OS crown / rotating bezel).
2//!
3//! This mirrors Jetpack Compose for Wear OS's
4//! `androidx.compose.ui.input.rotary` package: a [`RotaryScrollEvent`] carries
5//! a scroll amount **already converted to pixels**, and handlers return `true`
6//! to consume the event and stop it propagating.
7//!
8//! # Sign convention
9//!
10//! Android reports the crown/bezel delta on `MotionEvent.AXIS_SCROLL` in
11//! *detents*, where a **positive** value means the user scrolled *up / away
12//! from themselves*. Compose negates that value before scaling it to pixels,
13//! so a positive `AXIS_SCROLL` becomes a **negative**
14//! [`RotaryScrollEvent::vertical_scroll_pixels`].
15//!
16//! Evidence — `AndroidComposeView.android.kt`, `handleRotaryEvent`
17//! (androidx-main):
18//!
19//! ```text
20//! private fun handleRotaryEvent(event: MotionEvent): Boolean {
21//!     val config = android.view.ViewConfiguration.get(context)
22//!     val axisValue = -event.getAxisValue(AXIS_SCROLL)
23//!     val rotaryEvent =
24//!         RotaryScrollEvent(
25//!             verticalScrollPixels = axisValue * getScaledVerticalScrollFactor(config, context),
26//!             horizontalScrollPixels =
27//!                 axisValue * getScaledHorizontalScrollFactor(config, context),
28//!             uptimeMillis = event.eventTime,
29//!             inputDeviceId = event.deviceId,
30//!         )
31//!     ...
32//! }
33//! ```
34//!
35//! Note that Compose feeds the *same* (negated) `AXIS_SCROLL` value into both
36//! the vertical and the horizontal field, scaled by the respective scroll
37//! factor — it does **not** read `AXIS_HSCROLL`. A rotary encoder has one
38//! degree of freedom; the two fields exist so a horizontally scrolling
39//! container can consume the same gesture. [`RotaryScrollEvent::from_detents`]
40//! reproduces this exactly.
41//!
42//! The resulting pixel value is a *scroll amount in content space*: it can be
43//! handed straight to a scroll container's "scroll by N pixels" API with the
44//! same meaning a wheel/touch scroll delta would have.
45
46/// Android's default vertical scroll factor expressed in density-independent
47/// pixels.
48///
49/// Android derives the real factor from the current theme's
50/// `listPreferredItemHeight` via
51/// `ViewConfiguration.getScaledVerticalScrollFactor()`, which needs a JVM
52/// `Context`. Cranpose's Android backend has no JNI dependency in its input
53/// path, so it defaults to this value scaled by the display density and lets
54/// the host override it with the exact platform number (see
55/// `AppShell::set_rotary_scroll_factor`).
56pub const DEFAULT_ROTARY_SCROLL_FACTOR_DP: f32 = 64.0;
57
58/// A rotary scroll event produced by a Wear OS crown or rotating bezel.
59///
60/// Field-for-field equivalent to Compose's
61/// `androidx.compose.ui.input.rotary.RotaryScrollEvent`. The scroll amounts are
62/// in **pixels**, not detents: the platform layer has already multiplied the
63/// raw axis value by the system scroll factor.
64///
65/// This type is `Copy` and contains no heap data, so dispatching one allocates
66/// nothing.
67#[derive(Clone, Copy, Debug, Default, PartialEq)]
68pub struct RotaryScrollEvent {
69    /// How far to scroll (in pixels) in a container that scrolls vertically.
70    ///
71    /// Negative when the user turned the crown "up"/away (positive
72    /// `AXIS_SCROLL` on Android); see the [module docs](self) for the
73    /// derivation.
74    pub vertical_scroll_pixels: f32,
75    /// How far to scroll (in pixels) in a container that scrolls horizontally.
76    pub horizontal_scroll_pixels: f32,
77    /// Time in milliseconds at which the event occurred. The zero point is
78    /// platform-dependent (Android's uptime clock, the process start elsewhere),
79    /// so only differences between events are meaningful.
80    pub uptime_millis: u64,
81}
82
83impl RotaryScrollEvent {
84    /// Creates a rotary event from scroll amounts that are already in pixels.
85    pub const fn new(
86        vertical_scroll_pixels: f32,
87        horizontal_scroll_pixels: f32,
88        uptime_millis: u64,
89    ) -> Self {
90        Self {
91            vertical_scroll_pixels,
92            horizontal_scroll_pixels,
93            uptime_millis,
94        }
95    }
96
97    /// Builds a rotary event from a raw Android `AXIS_SCROLL` value in detents.
98    ///
99    /// `detents` is the value straight out of
100    /// `AMotionEvent_getAxisValue(event, AMOTION_EVENT_AXIS_SCROLL, 0)`; the
101    /// scroll factors are `ViewConfiguration.getScaledVerticalScrollFactor()`
102    /// and `getScaledHorizontalScrollFactor()`.
103    ///
104    /// The detent value is negated exactly once, matching Compose (see the
105    /// [module docs](self)), and the *same* negated value feeds both axes.
106    pub fn from_detents(
107        detents: f32,
108        vertical_scroll_factor: f32,
109        horizontal_scroll_factor: f32,
110        uptime_millis: u64,
111    ) -> Self {
112        let axis_value = -detents;
113        Self {
114            vertical_scroll_pixels: axis_value * vertical_scroll_factor,
115            horizontal_scroll_pixels: axis_value * horizontal_scroll_factor,
116            uptime_millis,
117        }
118    }
119
120    /// Returns true when neither axis carries a usable scroll amount.
121    ///
122    /// Non-finite values (NaN/inf from a misbehaving driver) count as empty so
123    /// they are dropped at the ingress instead of poisoning scroll offsets.
124    pub fn is_empty(&self) -> bool {
125        let vertical_dead =
126            !self.vertical_scroll_pixels.is_finite() || self.vertical_scroll_pixels == 0.0;
127        let horizontal_dead =
128            !self.horizontal_scroll_pixels.is_finite() || self.horizontal_scroll_pixels == 0.0;
129        vertical_dead && horizontal_dead
130    }
131}
132
133/// Converts a raw Android `AXIS_SCROLL` detent value into pixels using
134/// Compose's sign convention: positive detents (crown turned up/away) produce a
135/// **negative** pixel amount.
136pub fn rotary_scroll_pixels_from_detents(detents: f32, scroll_factor: f32) -> f32 {
137    -detents * scroll_factor
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn new_stores_pixel_amounts_verbatim() {
146        let event = RotaryScrollEvent::new(-12.0, 3.5, 4_200);
147
148        assert_eq!(event.vertical_scroll_pixels, -12.0);
149        assert_eq!(event.horizontal_scroll_pixels, 3.5);
150        assert_eq!(event.uptime_millis, 4_200);
151    }
152
153    #[test]
154    fn positive_detents_become_negative_pixels() {
155        // Compose: `val axisValue = -event.getAxisValue(AXIS_SCROLL)`.
156        // A crown turn that reports +1.0 detent must scroll by a NEGATIVE
157        // pixel amount.
158        assert_eq!(rotary_scroll_pixels_from_detents(1.0, 64.0), -64.0);
159        assert_eq!(rotary_scroll_pixels_from_detents(-1.0, 64.0), 64.0);
160        assert_eq!(rotary_scroll_pixels_from_detents(0.0, 64.0), 0.0);
161    }
162
163    #[test]
164    fn from_detents_matches_compose_and_feeds_both_axes() {
165        let event = RotaryScrollEvent::from_detents(2.0, 64.0, 48.0, 9);
166
167        // Same negated axis value scaled by each factor -- Compose does NOT
168        // read AXIS_HSCROLL for rotary.
169        assert_eq!(event.vertical_scroll_pixels, -128.0);
170        assert_eq!(event.horizontal_scroll_pixels, -96.0);
171        assert_eq!(event.uptime_millis, 9);
172    }
173
174    #[test]
175    fn from_detents_is_sign_symmetric() {
176        let up = RotaryScrollEvent::from_detents(1.5, 64.0, 64.0, 0);
177        let down = RotaryScrollEvent::from_detents(-1.5, 64.0, 64.0, 0);
178
179        assert_eq!(up.vertical_scroll_pixels, -down.vertical_scroll_pixels);
180        assert!(up.vertical_scroll_pixels < 0.0);
181        assert!(down.vertical_scroll_pixels > 0.0);
182    }
183
184    #[test]
185    fn is_empty_rejects_zero_and_non_finite_amounts() {
186        assert!(RotaryScrollEvent::default().is_empty());
187        assert!(RotaryScrollEvent::new(0.0, 0.0, 1).is_empty());
188        assert!(RotaryScrollEvent::new(f32::NAN, f32::INFINITY, 1).is_empty());
189
190        assert!(!RotaryScrollEvent::new(-1.0, 0.0, 1).is_empty());
191        assert!(!RotaryScrollEvent::new(0.0, 2.0, 1).is_empty());
192    }
193
194    #[test]
195    fn event_is_copy_and_allocation_free() {
196        // Guards the "no allocation per event" constraint: a Copy struct of
197        // three scalars cannot heap-allocate on dispatch.
198        fn assert_copy<T: Copy>() {}
199        assert_copy::<RotaryScrollEvent>();
200        assert_eq!(
201            std::mem::size_of::<RotaryScrollEvent>(),
202            std::mem::size_of::<f32>() * 2 + std::mem::size_of::<u64>()
203        );
204    }
205}