* fix(input): require mapped layers for keyboard focus * fix(input): match layer keyboard focus to fullscreen visibility --------- Co-authored-by: Samuel Onoja <[email protected]>
981 lines
39 KiB
Rust
981 lines
39 KiB
Rust
use super::*;
|
|
|
|
impl Ferese {
|
|
pub(super) fn presentation_now(&self) -> Duration {
|
|
#[cfg(test)]
|
|
if let Some(now) = self.animation_test_time {
|
|
return now;
|
|
}
|
|
self.start_time.elapsed()
|
|
}
|
|
|
|
pub fn advance_animations(&mut self, now: Instant) -> bool {
|
|
#[cfg(test)]
|
|
if let Some(time) = self.animation_test_time {
|
|
return self.advance_animations_at(Duration::ZERO, time);
|
|
}
|
|
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)
|
|
}
|
|
|
|
pub fn record_drm_presentation(&mut self, node: DrmNode, crtc: crtc::Handle, time: DrmEventTime, sequence: u32) {
|
|
if let Some(backend) = self.direct_backend.as_mut() {
|
|
backend.record_presentation(node, crtc, time, sequence);
|
|
}
|
|
}
|
|
|
|
#[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
|
|
.presentation_dependencies
|
|
.viewport_blocked(workspace, &self.windows)
|
|
&& self
|
|
.viewports
|
|
.get(&workspace)
|
|
.is_some_and(ViewportPresentation::spring_is_moving)
|
|
&& self
|
|
.output_workspaces
|
|
.output_for_workspace(workspace)
|
|
.is_some_and(|output| self.output_workspaces.active_workspace(output) == Some(workspace))
|
|
&& self.focus_swipe().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.presentation_dependencies.reflow_blocked(other, &self.windows)
|
|
&& 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 reset_animation_clock(&mut self) {
|
|
self.last_animation_tick = Instant::now();
|
|
#[cfg(feature = "resize-metrics")]
|
|
self.resize_metrics.pause_clock(self.start_time.elapsed());
|
|
}
|
|
|
|
fn animations_need_tick(&self) -> bool {
|
|
if self.focus_swipe().is_some()
|
|
|| self.presentation_dependencies.needs_tick()
|
|
|| !self.workspace_slides.is_empty()
|
|
|| !self.render.closing.is_empty()
|
|
|| !self.dismissing_popups.is_empty()
|
|
|| self.render.snapshots().next().is_some()
|
|
|| self.overview.needs_tick()
|
|
|| self.tab_indicator.is_animating()
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if self.viewports.iter().any(|(workspace, viewport)| {
|
|
self.output_workspaces
|
|
.output_for_workspace(*workspace)
|
|
.is_some_and(|output| self.output_workspaces.active_workspace(output) == Some(*workspace))
|
|
&& viewport.needs_tick()
|
|
}) {
|
|
return true;
|
|
}
|
|
|
|
let selected = if self.overview.is_active() {
|
|
self.overview.selected()
|
|
} else {
|
|
self.focused_window
|
|
};
|
|
|
|
self.windows.records().any(|(&id, record)| {
|
|
let focus = if selected == Some(id) { 1.0 } else { 0.0 };
|
|
let activation = crate::focus_effect::target(self.focus_effect, self.focused_window, id);
|
|
|
|
record_needs_tick(record, focus, activation, || {
|
|
self.windows
|
|
.window(id)
|
|
.is_some_and(|window| self.space.element_location(window).is_some())
|
|
})
|
|
})
|
|
}
|
|
|
|
pub(super) fn advance_animations_by(&mut self, delta: std::time::Duration) -> bool {
|
|
self.advance_animations_at(delta, self.presentation_now())
|
|
}
|
|
|
|
// The production caller supplies monotonic wall time; tests supply a fake clock.
|
|
pub(super) fn advance_animations_at(&mut self, delta: Duration, now: Duration) -> bool {
|
|
#[cfg(test)]
|
|
if self.animation_test_time.is_some() {
|
|
self.animation_test_time = Some(now);
|
|
}
|
|
if !self.animations_need_tick() {
|
|
self.sync_window_stacking();
|
|
return false;
|
|
}
|
|
|
|
let delta = delta.mul_f64(self.animation_speed);
|
|
if let Some(workspace) = self
|
|
.focus_swipe()
|
|
.filter(|swipe| !self.focus_swipe_is_current(swipe))
|
|
.map(|swipe| swipe.workspace)
|
|
{
|
|
let swipe = self.viewports.get_mut(&workspace).unwrap().end_gesture(None).unwrap();
|
|
self.presentation_dependencies
|
|
.restore_viewport(swipe.workspace, swipe.dependencies);
|
|
self.release_presentation_dependencies();
|
|
}
|
|
|
|
let mut active_animation = self
|
|
.tab_indicator
|
|
.advance(delta, self.spring_config, self.animations_enabled);
|
|
let mut completed_slides = Vec::new();
|
|
for (output, slide) in &mut self.workspace_slides {
|
|
if slide.held_progress.is_some() {
|
|
continue;
|
|
}
|
|
|
|
if slide.advance(delta) {
|
|
active_animation = true;
|
|
} else {
|
|
completed_slides.push(*output);
|
|
}
|
|
}
|
|
|
|
self.dismissing_popups.retain_mut(|(root, popup, motion)| {
|
|
if !smithay::utils::IsAlive::alive(popup.wl_surface()) {
|
|
return false;
|
|
}
|
|
|
|
let duration = if self.animations_enabled { 140.0 } else { 0.0 };
|
|
let active = motion.advance_visual(0.0, delta, duration);
|
|
crate::effects::fade_dismissed_surface(popup.wl_surface(), motion.current);
|
|
active_animation |= active;
|
|
if !active {
|
|
let _ = PopupManager::dismiss_popup(root, popup);
|
|
}
|
|
active
|
|
});
|
|
|
|
let effect = self.focus_effect;
|
|
let duration = if self.animations_enabled && effect.enabled {
|
|
effect.duration_ms
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
let mut appearance_changed = false;
|
|
// Include hidden workspace windows: overview can present them too.
|
|
// Read selection once so these immutable fields can be borrowed alongside
|
|
// each record without allocating a temporary window-ID vector.
|
|
let selected_window = if self.overview.is_active() {
|
|
self.overview.selected()
|
|
} else {
|
|
self.focused_window
|
|
};
|
|
|
|
for (&id, record) in self.windows.records_mut() {
|
|
let selected = selected_window == Some(id);
|
|
let target = if selected { 1.0 } else { 0.0 };
|
|
let focus = record.focus.get_or_insert_with(|| AnimatedValue::new(target));
|
|
let previous = focus.current;
|
|
let changed = focus.target != target;
|
|
focus.set_target(target);
|
|
if self.animations_enabled {
|
|
active_animation |= focus.advance(
|
|
if changed { Duration::ZERO } else { delta },
|
|
crate::presentation::emphasis_spring(self.spring_config),
|
|
);
|
|
} else {
|
|
focus.snap();
|
|
}
|
|
appearance_changed |= previous != focus.current;
|
|
let shadow = record.shadow.get_or_insert_with(|| AnimatedValue::new(target));
|
|
let previous = shadow.current;
|
|
let changed = shadow.target != target;
|
|
shadow.set_target(target);
|
|
if self.animations_enabled {
|
|
active_animation |= shadow.advance(
|
|
if changed { Duration::ZERO } else { delta },
|
|
crate::presentation::shadow_spring(self.spring_config),
|
|
);
|
|
} else {
|
|
shadow.snap();
|
|
}
|
|
appearance_changed |= previous != shadow.current;
|
|
|
|
let target = crate::focus_effect::target(effect, self.focused_window, id);
|
|
let transition = record
|
|
.focus_transition
|
|
.get_or_insert_with(|| crate::focus_effect::BoundedFade::new(target));
|
|
let previous = transition.current;
|
|
active_animation |= transition.advance_visual(target, delta, duration);
|
|
appearance_changed |= previous != transition.current;
|
|
}
|
|
|
|
for (_, record) in self.windows.records_mut() {
|
|
if let Some(opening) = &mut record.opening {
|
|
let active = if self.animations_enabled {
|
|
opening.advance(
|
|
delta,
|
|
SpringConfig {
|
|
position_tolerance: 0.00001,
|
|
velocity_tolerance: 0.00001,
|
|
..self.spring_config
|
|
},
|
|
)
|
|
} else {
|
|
opening.snap();
|
|
false
|
|
};
|
|
active_animation |= active;
|
|
if !active {
|
|
record.opening = None;
|
|
}
|
|
}
|
|
}
|
|
let mut finished = Vec::new();
|
|
self.render.closing.retain_mut(|window| {
|
|
let active =
|
|
self.animations_enabled && !self.session_lock.active() && window.advance(delta, self.spring_config);
|
|
if !active {
|
|
finished.push(window.presentation.id);
|
|
}
|
|
active_animation |= active;
|
|
active
|
|
});
|
|
for id in finished {
|
|
tracing::debug!(?id, "released close presentation");
|
|
self.render.remove_window(id);
|
|
}
|
|
|
|
// Capture holds before expiry so waiting wall time never steps reflow.
|
|
for (workspace, viewport) in &mut self.viewports {
|
|
if self
|
|
.presentation_dependencies
|
|
.viewport_blocked(*workspace, &self.windows)
|
|
{
|
|
viewport.reconcile_hold(true, true);
|
|
}
|
|
}
|
|
let mut held_reflow = self.presentation_dependencies.holds(&self.windows);
|
|
#[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);
|
|
}
|
|
}
|
|
}
|
|
let expired = self
|
|
.windows
|
|
.resizing()
|
|
.any(|id| self.windows.transaction(id).unwrap().expired(now));
|
|
self.windows.expire_transactions(now);
|
|
if expired {
|
|
self.release_presentation_dependencies();
|
|
}
|
|
|
|
let released_reflow = self.presentation_dependencies.holds(&self.windows);
|
|
held_reflow.extend(released_reflow);
|
|
let animations_enabled = self.animations_enabled;
|
|
let animation_speed = self.animation_speed;
|
|
#[cfg(feature = "resize-metrics")]
|
|
let resize_metrics = &mut self.resize_metrics;
|
|
self.render.retain_snapshots(|id, snapshot| {
|
|
if !animations_enabled {
|
|
return false;
|
|
}
|
|
|
|
let waiting_for_client = self.presentation_dependencies.reflow_blocked(*id, &self.windows);
|
|
// A shrinking client's destination buffer arrives before the
|
|
// animated bounds reach it. Keep the old native pixels covering
|
|
// that strip rather than fading them into the neutral resize fill.
|
|
let uncovered = self.windows.geometry(id).is_some_and(|geometry| {
|
|
geometry.client.committed_size.is_some_and(|size| {
|
|
crate::presentation::resize_needs_old_frame(
|
|
geometry.visual.current,
|
|
geometry.logical,
|
|
size.width,
|
|
size.height,
|
|
)
|
|
})
|
|
});
|
|
let blocked = waiting_for_client || uncovered;
|
|
#[cfg(feature = "resize-metrics")]
|
|
resize_metrics.handoff_tick(*id, now, blocked);
|
|
let active = crate::presentation::advance_handoff(
|
|
&mut snapshot.elapsed,
|
|
&mut snapshot.last_tick,
|
|
now,
|
|
blocked,
|
|
animation_speed,
|
|
);
|
|
if !blocked {
|
|
snapshot.commit.increment();
|
|
}
|
|
|
|
if !active {
|
|
#[cfg(feature = "resize-metrics")]
|
|
resize_metrics.handoff_end(*id, now, snapshot.elapsed);
|
|
tracing::debug!(?id, bytes = snapshot.bytes(), "released resize handoff snapshot");
|
|
}
|
|
active
|
|
});
|
|
active_animation |= self.render.snapshots().next().is_some();
|
|
// Keep scheduling frames while waiting, so the deadline cannot stall.
|
|
active_animation |= self.windows.resizing().next().is_some() || !held_reflow.is_empty();
|
|
for (workspace, viewport) in &mut self.viewports {
|
|
if self
|
|
.output_workspaces
|
|
.output_for_workspace(*workspace)
|
|
.is_none_or(|output| self.output_workspaces.active_workspace(output) != Some(*workspace))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
active_animation |= viewport.advance(
|
|
delta,
|
|
self.viewport_spring_config,
|
|
self.animations_enabled,
|
|
self.presentation_dependencies
|
|
.viewport_blocked(*workspace, &self.windows),
|
|
);
|
|
}
|
|
|
|
let mut remaps = Vec::new();
|
|
|
|
for window in self.space.elements() {
|
|
let Some(id) = self.windows.ids().get(window).copied() else {
|
|
continue;
|
|
};
|
|
|
|
let held = held_reflow.contains(&id);
|
|
let Some(record) = self.windows.record_mut(id) else {
|
|
continue;
|
|
};
|
|
|
|
let natural_pending = record.natural_floating_pending;
|
|
let Some(geometry) = record.geometry.as_mut() else {
|
|
continue;
|
|
};
|
|
|
|
let previous = geometry.visual.current;
|
|
geometry.presentation_changed = false;
|
|
let zooming = geometry.is_zooming();
|
|
|
|
let coupled_target = record.coupled_width.as_ref().map(|(_, width)| width.target);
|
|
if coupled_target.is_some() {
|
|
geometry.visual.target.width = geometry.visual.current.width;
|
|
geometry.visual.velocity.width = 0.0;
|
|
}
|
|
if !held {
|
|
active_animation |= geometry.advance(delta, self.spring_config, self.animations_enabled);
|
|
}
|
|
|
|
if zooming != geometry.is_zooming() {
|
|
self.stacking_cache.invalidate();
|
|
}
|
|
|
|
if let Some(target) = coupled_target {
|
|
geometry.visual.target.width = target;
|
|
}
|
|
|
|
if let Some((workspace, world_x)) = record.world_x.as_mut()
|
|
&& let Some(viewport) = self.viewports.get(workspace)
|
|
{
|
|
if zooming {
|
|
sync_scrolling_coordinates(geometry, world_x, viewport.motion(), true);
|
|
} else if !held && self.animations_enabled {
|
|
active_animation |= world_x.advance(delta, self.spring_config);
|
|
} else if !held {
|
|
world_x.snap();
|
|
}
|
|
|
|
if !zooming {
|
|
let mut presented_world = *world_x;
|
|
if held {
|
|
presented_world.velocity = 0.0;
|
|
}
|
|
let presented_viewport = viewport.presented_motion();
|
|
sync_scrolling_coordinates(geometry, &mut presented_world, &presented_viewport, false);
|
|
}
|
|
}
|
|
|
|
if let Some((_, width)) = record.coupled_width.as_mut()
|
|
&& !held
|
|
{
|
|
let width_active = if self.animations_enabled {
|
|
width.advance(delta, self.viewport_spring_config)
|
|
} else {
|
|
width.snap();
|
|
false
|
|
};
|
|
active_animation |= width_active;
|
|
geometry.visual.current.width = width.current;
|
|
geometry.visual.velocity.width = width.velocity;
|
|
if !width_active {
|
|
record.coupled_width = None;
|
|
}
|
|
}
|
|
|
|
if let Some(size) = geometry.client.expire_wait(now) {
|
|
tracing::warn!(
|
|
?id,
|
|
configured_width = size.width,
|
|
configured_height = size.height,
|
|
committed = ?geometry.client.committed_size,
|
|
"client did not commit the final configured size within 500 ms"
|
|
);
|
|
}
|
|
|
|
geometry.presentation_changed |= previous != geometry.visual.current;
|
|
if !held
|
|
&& let Some(size) = geometry.presentation_size_request(now)
|
|
&& !natural_pending
|
|
&& let Some(toplevel) = window.toplevel()
|
|
{
|
|
toplevel.with_pending_state(|state| {
|
|
state.size = Some((size.width, size.height).into());
|
|
});
|
|
toplevel.send_pending_configure();
|
|
}
|
|
|
|
let visual = geometry.visual.current;
|
|
let location = (visual.x.round() as i32, visual.y.round() as i32).into();
|
|
|
|
if self.space.element_location(window) != Some(location) {
|
|
remaps.push((id, location));
|
|
}
|
|
}
|
|
|
|
for (id, location) in remaps {
|
|
if let Some(window) = self.windows.window(id) {
|
|
self.space.map_element(window.clone(), location, false);
|
|
self.stacking_cache.remapped();
|
|
}
|
|
}
|
|
|
|
if !completed_slides.is_empty() {
|
|
for output in completed_slides {
|
|
self.workspace_slides.remove(&output);
|
|
}
|
|
|
|
let visible = self.visible_workspace_ids();
|
|
let visible_windows = self
|
|
.windows
|
|
.ids()
|
|
.values()
|
|
.copied()
|
|
.filter(|id| {
|
|
self.workspaces
|
|
.workspace_for_window(*id)
|
|
.is_none_or(|workspace| visible.contains(&workspace))
|
|
})
|
|
.collect::<HashSet<_>>();
|
|
self.unmap_invisible_windows(&visible_windows);
|
|
self.reconcile_workspaces(&visible);
|
|
self.send_shell_snapshots();
|
|
self.retarget_overview();
|
|
active_animation = true;
|
|
}
|
|
if !self.overview.is_active() {
|
|
self.retarget_overview();
|
|
}
|
|
let overview_was_presenting = self.overview.is_presenting();
|
|
active_animation |= self
|
|
.overview
|
|
.advance(delta, self.spring_config, self.animations_enabled);
|
|
if overview_was_presenting != self.overview.is_presenting() {
|
|
self.reconcile_layer_keyboard_focus();
|
|
}
|
|
self.refresh_workspace_slide_offsets();
|
|
self.sync_window_stacking();
|
|
|
|
active_animation |= appearance_changed;
|
|
active_animation
|
|
}
|
|
|
|
pub(crate) fn animations_enabled(&self) -> bool {
|
|
self.animations_enabled
|
|
}
|
|
|
|
pub(crate) fn animation_duration(&self, duration: Duration) -> Duration {
|
|
scaled_animation_duration(duration, self.animations_enabled, self.animation_speed)
|
|
}
|
|
|
|
pub(crate) fn workspace_slide_offset(&self, window: WindowId) -> (f64, f64) {
|
|
self.workspaces
|
|
.workspace_for_window(window)
|
|
.and_then(|workspace| self.workspace_slide_offsets.get(&workspace).copied())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub(super) fn sample_workspace_slide_offsets(
|
|
&self,
|
|
output: Option<OutputId>,
|
|
delta: Duration,
|
|
) -> HashMap<WorkspaceId, (f64, f64)> {
|
|
let mut offsets = HashMap::new();
|
|
self.fill_workspace_slide_offsets(output, delta, &mut offsets);
|
|
offsets
|
|
}
|
|
|
|
pub(super) fn refresh_workspace_slide_offsets(&mut self) {
|
|
// Reuse the input snapshot's allocation. Predicted render snapshots
|
|
// remain separate and never change hit-testing or gesture state.
|
|
let mut offsets = std::mem::take(&mut self.workspace_slide_offsets);
|
|
offsets.clear();
|
|
self.fill_workspace_slide_offsets(None, Duration::ZERO, &mut offsets);
|
|
self.workspace_slide_offsets = offsets;
|
|
}
|
|
|
|
fn fill_workspace_slide_offsets(
|
|
&self,
|
|
output: Option<OutputId>,
|
|
delta: Duration,
|
|
offsets: &mut HashMap<WorkspaceId, (f64, f64)>,
|
|
) {
|
|
for (output_id, slide) in &self.workspace_slides {
|
|
if output.is_some_and(|output| output != *output_id) {
|
|
continue;
|
|
}
|
|
|
|
let Some(size) = self
|
|
.outputs_by_id
|
|
.get(output_id)
|
|
.and_then(|output| self.space.output_geometry(output))
|
|
.map(|geometry| geometry.size)
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
for item in &slide.items {
|
|
let position = slide.position(item, delta);
|
|
offsets.insert(
|
|
item.workspace,
|
|
(position.x * f64::from(size.w), position.y * f64::from(size.h)),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn cancel_workspace_slides(&mut self) -> bool {
|
|
let active = !self.workspace_slides.is_empty();
|
|
self.workspace_slides.clear();
|
|
self.workspace_slide_offsets.clear();
|
|
active
|
|
}
|
|
}
|
|
|
|
// Inspect current targets, not the previous frame's activity. Target changes
|
|
// and client waits must wake a desktop that was settled on its last tick.
|
|
/// Zoom owns the visual rectangle until its final handoff tick. Otherwise the
|
|
/// strip coordinates own x. Keep this ownership decision explicit at callers.
|
|
pub(super) fn sync_scrolling_coordinates(
|
|
geometry: &mut WindowGeometry,
|
|
world: &mut AnimatedValue,
|
|
viewport: &AnimatedValue,
|
|
zoom_owns: bool,
|
|
) {
|
|
if zoom_owns {
|
|
world.current = geometry.visual.current.x + viewport.current;
|
|
world.velocity = geometry.visual.velocity.x + viewport.velocity;
|
|
debug_assert!((world.current - geometry.visual.current.x - viewport.current).abs() < 1e-6);
|
|
} else {
|
|
geometry.visual.current.x = world.current - viewport.current;
|
|
geometry.visual.velocity.x = world.velocity - viewport.velocity;
|
|
debug_assert!((geometry.logical.x - world.target + viewport.target).abs() < 1e-6);
|
|
}
|
|
}
|
|
|
|
fn record_needs_tick(
|
|
record: &super::window_registry::WindowRecord,
|
|
focus: f64,
|
|
activation: f64,
|
|
mapped: impl FnOnce() -> bool,
|
|
) -> bool {
|
|
if record.resize.is_some()
|
|
|| record.opening.is_some()
|
|
|| record.focus.as_ref().is_none_or(|motion| motion.needs_update(focus))
|
|
|| record.shadow.as_ref().is_none_or(|motion| motion.needs_update(focus))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
let moving = |value: &AnimatedValue| value.current != value.target || value.velocity != 0.0;
|
|
let pending = record
|
|
.focus_transition
|
|
.as_ref()
|
|
.is_none_or(|motion| motion.needs_update(activation))
|
|
|| record.world_x.as_ref().is_some_and(|(_, world)| moving(world))
|
|
|| record.coupled_width.is_some()
|
|
|| record.geometry.is_some_and(|geometry| {
|
|
geometry.is_zooming()
|
|
|| geometry.presentation_changed
|
|
|| geometry.client.waiting_for_commit()
|
|
|| geometry.visual.current != geometry.visual.target
|
|
|| geometry.visual.velocity != ferese_animation::RectVelocity::default()
|
|
|| (!record.natural_floating_pending
|
|
&& geometry.client.last_configured_size != Some(ClientSize::from_rect(geometry.logical)))
|
|
});
|
|
|
|
// A stale hidden target must not keep waking the whole desktop.
|
|
pending && mapped()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::super::window_registry::WindowRecord;
|
|
use super::*;
|
|
|
|
fn settled() -> WindowRecord {
|
|
let rect = Rect::new(0.0, 0.0, 400.0, 300.0);
|
|
let mut geometry = WindowGeometry::new(rect, Some(ClientSize::from_rect(rect)));
|
|
geometry.presentation_changed = false;
|
|
|
|
WindowRecord {
|
|
geometry: Some(geometry),
|
|
focus: Some(AnimatedValue::new(1.0)),
|
|
shadow: Some(AnimatedValue::new(1.0)),
|
|
focus_transition: Some(BoundedFade::new(1.0)),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn idle_check_skips_mapping_lookup_but_new_focus_and_activation_targets_wake_it() {
|
|
let record = settled();
|
|
|
|
for _ in 0..100 {
|
|
assert!(!record_needs_tick(&record, 1.0, 1.0, || panic!(
|
|
"settled record needs no mapping lookup"
|
|
)));
|
|
}
|
|
|
|
assert!(record_needs_tick(&record, 0.0, 1.0, || true));
|
|
assert!(record_needs_tick(&record, 1.0, 0.0, || true));
|
|
assert!(!record_needs_tick(&record, 1.0, 0.0, || false));
|
|
}
|
|
|
|
#[test]
|
|
fn shadow_keeps_ticks_until_it_settles_after_emphasis() {
|
|
let mut record = settled();
|
|
let shadow = record.shadow.as_mut().unwrap();
|
|
shadow.current = 0.9;
|
|
shadow.velocity = 0.2;
|
|
assert!(record_needs_tick(&record, 1.0, 1.0, || false));
|
|
record.shadow.as_mut().unwrap().advance(
|
|
Duration::from_secs(3),
|
|
crate::presentation::shadow_spring(SpringConfig::default()),
|
|
);
|
|
assert!(!record_needs_tick(&record, 1.0, 1.0, || false));
|
|
}
|
|
|
|
#[test]
|
|
fn geometry_motion_client_waits_and_resize_barriers_cannot_be_skipped() {
|
|
let mut record = settled();
|
|
record
|
|
.geometry
|
|
.as_mut()
|
|
.unwrap()
|
|
.visual
|
|
.set_target(Rect::new(10.0, 0.0, 400.0, 300.0));
|
|
assert!(record_needs_tick(&record, 1.0, 1.0, || true));
|
|
assert!(!record_needs_tick(&record, 1.0, 1.0, || false));
|
|
|
|
let mut record = settled();
|
|
record.geometry.as_mut().unwrap().client.request_size(
|
|
ClientSize {
|
|
width: 500,
|
|
height: 300,
|
|
},
|
|
Duration::ZERO,
|
|
);
|
|
assert!(record_needs_tick(&record, 1.0, 1.0, || true));
|
|
record.resize = Some(crate::resize_transaction::ResizeTransaction::new(
|
|
1.into(),
|
|
Duration::ZERO,
|
|
));
|
|
assert!(record_needs_tick(&record, 1.0, 1.0, || false));
|
|
}
|
|
|
|
#[test]
|
|
fn opening_and_coupled_width_cleanup_still_get_their_final_tick() {
|
|
let mut record = settled();
|
|
record.opening = Some(AnimatedValue::new(0.0));
|
|
assert!(record_needs_tick(&record, 1.0, 1.0, || false));
|
|
record.opening = None;
|
|
assert!(!record_needs_tick(&record, 1.0, 1.0, || true));
|
|
record.coupled_width = Some((WorkspaceId(1), AnimatedValue::new(400.0)));
|
|
assert!(record_needs_tick(&record, 1.0, 1.0, || true));
|
|
}
|
|
|
|
#[test]
|
|
fn scrolling_target_changes_and_zoom_ownership_switches_are_continuous() {
|
|
let config = SpringConfig::default();
|
|
let mut viewport = ViewportPresentation::new(100.0);
|
|
viewport.retarget(400.0, true);
|
|
viewport.advance(Duration::from_millis(40), config, true, false);
|
|
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.motion().current, 0.0, width.current, 300.0),
|
|
None,
|
|
);
|
|
let target = Rect::new(world.target - viewport.motion().target, 0.0, width.target, 300.0);
|
|
geometry.set_logical_target(target, Duration::from_millis(40));
|
|
sync_scrolling_coordinates(&mut geometry, &mut world, viewport.motion(), false);
|
|
geometry.visual.velocity.width = width.velocity;
|
|
let before = geometry.visual.current;
|
|
viewport.retarget(300.0, true);
|
|
world.retarget_preserving_motion(1000.0);
|
|
width.retarget_preserving_motion(600.0);
|
|
geometry.set_logical_target(
|
|
Rect::new(world.target - viewport.motion().target, 0.0, width.target, 300.0),
|
|
Duration::from_millis(40),
|
|
);
|
|
sync_scrolling_coordinates(&mut geometry, &mut world, viewport.motion(), false);
|
|
assert_eq!(geometry.visual.current, before);
|
|
assert_eq!(geometry.visual.velocity.x, world.velocity - viewport.motion().velocity);
|
|
assert_eq!(geometry.logical.x, world.target - viewport.motion().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.motion(), true);
|
|
assert_eq!(geometry.visual.current, before);
|
|
assert_eq!(world.current, geometry.visual.current.x + viewport.motion().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.motion(), true);
|
|
}
|
|
if !geometry.is_zooming() {
|
|
break;
|
|
}
|
|
}
|
|
assert!(!geometry.is_zooming());
|
|
let before = geometry.visual.current;
|
|
world.set_target(geometry.logical.x + viewport.motion().target);
|
|
sync_scrolling_coordinates(&mut geometry, &mut world, viewport.motion(), 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
|
|
.viewports
|
|
.insert(workspace, ViewportPresentation::from_motion(viewport));
|
|
state.advance_animations_at(Duration::from_millis(40), Duration::from_millis(40));
|
|
let frozen = *state.viewports[&workspace].motion();
|
|
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.rebuild_presentation_dependencies();
|
|
state
|
|
.presentation_dependencies
|
|
.wait_for_viewport(workspace, id, 1.into());
|
|
state.advance_animations_at(Duration::from_millis(300), Duration::from_millis(340));
|
|
assert!(state.windows.transaction(&id).is_none());
|
|
assert_eq!(*state.viewports[&workspace].motion(), 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.viewports[&workspace].motion(), 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.rebuild_presentation_dependencies();
|
|
state
|
|
.presentation_dependencies
|
|
.wait_for_viewport(workspace, id, 2.into());
|
|
state.advance_animations_at(Duration::from_millis(100), Duration::from_millis(456));
|
|
let frozen = *state.viewports[&workspace].motion();
|
|
state.windows.clear_transaction(&id);
|
|
state.advance_animations_at(Duration::from_millis(200), Duration::from_millis(656));
|
|
assert_eq!(*state.viewports[&workspace].motion(), 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.viewports[&workspace].motion(), expected);
|
|
|
|
let frozen = *state.viewports[&workspace].motion();
|
|
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.viewports[&workspace].motion(), 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.viewports[&workspace].motion(), 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.viewports[&workspace].motion();
|
|
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.viewports[&workspace].motion(), expected);
|
|
|
|
// A settled transaction still has to flush its resume guard; otherwise
|
|
// the next unrelated animation would inherit an old paused workspace.
|
|
let target = state.viewports[&workspace].motion().target;
|
|
state.viewports.get_mut(&workspace).unwrap().retarget(target, false);
|
|
state.windows.record_mut(id).unwrap().focus = Some(AnimatedValue::new(0.0));
|
|
state.windows.set_transaction(
|
|
id,
|
|
crate::resize_transaction::ResizeTransaction::new(3.into(), Duration::from_millis(800)),
|
|
);
|
|
state.rebuild_presentation_dependencies();
|
|
state
|
|
.presentation_dependencies
|
|
.wait_for_viewport(workspace, id, 3.into());
|
|
state.advance_animations_at(Duration::from_millis(100), Duration::from_millis(900));
|
|
assert!(state.viewports[&workspace].is_held());
|
|
state.windows.clear_transaction(&id);
|
|
state.advance_animations_at(Duration::from_millis(16), Duration::from_millis(916));
|
|
assert!(!state.presentation_dependencies.needs_tick());
|
|
assert!(!state.viewports[&workspace].is_held());
|
|
assert!(!state.viewports[&workspace].needs_tick());
|
|
}
|
|
}
|