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    OwnedMutableState, RuntimeCell, 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: RuntimeCell<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: RuntimeCell::with_runtime(
113                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()
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.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            | PointerEventKind::ButtonDown(_)
417            | PointerEventKind::ButtonUp(_) => {}
418        }
419    }
420
421    fn end(source: MutableInteractionSource, active: PressInteractionPress) {
422        source.cancel(active);
423    }
424}
425
426#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
427struct HoverTracker {
428    enabled: bool,
429}
430
431impl InteractionTracker for HoverTracker {
432    type Active = HoverInteractionEnter;
433
434    fn handle(
435        self,
436        source: MutableInteractionSource,
437        active: &RefCell<Option<HoverInteractionEnter>>,
438        event: &PointerEvent,
439    ) {
440        match event.kind {
441            PointerEventKind::Enter | PointerEventKind::Move
442                if self.enabled && active.borrow().is_none() =>
443            {
444                *active.borrow_mut() = Some(source.enter_hover());
445            }
446            PointerEventKind::Exit => {
447                if let Some(enter) = active.borrow_mut().take() {
448                    source.exit_hover(enter);
449                }
450            }
451            _ => {}
452        }
453    }
454
455    fn end(source: MutableInteractionSource, active: HoverInteractionEnter) {
456        source.exit_hover(active);
457    }
458}
459
460#[derive(Clone)]
461struct InteractionElement<T> {
462    interaction_source: MutableInteractionSource,
463    tracker: T,
464}
465
466impl<T: InteractionTracker> std::fmt::Debug for InteractionElement<T> {
467    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
468        f.debug_struct("InteractionElement")
469            .field("source_id", &self.interaction_source.id())
470            .field("tracker", &self.tracker)
471            .finish()
472    }
473}
474
475impl<T: InteractionTracker> PartialEq for InteractionElement<T> {
476    fn eq(&self, other: &Self) -> bool {
477        self.interaction_source == other.interaction_source && self.tracker == other.tracker
478    }
479}
480
481impl<T: InteractionTracker> Eq for InteractionElement<T> {}
482
483impl<T: InteractionTracker> Hash for InteractionElement<T> {
484    fn hash<H: Hasher>(&self, state: &mut H) {
485        self.tracker.hash(state);
486        self.interaction_source.id().hash(state);
487    }
488}
489
490impl<T: InteractionTracker> ModifierNodeElement for InteractionElement<T> {
491    type Node = InteractionNode<T>;
492
493    fn create(&self) -> Self::Node {
494        let active = Rc::new(RefCell::new(None));
495        InteractionNode {
496            interaction_source: self.interaction_source,
497            tracker: self.tracker,
498            cached_handler: InteractionNode::handler(
499                self.interaction_source,
500                self.tracker,
501                active.clone(),
502            ),
503            active,
504            state: NodeState::new(),
505        }
506    }
507
508    fn update(&self, node: &mut Self::Node) {
509        node.update(self.interaction_source, self.tracker);
510    }
511
512    fn capabilities(&self) -> NodeCapabilities {
513        NodeCapabilities::POINTER_INPUT
514    }
515}
516
517struct InteractionNode<T: InteractionTracker> {
518    interaction_source: MutableInteractionSource,
519    tracker: T,
520    active: Rc<RefCell<Option<T::Active>>>,
521    cached_handler: Rc<dyn Fn(PointerEvent)>,
522    state: NodeState,
523}
524
525impl<T: InteractionTracker> InteractionNode<T> {
526    fn update(&mut self, interaction_source: MutableInteractionSource, tracker: T) {
527        if self.interaction_source == interaction_source && self.tracker == tracker {
528            return;
529        }
530        self.end_active();
531        self.interaction_source = interaction_source;
532        self.tracker = tracker;
533        self.cached_handler = Self::handler(interaction_source, tracker, self.active.clone());
534    }
535
536    fn end_active(&self) {
537        if let Some(active) = self.active.borrow_mut().take() {
538            T::end(self.interaction_source, active);
539        }
540    }
541
542    fn handler(
543        interaction_source: MutableInteractionSource,
544        tracker: T,
545        active: Rc<RefCell<Option<T::Active>>>,
546    ) -> Rc<dyn Fn(PointerEvent)> {
547        Rc::new(move |event: PointerEvent| {
548            if event.id == 0 {
549                tracker.handle(interaction_source, &active, &event);
550            }
551        })
552    }
553}
554
555impl<T: InteractionTracker> std::fmt::Debug for InteractionNode<T> {
556    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
557        f.debug_struct("InteractionNode")
558            .field("source_id", &self.interaction_source.id())
559            .field("tracker", &self.tracker)
560            .finish()
561    }
562}
563
564impl<T: InteractionTracker> DelegatableNode for InteractionNode<T> {
565    fn node_state(&self) -> &NodeState {
566        &self.state
567    }
568}
569
570impl<T: InteractionTracker> ModifierNode for InteractionNode<T> {
571    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
572        context.invalidate(InvalidationKind::PointerInput);
573    }
574
575    fn as_pointer_input_node(&self) -> Option<&dyn PointerInputNode> {
576        Some(self)
577    }
578
579    fn as_pointer_input_node_mut(&mut self) -> Option<&mut dyn PointerInputNode> {
580        Some(self)
581    }
582
583    fn on_detach(&mut self) {
584        self.end_active();
585    }
586}
587
588impl<T: InteractionTracker> PointerInputNode for InteractionNode<T> {
589    fn pointer_input_handler(&self) -> Option<Rc<dyn Fn(PointerEvent)>> {
590        Some(self.cached_handler.clone())
591    }
592}
593
594#[cfg(test)]
595#[path = "tests/interaction_tests.rs"]
596mod tests;