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::sync::{Arc, OnceLock};
16
17use cranpose_core::{CompositionLocal, CompositionLocalProvider, compositionLocalOfWithPolicy};
18use cranpose_macros::composable;
19
20use crate::registry::ServiceRegistry;
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            && index >= timings_ms.len()
172        {
173            return Err(HapticError::RepeatOutOfRange {
174                index,
175                len: timings_ms.len(),
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: Send + Sync {
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 = Arc<dyn Haptics>;
291
292struct NoopHaptics;
293
294impl Haptics for NoopHaptics {
295    fn perform(&self, _feedback: HapticFeedback) {}
296}
297
298static PLATFORM_HAPTICS: ServiceRegistry<dyn Haptics> = ServiceRegistry::new();
299static NOOP_HAPTICS: OnceLock<HapticsRef> = OnceLock::new();
300static DEFAULT_HAPTICS: OnceLock<HapticsRef> = OnceLock::new();
301
302struct PlatformHaptics;
303
304fn registered_haptics() -> HapticsRef {
305    PLATFORM_HAPTICS
306        .get_or_warn("haptics")
307        .unwrap_or_else(|| NOOP_HAPTICS.get_or_init(|| Arc::new(NoopHaptics)).clone())
308}
309
310impl Haptics for PlatformHaptics {
311    fn perform(&self, feedback: HapticFeedback) {
312        registered_haptics().perform(feedback);
313    }
314
315    fn vibrate(&self, duration_ms: u32, amplitude: u8) {
316        registered_haptics().vibrate(duration_ms, amplitude);
317    }
318
319    fn play_pattern(&self, pattern: &HapticPattern) {
320        registered_haptics().play_pattern(pattern);
321    }
322
323    fn perform_effect(&self, effect: HapticEffect) {
324        registered_haptics().perform_effect(effect);
325    }
326
327    fn cancel(&self) {
328        registered_haptics().cancel();
329    }
330
331    fn has_amplitude_control(&self) -> bool {
332        registered_haptics().has_amplitude_control()
333    }
334}
335
336/// Installs a platform haptics implementation, replacing any previous one.
337pub fn set_platform_haptics(haptics: HapticsRef) {
338    PLATFORM_HAPTICS.set(haptics);
339}
340
341/// Removes any registered platform haptics (tests and teardown).
342pub fn clear_platform_haptics() {
343    PLATFORM_HAPTICS.clear();
344}
345
346pub fn default_haptics() -> HapticsRef {
347    DEFAULT_HAPTICS
348        .get_or_init(|| Arc::new(PlatformHaptics))
349        .clone()
350}
351
352pub fn local_haptics() -> CompositionLocal<HapticsRef> {
353    crate::composition_locals::cached_local(
354        |locals| &locals.haptics,
355        || compositionLocalOfWithPolicy(default_haptics, Arc::ptr_eq),
356    )
357}
358
359#[composable]
360pub fn ProvideHaptics(content: impl FnOnce()) {
361    let haptics = cranpose_core::remember(default_haptics).with(|state| state.clone());
362    let local = local_haptics();
363    CompositionLocalProvider([local.provides(haptics)], move || {
364        content();
365    });
366}
367
368#[cfg(test)]
369#[path = "tests/haptics_tests.rs"]
370mod tests;