Files
feresaria/crates/ferese-animation/src/traces.rs
T
Samuel Onoja e22f0ebef9 Add panel-wide styles and the Morph hub (#26)
Panel appearance now applies to the whole panel, with seven styles: Floating, Capsules, Notch, Edge to edge, Fused islands, Adaptive and Morph.

- Share composition, geometry, and motion through the renderer-independent `ferese-panel` crate, including native materials, input regions, and Settings previews.
- Add the Morph hub with media, calendar, notifications, controls, workspace previews, hover feedback, and attached mode. Keep recording and low-battery status visible and respect window clearance.
- Cache workspace preview geometry, draw thumbnails directly, centralize Morph transitions, and keep rendering settings local to each shell instance.
- Put Record Screen in the Morph header and show the display control only when an external monitor is connected. Update panel documentation, the desktop screenshot, and native check style names.

Local validation included 660 compositor tests, 191 shell tests, isolated preview-cache checks, GPU Morph captures at 1×/1.25×/1.5×, formatting, and Clippy with warnings denied. The final header change passed all 23 Morph tests. Release workflow jobs are skipped for this PR.
2026-10-12 01:31:34 +01:00

606 lines
21 KiB
Rust

use super::*;
#[derive(Debug, Clone, Copy)]
struct Sample {
time: Duration,
current: f64,
velocity: f64,
target: f64,
}
struct FakeClock {
now: Duration,
last: Duration,
paused: bool,
trace: Vec<Sample>,
}
impl FakeClock {
fn new() -> Self {
Self {
now: Duration::ZERO,
last: Duration::ZERO,
paused: false,
trace: Vec::new(),
}
}
fn sample(&mut self, time: Duration, value: &mut AnimatedValue, config: SpringConfig) -> Sample {
assert!(time >= self.now);
self.now = time;
let delta = time - self.last;
self.last = time;
if !self.paused {
value.advance(delta, config);
}
let sample = Sample {
time,
current: value.current,
velocity: value.velocity,
target: value.target,
};
self.trace.push(sample);
sample
}
fn pause(&mut self) {
self.paused = true;
}
fn resume(&mut self, time: Duration) {
self.now = time;
self.last = time;
self.paused = false;
}
}
// Frozen scalar integrator, including its cap, substeps and crossing snap.
fn legacy_advance(value: &mut AnimatedValue, delta: Duration, config: SpringConfig) {
let config = config.normalized();
let settled = |v: &AnimatedValue| {
(v.current - v.target).abs() <= config.position_tolerance && v.velocity.abs() <= config.velocity_tolerance
};
if settled(value) {
value.snap();
return;
}
let seconds = delta.min(Duration::from_millis(100)).as_secs_f64();
let steps = (seconds / (1.0 / 240.0)).ceil().max(1.0) as usize;
let dt = seconds / steps as f64;
for _ in 0..steps {
let old = value.current - value.target;
value.velocity += (-config.stiffness * old - config.damping * value.velocity) / config.mass * dt;
value.current += value.velocity * dt;
if old != 0.0 && old.signum() != (value.current - value.target).signum() {
value.snap();
break;
}
}
if settled(value) {
value.snap();
}
}
fn close(a: f64, b: f64, epsilon: f64) {
assert!((a - b).abs() <= epsilon, "{a} != {b}, tolerance {epsilon}");
}
#[test]
fn capture_old_golden_trace_and_retarget_policy() {
let mut value = AnimatedValue::new(0.0);
value.set_target(500.0);
for i in 1..=18 {
legacy_advance(&mut value, Duration::from_secs_f64(1.0 / 60.0), SpringConfig::default());
if [1, 3, 6, 12, 18].contains(&i) {
println!("old normal {i}: {}, {}", value.current, value.velocity);
}
}
let mut value = AnimatedValue::new(0.0);
value.set_target(1000.0);
legacy_advance(&mut value, Duration::from_millis(40), SpringConfig::default());
println!("old retarget before: {}, {}", value.current, value.velocity);
let position = value.current;
let velocity = value.velocity;
value.retarget_preserving_motion(2000.0);
assert_eq!(value.current, position);
assert_eq!(value.velocity, velocity);
value.retarget_preserving_motion(-500.0);
println!("old reversal: {}, {}", value.current, value.velocity);
assert_eq!(value.current, position);
assert_eq!(value.velocity, velocity);
}
#[test]
fn long_stall_uses_the_whole_elapsed_duration() {
let config = SpringConfig {
mass: 1.0,
stiffness: 100.0,
damping: 20.0,
..SpringConfig::default()
};
let mut clock = FakeClock::new();
let mut value = AnimatedValue::new(0.0);
value.set_target(500.0);
clock.sample(Duration::from_millis(16), &mut value, config);
let sample = clock.sample(Duration::from_millis(416), &mut value, config);
let t: f64 = 0.416;
let decay = (-10.0 * t).exp();
close(sample.current, 500.0 - 500.0 * (1.0 + 10.0 * t) * decay, 1e-8);
close(sample.velocity, 50000.0 * t * decay, 1e-8);
assert_eq!(sample.time, Duration::from_millis(416));
assert_eq!(sample.target, 500.0);
}
#[test]
fn physical_solver_crosses_equilibrium_without_zeroing_velocity() {
let mut x = 0.0;
let mut v = 20000.0;
advance_value(&mut x, 100.0, &mut v, 0.01, SpringConfig::default());
assert!(x > 100.0, "physical solution was snapped: {x}");
assert!(v > 0.0, "velocity was erased: {v}");
}
#[test]
fn fake_clock_pause_excludes_three_hundred_milliseconds() {
let mut clock = FakeClock::new();
let mut value = AnimatedValue::new(0.0);
value.set_target(500.0);
clock.sample(Duration::from_millis(40), &mut value, SpringConfig::default());
let frozen = value;
clock.pause();
clock.sample(Duration::from_millis(340), &mut value, SpringConfig::default());
assert_eq!(value, frozen);
clock.resume(Duration::from_millis(340));
clock.sample(Duration::from_millis(356), &mut value, SpringConfig::default());
let mut expected = frozen;
expected.advance(Duration::from_millis(16), SpringConfig::default());
assert_eq!(value, expected);
}
fn physical(x: f64, v: f64, target: f64, dt: f64, config: SpringConfig) -> (f64, f64) {
let (mut x, mut v) = (x, v);
advance_value(&mut x, target, &mut v, dt, config);
(x, v)
}
// Independent characteristic-root reference, used away from ill-conditioned roots.
fn reference(x: f64, v: f64, target: f64, dt: f64, config: SpringConfig) -> (f64, f64) {
let a = config.damping / (2.0 * config.mass);
let omega2 = config.stiffness / config.mass;
let y = x - target;
let d = a * a - omega2;
if d.abs() < 1e-10 {
let b = v + a * y;
let e = (-a * dt).exp();
return (target + e * (y + b * dt), e * (v - a * b * dt));
}
if d < 0.0 {
let w = (-d).sqrt();
let b = (v + a * y) / w;
let (s, c) = (w * dt).sin_cos();
let e = (-a * dt).exp();
(target + e * (y * c + b * s), e * (v * c - (a * v + omega2 * y) / w * s))
} else {
let q = d.sqrt();
let r1 = -a + q;
let r2 = -a - q;
let c1 = (v - r2 * y) / (r1 - r2);
let c2 = y - c1;
let e1 = (r1 * dt).exp();
let e2 = (r2 * dt).exp();
(target + c1 * e1 + c2 * e2, c1 * r1 * e1 + c2 * r2 * e2)
}
}
#[test]
fn solver_reference_regimes_initial_states_and_times() {
for damping in [0.0, 5.0, 20.0, 20.0 * (1.0 - 1e-9), 20.0 * (1.0 + 1e-9), 53.0, 2000.0] {
let config = SpringConfig {
stiffness: 100.0,
damping,
..SpringConfig::default()
};
for (x, v, target) in [
(0., 0., 0.),
(0., 100., 0.),
(500., 0., 0.),
(-500., 1234., 200.),
(500., -1234., -200.),
] {
for dt in [0.0, 1e-12, 0.0083, 0.05, 0.4, 5.0] {
let actual = physical(x, v, target, dt, config);
let expected = reference(x, v, target, dt, config);
close(actual.0, expected.0, 1e-6);
close(actual.1, expected.1, 1e-5);
}
}
}
}
#[test]
fn near_critical_sweep_has_no_regime_jump() {
let base = SpringConfig {
stiffness: 100.0,
damping: 20.0,
..SpringConfig::default()
};
for dt in [1e-12, 0.01, 0.2, 2.0] {
let critical = physical(-500., 1234., 100., dt, base);
for offset in [-1e-9, -1e-12, 0., 1e-12, 1e-9] {
let config = SpringConfig {
damping: 20.0 * (1.0 + offset),
..base
};
let actual = physical(-500., 1234., 100., dt, config);
close(actual.0, critical.0, 1e-6);
close(actual.1, critical.1, 1e-5);
}
}
}
#[test]
fn normal_trace_records_the_old_numerical_error() {
// Captured BEFORE replacing the integrator. Duration rounds to nanoseconds,
// giving five old substeps at 60 Hz, not four exact 1/240-second steps.
let gold = [
(1, 45.440622413787736, 3968.266514512032),
(3, 204.05862514520575, 4637.558952650968),
(6, 375.42995175442246, 2376.3948049425335),
(12, 482.2490947739653, 366.6034676365624),
(18, 497.62691927670414, 49.586155106479524),
];
let mut old = AnimatedValue::new(0.0);
old.set_target(500.0);
let mut max_error: f64 = 0.0;
for i in 1..=18 {
legacy_advance(&mut old, Duration::from_secs_f64(1.0 / 60.0), SpringConfig::default());
if let Some((_, x, v)) = gold.iter().find(|(n, _, _)| *n == i) {
close(old.current, *x, 1e-9);
close(old.velocity, *v, 1e-9);
}
let dt = Duration::from_secs_f64(1.0 / 60.0).as_secs_f64() * i as f64;
let exact = reference(0., 0., 500., dt, SpringConfig::default());
max_error = max_error.max((old.current - exact.0).abs());
}
assert!(
max_error > 13.0 && max_error < 14.0,
"old integrator error: {max_error}"
);
}
#[test]
fn retarget_golden_policy_values() {
let mut old = AnimatedValue::new(0.0);
old.set_target(1000.0);
legacy_advance(&mut old, Duration::from_millis(40), SpringConfig::default());
close(old.current, 318.94300938241565, 1e-9);
close(old.velocity, 9837.123292018367, 1e-9);
old.retarget_preserving_motion(2000.);
close(old.velocity, 9837.123292018367, 1e-9);
old.retarget_preserving_motion(-500.);
close(old.velocity, 9837.123292018367, 1e-9);
}
#[test]
fn zero_elapsed_time_is_a_strict_noop_even_inside_settling_tolerance() {
let mut value = AnimatedValue::new(0.05);
value.set_target(0.0);
let before = value;
value.advance(Duration::ZERO, SpringConfig::default());
assert_eq!(value, before);
}
#[test]
fn gesture_flick_trace_crosses_once_and_settles_while_clamp_stops_at_crossing() {
let config = SpringConfig::default();
let mut free = AnimatedValue::new(0.0);
free.velocity = 20000.0;
free.set_target(100.0);
let mut clamped = free;
let mut crossings = 0;
let mut previous_error: f64 = -100.0;
for ms in 1..=1500 {
free.advance_with_policy(Duration::from_millis(1), config, CrossingPolicy::AllowOvershoot);
clamped.advance_with_policy(Duration::from_millis(1), config, CrossingPolicy::NoCrossing);
let error = free.current - free.target;
if error != 0.0 && previous_error.signum() != error.signum() {
crossings += 1;
}
if error != 0.0 {
previous_error = error;
}
assert!(clamped.current <= clamped.target);
if clamped.current == clamped.target {
assert_eq!(clamped.velocity, 0.0);
}
if ms <= 200 {
let exact = reference(0., 20000., 100., ms as f64 / 1000.0, config);
close(free.current, exact.0, 1e-7);
close(free.velocity, exact.1, 1e-6);
}
}
assert_eq!(crossings, 1);
assert_eq!((free.current, free.velocity), (100., 0.));
assert_eq!((clamped.current, clamped.velocity), (100., 0.));
}
#[test]
fn rapid_retarget_trace_preserves_position_and_the_golden_policy() {
let mut clock = FakeClock::new();
let mut value = AnimatedValue::new(0.0);
value.set_target(1000.0);
clock.sample(Duration::from_millis(40), &mut value, SpringConfig::default());
let before = value;
value.retarget_preserving_motion(2000.0);
assert_eq!(value.current, before.current);
assert_eq!(value.velocity, before.velocity);
clock.sample(Duration::from_millis(65), &mut value, SpringConfig::default());
let before = value;
value.retarget_preserving_motion(-500.0);
assert_eq!(value.current, before.current);
assert_eq!(value.velocity, before.velocity);
clock.sample(Duration::from_millis(81), &mut value, SpringConfig::default());
assert_ne!(value.velocity, 0.0);
// Apply the current policy to the exact old in-flight state too.
value.current = 318.94300938241565;
value.velocity = 9837.123292018367;
value.retarget_preserving_motion(-500.0);
close(value.velocity, 9837.123292018367, 1e-9);
}
#[test]
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);
geometry.advance(Duration::from_millis(40), SpringConfig::default(), true);
for mode in [
PresentationMode::Normal,
PresentationMode::Maximized,
PresentationMode::Fullscreen,
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);
}
}
#[test]
fn variable_cadence_traces_match_reference_at_every_absolute_timestamp() {
let config = SpringConfig::default();
let mut schedules = vec![vec![16600], vec![8300], vec![5000, 22000, 11000, 37000]];
for mut seed in [1u64, 0x12345678, 0xdeadbeef] {
let mut seeded = Vec::new();
for _ in 0..64 {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
seeded.push((1 + (seed >> 32) % 47) * 1000);
}
schedules.push(seeded);
}
for schedule in schedules {
let mut clock = FakeClock::new();
let mut value = AnimatedValue::new(-100.0);
value.set_target(500.0);
let mut elapsed = Duration::ZERO;
let mut settled_at = None;
for i in 0..500 {
elapsed += Duration::from_micros(schedule[i % schedule.len()]);
let sample = clock.sample(elapsed, &mut value, config);
let exact = reference(-100., 0., 500., elapsed.as_secs_f64(), config);
if sample.current == sample.target && sample.velocity == 0.0 {
assert_eq!((sample.current, sample.velocity), (500., 0.));
settled_at.get_or_insert(elapsed);
} else {
close(sample.current, exact.0, config.position_tolerance);
close(sample.velocity, exact.1, config.velocity_tolerance);
}
if elapsed >= Duration::from_secs(2) {
break;
}
}
let settled_at = settled_at.unwrap();
let mut reference_settle_us = 0;
while {
let (x, v) = reference(-100., 0., 500., reference_settle_us as f64 / 1e6, config);
(x - 500.).abs() > config.position_tolerance || v.abs() > config.velocity_tolerance
} {
reference_settle_us += 100;
}
let exact_settle = Duration::from_micros(reference_settle_us);
assert!(settled_at.abs_diff(exact_settle) <= Duration::from_micros(*schedule.iter().max().unwrap() + 100));
}
}
#[test]
fn scalar_and_rect_share_math_and_rect_clamps_only_the_crossing_component() {
let mut value = AnimatedValue::new(0.0);
value.set_target(100.0);
value.velocity = 20000.0;
let mut rect = AnimatedRect::new(Rect::new(0., 0., 400., 300.));
rect.set_target(Rect::new(100., 500., 400., 300.));
rect.velocity.x = value.velocity;
for policy in [CrossingPolicy::NoCrossing, CrossingPolicy::AllowOvershoot] {
let mut scalar = value;
let mut shape = rect;
scalar.advance_with_policy(Duration::from_millis(10), SpringConfig::default(), policy);
shape.advance_with_policy(Duration::from_millis(10), SpringConfig::default(), policy);
assert_eq!(scalar.current, shape.current.x);
assert_eq!(scalar.velocity, shape.velocity.x);
assert!(shape.current.y > 0.0 && shape.current.y < 500.0);
if policy == CrossingPolicy::NoCrossing {
assert_eq!(shape.current.x, 100.0);
assert_eq!(shape.velocity.x, 0.0);
assert_ne!(shape.velocity.y, 0.0);
}
}
}
#[test]
fn clamp_detects_crossing_inside_a_stall_even_when_endpoints_have_same_sign() {
let config = SpringConfig {
stiffness: 100.0,
damping: 0.0,
..SpringConfig::default()
};
let mut value = AnimatedValue::new(0.0);
value.set_target(100.0);
value.advance_with_policy(
Duration::from_secs_f64(std::f64::consts::TAU / 10.0),
config,
CrossingPolicy::NoCrossing,
);
assert_eq!((value.current, value.velocity), (100., 0.));
}
#[test]
fn solver_is_finite_for_realistic_ranges_and_invalid_configuration_falls_back() {
for mass in [0.01, 1.0, 100.0] {
for stiffness in [0.1, 700.0, 100000.0] {
for damping in [0.0, 0.01, 53.0, 1000000.0] {
for dt in [1e-12, 0.008, 0.4, 10.0, 3600.0] {
let config = SpringConfig {
mass,
stiffness,
damping,
..SpringConfig::default()
};
let (x, v) = physical(-10000., 50000., 10000., dt, config);
assert!(x.is_finite() && v.is_finite(), "{config:?} dt {dt}: {x} {v}");
}
}
}
}
for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
for config in [
SpringConfig {
mass: bad,
..SpringConfig::default()
},
SpringConfig {
stiffness: bad,
..SpringConfig::default()
},
] {
assert_eq!(
physical(0., 10., 100., 0.1, config),
physical(0., 10., 100., 0.1, SpringConfig::default())
);
}
}
for bad in [-1.0, f64::NAN, f64::INFINITY] {
let config = SpringConfig {
damping: bad,
..SpringConfig::default()
};
assert_eq!(
physical(0., 10., 100., 0.1, config),
physical(0., 10., 100., 0.1, SpringConfig::default())
);
}
}
#[test]
fn physical_zero_and_negative_time_do_not_move_state() {
for dt in [0.0, -0.01] {
assert_eq!(physical(5.0, 100.0, 10.0, dt, SpringConfig::default()), (5.0, 100.0));
}
}
#[test]
fn resize_target_rebases_an_existing_zoom_without_a_zero_time_jump() {
let mut geometry = WindowGeometry::new(Rect::new(100., 50., 400., 300.), None);
geometry.set_presentation_mode(
Rect::new(0., 0., 1920., 1080.),
PresentationMode::Fullscreen,
Duration::ZERO,
);
geometry.advance(Duration::from_millis(40), SpringConfig::default(), true);
let before = (geometry.visual.current, geometry.decorations);
geometry.set_logical_target(Rect::new(0., 0., 1600., 900.), Duration::from_millis(40));
geometry.advance(Duration::ZERO, SpringConfig::default(), true);
assert_eq!((geometry.visual.current, geometry.decorations), before);
geometry.advance(Duration::from_nanos(1), SpringConfig::default(), true);
close(geometry.visual.current.width, before.0.width, 1e-4);
}
#[test]
fn small_default_normal_motion_matches_legacy_within_half_a_pixel() {
let mut old = AnimatedValue::new(0.0);
old.set_target(10.0);
let mut clock = FakeClock::new();
let mut exact = old;
for i in 1..=120 {
let step = Duration::from_secs_f64(1.0 / 60.0);
legacy_advance(&mut old, step, SpringConfig::default());
let sample = clock.sample(step * i, &mut exact, SpringConfig::default());
close(sample.current, old.current, 0.5);
}
}
#[test]
fn creation_clock_excludes_earlier_idle_time_and_records_all_sample_fields() {
let mut clock = FakeClock::new();
let created = Duration::from_secs(10);
clock.resume(created);
let mut value = AnimatedValue::new(0.0);
value.set_target(500.0);
let first = clock.sample(created + Duration::from_millis(16), &mut value, SpringConfig::default());
let exact = reference(0.0, 0.0, 500.0, 0.016, SpringConfig::default());
close(first.current, exact.0, 1e-8);
close(first.velocity, exact.1, 1e-8);
assert_eq!(first.time, created + Duration::from_millis(16));
assert_eq!(first.target, 500.0);
assert_eq!(clock.trace.len(), 1);
value.retarget_preserving_motion(800.0);
let same_time = clock.sample(first.time, &mut value, SpringConfig::default());
assert_eq!(same_time.current, first.current);
assert_eq!(same_time.velocity, first.velocity);
assert_eq!(same_time.target, 800.0);
}
#[test]
fn damped_physical_solutions_converge_in_all_three_regimes() {
for damping in [5.0, 20.0, 53.0, 2000.0] {
let config = SpringConfig {
stiffness: 100.0,
damping,
..SpringConfig::default()
};
let (x, v) = physical(-500.0, 1234.0, 100.0, 2000.0, config);
close(x, 100.0, 1e-9);
close(v, 0.0, 1e-9);
}
}
#[test]
fn solver_is_continuous_on_both_sides_of_the_flick_crossing() {
let config = SpringConfig::default();
let (mut low, mut high) = (0.0, 0.02);
for _ in 0..60 {
let middle = (low + high) / 2.0;
if physical(0.0, 20000.0, 100.0, middle, config).0 < 100.0 {
low = middle;
} else {
high = middle;
}
}
let before = physical(0.0, 20000.0, 100.0, low - 1e-9, config);
let after = physical(0.0, 20000.0, 100.0, high + 1e-9, config);
assert!(before.0 < 100.0 && after.0 > 100.0);
close(before.0, after.0, 1e-4);
close(before.1, after.1, 0.01);
assert!(after.1 > 1000.0);
}