//! 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 ferese_animation::AnimatedRect; 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: AnimatedRect, pub opacity: ferese_animation::AnimatedValue, pub focus_alpha: f32, pub dim: f64, pub emphasis: ferese_animation::AnimatedValue, pub shadow: ferese_animation::AnimatedValue, pub scale_content: bool, pub native_size: Option, } impl WindowPresentation { pub fn alpha(self) -> f32 { self.focus_alpha * 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 = 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, None, 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 { 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, scale: f64, ) -> Rectangle { 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, 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, 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, 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, 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) -> 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, source: smithay::utils::Size, 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, 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, } impl Element for PhysicalShaderElement { fn id(&self) -> &Id { self.inner.id() } fn current_commit(&self) -> CommitCounter { self.inner.current_commit() } fn src(&self) -> Rectangle { self.inner.src() } fn geometry(&self, _: Scale) -> Rectangle { self.geometry } fn damage_since( &self, _: Scale, commit: Option, ) -> DamageSet { if commit == Some(self.current_commit()) { DamageSet::default() } else { DamageSet::from_slice(&[Rectangle::from_size(self.geometry.size)]) } } } impl RenderElement for PhysicalShaderElement { fn draw( &self, frame: &mut GlesFrame<'_, '_>, src: Rectangle, dst: Rectangle, damage: &[Rectangle], opaque: &[Rectangle], cache: Option<&smithay::utils::user_data::UserDataMap>, ) -> Result<(), GlesError> { self.inner.draw(frame, src, dst, damage, opaque, cache) } } /// 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, pub source: Rectangle, pub alpha: f32, pub program: Option, pub uniforms: Vec>, } impl Element for NativeTextureElement { fn id(&self) -> &Id { &self.id } fn current_commit(&self) -> CommitCounter { self.commit } fn src(&self) -> Rectangle { self.source } fn geometry(&self, _: Scale) -> Rectangle { self.geometry } fn alpha(&self) -> f32 { self.alpha } fn kind(&self) -> Kind { Kind::Unspecified } fn damage_since( &self, _: Scale, commit: Option, ) -> DamageSet { if commit == Some(self.commit) { DamageSet::default() } else { DamageSet::from_slice(&[Rectangle::from_size(self.geometry.size)]) } } } impl RenderElement for NativeTextureElement { fn draw( &self, frame: &mut GlesFrame<'_, '_>, src: Rectangle, dst: Rectangle, damage: &[Rectangle], opaque: &[Rectangle], _cache: Option<&smithay::utils::user_data::UserDataMap>, ) -> 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 { #[test] fn activation_alpha_composes_with_lifecycle_without_retargeting_it() { let mut presentation = moving_presentation(); presentation.focus_alpha = 0.8; presentation.opacity.current = 0.5; let lifecycle = presentation.opacity; assert!((presentation.alpha() - 0.4).abs() < 1e-6); presentation.focus_alpha = 0.9; assert_eq!(presentation.opacity, lifecycle); assert!((presentation.alpha() - 0.45).abs() < 1e-6); presentation.close(); assert_eq!(presentation.focus_alpha, 0.9); assert_eq!(presentation.opacity.current, 0.5); assert_eq!(presentation.opacity.target, 0.0); assert_eq!( presentation .thumbnail(ferese_layout::Rect::new(0.0, 0.0, 100.0, 100.0)) .alpha(), presentation.alpha() ); } fn moving_presentation() -> WindowPresentation { use ferese_animation::{AnimatedRect, AnimatedValue, RectVelocity}; WindowPresentation { focus_alpha: 1.0, dim: 0.0, 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 { serial_number: String::new(), 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 { needs_capture: false, visible_area: 0, presentation_state: RenderElementPresentationState::Skipped, }, RenderElementState { needs_capture: false, 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 { needs_capture: false, 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); } } }