Files
feresaria/crates/ferese-panel/src/motion.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

258 lines
9.1 KiB
Rust

use std::time::Duration;
use ferese_animation::{AnimatedValue, CrossingPolicy, SpringConfig};
use ferese_layout::Rect;
/// Unsnapped logical geometry. Pixel snapping and radius normalization belong
/// to scene derivation, so neither changes the spring's trajectory.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Geometry {
pub bounds: Rect,
pub radii: [f64; 4],
pub margin: [f64; 4],
pub thickness: [f64; 4],
pub gap: f64,
pub fusion: f64,
}
const COUNT: usize = 18;
impl Geometry {
pub fn rounded(bounds: Rect, radius: f64) -> Self {
Self {
bounds,
radii: [radius; 4],
margin: [0.; 4],
thickness: [0.; 4],
gap: 0.,
fusion: 0.,
}
}
fn values(self) -> [f64; COUNT] {
let mut values = [0.; COUNT];
values[..4].copy_from_slice(&[self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height]);
values[4..8].copy_from_slice(&self.radii);
values[8..12].copy_from_slice(&self.margin);
values[12..16].copy_from_slice(&self.thickness);
values[16] = self.gap;
values[17] = self.fusion;
values
}
fn from_values(values: [f64; COUNT]) -> Self {
Self {
bounds: Rect::new(values[0], values[1], values[2], values[3]),
radii: values[4..8].try_into().unwrap(),
margin: values[8..12].try_into().unwrap(),
thickness: values[12..16].try_into().unwrap(),
gap: values[16],
fusion: values[17],
}
}
fn validate(self) -> Result<Self, &'static str> {
let values = self.values();
if values.iter().any(|value| !value.is_finite()) || values[2..].iter().any(|value| *value < 0.) {
Err("panel animation geometry must be finite with nonnegative extents")
} else {
Ok(self)
}
}
}
#[derive(Clone, Debug)]
pub struct Motion {
values: [AnimatedValue; COUNT],
spring: SpringConfig,
}
impl Motion {
pub fn new(geometry: Geometry) -> Result<Self, &'static str> {
Self::with_spring(
geometry,
SpringConfig {
stiffness: 6400.,
damping: 160.,
position_tolerance: 0.05,
velocity_tolerance: 0.1,
crossing: CrossingPolicy::NoCrossing,
..Default::default()
},
)
}
pub(crate) fn with_spring(geometry: Geometry, spring: SpringConfig) -> Result<Self, &'static str> {
Ok(Self {
values: geometry.validate()?.values().map(AnimatedValue::new),
spring,
})
}
/// Largest future dimensions, including the momentum carried through a retarget.
pub(crate) fn maximum_size(&self) -> [f64; 2] {
let omega2 = self.spring.stiffness / self.spring.mass;
let decay = self.spring.damping / (2. * self.spring.mass);
[self.values[2], self.values[3]].map(|value| {
let displacement = value.current - value.target;
let mut maximum = value.current.max(value.target);
if omega2 > decay * decay {
let frequency = (omega2 - decay * decay).sqrt();
let sine = (value.velocity + decay * displacement) / frequency;
let derivative_sine = -(decay * value.velocity + omega2 * displacement) / frequency;
let first = (-value.velocity)
.atan2(derivative_sine)
.rem_euclid(std::f64::consts::PI);
// Extrema alternate; later peaks decay, so the first two suffice.
for phase in [first, first + std::f64::consts::PI] {
let t = phase / frequency;
let position =
value.target + (-decay * t).exp() * (displacement * phase.cos() + sine * phase.sin());
maximum = maximum.max(position);
}
} else {
// Total spring energy bounds displacement for any nonnegative damping.
maximum = maximum.max(
value.target + (displacement * displacement + value.velocity * value.velocity / omega2).sqrt(),
);
}
maximum
})
}
pub fn current(&self) -> Geometry {
Geometry::from_values(self.values.map(|value| value.current))
}
pub fn target(&self) -> Geometry {
Geometry::from_values(self.values.map(|value| value.target))
}
pub fn retarget(&mut self, geometry: Geometry) -> Result<(), &'static str> {
let target = geometry.validate()?.values();
for (value, target) in self.values.iter_mut().zip(target) {
value.retarget_preserving_motion(target);
}
Ok(())
}
pub fn active(&self) -> bool {
self.values.iter().any(AnimatedValue::is_animating)
}
/// Returns whether another frame is needed. Reduced motion also clears
/// residual velocity, including when enabled halfway through an animation.
pub fn advance(&mut self, elapsed: Duration, reduced_motion: bool) -> bool {
let mut active = false;
for value in &mut self.values {
if reduced_motion {
value.snap();
} else {
active |= value.advance(elapsed, self.spring);
}
}
active
}
}
#[cfg(test)]
mod tests {
use super::*;
fn collapsed() -> Geometry {
Geometry::rounded(Rect::new(0., 0., 150., 34.), 17.)
}
fn expanded() -> Geometry {
Geometry::rounded(Rect::new(-95., 0., 340., 230.), 26.)
}
#[test]
fn interruption_keeps_the_current_geometry_and_velocity() {
let mut motion = Motion::new(collapsed()).unwrap();
motion.retarget(expanded()).unwrap();
motion.advance(Duration::from_millis(16), false);
let before = motion.current();
let velocity = motion.values.map(|value| value.velocity);
motion.retarget(collapsed()).unwrap();
assert_eq!(motion.current(), before);
assert_eq!(motion.values.map(|value| value.velocity), velocity);
for _ in 0..30 {
motion.advance(Duration::from_millis(16), false);
}
assert_eq!(motion.current(), collapsed());
assert!(!motion.active());
}
#[test]
fn dimensions_share_one_trajectory_and_stop_requesting_frames() {
let mut motion = Motion::new(collapsed()).unwrap();
motion.retarget(expanded()).unwrap();
for _ in 0..12 {
motion.advance(Duration::from_millis(16), false);
let geometry = motion.current();
let width_progress = (geometry.bounds.width - 150.) / 190.;
let height_progress = (geometry.bounds.height - 34.) / 196.;
let radius_progress = (geometry.radii[0] - 17.) / 9.;
// A dimension may snap at its final subpixel tolerance first.
assert!((width_progress - height_progress).abs() < 0.0005);
assert!((width_progress - radius_progress).abs() < 0.0005);
}
for _ in 0..12 {
motion.advance(Duration::from_millis(16), false);
}
assert_eq!(motion.current(), expanded());
assert!(!motion.advance(Duration::from_secs(1), false));
}
#[test]
fn reduced_motion_snaps_an_inflight_animation() {
let mut motion = Motion::new(collapsed()).unwrap();
motion.retarget(expanded()).unwrap();
motion.advance(Duration::from_millis(16), false);
assert!(!motion.advance(Duration::ZERO, true));
assert_eq!(motion.current(), expanded());
}
#[test]
fn underdamped_reversals_preserve_velocity_and_fit_the_predicted_extrema() {
let mut motion = Motion::with_spring(
collapsed(),
SpringConfig {
stiffness: 300.,
damping: 2. * 0.7 * 300_f64.sqrt(),
crossing: CrossingPolicy::AllowOvershoot,
..Default::default()
},
)
.unwrap();
for turn in 0..60 {
let before = motion.current();
let velocity = motion.values.map(|value| value.velocity);
motion
.retarget(if turn % 2 == 0 { expanded() } else { collapsed() })
.unwrap();
assert_eq!(motion.current(), before);
assert_eq!(motion.values.map(|value| value.velocity), velocity);
let bound = motion.maximum_size();
let mut prediction = motion.clone();
for _ in 0..200 {
prediction.advance(Duration::from_millis(8), false);
let geometry = prediction.current();
assert!(geometry.bounds.width <= bound[0] + 1e-9);
assert!(geometry.bounds.height <= bound[1] + 1e-9);
}
motion.advance(Duration::from_millis(16 + turn * 7), false);
}
}
#[test]
fn invalid_retarget_leaves_motion_untouched() {
let mut motion = Motion::new(collapsed()).unwrap();
let mut invalid = expanded();
invalid.bounds.height = f64::NAN;
assert!(motion.retarget(invalid).is_err());
assert_eq!(motion.target(), collapsed());
assert!(!motion.active());
}
}