Skip to main content

cranpose_ui/
interaction.rs

1#![expect(non_snake_case)]
2
3use std::{
4    cell::RefCell,
5    hash::{Hash, Hasher},
6    rc::Rc,
7};
8
9use cranpose_core::{
10    MutableState, OwnedMutableState, RuntimeHandle, State, collections::map::HashSet, remember,
11    with_current_composer,
12};
13use cranpose_foundation::{
14    DelegatableNode, InvalidationKind, ModifierNode, ModifierNodeContext, ModifierNodeElement,
15    NodeCapabilities, NodeState, PointerInputNode,
16};
17
18use crate::{
19    composable,
20    modifier::{Modifier, Point, PointerEvent, PointerEventKind, inspector_metadata},
21};
22
23/// Records the press and hover interactions of the nodes bound to it, and exposes them as state.
24#[derive(Clone, Copy)]
25pub struct MutableInteractionSource {
26    inner: MutableState<Rc<MutableInteractionSourceInner>>,
27}
28
29struct MutableInteractionSourceInner {
30    next_id: RefCell<u64>,
31    active_presses: RefCell<HashSet<u64>>,
32    active_hovers: RefCell<HashSet<u64>>,
33    pressed: OwnedMutableState<bool>,
34    hovered: OwnedMutableState<bool>,
35    last_interaction: OwnedMutableState<Option<Interaction>>,
36}
37
38/// One event an interaction source received.
39#[derive(Clone, Copy, Debug, PartialEq)]
40pub enum Interaction {
41    /// A press, release or cancel of a press.
42    Press(PressInteraction),
43    /// A hover enter or exit.
44    Hover(HoverInteraction),
45}
46
47/// The pointer resting over a node: Compose's `HoverInteraction`.
48#[derive(Clone, Copy, Debug, PartialEq)]
49pub enum HoverInteraction {
50    /// A pointer came to rest over a node.
51    Enter(HoverInteractionEnter),
52    /// A pointer left a node it was resting over.
53    Exit(HoverInteractionExit),
54}
55
56/// A pointer came to rest over a node.
57#[derive(Clone, Copy, Debug, PartialEq)]
58pub struct HoverInteractionEnter {
59    id: u64,
60}
61
62/// The pointer that [`Self::enter`] reported left.
63#[derive(Clone, Copy, Debug, PartialEq)]
64pub struct HoverInteractionExit {
65    /// The enter that this exit ends.
66    pub enter: HoverInteractionEnter,
67}
68
69/// The events of one press on a node.
70#[derive(Clone, Copy, Debug, PartialEq)]
71pub enum PressInteraction {
72    /// The pointer went down on the node.
73    Press(PressInteractionPress),
74    /// The pointer went up after a press.
75    Release(PressInteractionRelease),
76    /// The press ended without a release, for example because a parent consumed the gesture.
77    Cancel(PressInteractionCancel),
78}
79
80/// A pointer went down on a node.
81#[derive(Clone, Copy, Debug, PartialEq)]
82pub struct PressInteractionPress {
83    id: u64,
84    /// Where the pointer went down, in the node's local coordinates in logical pixels.
85    pub press_position: Point,
86}
87
88/// A press that ended with the pointer going up.
89#[derive(Clone, Copy, Debug, PartialEq)]
90pub struct PressInteractionRelease {
91    /// The press that ended.
92    pub press: PressInteractionPress,
93}
94
95/// A press that ended without the pointer going up.
96#[derive(Clone, Copy, Debug, PartialEq)]
97pub struct PressInteractionCancel {
98    /// The press that ended.
99    pub press: PressInteractionPress,
100}
101
102impl MutableInteractionSource {
103    /// Creates a source bound to the runtime of the composition that is active now.
104    pub fn new() -> Self {
105        let runtime = with_current_composer(cranpose_core::Composer::runtime_handle);
106        Self::with_runtime(runtime)
107    }
108
109    /// Creates a source bound to `runtime`, for use outside composition.
110    pub fn with_runtime(runtime: RuntimeHandle) -> Self {
111        Self {
112            inner: MutableState::with_runtime(
113                Rc::new(MutableInteractionSourceInner {
114                    next_id: RefCell::new(1),
115                    active_presses: RefCell::new(HashSet::default()),
116                    active_hovers: RefCell::new(HashSet::default()),
117                    pressed: OwnedMutableState::with_runtime(false, runtime.clone()),
118                    hovered: OwnedMutableState::with_runtime(false, runtime.clone()),
119                    last_interaction: OwnedMutableState::with_runtime(None, runtime.clone()),
120                }),
121                runtime,
122            ),
123        }
124    }
125
126    fn inner(&self) -> Rc<MutableInteractionSourceInner> {
127        self.inner.get_non_reactive()
128    }
129
130    /// Returns an id that is stable for this source and differs from the ids of other sources.
131    pub fn id(&self) -> u64 {
132        let mut hasher = cranpose_core::hash::default::new();
133        self.inner.runtime_state_id().hash(&mut hasher);
134        hasher.finish()
135    }
136
137    /// The next id this source hands an interaction.
138    fn next_id(&self) -> u64 {
139        let inner = self.inner();
140        let mut next_id = inner.next_id.borrow_mut();
141        let id = *next_id;
142        *next_id = id.saturating_add(1);
143        id
144    }
145
146    /// Reports a press at `press_position` in the node's local coordinates, in logical pixels, and
147    /// returns the press to end with [`Self::release`] or [`Self::cancel`].
148    pub fn press(&self, press_position: Point) -> PressInteractionPress {
149        let press = PressInteractionPress {
150            id: self.next_id(),
151            press_position,
152        };
153        self.emit(Interaction::Press(PressInteraction::Press(press)));
154        press
155    }
156
157    /// Reports that `press` ended with the pointer going up.
158    pub fn release(&self, press: PressInteractionPress) {
159        self.emit(Interaction::Press(PressInteraction::Release(
160            PressInteractionRelease { press },
161        )));
162    }
163
164    /// Reports that `press` ended without the pointer going up.
165    pub fn cancel(&self, press: PressInteractionPress) {
166        self.emit(Interaction::Press(PressInteraction::Cancel(
167            PressInteractionCancel { press },
168        )));
169    }
170
171    /// Reports that a pointer came to rest over a node, Compose's
172    /// `emit(HoverInteraction.Enter())`, and returns the enter to end it
173    /// with.
174    pub fn enter_hover(&self) -> HoverInteractionEnter {
175        let enter = HoverInteractionEnter { id: self.next_id() };
176        self.emit(Interaction::Hover(HoverInteraction::Enter(enter)));
177        enter
178    }
179
180    /// Reports that the pointer `enter` reported left.
181    pub fn exit_hover(&self, enter: HoverInteractionEnter) {
182        self.emit(Interaction::Hover(HoverInteraction::Exit(
183            HoverInteractionExit { enter },
184        )));
185    }
186
187    /// Records `interaction` and updates the pressed and hovered state to match.
188    pub fn emit(&self, interaction: Interaction) {
189        let inner = self.inner();
190        inner.last_interaction.set(Some(interaction));
191        let (active, state, id, starts) = match interaction {
192            Interaction::Press(press) => {
193                let (id, starts) = match press {
194                    PressInteraction::Press(press) => (press.id, true),
195                    PressInteraction::Release(release) => (release.press.id, false),
196                    PressInteraction::Cancel(cancel) => (cancel.press.id, false),
197                };
198                (&inner.active_presses, &inner.pressed, id, starts)
199            }
200            Interaction::Hover(hover) => {
201                let (id, starts) = match hover {
202                    HoverInteraction::Enter(enter) => (enter.id, true),
203                    HoverInteraction::Exit(exit) => (exit.enter.id, false),
204                };
205                (&inner.active_hovers, &inner.hovered, id, starts)
206            }
207        };
208        let any = {
209            let mut active = active.borrow_mut();
210            if starts {
211                active.insert(id);
212            } else {
213                active.remove(&id);
214            }
215            !active.is_empty()
216        };
217        if state.get_non_reactive() != any {
218            state.set(any);
219        }
220    }
221
222    /// Returns whether the interaction source is currently pressed as a
223    /// reactive [`State`].
224    ///
225    /// Mirrors Jetpack Compose: `InteractionSource.collectIsPressedAsState()`.
226    ///
227    /// The value flips to `true` when a `PressInteraction::Press` is emitted
228    /// and back to `false` once every active press has seen a matching
229    /// `PressInteraction::Release` or `PressInteraction::Cancel`. Reading the
230    /// returned state inside a composable subscribes the enclosing recompose
231    /// scope, so the composable recomposes whenever the pressed state changes.
232    pub fn collectIsPressedAsState(&self) -> State<bool> {
233        self.inner().pressed.as_state()
234    }
235
236    /// Returns whether a pointer rests over a node of this source as a
237    /// reactive [`State`]: `true` from a `HoverInteraction::Enter` until
238    /// every enter has seen its `HoverInteraction::Exit`.
239    ///
240    /// Mirrors Jetpack Compose: `InteractionSource.collectIsHoveredAsState()`.
241    pub fn collectIsHoveredAsState(&self) -> State<bool> {
242        self.inner().hovered.as_state()
243    }
244
245    /// Returns the interaction that happened last, or `None` before the first one, as a reactive
246    /// [`State`].
247    pub fn collectLastInteractionAsState(&self) -> State<Option<Interaction>> {
248        self.inner().last_interaction.as_state()
249    }
250}
251
252impl PressInteractionPress {
253    /// Returns the id of this press, which differs from the id of every other interaction of the
254    /// source.
255    pub fn id(&self) -> u64 {
256        self.id
257    }
258}
259
260impl HoverInteractionEnter {
261    /// Returns the id of this enter, which differs from the id of every other interaction of the
262    /// source.
263    pub fn id(&self) -> u64 {
264        self.id
265    }
266}
267
268impl std::fmt::Debug for MutableInteractionSource {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        f.debug_struct("MutableInteractionSource")
271            .field("id", &self.id())
272            .finish()
273    }
274}
275
276impl PartialEq for MutableInteractionSource {
277    fn eq(&self, other: &Self) -> bool {
278        self.inner == other.inner
279    }
280}
281
282impl Eq for MutableInteractionSource {}
283
284impl Default for MutableInteractionSource {
285    fn default() -> Self {
286        Self::new()
287    }
288}
289
290/// Returns an interaction source that stays the same across recompositions.
291#[composable]
292pub fn rememberMutableInteractionSource() -> MutableInteractionSource {
293    let runtime = with_current_composer(|composer| composer.runtime_handle());
294    remember(move || MutableInteractionSource::with_runtime(runtime)).with(|source| *source)
295}
296
297/// Free-function form of
298/// [`MutableInteractionSource::collectIsPressedAsState`].
299///
300/// Mirrors Jetpack Compose: `InteractionSource.collectIsPressedAsState()`.
301///
302/// Returns a reactive [`State`] derived from the press interactions emitted
303/// by `interaction_source`: `true` between `PressInteraction::Press` and the
304/// matching `PressInteraction::Release`/`PressInteraction::Cancel`.
305pub fn collect_is_pressed_as_state(interaction_source: &MutableInteractionSource) -> State<bool> {
306    interaction_source.collectIsPressedAsState()
307}
308
309/// Free-function form of
310/// [`MutableInteractionSource::collectIsHoveredAsState`].
311///
312/// Mirrors Jetpack Compose: `InteractionSource.collectIsHoveredAsState()`.
313pub fn collect_is_hovered_as_state(interaction_source: &MutableInteractionSource) -> State<bool> {
314    interaction_source.collectIsHoveredAsState()
315}
316
317impl Modifier {
318    /// Reports presses on this node to `interaction_source`: a press when the primary pointer
319    /// goes down, a release when it goes up, and a cancel when another handler consumes the
320    /// event or the gesture is cancelled.
321    pub fn press_interaction_source(self, interaction_source: MutableInteractionSource) -> Self {
322        self.interaction_node("pressInteractionSource", interaction_source, PressTracker)
323    }
324
325    /// Reports the pointer resting over this node to `interaction_source` as
326    /// a `HoverInteraction::Enter`, and its leaving as the matching
327    /// `HoverInteraction::Exit`, while `enabled`. Disabling the node or
328    /// removing it ends a hover it reported. Compose's
329    /// `Modifier.hoverable(interactionSource, enabled)`.
330    ///
331    /// Example: `let hovered = source.collectIsHoveredAsState();` and
332    /// `Modifier::empty().hoverable(source, true)` on the row it tints.
333    pub fn hoverable(self, interaction_source: MutableInteractionSource, enabled: bool) -> Self {
334        self.interaction_node("hoverable", interaction_source, HoverTracker { enabled })
335    }
336
337    fn interaction_node<T: InteractionTracker>(
338        self,
339        name: &'static str,
340        interaction_source: MutableInteractionSource,
341        tracker: T,
342    ) -> Self {
343        let source_id = interaction_source.id();
344        let modifier = Self::with_element(InteractionElement {
345            interaction_source,
346            tracker,
347        })
348        .with_inspector_metadata(inspector_metadata(name, move |info| {
349            info.add_property("sourceId", source_id.to_string());
350        }));
351        self.then(modifier)
352    }
353}
354
355/// What a pointer node reports to an interaction source: which pointer
356/// events start and end the interaction it tracks, and how to end one
357/// early.
358trait InteractionTracker: Copy + PartialEq + std::fmt::Debug + Hash + 'static {
359    /// The interaction the node holds while it lasts.
360    type Active: Copy + 'static;
361
362    /// Feeds the primary pointer's `event` to `source`, starting or ending
363    /// the interaction `active` holds.
364    fn handle(
365        self,
366        source: MutableInteractionSource,
367        active: &RefCell<Option<Self::Active>>,
368        event: &PointerEvent,
369    );
370
371    /// Ends `active` early: the node left, or stopped tracking.
372    fn end(source: MutableInteractionSource, active: Self::Active);
373}
374
375#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
376struct PressTracker;
377
378impl InteractionTracker for PressTracker {
379    type Active = PressInteractionPress;
380
381    fn handle(
382        self,
383        source: MutableInteractionSource,
384        active: &RefCell<Option<PressInteractionPress>>,
385        event: &PointerEvent,
386    ) {
387        if event.is_consumed() {
388            if let Some(press) = active.borrow_mut().take() {
389                source.cancel(press);
390            }
391            return;
392        }
393        match event.kind {
394            PointerEventKind::Down => {
395                if active.borrow().is_none() {
396                    *active.borrow_mut() = Some(source.press(event.position));
397                }
398            }
399            PointerEventKind::Up => {
400                if let Some(press) = active.borrow_mut().take() {
401                    source.release(press);
402                }
403            }
404            PointerEventKind::Cancel => {
405                if let Some(press) = active.borrow_mut().take() {
406                    source.cancel(press);
407                }
408            }
409            PointerEventKind::Move
410            | PointerEventKind::Scroll
411            | PointerEventKind::Zoom
412            | PointerEventKind::RotaryScrollPre
413            | PointerEventKind::RotaryScroll
414            | PointerEventKind::Enter
415            | PointerEventKind::Exit => {}
416        }
417    }
418
419    fn end(source: MutableInteractionSource, active: PressInteractionPress) {
420        source.cancel(active);
421    }
422}
423
424#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
425struct HoverTracker {
426    enabled: bool,
427}
428
429impl InteractionTracker for HoverTracker {
430    type Active = HoverInteractionEnter;
431
432    fn handle(
433        self,
434        source: MutableInteractionSource,
435        active: &RefCell<Option<HoverInteractionEnter>>,
436        event: &PointerEvent,
437    ) {
438        match event.kind {
439            PointerEventKind::Enter | PointerEventKind::Move
440                if self.enabled && active.borrow().is_none() =>
441            {
442                *active.borrow_mut() = Some(source.enter_hover());
443            }
444            PointerEventKind::Exit => {
445                if let Some(enter) = active.borrow_mut().take() {
446                    source.exit_hover(enter);
447                }
448            }
449            _ => {}
450        }
451    }
452
453    fn end(source: MutableInteractionSource, active: HoverInteractionEnter) {
454        source.exit_hover(active);
455    }
456}
457
458#[derive(Clone)]
459struct InteractionElement<T> {
460    interaction_source: MutableInteractionSource,
461    tracker: T,
462}
463
464impl<T: InteractionTracker> std::fmt::Debug for InteractionElement<T> {
465    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
466        f.debug_struct("InteractionElement")
467            .field("source_id", &self.interaction_source.id())
468            .field("tracker", &self.tracker)
469            .finish()
470    }
471}
472
473impl<T: InteractionTracker> PartialEq for InteractionElement<T> {
474    fn eq(&self, other: &Self) -> bool {
475        self.interaction_source == other.interaction_source && self.tracker == other.tracker
476    }
477}
478
479impl<T: InteractionTracker> Eq for InteractionElement<T> {}
480
481impl<T: InteractionTracker> Hash for InteractionElement<T> {
482    fn hash<H: Hasher>(&self, state: &mut H) {
483        self.tracker.hash(state);
484        self.interaction_source.id().hash(state);
485    }
486}
487
488impl<T: InteractionTracker> ModifierNodeElement for InteractionElement<T> {
489    type Node = InteractionNode<T>;
490
491    fn create(&self) -> Self::Node {
492        let active = Rc::new(RefCell::new(None));
493        InteractionNode {
494            interaction_source: self.interaction_source,
495            tracker: self.tracker,
496            cached_handler: InteractionNode::handler(
497                self.interaction_source,
498                self.tracker,
499                active.clone(),
500            ),
501            active,
502            state: NodeState::new(),
503        }
504    }
505
506    fn update(&self, node: &mut Self::Node) {
507        node.update(self.interaction_source, self.tracker);
508    }
509
510    fn capabilities(&self) -> NodeCapabilities {
511        NodeCapabilities::POINTER_INPUT
512    }
513}
514
515struct InteractionNode<T: InteractionTracker> {
516    interaction_source: MutableInteractionSource,
517    tracker: T,
518    active: Rc<RefCell<Option<T::Active>>>,
519    cached_handler: Rc<dyn Fn(PointerEvent)>,
520    state: NodeState,
521}
522
523impl<T: InteractionTracker> InteractionNode<T> {
524    fn update(&mut self, interaction_source: MutableInteractionSource, tracker: T) {
525        if self.interaction_source == interaction_source && self.tracker == tracker {
526            return;
527        }
528        self.end_active();
529        self.interaction_source = interaction_source;
530        self.tracker = tracker;
531        self.cached_handler = Self::handler(interaction_source, tracker, self.active.clone());
532    }
533
534    fn end_active(&self) {
535        if let Some(active) = self.active.borrow_mut().take() {
536            T::end(self.interaction_source, active);
537        }
538    }
539
540    fn handler(
541        interaction_source: MutableInteractionSource,
542        tracker: T,
543        active: Rc<RefCell<Option<T::Active>>>,
544    ) -> Rc<dyn Fn(PointerEvent)> {
545        Rc::new(move |event: PointerEvent| {
546            if event.id == 0 {
547                tracker.handle(interaction_source, &active, &event);
548            }
549        })
550    }
551}
552
553impl<T: InteractionTracker> std::fmt::Debug for InteractionNode<T> {
554    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
555        f.debug_struct("InteractionNode")
556            .field("source_id", &self.interaction_source.id())
557            .field("tracker", &self.tracker)
558            .finish()
559    }
560}
561
562impl<T: InteractionTracker> DelegatableNode for InteractionNode<T> {
563    fn node_state(&self) -> &NodeState {
564        &self.state
565    }
566}
567
568impl<T: InteractionTracker> ModifierNode for InteractionNode<T> {
569    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
570        context.invalidate(InvalidationKind::PointerInput);
571    }
572
573    fn as_pointer_input_node(&self) -> Option<&dyn PointerInputNode> {
574        Some(self)
575    }
576
577    fn as_pointer_input_node_mut(&mut self) -> Option<&mut dyn PointerInputNode> {
578        Some(self)
579    }
580
581    fn on_detach(&mut self) {
582        self.end_active();
583    }
584}
585
586impl<T: InteractionTracker> PointerInputNode for InteractionNode<T> {
587    fn pointer_input_handler(&self) -> Option<Rc<dyn Fn(PointerEvent)>> {
588        Some(self.cached_handler.clone())
589    }
590}
591
592#[cfg(test)]
593#[path = "tests/interaction_tests.rs"]
594mod tests;