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}