use std::sync::OnceLock;
use frust::SpringDesc;
pub use frust::authoring::PRESSED_OPACITY;
pub const HOVER_OPACITY: f32 = 0.08;
pub const FOCUS_OPACITY: f32 = 0.10;
pub const DRAGGED_OPACITY: f32 = 0.16;
pub const DISABLED_CONTENT_OPACITY: f32 = 0.38;
pub const DISABLED_CONTAINER_OPACITY: f32 = 0.12;
#[derive(Debug, Default, Clone, Copy, PartialEq)]
pub struct InteractionState {
pub hovered: bool,
pub focused: bool,
pub pressed: bool,
pub dragged: bool,
}
impl InteractionState {
pub fn new() -> Self {
Self::default()
}
pub fn set_hovered(&mut self, hovered: bool) -> bool {
let changed = self.hovered != hovered;
self.hovered = hovered;
changed
}
pub fn set_focused(&mut self, focused: bool) -> bool {
let changed = self.focused != focused;
self.focused = focused;
changed
}
pub fn set_pressed(&mut self, pressed: bool) -> bool {
let changed = self.pressed != pressed;
self.pressed = pressed;
changed
}
pub fn set_dragged(&mut self, dragged: bool) -> bool {
let changed = self.dragged != dragged;
self.dragged = dragged;
changed
}
pub fn is_active(&self) -> bool {
self.hovered || self.focused || self.pressed || self.dragged
}
pub fn resolve_opacity(&self) -> f32 {
if self.dragged {
DRAGGED_OPACITY
} else if self.pressed {
PRESSED_OPACITY
} else if self.focused {
FOCUS_OPACITY
} else if self.hovered {
HOVER_OPACITY
} else {
0.0
}
}
pub fn max_active_opacity(&self) -> f32 {
let mut opacity: f32 = 0.0;
if self.hovered {
opacity = opacity.max(HOVER_OPACITY);
}
if self.focused {
opacity = opacity.max(FOCUS_OPACITY);
}
if self.pressed {
opacity = opacity.max(PRESSED_OPACITY);
}
if self.dragged {
opacity = opacity.max(DRAGGED_OPACITY);
}
opacity
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PressSpringId {
FastSpatial,
FastEffects,
DefaultSpatial,
DefaultEffects,
SlowSpatial,
SlowEffects,
}
impl PressSpringId {
pub fn resolve(self) -> SpringDesc {
let m = crate::tokens::motion_scheme();
match self {
Self::FastSpatial => m.fast_spatial.into(),
Self::FastEffects => m.fast_effects.into(),
Self::DefaultSpatial => m.default_spatial.into(),
Self::DefaultEffects => m.default_effects.into(),
Self::SlowSpatial => m.slow_spatial.into(),
Self::SlowEffects => m.slow_effects.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PressScaleSpec {
pub target_scale: f64,
pub spring: PressSpringId,
}
impl PressScaleSpec {
pub const DISABLED_SCALE: f64 = 1.0;
pub fn is_active(&self) -> bool {
self.target_scale != Self::DISABLED_SCALE
}
}
impl Default for PressScaleSpec {
fn default() -> Self {
Self {
target_scale: Self::DISABLED_SCALE,
spring: PressSpringId::FastSpatial,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HapticSignal {
None,
Light,
Medium,
Heavy,
SliderTick,
}
static HAPTIC_HOOK: OnceLock<fn(HapticSignal)> = OnceLock::new();
fn noop_hook(_signal: HapticSignal) {}
pub struct MaterialHaptics;
impl MaterialHaptics {
pub fn set(hook: fn(HapticSignal)) -> bool {
HAPTIC_HOOK.set(hook).is_ok()
}
pub fn fire(signal: HapticSignal) {
let hook = HAPTIC_HOOK.get().copied().unwrap_or(noop_hook);
hook(signal);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn precedence_resolver_covers_all_sixteen_combinations() {
for hovered in [false, true] {
for focused in [false, true] {
for pressed in [false, true] {
for dragged in [false, true] {
let state = InteractionState {
hovered,
focused,
pressed,
dragged,
};
let expected = if dragged {
DRAGGED_OPACITY
} else if pressed {
PRESSED_OPACITY
} else if focused {
FOCUS_OPACITY
} else if hovered {
HOVER_OPACITY
} else {
0.0
};
assert_eq!(
state.resolve_opacity(),
expected,
"hovered={hovered} focused={focused} pressed={pressed} dragged={dragged}"
);
}
}
}
}
}
#[test]
fn precedence_and_max_agree_on_this_crates_monotonic_token_table() {
for hovered in [false, true] {
for focused in [false, true] {
for pressed in [false, true] {
for dragged in [false, true] {
let state = InteractionState {
hovered,
focused,
pressed,
dragged,
};
assert_eq!(state.resolve_opacity(), state.max_active_opacity());
}
}
}
}
}
#[test]
fn setters_report_whether_the_flag_changed() {
let mut state = InteractionState::new();
assert!(state.set_hovered(true), "false -> true is a change");
assert!(!state.set_hovered(true), "true -> true is not a change");
assert!(state.set_hovered(false), "true -> false is a change");
assert!(state.set_focused(true));
assert!(state.set_pressed(true));
assert!(state.set_dragged(true));
assert!(state.is_active());
}
#[test]
fn no_active_state_has_zero_opacity_and_is_inactive() {
let state = InteractionState::new();
assert_eq!(state.resolve_opacity(), 0.0);
assert_eq!(state.max_active_opacity(), 0.0);
assert!(!state.is_active());
}
#[test]
fn press_spring_id_resolves_to_the_named_motion_scheme_preset() {
let m = crate::tokens::motion_scheme();
assert_eq!(PressSpringId::FastSpatial.resolve(), m.fast_spatial.into());
assert_eq!(PressSpringId::FastEffects.resolve(), m.fast_effects.into());
assert_eq!(
PressSpringId::DefaultSpatial.resolve(),
m.default_spatial.into()
);
assert_eq!(
PressSpringId::DefaultEffects.resolve(),
m.default_effects.into()
);
assert_eq!(PressSpringId::SlowSpatial.resolve(), m.slow_spatial.into());
assert_eq!(PressSpringId::SlowEffects.resolve(), m.slow_effects.into());
}
#[test]
fn press_scale_spec_is_active_reflects_target_scale() {
let disabled = PressScaleSpec::default();
assert!(!disabled.is_active());
let active = PressScaleSpec {
target_scale: 0.95,
spring: PressSpringId::FastSpatial,
};
assert!(active.is_active());
}
static RECORDED: std::sync::Mutex<Vec<HapticSignal>> = std::sync::Mutex::new(Vec::new());
fn recording_hook(signal: HapticSignal) {
RECORDED.lock().unwrap().push(signal);
}
fn other_recording_hook(signal: HapticSignal) {
RECORDED.lock().unwrap().push(signal);
RECORDED.lock().unwrap().push(HapticSignal::None);
}
#[test]
fn haptics_default_noop_then_install_wins_and_rejects_second_set() {
MaterialHaptics::fire(HapticSignal::Medium);
assert!(
MaterialHaptics::set(recording_hook),
"the first set() must install and win"
);
MaterialHaptics::fire(HapticSignal::Light);
assert_eq!(*RECORDED.lock().unwrap(), vec![HapticSignal::Light]);
assert!(
!MaterialHaptics::set(other_recording_hook),
"a second set() after the first must be rejected"
);
RECORDED.lock().unwrap().clear();
MaterialHaptics::fire(HapticSignal::Heavy);
assert_eq!(
*RECORDED.lock().unwrap(),
vec![HapticSignal::Heavy],
"the rejected set() must not have replaced the installed hook \
(a plain single push, not other_recording_hook's push-then-None)"
);
}
}