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//!
7//! Two layers sit on one trait. [`HapticFeedback`] names the seven semantic
8//! events every platform can express, and is what UI code should use.
9//! [`HapticPattern`], [`Haptics::vibrate`] and [`Haptics::perform_effect`]
10//! address the vibrator directly, which is what an app that designs its own
11//! set of distinct "feels" needs; every one of them carries a defaulted body
12//! that degrades to the closest [`HapticFeedback`] constant, so a backend that
13//! implements only [`Haptics::perform`] still answers the whole trait.
14
15use cranpose_core::compositionLocalOfWithPolicy;
16use cranpose_core::CompositionLocal;
17use cranpose_core::CompositionLocalProvider;
18use cranpose_macros::composable;
19use std::cell::RefCell;
20use std::rc::Rc;
21
22/// A haptic feedback event.
23#[derive(Clone, Copy, PartialEq, Eq, Debug)]
24pub enum HapticFeedback {
25    /// A light physical impact (e.g. a small control toggling).
26    ImpactLight,
27    /// A medium physical impact (e.g. a button press).
28    ImpactMedium,
29    /// A heavy physical impact (e.g. a large snap).
30    ImpactHeavy,
31    /// A selection change (e.g. scrubbing through a picker).
32    Selection,
33    /// A task completed successfully.
34    Success,
35    /// A warning.
36    Warning,
37    /// An error / rejected action.
38    Error,
39}
40
41/// A system-defined vibration primitive.
42///
43/// These map to Android's `VibrationEffect.EFFECT_*` constants, which are tuned
44/// per device by the manufacturer and therefore feel more native than a
45/// hand-timed one-shot of the same length.
46#[derive(Clone, Copy, PartialEq, Eq, Debug)]
47pub enum HapticEffect {
48    /// `VibrationEffect.EFFECT_CLICK`.
49    Click,
50    /// `VibrationEffect.EFFECT_TICK` — the lightest primitive.
51    Tick,
52    /// `VibrationEffect.EFFECT_DOUBLE_CLICK`.
53    DoubleClick,
54    /// `VibrationEffect.EFFECT_HEAVY_CLICK`.
55    HeavyClick,
56}
57
58impl HapticEffect {
59    /// The closest [`HapticFeedback`] constant, used by the trait's defaulted
60    /// bodies and by backends without predefined effects.
61    pub fn closest_feedback(self) -> HapticFeedback {
62        match self {
63            HapticEffect::Tick => HapticFeedback::Selection,
64            HapticEffect::Click => HapticFeedback::ImpactLight,
65            HapticEffect::DoubleClick => HapticFeedback::ImpactMedium,
66            HapticEffect::HeavyClick => HapticFeedback::ImpactHeavy,
67        }
68    }
69}
70
71/// Why a haptic pattern could not be built.
72#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
73pub enum HapticError {
74    /// Timings and amplitudes must describe the same number of steps.
75    #[error("waveform has {timings} timings and {amplitudes} amplitudes; they must match")]
76    LengthMismatch {
77        /// How many timings were supplied.
78        timings: usize,
79        /// How many amplitudes were supplied.
80        amplitudes: usize,
81    },
82    /// A waveform needs at least one step.
83    #[error("waveform has no steps")]
84    Empty,
85    /// A waveform whose timings are all zero would never play.
86    #[error("waveform has a total duration of zero")]
87    ZeroDuration,
88    /// The repeat index must point at a step of the waveform.
89    #[error("repeat index {index} is out of range for a {len}-step waveform")]
90    RepeatOutOfRange {
91        /// The requested repeat index.
92        index: usize,
93        /// How many steps the waveform has.
94        len: usize,
95    },
96    /// The waveform is longer than the platform vibrator accepts.
97    #[error("waveform has {len} steps, more than the maximum of {max}")]
98    TooManySteps {
99        /// How many steps were supplied.
100        len: usize,
101        /// The maximum step count.
102        max: usize,
103    },
104}
105
106/// A vibration waveform: alternating durations with a target amplitude each.
107///
108/// This is Android's `VibrationEffect.createWaveform(long[], int[], int)` in
109/// framework terms. Index 0 is an off period by convention (its amplitude is
110/// usually 0), then on, then off — but nothing enforces that, so an app is
111/// free to shape a ramp out of consecutive non-zero amplitudes.
112///
113/// Amplitudes run 0 (off) to 255 (the device's strongest). Devices without
114/// amplitude control treat any non-zero amplitude as full strength; check
115/// [`Haptics::has_amplitude_control`] before designing around subtle levels.
116#[derive(Clone, Debug, PartialEq, Eq)]
117pub struct HapticPattern {
118    timings_ms: Vec<u32>,
119    amplitudes: Vec<u8>,
120    repeat: Option<usize>,
121}
122
123impl HapticPattern {
124    /// The longest waveform the framework passes to a platform vibrator.
125    /// Android's own limit is device-defined and far lower in practice; this
126    /// bound keeps a malformed pattern from reaching JNI at all.
127    pub const MAX_STEPS: usize = 512;
128
129    /// Builds a one-shot waveform.
130    ///
131    /// Fails when the two slices differ in length, when there are no steps, or
132    /// when every timing is zero.
133    pub fn new(timings_ms: &[u32], amplitudes: &[u8]) -> Result<HapticPattern, HapticError> {
134        Self::build(timings_ms, amplitudes, None)
135    }
136
137    /// Builds a waveform that loops back to `repeat_index` until
138    /// [`Haptics::cancel`] stops it.
139    pub fn repeating(
140        timings_ms: &[u32],
141        amplitudes: &[u8],
142        repeat_index: usize,
143    ) -> Result<HapticPattern, HapticError> {
144        Self::build(timings_ms, amplitudes, Some(repeat_index))
145    }
146
147    fn build(
148        timings_ms: &[u32],
149        amplitudes: &[u8],
150        repeat: Option<usize>,
151    ) -> Result<HapticPattern, HapticError> {
152        if timings_ms.len() != amplitudes.len() {
153            return Err(HapticError::LengthMismatch {
154                timings: timings_ms.len(),
155                amplitudes: amplitudes.len(),
156            });
157        }
158        if timings_ms.is_empty() {
159            return Err(HapticError::Empty);
160        }
161        if timings_ms.len() > HapticPattern::MAX_STEPS {
162            return Err(HapticError::TooManySteps {
163                len: timings_ms.len(),
164                max: HapticPattern::MAX_STEPS,
165            });
166        }
167        if timings_ms.iter().all(|step| *step == 0) {
168            return Err(HapticError::ZeroDuration);
169        }
170        if let Some(index) = repeat {
171            if index >= timings_ms.len() {
172                return Err(HapticError::RepeatOutOfRange {
173                    index,
174                    len: timings_ms.len(),
175                });
176            }
177        }
178        Ok(HapticPattern {
179            timings_ms: timings_ms.to_vec(),
180            amplitudes: amplitudes.to_vec(),
181            repeat,
182        })
183    }
184
185    /// The per-step durations in milliseconds.
186    pub fn timings_ms(&self) -> &[u32] {
187        &self.timings_ms
188    }
189
190    /// The per-step amplitudes, 0 to 255.
191    pub fn amplitudes(&self) -> &[u8] {
192        &self.amplitudes
193    }
194
195    /// The index the waveform loops back to, if it repeats.
196    pub fn repeat(&self) -> Option<usize> {
197        self.repeat
198    }
199
200    /// How many steps the waveform has.
201    pub fn len(&self) -> usize {
202        self.timings_ms.len()
203    }
204
205    /// Always `false`: a pattern cannot be built with no steps.
206    pub fn is_empty(&self) -> bool {
207        false
208    }
209
210    /// One pass through the waveform, in milliseconds.
211    pub fn total_duration_ms(&self) -> u32 {
212        self.timings_ms
213            .iter()
214            .fold(0u32, |sum, step| sum.saturating_add(*step))
215    }
216
217    /// The strongest amplitude in the waveform, which is what a backend
218    /// without waveform support falls back on.
219    pub fn peak_amplitude(&self) -> u8 {
220        self.amplitudes.iter().copied().max().unwrap_or(0)
221    }
222
223    /// The closest [`HapticFeedback`] constant for this pattern, derived from
224    /// its strength and length. Backends without waveform support use it.
225    pub fn closest_feedback(&self) -> HapticFeedback {
226        let peak = u32::from(self.peak_amplitude());
227        let duration = self.total_duration_ms();
228        if peak >= 200 || duration >= 120 {
229            HapticFeedback::ImpactHeavy
230        } else if peak >= 110 || duration >= 40 {
231            HapticFeedback::ImpactMedium
232        } else {
233            HapticFeedback::ImpactLight
234        }
235    }
236}
237
238/// Performs haptic feedback. Installed by the platform backend; the default is
239/// a no-op.
240///
241/// Only [`perform`](Haptics::perform) has to be implemented. Every other method
242/// falls back to it, so extending this trait cannot break an existing backend.
243pub trait Haptics {
244    /// Plays a semantic feedback event.
245    fn perform(&self, feedback: HapticFeedback);
246
247    /// Vibrates once for `duration_ms` at `amplitude` (1 to 255; 0 means the
248    /// device default strength).
249    ///
250    /// Maps to `VibrationEffect.createOneShot(long, int)` on Android. The
251    /// defaulted body picks the closest [`HapticFeedback`] constant.
252    fn vibrate(&self, duration_ms: u32, amplitude: u8) {
253        let feedback = if amplitude >= 200 || duration_ms >= 120 {
254            HapticFeedback::ImpactHeavy
255        } else if amplitude >= 110 || duration_ms >= 40 {
256            HapticFeedback::ImpactMedium
257        } else {
258            HapticFeedback::ImpactLight
259        };
260        self.perform(feedback);
261    }
262
263    /// Plays a waveform pattern.
264    ///
265    /// Maps to `VibrationEffect.createWaveform(long[], int[], int)` on Android.
266    /// The defaulted body plays [`HapticPattern::closest_feedback`] once.
267    fn play_pattern(&self, pattern: &HapticPattern) {
268        self.perform(pattern.closest_feedback());
269    }
270
271    /// Plays a system-defined primitive.
272    ///
273    /// Maps to `VibrationEffect.createPredefined(int)` on Android. The
274    /// defaulted body plays [`HapticEffect::closest_feedback`].
275    fn perform_effect(&self, effect: HapticEffect) {
276        self.perform(effect.closest_feedback());
277    }
278
279    /// Stops any vibration in progress, including a repeating waveform.
280    /// Backends with no way to cancel leave this as a no-op.
281    fn cancel(&self) {}
282
283    /// Whether the device reproduces amplitudes rather than treating every
284    /// non-zero level as full strength.
285    fn has_amplitude_control(&self) -> bool {
286        false
287    }
288}
289
290pub type HapticsRef = Rc<dyn Haptics>;
291
292struct NoopHaptics;
293
294impl Haptics for NoopHaptics {
295    fn perform(&self, _feedback: HapticFeedback) {}
296}
297
298thread_local! {
299    static PLATFORM_HAPTICS: RefCell<Option<HapticsRef>> = const { RefCell::new(None) };
300}
301
302/// Installs a platform haptics implementation, replacing any previous one.
303pub fn set_platform_haptics(haptics: HapticsRef) {
304    PLATFORM_HAPTICS.with(|cell| *cell.borrow_mut() = Some(haptics));
305}
306
307/// Removes any registered platform haptics (tests and teardown).
308pub fn clear_platform_haptics() {
309    PLATFORM_HAPTICS.with(|cell| *cell.borrow_mut() = None);
310}
311
312pub fn default_haptics() -> HapticsRef {
313    PLATFORM_HAPTICS
314        .with(|cell| cell.borrow().clone())
315        .unwrap_or_else(|| Rc::new(NoopHaptics))
316}
317
318pub fn local_haptics() -> CompositionLocal<HapticsRef> {
319    thread_local! {
320        static LOCAL_HAPTICS: RefCell<Option<CompositionLocal<HapticsRef>>> = const { RefCell::new(None) };
321    }
322
323    LOCAL_HAPTICS.with(|cell| {
324        let mut local = cell.borrow_mut();
325        local
326            .get_or_insert_with(|| compositionLocalOfWithPolicy(default_haptics, Rc::ptr_eq))
327            .clone()
328    })
329}
330
331#[allow(non_snake_case)]
332#[composable]
333pub fn ProvideHaptics(content: impl FnOnce()) {
334    let haptics = cranpose_core::remember(default_haptics).with(|state| state.clone());
335    let local = local_haptics();
336    CompositionLocalProvider(vec![local.provides(haptics)], move || {
337        content();
338    });
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use crate::run_test_composition;
345    use std::cell::Cell;
346
347    /// A backend that implements only `perform`, exactly as one written before
348    /// the waveform methods existed would.
349    #[derive(Default)]
350    struct Rec {
351        events: RefCell<Vec<HapticFeedback>>,
352    }
353
354    impl Haptics for Rec {
355        fn perform(&self, feedback: HapticFeedback) {
356            self.events.borrow_mut().push(feedback);
357        }
358    }
359
360    #[test]
361    fn registered_haptics_receives_events() {
362        clear_platform_haptics();
363        default_haptics().perform(HapticFeedback::Selection); // no-op, no panic
364
365        struct Counter(Rc<Cell<u32>>);
366        impl Haptics for Counter {
367            fn perform(&self, _f: HapticFeedback) {
368                self.0.set(self.0.get() + 1);
369            }
370        }
371        let count = Rc::new(Cell::new(0));
372        set_platform_haptics(Rc::new(Counter(count.clone())));
373        default_haptics().perform(HapticFeedback::ImpactMedium);
374        assert_eq!(count.get(), 1);
375        clear_platform_haptics();
376    }
377
378    #[test]
379    fn noop_backend_answers_every_method_without_panicking() {
380        clear_platform_haptics();
381        let haptics = default_haptics();
382        haptics.perform(HapticFeedback::Error);
383        haptics.vibrate(30, 128);
384        haptics.perform_effect(HapticEffect::DoubleClick);
385        haptics.play_pattern(&HapticPattern::new(&[0, 20, 10, 20], &[0, 255, 0, 120]).unwrap());
386        haptics.cancel();
387        assert!(!haptics.has_amplitude_control());
388    }
389
390    #[test]
391    fn waveform_rejects_length_mismatch_instead_of_panicking() {
392        assert_eq!(
393            HapticPattern::new(&[0, 20, 10], &[0, 255]),
394            Err(HapticError::LengthMismatch {
395                timings: 3,
396                amplitudes: 2
397            })
398        );
399        assert_eq!(
400            HapticPattern::repeating(&[0, 20], &[0, 255, 128], 0),
401            Err(HapticError::LengthMismatch {
402                timings: 2,
403                amplitudes: 3
404            })
405        );
406    }
407
408    #[test]
409    fn waveform_rejects_empty_zero_and_out_of_range_repeat() {
410        assert_eq!(HapticPattern::new(&[], &[]), Err(HapticError::Empty));
411        assert_eq!(
412            HapticPattern::new(&[0, 0, 0], &[0, 255, 0]),
413            Err(HapticError::ZeroDuration)
414        );
415        assert_eq!(
416            HapticPattern::repeating(&[0, 20], &[0, 255], 2),
417            Err(HapticError::RepeatOutOfRange { index: 2, len: 2 })
418        );
419        let long = vec![1u32; HapticPattern::MAX_STEPS + 1];
420        let amps = vec![1u8; HapticPattern::MAX_STEPS + 1];
421        assert_eq!(
422            HapticPattern::new(&long, &amps),
423            Err(HapticError::TooManySteps {
424                len: HapticPattern::MAX_STEPS + 1,
425                max: HapticPattern::MAX_STEPS
426            })
427        );
428    }
429
430    #[test]
431    fn waveform_exposes_its_shape() {
432        let pattern = HapticPattern::repeating(&[0, 40, 30, 40], &[0, 200, 0, 90], 1)
433            .expect("valid waveform");
434        assert_eq!(pattern.timings_ms(), &[0, 40, 30, 40]);
435        assert_eq!(pattern.amplitudes(), &[0, 200, 0, 90]);
436        assert_eq!(pattern.repeat(), Some(1));
437        assert_eq!(pattern.len(), 4);
438        assert!(!pattern.is_empty());
439        assert_eq!(pattern.total_duration_ms(), 110);
440        assert_eq!(pattern.peak_amplitude(), 200);
441        assert_eq!(pattern.closest_feedback(), HapticFeedback::ImpactHeavy);
442
443        let light = HapticPattern::new(&[0, 8], &[0, 40]).expect("valid waveform");
444        assert_eq!(light.closest_feedback(), HapticFeedback::ImpactLight);
445        let medium = HapticPattern::new(&[0, 50], &[0, 120]).expect("valid waveform");
446        assert_eq!(medium.closest_feedback(), HapticFeedback::ImpactMedium);
447        assert_eq!(light.repeat(), None);
448    }
449
450    #[test]
451    fn total_duration_saturates_instead_of_overflowing() {
452        let pattern = HapticPattern::new(&[u32::MAX, u32::MAX], &[255, 255]).expect("valid");
453        assert_eq!(pattern.total_duration_ms(), u32::MAX);
454    }
455
456    #[test]
457    fn defaulted_methods_fall_back_to_perform() {
458        let backend = Rc::new(Rec::default());
459        let haptics: HapticsRef = backend.clone();
460
461        haptics.vibrate(10, 20);
462        haptics.vibrate(60, 20);
463        haptics.vibrate(10, 220);
464        haptics.perform_effect(HapticEffect::Tick);
465        haptics.perform_effect(HapticEffect::Click);
466        haptics.perform_effect(HapticEffect::DoubleClick);
467        haptics.perform_effect(HapticEffect::HeavyClick);
468        haptics.play_pattern(&HapticPattern::new(&[0, 200], &[0, 255]).unwrap());
469        haptics.cancel();
470
471        assert_eq!(
472            *backend.events.borrow(),
473            vec![
474                HapticFeedback::ImpactLight,
475                HapticFeedback::ImpactMedium,
476                HapticFeedback::ImpactHeavy,
477                HapticFeedback::Selection,
478                HapticFeedback::ImpactLight,
479                HapticFeedback::ImpactMedium,
480                HapticFeedback::ImpactHeavy,
481                HapticFeedback::ImpactHeavy,
482            ]
483        );
484    }
485
486    #[test]
487    fn provide_haptics_publishes_the_platform_backend() {
488        clear_platform_haptics();
489        let backend = Rc::new(Rec::default());
490        let haptics: HapticsRef = backend.clone();
491        set_platform_haptics(haptics);
492
493        run_test_composition(move || {
494            ProvideHaptics(|| {
495                local_haptics().current().perform(HapticFeedback::Success);
496            });
497        });
498
499        assert_eq!(*backend.events.borrow(), vec![HapticFeedback::Success]);
500        clear_platform_haptics();
501    }
502}