Unify window presentation and frame-driven shell motion
Share window geometry across overview, resize snapshots and retained closes. Advance close handoffs and group retained rendering by stacking position. Drive shell motion from surface frames, reuse settled widget content, and synchronize notification materials with each card's fade. Replace physical config keys with duration and bounce, and update documentation and regression coverage.
This commit is contained in:
1 parent
f8bc17e913
commit
64a80dc069
33 files changed
+1742
-600
No files matched your search
@@ -579,12 +579,12 @@ pub(crate) fn config_path() -> Option<PathBuf> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn rejects_undamped_overshoot_before_publication() {
|
||||
fn rejects_removed_physics_keys_before_publication() {
|
||||
for property in ["spring", "viewport-spring"] {
|
||||
for coefficient in ["damping", "damping-ratio"] {
|
||||
for coefficient in ["mass", "stiffness", "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}");
|
||||
assert!(error.contains("unknown field"), "{error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,15 +355,6 @@ impl cosmic::Application for FereseShell {
|
||||
control.subscription().map(|_| Message::ControlReady)
|
||||
}),
|
||||
ferese_theme_client::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))),
|
||||
if self
|
||||
.system_modal
|
||||
.as_ref()
|
||||
.is_some_and(|modal| modal.motion.animating() || modal.motion.closing())
|
||||
{
|
||||
cosmic::iced::time::every(Duration::from_millis(16)).map(|_| Message::AnimatePower)
|
||||
} else {
|
||||
Subscription::none()
|
||||
},
|
||||
self.notifications.subscription().map(Message::NotificationEvent),
|
||||
self.notifications
|
||||
.tick_subscription()
|
||||
@@ -399,45 +390,10 @@ impl cosmic::Application for FereseShell {
|
||||
} else {
|
||||
Subscription::none()
|
||||
},
|
||||
if self
|
||||
.menu
|
||||
.as_ref()
|
||||
.is_some_and(|menu| menu.animating() || menu.motion.closing())
|
||||
{
|
||||
cosmic::iced::time::every(Duration::from_millis(16)).map(|_| Message::AnimateMenu)
|
||||
} else {
|
||||
Subscription::none()
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
fn update(&mut self, message: Self::Message) -> Task<Self::Message> {
|
||||
if let Some(menu) = &self.menu
|
||||
&& let Some(effects) = &menu.effects
|
||||
{
|
||||
if let Err(error) = effects.set_opacity(menu.progress()) {
|
||||
eprintln!("ferese-shell: could not update popup opacity: {error}");
|
||||
}
|
||||
|
||||
let regions = menu.regions.lock().unwrap().clone();
|
||||
|
||||
if let Err(error) = effects.set_regions(®ions) {
|
||||
eprintln!("ferese-shell: could not update card materials: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(surface) = &self.notification_surface
|
||||
&& let Some(effects) = &surface.effects
|
||||
{
|
||||
let regions = surface.regions.lock().unwrap().clone();
|
||||
|
||||
if let Err(error) = effects.set_regions(®ions) {
|
||||
eprintln!("ferese-shell: could not update notification materials: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
self.update_power_materials();
|
||||
|
||||
match message {
|
||||
Message::ThemeChanged(snapshot) => {
|
||||
let mut config = self.config.clone();
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
mod frames;
|
||||
pub(crate) use frames::frame_driven;
|
||||
|
||||
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};
|
||||
@@ -79,11 +82,7 @@ impl PopupMotion {
|
||||
value.snap();
|
||||
return value;
|
||||
}
|
||||
let spring = self
|
||||
.settings
|
||||
.spring
|
||||
.resolve(SpringConfig::default())
|
||||
.expect("validated motion settings");
|
||||
let spring = self.settings.spring_config().expect("validated motion settings");
|
||||
value.advance(
|
||||
now.saturating_duration_since(started).mul_f64(self.settings.speed),
|
||||
SpringConfig {
|
||||
@@ -105,8 +104,17 @@ impl PopupMotion {
|
||||
pub(crate) fn closing(&self) -> bool {
|
||||
self.value.target == 0.0
|
||||
}
|
||||
pub(crate) fn revision(&self) -> Option<std::time::Instant> {
|
||||
self.started
|
||||
}
|
||||
pub(crate) fn frame_active(&self, now: std::time::Instant) -> bool {
|
||||
self.started.is_some() && self.animating_at(now)
|
||||
}
|
||||
pub(crate) fn animating(&self) -> bool {
|
||||
let value = self.sample(std::time::Instant::now());
|
||||
self.animating_at(std::time::Instant::now())
|
||||
}
|
||||
pub(crate) fn animating_at(&self, now: std::time::Instant) -> bool {
|
||||
let value = self.sample(now);
|
||||
value.current != value.target || value.velocity != 0.0
|
||||
}
|
||||
|
||||
@@ -238,11 +246,14 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
|
||||
shell: &mut Shell<'_, M>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let now = std::time::Instant::now();
|
||||
let now = match event {
|
||||
Event::Window(cosmic::iced::window::Event::RedrawRequested(at)) => *at,
|
||||
_ => std::time::Instant::now(),
|
||||
};
|
||||
let hovered = cursor.is_over(layout.bounds()) && cursor.is_over(*viewport);
|
||||
let state = tree.state.downcast_mut::<HoverState>();
|
||||
if state.advance(if hovered { 1.0 } else { 0.0 }, now, self.duration) {
|
||||
shell.request_redraw_at(now + std::time::Duration::from_millis(16));
|
||||
shell.request_redraw();
|
||||
}
|
||||
self.progress.set(state.current);
|
||||
self.content.as_widget_mut().update(
|
||||
@@ -494,10 +505,12 @@ impl widget::Operation for CollectRegions {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn zero_damping_with_crossing_clamp_finishes_popup_close() {
|
||||
fn duration_bounce_spring_finishes_popup_close() {
|
||||
let settings = super::Settings {
|
||||
spring: ferese_animation::SpringSettings {
|
||||
damping: Some(0.0),
|
||||
duration_ms: Some(240.0),
|
||||
bounce: 0.3,
|
||||
overshoot: true,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
//! Surface-local animation sampling. Iced's Wayland backend gates NextFrame
|
||||
//! redraws on that surface's frame callback; no animation timer is needed.
|
||||
use super::*;
|
||||
use std::time::Instant;
|
||||
|
||||
pub(crate) fn frame_driven<'a, M: 'a>(
|
||||
revision: Option<Instant>,
|
||||
build: impl Fn(Instant) -> Element<'a, M> + 'a,
|
||||
active: impl Fn(Instant) -> bool + 'a,
|
||||
after_frame: impl Fn(Instant) + 'a,
|
||||
completion: impl Fn(Instant) -> Option<M> + 'a,
|
||||
) -> Element<'a, M> {
|
||||
let now = Instant::now();
|
||||
let content = build(now);
|
||||
let content_active = active(now);
|
||||
Element::new(FrameDriven {
|
||||
content,
|
||||
content_active,
|
||||
revision,
|
||||
build: Box::new(build),
|
||||
active: Box::new(active),
|
||||
after_frame: Box::new(after_frame),
|
||||
completion: Box::new(completion),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FrameState {
|
||||
revision: Option<Instant>,
|
||||
last_frame: Option<Instant>,
|
||||
completed: bool,
|
||||
}
|
||||
|
||||
impl FrameState {
|
||||
fn begin(&mut self, at: Instant, revision: Option<Instant>) -> bool {
|
||||
if self.revision != revision {
|
||||
self.revision = revision;
|
||||
self.last_frame = None;
|
||||
self.completed = false;
|
||||
}
|
||||
if self.last_frame == Some(at) {
|
||||
return false;
|
||||
}
|
||||
self.last_frame = Some(at);
|
||||
true
|
||||
}
|
||||
|
||||
fn complete(&mut self, active: bool) -> bool {
|
||||
if active {
|
||||
self.completed = false;
|
||||
return false;
|
||||
}
|
||||
!std::mem::replace(&mut self.completed, true)
|
||||
}
|
||||
}
|
||||
|
||||
struct FrameDriven<'a, M> {
|
||||
content: Element<'a, M>,
|
||||
content_active: bool,
|
||||
revision: Option<Instant>,
|
||||
build: Box<dyn Fn(Instant) -> Element<'a, M> + 'a>,
|
||||
active: Box<dyn Fn(Instant) -> bool + 'a>,
|
||||
after_frame: Box<dyn Fn(Instant) + 'a>,
|
||||
completion: Box<dyn Fn(Instant) -> Option<M> + 'a>,
|
||||
}
|
||||
|
||||
impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
|
||||
fn tag(&self) -> widget::tree::Tag {
|
||||
widget::tree::Tag::of::<FrameState>()
|
||||
}
|
||||
fn state(&self) -> widget::tree::State {
|
||||
widget::tree::State::new(FrameState::default())
|
||||
}
|
||||
fn children(&self) -> Vec<widget::Tree> {
|
||||
vec![widget::Tree::new(&self.content)]
|
||||
}
|
||||
fn diff(&mut self, tree: &mut widget::Tree) {
|
||||
tree.diff_children(std::slice::from_mut(&mut self.content));
|
||||
}
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
renderer: &cosmic::Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn a11y_nodes(
|
||||
&self,
|
||||
layout: Layout<'_>,
|
||||
tree: &widget::Tree,
|
||||
cursor: mouse::Cursor,
|
||||
) -> iced_accessibility::A11yTree {
|
||||
self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor)
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &cosmic::Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, M>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let redraw = if let Event::Window(cosmic::iced::window::Event::RedrawRequested(at)) = event {
|
||||
tree.state
|
||||
.downcast_mut::<FrameState>()
|
||||
.begin(*at, self.revision)
|
||||
.then_some(*at)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(at) = redraw {
|
||||
// Only paint parameters depend on time. Preserve the child tree's
|
||||
// focus/hover/scroll state and its existing layout.
|
||||
let active = (self.active)(at);
|
||||
// Include the final settled sample, then reuse the tree on hover
|
||||
// and other redraws. Application updates build fresh content.
|
||||
if active || self.content_active {
|
||||
self.content = (self.build)(at);
|
||||
tree.children[0].diff(&mut self.content);
|
||||
}
|
||||
self.content_active = active;
|
||||
}
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
if let Event::Window(cosmic::iced::window::Event::RedrawRequested(at)) = event {
|
||||
let active = (self.active)(*at);
|
||||
if active {
|
||||
shell.request_redraw();
|
||||
}
|
||||
// Iced can replay the same redraw after a child invalidates layout.
|
||||
// Keep its NextFrame request, but sample/publish only once.
|
||||
if redraw.is_some() {
|
||||
(self.after_frame)(*at);
|
||||
}
|
||||
if redraw.is_some() {
|
||||
let completion = (self.completion)(*at);
|
||||
if tree.state.downcast_mut::<FrameState>().complete(completion.is_none())
|
||||
&& let Some(message) = completion
|
||||
{
|
||||
shell.publish(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
renderer: &mut cosmic::Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content
|
||||
.as_widget()
|
||||
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &cosmic::Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.content
|
||||
.as_widget()
|
||||
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &cosmic::Renderer,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut widget::Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &cosmic::Renderer,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<cosmic::iced::advanced::overlay::Element<'b, M, Theme, cosmic::Renderer>> {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cosmic::iced::advanced::renderer::Headless;
|
||||
use cosmic::iced::window::{Event as WindowEvent, RedrawRequest};
|
||||
use std::{cell::RefCell, time::Duration};
|
||||
|
||||
#[test]
|
||||
fn callbacks_sample_every_refresh_without_timers_and_stop_when_settled() {
|
||||
let mut renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new(
|
||||
cosmic::iced::Font::default(),
|
||||
cosmic::iced::Pixels(14.0),
|
||||
Some("tiny-skia"),
|
||||
))
|
||||
.unwrap();
|
||||
for hz in [60, 120, 144, 240] {
|
||||
let start = Instant::now();
|
||||
let mut motion = PopupMotion::new(Settings::default());
|
||||
motion.begin(start);
|
||||
let samples = RefCell::new(Vec::new());
|
||||
let mut element = frame_driven(
|
||||
motion.revision(),
|
||||
|at| {
|
||||
samples.borrow_mut().push(motion.progress_at(at));
|
||||
cosmic::widget::Space::new().width(20.0).height(20.0).into()
|
||||
},
|
||||
|at| motion.frame_active(at),
|
||||
|_| {},
|
||||
|at| (!motion.frame_active(at)).then_some(()),
|
||||
);
|
||||
let mut tree = widget::Tree::new(&element);
|
||||
let node = element.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, Size::new(100.0, 100.0)),
|
||||
);
|
||||
let mut messages = Vec::new();
|
||||
let mut finished = false;
|
||||
let mut clipboard = cosmic::iced::advanced::clipboard::Null;
|
||||
samples.borrow_mut().clear();
|
||||
for frame in 0..=hz * 2 {
|
||||
let at = start + Duration::from_secs_f64(frame as f64 / hz as f64);
|
||||
let event = Event::Window(WindowEvent::RedrawRequested(at));
|
||||
let active = motion.frame_active(at);
|
||||
for _ in 0..2 {
|
||||
// Iced may replay a redraw after a layout/update pass.
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
element.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&event,
|
||||
Layout::new(&node),
|
||||
mouse::Cursor::Unavailable,
|
||||
&renderer,
|
||||
&mut clipboard,
|
||||
&mut shell,
|
||||
&Rectangle::new((0.0, 0.0).into(), (100.0, 100.0).into()),
|
||||
);
|
||||
assert_eq!(
|
||||
shell.redraw_request(),
|
||||
if active {
|
||||
RedrawRequest::NextFrame
|
||||
} else {
|
||||
RedrawRequest::Wait
|
||||
}
|
||||
);
|
||||
}
|
||||
assert_eq!(samples.borrow().len(), frame as usize + 1);
|
||||
if !active {
|
||||
finished = true;
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
messages.is_empty(),
|
||||
"motion should not publish application updates per frame"
|
||||
);
|
||||
}
|
||||
assert!(finished);
|
||||
assert_eq!(messages.len(), 1, "only one completion update");
|
||||
let settled_builds = samples.borrow().len();
|
||||
// A later unrelated redraw must not restart motion or cleanup.
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
element.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&Event::Window(WindowEvent::RedrawRequested(start + Duration::from_secs(3))),
|
||||
Layout::new(&node),
|
||||
mouse::Cursor::Unavailable,
|
||||
&renderer,
|
||||
&mut clipboard,
|
||||
&mut shell,
|
||||
&Rectangle::new((0.0, 0.0).into(), (100.0, 100.0).into()),
|
||||
);
|
||||
assert_eq!(shell.redraw_request(), RedrawRequest::Wait);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(samples.borrow().len(), settled_builds, "settled redraw rebuilt content");
|
||||
let values = samples.borrow();
|
||||
assert!(values.windows(2).all(|pair| pair[1] >= pair[0]));
|
||||
assert_eq!(values.last(), Some(&1.0));
|
||||
assert!(values.len() > (hz / 4) as usize, "high refresh callbacks were skipped");
|
||||
}
|
||||
// Keep this mutable: the real render path shares the same renderer.
|
||||
renderer.reset(Rectangle::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_transition_can_complete_after_previous_one_settled() {
|
||||
let start = Instant::now();
|
||||
let mut state = FrameState::default();
|
||||
assert!(state.begin(start, Some(start)));
|
||||
assert!(state.complete(false));
|
||||
assert!(!state.begin(start, Some(start)));
|
||||
assert!(!state.complete(false));
|
||||
let next = start + Duration::from_millis(10);
|
||||
assert!(state.begin(next, Some(next)));
|
||||
assert!(!state.complete(true));
|
||||
assert!(state.begin(next + Duration::from_millis(400), Some(next)));
|
||||
assert!(state.complete(false));
|
||||
assert!(!state.complete(false));
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,9 @@ impl FereseShell {
|
||||
}
|
||||
|
||||
pub(super) fn sync_notification_surface(&mut self) -> Task<Message> {
|
||||
// Hidden toast surfaces receive no frame callbacks. Their closes have
|
||||
// no visible transition to finish, so release them immediately.
|
||||
self.notifications.tick();
|
||||
if self.notifications.ready {
|
||||
self.status.notifications = Some(status::Notifications {
|
||||
count: self.notifications.unread(),
|
||||
@@ -513,10 +516,36 @@ impl FereseShell {
|
||||
}
|
||||
|
||||
pub(super) fn view_notifications(&self) -> Element<'_, cosmic::Action<Message>> {
|
||||
motion::frame_driven(
|
||||
self.notifications.motion_revision(),
|
||||
|now| self.view_notifications_at(now),
|
||||
|now| self.notifications.frame_active(now),
|
||||
|now| {
|
||||
if let Some(surface) = &self.notification_surface
|
||||
&& let Some(effects) = &surface.effects
|
||||
{
|
||||
let _ = effects.set_regions(&surface.regions.lock().unwrap());
|
||||
let _ = effects.set_region_opacities(
|
||||
self.notifications
|
||||
.popup_groups_at(now)
|
||||
.iter()
|
||||
.map(|(_, opacity, _)| *opacity),
|
||||
);
|
||||
}
|
||||
},
|
||||
|now| {
|
||||
self.notifications
|
||||
.has_finished_closes(now)
|
||||
.then_some(cosmic::Action::App(Message::NotificationTick))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn view_notifications_at(&self, now: std::time::Instant) -> Element<'_, cosmic::Action<Message>> {
|
||||
if self.notifications.history_open {
|
||||
let palette = self.config.theme;
|
||||
let surface = self.menu.as_ref();
|
||||
let progress = surface.map_or(1.0, status_ui::OpenMenu::progress);
|
||||
let progress = surface.map_or(1.0, |menu| menu.motion.progress_at(now));
|
||||
let opacity = 1.0;
|
||||
let color = move |rgba| color_with_opacity(rgba, opacity);
|
||||
let primary = color(palette.text_primary);
|
||||
@@ -751,7 +780,7 @@ impl FereseShell {
|
||||
)
|
||||
} else {
|
||||
let mut cards = column([]).spacing(8);
|
||||
for (notice, opacity, count) in self.notifications.popup_groups() {
|
||||
for (notice, opacity, count) in self.notifications.popup_groups_at(now) {
|
||||
cards = cards.push(self.notification_card(notice, opacity, false, count));
|
||||
}
|
||||
motion::animated(
|
||||
|
||||
@@ -330,22 +330,39 @@ impl Center {
|
||||
self.entries.iter().filter(|notice| notice.unread).count() as u32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn visible(&self) -> impl Iterator<Item = (&Notice, f32)> {
|
||||
self.visible_at(Instant::now())
|
||||
}
|
||||
|
||||
fn visible_at(&self, now: Instant) -> impl Iterator<Item = (&Notice, f32)> {
|
||||
self.toasts.iter().rev().filter_map(move |toast| {
|
||||
let notice = self.entries.iter().find(|notice| notice.id == toast.id)?;
|
||||
Some((notice, toast.motion.progress().clamp(0.0, 1.0)))
|
||||
Some((notice, toast.motion.progress_at(now).clamp(0.0, 1.0)))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn animating(&self) -> bool {
|
||||
pub fn frame_active(&self, now: Instant) -> bool {
|
||||
self.toasts.iter().any(|toast| toast.motion.frame_active(now))
|
||||
}
|
||||
|
||||
pub fn has_finished_closes(&self, now: Instant) -> bool {
|
||||
self.toasts
|
||||
.iter()
|
||||
.any(|toast| toast.closing.is_some() || toast.motion.animating())
|
||||
.any(|toast| toast.closing.is_some() && !toast.motion.animating_at(now))
|
||||
}
|
||||
|
||||
pub fn motion_revision(&self) -> Option<Instant> {
|
||||
self.toasts.iter().filter_map(|toast| toast.motion.revision()).max()
|
||||
}
|
||||
|
||||
pub fn popup_groups(&self) -> Vec<(&Notice, f32, usize)> {
|
||||
self.popup_groups_at(Instant::now())
|
||||
}
|
||||
|
||||
pub fn popup_groups_at(&self, now: Instant) -> Vec<(&Notice, f32, usize)> {
|
||||
let mut groups: Vec<(&Notice, f32, usize)> = Vec::new();
|
||||
for (notice, opacity) in self.visible() {
|
||||
for (notice, opacity) in self.visible_at(now) {
|
||||
if let Some(group) = groups.iter_mut().find(|(head, _, _)| head.app == notice.app) {
|
||||
group.2 += 1;
|
||||
} else {
|
||||
@@ -429,12 +446,12 @@ impl Center {
|
||||
self.command(Command::Close(id, revision, reason));
|
||||
}
|
||||
}
|
||||
if let Some(toast) = self.toasts.iter_mut().find(|toast| toast.id == id) {
|
||||
if toast.closing.is_none() {
|
||||
let now = Instant::now();
|
||||
toast.closing = Some(now);
|
||||
toast.motion.retarget(0.0, now);
|
||||
}
|
||||
if let Some(toast) = self.toasts.iter_mut().find(|toast| toast.id == id)
|
||||
&& toast.closing.is_none()
|
||||
{
|
||||
let now = Instant::now();
|
||||
toast.closing = Some(now);
|
||||
toast.motion.retarget(0.0, now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,10 +568,6 @@ impl Center {
|
||||
}
|
||||
|
||||
pub fn tick_subscription(&self) -> cosmic::iced::Subscription<()> {
|
||||
if self.animating() {
|
||||
return cosmic::iced::time::every(Duration::from_millis(16)).map(|_| ());
|
||||
}
|
||||
|
||||
fn deadline_stream(deadline: &Instant) -> impl cosmic::iced::futures::Stream<Item = ()> + use<> {
|
||||
let deadline = *deadline;
|
||||
cosmic::iced::futures::stream::once(async move {
|
||||
@@ -610,7 +623,7 @@ impl Center {
|
||||
self.close(id, 1);
|
||||
}
|
||||
self.toasts
|
||||
.retain(|toast| toast.closing.is_none() || toast.motion.animating());
|
||||
.retain(|toast| toast.closing.is_none() || (!self.history_open && 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));
|
||||
@@ -664,6 +677,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_toast_closes_do_not_wait_for_unavailable_frame_callbacks() {
|
||||
let (mut center, _) = fixture();
|
||||
center.receive(notice(1));
|
||||
center.toasts.front_mut().unwrap().motion.begin(Instant::now());
|
||||
center.history_open = true;
|
||||
center.close(1, 2);
|
||||
center.tick();
|
||||
assert!(center.toasts.is_empty());
|
||||
assert!(center.expiry_deadline().is_none());
|
||||
assert!(!center.frame_active(Instant::now()));
|
||||
assert_eq!(center.entries.len(), 1, "history retains the notice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_toast_can_finish_while_another_toast_is_still_moving() {
|
||||
let (mut center, _) = fixture();
|
||||
center.receive(notice(1));
|
||||
let old = Instant::now() - Duration::from_secs(3);
|
||||
center.toasts.front_mut().unwrap().motion.retarget(0.0, old);
|
||||
center.toasts.front_mut().unwrap().closing = Some(old);
|
||||
center.receive(notice(2));
|
||||
let now = Instant::now();
|
||||
assert!(center.frame_active(now));
|
||||
assert!(center.has_finished_closes(now));
|
||||
center.tick();
|
||||
assert!(!center.has_finished_closes(Instant::now()));
|
||||
assert_eq!(center.toasts.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_groups_expand_and_clear_without_removing_other_apps() {
|
||||
let (mut center, commands) = fixture();
|
||||
|
||||
@@ -131,12 +131,31 @@ impl OpenMenu {
|
||||
|
||||
impl FereseShell {
|
||||
fn view_status_menu(&self) -> Element<'_, cosmic::Action<Message>> {
|
||||
let Some(menu) = &self.menu else { return text("").into() };
|
||||
motion::frame_driven(
|
||||
menu.motion.revision(),
|
||||
|now| self.view_status_menu_at(now),
|
||||
|now| menu.motion.frame_active(now),
|
||||
|now| {
|
||||
if let Some(effects) = &menu.effects {
|
||||
let _ = effects.set_regions(&menu.regions.lock().unwrap());
|
||||
let _ = effects.set_opacity(menu.motion.progress_at(now).clamp(0.0, 1.0));
|
||||
}
|
||||
},
|
||||
|now| {
|
||||
(menu.motion.closing() && !menu.motion.animating_at(now))
|
||||
.then_some(cosmic::Action::App(Message::AnimateMenu))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn view_status_menu_at(&self, now: Instant) -> Element<'_, cosmic::Action<Message>> {
|
||||
let Some(menu) = &self.menu else {
|
||||
return text("").into();
|
||||
};
|
||||
if menu.kind == Menu::Notifications && self.notifications.ready {
|
||||
return cosmic::widget::autosize::autosize(
|
||||
self.view_notifications(),
|
||||
self.view_notifications_at(now),
|
||||
cosmic::iced::advanced::widget::Id::new("ferese-notification-center"),
|
||||
)
|
||||
.limits(
|
||||
@@ -229,7 +248,7 @@ impl FereseShell {
|
||||
cosmic::widget::autosize::autosize(
|
||||
super::motion::animated(
|
||||
panel.into(),
|
||||
menu.progress(),
|
||||
menu.motion.progress_at(now),
|
||||
menu.regions.clone(),
|
||||
theme.material_radius,
|
||||
),
|
||||
|
||||
@@ -481,6 +481,7 @@ pub(super) struct EffectsBinding {
|
||||
_queue: EventQueue<EffectsState>,
|
||||
regions: std::cell::RefCell<Option<Vec<[f32; 5]>>>,
|
||||
opacity: std::cell::Cell<Option<u32>>,
|
||||
region_opacities: std::cell::RefCell<Vec<u32>>,
|
||||
}
|
||||
|
||||
impl EffectsBinding {
|
||||
@@ -504,7 +505,7 @@ impl EffectsBinding {
|
||||
let connection = Connection::from_backend(backend);
|
||||
let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?;
|
||||
let qh = queue.handle();
|
||||
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 3..=3, ())?;
|
||||
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 4..=4, ())?;
|
||||
let effects = manager.get_surface_effects(surface, &qh, ());
|
||||
|
||||
let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
|
||||
@@ -523,9 +524,28 @@ impl EffectsBinding {
|
||||
_queue: queue,
|
||||
regions: Default::default(),
|
||||
opacity: std::cell::Cell::new(Some(opacity)),
|
||||
region_opacities: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn set_region_opacities(
|
||||
&self,
|
||||
values: impl IntoIterator<Item = f32>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let values: Vec<u32> = values
|
||||
.into_iter()
|
||||
.take(32)
|
||||
.map(|value| (value.clamp(0.0, 1.0) * 1000.0).round() as u32)
|
||||
.collect();
|
||||
if *self.region_opacities.borrow() != values {
|
||||
self.surface
|
||||
.set_region_opacities(values.iter().flat_map(|value| value.to_ne_bytes()).collect());
|
||||
self.connection.flush()?;
|
||||
*self.region_opacities.borrow_mut() = values;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn set_regions(&self, regions: &[[f32; 5]]) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.set_material_regions(regions, ferese_surface_effects_v1::Role::Popover)
|
||||
}
|
||||
|
||||
@@ -468,7 +468,32 @@ impl FereseShell {
|
||||
let Some(surface) = modal.surfaces.iter().find(|surface| surface.id == id) else {
|
||||
return text("").into();
|
||||
};
|
||||
let progress = modal.motion.progress();
|
||||
motion::frame_driven(
|
||||
modal.motion.revision(),
|
||||
move |now| self.view_system_modal_at(id, now),
|
||||
|now| modal.motion.frame_active(now),
|
||||
|now| {
|
||||
if let Some(effects) = &surface.effects {
|
||||
let _ = effects
|
||||
.set_material_regions(&surface.regions.lock().unwrap(), ferese_surface_effects_v1::Role::Modal);
|
||||
let _ = effects.set_opacity(modal.motion.progress_at(now).clamp(0.0, 1.0));
|
||||
}
|
||||
},
|
||||
|now| {
|
||||
(surface.primary && modal.motion.closing() && !modal.motion.animating_at(now))
|
||||
.then_some(cosmic::Action::App(Message::AnimatePower))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn view_system_modal_at(&self, id: window::Id, now: Instant) -> Element<'_, cosmic::Action<Message>> {
|
||||
let Some(modal) = &self.system_modal else {
|
||||
return text("").into();
|
||||
};
|
||||
let Some(surface) = modal.surfaces.iter().find(|surface| surface.id == id) else {
|
||||
return text("").into();
|
||||
};
|
||||
let progress = modal.motion.progress_at(now);
|
||||
let content: Element<'_, cosmic::Action<Message>> = if surface.primary {
|
||||
let theme = self.config.theme;
|
||||
let material = surface.effects.is_some();
|
||||
|
||||
Reference in new issue
Block a user