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