Decouple scrolling translation from resize readiness
Track configure-owned presentation dependencies so independent viewport translation can continue while clients resize. Preserve linked reflow waits, native snapshots, handoff deadlines and shared rendering/input coordinates. Closes #10.
This commit is contained in:
1 parent
c1801e1f07
commit
d5a829eb16
14 files changed
+1742
-72
No files matched your search
@@ -1,6 +1,5 @@
|
|||||||
//! A presentation barrier, not an event-loop or client-commit blocker.
|
//! A presentation barrier, not an event-loop or client-commit blocker.
|
||||||
//! Grouping barriers by workspace keeps viewport and neighboring tiles on the
|
//! Presentation properties bind to the configure they actually depend on. A serial identifies readiness even for cell-grid clients whose
|
||||||
//! same clock. A serial identifies readiness even for cell-grid clients whose
|
|
||||||
//! committed size is smaller than the configure, and after rapid reversals.
|
//! committed size is smaller than the configure, and after rapid reversals.
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -13,6 +12,7 @@ pub(crate) struct ResizeTransaction {
|
|||||||
serial: Serial,
|
serial: Serial,
|
||||||
started: Duration,
|
started: Duration,
|
||||||
source_geometry: Option<Rectangle<i32, Logical>>,
|
source_geometry: Option<Rectangle<i32, Logical>>,
|
||||||
|
column_width: Option<(f64, f64)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResizeTransaction {
|
impl ResizeTransaction {
|
||||||
@@ -21,9 +21,28 @@ impl ResizeTransaction {
|
|||||||
serial,
|
serial,
|
||||||
started,
|
started,
|
||||||
source_geometry: None,
|
source_geometry: None,
|
||||||
|
column_width: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn serial(self) -> Serial {
|
||||||
|
self.serial
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn with_column_width(mut self, source: Option<f64>, target: Option<f64>) -> Self {
|
||||||
|
self.column_width = source.zip(target);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn column_width(self) -> Option<(f64, f64)> {
|
||||||
|
self.column_width
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn width_changes(self) -> bool {
|
||||||
|
self.column_width
|
||||||
|
.is_some_and(|(source, target)| (source - target).abs() > 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn with_source_geometry(mut self, geometry: Rectangle<i32, Logical>) -> Self {
|
pub(crate) fn with_source_geometry(mut self, geometry: Rectangle<i32, Logical>) -> Self {
|
||||||
self.source_geometry = Some(geometry);
|
self.source_geometry = Some(geometry);
|
||||||
self
|
self
|
||||||
|
|||||||
+15
-2
@@ -9,6 +9,7 @@ mod outputs;
|
|||||||
mod reconciliation;
|
mod reconciliation;
|
||||||
pub(crate) use reconciliation::{DesktopChanges, DesktopOutput, DesktopTransition};
|
pub(crate) use reconciliation::{DesktopChanges, DesktopOutput, DesktopTransition};
|
||||||
mod prediction;
|
mod prediction;
|
||||||
|
mod presentation_dependencies;
|
||||||
pub(crate) use prediction::FrameScene;
|
pub(crate) use prediction::FrameScene;
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
mod window_registry;
|
mod window_registry;
|
||||||
@@ -154,6 +155,8 @@ struct FocusSwipe {
|
|||||||
start: f64,
|
start: f64,
|
||||||
destination: f64,
|
destination: f64,
|
||||||
progress: f64,
|
progress: f64,
|
||||||
|
layout: Option<ferese_layout::ScrollingLayout>,
|
||||||
|
dependencies: Option<presentation_dependencies::ViewportDependencies>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FocusSwipe {
|
impl FocusSwipe {
|
||||||
@@ -433,7 +436,7 @@ pub struct Ferese {
|
|||||||
pub(crate) logout_query: Option<u32>,
|
pub(crate) logout_query: Option<u32>,
|
||||||
pub(crate) logout_owner: Option<ObjectId>,
|
pub(crate) logout_owner: Option<ObjectId>,
|
||||||
viewport_animations: HashMap<WorkspaceId, AnimatedValue>,
|
viewport_animations: HashMap<WorkspaceId, AnimatedValue>,
|
||||||
paused_workspaces: HashSet<WorkspaceId>,
|
presentation_dependencies: presentation_dependencies::PresentationDependencies,
|
||||||
#[cfg(feature = "resize-metrics")]
|
#[cfg(feature = "resize-metrics")]
|
||||||
pub(crate) resize_metrics: crate::resize_metrics::ResizeMetrics,
|
pub(crate) resize_metrics: crate::resize_metrics::ResizeMetrics,
|
||||||
focus_swipe: Option<FocusSwipe>,
|
focus_swipe: Option<FocusSwipe>,
|
||||||
@@ -488,6 +491,8 @@ pub struct Ferese {
|
|||||||
pub(crate) overview: OverviewState,
|
pub(crate) overview: OverviewState,
|
||||||
next_output_id: u64,
|
next_output_id: u64,
|
||||||
last_animation_tick: Instant,
|
last_animation_tick: Instant,
|
||||||
|
#[cfg(test)]
|
||||||
|
animation_test_time: Option<Duration>,
|
||||||
pub popups: PopupManager,
|
pub popups: PopupManager,
|
||||||
pub(crate) dismissing_popups: Vec<(WlSurface, PopupKind, DimAnimation)>,
|
pub(crate) dismissing_popups: Vec<(WlSurface, PopupKind, DimAnimation)>,
|
||||||
pub seat: Seat<Self>,
|
pub seat: Seat<Self>,
|
||||||
@@ -676,7 +681,7 @@ impl Ferese {
|
|||||||
logout_query: None,
|
logout_query: None,
|
||||||
logout_owner: None,
|
logout_owner: None,
|
||||||
viewport_animations: HashMap::new(),
|
viewport_animations: HashMap::new(),
|
||||||
paused_workspaces: HashSet::new(),
|
presentation_dependencies: Default::default(),
|
||||||
#[cfg(feature = "resize-metrics")]
|
#[cfg(feature = "resize-metrics")]
|
||||||
resize_metrics: crate::resize_metrics::ResizeMetrics::default(),
|
resize_metrics: crate::resize_metrics::ResizeMetrics::default(),
|
||||||
focus_swipe: None,
|
focus_swipe: None,
|
||||||
@@ -726,6 +731,8 @@ impl Ferese {
|
|||||||
overview: OverviewState::with_font_family(config.overview_font_family),
|
overview: OverviewState::with_font_family(config.overview_font_family),
|
||||||
next_output_id: 1,
|
next_output_id: 1,
|
||||||
last_animation_tick: start_time,
|
last_animation_tick: start_time,
|
||||||
|
#[cfg(test)]
|
||||||
|
animation_test_time: None,
|
||||||
popups: PopupManager::default(),
|
popups: PopupManager::default(),
|
||||||
dismissing_popups: Vec::new(),
|
dismissing_popups: Vec::new(),
|
||||||
seat,
|
seat,
|
||||||
@@ -1213,6 +1220,8 @@ mod tests {
|
|||||||
start: 20.0,
|
start: 20.0,
|
||||||
destination: 20.0,
|
destination: 20.0,
|
||||||
progress: 2.0,
|
progress: 2.0,
|
||||||
|
layout: None,
|
||||||
|
dependencies: None,
|
||||||
};
|
};
|
||||||
assert!(swipe.position() > 20.0 && swipe.position() < 84.0);
|
assert!(swipe.position() > 20.0 && swipe.position() < 84.0);
|
||||||
assert!(swipe.release_velocity(0.0, 3.0) > 0.0);
|
assert!(swipe.release_velocity(0.0, 3.0) > 0.0);
|
||||||
@@ -1363,6 +1372,8 @@ mod tests {
|
|||||||
start: 500.0,
|
start: 500.0,
|
||||||
destination,
|
destination,
|
||||||
progress: 0.75,
|
progress: 0.75,
|
||||||
|
layout: None,
|
||||||
|
dependencies: None,
|
||||||
};
|
};
|
||||||
let far = swipe.position();
|
let far = swipe.position();
|
||||||
swipe.progress = 0.25;
|
swipe.progress = 0.25;
|
||||||
@@ -1391,6 +1402,8 @@ mod tests {
|
|||||||
start: 0.0,
|
start: 0.0,
|
||||||
destination: 0.0,
|
destination: 0.0,
|
||||||
progress: 0.5,
|
progress: 0.5,
|
||||||
|
layout: None,
|
||||||
|
dependencies: None,
|
||||||
};
|
};
|
||||||
assert_eq!(swipe.position(), sign * 32.0);
|
assert_eq!(swipe.position(), sign * 32.0);
|
||||||
assert_eq!(swipe.destination, 0.0);
|
assert_eq!(swipe.destination, 0.0);
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
impl Ferese {
|
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 {
|
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);
|
let delta = crate::presentation::frame_delta(&mut self.last_animation_tick, now);
|
||||||
// Consume wall time even while presentation is paused. A paused
|
// Consume wall time even while presentation is paused. A paused
|
||||||
// desktop must not turn suspend/DPMS time into a spring step.
|
// desktop must not turn suspend/DPMS time into a spring step.
|
||||||
@@ -35,7 +47,10 @@ impl Ferese {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let moving = |value: &AnimatedValue| value.current != value.target || value.velocity != 0.0;
|
let moving = |value: &AnimatedValue| value.current != value.target || value.velocity != 0.0;
|
||||||
let viewport = self.viewport_animations.get(&workspace).is_some_and(moving)
|
let viewport = self
|
||||||
|
.presentation_dependencies
|
||||||
|
.viewport_blocked(workspace, &self.windows)
|
||||||
|
&& self.viewport_animations.get(&workspace).is_some_and(moving)
|
||||||
&& self
|
&& self
|
||||||
.output_workspaces
|
.output_workspaces
|
||||||
.output_for_workspace(workspace)
|
.output_for_workspace(workspace)
|
||||||
@@ -51,6 +66,7 @@ impl Ferese {
|
|||||||
.chain(workspace.floating.iter().copied())
|
.chain(workspace.floating.iter().copied())
|
||||||
.any(|other| {
|
.any(|other| {
|
||||||
other != *id
|
other != *id
|
||||||
|
&& self.presentation_dependencies.reflow_blocked(other, &self.windows)
|
||||||
&& self
|
&& self
|
||||||
.windows
|
.windows
|
||||||
.window(other)
|
.window(other)
|
||||||
@@ -80,7 +96,7 @@ impl Ferese {
|
|||||||
|
|
||||||
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.presentation_dependencies.needs_tick()
|
||||||
|| !self.workspace_slides.is_empty()
|
|| !self.workspace_slides.is_empty()
|
||||||
|| !self.render.closing.is_empty()
|
|| !self.render.closing.is_empty()
|
||||||
|| !self.dismissing_popups.is_empty()
|
|| !self.dismissing_popups.is_empty()
|
||||||
@@ -125,11 +141,15 @@ 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())
|
self.advance_animations_at(delta, self.presentation_now())
|
||||||
}
|
}
|
||||||
|
|
||||||
// The production caller supplies monotonic wall time; tests supply a fake clock.
|
// 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 {
|
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() {
|
if !self.animations_need_tick() {
|
||||||
self.sync_window_stacking();
|
self.sync_window_stacking();
|
||||||
return false;
|
return false;
|
||||||
@@ -141,7 +161,11 @@ impl Ferese {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|swipe| !self.focus_swipe_is_current(swipe))
|
.is_some_and(|swipe| !self.focus_swipe_is_current(swipe))
|
||||||
{
|
{
|
||||||
self.focus_swipe = None;
|
if let Some(swipe) = self.focus_swipe.take() {
|
||||||
|
self.presentation_dependencies
|
||||||
|
.restore_viewport(swipe.workspace, swipe.dependencies);
|
||||||
|
self.release_presentation_dependencies();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut active_animation = false;
|
let mut active_animation = false;
|
||||||
@@ -274,13 +298,8 @@ impl Ferese {
|
|||||||
self.render.remove_window(id);
|
self.render.remove_window(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include transactions expiring on this dispatch: their waiting time
|
// Capture holds before expiry so waiting wall time never steps reflow.
|
||||||
// must not be charged to the first resumed animation step.
|
let (mut held_reflow, mut held_viewport) = self.presentation_dependencies.holds(&self.windows);
|
||||||
let paused_before_expiry = self
|
|
||||||
.windows
|
|
||||||
.resizing()
|
|
||||||
.filter_map(|id| self.workspaces.workspace_for_window(*id))
|
|
||||||
.collect::<HashSet<_>>();
|
|
||||||
#[cfg(feature = "resize-metrics")]
|
#[cfg(feature = "resize-metrics")]
|
||||||
self.measure_resize_pauses(now);
|
self.measure_resize_pauses(now);
|
||||||
#[cfg(feature = "resize-metrics")]
|
#[cfg(feature = "resize-metrics")]
|
||||||
@@ -291,15 +310,18 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.windows.expire_transactions(now);
|
let expired = self
|
||||||
|
|
||||||
let blocked_workspaces = self
|
|
||||||
.windows
|
.windows
|
||||||
.resizing()
|
.resizing()
|
||||||
.filter_map(|id| self.workspaces.workspace_for_window(*id))
|
.any(|id| self.windows.transaction(id).unwrap().expired(now));
|
||||||
.collect::<HashSet<_>>();
|
self.windows.expire_transactions(now);
|
||||||
let mut resume_pauses = std::mem::replace(&mut self.paused_workspaces, blocked_workspaces.clone());
|
if expired {
|
||||||
resume_pauses.extend(paused_before_expiry);
|
self.release_presentation_dependencies();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (released_reflow, released_viewport) = self.presentation_dependencies.holds(&self.windows);
|
||||||
|
held_reflow.extend(released_reflow);
|
||||||
|
held_viewport.extend(released_viewport);
|
||||||
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")]
|
#[cfg(feature = "resize-metrics")]
|
||||||
@@ -309,10 +331,7 @@ impl Ferese {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let waiting_for_client = self
|
let waiting_for_client = self.presentation_dependencies.reflow_blocked(*id, &self.windows);
|
||||||
.workspaces
|
|
||||||
.workspace_for_window(*id)
|
|
||||||
.is_some_and(|workspace| blocked_workspaces.contains(&workspace));
|
|
||||||
// A shrinking client's destination buffer arrives before the
|
// A shrinking client's destination buffer arrives before the
|
||||||
// animated bounds reach it. Keep the old native pixels covering
|
// animated bounds reach it. Keep the old native pixels covering
|
||||||
// that strip rather than fading them into the neutral resize fill.
|
// that strip rather than fading them into the neutral resize fill.
|
||||||
@@ -349,7 +368,8 @@ impl Ferese {
|
|||||||
});
|
});
|
||||||
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() || !resume_pauses.is_empty();
|
active_animation |=
|
||||||
|
self.windows.resizing().next().is_some() || !held_reflow.is_empty() || !held_viewport.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
|
||||||
@@ -359,7 +379,7 @@ impl Ferese {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if blocked_workspaces.contains(workspace) || resume_pauses.contains(workspace) {
|
if held_viewport.contains(workspace) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,14 +400,7 @@ impl Ferese {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
if self
|
let held = held_reflow.contains(&id);
|
||||||
.workspaces
|
|
||||||
.workspace_for_window(id)
|
|
||||||
.is_some_and(|workspace| blocked_workspaces.contains(&workspace) || resume_pauses.contains(&workspace))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(record) = self.windows.record_mut(id) else {
|
let Some(record) = self.windows.record_mut(id) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -397,6 +410,8 @@ impl Ferese {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let previous = geometry.visual.current;
|
||||||
|
geometry.presentation_changed = false;
|
||||||
let zooming = geometry.is_zooming();
|
let zooming = geometry.is_zooming();
|
||||||
|
|
||||||
let coupled_target = record.coupled_width.as_ref().map(|(_, width)| width.target);
|
let coupled_target = record.coupled_width.as_ref().map(|(_, width)| width.target);
|
||||||
@@ -404,7 +419,9 @@ impl Ferese {
|
|||||||
geometry.visual.target.width = geometry.visual.current.width;
|
geometry.visual.target.width = geometry.visual.current.width;
|
||||||
geometry.visual.velocity.width = 0.0;
|
geometry.visual.velocity.width = 0.0;
|
||||||
}
|
}
|
||||||
active_animation |= geometry.advance(delta, self.spring_config, self.animations_enabled);
|
if !held {
|
||||||
|
active_animation |= geometry.advance(delta, self.spring_config, self.animations_enabled);
|
||||||
|
}
|
||||||
|
|
||||||
if zooming != geometry.is_zooming() {
|
if zooming != geometry.is_zooming() {
|
||||||
self.stacking_cache.invalidate();
|
self.stacking_cache.invalidate();
|
||||||
@@ -419,18 +436,28 @@ impl Ferese {
|
|||||||
{
|
{
|
||||||
if zooming {
|
if zooming {
|
||||||
sync_scrolling_coordinates(geometry, world_x, viewport, true);
|
sync_scrolling_coordinates(geometry, world_x, viewport, true);
|
||||||
} else if self.animations_enabled {
|
} else if !held && self.animations_enabled {
|
||||||
active_animation |= world_x.advance(delta, self.spring_config);
|
active_animation |= world_x.advance(delta, self.spring_config);
|
||||||
} else {
|
} else if !held {
|
||||||
world_x.snap();
|
world_x.snap();
|
||||||
}
|
}
|
||||||
|
|
||||||
if !zooming {
|
if !zooming {
|
||||||
sync_scrolling_coordinates(geometry, world_x, viewport, false);
|
let mut presented_world = *world_x;
|
||||||
|
if held {
|
||||||
|
presented_world.velocity = 0.0;
|
||||||
|
}
|
||||||
|
let mut presented_viewport = *viewport;
|
||||||
|
if held_viewport.contains(workspace) {
|
||||||
|
presented_viewport.velocity = 0.0;
|
||||||
|
}
|
||||||
|
sync_scrolling_coordinates(geometry, &mut presented_world, &presented_viewport, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some((_, width)) = record.coupled_width.as_mut() {
|
if let Some((_, width)) = record.coupled_width.as_mut()
|
||||||
|
&& !held
|
||||||
|
{
|
||||||
let width_active = if self.animations_enabled {
|
let width_active = if self.animations_enabled {
|
||||||
width.advance(delta, self.viewport_spring_config)
|
width.advance(delta, self.viewport_spring_config)
|
||||||
} else {
|
} else {
|
||||||
@@ -455,7 +482,9 @@ impl Ferese {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(size) = geometry.presentation_size_request(now)
|
geometry.presentation_changed |= previous != geometry.visual.current;
|
||||||
|
if !held
|
||||||
|
&& let Some(size) = geometry.presentation_size_request(now)
|
||||||
&& !natural_pending
|
&& !natural_pending
|
||||||
&& let Some(toplevel) = window.toplevel()
|
&& let Some(toplevel) = window.toplevel()
|
||||||
{
|
{
|
||||||
@@ -864,6 +893,10 @@ mod tests {
|
|||||||
id,
|
id,
|
||||||
crate::resize_transaction::ResizeTransaction::new(1.into(), Duration::from_millis(40)),
|
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));
|
state.advance_animations_at(Duration::from_millis(300), Duration::from_millis(340));
|
||||||
assert!(state.windows.transaction(&id).is_none());
|
assert!(state.windows.transaction(&id).is_none());
|
||||||
assert_eq!(state.viewport_animations[&workspace], frozen);
|
assert_eq!(state.viewport_animations[&workspace], frozen);
|
||||||
@@ -882,6 +915,10 @@ mod tests {
|
|||||||
id,
|
id,
|
||||||
crate::resize_transaction::ResizeTransaction::new(2.into(), Duration::from_millis(356)),
|
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));
|
state.advance_animations_at(Duration::from_millis(100), Duration::from_millis(456));
|
||||||
let frozen = state.viewport_animations[&workspace];
|
let frozen = state.viewport_animations[&workspace];
|
||||||
state.windows.clear_transaction(&id);
|
state.windows.clear_transaction(&id);
|
||||||
@@ -935,10 +972,14 @@ mod tests {
|
|||||||
id,
|
id,
|
||||||
crate::resize_transaction::ResizeTransaction::new(3.into(), Duration::from_millis(800)),
|
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));
|
state.advance_animations_at(Duration::from_millis(100), Duration::from_millis(900));
|
||||||
assert!(state.paused_workspaces.contains(&workspace));
|
assert!(state.presentation_dependencies.needs_tick());
|
||||||
state.windows.clear_transaction(&id);
|
state.windows.clear_transaction(&id);
|
||||||
state.advance_animations_at(Duration::from_millis(16), Duration::from_millis(916));
|
state.advance_animations_at(Duration::from_millis(16), Duration::from_millis(916));
|
||||||
assert!(state.paused_workspaces.is_empty());
|
assert!(!state.presentation_dependencies.needs_tick());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ fn dispatch(events: &mut EventLoop<'static, Ferese>, state: &mut Ferese) {
|
|||||||
state.display_handle.flush_clients().unwrap();
|
state.display_handle.flush_clients().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ack_configure(wire: &mut UnixStream, xdg: u32) {
|
fn ack_configure(wire: &mut UnixStream, xdg: u32) -> u32 {
|
||||||
wire.set_nonblocking(true).unwrap();
|
wire.set_nonblocking(true).unwrap();
|
||||||
let mut bytes = Vec::new();
|
let mut bytes = Vec::new();
|
||||||
let _ = wire.read_to_end(&mut bytes);
|
let _ = wire.read_to_end(&mut bytes);
|
||||||
@@ -76,6 +76,7 @@ fn ack_configure(wire: &mut UnixStream, xdg: u32) {
|
|||||||
}
|
}
|
||||||
assert!(serial.is_some(), "missing configure for {xdg}: {bytes:?}");
|
assert!(serial.is_some(), "missing configure for {xdg}: {bytes:?}");
|
||||||
request(wire, xdg, 4, &[serial.unwrap()], None);
|
request(wire, xdg, 4, &[serial.unwrap()], None);
|
||||||
|
serial.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn window(state: &mut Ferese, events: &mut EventLoop<'static, Ferese>, color: u32) -> (Window, UnixStream) {
|
fn window(state: &mut Ferese, events: &mut EventLoop<'static, Ferese>, color: u32) -> (Window, UnixStream) {
|
||||||
@@ -593,3 +594,403 @@ fn capture_privacy_pixels_and_policy_transitions() {
|
|||||||
&mut capture_texture
|
&mut capture_texture
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires private Wayland sockets and an offscreen EGL device"]
|
||||||
|
fn resize_dependencies_protocol_and_pixels() {
|
||||||
|
if std::env::var_os("FERESE_RESIZE_TEST_CHILD").is_none() {
|
||||||
|
let runtime = tempfile::tempdir().unwrap();
|
||||||
|
let result = std::process::Command::new(std::env::current_exe().unwrap())
|
||||||
|
.args([
|
||||||
|
"--exact",
|
||||||
|
"state::capture_privacy::tests::resize_dependencies_protocol_and_pixels",
|
||||||
|
"--ignored",
|
||||||
|
"--nocapture",
|
||||||
|
])
|
||||||
|
.env("FERESE_RESIZE_TEST_CHILD", "1")
|
||||||
|
.env("FERESE_ENABLE_SCREENCOPY", "1")
|
||||||
|
.env("XDG_RUNTIME_DIR", runtime.path())
|
||||||
|
.env("XDG_CONFIG_HOME", runtime.path())
|
||||||
|
.env("XDG_STATE_HOME", runtime.path())
|
||||||
|
.env_remove("FERESE_SOCKET")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
result.status.success(),
|
||||||
|
"{}\n{}",
|
||||||
|
String::from_utf8_lossy(&result.stdout),
|
||||||
|
String::from_utf8_lossy(&result.stderr)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
use crate::gestures::SwipeDirection;
|
||||||
|
use ferese_core::WorkspaceLayout;
|
||||||
|
use ferese_layout::{ColumnWidth, Direction, Rect, ViewportFocusStrategy};
|
||||||
|
let mut events = EventLoop::try_new().unwrap();
|
||||||
|
let config = crate::config::Config::parse_source("animations { enabled #false; }\nlayout { inner-gap 0; outer-gap 0; }\ntheme { geometry { window-radius 0; border-width 0; focus-ring-width 0; }; }\nwindow-rule app-id=\"resize.secret\" block-out-from-screencasts=#true\n").unwrap().runtime_config().unwrap();
|
||||||
|
let mut state = Ferese::new(&mut events, Display::new().unwrap(), config).unwrap();
|
||||||
|
state.animation_test_time = Some(Duration::ZERO);
|
||||||
|
let output = Output::new(
|
||||||
|
"resize".into(),
|
||||||
|
PhysicalProperties {
|
||||||
|
size: (0, 0).into(),
|
||||||
|
subpixel: Subpixel::Unknown,
|
||||||
|
make: "test".into(),
|
||||||
|
model: "test".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
output.change_current_state(
|
||||||
|
Some(Mode {
|
||||||
|
size: (320, 240).into(),
|
||||||
|
refresh: 60_000,
|
||||||
|
}),
|
||||||
|
Some(Transform::Normal),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
state.space.map_output(&output, (0, 0));
|
||||||
|
state.register_output(&output, "resize".into());
|
||||||
|
let (a, mut a_wire) = window(&mut state, &mut events, 0xffff0000);
|
||||||
|
let (b, mut b_wire) = window(&mut state, &mut events, 0xff00ff00);
|
||||||
|
let (c, mut c_wire) = window(&mut state, &mut events, 0xff0000ff);
|
||||||
|
let a_id = state.windows.ids()[&a];
|
||||||
|
let b_id = state.windows.ids()[&b];
|
||||||
|
let c_id = state.windows.ids()[&c];
|
||||||
|
let workspace = state.workspaces.workspace_for_window(a_id).unwrap();
|
||||||
|
state.focused_window = Some(a_id);
|
||||||
|
state.workspaces.focus_window(a_id).unwrap();
|
||||||
|
state.relayout();
|
||||||
|
state.animations_enabled = true;
|
||||||
|
for (_, record) in state.windows.records_mut() {
|
||||||
|
record.opening = None;
|
||||||
|
}
|
||||||
|
let set_width = |state: &mut Ferese, id, width| {
|
||||||
|
let ferese_core::WorkspaceLayout::Scrolling(layout) =
|
||||||
|
&mut state.workspaces.workspace_mut(workspace).unwrap().layout
|
||||||
|
else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
layout.set_column_width(id, ColumnWidth::Fixed(width)).unwrap();
|
||||||
|
state.relayout();
|
||||||
|
state.display_handle.flush_clients().unwrap();
|
||||||
|
};
|
||||||
|
set_width(&mut state, b_id, 240.0);
|
||||||
|
let b_serial = ack_configure(&mut b_wire, 3);
|
||||||
|
dispatch(&mut events, &mut state);
|
||||||
|
assert!(state.windows.transaction(&b_id).is_some(), "ack alone must not release");
|
||||||
|
// This target uses the first column, independently of the pending later width.
|
||||||
|
let bounds = Rect::new(0.0, 0.0, 320.0, 240.0);
|
||||||
|
state
|
||||||
|
.workspaces
|
||||||
|
.center_window(a_id, bounds, state.gap_config, &state.window_constraints())
|
||||||
|
.unwrap();
|
||||||
|
state.relayout();
|
||||||
|
let before = state.viewport_animations[&workspace].current;
|
||||||
|
let held_size = state.windows.geometry(&b_id).unwrap().visual.current.width;
|
||||||
|
let held_world = state.windows.record(b_id).unwrap().world_x.unwrap().1;
|
||||||
|
state.advance_animations_at(Duration::from_millis(16), Duration::ZERO);
|
||||||
|
assert_ne!(
|
||||||
|
state.viewport_animations[&workspace].current, before,
|
||||||
|
"independent viewport stalled before delayed buffer commit"
|
||||||
|
);
|
||||||
|
assert_eq!(state.windows.geometry(&b_id).unwrap().visual.current.width, held_size);
|
||||||
|
assert_eq!(state.windows.record(b_id).unwrap().world_x.unwrap().1, held_world);
|
||||||
|
// BASELINE_REPRO_END: the stage above also runs against the reviewed base.
|
||||||
|
assert!(
|
||||||
|
!state
|
||||||
|
.presentation_dependencies
|
||||||
|
.viewport_blocked(workspace, &state.windows)
|
||||||
|
);
|
||||||
|
assert!(state.presentation_dependencies.reflow_blocked(b_id, &state.windows));
|
||||||
|
assert!(state.presentation_dependencies.reflow_blocked(c_id, &state.windows));
|
||||||
|
assert!(!state.presentation_dependencies.reflow_blocked(a_id, &state.windows));
|
||||||
|
let geometry = *state.windows.geometry(&b_id).unwrap();
|
||||||
|
let position = state.space.element_location(&b).unwrap();
|
||||||
|
assert_eq!(position.x, geometry.visual.current.x.round() as i32);
|
||||||
|
let point = (geometry.visual.current.x + 5.0, geometry.visual.current.y + 5.0).into();
|
||||||
|
let (hit, origin) = state.window_surface_under(point).unwrap();
|
||||||
|
assert_eq!(hit, *b.toplevel().unwrap().wl_surface());
|
||||||
|
assert!((origin.x - geometry.visual.current.x).abs() < 0.001);
|
||||||
|
let forecast = state.sample_frame(&output, Duration::from_millis(8));
|
||||||
|
let predicted = &forecast.windows[&b_id];
|
||||||
|
let mut viewport = state.viewport_animations[&workspace];
|
||||||
|
viewport.advance(
|
||||||
|
Duration::from_millis(8).mul_f64(state.animation_speed),
|
||||||
|
state.viewport_spring_config,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
predicted.geometry.visual.current.x,
|
||||||
|
held_world.current - viewport.current
|
||||||
|
);
|
||||||
|
assert_eq!(predicted.geometry.visual.current.width, held_size);
|
||||||
|
assert_eq!(
|
||||||
|
predicted.presentation.bounds.current.x,
|
||||||
|
predicted.geometry.visual.current.x
|
||||||
|
);
|
||||||
|
assert_eq!(predicted.presentation.bounds.velocity.x, -viewport.velocity);
|
||||||
|
assert_eq!(
|
||||||
|
*state.windows.geometry(&b_id).unwrap(),
|
||||||
|
geometry,
|
||||||
|
"sampling mutated authoritative state"
|
||||||
|
);
|
||||||
|
let device = EGLDevice::enumerate().unwrap().last().expect("EGL device");
|
||||||
|
let display = unsafe { EGLDisplay::new(device).unwrap() };
|
||||||
|
let context = EGLContext::new(&display).unwrap();
|
||||||
|
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
|
||||||
|
let mut display_texture =
|
||||||
|
Offscreen::<GlesTexture>::create_buffer(&mut renderer, Fourcc::Abgr8888, (320, 240).into()).unwrap();
|
||||||
|
let mut capture_texture =
|
||||||
|
Offscreen::<GlesTexture>::create_buffer(&mut renderer, Fourcc::Abgr8888, (320, 240).into()).unwrap();
|
||||||
|
// Offscreen fixture explicitly captures the existing native frame; production
|
||||||
|
// capture-before-commit is exercised separately in the privacy regression.
|
||||||
|
let snapshot =
|
||||||
|
crate::render::capture_resize_snapshot(&mut renderer, &b, b.geometry(), 1.0, 16 * 1024 * 1024).unwrap();
|
||||||
|
state.render.set_snapshot(b_id, snapshot.expect("native snapshot"));
|
||||||
|
app_id(&mut state, &b, "resize.secret");
|
||||||
|
state.refresh_capture_privacy();
|
||||||
|
state.advance_animations_at(Duration::from_millis(16), Duration::from_millis(16));
|
||||||
|
let actual = state.windows.geometry(&b_id).unwrap().visual.current;
|
||||||
|
let displayed = pixels(&mut state, &mut renderer, &output, false, &mut display_texture);
|
||||||
|
let at = ((actual.y.round() as usize + 5) * 320 + actual.x.round() as usize + 5) * 4;
|
||||||
|
assert_eq!(
|
||||||
|
&displayed[at..at + 4],
|
||||||
|
&[0, 255, 0, 255],
|
||||||
|
"live/snapshot pixels did not translate with geometry"
|
||||||
|
);
|
||||||
|
let captured = pixels(&mut state, &mut renderer, &output, true, &mut capture_texture);
|
||||||
|
assert!(
|
||||||
|
!captured.chunks_exact(4).any(|pixel| pixel == [0, 255, 0, 255]),
|
||||||
|
"translated protected snapshot leaked into capture"
|
||||||
|
);
|
||||||
|
assert!(state.render.snapshot(&b_id).is_some());
|
||||||
|
// A second slow client has an independent serial owner.
|
||||||
|
set_width(&mut state, c_id, 128.0);
|
||||||
|
ack_configure(&mut c_wire, 3);
|
||||||
|
dispatch(&mut events, &mut state);
|
||||||
|
assert!(state.windows.transaction(&c_id).is_some());
|
||||||
|
state.focused_window = Some(c_id);
|
||||||
|
state.workspaces.focus_window(c_id).unwrap();
|
||||||
|
// Centering a later column now genuinely uses both pending allocated widths.
|
||||||
|
state
|
||||||
|
.workspaces
|
||||||
|
.center_window(c_id, bounds, state.gap_config, &state.window_constraints())
|
||||||
|
.unwrap();
|
||||||
|
state.relayout();
|
||||||
|
assert!(
|
||||||
|
state
|
||||||
|
.presentation_dependencies
|
||||||
|
.viewport_blocked(workspace, &state.windows)
|
||||||
|
);
|
||||||
|
let frozen = state.viewport_animations[&workspace];
|
||||||
|
state.advance_animations_at(Duration::from_millis(16), Duration::from_millis(32));
|
||||||
|
assert_eq!(state.viewport_animations[&workspace], frozen);
|
||||||
|
// Supersede the acknowledged size, then commit the older acknowledgement.
|
||||||
|
set_width(&mut state, b_id, 96.0);
|
||||||
|
let latest = *state.windows.transaction(&b_id).unwrap();
|
||||||
|
assert!(!latest.accepts(Some(b_serial.into())));
|
||||||
|
request(&mut b_wire, 2, 1, &[6, 0, 0], None);
|
||||||
|
request(&mut b_wire, 2, 6, &[], None);
|
||||||
|
dispatch(&mut events, &mut state);
|
||||||
|
assert_eq!(
|
||||||
|
state.windows.transaction(&b_id).unwrap().serial(),
|
||||||
|
latest.serial(),
|
||||||
|
"obsolete commit released replacement"
|
||||||
|
);
|
||||||
|
ack_configure(&mut b_wire, 3);
|
||||||
|
dispatch(&mut events, &mut state);
|
||||||
|
assert!(
|
||||||
|
state.windows.transaction(&b_id).is_some(),
|
||||||
|
"latest ack without commit released resize"
|
||||||
|
);
|
||||||
|
state.focused_window = Some(a_id);
|
||||||
|
state.workspaces.focus_window(a_id).unwrap();
|
||||||
|
// Rapid reversal to the independent first-column target retires viewport waits.
|
||||||
|
state
|
||||||
|
.workspaces
|
||||||
|
.center_window(a_id, bounds, state.gap_config, &state.window_constraints())
|
||||||
|
.unwrap();
|
||||||
|
state.relayout();
|
||||||
|
assert!(
|
||||||
|
!state
|
||||||
|
.presentation_dependencies
|
||||||
|
.viewport_blocked(workspace, &state.windows)
|
||||||
|
);
|
||||||
|
let frozen = state.viewport_animations[&workspace].current;
|
||||||
|
state.advance_animations_at(Duration::from_millis(16), Duration::from_millis(48));
|
||||||
|
assert_ne!(state.viewport_animations[&workspace].current, frozen);
|
||||||
|
let client = b.toplevel().unwrap().wl_surface().client().unwrap();
|
||||||
|
let shm = client
|
||||||
|
.create_resource::<WlShm, (), Ferese>(&state.display_handle, 1, ())
|
||||||
|
.unwrap();
|
||||||
|
let mut resized = tempfile::tempfile().unwrap();
|
||||||
|
resized.write_all(&0xff00ff00u32.to_ne_bytes().repeat(32 * 48)).unwrap();
|
||||||
|
request(
|
||||||
|
&mut b_wire,
|
||||||
|
shm.id().protocol_id(),
|
||||||
|
0,
|
||||||
|
&[7, 32 * 48 * 4],
|
||||||
|
Some(resized.as_raw_fd()),
|
||||||
|
);
|
||||||
|
request(&mut b_wire, 7, 0, &[8, 0, 32, 48, 32 * 4, 0], None);
|
||||||
|
request(&mut b_wire, 3, 3, &[0, 0, 32, 48], None);
|
||||||
|
request(&mut b_wire, 2, 1, &[8, 0, 0], None);
|
||||||
|
request(&mut b_wire, 2, 6, &[], None);
|
||||||
|
dispatch(&mut events, &mut state);
|
||||||
|
assert_eq!(b.geometry().size.w, 32, "resized raster was not committed");
|
||||||
|
assert!(
|
||||||
|
state.windows.transaction(&b_id).is_none(),
|
||||||
|
"latest surface commit did not release"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
state.windows.transaction(&c_id).is_some(),
|
||||||
|
"one client released another owner"
|
||||||
|
);
|
||||||
|
// A hidden workspace must not impose its viewport wait on its replacement.
|
||||||
|
let other = state.workspaces.create_workspace();
|
||||||
|
let output_id = state.output_id(&output).unwrap();
|
||||||
|
state.output_workspaces.assign_workspace(output_id, other).unwrap();
|
||||||
|
state.workspaces.activate(other).unwrap();
|
||||||
|
state.relayout();
|
||||||
|
assert!(!state.presentation_dependencies.viewport_blocked(other, &state.windows));
|
||||||
|
state.output_workspaces.assign_workspace(output_id, workspace).unwrap();
|
||||||
|
state.workspaces.activate(workspace).unwrap();
|
||||||
|
state.focused_window = Some(a_id);
|
||||||
|
state.relayout();
|
||||||
|
request(&mut c_wire, 2, 1, &[0, 0, 0], None);
|
||||||
|
request(&mut c_wire, 2, 6, &[], None);
|
||||||
|
dispatch(&mut events, &mut state);
|
||||||
|
assert!(
|
||||||
|
state.windows.transaction(&c_id).is_none(),
|
||||||
|
"unmap kept a configure owner"
|
||||||
|
);
|
||||||
|
// Deliberately unresponsive final configure expires without charging the wait.
|
||||||
|
set_width(&mut state, b_id, 112.0);
|
||||||
|
ack_configure(&mut b_wire, 3);
|
||||||
|
dispatch(&mut events, &mut state);
|
||||||
|
assert!(state.windows.transaction(&b_id).is_some());
|
||||||
|
state.advance_animations_at(Duration::from_millis(300), Duration::from_secs(1));
|
||||||
|
assert!(state.windows.transaction(&b_id).is_none());
|
||||||
|
for tick in 1..=160 {
|
||||||
|
state.advance_animations_at(
|
||||||
|
Duration::from_millis(16),
|
||||||
|
Duration::from_secs(1) + Duration::from_millis(tick * 16),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
state.render.snapshot(&b_id).is_none(),
|
||||||
|
"resize snapshot outlived settled handoff"
|
||||||
|
);
|
||||||
|
// Aborting a preview must restore the original recipe, not append a
|
||||||
|
// reversal to the candidate recipe (oversized columns expose the difference).
|
||||||
|
state.animations_enabled = false;
|
||||||
|
set_width(&mut state, a_id, 400.0);
|
||||||
|
set_width(&mut state, b_id, 100.0);
|
||||||
|
state.focused_window = Some(a_id);
|
||||||
|
state.workspaces.focus_window(a_id).unwrap();
|
||||||
|
state
|
||||||
|
.workspaces
|
||||||
|
.center_window(a_id, bounds, state.gap_config, &state.window_constraints())
|
||||||
|
.unwrap();
|
||||||
|
state.relayout();
|
||||||
|
state.animations_enabled = true;
|
||||||
|
set_width(&mut state, b_id, 150.0);
|
||||||
|
ack_configure(&mut b_wire, 3);
|
||||||
|
dispatch(&mut events, &mut state);
|
||||||
|
assert!(
|
||||||
|
!state
|
||||||
|
.presentation_dependencies
|
||||||
|
.viewport_blocked(workspace, &state.windows)
|
||||||
|
);
|
||||||
|
state.preview_focus_swipe(Direction::Right, SwipeDirection::Left, 0.5);
|
||||||
|
assert!(state.focus_swipe.is_some());
|
||||||
|
assert!(
|
||||||
|
state
|
||||||
|
.presentation_dependencies
|
||||||
|
.viewport_blocked(workspace, &state.windows)
|
||||||
|
);
|
||||||
|
assert!(state.finish_focus_swipe(None));
|
||||||
|
assert!(
|
||||||
|
!state
|
||||||
|
.presentation_dependencies
|
||||||
|
.viewport_blocked(workspace, &state.windows),
|
||||||
|
"cancelled preview contaminated restored target"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Two shrinking prefixes can jointly cross a page boundary although
|
||||||
|
// neither source width does so alone. A normal partial commit must rebuild
|
||||||
|
// the remaining graph immediately, without an explicit relayout.
|
||||||
|
state.animations_enabled = false;
|
||||||
|
state.advance_animations_at(Duration::ZERO, Duration::from_secs(5));
|
||||||
|
state.focused_window = Some(b_id);
|
||||||
|
state.workspaces.focus_window(b_id).unwrap();
|
||||||
|
let (d, mut d_wire) = window(&mut state, &mut events, 0xffffffff);
|
||||||
|
let d_id = state.windows.ids()[&d];
|
||||||
|
set_width(&mut state, a_id, 160.0);
|
||||||
|
set_width(&mut state, b_id, 160.0);
|
||||||
|
set_width(&mut state, d_id, 64.0);
|
||||||
|
state.focused_window = Some(d_id);
|
||||||
|
{
|
||||||
|
let WorkspaceLayout::Scrolling(layout) = &mut state.workspaces.workspace_mut(workspace).unwrap().layout else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
layout.set_focus_strategy(ViewportFocusStrategy::Paged);
|
||||||
|
layout.focus(a_id).unwrap();
|
||||||
|
layout.focus(d_id).unwrap();
|
||||||
|
}
|
||||||
|
state.relayout();
|
||||||
|
state.animations_enabled = true;
|
||||||
|
set_width(&mut state, a_id, 64.0);
|
||||||
|
set_width(&mut state, b_id, 64.0);
|
||||||
|
ack_configure(&mut a_wire, 3);
|
||||||
|
ack_configure(&mut b_wire, 3);
|
||||||
|
dispatch(&mut events, &mut state);
|
||||||
|
assert!(
|
||||||
|
state
|
||||||
|
.presentation_dependencies
|
||||||
|
.viewport_blocked(workspace, &state.windows),
|
||||||
|
"joint page dependency was lost: layout={:?} a={:?} b={:?}",
|
||||||
|
state.workspaces.workspace(workspace).unwrap().layout,
|
||||||
|
state.windows.transaction(&a_id),
|
||||||
|
state.windows.transaction(&b_id)
|
||||||
|
);
|
||||||
|
request(&mut a_wire, 2, 1, &[6, 0, 0], None);
|
||||||
|
request(&mut a_wire, 2, 6, &[], None);
|
||||||
|
dispatch(&mut events, &mut state);
|
||||||
|
assert!(state.windows.transaction(&a_id).is_none());
|
||||||
|
assert!(state.windows.transaction(&b_id).is_some());
|
||||||
|
assert!(
|
||||||
|
!state
|
||||||
|
.presentation_dependencies
|
||||||
|
.viewport_blocked(workspace, &state.windows),
|
||||||
|
"partial commit failed to retire collective page wait"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A sibling that commits early still shares the held source width when
|
||||||
|
// a stacked column is retargeted. Input ordering must not choose its source.
|
||||||
|
state.animations_enabled = false;
|
||||||
|
state.advance_animations_at(Duration::ZERO, Duration::from_secs(10));
|
||||||
|
{
|
||||||
|
let WorkspaceLayout::Scrolling(layout) = &mut state.workspaces.workspace_mut(workspace).unwrap().layout else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
layout.move_into_column(d_id, b_id).unwrap();
|
||||||
|
}
|
||||||
|
set_width(&mut state, b_id, 100.0);
|
||||||
|
state.animations_enabled = true;
|
||||||
|
set_width(&mut state, b_id, 200.0);
|
||||||
|
ack_configure(&mut b_wire, 3);
|
||||||
|
ack_configure(&mut d_wire, 3);
|
||||||
|
dispatch(&mut events, &mut state);
|
||||||
|
request(&mut d_wire, 2, 1, &[6, 0, 0], None);
|
||||||
|
request(&mut d_wire, 2, 6, &[], None);
|
||||||
|
dispatch(&mut events, &mut state);
|
||||||
|
assert!(state.windows.transaction(&d_id).is_none());
|
||||||
|
set_width(&mut state, b_id, 150.0);
|
||||||
|
for id in [b_id, d_id] {
|
||||||
|
assert_eq!(
|
||||||
|
state.windows.transaction(&id).unwrap().column_width(),
|
||||||
|
Some((100.0, 150.0)),
|
||||||
|
"stacked replacement lost canonical presented source"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,6 +111,42 @@ impl Ferese {
|
|||||||
self.advance_animations(Instant::now());
|
self.advance_animations(Instant::now());
|
||||||
self.stacking_cache.invalidate();
|
self.stacking_cache.invalidate();
|
||||||
self.arrange_layers();
|
self.arrange_layers();
|
||||||
|
let source_column_widths = self
|
||||||
|
.windows
|
||||||
|
.records()
|
||||||
|
.filter_map(|(&id, record)| {
|
||||||
|
let layout = &self
|
||||||
|
.workspaces
|
||||||
|
.workspace(self.workspaces.workspace_for_window(id)?)?
|
||||||
|
.layout;
|
||||||
|
let WorkspaceLayout::Scrolling(layout) = layout else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
Some((
|
||||||
|
id,
|
||||||
|
record
|
||||||
|
.resize
|
||||||
|
.and_then(|transaction| transaction.column_width().map(|(source, _)| source))
|
||||||
|
.or_else(|| {
|
||||||
|
// A sibling may have committed while this column is still
|
||||||
|
// held. Replacement configures must share that cohort's
|
||||||
|
// presented source, rather than its latest allocated width.
|
||||||
|
layout
|
||||||
|
.columns()
|
||||||
|
.iter()
|
||||||
|
.find(|column| column.windows.contains(&id))
|
||||||
|
.and_then(|column| {
|
||||||
|
column.windows.iter().find_map(|sibling| {
|
||||||
|
self.windows.transaction(sibling).and_then(|transaction| {
|
||||||
|
transaction.column_width().map(|(source, _)| source)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.or_else(|| layout.allocated_column_width(id))?,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
let mut previous_scrolling_world_x = self.windows.take_world_positions();
|
let mut previous_scrolling_world_x = self.windows.take_world_positions();
|
||||||
let pending_column_width_cycles = self.windows.take_column_width_requests();
|
let pending_column_width_cycles = self.windows.take_column_width_requests();
|
||||||
let outputs = self.space.outputs().cloned().collect::<Vec<_>>();
|
let outputs = self.space.outputs().cloned().collect::<Vec<_>>();
|
||||||
@@ -261,7 +297,7 @@ impl Ferese {
|
|||||||
|
|
||||||
self.unmap_invisible_windows(&visible);
|
self.unmap_invisible_windows(&visible);
|
||||||
|
|
||||||
let now = self.start_time.elapsed();
|
let now = self.presentation_now();
|
||||||
|
|
||||||
placements.sort_by_key(|(_, id, ..)| self.window_stack.rank(*id));
|
placements.sort_by_key(|(_, id, ..)| self.window_stack.rank(*id));
|
||||||
|
|
||||||
@@ -408,7 +444,6 @@ 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")]
|
#[cfg(feature = "resize-metrics")]
|
||||||
self.resize_metrics.begin(
|
self.resize_metrics.begin(
|
||||||
id,
|
id,
|
||||||
@@ -419,12 +454,23 @@ impl Ferese {
|
|||||||
self.windows.set_transaction(
|
self.windows.set_transaction(
|
||||||
id,
|
id,
|
||||||
crate::resize_transaction::ResizeTransaction::new(serial, now)
|
crate::resize_transaction::ResizeTransaction::new(serial, now)
|
||||||
.with_source_geometry(window.geometry()),
|
.with_source_geometry(window.geometry())
|
||||||
|
.with_column_width(
|
||||||
|
source_column_widths.get(&id).copied(),
|
||||||
|
self.workspaces
|
||||||
|
.workspace_for_window(id)
|
||||||
|
.and_then(|workspace| self.workspaces.workspace(workspace))
|
||||||
|
.and_then(|workspace| match &workspace.layout {
|
||||||
|
WorkspaceLayout::Scrolling(layout) => layout.allocated_column_width(id),
|
||||||
|
_ => None,
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.rebuild_presentation_dependencies();
|
||||||
self.sync_window_stacking();
|
self.sync_window_stacking();
|
||||||
self.retarget_overview();
|
self.retarget_overview();
|
||||||
if !finalize {
|
if !finalize {
|
||||||
|
|||||||
@@ -71,7 +71,13 @@ impl Ferese {
|
|||||||
candidate.viewport_x().unwrap_or(start)
|
candidate.viewport_x().unwrap_or(start)
|
||||||
},
|
},
|
||||||
progress,
|
progress,
|
||||||
|
dependencies: self.presentation_dependencies.save_viewport(workspace_id),
|
||||||
|
layout: match candidate {
|
||||||
|
WorkspaceLayout::Scrolling(layout) => Some(layout),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
self.rebuild_presentation_dependencies();
|
||||||
self.swipe.mark_preview_started();
|
self.swipe.mark_preview_started();
|
||||||
}
|
}
|
||||||
if let Some(swipe) = &mut self.focus_swipe {
|
if let Some(swipe) = &mut self.focus_swipe {
|
||||||
@@ -94,8 +100,16 @@ impl Ferese {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let current = self.focus_swipe_is_current(&swipe);
|
let current = self.focus_swipe_is_current(&swipe);
|
||||||
|
let blocked = self
|
||||||
|
.presentation_dependencies
|
||||||
|
.viewport_blocked(swipe.workspace, &self.windows);
|
||||||
|
self.presentation_dependencies
|
||||||
|
.restore_viewport(swipe.workspace, swipe.dependencies.clone());
|
||||||
// Release from the last input position even if no frame rendered that update.
|
// Release from the last input position even if no frame rendered that update.
|
||||||
if current && let Some(viewport) = self.viewport_animations.get_mut(&swipe.workspace) {
|
if current
|
||||||
|
&& !blocked
|
||||||
|
&& let Some(viewport) = self.viewport_animations.get_mut(&swipe.workspace)
|
||||||
|
{
|
||||||
viewport.current = swipe.position();
|
viewport.current = swipe.position();
|
||||||
viewport.velocity = swipe
|
viewport.velocity = swipe
|
||||||
.release_velocity(self.swipe.release_velocity, self.swipe.unbounded_release_velocity)
|
.release_velocity(self.swipe.release_velocity, self.swipe.unbounded_release_velocity)
|
||||||
|
|||||||
@@ -37,7 +37,25 @@ impl Ferese {
|
|||||||
delta: Duration,
|
delta: Duration,
|
||||||
) -> crate::presentation::WindowPresentation {
|
) -> crate::presentation::WindowPresentation {
|
||||||
let record = self.windows.record(id).expect("presentation has a window record");
|
let record = self.windows.record(id).expect("presentation has a window record");
|
||||||
let mut bounds = overview.presented_bounds(id, geometry.visual);
|
let mut visual = geometry.visual;
|
||||||
|
if self.presentation_dependencies.reflow_blocked(id, &self.windows) {
|
||||||
|
let predicted_x_velocity = visual.velocity.x;
|
||||||
|
visual.velocity = Default::default();
|
||||||
|
if !geometry.is_zooming()
|
||||||
|
&& let Some((workspace, _)) = record.world_x.as_ref()
|
||||||
|
&& !self
|
||||||
|
.presentation_dependencies
|
||||||
|
.viewport_blocked(*workspace, &self.windows)
|
||||||
|
&& let Some(viewport) = self.viewport_animations.get(workspace)
|
||||||
|
{
|
||||||
|
visual.velocity.x = if delta.is_zero() {
|
||||||
|
-viewport.velocity
|
||||||
|
} else {
|
||||||
|
predicted_x_velocity
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut bounds = overview.presented_bounds(id, visual);
|
||||||
bounds.current.x += offset.0;
|
bounds.current.x += offset.0;
|
||||||
bounds.current.y += offset.1;
|
bounds.current.y += offset.1;
|
||||||
if let Some(workspace) = self.workspaces.workspace_for_window(id)
|
if let Some(workspace) = self.workspaces.workspace_for_window(id)
|
||||||
@@ -113,18 +131,17 @@ impl Ferese {
|
|||||||
.flat_map(move |output| self.output_workspaces.assigned_workspaces(output))
|
.flat_map(move |output| self.output_workspaces.assigned_workspaces(output))
|
||||||
.filter_map(move |id| self.workspaces.workspace(id))
|
.filter_map(move |id| self.workspaces.workspace(id))
|
||||||
.flat_map(move |workspace| {
|
.flat_map(move |workspace| {
|
||||||
let blocked = block_resizes
|
|
||||||
&& workspace
|
|
||||||
.layout
|
|
||||||
.window_ids()
|
|
||||||
.chain(workspace.floating.iter().copied())
|
|
||||||
.any(|id| self.windows.record(id).is_some_and(|record| record.resize.is_some()));
|
|
||||||
|
|
||||||
workspace
|
workspace
|
||||||
.layout
|
.layout
|
||||||
.window_ids()
|
.window_ids()
|
||||||
.chain(workspace.floating.iter().copied())
|
.chain(workspace.floating.iter().copied())
|
||||||
.filter_map(move |id| Some((id, self.windows.record(id)?, blocked)))
|
.filter_map(move |id| {
|
||||||
|
Some((
|
||||||
|
id,
|
||||||
|
self.windows.record(id)?,
|
||||||
|
block_resizes && self.presentation_dependencies.reflow_blocked(id, &self.windows),
|
||||||
|
))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,10 +269,16 @@ impl Ferese {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|swipe| swipe.workspace == *workspace);
|
.is_some_and(|swipe| swipe.workspace == *workspace);
|
||||||
|
|
||||||
Some((*world, *viewport, held))
|
Some((
|
||||||
|
*world,
|
||||||
|
*viewport,
|
||||||
|
held || self
|
||||||
|
.presentation_dependencies
|
||||||
|
.viewport_blocked(*workspace, &self.windows),
|
||||||
|
))
|
||||||
});
|
});
|
||||||
let width = record.coupled_width.as_ref().map(|(_, width)| *width);
|
let width = record.coupled_width.as_ref().map(|(_, width)| *width);
|
||||||
let geometry = if delta.is_zero() || blocked {
|
let geometry = if delta.is_zero() {
|
||||||
original
|
original
|
||||||
} else {
|
} else {
|
||||||
predict_geometry(
|
predict_geometry(
|
||||||
@@ -265,6 +288,7 @@ impl Ferese {
|
|||||||
self.viewport_spring_config,
|
self.viewport_spring_config,
|
||||||
world,
|
world,
|
||||||
width,
|
width,
|
||||||
|
blocked,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -304,6 +328,7 @@ fn predict_geometry(
|
|||||||
viewport_spring: SpringConfig,
|
viewport_spring: SpringConfig,
|
||||||
world: Option<(AnimatedValue, AnimatedValue, bool)>,
|
world: Option<(AnimatedValue, AnimatedValue, bool)>,
|
||||||
width: Option<AnimatedValue>,
|
width: Option<AnimatedValue>,
|
||||||
|
reflow_held: bool,
|
||||||
) -> WindowGeometry {
|
) -> WindowGeometry {
|
||||||
let mut predicted = original;
|
let mut predicted = original;
|
||||||
let zooming = predicted.is_zooming();
|
let zooming = predicted.is_zooming();
|
||||||
@@ -312,21 +337,32 @@ fn predict_geometry(
|
|||||||
predicted.visual.velocity.width = 0.0;
|
predicted.visual.velocity.width = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
predicted.advance(delta, spring, true);
|
if !reflow_held {
|
||||||
|
predicted.advance(delta, spring, true);
|
||||||
|
} else {
|
||||||
|
predicted.visual.velocity = Default::default();
|
||||||
|
}
|
||||||
predicted.visual.target.width = original.visual.target.width;
|
predicted.visual.target.width = original.visual.target.width;
|
||||||
if let Some((mut world, mut viewport, held)) = world
|
if let Some((mut world, mut viewport, held)) = world
|
||||||
&& !zooming
|
&& !zooming
|
||||||
{
|
{
|
||||||
world.advance(delta, spring);
|
if !reflow_held {
|
||||||
|
world.advance(delta, spring);
|
||||||
|
} else {
|
||||||
|
world.velocity = 0.0;
|
||||||
|
}
|
||||||
if !held {
|
if !held {
|
||||||
viewport.advance(delta, viewport_spring);
|
viewport.advance(delta, viewport_spring);
|
||||||
|
} else {
|
||||||
|
viewport.velocity = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
predicted.visual.current.x = world.current - viewport.current;
|
super::animation::sync_scrolling_coordinates(&mut predicted, &mut world, &viewport, false);
|
||||||
predicted.visual.velocity.x = world.velocity - viewport.velocity;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(mut width) = width {
|
if let Some(mut width) = width
|
||||||
|
&& !reflow_held
|
||||||
|
{
|
||||||
width.advance(delta, viewport_spring);
|
width.advance(delta, viewport_spring);
|
||||||
predicted.visual.current.width = width.current;
|
predicted.visual.current.width = width.current;
|
||||||
predicted.visual.velocity.width = width.velocity;
|
predicted.visual.velocity.width = width.velocity;
|
||||||
@@ -625,6 +661,7 @@ mod tests {
|
|||||||
id,
|
id,
|
||||||
crate::resize_transaction::ResizeTransaction::new(9.into(), Duration::ZERO),
|
crate::resize_transaction::ResizeTransaction::new(9.into(), Duration::ZERO),
|
||||||
);
|
);
|
||||||
|
state.rebuild_presentation_dependencies();
|
||||||
let blocked = state.sample_frame(&output, Duration::from_millis(16));
|
let blocked = state.sample_frame(&output, Duration::from_millis(16));
|
||||||
assert_eq!(blocked.windows[&id].geometry, geometry);
|
assert_eq!(blocked.windows[&id].geometry, geometry);
|
||||||
assert!(state.windows.transaction(&id).is_some());
|
assert!(state.windows.transaction(&id).is_some());
|
||||||
@@ -726,6 +763,7 @@ mod tests {
|
|||||||
SpringConfig::default(),
|
SpringConfig::default(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -741,7 +779,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scrolling_coordinates_and_coupled_width_use_the_same_forecast_time() {
|
fn scrolling_coordinates_and_coupled_width_use_the_same_forecast_time() {
|
||||||
let geometry = WindowGeometry::new(Rect::new(500., 0., 400., 300.), None);
|
let mut geometry = WindowGeometry::new(Rect::new(500., 0., 400., 300.), None);
|
||||||
|
geometry.set_logical_target(Rect::new(400., 0., 500., 300.), Duration::ZERO);
|
||||||
let mut world = AnimatedValue::new(500.);
|
let mut world = AnimatedValue::new(500.);
|
||||||
world.set_target(600.);
|
world.set_target(600.);
|
||||||
let mut viewport = AnimatedValue::new(0.);
|
let mut viewport = AnimatedValue::new(0.);
|
||||||
@@ -757,6 +796,7 @@ mod tests {
|
|||||||
spring,
|
spring,
|
||||||
Some((world, viewport, false)),
|
Some((world, viewport, false)),
|
||||||
Some(width),
|
Some(width),
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
world.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing);
|
world.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing);
|
||||||
viewport.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing);
|
viewport.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing);
|
||||||
|
|||||||
@@ -0,0 +1,479 @@
|
|||||||
|
use super::*;
|
||||||
|
use smithay::utils::Serial;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
struct ClientCommit {
|
||||||
|
window: WindowId,
|
||||||
|
serial: Serial,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct ViewportCommit {
|
||||||
|
commit: ClientCommit,
|
||||||
|
width: (f64, f64),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct ViewportStep {
|
||||||
|
target: ferese_layout::ViewportTarget,
|
||||||
|
inputs: Vec<ViewportCommit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub(super) struct ViewportDependencies {
|
||||||
|
steps: Vec<ViewportStep>,
|
||||||
|
waits: Vec<ClientCommit>,
|
||||||
|
// A pathological burst must not retain an unbounded history. The fallback
|
||||||
|
// preserves the linked wait until commit/deadline or an absolute retarget.
|
||||||
|
overflow: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_VIEWPORT_STEPS: usize = 32;
|
||||||
|
|
||||||
|
/// The buffer-dependent properties in this path. Translation has its own
|
||||||
|
/// inputs; it does not inherit a tile's resize/reflow wait by membership.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct PresentationDependencies {
|
||||||
|
reflow: HashMap<WindowId, Vec<ClientCommit>>,
|
||||||
|
viewport: HashMap<WorkspaceId, ViewportDependencies>,
|
||||||
|
// A release tick consumes waiting wall time without stepping held springs.
|
||||||
|
paused_reflow: HashSet<WindowId>,
|
||||||
|
paused_viewport: HashSet<WorkspaceId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PresentationDependencies {
|
||||||
|
pub(super) fn save_viewport(&self, workspace: WorkspaceId) -> Option<ViewportDependencies> {
|
||||||
|
self.viewport.get(&workspace).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn restore_viewport(&mut self, workspace: WorkspaceId, saved: Option<ViewportDependencies>) {
|
||||||
|
if let Some(saved) = saved {
|
||||||
|
self.viewport.insert(workspace, saved);
|
||||||
|
} else {
|
||||||
|
self.viewport.remove(&workspace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(super) fn wait_for_viewport(&mut self, workspace: WorkspaceId, id: WindowId, serial: Serial) {
|
||||||
|
self.viewport.insert(
|
||||||
|
workspace,
|
||||||
|
ViewportDependencies {
|
||||||
|
waits: vec![ClientCommit { window: id, serial }],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn needs_tick(&self) -> bool {
|
||||||
|
!self.paused_reflow.is_empty() || !self.paused_viewport.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending<W: std::hash::Hash + Eq + Clone>(
|
||||||
|
wait: ClientCommit,
|
||||||
|
windows: &window_registry::WindowRegistry<W>,
|
||||||
|
) -> bool {
|
||||||
|
windows
|
||||||
|
.transaction(&wait.window)
|
||||||
|
.is_some_and(|transaction| transaction.serial() == wait.serial)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn reflow_blocked<W: std::hash::Hash + Eq + Clone>(
|
||||||
|
&self,
|
||||||
|
id: WindowId,
|
||||||
|
windows: &window_registry::WindowRegistry<W>,
|
||||||
|
) -> bool {
|
||||||
|
self.reflow
|
||||||
|
.get(&id)
|
||||||
|
.is_some_and(|waits| waits.iter().any(|wait| Self::pending(*wait, windows)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn viewport_blocked<W: std::hash::Hash + Eq + Clone>(
|
||||||
|
&self,
|
||||||
|
workspace: WorkspaceId,
|
||||||
|
windows: &window_registry::WindowRegistry<W>,
|
||||||
|
) -> bool {
|
||||||
|
self.viewport
|
||||||
|
.get(&workspace)
|
||||||
|
.is_some_and(|waits| waits.waits.iter().any(|wait| Self::pending(*wait, windows)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn holds<W: std::hash::Hash + Eq + Clone>(
|
||||||
|
&mut self,
|
||||||
|
windows: &window_registry::WindowRegistry<W>,
|
||||||
|
) -> (HashSet<WindowId>, HashSet<WorkspaceId>) {
|
||||||
|
let reflow = self
|
||||||
|
.reflow
|
||||||
|
.keys()
|
||||||
|
.copied()
|
||||||
|
.filter(|id| self.reflow_blocked(*id, windows))
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
let viewport = self
|
||||||
|
.viewport
|
||||||
|
.keys()
|
||||||
|
.copied()
|
||||||
|
.filter(|workspace| self.viewport_blocked(*workspace, windows))
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
let mut held_reflow = std::mem::replace(&mut self.paused_reflow, reflow.clone());
|
||||||
|
let mut held_viewport = std::mem::replace(&mut self.paused_viewport, viewport.clone());
|
||||||
|
held_reflow.extend(reflow);
|
||||||
|
held_viewport.extend(viewport);
|
||||||
|
self.reflow.retain(|_, waits| {
|
||||||
|
waits.retain(|wait| Self::pending(*wait, windows));
|
||||||
|
!waits.is_empty()
|
||||||
|
});
|
||||||
|
self.viewport.retain(|_, waits| {
|
||||||
|
waits.waits.retain(|wait| Self::pending(*wait, windows));
|
||||||
|
for step in &mut waits.steps {
|
||||||
|
step.inputs.retain(|input| Self::pending(input.commit, windows));
|
||||||
|
}
|
||||||
|
if waits.steps.iter().all(|step| step.inputs.is_empty()) {
|
||||||
|
waits.steps.clear();
|
||||||
|
}
|
||||||
|
!waits.steps.is_empty() || !waits.waits.is_empty()
|
||||||
|
});
|
||||||
|
(held_reflow, held_viewport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn viewport_dependencies(
|
||||||
|
layout: &ferese_layout::ScrollingLayout,
|
||||||
|
pending: &[(WindowId, crate::resize_transaction::ResizeTransaction)],
|
||||||
|
mut previous: ViewportDependencies,
|
||||||
|
) -> ViewportDependencies {
|
||||||
|
let Some(target) = layout.viewport_target() else {
|
||||||
|
return ViewportDependencies::default();
|
||||||
|
};
|
||||||
|
let live = |input: &ViewportCommit| {
|
||||||
|
pending
|
||||||
|
.iter()
|
||||||
|
.any(|(window, transaction)| *window == input.commit.window && transaction.serial() == input.commit.serial)
|
||||||
|
};
|
||||||
|
for step in &mut previous.steps {
|
||||||
|
for input in &mut step.inputs {
|
||||||
|
if let Some((_, transaction)) = pending.iter().find(|(window, transaction)| {
|
||||||
|
*window == input.commit.window && transaction.column_width() == Some(input.width)
|
||||||
|
}) {
|
||||||
|
input.commit.serial = transaction.serial();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
step.inputs.retain(live);
|
||||||
|
}
|
||||||
|
// Fold any prefix with no current configure inputs to the numeric start
|
||||||
|
// already stored in the next request. No obsolete serial owns a recipe.
|
||||||
|
if let Some(first) = previous.steps.iter().position(|step| !step.inputs.is_empty()) {
|
||||||
|
previous.steps.drain(..first);
|
||||||
|
} else {
|
||||||
|
previous.steps.clear();
|
||||||
|
previous.overflow = false;
|
||||||
|
}
|
||||||
|
let inputs = pending
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(window, transaction)| {
|
||||||
|
let width = transaction.column_width()?;
|
||||||
|
transaction.width_changes().then_some(ViewportCommit {
|
||||||
|
commit: ClientCommit {
|
||||||
|
window: *window,
|
||||||
|
serial: transaction.serial(),
|
||||||
|
},
|
||||||
|
width,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if previous.steps.last().is_none_or(|step| step.target != *target) {
|
||||||
|
if !target.uses_previous_target() {
|
||||||
|
previous.steps.clear();
|
||||||
|
previous.overflow = false;
|
||||||
|
}
|
||||||
|
if previous.steps.len() < MAX_VIEWPORT_STEPS {
|
||||||
|
previous.steps.push(ViewportStep {
|
||||||
|
target: target.clone(),
|
||||||
|
inputs: inputs.clone(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
previous.overflow = true;
|
||||||
|
}
|
||||||
|
} else if let Some(step) = previous.steps.last_mut() {
|
||||||
|
// A replacement binds this request to its latest configure, including
|
||||||
|
// same-width height changes. Older recipe inputs retire above.
|
||||||
|
step.inputs = inputs.clone();
|
||||||
|
}
|
||||||
|
let mut owners = previous
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.flat_map(|step| step.inputs.iter().map(|input| input.commit))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
owners.extend(inputs.iter().map(|input| input.commit));
|
||||||
|
owners.sort_by_key(|owner| owner.window.0);
|
||||||
|
owners.dedup();
|
||||||
|
let evaluate = |only: Option<ClientCommit>, exclude: Option<ClientCommit>| {
|
||||||
|
let mut start = None;
|
||||||
|
for step in &previous.steps {
|
||||||
|
let widths = step
|
||||||
|
.inputs
|
||||||
|
.iter()
|
||||||
|
.filter(|input| only.is_none_or(|only| input.commit == only) && exclude != Some(input.commit))
|
||||||
|
.map(|input| (input.commit.window, input.width.0, input.width.1))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
start = Some(step.target.with_held_widths(&widths, start));
|
||||||
|
}
|
||||||
|
start.unwrap_or(layout.viewport_x())
|
||||||
|
};
|
||||||
|
let held = evaluate(None, None);
|
||||||
|
previous.waits = owners
|
||||||
|
.into_iter()
|
||||||
|
.filter(|owner| {
|
||||||
|
previous.overflow
|
||||||
|
|| (evaluate(Some(*owner), None) - layout.viewport_x()).abs() > 0.001
|
||||||
|
|| (held - evaluate(None, Some(*owner))).abs() > 0.001
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// Keep a bounded recipe while any of its inputs is pending: a partial
|
||||||
|
// commit can expose a page-packing dependency previously masked by others.
|
||||||
|
if previous.steps.iter().all(|step| step.inputs.is_empty()) {
|
||||||
|
return ViewportDependencies::default();
|
||||||
|
}
|
||||||
|
previous
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ferese {
|
||||||
|
pub(super) fn release_presentation_dependencies(&mut self) {
|
||||||
|
let paused_reflow = self.presentation_dependencies.paused_reflow.clone();
|
||||||
|
let paused_viewport = self.presentation_dependencies.paused_viewport.clone();
|
||||||
|
self.rebuild_presentation_dependencies();
|
||||||
|
self.presentation_dependencies.paused_reflow.extend(paused_reflow);
|
||||||
|
self.presentation_dependencies.paused_viewport.extend(paused_viewport);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn rebuild_presentation_dependencies(&mut self) {
|
||||||
|
let mut reflow = HashMap::new();
|
||||||
|
let mut viewport = HashMap::new();
|
||||||
|
let mut previous_viewport = std::mem::take(&mut self.presentation_dependencies.viewport);
|
||||||
|
for (&id, _) in self.windows.records() {
|
||||||
|
let Some(workspace_id) = self.workspaces.workspace_for_window(id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(workspace) = self.workspaces.workspace(workspace_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let own_column = match &workspace.layout {
|
||||||
|
WorkspaceLayout::Scrolling(layout) => {
|
||||||
|
layout.columns().iter().position(|column| column.windows.contains(&id))
|
||||||
|
}
|
||||||
|
WorkspaceLayout::Tree(_) => None,
|
||||||
|
};
|
||||||
|
let mut waits = Vec::new();
|
||||||
|
for slow in self.windows.resizing() {
|
||||||
|
if self.workspaces.workspace_for_window(*slow) != Some(workspace_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let transaction = *self.windows.transaction(slow).unwrap();
|
||||||
|
let commit = ClientCommit {
|
||||||
|
window: *slow,
|
||||||
|
serial: transaction.serial(),
|
||||||
|
};
|
||||||
|
let linked = id == *slow
|
||||||
|
|| match &workspace.layout {
|
||||||
|
WorkspaceLayout::Scrolling(layout) => own_column.is_some_and(|own| {
|
||||||
|
layout
|
||||||
|
.columns()
|
||||||
|
.iter()
|
||||||
|
.position(|column| column.windows.contains(slow))
|
||||||
|
.is_some_and(|source| source == own || (source < own && transaction.width_changes()))
|
||||||
|
}),
|
||||||
|
WorkspaceLayout::Tree(_) => workspace.layout.contains(id) && workspace.layout.contains(*slow),
|
||||||
|
};
|
||||||
|
if linked {
|
||||||
|
waits.push(commit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !waits.is_empty() {
|
||||||
|
reflow.insert(id, waits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for workspace in self.workspaces.iter() {
|
||||||
|
let layout = self
|
||||||
|
.focus_swipe
|
||||||
|
.as_ref()
|
||||||
|
.filter(|swipe| swipe.workspace == workspace.id)
|
||||||
|
.and_then(|swipe| swipe.layout.as_ref())
|
||||||
|
.or_else(|| match &workspace.layout {
|
||||||
|
WorkspaceLayout::Scrolling(layout) => Some(layout),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
let Some(layout) = layout else { continue };
|
||||||
|
let waits = viewport_dependencies(
|
||||||
|
layout,
|
||||||
|
&self
|
||||||
|
.windows
|
||||||
|
.resizing()
|
||||||
|
.filter(|id| {
|
||||||
|
self.workspaces.workspace_for_window(**id) == Some(workspace.id) && layout.contains(**id)
|
||||||
|
})
|
||||||
|
.map(|id| (*id, *self.windows.transaction(id).unwrap()))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
previous_viewport.remove(&workspace.id).unwrap_or_default(),
|
||||||
|
);
|
||||||
|
if !waits.steps.is_empty() || !waits.waits.is_empty() {
|
||||||
|
viewport.insert(workspace.id, waits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A retarget uses the new dependency set immediately. A dependency that
|
||||||
|
// disappeared must not freeze a newly independent target for one frame.
|
||||||
|
self.presentation_dependencies
|
||||||
|
.paused_reflow
|
||||||
|
.retain(|id| reflow.contains_key(id));
|
||||||
|
self.presentation_dependencies
|
||||||
|
.paused_viewport
|
||||||
|
.retain(|id| viewport.get(id).is_some_and(|waits| !waits.waits.is_empty()));
|
||||||
|
self.presentation_dependencies.reflow = reflow;
|
||||||
|
self.presentation_dependencies.viewport = viewport;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::resize_transaction::ResizeTransaction;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn viewport_recipe_handles_cancellation_joint_pages_and_bounded_bursts() {
|
||||||
|
use ferese_layout::{ColumnWidth, ScrollingLayout};
|
||||||
|
let bounds = Rect::new(0.0, 0.0, 200.0, 100.0);
|
||||||
|
let gaps = GapConfig {
|
||||||
|
inner: 0.0,
|
||||||
|
outer: 0.0,
|
||||||
|
smart: false,
|
||||||
|
};
|
||||||
|
let constraints = HashMap::new();
|
||||||
|
let a = WindowId(1);
|
||||||
|
let b = WindowId(2);
|
||||||
|
let c = WindowId(3);
|
||||||
|
let mut layout = ScrollingLayout::with_default_width(ColumnWidth::Fixed(100.0));
|
||||||
|
layout.set_focus_strategy(ViewportFocusStrategy::Center);
|
||||||
|
layout.insert(a, None).unwrap();
|
||||||
|
layout.insert(b, Some(a)).unwrap();
|
||||||
|
layout.set_column_width(a, ColumnWidth::Fixed(150.0)).unwrap();
|
||||||
|
layout.set_column_width(b, ColumnWidth::Fixed(300.0)).unwrap();
|
||||||
|
layout
|
||||||
|
.geometry_with_constraints(bounds, gaps, &constraints, Some(b))
|
||||||
|
.unwrap();
|
||||||
|
let pending = [(
|
||||||
|
a,
|
||||||
|
ResizeTransaction::new(10.into(), Duration::ZERO).with_column_width(Some(100.0), Some(150.0)),
|
||||||
|
)];
|
||||||
|
let first = viewport_dependencies(&layout, &pending, Default::default());
|
||||||
|
assert_eq!(first.waits.len(), 1);
|
||||||
|
layout.slide_focus_from(b, a).unwrap();
|
||||||
|
layout
|
||||||
|
.geometry_with_constraints(bounds, gaps, &constraints, Some(a))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(layout.viewport_x(), 50.0);
|
||||||
|
let canceled = viewport_dependencies(&layout, &pending, first);
|
||||||
|
assert!(
|
||||||
|
canceled.waits.is_empty(),
|
||||||
|
"relative target cancels pending prefix width"
|
||||||
|
);
|
||||||
|
let mut layout = ScrollingLayout::with_default_width(ColumnWidth::Fixed(40.0));
|
||||||
|
layout.set_focus_strategy(ViewportFocusStrategy::Paged);
|
||||||
|
layout.insert(a, None).unwrap();
|
||||||
|
layout.insert(b, Some(a)).unwrap();
|
||||||
|
layout.insert(c, Some(b)).unwrap();
|
||||||
|
layout
|
||||||
|
.geometry_with_constraints(bounds, gaps, &constraints, Some(c))
|
||||||
|
.unwrap();
|
||||||
|
let pending = [
|
||||||
|
(
|
||||||
|
a,
|
||||||
|
ResizeTransaction::new(10.into(), Duration::ZERO).with_column_width(Some(100.0), Some(40.0)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
b,
|
||||||
|
ResizeTransaction::new(20.into(), Duration::ZERO).with_column_width(Some(100.0), Some(40.0)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
assert!(!layout.viewport_depends_on_width(a, 100.0, 40.0));
|
||||||
|
assert!(!layout.viewport_depends_on_width(b, 100.0, 40.0));
|
||||||
|
let both = viewport_dependencies(&layout, &pending, Default::default());
|
||||||
|
assert_eq!(both.waits.len(), 2, "joint held widths select another page");
|
||||||
|
let partial = viewport_dependencies(&layout, &pending[1..], both);
|
||||||
|
assert!(partial.waits.is_empty(), "one remaining held width fits this page");
|
||||||
|
assert!(viewport_dependencies(&layout, &[], partial).steps.is_empty());
|
||||||
|
// Independent absolute requests reset history. Relative request storage
|
||||||
|
// is capped even if a client keeps replacing its configure.
|
||||||
|
layout.set_focus_strategy(ViewportFocusStrategy::Center);
|
||||||
|
layout.set_column_width(a, ColumnWidth::Fixed(150.0)).unwrap();
|
||||||
|
layout.set_column_width(b, ColumnWidth::Fixed(300.0)).unwrap();
|
||||||
|
layout
|
||||||
|
.geometry_with_constraints(bounds, gaps, &constraints, Some(b))
|
||||||
|
.unwrap();
|
||||||
|
let pending = [(
|
||||||
|
a,
|
||||||
|
ResizeTransaction::new(30.into(), Duration::ZERO).with_column_width(Some(100.0), Some(150.0)),
|
||||||
|
)];
|
||||||
|
let mut graph = viewport_dependencies(&layout, &pending, Default::default());
|
||||||
|
for index in 0..80 {
|
||||||
|
let (from, to) = if index % 2 == 0 { (b, a) } else { (a, b) };
|
||||||
|
layout.slide_focus_from(from, to).unwrap();
|
||||||
|
layout
|
||||||
|
.geometry_with_constraints(bounds, gaps, &constraints, Some(to))
|
||||||
|
.unwrap();
|
||||||
|
graph = viewport_dependencies(&layout, &pending, graph);
|
||||||
|
assert!(graph.steps.len() <= MAX_VIEWPORT_STEPS);
|
||||||
|
}
|
||||||
|
assert!(graph.overflow);
|
||||||
|
assert!(!graph.waits.is_empty(), "bounded fallback must not free linked input");
|
||||||
|
layout.center_window(a, bounds, gaps, &constraints).unwrap();
|
||||||
|
graph = viewport_dependencies(&layout, &pending, graph);
|
||||||
|
assert_eq!(graph.steps.len(), 1);
|
||||||
|
assert!(!graph.overflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn properties_wait_for_only_current_configures_and_consume_release_time() {
|
||||||
|
let mut windows = window_registry::WindowRegistry::default();
|
||||||
|
let a = WindowId(1);
|
||||||
|
let b = WindowId(2);
|
||||||
|
let neighbor = WindowId(3);
|
||||||
|
windows.register("a", a);
|
||||||
|
windows.register("b", b);
|
||||||
|
windows.register("neighbor", neighbor);
|
||||||
|
windows.set_transaction(a, ResizeTransaction::new(10.into(), Duration::ZERO));
|
||||||
|
windows.set_transaction(b, ResizeTransaction::new(20.into(), Duration::from_millis(100)));
|
||||||
|
let commit = |window, serial| ClientCommit {
|
||||||
|
window,
|
||||||
|
serial: Serial::from(serial),
|
||||||
|
};
|
||||||
|
let workspace = WorkspaceId(1);
|
||||||
|
let mut dependencies = PresentationDependencies::default();
|
||||||
|
dependencies.reflow.insert(a, vec![commit(a, 10)]);
|
||||||
|
dependencies.reflow.insert(neighbor, vec![commit(a, 10), commit(b, 20)]);
|
||||||
|
dependencies.wait_for_viewport(workspace, b, 20.into());
|
||||||
|
assert!(dependencies.reflow_blocked(neighbor, &windows));
|
||||||
|
assert!(!dependencies.reflow_blocked(b, &windows));
|
||||||
|
assert!(dependencies.viewport_blocked(workspace, &windows));
|
||||||
|
dependencies.holds(&windows);
|
||||||
|
// A replacement cannot be released by the older committed serial.
|
||||||
|
windows.set_transaction(b, ResizeTransaction::new(21.into(), Duration::from_millis(150)));
|
||||||
|
assert!(!windows.transaction(&b).unwrap().accepts(Some(20.into())));
|
||||||
|
assert!(!dependencies.viewport_blocked(workspace, &windows));
|
||||||
|
dependencies.wait_for_viewport(workspace, b, 21.into());
|
||||||
|
dependencies.reflow.insert(neighbor, vec![commit(a, 10), commit(b, 21)]);
|
||||||
|
windows.expire_transactions(Duration::from_millis(300));
|
||||||
|
assert!(windows.transaction(&a).is_none());
|
||||||
|
assert!(windows.transaction(&b).is_some());
|
||||||
|
assert!(dependencies.reflow_blocked(neighbor, &windows));
|
||||||
|
let (held, _) = dependencies.holds(&windows);
|
||||||
|
assert!(held.contains(&a), "release tick must not charge waiting time");
|
||||||
|
let (held, _) = dependencies.holds(&windows);
|
||||||
|
assert!(!held.contains(&a));
|
||||||
|
// Unmap removes the serial owner; both properties retire on the tick.
|
||||||
|
windows.remove(&"b");
|
||||||
|
assert!(!dependencies.viewport_blocked(workspace, &windows));
|
||||||
|
let (_, held) = dependencies.holds(&windows);
|
||||||
|
assert!(held.contains(&workspace));
|
||||||
|
assert!(dependencies.holds(&windows).1.is_empty());
|
||||||
|
assert!(!dependencies.needs_tick());
|
||||||
|
assert!(dependencies.reflow.is_empty() && dependencies.viewport.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -432,7 +432,7 @@ impl Ferese {
|
|||||||
self.focus_history.remove(id);
|
self.focus_history.remove(id);
|
||||||
#[cfg(feature = "resize-metrics")]
|
#[cfg(feature = "resize-metrics")]
|
||||||
self.resize_metrics
|
self.resize_metrics
|
||||||
.end(id, self.start_time.elapsed(), crate::resize_metrics::End::Cancelled);
|
.end(id, self.presentation_now(), crate::resize_metrics::End::Cancelled);
|
||||||
self.render.remove_window(id);
|
self.render.remove_window(id);
|
||||||
self.capture_render.remove_window(id);
|
self.capture_render.remove_window(id);
|
||||||
self.window_stack.remove(id);
|
self.window_stack.remove(id);
|
||||||
@@ -554,11 +554,12 @@ impl Ferese {
|
|||||||
{
|
{
|
||||||
#[cfg(feature = "resize-metrics")]
|
#[cfg(feature = "resize-metrics")]
|
||||||
{
|
{
|
||||||
self.measure_resize_pauses(self.start_time.elapsed());
|
self.measure_resize_pauses(self.presentation_now());
|
||||||
self.resize_metrics
|
self.resize_metrics
|
||||||
.end(id, self.start_time.elapsed(), crate::resize_metrics::End::Commit);
|
.end(id, self.presentation_now(), crate::resize_metrics::End::Commit);
|
||||||
}
|
}
|
||||||
self.windows.clear_transaction(&id);
|
self.windows.clear_transaction(&id);
|
||||||
|
self.release_presentation_dependencies();
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(size) = client_size(window) else {
|
let Some(size) = client_size(window) else {
|
||||||
@@ -611,7 +612,7 @@ impl Ferese {
|
|||||||
self.windows.set_geometry(id, WindowGeometry::new(rect, Some(size)));
|
self.windows.set_geometry(id, WindowGeometry::new(rect, Some(size)));
|
||||||
#[cfg(feature = "resize-metrics")]
|
#[cfg(feature = "resize-metrics")]
|
||||||
self.resize_metrics
|
self.resize_metrics
|
||||||
.end(id, self.start_time.elapsed(), crate::resize_metrics::End::Cancelled);
|
.end(id, self.presentation_now(), crate::resize_metrics::End::Cancelled);
|
||||||
self.windows.clear_transaction(&id);
|
self.windows.clear_transaction(&id);
|
||||||
self.relayout();
|
self.relayout();
|
||||||
}
|
}
|
||||||
@@ -650,7 +651,7 @@ impl Ferese {
|
|||||||
self.windows.set_geometry(id, WindowGeometry::new(rect, Some(size)));
|
self.windows.set_geometry(id, WindowGeometry::new(rect, Some(size)));
|
||||||
#[cfg(feature = "resize-metrics")]
|
#[cfg(feature = "resize-metrics")]
|
||||||
self.resize_metrics
|
self.resize_metrics
|
||||||
.end(id, self.start_time.elapsed(), crate::resize_metrics::End::Cancelled);
|
.end(id, self.presentation_now(), 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();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::fmt;
|
|||||||
|
|
||||||
mod scrolling;
|
mod scrolling;
|
||||||
|
|
||||||
pub use scrolling::{Column, ColumnWidth, ScrollingLayout, ViewportFocusStrategy};
|
pub use scrolling::{Column, ColumnWidth, ScrollingLayout, ViewportFocusStrategy, ViewportTarget};
|
||||||
|
|
||||||
const MIN_SPLIT_RATIO: f64 = 0.05;
|
const MIN_SPLIT_RATIO: f64 = 0.05;
|
||||||
const MAX_SPLIT_RATIO: f64 = 0.95;
|
const MAX_SPLIT_RATIO: f64 = 0.95;
|
||||||
|
|||||||
@@ -69,6 +69,86 @@ impl Column {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The last calculation that selected the viewport target. Keep it separate
|
||||||
|
// from pending focus requests so an unrelated relayout cannot lose provenance.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct ViewportTarget {
|
||||||
|
columns: Vec<(Vec<WindowId>, f64)>,
|
||||||
|
inner: f64,
|
||||||
|
viewport_width: f64,
|
||||||
|
strategy: ViewportFocusStrategy,
|
||||||
|
start: f64,
|
||||||
|
request: ViewportRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
enum ViewportRequest {
|
||||||
|
Reveal(usize),
|
||||||
|
WidthCycle(usize),
|
||||||
|
Slide(usize, usize),
|
||||||
|
Center(usize, Option<f64>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ViewportTarget {
|
||||||
|
fn target(&self, replacements: &HashMap<usize, f64>, start: Option<f64>) -> f64 {
|
||||||
|
let start = start.unwrap_or(self.start);
|
||||||
|
let mut x = 0.0;
|
||||||
|
let positions = self
|
||||||
|
.columns
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, (_, width))| {
|
||||||
|
let width = replacements.get(&index).copied().unwrap_or(*width);
|
||||||
|
let position = (x, width);
|
||||||
|
x += width + self.inner;
|
||||||
|
position
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut layout = ScrollingLayout {
|
||||||
|
viewport_x: start,
|
||||||
|
focus_strategy: self.strategy,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
match self.request {
|
||||||
|
ViewportRequest::Reveal(active) => layout.reveal_column(&positions, self.viewport_width, active),
|
||||||
|
ViewportRequest::WidthCycle(active) => {
|
||||||
|
layout.active_column = Some(active);
|
||||||
|
layout.retarget_after_width_cycle(&positions, self.viewport_width);
|
||||||
|
}
|
||||||
|
ViewportRequest::Slide(from, to) => {
|
||||||
|
let (last, width) = positions.last().copied().unwrap_or_default();
|
||||||
|
layout.viewport_x = (start + positions[to].0 - positions[from].0)
|
||||||
|
.clamp(0.0, (last + width - self.viewport_width).max(0.0));
|
||||||
|
}
|
||||||
|
ViewportRequest::Center(active, maximum) => {
|
||||||
|
let width = maximum.map_or(positions[active].1, |maximum| positions[active].1.min(maximum));
|
||||||
|
layout.viewport_x = positions[active].0 + width / 2.0 - self.viewport_width / 2.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layout.viewport_x
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-evaluate the same target with pending allocated widths held back.
|
||||||
|
/// A start-relative request can also use the previous held target.
|
||||||
|
pub fn with_held_widths(&self, widths: &[(WindowId, f64, f64)], start: Option<f64>) -> f64 {
|
||||||
|
let replacements = widths
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(window, source, target)| {
|
||||||
|
self.columns
|
||||||
|
.iter()
|
||||||
|
.position(|(windows, width)| windows.contains(window) && (*width - target).abs() < 0.001)
|
||||||
|
.map(|index| (index, *source))
|
||||||
|
})
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
self.target(&replacements, start)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn uses_previous_target(&self) -> bool {
|
||||||
|
matches!(self.request, ViewportRequest::Slide(..))
|
||||||
|
|| (matches!(self.request, ViewportRequest::Reveal(..)) && self.strategy == ViewportFocusStrategy::Minimal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct ScrollingLayout {
|
pub struct ScrollingLayout {
|
||||||
columns: Vec<Column>,
|
columns: Vec<Column>,
|
||||||
@@ -80,6 +160,8 @@ pub struct ScrollingLayout {
|
|||||||
swipe_focus_pending: Option<(WindowId, WindowId)>,
|
swipe_focus_pending: Option<(WindowId, WindowId)>,
|
||||||
width_cycle_pending: bool,
|
width_cycle_pending: bool,
|
||||||
last_viewport_width: Option<f64>,
|
last_viewport_width: Option<f64>,
|
||||||
|
allocated_widths: HashMap<WindowId, f64>,
|
||||||
|
viewport_basis: Option<ViewportTarget>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ScrollingLayout {
|
impl Default for ScrollingLayout {
|
||||||
@@ -94,6 +176,8 @@ impl Default for ScrollingLayout {
|
|||||||
swipe_focus_pending: None,
|
swipe_focus_pending: None,
|
||||||
width_cycle_pending: false,
|
width_cycle_pending: false,
|
||||||
last_viewport_width: None,
|
last_viewport_width: None,
|
||||||
|
allocated_widths: HashMap::new(),
|
||||||
|
viewport_basis: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,6 +221,48 @@ impl ScrollingLayout {
|
|||||||
self.viewport_x
|
self.viewport_x
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn allocated_column_width(&self, window: WindowId) -> Option<f64> {
|
||||||
|
self.allocated_widths.get(&window).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this target changes when the pending column width is held back.
|
||||||
|
/// Allocated widths, rather than cell-grid/native buffer sizes, own the strip.
|
||||||
|
pub fn viewport_depends_on_width(&self, window: WindowId, source: f64, target: f64) -> bool {
|
||||||
|
(source - target).abs() > 0.001
|
||||||
|
&& self
|
||||||
|
.viewport_basis
|
||||||
|
.as_ref()
|
||||||
|
.map(|basis| basis.with_held_widths(&[(window, source, target)], None))
|
||||||
|
.is_some_and(|held| (held - self.viewport_x).abs() > 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn viewport_target(&self) -> Option<&ViewportTarget> {
|
||||||
|
self.viewport_basis.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_viewport_basis(
|
||||||
|
&mut self,
|
||||||
|
positions: &[(f64, f64)],
|
||||||
|
inner: f64,
|
||||||
|
viewport_width: f64,
|
||||||
|
request: ViewportRequest,
|
||||||
|
) {
|
||||||
|
let next = ViewportTarget {
|
||||||
|
columns: self
|
||||||
|
.columns
|
||||||
|
.iter()
|
||||||
|
.zip(positions)
|
||||||
|
.map(|(column, (_, width))| (column.windows.clone(), *width))
|
||||||
|
.collect(),
|
||||||
|
inner,
|
||||||
|
viewport_width,
|
||||||
|
strategy: self.focus_strategy,
|
||||||
|
start: self.viewport_x,
|
||||||
|
request,
|
||||||
|
};
|
||||||
|
self.viewport_basis = Some(next);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn contains(&self, window: WindowId) -> bool {
|
pub fn contains(&self, window: WindowId) -> bool {
|
||||||
self.window_location(window).is_some()
|
self.window_location(window).is_some()
|
||||||
}
|
}
|
||||||
@@ -313,6 +439,43 @@ impl ScrollingLayout {
|
|||||||
let viewport_center = bounds.x + bounds.width / 2.0;
|
let viewport_center = bounds.x + bounds.width / 2.0;
|
||||||
let window_center = rect.x + rect.width / 2.0;
|
let window_center = rect.x + rect.width / 2.0;
|
||||||
self.viewport_x += window_center - viewport_center;
|
self.viewport_x += window_center - viewport_center;
|
||||||
|
let active = self.window_location(window).unwrap().0;
|
||||||
|
let inner = finite_nonnegative(gaps.inner);
|
||||||
|
let positions = self
|
||||||
|
.columns
|
||||||
|
.iter()
|
||||||
|
.scan(0.0, |x, column| {
|
||||||
|
let width = self.allocated_widths[&column.windows[0]];
|
||||||
|
let position = (*x, width);
|
||||||
|
*x += width + inner;
|
||||||
|
Some(position)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let viewport_width = self.last_viewport_width.unwrap();
|
||||||
|
self.record_viewport_basis(
|
||||||
|
&positions,
|
||||||
|
inner,
|
||||||
|
viewport_width,
|
||||||
|
ViewportRequest::Center(
|
||||||
|
active,
|
||||||
|
constraints
|
||||||
|
.get(&window)
|
||||||
|
.copied()
|
||||||
|
.map(normalized_constraints)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.max_width
|
||||||
|
.map(|maximum| {
|
||||||
|
maximum.max(
|
||||||
|
constraints
|
||||||
|
.get(&window)
|
||||||
|
.copied()
|
||||||
|
.map(normalized_constraints)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.min_width,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
self.geometry_with_constraints(bounds, gaps, constraints, Some(window))?;
|
self.geometry_with_constraints(bounds, gaps, constraints, Some(window))?;
|
||||||
|
|
||||||
Ok(self.viewport_x != before)
|
Ok(self.viewport_x != before)
|
||||||
@@ -445,15 +608,35 @@ impl ScrollingLayout {
|
|||||||
column_positions.push((next_column_x, width));
|
column_positions.push((next_column_x, width));
|
||||||
next_column_x += width + inner;
|
next_column_x += width + inner;
|
||||||
}
|
}
|
||||||
|
self.allocated_widths = self
|
||||||
|
.columns
|
||||||
|
.iter()
|
||||||
|
.zip(&column_positions)
|
||||||
|
.flat_map(|(column, (_, width))| column.windows.iter().map(move |id| (*id, *width)))
|
||||||
|
.collect();
|
||||||
let viewport_resized = self.last_viewport_width != Some(viewport_width);
|
let viewport_resized = self.last_viewport_width != Some(viewport_width);
|
||||||
self.last_viewport_width = Some(viewport_width);
|
self.last_viewport_width = Some(viewport_width);
|
||||||
if self.width_cycle_pending {
|
if self.width_cycle_pending {
|
||||||
|
if let Some(active) = self.active_column {
|
||||||
|
self.record_viewport_basis(
|
||||||
|
&column_positions,
|
||||||
|
inner,
|
||||||
|
viewport_width,
|
||||||
|
ViewportRequest::WidthCycle(active),
|
||||||
|
);
|
||||||
|
}
|
||||||
self.retarget_after_width_cycle(&column_positions, viewport_width);
|
self.retarget_after_width_cycle(&column_positions, viewport_width);
|
||||||
self.width_cycle_pending = false;
|
self.width_cycle_pending = false;
|
||||||
self.reveal_pending = None;
|
self.reveal_pending = None;
|
||||||
self.swipe_focus_pending = None;
|
self.swipe_focus_pending = None;
|
||||||
} else if let Some((previous, focused)) = self.swipe_focus_pending.take() {
|
} else if let Some((previous, focused)) = self.swipe_focus_pending.take() {
|
||||||
if let (Some((from, _)), Some((to, _))) = (self.window_location(previous), self.window_location(focused)) {
|
if let (Some((from, _)), Some((to, _))) = (self.window_location(previous), self.window_location(focused)) {
|
||||||
|
self.record_viewport_basis(
|
||||||
|
&column_positions,
|
||||||
|
inner,
|
||||||
|
viewport_width,
|
||||||
|
ViewportRequest::Slide(from, to),
|
||||||
|
);
|
||||||
let last_end = column_positions
|
let last_end = column_positions
|
||||||
.last()
|
.last()
|
||||||
.map(|(start, width)| start + width)
|
.map(|(start, width)| start + width)
|
||||||
@@ -470,6 +653,12 @@ impl ScrollingLayout {
|
|||||||
.flatten()
|
.flatten()
|
||||||
});
|
});
|
||||||
if let Some((column, _)) = reveal.and_then(|window| self.window_location(window)) {
|
if let Some((column, _)) = reveal.and_then(|window| self.window_location(window)) {
|
||||||
|
self.record_viewport_basis(
|
||||||
|
&column_positions,
|
||||||
|
inner,
|
||||||
|
viewport_width,
|
||||||
|
ViewportRequest::Reveal(column),
|
||||||
|
);
|
||||||
self.reveal_column(&column_positions, viewport_width, column);
|
self.reveal_column(&column_positions, viewport_width, column);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -723,6 +912,121 @@ mod tests {
|
|||||||
WindowId(id)
|
WindowId(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_center_replays_maximum_even_when_only_held_width_crosses_it() {
|
||||||
|
let bounds = Rect::new(0.0, 0.0, 200.0, 100.0);
|
||||||
|
let gaps = GapConfig {
|
||||||
|
inner: 0.0,
|
||||||
|
outer: 0.0,
|
||||||
|
smart: false,
|
||||||
|
};
|
||||||
|
let id = WindowId(1);
|
||||||
|
let mut layout = ScrollingLayout::with_default_width(ColumnWidth::Fixed(100.0));
|
||||||
|
layout.insert(id, None).unwrap();
|
||||||
|
let constraints = HashMap::from([(
|
||||||
|
id,
|
||||||
|
SizeConstraints {
|
||||||
|
max_width: Some(200.0),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)]);
|
||||||
|
layout.center_window(id, bounds, gaps, &constraints).unwrap();
|
||||||
|
assert_eq!(layout.viewport_x(), -50.0);
|
||||||
|
assert_eq!(
|
||||||
|
layout
|
||||||
|
.viewport_target()
|
||||||
|
.unwrap()
|
||||||
|
.with_held_widths(&[(id, 300.0, 100.0)], None),
|
||||||
|
0.0
|
||||||
|
);
|
||||||
|
let constraints = HashMap::from([(
|
||||||
|
id,
|
||||||
|
SizeConstraints {
|
||||||
|
max_width: Some(40.0),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)]);
|
||||||
|
layout.set_column_width(id, ColumnWidth::Fixed(150.0)).unwrap();
|
||||||
|
layout.center_window(id, bounds, gaps, &constraints).unwrap();
|
||||||
|
assert!(!layout.viewport_depends_on_width(id, 100.0, 150.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn viewport_dependencies_follow_allocated_widths_and_target_derivation() {
|
||||||
|
let bounds = Rect::new(0.0, 0.0, 200.0, 100.0);
|
||||||
|
let gaps = GapConfig {
|
||||||
|
inner: 0.0,
|
||||||
|
outer: 0.0,
|
||||||
|
smart: false,
|
||||||
|
};
|
||||||
|
let a = WindowId(1);
|
||||||
|
let b = WindowId(2);
|
||||||
|
let c = WindowId(3);
|
||||||
|
for strategy in [
|
||||||
|
ViewportFocusStrategy::Minimal,
|
||||||
|
ViewportFocusStrategy::Center,
|
||||||
|
ViewportFocusStrategy::Paged,
|
||||||
|
] {
|
||||||
|
let mut layout = ScrollingLayout::with_default_width(ColumnWidth::Fixed(100.0));
|
||||||
|
layout.set_focus_strategy(strategy);
|
||||||
|
layout.insert(a, None).unwrap();
|
||||||
|
layout.insert(b, Some(a)).unwrap();
|
||||||
|
layout.insert(c, Some(b)).unwrap();
|
||||||
|
layout
|
||||||
|
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(c))
|
||||||
|
.unwrap();
|
||||||
|
layout.set_column_width(b, ColumnWidth::Fixed(150.0)).unwrap();
|
||||||
|
layout
|
||||||
|
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(c))
|
||||||
|
.unwrap();
|
||||||
|
assert!(layout.viewport_depends_on_width(b, 100.0, 150.0), "{strategy:?}");
|
||||||
|
assert!(
|
||||||
|
!layout.viewport_depends_on_width(b, 150.0, 150.0),
|
||||||
|
"height-only size change"
|
||||||
|
);
|
||||||
|
// An unrelated relayout does not erase the last calculation.
|
||||||
|
layout
|
||||||
|
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(c))
|
||||||
|
.unwrap();
|
||||||
|
assert!(layout.viewport_depends_on_width(b, 100.0, 150.0));
|
||||||
|
layout.focus_and_reveal(a).unwrap();
|
||||||
|
layout
|
||||||
|
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(a))
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
!layout.viewport_depends_on_width(b, 100.0, 150.0),
|
||||||
|
"independent reversal {strategy:?}"
|
||||||
|
);
|
||||||
|
layout.center_window(b, bounds, gaps, &HashMap::new()).unwrap();
|
||||||
|
assert!(
|
||||||
|
layout.viewport_depends_on_width(b, 100.0, 150.0),
|
||||||
|
"explicit centering {strategy:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!layout.viewport_depends_on_width(b, 150.0, 100.0),
|
||||||
|
"obsolete width generation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut layout = ScrollingLayout::with_default_width(ColumnWidth::Fixed(100.0));
|
||||||
|
layout.insert(a, None).unwrap();
|
||||||
|
layout.insert(b, Some(a)).unwrap();
|
||||||
|
layout.insert(c, Some(b)).unwrap();
|
||||||
|
let constraints = HashMap::from([(
|
||||||
|
b,
|
||||||
|
SizeConstraints {
|
||||||
|
min_width: 150.0,
|
||||||
|
max_width: Some(40.0),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)]);
|
||||||
|
let result = layout
|
||||||
|
.geometry_with_constraints(bounds, gaps, &constraints, Some(c))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(layout.allocated_column_width(b), Some(150.0));
|
||||||
|
assert_eq!(result.geometry[&b].width, 150.0, "minimum wins inconsistent maximum");
|
||||||
|
assert!(layout.viewport_depends_on_width(b, 100.0, 150.0));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn inserts_new_columns_after_focus() {
|
fn inserts_new_columns_after_focus() {
|
||||||
let mut layout = ScrollingLayout::default();
|
let mut layout = ScrollingLayout::default();
|
||||||
|
|||||||
@@ -34,3 +34,35 @@ up to about 200 ms.
|
|||||||
|
|
||||||
Repeat on the current build. A nested result does not establish direct-session
|
Repeat on the current build. A nested result does not establish direct-session
|
||||||
resize or frame-pacing behavior.
|
resize or frame-pacing behavior.
|
||||||
|
|
||||||
|
## Controlled slow client
|
||||||
|
|
||||||
|
Build the compositor and control tool, then run on a Wayland host:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --release --locked -p ferese -p feresectl
|
||||||
|
FERESE_TEST_RESIZE=1 FERESE_TEST_BINARY=target/release/ferese \
|
||||||
|
FERESE_TEST_CTL=target/release/feresectl \
|
||||||
|
python3 scripts/tests/test_resize_dependencies_isolated.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The test creates a private nested desktop and two SHM clients. One acknowledges
|
||||||
|
a resize immediately but withholds its new buffer until instructed. It checks
|
||||||
|
that an independent first-column viewport target moves both windows together
|
||||||
|
before the 300 ms deadline, while the slow client's raster and presented size
|
||||||
|
stay held, then commits an actually resized raster. This is a state/coordinate
|
||||||
|
check on the selected host renderer, not a smoothness or hardware benchmark.
|
||||||
|
|
||||||
|
For deterministic commit, supersession, multiple-client, timeout, unmap,
|
||||||
|
workspace, swipe-cancel, stacked-column, prediction, hit-testing, snapshot and
|
||||||
|
capture privacy coverage without a host desktop:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test --locked -p ferese resize_dependencies_protocol_and_pixels -- --ignored
|
||||||
|
```
|
||||||
|
|
||||||
|
That test requires an offscreen EGL device, including a software device. Layout
|
||||||
|
and dependency unit tests run without EGL in the ordinary workspace test suite.
|
||||||
|
Viewport dependencies retain at most 32 relative target recipes; a longer burst
|
||||||
|
falls back to a conservative linked wait until commit, deadline, or an absolute
|
||||||
|
retarget. Existing resize snapshots and their handoff lifetime are reused.
|
||||||
Vendored
+122
@@ -0,0 +1,122 @@
|
|||||||
|
/* Controlled resize client: acknowledge immediately, attach only on command.
|
||||||
|
* Sync markers mean the server has processed the preceding ack/commit. */
|
||||||
|
#define _GNU_SOURCE
|
||||||
|
#include <poll.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <sys/mman.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <wayland-client.h>
|
||||||
|
#include "xdg-shell-client-protocol.h"
|
||||||
|
|
||||||
|
static struct wl_display *display;
|
||||||
|
static struct wl_compositor *compositor;
|
||||||
|
static struct wl_shm *shm;
|
||||||
|
static struct xdg_wm_base *wm;
|
||||||
|
static struct wl_surface *surface;
|
||||||
|
static struct xdg_surface *xdg;
|
||||||
|
static struct wl_buffer *buffer;
|
||||||
|
static int width = 64, height = 64, holding;
|
||||||
|
static uint32_t serial;
|
||||||
|
|
||||||
|
struct marker { const char *kind; uint32_t serial; int width, height; };
|
||||||
|
static void done(void *data, struct wl_callback *callback, uint32_t time) {
|
||||||
|
(void)time;
|
||||||
|
struct marker *m = data;
|
||||||
|
printf("%s %u %d %d\n", m->kind, m->serial, m->width, m->height);
|
||||||
|
fflush(stdout);
|
||||||
|
wl_callback_destroy(callback);
|
||||||
|
free(m);
|
||||||
|
}
|
||||||
|
static const struct wl_callback_listener sync_listener = {.done = done};
|
||||||
|
static void mark(const char *kind) {
|
||||||
|
struct marker *m = malloc(sizeof(*m));
|
||||||
|
if (!m) exit(3);
|
||||||
|
*m = (struct marker){kind, serial, width, height};
|
||||||
|
wl_callback_add_listener(wl_display_sync(display), &sync_listener, m);
|
||||||
|
}
|
||||||
|
static void attach(void) {
|
||||||
|
size_t bytes = (size_t)width * height * 4;
|
||||||
|
int fd = memfd_create("slow-resize", MFD_CLOEXEC);
|
||||||
|
if (fd < 0 || ftruncate(fd, bytes)) exit(3);
|
||||||
|
uint32_t *pixels = mmap(NULL, bytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
|
||||||
|
if (pixels == MAP_FAILED) exit(4);
|
||||||
|
for (size_t i = 0; i < bytes / 4; i++) pixels[i] = 0xff3182ce;
|
||||||
|
struct wl_shm_pool *pool = wl_shm_create_pool(shm, fd, bytes);
|
||||||
|
struct wl_buffer *next = wl_shm_pool_create_buffer(pool, 0, width, height,
|
||||||
|
width * 4, WL_SHM_FORMAT_XRGB8888);
|
||||||
|
wl_shm_pool_destroy(pool);
|
||||||
|
munmap(pixels, bytes);
|
||||||
|
close(fd);
|
||||||
|
if (buffer) wl_buffer_destroy(buffer);
|
||||||
|
buffer = next;
|
||||||
|
xdg_surface_set_window_geometry(xdg, 0, 0, width, height);
|
||||||
|
wl_surface_attach(surface, buffer, 0, 0);
|
||||||
|
wl_surface_damage(surface, 0, 0, width, height);
|
||||||
|
wl_surface_commit(surface);
|
||||||
|
mark("committed");
|
||||||
|
}
|
||||||
|
static void configure(void *data, struct xdg_surface *shell, uint32_t value) {
|
||||||
|
(void)data;
|
||||||
|
serial = value;
|
||||||
|
xdg_surface_ack_configure(shell, serial);
|
||||||
|
if (holding) mark("held"); else attach();
|
||||||
|
}
|
||||||
|
static const struct xdg_surface_listener surface_listener = {.configure = configure};
|
||||||
|
static void size(void *data, struct xdg_toplevel *top, int32_t w, int32_t h, struct wl_array *states) {
|
||||||
|
(void)data; (void)top; (void)states;
|
||||||
|
if (w > 0) width = w;
|
||||||
|
if (h > 0) height = h;
|
||||||
|
}
|
||||||
|
static void close_window(void *data, struct xdg_toplevel *top) {
|
||||||
|
(void)data; (void)top; exit(0);
|
||||||
|
}
|
||||||
|
static const struct xdg_toplevel_listener top_listener = {.configure = size, .close = close_window};
|
||||||
|
static void ping(void *data, struct xdg_wm_base *base, uint32_t value) {
|
||||||
|
(void)data; xdg_wm_base_pong(base, value);
|
||||||
|
}
|
||||||
|
static const struct xdg_wm_base_listener wm_listener = {.ping = ping};
|
||||||
|
static void global(void *data, struct wl_registry *registry, uint32_t name, const char *interface, uint32_t version) {
|
||||||
|
(void)data; (void)version;
|
||||||
|
if (!strcmp(interface, "wl_compositor")) compositor = wl_registry_bind(registry, name, &wl_compositor_interface, 4);
|
||||||
|
else if (!strcmp(interface, "wl_shm")) shm = wl_registry_bind(registry, name, &wl_shm_interface, 1);
|
||||||
|
else if (!strcmp(interface, "xdg_wm_base")) {
|
||||||
|
wm = wl_registry_bind(registry, name, &xdg_wm_base_interface, 1);
|
||||||
|
xdg_wm_base_add_listener(wm, &wm_listener, NULL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static void removed(void *data, struct wl_registry *registry, uint32_t name) {
|
||||||
|
(void)data; (void)registry; (void)name;
|
||||||
|
}
|
||||||
|
static const struct wl_registry_listener registry_listener = {global, removed};
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
display = wl_display_connect(NULL);
|
||||||
|
if (!display) return 1;
|
||||||
|
wl_registry_add_listener(wl_display_get_registry(display), ®istry_listener, NULL);
|
||||||
|
if (wl_display_roundtrip(display) < 0 || !compositor || !shm || !wm) return 2;
|
||||||
|
surface = wl_compositor_create_surface(compositor);
|
||||||
|
xdg = xdg_wm_base_get_xdg_surface(wm, surface);
|
||||||
|
xdg_surface_add_listener(xdg, &surface_listener, NULL);
|
||||||
|
struct xdg_toplevel *top = xdg_surface_get_toplevel(xdg);
|
||||||
|
xdg_toplevel_add_listener(top, &top_listener, NULL);
|
||||||
|
xdg_toplevel_set_app_id(top, argc > 1 ? argv[1] : "ferese.test.slow-resize");
|
||||||
|
xdg_toplevel_set_title(top, "Controlled slow resize");
|
||||||
|
wl_surface_commit(surface);
|
||||||
|
for (;;) {
|
||||||
|
if (wl_display_dispatch_pending(display) < 0 || wl_display_flush(display) < 0) return 5;
|
||||||
|
struct pollfd fds[] = {{wl_display_get_fd(display), POLLIN, 0}, {STDIN_FILENO, POLLIN, 0}};
|
||||||
|
if (poll(fds, 2, -1) < 0) return 6;
|
||||||
|
if (fds[0].revents && wl_display_dispatch(display) < 0) return 5;
|
||||||
|
if (fds[1].revents) {
|
||||||
|
char command;
|
||||||
|
if (read(STDIN_FILENO, &command, 1) != 1 || command == 'q') break;
|
||||||
|
if (command == 'h') { holding = 1; mark("holding"); }
|
||||||
|
if (command == 'c') attach();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (buffer) wl_buffer_destroy(buffer);
|
||||||
|
wl_display_disconnect(display);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Controlled ack/commit resize test on a disposable nested desktop.
|
||||||
|
|
||||||
|
FERESE_TEST_RESIZE=1 FERESE_TEST_BINARY=target/release/ferese \
|
||||||
|
FERESE_TEST_CTL=target/release/feresectl python3 scripts/tests/test_resize_dependencies_isolated.py
|
||||||
|
Requires a Wayland host, cc, pkg-config and wayland-scanner. This checks state
|
||||||
|
before the 300 ms deadline; it does not measure frame pacing or GPU performance.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import select
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.environ.get("FERESE_TEST_RESIZE") == "1", "opt-in nested test")
|
||||||
|
class ResizeDependencies(unittest.TestCase):
|
||||||
|
def test_independent_translation_before_resized_buffer(self):
|
||||||
|
processes = []
|
||||||
|
with tempfile.TemporaryDirectory(prefix="ferese-resize-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
protocols = Path(subprocess.check_output(
|
||||||
|
["pkg-config", "--variable=pkgdatadir", "wayland-protocols"], text=True).strip())
|
||||||
|
xml = str(protocols / "stable/xdg-shell/xdg-shell.xml")
|
||||||
|
for kind, name in [("client-header", "xdg-shell-client-protocol.h"),
|
||||||
|
("private-code", "xdg-shell-protocol.c")]:
|
||||||
|
subprocess.run(["wayland-scanner", kind, xml, str(root / name)], check=True)
|
||||||
|
flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", "wayland-client"], text=True).split()
|
||||||
|
subprocess.run(["cc", "-Wall", "-Wextra", "-Werror", "-I", str(root),
|
||||||
|
str(REPO / "scripts/tests/fixtures/slow-resize.c"),
|
||||||
|
str(root / "xdg-shell-protocol.c"), "-o", str(root / "client"), *flags], check=True)
|
||||||
|
runtime = root / "runtime"
|
||||||
|
runtime.mkdir(mode=0o700)
|
||||||
|
config = root / "config/ferese/config.kdl"
|
||||||
|
config.parent.mkdir(parents=True)
|
||||||
|
config.write_text('layout { inner-gap 0; outer-gap 0; }\n'
|
||||||
|
'theme { geometry { window-radius 0; border-width 0; focus-ring-width 0; }; }\n')
|
||||||
|
host = Path(os.environ["WAYLAND_DISPLAY"])
|
||||||
|
if not host.is_absolute():
|
||||||
|
host = Path(os.environ["XDG_RUNTIME_DIR"]) / host
|
||||||
|
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"),
|
||||||
|
XDG_STATE_HOME=str(root / "state"), WAYLAND_DISPLAY=str(host))
|
||||||
|
for key in ["WAYLAND_SOCKET", "FERESE_SOCKET", "FERESE_SHELL_CONTROL_SOCKET"]:
|
||||||
|
env.pop(key, None)
|
||||||
|
binary = REPO / os.environ.get("FERESE_TEST_BINARY", "target/debug/ferese")
|
||||||
|
ctl = REPO / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl")
|
||||||
|
with (root / "compositor.log").open("w") as log:
|
||||||
|
try:
|
||||||
|
compositor = subprocess.Popen([str(binary), "--backend=nested"], env=env, stdout=log, stderr=log)
|
||||||
|
processes.append(compositor)
|
||||||
|
deadline = time.monotonic() + 10
|
||||||
|
while not (runtime / "ferese/control.sock").exists():
|
||||||
|
if compositor.poll() is not None or time.monotonic() > deadline:
|
||||||
|
self.fail((root / "compositor.log").read_text())
|
||||||
|
time.sleep(.02)
|
||||||
|
socket = next(p for p in runtime.glob("wayland-*") if not p.name.endswith(".lock"))
|
||||||
|
client_env = dict(env, WAYLAND_DISPLAY=str(socket))
|
||||||
|
|
||||||
|
def call(*args):
|
||||||
|
return json.loads(subprocess.check_output([str(ctl), *args], env=env, timeout=5))
|
||||||
|
|
||||||
|
def action(*args):
|
||||||
|
subprocess.run([str(ctl), *args], env=env, check=True, capture_output=True, timeout=5)
|
||||||
|
|
||||||
|
def client(name):
|
||||||
|
process = subprocess.Popen([str(root / "client"), name], env=client_env,
|
||||||
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||||
|
stderr=log, bufsize=0)
|
||||||
|
processes.append(process)
|
||||||
|
return process
|
||||||
|
|
||||||
|
pending = {}
|
||||||
|
|
||||||
|
def marker(process, kind, timeout=5):
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
data = pending.pop(process.pid, b"")
|
||||||
|
while True:
|
||||||
|
while b"\n" in data:
|
||||||
|
line, data = data.split(b"\n", 1)
|
||||||
|
fields = line.decode().split()
|
||||||
|
if fields[0] == kind:
|
||||||
|
pending[process.pid] = data
|
||||||
|
return list(map(int, fields[1:]))
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
self.assertGreater(remaining, 0, f"missing {kind} marker")
|
||||||
|
ready, _, _ = select.select([process.stdout], [], [], remaining)
|
||||||
|
self.assertTrue(ready, f"missing {kind} marker")
|
||||||
|
chunk = os.read(process.stdout.fileno(), 4096)
|
||||||
|
self.assertTrue(chunk, (root / "compositor.log").read_text())
|
||||||
|
data += chunk
|
||||||
|
|
||||||
|
def windows(count):
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
current = call("get-windows")
|
||||||
|
if len(current) == count and all(w["mapped"] for w in current):
|
||||||
|
return {w["app_id"]: w for w in current}
|
||||||
|
time.sleep(.02)
|
||||||
|
self.fail((root / "compositor.log").read_text())
|
||||||
|
|
||||||
|
first = client("ferese.test.first")
|
||||||
|
marker(first, "committed")
|
||||||
|
windows(1)
|
||||||
|
slow = client("ferese.test.slow")
|
||||||
|
marker(slow, "committed")
|
||||||
|
windows(2)
|
||||||
|
# Settle opening/focus motion before isolating one configure.
|
||||||
|
time.sleep(1)
|
||||||
|
slow.stdin.write(b"h")
|
||||||
|
marker(slow, "holding")
|
||||||
|
before = windows(2)
|
||||||
|
original = before["ferese.test.slow"]
|
||||||
|
started = time.monotonic()
|
||||||
|
action("resize", "right")
|
||||||
|
configured = marker(slow, "held", timeout=.25)
|
||||||
|
self.assertGreater(configured[1], original["capture_width"])
|
||||||
|
# The ack has reached the compositor; no new buffer exists.
|
||||||
|
action("focus", "left")
|
||||||
|
action("center-column")
|
||||||
|
moved = None
|
||||||
|
while time.monotonic() - started < .25:
|
||||||
|
current = windows(2)
|
||||||
|
observed_elapsed = time.monotonic() - started
|
||||||
|
if observed_elapsed >= .25:
|
||||||
|
break
|
||||||
|
a, b = current["ferese.test.first"], current["ferese.test.slow"]
|
||||||
|
if abs(a["x"] - before["ferese.test.first"]["x"]) >= 2:
|
||||||
|
moved = current
|
||||||
|
break
|
||||||
|
time.sleep(.005)
|
||||||
|
self.assertIsNotNone(moved, "viewport did not move before the resize deadline")
|
||||||
|
a, b = moved["ferese.test.first"], moved["ferese.test.slow"]
|
||||||
|
self.assertEqual(b["capture_width"], original["capture_width"])
|
||||||
|
self.assertEqual(b["width"], original["width"])
|
||||||
|
self.assertAlmostEqual(a["x"] - before["ferese.test.first"]["x"],
|
||||||
|
b["x"] - original["x"], delta=1)
|
||||||
|
slow.stdin.write(b"c")
|
||||||
|
committed = marker(slow, "committed")
|
||||||
|
self.assertEqual(committed[1:], configured[1:])
|
||||||
|
self.assertEqual(windows(2)["ferese.test.slow"]["capture_width"], configured[1])
|
||||||
|
print(f"nested: movement observed at {observed_elapsed * 1000:.1f} ms; "
|
||||||
|
"held raster/size; resized SHM commit (state check, not frame pacing)")
|
||||||
|
finally:
|
||||||
|
for process in reversed(processes):
|
||||||
|
if process.poll() is None:
|
||||||
|
process.terminate()
|
||||||
|
process.wait(timeout=5)
|
||||||
|
for stream in [process.stdin, process.stdout]:
|
||||||
|
if stream is not None:
|
||||||
|
stream.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user