use crate::spring::{MotionModel, MotionSpringPhysics};
use std::time::Duration;
pub const MOTION_POLICY_MAX_UI_DURATION: Duration = Duration::from_millis(300);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MotionPolicyContext {
PointerDrag,
KeyboardFocus,
VisualAffordancePreview,
AffordancePresence,
CommittedLayout,
Continuity,
Decorative,
}
impl MotionPolicyContext {
pub const fn allows_spatial_motion(self) -> bool {
!matches!(self, Self::PointerDrag | Self::KeyboardFocus)
}
pub const fn allows_extended_duration(self) -> bool {
matches!(self, Self::Continuity)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MotionPreviewTargetPolicy {
NotPreview,
SameIdentity,
UnrelatedIdentity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MotionPolicyIssue {
SpatialMotionForbidden,
DurationOverBudget,
ExcessiveBounce,
MissingReducedMotionFinalState,
UnrelatedTargetPreviewInterpolation,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MotionPolicyInput {
context: MotionPolicyContext,
model: MotionModel,
spatial_motion: bool,
reduced_motion_final_state: bool,
preview_target: MotionPreviewTargetPolicy,
reported_bounce: Option<f32>,
}
impl MotionPolicyInput {
pub const fn new(context: MotionPolicyContext, model: MotionModel) -> Self {
Self {
context,
model,
spatial_motion: false,
reduced_motion_final_state: false,
preview_target: MotionPreviewTargetPolicy::NotPreview,
reported_bounce: None,
}
}
pub const fn context(self) -> MotionPolicyContext {
self.context
}
pub const fn model(self) -> MotionModel {
self.model
}
pub const fn spatial_motion(self) -> bool {
self.spatial_motion
}
pub const fn reduced_motion_final_state(self) -> bool {
self.reduced_motion_final_state
}
pub const fn preview_target(self) -> MotionPreviewTargetPolicy {
self.preview_target
}
pub const fn reported_bounce(self) -> Option<f32> {
self.reported_bounce
}
pub const fn with_spatial_motion(mut self, spatial_motion: bool) -> Self {
self.spatial_motion = spatial_motion;
self
}
pub const fn with_reduced_motion_final_state(
mut self,
reduced_motion_final_state: bool,
) -> Self {
self.reduced_motion_final_state = reduced_motion_final_state;
self
}
pub const fn with_preview_target(mut self, preview_target: MotionPreviewTargetPolicy) -> Self {
self.preview_target = preview_target;
self
}
pub const fn with_reported_bounce(mut self, bounce: f32) -> Self {
self.reported_bounce = Some(bounce);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MotionPolicyReport {
issues: Vec<MotionPolicyIssue>,
}
impl MotionPolicyReport {
pub fn new(issues: Vec<MotionPolicyIssue>) -> Self {
Self { issues }
}
pub fn issues(&self) -> &[MotionPolicyIssue] {
&self.issues
}
pub fn is_ok(&self) -> bool {
self.issues.is_empty()
}
pub fn has_issue(&self, issue: MotionPolicyIssue) -> bool {
self.issues.contains(&issue)
}
}
pub fn validate_motion_policy(input: MotionPolicyInput) -> MotionPolicyReport {
let mut issues = Vec::new();
if input.spatial_motion() && !input.context().allows_spatial_motion() {
issues.push(MotionPolicyIssue::SpatialMotionForbidden);
}
if !input.context().allows_extended_duration()
&& model_review_duration(input.model()) > MOTION_POLICY_MAX_UI_DURATION
{
issues.push(MotionPolicyIssue::DurationOverBudget);
}
if input
.reported_bounce()
.unwrap_or_else(|| model_bounce(input.model()))
> MotionSpringPhysics::MAX_REVIEWABLE_BOUNCE
{
issues.push(MotionPolicyIssue::ExcessiveBounce);
}
if !input.reduced_motion_final_state() {
issues.push(MotionPolicyIssue::MissingReducedMotionFinalState);
}
if input.spatial_motion()
&& matches!(
input.preview_target(),
MotionPreviewTargetPolicy::UnrelatedIdentity
)
{
issues.push(MotionPolicyIssue::UnrelatedTargetPreviewInterpolation);
}
MotionPolicyReport::new(issues)
}
fn model_review_duration(model: MotionModel) -> Duration {
match model {
MotionModel::Timeline(spec) => spec.duration().as_duration(),
MotionModel::Spring(spec) => spec.physics().review_duration(),
}
}
fn model_bounce(model: MotionModel) -> f32 {
match model {
MotionModel::Timeline(_) => 0.0,
MotionModel::Spring(spec) => spec.physics().bounce(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
MotionDuration, MotionEasing, MotionModel, MotionPreference, MotionSpec, MotionSpringSpec,
};
use std::time::Duration;
#[test]
fn committed_layout_motion_under_budget_passes_policy() {
let input = MotionPolicyInput::new(
MotionPolicyContext::CommittedLayout,
MotionModel::timeline(MotionSpec::committed_layout(MotionPreference::Animated)),
)
.with_spatial_motion(true)
.with_reduced_motion_final_state(true);
assert!(validate_motion_policy(input).is_ok());
}
#[test]
fn pointer_drag_spatial_motion_is_rejected() {
let input = MotionPolicyInput::new(
MotionPolicyContext::PointerDrag,
MotionModel::spring(MotionSpringSpec::layout(MotionPreference::Animated)),
)
.with_spatial_motion(true)
.with_reduced_motion_final_state(true);
let report = validate_motion_policy(input);
assert!(report.has_issue(MotionPolicyIssue::SpatialMotionForbidden));
}
#[test]
fn keyboard_focus_spatial_motion_is_rejected() {
let input = MotionPolicyInput::new(
MotionPolicyContext::KeyboardFocus,
MotionModel::timeline(MotionSpec::continuity(MotionPreference::Animated)),
)
.with_spatial_motion(true)
.with_reduced_motion_final_state(true);
let report = validate_motion_policy(input);
assert!(report.has_issue(MotionPolicyIssue::SpatialMotionForbidden));
}
#[test]
fn overlong_ui_motion_without_continuity_reason_is_rejected() {
let input = MotionPolicyInput::new(
MotionPolicyContext::CommittedLayout,
MotionModel::timeline(MotionSpec::new(
MotionPreference::Animated,
MotionDuration::Custom(Duration::from_millis(420)),
MotionEasing::EaseOut,
)),
)
.with_spatial_motion(true)
.with_reduced_motion_final_state(true);
let report = validate_motion_policy(input);
assert!(report.has_issue(MotionPolicyIssue::DurationOverBudget));
}
#[test]
fn excessive_bounce_is_rejected() {
let input = MotionPolicyInput::new(
MotionPolicyContext::CommittedLayout,
MotionModel::spring(MotionSpringSpec::layout(MotionPreference::Animated)),
)
.with_spatial_motion(true)
.with_reported_bounce(0.8)
.with_reduced_motion_final_state(true);
let report = validate_motion_policy(input);
assert!(report.has_issue(MotionPolicyIssue::ExcessiveBounce));
}
#[test]
fn unrelated_target_preview_interpolation_is_rejected() {
let input = MotionPolicyInput::new(
MotionPolicyContext::VisualAffordancePreview,
MotionModel::spring(MotionSpringSpec::affordance(MotionPreference::Animated)),
)
.with_spatial_motion(true)
.with_preview_target(MotionPreviewTargetPolicy::UnrelatedIdentity)
.with_reduced_motion_final_state(true);
let report = validate_motion_policy(input);
assert!(report.has_issue(MotionPolicyIssue::UnrelatedTargetPreviewInterpolation));
}
#[test]
fn reduced_motion_final_semantics_without_spatial_motion_pass() {
let input = MotionPolicyInput::new(
MotionPolicyContext::CommittedLayout,
MotionModel::spring(MotionSpringSpec::layout(MotionPreference::Reduced)),
)
.with_spatial_motion(false)
.with_reduced_motion_final_state(true);
assert!(validate_motion_policy(input).is_ok());
}
}