Files
feresaria/compositor/src/presentation.rs
T
Samuel Onoja d75c93db85 Match shell squircle outlines with compositor masks
Use shared corner geometry for shell controls, window clipping, materials and blur masks. Preserve circular pills and controls where appropriate, track contour changes in damage, and use tagged ferese-shape and libcosmic dependencies.
2026-10-04 07:39:54 +01:00

735 lines
26 KiB
Rust

//! Presentation geometry is rounded once, in output pixels, never first in
//! logical pixels. Content, clips and decorations share those exact edges.
use std::time::{Duration, Instant};
use smithay::backend::renderer::element::{Element, Id, Kind, RenderElement, RenderElementStates};
use smithay::backend::renderer::gles::{GlesError, GlesFrame, GlesRenderer, GlesTexProgram, GlesTexture, Uniform};
use smithay::backend::renderer::utils::{CommitCounter, DamageSet};
use smithay::desktop::utils::OutputPresentationFeedback;
use smithay::output::Output;
use smithay::reexports::wayland_protocols::wp::presentation_time::server::wp_presentation_feedback;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::utils::{Buffer, Logical, Physical, Point, Rectangle, Scale, Transform};
use smithay::wayland::compositor::SurfaceData;
use crate::render::SharedPixelShaderElement;
pub(crate) const HANDOFF: Duration = Duration::from_millis(80);
pub(crate) const SNAPSHOT_BUDGET: usize = 64 * 1024 * 1024;
/// One window identity and motion sample, regardless of whether its pixels
/// come from a live surface, a resize snapshot, or a retained close image.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct WindowPresentation {
pub id: ferese_layout::WindowId,
pub bounds: ferese_animation::AnimatedRect,
pub opacity: ferese_animation::AnimatedValue,
pub emphasis: ferese_animation::AnimatedValue,
pub shadow: ferese_animation::AnimatedValue,
pub scale_content: bool,
pub native_size: Option<ferese_animation::ClientSize>,
}
impl WindowPresentation {
pub fn alpha(self) -> f32 {
self.opacity.current.clamp(0.0, 1.0) as f32
}
pub fn focus(self) -> f64 {
self.emphasis.current.clamp(0.0, 1.0)
}
/// A thumbnail is another view of the same content and lifecycle state.
pub fn thumbnail(mut self, rect: ferese_layout::Rect) -> Self {
self.bounds = ferese_animation::AnimatedRect::new(rect);
self.scale_content = true;
self.native_size = None;
self
}
pub fn close(&mut self) {
self.bounds.set_target(scaled_visual_rect(self.bounds.current, 0.98));
self.opacity.set_target(0.0);
self.emphasis.set_target(0.0);
self.shadow.set_target(0.0);
self.scale_content = true;
self.native_size = None;
}
/// Shadow intensity and elevation may respond independently; its outline
/// always follows the window's shared rounded geometry.
pub fn shadow_factors(self) -> (f64, f64, f64) {
let strength = self.shadow.current.clamp(0.0, 1.0);
(0.75 + 0.25 * strength, 0.9 + 0.1 * strength, 0.8 + 0.2 * strength)
}
}
// Response multipliers preserve damping ratio and the user's crossing policy.
// Emphasis responds sooner; the shadow settles a little later than the bounds.
pub(crate) fn emphasis_spring(base: ferese_animation::SpringConfig) -> ferese_animation::SpringConfig {
visual_response(base, 0.8)
}
pub(crate) fn shadow_spring(base: ferese_animation::SpringConfig) -> ferese_animation::SpringConfig {
visual_response(base, 1.2)
}
fn visual_response(base: ferese_animation::SpringConfig, response: f64) -> ferese_animation::SpringConfig {
ferese_animation::SpringConfig {
stiffness: base.stiffness / (response * response),
damping: base.damping / response,
position_tolerance: 0.001,
velocity_tolerance: 0.001,
..base
}
}
pub(crate) fn take_output_feedback(
state: &crate::Ferese,
output: &Output,
rendered: &RenderElementStates,
flags: wp_presentation_feedback::Kind,
) -> OutputPresentationFeedback {
use smithay::desktop::layer_map_for_output;
use smithay::desktop::utils::{OutputPresentationFeedback, surface_presentation_feedback_flags_from_states};
let mut feedback = OutputPresentationFeedback::new(output);
let visible_output =
|surface: &WlSurface, _: &SurfaceData| rendered_feedback_output(output, surface.into(), rendered);
let surface_flags = |surface: &WlSurface, _: &SurfaceData| {
flags | surface_presentation_feedback_flags_from_states(surface, rendered)
};
for window in state.space.elements().filter(|window| {
state
.windows
.ids()
.get(*window)
.is_some_and(|id| state.window_belongs_to_output(*id, output))
}) {
window.take_presentation_feedback(&mut feedback, visible_output, surface_flags);
}
for layer in layer_map_for_output(output).layers() {
layer.take_presentation_feedback(&mut feedback, visible_output, surface_flags);
}
feedback
}
fn rendered_feedback_output(output: &Output, id: Id, rendered: &RenderElementStates) -> Option<Output> {
rendered
.element_render_state(id)
.is_some_and(crate::display_presentation::is_visible)
.then(|| output.clone())
}
pub(crate) fn frame_delta(last: &mut Instant, now: Instant) -> Duration {
let delta = now.saturating_duration_since(*last);
*last = (*last).max(now);
delta
}
pub(crate) fn physical_rect(
rect: ferese_layout::Rect,
origin: Point<i32, Logical>,
scale: f64,
) -> Rectangle<i32, Physical> {
let left = ((rect.x - f64::from(origin.x)) * scale).round() as i32;
let top = ((rect.y - f64::from(origin.y)) * scale).round() as i32;
let right = ((rect.x + rect.width - f64::from(origin.x)) * scale).round() as i32;
let bottom = ((rect.y + rect.height - f64::from(origin.y)) * scale).round() as i32;
Rectangle::new(
(left, top).into(),
((right - left).max(1), (bottom - top).max(1)).into(),
)
}
pub(crate) use ferese_shape::Shape as CornerShape;
/// Conservative extent of the corner shoulders, in physical pixels.
pub(crate) fn corner_extent(radius: f32, size: smithay::utils::Size<i32, Physical>, shape: CornerShape) -> f32 {
if shape == CornerShape::Circular {
return radius;
}
(radius * 1.528_665).min(size.w.min(size.h) as f32 * 0.5)
}
/// A single physical outline shared by content and all of its decorations.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct RoundedRect {
pub rect: Rectangle<i32, Physical>,
pub radius: f32,
pub shape: CornerShape,
}
impl RoundedRect {
pub(crate) fn with_shape(mut self, shape: CornerShape) -> Self {
self.shape = shape;
self
}
pub(crate) fn new(rect: ferese_layout::Rect, origin: Point<i32, Logical>, scale: f64, radius: f64) -> Self {
let rect = physical_rect(rect, origin, scale);
Self {
radius: clamp_radius(radius * scale, rect.size),
rect,
shape: CornerShape::Circular,
}
}
pub(crate) fn from_logical(rect: Rectangle<i32, Logical>, scale: f64, radius: f64) -> Self {
Self::new(
ferese_layout::Rect::new(
rect.loc.x as f64,
rect.loc.y as f64,
rect.size.w as f64,
rect.size.h as f64,
),
(0, 0).into(),
scale,
radius,
)
}
}
pub(crate) fn clamp_radius(requested: f64, size: smithay::utils::Size<i32, Physical>) -> f32 {
requested.min(f64::from(size.w.min(size.h)) / 2.0).max(0.0) as f32
}
pub(crate) fn handoff_alpha(elapsed: Duration) -> f32 {
let progress = (elapsed.as_secs_f64() / HANDOFF.as_secs_f64()).clamp(0.0, 1.0);
(1.0 - progress * progress * (3.0 - 2.0 * progress)) as f32
}
pub(crate) fn resize_needs_old_frame(
visual: ferese_layout::Rect,
target: ferese_layout::Rect,
buffer_width: i32,
buffer_height: i32,
) -> bool {
// Some clients round their configured dimensions to a character grid.
// Do not retain a snapshot forever for that permanent size difference.
visual.width > target.width.max(f64::from(buffer_width)) + 0.5
|| visual.height > target.height.max(f64::from(buffer_height)) + 0.5
}
pub(crate) fn snapshot_covers_source(
snapshot: smithay::utils::Size<i32, Buffer>,
source: smithay::utils::Size<i32, Physical>,
snapshot_scale: f64,
scale: f64,
) -> bool {
(snapshot_scale - scale).abs() < 0.001 && snapshot.w >= source.w && snapshot.h >= source.h
}
pub(crate) fn advance_handoff(
elapsed: &mut Duration,
last: &mut Option<Duration>,
now: Duration,
blocked: bool,
speed: f64,
) -> bool {
if blocked {
// A retarget must not consume its client's waiting time as fade time.
*last = None;
} else {
if let Some(previous) = last.replace(now) {
*elapsed += now.saturating_sub(previous).mul_f64(speed);
}
}
*elapsed < HANDOFF
}
/// A shader canvas whose damage/paint destination uses the shared pixel edges.
#[derive(Clone, Debug)]
pub(crate) struct PhysicalShaderElement {
pub inner: SharedPixelShaderElement,
pub geometry: Rectangle<i32, Physical>,
}
impl Element for PhysicalShaderElement {
fn id(&self) -> &Id {
self.inner.id()
}
fn current_commit(&self) -> CommitCounter {
self.inner.current_commit()
}
fn src(&self) -> Rectangle<f64, Buffer> {
self.inner.src()
}
fn geometry(&self, _: Scale<f64>) -> Rectangle<i32, Physical> {
self.geometry
}
fn damage_since(&self, _: Scale<f64>, commit: Option<CommitCounter>) -> DamageSet<i32, Physical> {
if commit == Some(self.current_commit()) {
DamageSet::default()
} else {
DamageSet::from_slice(&[Rectangle::from_size(self.geometry.size)])
}
}
}
impl RenderElement<GlesRenderer> for PhysicalShaderElement {
fn draw(
&self,
frame: &mut GlesFrame<'_, '_>,
src: Rectangle<f64, Buffer>,
dst: Rectangle<i32, Physical>,
damage: &[Rectangle<i32, Physical>],
opaque: &[Rectangle<i32, Physical>],
) -> Result<(), GlesError> {
self.inner.draw(frame, src, dst, damage, opaque)
}
}
/// Stable native-pixel texture element. Unlike a resized client buffer, an old
/// frame is only translated/cropped; it never changes the size of its glyphs.
#[derive(Clone, Debug)]
pub(crate) struct NativeTextureElement {
pub id: Id,
pub commit: CommitCounter,
pub texture: GlesTexture,
pub geometry: Rectangle<i32, Physical>,
pub source: Rectangle<f64, Buffer>,
pub alpha: f32,
pub program: Option<GlesTexProgram>,
pub uniforms: Vec<Uniform<'static>>,
}
impl Element for NativeTextureElement {
fn id(&self) -> &Id {
&self.id
}
fn current_commit(&self) -> CommitCounter {
self.commit
}
fn src(&self) -> Rectangle<f64, Buffer> {
self.source
}
fn geometry(&self, _: Scale<f64>) -> Rectangle<i32, Physical> {
self.geometry
}
fn alpha(&self) -> f32 {
self.alpha
}
fn kind(&self) -> Kind {
Kind::Unspecified
}
fn damage_since(&self, _: Scale<f64>, commit: Option<CommitCounter>) -> DamageSet<i32, Physical> {
if commit == Some(self.commit) {
DamageSet::default()
} else {
DamageSet::from_slice(&[Rectangle::from_size(self.geometry.size)])
}
}
}
impl RenderElement<GlesRenderer> for NativeTextureElement {
fn draw(
&self,
frame: &mut GlesFrame<'_, '_>,
src: Rectangle<f64, Buffer>,
dst: Rectangle<i32, Physical>,
damage: &[Rectangle<i32, Physical>],
opaque: &[Rectangle<i32, Physical>],
) -> Result<(), GlesError> {
frame.render_texture_from_to(
&self.texture,
src,
dst,
damage,
opaque,
Transform::Normal,
self.alpha,
self.program.as_ref(),
&self.uniforms,
)
}
}
#[cfg(test)]
mod tests {
fn moving_presentation() -> WindowPresentation {
use ferese_animation::{AnimatedRect, AnimatedValue, RectVelocity};
WindowPresentation {
id: ferese_layout::WindowId(7),
bounds: AnimatedRect {
velocity: RectVelocity {
x: 150.0,
y: -90.0,
width: 20.0,
height: 5.0,
},
..AnimatedRect::new(ferese_layout::Rect::new(12.25, -4.5, 601.75, 399.25))
},
opacity: AnimatedValue {
current: 0.7,
target: 1.0,
velocity: 0.4,
},
emphasis: AnimatedValue {
current: 0.6,
target: 1.0,
velocity: 0.3,
},
shadow: AnimatedValue {
current: 0.4,
target: 1.0,
velocity: 0.2,
},
scale_content: false,
native_size: None,
}
}
#[test]
fn thumbnail_and_close_keep_window_identity_and_live_motion() {
let live = moving_presentation();
let thumbnail = live.thumbnail(ferese_layout::Rect::new(0.25, 8.5, 120.5, 80.25));
assert_eq!(thumbnail.id, live.id);
assert_eq!(thumbnail.opacity, live.opacity);
assert_eq!(thumbnail.emphasis, live.emphasis);
assert_eq!(thumbnail.shadow, live.shadow);
let mut closing = live;
closing.close();
assert_eq!(closing.id, live.id);
assert_eq!(closing.bounds.current, live.bounds.current);
assert_eq!(closing.bounds.velocity, live.bounds.velocity);
assert_eq!(closing.opacity.current, live.opacity.current);
assert_eq!(closing.opacity.velocity, live.opacity.velocity);
assert_eq!(closing.emphasis.velocity, live.emphasis.velocity);
assert_eq!(closing.shadow.velocity, live.shadow.velocity);
for scale in [1.0, 1.25, 1.5, 2.0] {
assert_eq!(
RoundedRect::new(live.bounds.current, (0, 0).into(), scale, 14.0),
RoundedRect::new(closing.bounds.current, (0, 0).into(), scale, 14.0)
);
}
}
#[test]
fn layered_responses_preserve_damping_and_settle_independently() {
use ferese_animation::{AnimatedValue, SpringConfig};
let base = SpringConfig::default();
let damping_ratio = |spring: SpringConfig| spring.damping / (2.0 * (spring.mass * spring.stiffness).sqrt());
for spring in [emphasis_spring(base), shadow_spring(base)] {
assert!((damping_ratio(spring) - damping_ratio(base)).abs() < 1e-12);
assert_eq!(spring.crossing, base.crossing);
}
let mut emphasis = AnimatedValue {
current: 0.0,
target: 1.0,
velocity: 0.0,
};
let mut shadow = emphasis;
let dt = Duration::from_millis(80);
emphasis.advance(dt, emphasis_spring(base));
shadow.advance(dt, shadow_spring(base));
assert!(emphasis.current > shadow.current);
let before = (emphasis.velocity, shadow.velocity);
emphasis.set_target(0.0);
shadow.set_target(0.0);
assert_eq!((emphasis.velocity, shadow.velocity), before);
emphasis.advance(Duration::from_secs(3), emphasis_spring(base));
shadow.advance(Duration::from_secs(3), shadow_spring(base));
assert!(!emphasis.is_animating());
assert!(!shadow.is_animating());
}
#[test]
fn rounded_outline_clamps_after_physical_snapping() {
// Logical clamping would give 1.125; the snapped 3px rect allows radius 1.5.
let outline = RoundedRect::new(ferese_layout::Rect::new(0.2, 0.2, 1.5, 4.0), (0, 0).into(), 1.5, 20.0);
assert_eq!(outline.rect, Rectangle::new((0, 0).into(), (3, 6).into()));
assert_eq!(outline.radius, 1.5);
for scale in [1.0, 1.25, 1.5, 2.0] {
let rect = ferese_layout::Rect::new(-20.4, -10.2, 8.8, 6.4);
let outline = RoundedRect::new(rect, (-10, -5).into(), scale, 100.0);
assert_eq!(outline.rect, physical_rect(rect, (-10, -5).into(), scale));
assert_eq!(
outline.radius,
outline.rect.size.w.min(outline.rect.size.h) as f32 / 2.0
);
}
}
#[test]
fn physical_radius_preserves_fractions_and_handles_zero() {
let size = (11, 7).into();
assert_eq!(clamp_radius(100.0, size), 3.5);
assert_eq!(clamp_radius(2.25, size), 2.25);
assert_eq!(clamp_radius(0.0, size), 0.0);
assert_eq!(clamp_radius(-1.0, size), 0.0);
assert_eq!(clamp_radius(2.0, (0, 10).into()), 0.0);
let outline = RoundedRect::new(ferese_layout::Rect::new(0.0, 0.0, 10.0, 10.0), (0, 0).into(), 1.5, 1.5);
assert_eq!(outline.radius, 2.25);
}
#[test]
fn material_and_window_outline_agree_for_the_same_rect() {
let logical = Rectangle::new((-3, 7).into(), (11, 9).into());
for scale in [1.0, 1.25, 1.5, 2.0] {
assert_eq!(
RoundedRect::from_logical(logical, scale, 100.0),
RoundedRect::new(
ferese_layout::Rect::new(-3.0, 7.0, 11.0, 9.0),
(0, 0).into(),
scale,
100.0
)
);
}
}
use super::*;
#[test]
fn presentation_feedback_excludes_missing_and_occluded_surfaces() {
use smithay::backend::renderer::element::{RenderElementPresentationState, RenderElementState};
use smithay::output::{PhysicalProperties, Subpixel};
let output = Output::new(
"owner".into(),
PhysicalProperties {
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
let id = Id::new();
let mut states = RenderElementStates::default();
assert!(rendered_feedback_output(&output, id.clone(), &states).is_none());
for state in [
RenderElementState {
visible_area: 0,
presentation_state: RenderElementPresentationState::Skipped,
},
RenderElementState {
visible_area: 0,
presentation_state: RenderElementPresentationState::Rendering { reason: None },
},
] {
states.states.insert(id.clone(), state);
assert!(rendered_feedback_output(&output, id.clone(), &states).is_none());
}
for presentation_state in [
RenderElementPresentationState::Rendering { reason: None },
RenderElementPresentationState::ZeroCopy,
] {
states.states.insert(
id.clone(),
RenderElementState {
visible_area: 32,
presentation_state,
},
);
assert_eq!(
rendered_feedback_output(&output, id.clone(), &states),
Some(output.clone())
);
}
}
#[test]
fn reversing_a_grow_replaces_the_smaller_snapshot() {
assert!(!snapshot_covers_source((500, 600).into(), (1000, 600).into(), 1.0, 1.0));
assert!(snapshot_covers_source((1000, 600).into(), (500, 600).into(), 1.0, 1.0));
assert!(!snapshot_covers_source((1000, 600).into(), (500, 600).into(), 1.0, 1.8));
}
#[test]
fn shrinking_keeps_old_pixels_until_destination_covers_bounds() {
let target = ferese_layout::Rect::new(0.0, 0.0, 500.0, 600.0);
let visual = ferese_layout::Rect::new(0.0, 0.0, 750.0, 600.0);
assert!(resize_needs_old_frame(visual, target, 500, 600));
assert!(!resize_needs_old_frame(target, target, 500, 600));
// Character-grid clients must still release the bounded snapshot.
assert!(!resize_needs_old_frame(target, target, 492, 592));
let growing_target = ferese_layout::Rect::new(0.0, 0.0, 1000.0, 600.0);
assert!(!resize_needs_old_frame(visual, growing_target, 1000, 600));
}
#[test]
fn fresh_snapshot_and_retarget_wait_do_not_skip_the_handoff() {
let mut elapsed = Duration::ZERO;
let mut last = None;
assert!(advance_handoff(
&mut elapsed,
&mut last,
Duration::from_secs(5),
false,
1.0
));
assert_eq!(elapsed, Duration::ZERO);
assert!(advance_handoff(
&mut elapsed,
&mut last,
Duration::from_millis(5010),
false,
1.0
));
assert_eq!(elapsed, Duration::from_millis(10));
advance_handoff(&mut elapsed, &mut last, Duration::from_secs(6), true, 1.0);
advance_handoff(&mut elapsed, &mut last, Duration::from_secs(7), false, 1.0);
assert_eq!(elapsed, Duration::from_millis(10));
assert!(!advance_handoff(
&mut elapsed,
&mut last,
Duration::from_millis(7070),
false,
1.0
));
}
#[test]
fn no_damage_requests_are_paced_without_buffer_submission() {
let start = Duration::ZERO;
let refresh = Duration::from_millis(16);
let mut clock = crate::frame_scheduler::FrameScheduler::new(refresh);
assert_eq!(clock.callback_deadline(start, start).unwrap(), start);
clock.callback_sent(start);
for millisecond in 1..16 {
assert_eq!(
clock
.callback_deadline(start + Duration::from_millis(millisecond), start)
.unwrap(),
start + refresh
);
}
let late = start + Duration::from_millis(50);
assert_eq!(clock.callback_deadline(late, late).unwrap(), late);
clock.callback_sent(late);
assert_eq!(clock.callback_deadline(late, late).unwrap(), late + refresh);
}
#[test]
fn independent_output_callback_clocks_do_not_sum_their_rates() {
let start = Duration::ZERO;
let mut slow = crate::frame_scheduler::FrameScheduler::new(Duration::from_millis(16));
let mut fast = crate::frame_scheduler::FrameScheduler::new(Duration::from_millis(8));
slow.callback_sent(start);
fast.callback_sent(start);
fast.callback_sent(start + Duration::from_millis(8));
assert_eq!(
slow.callback_deadline(start + Duration::from_millis(8), start).unwrap(),
start + Duration::from_millis(16)
);
assert_eq!(
fast.callback_deadline(start + Duration::from_millis(8), start).unwrap(),
start + Duration::from_millis(16)
);
}
#[test]
fn mixed_refresh_outputs_do_not_double_the_animation_clock() {
let start = Instant::now();
let mut last = start;
let mut elapsed = Duration::ZERO;
for ms in [8, 16, 16, 24, 32, 32] {
elapsed += frame_delta(&mut last, start + Duration::from_millis(ms));
}
assert_eq!(elapsed, Duration::from_millis(32));
assert_eq!(frame_delta(&mut last, start), Duration::ZERO);
assert_eq!(last, start + Duration::from_millis(32));
}
#[test]
fn fractional_motion_does_not_quantize_to_logical_pixels() {
let rect = ferese_layout::Rect::new(0.3, 0.3, 100.2, 80.2);
assert_eq!(physical_rect(rect, (0, 0).into(), 1.8).loc, (1, 1).into());
assert_eq!(physical_rect(rect, (0, 0).into(), 1.8).size, (180, 144).into());
}
#[test]
fn adjacent_frames_share_edges_at_all_output_scales() {
for scale in [1.0, 1.25, 1.5, 1.8, 2.0] {
let left = physical_rect(ferese_layout::Rect::new(13.3, 0.0, 500.4, 800.0), (0, 0).into(), scale);
let right = physical_rect(ferese_layout::Rect::new(513.7, 0.0, 500.4, 800.0), (0, 0).into(), scale);
assert_eq!(left.loc.x + left.size.w, right.loc.x);
}
}
#[test]
fn handoff_has_bounded_lifetime_and_no_alpha_jump() {
assert_eq!(handoff_alpha(Duration::ZERO), 1.0);
assert_eq!(handoff_alpha(HANDOFF), 0.0);
assert_eq!(handoff_alpha(Duration::from_secs(1)), 0.0);
assert!(handoff_alpha(Duration::from_millis(1)) > 0.99);
}
}
pub(crate) fn scaled_visual_rect(rect: ferese_layout::Rect, scale: f64) -> ferese_layout::Rect {
let scale = scale.max(0.0);
let width = rect.width * scale;
let height = rect.height * scale;
ferese_layout::Rect::new(
rect.x + (rect.width - width) / 2.0,
rect.y + (rect.height - height) / 2.0,
width,
height,
)
}
pub(crate) fn scaled_visual_velocity(
rect: ferese_layout::Rect,
velocity: ferese_animation::RectVelocity,
scale: f64,
scale_velocity: f64,
) -> ferese_animation::RectVelocity {
ferese_animation::RectVelocity {
x: velocity.x + ((1.0 - scale) * velocity.width - rect.width * scale_velocity) * 0.5,
y: velocity.y + ((1.0 - scale) * velocity.height - rect.height * scale_velocity) * 0.5,
width: velocity.width * scale + rect.width * scale_velocity,
height: velocity.height * scale + rect.height * scale_velocity,
}
}
#[cfg(test)]
mod motion_tests {
use super::*;
#[test]
fn center_scale_velocity_matches_the_visible_rectangle() {
let rect = ferese_layout::Rect::new(12.25, 18.75, 400.5, 300.25);
let velocity = ferese_animation::RectVelocity {
x: 80.0,
y: -40.0,
width: 100.0,
height: 20.0,
};
let v = scaled_visual_velocity(rect, velocity, 0.98, 0.07);
let before = scaled_visual_rect(rect, 0.98);
let dt = 1e-5;
let next = ferese_layout::Rect::new(
rect.x + velocity.x * dt,
rect.y + velocity.y * dt,
rect.width + velocity.width * dt,
rect.height + velocity.height * dt,
);
let after = scaled_visual_rect(next, 0.98 + 0.07 * dt);
for (measured, expected) in [
((after.x - before.x) / dt, v.x),
((after.y - before.y) / dt, v.y),
((after.width - before.width) / dt, v.width),
((after.height - before.height) / dt, v.height),
] {
assert!((measured - expected).abs() < 0.0001);
}
}
}