Use analytic springs and instrument resize barriers

Share a stable damped oscillator solver across scalar and rectangle animations while preserving retarget and explicit no-crossing presentation policies. Add deterministic traces, scrolling and zoom continuity coverage, and pause-aware advancement.

Measure resize barrier and handoff timing behind resize-metrics, expose the feature through the release installer, and wait for the intended backend in isolated portal tests.
This commit is contained in:
Samuel Onoja committed 2026-10-02 00:49:52 +01:00
1 parent 42f022fde5
commit 28e03a2624
18 files changed
+1502 -84

No files matched your search

+1
View File
@@ -660,6 +660,7 @@ pub(crate) fn sleep_locked_outputs(state: &mut Ferese) {
}
pub(crate) fn wake_locked_outputs(state: &mut Ferese) {
state.reset_animation_clock();
if let Some(backend) = state.direct_backend.as_mut() {
for device in backend.devices.values_mut() {
for output in device.outputs.values_mut() {
+5
View File
@@ -22,6 +22,8 @@ mod presentation;
mod private_client;
mod reload;
mod render;
#[cfg(feature = "resize-metrics")]
mod resize_metrics;
mod resize_transaction;
mod session_lock;
mod shell_control;
@@ -143,6 +145,9 @@ fn main() -> Result<(), Box<dyn Error>> {
}
});
#[cfg(feature = "resize-metrics")]
state.resize_metrics.dump();
runner.borrow_mut().stop();
if let Some(child) = &mut child {
terminate_child(child);
+4 -3
View File
@@ -3,7 +3,7 @@ use std::sync::{Arc, Mutex, mpsc};
use std::time::Duration;
use ab_glyph::{Font, FontArc, FontVec, ScaleFont};
use ferese_animation::{AnimatedRect, AnimatedValue, SpringConfig};
use ferese_animation::{AnimatedRect, AnimatedValue, CrossingPolicy, SpringConfig};
use ferese_core::{OutputId, WorkspaceId};
use ferese_layout::{Direction, Rect, WindowId};
use smithay::backend::allocator::Fourcc;
@@ -1016,7 +1016,7 @@ impl OverviewMotion {
for presentation in self.presentations.values_mut() {
if animations_enabled {
active_animation |= presentation.advance(delta, spring);
active_animation |= presentation.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing);
} else {
presentation.snap();
}
@@ -1029,13 +1029,14 @@ impl OverviewMotion {
if animations_enabled {
// Geometry's 0.1-pixel tolerance is far too coarse for opacity:
// it would abruptly drop the last ten percent of the fade.
active_animation |= self.opacity.advance(
active_animation |= self.opacity.advance_with_policy(
delta,
SpringConfig {
position_tolerance: 0.001,
velocity_tolerance: 0.005,
..spring
},
CrossingPolicy::NoCrossing,
);
} else {
self.opacity.snap();
+241
View File
@@ -0,0 +1,241 @@
//! Opt-in resize diagnostics. No measurement code exists without resize-metrics.
//! Times are unscaled wall time except handoff_animation, which is the existing
//! handoff clock (affected by animation speed and pauses).
use std::collections::HashMap;
use std::time::Duration;
use ferese_core::WorkspaceId;
use ferese_layout::WindowId;
const BUCKETS_MS: [u64; 16] = [16, 32, 64, 75, 80, 85, 100, 150, 250, 290, 299, 300, 301, 310, 350, 500];
#[derive(Debug, Default)]
struct Histogram {
counts: [u64; 17],
}
impl Histogram {
fn add(&mut self, duration: Duration) {
let index = BUCKETS_MS.partition_point(|ms| Duration::from_millis(*ms) < duration);
self.counts[index] += 1;
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) enum End {
Commit,
Deadline,
Replaced,
Cancelled,
}
#[derive(Default, Debug)]
struct Client {
workspace: Option<WorkspaceId>,
app_id: String,
started: Option<Duration>,
target_changed: Option<Duration>,
last_observation: Duration,
handoff_started: Option<Duration>,
barriers: u64,
deadlines: u64,
commits: u64,
replaced: u64,
cancelled: u64,
viewport_pauses: u64,
other_window_pauses: u64,
viewport_seen: bool,
others_seen: bool,
viewport_overlap: Duration,
other_overlap: Duration,
barrier_duration: Histogram,
viewport_blocked: Histogram,
other_windows_blocked: Histogram,
target_to_handoff: Histogram,
handoff_wall: Histogram,
handoff_animation: Histogram,
}
#[derive(Default)]
pub(crate) struct ResizeMetrics {
clients: HashMap<WindowId, Client>,
workspace_started: HashMap<WorkspaceId, Duration>,
workspace_duration: Histogram,
}
impl ResizeMetrics {
pub(crate) fn begin(&mut self, id: WindowId, workspace: Option<WorkspaceId>, app_id: &str, now: Duration) {
// Keep an overlapping workspace barrier continuous through retargets.
let previous_workspace = self.end_client(id, now, End::Replaced);
if previous_workspace != workspace {
self.finish_workspace(previous_workspace, now);
}
let client = self.clients.entry(id).or_default();
client.workspace = workspace;
client.app_id.clear();
client.app_id.push_str(app_id);
client.started = Some(now);
client.target_changed = Some(now);
client.last_observation = now;
client.handoff_started = None;
client.viewport_seen = false;
client.others_seen = false;
client.viewport_overlap = Duration::ZERO;
client.other_overlap = Duration::ZERO;
client.barriers += 1;
if let Some(workspace) = workspace {
self.workspace_started.entry(workspace).or_insert(now);
}
}
fn end_client(&mut self, id: WindowId, now: Duration, reason: End) -> Option<WorkspaceId> {
let client = self.clients.get_mut(&id)?;
let started = client.started.take()?;
client.barrier_duration.add(now.saturating_sub(started));
client.viewport_blocked.add(client.viewport_overlap);
client.other_windows_blocked.add(client.other_overlap);
match reason {
End::Commit => client.commits += 1,
End::Deadline => client.deadlines += 1,
End::Replaced => client.replaced += 1,
End::Cancelled => client.cancelled += 1,
}
client.workspace
}
pub(crate) fn end(&mut self, id: WindowId, now: Duration, reason: End) {
let Some(workspace) = self.end_client(id, now, reason) else {
return;
};
self.finish_workspace(Some(workspace), now);
}
fn finish_workspace(&mut self, workspace: Option<WorkspaceId>, now: Duration) {
let Some(workspace) = workspace else { return };
if !self
.clients
.values()
.any(|client| client.workspace == Some(workspace) && client.started.is_some())
&& let Some(started) = self.workspace_started.remove(&workspace)
{
self.workspace_duration.add(now.saturating_sub(started));
}
}
pub(crate) fn observe(&mut self, id: WindowId, now: Duration, viewport_active: bool, other_active: bool) {
let Some(client) = self.clients.get_mut(&id) else {
return;
};
if client.started.is_none() {
return;
}
let interval = now.saturating_sub(client.last_observation);
client.last_observation = now;
if viewport_active {
client.viewport_overlap += interval;
if !client.viewport_seen {
client.viewport_seen = true;
client.viewport_pauses += 1;
}
}
if other_active {
client.other_overlap += interval;
if !client.others_seen {
client.others_seen = true;
client.other_window_pauses += 1;
}
}
}
pub(crate) fn pause_clock(&mut self, now: Duration) {
// Activity overlap excludes globally paused presentation time. Barrier
// and target-to-handoff durations remain actual wall-clock durations.
for client in self.clients.values_mut().filter(|client| client.started.is_some()) {
client.last_observation = now;
}
}
pub(crate) fn handoff_tick(&mut self, id: WindowId, now: Duration, blocked: bool) {
let Some(client) = self.clients.get_mut(&id) else {
return;
};
if !blocked && client.handoff_started.is_none() {
client.handoff_started = Some(now);
if let Some(changed) = client.target_changed.take() {
client.target_to_handoff.add(now.saturating_sub(changed));
}
}
}
pub(crate) fn handoff_end(&mut self, id: WindowId, now: Duration, elapsed: Duration) {
let Some(client) = self.clients.get_mut(&id) else {
return;
};
if let Some(started) = client.handoff_started.take() {
client.handoff_wall.add(now.saturating_sub(started));
client.handoff_animation.add(elapsed);
}
}
pub(crate) fn dump(&self) {
// Enabling the feature explicitly opts into the exit report. This does
// not depend on a tracing filter and adds no per-frame log messages.
eprintln!("resize-metrics: histogram upper bounds ms {BUCKETS_MS:?}, final bucket overflow");
eprintln!(
"resize-metrics: workspace barrier durations {:?}",
self.workspace_duration
);
for (id, client) in &self.clients {
eprintln!("resize-metrics: client {id:?} {client:?}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn overlapping_clients_deadlines_and_handoff_are_measured_separately() {
let mut metrics = ResizeMetrics::default();
let ws = Some(WorkspaceId(1));
let ms = Duration::from_millis;
metrics.begin(WindowId(1), ws, "terminal", ms(0));
metrics.begin(WindowId(2), ws, "editor", ms(50));
metrics.observe(WindowId(1), ms(100), true, true);
metrics.observe(WindowId(1), ms(300), true, true);
metrics.end(WindowId(1), ms(300), End::Deadline);
assert!(metrics.workspace_started.contains_key(&WorkspaceId(1)));
metrics.end(WindowId(2), ms(310), End::Commit);
assert!(metrics.workspace_started.is_empty());
metrics.handoff_tick(WindowId(1), ms(310), false);
metrics.handoff_end(WindowId(1), ms(390), ms(80));
let client = &metrics.clients[&WindowId(1)];
assert_eq!(client.deadlines, 1);
assert_eq!(client.viewport_pauses, 1);
assert_eq!(client.other_window_pauses, 1);
assert_eq!(client.viewport_overlap, ms(300));
assert_eq!(client.other_overlap, ms(300));
assert_eq!(client.barrier_duration.counts[11], 1);
assert_eq!(client.handoff_animation.counts[4], 1);
assert_eq!(client.handoff_wall.counts[4], 1);
assert_eq!(metrics.workspace_duration.counts[13], 1);
}
#[test]
fn globally_paused_time_is_not_counted_as_animation_blocking_overlap() {
let mut metrics = ResizeMetrics::default();
let ms = Duration::from_millis;
metrics.begin(WindowId(1), Some(WorkspaceId(1)), "terminal", ms(0));
metrics.observe(WindowId(1), ms(40), true, true);
metrics.pause_clock(ms(340));
metrics.observe(WindowId(1), ms(356), true, true);
metrics.end(WindowId(1), ms(356), End::Deadline);
let client = &metrics.clients[&WindowId(1)];
assert_eq!(client.viewport_overlap, ms(56));
assert_eq!(client.other_overlap, ms(56));
assert_eq!(client.barrier_duration.counts[15], 1);
assert_eq!(client.deadlines, 1);
}
}
+7 -1
View File
@@ -17,7 +17,7 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use calloop::LoopHandle;
use ferese_animation::{AnimatedValue, ClientSize, PresentationMode, SpringConfig, WindowGeometry};
use ferese_animation::{AnimatedValue, ClientSize, CrossingPolicy, PresentationMode, SpringConfig, WindowGeometry};
use ferese_core::{
LayoutMode, OutputGeometry, OutputId, OutputWorkspaceMap, WindowPlacement, WorkspaceId, WorkspaceLayout,
WorkspaceSet,
@@ -348,6 +348,9 @@ pub struct Ferese {
pub(crate) logout_owner: Option<ObjectId>,
pub(crate) backdrop_generation: u64,
viewport_animations: HashMap<WorkspaceId, AnimatedValue>,
paused_workspaces: HashSet<WorkspaceId>,
#[cfg(feature = "resize-metrics")]
pub(crate) resize_metrics: crate::resize_metrics::ResizeMetrics,
focus_swipe: Option<FocusSwipe>,
workspace_slides: HashMap<OutputId, WorkspaceSlide>,
workspace_slide_offsets: HashMap<WorkspaceId, (f64, f64)>,
@@ -574,6 +577,9 @@ impl Ferese {
logout_owner: None,
backdrop_generation: 0,
viewport_animations: HashMap::new(),
paused_workspaces: HashSet::new(),
#[cfg(feature = "resize-metrics")]
resize_metrics: crate::resize_metrics::ResizeMetrics::default(),
focus_swipe: None,
workspace_slides: HashMap::new(),
workspace_slide_offsets: HashMap::new(),
+335 -11
View File
@@ -3,6 +3,17 @@ use super::*;
impl Ferese {
pub fn advance_animations(&mut self, now: Instant) -> bool {
let delta = crate::presentation::frame_delta(&mut self.last_animation_tick, now);
// Consume wall time even while presentation is paused. A paused
// desktop must not turn suspend/DPMS time into a spring step.
if self.space.outputs().next().is_none()
|| self.session_lock.sleeping
|| self.direct_backend.as_ref().is_some_and(|backend| !backend.active)
{
#[cfg(feature = "resize-metrics")]
self.resize_metrics.pause_clock(self.start_time.elapsed());
return self.animations_need_tick();
}
self.advance_animations_by(delta)
}
@@ -12,16 +23,68 @@ impl Ferese {
}
}
#[cfg(feature = "resize-metrics")]
pub(super) fn measure_resize_pauses(&mut self, now: Duration) {
if self.session_lock.sleeping || self.direct_backend.as_ref().is_some_and(|backend| !backend.active) {
self.resize_metrics.pause_clock(now);
return;
}
for id in self.windows.resizing() {
let Some(workspace) = self.workspaces.workspace_for_window(*id) else {
continue;
};
let moving = |value: &AnimatedValue| value.current != value.target || value.velocity != 0.0;
let viewport = self.viewport_animations.get(&workspace).is_some_and(moving)
&& self
.output_workspaces
.output_for_workspace(workspace)
.is_some_and(|output| self.output_workspaces.active_workspace(output) == Some(workspace))
&& self
.focus_swipe
.as_ref()
.is_none_or(|swipe| swipe.workspace != workspace);
let others = self.workspaces.workspace(workspace).is_some_and(|workspace| {
workspace
.layout
.window_ids()
.chain(workspace.floating.iter().copied())
.any(|other| {
other != *id
&& self
.windows
.window(other)
.is_some_and(|window| self.space.element_location(window).is_some())
&& self.windows.record(other).is_some_and(|record| {
record.geometry.is_some_and(|mut geometry| {
geometry.advance(Duration::ZERO, self.spring_config, self.animations_enabled)
}) || record.world_x.as_ref().is_some_and(|(_, value)| moving(value))
|| record.coupled_width.as_ref().is_some_and(|(_, value)| moving(value))
})
})
});
self.resize_metrics.observe(
*id,
now,
self.animations_enabled && viewport,
self.animations_enabled && others,
);
}
}
pub(crate) fn animation_fallback_deadline(&self, interval: std::time::Duration) -> Instant {
self.last_animation_tick + interval.saturating_mul(2)
}
pub(crate) fn reset_animation_clock(&mut self) {
self.last_animation_tick = Instant::now();
#[cfg(feature = "resize-metrics")]
self.resize_metrics.pause_clock(self.start_time.elapsed());
}
fn animations_need_tick(&self) -> bool {
if self.focus_swipe.is_some()
|| !self.paused_workspaces.is_empty()
|| !self.workspace_slides.is_empty()
|| !self.dismissing_popups.is_empty()
|| self.render.snapshots().next().is_some()
@@ -65,6 +128,11 @@ impl Ferese {
}
pub(super) fn advance_animations_by(&mut self, delta: std::time::Duration) -> bool {
self.advance_animations_at(delta, self.start_time.elapsed())
}
// The production caller supplies monotonic wall time; tests supply a fake clock.
fn advance_animations_at(&mut self, delta: Duration, now: Duration) -> bool {
if !self.animations_need_tick() {
self.sync_window_stacking();
return false;
@@ -168,7 +236,23 @@ impl Ferese {
self.send_window_close(id);
}
let now = self.start_time.elapsed();
// Include transactions expiring on this dispatch: their waiting time
// must not be charged to the first resumed animation step.
let paused_before_expiry = self
.windows
.resizing()
.filter_map(|id| self.workspaces.workspace_for_window(*id))
.collect::<HashSet<_>>();
#[cfg(feature = "resize-metrics")]
self.measure_resize_pauses(now);
#[cfg(feature = "resize-metrics")]
{
for (&id, record) in self.windows.records() {
if record.resize.is_some_and(|transaction| transaction.expired(now)) {
self.resize_metrics.end(id, now, crate::resize_metrics::End::Deadline);
}
}
}
self.windows.expire_transactions(now);
let blocked_workspaces = self
@@ -176,8 +260,12 @@ impl Ferese {
.resizing()
.filter_map(|id| self.workspaces.workspace_for_window(*id))
.collect::<HashSet<_>>();
let mut resume_pauses = std::mem::replace(&mut self.paused_workspaces, blocked_workspaces.clone());
resume_pauses.extend(paused_before_expiry);
let animations_enabled = self.animations_enabled;
let animation_speed = self.animation_speed;
#[cfg(feature = "resize-metrics")]
let resize_metrics = &mut self.resize_metrics;
self.render.retain_snapshots(|id, snapshot| {
if !animations_enabled {
return false;
@@ -201,6 +289,8 @@ impl Ferese {
})
});
let blocked = waiting_for_client || uncovered;
#[cfg(feature = "resize-metrics")]
resize_metrics.handoff_tick(*id, now, blocked);
let active = crate::presentation::advance_handoff(
&mut snapshot.elapsed,
&mut snapshot.last_tick,
@@ -213,13 +303,15 @@ impl Ferese {
}
if !active {
#[cfg(feature = "resize-metrics")]
resize_metrics.handoff_end(*id, now, snapshot.elapsed);
tracing::debug!(?id, bytes = snapshot.bytes(), "released resize handoff snapshot");
}
active
});
active_animation |= self.render.snapshots().next().is_some();
// Keep scheduling frames while waiting, so the deadline cannot stall.
active_animation |= !blocked_workspaces.is_empty();
active_animation |= !blocked_workspaces.is_empty() || !resume_pauses.is_empty();
for (workspace, viewport) in &mut self.viewport_animations {
if !self
.output_workspaces
@@ -229,7 +321,7 @@ impl Ferese {
continue;
}
if blocked_workspaces.contains(workspace) {
if blocked_workspaces.contains(workspace) || resume_pauses.contains(workspace) {
continue;
}
@@ -237,7 +329,8 @@ impl Ferese {
viewport.current = swipe.position();
viewport.velocity = 0.0;
} else if self.animations_enabled {
active_animation |= viewport.advance(delta, self.viewport_spring_config);
active_animation |=
viewport.advance_with_policy(delta, self.viewport_spring_config, CrossingPolicy::NoCrossing);
} else {
viewport.snap();
}
@@ -253,7 +346,7 @@ impl Ferese {
if self
.workspaces
.workspace_for_window(id)
.is_some_and(|workspace| blocked_workspaces.contains(&workspace))
.is_some_and(|workspace| blocked_workspaces.contains(&workspace) || resume_pauses.contains(&workspace))
{
continue;
}
@@ -288,23 +381,22 @@ impl Ferese {
&& let Some(viewport) = self.viewport_animations.get(workspace)
{
if zooming {
world_x.current = geometry.visual.current.x + viewport.current;
world_x.velocity = geometry.visual.velocity.x + viewport.velocity;
sync_scrolling_coordinates(geometry, world_x, viewport, true);
} else if self.animations_enabled {
active_animation |= world_x.advance(delta, self.spring_config);
active_animation |=
world_x.advance_with_policy(delta, self.spring_config, CrossingPolicy::NoCrossing);
} else {
world_x.snap();
}
if !zooming {
geometry.visual.current.x = world_x.current - viewport.current;
geometry.visual.velocity.x = world_x.velocity - viewport.velocity;
sync_scrolling_coordinates(geometry, world_x, viewport, false);
}
}
if let Some((_, width)) = record.coupled_width.as_mut() {
let width_active = if self.animations_enabled {
width.advance(delta, self.viewport_spring_config)
width.advance_with_policy(delta, self.viewport_spring_config, CrossingPolicy::NoCrossing)
} else {
width.snap();
false
@@ -461,6 +553,25 @@ impl Ferese {
// Inspect current targets, not the previous frame's activity. Target changes
// and client waits must wake a desktop that was settled on its last tick.
/// Zoom owns the visual rectangle until its final handoff tick. Otherwise the
/// strip coordinates own x. Keep this ownership decision explicit at callers.
pub(super) fn sync_scrolling_coordinates(
geometry: &mut WindowGeometry,
world: &mut AnimatedValue,
viewport: &AnimatedValue,
zoom_owns: bool,
) {
if zoom_owns {
world.current = geometry.visual.current.x + viewport.current;
world.velocity = geometry.visual.velocity.x + viewport.velocity;
debug_assert!((world.current - geometry.visual.current.x - viewport.current).abs() < 1e-6);
} else {
geometry.visual.current.x = world.current - viewport.current;
geometry.visual.velocity.x = world.velocity - viewport.velocity;
debug_assert!((geometry.logical.x - world.target + viewport.target).abs() < 1e-6);
}
}
fn record_needs_tick(
record: &super::window_registry::WindowRecord,
focus: f64,
@@ -564,4 +675,217 @@ mod tests {
record.coupled_width = Some((WorkspaceId(1), AnimatedValue::new(400.0)));
assert!(record_needs_tick(&record, 1.0, 0.0, || true));
}
#[test]
fn scrolling_target_changes_and_zoom_ownership_switches_are_continuous() {
let config = SpringConfig::default();
let mut viewport = AnimatedValue::new(100.0);
viewport.set_target(400.0);
viewport.advance_with_policy(Duration::from_millis(40), config, CrossingPolicy::NoCrossing);
let mut world = AnimatedValue::new(500.0);
world.set_target(900.0);
world.advance_with_policy(Duration::from_millis(40), config, CrossingPolicy::NoCrossing);
let mut width = AnimatedValue::new(400.0);
width.set_target(700.0);
width.advance_with_policy(Duration::from_millis(40), config, CrossingPolicy::NoCrossing);
let mut geometry = WindowGeometry::new(
Rect::new(world.current - viewport.current, 0.0, width.current, 300.0),
None,
);
let target = Rect::new(world.target - viewport.target, 0.0, width.target, 300.0);
geometry.set_logical_target(target, Duration::from_millis(40));
sync_scrolling_coordinates(&mut geometry, &mut world, &viewport, false);
geometry.visual.velocity.width = width.velocity;
let before = geometry.visual.current;
viewport.retarget_preserving_motion(300.0);
world.retarget_preserving_motion(1000.0);
width.retarget_preserving_motion(600.0);
geometry.set_logical_target(
Rect::new(world.target - viewport.target, 0.0, width.target, 300.0),
Duration::from_millis(40),
);
sync_scrolling_coordinates(&mut geometry, &mut world, &viewport, false);
assert_eq!(geometry.visual.current, before);
assert_eq!(geometry.visual.velocity.x, world.velocity - viewport.velocity);
assert_eq!(geometry.logical.x, world.target - viewport.target);
for mode in [
PresentationMode::Maximized,
PresentationMode::Fullscreen,
PresentationMode::Normal,
] {
let before = geometry.visual.current;
geometry.set_presentation_mode(Rect::new(0.0, 0.0, 1920.0, 1080.0), mode, Duration::from_millis(40));
sync_scrolling_coordinates(&mut geometry, &mut world, &viewport, true);
assert_eq!(geometry.visual.current, before);
assert_eq!(world.current, geometry.visual.current.x + viewport.current);
geometry.advance(Duration::ZERO, config, true);
assert_eq!(geometry.visual.current, before);
geometry.advance(Duration::from_millis(16), config, true);
}
// Hand back to scrolling after a completed zoom. world_x was mirrored
// on the final zoom tick; changing its destination must not change x.
for _ in 0..200 {
let zoom_owned = geometry.is_zooming();
geometry.advance(Duration::from_millis(16), config, true);
if zoom_owned {
sync_scrolling_coordinates(&mut geometry, &mut world, &viewport, true);
}
if !geometry.is_zooming() {
break;
}
}
assert!(!geometry.is_zooming());
let before = geometry.visual.current;
world.set_target(geometry.logical.x + viewport.target);
sync_scrolling_coordinates(&mut geometry, &mut world, &viewport, false);
assert_eq!(geometry.visual.current, before);
}
#[test]
#[ignore = "requires a private XDG_RUNTIME_DIR and permission to bind test sockets"]
fn resize_pause_and_resume_use_fake_wall_time_without_charging_the_pause() {
if std::env::var_os("FERESE_ANIMATION_PAUSE_TEST_CHILD").is_none() {
let runtime = tempfile::tempdir().unwrap();
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"state::animation::tests::resize_pause_and_resume_use_fake_wall_time_without_charging_the_pause",
"--ignored",
"--nocapture",
])
.env("FERESE_ANIMATION_PAUSE_TEST_CHILD", "1")
.env("XDG_RUNTIME_DIR", runtime.path())
.env_remove("FERESE_SOCKET")
.output()
.unwrap();
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
}
let runtime = std::path::PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR").unwrap());
assert!(runtime.starts_with(std::env::temp_dir()));
let mut event_loop = EventLoop::try_new().unwrap();
let display = Display::new().unwrap();
let config = crate::config::Config::default().runtime_config().unwrap();
let mut state = Ferese::new(&mut event_loop, display, config).unwrap();
let output = Output::new(
"pause-test".into(),
smithay::output::PhysicalProperties {
size: (0, 0).into(),
subpixel: smithay::output::Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
output.change_current_state(
Some(smithay::output::Mode {
size: (1920, 1080).into(),
refresh: 60000,
}),
None,
None,
None,
);
state.space.map_output(&output, (0, 0));
state.register_output(&output, "pause-test".into());
let workspace = state.workspaces.active_id();
let id = WindowId(1);
state.workspaces.insert_window(id, Axis::Horizontal, 0.5).unwrap();
state.windows.records.insert(id, settled());
let mut viewport = AnimatedValue::new(0.0);
viewport.set_target(500.0);
state.viewport_animations.insert(workspace, viewport);
state.advance_animations_at(Duration::from_millis(40), Duration::from_millis(40));
let frozen = state.viewport_animations[&workspace];
assert!(frozen.current > 0.0 && frozen.current < 500.0);
// No intervening dispatch at all: expiration happens on the 300ms tick.
state.windows.set_transaction(
id,
crate::resize_transaction::ResizeTransaction::new(1.into(), Duration::from_millis(40)),
);
state.advance_animations_at(Duration::from_millis(300), Duration::from_millis(340));
assert!(state.windows.transaction(&id).is_none());
assert_eq!(state.viewport_animations[&workspace], frozen);
state.advance_animations_at(Duration::from_millis(16), Duration::from_millis(356));
let mut expected = frozen;
expected.advance_with_policy(
Duration::from_millis(16),
state.viewport_spring_config,
CrossingPolicy::NoCrossing,
);
assert_eq!(state.viewport_animations[&workspace], expected);
// A commit releases the barrier between ticks. Drop that release tick's
// elapsed pause too, then resume at the next normal 16ms interval.
state.windows.set_transaction(
id,
crate::resize_transaction::ResizeTransaction::new(2.into(), Duration::from_millis(356)),
);
state.advance_animations_at(Duration::from_millis(100), Duration::from_millis(456));
let frozen = state.viewport_animations[&workspace];
state.windows.clear_transaction(&id);
state.advance_animations_at(Duration::from_millis(200), Duration::from_millis(656));
assert_eq!(state.viewport_animations[&workspace], frozen);
state.advance_animations_at(Duration::from_millis(16), Duration::from_millis(672));
let mut expected = frozen;
expected.advance_with_policy(
Duration::from_millis(16),
state.viewport_spring_config,
CrossingPolicy::NoCrossing,
);
assert_eq!(state.viewport_animations[&workspace], expected);
let frozen = state.viewport_animations[&workspace];
let instant = Instant::now();
state.last_animation_tick = instant;
state.session_lock.sleeping = true;
state.advance_animations(instant + Duration::from_millis(300));
assert_eq!(state.viewport_animations[&workspace], frozen);
state.session_lock.sleeping = false;
state.advance_animations(instant + Duration::from_millis(316));
let mut expected = frozen;
expected.advance_with_policy(
Duration::from_millis(16),
state.viewport_spring_config,
CrossingPolicy::NoCrossing,
);
assert_eq!(state.viewport_animations[&workspace], expected);
// Wake paths reset the global clock even when nothing dispatched while
// asleep. This excludes a whole undelivered pause interval as well.
let frozen = state.viewport_animations[&workspace];
state.last_animation_tick = Instant::now() - Duration::from_secs(5);
state.reset_animation_clock();
let resumed = state.last_animation_tick;
state.advance_animations(resumed + Duration::from_millis(16));
let mut expected = frozen;
expected.advance_with_policy(
Duration::from_millis(16),
state.viewport_spring_config,
CrossingPolicy::NoCrossing,
);
assert_eq!(state.viewport_animations[&workspace], expected);
// A settled transaction still has to flush its resume guard; otherwise
// the next unrelated animation would inherit an old paused workspace.
state.viewport_animations.get_mut(&workspace).unwrap().snap();
state.windows.record_mut(id).unwrap().focus = Some(DimAnimation::new(0.0));
state.windows.set_transaction(
id,
crate::resize_transaction::ResizeTransaction::new(3.into(), Duration::from_millis(800)),
);
state.advance_animations_at(Duration::from_millis(100), Duration::from_millis(900));
assert!(state.paused_workspaces.contains(&workspace));
state.windows.clear_transaction(&id);
state.advance_animations_at(Duration::from_millis(16), Duration::from_millis(916));
assert!(state.paused_workspaces.is_empty());
}
}
+3
View File
@@ -164,6 +164,9 @@ impl Ferese {
// Direct manipulation follows the pointer, including while the client is
// still drawing its next buffer. Rendering and hit testing share this rect.
#[cfg(feature = "resize-metrics")]
self.resize_metrics
.end(id, self.start_time.elapsed(), crate::resize_metrics::End::Cancelled);
self.windows.clear_transaction(&id);
self.render.clear_snapshot(&id);
let Some(record) = self.windows.record_mut(id) else {
+24 -4
View File
@@ -261,15 +261,27 @@ impl Ferese {
.remove(&id)
.filter(|(previous_workspace, _)| *previous_workspace == workspace)
.map(|(_, world_x)| world_x)
.unwrap_or_else(|| AnimatedValue::new(restored_world_x));
.unwrap_or_else(|| {
let mut world = AnimatedValue::new(restored_world_x);
world.velocity = geometry.visual.velocity.x
+ self
.viewport_animations
.get(&workspace)
.map_or(0.0, |viewport| viewport.velocity);
world
});
animated_world_x.set_target(world_x);
if !self.animations_enabled {
animated_world_x.snap();
}
if !geometry.is_zooming() {
geometry.visual.current.x = animated_world_x.current - viewport_x;
geometry.visual.velocity.x = animated_world_x.velocity;
if let Some(viewport) = self.viewport_animations.get(&workspace) {
super::animation::sync_scrolling_coordinates(
geometry,
&mut animated_world_x,
viewport,
geometry.is_zooming(),
);
}
record.world_x = Some((workspace, animated_world_x));
@@ -347,6 +359,14 @@ impl Ferese {
&& had_geometry
&& self.animations_enabled
{
self.paused_workspaces.extend(self.workspaces.workspace_for_window(id));
#[cfg(feature = "resize-metrics")]
self.resize_metrics.begin(
id,
self.workspaces.workspace_for_window(id),
&self.windows.record(id).unwrap().app_id,
now,
);
self.windows.set_transaction(
id,
crate::resize_transaction::ResizeTransaction::new(serial, now)
+4
View File
@@ -2,6 +2,10 @@ use super::*;
impl Ferese {
pub fn register_output(&mut self, output: &Output, identity: String) {
if self.output_ids.is_empty() {
self.reset_animation_clock();
}
if self.session_lock.active {
self.session_lock.output_added(output);
self.lock_input_activity();
+14 -9
View File
@@ -89,10 +89,15 @@ impl Ferese {
.focus_swipe
.as_ref()
.is_some_and(|swipe| swipe.workspace == workspace);
!held && viewport.advance(Duration::ZERO, self.viewport_spring_config)
!held
&& viewport.advance_with_policy(
Duration::ZERO,
self.viewport_spring_config,
CrossingPolicy::NoCrossing,
)
}) || record.coupled_width.as_ref().is_some_and(|(_, width)| {
let mut width = *width;
width.advance(Duration::ZERO, self.viewport_spring_config)
width.advance_with_policy(Duration::ZERO, self.viewport_spring_config, CrossingPolicy::NoCrossing)
})
})
}
@@ -100,7 +105,7 @@ impl Ferese {
pub(crate) fn sample_frame(&self, output: &Output, horizon: Duration) -> FrameScene {
let animating = self.output_has_animations(output);
let delta = if self.animations_enabled && animating {
horizon.min(Duration::from_millis(100)).mul_f64(self.animation_speed)
horizon.mul_f64(self.animation_speed)
} else {
Duration::ZERO
};
@@ -219,9 +224,9 @@ fn predict_geometry(
if let Some((mut world, mut viewport, held)) = world
&& !zooming
{
world.advance(delta, spring);
world.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing);
if !held {
viewport.advance(delta, viewport_spring);
viewport.advance_with_policy(delta, viewport_spring, CrossingPolicy::NoCrossing);
}
predicted.visual.current.x = world.current - viewport.current;
@@ -229,7 +234,7 @@ fn predict_geometry(
}
if let Some(mut width) = width {
width.advance(delta, viewport_spring);
width.advance_with_policy(delta, viewport_spring, CrossingPolicy::NoCrossing);
predicted.visual.current.width = width.current;
predicted.visual.velocity.width = width.velocity;
}
@@ -463,9 +468,9 @@ mod tests {
Some((world, viewport, false)),
Some(width),
);
world.advance(delta, spring);
viewport.advance(delta, spring);
width.advance(delta, spring);
world.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing);
viewport.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing);
width.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing);
assert_eq!(predicted.visual.current.x, world.current - viewport.current);
assert_eq!(predicted.visual.current.width, width.current);
assert_eq!(predicted.visual.target, geometry.visual.target);
+15
View File
@@ -424,6 +424,9 @@ impl Ferese {
};
self.focus_history.remove(id);
#[cfg(feature = "resize-metrics")]
self.resize_metrics
.end(id, self.start_time.elapsed(), crate::resize_metrics::End::Cancelled);
self.render.remove_window(id);
self.window_stack.remove(id);
self.floating_above_fullscreen.remove(&id);
@@ -541,6 +544,12 @@ impl Ferese {
.transaction(&id)
.is_some_and(|transaction| transaction.accepts(committed_serial))
{
#[cfg(feature = "resize-metrics")]
{
self.measure_resize_pauses(self.start_time.elapsed());
self.resize_metrics
.end(id, self.start_time.elapsed(), crate::resize_metrics::End::Commit);
}
self.windows.clear_transaction(&id);
}
@@ -578,6 +587,9 @@ impl Ferese {
// This is the first mapped client buffer, not a user resize.
// Do not animate from the temporary placement box.
self.windows.set_geometry(id, WindowGeometry::new(rect, Some(size)));
#[cfg(feature = "resize-metrics")]
self.resize_metrics
.end(id, self.start_time.elapsed(), crate::resize_metrics::End::Cancelled);
self.windows.clear_transaction(&id);
self.relayout();
}
@@ -614,6 +626,9 @@ impl Ferese {
if self.workspaces.set_floating_rect(id, rect).is_ok() {
self.windows.set_geometry(id, WindowGeometry::new(rect, Some(size)));
#[cfg(feature = "resize-metrics")]
self.resize_metrics
.end(id, self.start_time.elapsed(), crate::resize_metrics::End::Cancelled);
self.windows.clear_transaction(&id);
self.render.clear_snapshot(&id);
self.relayout();