Skip to main content

cranpose_services/
haptics.rs

1//! Haptic feedback — the framework analogue of Jetpack's `LocalHapticFeedback`.
2//!
3//! The compiled-in default is a no-op; platform backends install a real
4//! implementation through [`set_platform_haptics`] (iOS `UIFeedbackGenerator`,
5//! Android `Vibrator`). Desktop and the web have no haptics and drop it.
6
7use cranpose_core::compositionLocalOfWithPolicy;
8use cranpose_core::CompositionLocal;
9use cranpose_core::CompositionLocalProvider;
10use cranpose_macros::composable;
11use std::cell::RefCell;
12use std::rc::Rc;
13
14/// A haptic feedback event.
15#[derive(Clone, Copy, PartialEq, Eq, Debug)]
16pub enum HapticFeedback {
17    /// A light physical impact (e.g. a small control toggling).
18    ImpactLight,
19    /// A medium physical impact (e.g. a button press).
20    ImpactMedium,
21    /// A heavy physical impact (e.g. a large snap).
22    ImpactHeavy,
23    /// A selection change (e.g. scrubbing through a picker).
24    Selection,
25    /// A task completed successfully.
26    Success,
27    /// A warning.
28    Warning,
29    /// An error / rejected action.
30    Error,
31}
32
33/// Performs haptic feedback. Installed by the platform backend; the default is
34/// a no-op.
35pub trait Haptics {
36    fn perform(&self, feedback: HapticFeedback);
37}
38
39pub type HapticsRef = Rc<dyn Haptics>;
40
41struct NoopHaptics;
42
43impl Haptics for NoopHaptics {
44    fn perform(&self, _feedback: HapticFeedback) {}
45}
46
47thread_local! {
48    static PLATFORM_HAPTICS: RefCell<Option<HapticsRef>> = const { RefCell::new(None) };
49}
50
51/// Installs a platform haptics implementation, replacing any previous one.
52pub fn set_platform_haptics(haptics: HapticsRef) {
53    PLATFORM_HAPTICS.with(|cell| *cell.borrow_mut() = Some(haptics));
54}
55
56/// Removes any registered platform haptics (tests and teardown).
57pub fn clear_platform_haptics() {
58    PLATFORM_HAPTICS.with(|cell| *cell.borrow_mut() = None);
59}
60
61pub fn default_haptics() -> HapticsRef {
62    PLATFORM_HAPTICS
63        .with(|cell| cell.borrow().clone())
64        .unwrap_or_else(|| Rc::new(NoopHaptics))
65}
66
67pub fn local_haptics() -> CompositionLocal<HapticsRef> {
68    thread_local! {
69        static LOCAL_HAPTICS: RefCell<Option<CompositionLocal<HapticsRef>>> = const { RefCell::new(None) };
70    }
71
72    LOCAL_HAPTICS.with(|cell| {
73        let mut local = cell.borrow_mut();
74        local
75            .get_or_insert_with(|| compositionLocalOfWithPolicy(default_haptics, Rc::ptr_eq))
76            .clone()
77    })
78}
79
80#[allow(non_snake_case)]
81#[composable]
82pub fn ProvideHaptics(content: impl FnOnce()) {
83    let haptics = cranpose_core::remember(default_haptics).with(|state| state.clone());
84    let local = local_haptics();
85    CompositionLocalProvider(vec![local.provides(haptics)], move || {
86        content();
87    });
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use std::cell::Cell;
94
95    #[test]
96    fn registered_haptics_receives_events() {
97        clear_platform_haptics();
98        default_haptics().perform(HapticFeedback::Selection); // no-op, no panic
99
100        struct Rec(Rc<Cell<u32>>);
101        impl Haptics for Rec {
102            fn perform(&self, _f: HapticFeedback) {
103                self.0.set(self.0.get() + 1);
104            }
105        }
106        let count = Rc::new(Cell::new(0));
107        set_platform_haptics(Rc::new(Rec(count.clone())));
108        default_haptics().perform(HapticFeedback::ImpactMedium);
109        assert_eq!(count.get(), 1);
110        clear_platform_haptics();
111    }
112}