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/// Turns continuous rotary travel into discrete steps while retaining sub-step
158/// travel between events. The input and step size use the same caller-chosen
159/// unit, typically platform-resolved pixels.
160#[derive(Clone, Copy, Debug, PartialEq)]
161pub struct RotaryStepAccumulator {
162 pixels_per_step: f32,
163 pending_pixels: f32,
164}
165
166impl RotaryStepAccumulator {
167 /// Creates an accumulator for the given amount of travel per step.
168 pub fn new(pixels_per_step: f32) -> Self {
169 assert!(
170 pixels_per_step.is_finite() && pixels_per_step > 0.0,
171 "rotary step size must be finite and positive"
172 );
173 Self {
174 pixels_per_step,
175 pending_pixels: 0.0,
176 }
177 }
178
179 /// Adds a rotary delta and returns all whole steps crossed by this event.
180 pub fn accept(&mut self, pixels: f32) -> i32 {
181 if !pixels.is_finite() || pixels == 0.0 {
182 return 0;
183 }
184 self.pending_pixels += pixels;
185 let steps = (self.pending_pixels / self.pixels_per_step).trunc() as i32;
186 self.pending_pixels -= steps as f32 * self.pixels_per_step;
187 steps
188 }
189
190 /// Drops any partial step retained from previous events.
191 pub fn reset(&mut self) {
192 self.pending_pixels = 0.0;
193 }
194}
195
196/// Converts a raw Android `AXIS_SCROLL` detent value into pixels using
197/// Compose's sign convention: positive detents (crown turned up/away) produce a
198/// **negative** pixel amount.
199pub fn rotary_scroll_pixels_from_detents(detents: f32, scroll_factor: f32) -> f32 {
200 -detents * scroll_factor
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 #[test]
208 fn new_stores_pixel_amounts_verbatim() {
209 let event = RotaryScrollEvent::new(-12.0, 3.5, 4_200);
210
211 assert_eq!(event.vertical_scroll_pixels, -12.0);
212 assert_eq!(event.horizontal_scroll_pixels, 3.5);
213 assert_eq!(event.uptime_millis, 4_200);
214 }
215
216 #[test]
217 fn step_accumulator_retains_partial_travel_and_emits_every_crossed_step() {
218 let mut steps = RotaryStepAccumulator::new(10.0);
219 assert_eq!(steps.accept(6.0), 0);
220 assert_eq!(steps.accept(6.0), 1);
221 assert_eq!(steps.accept(29.0), 3);
222 assert_eq!(steps.accept(-12.0), -1);
223 }
224
225 #[test]
226 fn step_accumulator_reset_drops_partial_travel() {
227 let mut steps = RotaryStepAccumulator::new(10.0);
228 assert_eq!(steps.accept(9.0), 0);
229 steps.reset();
230 assert_eq!(steps.accept(1.0), 0);
231 }
232
233 #[test]
234 fn positive_detents_become_negative_pixels() {
235 assert_eq!(rotary_scroll_pixels_from_detents(1.0, 64.0), -64.0);
236 assert_eq!(rotary_scroll_pixels_from_detents(-1.0, 64.0), 64.0);
237 assert_eq!(rotary_scroll_pixels_from_detents(0.0, 64.0), 0.0);
238 }
239
240 #[test]
241 fn from_detents_matches_compose_and_feeds_both_axes() {
242 let event = RotaryScrollEvent::from_detents(2.0, 64.0, 48.0, 9);
243
244 assert_eq!(event.vertical_scroll_pixels, -128.0);
245 assert_eq!(event.horizontal_scroll_pixels, -96.0);
246 assert_eq!(event.uptime_millis, 9);
247 }
248
249 #[test]
250 fn from_detents_is_sign_symmetric() {
251 let up = RotaryScrollEvent::from_detents(1.5, 64.0, 64.0, 0);
252 let down = RotaryScrollEvent::from_detents(-1.5, 64.0, 64.0, 0);
253
254 assert_eq!(up.vertical_scroll_pixels, -down.vertical_scroll_pixels);
255 assert!(up.vertical_scroll_pixels < 0.0);
256 assert!(down.vertical_scroll_pixels > 0.0);
257 }
258
259 #[test]
260 fn a_wheel_turned_up_lands_where_a_crown_turned_up_does() {
261 let wheel = RotaryScrollEvent::from_wheel_pixels(64.0, 0.0, 0);
262 let crown = RotaryScrollEvent::from_detents(1.0, 64.0, 64.0, 0);
263
264 assert_eq!(
265 wheel.vertical_scroll_pixels, crown.vertical_scroll_pixels,
266 "a positive wheel delta and a positive detent are the same physical turn"
267 );
268 assert!(wheel.vertical_scroll_pixels < 0.0);
269 }
270
271 #[test]
272 fn a_wheel_carries_each_axis_on_its_own() {
273 let event = RotaryScrollEvent::from_wheel_pixels(12.0, -5.0, 77);
274
275 assert_eq!(event.vertical_scroll_pixels, -12.0);
276 assert_eq!(event.horizontal_scroll_pixels, 5.0);
277 assert_eq!(event.uptime_millis, 77);
278 assert!(RotaryScrollEvent::from_wheel_pixels(0.0, 0.0, 1).is_empty());
279 }
280
281 #[test]
282 fn is_empty_rejects_zero_and_non_finite_amounts() {
283 assert!(RotaryScrollEvent::default().is_empty());
284 assert!(RotaryScrollEvent::new(0.0, 0.0, 1).is_empty());
285 assert!(RotaryScrollEvent::new(f32::NAN, f32::INFINITY, 1).is_empty());
286
287 assert!(!RotaryScrollEvent::new(-1.0, 0.0, 1).is_empty());
288 assert!(!RotaryScrollEvent::new(0.0, 2.0, 1).is_empty());
289 }
290
291 #[test]
292 fn event_is_copy_and_allocation_free() {
293 fn assert_copy<T: Copy>() {}
294 assert_copy::<RotaryScrollEvent>();
295 assert_eq!(
296 std::mem::size_of::<RotaryScrollEvent>(),
297 std::mem::size_of::<f32>() * 2 + std::mem::size_of::<u64>()
298 );
299 }
300}