use cranpose_core::compositionLocalOfWithPolicy;
use cranpose_core::CompositionLocal;
use cranpose_core::CompositionLocalProvider;
use cranpose_macros::composable;
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum HapticFeedback {
ImpactLight,
ImpactMedium,
ImpactHeavy,
Selection,
Success,
Warning,
Error,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum HapticEffect {
Click,
Tick,
DoubleClick,
HeavyClick,
}
impl HapticEffect {
pub fn closest_feedback(self) -> HapticFeedback {
match self {
HapticEffect::Tick => HapticFeedback::Selection,
HapticEffect::Click => HapticFeedback::ImpactLight,
HapticEffect::DoubleClick => HapticFeedback::ImpactMedium,
HapticEffect::HeavyClick => HapticFeedback::ImpactHeavy,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum HapticError {
#[error("waveform has {timings} timings and {amplitudes} amplitudes; they must match")]
LengthMismatch {
timings: usize,
amplitudes: usize,
},
#[error("waveform has no steps")]
Empty,
#[error("waveform has a total duration of zero")]
ZeroDuration,
#[error("repeat index {index} is out of range for a {len}-step waveform")]
RepeatOutOfRange {
index: usize,
len: usize,
},
#[error("waveform has {len} steps, more than the maximum of {max}")]
TooManySteps {
len: usize,
max: usize,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HapticPattern {
timings_ms: Vec<u32>,
amplitudes: Vec<u8>,
repeat: Option<usize>,
}
impl HapticPattern {
pub const MAX_STEPS: usize = 512;
pub fn new(timings_ms: &[u32], amplitudes: &[u8]) -> Result<HapticPattern, HapticError> {
Self::build(timings_ms, amplitudes, None)
}
pub fn repeating(
timings_ms: &[u32],
amplitudes: &[u8],
repeat_index: usize,
) -> Result<HapticPattern, HapticError> {
Self::build(timings_ms, amplitudes, Some(repeat_index))
}
fn build(
timings_ms: &[u32],
amplitudes: &[u8],
repeat: Option<usize>,
) -> Result<HapticPattern, HapticError> {
if timings_ms.len() != amplitudes.len() {
return Err(HapticError::LengthMismatch {
timings: timings_ms.len(),
amplitudes: amplitudes.len(),
});
}
if timings_ms.is_empty() {
return Err(HapticError::Empty);
}
if timings_ms.len() > HapticPattern::MAX_STEPS {
return Err(HapticError::TooManySteps {
len: timings_ms.len(),
max: HapticPattern::MAX_STEPS,
});
}
if timings_ms.iter().all(|step| *step == 0) {
return Err(HapticError::ZeroDuration);
}
if let Some(index) = repeat {
if index >= timings_ms.len() {
return Err(HapticError::RepeatOutOfRange {
index,
len: timings_ms.len(),
});
}
}
Ok(HapticPattern {
timings_ms: timings_ms.to_vec(),
amplitudes: amplitudes.to_vec(),
repeat,
})
}
pub fn timings_ms(&self) -> &[u32] {
&self.timings_ms
}
pub fn amplitudes(&self) -> &[u8] {
&self.amplitudes
}
pub fn repeat(&self) -> Option<usize> {
self.repeat
}
pub fn len(&self) -> usize {
self.timings_ms.len()
}
pub fn is_empty(&self) -> bool {
false
}
pub fn total_duration_ms(&self) -> u32 {
self.timings_ms
.iter()
.fold(0u32, |sum, step| sum.saturating_add(*step))
}
pub fn peak_amplitude(&self) -> u8 {
self.amplitudes.iter().copied().max().unwrap_or(0)
}
pub fn closest_feedback(&self) -> HapticFeedback {
let peak = u32::from(self.peak_amplitude());
let duration = self.total_duration_ms();
if peak >= 200 || duration >= 120 {
HapticFeedback::ImpactHeavy
} else if peak >= 110 || duration >= 40 {
HapticFeedback::ImpactMedium
} else {
HapticFeedback::ImpactLight
}
}
}
pub trait Haptics {
fn perform(&self, feedback: HapticFeedback);
fn vibrate(&self, duration_ms: u32, amplitude: u8) {
let feedback = if amplitude >= 200 || duration_ms >= 120 {
HapticFeedback::ImpactHeavy
} else if amplitude >= 110 || duration_ms >= 40 {
HapticFeedback::ImpactMedium
} else {
HapticFeedback::ImpactLight
};
self.perform(feedback);
}
fn play_pattern(&self, pattern: &HapticPattern) {
self.perform(pattern.closest_feedback());
}
fn perform_effect(&self, effect: HapticEffect) {
self.perform(effect.closest_feedback());
}
fn cancel(&self) {}
fn has_amplitude_control(&self) -> bool {
false
}
}
pub type HapticsRef = Rc<dyn Haptics>;
struct NoopHaptics;
impl Haptics for NoopHaptics {
fn perform(&self, _feedback: HapticFeedback) {}
}
thread_local! {
static PLATFORM_HAPTICS: RefCell<Option<HapticsRef>> = const { RefCell::new(None) };
}
pub fn set_platform_haptics(haptics: HapticsRef) {
PLATFORM_HAPTICS.with(|cell| *cell.borrow_mut() = Some(haptics));
}
pub fn clear_platform_haptics() {
PLATFORM_HAPTICS.with(|cell| *cell.borrow_mut() = None);
}
pub fn default_haptics() -> HapticsRef {
PLATFORM_HAPTICS
.with(|cell| cell.borrow().clone())
.unwrap_or_else(|| Rc::new(NoopHaptics))
}
pub fn local_haptics() -> CompositionLocal<HapticsRef> {
thread_local! {
static LOCAL_HAPTICS: RefCell<Option<CompositionLocal<HapticsRef>>> = const { RefCell::new(None) };
}
LOCAL_HAPTICS.with(|cell| {
let mut local = cell.borrow_mut();
local
.get_or_insert_with(|| compositionLocalOfWithPolicy(default_haptics, Rc::ptr_eq))
.clone()
})
}
#[allow(non_snake_case)]
#[composable]
pub fn ProvideHaptics(content: impl FnOnce()) {
let haptics = cranpose_core::remember(default_haptics).with(|state| state.clone());
let local = local_haptics();
CompositionLocalProvider(vec![local.provides(haptics)], move || {
content();
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::run_test_composition;
use std::cell::Cell;
#[derive(Default)]
struct Rec {
events: RefCell<Vec<HapticFeedback>>,
}
impl Haptics for Rec {
fn perform(&self, feedback: HapticFeedback) {
self.events.borrow_mut().push(feedback);
}
}
#[test]
fn registered_haptics_receives_events() {
clear_platform_haptics();
default_haptics().perform(HapticFeedback::Selection);
struct Counter(Rc<Cell<u32>>);
impl Haptics for Counter {
fn perform(&self, _f: HapticFeedback) {
self.0.set(self.0.get() + 1);
}
}
let count = Rc::new(Cell::new(0));
set_platform_haptics(Rc::new(Counter(count.clone())));
default_haptics().perform(HapticFeedback::ImpactMedium);
assert_eq!(count.get(), 1);
clear_platform_haptics();
}
#[test]
fn noop_backend_answers_every_method_without_panicking() {
clear_platform_haptics();
let haptics = default_haptics();
haptics.perform(HapticFeedback::Error);
haptics.vibrate(30, 128);
haptics.perform_effect(HapticEffect::DoubleClick);
haptics.play_pattern(&HapticPattern::new(&[0, 20, 10, 20], &[0, 255, 0, 120]).unwrap());
haptics.cancel();
assert!(!haptics.has_amplitude_control());
}
#[test]
fn waveform_rejects_length_mismatch_instead_of_panicking() {
assert_eq!(
HapticPattern::new(&[0, 20, 10], &[0, 255]),
Err(HapticError::LengthMismatch {
timings: 3,
amplitudes: 2
})
);
assert_eq!(
HapticPattern::repeating(&[0, 20], &[0, 255, 128], 0),
Err(HapticError::LengthMismatch {
timings: 2,
amplitudes: 3
})
);
}
#[test]
fn waveform_rejects_empty_zero_and_out_of_range_repeat() {
assert_eq!(HapticPattern::new(&[], &[]), Err(HapticError::Empty));
assert_eq!(
HapticPattern::new(&[0, 0, 0], &[0, 255, 0]),
Err(HapticError::ZeroDuration)
);
assert_eq!(
HapticPattern::repeating(&[0, 20], &[0, 255], 2),
Err(HapticError::RepeatOutOfRange { index: 2, len: 2 })
);
let long = vec![1u32; HapticPattern::MAX_STEPS + 1];
let amps = vec![1u8; HapticPattern::MAX_STEPS + 1];
assert_eq!(
HapticPattern::new(&long, &s),
Err(HapticError::TooManySteps {
len: HapticPattern::MAX_STEPS + 1,
max: HapticPattern::MAX_STEPS
})
);
}
#[test]
fn waveform_exposes_its_shape() {
let pattern = HapticPattern::repeating(&[0, 40, 30, 40], &[0, 200, 0, 90], 1)
.expect("valid waveform");
assert_eq!(pattern.timings_ms(), &[0, 40, 30, 40]);
assert_eq!(pattern.amplitudes(), &[0, 200, 0, 90]);
assert_eq!(pattern.repeat(), Some(1));
assert_eq!(pattern.len(), 4);
assert!(!pattern.is_empty());
assert_eq!(pattern.total_duration_ms(), 110);
assert_eq!(pattern.peak_amplitude(), 200);
assert_eq!(pattern.closest_feedback(), HapticFeedback::ImpactHeavy);
let light = HapticPattern::new(&[0, 8], &[0, 40]).expect("valid waveform");
assert_eq!(light.closest_feedback(), HapticFeedback::ImpactLight);
let medium = HapticPattern::new(&[0, 50], &[0, 120]).expect("valid waveform");
assert_eq!(medium.closest_feedback(), HapticFeedback::ImpactMedium);
assert_eq!(light.repeat(), None);
}
#[test]
fn total_duration_saturates_instead_of_overflowing() {
let pattern = HapticPattern::new(&[u32::MAX, u32::MAX], &[255, 255]).expect("valid");
assert_eq!(pattern.total_duration_ms(), u32::MAX);
}
#[test]
fn defaulted_methods_fall_back_to_perform() {
let backend = Rc::new(Rec::default());
let haptics: HapticsRef = backend.clone();
haptics.vibrate(10, 20);
haptics.vibrate(60, 20);
haptics.vibrate(10, 220);
haptics.perform_effect(HapticEffect::Tick);
haptics.perform_effect(HapticEffect::Click);
haptics.perform_effect(HapticEffect::DoubleClick);
haptics.perform_effect(HapticEffect::HeavyClick);
haptics.play_pattern(&HapticPattern::new(&[0, 200], &[0, 255]).unwrap());
haptics.cancel();
assert_eq!(
*backend.events.borrow(),
vec![
HapticFeedback::ImpactLight,
HapticFeedback::ImpactMedium,
HapticFeedback::ImpactHeavy,
HapticFeedback::Selection,
HapticFeedback::ImpactLight,
HapticFeedback::ImpactMedium,
HapticFeedback::ImpactHeavy,
HapticFeedback::ImpactHeavy,
]
);
}
#[test]
fn provide_haptics_publishes_the_platform_backend() {
clear_platform_haptics();
let backend = Rc::new(Rec::default());
let haptics: HapticsRef = backend.clone();
set_platform_haptics(haptics);
run_test_composition(move || {
ProvideHaptics(|| {
local_haptics().current().perform(HapticFeedback::Success);
});
});
assert_eq!(*backend.events.borrow(), vec![HapticFeedback::Success]);
clear_platform_haptics();
}
}