Unify window presentation and frame-driven shell motion

Share window geometry across overview, resize snapshots and retained closes. Advance close handoffs and group retained rendering by stacking position.

Drive shell motion from surface frames, reuse settled widget content, and synchronize notification materials with each card's fade. Replace physical config keys with duration and bounce, and update documentation and regression coverage.
This commit is contained in:
Samuel Onoja committed 2026-10-03 04:22:36 +01:00
1 parent f8bc17e913
commit 64a80dc069
33 files changed
+1742 -600

No files matched your search

+31 -6
View File
@@ -200,16 +200,25 @@ impl Ferese {
if self.animations_enabled {
active_animation |= focus.advance(
if changed { Duration::ZERO } else { delta },
SpringConfig {
position_tolerance: 0.001,
velocity_tolerance: 0.001,
..self.spring_config
},
crate::presentation::emphasis_spring(self.spring_config),
);
} else {
focus.snap();
}
dim_changed |= previous != focus.current;
let shadow = record.shadow.get_or_insert_with(|| AnimatedValue::new(target));
let previous = shadow.current;
let changed = shadow.target != target;
shadow.set_target(target);
if self.animations_enabled {
active_animation |= shadow.advance(
if changed { Duration::ZERO } else { delta },
crate::presentation::shadow_spring(self.spring_config),
);
} else {
shadow.snap();
}
dim_changed |= previous != shadow.current;
}
for window in self.space.elements() {
@@ -255,7 +264,7 @@ impl Ferese {
let active =
self.animations_enabled && !self.session_lock.active() && window.advance(delta, self.spring_config);
if !active {
finished.push(window.id);
finished.push(window.presentation.id);
}
active_animation |= active;
active
@@ -608,6 +617,7 @@ fn record_needs_tick(
if record.resize.is_some()
|| record.opening.is_some()
|| record.focus.as_ref().is_none_or(|motion| motion.needs_update(focus))
|| record.shadow.as_ref().is_none_or(|motion| motion.needs_update(focus))
{
return true;
}
@@ -644,6 +654,7 @@ mod tests {
WindowRecord {
geometry: Some(geometry),
focus: Some(AnimatedValue::new(1.0)),
shadow: Some(AnimatedValue::new(1.0)),
dimming: Some(DimAnimation::new(0.0)),
..Default::default()
}
@@ -664,6 +675,20 @@ mod tests {
assert!(!record_needs_tick(&record, 1.0, 0.15, || false));
}
#[test]
fn shadow_keeps_ticks_until_it_settles_after_emphasis() {
let mut record = settled();
let shadow = record.shadow.as_mut().unwrap();
shadow.current = 0.9;
shadow.velocity = 0.2;
assert!(record_needs_tick(&record, 1.0, 0.0, || false));
record.shadow.as_mut().unwrap().advance(
Duration::from_secs(3),
crate::presentation::shadow_spring(SpringConfig::default()),
);
assert!(!record_needs_tick(&record, 1.0, 0.0, || false));
}
#[test]
fn geometry_motion_client_waits_and_resize_barriers_cannot_be_skipped() {
let mut record = settled();
+17 -39
View File
@@ -1,6 +1,4 @@
use super::*;
use ferese_animation::AnimatedRect;
use smithay::backend::renderer::Texture;
use smithay::wayland::compositor::{BufferAssignment, SurfaceAttributes};
impl Ferese {
@@ -31,7 +29,7 @@ impl Ferese {
let Some(&id) = self.windows.ids().get(window) else {
return;
};
if self.render.closing.iter().any(|window| window.id == id) || !self.window_content_ready(window) {
if self.render.closing.iter().any(|window| window.presentation.id == id) || !self.window_content_ready(window) {
return;
}
let Some(output) = self
@@ -45,36 +43,19 @@ impl Ferese {
let Some(output_id) = self.output_id(&output) else {
return;
};
let Some(rect) = self.presented_window_rect(id) else {
let Some(mut presentation) = self.current_window_presentation(id) else {
return;
};
let record = self.windows.record(id).unwrap();
let geometry = record.geometry.unwrap();
let decorations = geometry.decorations.clamp(0.0, 1.0);
let focus = record.focus.as_ref().map_or(0.0, |focus| focus.current.clamp(0.0, 1.0));
let dim = record.dimming.as_ref().map_or(0.0, |dim| dim.current);
let mut opacity = record.opening.unwrap_or_else(|| AnimatedValue::new(1.0));
opacity.set_target(0.0);
let mut bounds = AnimatedRect::new(rect);
bounds.velocity = self.overview.presented_velocity(id).unwrap_or(geometry.visual.velocity);
if !self.overview.is_presenting()
&& let Some(slide) = self.workspace_slides.get(&output_id)
&& let Some(workspace) = self.workspaces.workspace_for_window(id)
&& let Some(item) = slide.items.iter().find(|item| item.workspace == workspace)
&& let Some(area) = self.space.output_geometry(&output)
{
bounds.velocity.x += item.velocity.x * f64::from(area.size.w);
bounds.velocity.y += item.velocity.y * f64::from(area.size.h);
}
if let Some(opening) = record.opening {
bounds.velocity = crate::presentation::scaled_visual_velocity(
self.overview.presented_rect(id, geometry.visual.current),
bounds.velocity,
0.97 + 0.03 * opening.current,
0.03 * opening.velocity,
);
}
bounds.set_target(crate::presentation::scaled_visual_rect(rect, 0.98));
let handoff_size = (!presentation.scale_content).then(|| {
presentation
.native_size
.unwrap_or_else(|| ClientSize::from_rect(presentation.bounds.current))
});
presentation.close();
let mut source_geometry = window.geometry();
if !self.overview.is_presenting() {
let size = ClientSize::from_rect(geometry.visual.current);
@@ -98,12 +79,7 @@ impl Ferese {
.and_then(|index| order.get(index + 1))
.copied();
let used = self.render.snapshots().map(|snapshot| snapshot.bytes()).sum::<usize>()
+ self
.render
.closing
.iter()
.map(|window| window.snapshot.bytes())
.sum::<usize>();
+ self.render.closing.iter().map(|window| window.bytes()).sum::<usize>();
let remaining = crate::presentation::SNAPSHOT_BUDGET.saturating_sub(used);
let scale = output.current_scale().fractional_scale();
let result = if let Some(backend) = &self.nested_backend {
@@ -125,7 +101,12 @@ impl Ferese {
match result {
Ok(Some(snapshot)) => {
tracing::debug!(?id, bytes = snapshot.bytes(), "retained close presentation");
let size = snapshot.texture.size();
let handoff = self
.render
.snapshot(&id)
.filter(|old| old.context == snapshot.context && (old.scale - scale).abs() < 0.001)
.cloned()
.map(|old| (old, handoff_size));
let material = window.toplevel().and_then(|toplevel| {
let surface = toplevel.wl_surface();
let (role, generation) = crate::effects::surface_role(surface)?;
@@ -149,16 +130,13 @@ impl Ferese {
&& (client.w < source_geometry.size.w || client.h < source_geometry.size.h))
.then_some(self.theme_settings.surface_base_color.0);
self.render.closing.push(crate::render::ClosedWindow {
id,
presentation,
output: output_id,
below,
bounds,
opacity,
snapshot,
source: Rectangle::from_size((f64::from(size.w), f64::from(size.h)).into()),
handoff,
radius,
shape: crate::render::window_corner_shape(window),
focus,
decorations,
dim,
fill,
+106 -69
View File
@@ -12,15 +12,97 @@ pub(crate) struct FrameScene {
pub(crate) struct WindowFrame {
pub geometry: WindowGeometry,
pub rect: Rect,
pub scale_content: bool,
pub native_size: Option<ClientSize>,
pub alpha: f32,
pub focus: f64,
pub presentation: crate::presentation::WindowPresentation,
pub dim: f64,
}
impl Ferese {
pub(crate) fn current_window_presentation(&self, id: WindowId) -> Option<crate::presentation::WindowPresentation> {
let geometry = *self.windows.geometry(&id)?;
Some(self.compose_window_presentation(
id,
geometry,
self.overview.motion(),
self.workspace_slide_offset(id),
Duration::ZERO,
))
}
fn compose_window_presentation(
&self,
id: WindowId,
geometry: WindowGeometry,
overview: &crate::overview::OverviewMotion,
offset: (f64, f64),
delta: Duration,
) -> crate::presentation::WindowPresentation {
let record = self.windows.record(id).expect("presentation has a window record");
let mut bounds = overview.presented_bounds(id, geometry.visual);
if !overview.is_presenting() {
bounds.current.x += offset.0;
bounds.current.y += offset.1;
if let Some(workspace) = self.workspaces.workspace_for_window(id)
&& let Some(output_id) = self.output_workspaces.output_for_workspace(workspace)
&& let Some(slide) = self.workspace_slides.get(&output_id)
&& let Some(item) = slide.items.iter().find(|item| item.workspace == workspace)
&& let Some(area) = self
.outputs_by_id
.get(&output_id)
.and_then(|output| self.space.output_geometry(output))
{
let (_, velocity) = slide.sample(item, delta);
bounds.velocity.x += velocity.x * f64::from(area.size.w);
bounds.velocity.y += velocity.y * f64::from(area.size.h);
}
}
let focused = if self.overview.is_active() {
self.overview_selected(id)
} else {
self.focused_window == Some(id)
};
let mut opacity = record.opening.unwrap_or_else(|| AnimatedValue::new(1.0));
let mut emphasis = record
.focus
.unwrap_or_else(|| AnimatedValue::new(if focused { 1.0 } else { 0.0 }));
let mut shadow = record.shadow.unwrap_or(emphasis);
if !self.animations_enabled {
opacity.snap();
emphasis.snap();
shadow.snap();
} else if !delta.is_zero() {
opacity.advance(
delta,
SpringConfig {
position_tolerance: 0.00001,
velocity_tolerance: 0.00001,
..self.spring_config
},
);
emphasis.advance(delta, crate::presentation::emphasis_spring(self.spring_config));
shadow.advance(delta, crate::presentation::shadow_spring(self.spring_config));
}
let presence_scale = 0.97 + 0.03 * opacity.current;
let native_size =
(!overview.is_presenting() && presence_scale != 1.0).then(|| ClientSize::from_rect(bounds.current));
bounds.velocity = crate::presentation::scaled_visual_velocity(
bounds.current,
bounds.velocity,
presence_scale,
0.03 * opacity.velocity,
);
bounds.current = crate::presentation::scaled_visual_rect(bounds.current, presence_scale);
bounds.target = bounds.current;
crate::presentation::WindowPresentation {
id,
bounds,
opacity,
emphasis,
shadow,
scale_content: overview.is_presenting(),
native_size,
}
}
fn output_window_records<'a>(
&'a self,
output: &'a Output,
@@ -58,6 +140,10 @@ impl Ferese {
.focus
.as_ref()
.is_some_and(|focus| focus.needs_update(if selected == Some(id) { 1.0 } else { 0.0 }))
|| record
.shadow
.as_ref()
.is_some_and(|shadow| shadow.needs_update(if selected == Some(id) { 1.0 } else { 0.0 }))
|| record.dimming.as_ref().is_some_and(|dim| {
dim.needs_update(crate::dimming::target(
self.inactive_dim,
@@ -105,6 +191,7 @@ impl Ferese {
|| record.opening.is_some()
|| self.render.snapshot(&id).is_some()
|| record.focus.as_ref().is_some_and(|focus| focus.is_animating())
|| record.shadow.as_ref().is_some_and(|shadow| shadow.is_animating())
|| record.dimming.as_ref().is_some_and(|dim| dim.is_animating())
{
return true;
@@ -178,74 +265,22 @@ impl Ferese {
)
};
let mut rect = overview.presented_rect(id, geometry.visual.current);
if !overview.is_presenting() {
let (x, y) = self
.workspaces
.workspace_for_window(id)
.and_then(|workspace| slide_offsets.get(&workspace).copied())
.unwrap_or_default();
rect.x += x;
rect.y += y;
let offset = self
.workspaces
.workspace_for_window(id)
.and_then(|workspace| slide_offsets.get(&workspace).copied())
.unwrap_or_default();
let presentation = self.compose_window_presentation(id, geometry, &overview, offset, delta);
let mut dim = record.dimming.clone();
if let Some(dim) = &mut dim {
dim.predict(delta, self.inactive_dim.duration_ms);
}
let mut opening = record.opening.unwrap_or_else(|| AnimatedValue::new(1.0));
if !self.animations_enabled {
opening.snap();
} else if !delta.is_zero() {
opening.advance(
delta,
SpringConfig {
position_tolerance: 0.00001,
velocity_tolerance: 0.00001,
..self.spring_config
},
);
}
let focused = if self.overview.is_active() {
self.overview_selected(id)
} else {
self.focused_window == Some(id)
};
let sample_dim = |motion: Option<&DimAnimation>, fallback| {
let Some(mut motion) = motion.cloned() else {
return fallback;
};
if !delta.is_zero() {
motion.predict(delta, self.inactive_dim.duration_ms);
}
motion.current
};
let mut focus = record
.focus
.unwrap_or_else(|| AnimatedValue::new(if focused { 1.0 } else { 0.0 }));
if !delta.is_zero() {
focus.advance(
delta,
SpringConfig {
position_tolerance: 0.001,
velocity_tolerance: 0.001,
..self.spring_config
},
);
}
let presence_scale = 0.97 + 0.03 * opening.current;
let native_size = (!overview.is_presenting() && presence_scale != 1.0).then(|| ClientSize::from_rect(rect));
let rect = crate::presentation::scaled_visual_rect(rect, presence_scale);
windows.insert(
id,
WindowFrame {
geometry,
rect,
scale_content: overview.is_presenting(),
native_size,
alpha: opening.current.clamp(0.0, 1.0) as f32,
focus: focus.current.clamp(0.0, 1.0),
dim: sample_dim(record.dimming.as_ref(), 0.0),
presentation,
dim: dim.map_or(0.0, |dim| dim.current),
},
);
}
@@ -439,7 +474,9 @@ mod tests {
assert_eq!(offsets[&to].0, 1440.0);
assert_eq!(
state.sample_frame(&output, Duration::from_millis(8)).windows[&id]
.rect
.presentation
.bounds
.current
.x,
state.sample_frame(&output, Duration::from_millis(8)).windows[&id]
.geometry
+1
View File
@@ -22,6 +22,7 @@ pub(crate) struct WindowRecord {
pub metadata_revision: u64,
pub resize: Option<ResizeTransaction>,
pub focus: Option<AnimatedValue>,
pub shadow: Option<AnimatedValue>,
pub dimming: Option<crate::dimming::DimAnimation>,
pub opening: Option<AnimatedValue>,
pub mapped_once: bool,
+1 -6
View File
@@ -495,12 +495,7 @@ impl Ferese {
}
let used: usize = self.render.snapshots().map(|snapshot| snapshot.bytes()).sum::<usize>()
+ self
.render
.closing
.iter()
.map(|window| window.snapshot.bytes())
.sum::<usize>();
+ self.render.closing.iter().map(|window| window.bytes()).sum::<usize>();
let remaining = crate::presentation::SNAPSHOT_BUDGET.saturating_sub(used);
let result = if let Some(backend) = &self.nested_backend {
// Commit dispatch does not run inside the Winit event callback;