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

1069 lines
34 KiB
Rust

mod frames;
pub(crate) use frames::{ContentMeasurement, frame_driven, geometry_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};
pub type Regions = std::sync::Arc<std::sync::Mutex<Vec<[f32; 5]>>>;
pub(crate) use ferese_animation::MotionSettings as Settings;
use ferese_animation::{AnimatedValue, SpringConfig};
pub(crate) struct PopupMotion {
value: AnimatedValue,
started: Option<std::time::Instant>,
settings: Settings,
}
impl PopupMotion {
pub(crate) fn update_settings(&mut self, settings: Settings) {
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);
}
}
pub(crate) fn new(settings: Settings) -> Self {
Self {
value: AnimatedValue {
current: 0.0,
target: 1.0,
velocity: 0.0,
},
started: None,
settings,
}
}
pub(crate) fn begin(&mut self, now: std::time::Instant) {
if self.started.is_none() {
self.started = Some(now);
}
}
fn sample(&self, now: std::time::Instant) -> AnimatedValue {
let mut value = self.value;
let Some(started) = self.started else {
return value;
};
if !self.settings.motion_enabled() {
value.snap();
return value;
}
let spring = self
.settings
.spring_config()
.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
}
#[cfg(test)]
pub(crate) fn progress(&self) -> f32 {
self.progress_at(std::time::Instant::now())
}
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 {
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
}
pub(crate) fn retarget(&mut self, target: f32, now: std::time::Instant) {
self.value = self.sample(now);
self.value.set_target(f64::from(target));
self.started = Some(now);
}
}
/// Animate paint only; the original button retains its layout, hit target,
/// keyboard handling, pressed state, and accessibility identity.
pub(crate) fn button<'a, M: Clone + 'a>(
render: crate::rendering::Rendering,
button: cosmic::widget::button::Button<'a, M>,
foreground: cosmic::iced::Color,
selected: bool,
opacity: f32,
) -> Element<'a, M> {
let paint = move |progress: f32, pressed: bool| {
ferese_theme::controls::shell_button(
foreground,
selected,
opacity,
render.radius(14.),
progress,
pressed,
)
};
painted_button(render, button, paint)
}
pub(crate) fn circular_button<'a, M: Clone + 'a>(
render: crate::rendering::Rendering,
button: cosmic::widget::button::Button<'a, M>,
foreground: cosmic::iced::Color,
fill: Option<cosmic::iced::Color>,
opacity: f32,
diameter: f32,
) -> Element<'a, M> {
let paint = move |progress: f32, pressed: bool| {
let mut style = ferese_theme::controls::shell_button(
foreground,
false,
opacity,
diameter / 2.0,
progress,
pressed,
);
style.shape = Some(cosmic::iced::border::Shape::Circular);
if let Some(fill) = fill {
style.background = Some(
cosmic::iced::Color {
a: opacity * if pressed { 0.75 } else { 1.0 - 0.1 * progress },
..fill
}
.into(),
);
}
style
};
painted_button(render, button, paint)
}
fn painted_button<'a, M: Clone + 'a>(
render: crate::rendering::Rendering,
button: cosmic::widget::button::Button<'a, M>,
paint: impl Fn(f32, bool) -> cosmic::widget::button::Style + Copy + 'static,
) -> Element<'a, M> {
let progress = std::rc::Rc::new(std::cell::Cell::new(0.0));
let active = progress.clone();
let hovered = progress.clone();
let content = button
.class(cosmic::theme::Button::Custom {
active: Box::new(move |_, _| paint(active.get(), false)),
hovered: Box::new(move |_, _| paint(hovered.get(), false)),
pressed: Box::new(move |_, _| paint(1.0, true)),
disabled: Box::new(move |_| paint(0.0, false)),
})
.into();
Element::new(Hover {
content,
progress,
duration: render.motion.duration(120.0),
})
}
#[derive(Default)]
struct HoverState {
current: f32,
start: f32,
target: f32,
started: Option<std::time::Instant>,
}
impl HoverState {
fn advance(
&mut self,
target: f32,
now: std::time::Instant,
duration: std::time::Duration,
) -> bool {
if self.target != target {
self.start = self.current;
self.target = target;
self.started = Some(now);
}
let t = if duration.is_zero() {
1.0
} else {
self.started.map_or(1.0, |start| {
(now.saturating_duration_since(start).as_secs_f32() / duration.as_secs_f32())
.min(1.0)
})
};
self.current = self.start + (self.target - self.start) * t * t * (3.0 - 2.0 * t);
if t == 1.0 {
self.current = target;
self.started = None;
}
self.current != target
}
}
struct Hover<'a, M> {
content: Element<'a, M>,
progress: std::rc::Rc<std::cell::Cell<f32>>,
duration: std::time::Duration,
}
impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
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 tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<HoverState>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(HoverState::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 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 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();
}
self.progress.set(state.current);
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut cosmic::Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.progress
.set(tree.state.downcast_ref::<HoverState>().current);
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<'a>(
&'a mut self,
tree: &'a mut widget::Tree,
layout: Layout<'a>,
renderer: &cosmic::Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<cosmic::iced::advanced::overlay::Element<'a, M, Theme, cosmic::Renderer>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
pub fn animated<'a, M: 'a>(
content: Element<'a, M>,
progress: f32,
regions: Regions,
radius: f32,
) -> Element<'a, M> {
animated_from_edge(content, progress, regions, radius, ferese_panel::Edge::Top)
}
pub fn animated_from_edge<'a, M: 'a>(
content: Element<'a, M>,
progress: f32,
regions: Regions,
radius: f32,
edge: ferese_panel::Edge,
) -> Element<'a, M> {
Element::new(Motion {
content,
progress,
regions: Some(regions),
radius,
scale: 1.,
direction: if edge == ferese_panel::Edge::Top {
-1.
} else {
1.
},
})
}
pub fn scaled<'a, M: 'a>(content: Element<'a, M>, scale: f32, regions: Regions) -> Element<'a, M> {
Element::new(Motion {
content,
progress: 1.,
regions: Some(regions),
radius: 28.,
direction: 0.,
scale,
})
}
struct Motion<'a, M> {
content: Element<'a, M>,
progress: f32,
regions: Option<Regions>,
radius: f32,
direction: f32,
scale: f32,
}
impl<M> Motion<'_, M> {
fn translation(&self) -> Vector {
Vector::new(0.0, self.direction * 4.0 * (1.0 - self.progress))
}
fn cursor(&self, layout: Layout<'_>, cursor: mouse::Cursor) -> mouse::Cursor {
cursor.position().map_or(mouse::Cursor::Unavailable, |p| {
mouse::Cursor::Available(
layout.bounds().center()
+ (p - layout.bounds().center() - self.translation()) / self.scale,
)
})
}
}
impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
fn a11y_nodes(
&self,
layout: Layout<'_>,
tree: &widget::Tree,
cursor: mouse::Cursor,
) -> iced_accessibility::A11yTree {
let mut nodes =
self.content
.as_widget()
.a11y_nodes(layout, tree, self.cursor(layout, cursor));
let offset = self.translation();
for node in nodes.root_mut() {
transform_accessible_node(node, offset, layout.bounds().center(), self.scale);
}
for node in nodes.children_mut() {
transform_accessible_node(node, offset, layout.bounds().center(), self.scale);
}
nodes
}
fn tag(&self) -> widget::tree::Tag {
self.content.as_widget().tag()
}
fn state(&self) -> widget::tree::State {
self.content.as_widget().state()
}
fn children(&self) -> Vec<widget::Tree> {
self.content.as_widget().children()
}
fn diff(&mut self, tree: &mut widget::Tree) {
self.content.as_widget_mut().diff(tree);
}
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(tree, renderer, limits)
}
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,
) {
if let Some(regions) = &self.regions {
let mut collected = regions.lock().unwrap();
collected.clear();
self.content.as_widget_mut().operate(
tree,
layout,
renderer,
&mut ReuseRegions(&mut collected),
);
if collected.is_empty()
&& let Some(content) = layout.children().next()
{
let bounds = content.bounds();
collected.push([bounds.x, bounds.y, bounds.width, bounds.height, 0.]);
}
transform_regions(
&mut collected,
layout.bounds().center(),
self.scale,
self.translation(),
self.radius,
);
}
let cursor = self.cursor(layout, cursor);
self.content.as_widget_mut().update(
tree, event, layout, cursor, renderer, clipboard, shell, viewport,
);
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut cosmic::Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
let v = self.translation();
let center = layout.bounds().center();
let transform = Transformation::translate(center.x + v.x, center.y + v.y)
* Transformation::scale(self.scale)
* Transformation::translate(-center.x, -center.y);
// Layout/region collection still run before the surface starts opening.
// No child may paint an opaque control before the presentation begins.
if self.progress <= 0.0 {
return;
}
renderer.with_transformation(transform, |renderer| {
self.content.as_widget().draw(
tree,
renderer,
theme,
style,
layout,
self.cursor(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,
layout,
self.cursor(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(tree, layout, renderer, operation);
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut widget::Tree,
layout: Layout<'a>,
renderer: &cosmic::Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<cosmic::iced::advanced::overlay::Element<'a, M, Theme, cosmic::Renderer>> {
let translation = translation + self.translation();
self.content
.as_widget_mut()
.overlay(tree, layout, renderer, viewport, translation)
}
}
fn transform_accessible_node(
node: &mut iced_accessibility::A11yNode,
offset: Vector,
center: cosmic::iced::Point,
scale: f32,
) {
if let Some(mut bounds) = node.node().bounds() {
let x = f64::from(center.x);
let y = f64::from(center.y);
bounds.x0 = x + (bounds.x0 - x) * f64::from(scale) + f64::from(offset.x);
bounds.x1 = x + (bounds.x1 - x) * f64::from(scale) + f64::from(offset.x);
bounds.y0 = y + (bounds.y0 - y) * f64::from(scale) + f64::from(offset.y);
bounds.y1 = y + (bounds.y1 - y) * f64::from(scale) + f64::from(offset.y);
node.node_mut().set_bounds(bounds);
}
}
fn transform_regions(
regions: &mut [[f32; 5]],
center: cosmic::iced::Point,
scale: f32,
offset: Vector,
radius: f32,
) {
for region in regions {
region[0] = center.x + (region[0] - center.x) * scale + offset.x;
region[1] = center.y + (region[1] - center.y) * scale + offset.y;
region[2] *= scale;
region[3] *= scale;
region[4] = radius.max(0.) * scale;
}
}
struct ReuseRegions<'a>(&'a mut Vec<[f32; 5]>);
impl widget::Operation for ReuseRegions<'_> {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) {
children(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id != Some(&widget::Id::new("ferese-blur-card"))
|| bounds.width <= 0.
|| bounds.height <= 0.
{
return;
}
let r = [bounds.x, bounds.y, bounds.width, bounds.height, 0.];
let contains = |outer: &[f32; 5], inner: &[f32; 5]| {
outer[0] <= inner[0]
&& outer[1] <= inner[1]
&& outer[0] + outer[2] >= inner[0] + inner[2]
&& outer[1] + outer[3] >= inner[1] + inner[3]
};
if self.0.iter().any(|outer| contains(outer, &r)) {
return;
}
self.0.retain(|inner| !contains(&r, inner));
if self.0.len() < 32 {
self.0.push(r);
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn motion_preserves_accessible_controls_and_translates_tooltip_overlays() {
use super::*;
use cosmic::iced::advanced::clipboard;
use cosmic::iced::advanced::renderer::Headless;
use cosmic::iced::{Font, Pixels, Point};
let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let viewport = Rectangle::with_size(Size::new(400., 300.));
for edge in [ferese_panel::Edge::Top, ferese_panel::Edge::Bottom] {
let make_content = || {
// The toolkit tooltip itself does not expose an accessibility
// tree. A sibling button verifies our wrapper's forwarding.
cosmic::widget::row::with_capacity(2)
.push(cosmic::widget::tooltip(
cosmic::widget::Space::new().width(100).height(40),
cosmic::widget::text("Tooltip"),
cosmic::widget::tooltip::Position::Bottom,
))
.push(
cosmic::widget::button::custom(cosmic::widget::text("Action"))
.name("Accessible action")
.on_press(())
.width(100)
.height(40),
)
};
let mut plain: Element<'_, ()> = make_content().into();
let mut plain_tree = widget::Tree::new(&plain);
let plain_node = plain.as_widget_mut().layout(
&mut plain_tree,
&renderer,
&layout::Limits::new(Size::ZERO, viewport.size()),
);
let plain_a11y = plain.as_widget().a11y_nodes(
Layout::new(&plain_node),
&plain_tree,
mouse::Cursor::Unavailable,
);
assert!(!plain_a11y.root().is_empty());
let mut wrapped =
animated_from_edge(make_content().into(), 0.5, Default::default(), 10., edge);
let mut tree = widget::Tree::new(&wrapped);
let node = wrapped.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, viewport.size()),
);
let offset = if edge == ferese_panel::Edge::Top {
-2.
} else {
2.
};
let accessible = wrapped.as_widget().a11y_nodes(
Layout::new(&node),
&tree,
mouse::Cursor::Unavailable,
);
assert_eq!(accessible.root().len(), plain_a11y.root().len());
for (actual, expected) in accessible
.root()
.iter()
.chain(accessible.children())
.zip(plain_a11y.root().iter().chain(plain_a11y.children()))
{
assert_eq!(actual.node().label(), expected.node().label());
if let Some(bounds) = expected.node().bounds() {
let actual = actual.node().bounds().unwrap();
assert_eq!(actual.x0, bounds.x0);
assert_eq!(actual.y0, bounds.y0 + offset);
}
}
let cursor = mouse::Cursor::Available(Point::new(50., 20.));
let event = Event::Mouse(mouse::Event::CursorMoved {
position: Point::new(50., 20.),
});
let mut messages = Vec::new();
plain.as_widget_mut().update(
&mut plain_tree,
&event,
Layout::new(&plain_node),
cursor,
&renderer,
&mut clipboard::Null,
&mut Shell::new(&mut messages),
&viewport,
);
wrapped.as_widget_mut().update(
&mut tree,
&event,
Layout::new(&node),
cursor,
&renderer,
&mut clipboard::Null,
&mut Shell::new(&mut messages),
&viewport,
);
let mut plain_overlay = plain
.as_widget_mut()
.overlay(
&mut plain_tree,
Layout::new(&plain_node),
&renderer,
&viewport,
Vector::new(40., 80.),
)
.expect("tooltip opens");
let mut overlay = wrapped
.as_widget_mut()
.overlay(
&mut tree,
Layout::new(&node),
&renderer,
&viewport,
Vector::new(40., 80.),
)
.expect("motion forwards tooltip");
let plain_layout = plain_overlay
.as_overlay_mut()
.layout(&renderer, viewport.size());
let overlay_layout = overlay.as_overlay_mut().layout(&renderer, viewport.size());
fn tooltip_bounds(node: &layout::Node) -> Rectangle {
// Tooltip and row each return an overlay group; descend past
// full-viewport group nodes to the positioned tooltip root.
let mut node = node;
while let [child] = node.children() {
if node.bounds().position() != cosmic::iced::Point::ORIGIN {
break;
}
node = child;
}
node.bounds()
}
let plain_bounds = tooltip_bounds(&plain_layout);
let bounds = tooltip_bounds(&overlay_layout);
assert!(plain_bounds.y > 0.);
assert_eq!(bounds.x, plain_bounds.x);
assert_eq!(bounds.y, plain_bounds.y + offset as f32);
}
}
#[test]
fn duration_bounce_spring_finishes_popup_close() {
let settings = super::Settings {
spring: ferese_animation::SpringSettings {
duration_ms: Some(240.0),
bounce: 0.3,
overshoot: true,
},
..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]
fn material_regions_preserve_fractional_layout_and_translation() {
let mut regions = vec![[0.25, 2.5, 100.25, 40.5, 0.]];
transform_regions(
&mut regions,
cosmic::iced::Point::ORIGIN,
1.,
Vector::new(0.125, -0.25),
13.5,
);
assert_eq!(regions, vec![[0.375, 2.25, 100.25, 40.5, 13.5]]);
}
#[test]
fn centered_modal_material_tracks_surface_position_and_animation() {
let mut regions = Vec::new();
let mut collector = ReuseRegions(&mut regions);
let card = Rectangle::new((380.0, 420.0).into(), (440.0, 220.0).into());
widget::Operation::container(
&mut collector,
Some(&widget::Id::new("ferese-blur-card")),
card,
);
transform_regions(
&mut regions,
cosmic::iced::Point::ORIGIN,
1.,
Vector::new(0.0, -2.0),
14.0,
);
assert_eq!(regions, vec![[380., 418., 440., 220., 14.]]);
}
#[test]
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);
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_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]
fn popup_reversal_and_motion_policy_are_consistent() {
let now = std::time::Instant::now();
let mut motion = PopupMotion::new(Settings {
speed: 0.75,
..Settings::default()
});
motion.begin(now);
let reverse = now + std::time::Duration::from_millis(120);
let before = motion.progress_at(reverse);
motion.retarget(0.0, reverse);
assert_eq!(motion.progress_at(reverse), before);
let reopen = reverse + std::time::Duration::from_millis(60);
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(2)),
1.0
);
for settings in [
Settings {
reduced_motion: true,
..Settings::default()
},
Settings {
enabled: false,
..Settings::default()
},
] {
let mut motion = PopupMotion::new(settings);
assert_eq!(motion.progress_at(now), 0.0);
motion.begin(now);
assert_eq!(motion.progress_at(now), 1.0);
motion.retarget(0.0, now);
assert_eq!(motion.progress_at(now), 0.0);
}
}
#[test]
fn updating_motion_policy_preserves_surface_readiness() {
let mut motion = PopupMotion::new(Settings::default());
motion.update_settings(Settings {
reduced_motion: true,
..Settings::default()
});
assert_eq!(motion.progress(), 0.0);
motion.begin(std::time::Instant::now());
assert_eq!(motion.progress(), 1.0);
}
#[test]
fn hover_fades_settle_and_idle_does_not_schedule_frames() {
let now = std::time::Instant::now();
let duration = std::time::Duration::from_millis(120);
let mut hover = HoverState::default();
assert!(!hover.advance(0.0, now, duration));
assert!(hover.advance(1.0, now + std::time::Duration::from_secs(10), duration));
assert_eq!(hover.current, 0.0);
let start = now + std::time::Duration::from_secs(10);
assert!(hover.advance(1.0, start + duration / 2, duration));
assert!((hover.current - 0.5).abs() < 0.00001);
assert!(!hover.advance(1.0, start + duration, duration));
assert_eq!(hover.current, 1.0);
assert!(!hover.advance(1.0, start + duration * 10, duration));
assert!(hover.advance(0.0, start + duration * 11, duration));
assert!(!hover.advance(0.0, start + duration * 12, duration));
assert_eq!(hover.current, 0.0);
}
#[test]
fn hover_reversals_preserve_current_paint() {
let now = std::time::Instant::now();
let duration = std::time::Duration::from_millis(120);
let mut hover = HoverState::default();
hover.advance(1.0, now, duration);
hover.advance(1.0, now + duration / 2, duration);
let before = hover.current;
assert!(hover.advance(0.0, now + duration / 2, duration));
assert_eq!(hover.current, before);
hover.advance(0.0, now + duration, duration);
assert!(hover.current > 0.0 && hover.current < before);
}
#[test]
fn hover_obeys_global_motion_policy() {
assert_eq!(
Settings {
speed: 0.75,
..Settings::default()
}
.duration(120.0),
std::time::Duration::from_millis(160)
);
for settings in [
Settings {
enabled: false,
..Settings::default()
},
Settings {
reduced_motion: true,
..Settings::default()
},
] {
assert!(settings.duration(120.0).is_zero());
let mut hover = HoverState::default();
assert!(!hover.advance(1.0, std::time::Instant::now(), settings.duration(120.0)));
assert_eq!(hover.current, 1.0);
}
}
#[test]
fn material_regions_include_cards_but_not_the_parent_or_gaps() {
let mut regions = Vec::new();
let mut collector = ReuseRegions(&mut regions);
let id = widget::Id::new("ferese-blur-card");
widget::Operation::container(
&mut collector,
None,
Rectangle::new((0.0, 0.0).into(), (320.0, 200.0).into()),
);
let first = Rectangle::new((12.0, 12.0).into(), (140.0, 80.0).into());
let second = Rectangle::new((160.0, 12.0).into(), (140.0, 80.0).into());
widget::Operation::container(&mut collector, Some(&id), first);
widget::Operation::container(&mut collector, Some(&id), second);
assert_eq!(
*collector.0,
vec![[12., 12., 140., 80., 0.], [160., 12., 140., 80., 0.]]
);
widget::Operation::container(
&mut collector,
Some(&id),
Rectangle::new((20.0, 20.0).into(), (40.0, 40.0).into()),
);
assert_eq!(
*collector.0,
vec![[12., 12., 140., 80., 0.], [160., 12., 140., 80., 0.]]
);
}
}