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:
1 parent
42f022fde5
commit
28e03a2624
18 files changed
+1502
-84
No files matched your search
@@ -29,3 +29,7 @@ xcursor.workspace = true
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# Opt-in counters/histograms with a per-client exit summary.
|
||||||
|
resize-metrics = []
|
||||||
@@ -660,6 +660,7 @@ pub(crate) fn sleep_locked_outputs(state: &mut Ferese) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn wake_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() {
|
if let Some(backend) = state.direct_backend.as_mut() {
|
||||||
for device in backend.devices.values_mut() {
|
for device in backend.devices.values_mut() {
|
||||||
for output in device.outputs.values_mut() {
|
for output in device.outputs.values_mut() {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ mod presentation;
|
|||||||
mod private_client;
|
mod private_client;
|
||||||
mod reload;
|
mod reload;
|
||||||
mod render;
|
mod render;
|
||||||
|
#[cfg(feature = "resize-metrics")]
|
||||||
|
mod resize_metrics;
|
||||||
mod resize_transaction;
|
mod resize_transaction;
|
||||||
mod session_lock;
|
mod session_lock;
|
||||||
mod shell_control;
|
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();
|
runner.borrow_mut().stop();
|
||||||
if let Some(child) = &mut child {
|
if let Some(child) = &mut child {
|
||||||
terminate_child(child);
|
terminate_child(child);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::sync::{Arc, Mutex, mpsc};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use ab_glyph::{Font, FontArc, FontVec, ScaleFont};
|
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_core::{OutputId, WorkspaceId};
|
||||||
use ferese_layout::{Direction, Rect, WindowId};
|
use ferese_layout::{Direction, Rect, WindowId};
|
||||||
use smithay::backend::allocator::Fourcc;
|
use smithay::backend::allocator::Fourcc;
|
||||||
@@ -1016,7 +1016,7 @@ impl OverviewMotion {
|
|||||||
|
|
||||||
for presentation in self.presentations.values_mut() {
|
for presentation in self.presentations.values_mut() {
|
||||||
if animations_enabled {
|
if animations_enabled {
|
||||||
active_animation |= presentation.advance(delta, spring);
|
active_animation |= presentation.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing);
|
||||||
} else {
|
} else {
|
||||||
presentation.snap();
|
presentation.snap();
|
||||||
}
|
}
|
||||||
@@ -1029,13 +1029,14 @@ impl OverviewMotion {
|
|||||||
if animations_enabled {
|
if animations_enabled {
|
||||||
// Geometry's 0.1-pixel tolerance is far too coarse for opacity:
|
// Geometry's 0.1-pixel tolerance is far too coarse for opacity:
|
||||||
// it would abruptly drop the last ten percent of the fade.
|
// it would abruptly drop the last ten percent of the fade.
|
||||||
active_animation |= self.opacity.advance(
|
active_animation |= self.opacity.advance_with_policy(
|
||||||
delta,
|
delta,
|
||||||
SpringConfig {
|
SpringConfig {
|
||||||
position_tolerance: 0.001,
|
position_tolerance: 0.001,
|
||||||
velocity_tolerance: 0.005,
|
velocity_tolerance: 0.005,
|
||||||
..spring
|
..spring
|
||||||
},
|
},
|
||||||
|
CrossingPolicy::NoCrossing,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
self.opacity.snap();
|
self.opacity.snap();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ use std::sync::Arc;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use calloop::LoopHandle;
|
use calloop::LoopHandle;
|
||||||
use ferese_animation::{AnimatedValue, ClientSize, PresentationMode, SpringConfig, WindowGeometry};
|
use ferese_animation::{AnimatedValue, ClientSize, CrossingPolicy, PresentationMode, SpringConfig, WindowGeometry};
|
||||||
use ferese_core::{
|
use ferese_core::{
|
||||||
LayoutMode, OutputGeometry, OutputId, OutputWorkspaceMap, WindowPlacement, WorkspaceId, WorkspaceLayout,
|
LayoutMode, OutputGeometry, OutputId, OutputWorkspaceMap, WindowPlacement, WorkspaceId, WorkspaceLayout,
|
||||||
WorkspaceSet,
|
WorkspaceSet,
|
||||||
@@ -348,6 +348,9 @@ pub struct Ferese {
|
|||||||
pub(crate) logout_owner: Option<ObjectId>,
|
pub(crate) logout_owner: Option<ObjectId>,
|
||||||
pub(crate) backdrop_generation: u64,
|
pub(crate) backdrop_generation: u64,
|
||||||
viewport_animations: HashMap<WorkspaceId, AnimatedValue>,
|
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>,
|
focus_swipe: Option<FocusSwipe>,
|
||||||
workspace_slides: HashMap<OutputId, WorkspaceSlide>,
|
workspace_slides: HashMap<OutputId, WorkspaceSlide>,
|
||||||
workspace_slide_offsets: HashMap<WorkspaceId, (f64, f64)>,
|
workspace_slide_offsets: HashMap<WorkspaceId, (f64, f64)>,
|
||||||
@@ -574,6 +577,9 @@ impl Ferese {
|
|||||||
logout_owner: None,
|
logout_owner: None,
|
||||||
backdrop_generation: 0,
|
backdrop_generation: 0,
|
||||||
viewport_animations: HashMap::new(),
|
viewport_animations: HashMap::new(),
|
||||||
|
paused_workspaces: HashSet::new(),
|
||||||
|
#[cfg(feature = "resize-metrics")]
|
||||||
|
resize_metrics: crate::resize_metrics::ResizeMetrics::default(),
|
||||||
focus_swipe: None,
|
focus_swipe: None,
|
||||||
workspace_slides: HashMap::new(),
|
workspace_slides: HashMap::new(),
|
||||||
workspace_slide_offsets: HashMap::new(),
|
workspace_slide_offsets: HashMap::new(),
|
||||||
|
|||||||
@@ -3,6 +3,17 @@ use super::*;
|
|||||||
impl Ferese {
|
impl Ferese {
|
||||||
pub fn advance_animations(&mut self, now: Instant) -> bool {
|
pub fn advance_animations(&mut self, now: Instant) -> bool {
|
||||||
let delta = crate::presentation::frame_delta(&mut self.last_animation_tick, now);
|
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)
|
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 {
|
pub(crate) fn animation_fallback_deadline(&self, interval: std::time::Duration) -> Instant {
|
||||||
self.last_animation_tick + interval.saturating_mul(2)
|
self.last_animation_tick + interval.saturating_mul(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn reset_animation_clock(&mut self) {
|
pub(crate) fn reset_animation_clock(&mut self) {
|
||||||
self.last_animation_tick = Instant::now();
|
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 {
|
fn animations_need_tick(&self) -> bool {
|
||||||
if self.focus_swipe.is_some()
|
if self.focus_swipe.is_some()
|
||||||
|
|| !self.paused_workspaces.is_empty()
|
||||||
|| !self.workspace_slides.is_empty()
|
|| !self.workspace_slides.is_empty()
|
||||||
|| !self.dismissing_popups.is_empty()
|
|| !self.dismissing_popups.is_empty()
|
||||||
|| self.render.snapshots().next().is_some()
|
|| 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 {
|
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() {
|
if !self.animations_need_tick() {
|
||||||
self.sync_window_stacking();
|
self.sync_window_stacking();
|
||||||
return false;
|
return false;
|
||||||
@@ -168,7 +236,23 @@ impl Ferese {
|
|||||||
self.send_window_close(id);
|
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);
|
self.windows.expire_transactions(now);
|
||||||
|
|
||||||
let blocked_workspaces = self
|
let blocked_workspaces = self
|
||||||
@@ -176,8 +260,12 @@ impl Ferese {
|
|||||||
.resizing()
|
.resizing()
|
||||||
.filter_map(|id| self.workspaces.workspace_for_window(*id))
|
.filter_map(|id| self.workspaces.workspace_for_window(*id))
|
||||||
.collect::<HashSet<_>>();
|
.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 animations_enabled = self.animations_enabled;
|
||||||
let animation_speed = self.animation_speed;
|
let animation_speed = self.animation_speed;
|
||||||
|
#[cfg(feature = "resize-metrics")]
|
||||||
|
let resize_metrics = &mut self.resize_metrics;
|
||||||
self.render.retain_snapshots(|id, snapshot| {
|
self.render.retain_snapshots(|id, snapshot| {
|
||||||
if !animations_enabled {
|
if !animations_enabled {
|
||||||
return false;
|
return false;
|
||||||
@@ -201,6 +289,8 @@ impl Ferese {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
let blocked = waiting_for_client || uncovered;
|
let blocked = waiting_for_client || uncovered;
|
||||||
|
#[cfg(feature = "resize-metrics")]
|
||||||
|
resize_metrics.handoff_tick(*id, now, blocked);
|
||||||
let active = crate::presentation::advance_handoff(
|
let active = crate::presentation::advance_handoff(
|
||||||
&mut snapshot.elapsed,
|
&mut snapshot.elapsed,
|
||||||
&mut snapshot.last_tick,
|
&mut snapshot.last_tick,
|
||||||
@@ -213,13 +303,15 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !active {
|
if !active {
|
||||||
|
#[cfg(feature = "resize-metrics")]
|
||||||
|
resize_metrics.handoff_end(*id, now, snapshot.elapsed);
|
||||||
tracing::debug!(?id, bytes = snapshot.bytes(), "released resize handoff snapshot");
|
tracing::debug!(?id, bytes = snapshot.bytes(), "released resize handoff snapshot");
|
||||||
}
|
}
|
||||||
active
|
active
|
||||||
});
|
});
|
||||||
active_animation |= self.render.snapshots().next().is_some();
|
active_animation |= self.render.snapshots().next().is_some();
|
||||||
// Keep scheduling frames while waiting, so the deadline cannot stall.
|
// 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 {
|
for (workspace, viewport) in &mut self.viewport_animations {
|
||||||
if !self
|
if !self
|
||||||
.output_workspaces
|
.output_workspaces
|
||||||
@@ -229,7 +321,7 @@ impl Ferese {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if blocked_workspaces.contains(workspace) {
|
if blocked_workspaces.contains(workspace) || resume_pauses.contains(workspace) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +329,8 @@ impl Ferese {
|
|||||||
viewport.current = swipe.position();
|
viewport.current = swipe.position();
|
||||||
viewport.velocity = 0.0;
|
viewport.velocity = 0.0;
|
||||||
} else if self.animations_enabled {
|
} 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 {
|
} else {
|
||||||
viewport.snap();
|
viewport.snap();
|
||||||
}
|
}
|
||||||
@@ -253,7 +346,7 @@ impl Ferese {
|
|||||||
if self
|
if self
|
||||||
.workspaces
|
.workspaces
|
||||||
.workspace_for_window(id)
|
.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;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -288,23 +381,22 @@ impl Ferese {
|
|||||||
&& let Some(viewport) = self.viewport_animations.get(workspace)
|
&& let Some(viewport) = self.viewport_animations.get(workspace)
|
||||||
{
|
{
|
||||||
if zooming {
|
if zooming {
|
||||||
world_x.current = geometry.visual.current.x + viewport.current;
|
sync_scrolling_coordinates(geometry, world_x, viewport, true);
|
||||||
world_x.velocity = geometry.visual.velocity.x + viewport.velocity;
|
|
||||||
} else if self.animations_enabled {
|
} 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 {
|
} else {
|
||||||
world_x.snap();
|
world_x.snap();
|
||||||
}
|
}
|
||||||
|
|
||||||
if !zooming {
|
if !zooming {
|
||||||
geometry.visual.current.x = world_x.current - viewport.current;
|
sync_scrolling_coordinates(geometry, world_x, viewport, false);
|
||||||
geometry.visual.velocity.x = world_x.velocity - viewport.velocity;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some((_, width)) = record.coupled_width.as_mut() {
|
if let Some((_, width)) = record.coupled_width.as_mut() {
|
||||||
let width_active = if self.animations_enabled {
|
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 {
|
} else {
|
||||||
width.snap();
|
width.snap();
|
||||||
false
|
false
|
||||||
@@ -461,6 +553,25 @@ impl Ferese {
|
|||||||
|
|
||||||
// Inspect current targets, not the previous frame's activity. Target changes
|
// 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.
|
// 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(
|
fn record_needs_tick(
|
||||||
record: &super::window_registry::WindowRecord,
|
record: &super::window_registry::WindowRecord,
|
||||||
focus: f64,
|
focus: f64,
|
||||||
@@ -564,4 +675,217 @@ mod tests {
|
|||||||
record.coupled_width = Some((WorkspaceId(1), AnimatedValue::new(400.0)));
|
record.coupled_width = Some((WorkspaceId(1), AnimatedValue::new(400.0)));
|
||||||
assert!(record_needs_tick(&record, 1.0, 0.0, || true));
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -164,6 +164,9 @@ impl Ferese {
|
|||||||
|
|
||||||
// Direct manipulation follows the pointer, including while the client is
|
// Direct manipulation follows the pointer, including while the client is
|
||||||
// still drawing its next buffer. Rendering and hit testing share this rect.
|
// 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.windows.clear_transaction(&id);
|
||||||
self.render.clear_snapshot(&id);
|
self.render.clear_snapshot(&id);
|
||||||
let Some(record) = self.windows.record_mut(id) else {
|
let Some(record) = self.windows.record_mut(id) else {
|
||||||
|
|||||||
@@ -261,15 +261,27 @@ impl Ferese {
|
|||||||
.remove(&id)
|
.remove(&id)
|
||||||
.filter(|(previous_workspace, _)| *previous_workspace == workspace)
|
.filter(|(previous_workspace, _)| *previous_workspace == workspace)
|
||||||
.map(|(_, world_x)| world_x)
|
.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);
|
animated_world_x.set_target(world_x);
|
||||||
if !self.animations_enabled {
|
if !self.animations_enabled {
|
||||||
animated_world_x.snap();
|
animated_world_x.snap();
|
||||||
}
|
}
|
||||||
|
|
||||||
if !geometry.is_zooming() {
|
if let Some(viewport) = self.viewport_animations.get(&workspace) {
|
||||||
geometry.visual.current.x = animated_world_x.current - viewport_x;
|
super::animation::sync_scrolling_coordinates(
|
||||||
geometry.visual.velocity.x = animated_world_x.velocity;
|
geometry,
|
||||||
|
&mut animated_world_x,
|
||||||
|
viewport,
|
||||||
|
geometry.is_zooming(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
record.world_x = Some((workspace, animated_world_x));
|
record.world_x = Some((workspace, animated_world_x));
|
||||||
|
|
||||||
@@ -347,6 +359,14 @@ impl Ferese {
|
|||||||
&& had_geometry
|
&& had_geometry
|
||||||
&& self.animations_enabled
|
&& 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(
|
self.windows.set_transaction(
|
||||||
id,
|
id,
|
||||||
crate::resize_transaction::ResizeTransaction::new(serial, now)
|
crate::resize_transaction::ResizeTransaction::new(serial, now)
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ use super::*;
|
|||||||
|
|
||||||
impl Ferese {
|
impl Ferese {
|
||||||
pub fn register_output(&mut self, output: &Output, identity: String) {
|
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 {
|
if self.session_lock.active {
|
||||||
self.session_lock.output_added(output);
|
self.session_lock.output_added(output);
|
||||||
self.lock_input_activity();
|
self.lock_input_activity();
|
||||||
|
|||||||
@@ -89,10 +89,15 @@ impl Ferese {
|
|||||||
.focus_swipe
|
.focus_swipe
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|swipe| swipe.workspace == workspace);
|
.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)| {
|
}) || record.coupled_width.as_ref().is_some_and(|(_, width)| {
|
||||||
let mut width = *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 {
|
pub(crate) fn sample_frame(&self, output: &Output, horizon: Duration) -> FrameScene {
|
||||||
let animating = self.output_has_animations(output);
|
let animating = self.output_has_animations(output);
|
||||||
let delta = if self.animations_enabled && animating {
|
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 {
|
} else {
|
||||||
Duration::ZERO
|
Duration::ZERO
|
||||||
};
|
};
|
||||||
@@ -219,9 +224,9 @@ fn predict_geometry(
|
|||||||
if let Some((mut world, mut viewport, held)) = world
|
if let Some((mut world, mut viewport, held)) = world
|
||||||
&& !zooming
|
&& !zooming
|
||||||
{
|
{
|
||||||
world.advance(delta, spring);
|
world.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing);
|
||||||
if !held {
|
if !held {
|
||||||
viewport.advance(delta, viewport_spring);
|
viewport.advance_with_policy(delta, viewport_spring, CrossingPolicy::NoCrossing);
|
||||||
}
|
}
|
||||||
|
|
||||||
predicted.visual.current.x = world.current - viewport.current;
|
predicted.visual.current.x = world.current - viewport.current;
|
||||||
@@ -229,7 +234,7 @@ fn predict_geometry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(mut width) = width {
|
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.current.width = width.current;
|
||||||
predicted.visual.velocity.width = width.velocity;
|
predicted.visual.velocity.width = width.velocity;
|
||||||
}
|
}
|
||||||
@@ -463,9 +468,9 @@ mod tests {
|
|||||||
Some((world, viewport, false)),
|
Some((world, viewport, false)),
|
||||||
Some(width),
|
Some(width),
|
||||||
);
|
);
|
||||||
world.advance(delta, spring);
|
world.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing);
|
||||||
viewport.advance(delta, spring);
|
viewport.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing);
|
||||||
width.advance(delta, spring);
|
width.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing);
|
||||||
assert_eq!(predicted.visual.current.x, world.current - viewport.current);
|
assert_eq!(predicted.visual.current.x, world.current - viewport.current);
|
||||||
assert_eq!(predicted.visual.current.width, width.current);
|
assert_eq!(predicted.visual.current.width, width.current);
|
||||||
assert_eq!(predicted.visual.target, geometry.visual.target);
|
assert_eq!(predicted.visual.target, geometry.visual.target);
|
||||||
|
|||||||
@@ -424,6 +424,9 @@ impl Ferese {
|
|||||||
};
|
};
|
||||||
|
|
||||||
self.focus_history.remove(id);
|
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.render.remove_window(id);
|
||||||
self.window_stack.remove(id);
|
self.window_stack.remove(id);
|
||||||
self.floating_above_fullscreen.remove(&id);
|
self.floating_above_fullscreen.remove(&id);
|
||||||
@@ -541,6 +544,12 @@ impl Ferese {
|
|||||||
.transaction(&id)
|
.transaction(&id)
|
||||||
.is_some_and(|transaction| transaction.accepts(committed_serial))
|
.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);
|
self.windows.clear_transaction(&id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,6 +587,9 @@ impl Ferese {
|
|||||||
// This is the first mapped client buffer, not a user resize.
|
// This is the first mapped client buffer, not a user resize.
|
||||||
// Do not animate from the temporary placement box.
|
// Do not animate from the temporary placement box.
|
||||||
self.windows.set_geometry(id, WindowGeometry::new(rect, Some(size)));
|
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.windows.clear_transaction(&id);
|
||||||
self.relayout();
|
self.relayout();
|
||||||
}
|
}
|
||||||
@@ -614,6 +626,9 @@ impl Ferese {
|
|||||||
|
|
||||||
if self.workspaces.set_floating_rect(id, rect).is_ok() {
|
if self.workspaces.set_floating_rect(id, rect).is_ok() {
|
||||||
self.windows.set_geometry(id, WindowGeometry::new(rect, Some(size)));
|
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.windows.clear_transaction(&id);
|
||||||
self.render.clear_snapshot(&id);
|
self.render.clear_snapshot(&id);
|
||||||
self.relayout();
|
self.relayout();
|
||||||
|
|||||||
@@ -2,8 +2,15 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use ferese_layout::Rect;
|
use ferese_layout::Rect;
|
||||||
|
|
||||||
const MAX_FRAME_DELTA: Duration = Duration::from_millis(100);
|
mod spring;
|
||||||
const MAX_STEP_SECONDS: f64 = 1.0 / 240.0;
|
|
||||||
|
/// 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);
|
const CLIENT_COMMIT_TIMEOUT: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
@@ -93,32 +100,35 @@ impl AnimatedValue {
|
|||||||
self.velocity = 0.0;
|
self.velocity = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compatibility entry point; existing presentation properties do not cross.
|
||||||
pub fn advance(&mut self, delta: Duration, config: SpringConfig) -> bool {
|
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();
|
let config = config.normalized();
|
||||||
if (self.current - self.target).abs() <= config.position_tolerance
|
let settled = |value: &Self| {
|
||||||
&& self.velocity.abs() <= config.velocity_tolerance
|
(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();
|
self.snap();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let seconds = delta.min(MAX_FRAME_DELTA).as_secs_f64();
|
advance_property(
|
||||||
let steps = (seconds / MAX_STEP_SECONDS).ceil().max(1.0) as usize;
|
&mut self.current,
|
||||||
let step = seconds / steps as f64;
|
self.target,
|
||||||
|
&mut self.velocity,
|
||||||
for _ in 0..steps {
|
delta.as_secs_f64(),
|
||||||
let previous_error = self.current - self.target;
|
config,
|
||||||
advance_value(&mut self.current, self.target, &mut self.velocity, step, config);
|
policy,
|
||||||
let current_error = self.current - self.target;
|
);
|
||||||
if previous_error != 0.0 && previous_error.signum() != current_error.signum() {
|
if settled(self) {
|
||||||
self.snap();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (self.current - self.target).abs() <= config.position_tolerance
|
|
||||||
&& self.velocity.abs() <= config.velocity_tolerance
|
|
||||||
{
|
|
||||||
self.snap();
|
self.snap();
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
@@ -146,34 +156,54 @@ impl AnimatedRect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn advance(&mut self, delta: Duration, config: SpringConfig) -> bool {
|
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) {
|
if self.is_settled(config) {
|
||||||
self.snap();
|
self.snap();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let config = config.normalized();
|
// The old crossing clamp applied independently to each component.
|
||||||
let seconds = delta.min(MAX_FRAME_DELTA).as_secs_f64();
|
let dt = delta.as_secs_f64();
|
||||||
let steps = (seconds / MAX_STEP_SECONDS).ceil().max(1.0) as usize;
|
advance_property(
|
||||||
let step = seconds / steps as f64;
|
&mut self.current.x,
|
||||||
|
self.target.x,
|
||||||
for _ in 0..steps {
|
&mut self.velocity.x,
|
||||||
advance_value(&mut self.current.x, self.target.x, &mut self.velocity.x, step, config);
|
dt,
|
||||||
advance_value(&mut self.current.y, self.target.y, &mut self.velocity.y, step, config);
|
config,
|
||||||
advance_value(
|
policy,
|
||||||
&mut self.current.width,
|
);
|
||||||
self.target.width,
|
advance_property(
|
||||||
&mut self.velocity.width,
|
&mut self.current.y,
|
||||||
step,
|
self.target.y,
|
||||||
config,
|
&mut self.velocity.y,
|
||||||
);
|
dt,
|
||||||
advance_value(
|
config,
|
||||||
&mut self.current.height,
|
policy,
|
||||||
self.target.height,
|
);
|
||||||
&mut self.velocity.height,
|
advance_property(
|
||||||
step,
|
&mut self.current.width,
|
||||||
config,
|
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) {
|
if self.is_settled(config) {
|
||||||
self.snap();
|
self.snap();
|
||||||
@@ -300,9 +330,15 @@ impl WindowGeometry {
|
|||||||
|
|
||||||
pub fn set_logical_target(&mut self, rect: Rect, now: Duration) -> Option<ClientSize> {
|
pub fn set_logical_target(&mut self, rect: Rect, now: Duration) -> Option<ClientSize> {
|
||||||
let rect = normalized_rect(rect);
|
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);
|
let size = ClientSize::from_rect(rect);
|
||||||
self.logical = rect;
|
self.logical = rect;
|
||||||
self.visual.set_target(rect);
|
self.visual.set_target(rect);
|
||||||
|
debug_assert_eq!(self.visual.current, before);
|
||||||
|
|
||||||
self.client.request_size(size, now).then_some(size)
|
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<ClientSize> {
|
pub fn set_presentation_mode(&mut self, rect: Rect, mode: PresentationMode, now: Duration) -> Option<ClientSize> {
|
||||||
let rect = normalized_rect(rect);
|
let rect = normalized_rect(rect);
|
||||||
|
let before = (self.visual.current, self.decorations);
|
||||||
if self.mode != mode || (self.zoom.is_some() && self.logical != rect) {
|
if self.mode != mode || (self.zoom.is_some() && self.logical != rect) {
|
||||||
let mut progress = AnimatedValue::new(0.0);
|
let mut progress = AnimatedValue::new(0.0);
|
||||||
progress.set_target(1.0);
|
progress.set_target(1.0);
|
||||||
@@ -348,6 +385,7 @@ impl WindowGeometry {
|
|||||||
self.mode = mode;
|
self.mode = mode;
|
||||||
self.logical = rect;
|
self.logical = rect;
|
||||||
self.visual.set_target(rect);
|
self.visual.set_target(rect);
|
||||||
|
debug_assert_eq!((self.visual.current, self.decorations), before);
|
||||||
self.presentation_size_request(now)
|
self.presentation_size_request(now)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,6 +436,10 @@ impl WindowGeometry {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if delta.is_zero() {
|
||||||
|
return self.zoom.is_some() || !self.visual.is_settled(config);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(zoom) = &mut self.zoom {
|
if let Some(zoom) = &mut self.zoom {
|
||||||
let target = self.visual.target;
|
let target = self.visual.target;
|
||||||
// Spring tolerances describe pixels and pixels/second, whereas this
|
// Spring tolerances describe pixels and pixels/second, whereas this
|
||||||
@@ -413,7 +455,9 @@ impl WindowGeometry {
|
|||||||
velocity_tolerance: config.velocity_tolerance / travel,
|
velocity_tolerance: config.velocity_tolerance / travel,
|
||||||
..config
|
..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 p = zoom.progress.current;
|
||||||
let lerp = |a: f64, b: f64| a + (b - a) * p;
|
let lerp = |a: f64, b: f64| a + (b - a) * p;
|
||||||
self.visual.current = Rect::new(
|
self.visual.current = Rect::new(
|
||||||
@@ -443,7 +487,8 @@ impl WindowGeometry {
|
|||||||
return active;
|
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)> {
|
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) {
|
fn advance_property(
|
||||||
let acceleration = (-config.stiffness * (*current - target) - config.damping * *velocity) / config.mass;
|
current: &mut f64,
|
||||||
*velocity += acceleration * delta;
|
target: f64,
|
||||||
let previous_error = *current - target;
|
velocity: &mut f64,
|
||||||
*current += *velocity * delta;
|
dt: f64,
|
||||||
let current_error = *current - target;
|
config: SpringConfig,
|
||||||
|
policy: CrossingPolicy,
|
||||||
if previous_error != 0.0 && previous_error.signum() != current_error.signum() {
|
) {
|
||||||
|
if policy == CrossingPolicy::NoCrossing && spring::crosses(*current, *velocity, target, dt, config) {
|
||||||
*current = target;
|
*current = target;
|
||||||
*velocity = 0.0;
|
*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 {
|
fn finite_or_zero(value: f64) -> f64 {
|
||||||
if value.is_finite() { value } else { 0.0 }
|
if value.is_finite() { value } else { 0.0 }
|
||||||
}
|
}
|
||||||
@@ -893,7 +946,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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);
|
let mut value = AnimatedValue::new(0.0);
|
||||||
value.velocity = 20_000.0;
|
value.velocity = 20_000.0;
|
||||||
value.set_target(100.0);
|
value.set_target(100.0);
|
||||||
@@ -901,6 +954,9 @@ mod tests {
|
|||||||
for _ in 0..60 {
|
for _ in 0..60 {
|
||||||
value.advance(Duration::from_secs_f64(1.0 / 60.0), SpringConfig::default());
|
value.advance(Duration::from_secs_f64(1.0 / 60.0), SpringConfig::default());
|
||||||
assert!(value.current <= 100.0);
|
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);
|
assert_eq!(geometry.visual.current, target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod traces;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<Sample>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -244,6 +244,7 @@ Installer options:
|
|||||||
```sh
|
```sh
|
||||||
./scripts/install.sh --dry-run
|
./scripts/install.sh --dry-run
|
||||||
./scripts/install.sh --offline --release-id my-build
|
./scripts/install.sh --offline --release-id my-build
|
||||||
|
./scripts/install.sh --resize-metrics
|
||||||
./scripts/install.sh --skip-build
|
./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
|
install. The latter uses binaries already in `target/release/`, and
|
||||||
`./scripts/install.sh --help` lists all available options.
|
`./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
|
## Logout and recovery
|
||||||
|
|
||||||
Save your work before leaving: **Super+Shift+E** and `feresectl request-logout` ask for
|
Save your work before leaving: **Super+Shift+E** and `feresectl request-logout` ask for
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Run from any directory as your normal user. Existing user config is preserved.
|
|||||||
--skip-build Install existing target/release binaries
|
--skip-build Install existing target/release binaries
|
||||||
--replace-portal-config Back up and replace existing Ferese portal configuration
|
--replace-portal-config Back up and replace existing Ferese portal configuration
|
||||||
--offline Build using only cached Cargo dependencies
|
--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
|
--dry-run Print commands without building or installing
|
||||||
-h, --help Show this help
|
-h, --help Show this help
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ release_id="$(date -u +%Y%m%d-%H%M%S)-$$"
|
|||||||
skip_build=false
|
skip_build=false
|
||||||
replace_portal_config=false
|
replace_portal_config=false
|
||||||
offline=false
|
offline=false
|
||||||
|
resize_metrics=false
|
||||||
dry_run=false
|
dry_run=false
|
||||||
while (($#)); do
|
while (($#)); do
|
||||||
case $1 in
|
case $1 in
|
||||||
@@ -59,12 +61,17 @@ while (($#)); do
|
|||||||
--skip-build) skip_build=true; shift ;;
|
--skip-build) skip_build=true; shift ;;
|
||||||
--replace-portal-config) replace_portal_config=true; shift ;;
|
--replace-portal-config) replace_portal_config=true; shift ;;
|
||||||
--offline) offline=true; shift ;;
|
--offline) offline=true; shift ;;
|
||||||
|
--resize-metrics) resize_metrics=true; shift ;;
|
||||||
--dry-run) dry_run=true; shift ;;
|
--dry-run) dry_run=true; shift ;;
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
*) fail "unknown option: $1 (see --help)" ;;
|
*) fail "unknown option: $1 (see --help)" ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
[[ $release_id =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || fail 'invalid release ID'
|
[[ $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'
|
[[ $(uname -s) == Linux ]] || fail 'Ferese requires Linux'
|
||||||
for tool in desktop-file-validate dbus-run-session; do
|
for tool in desktop-file-validate dbus-run-session; do
|
||||||
command -v "$tool" >/dev/null || fail "required command not found: $tool"
|
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
|
--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)
|
-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 $offline; then build+=(--offline); fi
|
||||||
|
if $resize_metrics; then build+=(--features ferese/resize-metrics); fi
|
||||||
if ! $skip_build; then
|
if ! $skip_build; then
|
||||||
((EUID != 0)) || fail 'build as your normal user, without sudo (or use --skip-build)'
|
((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'
|
command -v cargo >/dev/null || fail 'install the Rust toolchain and Cargo first'
|
||||||
|
|||||||
@@ -101,6 +101,23 @@ class PortalTest(unittest.TestCase):
|
|||||||
connection = Gio.DBusConnection.new_for_address_sync(env["DBUS_SESSION_BUS_ADDRESS"],
|
connection = Gio.DBusConnection.new_for_address_sync(env["DBUS_SESSION_BUS_ADDRESS"],
|
||||||
Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, None, None)
|
Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, None, None)
|
||||||
backend = launch("backend", [str(binary)])
|
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")])
|
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"
|
name, path, iface = "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.ScreenCast"
|
||||||
responses = {}
|
responses = {}
|
||||||
|
|||||||
Reference in new issue
Block a user