Implement Continuum compositor and shell motion
This commit is contained in:
1 parent
259f7e93b6
commit
f8bc17e913
42 files changed
+1873
-647
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¬ice.id));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -295,7 +295,7 @@ impl FereseShell {
|
||||
if let Some(effects) = &entry.effects {
|
||||
let regions = entry.regions.lock().unwrap().clone();
|
||||
let _ = effects.set_material_regions(®ions, 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user