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:
1 parent
f8bc17e913
commit
64a80dc069
33 files changed
+1742
-600
No files matched your search
+11
-11
@@ -357,12 +357,12 @@ pub(crate) fn config_path() -> Option<PathBuf> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_undamped_overshoot_before_publication() {
|
fn rejects_removed_physics_keys_before_publication() {
|
||||||
for property in ["spring", "viewport-spring"] {
|
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 source = format!("animations {{ {property} {{ {coefficient} 0; overshoot #true; }}; }}");
|
||||||
let error = Config::parse_source(&source).unwrap().runtime_config().err().unwrap().to_string();
|
let error = Config::parse_source(&source).unwrap_err().to_string();
|
||||||
assert!(error.contains("overshoot requires positive damping"), "{error}");
|
assert!(error.contains("unknown field"), "{error}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -582,7 +582,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parses_animation_policy_and_reduced_motion() {
|
fn parses_animation_policy_and_reduced_motion() {
|
||||||
let config = parse(
|
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());
|
assert!(!config.animations_enabled());
|
||||||
@@ -590,9 +590,9 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.spring_config().unwrap(),
|
config.spring_config().unwrap(),
|
||||||
SpringConfig {
|
SpringConfig {
|
||||||
mass: 2.0,
|
mass: 1.0,
|
||||||
stiffness: 500.0,
|
stiffness: (std::f64::consts::TAU / 0.3).powi(2),
|
||||||
damping: 40.0,
|
damping: 2.0 * std::f64::consts::TAU / 0.3,
|
||||||
..SpringConfig::default()
|
..SpringConfig::default()
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -601,7 +601,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn rejects_invalid_animation_numbers() {
|
fn rejects_invalid_animation_numbers() {
|
||||||
let speed = parse("animations {\n speed 0.0\n}\n");
|
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!(speed.animation_speed().is_err());
|
||||||
assert!(damping.spring_config().is_err());
|
assert!(damping.spring_config().is_err());
|
||||||
@@ -1043,8 +1043,8 @@ mod tests {
|
|||||||
let spring = config.viewport_spring_config().unwrap();
|
let spring = config.viewport_spring_config().unwrap();
|
||||||
|
|
||||||
assert_eq!(spring.mass, 1.0);
|
assert_eq!(spring.mass, 1.0);
|
||||||
assert_eq!(spring.stiffness, 320.0);
|
assert!((spring.stiffness - (std::f64::consts::TAU / 0.350).powi(2)).abs() < 1e-9);
|
||||||
assert!((spring.damping - 35.777_087_64).abs() < 0.000_001);
|
assert!((spring.damping - 2.0 * spring.stiffness.sqrt()).abs() < 1e-9);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -52,10 +52,7 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn spring_config(&self) -> Result<SpringConfig, ConfigError> {
|
pub fn spring_config(&self) -> Result<SpringConfig, ConfigError> {
|
||||||
self.animations
|
self.animations.spring_config().map_err(ConfigError::Binding)
|
||||||
.spring
|
|
||||||
.resolve(SpringConfig::default())
|
|
||||||
.map_err(ConfigError::Binding)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn viewport_spring_config(&self) -> Result<SpringConfig, ConfigError> {
|
pub fn viewport_spring_config(&self) -> Result<SpringConfig, ConfigError> {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ struct SurfaceEffectsState {
|
|||||||
role: Mutex<Option<SemanticRole>>,
|
role: Mutex<Option<SemanticRole>>,
|
||||||
regions: Mutex<Option<Vec<[f64; 5]>>>,
|
regions: Mutex<Option<Vec<[f64; 5]>>>,
|
||||||
opacity: Mutex<u32>,
|
opacity: Mutex<u32>,
|
||||||
|
region_opacities: Mutex<Vec<u32>>,
|
||||||
presentation_supported: AtomicBool,
|
presentation_supported: AtomicBool,
|
||||||
dismissing: AtomicBool,
|
dismissing: AtomicBool,
|
||||||
}
|
}
|
||||||
@@ -70,6 +71,7 @@ impl SurfaceEffectsState {
|
|||||||
role: Mutex::new(None),
|
role: Mutex::new(None),
|
||||||
regions: Mutex::new(None),
|
regions: Mutex::new(None),
|
||||||
opacity: Mutex::new(1000),
|
opacity: Mutex::new(1000),
|
||||||
|
region_opacities: Mutex::new(Vec::new()),
|
||||||
presentation_supported: AtomicBool::new(false),
|
presentation_supported: AtomicBool::new(false),
|
||||||
dismissing: AtomicBool::new(false),
|
dismissing: AtomicBool::new(false),
|
||||||
}
|
}
|
||||||
@@ -101,7 +103,7 @@ impl SurfaceEffectsUserData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn init_global(display: &DisplayHandle) {
|
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, ());
|
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]>> {
|
fn decode_regions(bytes: &[u8]) -> Option<Vec<[f64; 5]>> {
|
||||||
if !bytes.len().is_multiple_of(20) || bytes.len() > 32 * 20 {
|
if !bytes.len().is_multiple_of(20) || bytes.len() > 32 * 20 {
|
||||||
return None;
|
return None;
|
||||||
@@ -339,6 +366,26 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
|
|||||||
crate::backends::direct::render_surface(state, &surface);
|
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 } => {
|
ferese_surface_effects_v1::Request::SetRegions { regions } => {
|
||||||
let Some(regions) = decode_regions(®ions) else {
|
let Some(regions) = decode_regions(®ions) else {
|
||||||
return;
|
return;
|
||||||
@@ -400,6 +447,7 @@ fn detach(surface: &WlSurface) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let role_changed = effects.set_role(None);
|
let role_changed = effects.set_role(None);
|
||||||
|
effects.region_opacities.lock().unwrap().clear();
|
||||||
effects.dismissing.store(false, Ordering::Release);
|
effects.dismissing.store(false, Ordering::Release);
|
||||||
let mut opacity = effects.opacity.lock().unwrap();
|
let mut opacity = effects.opacity.lock().unwrap();
|
||||||
let opacity_changed = *opacity != 1000;
|
let opacity_changed = *opacity != 1000;
|
||||||
@@ -441,6 +489,16 @@ pub(crate) fn fade_dismissed_surface(surface: &WlSurface, opacity: f64) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn fractional_regions_validate_all_wire_values() {
|
fn fractional_regions_validate_all_wire_values() {
|
||||||
let region = [0.25f32, -0.75, 100.5, 40.25, 13.5];
|
let region = [0.25f32, -0.75, 100.5, 40.25, 13.5];
|
||||||
|
|||||||
+98
-23
@@ -359,18 +359,36 @@ impl OverviewState {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
pub(crate) fn presented_rect(&self, id: WindowId, normal: Rect) -> Rect {
|
pub(crate) fn presented_rect(&self, id: WindowId, normal: Rect) -> Rect {
|
||||||
self.motion.presented_rect(id, normal)
|
self.motion.presented_rect(id, normal)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn presented_velocity(&self, id: WindowId) -> Option<ferese_animation::RectVelocity> {
|
pub(crate) fn motion(&self) -> &OverviewMotion {
|
||||||
let velocity = self.motion.presentations.get(&id)?.velocity;
|
&self.motion
|
||||||
Some(ferese_animation::RectVelocity {
|
}
|
||||||
x: velocity.x * OVERVIEW_MOTION_SPEED,
|
|
||||||
y: velocity.y * OVERVIEW_MOTION_SPEED,
|
fn seed_presentations(&mut self, windows: impl IntoIterator<Item = crate::presentation::WindowPresentation>) {
|
||||||
width: velocity.width * OVERVIEW_MOTION_SPEED,
|
for window in windows {
|
||||||
height: velocity.height * OVERVIEW_MOTION_SPEED,
|
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(
|
fn enter(
|
||||||
@@ -551,6 +569,12 @@ impl Ferese {
|
|||||||
self.advance_animations(std::time::Instant::now());
|
self.advance_animations(std::time::Instant::now());
|
||||||
|
|
||||||
if active {
|
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() {
|
if self.cancel_workspace_slides() {
|
||||||
self.relayout();
|
self.relayout();
|
||||||
}
|
}
|
||||||
@@ -640,19 +664,7 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn presented_window_rect(&self, id: WindowId) -> Option<Rect> {
|
pub(crate) fn presented_window_rect(&self, id: WindowId) -> Option<Rect> {
|
||||||
let normal = self.windows.geometry(&id)?.visual.current;
|
Some(self.current_window_presentation(id)?.bounds.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,
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn inverse_presented_window_point(&self, id: WindowId, x: f64, y: f64) -> Option<(f64, f64)> {
|
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
|
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 {
|
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 {
|
pub(crate) fn advance(&mut self, delta: Duration, spring: SpringConfig, animations_enabled: bool) -> bool {
|
||||||
@@ -1067,6 +1091,57 @@ impl OverviewMotion {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn tiny_overview_target_changes_still_get_a_settling_tick() {
|
fn tiny_overview_target_changes_still_get_a_settling_tick() {
|
||||||
let mut overview = OverviewState::default();
|
let mut overview = OverviewState::default();
|
||||||
|
|||||||
@@ -17,6 +17,73 @@ use crate::render::SharedPixelShaderElement;
|
|||||||
pub(crate) const HANDOFF: Duration = Duration::from_millis(80);
|
pub(crate) const HANDOFF: Duration = Duration::from_millis(80);
|
||||||
pub(crate) const SNAPSHOT_BUDGET: usize = 64 * 1024 * 1024;
|
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(
|
pub(crate) fn take_output_feedback(
|
||||||
state: &crate::Ferese,
|
state: &crate::Ferese,
|
||||||
output: &Output,
|
output: &Output,
|
||||||
@@ -300,6 +367,93 @@ impl RenderElement<GlesRenderer> for NativeTextureElement {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn rounded_outline_clamps_after_physical_snapping() {
|
fn rounded_outline_clamps_after_physical_snapping() {
|
||||||
// Logical clamping would give 1.125; the snapped 3px rect allows radius 1.5.
|
// Logical clamping would give 1.125; the snapped 3px rect allows radius 1.5.
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use ferese_animation::{AnimatedRect, AnimatedValue, CrossingPolicy, SpringConfig};
|
use ferese_animation::{CrossingPolicy, SpringConfig};
|
||||||
use ferese_core::OutputId;
|
use ferese_core::OutputId;
|
||||||
use ferese_layout::WindowId;
|
use ferese_layout::WindowId;
|
||||||
|
|
||||||
/// Detached presentation only: no live window, input, focus, or layout entry.
|
/// Detached presentation only: no live window, input, focus, or layout entry.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(crate) struct ClosedWindow {
|
pub(crate) struct ClosedWindow {
|
||||||
pub id: WindowId,
|
pub presentation: crate::presentation::WindowPresentation,
|
||||||
pub output: OutputId,
|
pub output: OutputId,
|
||||||
pub below: Option<WindowId>,
|
pub below: Option<WindowId>,
|
||||||
pub bounds: AnimatedRect,
|
|
||||||
pub opacity: AnimatedValue,
|
|
||||||
pub snapshot: ResizeSnapshot,
|
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 radius: f64,
|
||||||
pub shape: CornerShape,
|
pub shape: CornerShape,
|
||||||
pub focus: f64,
|
|
||||||
pub decorations: f64,
|
pub decorations: f64,
|
||||||
pub dim: f64,
|
pub dim: f64,
|
||||||
pub fill: Option<[f32; 4]>,
|
pub fill: Option<[f32; 4]>,
|
||||||
@@ -28,9 +26,22 @@ pub(crate) struct ClosedWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
pub fn advance(&mut self, delta: Duration, spring: SpringConfig) -> bool {
|
||||||
self.bounds.advance(delta, spring);
|
// The client is gone, so this handoff no longer waits for configure
|
||||||
let opacity = self.opacity.advance_with_policy(
|
// 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,
|
delta,
|
||||||
SpringConfig {
|
SpringConfig {
|
||||||
position_tolerance: 0.001,
|
position_tolerance: 0.001,
|
||||||
@@ -39,58 +50,64 @@ impl ClosedWindow {
|
|||||||
},
|
},
|
||||||
CrossingPolicy::NoCrossing,
|
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();
|
self.snapshot.commit.increment();
|
||||||
// Once invisible there is no reason to keep a texture (including with
|
// Texture lifetime ends when the shared presentation becomes invisible.
|
||||||
// intentionally undamped, overshooting geometry configurations).
|
|
||||||
opacity
|
opacity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn elements(
|
pub(super) fn grouped_elements(
|
||||||
state: &mut Ferese,
|
state: &mut Ferese,
|
||||||
renderer: &mut GlesRenderer,
|
renderer: &mut GlesRenderer,
|
||||||
output: &Output,
|
output: &Output,
|
||||||
below: Option<WindowId>,
|
live: impl IntoIterator<Item = WindowId>,
|
||||||
live: &std::collections::HashSet<WindowId>,
|
|
||||||
delta: Duration,
|
delta: Duration,
|
||||||
) -> Vec<AnimatedWindowRenderElement> {
|
) -> std::collections::HashMap<Option<WindowId>, Vec<AnimatedWindowRenderElement>> {
|
||||||
if state.session_lock.active() {
|
if state.render.closing.is_empty() || state.session_lock.active() {
|
||||||
return Vec::new();
|
return Default::default();
|
||||||
}
|
}
|
||||||
let Some(output_id) = state.output_id(output) else {
|
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 {
|
let Some(output_geometry) = state.space.output_geometry(output) else {
|
||||||
return Vec::new();
|
return Default::default();
|
||||||
};
|
};
|
||||||
let scale = output.current_scale().fractional_scale();
|
let scale = output.current_scale().fractional_scale();
|
||||||
let windows: Vec<_> = state
|
let windows: Vec<_> = state
|
||||||
.render
|
.render
|
||||||
.closing
|
.closing
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|window| {
|
.filter(|window| window.output == output_id && window.snapshot.context == renderer.context_id().erased())
|
||||||
window.output == output_id
|
|
||||||
&& window.below.filter(|id| live.contains(id)) == below
|
|
||||||
&& window.snapshot.context == renderer.context_id().erased()
|
|
||||||
})
|
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.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 {
|
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() {
|
if !delta.is_zero() {
|
||||||
window.advance(delta, state.spring_config);
|
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 corners = RoundedRect::new(visual, output_geometry.loc, scale, window.radius).with_shape(window.shape);
|
||||||
let constrain = rounded_visual_rect(visual, output_geometry.loc);
|
let constrain = rounded_visual_rect(visual, output_geometry.loc);
|
||||||
let Some(programs) = corner_program(&mut state.render, renderer, window.shape) else {
|
let Some(programs) = corner_program(&mut state.render, renderer, window.shape) else {
|
||||||
continue;
|
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(
|
if let Some(dim) = window_tint_element(
|
||||||
&mut state.render,
|
&mut state.render,
|
||||||
renderer,
|
renderer,
|
||||||
window.id,
|
window.presentation.id,
|
||||||
constrain,
|
constrain,
|
||||||
corners,
|
corners,
|
||||||
[0.0, 0.0, 0.0, window.dim as f32 * alpha],
|
[0.0, 0.0, 0.0, window.dim as f32 * alpha],
|
||||||
@@ -104,44 +121,53 @@ pub(super) fn elements(
|
|||||||
&mut state.render,
|
&mut state.render,
|
||||||
theme,
|
theme,
|
||||||
renderer,
|
renderer,
|
||||||
window.id,
|
window.presentation.id,
|
||||||
constrain,
|
constrain,
|
||||||
corners,
|
corners,
|
||||||
scale,
|
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_color.0,
|
||||||
theme.border_gradient,
|
theme.border_gradient,
|
||||||
window.focus as f32,
|
window.presentation.focus() as f32,
|
||||||
alpha * window.decorations as f32,
|
alpha * window.decorations as f32,
|
||||||
output,
|
output,
|
||||||
&programs,
|
&programs,
|
||||||
) {
|
) {
|
||||||
elements.push(border.into());
|
elements.push(border.into());
|
||||||
}
|
}
|
||||||
let clip = framebuffer_clip_rect(
|
if let Some((snapshot, native_size)) = &window.handoff {
|
||||||
corners.rect,
|
let mut presentation = window.presentation;
|
||||||
output.current_mode().unwrap().size,
|
presentation.scale_content = native_size.is_none();
|
||||||
output.current_transform().invert(),
|
presentation.native_size = *native_size;
|
||||||
);
|
if let Some(element) = super::window_content::snapshot_element(
|
||||||
elements.push(
|
snapshot,
|
||||||
NativeTextureElement {
|
presentation,
|
||||||
id: window.snapshot.id.clone(),
|
corners,
|
||||||
commit: window.snapshot.commit,
|
scale,
|
||||||
texture: window.snapshot.texture.clone(),
|
output,
|
||||||
geometry: corners.rect,
|
&programs,
|
||||||
source: window.source,
|
crate::presentation::handoff_alpha(snapshot.elapsed),
|
||||||
alpha,
|
) {
|
||||||
program: Some(programs.texture.clone()),
|
elements.push(element.into());
|
||||||
uniforms: vec![Uniform::new("clip_rect", clip), Uniform::new("radius", corners.radius)],
|
|
||||||
}
|
}
|
||||||
.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 {
|
if let Some(mut fill) = window.fill {
|
||||||
fill[3] = alpha;
|
fill[3] = alpha;
|
||||||
if let Some(fill) = window_tint_element(
|
if let Some(fill) = window_tint_element(
|
||||||
&mut state.render,
|
&mut state.render,
|
||||||
renderer,
|
renderer,
|
||||||
window.id,
|
window.presentation.id,
|
||||||
constrain,
|
constrain,
|
||||||
corners,
|
corners,
|
||||||
fill,
|
fill,
|
||||||
@@ -151,8 +177,8 @@ pub(super) fn elements(
|
|||||||
elements.push(fill.into());
|
elements.push(fill.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some((surface, role, generation, opacity)) = &window.material {
|
if let Some((surface, role, generation, opacity)) = &window.material
|
||||||
if let Some((material, _)) = material_element_with_role(
|
&& let Some((material, _)) = material_element_with_role(
|
||||||
state,
|
state,
|
||||||
renderer,
|
renderer,
|
||||||
output,
|
output,
|
||||||
@@ -165,21 +191,22 @@ pub(super) fn elements(
|
|||||||
alpha,
|
alpha,
|
||||||
},
|
},
|
||||||
(*role, *generation, *opacity),
|
(*role, *generation, *opacity),
|
||||||
) {
|
)
|
||||||
elements.push(material);
|
{
|
||||||
}
|
elements.push(material);
|
||||||
}
|
}
|
||||||
let theme = &state.theme_settings;
|
let theme = &state.theme_settings;
|
||||||
|
let (offset_factor, blur_factor, opacity_factor) = window.presentation.shadow_factors();
|
||||||
if let Some(shadow) = window_shadow_element(
|
if let Some(shadow) = window_shadow_element(
|
||||||
&mut state.render,
|
&mut state.render,
|
||||||
renderer,
|
renderer,
|
||||||
window.id,
|
window.presentation.id,
|
||||||
constrain,
|
constrain,
|
||||||
corners,
|
corners,
|
||||||
scale,
|
scale,
|
||||||
theme.shadow_offset_y,
|
theme.shadow_offset_y * offset_factor,
|
||||||
theme.shadow_blur,
|
theme.shadow_blur * blur_factor,
|
||||||
theme.shadow_opacity * f64::from(alpha) * window.decorations,
|
theme.shadow_opacity * opacity_factor * f64::from(alpha) * window.decorations,
|
||||||
theme.shadow_color.0,
|
theme.shadow_color.0,
|
||||||
output,
|
output,
|
||||||
&programs,
|
&programs,
|
||||||
@@ -187,5 +214,5 @@ pub(super) fn elements(
|
|||||||
elements.push(shadow.into());
|
elements.push(shadow.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
elements
|
groups
|
||||||
}
|
}
|
||||||
@@ -19,17 +19,27 @@ pub(super) fn append_material_surface(
|
|||||||
state.theme_settings.material_radius
|
state.theme_settings.material_radius
|
||||||
};
|
};
|
||||||
let regions = crate::effects::surface_regions(surface);
|
let regions = crate::effects::surface_regions(surface);
|
||||||
|
let opacities = crate::effects::surface_region_opacities(surface);
|
||||||
let targets: Vec<_> = match ®ions {
|
let targets: Vec<_> = match ®ions {
|
||||||
None => vec![(geometry, RoundedRect::from_logical(geometry, scale, radius))],
|
None => vec![(geometry, RoundedRect::from_logical(geometry, scale, radius), 1.0)],
|
||||||
Some(regions) => regions
|
Some(regions) => regions
|
||||||
.iter()
|
.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(),
|
.collect(),
|
||||||
};
|
};
|
||||||
let materials: Vec<_> = targets
|
let materials: Vec<_> = targets
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter_map(|(index, (rect, corners))| {
|
.filter_map(|(index, (rect, corners, alpha))| {
|
||||||
material_element(
|
material_element(
|
||||||
state,
|
state,
|
||||||
renderer,
|
renderer,
|
||||||
@@ -40,7 +50,7 @@ pub(super) fn append_material_surface(
|
|||||||
corners: *corners,
|
corners: *corners,
|
||||||
index,
|
index,
|
||||||
capture_geometry: geometry,
|
capture_geometry: geometry,
|
||||||
alpha: 1.0,
|
alpha: *alpha,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
mod backdrop;
|
mod backdrop;
|
||||||
mod capture;
|
mod capture;
|
||||||
mod closing;
|
mod closing;
|
||||||
|
mod window_content;
|
||||||
pub(crate) use closing::ClosedWindow;
|
pub(crate) use closing::ClosedWindow;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod corner_tests;
|
mod corner_tests;
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ pub(super) fn overview_strip_elements(
|
|||||||
if !state.window_belongs_to_output(id, output) {
|
if !state.window_belongs_to_output(id, output) {
|
||||||
continue;
|
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;
|
continue;
|
||||||
};
|
};
|
||||||
let Some((buffer, size)) = state.overview_window_label(id, scale, rect.width) else {
|
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
|
let clip_changed = state
|
||||||
.output_id(output)
|
.output_id(output)
|
||||||
.is_some_and(|output| state.render.thumbnail_corner_changed(*id, output, shape));
|
.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(
|
elements.extend(rounded_window_elements(
|
||||||
renderer,
|
renderer,
|
||||||
window,
|
window,
|
||||||
RoundedRect::new(*rect, output_geometry.loc, scale, 4.0).with_shape(shape),
|
corners,
|
||||||
scale,
|
scale,
|
||||||
alpha,
|
alpha * presentation.alpha(),
|
||||||
// Crop/scale/alpha/geometry already track their own
|
// Crop/scale/alpha/geometry already track their own
|
||||||
// damage. Role changes also alter the corner shader,
|
// damage. Role changes also alter the corner shader,
|
||||||
// even when the client buffer itself is unchanged.
|
// even when the client buffer itself is unchanged.
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ impl RenderResources {
|
|||||||
.closing
|
.closing
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|window| window.output == id)
|
.filter(|window| window.output == id)
|
||||||
.map(|window| window.id)
|
.map(|window| window.presentation.id)
|
||||||
.collect();
|
.collect();
|
||||||
self.closing.retain(|window| window.output != id);
|
self.closing.retain(|window| window.output != id);
|
||||||
for id in removed {
|
for id in removed {
|
||||||
@@ -139,7 +139,7 @@ impl RenderResources {
|
|||||||
.closing
|
.closing
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|window| &window.snapshot.context == context)
|
.filter(|window| &window.snapshot.context == context)
|
||||||
.map(|window| window.id)
|
.map(|window| window.presentation.id)
|
||||||
.collect();
|
.collect();
|
||||||
self.closing.retain(|window| &window.snapshot.context != context);
|
self.closing.retain(|window| &window.snapshot.context != context);
|
||||||
for id in removed {
|
for id in removed {
|
||||||
|
|||||||
@@ -124,8 +124,8 @@ pub(crate) fn sampled_output_elements(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let sample = frame.windows.get(&id)?;
|
let sample = frame.windows.get(&id)?;
|
||||||
let visual = sample.rect;
|
let visual = sample.presentation.bounds.current;
|
||||||
let close_alpha = sample.alpha;
|
let close_alpha = sample.presentation.alpha();
|
||||||
let decoration_progress = sample.geometry.decorations.clamp(0.0, 1.0);
|
let decoration_progress = sample.geometry.decorations.clamp(0.0, 1.0);
|
||||||
|
|
||||||
Some((window.clone(), id, sample, visual, decoration_progress, close_alpha))
|
Some((window.clone(), id, sample, visual, decoration_progress, close_alpha))
|
||||||
@@ -145,16 +145,17 @@ pub(crate) fn sampled_output_elements(
|
|||||||
.collect::<Vec<_>>()
|
.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 {
|
for (window, id, sample, visual, decoration_progress, close_alpha) in windows {
|
||||||
elements.extend(super::closing::elements(
|
if let Some(group) = closing.remove(&Some(id)) {
|
||||||
state,
|
elements.extend(group);
|
||||||
renderer,
|
}
|
||||||
output,
|
|
||||||
Some(id),
|
|
||||||
&live,
|
|
||||||
frame.delta,
|
|
||||||
));
|
|
||||||
let constrain = rounded_visual_rect(visual, output_geometry.loc);
|
let constrain = rounded_visual_rect(visual, output_geometry.loc);
|
||||||
let material_surface = window
|
let material_surface = window
|
||||||
.toplevel()
|
.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 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 corner_shape_changed = state.render.prepare_window_corners(id, shape);
|
||||||
let rounded_clip_program = corner_program(&mut state.render, renderer, shape);
|
let rounded_clip_program = corner_program(&mut state.render, renderer, shape);
|
||||||
let pixels = corners.rect;
|
|
||||||
// Only overview/close intentionally scale the complete application.
|
// 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 behavior = resize_content_behavior(scale_content);
|
||||||
|
|
||||||
let dim = sample.dim;
|
let dim = sample.dim;
|
||||||
@@ -198,7 +198,7 @@ pub(crate) fn sampled_output_elements(
|
|||||||
let shadow_blur = state.theme_settings.shadow_blur;
|
let shadow_blur = state.theme_settings.shadow_blur;
|
||||||
let shadow_opacity = state.theme_settings.shadow_opacity;
|
let shadow_opacity = state.theme_settings.shadow_opacity;
|
||||||
let shadow_color = state.theme_settings.shadow_color.0;
|
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
|
let border_width = state.theme_settings.border_width
|
||||||
+ (state.theme_settings.focus_ring_width - state.theme_settings.border_width) * focus;
|
+ (state.theme_settings.focus_ring_width - state.theme_settings.border_width) * focus;
|
||||||
let border_color = state.theme_settings.border_color.0;
|
let border_color = state.theme_settings.border_color.0;
|
||||||
@@ -222,6 +222,7 @@ pub(crate) fn sampled_output_elements(
|
|||||||
) {
|
) {
|
||||||
elements.push(border.into());
|
elements.push(border.into());
|
||||||
}
|
}
|
||||||
|
let (offset_factor, blur_factor, opacity_factor) = sample.presentation.shadow_factors();
|
||||||
let shadow = window_shadow_element(
|
let shadow = window_shadow_element(
|
||||||
&mut state.render,
|
&mut state.render,
|
||||||
renderer,
|
renderer,
|
||||||
@@ -229,9 +230,9 @@ pub(crate) fn sampled_output_elements(
|
|||||||
constrain,
|
constrain,
|
||||||
corners,
|
corners,
|
||||||
scale,
|
scale,
|
||||||
shadow_offset_y,
|
shadow_offset_y * offset_factor,
|
||||||
shadow_blur,
|
shadow_blur * blur_factor,
|
||||||
shadow_opacity * f64::from(close_alpha) * decoration_progress,
|
shadow_opacity * opacity_factor * f64::from(close_alpha) * decoration_progress,
|
||||||
shadow_color,
|
shadow_color,
|
||||||
output,
|
output,
|
||||||
&programs,
|
&programs,
|
||||||
@@ -239,54 +240,19 @@ pub(crate) fn sampled_output_elements(
|
|||||||
if let Some(snapshot) = state.render.snapshot(&id)
|
if let Some(snapshot) = state.render.snapshot(&id)
|
||||||
&& snapshot.context == renderer.context_id().erased()
|
&& snapshot.context == renderer.context_id().erased()
|
||||||
&& (snapshot.scale - scale).abs() < 0.001
|
&& (snapshot.scale - scale).abs() < 0.001
|
||||||
{
|
&& let Some(element) = super::window_content::snapshot_element(
|
||||||
let size = snapshot.texture.size();
|
snapshot,
|
||||||
let (sx, sy) = if scale_content {
|
sample.presentation,
|
||||||
(
|
corners,
|
||||||
f64::from(pixels.size.w) / f64::from(size.w),
|
scale,
|
||||||
f64::from(pixels.size.h) / f64::from(size.h),
|
output,
|
||||||
)
|
&programs,
|
||||||
} else if let Some(native) = sample.native_size {
|
crate::presentation::handoff_alpha(snapshot.elapsed),
|
||||||
(
|
|
||||||
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);
|
{
|
||||||
if let Some(visible) = visible {
|
elements.push(element.into());
|
||||||
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.extend(rounded_window_elements(
|
elements.extend(rounded_window_elements(
|
||||||
renderer,
|
renderer,
|
||||||
&window,
|
&window,
|
||||||
@@ -297,7 +263,7 @@ pub(crate) fn sampled_output_elements(
|
|||||||
output,
|
output,
|
||||||
programs.clone(),
|
programs.clone(),
|
||||||
behavior,
|
behavior,
|
||||||
sample.native_size,
|
sample.presentation.native_size,
|
||||||
));
|
));
|
||||||
let source = window.geometry().size;
|
let source = window.geometry().size;
|
||||||
if material_surface.is_none()
|
if material_surface.is_none()
|
||||||
@@ -352,14 +318,9 @@ pub(crate) fn sampled_output_elements(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
elements.extend(super::closing::elements(
|
if let Some(group) = closing.remove(&None) {
|
||||||
state,
|
elements.extend(group);
|
||||||
renderer,
|
}
|
||||||
output,
|
|
||||||
None,
|
|
||||||
&live,
|
|
||||||
frame.delta,
|
|
||||||
));
|
|
||||||
elements.extend(layer_elements(
|
elements.extend(layer_elements(
|
||||||
state,
|
state,
|
||||||
renderer,
|
renderer,
|
||||||
|
|||||||
@@ -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
@@ -160,7 +160,8 @@ impl FocusSwipe {
|
|||||||
let distance = self.destination - self.start;
|
let distance = self.destination - self.start;
|
||||||
// When both columns already fit, give bounded drag feedback and settle
|
// When both columns already fit, give bounded drag feedback and settle
|
||||||
// back to the unchanged viewport after selecting the next window.
|
// 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 {
|
if self.direction == Direction::Right {
|
||||||
64.0
|
64.0
|
||||||
} else {
|
} else {
|
||||||
@@ -168,8 +169,7 @@ impl FocusSwipe {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
distance
|
distance
|
||||||
};
|
}
|
||||||
distance
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn position(&self) -> f64 {
|
fn position(&self) -> f64 {
|
||||||
@@ -266,8 +266,12 @@ impl WorkspaceSlide {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn position(&self, item: &WorkspaceSlideItem, delta: Duration) -> SlideOffset {
|
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 {
|
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 {
|
let mut x = AnimatedValue {
|
||||||
current: item.start.x,
|
current: item.start.x,
|
||||||
@@ -281,10 +285,16 @@ impl WorkspaceSlide {
|
|||||||
};
|
};
|
||||||
x.advance(delta, self.spring);
|
x.advance(delta, self.spring);
|
||||||
y.advance(delta, self.spring);
|
y.advance(delta, self.spring);
|
||||||
SlideOffset {
|
(
|
||||||
x: x.current,
|
SlideOffset {
|
||||||
y: y.current,
|
x: x.current,
|
||||||
}
|
y: y.current,
|
||||||
|
},
|
||||||
|
SlideOffset {
|
||||||
|
x: x.velocity,
|
||||||
|
y: y.velocity,
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn moving(&self) -> bool {
|
fn moving(&self) -> bool {
|
||||||
@@ -1105,6 +1115,27 @@ impl ClientData for ClientState {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn workspace_reversal_keeps_position_and_velocity() {
|
fn workspace_reversal_keeps_position_and_velocity() {
|
||||||
let from = WorkspaceId(1);
|
let from = WorkspaceId(1);
|
||||||
|
|||||||
@@ -200,16 +200,25 @@ impl Ferese {
|
|||||||
if self.animations_enabled {
|
if self.animations_enabled {
|
||||||
active_animation |= focus.advance(
|
active_animation |= focus.advance(
|
||||||
if changed { Duration::ZERO } else { delta },
|
if changed { Duration::ZERO } else { delta },
|
||||||
SpringConfig {
|
crate::presentation::emphasis_spring(self.spring_config),
|
||||||
position_tolerance: 0.001,
|
|
||||||
velocity_tolerance: 0.001,
|
|
||||||
..self.spring_config
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
focus.snap();
|
focus.snap();
|
||||||
}
|
}
|
||||||
dim_changed |= previous != focus.current;
|
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() {
|
for window in self.space.elements() {
|
||||||
@@ -255,7 +264,7 @@ impl Ferese {
|
|||||||
let active =
|
let active =
|
||||||
self.animations_enabled && !self.session_lock.active() && window.advance(delta, self.spring_config);
|
self.animations_enabled && !self.session_lock.active() && window.advance(delta, self.spring_config);
|
||||||
if !active {
|
if !active {
|
||||||
finished.push(window.id);
|
finished.push(window.presentation.id);
|
||||||
}
|
}
|
||||||
active_animation |= active;
|
active_animation |= active;
|
||||||
active
|
active
|
||||||
@@ -608,6 +617,7 @@ fn record_needs_tick(
|
|||||||
if record.resize.is_some()
|
if record.resize.is_some()
|
||||||
|| record.opening.is_some()
|
|| record.opening.is_some()
|
||||||
|| record.focus.as_ref().is_none_or(|motion| motion.needs_update(focus))
|
|| 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;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -644,6 +654,7 @@ mod tests {
|
|||||||
WindowRecord {
|
WindowRecord {
|
||||||
geometry: Some(geometry),
|
geometry: Some(geometry),
|
||||||
focus: Some(AnimatedValue::new(1.0)),
|
focus: Some(AnimatedValue::new(1.0)),
|
||||||
|
shadow: Some(AnimatedValue::new(1.0)),
|
||||||
dimming: Some(DimAnimation::new(0.0)),
|
dimming: Some(DimAnimation::new(0.0)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
@@ -664,6 +675,20 @@ mod tests {
|
|||||||
assert!(!record_needs_tick(&record, 1.0, 0.15, || false));
|
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]
|
#[test]
|
||||||
fn geometry_motion_client_waits_and_resize_barriers_cannot_be_skipped() {
|
fn geometry_motion_client_waits_and_resize_barriers_cannot_be_skipped() {
|
||||||
let mut record = settled();
|
let mut record = settled();
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use ferese_animation::AnimatedRect;
|
|
||||||
use smithay::backend::renderer::Texture;
|
|
||||||
use smithay::wayland::compositor::{BufferAssignment, SurfaceAttributes};
|
use smithay::wayland::compositor::{BufferAssignment, SurfaceAttributes};
|
||||||
|
|
||||||
impl Ferese {
|
impl Ferese {
|
||||||
@@ -31,7 +29,7 @@ impl Ferese {
|
|||||||
let Some(&id) = self.windows.ids().get(window) else {
|
let Some(&id) = self.windows.ids().get(window) else {
|
||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
let Some(output) = self
|
let Some(output) = self
|
||||||
@@ -45,36 +43,19 @@ impl Ferese {
|
|||||||
let Some(output_id) = self.output_id(&output) else {
|
let Some(output_id) = self.output_id(&output) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(rect) = self.presented_window_rect(id) else {
|
let Some(mut presentation) = self.current_window_presentation(id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let record = self.windows.record(id).unwrap();
|
let record = self.windows.record(id).unwrap();
|
||||||
let geometry = record.geometry.unwrap();
|
let geometry = record.geometry.unwrap();
|
||||||
let decorations = geometry.decorations.clamp(0.0, 1.0);
|
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 dim = record.dimming.as_ref().map_or(0.0, |dim| dim.current);
|
||||||
let mut opacity = record.opening.unwrap_or_else(|| AnimatedValue::new(1.0));
|
let handoff_size = (!presentation.scale_content).then(|| {
|
||||||
opacity.set_target(0.0);
|
presentation
|
||||||
let mut bounds = AnimatedRect::new(rect);
|
.native_size
|
||||||
bounds.velocity = self.overview.presented_velocity(id).unwrap_or(geometry.visual.velocity);
|
.unwrap_or_else(|| ClientSize::from_rect(presentation.bounds.current))
|
||||||
if !self.overview.is_presenting()
|
});
|
||||||
&& let Some(slide) = self.workspace_slides.get(&output_id)
|
presentation.close();
|
||||||
&& 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 mut source_geometry = window.geometry();
|
let mut source_geometry = window.geometry();
|
||||||
if !self.overview.is_presenting() {
|
if !self.overview.is_presenting() {
|
||||||
let size = ClientSize::from_rect(geometry.visual.current);
|
let size = ClientSize::from_rect(geometry.visual.current);
|
||||||
@@ -98,12 +79,7 @@ impl Ferese {
|
|||||||
.and_then(|index| order.get(index + 1))
|
.and_then(|index| order.get(index + 1))
|
||||||
.copied();
|
.copied();
|
||||||
let used = self.render.snapshots().map(|snapshot| snapshot.bytes()).sum::<usize>()
|
let used = self.render.snapshots().map(|snapshot| snapshot.bytes()).sum::<usize>()
|
||||||
+ self
|
+ self.render.closing.iter().map(|window| window.bytes()).sum::<usize>();
|
||||||
.render
|
|
||||||
.closing
|
|
||||||
.iter()
|
|
||||||
.map(|window| window.snapshot.bytes())
|
|
||||||
.sum::<usize>();
|
|
||||||
let remaining = crate::presentation::SNAPSHOT_BUDGET.saturating_sub(used);
|
let remaining = crate::presentation::SNAPSHOT_BUDGET.saturating_sub(used);
|
||||||
let scale = output.current_scale().fractional_scale();
|
let scale = output.current_scale().fractional_scale();
|
||||||
let result = if let Some(backend) = &self.nested_backend {
|
let result = if let Some(backend) = &self.nested_backend {
|
||||||
@@ -125,7 +101,12 @@ impl Ferese {
|
|||||||
match result {
|
match result {
|
||||||
Ok(Some(snapshot)) => {
|
Ok(Some(snapshot)) => {
|
||||||
tracing::debug!(?id, bytes = snapshot.bytes(), "retained close presentation");
|
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 material = window.toplevel().and_then(|toplevel| {
|
||||||
let surface = toplevel.wl_surface();
|
let surface = toplevel.wl_surface();
|
||||||
let (role, generation) = crate::effects::surface_role(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))
|
&& (client.w < source_geometry.size.w || client.h < source_geometry.size.h))
|
||||||
.then_some(self.theme_settings.surface_base_color.0);
|
.then_some(self.theme_settings.surface_base_color.0);
|
||||||
self.render.closing.push(crate::render::ClosedWindow {
|
self.render.closing.push(crate::render::ClosedWindow {
|
||||||
id,
|
presentation,
|
||||||
output: output_id,
|
output: output_id,
|
||||||
below,
|
below,
|
||||||
bounds,
|
|
||||||
opacity,
|
|
||||||
snapshot,
|
snapshot,
|
||||||
source: Rectangle::from_size((f64::from(size.w), f64::from(size.h)).into()),
|
handoff,
|
||||||
radius,
|
radius,
|
||||||
shape: crate::render::window_corner_shape(window),
|
shape: crate::render::window_corner_shape(window),
|
||||||
focus,
|
|
||||||
decorations,
|
decorations,
|
||||||
dim,
|
dim,
|
||||||
fill,
|
fill,
|
||||||
|
|||||||
@@ -12,15 +12,97 @@ pub(crate) struct FrameScene {
|
|||||||
|
|
||||||
pub(crate) struct WindowFrame {
|
pub(crate) struct WindowFrame {
|
||||||
pub geometry: WindowGeometry,
|
pub geometry: WindowGeometry,
|
||||||
pub rect: Rect,
|
pub presentation: crate::presentation::WindowPresentation,
|
||||||
pub scale_content: bool,
|
|
||||||
pub native_size: Option<ClientSize>,
|
|
||||||
pub alpha: f32,
|
|
||||||
pub focus: f64,
|
|
||||||
pub dim: f64,
|
pub dim: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ferese {
|
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>(
|
fn output_window_records<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
output: &'a Output,
|
output: &'a Output,
|
||||||
@@ -58,6 +140,10 @@ impl Ferese {
|
|||||||
.focus
|
.focus
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|focus| focus.needs_update(if selected == Some(id) { 1.0 } else { 0.0 }))
|
.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| {
|
|| record.dimming.as_ref().is_some_and(|dim| {
|
||||||
dim.needs_update(crate::dimming::target(
|
dim.needs_update(crate::dimming::target(
|
||||||
self.inactive_dim,
|
self.inactive_dim,
|
||||||
@@ -105,6 +191,7 @@ impl Ferese {
|
|||||||
|| record.opening.is_some()
|
|| record.opening.is_some()
|
||||||
|| self.render.snapshot(&id).is_some()
|
|| self.render.snapshot(&id).is_some()
|
||||||
|| record.focus.as_ref().is_some_and(|focus| focus.is_animating())
|
|| 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())
|
|| record.dimming.as_ref().is_some_and(|dim| dim.is_animating())
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
@@ -178,74 +265,22 @@ impl Ferese {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut rect = overview.presented_rect(id, geometry.visual.current);
|
let offset = self
|
||||||
if !overview.is_presenting() {
|
.workspaces
|
||||||
let (x, y) = self
|
.workspace_for_window(id)
|
||||||
.workspaces
|
.and_then(|workspace| slide_offsets.get(&workspace).copied())
|
||||||
.workspace_for_window(id)
|
.unwrap_or_default();
|
||||||
.and_then(|workspace| slide_offsets.get(&workspace).copied())
|
let presentation = self.compose_window_presentation(id, geometry, &overview, offset, delta);
|
||||||
.unwrap_or_default();
|
let mut dim = record.dimming.clone();
|
||||||
rect.x += x;
|
if let Some(dim) = &mut dim {
|
||||||
rect.y += y;
|
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(
|
windows.insert(
|
||||||
id,
|
id,
|
||||||
WindowFrame {
|
WindowFrame {
|
||||||
geometry,
|
geometry,
|
||||||
rect,
|
presentation,
|
||||||
scale_content: overview.is_presenting(),
|
dim: dim.map_or(0.0, |dim| dim.current),
|
||||||
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),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -439,7 +474,9 @@ mod tests {
|
|||||||
assert_eq!(offsets[&to].0, 1440.0);
|
assert_eq!(offsets[&to].0, 1440.0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
state.sample_frame(&output, Duration::from_millis(8)).windows[&id]
|
state.sample_frame(&output, Duration::from_millis(8)).windows[&id]
|
||||||
.rect
|
.presentation
|
||||||
|
.bounds
|
||||||
|
.current
|
||||||
.x,
|
.x,
|
||||||
state.sample_frame(&output, Duration::from_millis(8)).windows[&id]
|
state.sample_frame(&output, Duration::from_millis(8)).windows[&id]
|
||||||
.geometry
|
.geometry
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub(crate) struct WindowRecord {
|
|||||||
pub metadata_revision: u64,
|
pub metadata_revision: u64,
|
||||||
pub resize: Option<ResizeTransaction>,
|
pub resize: Option<ResizeTransaction>,
|
||||||
pub focus: Option<AnimatedValue>,
|
pub focus: Option<AnimatedValue>,
|
||||||
|
pub shadow: Option<AnimatedValue>,
|
||||||
pub dimming: Option<crate::dimming::DimAnimation>,
|
pub dimming: Option<crate::dimming::DimAnimation>,
|
||||||
pub opening: Option<AnimatedValue>,
|
pub opening: Option<AnimatedValue>,
|
||||||
pub mapped_once: bool,
|
pub mapped_once: bool,
|
||||||
|
|||||||
@@ -495,12 +495,7 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let used: usize = self.render.snapshots().map(|snapshot| snapshot.bytes()).sum::<usize>()
|
let used: usize = self.render.snapshots().map(|snapshot| snapshot.bytes()).sum::<usize>()
|
||||||
+ self
|
+ self.render.closing.iter().map(|window| window.bytes()).sum::<usize>();
|
||||||
.render
|
|
||||||
.closing
|
|
||||||
.iter()
|
|
||||||
.map(|window| window.snapshot.bytes())
|
|
||||||
.sum::<usize>();
|
|
||||||
let remaining = crate::presentation::SNAPSHOT_BUDGET.saturating_sub(used);
|
let remaining = crate::presentation::SNAPSHOT_BUDGET.saturating_sub(used);
|
||||||
let result = if let Some(backend) = &self.nested_backend {
|
let result = if let Some(backend) = &self.nested_backend {
|
||||||
// Commit dispatch does not run inside the Winit event callback;
|
// Commit dispatch does not run inside the Winit event callback;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use crate::{CrossingPolicy, SpringConfig};
|
|||||||
|
|
||||||
/// One configuration contract for compositor and out-of-process shell motion.
|
/// One configuration contract for compositor and out-of-process shell motion.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default, deny_unknown_fields)]
|
||||||
pub struct MotionSettings {
|
pub struct MotionSettings {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub reduced_motion: bool,
|
pub reduced_motion: bool,
|
||||||
@@ -32,21 +32,17 @@ impl MotionSettings {
|
|||||||
if !self.speed.is_finite() || self.speed <= 0.0 {
|
if !self.speed.is_finite() || self.speed <= 0.0 {
|
||||||
return Err("animation speed must be finite and positive".into());
|
return Err("animation speed must be finite and positive".into());
|
||||||
}
|
}
|
||||||
self.spring.resolve(SpringConfig::default())?;
|
self.spring_config()?;
|
||||||
self.viewport_config()?;
|
self.viewport_config()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn spring_config(self) -> Result<SpringConfig, String> {
|
||||||
|
self.spring.resolve(240.0)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn viewport_config(self) -> Result<SpringConfig, String> {
|
pub fn viewport_config(self) -> Result<SpringConfig, String> {
|
||||||
let mut settings = self.viewport_spring;
|
self.viewport_spring.resolve(350.0)
|
||||||
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()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Only small opacity changes should use a timed duration.
|
/// Only small opacity changes should use a timed duration.
|
||||||
@@ -59,73 +55,50 @@ impl MotionSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Deserialize)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default, deny_unknown_fields)]
|
||||||
pub struct SpringSettings {
|
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 duration_ms: Option<f64>,
|
||||||
pub bounce: Option<f64>,
|
pub bounce: f64,
|
||||||
pub overshoot: bool,
|
pub overshoot: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SpringSettings {
|
impl SpringSettings {
|
||||||
pub fn resolve(self, base: SpringConfig) -> Result<SpringConfig, String> {
|
fn resolve(self, default_duration_ms: f64) -> Result<SpringConfig, String> {
|
||||||
let positive = |value: f64| value.is_finite() && value > 0.0;
|
let duration = self.duration_ms.unwrap_or(default_duration_ms);
|
||||||
let mut config = SpringConfig {
|
if !duration.is_finite() || duration <= 0.0 {
|
||||||
mass: self.mass.unwrap_or(base.mass),
|
return Err("spring duration-ms must be finite and positive".into());
|
||||||
stiffness: self.stiffness.unwrap_or(base.stiffness),
|
}
|
||||||
damping: self.damping.unwrap_or(base.damping),
|
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 {
|
crossing: if self.overshoot {
|
||||||
CrossingPolicy::AllowOvershoot
|
CrossingPolicy::AllowOvershoot
|
||||||
} else {
|
} else {
|
||||||
CrossingPolicy::NoCrossing
|
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());
|
return Err("spring duration-ms/bounce produce out-of-range coefficients".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());
|
|
||||||
}
|
}
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
@@ -134,17 +107,76 @@ impl SpringSettings {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[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 [
|
for spring in [
|
||||||
SpringSettings {
|
SpringSettings {
|
||||||
damping: Some(0.0),
|
bounce: 1.0,
|
||||||
overshoot: true,
|
overshoot: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
SpringSettings {
|
SpringSettings {
|
||||||
damping_ratio: Some(0.0),
|
bounce: -1.0,
|
||||||
overshoot: true,
|
..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()
|
..Default::default()
|
||||||
},
|
},
|
||||||
] {
|
] {
|
||||||
@@ -165,64 +197,28 @@ mod tests {
|
|||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert!(
|
|
||||||
SpringSettings {
|
|
||||||
overshoot: true,
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
.resolve(SpringConfig {
|
|
||||||
damping: 0.0,
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn undamped_motion_needs_the_crossing_clamp_to_settle() {
|
fn accepted_bounce_retargets_and_settles() {
|
||||||
use crate::AnimatedValue;
|
let spring = SpringSettings {
|
||||||
let clamped = SpringSettings {
|
bounce: 0.3,
|
||||||
damping: Some(0.0),
|
overshoot: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.resolve(SpringConfig::default())
|
.resolve(240.0)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut closing = AnimatedValue {
|
let mut motion = crate::AnimatedValue {
|
||||||
current: 1.0,
|
current: 0.0,
|
||||||
target: 0.0,
|
target: 1.0,
|
||||||
velocity: 0.0,
|
velocity: 0.0,
|
||||||
};
|
};
|
||||||
let mut free = closing;
|
motion.advance(std::time::Duration::from_millis(50), spring);
|
||||||
for _ in 0..3600 {
|
let before = (motion.current, motion.velocity);
|
||||||
let dt = std::time::Duration::from_secs_f64(1.0 / 60.0);
|
motion.set_target(0.0);
|
||||||
closing.advance(dt, clamped);
|
assert_eq!((motion.current, motion.velocity), before);
|
||||||
free.advance(
|
motion.advance(std::time::Duration::from_secs(3), spring);
|
||||||
dt,
|
assert!(!motion.is_animating());
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -248,51 +244,9 @@ mod tests {
|
|||||||
.is_zero()
|
.is_zero()
|
||||||
);
|
);
|
||||||
assert!(MotionSettings { speed: 0.0, ..slow }.validate().is_err());
|
assert!(MotionSettings { speed: 0.0, ..slow }.validate().is_err());
|
||||||
}
|
assert_eq!(
|
||||||
#[test]
|
slow.spring_config().unwrap(),
|
||||||
fn perceptual_conversion_and_legacy_defaults() {
|
MotionSettings::default().spring_config().unwrap()
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+55
-47
@@ -126,15 +126,15 @@ animations {
|
|||||||
speed 1.0
|
speed 1.0
|
||||||
|
|
||||||
spring {
|
spring {
|
||||||
mass 1.0
|
duration-ms 240.0
|
||||||
stiffness 700.0
|
bounce 0.0
|
||||||
damping 53.0
|
overshoot #false
|
||||||
}
|
}
|
||||||
|
|
||||||
viewport-spring {
|
viewport-spring {
|
||||||
mass 1.0
|
duration-ms 350.0
|
||||||
stiffness 320.0
|
bounce 0.0
|
||||||
damping-ratio 1.0
|
overshoot #false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -147,25 +147,21 @@ You can omit either spring block to keep its defaults.
|
|||||||
| `animations.enabled` | boolean | `true` | Enable motion |
|
| `animations.enabled` | boolean | `true` | Enable motion |
|
||||||
| `animations.reduced-motion` | boolean | `false` | Disable animated motion |
|
| `animations.reduced-motion` | boolean | `false` | Disable animated motion |
|
||||||
| `animations.speed` | number > 0 | `1` | Higher is faster; `0.75` is slower |
|
| `animations.speed` | number > 0 | `1` | Higher is faster; `0.75` is slower |
|
||||||
| `animations.spring.mass` | number > 0 | `1` | Window-motion spring mass |
|
| `animations.spring.duration-ms` | number > 0 | `240` | Window and shell spring response |
|
||||||
| `animations.spring.stiffness` | number > 0 | `700` | Spring stiffness |
|
| `animations.viewport-spring.duration-ms` | number > 0 | `350` | Scrolling and workspace spring response |
|
||||||
| `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 |
|
|
||||||
|
|
||||||
Both `spring` and `viewport-spring` also accept these keys:
|
Both `spring` and `viewport-spring` accept these keys:
|
||||||
|
|
||||||
| Key | Type | Default | Meaning |
|
| 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 | `240` or `350`, as above | Larger values give a slower response; not a completion deadline |
|
||||||
| `duration-ms` | number > 0 | Unset | Response parameter instead of stiffness/damping; not a completion deadline |
|
| `bounce` | number strictly between −1 and 1 | `0` | Zero is critical damping; positive values require overshoot |
|
||||||
| `bounce` | number strictly between −1 and 1 | `0` with `duration-ms` | Requires `duration-ms`; positive values also require overshoot |
|
| `overshoot` | boolean | `false` | Allow this spring to cross its target |
|
||||||
| `overshoot` | boolean | `false` | Allow target crossings for this spring; requires positive damping |
|
|
||||||
|
|
||||||
`mass`, `stiffness` and `damping` are accepted in both blocks. The viewport's
|
Use duration and bounce to configure spring physics. `mass`, `stiffness`,
|
||||||
default damping ratio tracks changes to mass and stiffness unless you supply
|
`damping` and `damping-ratio` are internal solver parameters, not configuration
|
||||||
`damping`, `damping-ratio` or `duration-ms` explicitly.
|
keys. Removed or misspelled animation keys are rejected. Invalid edits leave
|
||||||
|
the last accepted configuration active.
|
||||||
|
|
||||||
### Speed and reduced motion
|
### 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
|
`speed 0` is invalid. These settings control Ferese's animations, not animations
|
||||||
inside other applications.
|
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
|
Overview can reverse while moving, and workspace changes slide in their navigation
|
||||||
direction. Workspace and scrolling gestures carry their release velocity into
|
direction. Workspace and scrolling gestures carry their release velocity into
|
||||||
the settling spring. Scrolling gestures resist at viewport limits and spring back.
|
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
|
to unmap, then animates its retained image. Input and application close requests
|
||||||
remain responsive while the animations finish.
|
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 tuning
|
||||||
|
|
||||||
`spring` controls window position and size motion, fullscreen/maximize zoom and
|
`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
|
compositor and shell. Small opacity changes (hover, dimming, theme fades) may
|
||||||
still use timed transitions.
|
still use timed transitions.
|
||||||
|
|
||||||
Spring settling time depends on distance, velocity and tolerances. In either
|
Focus emphasis and shadows have separate spring responses. Emphasis uses 80% of
|
||||||
spring block, you can use duration and bounce instead of stiffness and damping:
|
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
|
```kdl
|
||||||
animations {
|
animations {
|
||||||
spring {
|
spring {
|
||||||
duration-ms 300.0
|
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
|
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`
|
requires `overshoot #true` in that spring block. Negative bounce adds overdamping.
|
||||||
with `stiffness`, `damping` or `damping-ratio`; existing physical settings keep
|
Without overshoot, Ferese stops a spring at its first target crossing. Opacity
|
||||||
working. This conversion does not guarantee the same perceptual duration or
|
remains bounded even when geometry can overshoot. Overshoot is never enabled globally.
|
||||||
settling time as Apple's springs.
|
|
||||||
|
|
||||||
With the other parameters unchanged, higher stiffness makes motion faster and
|
For the solver, mass is fixed at `1`. With `T = duration-ms / 1000`, Continuum uses
|
||||||
higher mass makes it slower. Damping controls how quickly velocity dies away.
|
`omega = 2*pi/T`, `stiffness = omega^2` and `damping = 2*omega*zeta`.
|
||||||
The default window spring is slightly overdamped, close to critical damping;
|
`zeta = 1-bounce` for nonnegative bounce, otherwise `zeta = 1/(1+bounce)`.
|
||||||
the viewport spring is critically damped.
|
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`
|
Duration and speed must be finite and greater than zero. Bounce must be finite.
|
||||||
are underdamped and values above `1` are overdamped. Both spring blocks accept a
|
Values that overflow or underflow the derived coefficients are rejected. The
|
||||||
damping ratio or a damping coefficient, but not both; critical damping is
|
allowed bounce range keeps damping positive, so undamped oscillation cannot
|
||||||
`damping = 2 * sqrt(stiffness * mass)`. Ferese stops spring animations
|
prevent popup cleanup.
|
||||||
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.
|
|
||||||
|
|
||||||
## Appearance
|
## Appearance
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,16 @@ animations {
|
|||||||
enabled #true
|
enabled #true
|
||||||
reduced-motion #false
|
reduced-motion #false
|
||||||
speed 0.9
|
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 {
|
appearance {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
DEALINGS IN THE SOFTWARE.
|
DEALINGS IN THE SOFTWARE.
|
||||||
</copyright>
|
</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">
|
<description summary="assign semantic material roles to surfaces">
|
||||||
This private global is visible only to clients granted the Ferese effects
|
This private global is visible only to clients granted the Ferese effects
|
||||||
capability. Roles describe compositor-owned appearance policy and do not
|
capability. Roles describe compositor-owned appearance policy and do not
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
</enum>
|
</enum>
|
||||||
</interface>
|
</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">
|
<description summary="semantic material role for one surface">
|
||||||
The role affects only compositor rendering. It does not change surface
|
The role affects only compositor rendering. It does not change surface
|
||||||
geometry, input, focus, stacking, or the surface's Wayland role.
|
geometry, input, focus, stacking, or the surface's Wayland role.
|
||||||
@@ -98,5 +98,14 @@
|
|||||||
</description>
|
</description>
|
||||||
<arg name="opacity" type="uint"/>
|
<arg name="opacity" type="uint"/>
|
||||||
</request>
|
</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>
|
</interface>
|
||||||
</protocol>
|
</protocol>
|
||||||
@@ -72,8 +72,17 @@ class OverviewDamageTest(unittest.TestCase):
|
|||||||
# The first report includes time before this call. The second
|
# The first report includes time before this call. The second
|
||||||
# covers a full five seconds of the requested stationary scene.
|
# covers a full five seconds of the requested stationary scene.
|
||||||
count = len(reports())
|
count = len(reports())
|
||||||
wait_for(lambda: len(reports()) >= count + 2)
|
for attempt in range(3):
|
||||||
return reports()[-1]
|
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):
|
def assert_quiet(label, report):
|
||||||
print(label + ":", report, flush=True)
|
print(label + ":", report, flush=True)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -579,12 +579,12 @@ pub(crate) fn config_path() -> Option<PathBuf> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_undamped_overshoot_before_publication() {
|
fn rejects_removed_physics_keys_before_publication() {
|
||||||
for property in ["spring", "viewport-spring"] {
|
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 source = format!("animations {{ {property} {{ {coefficient} 0; overshoot #true; }}; }}");
|
||||||
let error = parse_test_source(&source).unwrap_err().to_string();
|
let error = parse_test_source(&source).unwrap_err().to_string();
|
||||||
assert!(error.contains("overshoot requires positive damping"), "{error}");
|
assert!(error.contains("unknown field"), "{error}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,15 +355,6 @@ impl cosmic::Application for FereseShell {
|
|||||||
control.subscription().map(|_| Message::ControlReady)
|
control.subscription().map(|_| Message::ControlReady)
|
||||||
}),
|
}),
|
||||||
ferese_theme_client::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))),
|
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.subscription().map(Message::NotificationEvent),
|
||||||
self.notifications
|
self.notifications
|
||||||
.tick_subscription()
|
.tick_subscription()
|
||||||
@@ -399,45 +390,10 @@ impl cosmic::Application for FereseShell {
|
|||||||
} else {
|
} else {
|
||||||
Subscription::none()
|
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> {
|
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(®ions) {
|
|
||||||
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(®ions) {
|
|
||||||
eprintln!("ferese-shell: could not update notification materials: {error}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.update_power_materials();
|
|
||||||
|
|
||||||
match message {
|
match message {
|
||||||
Message::ThemeChanged(snapshot) => {
|
Message::ThemeChanged(snapshot) => {
|
||||||
let mut config = self.config.clone();
|
let mut config = self.config.clone();
|
||||||
|
|||||||
@@ -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::advanced::{Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget};
|
||||||
use cosmic::iced::{Event, Length, Rectangle, Size, Transformation, Vector};
|
use cosmic::iced::{Event, Length, Rectangle, Size, Transformation, Vector};
|
||||||
use cosmic::{Element, Theme};
|
use cosmic::{Element, Theme};
|
||||||
@@ -79,11 +82,7 @@ impl PopupMotion {
|
|||||||
value.snap();
|
value.snap();
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
let spring = self
|
let spring = self.settings.spring_config().expect("validated motion settings");
|
||||||
.settings
|
|
||||||
.spring
|
|
||||||
.resolve(SpringConfig::default())
|
|
||||||
.expect("validated motion settings");
|
|
||||||
value.advance(
|
value.advance(
|
||||||
now.saturating_duration_since(started).mul_f64(self.settings.speed),
|
now.saturating_duration_since(started).mul_f64(self.settings.speed),
|
||||||
SpringConfig {
|
SpringConfig {
|
||||||
@@ -105,8 +104,17 @@ impl PopupMotion {
|
|||||||
pub(crate) fn closing(&self) -> bool {
|
pub(crate) fn closing(&self) -> bool {
|
||||||
self.value.target == 0.0
|
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 {
|
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
|
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>,
|
shell: &mut Shell<'_, M>,
|
||||||
viewport: &Rectangle,
|
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 hovered = cursor.is_over(layout.bounds()) && cursor.is_over(*viewport);
|
||||||
let state = tree.state.downcast_mut::<HoverState>();
|
let state = tree.state.downcast_mut::<HoverState>();
|
||||||
if state.advance(if hovered { 1.0 } else { 0.0 }, now, self.duration) {
|
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.progress.set(state.current);
|
||||||
self.content.as_widget_mut().update(
|
self.content.as_widget_mut().update(
|
||||||
@@ -494,10 +505,12 @@ impl widget::Operation for CollectRegions {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn zero_damping_with_crossing_clamp_finishes_popup_close() {
|
fn duration_bounce_spring_finishes_popup_close() {
|
||||||
let settings = super::Settings {
|
let settings = super::Settings {
|
||||||
spring: ferese_animation::SpringSettings {
|
spring: ferese_animation::SpringSettings {
|
||||||
damping: Some(0.0),
|
duration_ms: Some(240.0),
|
||||||
|
bounce: 0.3,
|
||||||
|
overshoot: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -148,6 +148,9 @@ impl FereseShell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn sync_notification_surface(&mut self) -> Task<Message> {
|
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 {
|
if self.notifications.ready {
|
||||||
self.status.notifications = Some(status::Notifications {
|
self.status.notifications = Some(status::Notifications {
|
||||||
count: self.notifications.unread(),
|
count: self.notifications.unread(),
|
||||||
@@ -513,10 +516,36 @@ impl FereseShell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn view_notifications(&self) -> Element<'_, cosmic::Action<Message>> {
|
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 {
|
if self.notifications.history_open {
|
||||||
let palette = self.config.theme;
|
let palette = self.config.theme;
|
||||||
let surface = self.menu.as_ref();
|
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 opacity = 1.0;
|
||||||
let color = move |rgba| color_with_opacity(rgba, opacity);
|
let color = move |rgba| color_with_opacity(rgba, opacity);
|
||||||
let primary = color(palette.text_primary);
|
let primary = color(palette.text_primary);
|
||||||
@@ -751,7 +780,7 @@ impl FereseShell {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
let mut cards = column([]).spacing(8);
|
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));
|
cards = cards.push(self.notification_card(notice, opacity, false, count));
|
||||||
}
|
}
|
||||||
motion::animated(
|
motion::animated(
|
||||||
|
|||||||
@@ -330,22 +330,39 @@ impl Center {
|
|||||||
self.entries.iter().filter(|notice| notice.unread).count() as u32
|
self.entries.iter().filter(|notice| notice.unread).count() as u32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
pub fn visible(&self) -> impl Iterator<Item = (&Notice, f32)> {
|
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| {
|
self.toasts.iter().rev().filter_map(move |toast| {
|
||||||
let notice = self.entries.iter().find(|notice| notice.id == toast.id)?;
|
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
|
self.toasts
|
||||||
.iter()
|
.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)> {
|
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();
|
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) {
|
if let Some(group) = groups.iter_mut().find(|(head, _, _)| head.app == notice.app) {
|
||||||
group.2 += 1;
|
group.2 += 1;
|
||||||
} else {
|
} else {
|
||||||
@@ -429,12 +446,12 @@ impl Center {
|
|||||||
self.command(Command::Close(id, revision, reason));
|
self.command(Command::Close(id, revision, reason));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(toast) = self.toasts.iter_mut().find(|toast| toast.id == id) {
|
if let Some(toast) = self.toasts.iter_mut().find(|toast| toast.id == id)
|
||||||
if toast.closing.is_none() {
|
&& toast.closing.is_none()
|
||||||
let now = Instant::now();
|
{
|
||||||
toast.closing = Some(now);
|
let now = Instant::now();
|
||||||
toast.motion.retarget(0.0, 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<()> {
|
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<> {
|
fn deadline_stream(deadline: &Instant) -> impl cosmic::iced::futures::Stream<Item = ()> + use<> {
|
||||||
let deadline = *deadline;
|
let deadline = *deadline;
|
||||||
cosmic::iced::futures::stream::once(async move {
|
cosmic::iced::futures::stream::once(async move {
|
||||||
@@ -610,7 +623,7 @@ impl Center {
|
|||||||
self.close(id, 1);
|
self.close(id, 1);
|
||||||
}
|
}
|
||||||
self.toasts
|
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();
|
let visible: HashSet<_> = self.toasts.iter().map(|toast| toast.id).collect();
|
||||||
self.entries
|
self.entries
|
||||||
.retain(|notice| !notice.transient || notice.live || visible.contains(¬ice.id));
|
.retain(|notice| !notice.transient || notice.live || visible.contains(¬ice.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]
|
#[test]
|
||||||
fn history_groups_expand_and_clear_without_removing_other_apps() {
|
fn history_groups_expand_and_clear_without_removing_other_apps() {
|
||||||
let (mut center, commands) = fixture();
|
let (mut center, commands) = fixture();
|
||||||
|
|||||||
@@ -131,12 +131,31 @@ impl OpenMenu {
|
|||||||
|
|
||||||
impl FereseShell {
|
impl FereseShell {
|
||||||
fn view_status_menu(&self) -> Element<'_, cosmic::Action<Message>> {
|
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 {
|
let Some(menu) = &self.menu else {
|
||||||
return text("").into();
|
return text("").into();
|
||||||
};
|
};
|
||||||
if menu.kind == Menu::Notifications && self.notifications.ready {
|
if menu.kind == Menu::Notifications && self.notifications.ready {
|
||||||
return cosmic::widget::autosize::autosize(
|
return cosmic::widget::autosize::autosize(
|
||||||
self.view_notifications(),
|
self.view_notifications_at(now),
|
||||||
cosmic::iced::advanced::widget::Id::new("ferese-notification-center"),
|
cosmic::iced::advanced::widget::Id::new("ferese-notification-center"),
|
||||||
)
|
)
|
||||||
.limits(
|
.limits(
|
||||||
@@ -229,7 +248,7 @@ impl FereseShell {
|
|||||||
cosmic::widget::autosize::autosize(
|
cosmic::widget::autosize::autosize(
|
||||||
super::motion::animated(
|
super::motion::animated(
|
||||||
panel.into(),
|
panel.into(),
|
||||||
menu.progress(),
|
menu.motion.progress_at(now),
|
||||||
menu.regions.clone(),
|
menu.regions.clone(),
|
||||||
theme.material_radius,
|
theme.material_radius,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -481,6 +481,7 @@ pub(super) struct EffectsBinding {
|
|||||||
_queue: EventQueue<EffectsState>,
|
_queue: EventQueue<EffectsState>,
|
||||||
regions: std::cell::RefCell<Option<Vec<[f32; 5]>>>,
|
regions: std::cell::RefCell<Option<Vec<[f32; 5]>>>,
|
||||||
opacity: std::cell::Cell<Option<u32>>,
|
opacity: std::cell::Cell<Option<u32>>,
|
||||||
|
region_opacities: std::cell::RefCell<Vec<u32>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EffectsBinding {
|
impl EffectsBinding {
|
||||||
@@ -504,7 +505,7 @@ impl EffectsBinding {
|
|||||||
let connection = Connection::from_backend(backend);
|
let connection = Connection::from_backend(backend);
|
||||||
let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?;
|
let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?;
|
||||||
let qh = queue.handle();
|
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 effects = manager.get_surface_effects(surface, &qh, ());
|
||||||
|
|
||||||
let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
|
let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
|
||||||
@@ -523,9 +524,28 @@ impl EffectsBinding {
|
|||||||
_queue: queue,
|
_queue: queue,
|
||||||
regions: Default::default(),
|
regions: Default::default(),
|
||||||
opacity: std::cell::Cell::new(Some(opacity)),
|
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>> {
|
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)
|
self.set_material_regions(regions, ferese_surface_effects_v1::Role::Popover)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -468,7 +468,32 @@ impl FereseShell {
|
|||||||
let Some(surface) = modal.surfaces.iter().find(|surface| surface.id == id) else {
|
let Some(surface) = modal.surfaces.iter().find(|surface| surface.id == id) else {
|
||||||
return text("").into();
|
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 content: Element<'_, cosmic::Action<Message>> = if surface.primary {
|
||||||
let theme = self.config.theme;
|
let theme = self.config.theme;
|
||||||
let material = surface.effects.is_some();
|
let material = surface.effects.is_some();
|
||||||
|
|||||||
Reference in new issue
Block a user