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;
+119 -165
View File
@@ -2,7 +2,7 @@ use crate::{CrossingPolicy, SpringConfig};
/// One configuration contract for compositor and out-of-process shell motion.
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
#[serde(default)]
#[serde(default, deny_unknown_fields)]
pub struct MotionSettings {
pub enabled: bool,
pub reduced_motion: bool,
@@ -32,21 +32,17 @@ impl MotionSettings {
if !self.speed.is_finite() || self.speed <= 0.0 {
return Err("animation speed must be finite and positive".into());
}
self.spring.resolve(SpringConfig::default())?;
self.spring_config()?;
self.viewport_config()?;
Ok(())
}
pub fn spring_config(self) -> Result<SpringConfig, String> {
self.spring.resolve(240.0)
}
pub fn viewport_config(self) -> Result<SpringConfig, String> {
let mut settings = self.viewport_spring;
if settings.duration_ms.is_none() && settings.damping.is_none() && settings.damping_ratio.is_none() {
settings.damping_ratio = Some(1.0);
}
settings.resolve(SpringConfig {
stiffness: 320.0,
damping: 2.0 * 320.0_f64.sqrt(),
..SpringConfig::default()
})
self.viewport_spring.resolve(350.0)
}
/// Only small opacity changes should use a timed duration.
@@ -59,73 +55,50 @@ impl MotionSettings {
}
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Deserialize)]
#[serde(default)]
#[serde(default, deny_unknown_fields)]
pub struct SpringSettings {
pub mass: Option<f64>,
pub stiffness: Option<f64>,
pub damping: Option<f64>,
pub damping_ratio: Option<f64>,
pub duration_ms: Option<f64>,
pub bounce: Option<f64>,
pub bounce: f64,
pub overshoot: bool,
}
impl SpringSettings {
pub fn resolve(self, base: SpringConfig) -> Result<SpringConfig, String> {
let positive = |value: f64| value.is_finite() && value > 0.0;
let mut config = SpringConfig {
mass: self.mass.unwrap_or(base.mass),
stiffness: self.stiffness.unwrap_or(base.stiffness),
damping: self.damping.unwrap_or(base.damping),
fn resolve(self, default_duration_ms: f64) -> Result<SpringConfig, String> {
let duration = self.duration_ms.unwrap_or(default_duration_ms);
if !duration.is_finite() || duration <= 0.0 {
return Err("spring duration-ms must be finite and positive".into());
}
if !self.bounce.is_finite() || self.bounce <= -1.0 || self.bounce >= 1.0 {
return Err("spring bounce must be between -1 and 1 (exclusive)".into());
}
if self.bounce > 0.0 && !self.overshoot {
return Err("positive spring bounce requires overshoot".into());
}
let ratio = if self.bounce >= 0.0 {
1.0 - self.bounce
} else {
1.0 / (1.0 + self.bounce)
};
let omega = std::f64::consts::TAU / (duration / 1000.0);
let config = SpringConfig {
mass: 1.0,
stiffness: omega * omega,
damping: 2.0 * omega * ratio,
crossing: if self.overshoot {
CrossingPolicy::AllowOvershoot
} else {
CrossingPolicy::NoCrossing
},
..base
..SpringConfig::default()
};
if !positive(config.mass) || !positive(config.stiffness) || !config.damping.is_finite() || config.damping < 0.0
// Guard derived values too: extreme finite inputs can overflow or
// underflow. Every accepted presentation spring must dissipate motion.
if !config.stiffness.is_finite()
|| config.stiffness <= 0.0
|| !config.damping.is_finite()
|| config.damping <= 0.0
{
return Err("spring mass/stiffness must be positive and damping nonnegative".into());
}
if let Some(duration) = self.duration_ms {
if self.stiffness.is_some() || self.damping.is_some() || self.damping_ratio.is_some() {
return Err("spring duration-ms cannot be combined with stiffness/damping/damping-ratio".into());
}
let bounce = self.bounce.unwrap_or(0.0);
if !positive(duration)
|| !bounce.is_finite()
|| (bounce <= -1.0 || bounce >= 1.0)
|| (bounce > 0.0 && !self.overshoot)
{
return Err("spring duration-ms must be positive; bounce must be between -1 and 1 (exclusive), and positive bounce requires overshoot".into());
}
let ratio = if bounce >= 0.0 {
1.0 - bounce
} else {
1.0 / (1.0 + bounce)
};
let omega = std::f64::consts::TAU / (duration / 1000.0);
config.stiffness = config.mass * omega * omega;
config.damping = 2.0 * config.mass * omega * ratio;
} else {
if self.bounce.is_some() {
return Err("spring bounce requires duration-ms".into());
}
if let Some(ratio) = self.damping_ratio {
if self.damping.is_some() || !ratio.is_finite() || ratio < 0.0 {
return Err("spring damping-ratio must be nonnegative and cannot be combined with damping".into());
}
config.damping = 2.0 * ratio * (config.mass * config.stiffness).sqrt();
}
}
if !positive(config.stiffness) || !config.damping.is_finite() {
return Err("spring coefficients overflow".into());
}
// Presentation lifetimes can depend on settlement. An undamped spring
// only settles when the first-crossing clamp is enabled.
if config.damping == 0.0 && config.crossing == CrossingPolicy::AllowOvershoot {
return Err("spring overshoot requires positive damping so motion can settle".into());
return Err("spring duration-ms/bounce produce out-of-range coefficients".into());
}
Ok(config)
}
@@ -134,17 +107,76 @@ impl SpringSettings {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn undamped_overshoot_is_rejected_for_both_motion_properties() {
fn defaults_are_critical_with_distinct_spatial_and_viewport_responses() {
let settings = MotionSettings::default();
for (spring, duration) in [
(settings.spring_config().unwrap(), 0.240),
(settings.viewport_config().unwrap(), 0.350),
] {
assert_eq!(spring.mass, 1.0);
assert!((spring.damping / (2.0 * spring.stiffness.sqrt()) - 1.0).abs() < 1e-12);
assert!((std::f64::consts::TAU / spring.stiffness.sqrt() - duration).abs() < 1e-12);
assert_eq!(spring.crossing, CrossingPolicy::NoCrossing);
}
settings.validate().unwrap();
}
#[test]
fn perceptual_conversion_covers_under_critical_and_overdamping() {
for (bounce, ratio) in [(0.0, 1.0), (0.3, 0.7), (-0.5, 2.0)] {
let spring = SpringSettings {
duration_ms: Some(500.0),
bounce,
overshoot: bounce > 0.0,
}
.resolve(240.0)
.unwrap();
assert!((spring.stiffness - 157.913670417).abs() < 1e-8);
assert!((spring.damping / (2.0 * spring.stiffness.sqrt()) - ratio).abs() < 1e-10);
assert!((std::f64::consts::TAU * (spring.mass / spring.stiffness).sqrt() - 0.5).abs() < 1e-10);
}
}
#[test]
fn rejects_nonsettling_and_out_of_range_settings_for_both_properties() {
for spring in [
SpringSettings {
damping: Some(0.0),
bounce: 1.0,
overshoot: true,
..Default::default()
},
SpringSettings {
damping_ratio: Some(0.0),
overshoot: true,
bounce: -1.0,
..Default::default()
},
SpringSettings {
bounce: f64::NAN,
..Default::default()
},
SpringSettings {
bounce: 0.2,
..Default::default()
},
SpringSettings {
duration_ms: Some(0.0),
..Default::default()
},
SpringSettings {
duration_ms: Some(-100.0),
..Default::default()
},
SpringSettings {
duration_ms: Some(f64::INFINITY),
..Default::default()
},
SpringSettings {
duration_ms: Some(f64::MIN_POSITIVE),
..Default::default()
},
SpringSettings {
duration_ms: Some(f64::MAX),
..Default::default()
},
] {
@@ -165,64 +197,28 @@ mod tests {
.is_err()
);
}
assert!(
SpringSettings {
overshoot: true,
..Default::default()
}
.resolve(SpringConfig {
damping: 0.0,
..Default::default()
})
.is_err()
);
}
#[test]
fn undamped_motion_needs_the_crossing_clamp_to_settle() {
use crate::AnimatedValue;
let clamped = SpringSettings {
damping: Some(0.0),
fn accepted_bounce_retargets_and_settles() {
let spring = SpringSettings {
bounce: 0.3,
overshoot: true,
..Default::default()
}
.resolve(SpringConfig::default())
.resolve(240.0)
.unwrap();
let mut closing = AnimatedValue {
current: 1.0,
target: 0.0,
let mut motion = crate::AnimatedValue {
current: 0.0,
target: 1.0,
velocity: 0.0,
};
let mut free = closing;
for _ in 0..3600 {
let dt = std::time::Duration::from_secs_f64(1.0 / 60.0);
closing.advance(dt, clamped);
free.advance(
dt,
SpringConfig {
crossing: CrossingPolicy::AllowOvershoot,
..clamped
},
);
assert!(free.current != free.target || free.velocity != 0.0);
}
assert_eq!(closing.current, closing.target);
assert_eq!(closing.velocity, 0.0);
}
#[test]
fn viewport_legacy_damping_tracks_overridden_coefficients() {
let settings = MotionSettings {
viewport_spring: SpringSettings {
mass: Some(2.0),
stiffness: Some(500.0),
..Default::default()
},
..Default::default()
};
let spring = settings.viewport_config().unwrap();
assert_eq!(spring.damping, 2.0 * 1000.0_f64.sqrt());
assert_eq!(spring.crossing, CrossingPolicy::NoCrossing);
assert!(settings.validate().is_ok());
motion.advance(std::time::Duration::from_millis(50), spring);
let before = (motion.current, motion.velocity);
motion.set_target(0.0);
assert_eq!((motion.current, motion.velocity), before);
motion.advance(std::time::Duration::from_secs(3), spring);
assert!(!motion.is_animating());
}
#[test]
@@ -248,51 +244,9 @@ mod tests {
.is_zero()
);
assert!(MotionSettings { speed: 0.0, ..slow }.validate().is_err());
}
#[test]
fn perceptual_conversion_and_legacy_defaults() {
let base = SpringConfig::default();
assert_eq!(SpringSettings::default().resolve(base).unwrap(), base);
for (bounce, ratio) in [(0.0, 1.0), (0.3, 0.7), (-0.5, 2.0)] {
let spring = SpringSettings {
duration_ms: Some(500.0),
bounce: Some(bounce),
overshoot: bounce > 0.0,
..Default::default()
}
.resolve(base)
.unwrap();
assert!((spring.stiffness - 157.913670417).abs() < 1e-8);
assert!((spring.damping / (2.0 * spring.stiffness.sqrt()) - ratio).abs() < 1e-10);
assert!((std::f64::consts::TAU * (spring.mass / spring.stiffness).sqrt() - 0.5).abs() < 1e-10);
}
}
#[test]
fn rejects_ambiguous_or_nonsettling_configuration() {
for settings in [
SpringSettings {
duration_ms: Some(300.0),
stiffness: Some(100.0),
..Default::default()
},
SpringSettings {
duration_ms: Some(300.0),
bounce: Some(0.3),
..Default::default()
},
SpringSettings {
duration_ms: Some(300.0),
bounce: Some(-1.0),
..Default::default()
},
SpringSettings {
duration_ms: Some(300.0),
bounce: Some(1.0),
overshoot: true,
..Default::default()
},
] {
assert!(settings.resolve(SpringConfig::default()).is_err());
}
assert_eq!(
slow.spring_config().unwrap(),
MotionSettings::default().spring_config().unwrap()
);
}
}
+55 -47
View File
@@ -126,15 +126,15 @@ animations {
speed 1.0
spring {
mass 1.0
stiffness 700.0
damping 53.0
duration-ms 240.0
bounce 0.0
overshoot #false
}
viewport-spring {
mass 1.0
stiffness 320.0
damping-ratio 1.0
duration-ms 350.0
bounce 0.0
overshoot #false
}
}
```
@@ -147,25 +147,21 @@ You can omit either spring block to keep its defaults.
| `animations.enabled` | boolean | `true` | Enable motion |
| `animations.reduced-motion` | boolean | `false` | Disable animated motion |
| `animations.speed` | number > 0 | `1` | Higher is faster; `0.75` is slower |
| `animations.spring.mass` | number > 0 | `1` | Window-motion spring mass |
| `animations.spring.stiffness` | number > 0 | `700` | Spring stiffness |
| `animations.spring.damping` | number ≥ 0 | `53` | Spring damping |
| `animations.viewport-spring.mass` | number > 0 | `1` | Scrolling spring mass |
| `animations.viewport-spring.stiffness` | number > 0 | `320` | Scrolling stiffness |
| `animations.viewport-spring.damping-ratio` | number ≥ 0 | `1` | `1` is critically damped; zero requires overshoot disabled |
| `animations.spring.duration-ms` | number > 0 | `240` | Window and shell spring response |
| `animations.viewport-spring.duration-ms` | number > 0 | `350` | Scrolling and workspace spring response |
Both `spring` and `viewport-spring` also accept these keys:
Both `spring` and `viewport-spring` accept these keys:
| Key | Type | Default | Meaning |
| --- | --- | --- | --- |
| `damping-ratio` | number ≥ 0 | Derived from coefficients for `spring`; `1` for `viewport-spring` | Alternative to `damping`; zero requires overshoot disabled |
| `duration-ms` | number > 0 | Unset | Response parameter instead of stiffness/damping; not a completion deadline |
| `bounce` | number strictly between −1 and 1 | `0` with `duration-ms` | Requires `duration-ms`; positive values also require overshoot |
| `overshoot` | boolean | `false` | Allow target crossings for this spring; requires positive damping |
| `duration-ms` | number > 0 | `240` or `350`, as above | Larger values give a slower response; not a completion deadline |
| `bounce` | number strictly between −1 and 1 | `0` | Zero is critical damping; positive values require overshoot |
| `overshoot` | boolean | `false` | Allow this spring to cross its target |
`mass`, `stiffness` and `damping` are accepted in both blocks. The viewport's
default damping ratio tracks changes to mass and stiffness unless you supply
`damping`, `damping-ratio` or `duration-ms` explicitly.
Use duration and bounce to configure spring physics. `mass`, `stiffness`,
`damping` and `damping-ratio` are internal solver parameters, not configuration
keys. Removed or misspelled animation keys are rejected. Invalid edits leave
the last accepted configuration active.
### Speed and reduced motion
@@ -180,6 +176,12 @@ immediate. Reduced motion takes precedence over `enabled #true`; setting
`speed 0` is invalid. These settings control Ferese's animations, not animations
inside other applications.
Shell animations sample motion on each surface's redraw, paced by its Wayland
frame callbacks. They request animation frames only while moving.
Menus, modals, notifications and hover fades use this path instead of a fixed
16 ms animation timer. Clock, notification-expiry and service updates keep their
own schedules.
Overview can reverse while moving, and workspace changes slide in their navigation
direction. Workspace and scrolling gestures carry their release velocity into
the settling spring. Scrolling gestures resist at viewport limits and spring back.
@@ -187,6 +189,12 @@ Window opening starts when content is available; closing waits for the applicati
to unmap, then animates its retained image. Input and application close requests
remain responsive while the animations finish.
Live windows, overview thumbnails and retained images share a window identity
and presentation state. Entering overview inherits the window's visible position
and velocity, including motion from a workspace slide. Resize images use the same
content mapping in overview and remain visible if the window closes during a resize.
Content, clipping and borders use the same rounded rectangle at the output's scale.
### Spring tuning
`spring` controls window position and size motion, fullscreen/maximize zoom and
@@ -197,45 +205,45 @@ Continuum shares the analytic spring solver and motion settings between the
compositor and shell. Small opacity changes (hover, dimming, theme fades) may
still use timed transitions.
Spring settling time depends on distance, velocity and tolerances. In either
spring block, you can use duration and bounce instead of stiffness and damping:
Focus emphasis and shadows have separate spring responses. Emphasis uses 80% of
the main spring's response time; shadows use 120%. These ratios preserve the
configured damping ratio and overshoot policy, and follow `speed` and reduced
motion. They are built-in tuning values, not additional configuration keys.
Focused windows use the theme's full shadow. Unfocused shadows use 75% of its
vertical offset, 90% of its blur radius and 80% of its opacity, with spring motion
between those values. Shadow motion does not change the content or border geometry.
Spring settling time depends on distance, velocity and tolerances. `duration-ms`
controls the response, not a fixed completion deadline. Both default springs are
critically damped; the viewport has a slower response than windows and shell popups.
To opt into bounce for one spring:
```kdl
animations {
spring {
duration-ms 300.0
bounce 0.0
bounce 0.2
overshoot #true
}
}
```
`duration-ms` is a response parameter, not a fixed completion deadline. With
`T = duration-ms / 1000`, Continuum uses `omega = 2*pi/T`,
`stiffness = mass*omega^2` and `damping = 2*mass*omega*zeta`.
`zeta = 1-bounce` for nonnegative bounce, otherwise `zeta = 1/(1+bounce)`.
Bounce defaults to zero and must be strictly between -1 and 1. Positive bounce
requires `overshoot #true` in that spring block. Do not combine `duration-ms`
with `stiffness`, `damping` or `damping-ratio`; existing physical settings keep
working. This conversion does not guarantee the same perceptual duration or
settling time as Apple's springs.
requires `overshoot #true` in that spring block. Negative bounce adds overdamping.
Without overshoot, Ferese stops a spring at its first target crossing. Opacity
remains bounded even when geometry can overshoot. Overshoot is never enabled globally.
With the other parameters unchanged, higher stiffness makes motion faster and
higher mass makes it slower. Damping controls how quickly velocity dies away.
The default window spring is slightly overdamped, close to critical damping;
the viewport spring is critically damped.
For the solver, mass is fixed at `1`. With `T = duration-ms / 1000`, Continuum uses
`omega = 2*pi/T`, `stiffness = omega^2` and `damping = 2*omega*zeta`.
`zeta = 1-bounce` for nonnegative bounce, otherwise `zeta = 1/(1+bounce)`.
This conversion does not guarantee the same perceptual duration or settling time
as Apple's springs.
For `viewport-spring`, `damping-ratio 1.0` is critical damping, values below `1`
are underdamped and values above `1` are overdamped. Both spring blocks accept a
damping ratio or a damping coefficient, but not both; critical damping is
`damping = 2 * sqrt(stiffness * mass)`. Ferese stops spring animations
at their first target crossing by default. `overshoot #true` allows that spring
to move past its target; opacity remains bounded. Overshoot is never enabled globally.
Mass, stiffness and speed must be finite and greater than zero.
Damping coefficients and damping ratios may be zero only with overshoot disabled,
so the first-crossing clamp can settle the animation. Overshoot requires positive
damping; otherwise closing popups could wait indefinitely for settlement. Invalid edits leave
the last accepted configuration active.
Duration and speed must be finite and greater than zero. Bounce must be finite.
Values that overflow or underflow the derived coefficients are rejected. The
allowed bounce range keeps damping positive, so undamped oscillation cannot
prevent popup cleanup.
## Appearance
+10
View File
@@ -83,6 +83,16 @@ animations {
enabled #true
reduced-motion #false
speed 0.9
spring {
duration-ms 240.0
bounce 0.0
overshoot #false
}
viewport-spring {
duration-ms 350.0
bounce 0.0
overshoot #false
}
}
appearance {
+11 -2
View File
@@ -22,7 +22,7 @@
DEALINGS IN THE SOFTWARE.
</copyright>
<interface name="ferese_effects_manager_v1" version="3">
<interface name="ferese_effects_manager_v1" version="4">
<description summary="assign semantic material roles to surfaces">
This private global is visible only to clients granted the Ferese effects
capability. Roles describe compositor-owned appearance policy and do not
@@ -48,7 +48,7 @@
</enum>
</interface>
<interface name="ferese_surface_effects_v1" version="3">
<interface name="ferese_surface_effects_v1" version="4">
<description summary="semantic material role for one surface">
The role affects only compositor rendering. It does not change surface
geometry, input, focus, stacking, or the surface's Wayland role.
@@ -98,5 +98,14 @@
</description>
<arg name="opacity" type="uint"/>
</request>
<request name="set_region_opacities" since="4">
<description summary="fade individual region materials and shadows">
An array of native-endian uint32 opacity values from 0 to 1000,
indexed in set_regions order. Content is not affected. Missing entries
use 1000. At most 32 entries are allowed; invalid arrays are ignored.
These values multiply the surface opacity and persist until replaced.
</description>
<arg name="opacities" type="array"/>
</request>
</interface>
</protocol>
+11 -2
View File
@@ -72,8 +72,17 @@ class OverviewDamageTest(unittest.TestCase):
# The first report includes time before this call. The second
# covers a full five seconds of the requested stationary scene.
count = len(reports())
wait_for(lambda: len(reports()) >= count + 2)
return reports()[-1]
for attempt in range(3):
wait_for(lambda: len(reports()) >= count + 2)
lines = log_path.read_text().splitlines()
boundaries = [index for index, line in enumerate(lines)
if "render performance" in line]
interval = lines[boundaries[-2] + 1:boundaries[-1]]
if not any("ferese::nested_input:" in line for line in interval):
return reports()[-1]
print("Discarding interval with host input; waiting for a quiet interval", flush=True)
count = len(boundaries) - 1
self.fail("No input-free interval for the stationary overview check")
def assert_quiet(label, report):
print(label + ":", report, flush=True)
+153
View File
@@ -0,0 +1,153 @@
"""Exercise real shell frame-driven motion on a private nested desktop and bus.
FERESE_TEST_SHELL_MOTION=1 python3 scripts/tests/test_shell_motion_isolated.py
Uses release binaries by default. Requires a Wayland host, dbus-daemon and gdbus.
Protocol traces contain only this test's shell traffic, never the host desktop.
"""
import os
from pathlib import Path
import re
import signal
import subprocess
import tempfile
import time
import unittest
@unittest.skipUnless(os.environ.get("FERESE_TEST_SHELL_MOTION") == "1", "opt-in nested shell test")
class ShellMotionTest(unittest.TestCase):
def test_notifications_and_modals_follow_frames_and_finish_closing(self):
repo = Path(__file__).resolve().parents[2]
binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/release/ferese")
shell = repo / os.environ.get("FERESE_TEST_SHELL", "target/release/ferese-shell")
ctl = repo / os.environ.get("FERESE_TEST_CTL", "target/release/feresectl")
children = []
with tempfile.TemporaryDirectory(prefix="ferese-shell-motion-") as temporary:
root = Path(temporary)
runtime = root / "runtime"
runtime.mkdir(mode=0o700)
config = root / "config/ferese"
config.mkdir(parents=True)
(config / "config.kdl").write_text(
'animations { speed 0.5; }\nstatus { keybinding-guide #false; }\n')
display = Path(os.environ["WAYLAND_DISPLAY"])
if not display.is_absolute():
display = Path(os.environ["XDG_RUNTIME_DIR"]) / display
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), WAYLAND_DISPLAY=str(display),
XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state"),
XDG_CACHE_HOME=str(root / "cache"), RUST_LOG="ferese=info,ferese::nested_input=debug")
env.pop("WAYLAND_SOCKET", None)
env.pop("FERESE_SHELL_CONTROL_SOCKET", None)
log_path = root / "session.log"
# No service directories: readiness checks must not auto-activate
# the host's notification daemon on this private bus.
bus_config = root / "bus.conf"
bus_config.write_text(
'<busconfig><type>session</type><listen>unix:tmpdir=/tmp</listen>'
'<policy context="default"><allow send_destination="*"/>'
'<allow receive_sender="*"/><allow own="*"/></policy></busconfig>')
def launch(command, **kwargs):
process = subprocess.Popen(command, env=env, start_new_session=True, **kwargs)
children.append(process)
return process
def wait_for(predicate, timeout=15):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
value = predicate()
if value:
return value
time.sleep(.025)
trace = log_path.read_text()
relevant = '\n'.join(line for line in trace.splitlines() if any(
word in line for word in ('get_layer_surface', 'ERROR', 'WARN', 'notification')))
self.fail("Timed out:\n" + relevant[-16000:] + '\n' + trace[-4000:])
def dbus(method, *args, check=True):
return subprocess.run(["gdbus", "call", "--session", "--dest", "org.freedesktop.Notifications",
"--object-path", "/org/freedesktop/Notifications", "--method",
"org.freedesktop.Notifications." + method, *args],
env=env, text=True, capture_output=True, timeout=5, check=check)
def layer_surface(namespace):
matches = list(re.finditer(r'get_layer_surface\([^\n]*?wl_surface[@#](\d+)[^\n]*"' + namespace + '"', log_path.read_text()))
# Wayland object IDs can be reused after destruction. Restrict
# assertions to this surface's lifetime in the trace.
return (matches[-1].group(1), matches[-1].start()) if matches else None
def frames(surface):
identity, start = surface
return len(re.findall(r'wl_surface[@#]' + identity + r'\.frame\(', log_path.read_text()[start:]))
def commits(surface):
identity, start = surface
return len(re.findall(r'wl_surface[@#]' + identity + r'\.commit\(', log_path.read_text()[start:]))
def destroyed(surface):
identity, start = surface
return re.search(r'wl_surface[@#]' + identity + r'\.destroy\(', log_path.read_text()[start:]) is not None
with log_path.open("w") as log:
try:
bus = launch(["dbus-daemon", "--config-file=" + str(bus_config), "--nofork", "--print-address=1"],
stdout=subprocess.PIPE, stderr=log, text=True)
env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip()
compositor = launch([str(binary), "--backend", "nested", "--grant-effects", "--grant-shell-control",
"--", "env", "WAYLAND_DEBUG=client", "ICED_BACKEND=tiny-skia", str(shell)],
stdout=log, stderr=log)
wait_for(lambda: (runtime / "ferese/control.sock").is_socket())
wait_for(lambda: dbus("GetServerInformation", check=False).returncode == 0)
wait_for(lambda: layer_surface("ferese-shell-top-bar"))
time.sleep(.5)
reply = dbus("Notify", "Ferese motion test", "0", "", "Frame cadence", "Synthetic test notification", "[]", "{}", "0").stdout
notice = re.search(r'uint32 (\d+)', reply).group(1)
surface = wait_for(lambda: layer_surface("ferese-shell-notifications"))
time.sleep(1.5)
opening_frames = frames(surface)
self.assertGreater(opening_frames, 10, log_path.read_text()[-8000:])
time.sleep(1)
for attempt in range(3):
trace_start = len(log_path.read_text())
before = commits(surface)
time.sleep(2)
idle_frames = commits(surface) - before
if 'ferese::nested_input:' not in log_path.read_text()[trace_start:]:
break
else:
self.fail("Host input interrupted all three idle measurement intervals")
# Clock/status updates still redraw settled surfaces. Count
# commits: Iced can request two callbacks for one redraw.
self.assertLessEqual(idle_frames, 12, "settled toast kept requesting animation frames")
material_updates = log_path.read_text().count('.set_region_opacities(')
dbus("CloseNotification", notice)
wait_for(lambda: destroyed(surface), timeout=5)
self.assertGreater(log_path.read_text().count('.set_region_opacities(') - material_updates, 5,
"notification materials did not follow their fade")
print(f"Toast: {opening_frames} opening callback requests, {idle_frames / 2:g} settled commits/s; destroyed", flush=True)
subprocess.run([str(ctl), "toggle-keybinding-guide"], env=env, check=True, capture_output=True)
modal = wait_for(lambda: layer_surface("ferese-system-modal"))
wait_for(lambda: frames(modal) >= 4)
time.sleep(.3)
subprocess.run([str(ctl), "toggle-keybinding-guide"], env=env, check=True, capture_output=True)
wait_for(lambda: destroyed(modal), timeout=5)
self.assertGreater(frames(modal), 10, "modal close did not follow frames")
self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:])
self.assertNotIn("panicked at", log_path.read_text())
print(f"Modal: {frames(modal)} callbacks; interrupted opening closed and destroyed", flush=True)
finally:
for child in reversed(children):
if child.poll() is None:
os.killpg(child.pid, signal.SIGTERM)
try:
child.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(child.pid, signal.SIGKILL)
child.wait(timeout=5)
if child.stdout is not None:
child.stdout.close()
if __name__ == "__main__":
unittest.main()
+3 -3
View File
@@ -579,12 +579,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 = parse_test_source(&source).unwrap_err().to_string();
assert!(error.contains("overshoot requires positive damping"), "{error}");
assert!(error.contains("unknown field"), "{error}");
}
}
}
-44
View File
@@ -355,15 +355,6 @@ impl cosmic::Application for FereseShell {
control.subscription().map(|_| Message::ControlReady)
}),
ferese_theme_client::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))),
if self
.system_modal
.as_ref()
.is_some_and(|modal| modal.motion.animating() || modal.motion.closing())
{
cosmic::iced::time::every(Duration::from_millis(16)).map(|_| Message::AnimatePower)
} else {
Subscription::none()
},
self.notifications.subscription().map(Message::NotificationEvent),
self.notifications
.tick_subscription()
@@ -399,45 +390,10 @@ impl cosmic::Application for FereseShell {
} else {
Subscription::none()
},
if self
.menu
.as_ref()
.is_some_and(|menu| menu.animating() || menu.motion.closing())
{
cosmic::iced::time::every(Duration::from_millis(16)).map(|_| Message::AnimateMenu)
} else {
Subscription::none()
},
])
}
fn update(&mut self, message: Self::Message) -> Task<Self::Message> {
if let Some(menu) = &self.menu
&& let Some(effects) = &menu.effects
{
if let Err(error) = effects.set_opacity(menu.progress()) {
eprintln!("ferese-shell: could not update popup opacity: {error}");
}
let regions = menu.regions.lock().unwrap().clone();
if let Err(error) = effects.set_regions(&regions) {
eprintln!("ferese-shell: could not update card materials: {error}");
}
}
if let Some(surface) = &self.notification_surface
&& let Some(effects) = &surface.effects
{
let regions = surface.regions.lock().unwrap().clone();
if let Err(error) = effects.set_regions(&regions) {
eprintln!("ferese-shell: could not update notification materials: {error}");
}
}
self.update_power_materials();
match message {
Message::ThemeChanged(snapshot) => {
let mut config = self.config.clone();
+23 -10
View File
@@ -1,3 +1,6 @@
mod frames;
pub(crate) use frames::frame_driven;
use cosmic::iced::advanced::{Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget};
use cosmic::iced::{Event, Length, Rectangle, Size, Transformation, Vector};
use cosmic::{Element, Theme};
@@ -79,11 +82,7 @@ impl PopupMotion {
value.snap();
return value;
}
let spring = self
.settings
.spring
.resolve(SpringConfig::default())
.expect("validated motion settings");
let spring = self.settings.spring_config().expect("validated motion settings");
value.advance(
now.saturating_duration_since(started).mul_f64(self.settings.speed),
SpringConfig {
@@ -105,8 +104,17 @@ impl PopupMotion {
pub(crate) fn closing(&self) -> bool {
self.value.target == 0.0
}
pub(crate) fn revision(&self) -> Option<std::time::Instant> {
self.started
}
pub(crate) fn frame_active(&self, now: std::time::Instant) -> bool {
self.started.is_some() && self.animating_at(now)
}
pub(crate) fn animating(&self) -> bool {
let value = self.sample(std::time::Instant::now());
self.animating_at(std::time::Instant::now())
}
pub(crate) fn animating_at(&self, now: std::time::Instant) -> bool {
let value = self.sample(now);
value.current != value.target || value.velocity != 0.0
}
@@ -238,11 +246,14 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
shell: &mut Shell<'_, M>,
viewport: &Rectangle,
) {
let now = std::time::Instant::now();
let now = match event {
Event::Window(cosmic::iced::window::Event::RedrawRequested(at)) => *at,
_ => std::time::Instant::now(),
};
let hovered = cursor.is_over(layout.bounds()) && cursor.is_over(*viewport);
let state = tree.state.downcast_mut::<HoverState>();
if state.advance(if hovered { 1.0 } else { 0.0 }, now, self.duration) {
shell.request_redraw_at(now + std::time::Duration::from_millis(16));
shell.request_redraw();
}
self.progress.set(state.current);
self.content.as_widget_mut().update(
@@ -494,10 +505,12 @@ impl widget::Operation for CollectRegions {
#[cfg(test)]
mod tests {
#[test]
fn zero_damping_with_crossing_clamp_finishes_popup_close() {
fn duration_bounce_spring_finishes_popup_close() {
let settings = super::Settings {
spring: ferese_animation::SpringSettings {
damping: Some(0.0),
duration_ms: Some(240.0),
bounce: 0.3,
overshoot: true,
..Default::default()
},
..Default::default()
+331
View File
@@ -0,0 +1,331 @@
//! Surface-local animation sampling. Iced's Wayland backend gates NextFrame
//! redraws on that surface's frame callback; no animation timer is needed.
use super::*;
use std::time::Instant;
pub(crate) fn frame_driven<'a, M: 'a>(
revision: Option<Instant>,
build: impl Fn(Instant) -> Element<'a, M> + 'a,
active: impl Fn(Instant) -> bool + 'a,
after_frame: impl Fn(Instant) + 'a,
completion: impl Fn(Instant) -> Option<M> + 'a,
) -> Element<'a, M> {
let now = Instant::now();
let content = build(now);
let content_active = active(now);
Element::new(FrameDriven {
content,
content_active,
revision,
build: Box::new(build),
active: Box::new(active),
after_frame: Box::new(after_frame),
completion: Box::new(completion),
})
}
#[derive(Default)]
struct FrameState {
revision: Option<Instant>,
last_frame: Option<Instant>,
completed: bool,
}
impl FrameState {
fn begin(&mut self, at: Instant, revision: Option<Instant>) -> bool {
if self.revision != revision {
self.revision = revision;
self.last_frame = None;
self.completed = false;
}
if self.last_frame == Some(at) {
return false;
}
self.last_frame = Some(at);
true
}
fn complete(&mut self, active: bool) -> bool {
if active {
self.completed = false;
return false;
}
!std::mem::replace(&mut self.completed, true)
}
}
struct FrameDriven<'a, M> {
content: Element<'a, M>,
content_active: bool,
revision: Option<Instant>,
build: Box<dyn Fn(Instant) -> Element<'a, M> + 'a>,
active: Box<dyn Fn(Instant) -> bool + 'a>,
after_frame: Box<dyn Fn(Instant) + 'a>,
completion: Box<dyn Fn(Instant) -> Option<M> + 'a>,
}
impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<FrameState>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(FrameState::default())
}
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(&self.content)]
}
fn diff(&mut self, tree: &mut widget::Tree) {
tree.diff_children(std::slice::from_mut(&mut self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &cosmic::Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn a11y_nodes(
&self,
layout: Layout<'_>,
tree: &widget::Tree,
cursor: mouse::Cursor,
) -> iced_accessibility::A11yTree {
self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor)
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &cosmic::Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, M>,
viewport: &Rectangle,
) {
let redraw = if let Event::Window(cosmic::iced::window::Event::RedrawRequested(at)) = event {
tree.state
.downcast_mut::<FrameState>()
.begin(*at, self.revision)
.then_some(*at)
} else {
None
};
if let Some(at) = redraw {
// Only paint parameters depend on time. Preserve the child tree's
// focus/hover/scroll state and its existing layout.
let active = (self.active)(at);
// Include the final settled sample, then reuse the tree on hover
// and other redraws. Application updates build fresh content.
if active || self.content_active {
self.content = (self.build)(at);
tree.children[0].diff(&mut self.content);
}
self.content_active = active;
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
if let Event::Window(cosmic::iced::window::Event::RedrawRequested(at)) = event {
let active = (self.active)(*at);
if active {
shell.request_redraw();
}
// Iced can replay the same redraw after a child invalidates layout.
// Keep its NextFrame request, but sample/publish only once.
if redraw.is_some() {
(self.after_frame)(*at);
}
if redraw.is_some() {
let completion = (self.completion)(*at);
if tree.state.downcast_mut::<FrameState>().complete(completion.is_none())
&& let Some(message) = completion
{
shell.publish(message);
}
}
}
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut cosmic::Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content
.as_widget()
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &cosmic::Renderer,
) -> mouse::Interaction {
self.content
.as_widget()
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &cosmic::Renderer,
operation: &mut dyn widget::Operation,
) {
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut widget::Tree,
layout: Layout<'b>,
renderer: &cosmic::Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<cosmic::iced::advanced::overlay::Element<'b, M, Theme, cosmic::Renderer>> {
self.content
.as_widget_mut()
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
}
}
#[cfg(test)]
mod tests {
use super::*;
use cosmic::iced::advanced::renderer::Headless;
use cosmic::iced::window::{Event as WindowEvent, RedrawRequest};
use std::{cell::RefCell, time::Duration};
#[test]
fn callbacks_sample_every_refresh_without_timers_and_stop_when_settled() {
let mut renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new(
cosmic::iced::Font::default(),
cosmic::iced::Pixels(14.0),
Some("tiny-skia"),
))
.unwrap();
for hz in [60, 120, 144, 240] {
let start = Instant::now();
let mut motion = PopupMotion::new(Settings::default());
motion.begin(start);
let samples = RefCell::new(Vec::new());
let mut element = frame_driven(
motion.revision(),
|at| {
samples.borrow_mut().push(motion.progress_at(at));
cosmic::widget::Space::new().width(20.0).height(20.0).into()
},
|at| motion.frame_active(at),
|_| {},
|at| (!motion.frame_active(at)).then_some(()),
);
let mut tree = widget::Tree::new(&element);
let node = element.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(100.0, 100.0)),
);
let mut messages = Vec::new();
let mut finished = false;
let mut clipboard = cosmic::iced::advanced::clipboard::Null;
samples.borrow_mut().clear();
for frame in 0..=hz * 2 {
let at = start + Duration::from_secs_f64(frame as f64 / hz as f64);
let event = Event::Window(WindowEvent::RedrawRequested(at));
let active = motion.frame_active(at);
for _ in 0..2 {
// Iced may replay a redraw after a layout/update pass.
let mut shell = Shell::new(&mut messages);
element.as_widget_mut().update(
&mut tree,
&event,
Layout::new(&node),
mouse::Cursor::Unavailable,
&renderer,
&mut clipboard,
&mut shell,
&Rectangle::new((0.0, 0.0).into(), (100.0, 100.0).into()),
);
assert_eq!(
shell.redraw_request(),
if active {
RedrawRequest::NextFrame
} else {
RedrawRequest::Wait
}
);
}
assert_eq!(samples.borrow().len(), frame as usize + 1);
if !active {
finished = true;
break;
}
assert!(
messages.is_empty(),
"motion should not publish application updates per frame"
);
}
assert!(finished);
assert_eq!(messages.len(), 1, "only one completion update");
let settled_builds = samples.borrow().len();
// A later unrelated redraw must not restart motion or cleanup.
let mut shell = Shell::new(&mut messages);
element.as_widget_mut().update(
&mut tree,
&Event::Window(WindowEvent::RedrawRequested(start + Duration::from_secs(3))),
Layout::new(&node),
mouse::Cursor::Unavailable,
&renderer,
&mut clipboard,
&mut shell,
&Rectangle::new((0.0, 0.0).into(), (100.0, 100.0).into()),
);
assert_eq!(shell.redraw_request(), RedrawRequest::Wait);
assert_eq!(messages.len(), 1);
assert_eq!(samples.borrow().len(), settled_builds, "settled redraw rebuilt content");
let values = samples.borrow();
assert!(values.windows(2).all(|pair| pair[1] >= pair[0]));
assert_eq!(values.last(), Some(&1.0));
assert!(values.len() > (hz / 4) as usize, "high refresh callbacks were skipped");
}
// Keep this mutable: the real render path shares the same renderer.
renderer.reset(Rectangle::default());
}
#[test]
fn new_transition_can_complete_after_previous_one_settled() {
let start = Instant::now();
let mut state = FrameState::default();
assert!(state.begin(start, Some(start)));
assert!(state.complete(false));
assert!(!state.begin(start, Some(start)));
assert!(!state.complete(false));
let next = start + Duration::from_millis(10);
assert!(state.begin(next, Some(next)));
assert!(!state.complete(true));
assert!(state.begin(next + Duration::from_millis(400), Some(next)));
assert!(state.complete(false));
assert!(!state.complete(false));
}
}
+31 -2
View File
@@ -148,6 +148,9 @@ impl FereseShell {
}
pub(super) fn sync_notification_surface(&mut self) -> Task<Message> {
// Hidden toast surfaces receive no frame callbacks. Their closes have
// no visible transition to finish, so release them immediately.
self.notifications.tick();
if self.notifications.ready {
self.status.notifications = Some(status::Notifications {
count: self.notifications.unread(),
@@ -513,10 +516,36 @@ impl FereseShell {
}
pub(super) fn view_notifications(&self) -> Element<'_, cosmic::Action<Message>> {
motion::frame_driven(
self.notifications.motion_revision(),
|now| self.view_notifications_at(now),
|now| self.notifications.frame_active(now),
|now| {
if let Some(surface) = &self.notification_surface
&& let Some(effects) = &surface.effects
{
let _ = effects.set_regions(&surface.regions.lock().unwrap());
let _ = effects.set_region_opacities(
self.notifications
.popup_groups_at(now)
.iter()
.map(|(_, opacity, _)| *opacity),
);
}
},
|now| {
self.notifications
.has_finished_closes(now)
.then_some(cosmic::Action::App(Message::NotificationTick))
},
)
}
pub(super) fn view_notifications_at(&self, now: std::time::Instant) -> Element<'_, cosmic::Action<Message>> {
if self.notifications.history_open {
let palette = self.config.theme;
let surface = self.menu.as_ref();
let progress = surface.map_or(1.0, status_ui::OpenMenu::progress);
let progress = surface.map_or(1.0, |menu| menu.motion.progress_at(now));
let opacity = 1.0;
let color = move |rgba| color_with_opacity(rgba, opacity);
let primary = color(palette.text_primary);
@@ -751,7 +780,7 @@ impl FereseShell {
)
} else {
let mut cards = column([]).spacing(8);
for (notice, opacity, count) in self.notifications.popup_groups() {
for (notice, opacity, count) in self.notifications.popup_groups_at(now) {
cards = cards.push(self.notification_card(notice, opacity, false, count));
}
motion::animated(
+58 -15
View File
@@ -330,22 +330,39 @@ impl Center {
self.entries.iter().filter(|notice| notice.unread).count() as u32
}
#[cfg(test)]
pub fn visible(&self) -> impl Iterator<Item = (&Notice, f32)> {
self.visible_at(Instant::now())
}
fn visible_at(&self, now: Instant) -> impl Iterator<Item = (&Notice, f32)> {
self.toasts.iter().rev().filter_map(move |toast| {
let notice = self.entries.iter().find(|notice| notice.id == toast.id)?;
Some((notice, toast.motion.progress().clamp(0.0, 1.0)))
Some((notice, toast.motion.progress_at(now).clamp(0.0, 1.0)))
})
}
pub fn animating(&self) -> bool {
pub fn frame_active(&self, now: Instant) -> bool {
self.toasts.iter().any(|toast| toast.motion.frame_active(now))
}
pub fn has_finished_closes(&self, now: Instant) -> bool {
self.toasts
.iter()
.any(|toast| toast.closing.is_some() || toast.motion.animating())
.any(|toast| toast.closing.is_some() && !toast.motion.animating_at(now))
}
pub fn motion_revision(&self) -> Option<Instant> {
self.toasts.iter().filter_map(|toast| toast.motion.revision()).max()
}
pub fn popup_groups(&self) -> Vec<(&Notice, f32, usize)> {
self.popup_groups_at(Instant::now())
}
pub fn popup_groups_at(&self, now: Instant) -> Vec<(&Notice, f32, usize)> {
let mut groups: Vec<(&Notice, f32, usize)> = Vec::new();
for (notice, opacity) in self.visible() {
for (notice, opacity) in self.visible_at(now) {
if let Some(group) = groups.iter_mut().find(|(head, _, _)| head.app == notice.app) {
group.2 += 1;
} else {
@@ -429,12 +446,12 @@ impl Center {
self.command(Command::Close(id, revision, reason));
}
}
if let Some(toast) = self.toasts.iter_mut().find(|toast| toast.id == id) {
if toast.closing.is_none() {
let now = Instant::now();
toast.closing = Some(now);
toast.motion.retarget(0.0, now);
}
if let Some(toast) = self.toasts.iter_mut().find(|toast| toast.id == id)
&& toast.closing.is_none()
{
let now = Instant::now();
toast.closing = Some(now);
toast.motion.retarget(0.0, now);
}
}
@@ -551,10 +568,6 @@ impl Center {
}
pub fn tick_subscription(&self) -> cosmic::iced::Subscription<()> {
if self.animating() {
return cosmic::iced::time::every(Duration::from_millis(16)).map(|_| ());
}
fn deadline_stream(deadline: &Instant) -> impl cosmic::iced::futures::Stream<Item = ()> + use<> {
let deadline = *deadline;
cosmic::iced::futures::stream::once(async move {
@@ -610,7 +623,7 @@ impl Center {
self.close(id, 1);
}
self.toasts
.retain(|toast| toast.closing.is_none() || toast.motion.animating());
.retain(|toast| toast.closing.is_none() || (!self.history_open && toast.motion.animating()));
let visible: HashSet<_> = self.toasts.iter().map(|toast| toast.id).collect();
self.entries
.retain(|notice| !notice.transient || notice.live || visible.contains(&notice.id));
@@ -664,6 +677,36 @@ mod tests {
}
}
#[test]
fn hidden_toast_closes_do_not_wait_for_unavailable_frame_callbacks() {
let (mut center, _) = fixture();
center.receive(notice(1));
center.toasts.front_mut().unwrap().motion.begin(Instant::now());
center.history_open = true;
center.close(1, 2);
center.tick();
assert!(center.toasts.is_empty());
assert!(center.expiry_deadline().is_none());
assert!(!center.frame_active(Instant::now()));
assert_eq!(center.entries.len(), 1, "history retains the notice");
}
#[test]
fn closing_toast_can_finish_while_another_toast_is_still_moving() {
let (mut center, _) = fixture();
center.receive(notice(1));
let old = Instant::now() - Duration::from_secs(3);
center.toasts.front_mut().unwrap().motion.retarget(0.0, old);
center.toasts.front_mut().unwrap().closing = Some(old);
center.receive(notice(2));
let now = Instant::now();
assert!(center.frame_active(now));
assert!(center.has_finished_closes(now));
center.tick();
assert!(!center.has_finished_closes(Instant::now()));
assert_eq!(center.toasts.len(), 1);
}
#[test]
fn history_groups_expand_and_clear_without_removing_other_apps() {
let (mut center, commands) = fixture();
+21 -2
View File
@@ -131,12 +131,31 @@ impl OpenMenu {
impl FereseShell {
fn view_status_menu(&self) -> Element<'_, cosmic::Action<Message>> {
let Some(menu) = &self.menu else { return text("").into() };
motion::frame_driven(
menu.motion.revision(),
|now| self.view_status_menu_at(now),
|now| menu.motion.frame_active(now),
|now| {
if let Some(effects) = &menu.effects {
let _ = effects.set_regions(&menu.regions.lock().unwrap());
let _ = effects.set_opacity(menu.motion.progress_at(now).clamp(0.0, 1.0));
}
},
|now| {
(menu.motion.closing() && !menu.motion.animating_at(now))
.then_some(cosmic::Action::App(Message::AnimateMenu))
},
)
}
fn view_status_menu_at(&self, now: Instant) -> Element<'_, cosmic::Action<Message>> {
let Some(menu) = &self.menu else {
return text("").into();
};
if menu.kind == Menu::Notifications && self.notifications.ready {
return cosmic::widget::autosize::autosize(
self.view_notifications(),
self.view_notifications_at(now),
cosmic::iced::advanced::widget::Id::new("ferese-notification-center"),
)
.limits(
@@ -229,7 +248,7 @@ impl FereseShell {
cosmic::widget::autosize::autosize(
super::motion::animated(
panel.into(),
menu.progress(),
menu.motion.progress_at(now),
menu.regions.clone(),
theme.material_radius,
),
+21 -1
View File
@@ -481,6 +481,7 @@ pub(super) struct EffectsBinding {
_queue: EventQueue<EffectsState>,
regions: std::cell::RefCell<Option<Vec<[f32; 5]>>>,
opacity: std::cell::Cell<Option<u32>>,
region_opacities: std::cell::RefCell<Vec<u32>>,
}
impl EffectsBinding {
@@ -504,7 +505,7 @@ impl EffectsBinding {
let connection = Connection::from_backend(backend);
let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?;
let qh = queue.handle();
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 3..=3, ())?;
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 4..=4, ())?;
let effects = manager.get_surface_effects(surface, &qh, ());
let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
@@ -523,9 +524,28 @@ impl EffectsBinding {
_queue: queue,
regions: Default::default(),
opacity: std::cell::Cell::new(Some(opacity)),
region_opacities: Default::default(),
})
}
pub(super) fn set_region_opacities(
&self,
values: impl IntoIterator<Item = f32>,
) -> Result<(), Box<dyn std::error::Error>> {
let values: Vec<u32> = values
.into_iter()
.take(32)
.map(|value| (value.clamp(0.0, 1.0) * 1000.0).round() as u32)
.collect();
if *self.region_opacities.borrow() != values {
self.surface
.set_region_opacities(values.iter().flat_map(|value| value.to_ne_bytes()).collect());
self.connection.flush()?;
*self.region_opacities.borrow_mut() = values;
}
Ok(())
}
pub(super) fn set_regions(&self, regions: &[[f32; 5]]) -> Result<(), Box<dyn std::error::Error>> {
self.set_material_regions(regions, ferese_surface_effects_v1::Role::Popover)
}
+26 -1
View File
@@ -468,7 +468,32 @@ impl FereseShell {
let Some(surface) = modal.surfaces.iter().find(|surface| surface.id == id) else {
return text("").into();
};
let progress = modal.motion.progress();
motion::frame_driven(
modal.motion.revision(),
move |now| self.view_system_modal_at(id, now),
|now| modal.motion.frame_active(now),
|now| {
if let Some(effects) = &surface.effects {
let _ = effects
.set_material_regions(&surface.regions.lock().unwrap(), ferese_surface_effects_v1::Role::Modal);
let _ = effects.set_opacity(modal.motion.progress_at(now).clamp(0.0, 1.0));
}
},
|now| {
(surface.primary && modal.motion.closing() && !modal.motion.animating_at(now))
.then_some(cosmic::Action::App(Message::AnimatePower))
},
)
}
fn view_system_modal_at(&self, id: window::Id, now: Instant) -> Element<'_, cosmic::Action<Message>> {
let Some(modal) = &self.system_modal else {
return text("").into();
};
let Some(surface) = modal.surfaces.iter().find(|surface| surface.id == id) else {
return text("").into();
};
let progress = modal.motion.progress_at(now);
let content: Element<'_, cosmic::Action<Message>> = if surface.primary {
let theme = self.config.theme;
let material = surface.effects.is_some();