azul_core/haptics.rs
1//! Haptic feedback — the one OUTPUT in the input subsystem.
2//!
3//! It does not fit the event model, because nothing is being reported: the app
4//! is asking the hardware to do something. But it belongs here, because every
5//! platform exposes it through its input stack and because the thing being
6//! driven is an input device — a trackpad, a controller, a pen.
7//!
8//! # Why the vocabulary is SEMANTIC and not a waveform
9//!
10//! Each platform has its own waveform language (Core Haptics patterns, Android
11//! `VibrationEffect` compositions, DualSense trigger profiles) and none of them
12//! translate. Worse, the same waveform feels different on every actuator: a
13//! Taptic Engine, an LRA in a phone, and the ERM motors in a gamepad have
14//! different resonant frequencies and rise times, so a millisecond envelope
15//! tuned on one is mush on another.
16//!
17//! So the vocabulary names the INTENT — "a selection changed", "an action was
18//! rejected" — and each backend picks the actuator's own best rendering. This
19//! is what Apple, Google and Microsoft all recommend for their own APIs, and it
20//! is the only way one call site can be correct on a trackpad, a phone and a
21//! controller at once.
22//!
23//! The set is the union of the platform vocabularies rather than their
24//! intersection, because the intersection is nearly empty (macOS has THREE
25//! patterns) and a caller that wants `TextHandleMove` on Android should not be
26//! denied it because macOS would render it as a generic tap. Anything a
27//! platform cannot render natively degrades along [`HapticPattern::fallback`]
28//! until it reaches something the platform does have.
29//!
30//! # Platform mapping
31//!
32//! | pattern | macOS `NSHapticFeedbackPattern` | Android | iOS `UIFeedbackGenerator` |
33//! |---|---|---|---|
34//! | `Selection` | `Alignment` | `CLOCK_TICK` | `UISelectionFeedbackGenerator` |
35//! | `ImpactLight` | `Alignment` | `PRIMITIVE_LOW_TICK` | `.light` |
36//! | `ImpactMedium` | `LevelChange` | `PRIMITIVE_TICK` | `.medium` |
37//! | `ImpactHeavy` | `Generic` | `PRIMITIVE_CLICK` | `.heavy` |
38//! | `ImpactSoft` | `Alignment` | `PRIMITIVE_LOW_TICK` | `.soft` |
39//! | `ImpactRigid` | `LevelChange` | `PRIMITIVE_CLICK` | `.rigid` |
40//! | `Success` | `LevelChange` | `CONFIRM` | `.success` |
41//! | `Warning` | `Generic` | `EFFECT_DOUBLE_CLICK` | `.warning` |
42//! | `Error` | `Generic` | `REJECT` | `.error` |
43//! | `KeyPress` | `Alignment` | `KEYBOARD_PRESS` | — |
44//! | `KeyRelease` | `Alignment` | `KEYBOARD_RELEASE` | — |
45//! | `LongPress` | `LevelChange` | `LONG_PRESS` | — |
46//! | `ContextClick` | `LevelChange` | `CONTEXT_CLICK` | — |
47//! | `TextHandleMove` | `Alignment` | `TEXT_HANDLE_MOVE` | — |
48//! | `GestureStart` | `Alignment` | `GESTURE_START` | — |
49//! | `GestureEnd` | `Alignment` | `GESTURE_END` | — |
50//! | `Rise` | `Alignment` | `PRIMITIVE_QUICK_RISE` | — |
51//! | `Fall` | `Alignment` | `PRIMITIVE_QUICK_FALL` | — |
52//! | `Spin` | `Alignment` | `PRIMITIVE_SPIN` (API 31+) | — |
53//!
54//! A dash means the platform has no native equivalent and the backend walks
55//! [`HapticPattern::fallback`] to reach one it does.
56
57/// A haptic pattern to play, named by INTENT rather than by waveform.
58///
59/// See the module docs for the per-platform mapping and for why this names
60/// intents instead of envelopes.
61#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
62#[repr(C)]
63pub enum HapticPattern {
64 // --- selection ---------------------------------------------------------
65 /// A value moved to a new discrete step — a slider hitting a detent, a
66 /// picker wheel advancing, a segmented control changing. The lightest
67 /// thing in the vocabulary; safe to fire repeatedly during a drag.
68 Selection,
69
70 // --- impacts, by weight ------------------------------------------------
71 /// A small, light collision — a lightweight object landing.
72 ImpactLight,
73 /// A moderate collision. The sensible default for "something happened".
74 ImpactMedium,
75 /// A large, heavy collision — a heavyweight object landing.
76 ImpactHeavy,
77 /// A dull, compliant thud — the impact of something soft. Distinguished
78 /// from `ImpactLight` by texture, not strength.
79 ImpactSoft,
80 /// A crisp, hard tap — the impact of something inflexible. Distinguished
81 /// from `ImpactHeavy` by texture, not strength.
82 ImpactRigid,
83
84 // --- notifications -----------------------------------------------------
85 /// A task completed successfully.
86 Success,
87 /// A task completed, but something needs attention.
88 Warning,
89 /// A task failed, or an action was rejected — an invalid drop target, a
90 /// form that will not submit.
91 Error,
92
93 // --- discrete UI events ------------------------------------------------
94 /// A key went down on an on-screen keyboard.
95 KeyPress,
96 /// A key came up on an on-screen keyboard.
97 KeyRelease,
98 /// A long press crossed its threshold and is now committed. Fire this at
99 /// the MOMENT the threshold is crossed, not when the finger lifts — the
100 /// whole point is to tell the user they can let go.
101 LongPress,
102 /// A secondary/context action was invoked — a right-click, a long-press
103 /// menu opening.
104 ContextClick,
105 /// A text selection handle moved to a new character position.
106 TextHandleMove,
107
108 // --- gesture boundaries ------------------------------------------------
109 /// A continuous gesture began and is now tracking.
110 GestureStart,
111 /// A continuous gesture ended.
112 GestureEnd,
113
114 // --- chirps (amplitude/frequency sweeps) -------------------------------
115 /// A quick upward sweep — something growing, expanding, being picked up.
116 Rise,
117 /// A quick downward sweep — something shrinking, collapsing, being
118 /// dropped.
119 Fall,
120 /// A spinning, bidirectional flutter — momentum, a wheel being flicked.
121 Spin,
122}
123
124impl HapticPattern {
125 /// The next-simplest pattern to try when a backend cannot render this one.
126 ///
127 /// Backends walk this chain rather than silently dropping the request, so
128 /// a caller asking for `Spin` on a device whose actuator predates the
129 /// primitive still feels *something*. Every chain terminates at
130 /// `Selection`, which every haptic device on every platform can render;
131 /// `Selection` itself returns `None`, which is the signal to give up.
132 ///
133 /// This exists because the degradation is a property of the PATTERN, not
134 /// of the backend — otherwise all six backends would each invent their own
135 /// (differing) fallback and the same call would feel unrelated across
136 /// platforms.
137 #[must_use]
138 pub const fn fallback(self) -> Option<HapticPattern> {
139 use HapticPattern::*;
140 match self {
141 // The terminus: nothing is simpler than a selection tick.
142 Selection => None,
143
144 // Impacts collapse toward the middle weight, then to a tick.
145 ImpactLight | ImpactSoft => Some(Selection),
146 ImpactMedium => Some(ImpactLight),
147 ImpactHeavy | ImpactRigid => Some(ImpactMedium),
148
149 // Notifications degrade to an impact of matching weight: a
150 // failure should still feel heavier than a success.
151 Success => Some(ImpactLight),
152 Warning => Some(ImpactMedium),
153 Error => Some(ImpactHeavy),
154
155 // Discrete UI events are all light taps underneath.
156 KeyPress | KeyRelease | TextHandleMove => Some(Selection),
157 LongPress | ContextClick => Some(ImpactMedium),
158
159 // Gesture boundaries are the lightest possible marker.
160 GestureStart | GestureEnd => Some(Selection),
161
162 // Chirps have no simple equivalent; a medium impact at least
163 // marks the moment.
164 Rise | Fall => Some(ImpactLight),
165 Spin => Some(ImpactMedium),
166 }
167 }
168
169 /// Walk [`fallback`](Self::fallback) until `supported` accepts a pattern.
170 ///
171 /// Backends call this instead of matching on the pattern twice. Returns
172 /// `None` if nothing in the chain is supported, which means the request
173 /// should be dropped.
174 #[must_use]
175 pub fn resolve(self, supported: impl Fn(HapticPattern) -> bool) -> Option<HapticPattern> {
176 let mut current = Some(self);
177 while let Some(p) = current {
178 if supported(p) {
179 return Some(p);
180 }
181 current = p.fallback();
182 }
183 None
184 }
185}
186
187/// Which device should play the pattern.
188///
189/// `#[repr(C, u8)]` rather than `#[repr(C)]` because `Gamepad` carries a
190/// payload: a data-carrying enum needs an explicit discriminant type for its
191/// layout to be defined across the FFI boundary, and the API's FFI checker
192/// rejects `repr(C)` on one for exactly that reason.
193#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
194#[repr(C, u8)]
195pub enum HapticTarget {
196 /// Whatever the system considers the default — the trackpad on a laptop,
197 /// the body of a phone.
198 System,
199 /// A specific gamepad, by its `GamepadId`. Rumble rather than a tap: a
200 /// controller's actuators are motors, so the light patterns become short
201 /// low-amplitude pulses rather than the crisp taps a trackpad gives.
202 Gamepad(u32),
203 /// The pen currently in proximity. Only Apple Pencil Pro and some Surface
204 /// pens have an actuator; a request to any other pen is silently ignored.
205 Pen,
206}
207
208/// A queued haptic request.
209///
210/// Queued rather than played synchronously because a callback runs on the
211/// layout thread, and the platform APIs that drive actuators want to be called
212/// from the event loop — the same reason clipboard writes and menu opens are
213/// deferred.
214#[derive(Debug, Copy, Clone, PartialEq)]
215#[repr(C)]
216pub struct HapticRequest {
217 pub pattern: HapticPattern,
218 pub target: HapticTarget,
219 /// Strength scale in `0.0..=1.0`, where `1.0` is the platform's own
220 /// default strength for the pattern.
221 ///
222 /// Portable because three of the four backends take a scale natively:
223 /// Android's composition primitives take a float scale, iOS's
224 /// `impactOccurred(intensity:)` takes one, and gamepad rumble IS an
225 /// amplitude. macOS is the exception — `NSHapticFeedbackPattern` has no
226 /// strength axis at all, so the value is ignored there rather than
227 /// emulated, because faking it with repeated taps feels like a stutter.
228 pub intensity: f32,
229 /// How long the effect should last, in milliseconds. `0` means "the
230 /// pattern's own natural duration", which is what every tap-style
231 /// actuator wants.
232 ///
233 /// Only meaningful for continuous actuators — gamepad rumble motors,
234 /// which run until told to stop. Tap-style actuators ignore it.
235 pub duration_ms: u32,
236}
237
238impl HapticRequest {
239 /// A request at full strength and natural duration — the common case.
240 #[must_use]
241 pub const fn new(pattern: HapticPattern, target: HapticTarget) -> Self {
242 Self { pattern, target, intensity: 1.0, duration_ms: 0 }
243 }
244
245 /// Clamp the scale into the range every backend assumes.
246 ///
247 /// Callers compute intensity from things like drag velocity, so an
248 /// out-of-range or NaN value is expected rather than exceptional; a NaN
249 /// reaching Android's `addPrimitive` throws.
250 #[must_use]
251 pub fn intensity_clamped(&self) -> f32 {
252 if self.intensity.is_nan() {
253 1.0
254 } else {
255 self.intensity.clamp(0.0, 1.0)
256 }
257 }
258
259 /// How long a CONTINUOUS actuator should run, in milliseconds.
260 ///
261 /// `duration_ms == 0` means "the pattern's natural duration", which for a
262 /// tap-style actuator is whatever the platform does and for a MOTOR is not
263 /// zero: a rumble of zero length is not something a person can feel.
264 /// 150ms is gilrs's own example figure and about the shortest pulse an ERM
265 /// motor can spin up and down within.
266 ///
267 /// Shared rather than repeated per backend: the gilrs path and the Android
268 /// per-controller vibrator answer the same question, and two copies of a
269 /// magic number drift.
270 #[must_use]
271 pub fn rumble_duration_ms(&self) -> u32 {
272 rumble_duration_ms(self.duration_ms)
273 }
274
275 /// Which MOTOR this pattern belongs on, not how hard to drive it.
276 ///
277 /// A controller has a low-frequency motor that THUDS and a high-frequency
278 /// one that BUZZES, and the pattern picks between them. Driving both
279 /// together is a different, muddier sensation rather than a louder one -
280 /// which is why this is a bool and not a mix.
281 ///
282 /// `true` = the strong (low-frequency) motor.
283 #[must_use]
284 pub fn wants_strong_motor(&self) -> bool {
285 matches!(
286 self.pattern,
287 HapticPattern::ImpactHeavy
288 | HapticPattern::ImpactMedium
289 | HapticPattern::ImpactRigid
290 | HapticPattern::Error
291 | HapticPattern::Warning
292 | HapticPattern::LongPress
293 )
294 }
295
296 /// The intensity as an 8-bit amplitude in `1..=255`.
297 ///
298 /// Android's `VibrationEffect.createOneShot` takes amplitude in that
299 /// range, where `0` is not "silent" but INVALID - it throws
300 /// `IllegalArgumentException`. A caller that wants silence must not play
301 /// at all, so this floors at 1 and the backend checks for a zero intensity
302 /// before calling.
303 #[must_use]
304 pub fn amplitude_u8(&self) -> u8 {
305 let scaled = self.intensity_clamped() * 255.0;
306 // `as` saturates, and the clamp above already bounds it; the floor at
307 // 1 is the part that matters.
308 (scaled as u8).max(1)
309 }
310}
311
312/// See [`HapticRequest::rumble_duration_ms`].
313const DEFAULT_RUMBLE_MS: u32 = 150;
314
315/// The free-function form of [`HapticRequest::rumble_duration_ms`], for a
316/// backend that was handed the duration rather than the whole request.
317#[must_use]
318pub fn rumble_duration_ms(duration_ms: u32) -> u32 {
319 if duration_ms == 0 {
320 DEFAULT_RUMBLE_MS
321 } else {
322 duration_ms
323 }
324}
325
326/// Collects haptic requests for the platform backend to play.
327#[derive(Debug, Clone, Default, PartialEq)]
328pub struct HapticManager {
329 pending: alloc::vec::Vec<HapticRequest>,
330}
331
332impl HapticManager {
333 #[must_use]
334 pub fn new() -> Self {
335 Self::default()
336 }
337
338 /// Queue a pattern at full strength.
339 pub fn play(&mut self, pattern: HapticPattern, target: HapticTarget) {
340 self.play_request(HapticRequest::new(pattern, target));
341 }
342
343 /// Queue a fully-specified request.
344 pub fn play_request(&mut self, request: HapticRequest) {
345 // Coalesced: a callback that fires per-frame during a drag would
346 // otherwise queue a tick per frame and the device would buzz
347 // continuously instead of ticking once.
348 if self
349 .pending
350 .last()
351 .is_some_and(|r| r.pattern == request.pattern && r.target == request.target)
352 {
353 return;
354 }
355 self.pending.push(request);
356 }
357
358 /// Drain the queue — called by the shell each pass.
359 pub fn take_pending(&mut self) -> alloc::vec::Vec<HapticRequest> {
360 core::mem::take(&mut self.pending)
361 }
362
363 /// Whether anything is queued, so a backend can skip acquiring a native
364 /// performer (which on macOS means an Objective-C round trip) when there
365 /// is nothing to play. Called once per pass on every window.
366 #[must_use]
367 pub fn has_pending(&self) -> bool {
368 !self.pending.is_empty()
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 /// A rumble of zero length is not something a person can feel, so `0`
377 /// ("natural duration") has to become a real pulse for a MOTOR - unlike a
378 /// tap actuator, where the platform decides.
379 #[test]
380 fn a_natural_duration_becomes_a_real_pulse_for_a_motor() {
381 let mut r = HapticRequest::new(HapticPattern::ImpactHeavy, HapticTarget::System);
382 assert_eq!(r.duration_ms, 0, "the constructor still means `natural`");
383 assert_eq!(r.rumble_duration_ms(), 150);
384
385 r.duration_ms = 40;
386 assert_eq!(r.rumble_duration_ms(), 40, "an explicit duration wins");
387 }
388
389 /// The split is by PATTERN, not by strength: a heavy impact belongs on the
390 /// low-frequency motor and a selection tick on the high-frequency one, and
391 /// swapping them makes every tick feel like a thud.
392 #[test]
393 fn the_motor_split_follows_the_pattern() {
394 for p in [
395 HapticPattern::ImpactHeavy,
396 HapticPattern::ImpactMedium,
397 HapticPattern::ImpactRigid,
398 HapticPattern::Error,
399 HapticPattern::Warning,
400 HapticPattern::LongPress,
401 ] {
402 assert!(
403 HapticRequest::new(p, HapticTarget::System).wants_strong_motor(),
404 "{p:?} must drive the strong motor"
405 );
406 }
407 for p in [
408 HapticPattern::Selection,
409 HapticPattern::ImpactLight,
410 HapticPattern::ImpactSoft,
411 HapticPattern::Success,
412 HapticPattern::KeyPress,
413 HapticPattern::TextHandleMove,
414 ] {
415 assert!(
416 !HapticRequest::new(p, HapticTarget::System).wants_strong_motor(),
417 "{p:?} must drive the weak motor"
418 );
419 }
420 }
421
422 /// Android's `createOneShot` REJECTS amplitude 0 - it is invalid, not
423 /// silent - so the floor at 1 is what keeps a very small intensity from
424 /// throwing instead of buzzing faintly.
425 #[test]
426 fn the_amplitude_never_reaches_the_value_android_rejects() {
427 let mut r = HapticRequest::new(HapticPattern::ImpactHeavy, HapticTarget::System);
428
429 r.intensity = 1.0;
430 assert_eq!(r.amplitude_u8(), 255);
431 r.intensity = 0.5;
432 assert_eq!(r.amplitude_u8(), 127);
433
434 for tiny in [0.0, 0.0001, -5.0, f32::NAN] {
435 r.intensity = tiny;
436 let a = r.amplitude_u8();
437 assert!(a >= 1, "intensity {tiny} produced amplitude {a}, which Android rejects");
438 }
439 // NaN is treated as "full" by `intensity_clamped`, not as zero.
440 r.intensity = f32::NAN;
441 assert_eq!(r.amplitude_u8(), 255);
442 }
443
444 /// Every chain has to TERMINATE, or a backend walking it hangs. A cycle
445 /// here would be an infinite loop inside the event loop, on the device
446 /// only, which is the worst possible place to discover it.
447 #[test]
448 fn every_fallback_chain_terminates_at_selection() {
449 const ALL: &[HapticPattern] = &[
450 HapticPattern::Selection,
451 HapticPattern::ImpactLight,
452 HapticPattern::ImpactMedium,
453 HapticPattern::ImpactHeavy,
454 HapticPattern::ImpactSoft,
455 HapticPattern::ImpactRigid,
456 HapticPattern::Success,
457 HapticPattern::Warning,
458 HapticPattern::Error,
459 HapticPattern::KeyPress,
460 HapticPattern::KeyRelease,
461 HapticPattern::LongPress,
462 HapticPattern::ContextClick,
463 HapticPattern::TextHandleMove,
464 HapticPattern::GestureStart,
465 HapticPattern::GestureEnd,
466 HapticPattern::Rise,
467 HapticPattern::Fall,
468 HapticPattern::Spin,
469 ];
470
471 for start in ALL {
472 let mut seen = alloc::vec::Vec::new();
473 let mut current = Some(*start);
474 while let Some(p) = current {
475 assert!(
476 !seen.contains(&p),
477 "fallback chain from {start:?} cycles at {p:?} (seen {seen:?})"
478 );
479 seen.push(p);
480 assert!(
481 seen.len() <= ALL.len(),
482 "fallback chain from {start:?} is longer than the vocabulary"
483 );
484 current = p.fallback();
485 }
486 assert_eq!(
487 seen.last(),
488 Some(&HapticPattern::Selection),
489 "chain from {start:?} ended at {:?}, not Selection — a backend that only \
490 supports Selection would drop it",
491 seen.last()
492 );
493 }
494 }
495
496 /// The point of the chain: a backend supporting only the one universal
497 /// pattern still renders every request in the vocabulary.
498 #[test]
499 fn a_selection_only_backend_resolves_everything() {
500 for p in [
501 HapticPattern::Spin,
502 HapticPattern::Error,
503 HapticPattern::ImpactHeavy,
504 HapticPattern::TextHandleMove,
505 ] {
506 assert_eq!(
507 p.resolve(|c| c == HapticPattern::Selection),
508 Some(HapticPattern::Selection),
509 "{p:?} did not degrade to Selection"
510 );
511 }
512 }
513
514 /// A backend that supports the pattern natively must NOT degrade it.
515 #[test]
516 fn resolve_prefers_the_exact_pattern() {
517 assert_eq!(
518 HapticPattern::Spin.resolve(|_| true),
519 Some(HapticPattern::Spin)
520 );
521 }
522
523 /// A backend with no actuator at all gets `None` rather than looping.
524 #[test]
525 fn resolve_gives_up_when_nothing_is_supported() {
526 assert_eq!(HapticPattern::Spin.resolve(|_| false), None);
527 }
528
529 /// The dedup exists so a per-frame drag callback ticks once, not 60×.
530 #[test]
531 fn adjacent_identical_requests_coalesce() {
532 let mut m = HapticManager::new();
533 for _ in 0..60 {
534 m.play(HapticPattern::Selection, HapticTarget::System);
535 }
536 assert_eq!(m.take_pending().len(), 1);
537 }
538
539 /// ...but a DIFFERENT pattern in between must survive: coalescing is
540 /// adjacent-only, not a set.
541 #[test]
542 fn a_different_pattern_breaks_the_coalescing_run() {
543 let mut m = HapticManager::new();
544 m.play(HapticPattern::Selection, HapticTarget::System);
545 m.play(HapticPattern::Error, HapticTarget::System);
546 m.play(HapticPattern::Selection, HapticTarget::System);
547 assert_eq!(m.take_pending().len(), 3);
548 }
549
550 /// The same pattern on two different devices is two different requests.
551 #[test]
552 fn the_target_is_part_of_the_coalescing_key() {
553 let mut m = HapticManager::new();
554 m.play(HapticPattern::Selection, HapticTarget::System);
555 m.play(HapticPattern::Selection, HapticTarget::Gamepad(0));
556 assert_eq!(m.take_pending().len(), 2);
557 }
558
559 /// A NaN intensity reaching Android's `addPrimitive` throws, and callers
560 /// derive intensity from velocities that can divide by zero.
561 #[test]
562 fn intensity_is_clamped_and_nan_becomes_full_strength() {
563 let mk = |i: f32| HapticRequest {
564 intensity: i,
565 ..HapticRequest::new(HapticPattern::Selection, HapticTarget::System)
566 };
567 assert_eq!(mk(f32::NAN).intensity_clamped(), 1.0);
568 assert_eq!(mk(-3.0).intensity_clamped(), 0.0);
569 assert_eq!(mk(9.0).intensity_clamped(), 1.0);
570 assert_eq!(mk(0.25).intensity_clamped(), 0.25);
571 }
572
573 /// Draining must actually empty the queue, or the coalescing key (the
574 /// LAST entry) never changes and every later request is swallowed.
575 #[test]
576 fn take_pending_empties_the_queue() {
577 let mut m = HapticManager::new();
578 m.play(HapticPattern::Error, HapticTarget::System);
579 assert!(m.has_pending());
580 assert_eq!(m.take_pending().len(), 1);
581 assert!(!m.has_pending());
582 assert!(m.take_pending().is_empty());
583 }
584}