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