Skip to main content

frust_material/
interaction.rs

1//! The unified M3(E) interaction substrate: hover/focus/pressed/dragged
2//! tracking, an M3E-precedence state-layer opacity resolver, an optional
3//! press-scale spring spec, and a pluggable haptics hook. Modeled on the
4//! reference's `M3ETappable`/`M3EStateLayer`/`M3EHaptics` foundations
5//! (`tmp/material_3_expressive/lib/foundations/m3e_tappable.dart`,
6//! `m3e_state_layer.dart`, `m3e_haptics.dart`), unified here into one
7//! substrate every interactive component in this crate builds on, rather
8//! than each hand-rolling its own copy of the same four booleans.
9//!
10//! # A deliberate porting decision
11//!
12//! The reference's own `M3EButton` notably does **not** build on
13//! `M3ETappable` — it hand-rolls its own state mixin instead (a verified
14//! research correction, not an omission in this port). This crate unifies
15//! anyway: every interactive component here — present and future — is
16//! meant to track its interaction state through [`InteractionState`]
17//! rather than re-deriving the same four flags per widget, trading the
18//! reference's per-widget flexibility for one shared substrate.
19//!
20//! # Opacity model: precedence, not max-of-active
21//!
22//! [`InteractionState::resolve_opacity`] is the new documented default,
23//! porting `M3EInteractionState.opacity`'s **priority** resolution
24//! exactly: `dragged > pressed > focused > hovered` — the single
25//! highest-priority active state contributes the overlay, never a stack of
26//! several. [`crate::state_layer::StateLayer`] (this crate's older helper,
27//! kept fully back-compatible for its existing consumers —
28//! `list_item`/`card`/`chips`/`fab`/`switch`) instead resolves by
29//! [`InteractionState::max_active_opacity`], the **maximum** of every
30//! active state's opacity.
31//!
32//! Given this crate's own token table — `hover 8% < focus 10% = pressed
33//! 10% < dragged 16%`, i.e. every state later in the precedence order
34//! carries opacity `>=` every state earlier in it — the two algorithms
35//! always agree numerically today: the highest-priority active state is
36//! *always* the maximum active one. They stay two distinct functions
37//! rather than collapsing to one because that agreement is a property of
38//! today's token values, not a guarantee the resolver shapes make on their
39//! own — a future token retune that broke the monotonic ordering would
40//! make them diverge, and `StateLayer`'s own tests pin its (max-of-active)
41//! behavior specifically, independent of this module.
42//!
43//! # Haptics: a pluggable, no-op-by-default hook
44//!
45//! [`MaterialHaptics`] is a process-global, set-once hook. This crate
46//! reaches no `frust-haptics` dependency itself — the design-system
47//! charter forbids a plugin-tier crate depending on a sibling plugin (see
48//! `docs/PLUGINS_ARCHITECTURE.md`'s Layer Dependencies). Firing a
49//! [`HapticSignal`] before an app installs a hook is a silent no-op.
50//! [`MaterialHaptics::set`]'s doc has the exact install-once timing
51//! contract and a worked `frust-haptics` wiring example.
52//!
53//! # Press-scale: shared spec, no animation runtime
54//!
55//! [`PressScaleSpec`] carries only the *shape* of an optional press-scale
56//! spring — a target scale plus a named [`PressSpringId`] — mirroring the
57//! reference's `M3ETappable.pressedScale` + `.spring` pair. This module
58//! ships no animation-controller driving loop of its own: actually
59//! springing a widget's paint scale toward `target_scale` on press (and
60//! back on release) stays each widget's own job, the established pattern
61//! `button_group`'s own `PRESS_SPRING` constant plus `press_anim` field
62//! set — this task ships the shared state and resolved-values plumbing
63//! ([`PressScaleSpec::is_active`], [`PressSpringId::resolve`]), not a
64//! runtime.
65
66use std::sync::OnceLock;
67
68use frust::SpringDesc;
69
70pub use frust::authoring::PRESSED_OPACITY;
71
72/// Hover-state overlay opacity (8%). Source: androidx Compose Material3
73/// `StateTokens` (`v0_210`), the same table [`crate::state_layer`] cites.
74pub const HOVER_OPACITY: f32 = 0.08;
75/// Focus-state overlay opacity (10%).
76pub const FOCUS_OPACITY: f32 = 0.10;
77/// Dragged-state overlay opacity (16%).
78pub const DRAGGED_OPACITY: f32 = 0.16;
79/// Content (icon/label) opacity for a disabled component (38%). Not yet
80/// wired to any live disabled-state widget in this crate — a named token
81/// so the value has one source the moment one exists, the same
82/// aspirational-but-stable status [`crate::state_layer`] documents for its
83/// still-unwired `dragged` state.
84pub const DISABLED_CONTENT_OPACITY: f32 = 0.38;
85/// Container opacity for a disabled component (12%). Same unwired status as
86/// [`DISABLED_CONTENT_OPACITY`].
87pub const DISABLED_CONTAINER_OPACITY: f32 = 0.12;
88
89/// Tracks a widget's hover/focus/pressed/dragged interaction state. See the
90/// [module docs](self) for the two opacity resolvers this feeds.
91#[derive(Debug, Default, Clone, Copy, PartialEq)]
92pub struct InteractionState {
93    pub hovered: bool,
94    pub focused: bool,
95    pub pressed: bool,
96    pub dragged: bool,
97}
98
99impl InteractionState {
100    /// A fresh state with every interaction flag clear.
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    /// Record the widget's hover state. Returns whether the flag changed —
106    /// the frame source for hover *gain* per `docs/CODE_STANDARDS.md`'s
107    /// Interaction Semantics hover-claiming seam (`claim_hover` itself
108    /// requests no redraw).
109    pub fn set_hovered(&mut self, hovered: bool) -> bool {
110        let changed = self.hovered != hovered;
111        self.hovered = hovered;
112        changed
113    }
114
115    /// Record the widget's focus state. Returns whether the flag changed.
116    pub fn set_focused(&mut self, focused: bool) -> bool {
117        let changed = self.focused != focused;
118        self.focused = focused;
119        changed
120    }
121
122    /// Record the widget's pressed state. Returns whether the flag changed.
123    pub fn set_pressed(&mut self, pressed: bool) -> bool {
124        let changed = self.pressed != pressed;
125        self.pressed = pressed;
126        changed
127    }
128
129    /// Record the widget's dragged state. Returns whether the flag changed.
130    pub fn set_dragged(&mut self, dragged: bool) -> bool {
131        let changed = self.dragged != dragged;
132        self.dragged = dragged;
133        changed
134    }
135
136    /// Whether any interaction state is currently active.
137    pub fn is_active(&self) -> bool {
138        self.hovered || self.focused || self.pressed || self.dragged
139    }
140
141    /// The M3E-precedence overlay opacity: `dragged > pressed > focused >
142    /// hovered` — the single highest-priority active state's opacity, or
143    /// `0.0` if none are active. The new documented default; see the
144    /// [module docs](self) for how this differs from
145    /// [`Self::max_active_opacity`].
146    pub fn resolve_opacity(&self) -> f32 {
147        if self.dragged {
148            DRAGGED_OPACITY
149        } else if self.pressed {
150            PRESSED_OPACITY
151        } else if self.focused {
152            FOCUS_OPACITY
153        } else if self.hovered {
154            HOVER_OPACITY
155        } else {
156            0.0
157        }
158    }
159
160    /// The maximum of every active state's opacity —
161    /// [`crate::state_layer::StateLayer`]'s original resolver, kept for its
162    /// back-compat contract. See the [module docs](self).
163    pub fn max_active_opacity(&self) -> f32 {
164        let mut opacity: f32 = 0.0;
165        if self.hovered {
166            opacity = opacity.max(HOVER_OPACITY);
167        }
168        if self.focused {
169            opacity = opacity.max(FOCUS_OPACITY);
170        }
171        if self.pressed {
172            opacity = opacity.max(PRESSED_OPACITY);
173        }
174        if self.dragged {
175            opacity = opacity.max(DRAGGED_OPACITY);
176        }
177        opacity
178    }
179}
180
181/// A named reference into `crate::tokens::motion_scheme()`'s six spring
182/// presets. A [`PressScaleSpec`] carries this id rather than a raw
183/// `SpringDesc` so a widget's event-pass code — which never reads a theme,
184/// see `docs/CODE_STANDARDS.md`'s Theming conventions — can still name a
185/// resolvable spring by its motion-scheme slot; [`Self::resolve`] reads
186/// this crate's own compile-time token table, not a `Theme` recovered from
187/// paint/layout context.
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum PressSpringId {
190    FastSpatial,
191    FastEffects,
192    DefaultSpatial,
193    DefaultEffects,
194    SlowSpatial,
195    SlowEffects,
196}
197
198impl PressSpringId {
199    /// Resolve to the concrete [`SpringDesc`] this id names, read from
200    /// `crate::tokens::motion_scheme()`.
201    pub fn resolve(self) -> SpringDesc {
202        let m = crate::tokens::motion_scheme();
203        match self {
204            Self::FastSpatial => m.fast_spatial.into(),
205            Self::FastEffects => m.fast_effects.into(),
206            Self::DefaultSpatial => m.default_spatial.into(),
207            Self::DefaultEffects => m.default_effects.into(),
208            Self::SlowSpatial => m.slow_spatial.into(),
209            Self::SlowEffects => m.slow_effects.into(),
210        }
211    }
212}
213
214/// The optional press-scale spring spec a tappable surface may opt into:
215/// the target scale to spring toward while pressed, and which named spring
216/// drives it. Mirrors the reference's `M3ETappable.pressedScale` + `.spring`
217/// pair. See the [module docs](self) for why this is spec data only, with
218/// no driving loop of its own.
219#[derive(Debug, Clone, Copy, PartialEq)]
220pub struct PressScaleSpec {
221    /// The scale factor to spring toward while pressed.
222    /// [`Self::DISABLED_SCALE`] disables press-scale entirely.
223    pub target_scale: f64,
224    /// Which named `crate::tokens::motion_scheme()` spring drives the
225    /// scale animation.
226    pub spring: PressSpringId,
227}
228
229impl PressScaleSpec {
230    /// The `target_scale` value that disables press-scale (mirrors the
231    /// reference's `pressedScale: 1`).
232    pub const DISABLED_SCALE: f64 = 1.0;
233
234    /// Whether this spec actually springs the surface
235    /// (`target_scale != `[`Self::DISABLED_SCALE`]).
236    pub fn is_active(&self) -> bool {
237        self.target_scale != Self::DISABLED_SCALE
238    }
239}
240
241impl Default for PressScaleSpec {
242    /// Disabled (`target_scale == 1.0`), spring id irrelevant while
243    /// inactive — [`Self::DISABLED_SCALE`] with [`PressSpringId::FastSpatial`]
244    /// as an arbitrary but harmless placeholder.
245    fn default() -> Self {
246        Self {
247            target_scale: Self::DISABLED_SCALE,
248            spring: PressSpringId::FastSpatial,
249        }
250    }
251}
252
253/// A haptic feedback intensity signal, mirroring the reference's
254/// `M3EHapticFeedback` (`none`/`light`/`medium`/`heavy`) plus a
255/// `SliderTick` variant for a discrete stepped control's tick — the
256/// reference's separate `M3EHaptics.selection()` entry point, folded into
257/// this one closed vocabulary rather than left as a same-numbered alias of
258/// `Light`, since a caller may want to route it to a different platform
259/// primitive (e.g. iOS's `UISelectionFeedbackGenerator` rather than an
260/// impact generator).
261///
262/// A **closed** enum (not `#[non_exhaustive]`) — this crate owns the whole
263/// vocabulary a hook needs to match on exhaustively.
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub enum HapticSignal {
266    /// No haptic feedback — a hook seeing this should treat it as a no-op,
267    /// same as firing nothing at all.
268    None,
269    /// Light tap feedback for subtle interactions.
270    Light,
271    /// Medium impact for a standard press.
272    Medium,
273    /// Heavy impact for a significant action.
274    Heavy,
275    /// A discrete stepped control's tick (e.g. a slider crossing a step, a
276    /// segmented picker landing on a new segment).
277    SliderTick,
278}
279
280/// The set-once hook cell backing [`MaterialHaptics`]. Private: all access
281/// goes through [`MaterialHaptics::set`]/[`MaterialHaptics::fire`].
282static HAPTIC_HOOK: OnceLock<fn(HapticSignal)> = OnceLock::new();
283
284/// The default hook: does nothing. In effect whenever
285/// [`MaterialHaptics::fire`] runs before any [`MaterialHaptics::set`] call
286/// has won.
287fn noop_hook(_signal: HapticSignal) {}
288
289/// The design system's pluggable, process-global haptics seam. Carries no
290/// state of its own — every method is a plain associated function over one
291/// process-wide hook cell. See the [module docs](self) for why this crate
292/// reaches no `frust-haptics` dependency itself.
293pub struct MaterialHaptics;
294
295impl MaterialHaptics {
296    /// Install the process-wide haptics hook.
297    ///
298    /// **Timing contract:** intended to be called once, from an app's own
299    /// `install()`-era setup (mirroring how [`crate::install`] seeds the
300    /// default theme) — before any widget can call [`Self::fire`]. The
301    /// underlying cell is set-once: the *first* successful call wins, and
302    /// every later call is rejected outright — a hook swap (last-wins) is
303    /// deliberately **not** supported, so there is no way to uninstall or
304    /// replace a hook once one has won. Returns `true` iff this call
305    /// installed the hook, `false` if a hook was already installed and this
306    /// one was rejected.
307    ///
308    /// # Wiring to `frust-haptics`
309    ///
310    /// `frust-material` never depends on `frust-haptics` (or any other
311    /// plugin) itself — the design-system charter forbids a plugin-tier
312    /// crate depending on a sibling plugin (`docs/PLUGINS_ARCHITECTURE.md`'s
313    /// Layer Dependencies). An app that wants real haptic feedback depends
314    /// on `frust-haptics` itself and wires it here — this example is
315    /// `ignore`d (not `no_run`) because `frust-haptics` is intentionally
316    /// absent from this crate's own dependency graph, including as a
317    /// dev-dependency, so it cannot compile in this doctest:
318    ///
319    /// ```ignore
320    /// // In the app's own crate — frust-haptics is *its* dependency, never
321    /// // frust-material's.
322    /// fn install_haptics() {
323    ///     frust_material::MaterialHaptics::set(|signal| {
324    ///         use frust_material::HapticSignal;
325    ///         use frust_haptics::{HapticEffect, Haptics};
326    ///         let effect = match signal {
327    ///             HapticSignal::None => return,
328    ///             HapticSignal::Light => HapticEffect::ImpactLight,
329    ///             HapticSignal::Medium => HapticEffect::ImpactMedium,
330    ///             HapticSignal::Heavy => HapticEffect::ImpactHeavy,
331    ///             HapticSignal::SliderTick => HapticEffect::SelectionClick,
332    ///         };
333    ///         let _ = Haptics::perform(effect);
334    ///     });
335    /// }
336    /// ```
337    pub fn set(hook: fn(HapticSignal)) -> bool {
338        HAPTIC_HOOK.set(hook).is_ok()
339    }
340
341    /// Fire `signal` through the installed hook — a silent no-op if none
342    /// has been installed yet (the default; see the [module docs](self)).
343    pub fn fire(signal: HapticSignal) {
344        let hook = HAPTIC_HOOK.get().copied().unwrap_or(noop_hook);
345        hook(signal);
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn precedence_resolver_covers_all_sixteen_combinations() {
355        for hovered in [false, true] {
356            for focused in [false, true] {
357                for pressed in [false, true] {
358                    for dragged in [false, true] {
359                        let state = InteractionState {
360                            hovered,
361                            focused,
362                            pressed,
363                            dragged,
364                        };
365                        let expected = if dragged {
366                            DRAGGED_OPACITY
367                        } else if pressed {
368                            PRESSED_OPACITY
369                        } else if focused {
370                            FOCUS_OPACITY
371                        } else if hovered {
372                            HOVER_OPACITY
373                        } else {
374                            0.0
375                        };
376                        assert_eq!(
377                            state.resolve_opacity(),
378                            expected,
379                            "hovered={hovered} focused={focused} pressed={pressed} dragged={dragged}"
380                        );
381                    }
382                }
383            }
384        }
385    }
386
387    #[test]
388    fn precedence_and_max_agree_on_this_crates_monotonic_token_table() {
389        // Documented in the module doc: given hover < focus == pressed <
390        // dragged, precedence-selecting the highest-priority active state
391        // always yields the same value as maxing every active state.
392        for hovered in [false, true] {
393            for focused in [false, true] {
394                for pressed in [false, true] {
395                    for dragged in [false, true] {
396                        let state = InteractionState {
397                            hovered,
398                            focused,
399                            pressed,
400                            dragged,
401                        };
402                        assert_eq!(state.resolve_opacity(), state.max_active_opacity());
403                    }
404                }
405            }
406        }
407    }
408
409    #[test]
410    fn setters_report_whether_the_flag_changed() {
411        let mut state = InteractionState::new();
412        assert!(state.set_hovered(true), "false -> true is a change");
413        assert!(!state.set_hovered(true), "true -> true is not a change");
414        assert!(state.set_hovered(false), "true -> false is a change");
415
416        assert!(state.set_focused(true));
417        assert!(state.set_pressed(true));
418        assert!(state.set_dragged(true));
419        assert!(state.is_active());
420    }
421
422    #[test]
423    fn no_active_state_has_zero_opacity_and_is_inactive() {
424        let state = InteractionState::new();
425        assert_eq!(state.resolve_opacity(), 0.0);
426        assert_eq!(state.max_active_opacity(), 0.0);
427        assert!(!state.is_active());
428    }
429
430    #[test]
431    fn press_spring_id_resolves_to_the_named_motion_scheme_preset() {
432        let m = crate::tokens::motion_scheme();
433        assert_eq!(PressSpringId::FastSpatial.resolve(), m.fast_spatial.into());
434        assert_eq!(PressSpringId::FastEffects.resolve(), m.fast_effects.into());
435        assert_eq!(
436            PressSpringId::DefaultSpatial.resolve(),
437            m.default_spatial.into()
438        );
439        assert_eq!(
440            PressSpringId::DefaultEffects.resolve(),
441            m.default_effects.into()
442        );
443        assert_eq!(PressSpringId::SlowSpatial.resolve(), m.slow_spatial.into());
444        assert_eq!(PressSpringId::SlowEffects.resolve(), m.slow_effects.into());
445    }
446
447    #[test]
448    fn press_scale_spec_is_active_reflects_target_scale() {
449        let disabled = PressScaleSpec::default();
450        assert!(!disabled.is_active());
451
452        let active = PressScaleSpec {
453            target_scale: 0.95,
454            spring: PressSpringId::FastSpatial,
455        };
456        assert!(active.is_active());
457    }
458
459    // -- MaterialHaptics ------------------------------------------------
460    //
461    // `HAPTIC_HOOK` is one process-wide `OnceLock` per the set-once
462    // contract `MaterialHaptics::set` documents, so every assertion that
463    // depends on install/rejection ordering lives in this single test —
464    // splitting it across multiple `#[test]` fns would make the outcome
465    // depend on which one the test harness happens to run first (they all
466    // share one process).
467
468    static RECORDED: std::sync::Mutex<Vec<HapticSignal>> = std::sync::Mutex::new(Vec::new());
469
470    fn recording_hook(signal: HapticSignal) {
471        RECORDED.lock().unwrap().push(signal);
472    }
473
474    fn other_recording_hook(signal: HapticSignal) {
475        // A second, distinct hook — used only to prove it never wins once
476        // the first `set` has already succeeded.
477        RECORDED.lock().unwrap().push(signal);
478        RECORDED.lock().unwrap().push(HapticSignal::None);
479    }
480
481    #[test]
482    fn haptics_default_noop_then_install_wins_and_rejects_second_set() {
483        // Default: firing before any install must not panic. There is
484        // nothing else to assert about a no-op.
485        MaterialHaptics::fire(HapticSignal::Medium);
486
487        assert!(
488            MaterialHaptics::set(recording_hook),
489            "the first set() must install and win"
490        );
491
492        MaterialHaptics::fire(HapticSignal::Light);
493        assert_eq!(*RECORDED.lock().unwrap(), vec![HapticSignal::Light]);
494
495        assert!(
496            !MaterialHaptics::set(other_recording_hook),
497            "a second set() after the first must be rejected"
498        );
499
500        RECORDED.lock().unwrap().clear();
501        MaterialHaptics::fire(HapticSignal::Heavy);
502        assert_eq!(
503            *RECORDED.lock().unwrap(),
504            vec![HapticSignal::Heavy],
505            "the rejected set() must not have replaced the installed hook \
506             (a plain single push, not other_recording_hook's push-then-None)"
507        );
508    }
509}