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

+1
View File
@@ -7,3 +7,4 @@ rust-version.workspace = true
[dependencies]
ferese-layout = { path = "../ferese-layout" }
serde.workspace = true
+135
View File
@@ -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);
}
}
+38 -80
View File
@@ -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);
}
+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());
}
}
}
+7 -5
View File
@@ -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);