Files
feresaria/crates/ferese-panel/src/morph.rs
T

523 lines
18 KiB
Rust

use std::time::Duration;
use ferese_layout::Rect;
use serde::{Deserialize, Serialize};
use crate::motion::{Geometry, Motion};
const FADE: Duration = Duration::from_millis(120);
const CONTENT_DELAY: Duration = Duration::from_millis(130);
#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct Profile {
pub peek_on_hover: bool,
pub attached: bool,
pub idle_width: f64,
pub peek_width: f64,
pub peek_height: f64,
pub toast_width: f64,
pub toast_height: f64,
pub expanded_width: f64,
pub workspaces_height: f64,
pub media_height: f64,
pub calendar_height: f64,
pub notifications_height: f64,
pub controls_height: f64,
pub expanded_radius: f64,
}
impl Default for Profile {
fn default() -> Self {
Self {
peek_on_hover: true,
attached: false,
idle_width: 150.,
peek_width: 260.,
peek_height: 44.,
toast_width: 320.,
toast_height: 48.,
expanded_width: 340.,
workspaces_height: 176.,
media_height: 170.,
calendar_height: 650.,
notifications_height: 176.,
controls_height: 248.,
expanded_radius: 26.,
}
}
}
impl Profile {
pub fn validate(self) -> Result<(), &'static str> {
let sizes = [
self.idle_width,
self.peek_width,
self.peek_height,
self.toast_width,
self.toast_height,
self.expanded_width,
self.workspaces_height,
self.media_height,
self.calendar_height,
self.notifications_height,
self.controls_height,
];
if sizes
.iter()
.any(|value| !value.is_finite() || !(24. ..=1024.).contains(value))
|| !self.expanded_radius.is_finite()
|| !(0. ..=128.).contains(&self.expanded_radius)
{
Err("hub dimensions must be 24..1024 and its expanded radius 0..128")
} else {
Ok(())
}
}
#[must_use]
pub fn size(self, mode: Mode, collapsed_height: f64) -> [f64; 2] {
match mode {
Mode::Idle => [self.idle_width, collapsed_height],
Mode::Peek => [self.peek_width, self.peek_height],
Mode::Toast => [self.toast_width, self.toast_height],
Mode::Expanded(content) => [
self.expanded_width,
match content {
Content::Workspaces => self.workspaces_height,
Content::Media => self.media_height,
Content::Calendar => self.calendar_height,
Content::Notifications | Content::Activities => self.notifications_height,
Content::Controls => self.controls_height,
},
],
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Content {
Activities,
Workspaces,
Media,
Calendar,
Notifications,
Controls,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Mode {
#[default]
Idle,
Peek,
Toast,
Expanded(Content),
}
impl Mode {
#[must_use]
pub fn expanded(self) -> bool {
matches!(self, Self::Expanded(_))
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Sample {
pub geometry: Geometry,
pub previous: Mode,
pub previous_opacity: f32,
pub current: Mode,
pub opacity: f32,
}
/// The exclusive zone is deliberately absent. Expansion changes the painted
/// island and its hit target; the caller retains the collapsed reservation.
#[derive(Clone, Debug)]
pub struct Hub {
profile: Profile,
pins: Option<[f64; 4]>,
height: f64,
motion: Motion,
mode: Mode,
previous: Mode,
previous_opacity: f32,
initial_opacity: f32,
elapsed: Duration,
available: [f64; 2],
}
impl Hub {
pub fn try_new(profile: Profile, collapsed_height: f64) -> Result<Self, &'static str> {
profile.validate()?;
if !collapsed_height.is_finite() || !(24. ..=128.).contains(&collapsed_height) {
return Err("hub height must be 24..128");
}
let size = profile.size(Mode::Idle, collapsed_height);
Ok(Self {
profile,
pins: None,
height: collapsed_height,
motion: Motion::with_spring(
Geometry::rounded(Rect::new(0., 0., size[0], size[1]), size[1] / 2.),
ferese_animation::SpringConfig {
stiffness: 300.,
damping: 2. * 0.7 * 300_f64.sqrt(),
position_tolerance: 0.05,
velocity_tolerance: 0.05,
crossing: ferese_animation::CrossingPolicy::AllowOvershoot,
..Default::default()
},
)?,
mode: Mode::Idle,
previous: Mode::Idle,
previous_opacity: 0.,
initial_opacity: 1.,
elapsed: FADE,
available: [f64::MAX; 2],
})
}
pub fn pin_radii(&mut self, pins: Option<[f64; 4]>) -> Result<(), &'static str> {
if pins.is_some_and(|pins| {
pins.iter()
.any(|value| !value.is_finite() || !(0. ..=f64::from(f32::MAX)).contains(value))
}) {
return Err(
"hub radius pins must be finite, nonnegative pixel values representable as f32",
);
}
self.pins = pins;
let mut target = self.motion.target();
target.radii = pins.unwrap_or(
[if self.mode.expanded() {
self.profile.expanded_radius
} else {
target.bounds.height / 2.
}; 4],
);
self.motion.retarget(target)
}
#[must_use]
pub fn mode(&self) -> Mode {
self.mode
}
pub fn retarget_peek(&mut self, width: f64, available: [f64; 2]) -> Result<(), &'static str> {
if !width.is_finite() || !(24. ..=1024.).contains(&width) {
return Err("peek width must be 24..1024");
}
let configured = self.profile.peek_width;
self.profile.peek_width = width;
let result = self.retarget(Mode::Peek, available);
self.profile.peek_width = configured;
result
}
pub fn retarget_idle(&mut self, width: f64, available: [f64; 2]) -> Result<(), &'static str> {
if !width.is_finite() || !(24. ..=1024.).contains(&width) {
return Err("idle width must be 24..1024");
}
let configured = self.profile.idle_width;
self.profile.idle_width = width;
let result = self.retarget(Mode::Idle, available);
self.profile.idle_width = configured;
result
}
pub fn retarget(&mut self, mode: Mode, available: [f64; 2]) -> Result<(), &'static str> {
if available
.iter()
.any(|value| !value.is_finite() || *value <= 0.)
{
return Err("hub requires a finite positive available area");
}
let [width, height] = self.profile.size(mode, self.height);
let width = width.min(available[0]);
let height = height.min(available[1]);
let radius = if mode.expanded() {
self.profile.expanded_radius
} else {
height / 2.
};
let mut geometry = Geometry::rounded(Rect::new(0., 0., width, height), radius);
geometry.radii = self.pins.unwrap_or(geometry.radii);
if mode == self.mode {
self.available = available;
return self.motion.retarget(geometry);
}
let sample = self.sample();
self.available = available;
if sample.previous_opacity > sample.opacity {
self.previous = sample.previous;
self.previous_opacity = sample.previous_opacity;
} else {
self.previous = sample.current;
self.previous_opacity = sample.opacity;
}
self.initial_opacity = if mode == sample.current {
sample.opacity
} else if mode == sample.previous {
sample.previous_opacity
} else {
0.
};
if self.previous == mode {
self.previous_opacity = 0.;
}
self.mode = mode;
self.elapsed = Duration::ZERO;
self.motion.retarget(geometry)
}
/// Fit the expanded shell to measured content without restarting its content fade.
pub fn fit_content_height(&mut self, height: f64) -> Result<(), &'static str> {
if !height.is_finite() || height <= 0. {
return Err("content height must be finite and positive");
}
if self.mode.expanded() {
let mut target = self.motion.target();
target.bounds.height = height
.max(24.)
.min(self.profile.size(self.mode, self.height)[1])
.min(self.available[1]);
self.motion.retarget(target)?;
}
Ok(())
}
/// Replace content inside an already settled shape without restarting its springs.
pub fn replace_content(&mut self) {
self.previous = self.mode;
self.previous_opacity = self.sample().opacity;
self.initial_opacity = 0.;
self.elapsed = Duration::ZERO;
}
pub fn advance(&mut self, elapsed: Duration, reduced_motion: bool) -> bool {
self.elapsed = self.elapsed.saturating_add(elapsed);
self.motion.advance(elapsed, reduced_motion);
if reduced_motion {
self.previous_opacity = 0.;
self.initial_opacity = 1.;
}
self.active()
}
#[must_use]
pub fn active(&self) -> bool {
self.motion.active() || self.sample().previous_opacity > 0. || self.sample().opacity < 1.
}
#[must_use]
pub fn sample(&self) -> Sample {
let fade = |elapsed: Duration| (elapsed.as_secs_f32() / FADE.as_secs_f32()).clamp(0., 1.);
let opacity = self.initial_opacity
+ (1. - self.initial_opacity) * fade(self.elapsed.saturating_sub(CONTENT_DELAY));
let mut geometry = self.motion.current();
geometry.bounds.width = geometry.bounds.width.clamp(0., self.available[0]);
geometry.bounds.height = geometry.bounds.height.clamp(0., self.available[1]);
geometry.radii = geometry
.radii
.map(|radius| radius.clamp(0., f64::from(f32::MAX)));
Sample {
geometry,
previous: self.previous,
previous_opacity: self.previous_opacity * (1. - fade(self.elapsed)),
current: self.mode,
opacity,
}
}
#[must_use]
pub fn allocation_size(&self) -> [f64; 2] {
let maximum = self.motion.maximum_size();
std::array::from_fn(|i| maximum[i].min(self.available[i]))
}
#[must_use]
pub fn target_size(&self) -> [f64; 2] {
let target = self.motion.target().bounds;
[target.width, target.height]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn workspace_width_changes_preserve_content_and_motion() {
let mut hub = Hub::try_new(Profile::default(), 34.).unwrap();
hub.retarget_idle(270., [800., 600.]).unwrap();
hub.advance(Duration::from_millis(60), false);
let before = hub.sample();
assert_eq!(before.opacity, 1.);
hub.retarget_idle(300., [800., 600.]).unwrap();
assert_eq!(hub.sample(), before);
assert_eq!(hub.target_size(), [300., 34.]);
hub.advance(Duration::from_secs(2), false);
assert_eq!(hub.sample().geometry.bounds.width, 300.);
assert_eq!(hub.sample().opacity, 1.);
hub.retarget_peek(360., [320., 600.]).unwrap();
assert_eq!(hub.target_size(), [320., 44.]);
}
#[test]
fn measured_height_preserves_fade_and_motion_and_caps_long_content() {
let mut hub = Hub::try_new(Profile::default(), 34.).unwrap();
hub.retarget(Mode::Expanded(Content::Controls), [800., 600.])
.unwrap();
hub.advance(Duration::from_millis(180), false);
let before = hub.sample();
hub.fit_content_height(148.).unwrap();
assert_eq!(hub.target_size(), [340., 148.]);
assert_eq!(hub.sample().geometry, before.geometry);
assert_eq!(hub.sample().opacity, before.opacity);
hub.advance(Duration::from_secs(2), false);
assert_eq!(hub.sample().geometry.bounds.height, 148.);
assert_eq!(hub.sample().opacity, 1.);
hub.fit_content_height(900.).unwrap();
assert_eq!(
hub.target_size(),
[340., Profile::default().controls_height]
);
hub.retarget(Mode::Idle, [800., 600.]).unwrap();
hub.fit_content_height(100.).unwrap();
assert_eq!(hub.target_size(), [150., 34.]);
}
#[test]
fn a_content_swap_fades_without_restarting_the_geometry() {
let mut hub = Hub::try_new(Profile::default(), 34.).unwrap();
hub.retarget(Mode::Expanded(Content::Controls), [800., 600.])
.unwrap();
hub.advance(Duration::from_secs(2), false);
hub.advance(Duration::from_secs(1), false);
let geometry = hub.sample().geometry;
hub.replace_content();
assert_eq!(hub.sample().geometry, geometry);
assert_eq!(hub.sample().opacity, 0.);
assert_eq!(hub.sample().previous_opacity, 1.);
hub.advance(Duration::from_millis(60), false);
assert_eq!(hub.sample().opacity, 0.);
assert!((hub.sample().previous_opacity - 0.5).abs() < 0.001);
hub.advance(Duration::from_millis(130), false);
assert!((hub.sample().opacity - 0.5).abs() < 0.001);
assert_eq!(hub.sample().previous_opacity, 0.);
hub.advance(Duration::from_millis(60), false);
assert!(!hub.active());
}
#[test]
fn content_swap_uses_a_fixed_delay_instead_of_waiting_for_spring_settlement() {
let mut hub = Hub::try_new(Profile::default(), 34.).unwrap();
assert!(!hub.active());
hub.retarget(Mode::Expanded(Content::Calendar), [800., 600.])
.unwrap();
assert_eq!(hub.target_size(), [340., 600.]); // clipped to the output
hub.advance(Duration::from_millis(120), false);
assert_eq!(hub.sample().opacity, 0.);
assert_eq!(hub.sample().previous_opacity, 0.);
hub.advance(Duration::from_millis(70), false);
assert!((hub.sample().opacity - 0.5).abs() < 0.001);
hub.advance(Duration::from_millis(60), false);
assert_eq!(hub.sample().opacity, 1.);
assert!(
hub.active(),
"content finishes while the shape is still settling"
);
for _ in 0..50 {
hub.advance(Duration::from_millis(16), false);
}
assert_eq!(hub.sample().opacity, 1.);
assert_eq!(hub.sample().previous_opacity, 0.);
assert!(!hub.active());
}
#[test]
fn an_interrupted_content_delay_never_reveals_the_cancelled_panel() {
let mut hub = Hub::try_new(Profile::default(), 34.).unwrap();
hub.retarget(Mode::Expanded(Content::Media), [800., 600.])
.unwrap();
hub.advance(Duration::from_millis(80), false);
hub.retarget(Mode::Expanded(Content::Calendar), [800., 600.])
.unwrap();
for _ in 0..130 {
hub.advance(Duration::from_millis(8), false);
let sample = hub.sample();
assert_eq!(sample.current, Mode::Expanded(Content::Calendar));
assert_eq!(sample.previous, Mode::Idle);
}
assert!(!hub.active());
assert_eq!(hub.sample().opacity, 1.);
}
#[test]
fn spring_overshoots_then_settles_within_its_reserved_surface() {
let mut hub = Hub::try_new(Profile::default(), 34.).unwrap();
hub.retarget(Mode::Expanded(Content::Calendar), [800., 600.])
.unwrap();
let allocation = hub.allocation_size();
let mut peak = 340_f64;
for _ in 0..200 {
hub.advance(Duration::from_millis(8), false);
let sample = hub.sample();
peak = peak.max(sample.geometry.bounds.width);
assert!(sample.geometry.bounds.width <= allocation[0] + 1e-9);
assert!(sample.geometry.bounds.height <= allocation[1] + 1e-9);
}
let overshoot = (peak - 340.) / (340. - 150.);
assert!((0.04..0.05).contains(&overshoot));
assert_eq!(hub.sample().geometry.bounds, Rect::new(0., 0., 340., 600.));
assert!(!hub.active());
}
#[test]
fn overshoot_is_clipped_to_small_outputs_and_reduced_motion_removes_all_fades() {
let mut hub = Hub::try_new(Profile::default(), 34.).unwrap();
hub.retarget(Mode::Expanded(Content::Calendar), [240., 180.])
.unwrap();
for _ in 0..60 {
hub.advance(Duration::from_millis(8), false);
let bounds = hub.sample().geometry.bounds;
assert!(bounds.width <= 240. && bounds.height <= 180.);
}
hub.retarget(Mode::Idle, [240., 180.]).unwrap();
hub.advance(Duration::ZERO, true);
assert_eq!(hub.sample().geometry.bounds, Rect::new(0., 0., 150., 34.));
assert_eq!(hub.sample().previous_opacity, 0.);
assert_eq!(hub.sample().opacity, 1.);
assert!(!hub.active());
}
#[test]
fn interrupted_expansion_keeps_the_presented_shape() {
let mut hub = Hub::try_new(Profile::default(), 34.).unwrap();
hub.retarget(Mode::Expanded(Content::Media), [800., 600.])
.unwrap();
hub.advance(Duration::from_millis(40), false);
let before = hub.sample().geometry;
hub.retarget(Mode::Idle, [800., 600.]).unwrap();
assert_eq!(hub.sample().geometry, before);
for _ in 0..50 {
hub.advance(Duration::from_millis(16), false);
}
assert_eq!(hub.target_size(), [150., 34.]);
assert!(!hub.active());
}
#[test]
fn narrow_outputs_and_reduced_motion_keep_the_hub_bounded() {
let mut hub = Hub::try_new(Profile::default(), 30.).unwrap();
hub.retarget(Mode::Expanded(Content::Calendar), [240., 180.])
.unwrap();
hub.advance(Duration::ZERO, true);
assert_eq!(hub.sample().geometry.bounds, Rect::new(0., 0., 240., 180.));
assert_eq!(hub.sample().opacity, 1.);
assert!(!hub.active());
assert!(hub.retarget(Mode::Idle, [f64::NAN, 180.]).is_err());
assert_eq!(hub.mode(), Mode::Expanded(Content::Calendar));
}
}