Skip to main content

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}