1use 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
27pub enum HapticFeedback {
28 ImpactLight,
30 ImpactMedium,
32 ImpactHeavy,
34 Selection,
36 Success,
38 Warning,
40 Error,
42}
43
44#[derive(Clone, Copy, PartialEq, Eq, Debug)]
50pub enum HapticEffect {
51 Click,
53 Tick,
55 DoubleClick,
57 HeavyClick,
59}
60
61impl HapticEffect {
62 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#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
76pub enum HapticError {
77 #[error("waveform has {timings} timings and {amplitudes} amplitudes; they must match")]
79 LengthMismatch {
80 timings: usize,
82 amplitudes: usize,
84 },
85 #[error("waveform has no steps")]
87 Empty,
88 #[error("waveform has a total duration of zero")]
90 ZeroDuration,
91 #[error("repeat index {index} is out of range for a {len}-step waveform")]
93 RepeatOutOfRange {
94 index: usize,
96 len: usize,
98 },
99 #[error("waveform has {len} steps, more than the maximum of {max}")]
101 TooManySteps {
102 len: usize,
104 max: usize,
106 },
107}
108
109#[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 pub const MAX_STEPS: usize = 512;
131
132 pub fn new(timings_ms: &[u32], amplitudes: &[u8]) -> Result<HapticPattern, HapticError> {
137 Self::build(timings_ms, amplitudes, None)
138 }
139
140 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 pub fn timings_ms(&self) -> &[u32] {
190 &self.timings_ms
191 }
192
193 pub fn amplitudes(&self) -> &[u8] {
195 &self.amplitudes
196 }
197
198 pub fn repeat(&self) -> Option<usize> {
200 self.repeat
201 }
202
203 pub fn len(&self) -> usize {
205 self.timings_ms.len()
206 }
207
208 pub fn is_empty(&self) -> bool {
210 false
211 }
212
213 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 pub fn peak_amplitude(&self) -> u8 {
223 self.amplitudes.iter().copied().max().unwrap_or(0)
224 }
225
226 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
241pub trait Haptics: Send + Sync {
247 fn perform(&self, feedback: HapticFeedback);
249
250 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 fn play_pattern(&self, pattern: &HapticPattern) {
271 self.perform(pattern.closest_feedback());
272 }
273
274 fn perform_effect(&self, effect: HapticEffect) {
279 self.perform(effect.closest_feedback());
280 }
281
282 fn cancel(&self) {}
285
286 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
339pub fn set_platform_haptics(haptics: HapticsRef) {
341 PLATFORM_HAPTICS.set(haptics);
342}
343
344pub 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, &s),
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}