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
@@ -7,3 +7,4 @@ rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ferese-layout = { path = "../ferese-layout" }
|
||||
serde.workspace = true
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Input estimation and destination selection; all post-release motion uses springs.
|
||||
use std::{collections::VecDeque, time::Duration};
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct VelocityTracker {
|
||||
samples: VecDeque<(Duration, f64)>,
|
||||
}
|
||||
|
||||
impl VelocityTracker {
|
||||
pub fn push(&mut self, time: Duration, position: f64) {
|
||||
if !position.is_finite() {
|
||||
return;
|
||||
}
|
||||
if let Some(&(last, _)) = self.samples.back() {
|
||||
if time < last {
|
||||
return;
|
||||
}
|
||||
if time == last {
|
||||
self.samples.pop_back();
|
||||
}
|
||||
}
|
||||
self.samples.push_back((time, position));
|
||||
while self
|
||||
.samples
|
||||
.front()
|
||||
.is_some_and(|(t, _)| time.saturating_sub(*t) > Duration::from_millis(100))
|
||||
|| self.samples.len() > 32
|
||||
{
|
||||
self.samples.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn velocity(&self, now: Duration) -> f64 {
|
||||
let Some(&(last, origin)) = self.samples.back() else {
|
||||
return 0.0;
|
||||
};
|
||||
if now.saturating_sub(last) > Duration::from_millis(40) || self.samples.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
// Fit positions relative to the newest sample, with time normalized to
|
||||
// the history horizon to avoid ill-conditioned powers of tiny seconds.
|
||||
let degree = if self.samples.len() >= 3 { 2 } else { 1 };
|
||||
let mut matrix = [[0.0; 4]; 3];
|
||||
for &(time, position) in &self.samples {
|
||||
let t = -last.saturating_sub(time).as_secs_f64() / 0.1;
|
||||
let powers = [1.0, t, t * t, t * t * t, t * t * t * t];
|
||||
for row in 0..=degree {
|
||||
for col in 0..=degree {
|
||||
matrix[row][col] += powers[row + col];
|
||||
}
|
||||
matrix[row][degree + 1] += powers[row] * (position - origin);
|
||||
}
|
||||
}
|
||||
for col in 0..=degree {
|
||||
let pivot = (col..=degree)
|
||||
.max_by(|&a, &b| matrix[a][col].abs().total_cmp(&matrix[b][col].abs()))
|
||||
.unwrap();
|
||||
matrix.swap(col, pivot);
|
||||
let divisor = matrix[col][col];
|
||||
if divisor.abs() < 1e-12 {
|
||||
let (first, position) = self.samples[0];
|
||||
let dt = last.saturating_sub(first).as_secs_f64();
|
||||
return if dt > 0.0 { (origin - position) / dt } else { 0.0 };
|
||||
}
|
||||
for entry in col..=degree + 1 {
|
||||
matrix[col][entry] /= divisor;
|
||||
}
|
||||
for row in 0..=degree {
|
||||
if row == col {
|
||||
continue;
|
||||
}
|
||||
let factor = matrix[row][col];
|
||||
for entry in col..=degree + 1 {
|
||||
matrix[row][entry] -= factor * matrix[col][entry];
|
||||
}
|
||||
}
|
||||
}
|
||||
matrix[1][degree + 1] / 0.1
|
||||
}
|
||||
}
|
||||
|
||||
pub fn project(position: f64, velocity: f64, rate: f64) -> f64 {
|
||||
if !velocity.is_finite() || !(0.0..1.0).contains(&rate) || rate == 0.0 {
|
||||
return position;
|
||||
}
|
||||
position - velocity / (1000.0 * rate.ln())
|
||||
}
|
||||
|
||||
/// Nonlinear resistance, bounded by `extent`; the derivative transfers the
|
||||
/// velocity of the displayed (resisted) position into the release spring.
|
||||
pub fn rubber_band(distance: f64, extent: f64, resistance: f64) -> (f64, f64) {
|
||||
if !distance.is_finite() || !extent.is_finite() || extent <= 0.0 || !resistance.is_finite() || resistance <= 0.0 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let denominator = 1.0 + resistance * distance.abs() / extent;
|
||||
(
|
||||
distance.signum() * extent * (1.0 - denominator.recip()),
|
||||
resistance / denominator.powi(2),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn rubber_band_is_bounded_symmetric_and_has_matching_velocity() {
|
||||
for x in [0.0, 1.0, 100.0, 10000.0] {
|
||||
let (position, derivative) = rubber_band(x, 64.0, 0.55);
|
||||
assert!((0.0..64.0).contains(&position));
|
||||
assert_eq!(rubber_band(-x, 64.0, 0.55).0, -position);
|
||||
let measured = (rubber_band(x + 0.0001, 64.0, 0.55).0 - rubber_band(x - 0.0001, 64.0, 0.55).0) / 0.0002;
|
||||
assert!((measured - derivative).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn quadratic_velocity_and_pause() {
|
||||
let mut tracker = VelocityTracker::default();
|
||||
for ms in [0, 7, 19, 40, 60, 90] {
|
||||
let t = ms as f64 / 1000.0;
|
||||
tracker.push(Duration::from_millis(ms), 100.0 * t + 200.0 * t * t);
|
||||
}
|
||||
assert!((tracker.velocity(Duration::from_millis(90)) - 136.0).abs() < 1e-8);
|
||||
assert_eq!(tracker.velocity(Duration::from_millis(140)), 0.0);
|
||||
assert!((project(0.0, 1.0, 0.997) - 0.332833082957).abs() < 1e-10);
|
||||
}
|
||||
#[test]
|
||||
fn duplicate_and_out_of_order_samples() {
|
||||
let mut tracker = VelocityTracker::default();
|
||||
tracker.push(Duration::ZERO, 0.0);
|
||||
tracker.push(Duration::from_millis(10), 1.0);
|
||||
tracker.push(Duration::from_millis(10), 2.0);
|
||||
tracker.push(Duration::from_millis(5), 99.0);
|
||||
assert!((tracker.velocity(Duration::from_millis(10)) - 200.0).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@ use std::time::Duration;
|
||||
|
||||
use ferese_layout::Rect;
|
||||
|
||||
mod settings;
|
||||
mod spring;
|
||||
pub use settings::{MotionSettings, SpringSettings};
|
||||
pub mod gesture;
|
||||
|
||||
/// Crossing behavior belongs to the property, not the physical solver.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -20,6 +23,7 @@ pub struct SpringConfig {
|
||||
pub damping: f64,
|
||||
pub position_tolerance: f64,
|
||||
pub velocity_tolerance: f64,
|
||||
pub crossing: CrossingPolicy,
|
||||
}
|
||||
|
||||
impl Default for SpringConfig {
|
||||
@@ -30,6 +34,7 @@ impl Default for SpringConfig {
|
||||
damping: 53.0,
|
||||
position_tolerance: 0.1,
|
||||
velocity_tolerance: 0.1,
|
||||
crossing: CrossingPolicy::NoCrossing,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +49,7 @@ impl SpringConfig {
|
||||
damping: non_negative_or(self.damping, defaults.damping),
|
||||
position_tolerance: positive_or(self.position_tolerance, defaults.position_tolerance),
|
||||
velocity_tolerance: positive_or(self.velocity_tolerance, defaults.velocity_tolerance),
|
||||
crossing: self.crossing,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +77,14 @@ pub struct AnimatedValue {
|
||||
}
|
||||
|
||||
impl AnimatedValue {
|
||||
pub fn needs_update(&self, target: f64) -> bool {
|
||||
self.target != target || self.is_animating()
|
||||
}
|
||||
|
||||
pub fn is_animating(&self) -> bool {
|
||||
self.current != self.target || self.velocity != 0.0
|
||||
}
|
||||
|
||||
pub fn new(value: f64) -> Self {
|
||||
let value = finite_or_zero(value);
|
||||
|
||||
@@ -86,13 +100,7 @@ impl AnimatedValue {
|
||||
}
|
||||
|
||||
pub fn retarget_preserving_motion(&mut self, target: f64) {
|
||||
let target = finite_or_zero(target);
|
||||
let direction = (target - self.current).signum();
|
||||
|
||||
if direction != 0.0 && self.velocity.signum() != direction {
|
||||
self.velocity *= 0.35;
|
||||
}
|
||||
self.target = target;
|
||||
self.set_target(target);
|
||||
}
|
||||
|
||||
pub fn snap(&mut self) {
|
||||
@@ -100,9 +108,9 @@ impl AnimatedValue {
|
||||
self.velocity = 0.0;
|
||||
}
|
||||
|
||||
/// Compatibility entry point; existing presentation properties do not cross.
|
||||
/// Advance with the spring's configured crossing policy.
|
||||
pub fn advance(&mut self, delta: Duration, config: SpringConfig) -> bool {
|
||||
self.advance_with_policy(delta, config, CrossingPolicy::NoCrossing)
|
||||
self.advance_with_policy(delta, config, config.crossing)
|
||||
}
|
||||
|
||||
pub fn advance_with_policy(&mut self, delta: Duration, config: SpringConfig, policy: CrossingPolicy) -> bool {
|
||||
@@ -156,7 +164,7 @@ impl AnimatedRect {
|
||||
}
|
||||
|
||||
pub fn advance(&mut self, delta: Duration, config: SpringConfig) -> bool {
|
||||
self.advance_with_policy(delta, config, CrossingPolicy::NoCrossing)
|
||||
self.advance_with_policy(delta, config, config.crossing)
|
||||
}
|
||||
|
||||
pub fn advance_with_policy(&mut self, delta: Duration, config: SpringConfig, policy: CrossingPolicy) -> bool {
|
||||
@@ -304,9 +312,7 @@ pub struct WindowGeometry {
|
||||
/// Maximized/fullscreen geometry and decorations share a clock, including on reversal.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct ZoomTransition {
|
||||
from: Rect,
|
||||
decorations_from: f64,
|
||||
progress: AnimatedValue,
|
||||
decorations: AnimatedValue,
|
||||
}
|
||||
|
||||
impl WindowGeometry {
|
||||
@@ -359,28 +365,12 @@ impl WindowGeometry {
|
||||
let rect = normalized_rect(rect);
|
||||
let before = (self.visual.current, self.decorations);
|
||||
if self.mode != mode || (self.zoom.is_some() && self.logical != rect) {
|
||||
let mut progress = AnimatedValue::new(0.0);
|
||||
progress.set_target(1.0);
|
||||
let from = self.visual.current;
|
||||
let distance = [
|
||||
rect.x - from.x,
|
||||
rect.y - from.y,
|
||||
rect.width - from.width,
|
||||
rect.height - from.height,
|
||||
];
|
||||
let velocity = self.visual.velocity;
|
||||
let speed = [velocity.x, velocity.y, velocity.width, velocity.height];
|
||||
let squared_length = distance.iter().map(|value| value * value).sum::<f64>();
|
||||
if squared_length > 0.001 {
|
||||
let projected = distance.iter().zip(speed).map(|(d, v)| d * v).sum::<f64>() / squared_length;
|
||||
// Keep forward momentum; soften a reversal without jumping position.
|
||||
progress.velocity = if projected < 0.0 { projected * 0.35 } else { projected };
|
||||
}
|
||||
self.zoom = Some(ZoomTransition {
|
||||
from: self.visual.current,
|
||||
decorations_from: self.decorations,
|
||||
progress,
|
||||
});
|
||||
let mut decorations = self
|
||||
.zoom
|
||||
.map(|zoom| zoom.decorations)
|
||||
.unwrap_or_else(|| AnimatedValue::new(self.decorations));
|
||||
decorations.set_target(if mode == PresentationMode::Fullscreen { 0.0 } else { 1.0 });
|
||||
self.zoom = Some(ZoomTransition { decorations });
|
||||
}
|
||||
self.mode = mode;
|
||||
self.logical = rect;
|
||||
@@ -440,55 +430,23 @@ impl WindowGeometry {
|
||||
return self.zoom.is_some() || !self.visual.is_settled(config);
|
||||
}
|
||||
|
||||
// Retarget the existing rectangle directly: projecting four independent
|
||||
// velocities onto a new scalar progress loses motion on interruption.
|
||||
let geometry_active = self.visual.advance(delta, config);
|
||||
if let Some(zoom) = &mut self.zoom {
|
||||
let target = self.visual.target;
|
||||
// Spring tolerances describe pixels and pixels/second, whereas this
|
||||
// spring advances unit progress. Without conversion it snaps several
|
||||
// pixels early on large zooms, visibly shifting the trailing edge.
|
||||
let travel = rect_distance(zoom.from, target)
|
||||
.max((zoom.from.x + zoom.from.width - target.x - target.width).abs())
|
||||
.max((zoom.from.y + zoom.from.height - target.y - target.height).abs())
|
||||
.max(1.0);
|
||||
let config = config.normalized();
|
||||
let progress_config = SpringConfig {
|
||||
position_tolerance: config.position_tolerance / travel,
|
||||
velocity_tolerance: config.velocity_tolerance / travel,
|
||||
let decoration_config = SpringConfig {
|
||||
position_tolerance: 0.00001,
|
||||
velocity_tolerance: 0.00001,
|
||||
..config
|
||||
};
|
||||
let active = zoom
|
||||
.progress
|
||||
.advance_with_policy(delta, progress_config, CrossingPolicy::NoCrossing);
|
||||
let p = zoom.progress.current;
|
||||
let lerp = |a: f64, b: f64| a + (b - a) * p;
|
||||
self.visual.current = Rect::new(
|
||||
lerp(zoom.from.x, target.x),
|
||||
lerp(zoom.from.y, target.y),
|
||||
lerp(zoom.from.width, target.width),
|
||||
lerp(zoom.from.height, target.height),
|
||||
);
|
||||
self.visual.velocity = RectVelocity {
|
||||
x: (target.x - zoom.from.x) * zoom.progress.velocity,
|
||||
y: (target.y - zoom.from.y) * zoom.progress.velocity,
|
||||
width: (target.width - zoom.from.width) * zoom.progress.velocity,
|
||||
height: (target.height - zoom.from.height) * zoom.progress.velocity,
|
||||
};
|
||||
self.decorations = lerp(
|
||||
zoom.decorations_from,
|
||||
if self.mode == PresentationMode::Fullscreen {
|
||||
0.0
|
||||
} else {
|
||||
1.0
|
||||
},
|
||||
);
|
||||
if !active {
|
||||
let decorations_active = zoom.decorations.advance(delta, decoration_config);
|
||||
self.decorations = zoom.decorations.current.clamp(0.0, 1.0);
|
||||
if !geometry_active && !decorations_active {
|
||||
self.zoom = None;
|
||||
self.visual.snap();
|
||||
}
|
||||
return active;
|
||||
return geometry_active || decorations_active;
|
||||
}
|
||||
|
||||
self.visual
|
||||
.advance_with_policy(delta, config, CrossingPolicy::NoCrossing)
|
||||
geometry_active
|
||||
}
|
||||
|
||||
pub fn inverse_visual_point(&self, x: f64, y: f64) -> Option<(f64, f64)> {
|
||||
@@ -931,7 +889,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_spring_softens_opposing_velocity_on_reversal() {
|
||||
fn scalar_spring_preserves_opposing_velocity_on_reversal() {
|
||||
let mut value = AnimatedValue::new(0.0);
|
||||
value.set_target(1_000.0);
|
||||
value.advance(Duration::from_millis(40), SpringConfig::default());
|
||||
@@ -941,7 +899,7 @@ mod tests {
|
||||
value.retarget_preserving_motion(-500.0);
|
||||
|
||||
assert!(value.velocity > 0.0);
|
||||
assert_eq!(value.velocity, velocity * 0.35);
|
||||
assert_eq!(value.velocity, velocity);
|
||||
assert_eq!(value.current, current);
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ fn capture_old_golden_trace_and_retarget_policy() {
|
||||
value.retarget_preserving_motion(-500.0);
|
||||
println!("old reversal: {}, {}", value.current, value.velocity);
|
||||
assert_eq!(value.current, position);
|
||||
assert_eq!(value.velocity, velocity * 0.35);
|
||||
assert_eq!(value.velocity, velocity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -282,7 +282,7 @@ fn retarget_golden_policy_values() {
|
||||
old.retarget_preserving_motion(2000.);
|
||||
close(old.velocity, 9837.123292018367, 1e-9);
|
||||
old.retarget_preserving_motion(-500.);
|
||||
close(old.velocity, 3442.993152206428, 1e-9);
|
||||
close(old.velocity, 9837.123292018367, 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -342,7 +342,7 @@ fn rapid_retarget_trace_preserves_position_and_the_golden_policy() {
|
||||
let before = value;
|
||||
value.retarget_preserving_motion(-500.0);
|
||||
assert_eq!(value.current, before.current);
|
||||
assert_eq!(value.velocity, before.velocity * 0.35);
|
||||
assert_eq!(value.velocity, before.velocity);
|
||||
clock.sample(Duration::from_millis(81), &mut value, SpringConfig::default());
|
||||
assert_ne!(value.velocity, 0.0);
|
||||
|
||||
@@ -350,11 +350,11 @@ fn rapid_retarget_trace_preserves_position_and_the_golden_policy() {
|
||||
value.current = 318.94300938241565;
|
||||
value.velocity = 9837.123292018367;
|
||||
value.retarget_preserving_motion(-500.0);
|
||||
close(value.velocity, 3442.993152206428, 1e-9);
|
||||
close(value.velocity, 9837.123292018367, 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupted_resize_and_zoom_targets_are_c0_continuous() {
|
||||
fn interrupted_resize_and_zoom_targets_preserve_position_and_velocity() {
|
||||
let initial = Rect::new(0., 0., 400., 300.);
|
||||
let mut geometry = WindowGeometry::new(initial, None);
|
||||
geometry.set_logical_target(Rect::new(100., 50., 900., 600.), Duration::ZERO);
|
||||
@@ -366,8 +366,10 @@ fn interrupted_resize_and_zoom_targets_are_c0_continuous() {
|
||||
PresentationMode::Normal,
|
||||
] {
|
||||
let before = (geometry.visual.current, geometry.decorations);
|
||||
let velocity = geometry.visual.velocity;
|
||||
geometry.set_presentation_mode(Rect::new(200., 100., 700., 500.), mode, Duration::from_millis(40));
|
||||
assert_eq!((geometry.visual.current, geometry.decorations), before);
|
||||
assert_eq!(geometry.visual.velocity, velocity);
|
||||
geometry.advance(Duration::ZERO, SpringConfig::default(), true);
|
||||
assert_eq!((geometry.visual.current, geometry.decorations), before);
|
||||
geometry.advance(Duration::from_millis(16), SpringConfig::default(), true);
|
||||
|
||||
Reference in new issue
Block a user