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
290/// Shared handle to a [`Haptics`] implementation.
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
347/// Returns the haptics that forward to the installed platform implementation. They do nothing
348/// while none is installed.
349pub fn default_haptics() -> HapticsRef {
350 DEFAULT_HAPTICS
351 .get_or_init(|| Arc::new(PlatformHaptics))
352 .clone()
353}
354
355/// The haptics a composable reads. It is the platform haptics unless a composition local provider
356/// above it supplies others.
357pub fn local_haptics() -> CompositionLocal<HapticsRef> {
358 crate::composition_locals::cached_local(
359 |locals| &locals.haptics,
360 || compositionLocalOfWithPolicy(default_haptics, Arc::ptr_eq),
361 )
362}
363
364/// Provides the platform haptics to `content` through [`local_haptics`].
365#[composable]
366pub fn ProvideHaptics(content: impl FnOnce()) {
367 let haptics = cranpose_core::remember(default_haptics).with(|state| state.clone());
368 let local = local_haptics();
369 CompositionLocalProvider([local.provides(haptics)], move || {
370 content();
371 });
372}
373
374#[cfg(test)]
375#[path = "tests/haptics_tests.rs"]
376mod tests;