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

+11 -11
View File
@@ -357,12 +357,12 @@ pub(crate) fn config_path() -> Option<PathBuf> {
#[cfg(test)]
mod tests {
#[test]
fn rejects_undamped_overshoot_before_publication() {
fn rejects_removed_physics_keys_before_publication() {
for property in ["spring", "viewport-spring"] {
for coefficient in ["damping", "damping-ratio"] {
for coefficient in ["mass", "stiffness", "damping", "damping-ratio"] {
let source = format!("animations {{ {property} {{ {coefficient} 0; overshoot #true; }}; }}");
let error = Config::parse_source(&source).unwrap().runtime_config().err().unwrap().to_string();
assert!(error.contains("overshoot requires positive damping"), "{error}");
let error = Config::parse_source(&source).unwrap_err().to_string();
assert!(error.contains("unknown field"), "{error}");
}
}
}
@@ -582,7 +582,7 @@ mod tests {
#[test]
fn parses_animation_policy_and_reduced_motion() {
let config = parse(
"animations {\n speed 1.5\n reduced-motion #true\n spring {\n mass 2.0\n stiffness 500.0\n damping 40.0\n }\n}\n",
"animations {\n speed 1.5\n reduced-motion #true\n spring {\n duration-ms 300.0\n bounce 0.0\n }\n}\n",
);
assert!(!config.animations_enabled());
@@ -590,9 +590,9 @@ mod tests {
assert_eq!(
config.spring_config().unwrap(),
SpringConfig {
mass: 2.0,
stiffness: 500.0,
damping: 40.0,
mass: 1.0,
stiffness: (std::f64::consts::TAU / 0.3).powi(2),
damping: 2.0 * std::f64::consts::TAU / 0.3,
..SpringConfig::default()
}
);
@@ -601,7 +601,7 @@ mod tests {
#[test]
fn rejects_invalid_animation_numbers() {
let speed = parse("animations {\n speed 0.0\n}\n");
let damping = parse("animations {\n spring {\n damping -1.0\n }\n}\n");
let damping = parse("animations {\n spring {\n duration-ms -1.0\n }\n}\n");
assert!(speed.animation_speed().is_err());
assert!(damping.spring_config().is_err());
@@ -1043,8 +1043,8 @@ mod tests {
let spring = config.viewport_spring_config().unwrap();
assert_eq!(spring.mass, 1.0);
assert_eq!(spring.stiffness, 320.0);
assert!((spring.damping - 35.777_087_64).abs() < 0.000_001);
assert!((spring.stiffness - (std::f64::consts::TAU / 0.350).powi(2)).abs() < 1e-9);
assert!((spring.damping - 2.0 * spring.stiffness.sqrt()).abs() < 1e-9);
}
#[test]
+1 -4
View File
@@ -52,10 +52,7 @@ impl Config {
}
pub fn spring_config(&self) -> Result<SpringConfig, ConfigError> {
self.animations
.spring
.resolve(SpringConfig::default())
.map_err(ConfigError::Binding)
self.animations.spring_config().map_err(ConfigError::Binding)
}
pub fn viewport_spring_config(&self) -> Result<SpringConfig, ConfigError> {
+59 -1
View File
@@ -58,6 +58,7 @@ struct SurfaceEffectsState {
role: Mutex<Option<SemanticRole>>,
regions: Mutex<Option<Vec<[f64; 5]>>>,
opacity: Mutex<u32>,
region_opacities: Mutex<Vec<u32>>,
presentation_supported: AtomicBool,
dismissing: AtomicBool,
}
@@ -70,6 +71,7 @@ impl SurfaceEffectsState {
role: Mutex::new(None),
regions: Mutex::new(None),
opacity: Mutex::new(1000),
region_opacities: Mutex::new(Vec::new()),
presentation_supported: AtomicBool::new(false),
dismissing: AtomicBool::new(false),
}
@@ -101,7 +103,7 @@ impl SurfaceEffectsUserData {
}
pub(crate) fn init_global(display: &DisplayHandle) {
display.create_global::<Ferese, FereseEffectsManagerV1, _>(3, ());
display.create_global::<Ferese, FereseEffectsManagerV1, _>(4, ());
display.create_global::<Ferese, FereseMaterialManagerV1, _>(1, ());
}
@@ -195,6 +197,31 @@ pub(crate) fn surface_regions(surface: &WlSurface) -> Option<Vec<[f64; 5]>> {
})
}
pub(crate) fn surface_region_opacities(surface: &WlSurface) -> Vec<u32> {
with_states(surface, |states| {
states
.data_map
.get::<SurfaceEffectsState>()
.map(|effects| effects.region_opacities.lock().unwrap().clone())
.unwrap_or_default()
})
}
fn decode_region_opacities(bytes: &[u8]) -> Option<Vec<u32>> {
if !bytes.len().is_multiple_of(4) || bytes.len() > 32 * 4 {
return None;
}
bytes
.as_chunks::<4>()
.0
.iter()
.map(|v| {
let value = u32::from_ne_bytes(*v);
(value <= 1000).then_some(value)
})
.collect()
}
fn decode_regions(bytes: &[u8]) -> Option<Vec<[f64; 5]>> {
if !bytes.len().is_multiple_of(20) || bytes.len() > 32 * 20 {
return None;
@@ -339,6 +366,26 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
crate::backends::direct::render_surface(state, &surface);
}
}
ferese_surface_effects_v1::Request::SetRegionOpacities { opacities } => {
let Some(opacities) = decode_region_opacities(&opacities) else {
return;
};
let changed = with_states(&surface, |states| {
let Some(effects) = states.data_map.get::<SurfaceEffectsState>() else {
return false;
};
let mut old = effects.region_opacities.lock().unwrap();
if *old == opacities {
return false;
}
*old = opacities;
effects.generation.fetch_add(1, Ordering::Release);
true
});
if changed {
crate::backends::direct::render_surface(state, &surface);
}
}
ferese_surface_effects_v1::Request::SetRegions { regions } => {
let Some(regions) = decode_regions(&regions) else {
return;
@@ -400,6 +447,7 @@ fn detach(surface: &WlSurface) -> bool {
return false;
};
let role_changed = effects.set_role(None);
effects.region_opacities.lock().unwrap().clear();
effects.dismissing.store(false, Ordering::Release);
let mut opacity = effects.opacity.lock().unwrap();
let opacity_changed = *opacity != 1000;
@@ -441,6 +489,16 @@ pub(crate) fn fade_dismissed_surface(surface: &WlSurface, opacity: f64) {
mod tests {
use super::*;
#[test]
fn region_opacities_validate_independent_fades() {
let bytes: Vec<_> = [0u32, 500, 1000].into_iter().flat_map(u32::to_ne_bytes).collect();
assert_eq!(decode_region_opacities(&bytes), Some(vec![0, 500, 1000]));
assert_eq!(decode_region_opacities(&[]), Some(vec![]));
assert!(decode_region_opacities(&bytes[..11]).is_none());
assert!(decode_region_opacities(&1001u32.to_ne_bytes()).is_none());
assert!(decode_region_opacities(&vec![0; 33 * 4]).is_none());
}
#[test]
fn fractional_regions_validate_all_wire_values() {
let region = [0.25f32, -0.75, 100.5, 40.25, 13.5];
+98 -23
View File
@@ -359,18 +359,36 @@ impl OverviewState {
true
}
#[cfg(test)]
pub(crate) fn presented_rect(&self, id: WindowId, normal: Rect) -> Rect {
self.motion.presented_rect(id, normal)
}
pub(crate) fn presented_velocity(&self, id: WindowId) -> Option<ferese_animation::RectVelocity> {
let velocity = self.motion.presentations.get(&id)?.velocity;
Some(ferese_animation::RectVelocity {
x: velocity.x * OVERVIEW_MOTION_SPEED,
y: velocity.y * OVERVIEW_MOTION_SPEED,
width: velocity.width * OVERVIEW_MOTION_SPEED,
height: velocity.height * OVERVIEW_MOTION_SPEED,
})
pub(crate) fn motion(&self) -> &OverviewMotion {
&self.motion
}
fn seed_presentations(&mut self, windows: impl IntoIterator<Item = crate::presentation::WindowPresentation>) {
for window in windows {
self.motion.presentations.entry(window.id).or_insert_with(|| {
// Opening scales the final presentation after overview. Remove
// it here so the same opening motion is not applied twice.
let scale = 0.97 + 0.03 * window.opacity.current;
let mut bounds = window.bounds;
bounds.velocity = crate::presentation::scaled_visual_velocity(
bounds.current,
bounds.velocity,
1.0 / scale,
-0.03 * window.opacity.velocity / (scale * scale),
);
bounds.current = crate::presentation::scaled_visual_rect(bounds.current, 1.0 / scale);
bounds.velocity.x /= OVERVIEW_MOTION_SPEED;
bounds.velocity.y /= OVERVIEW_MOTION_SPEED;
bounds.velocity.width /= OVERVIEW_MOTION_SPEED;
bounds.velocity.height /= OVERVIEW_MOTION_SPEED;
bounds
});
}
}
fn enter(
@@ -551,6 +569,12 @@ impl Ferese {
self.advance_animations(std::time::Instant::now());
if active {
let presentations = self
.windows
.ordered_ids()
.filter_map(|id| self.current_window_presentation(id))
.collect::<Vec<_>>();
self.overview.seed_presentations(presentations);
if self.cancel_workspace_slides() {
self.relayout();
}
@@ -640,19 +664,7 @@ impl Ferese {
}
pub(crate) fn presented_window_rect(&self, id: WindowId) -> Option<Rect> {
let normal = self.windows.geometry(&id)?.visual.current;
let mut presented = self.overview.presented_rect(id, normal);
if !self.overview.is_presenting() {
let (x, y) = self.workspace_slide_offset(id);
presented.x += x;
presented.y += y;
}
let record = self.windows.record(id)?;
let opening = record.opening.map_or(1.0, |value| value.current);
Some(crate::presentation::scaled_visual_rect(
presented,
0.97 + 0.03 * opening,
))
Some(self.current_window_presentation(id)?.bounds.current)
}
pub(crate) fn inverse_presented_window_point(&self, id: WindowId, x: f64, y: f64) -> Option<(f64, f64)> {
@@ -1021,9 +1033,21 @@ impl OverviewMotion {
self.opacity.current.clamp(0.0, 1.0) as f32
}
pub(crate) fn presented_bounds(&self, id: WindowId, normal: AnimatedRect) -> AnimatedRect {
let Some(mut bounds) = self.presentations.get(&id).copied() else {
return normal;
};
bounds.velocity.x *= OVERVIEW_MOTION_SPEED;
bounds.velocity.y *= OVERVIEW_MOTION_SPEED;
bounds.velocity.width *= OVERVIEW_MOTION_SPEED;
bounds.velocity.height *= OVERVIEW_MOTION_SPEED;
bounds
}
#[cfg(test)]
pub(crate) fn presented_rect(&self, id: WindowId, normal: Rect) -> Rect {
let preview = self.presentations.get(&id).map_or(normal, |p| p.current);
preview
self.presentations.get(&id).map_or(normal, |p| p.current)
}
pub(crate) fn advance(&mut self, delta: Duration, spring: SpringConfig, animations_enabled: bool) -> bool {
@@ -1067,6 +1091,57 @@ impl OverviewMotion {
mod tests {
use super::*;
#[test]
fn overview_entry_inherits_visible_velocity_and_does_not_double_opening_scale() {
use crate::presentation::{WindowPresentation, scaled_visual_rect, scaled_visual_velocity};
use ferese_animation::RectVelocity;
let normal = Rect::new(20.25, -3.5, 640.0, 480.0);
let velocity = RectVelocity {
x: 300.0,
y: -50.0,
width: 25.0,
height: 8.0,
};
let opacity = AnimatedValue {
current: 0.5,
target: 1.0,
velocity: 0.8,
};
let scale = 0.97 + 0.03 * opacity.current;
let id = WindowId(1);
let presentation = WindowPresentation {
id,
bounds: AnimatedRect {
current: scaled_visual_rect(normal, scale),
target: normal,
velocity: scaled_visual_velocity(normal, velocity, scale, 0.03 * opacity.velocity),
},
opacity,
emphasis: AnimatedValue::new(1.0),
shadow: AnimatedValue::new(1.0),
scale_content: false,
native_size: None,
};
let mut overview = OverviewState::default();
overview.seed_presentations([presentation]);
overview.enter(
HashMap::from([(id, (normal, Rect::new(200.0, 100.0, 320.0, 240.0)))]),
Some(id),
true,
);
let sampled = overview.motion.presented_bounds(id, AnimatedRect::new(normal));
assert!((sampled.current.x - normal.x).abs() < 1e-10);
assert!((sampled.current.width - normal.width).abs() < 1e-10);
assert!((sampled.velocity.x - velocity.x).abs() < 1e-10);
assert!((sampled.velocity.width - velocity.width).abs() < 1e-10);
// Re-entering during dismissal must keep the current spring, not seed
// it from a stale desktop sample.
overview.exit(HashMap::from([(id, normal)]), true);
let before = overview.motion.presentations[&id];
overview.seed_presentations([presentation]);
assert_eq!(overview.motion.presentations[&id], before);
}
#[test]
fn tiny_overview_target_changes_still_get_a_settling_tick() {
let mut overview = OverviewState::default();
+154
View File
@@ -17,6 +17,73 @@ use crate::render::SharedPixelShaderElement;
pub(crate) const HANDOFF: Duration = Duration::from_millis(80);
pub(crate) const SNAPSHOT_BUDGET: usize = 64 * 1024 * 1024;
/// One window identity and motion sample, regardless of whether its pixels
/// come from a live surface, a resize snapshot, or a retained close image.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct WindowPresentation {
pub id: ferese_layout::WindowId,
pub bounds: ferese_animation::AnimatedRect,
pub opacity: ferese_animation::AnimatedValue,
pub emphasis: ferese_animation::AnimatedValue,
pub shadow: ferese_animation::AnimatedValue,
pub scale_content: bool,
pub native_size: Option<ferese_animation::ClientSize>,
}
impl WindowPresentation {
pub fn alpha(self) -> f32 {
self.opacity.current.clamp(0.0, 1.0) as f32
}
pub fn focus(self) -> f64 {
self.emphasis.current.clamp(0.0, 1.0)
}
/// A thumbnail is another view of the same content and lifecycle state.
pub fn thumbnail(mut self, rect: ferese_layout::Rect) -> Self {
self.bounds = ferese_animation::AnimatedRect::new(rect);
self.scale_content = true;
self.native_size = None;
self
}
pub fn close(&mut self) {
self.bounds.set_target(scaled_visual_rect(self.bounds.current, 0.98));
self.opacity.set_target(0.0);
self.emphasis.set_target(0.0);
self.shadow.set_target(0.0);
self.scale_content = true;
self.native_size = None;
}
/// Shadow intensity and elevation may respond independently; its outline
/// always follows the window's shared rounded geometry.
pub fn shadow_factors(self) -> (f64, f64, f64) {
let strength = self.shadow.current.clamp(0.0, 1.0);
(0.75 + 0.25 * strength, 0.9 + 0.1 * strength, 0.8 + 0.2 * strength)
}
}
// Response multipliers preserve damping ratio and the user's crossing policy.
// Emphasis responds sooner; the shadow settles a little later than the bounds.
pub(crate) fn emphasis_spring(base: ferese_animation::SpringConfig) -> ferese_animation::SpringConfig {
visual_response(base, 0.8)
}
pub(crate) fn shadow_spring(base: ferese_animation::SpringConfig) -> ferese_animation::SpringConfig {
visual_response(base, 1.2)
}
fn visual_response(base: ferese_animation::SpringConfig, response: f64) -> ferese_animation::SpringConfig {
ferese_animation::SpringConfig {
stiffness: base.stiffness / (response * response),
damping: base.damping / response,
position_tolerance: 0.001,
velocity_tolerance: 0.001,
..base
}
}
pub(crate) fn take_output_feedback(
state: &crate::Ferese,
output: &Output,
@@ -300,6 +367,93 @@ impl RenderElement<GlesRenderer> for NativeTextureElement {
#[cfg(test)]
mod tests {
fn moving_presentation() -> WindowPresentation {
use ferese_animation::{AnimatedRect, AnimatedValue, RectVelocity};
WindowPresentation {
id: ferese_layout::WindowId(7),
bounds: AnimatedRect {
velocity: RectVelocity {
x: 150.0,
y: -90.0,
width: 20.0,
height: 5.0,
},
..AnimatedRect::new(ferese_layout::Rect::new(12.25, -4.5, 601.75, 399.25))
},
opacity: AnimatedValue {
current: 0.7,
target: 1.0,
velocity: 0.4,
},
emphasis: AnimatedValue {
current: 0.6,
target: 1.0,
velocity: 0.3,
},
shadow: AnimatedValue {
current: 0.4,
target: 1.0,
velocity: 0.2,
},
scale_content: false,
native_size: None,
}
}
#[test]
fn thumbnail_and_close_keep_window_identity_and_live_motion() {
let live = moving_presentation();
let thumbnail = live.thumbnail(ferese_layout::Rect::new(0.25, 8.5, 120.5, 80.25));
assert_eq!(thumbnail.id, live.id);
assert_eq!(thumbnail.opacity, live.opacity);
assert_eq!(thumbnail.emphasis, live.emphasis);
assert_eq!(thumbnail.shadow, live.shadow);
let mut closing = live;
closing.close();
assert_eq!(closing.id, live.id);
assert_eq!(closing.bounds.current, live.bounds.current);
assert_eq!(closing.bounds.velocity, live.bounds.velocity);
assert_eq!(closing.opacity.current, live.opacity.current);
assert_eq!(closing.opacity.velocity, live.opacity.velocity);
assert_eq!(closing.emphasis.velocity, live.emphasis.velocity);
assert_eq!(closing.shadow.velocity, live.shadow.velocity);
for scale in [1.0, 1.25, 1.5, 2.0] {
assert_eq!(
RoundedRect::new(live.bounds.current, (0, 0).into(), scale, 14.0),
RoundedRect::new(closing.bounds.current, (0, 0).into(), scale, 14.0)
);
}
}
#[test]
fn layered_responses_preserve_damping_and_settle_independently() {
use ferese_animation::{AnimatedValue, SpringConfig};
let base = SpringConfig::default();
let damping_ratio = |spring: SpringConfig| spring.damping / (2.0 * (spring.mass * spring.stiffness).sqrt());
for spring in [emphasis_spring(base), shadow_spring(base)] {
assert!((damping_ratio(spring) - damping_ratio(base)).abs() < 1e-12);
assert_eq!(spring.crossing, base.crossing);
}
let mut emphasis = AnimatedValue {
current: 0.0,
target: 1.0,
velocity: 0.0,
};
let mut shadow = emphasis;
let dt = Duration::from_millis(80);
emphasis.advance(dt, emphasis_spring(base));
shadow.advance(dt, shadow_spring(base));
assert!(emphasis.current > shadow.current);
let before = (emphasis.velocity, shadow.velocity);
emphasis.set_target(0.0);
shadow.set_target(0.0);
assert_eq!((emphasis.velocity, shadow.velocity), before);
emphasis.advance(Duration::from_secs(3), emphasis_spring(base));
shadow.advance(Duration::from_secs(3), shadow_spring(base));
assert!(!emphasis.is_animating());
assert!(!shadow.is_animating());
}
#[test]
fn rounded_outline_clamps_after_physical_snapping() {
// Logical clamping would give 1.125; the snapped 3px rect allows radius 1.5.
+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);
}
}
}
+39 -8
View File
@@ -160,7 +160,8 @@ impl FocusSwipe {
let distance = self.destination - self.start;
// When both columns already fit, give bounded drag feedback and settle
// back to the unchanged viewport after selecting the next window.
let distance = if distance.abs() < 0.001 {
if distance.abs() < 0.001 {
if self.direction == Direction::Right {
64.0
} else {
@@ -168,8 +169,7 @@ impl FocusSwipe {
}
} else {
distance
};
distance
}
}
fn position(&self) -> f64 {
@@ -266,8 +266,12 @@ impl WorkspaceSlide {
}
fn position(&self, item: &WorkspaceSlideItem, delta: Duration) -> SlideOffset {
self.sample(item, delta).0
}
fn sample(&self, item: &WorkspaceSlideItem, delta: Duration) -> (SlideOffset, SlideOffset) {
if let Some(progress) = self.held_progress {
return item.start.between(item.target, progress);
return (item.start.between(item.target, progress), item.velocity);
}
let mut x = AnimatedValue {
current: item.start.x,
@@ -281,10 +285,16 @@ impl WorkspaceSlide {
};
x.advance(delta, self.spring);
y.advance(delta, self.spring);
SlideOffset {
x: x.current,
y: y.current,
}
(
SlideOffset {
x: x.current,
y: y.current,
},
SlideOffset {
x: x.velocity,
y: y.velocity,
},
)
}
fn moving(&self) -> bool {
@@ -1105,6 +1115,27 @@ impl ClientData for ClientState {
#[cfg(test)]
mod tests {
#[test]
fn slide_item_sampling_matches_advance_without_mutating_the_slide() {
let mut slide = WorkspaceSlide::new(None, WorkspaceId(1), WorkspaceId(2), SwipeDirection::Up);
slide.advance(Duration::from_millis(30));
for held in [None, Some(0.35)] {
slide.held_progress = held;
for delta in [Duration::ZERO, Duration::from_millis(8), Duration::from_millis(400)] {
let before = (slide.items[0].start, slide.items[0].velocity);
let mut reference = slide.clone();
reference.advance(delta);
for (item, advanced) in slide.items.iter().zip(&reference.items) {
let (position, velocity) = slide.sample(item, delta);
let expected = held.map_or(advanced.start, |p| advanced.start.between(advanced.target, p));
assert_eq!(position, expected);
assert_eq!(velocity, advanced.velocity);
}
assert_eq!((slide.items[0].start, slide.items[0].velocity), before);
}
}
}
#[test]
fn workspace_reversal_keeps_position_and_velocity() {
let from = WorkspaceId(1);
+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;