Share window geometry across overview, resize snapshots and retained closes. Advance close handoffs and group retained rendering by stacking position. Drive shell motion from surface frames, reuse settled widget content, and synchronize notification materials with each card's fade. Replace physical config keys with duration and bounce, and update documentation and regression coverage.
253 lines
7.9 KiB
Rust
253 lines
7.9 KiB
Rust
use crate::{CrossingPolicy, SpringConfig};
|
|
|
|
/// One configuration contract for compositor and out-of-process shell motion.
|
|
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
|
|
#[serde(default, deny_unknown_fields)]
|
|
pub struct MotionSettings {
|
|
pub enabled: bool,
|
|
pub reduced_motion: bool,
|
|
pub speed: f64,
|
|
pub spring: SpringSettings,
|
|
pub viewport_spring: SpringSettings,
|
|
}
|
|
|
|
impl Default for MotionSettings {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: true,
|
|
reduced_motion: false,
|
|
speed: 1.0,
|
|
spring: SpringSettings::default(),
|
|
viewport_spring: SpringSettings::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl MotionSettings {
|
|
pub fn motion_enabled(self) -> bool {
|
|
self.enabled && !self.reduced_motion
|
|
}
|
|
|
|
pub fn validate(self) -> Result<(), String> {
|
|
if !self.speed.is_finite() || self.speed <= 0.0 {
|
|
return Err("animation speed must be finite and positive".into());
|
|
}
|
|
self.spring_config()?;
|
|
self.viewport_config()?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn spring_config(self) -> Result<SpringConfig, String> {
|
|
self.spring.resolve(240.0)
|
|
}
|
|
|
|
pub fn viewport_config(self) -> Result<SpringConfig, String> {
|
|
self.viewport_spring.resolve(350.0)
|
|
}
|
|
|
|
/// Only small opacity changes should use a timed duration.
|
|
pub fn duration(self, milliseconds: f64) -> std::time::Duration {
|
|
if !self.motion_enabled() {
|
|
return std::time::Duration::ZERO;
|
|
}
|
|
std::time::Duration::from_secs_f64(milliseconds / 1000.0 / self.speed)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Deserialize)]
|
|
#[serde(default, deny_unknown_fields)]
|
|
pub struct SpringSettings {
|
|
pub duration_ms: Option<f64>,
|
|
pub bounce: f64,
|
|
pub overshoot: bool,
|
|
}
|
|
|
|
impl SpringSettings {
|
|
fn resolve(self, default_duration_ms: f64) -> Result<SpringConfig, String> {
|
|
let duration = self.duration_ms.unwrap_or(default_duration_ms);
|
|
if !duration.is_finite() || duration <= 0.0 {
|
|
return Err("spring duration-ms must be finite and positive".into());
|
|
}
|
|
if !self.bounce.is_finite() || self.bounce <= -1.0 || self.bounce >= 1.0 {
|
|
return Err("spring bounce must be between -1 and 1 (exclusive)".into());
|
|
}
|
|
if self.bounce > 0.0 && !self.overshoot {
|
|
return Err("positive spring bounce requires overshoot".into());
|
|
}
|
|
let ratio = if self.bounce >= 0.0 {
|
|
1.0 - self.bounce
|
|
} else {
|
|
1.0 / (1.0 + self.bounce)
|
|
};
|
|
let omega = std::f64::consts::TAU / (duration / 1000.0);
|
|
let config = SpringConfig {
|
|
mass: 1.0,
|
|
stiffness: omega * omega,
|
|
damping: 2.0 * omega * ratio,
|
|
crossing: if self.overshoot {
|
|
CrossingPolicy::AllowOvershoot
|
|
} else {
|
|
CrossingPolicy::NoCrossing
|
|
},
|
|
..SpringConfig::default()
|
|
};
|
|
// Guard derived values too: extreme finite inputs can overflow or
|
|
// underflow. Every accepted presentation spring must dissipate motion.
|
|
if !config.stiffness.is_finite()
|
|
|| config.stiffness <= 0.0
|
|
|| !config.damping.is_finite()
|
|
|| config.damping <= 0.0
|
|
{
|
|
return Err("spring duration-ms/bounce produce out-of-range coefficients".into());
|
|
}
|
|
Ok(config)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn defaults_are_critical_with_distinct_spatial_and_viewport_responses() {
|
|
let settings = MotionSettings::default();
|
|
for (spring, duration) in [
|
|
(settings.spring_config().unwrap(), 0.240),
|
|
(settings.viewport_config().unwrap(), 0.350),
|
|
] {
|
|
assert_eq!(spring.mass, 1.0);
|
|
assert!((spring.damping / (2.0 * spring.stiffness.sqrt()) - 1.0).abs() < 1e-12);
|
|
assert!((std::f64::consts::TAU / spring.stiffness.sqrt() - duration).abs() < 1e-12);
|
|
assert_eq!(spring.crossing, CrossingPolicy::NoCrossing);
|
|
}
|
|
settings.validate().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn perceptual_conversion_covers_under_critical_and_overdamping() {
|
|
for (bounce, ratio) in [(0.0, 1.0), (0.3, 0.7), (-0.5, 2.0)] {
|
|
let spring = SpringSettings {
|
|
duration_ms: Some(500.0),
|
|
bounce,
|
|
overshoot: bounce > 0.0,
|
|
}
|
|
.resolve(240.0)
|
|
.unwrap();
|
|
assert!((spring.stiffness - 157.913670417).abs() < 1e-8);
|
|
assert!((spring.damping / (2.0 * spring.stiffness.sqrt()) - ratio).abs() < 1e-10);
|
|
assert!((std::f64::consts::TAU * (spring.mass / spring.stiffness).sqrt() - 0.5).abs() < 1e-10);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_nonsettling_and_out_of_range_settings_for_both_properties() {
|
|
for spring in [
|
|
SpringSettings {
|
|
bounce: 1.0,
|
|
overshoot: true,
|
|
..Default::default()
|
|
},
|
|
SpringSettings {
|
|
bounce: -1.0,
|
|
..Default::default()
|
|
},
|
|
SpringSettings {
|
|
bounce: f64::NAN,
|
|
..Default::default()
|
|
},
|
|
SpringSettings {
|
|
bounce: 0.2,
|
|
..Default::default()
|
|
},
|
|
SpringSettings {
|
|
duration_ms: Some(0.0),
|
|
..Default::default()
|
|
},
|
|
SpringSettings {
|
|
duration_ms: Some(-100.0),
|
|
..Default::default()
|
|
},
|
|
SpringSettings {
|
|
duration_ms: Some(f64::INFINITY),
|
|
..Default::default()
|
|
},
|
|
SpringSettings {
|
|
duration_ms: Some(f64::MIN_POSITIVE),
|
|
..Default::default()
|
|
},
|
|
SpringSettings {
|
|
duration_ms: Some(f64::MAX),
|
|
..Default::default()
|
|
},
|
|
] {
|
|
assert!(
|
|
MotionSettings {
|
|
spring,
|
|
..Default::default()
|
|
}
|
|
.validate()
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
MotionSettings {
|
|
viewport_spring: spring,
|
|
..Default::default()
|
|
}
|
|
.validate()
|
|
.is_err()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn accepted_bounce_retargets_and_settles() {
|
|
let spring = SpringSettings {
|
|
bounce: 0.3,
|
|
overshoot: true,
|
|
..Default::default()
|
|
}
|
|
.resolve(240.0)
|
|
.unwrap();
|
|
let mut motion = crate::AnimatedValue {
|
|
current: 0.0,
|
|
target: 1.0,
|
|
velocity: 0.0,
|
|
};
|
|
motion.advance(std::time::Duration::from_millis(50), spring);
|
|
let before = (motion.current, motion.velocity);
|
|
motion.set_target(0.0);
|
|
assert_eq!((motion.current, motion.velocity), before);
|
|
motion.advance(std::time::Duration::from_secs(3), spring);
|
|
assert!(!motion.is_animating());
|
|
}
|
|
|
|
#[test]
|
|
fn policy_applies_speed_and_reduced_motion_consistently() {
|
|
let slow = MotionSettings {
|
|
speed: 0.5,
|
|
..Default::default()
|
|
};
|
|
assert_eq!(slow.duration(120.0), std::time::Duration::from_millis(240));
|
|
assert!(
|
|
!MotionSettings {
|
|
reduced_motion: true,
|
|
..slow
|
|
}
|
|
.motion_enabled()
|
|
);
|
|
assert!(
|
|
MotionSettings {
|
|
reduced_motion: true,
|
|
..slow
|
|
}
|
|
.duration(120.0)
|
|
.is_zero()
|
|
);
|
|
assert!(MotionSettings { speed: 0.0, ..slow }.validate().is_err());
|
|
assert_eq!(
|
|
slow.spring_config().unwrap(),
|
|
MotionSettings::default().spring_config().unwrap()
|
|
);
|
|
}
|
|
}
|