Files
feresaria/shell/ferese-shell/src/motion/frames.rs
T

474 lines
16 KiB
Rust

//! Surface-local animation sampling. Iced's Wayland backend gates NextFrame
//! redraws on that surface's frame callback; no animation timer is needed.
#[cfg(test)]
use crate::motion::Settings;
use super::{
Clipboard, Element, Event, Layout, Length, Rectangle, Shell, Size, Theme, Vector, Widget,
layout, mouse, renderer, widget,
};
use std::time::Instant;
pub(crate) struct ContentMeasurement<'a, M> {
pub content: Element<'a, M>,
pub width: f32,
pub measured: Box<dyn Fn(Size) + 'a>,
}
pub(crate) fn geometry_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,
measurement: Option<ContentMeasurement<'a, M>>,
) -> Element<'a, M> {
driven(
revision,
build,
active,
after_frame,
completion,
true,
measurement,
)
}
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> {
driven(
revision,
build,
active,
after_frame,
completion,
false,
None,
)
}
fn 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,
geometry_changes: bool,
measurement: Option<ContentMeasurement<'a, M>>,
) -> Element<'a, M> {
let now = Instant::now();
let content = build(now);
let content_active = active(now);
Element::new(FrameDriven {
content,
measurement,
measured: false,
geometry_changes,
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,
pending: bool,
limits: Option<layout::Limits>,
}
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);
self.pending = true;
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>,
measurement: Option<ContentMeasurement<'a, M>>,
measured: bool,
content_active: bool,
geometry_changes: 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> {
std::iter::once(widget::Tree::new(&self.content))
.chain(
self.measurement
.iter()
.map(|m| widget::Tree::new(&m.content)),
)
.collect()
}
fn diff(&mut self, tree: &mut widget::Tree) {
tree.children
.truncate(1 + usize::from(self.measurement.is_some()));
tree.children[0].diff(&mut self.content);
if let Some(measurement) = &mut self.measurement {
if tree.children.len() == 1 {
tree.children.push(widget::Tree::new(&measurement.content));
} else {
tree.children[1].diff(&mut measurement.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 {
tree.state.downcast_mut::<FrameState>().limits = Some(*limits);
if !self.measured {
if let Some(measurement) = &mut self.measurement {
// Measure at the final width once, so spring overshoot never changes wrapping or its target.
let probe_limits =
layout::Limits::new(Size::ZERO, Size::new(measurement.width, f32::INFINITY));
let node = measurement.content.as_widget_mut().layout(
&mut tree.children[1],
renderer,
&probe_limits,
);
(measurement.measured)(node.size());
let now = Instant::now();
self.content = (self.build)(now);
self.content_active = (self.active)(now);
tree.children[0].diff(&mut self.content);
}
self.measured = true;
}
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
};
let mut updated_geometry = false;
let mut fresh_layout = None;
if let Some(at) = redraw {
// Preserve focus and scroll state while sampling the current frame.
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);
if self.geometry_changes {
self.content_active = (self.active)(at);
let limits = tree
.state
.downcast_ref::<FrameState>()
.limits
.expect("laid-out animation");
fresh_layout = Some(
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, &limits)
.move_to(layout.position()),
);
updated_geometry = true;
}
}
self.content_active = (self.active)(at);
}
// Iced revalidates the base layout without replaying the event. Publish
// the measured shape and run the child against this frame's layout now.
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
fresh_layout.as_ref().map_or(layout, Layout::new),
cursor,
renderer,
clipboard,
shell,
viewport,
);
if updated_geometry {
shell.invalidate_layout();
}
if let Event::Window(cosmic::iced::window::Event::RedrawRequested(at)) = event {
let active = (self.active)(*at);
if active {
shell.request_redraw();
}
// Complete only after a valid layout, including the replay of this frame.
if std::mem::take(&mut tree.state.downcast_mut::<FrameState>().pending) {
(self.after_frame)(*at);
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 crate::motion::PopupMotion;
use cosmic::iced::advanced::Renderer;
use cosmic::iced::advanced::renderer::Headless;
use cosmic::iced::window::{Event as WindowEvent, RedrawRequest};
use std::cell::RefCell;
use std::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));
}
}