From 28e03a2624858feeab91f7d42eb28e824b95fdf4 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Fri, 2 Oct 2026 00:49:52 +0100 Subject: [PATCH] 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. --- compositor/Cargo.toml | 4 + compositor/src/backends/direct.rs | 1 + compositor/src/main.rs | 5 + compositor/src/overview.rs | 7 +- compositor/src/resize_metrics.rs | 241 ++++++++++ compositor/src/state.rs | 8 +- compositor/src/state/animation.rs | 346 ++++++++++++++- compositor/src/state/floating.rs | 3 + compositor/src/state/layout.rs | 28 +- compositor/src/state/outputs.rs | 4 + compositor/src/state/prediction.rs | 23 +- compositor/src/state/windows.rs | 15 + crates/ferese-animation/src/lib.rs | 171 +++++--- crates/ferese-animation/src/spring.rs | 96 ++++ crates/ferese-animation/src/traces.rs | 604 ++++++++++++++++++++++++++ docs/installation.md | 5 + scripts/install.sh | 8 + scripts/tests/test_portal_isolated.py | 17 + 18 files changed, 1502 insertions(+), 84 deletions(-) create mode 100644 compositor/src/resize_metrics.rs create mode 100644 crates/ferese-animation/src/spring.rs create mode 100644 crates/ferese-animation/src/traces.rs diff --git a/compositor/Cargo.toml b/compositor/Cargo.toml index 4c559f9..6e515a9 100644 --- a/compositor/Cargo.toml +++ b/compositor/Cargo.toml @@ -29,3 +29,7 @@ xcursor.workspace = true [dev-dependencies] tempfile.workspace = true + +[features] +# Opt-in counters/histograms with a per-client exit summary. +resize-metrics = [] diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index d9f0127..509765a 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -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() { diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 02e34fd..4c9b196 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -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> { } }); + #[cfg(feature = "resize-metrics")] + state.resize_metrics.dump(); + runner.borrow_mut().stop(); if let Some(child) = &mut child { terminate_child(child); diff --git a/compositor/src/overview.rs b/compositor/src/overview.rs index bf280cd..c6e1308 100644 --- a/compositor/src/overview.rs +++ b/compositor/src/overview.rs @@ -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(); diff --git a/compositor/src/resize_metrics.rs b/compositor/src/resize_metrics.rs new file mode 100644 index 0000000..0874f3b --- /dev/null +++ b/compositor/src/resize_metrics.rs @@ -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, + app_id: String, + started: Option, + target_changed: Option, + last_observation: Duration, + handoff_started: Option, + 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, + workspace_started: HashMap, + workspace_duration: Histogram, +} + +impl ResizeMetrics { + pub(crate) fn begin(&mut self, id: WindowId, workspace: Option, 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 { + 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, 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); + } +} diff --git a/compositor/src/state.rs b/compositor/src/state.rs index b7a057a..e1c5f27 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -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, pub(crate) backdrop_generation: u64, viewport_animations: HashMap, + paused_workspaces: HashSet, + #[cfg(feature = "resize-metrics")] + pub(crate) resize_metrics: crate::resize_metrics::ResizeMetrics, focus_swipe: Option, workspace_slides: HashMap, workspace_slide_offsets: HashMap, @@ -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(), diff --git a/compositor/src/state/animation.rs b/compositor/src/state/animation.rs index a11af6b..e747faf 100644 --- a/compositor/src/state/animation.rs +++ b/compositor/src/state/animation.rs @@ -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::>(); + #[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::>(); + 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()); + } } diff --git a/compositor/src/state/floating.rs b/compositor/src/state/floating.rs index e3ea2a4..786a4f4 100644 --- a/compositor/src/state/floating.rs +++ b/compositor/src/state/floating.rs @@ -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 { diff --git a/compositor/src/state/layout.rs b/compositor/src/state/layout.rs index c6a2d49..61c9822 100644 --- a/compositor/src/state/layout.rs +++ b/compositor/src/state/layout.rs @@ -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) diff --git a/compositor/src/state/outputs.rs b/compositor/src/state/outputs.rs index b3dd551..954cf90 100644 --- a/compositor/src/state/outputs.rs +++ b/compositor/src/state/outputs.rs @@ -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(); diff --git a/compositor/src/state/prediction.rs b/compositor/src/state/prediction.rs index 3fc9000..ee4ebfe 100644 --- a/compositor/src/state/prediction.rs +++ b/compositor/src/state/prediction.rs @@ -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); diff --git a/compositor/src/state/windows.rs b/compositor/src/state/windows.rs index 1f9e7fd..df6c0e3 100644 --- a/compositor/src/state/windows.rs +++ b/compositor/src/state/windows.rs @@ -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(); diff --git a/crates/ferese-animation/src/lib.rs b/crates/ferese-animation/src/lib.rs index 2fdce03..0b45c8d 100644 --- a/crates/ferese-animation/src/lib.rs +++ b/crates/ferese-animation/src/lib.rs @@ -2,8 +2,15 @@ use std::time::Duration; use ferese_layout::Rect; -const MAX_FRAME_DELTA: Duration = Duration::from_millis(100); -const MAX_STEP_SECONDS: f64 = 1.0 / 240.0; +mod spring; + +/// Crossing behavior belongs to the property, not the physical solver. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CrossingPolicy { + NoCrossing, + AllowOvershoot, +} + const CLIENT_COMMIT_TIMEOUT: Duration = Duration::from_millis(500); #[derive(Clone, Copy, Debug, PartialEq)] @@ -93,32 +100,35 @@ impl AnimatedValue { self.velocity = 0.0; } + /// Compatibility entry point; existing presentation properties do not cross. pub fn advance(&mut self, delta: Duration, config: SpringConfig) -> bool { + self.advance_with_policy(delta, config, CrossingPolicy::NoCrossing) + } + + pub fn advance_with_policy(&mut self, delta: Duration, config: SpringConfig, policy: CrossingPolicy) -> bool { let config = config.normalized(); - if (self.current - self.target).abs() <= config.position_tolerance - && self.velocity.abs() <= config.velocity_tolerance - { + let settled = |value: &Self| { + (value.current - value.target).abs() <= config.position_tolerance + && value.velocity.abs() <= config.velocity_tolerance + }; + if delta.is_zero() { + return !settled(self); + } + + if settled(self) { self.snap(); return false; } - let seconds = delta.min(MAX_FRAME_DELTA).as_secs_f64(); - let steps = (seconds / MAX_STEP_SECONDS).ceil().max(1.0) as usize; - let step = seconds / steps as f64; - - for _ in 0..steps { - let previous_error = self.current - self.target; - advance_value(&mut self.current, self.target, &mut self.velocity, step, config); - let current_error = self.current - self.target; - if previous_error != 0.0 && previous_error.signum() != current_error.signum() { - self.snap(); - break; - } - } - - if (self.current - self.target).abs() <= config.position_tolerance - && self.velocity.abs() <= config.velocity_tolerance - { + advance_property( + &mut self.current, + self.target, + &mut self.velocity, + delta.as_secs_f64(), + config, + policy, + ); + if settled(self) { self.snap(); false } else { @@ -146,34 +156,54 @@ impl AnimatedRect { } pub fn advance(&mut self, delta: Duration, config: SpringConfig) -> bool { + self.advance_with_policy(delta, config, CrossingPolicy::NoCrossing) + } + + pub fn advance_with_policy(&mut self, delta: Duration, config: SpringConfig, policy: CrossingPolicy) -> bool { + let config = config.normalized(); + if delta.is_zero() { + return !self.is_settled(config); + } + if self.is_settled(config) { self.snap(); return false; } - let config = config.normalized(); - let seconds = delta.min(MAX_FRAME_DELTA).as_secs_f64(); - let steps = (seconds / MAX_STEP_SECONDS).ceil().max(1.0) as usize; - let step = seconds / steps as f64; - - for _ in 0..steps { - advance_value(&mut self.current.x, self.target.x, &mut self.velocity.x, step, config); - advance_value(&mut self.current.y, self.target.y, &mut self.velocity.y, step, config); - advance_value( - &mut self.current.width, - self.target.width, - &mut self.velocity.width, - step, - config, - ); - advance_value( - &mut self.current.height, - self.target.height, - &mut self.velocity.height, - step, - config, - ); - } + // The old crossing clamp applied independently to each component. + let dt = delta.as_secs_f64(); + advance_property( + &mut self.current.x, + self.target.x, + &mut self.velocity.x, + dt, + config, + policy, + ); + advance_property( + &mut self.current.y, + self.target.y, + &mut self.velocity.y, + dt, + config, + policy, + ); + advance_property( + &mut self.current.width, + self.target.width, + &mut self.velocity.width, + dt, + config, + policy, + ); + advance_property( + &mut self.current.height, + self.target.height, + &mut self.velocity.height, + dt, + config, + policy, + ); if self.is_settled(config) { self.snap(); @@ -300,9 +330,15 @@ impl WindowGeometry { pub fn set_logical_target(&mut self, rect: Rect, now: Duration) -> Option { let rect = normalized_rect(rect); + if self.zoom.is_some() { + return self.set_presentation_mode(rect, self.mode, now); + } + + let before = self.visual.current; let size = ClientSize::from_rect(rect); self.logical = rect; self.visual.set_target(rect); + debug_assert_eq!(self.visual.current, before); self.client.request_size(size, now).then_some(size) } @@ -321,6 +357,7 @@ impl WindowGeometry { pub fn set_presentation_mode(&mut self, rect: Rect, mode: PresentationMode, now: Duration) -> Option { let rect = normalized_rect(rect); + let before = (self.visual.current, self.decorations); if self.mode != mode || (self.zoom.is_some() && self.logical != rect) { let mut progress = AnimatedValue::new(0.0); progress.set_target(1.0); @@ -348,6 +385,7 @@ impl WindowGeometry { self.mode = mode; self.logical = rect; self.visual.set_target(rect); + debug_assert_eq!((self.visual.current, self.decorations), before); self.presentation_size_request(now) } @@ -398,6 +436,10 @@ impl WindowGeometry { return false; } + if delta.is_zero() { + return self.zoom.is_some() || !self.visual.is_settled(config); + } + if let Some(zoom) = &mut self.zoom { let target = self.visual.target; // Spring tolerances describe pixels and pixels/second, whereas this @@ -413,7 +455,9 @@ impl WindowGeometry { velocity_tolerance: config.velocity_tolerance / travel, ..config }; - let active = zoom.progress.advance(delta, progress_config); + let active = zoom + .progress + .advance_with_policy(delta, progress_config, CrossingPolicy::NoCrossing); let p = zoom.progress.current; let lerp = |a: f64, b: f64| a + (b - a) * p; self.visual.current = Rect::new( @@ -443,7 +487,8 @@ impl WindowGeometry { return active; } - self.visual.advance(delta, config) + self.visual + .advance_with_policy(delta, config, CrossingPolicy::NoCrossing) } pub fn inverse_visual_point(&self, x: f64, y: f64) -> Option<(f64, f64)> { @@ -467,19 +512,27 @@ impl WindowGeometry { } } -fn advance_value(current: &mut f64, target: f64, velocity: &mut f64, delta: f64, config: SpringConfig) { - let acceleration = (-config.stiffness * (*current - target) - config.damping * *velocity) / config.mass; - *velocity += acceleration * delta; - let previous_error = *current - target; - *current += *velocity * delta; - let current_error = *current - target; - - if previous_error != 0.0 && previous_error.signum() != current_error.signum() { +fn advance_property( + current: &mut f64, + target: f64, + velocity: &mut f64, + dt: f64, + config: SpringConfig, + policy: CrossingPolicy, +) { + if policy == CrossingPolicy::NoCrossing && spring::crosses(*current, *velocity, target, dt, config) { *current = target; *velocity = 0.0; + } else { + (*current, *velocity) = spring::solve(*current, *velocity, target, dt, config); } } +#[cfg(test)] +fn advance_value(current: &mut f64, target: f64, velocity: &mut f64, dt: f64, config: SpringConfig) { + (*current, *velocity) = spring::solve(*current, *velocity, target, dt, config); +} + fn finite_or_zero(value: f64) -> f64 { if value.is_finite() { value } else { 0.0 } } @@ -893,7 +946,7 @@ mod tests { } #[test] - fn scalar_spring_does_not_overshoot_target() { + fn scalar_presentation_clamp_does_not_overshoot_and_clears_velocity() { let mut value = AnimatedValue::new(0.0); value.velocity = 20_000.0; value.set_target(100.0); @@ -901,6 +954,9 @@ mod tests { for _ in 0..60 { value.advance(Duration::from_secs_f64(1.0 / 60.0), SpringConfig::default()); assert!(value.current <= 100.0); + if value.current == value.target { + assert_eq!(value.velocity, 0.0); + } } } @@ -1024,3 +1080,6 @@ mod tests { assert_eq!(geometry.visual.current, target); } } + +#[cfg(test)] +mod traces; diff --git a/crates/ferese-animation/src/spring.rs b/crates/ferese-animation/src/spring.rs new file mode 100644 index 0000000..3801fe5 --- /dev/null +++ b/crates/ferese-animation/src/spring.rs @@ -0,0 +1,96 @@ +//! Exact physical spring motion. Presentation policy is deliberately separate. +use super::SpringConfig; + +/// Solve m y'' + c y' + k y = 0, with y = current - target. +/// No settling tolerance or crossing clamp is applied here. +pub(super) fn solve(current: f64, velocity: f64, target: f64, dt: f64, config: SpringConfig) -> (f64, f64) { + if dt <= 0.0 || !dt.is_finite() { + return (current, velocity); + } + + let config = config.normalized(); + let omega2 = config.stiffness / config.mass; + let omega = omega2.sqrt(); + let a = config.damping / (2.0 * config.mass); + let d = (a - omega) * (a + omega); + let y = current - target; + let z2 = d * dt * dt; + + // cos/sinc and cosh/sinhc have the same power series in signed z². + // This keeps the actual discriminant, including near-critical damping, + // rather than replacing a neighborhood with the critical solution. + if z2.abs() < 0.01 { + let c = 1.0 + z2 * (0.5 + z2 * (1.0 / 24.0 + z2 * (1.0 / 720.0 + z2 / 40320.0))); + let s = dt * (1.0 + z2 * (1.0 / 6.0 + z2 * (1.0 / 120.0 + z2 * (1.0 / 5040.0 + z2 / 362880.0)))); + let e = (-a * dt).exp(); + return ( + target + e * (y * c + (velocity + a * y) * s), + e * (velocity * c - (a * velocity + omega2 * y) * s), + ); + } + + if d < 0.0 { + let w = (-d).sqrt(); + let (sin, cos) = (w * dt).sin_cos(); + let e = (-a * dt).exp(); + let s = sin / w; + ( + target + e * (y * cos + (velocity + a * y) * s), + e * (velocity * cos - (a * velocity + omega2 * y) * s), + ) + } else { + // Avoid -a + sqrt(a²-omega²) cancellation and exp/cosh overflow. + let q = d.sqrt(); + let fast = -a - q; + let slow = -omega2 / (a + q); + let slow_coefficient = (velocity - fast * y) / (2.0 * q); + let fast_coefficient = (slow * y - velocity) / (2.0 * q); + let slow_term = slow_coefficient * (slow * dt).exp(); + let fast_term = fast_coefficient * (fast * dt).exp(); + (target + slow_term + fast_term, slow * slow_term + fast * fast_term) + } +} + +/// True if the physical trajectory reaches equilibrium anywhere in (0, dt]. +/// Endpoint-only tests miss an even number of crossings after a long stall. +pub(super) fn crosses(current: f64, velocity: f64, target: f64, dt: f64, config: SpringConfig) -> bool { + if dt <= 0.0 { + return false; + } + + let config = config.normalized(); + let a = config.damping / (2.0 * config.mass); + let omega = (config.stiffness / config.mass).sqrt(); + let d = (a - omega) * (a + omega); + let y = current - target; + let b = velocity + a * y; + if y == 0.0 && velocity == 0.0 { + return false; + } + + let crossing = if d < 0.0 { + let w = (-d).sqrt(); + let phase = (-y * w).atan2(b).rem_euclid(std::f64::consts::PI); + let phase = if phase == 0.0 { std::f64::consts::PI } else { phase }; + phase / w + } else if y == 0.0 || b == 0.0 { + return false; + } else { + let t = -y / b; + if t <= 0.0 { + return false; + } + + // atanh(q*t)/q = t * (1 + (q*t)²/3 + ...), including q = 0. + let z2 = d * t * t; + if z2 < 1e-8 { + t * (1.0 + z2 * (1.0 / 3.0 + z2 / 5.0)) + } else if z2 < 1.0 { + let q = d.sqrt(); + (q * t).atanh() / q + } else { + return false; + } + }; + crossing <= dt +} diff --git a/crates/ferese-animation/src/traces.rs b/crates/ferese-animation/src/traces.rs new file mode 100644 index 0000000..44c92ad --- /dev/null +++ b/crates/ferese-animation/src/traces.rs @@ -0,0 +1,604 @@ +//! Deterministic elapsed-time traces and the frozen pre-analytic oracle. +use super::*; + +#[derive(Debug, Clone, Copy)] +struct Sample { + time: Duration, + current: f64, + velocity: f64, + target: f64, +} + +struct FakeClock { + now: Duration, + last: Duration, + paused: bool, + trace: Vec, +} + +impl FakeClock { + fn new() -> Self { + Self { + now: Duration::ZERO, + last: Duration::ZERO, + paused: false, + trace: Vec::new(), + } + } + + fn sample(&mut self, time: Duration, value: &mut AnimatedValue, config: SpringConfig) -> Sample { + assert!(time >= self.now); + self.now = time; + let delta = time - self.last; + self.last = time; + if !self.paused { + value.advance(delta, config); + } + let sample = Sample { + time, + current: value.current, + velocity: value.velocity, + target: value.target, + }; + self.trace.push(sample); + sample + } + + fn pause(&mut self) { + self.paused = true; + } + + fn resume(&mut self, time: Duration) { + self.now = time; + self.last = time; + self.paused = false; + } +} + +// Frozen scalar integrator, including its cap, substeps and crossing snap. +fn legacy_advance(value: &mut AnimatedValue, delta: Duration, config: SpringConfig) { + let config = config.normalized(); + let settled = |v: &AnimatedValue| { + (v.current - v.target).abs() <= config.position_tolerance && v.velocity.abs() <= config.velocity_tolerance + }; + if settled(value) { + value.snap(); + return; + } + + let seconds = delta.min(Duration::from_millis(100)).as_secs_f64(); + let steps = (seconds / (1.0 / 240.0)).ceil().max(1.0) as usize; + let dt = seconds / steps as f64; + for _ in 0..steps { + let old = value.current - value.target; + value.velocity += (-config.stiffness * old - config.damping * value.velocity) / config.mass * dt; + value.current += value.velocity * dt; + if old != 0.0 && old.signum() != (value.current - value.target).signum() { + value.snap(); + break; + } + } + if settled(value) { + value.snap(); + } +} + +fn close(a: f64, b: f64, epsilon: f64) { + assert!((a - b).abs() <= epsilon, "{a} != {b}, tolerance {epsilon}"); +} + +#[test] +fn capture_old_golden_trace_and_retarget_policy() { + let mut value = AnimatedValue::new(0.0); + value.set_target(500.0); + for i in 1..=18 { + legacy_advance(&mut value, Duration::from_secs_f64(1.0 / 60.0), SpringConfig::default()); + if [1, 3, 6, 12, 18].contains(&i) { + println!("old normal {i}: {}, {}", value.current, value.velocity); + } + } + + let mut value = AnimatedValue::new(0.0); + value.set_target(1000.0); + legacy_advance(&mut value, Duration::from_millis(40), SpringConfig::default()); + println!("old retarget before: {}, {}", value.current, value.velocity); + let position = value.current; + let velocity = value.velocity; + value.retarget_preserving_motion(2000.0); + assert_eq!(value.current, position); + assert_eq!(value.velocity, velocity); + value.retarget_preserving_motion(-500.0); + println!("old reversal: {}, {}", value.current, value.velocity); + assert_eq!(value.current, position); + assert_eq!(value.velocity, velocity * 0.35); +} + +#[test] +fn long_stall_uses_the_whole_elapsed_duration() { + let config = SpringConfig { + mass: 1.0, + stiffness: 100.0, + damping: 20.0, + ..SpringConfig::default() + }; + let mut clock = FakeClock::new(); + let mut value = AnimatedValue::new(0.0); + value.set_target(500.0); + clock.sample(Duration::from_millis(16), &mut value, config); + let sample = clock.sample(Duration::from_millis(416), &mut value, config); + let t: f64 = 0.416; + let decay = (-10.0 * t).exp(); + close(sample.current, 500.0 - 500.0 * (1.0 + 10.0 * t) * decay, 1e-8); + close(sample.velocity, 50000.0 * t * decay, 1e-8); + assert_eq!(sample.time, Duration::from_millis(416)); + assert_eq!(sample.target, 500.0); +} + +#[test] +fn physical_solver_crosses_equilibrium_without_zeroing_velocity() { + let mut x = 0.0; + let mut v = 20000.0; + advance_value(&mut x, 100.0, &mut v, 0.01, SpringConfig::default()); + assert!(x > 100.0, "physical solution was snapped: {x}"); + assert!(v > 0.0, "velocity was erased: {v}"); +} + +#[test] +fn fake_clock_pause_excludes_three_hundred_milliseconds() { + let mut clock = FakeClock::new(); + let mut value = AnimatedValue::new(0.0); + value.set_target(500.0); + clock.sample(Duration::from_millis(40), &mut value, SpringConfig::default()); + let frozen = value; + clock.pause(); + clock.sample(Duration::from_millis(340), &mut value, SpringConfig::default()); + assert_eq!(value, frozen); + clock.resume(Duration::from_millis(340)); + clock.sample(Duration::from_millis(356), &mut value, SpringConfig::default()); + let mut expected = frozen; + expected.advance(Duration::from_millis(16), SpringConfig::default()); + assert_eq!(value, expected); +} + +fn physical(x: f64, v: f64, target: f64, dt: f64, config: SpringConfig) -> (f64, f64) { + let (mut x, mut v) = (x, v); + advance_value(&mut x, target, &mut v, dt, config); + (x, v) +} + +// Independent characteristic-root reference, used away from ill-conditioned roots. +fn reference(x: f64, v: f64, target: f64, dt: f64, config: SpringConfig) -> (f64, f64) { + let a = config.damping / (2.0 * config.mass); + let omega2 = config.stiffness / config.mass; + let y = x - target; + let d = a * a - omega2; + if d.abs() < 1e-10 { + let b = v + a * y; + let e = (-a * dt).exp(); + return (target + e * (y + b * dt), e * (v - a * b * dt)); + } + + if d < 0.0 { + let w = (-d).sqrt(); + let b = (v + a * y) / w; + let (s, c) = (w * dt).sin_cos(); + let e = (-a * dt).exp(); + (target + e * (y * c + b * s), e * (v * c - (a * v + omega2 * y) / w * s)) + } else { + let q = d.sqrt(); + let r1 = -a + q; + let r2 = -a - q; + let c1 = (v - r2 * y) / (r1 - r2); + let c2 = y - c1; + let e1 = (r1 * dt).exp(); + let e2 = (r2 * dt).exp(); + (target + c1 * e1 + c2 * e2, c1 * r1 * e1 + c2 * r2 * e2) + } +} + +#[test] +fn solver_reference_regimes_initial_states_and_times() { + for damping in [0.0, 5.0, 20.0, 20.0 * (1.0 - 1e-9), 20.0 * (1.0 + 1e-9), 53.0, 2000.0] { + let config = SpringConfig { + stiffness: 100.0, + damping, + ..SpringConfig::default() + }; + for (x, v, target) in [ + (0., 0., 0.), + (0., 100., 0.), + (500., 0., 0.), + (-500., 1234., 200.), + (500., -1234., -200.), + ] { + for dt in [0.0, 1e-12, 0.0083, 0.05, 0.4, 5.0] { + let actual = physical(x, v, target, dt, config); + let expected = reference(x, v, target, dt, config); + close(actual.0, expected.0, 1e-6); + close(actual.1, expected.1, 1e-5); + } + } + } +} + +#[test] +fn near_critical_sweep_has_no_regime_jump() { + let base = SpringConfig { + stiffness: 100.0, + damping: 20.0, + ..SpringConfig::default() + }; + for dt in [1e-12, 0.01, 0.2, 2.0] { + let critical = physical(-500., 1234., 100., dt, base); + for offset in [-1e-9, -1e-12, 0., 1e-12, 1e-9] { + let config = SpringConfig { + damping: 20.0 * (1.0 + offset), + ..base + }; + let actual = physical(-500., 1234., 100., dt, config); + close(actual.0, critical.0, 1e-6); + close(actual.1, critical.1, 1e-5); + } + } +} + +#[test] +fn normal_trace_records_the_old_numerical_error() { + // Captured BEFORE replacing the integrator. Duration rounds to nanoseconds, + // giving five old substeps at 60 Hz, not four exact 1/240-second steps. + let gold = [ + (1, 45.440622413787736, 3968.266514512032), + (3, 204.05862514520575, 4637.558952650968), + (6, 375.42995175442246, 2376.3948049425335), + (12, 482.2490947739653, 366.6034676365624), + (18, 497.62691927670414, 49.586155106479524), + ]; + let mut old = AnimatedValue::new(0.0); + old.set_target(500.0); + let mut max_error: f64 = 0.0; + for i in 1..=18 { + legacy_advance(&mut old, Duration::from_secs_f64(1.0 / 60.0), SpringConfig::default()); + if let Some((_, x, v)) = gold.iter().find(|(n, _, _)| *n == i) { + close(old.current, *x, 1e-9); + close(old.velocity, *v, 1e-9); + } + let dt = Duration::from_secs_f64(1.0 / 60.0).as_secs_f64() * i as f64; + let exact = reference(0., 0., 500., dt, SpringConfig::default()); + max_error = max_error.max((old.current - exact.0).abs()); + } + assert!( + max_error > 13.0 && max_error < 14.0, + "old integrator error: {max_error}" + ); +} + +#[test] +fn retarget_golden_policy_values() { + let mut old = AnimatedValue::new(0.0); + old.set_target(1000.0); + legacy_advance(&mut old, Duration::from_millis(40), SpringConfig::default()); + close(old.current, 318.94300938241565, 1e-9); + close(old.velocity, 9837.123292018367, 1e-9); + old.retarget_preserving_motion(2000.); + close(old.velocity, 9837.123292018367, 1e-9); + old.retarget_preserving_motion(-500.); + close(old.velocity, 3442.993152206428, 1e-9); +} + +#[test] +fn zero_elapsed_time_is_a_strict_noop_even_inside_settling_tolerance() { + let mut value = AnimatedValue::new(0.05); + value.set_target(0.0); + let before = value; + value.advance(Duration::ZERO, SpringConfig::default()); + assert_eq!(value, before); +} + +#[test] +fn gesture_flick_trace_crosses_once_and_settles_while_clamp_stops_at_crossing() { + let config = SpringConfig::default(); + let mut free = AnimatedValue::new(0.0); + free.velocity = 20000.0; + free.set_target(100.0); + let mut clamped = free; + let mut crossings = 0; + let mut previous_error: f64 = -100.0; + for ms in 1..=1500 { + free.advance_with_policy(Duration::from_millis(1), config, CrossingPolicy::AllowOvershoot); + clamped.advance_with_policy(Duration::from_millis(1), config, CrossingPolicy::NoCrossing); + let error = free.current - free.target; + if error != 0.0 && previous_error.signum() != error.signum() { + crossings += 1; + } + if error != 0.0 { + previous_error = error; + } + assert!(clamped.current <= clamped.target); + if clamped.current == clamped.target { + assert_eq!(clamped.velocity, 0.0); + } + if ms <= 200 { + let exact = reference(0., 20000., 100., ms as f64 / 1000.0, config); + close(free.current, exact.0, 1e-7); + close(free.velocity, exact.1, 1e-6); + } + } + assert_eq!(crossings, 1); + assert_eq!((free.current, free.velocity), (100., 0.)); + assert_eq!((clamped.current, clamped.velocity), (100., 0.)); +} + +#[test] +fn rapid_retarget_trace_preserves_position_and_the_golden_policy() { + let mut clock = FakeClock::new(); + let mut value = AnimatedValue::new(0.0); + value.set_target(1000.0); + clock.sample(Duration::from_millis(40), &mut value, SpringConfig::default()); + let before = value; + value.retarget_preserving_motion(2000.0); + assert_eq!(value.current, before.current); + assert_eq!(value.velocity, before.velocity); + clock.sample(Duration::from_millis(65), &mut value, SpringConfig::default()); + let before = value; + value.retarget_preserving_motion(-500.0); + assert_eq!(value.current, before.current); + assert_eq!(value.velocity, before.velocity * 0.35); + clock.sample(Duration::from_millis(81), &mut value, SpringConfig::default()); + assert_ne!(value.velocity, 0.0); + + // Apply the current policy to the exact old in-flight state too. + value.current = 318.94300938241565; + value.velocity = 9837.123292018367; + value.retarget_preserving_motion(-500.0); + close(value.velocity, 3442.993152206428, 1e-9); +} + +#[test] +fn interrupted_resize_and_zoom_targets_are_c0_continuous() { + let initial = Rect::new(0., 0., 400., 300.); + let mut geometry = WindowGeometry::new(initial, None); + geometry.set_logical_target(Rect::new(100., 50., 900., 600.), Duration::ZERO); + geometry.advance(Duration::from_millis(40), SpringConfig::default(), true); + for mode in [ + PresentationMode::Normal, + PresentationMode::Maximized, + PresentationMode::Fullscreen, + PresentationMode::Normal, + ] { + let before = (geometry.visual.current, geometry.decorations); + geometry.set_presentation_mode(Rect::new(200., 100., 700., 500.), mode, Duration::from_millis(40)); + assert_eq!((geometry.visual.current, geometry.decorations), before); + geometry.advance(Duration::ZERO, SpringConfig::default(), true); + assert_eq!((geometry.visual.current, geometry.decorations), before); + geometry.advance(Duration::from_millis(16), SpringConfig::default(), true); + } +} + +#[test] +fn variable_cadence_traces_match_reference_at_every_absolute_timestamp() { + let config = SpringConfig::default(); + let mut schedules = vec![vec![16600], vec![8300], vec![5000, 22000, 11000, 37000]]; + for mut seed in [1u64, 0x12345678, 0xdeadbeef] { + let mut seeded = Vec::new(); + for _ in 0..64 { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + seeded.push((1 + (seed >> 32) % 47) * 1000); + } + schedules.push(seeded); + } + + for schedule in schedules { + let mut clock = FakeClock::new(); + let mut value = AnimatedValue::new(-100.0); + value.set_target(500.0); + let mut elapsed = Duration::ZERO; + let mut settled_at = None; + for i in 0..500 { + elapsed += Duration::from_micros(schedule[i % schedule.len()]); + let sample = clock.sample(elapsed, &mut value, config); + let exact = reference(-100., 0., 500., elapsed.as_secs_f64(), config); + if sample.current == sample.target && sample.velocity == 0.0 { + assert_eq!((sample.current, sample.velocity), (500., 0.)); + settled_at.get_or_insert(elapsed); + } else { + close(sample.current, exact.0, config.position_tolerance); + close(sample.velocity, exact.1, config.velocity_tolerance); + } + if elapsed >= Duration::from_secs(2) { + break; + } + } + let settled_at = settled_at.unwrap(); + let mut reference_settle_us = 0; + while { + let (x, v) = reference(-100., 0., 500., reference_settle_us as f64 / 1e6, config); + (x - 500.).abs() > config.position_tolerance || v.abs() > config.velocity_tolerance + } { + reference_settle_us += 100; + } + let exact_settle = Duration::from_micros(reference_settle_us); + assert!(settled_at.abs_diff(exact_settle) <= Duration::from_micros(*schedule.iter().max().unwrap() + 100)); + } +} + +#[test] +fn scalar_and_rect_share_math_and_rect_clamps_only_the_crossing_component() { + let mut value = AnimatedValue::new(0.0); + value.set_target(100.0); + value.velocity = 20000.0; + let mut rect = AnimatedRect::new(Rect::new(0., 0., 400., 300.)); + rect.set_target(Rect::new(100., 500., 400., 300.)); + rect.velocity.x = value.velocity; + for policy in [CrossingPolicy::NoCrossing, CrossingPolicy::AllowOvershoot] { + let mut scalar = value; + let mut shape = rect; + scalar.advance_with_policy(Duration::from_millis(10), SpringConfig::default(), policy); + shape.advance_with_policy(Duration::from_millis(10), SpringConfig::default(), policy); + assert_eq!(scalar.current, shape.current.x); + assert_eq!(scalar.velocity, shape.velocity.x); + assert!(shape.current.y > 0.0 && shape.current.y < 500.0); + if policy == CrossingPolicy::NoCrossing { + assert_eq!(shape.current.x, 100.0); + assert_eq!(shape.velocity.x, 0.0); + assert_ne!(shape.velocity.y, 0.0); + } + } +} + +#[test] +fn clamp_detects_crossing_inside_a_stall_even_when_endpoints_have_same_sign() { + let config = SpringConfig { + stiffness: 100.0, + damping: 0.0, + ..SpringConfig::default() + }; + let mut value = AnimatedValue::new(0.0); + value.set_target(100.0); + value.advance_with_policy( + Duration::from_secs_f64(std::f64::consts::TAU / 10.0), + config, + CrossingPolicy::NoCrossing, + ); + assert_eq!((value.current, value.velocity), (100., 0.)); +} + +#[test] +fn solver_is_finite_for_realistic_ranges_and_invalid_configuration_falls_back() { + for mass in [0.01, 1.0, 100.0] { + for stiffness in [0.1, 700.0, 100000.0] { + for damping in [0.0, 0.01, 53.0, 1000000.0] { + for dt in [1e-12, 0.008, 0.4, 10.0, 3600.0] { + let config = SpringConfig { + mass, + stiffness, + damping, + ..SpringConfig::default() + }; + let (x, v) = physical(-10000., 50000., 10000., dt, config); + assert!(x.is_finite() && v.is_finite(), "{config:?} dt {dt}: {x} {v}"); + } + } + } + } + for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] { + for config in [ + SpringConfig { + mass: bad, + ..SpringConfig::default() + }, + SpringConfig { + stiffness: bad, + ..SpringConfig::default() + }, + ] { + assert_eq!( + physical(0., 10., 100., 0.1, config), + physical(0., 10., 100., 0.1, SpringConfig::default()) + ); + } + } + + for bad in [-1.0, f64::NAN, f64::INFINITY] { + let config = SpringConfig { + damping: bad, + ..SpringConfig::default() + }; + assert_eq!( + physical(0., 10., 100., 0.1, config), + physical(0., 10., 100., 0.1, SpringConfig::default()) + ); + } +} + +#[test] +fn physical_zero_and_negative_time_do_not_move_state() { + for dt in [0.0, -0.01] { + assert_eq!(physical(5.0, 100.0, 10.0, dt, SpringConfig::default()), (5.0, 100.0)); + } +} + +#[test] +fn resize_target_rebases_an_existing_zoom_without_a_zero_time_jump() { + let mut geometry = WindowGeometry::new(Rect::new(100., 50., 400., 300.), None); + geometry.set_presentation_mode( + Rect::new(0., 0., 1920., 1080.), + PresentationMode::Fullscreen, + Duration::ZERO, + ); + geometry.advance(Duration::from_millis(40), SpringConfig::default(), true); + let before = (geometry.visual.current, geometry.decorations); + geometry.set_logical_target(Rect::new(0., 0., 1600., 900.), Duration::from_millis(40)); + geometry.advance(Duration::ZERO, SpringConfig::default(), true); + assert_eq!((geometry.visual.current, geometry.decorations), before); + geometry.advance(Duration::from_nanos(1), SpringConfig::default(), true); + close(geometry.visual.current.width, before.0.width, 1e-4); +} + +#[test] +fn small_default_normal_motion_matches_legacy_within_half_a_pixel() { + let mut old = AnimatedValue::new(0.0); + old.set_target(10.0); + let mut clock = FakeClock::new(); + let mut exact = old; + for i in 1..=120 { + let step = Duration::from_secs_f64(1.0 / 60.0); + legacy_advance(&mut old, step, SpringConfig::default()); + let sample = clock.sample(step * i, &mut exact, SpringConfig::default()); + close(sample.current, old.current, 0.5); + } +} + +#[test] +fn creation_clock_excludes_earlier_idle_time_and_records_all_sample_fields() { + let mut clock = FakeClock::new(); + let created = Duration::from_secs(10); + clock.resume(created); + let mut value = AnimatedValue::new(0.0); + value.set_target(500.0); + let first = clock.sample(created + Duration::from_millis(16), &mut value, SpringConfig::default()); + let exact = reference(0.0, 0.0, 500.0, 0.016, SpringConfig::default()); + close(first.current, exact.0, 1e-8); + close(first.velocity, exact.1, 1e-8); + assert_eq!(first.time, created + Duration::from_millis(16)); + assert_eq!(first.target, 500.0); + assert_eq!(clock.trace.len(), 1); + value.retarget_preserving_motion(800.0); + let same_time = clock.sample(first.time, &mut value, SpringConfig::default()); + assert_eq!(same_time.current, first.current); + assert_eq!(same_time.velocity, first.velocity); + assert_eq!(same_time.target, 800.0); +} + +#[test] +fn damped_physical_solutions_converge_in_all_three_regimes() { + for damping in [5.0, 20.0, 53.0, 2000.0] { + let config = SpringConfig { + stiffness: 100.0, + damping, + ..SpringConfig::default() + }; + let (x, v) = physical(-500.0, 1234.0, 100.0, 2000.0, config); + close(x, 100.0, 1e-9); + close(v, 0.0, 1e-9); + } +} + +#[test] +fn solver_is_continuous_on_both_sides_of_the_flick_crossing() { + let config = SpringConfig::default(); + let (mut low, mut high) = (0.0, 0.02); + for _ in 0..60 { + let middle = (low + high) / 2.0; + if physical(0.0, 20000.0, 100.0, middle, config).0 < 100.0 { + low = middle; + } else { + high = middle; + } + } + let before = physical(0.0, 20000.0, 100.0, low - 1e-9, config); + let after = physical(0.0, 20000.0, 100.0, high + 1e-9, config); + assert!(before.0 < 100.0 && after.0 > 100.0); + close(before.0, after.0, 1e-4); + close(before.1, after.1, 0.01); + assert!(after.1 > 1000.0); +} diff --git a/docs/installation.md b/docs/installation.md index 7e8fe2e..1f93c99 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -244,6 +244,7 @@ Installer options: ```sh ./scripts/install.sh --dry-run ./scripts/install.sh --offline --release-id my-build +./scripts/install.sh --resize-metrics ./scripts/install.sh --skip-build ``` @@ -252,6 +253,10 @@ cached, or `--skip-build` after building every component from the revision you w install. The latter uses binaries already in `target/release/`, and `./scripts/install.sh --help` lists all available options. +Use `--resize-metrics` to enable resize-barrier counters and duration histograms in +the release build. Ferese prints the measurements to its session log on normal exit. +This option requires building and cannot be combined with `--skip-build`. + ## Logout and recovery Save your work before leaving: **Super+Shift+E** and `feresectl request-logout` ask for diff --git a/scripts/install.sh b/scripts/install.sh index cdea6ae..710c0a4 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -13,6 +13,7 @@ Run from any directory as your normal user. Existing user config is preserved. --skip-build Install existing target/release binaries --replace-portal-config Back up and replace existing Ferese portal configuration --offline Build using only cached Cargo dependencies + --resize-metrics Build with resize-barrier measurements (reported on exit) --dry-run Print commands without building or installing -h, --help Show this help @@ -48,6 +49,7 @@ release_id="$(date -u +%Y%m%d-%H%M%S)-$$" skip_build=false replace_portal_config=false offline=false +resize_metrics=false dry_run=false while (($#)); do case $1 in @@ -59,12 +61,17 @@ while (($#)); do --skip-build) skip_build=true; shift ;; --replace-portal-config) replace_portal_config=true; shift ;; --offline) offline=true; shift ;; + --resize-metrics) resize_metrics=true; shift ;; --dry-run) dry_run=true; shift ;; -h|--help) usage; exit 0 ;; *) fail "unknown option: $1 (see --help)" ;; esac done [[ $release_id =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || fail 'invalid release ID' +if $skip_build && $resize_metrics; then + fail '--resize-metrics requires a build; omit --skip-build' +fi + [[ $(uname -s) == Linux ]] || fail 'Ferese requires Linux' for tool in desktop-file-validate dbus-run-session; do command -v "$tool" >/dev/null || fail "required command not found: $tool" @@ -79,6 +86,7 @@ build=(cargo build --manifest-path "$repo_dir/Cargo.toml" --target-dir "$repo_dir/target" --release --locked -p ferese -p ferese-shell -p ferese-settings -p feresectl -p ferese-lock -p ferese-polkit-agent -p xdg-desktop-portal-ferese) if $offline; then build+=(--offline); fi +if $resize_metrics; then build+=(--features ferese/resize-metrics); fi if ! $skip_build; then ((EUID != 0)) || fail 'build as your normal user, without sudo (or use --skip-build)' command -v cargo >/dev/null || fail 'install the Rust toolchain and Cargo first' diff --git a/scripts/tests/test_portal_isolated.py b/scripts/tests/test_portal_isolated.py index bbf1bcf..f56c2d1 100644 --- a/scripts/tests/test_portal_isolated.py +++ b/scripts/tests/test_portal_isolated.py @@ -101,6 +101,23 @@ class PortalTest(unittest.TestCase): connection = Gio.DBusConnection.new_for_address_sync(env["DBUS_SESSION_BUS_ADDRESS"], Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, None, None) backend = launch("backend", [str(binary)]) + # The frontend can D-Bus-activate an installed backend if it + # starts before this test's binary owns its service name. + def backend_ready(): + bus_name = "org.freedesktop.DBus" + impl_name = "org.freedesktop.impl.portal.desktop.ferese" + owner = connection.call_sync(bus_name, "/org/freedesktop/DBus", bus_name, + "NameHasOwner", GLib.Variant("(s)", (impl_name,)), GLib.VariantType("(b)"), + Gio.DBusCallFlags.NONE, 1000, None).unpack()[0] + if not owner: + return False + + pid = connection.call_sync(bus_name, "/org/freedesktop/DBus", bus_name, + "GetConnectionUnixProcessID", GLib.Variant("(s)", (impl_name,)), GLib.VariantType("(u)"), + Gio.DBusCallFlags.NONE, 1000, None).unpack()[0] + return pid == backend.pid + + until(backend_ready) frontend = launch("frontend", [os.environ.get("FERESE_TEST_PORTAL_FRONTEND", "/usr/libexec/xdg-desktop-portal")]) name, path, iface = "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.ScreenCast" responses = {}