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    /// Builds a rotary event from a mouse-wheel / trackpad sample already in
121    /// logical pixels.
122    ///
123    /// The final target for rotary input is a Wear OS crown, but the machines
124    /// it is developed on have wheels, so every desktop-class host offers its
125    /// wheel to the rotary handlers first. `vertical` and `horizontal` are in
126    /// the shell's wheel convention — positive when the content should move
127    /// down and right, the direction a wheel turned *up* produces — which is
128    /// the same physical direction as a positive Android detent. Both are
129    /// therefore negated exactly once, like [`from_detents`](Self::from_detents),
130    /// so a wheel turned up and a crown turned up land on the same negative
131    /// [`vertical_scroll_pixels`](Self::vertical_scroll_pixels).
132    ///
133    /// Unlike the detent path, a wheel really does have two axes, so each one
134    /// is carried through on its own rather than fanned out from a single
135    /// value.
136    pub fn from_wheel_pixels(vertical: f32, horizontal: f32, uptime_millis: u64) -> Self {
137        Self {
138            vertical_scroll_pixels: -vertical,
139            horizontal_scroll_pixels: -horizontal,
140            uptime_millis,
141        }
142    }
143
144    /// Returns true when neither axis carries a usable scroll amount.
145    ///
146    /// Non-finite values (NaN/inf from a misbehaving driver) count as empty so
147    /// they are dropped at the ingress instead of poisoning scroll offsets.
148    pub fn is_empty(&self) -> bool {
149        let vertical_dead =
150            !self.vertical_scroll_pixels.is_finite() || self.vertical_scroll_pixels == 0.0;
151        let horizontal_dead =
152            !self.horizontal_scroll_pixels.is_finite() || self.horizontal_scroll_pixels == 0.0;
153        vertical_dead && horizontal_dead
154    }
155}
156
157/// Converts a raw Android `AXIS_SCROLL` detent value into pixels using
158/// Compose's sign convention: positive detents (crown turned up/away) produce a
159/// **negative** pixel amount.
160pub fn rotary_scroll_pixels_from_detents(detents: f32, scroll_factor: f32) -> f32 {
161    -detents * scroll_factor
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn new_stores_pixel_amounts_verbatim() {
170        let event = RotaryScrollEvent::new(-12.0, 3.5, 4_200);
171
172        assert_eq!(event.vertical_scroll_pixels, -12.0);
173        assert_eq!(event.horizontal_scroll_pixels, 3.5);
174        assert_eq!(event.uptime_millis, 4_200);
175    }
176
177    #[test]
178    fn positive_detents_become_negative_pixels() {
179        // Compose: `val axisValue = -event.getAxisValue(AXIS_SCROLL)`.
180        // A crown turn that reports +1.0 detent must scroll by a NEGATIVE
181        // pixel amount.
182        assert_eq!(rotary_scroll_pixels_from_detents(1.0, 64.0), -64.0);
183        assert_eq!(rotary_scroll_pixels_from_detents(-1.0, 64.0), 64.0);
184        assert_eq!(rotary_scroll_pixels_from_detents(0.0, 64.0), 0.0);
185    }
186
187    #[test]
188    fn from_detents_matches_compose_and_feeds_both_axes() {
189        let event = RotaryScrollEvent::from_detents(2.0, 64.0, 48.0, 9);
190
191        // Same negated axis value scaled by each factor -- Compose does NOT
192        // read AXIS_HSCROLL for rotary.
193        assert_eq!(event.vertical_scroll_pixels, -128.0);
194        assert_eq!(event.horizontal_scroll_pixels, -96.0);
195        assert_eq!(event.uptime_millis, 9);
196    }
197
198    #[test]
199    fn from_detents_is_sign_symmetric() {
200        let up = RotaryScrollEvent::from_detents(1.5, 64.0, 64.0, 0);
201        let down = RotaryScrollEvent::from_detents(-1.5, 64.0, 64.0, 0);
202
203        assert_eq!(up.vertical_scroll_pixels, -down.vertical_scroll_pixels);
204        assert!(up.vertical_scroll_pixels < 0.0);
205        assert!(down.vertical_scroll_pixels > 0.0);
206    }
207
208    #[test]
209    fn a_wheel_turned_up_lands_where_a_crown_turned_up_does() {
210        // The two ingresses must agree on direction or the same gesture
211        // scrolls opposite ways on a watch and on the machine it is built on.
212        let wheel = RotaryScrollEvent::from_wheel_pixels(64.0, 0.0, 0);
213        let crown = RotaryScrollEvent::from_detents(1.0, 64.0, 64.0, 0);
214
215        assert_eq!(
216            wheel.vertical_scroll_pixels, crown.vertical_scroll_pixels,
217            "a positive wheel delta and a positive detent are the same physical turn"
218        );
219        assert!(wheel.vertical_scroll_pixels < 0.0);
220    }
221
222    #[test]
223    fn a_wheel_carries_each_axis_on_its_own() {
224        // A crown has one degree of freedom and fans it across both fields; a
225        // wheel has two, and a horizontal nudge must not become a vertical one.
226        let event = RotaryScrollEvent::from_wheel_pixels(12.0, -5.0, 77);
227
228        assert_eq!(event.vertical_scroll_pixels, -12.0);
229        assert_eq!(event.horizontal_scroll_pixels, 5.0);
230        assert_eq!(event.uptime_millis, 77);
231        assert!(RotaryScrollEvent::from_wheel_pixels(0.0, 0.0, 1).is_empty());
232    }
233
234    #[test]
235    fn is_empty_rejects_zero_and_non_finite_amounts() {
236        assert!(RotaryScrollEvent::default().is_empty());
237        assert!(RotaryScrollEvent::new(0.0, 0.0, 1).is_empty());
238        assert!(RotaryScrollEvent::new(f32::NAN, f32::INFINITY, 1).is_empty());
239
240        assert!(!RotaryScrollEvent::new(-1.0, 0.0, 1).is_empty());
241        assert!(!RotaryScrollEvent::new(0.0, 2.0, 1).is_empty());
242    }
243
244    #[test]
245    fn event_is_copy_and_allocation_free() {
246        // Guards the "no allocation per event" constraint: a Copy struct of
247        // three scalars cannot heap-allocate on dispatch.
248        fn assert_copy<T: Copy>() {}
249        assert_copy::<RotaryScrollEvent>();
250        assert_eq!(
251            std::mem::size_of::<RotaryScrollEvent>(),
252            std::mem::size_of::<f32>() * 2 + std::mem::size_of::<u64>()
253        );
254    }
255}