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

+85 -58
View File
@@ -1,21 +1,19 @@
use super::*;
use ferese_animation::{AnimatedRect, AnimatedValue, CrossingPolicy, SpringConfig};
use ferese_animation::{CrossingPolicy, SpringConfig};
use ferese_core::OutputId;
use ferese_layout::WindowId;
/// Detached presentation only: no live window, input, focus, or layout entry.
#[derive(Clone)]
pub(crate) struct ClosedWindow {
pub id: WindowId,
pub presentation: crate::presentation::WindowPresentation,
pub output: OutputId,
pub below: Option<WindowId>,
pub bounds: AnimatedRect,
pub opacity: AnimatedValue,
pub snapshot: ResizeSnapshot,
pub source: Rectangle<f64, Buffer>,
// Keep a resize handoff's visible content when the client unmaps mid-resize.
pub handoff: Option<(ResizeSnapshot, Option<ferese_animation::ClientSize>)>,
pub radius: f64,
pub shape: CornerShape,
pub focus: f64,
pub decorations: f64,
pub dim: f64,
pub fill: Option<[f32; 4]>,
@@ -28,9 +26,22 @@ pub(crate) struct ClosedWindow {
}
impl ClosedWindow {
pub fn bytes(&self) -> usize {
self.snapshot.bytes() + self.handoff.as_ref().map_or(0, |(snapshot, _)| snapshot.bytes())
}
pub fn advance(&mut self, delta: Duration, spring: SpringConfig) -> bool {
self.bounds.advance(delta, spring);
let opacity = self.opacity.advance_with_policy(
// The client is gone, so this handoff no longer waits for configure
// acknowledgements. Delta already includes the global motion speed.
if let Some((snapshot, _)) = &mut self.handoff {
snapshot.elapsed = snapshot.elapsed.saturating_add(delta);
snapshot.commit.increment();
if snapshot.elapsed >= crate::presentation::HANDOFF {
self.handoff = None;
}
}
self.presentation.bounds.advance(delta, spring);
let opacity = self.presentation.opacity.advance_with_policy(
delta,
SpringConfig {
position_tolerance: 0.001,
@@ -39,58 +50,64 @@ impl ClosedWindow {
},
CrossingPolicy::NoCrossing,
);
self.presentation
.emphasis
.advance(delta, crate::presentation::emphasis_spring(spring));
self.presentation
.shadow
.advance(delta, crate::presentation::shadow_spring(spring));
self.snapshot.commit.increment();
// Once invisible there is no reason to keep a texture (including with
// intentionally undamped, overshooting geometry configurations).
// Texture lifetime ends when the shared presentation becomes invisible.
opacity
}
}
pub(super) fn elements(
pub(super) fn grouped_elements(
state: &mut Ferese,
renderer: &mut GlesRenderer,
output: &Output,
below: Option<WindowId>,
live: &std::collections::HashSet<WindowId>,
live: impl IntoIterator<Item = WindowId>,
delta: Duration,
) -> Vec<AnimatedWindowRenderElement> {
if state.session_lock.active() {
return Vec::new();
) -> std::collections::HashMap<Option<WindowId>, Vec<AnimatedWindowRenderElement>> {
if state.render.closing.is_empty() || state.session_lock.active() {
return Default::default();
}
let Some(output_id) = state.output_id(output) else {
return Vec::new();
return Default::default();
};
let Some(output_geometry) = state.space.output_geometry(output) else {
return Vec::new();
return Default::default();
};
let scale = output.current_scale().fractional_scale();
let windows: Vec<_> = state
.render
.closing
.iter()
.filter(|window| {
window.output == output_id
&& window.below.filter(|id| live.contains(id)) == below
&& window.snapshot.context == renderer.context_id().erased()
})
.filter(|window| window.output == output_id && window.snapshot.context == renderer.context_id().erased())
.cloned()
.collect();
let mut elements = Vec::new();
if windows.is_empty() {
return Default::default();
}
let live: std::collections::HashSet<_> = live.into_iter().collect();
let mut groups = std::collections::HashMap::new();
for mut window in windows {
let anchor = window.below.filter(|id| live.contains(id));
let elements: &mut Vec<AnimatedWindowRenderElement> = groups.entry(anchor).or_default();
if !delta.is_zero() {
window.advance(delta, state.spring_config);
}
let visual = window.bounds.current;
let visual = window.presentation.bounds.current;
let corners = RoundedRect::new(visual, output_geometry.loc, scale, window.radius).with_shape(window.shape);
let constrain = rounded_visual_rect(visual, output_geometry.loc);
let Some(programs) = corner_program(&mut state.render, renderer, window.shape) else {
continue;
};
let alpha = window.opacity.current.clamp(0.0, 1.0) as f32;
let alpha = window.presentation.alpha();
if let Some(dim) = window_tint_element(
&mut state.render,
renderer,
window.id,
window.presentation.id,
constrain,
corners,
[0.0, 0.0, 0.0, window.dim as f32 * alpha],
@@ -104,44 +121,53 @@ pub(super) fn elements(
&mut state.render,
theme,
renderer,
window.id,
window.presentation.id,
constrain,
corners,
scale,
theme.border_width + (theme.focus_ring_width - theme.border_width) * window.focus,
theme.border_width + (theme.focus_ring_width - theme.border_width) * window.presentation.focus(),
theme.border_color.0,
theme.border_gradient,
window.focus as f32,
window.presentation.focus() as f32,
alpha * window.decorations as f32,
output,
&programs,
) {
elements.push(border.into());
}
let clip = framebuffer_clip_rect(
corners.rect,
output.current_mode().unwrap().size,
output.current_transform().invert(),
);
elements.push(
NativeTextureElement {
id: window.snapshot.id.clone(),
commit: window.snapshot.commit,
texture: window.snapshot.texture.clone(),
geometry: corners.rect,
source: window.source,
alpha,
program: Some(programs.texture.clone()),
uniforms: vec![Uniform::new("clip_rect", clip), Uniform::new("radius", corners.radius)],
if let Some((snapshot, native_size)) = &window.handoff {
let mut presentation = window.presentation;
presentation.scale_content = native_size.is_none();
presentation.native_size = *native_size;
if let Some(element) = super::window_content::snapshot_element(
snapshot,
presentation,
corners,
scale,
output,
&programs,
crate::presentation::handoff_alpha(snapshot.elapsed),
) {
elements.push(element.into());
}
.into(),
);
}
if let Some(element) = super::window_content::snapshot_element(
&window.snapshot,
window.presentation,
corners,
scale,
output,
&programs,
1.0,
) {
elements.push(element.into());
}
if let Some(mut fill) = window.fill {
fill[3] = alpha;
if let Some(fill) = window_tint_element(
&mut state.render,
renderer,
window.id,
window.presentation.id,
constrain,
corners,
fill,
@@ -151,8 +177,8 @@ pub(super) fn elements(
elements.push(fill.into());
}
}
if let Some((surface, role, generation, opacity)) = &window.material {
if let Some((material, _)) = material_element_with_role(
if let Some((surface, role, generation, opacity)) = &window.material
&& let Some((material, _)) = material_element_with_role(
state,
renderer,
output,
@@ -165,21 +191,22 @@ pub(super) fn elements(
alpha,
},
(*role, *generation, *opacity),
) {
elements.push(material);
}
)
{
elements.push(material);
}
let theme = &state.theme_settings;
let (offset_factor, blur_factor, opacity_factor) = window.presentation.shadow_factors();
if let Some(shadow) = window_shadow_element(
&mut state.render,
renderer,
window.id,
window.presentation.id,
constrain,
corners,
scale,
theme.shadow_offset_y,
theme.shadow_blur,
theme.shadow_opacity * f64::from(alpha) * window.decorations,
theme.shadow_offset_y * offset_factor,
theme.shadow_blur * blur_factor,
theme.shadow_opacity * opacity_factor * f64::from(alpha) * window.decorations,
theme.shadow_color.0,
output,
&programs,
@@ -187,5 +214,5 @@ pub(super) fn elements(
elements.push(shadow.into());
}
}
elements
groups
}
+14 -4
View File
@@ -19,17 +19,27 @@ pub(super) fn append_material_surface(
state.theme_settings.material_radius
};
let regions = crate::effects::surface_regions(surface);
let opacities = crate::effects::surface_region_opacities(surface);
let targets: Vec<_> = match &regions {
None => vec![(geometry, RoundedRect::from_logical(geometry, scale, radius))],
None => vec![(geometry, RoundedRect::from_logical(geometry, scale, radius), 1.0)],
Some(regions) => regions
.iter()
.filter_map(|r| material_region_geometry(*r, geometry, content_origin, scale))
.enumerate()
.filter_map(|(index, r)| {
material_region_geometry(*r, geometry, content_origin, scale).map(|(rect, corners)| {
(
rect,
corners,
opacities.get(index).copied().unwrap_or(1000) as f32 / 1000.0,
)
})
})
.collect(),
};
let materials: Vec<_> = targets
.iter()
.enumerate()
.filter_map(|(index, (rect, corners))| {
.filter_map(|(index, (rect, corners, alpha))| {
material_element(
state,
renderer,
@@ -40,7 +50,7 @@ pub(super) fn append_material_surface(
corners: *corners,
index,
capture_geometry: geometry,
alpha: 1.0,
alpha: *alpha,
},
)
})
+1
View File
@@ -1,6 +1,7 @@
mod backdrop;
mod capture;
mod closing;
mod window_content;
pub(crate) use closing::ClosedWindow;
#[cfg(test)]
mod corner_tests;
+22 -3
View File
@@ -142,7 +142,7 @@ pub(super) fn overview_strip_elements(
if !state.window_belongs_to_output(id, output) {
continue;
}
let Some(rect) = frame.windows.get(&id).map(|sample| sample.rect) else {
let Some(rect) = frame.windows.get(&id).map(|sample| sample.presentation.bounds.current) else {
continue;
};
let Some((buffer, size)) = state.overview_window_label(id, scale, rect.width) else {
@@ -228,12 +228,31 @@ pub(super) fn overview_strip_elements(
let clip_changed = state
.output_id(output)
.is_some_and(|output| state.render.thumbnail_corner_changed(*id, output, shape));
let Some(sample) = frame.windows.get(id) else { continue };
let presentation = sample.presentation.thumbnail(*rect);
let corners = RoundedRect::new(presentation.bounds.current, output_geometry.loc, scale, 4.0)
.with_shape(shape);
if let Some(snapshot) = state.render.snapshot(id)
&& snapshot.context == renderer.context_id().erased()
&& (snapshot.scale - scale).abs() < 0.001
&& let Some(element) = super::window_content::snapshot_element(
snapshot,
presentation,
corners,
scale,
output,
&programs,
alpha * crate::presentation::handoff_alpha(snapshot.elapsed),
)
{
elements.push(element.into());
}
elements.extend(rounded_window_elements(
renderer,
window,
RoundedRect::new(*rect, output_geometry.loc, scale, 4.0).with_shape(shape),
corners,
scale,
alpha,
alpha * presentation.alpha(),
// Crop/scale/alpha/geometry already track their own
// damage. Role changes also alter the corner shader,
// even when the client buffer itself is unchanged.
+2 -2
View File
@@ -76,7 +76,7 @@ impl RenderResources {
.closing
.iter()
.filter(|window| window.output == id)
.map(|window| window.id)
.map(|window| window.presentation.id)
.collect();
self.closing.retain(|window| window.output != id);
for id in removed {
@@ -139,7 +139,7 @@ impl RenderResources {
.closing
.iter()
.filter(|window| &window.snapshot.context == context)
.map(|window| window.id)
.map(|window| window.presentation.id)
.collect();
self.closing.retain(|window| &window.snapshot.context != context);
for id in removed {
+33 -72
View File
@@ -124,8 +124,8 @@ pub(crate) fn sampled_output_elements(
return None;
}
let sample = frame.windows.get(&id)?;
let visual = sample.rect;
let close_alpha = sample.alpha;
let visual = sample.presentation.bounds.current;
let close_alpha = sample.presentation.alpha();
let decoration_progress = sample.geometry.decorations.clamp(0.0, 1.0);
Some((window.clone(), id, sample, visual, decoration_progress, close_alpha))
@@ -145,16 +145,17 @@ pub(crate) fn sampled_output_elements(
.collect::<Vec<_>>()
};
let live = windows.iter().map(|(_, id, ..)| *id).collect();
let mut closing = super::closing::grouped_elements(
state,
renderer,
output,
windows.iter().map(|(_, id, ..)| *id),
frame.delta,
);
for (window, id, sample, visual, decoration_progress, close_alpha) in windows {
elements.extend(super::closing::elements(
state,
renderer,
output,
Some(id),
&live,
frame.delta,
));
if let Some(group) = closing.remove(&Some(id)) {
elements.extend(group);
}
let constrain = rounded_visual_rect(visual, output_geometry.loc);
let material_surface = window
.toplevel()
@@ -172,9 +173,8 @@ pub(crate) fn sampled_output_elements(
let corners = RoundedRect::new(visual, output_geometry.loc, scale, window_radius).with_shape(shape);
let corner_shape_changed = state.render.prepare_window_corners(id, shape);
let rounded_clip_program = corner_program(&mut state.render, renderer, shape);
let pixels = corners.rect;
// Only overview/close intentionally scale the complete application.
let scale_content = sample.scale_content;
let scale_content = sample.presentation.scale_content;
let behavior = resize_content_behavior(scale_content);
let dim = sample.dim;
@@ -198,7 +198,7 @@ pub(crate) fn sampled_output_elements(
let shadow_blur = state.theme_settings.shadow_blur;
let shadow_opacity = state.theme_settings.shadow_opacity;
let shadow_color = state.theme_settings.shadow_color.0;
let focus = sample.focus;
let focus = sample.presentation.focus();
let border_width = state.theme_settings.border_width
+ (state.theme_settings.focus_ring_width - state.theme_settings.border_width) * focus;
let border_color = state.theme_settings.border_color.0;
@@ -222,6 +222,7 @@ pub(crate) fn sampled_output_elements(
) {
elements.push(border.into());
}
let (offset_factor, blur_factor, opacity_factor) = sample.presentation.shadow_factors();
let shadow = window_shadow_element(
&mut state.render,
renderer,
@@ -229,9 +230,9 @@ pub(crate) fn sampled_output_elements(
constrain,
corners,
scale,
shadow_offset_y,
shadow_blur,
shadow_opacity * f64::from(close_alpha) * decoration_progress,
shadow_offset_y * offset_factor,
shadow_blur * blur_factor,
shadow_opacity * opacity_factor * f64::from(close_alpha) * decoration_progress,
shadow_color,
output,
&programs,
@@ -239,54 +240,19 @@ pub(crate) fn sampled_output_elements(
if let Some(snapshot) = state.render.snapshot(&id)
&& snapshot.context == renderer.context_id().erased()
&& (snapshot.scale - scale).abs() < 0.001
{
let size = snapshot.texture.size();
let (sx, sy) = if scale_content {
(
f64::from(pixels.size.w) / f64::from(size.w),
f64::from(pixels.size.h) / f64::from(size.h),
)
} else if let Some(native) = sample.native_size {
(
f64::from(pixels.size.w) / (f64::from(native.width) * scale).round().max(1.0),
f64::from(pixels.size.h) / (f64::from(native.height) * scale).round().max(1.0),
)
} else {
(1.0, 1.0)
};
let visible = Rectangle::new(
pixels.loc,
(
(f64::from(size.w) * sx).round() as i32,
(f64::from(size.h) * sy).round() as i32,
)
.into(),
&& let Some(element) = super::window_content::snapshot_element(
snapshot,
sample.presentation,
corners,
scale,
output,
&programs,
crate::presentation::handoff_alpha(snapshot.elapsed),
)
.intersection(pixels);
if let Some(visible) = visible {
let clip = framebuffer_clip_rect(
pixels,
output.current_mode().unwrap().size,
output.current_transform().invert(),
);
elements.push(
NativeTextureElement {
id: snapshot.id.clone(),
commit: snapshot.commit,
texture: snapshot.texture.clone(),
geometry: visible,
source: Rectangle::from_size(Size::from((
f64::from(visible.size.w) / sx,
f64::from(visible.size.h) / sy,
))),
alpha: crate::presentation::handoff_alpha(snapshot.elapsed) * close_alpha,
program: Some(programs.texture.clone()),
uniforms: vec![Uniform::new("clip_rect", clip), Uniform::new("radius", corners.radius)],
}
.into(),
);
}
{
elements.push(element.into());
}
elements.extend(rounded_window_elements(
renderer,
&window,
@@ -297,7 +263,7 @@ pub(crate) fn sampled_output_elements(
output,
programs.clone(),
behavior,
sample.native_size,
sample.presentation.native_size,
));
let source = window.geometry().size;
if material_surface.is_none()
@@ -352,14 +318,9 @@ pub(crate) fn sampled_output_elements(
));
}
}
elements.extend(super::closing::elements(
state,
renderer,
output,
None,
&live,
frame.delta,
));
if let Some(group) = closing.remove(&None) {
elements.extend(group);
}
elements.extend(layer_elements(
state,
renderer,
+194
View File
@@ -0,0 +1,194 @@
//! Live, overview and retained views use the same snapshot mapping and clip.
use super::*;
pub(super) fn snapshot_element(
snapshot: &ResizeSnapshot,
presentation: crate::presentation::WindowPresentation,
corners: RoundedRect,
scale: f64,
output: &Output,
programs: &RoundedClipPrograms,
handoff: f32,
) -> Option<NativeTextureElement> {
let pixels = corners.rect;
let size = snapshot.texture.size();
let (sx, sy) = if presentation.scale_content {
(
f64::from(pixels.size.w) / f64::from(size.w),
f64::from(pixels.size.h) / f64::from(size.h),
)
} else if let Some(native) = presentation.native_size {
(
f64::from(pixels.size.w) / (f64::from(native.width) * scale).round().max(1.0),
f64::from(pixels.size.h) / (f64::from(native.height) * scale).round().max(1.0),
)
} else {
(1.0, 1.0)
};
let visible = Rectangle::new(
pixels.loc,
(
(f64::from(size.w) * sx).round() as i32,
(f64::from(size.h) * sy).round() as i32,
)
.into(),
)
.intersection(pixels)?;
let clip = framebuffer_clip_rect(pixels, output.current_mode()?.size, output.current_transform().invert());
Some(NativeTextureElement {
id: snapshot.id.clone(),
commit: snapshot.commit,
texture: snapshot.texture.clone(),
geometry: visible,
source: Rectangle::from_size(Size::from((
f64::from(visible.size.w) / sx,
f64::from(visible.size.h) / sy,
))),
alpha: presentation.alpha() * handoff,
program: Some(programs.texture.clone()),
uniforms: vec![Uniform::new("clip_rect", clip), Uniform::new("radius", corners.radius)],
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore = "requires an EGL rendering device"]
fn snapshot_views_keep_fractional_edges_and_close_pixels() {
use ferese_animation::{AnimatedRect, AnimatedValue};
use smithay::backend::egl::{EGLContext, EGLDevice, EGLDisplay};
use smithay::output::{Mode, PhysicalProperties, Subpixel};
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 output = Output::new(
"snapshot-test".into(),
PhysicalProperties {
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
output.change_current_state(
Some(Mode {
size: (128, 128).into(),
refresh: 60_000,
}),
Some(Transform::Normal),
None,
Some((0, 0).into()),
);
let mut resources = RenderResources::default();
let programs = corner_program(&mut resources, &mut renderer, CornerShape::Continuous).unwrap();
for scale in [1.0, 1.25, 1.5, 2.0] {
let live = crate::presentation::WindowPresentation {
id: ferese_layout::WindowId(1),
bounds: AnimatedRect::new(ferese_layout::Rect::new(20.25, 20.5, 32.25, 24.25)),
opacity: AnimatedValue::new(1.0),
emphasis: AnimatedValue::new(1.0),
shadow: AnimatedValue::new(1.0),
scale_content: false,
native_size: None,
};
let corners =
RoundedRect::new(live.bounds.current, (0, 0).into(), scale, 8.0).with_shape(CornerShape::Continuous);
let size = corners.rect.size;
let mut texture: GlesTexture = renderer
.create_buffer(Fourcc::Abgr8888, (size.w, size.h).into())
.unwrap();
{
let mut target = renderer.bind(&mut texture).unwrap();
let mut frame = renderer.render(&mut target, size, Transform::Normal).unwrap();
frame
.clear(Color32F::new(1.0, 0.0, 0.0, 1.0), &[Rectangle::from_size(size)])
.unwrap();
let _ = frame.finish().unwrap();
}
let snapshot = ResizeSnapshot {
texture,
context: renderer.context_id().erased(),
id: Id::new(),
commit: CommitCounter::default(),
elapsed: Duration::ZERO,
last_tick: None,
scale,
};
let mut closing = live;
closing.close();
let mut pixels = Vec::new();
for presentation in [live, closing] {
let element =
snapshot_element(&snapshot, presentation, corners, scale, &output, &programs, 1.0).unwrap();
assert_eq!(element.geometry, corners.rect);
let mut texture: GlesTexture = renderer.create_buffer(Fourcc::Abgr8888, (128, 128).into()).unwrap();
{
let mut target = renderer.bind(&mut texture).unwrap();
let mut frame = renderer
.render(&mut target, (128, 128).into(), Transform::Normal)
.unwrap();
let damage = Rectangle::from_size((128, 128).into());
frame.clear(Color32F::TRANSPARENT, &[damage]).unwrap();
draw_render_elements(
&mut frame,
scale,
&[AnimatedWindowRenderElement::from(element)],
&[damage],
)
.unwrap();
let _ = frame.finish().unwrap();
}
let mapping = renderer
.copy_texture(&texture, Rectangle::from_size((128, 128).into()), Fourcc::Abgr8888)
.unwrap();
pixels.push(renderer.map_texture(&mapping).unwrap().to_vec());
}
assert_eq!(pixels[0], pixels[1], "close handoff changed pixels at scale {scale}");
assert!(pixels[0].chunks_exact(4).any(|pixel| pixel[3] == 255));
let mut retained = super::super::closing::ClosedWindow {
presentation: closing,
output: ferese_core::OutputId(1),
below: None,
snapshot: snapshot.clone(),
handoff: Some((snapshot.clone(), None)),
radius: 8.0,
shape: CornerShape::Continuous,
decorations: 1.0,
dim: 0.0,
fill: None,
material: None,
};
// A partially faded resize must keep fading after unmap, including
// in predicted frames, without advancing authoritative state twice.
retained.handoff.as_mut().unwrap().0.elapsed = Duration::from_millis(20);
let mut predicted = retained.clone();
predicted.advance(Duration::from_millis(20), Default::default());
let faded = &predicted.handoff.as_ref().unwrap().0;
assert_eq!(faded.elapsed, Duration::from_millis(40));
assert_eq!(retained.handoff.as_ref().unwrap().0.elapsed, Duration::from_millis(20));
let element = snapshot_element(
faded,
predicted.presentation,
corners,
scale,
&output,
&programs,
crate::presentation::handoff_alpha(faded.elapsed),
)
.unwrap();
assert!((element.alpha - predicted.presentation.alpha() * 0.5).abs() < 1e-6);
assert_eq!(element.geometry, corners.rect);
retained.advance(Duration::from_millis(60), Default::default());
assert!(retained.handoff.is_none());
let thumbnail = live.thumbnail(ferese_layout::Rect::new(4.25, 3.75, 16.5, 12.25));
let corners = RoundedRect::new(thumbnail.bounds.current, (0, 0).into(), scale, 4.0);
let element = snapshot_element(&snapshot, thumbnail, corners, scale, &output, &programs, 1.0).unwrap();
assert_eq!(element.geometry, corners.rect);
assert!((element.source.size.w - f64::from(size.w)).abs() < 1e-9);
assert!((element.source.size.h - f64::from(size.h)).abs() < 1e-9);
}
}
}