Implement Continuum compositor and shell motion

This commit is contained in:
Samuel Onoja committed 2026-10-03 03:19:11 +01:00
1 parent 259f7e93b6
commit f8bc17e913
42 files changed
+1873 -647

No files matched your search

+298
View File
@@ -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());
}
}
}