1use 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
24pub enum HapticFeedback {
25 ImpactLight,
27 ImpactMedium,
29 ImpactHeavy,
31 Selection,
33 Success,
35 Warning,
37 Error,
39}
40
41#[derive(Clone, Copy, PartialEq, Eq, Debug)]
47pub enum HapticEffect {
48 Click,
50 Tick,
52 DoubleClick,
54 HeavyClick,
56}
57
58impl HapticEffect {
59 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#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
73pub enum HapticError {
74 #[error("waveform has {timings} timings and {amplitudes} amplitudes; they must match")]
76 LengthMismatch {
77 timings: usize,
79 amplitudes: usize,
81 },
82 #[error("waveform has no steps")]
84 Empty,
85 #[error("waveform has a total duration of zero")]
87 ZeroDuration,
88 #[error("repeat index {index} is out of range for a {len}-step waveform")]
90 RepeatOutOfRange {
91 index: usize,
93 len: usize,
95 },
96 #[error("waveform has {len} steps, more than the maximum of {max}")]
98 TooManySteps {
99 len: usize,
101 max: usize,
103 },
104}
105
106#[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 pub const MAX_STEPS: usize = 512;
128
129 pub fn new(timings_ms: &[u32], amplitudes: &[u8]) -> Result<HapticPattern, HapticError> {
134 Self::build(timings_ms, amplitudes, None)
135 }
136
137 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 pub fn timings_ms(&self) -> &[u32] {
187 &self.timings_ms
188 }
189
190 pub fn amplitudes(&self) -> &[u8] {
192 &self.amplitudes
193 }
194
195 pub fn repeat(&self) -> Option<usize> {
197 self.repeat
198 }
199
200 pub fn len(&self) -> usize {
202 self.timings_ms.len()
203 }
204
205 pub fn is_empty(&self) -> bool {
207 false
208 }
209
210 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 pub fn peak_amplitude(&self) -> u8 {
220 self.amplitudes.iter().copied().max().unwrap_or(0)
221 }
222
223 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
238pub trait Haptics {
244 fn perform(&self, feedback: HapticFeedback);
246
247 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 fn play_pattern(&self, pattern: &HapticPattern) {
268 self.perform(pattern.closest_feedback());
269 }
270
271 fn perform_effect(&self, effect: HapticEffect) {
276 self.perform(effect.closest_feedback());
277 }
278
279 fn cancel(&self) {}
282
283 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
302pub fn set_platform_haptics(haptics: HapticsRef) {
304 PLATFORM_HAPTICS.with(|cell| *cell.borrow_mut() = Some(haptics));
305}
306
307pub 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 #[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); 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, &s),
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}