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 { 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)); } }