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
@@ -6,6 +6,7 @@ license.workspace = true
rust-version.workspace = true
[dependencies]
ferese-animation = { path = "../../crates/ferese-animation" }
ferese-theme-client = { path = "../../crates/ferese-theme-client" }
ferese-ipc = { path = "../../crates/ferese-ipc" }
ferese-theme = { path = "../../crates/ferese-theme" }
+12
View File
@@ -360,6 +360,7 @@ fn parse_document(
.map_err(|error| ferese_config::Error::from(error.to_string()))
{
Ok(config) => {
config.animations.validate().map_err(ferese_config::Error::from)?;
config.notifications.validate().map_err(ferese_config::Error::from)?;
config.desktop_widgets.validate().map_err(ferese_config::Error::from)?;
let mut theme = shell_theme(&config.theme);
@@ -577,6 +578,17 @@ pub(crate) fn config_path() -> Option<PathBuf> {
#[cfg(test)]
mod tests {
#[test]
fn rejects_undamped_overshoot_before_publication() {
for property in ["spring", "viewport-spring"] {
for coefficient in ["damping", "damping-ratio"] {
let source = format!("animations {{ {property} {{ {coefficient} 0; overshoot #true; }}; }}");
let error = parse_test_source(&source).unwrap_err().to_string();
assert!(error.contains("overshoot requires positive damping"), "{error}");
}
}
}
#[test]
fn desktop_clock_parses_and_rejects_invalid_reload_values() {
let clock = parse_test_source(
+1
View File
@@ -904,6 +904,7 @@ impl FereseShell {
motion::configure(config.animations, config.theme.material_radius);
if self.config.animations != config.animations {
self.notifications.update_motion_settings(config.animations);
if let Some(menu) = &mut self.menu {
menu.motion.update_settings(config.animations);
}
+111 -73
View File
@@ -1,45 +1,12 @@
//! Draw-only popup transform; layout stays stable while its presentation animates.
use cosmic::iced::advanced::{Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget};
use cosmic::iced::{Event, Length, Rectangle, Size, Transformation, Vector};
use cosmic::{Element, Theme};
pub type Regions = std::sync::Arc<std::sync::Mutex<Vec<[f32; 5]>>>;
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
#[serde(default)]
pub(crate) struct Settings {
enabled: bool,
reduced_motion: bool,
speed: f64,
}
pub(crate) use ferese_animation::MotionSettings as Settings;
use ferese_animation::{AnimatedValue, SpringConfig};
impl Default for Settings {
fn default() -> Self {
Self {
enabled: true,
reduced_motion: false,
speed: 1.0,
}
}
}
impl Settings {
fn hover_duration(self) -> std::time::Duration {
self.duration(120.0)
}
pub(crate) fn duration(self, milliseconds: f64) -> std::time::Duration {
if !self.enabled || self.reduced_motion {
return std::time::Duration::ZERO;
}
let speed = if self.speed.is_finite() && self.speed > 0.0 {
self.speed.clamp(0.01, 100.0)
} else {
1.0
};
std::time::Duration::from_secs_f64(milliseconds / 1000.0 / speed)
}
}
static SETTINGS: std::sync::OnceLock<std::sync::RwLock<Settings>> = std::sync::OnceLock::new();
static SHELL_RADIUS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(14.0_f32.to_bits());
@@ -56,25 +23,29 @@ pub(crate) fn configure(settings: Settings, radius: f32) {
.unwrap() = settings;
}
pub(crate) fn notification_duration() -> std::time::Duration {
SETTINGS
pub(crate) fn settings() -> Settings {
*SETTINGS
.get_or_init(|| std::sync::RwLock::new(Settings::default()))
.read()
.unwrap()
.duration(180.0)
}
pub(crate) struct PopupMotion {
start: f32,
target: f32,
value: AnimatedValue,
started: Option<std::time::Instant>,
settings: Settings,
}
impl PopupMotion {
pub(crate) fn update_settings(&mut self, settings: Settings) {
let now = std::time::Instant::now();
self.start = self.progress_at(now);
self.update_settings_at(settings, std::time::Instant::now());
}
fn update_settings_at(&mut self, settings: Settings, now: std::time::Instant) {
self.value = self.sample(now);
// Stored velocities use simulation seconds. Preserve visible velocity
// when changing the clock rate during an in-flight transition.
self.value.velocity *= self.settings.speed / settings.speed;
self.settings = settings;
if self.started.is_some() {
self.started = Some(now);
@@ -83,8 +54,11 @@ impl PopupMotion {
pub(crate) fn new(settings: Settings) -> Self {
Self {
start: 0.0,
target: 1.0,
value: AnimatedValue {
current: 0.0,
target: 1.0,
velocity: 0.0,
},
started: None,
settings,
}
@@ -96,36 +70,49 @@ impl PopupMotion {
}
}
pub(crate) fn progress_at(&self, now: std::time::Instant) -> f32 {
fn sample(&self, now: std::time::Instant) -> AnimatedValue {
let mut value = self.value;
let Some(started) = self.started else {
return self.start;
return value;
};
let duration = self.settings.duration(if self.target == 1.0 { 200.0 } else { 140.0 });
if duration.is_zero() {
return self.target;
if !self.settings.motion_enabled() {
value.snap();
return value;
}
let t = (now.saturating_duration_since(started).as_secs_f32() / duration.as_secs_f32()).min(1.0);
if t == 1.0 {
return self.target;
}
self.start + (self.target - self.start) * t * t * (3.0 - 2.0 * t)
let spring = self
.settings
.spring
.resolve(SpringConfig::default())
.expect("validated motion settings");
value.advance(
now.saturating_duration_since(started).mul_f64(self.settings.speed),
SpringConfig {
position_tolerance: 0.00001,
velocity_tolerance: 0.00001,
..spring
},
);
value
}
pub(crate) fn progress_at(&self, now: std::time::Instant) -> f32 {
self.sample(now).current as f32
}
pub(crate) fn progress(&self) -> f32 {
self.progress_at(std::time::Instant::now())
}
pub(crate) fn closing(&self) -> bool {
self.target == 0.0
self.value.target == 0.0
}
pub(crate) fn animating(&self) -> bool {
self.progress() != self.target
let value = self.sample(std::time::Instant::now());
value.current != value.target || value.velocity != 0.0
}
pub(crate) fn retarget(&mut self, target: f32, now: std::time::Instant) {
self.start = self.progress_at(now);
self.target = target;
self.value = self.sample(now);
self.value.set_target(f64::from(target));
self.started = Some(now);
}
}
@@ -158,7 +145,7 @@ pub(crate) fn button<'a, M: Clone + 'a>(
duration: SETTINGS
.get()
.map_or_else(Settings::default, |settings| *settings.read().unwrap())
.hover_duration(),
.duration(120.0),
})
}
@@ -506,6 +493,28 @@ impl widget::Operation for CollectRegions {
#[cfg(test)]
mod tests {
#[test]
fn zero_damping_with_crossing_clamp_finishes_popup_close() {
let settings = super::Settings {
spring: ferese_animation::SpringSettings {
damping: Some(0.0),
..Default::default()
},
..Default::default()
};
settings.validate().unwrap();
let now = std::time::Instant::now();
let mut motion = super::PopupMotion::new(settings);
motion.begin(now);
let opened = now + std::time::Duration::from_secs(1);
assert_eq!(motion.sample(opened).current, 1.0);
motion.retarget(0.0, opened);
let closed = motion.sample(opened + std::time::Duration::from_secs(1));
assert!(motion.closing());
assert_eq!(closed.current, closed.target);
assert_eq!(closed.velocity, 0.0);
}
use super::*;
#[test]
@@ -529,19 +538,48 @@ mod tests {
}
#[test]
fn popup_waits_for_configuration_then_opens_and_closes_faster() {
fn popup_waits_for_configuration_and_preserves_reversal_velocity() {
let now = std::time::Instant::now();
let mut motion = PopupMotion::new(Settings::default());
assert_eq!(motion.progress_at(now + std::time::Duration::from_secs(30)), 0.0);
motion.begin(now);
// Later configure/focus notifications must not restart the clock.
motion.begin(now + std::time::Duration::from_millis(90));
assert!((motion.progress_at(now + std::time::Duration::from_millis(100)) - 0.5).abs() < 0.00001);
assert_eq!(motion.progress_at(now + std::time::Duration::from_millis(200)), 1.0);
motion.retarget(0.0, now + std::time::Duration::from_millis(200));
let reverse = now + std::time::Duration::from_millis(90);
let before = motion.sample(reverse);
motion.begin(reverse);
assert_eq!(motion.sample(reverse), before);
motion.retarget(0.0, reverse);
let after = motion.sample(reverse);
assert_eq!(after.current, before.current);
assert_eq!(after.velocity, before.velocity);
assert!(motion.closing());
assert!((motion.progress_at(now + std::time::Duration::from_millis(270)) - 0.5).abs() < 0.00001);
assert_eq!(motion.progress_at(now + std::time::Duration::from_millis(340)), 0.0);
assert_eq!(motion.progress_at(reverse + std::time::Duration::from_secs(2)), 0.0);
}
#[test]
fn live_speed_change_preserves_visible_velocity_and_reduced_motion_snaps() {
let now = std::time::Instant::now();
let mut motion = PopupMotion::new(Settings::default());
motion.begin(now);
let time = now + std::time::Duration::from_millis(90);
let before = motion.sample(time);
motion.update_settings_at(
Settings {
speed: 2.0,
..Settings::default()
},
time,
);
let after = motion.sample(time);
assert_eq!(after.current, before.current);
assert_eq!(after.velocity * 2.0, before.velocity);
motion.update_settings_at(
Settings {
reduced_motion: true,
..Settings::default()
},
time,
);
assert_eq!(motion.progress_at(time), 1.0);
}
#[test]
@@ -560,7 +598,7 @@ mod tests {
let before = motion.progress_at(reopen);
motion.retarget(1.0, reopen);
assert_eq!(motion.progress_at(reopen), before);
assert_eq!(motion.progress_at(reopen + std::time::Duration::from_secs(1)), 1.0);
assert_eq!(motion.progress_at(reopen + std::time::Duration::from_secs(2)), 1.0);
for settings in [
Settings {
reduced_motion: true,
@@ -632,7 +670,7 @@ mod tests {
speed: 0.75,
..Settings::default()
}
.hover_duration(),
.duration(120.0),
std::time::Duration::from_millis(160)
);
for settings in [
@@ -645,9 +683,9 @@ mod tests {
..Settings::default()
},
] {
assert!(settings.hover_duration().is_zero());
assert!(settings.duration(120.0).is_zero());
let mut hover = HoverState::default();
assert!(!hover.advance(1.0, std::time::Instant::now(), settings.hover_duration()));
assert!(!hover.advance(1.0, std::time::Instant::now(), settings.duration(120.0)));
assert_eq!(hover.current, 1.0);
}
}
+21 -19
View File
@@ -262,7 +262,7 @@ impl Service {
struct Toast {
id: u32,
born: Instant,
motion: super::motion::PopupMotion,
closing: Option<Instant>,
remaining: Option<Duration>,
hovered: bool,
@@ -320,30 +320,27 @@ impl Center {
}
}
pub fn update_motion_settings(&mut self, settings: super::motion::Settings) {
for toast in &mut self.toasts {
toast.motion.update_settings(settings);
}
}
pub fn unread(&self) -> u32 {
self.entries.iter().filter(|notice| notice.unread).count() as u32
}
pub fn visible(&self) -> impl Iterator<Item = (&Notice, f32)> {
let duration = super::motion::notification_duration();
self.toasts.iter().rev().filter_map(move |toast| {
let notice = self.entries.iter().find(|notice| notice.id == toast.id)?;
let p = if duration.is_zero() {
if toast.closing.is_some() { 0.0 } else { 1.0 }
} else {
toast.closing.map_or_else(
|| (toast.born.elapsed().as_secs_f32() / duration.as_secs_f32()).min(1.0),
|closing| (1.0 - closing.elapsed().as_secs_f32() / duration.as_secs_f32()).max(0.0),
)
};
Some((notice, p * p * (3.0 - 2.0 * p)))
Some((notice, toast.motion.progress().clamp(0.0, 1.0)))
})
}
pub fn animating(&self) -> bool {
self.toasts
.iter()
.any(|toast| toast.closing.is_some() || toast.born.elapsed() < super::motion::notification_duration())
.any(|toast| toast.closing.is_some() || toast.motion.animating())
}
pub fn popup_groups(&self) -> Vec<(&Notice, f32, usize)> {
@@ -433,7 +430,11 @@ impl Center {
}
}
if let Some(toast) = self.toasts.iter_mut().find(|toast| toast.id == id) {
toast.closing.get_or_insert_with(Instant::now);
if toast.closing.is_none() {
let now = Instant::now();
toast.closing = Some(now);
toast.motion.retarget(0.0, now);
}
}
}
@@ -499,7 +500,11 @@ impl Center {
if show {
self.toasts.push_back(Toast {
id: notice.id,
born: Instant::now(),
motion: {
let mut motion = super::motion::PopupMotion::new(super::motion::settings());
motion.begin(Instant::now());
motion
},
closing: None,
remaining: notice.timeout,
hovered: group_hovered,
@@ -604,11 +609,8 @@ impl Center {
for id in expired {
self.close(id, 1);
}
self.toasts.retain(|toast| {
toast
.closing
.is_none_or(|closing| now.duration_since(closing) < super::motion::notification_duration())
});
self.toasts
.retain(|toast| toast.closing.is_none() || toast.motion.animating());
let visible: HashSet<_> = self.toasts.iter().map(|toast| toast.id).collect();
self.entries
.retain(|notice| !notice.transient || notice.live || visible.contains(&notice.id));
+35 -25
View File
@@ -26,9 +26,12 @@ impl FereseShell {
self.calendar_offset = 0;
}
let destroy = self.destroy_menu();
if !kind.available(&self.status) {
return destroy;
return Task::none();
}
if self.menu.as_ref().is_some_and(|menu| menu.kind == Menu::Notifications) {
self.notifications.history_open = false;
self.notifications.hovered = None;
}
if kind == Menu::Notifications && self.notifications.ready {
@@ -36,14 +39,6 @@ impl FereseShell {
}
let notifications = self.sync_notification_surface();
let id = window::Id::unique();
self.menu = Some(OpenMenu {
id,
kind,
motion: crate::motion::PopupMotion::new(self.config.animations),
effects: None,
regions: Default::default(),
});
crate::EFFECT_FRAME_PENDING.store(true, std::sync::atomic::Ordering::Relaxed);
self.status_error = None;
let parent = self.bar_surface_id;
@@ -70,6 +65,33 @@ impl FereseShell {
} else {
kind.height_limit()
};
let positioner = SctkPositioner {
anchor_rect: anchor,
anchor: 2u32.try_into().unwrap(),
gravity: 6u32.try_into().unwrap(),
offset: (anchor.width / 2, 8),
// The view's autosize widget supplies the active panel's bounds.
// Keep initial popup limits broad enough for later panel changes.
size_limits: Limits::NONE.max_width(368.0).max_height(720.0_f32.max(height_limit)),
constraint_adjustment: 3,
..Default::default()
};
if let Some(menu) = &mut self.menu {
menu.kind = kind;
menu.motion.retarget(1.0, Instant::now());
return Task::batch([
notifications,
cosmic::iced::platform_specific::shell::commands::popup::reposition(menu.id, positioner),
]);
}
let id = window::Id::unique();
self.menu = Some(OpenMenu {
id,
kind,
motion: crate::motion::PopupMotion::new(self.config.animations),
effects: None,
regions: Default::default(),
});
let action = cosmic::surface::action::app_popup::<Self>(
|_| Default::default(),
move |_| SctkPopupSettings {
@@ -79,23 +101,11 @@ impl FereseShell {
grab: true,
close_with_children: false,
input_zone: None,
positioner: SctkPositioner {
anchor_rect: anchor,
// Numeric protocol values avoid depending on libcosmic's private SCTK reexport.
anchor: 2u32.try_into().unwrap(), // bottom
gravity: 6u32.try_into().unwrap(), // bottom-left
offset: (anchor.width / 2, 8),
size_limits: Limits::NONE
.min_width(kind.width())
.max_width(kind.width())
.max_height(height_limit),
constraint_adjustment: 3, // slide X/Y, never flip above the bar
..Default::default()
},
positioner: positioner.clone(),
},
Some(Box::new(Self::view_status_menu)),
);
Task::batch([destroy, notifications]).chain(cosmic::task::message(cosmic::Action::Surface(action)))
notifications.chain(cosmic::task::message(cosmic::Action::Surface(action)))
}
pub fn destroy_menu(&mut self) -> Task<Message> {
@@ -136,7 +146,7 @@ impl FereseShell {
pub fn animate_menu(&mut self) -> Task<Message> {
if let Some(menu) = &self.menu {
if let Some(effects) = &menu.effects {
let _ = effects.set_opacity(menu.progress());
let _ = effects.set_opacity(menu.progress().clamp(0.0, 1.0));
}
if menu.motion.closing() && !menu.animating() {
return self.destroy_menu();
+1 -1
View File
@@ -295,7 +295,7 @@ impl FereseShell {
if let Some(effects) = &entry.effects {
let regions = entry.regions.lock().unwrap().clone();
let _ = effects.set_material_regions(&regions, ferese_surface_effects_v1::Role::Modal);
let _ = effects.set_opacity(modal.motion.progress());
let _ = effects.set_opacity(modal.motion.progress().clamp(0.0, 1.0));
}
}
}