Implement Continuum compositor and shell motion
This commit is contained in:
1 parent
259f7e93b6
commit
f8bc17e913
42 files changed
+1873
-647
No files matched your search
@@ -0,0 +1,298 @@
|
||||
use crate::{CrossingPolicy, SpringConfig};
|
||||
|
||||
/// One configuration contract for compositor and out-of-process shell motion.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
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.resolve(SpringConfig::default())?;
|
||||
self.viewport_config()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn viewport_config(self) -> Result<SpringConfig, String> {
|
||||
let mut settings = self.viewport_spring;
|
||||
if settings.duration_ms.is_none() && settings.damping.is_none() && settings.damping_ratio.is_none() {
|
||||
settings.damping_ratio = Some(1.0);
|
||||
}
|
||||
settings.resolve(SpringConfig {
|
||||
stiffness: 320.0,
|
||||
damping: 2.0 * 320.0_f64.sqrt(),
|
||||
..SpringConfig::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub struct SpringSettings {
|
||||
pub mass: Option<f64>,
|
||||
pub stiffness: Option<f64>,
|
||||
pub damping: Option<f64>,
|
||||
pub damping_ratio: Option<f64>,
|
||||
pub duration_ms: Option<f64>,
|
||||
pub bounce: Option<f64>,
|
||||
pub overshoot: bool,
|
||||
}
|
||||
|
||||
impl SpringSettings {
|
||||
pub fn resolve(self, base: SpringConfig) -> Result<SpringConfig, String> {
|
||||
let positive = |value: f64| value.is_finite() && value > 0.0;
|
||||
let mut config = SpringConfig {
|
||||
mass: self.mass.unwrap_or(base.mass),
|
||||
stiffness: self.stiffness.unwrap_or(base.stiffness),
|
||||
damping: self.damping.unwrap_or(base.damping),
|
||||
crossing: if self.overshoot {
|
||||
CrossingPolicy::AllowOvershoot
|
||||
} else {
|
||||
CrossingPolicy::NoCrossing
|
||||
},
|
||||
..base
|
||||
};
|
||||
if !positive(config.mass) || !positive(config.stiffness) || !config.damping.is_finite() || config.damping < 0.0
|
||||
{
|
||||
return Err("spring mass/stiffness must be positive and damping nonnegative".into());
|
||||
}
|
||||
if let Some(duration) = self.duration_ms {
|
||||
if self.stiffness.is_some() || self.damping.is_some() || self.damping_ratio.is_some() {
|
||||
return Err("spring duration-ms cannot be combined with stiffness/damping/damping-ratio".into());
|
||||
}
|
||||
let bounce = self.bounce.unwrap_or(0.0);
|
||||
if !positive(duration)
|
||||
|| !bounce.is_finite()
|
||||
|| (bounce <= -1.0 || bounce >= 1.0)
|
||||
|| (bounce > 0.0 && !self.overshoot)
|
||||
{
|
||||
return Err("spring duration-ms must be positive; bounce must be between -1 and 1 (exclusive), and positive bounce requires overshoot".into());
|
||||
}
|
||||
let ratio = if bounce >= 0.0 {
|
||||
1.0 - bounce
|
||||
} else {
|
||||
1.0 / (1.0 + bounce)
|
||||
};
|
||||
let omega = std::f64::consts::TAU / (duration / 1000.0);
|
||||
config.stiffness = config.mass * omega * omega;
|
||||
config.damping = 2.0 * config.mass * omega * ratio;
|
||||
} else {
|
||||
if self.bounce.is_some() {
|
||||
return Err("spring bounce requires duration-ms".into());
|
||||
}
|
||||
if let Some(ratio) = self.damping_ratio {
|
||||
if self.damping.is_some() || !ratio.is_finite() || ratio < 0.0 {
|
||||
return Err("spring damping-ratio must be nonnegative and cannot be combined with damping".into());
|
||||
}
|
||||
config.damping = 2.0 * ratio * (config.mass * config.stiffness).sqrt();
|
||||
}
|
||||
}
|
||||
if !positive(config.stiffness) || !config.damping.is_finite() {
|
||||
return Err("spring coefficients overflow".into());
|
||||
}
|
||||
// Presentation lifetimes can depend on settlement. An undamped spring
|
||||
// only settles when the first-crossing clamp is enabled.
|
||||
if config.damping == 0.0 && config.crossing == CrossingPolicy::AllowOvershoot {
|
||||
return Err("spring overshoot requires positive damping so motion can settle".into());
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn undamped_overshoot_is_rejected_for_both_motion_properties() {
|
||||
for spring in [
|
||||
SpringSettings {
|
||||
damping: Some(0.0),
|
||||
overshoot: true,
|
||||
..Default::default()
|
||||
},
|
||||
SpringSettings {
|
||||
damping_ratio: Some(0.0),
|
||||
overshoot: true,
|
||||
..Default::default()
|
||||
},
|
||||
] {
|
||||
assert!(
|
||||
MotionSettings {
|
||||
spring,
|
||||
..Default::default()
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
MotionSettings {
|
||||
viewport_spring: spring,
|
||||
..Default::default()
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
SpringSettings {
|
||||
overshoot: true,
|
||||
..Default::default()
|
||||
}
|
||||
.resolve(SpringConfig {
|
||||
damping: 0.0,
|
||||
..Default::default()
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undamped_motion_needs_the_crossing_clamp_to_settle() {
|
||||
use crate::AnimatedValue;
|
||||
let clamped = SpringSettings {
|
||||
damping: Some(0.0),
|
||||
..Default::default()
|
||||
}
|
||||
.resolve(SpringConfig::default())
|
||||
.unwrap();
|
||||
let mut closing = AnimatedValue {
|
||||
current: 1.0,
|
||||
target: 0.0,
|
||||
velocity: 0.0,
|
||||
};
|
||||
let mut free = closing;
|
||||
for _ in 0..3600 {
|
||||
let dt = std::time::Duration::from_secs_f64(1.0 / 60.0);
|
||||
closing.advance(dt, clamped);
|
||||
free.advance(
|
||||
dt,
|
||||
SpringConfig {
|
||||
crossing: CrossingPolicy::AllowOvershoot,
|
||||
..clamped
|
||||
},
|
||||
);
|
||||
assert!(free.current != free.target || free.velocity != 0.0);
|
||||
}
|
||||
assert_eq!(closing.current, closing.target);
|
||||
assert_eq!(closing.velocity, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_legacy_damping_tracks_overridden_coefficients() {
|
||||
let settings = MotionSettings {
|
||||
viewport_spring: SpringSettings {
|
||||
mass: Some(2.0),
|
||||
stiffness: Some(500.0),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let spring = settings.viewport_config().unwrap();
|
||||
assert_eq!(spring.damping, 2.0 * 1000.0_f64.sqrt());
|
||||
assert_eq!(spring.crossing, CrossingPolicy::NoCrossing);
|
||||
assert!(settings.validate().is_ok());
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
#[test]
|
||||
fn perceptual_conversion_and_legacy_defaults() {
|
||||
let base = SpringConfig::default();
|
||||
assert_eq!(SpringSettings::default().resolve(base).unwrap(), base);
|
||||
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: Some(bounce),
|
||||
overshoot: bounce > 0.0,
|
||||
..Default::default()
|
||||
}
|
||||
.resolve(base)
|
||||
.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_ambiguous_or_nonsettling_configuration() {
|
||||
for settings in [
|
||||
SpringSettings {
|
||||
duration_ms: Some(300.0),
|
||||
stiffness: Some(100.0),
|
||||
..Default::default()
|
||||
},
|
||||
SpringSettings {
|
||||
duration_ms: Some(300.0),
|
||||
bounce: Some(0.3),
|
||||
..Default::default()
|
||||
},
|
||||
SpringSettings {
|
||||
duration_ms: Some(300.0),
|
||||
bounce: Some(-1.0),
|
||||
..Default::default()
|
||||
},
|
||||
SpringSettings {
|
||||
duration_ms: Some(300.0),
|
||||
bounce: Some(1.0),
|
||||
overshoot: true,
|
||||
..Default::default()
|
||||
},
|
||||
] {
|
||||
assert!(settings.resolve(SpringConfig::default()).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user