Skip to main content

cranpose_ui/modifier/
rotary_input.rs

1//! Rotary input modifiers (Wear OS crown / rotating bezel).
2//!
3//! Mirrors Jetpack Compose for Wear OS's `Modifier.onRotaryScrollEvent` and
4//! `Modifier.onPreRotaryScrollEvent`. Returning `true` from a handler consumes
5//! the event and stops propagation, exactly as in Compose.
6//!
7//! ## Dispatch order
8//!
9//! Rotary events run two passes over the target node's modifier chain, matching
10//! `RotaryInputModifierNode`'s documented contract:
11//!
12//! 1. **Capture (pre)** — root to focused node, invoking
13//!    [`Modifier::on_pre_rotary_scroll_event`] handlers. An ancestor can
14//!    intercept the event before the focused node sees it.
15//! 2. **Bubble** — focused node to root, invoking
16//!    [`Modifier::on_rotary_scroll_event`] handlers.
17//!
18//! The first handler that returns `true` ends both passes.
19
20use super::{inspector_metadata, Modifier};
21use cranpose_foundation::{
22    impl_pointer_input_node, DelegatableNode, ModifierNode, ModifierNodeElement, NodeCapabilities,
23    NodeState, PointerEvent, PointerEventKind, PointerInputNode, RotaryScrollEvent,
24};
25use std::cell::Cell;
26use std::fmt;
27use std::hash::{Hash, Hasher};
28use std::rc::Rc;
29
30/// Which dispatch pass a rotary handler listens on.
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
32enum RotaryPass {
33    /// Capture pass, root to focused node (`onPreRotaryScrollEvent`).
34    Pre,
35    /// Bubble pass, focused node to root (`onRotaryScrollEvent`).
36    Bubble,
37}
38
39impl RotaryPass {
40    fn matches(self, kind: PointerEventKind) -> bool {
41        matches!(
42            (self, kind),
43            (RotaryPass::Pre, PointerEventKind::RotaryScrollPre)
44                | (RotaryPass::Bubble, PointerEventKind::RotaryScroll)
45        )
46    }
47}
48
49type RotaryHandler = Rc<dyn Fn(RotaryScrollEvent) -> bool>;
50
51impl Modifier {
52    /// Handles rotary scroll events (Pixel Watch crown, Galaxy Watch rotating
53    /// bezel) during the **bubble** pass.
54    ///
55    /// The handler receives a [`RotaryScrollEvent`] whose scroll amounts are
56    /// already in pixels. Return `true` to consume the event and stop it
57    /// propagating to ancestors; return `false` to let it keep bubbling.
58    ///
59    /// Equivalent to Compose's `Modifier.onRotaryScrollEvent`.
60    ///
61    /// ```ignore
62    /// Modifier::new().on_rotary_scroll_event(move |event| {
63    ///     offset.set(offset.get() + event.vertical_scroll_pixels);
64    ///     true
65    /// })
66    /// ```
67    pub fn on_rotary_scroll_event<F>(self, handler: F) -> Self
68    where
69        F: Fn(RotaryScrollEvent) -> bool + 'static,
70    {
71        self.rotary_element(RotaryPass::Bubble, Rc::new(handler), "onRotaryScrollEvent")
72    }
73
74    /// Handles rotary scroll events during the **capture** pass, before the
75    /// focused node sees them.
76    ///
77    /// Return `true` to consume the event and stop it reaching descendants.
78    ///
79    /// Equivalent to Compose's `Modifier.onPreRotaryScrollEvent`.
80    pub fn on_pre_rotary_scroll_event<F>(self, handler: F) -> Self
81    where
82        F: Fn(RotaryScrollEvent) -> bool + 'static,
83    {
84        self.rotary_element(RotaryPass::Pre, Rc::new(handler), "onPreRotaryScrollEvent")
85    }
86
87    fn rotary_element(
88        self,
89        pass: RotaryPass,
90        handler: RotaryHandler,
91        inspector_name: &'static str,
92    ) -> Self {
93        let element = RotaryInputElement::new(pass, handler);
94        let handler_id = element.handler_id;
95        self.then(
96            Self::with_element(element).with_inspector_metadata(inspector_metadata(
97                inspector_name,
98                move |info| {
99                    info.add_property("handlerId", handler_id.to_string());
100                },
101            )),
102        )
103    }
104}
105
106#[derive(Clone)]
107struct RotaryInputElement {
108    pass: RotaryPass,
109    handler: RotaryHandler,
110    handler_id: u64,
111}
112
113impl RotaryInputElement {
114    fn new(pass: RotaryPass, handler: RotaryHandler) -> Self {
115        let handler_id = Rc::as_ptr(&handler) as *const () as usize as u64;
116        Self {
117            pass,
118            handler,
119            handler_id,
120        }
121    }
122}
123
124impl fmt::Debug for RotaryInputElement {
125    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126        f.debug_struct("RotaryInputElement")
127            .field("pass", &self.pass)
128            .field("handler_id", &self.handler_id)
129            .finish()
130    }
131}
132
133impl PartialEq for RotaryInputElement {
134    fn eq(&self, other: &Self) -> bool {
135        // Compare only the pass, never the closure identity: handlers are
136        // recreated on every recomposition, and comparing them would drop and
137        // rebuild the node each frame. Matches `PointerInputElement`.
138        self.pass == other.pass
139    }
140}
141
142impl Eq for RotaryInputElement {}
143
144impl Hash for RotaryInputElement {
145    fn hash<H: Hasher>(&self, state: &mut H) {
146        self.pass.hash(state);
147    }
148}
149
150impl ModifierNodeElement for RotaryInputElement {
151    type Node = RotaryInputModifierNode;
152
153    fn create(&self) -> Self::Node {
154        RotaryInputModifierNode::new(self.pass, self.handler.clone())
155    }
156
157    fn update(&self, node: &mut Self::Node) {
158        // Always refresh the closure so the node calls the latest captured
159        // state, without restarting anything.
160        node.handler.set(Some(self.handler.clone()));
161    }
162
163    fn always_update(&self) -> bool {
164        true
165    }
166
167    fn capabilities(&self) -> NodeCapabilities {
168        NodeCapabilities::POINTER_INPUT
169    }
170}
171
172/// Modifier node that forwards rotary scroll events to an app handler.
173///
174/// Named after Compose's `RotaryInputModifierNode`. It rides the existing
175/// pointer dispatch path: the shell sends rotary passes as
176/// [`PointerEventKind::RotaryScrollPre`]/[`PointerEventKind::RotaryScroll`]
177/// pointer events, and this node filters for its own pass.
178pub struct RotaryInputModifierNode {
179    handler: Rc<Cell<Option<RotaryHandler>>>,
180    dispatch: Rc<dyn Fn(PointerEvent)>,
181    state: NodeState,
182}
183
184impl RotaryInputModifierNode {
185    fn new(pass: RotaryPass, handler: RotaryHandler) -> Self {
186        let handler_cell: Rc<Cell<Option<RotaryHandler>>> = Rc::new(Cell::new(Some(handler)));
187        let handler_for_dispatch = Rc::clone(&handler_cell);
188        // One closure allocated per node at construction time, not per event.
189        let dispatch = Rc::new(move |event: PointerEvent| {
190            if !pass.matches(event.kind) || event.is_consumed() {
191                return;
192            }
193            let Some(rotary) = event.rotary_scroll_event() else {
194                return;
195            };
196            // Take-and-restore keeps the handler behind a `Cell` (no RefCell
197            // borrow can be held across the call, so a handler is free to
198            // rebuild the composition).
199            let Some(handler) = handler_for_dispatch.take() else {
200                return;
201            };
202            let consumed = handler(rotary);
203            if handler_for_dispatch.take().is_none() {
204                // Nothing replaced it while we were running: put ours back.
205                handler_for_dispatch.set(Some(handler));
206            }
207            if consumed {
208                event.consume();
209            }
210        });
211
212        Self {
213            handler: handler_cell,
214            dispatch,
215            state: NodeState::new(),
216        }
217    }
218}
219
220impl ModifierNode for RotaryInputModifierNode {
221    impl_pointer_input_node!();
222}
223
224impl DelegatableNode for RotaryInputModifierNode {
225    fn node_state(&self) -> &NodeState {
226        &self.state
227    }
228}
229
230impl PointerInputNode for RotaryInputModifierNode {
231    fn pointer_input_handler(&self) -> Option<Rc<dyn Fn(PointerEvent)>> {
232        Some(Rc::clone(&self.dispatch))
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use cranpose_ui_graphics::Point;
240    use std::cell::RefCell;
241
242    fn rotary_event(kind: PointerEventKind, vertical: f32) -> PointerEvent {
243        PointerEvent::rotary(
244            kind,
245            RotaryScrollEvent::new(vertical, 0.0, 42),
246            Point { x: 0.0, y: 0.0 },
247        )
248    }
249
250    fn node(pass: RotaryPass, handler: RotaryHandler) -> RotaryInputModifierNode {
251        RotaryInputModifierNode::new(pass, handler)
252    }
253
254    #[test]
255    fn bubble_node_receives_bubble_events_only() {
256        let seen = Rc::new(RefCell::new(Vec::new()));
257        let sink = Rc::clone(&seen);
258        let node = node(
259            RotaryPass::Bubble,
260            Rc::new(move |event: RotaryScrollEvent| {
261                sink.borrow_mut().push(event.vertical_scroll_pixels);
262                false
263            }),
264        );
265        let dispatch = node.pointer_input_handler().expect("handler");
266
267        dispatch(rotary_event(PointerEventKind::RotaryScrollPre, -1.0));
268        dispatch(rotary_event(PointerEventKind::RotaryScroll, -2.0));
269        dispatch(PointerEvent::new(
270            PointerEventKind::Scroll,
271            Point { x: 0.0, y: 0.0 },
272            Point { x: 0.0, y: 0.0 },
273        ));
274
275        assert_eq!(*seen.borrow(), vec![-2.0]);
276    }
277
278    #[test]
279    fn pre_node_receives_capture_events_only() {
280        let seen = Rc::new(RefCell::new(Vec::new()));
281        let sink = Rc::clone(&seen);
282        let node = node(
283            RotaryPass::Pre,
284            Rc::new(move |event: RotaryScrollEvent| {
285                sink.borrow_mut().push(event.vertical_scroll_pixels);
286                false
287            }),
288        );
289        let dispatch = node.pointer_input_handler().expect("handler");
290
291        dispatch(rotary_event(PointerEventKind::RotaryScrollPre, -1.0));
292        dispatch(rotary_event(PointerEventKind::RotaryScroll, -2.0));
293
294        assert_eq!(*seen.borrow(), vec![-1.0]);
295    }
296
297    #[test]
298    fn returning_true_consumes_the_event() {
299        let node = node(RotaryPass::Bubble, Rc::new(|_| true));
300        let dispatch = node.pointer_input_handler().expect("handler");
301
302        let event = rotary_event(PointerEventKind::RotaryScroll, -8.0);
303        dispatch(event.clone());
304
305        assert!(event.is_consumed());
306    }
307
308    #[test]
309    fn returning_false_leaves_the_event_unconsumed() {
310        let node = node(RotaryPass::Bubble, Rc::new(|_| false));
311        let dispatch = node.pointer_input_handler().expect("handler");
312
313        let event = rotary_event(PointerEventKind::RotaryScroll, -8.0);
314        dispatch(event.clone());
315
316        assert!(!event.is_consumed());
317    }
318
319    #[test]
320    fn an_already_consumed_event_never_reaches_the_handler() {
321        // This is what stops propagation: once an inner node consumed the
322        // event, outer nodes in the same pass must not run.
323        let calls = Rc::new(Cell::new(0));
324        let counter = Rc::clone(&calls);
325        let node = node(
326            RotaryPass::Bubble,
327            Rc::new(move |_| {
328                counter.set(counter.get() + 1);
329                false
330            }),
331        );
332        let dispatch = node.pointer_input_handler().expect("handler");
333
334        let event = rotary_event(PointerEventKind::RotaryScroll, -8.0);
335        event.consume();
336        dispatch(event);
337
338        assert_eq!(calls.get(), 0);
339    }
340
341    #[test]
342    fn handler_sees_the_full_rotary_payload() {
343        let captured = Rc::new(Cell::new(None));
344        let sink = Rc::clone(&captured);
345        let node = node(
346            RotaryPass::Bubble,
347            Rc::new(move |event: RotaryScrollEvent| {
348                sink.set(Some(event));
349                true
350            }),
351        );
352        let dispatch = node.pointer_input_handler().expect("handler");
353
354        dispatch(PointerEvent::rotary(
355            PointerEventKind::RotaryScroll,
356            RotaryScrollEvent::new(-64.0, 32.0, 777),
357            Point { x: 0.0, y: 0.0 },
358        ));
359
360        assert_eq!(
361            captured.get(),
362            Some(RotaryScrollEvent::new(-64.0, 32.0, 777))
363        );
364    }
365
366    #[test]
367    fn element_reuses_the_node_across_recomposition() {
368        // Equal elements (same pass) must not churn the node, but the closure
369        // must still be refreshed so it observes the latest state.
370        let first = RotaryInputElement::new(RotaryPass::Bubble, Rc::new(|_| false));
371        let second = RotaryInputElement::new(RotaryPass::Bubble, Rc::new(|_| true));
372        assert_eq!(first, second);
373        assert!(first.always_update());
374
375        let mut node = first.create();
376        second.update(&mut node);
377
378        let event = rotary_event(PointerEventKind::RotaryScroll, -1.0);
379        node.pointer_input_handler().expect("handler")(event.clone());
380
381        assert!(event.is_consumed(), "updated handler should be in effect");
382    }
383
384    #[test]
385    fn pre_and_bubble_elements_are_distinct() {
386        let pre = RotaryInputElement::new(RotaryPass::Pre, Rc::new(|_| false));
387        let bubble = RotaryInputElement::new(RotaryPass::Bubble, Rc::new(|_| false));
388
389        assert_ne!(pre, bubble);
390    }
391
392    #[test]
393    fn modifier_builders_add_pointer_input_elements() {
394        let modifier = Modifier::empty()
395            .on_pre_rotary_scroll_event(|_| false)
396            .on_rotary_scroll_event(|_| true);
397
398        assert_eq!(modifier.elements().len(), 2);
399    }
400}