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