Implement Continuum compositor and shell motion

This commit is contained in:
Samuel Onoja committed 2026-10-03 03:19:11 +01:00
1 parent 259f7e93b6
commit f8bc17e913
42 files changed
+1873 -647

No files matched your search

Generated
+2
View File
@@ -1675,6 +1675,7 @@ name = "ferese-animation"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ferese-layout", "ferese-layout",
"serde",
] ]
[[package]] [[package]]
@@ -1784,6 +1785,7 @@ dependencies = [
name = "ferese-shell" name = "ferese-shell"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ferese-animation",
"ferese-config", "ferese-config",
"ferese-ipc", "ferese-ipc",
"ferese-protocols", "ferese-protocols",
+2 -1
View File
@@ -22,6 +22,7 @@ Ferese combines a window manager and desktop shell with one configuration.
- Scrolling columns with window grouping, tree tiling and floating windows that remember their size and placement - Scrolling columns with window grouping, tree tiling and floating windows that remember their size and placement
- Workspace overview and recent-window switching - Workspace overview and recent-window switching
- Remappable keyboard shortcuts and touchpad gestures - Remappable keyboard shortcuts and touchpad gestures
- [Continuum motion](docs/configuration.md#animations): interruptible springs, gesture momentum and motion settings shared by the compositor and shell
- Multiple monitors with fractional scaling and automatic refresh-rate switching on low battery - Multiple monitors with fractional scaling and automatic refresh-rate switching on low battery
- Themes, wallpapers and blur that update live, with Light/Dark/Auto modes and custom KDL themes - Themes, wallpapers and blur that update live, with Light/Dark/Auto modes and custom KDL themes
- Squircle window corners with matching borders and shadows - Squircle window corners with matching borders and shadows
@@ -40,7 +41,7 @@ compares current upstream features and names separate tools where they are neede
| Tiling layouts | Scrolling columns and tree tiling; switch per workspace | [Scrolling, dwindle, master and monocle; per-workspace layouts](https://github.com/hyprwm/Hyprland#features) | Scrolling columns | | Tiling layouts | Scrolling columns and tree tiling; switch per workspace | [Scrolling, dwindle, master and monocle; per-workspace layouts](https://github.com/hyprwm/Hyprland#features) | Scrolling columns |
| Floating windows | Built in | Built in | Built in | | Floating windows | Built in | Built in | Built in |
| Workspace overview | Built in | Plugins such as [HyprExpo](https://github.com/sandwichfarm/hyprexpo) | Built in | | Workspace overview | Built in | Plugins such as [HyprExpo](https://github.com/sandwichfarm/hyprexpo) | Built in |
| Animations | Spring and easing animations | Bézier and spring curves | [Spring and easing animations; custom shaders](https://niri-wm.github.io/niri/Configuration:-Animations.html) | | Animations | [Continuum: interruptible springs, gesture momentum; speed and reduced-motion settings shared by the compositor and shell](docs/configuration.md#animations) | Bézier and spring curves | [Spring and easing animations; custom shaders](https://niri-wm.github.io/niri/Configuration:-Animations.html) |
| Live configuration reload | Yes | Yes | Yes | | Live configuration reload | Yes | Yes | Yes |
| Monitor mirroring | Built in | [Built in](https://wiki.hypr.land/configuring/core/monitors/) | Separate tool, such as [wl-mirror](https://niri-wm.github.io/niri/Screencasting.html#screen-mirroring) | | Monitor mirroring | Built in | [Built in](https://wiki.hypr.land/configuring/core/monitors/) | Separate tool, such as [wl-mirror](https://niri-wm.github.io/niri/Screencasting.html#screen-mirroring) |
| Desktop controls | Included Settings and Control Center | [Separate utilities](https://wiki.hypr.land/hypr-ecosystem/) or a desktop shell | [Separate desktop shell or tools](https://niri-wm.github.io/niri/Integrating-niri.html#desktop-components) | | Desktop controls | Included Settings and Control Center | [Separate utilities](https://wiki.hypr.land/hypr-ecosystem/) or a desktop shell | [Separate desktop shell or tools](https://niri-wm.github.io/niri/Integrating-niri.html#desktop-components) |
-11
View File
@@ -1058,17 +1058,6 @@ fn cursor_affected_outputs(state: &Ferese) -> Vec<Output> {
.collect() .collect()
} }
#[track_caller]
pub(crate) fn render_window(state: &mut Ferese, window: ferese_layout::WindowId) {
let outputs = state
.space
.outputs()
.filter(|output| state.window_belongs_to_output(window, output))
.cloned()
.collect::<Vec<_>>();
render_on(state, &outputs);
}
#[track_caller] #[track_caller]
pub(crate) fn render_surface( pub(crate) fn render_surface(
state: &mut Ferese, state: &mut Ferese,
+11 -28
View File
@@ -346,34 +346,6 @@ const fn default_repeat_delay() -> i32 {
600 600
} }
const fn default_animation_speed() -> f64 {
1.0
}
const fn default_spring_mass() -> f64 {
1.0
}
const fn default_spring_stiffness() -> f64 {
700.0
}
const fn default_spring_damping() -> f64 {
53.0
}
const fn default_viewport_mass() -> f64 {
1.0
}
const fn default_viewport_stiffness() -> f64 {
320.0
}
const fn default_viewport_damping_ratio() -> f64 {
1.0
}
const fn default_true() -> bool { const fn default_true() -> bool {
true true
} }
@@ -384,6 +356,17 @@ pub(crate) fn config_path() -> Option<PathBuf> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn rejects_undamped_overshoot_before_publication() {
for property in ["spring", "viewport-spring"] {
for coefficient in ["damping", "damping-ratio"] {
let source = format!("animations {{ {property} {{ {coefficient} 0; overshoot #true; }}; }}");
let error = Config::parse_source(&source).unwrap().runtime_config().err().unwrap().to_string();
assert!(error.contains("overshoot requires positive damping"), "{error}");
}
}
}
use super::*; use super::*;
#[test] #[test]
+7 -96
View File
@@ -44,7 +44,7 @@ impl Config {
} }
pub fn animations_enabled(&self) -> bool { pub fn animations_enabled(&self) -> bool {
self.animations.enabled && !self.animations.reduced_motion self.animations.motion_enabled()
} }
pub fn animation_speed(&self) -> Result<f64, ConfigError> { pub fn animation_speed(&self) -> Result<f64, ConfigError> {
@@ -52,39 +52,14 @@ impl Config {
} }
pub fn spring_config(&self) -> Result<SpringConfig, ConfigError> { pub fn spring_config(&self) -> Result<SpringConfig, ConfigError> {
let mass = positive_animation_value(self.animations.spring.mass, "spring.mass")?; self.animations
let stiffness = positive_animation_value(self.animations.spring.stiffness, "spring.stiffness")?; .spring
let damping = self.animations.spring.damping; .resolve(SpringConfig::default())
if !damping.is_finite() || damping < 0.0 { .map_err(ConfigError::Binding)
return Err(ConfigError::AnimationValue {
field: "spring.damping",
value: damping,
});
}
Ok(SpringConfig {
mass,
stiffness,
damping,
..SpringConfig::default()
})
} }
pub fn viewport_spring_config(&self) -> Result<SpringConfig, ConfigError> { pub fn viewport_spring_config(&self) -> Result<SpringConfig, ConfigError> {
let mass = positive_animation_value(self.animations.viewport_spring.mass, "viewport_spring.mass")?; self.animations.viewport_config().map_err(ConfigError::Binding)
let stiffness =
positive_animation_value(self.animations.viewport_spring.stiffness, "viewport_spring.stiffness")?;
let damping_ratio = positive_animation_value(
self.animations.viewport_spring.damping_ratio,
"viewport_spring.damping_ratio",
)?;
Ok(SpringConfig {
mass,
stiffness,
damping: 2.0 * damping_ratio * (stiffness * mass).sqrt(),
..SpringConfig::default()
})
} }
} }
@@ -178,68 +153,4 @@ impl Default for TouchpadConfig {
} }
} }
#[derive(Debug, Deserialize)] pub(super) type AnimationsConfig = ferese_animation::MotionSettings;
pub(super) struct AnimationsConfig {
#[serde(default = "enabled_by_default")]
pub(super) enabled: bool,
#[serde(default)]
pub(super) reduced_motion: bool,
#[serde(default = "default_animation_speed")]
pub(super) speed: f64,
#[serde(default)]
pub(super) spring: SpringSettings,
#[serde(default)]
pub(super) viewport_spring: ViewportSpringSettings,
}
impl Default for AnimationsConfig {
fn default() -> Self {
Self {
enabled: true,
reduced_motion: false,
speed: 1.0,
spring: SpringSettings::default(),
viewport_spring: ViewportSpringSettings::default(),
}
}
}
#[derive(Debug, Deserialize)]
pub(super) struct ViewportSpringSettings {
#[serde(default = "default_viewport_mass")]
pub(super) mass: f64,
#[serde(default = "default_viewport_stiffness")]
pub(super) stiffness: f64,
#[serde(default = "default_viewport_damping_ratio")]
pub(super) damping_ratio: f64,
}
impl Default for ViewportSpringSettings {
fn default() -> Self {
Self {
mass: default_viewport_mass(),
stiffness: default_viewport_stiffness(),
damping_ratio: default_viewport_damping_ratio(),
}
}
}
#[derive(Debug, Deserialize)]
pub(super) struct SpringSettings {
#[serde(default = "default_spring_mass")]
pub(super) mass: f64,
#[serde(default = "default_spring_stiffness")]
pub(super) stiffness: f64,
#[serde(default = "default_spring_damping")]
pub(super) damping: f64,
}
impl Default for SpringSettings {
fn default() -> Self {
Self {
mass: default_spring_mass(),
stiffness: default_spring_stiffness(),
damping: default_spring_damping(),
}
}
}
+110 -3
View File
@@ -1,5 +1,3 @@
//! Navigation previews follow finger motion; actions commit once on release.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[repr(u32)] #[repr(u32)]
pub(crate) enum SwipeDirection { pub(crate) enum SwipeDirection {
@@ -12,10 +10,16 @@ pub(crate) enum SwipeDirection {
#[derive(Default)] #[derive(Default)]
pub(crate) struct Swipe { pub(crate) struct Swipe {
delta: Option<(f64, f64)>, delta: Option<(f64, f64)>,
tracker_x: ferese_animation::gesture::VelocityTracker,
tracker_y: ferese_animation::gesture::VelocityTracker,
pub release_velocity: f64,
pub unbounded_release_velocity: f64,
threshold: f64, threshold: f64,
fingers: u32, fingers: u32,
direction: Option<SwipeDirection>, direction: Option<SwipeDirection>,
preview_started: bool, preview_started: bool,
momentum_navigation: bool,
} }
impl Swipe { impl Swipe {
@@ -25,6 +29,59 @@ impl Swipe {
self.fingers = fingers; self.fingers = fingers;
self.direction = None; self.direction = None;
self.preview_started = false; self.preview_started = false;
self.momentum_navigation = false;
self.tracker_x = Default::default();
self.tracker_y = Default::default();
self.release_velocity = 0.0;
self.unbounded_release_velocity = 0.0;
}
pub fn start_time(&mut self, time: std::time::Duration) {
self.tracker_x.push(time, 0.0);
self.tracker_y.push(time, 0.0);
}
pub fn update_at(&mut self, dx: f64, dy: f64, time: std::time::Duration) {
self.update(dx, dy);
if let Some((x, y)) = self.delta {
self.tracker_x.push(time, x);
self.tracker_y.push(time, y);
}
}
pub fn finish_at(&mut self, cancelled: bool, time: std::time::Duration) -> Option<SwipeDirection> {
let Some(direction) = self.direction else {
return self.finish(cancelled);
};
let v = match direction {
SwipeDirection::Left => -self.tracker_x.velocity(time),
SwipeDirection::Right => self.tracker_x.velocity(time),
SwipeDirection::Up => -self.tracker_y.velocity(time),
SwipeDirection::Down => self.tracker_y.velocity(time),
};
self.release_velocity = if cancelled {
0.0
} else {
v / (2.0 * self.threshold.max(1.0))
};
self.unbounded_release_velocity = self.release_velocity;
if !self.preview_started && !self.momentum_navigation {
return self.finish(cancelled);
}
let Some((_, progress)) = self.preview() else {
self.release_velocity = 0.0;
self.unbounded_release_velocity = 0.0;
self.delta = None;
return None;
};
// The preview is bounded. Its visible velocity is zero when pushing
// farther beyond either endpoint, even if the fingers keep moving.
if (progress == 0.0 && self.release_velocity < 0.0) || (progress == 1.0 && self.release_velocity > 0.0) {
self.release_velocity = 0.0;
}
self.delta = None;
(!cancelled && ferese_animation::gesture::project(progress, self.release_velocity, 0.997) >= 0.5)
.then_some(direction)
} }
pub fn fingers(&self) -> u32 { pub fn fingers(&self) -> u32 {
@@ -49,11 +106,20 @@ impl Swipe {
self.preview_started = true; self.preview_started = true;
} }
pub fn mark_momentum_navigation(&mut self) {
self.momentum_navigation = true;
}
pub fn preview_started(&self) -> bool { pub fn preview_started(&self) -> bool {
self.preview_started self.preview_started
} }
pub fn preview(&self) -> Option<(SwipeDirection, f64)> { pub fn preview(&self) -> Option<(SwipeDirection, f64)> {
self.unbounded_preview()
.map(|(direction, progress)| (direction, progress.clamp(0.0, 1.0)))
}
pub fn unbounded_preview(&self) -> Option<(SwipeDirection, f64)> {
let (x, y) = self.delta?; let (x, y) = self.delta?;
if !x.is_finite() || !y.is_finite() { if !x.is_finite() || !y.is_finite() {
return None; return None;
@@ -65,7 +131,7 @@ impl Swipe {
SwipeDirection::Up => -y, SwipeDirection::Up => -y,
SwipeDirection::Down => y, SwipeDirection::Down => y,
}; };
Some((direction, (distance / (self.threshold.max(1.0) * 2.0)).clamp(0.0, 1.0))) Some((direction, distance / (self.threshold.max(1.0) * 2.0)))
} }
pub fn finish(&mut self, cancelled: bool) -> Option<SwipeDirection> { pub fn finish(&mut self, cancelled: bool) -> Option<SwipeDirection> {
@@ -103,6 +169,47 @@ fn dominant_direction(x: f64, y: f64, threshold: f64) -> Option<SwipeDirection>
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn release_uses_momentum_and_a_pause_removes_it() {
use std::time::Duration;
let swipe = || {
let mut swipe = Swipe::default();
swipe.begin(3, false, 80);
swipe.start_time(Duration::ZERO);
swipe.update_at(0.0, -20.0, Duration::from_millis(10));
swipe.update_at(0.0, -20.0, Duration::from_millis(20));
swipe.mark_preview_started();
swipe
};
let mut flick = swipe();
assert_eq!(flick.preview(), Some((SwipeDirection::Up, 0.25)));
assert_eq!(
flick.finish_at(false, Duration::from_millis(20)),
Some(SwipeDirection::Up)
);
assert!((flick.release_velocity - 12.5).abs() < 1e-9);
let mut paused = swipe();
assert_eq!(paused.finish_at(false, Duration::from_millis(80)), None);
assert_eq!(paused.release_velocity, 0.0);
let mut cancelled = swipe();
assert_eq!(cancelled.finish_at(true, Duration::from_millis(20)), None);
assert_eq!(cancelled.release_velocity, 0.0);
}
#[test]
fn reversing_momentum_can_cancel_past_halfway() {
use std::time::Duration;
let mut swipe = Swipe::default();
swipe.begin(3, false, 80);
swipe.start_time(Duration::ZERO);
swipe.update_at(0.0, -120.0, Duration::from_millis(200));
swipe.update_at(0.0, 20.0, Duration::from_millis(210));
swipe.mark_preview_started();
assert_eq!(swipe.preview(), Some((SwipeDirection::Up, 0.625)));
assert_eq!(swipe.finish_at(false, Duration::from_millis(210)), None);
assert!(swipe.release_velocity < 0.0);
}
#[test] #[test]
fn cancellation_and_new_gestures_reset_preview_state() { fn cancellation_and_new_gestures_reset_preview_state() {
let mut swipe = Swipe::default(); let mut swipe = Swipe::default();
+1
View File
@@ -37,6 +37,7 @@ impl CompositorHandler for Ferese {
fn commit(&mut self, surface: &WlSurface) { fn commit(&mut self, surface: &WlSurface) {
self.update_surface_preferences(surface); self.update_surface_preferences(surface);
self.capture_resize_before_commit(surface); self.capture_resize_before_commit(surface);
self.capture_close_before_commit(surface);
on_commit_buffer_handler::<Self>(surface); on_commit_buffer_handler::<Self>(surface);
if !is_sync_subsurface(surface) { if !is_sync_subsurface(surface) {
let mut root = surface.clone(); let mut root = surface.clone();
+13 -3
View File
@@ -170,6 +170,7 @@ impl Ferese {
.any(|binding| binding.swipe_fingers(event.fingers())), .any(|binding| binding.swipe_fingers(event.fingers())),
self.input_settings.touchpad.swipe_threshold, self.input_settings.touchpad.swipe_threshold,
); );
self.swipe.start_time(std::time::Duration::from_micros(event.time()));
if self.swipe.active() { if self.swipe.active() {
self.focus_output_at(pointer.current_location()); self.focus_output_at(pointer.current_location());
} else { } else {
@@ -185,7 +186,11 @@ impl Ferese {
} }
InputEvent::GestureSwipeUpdate { event } => { InputEvent::GestureSwipeUpdate { event } => {
if self.swipe.active() { if self.swipe.active() {
self.swipe.update(event.delta_x(), event.delta_y()); self.swipe.update_at(
event.delta_x(),
event.delta_y(),
std::time::Duration::from_micros(event.time()),
);
if self.swipe_navigation_blocked() { if self.swipe_navigation_blocked() {
self.finish_workspace_swipe(None); self.finish_workspace_swipe(None);
self.finish_focus_swipe(None); self.finish_focus_swipe(None);
@@ -198,10 +203,13 @@ impl Ferese {
{ {
match action { match action {
BindingAction::SwitchRelativeWorkspace(next) => { BindingAction::SwitchRelativeWorkspace(next) => {
self.swipe.mark_momentum_navigation();
self.preview_workspace_swipe(next, direction, progress); self.preview_workspace_swipe(next, direction, progress);
} }
BindingAction::Focus(focus @ (Direction::Left | Direction::Right)) => { BindingAction::Focus(focus @ (Direction::Left | Direction::Right)) => {
self.preview_focus_swipe(focus, direction, progress); self.swipe.mark_momentum_navigation();
let raw = self.swipe.unbounded_preview().map_or(progress, |(_, value)| value);
self.preview_focus_swipe(focus, direction, raw);
} }
_ => {} _ => {}
} }
@@ -220,7 +228,9 @@ impl Ferese {
if self.swipe.active() { if self.swipe.active() {
let fingers = self.swipe.fingers(); let fingers = self.swipe.fingers();
let cancelled = event.cancelled() || self.swipe_navigation_blocked(); let cancelled = event.cancelled() || self.swipe_navigation_blocked();
let direction = self.swipe.finish(cancelled); let direction = self
.swipe
.finish_at(cancelled, std::time::Duration::from_micros(event.time()));
let workspace_handled = self.finish_workspace_swipe(direction); let workspace_handled = self.finish_workspace_swipe(direction);
let focus_handled = self.finish_focus_swipe(direction); let focus_handled = self.finish_focus_swipe(direction);
let handled = workspace_handled || focus_handled || self.swipe.preview_started(); let handled = workspace_handled || focus_handled || self.swipe.preview_started();
+50 -58
View File
@@ -17,7 +17,6 @@ const OVERVIEW_MARGIN: f64 = 48.0;
const OVERVIEW_GAP: f64 = 40.0; const OVERVIEW_GAP: f64 = 40.0;
// Applied after the configured animation speed, in both live and predicted motion. // Applied after the configured animation speed, in both live and predicted motion.
const OVERVIEW_MOTION_SPEED: f64 = 0.6; const OVERVIEW_MOTION_SPEED: f64 = 0.6;
const EXIT_DURATION: f64 = 0.18;
const WORKSPACE_CARD_GAP: f64 = 8.0; const WORKSPACE_CARD_GAP: f64 = 8.0;
const MAX_PREVIEW_SCALE: f64 = 0.82; const MAX_PREVIEW_SCALE: f64 = 0.82;
@@ -117,7 +116,6 @@ pub(crate) struct OverviewMotion {
active: bool, active: bool,
presentations: HashMap<WindowId, AnimatedRect>, presentations: HashMap<WindowId, AnimatedRect>,
opacity: AnimatedValue, opacity: AnimatedValue,
exit_transition: Option<(f64, f64)>,
} }
impl Default for OverviewState { impl Default for OverviewState {
@@ -179,8 +177,7 @@ fn centered_row_start(strip: Rect, count: usize, card_width: f64) -> f64 {
impl OverviewState { impl OverviewState {
pub(crate) fn needs_tick(&self) -> bool { pub(crate) fn needs_tick(&self) -> bool {
self.motion.exit_transition.is_some() self.motion.opacity.current != self.motion.opacity.target
|| self.motion.opacity.current != self.motion.opacity.target
|| self.motion.opacity.velocity != 0.0 || self.motion.opacity.velocity != 0.0
|| self || self
.motion .motion
@@ -366,25 +363,22 @@ impl OverviewState {
self.motion.presented_rect(id, normal) self.motion.presented_rect(id, normal)
} }
pub(crate) fn presented_velocity(&self, id: WindowId) -> Option<ferese_animation::RectVelocity> {
let velocity = self.motion.presentations.get(&id)?.velocity;
Some(ferese_animation::RectVelocity {
x: velocity.x * OVERVIEW_MOTION_SPEED,
y: velocity.y * OVERVIEW_MOTION_SPEED,
width: velocity.width * OVERVIEW_MOTION_SPEED,
height: velocity.height * OVERVIEW_MOTION_SPEED,
})
}
fn enter( fn enter(
&mut self, &mut self,
targets: HashMap<WindowId, (Rect, Rect)>, targets: HashMap<WindowId, (Rect, Rect)>,
selected: Option<WindowId>, selected: Option<WindowId>,
animations_enabled: bool, animations_enabled: bool,
) { ) {
// Reverse a dismissal from the currently visible geometry, not the old grid.
if self.motion.exit_transition.is_some() {
let visible: Vec<_> = targets
.iter()
.map(|(id, (normal, _))| (*id, self.presented_rect(*id, *normal)))
.collect();
for (id, rect) in visible {
if let Some(presentation) = self.motion.presentations.get_mut(&id) {
*presentation = AnimatedRect::new(rect);
}
}
}
self.motion.exit_transition = None;
self.selection_workspace = None; self.selection_workspace = None;
self.motion.active = true; self.motion.active = true;
self.motion.opacity.retarget_preserving_motion(1.0); self.motion.opacity.retarget_preserving_motion(1.0);
@@ -423,7 +417,6 @@ impl OverviewState {
fn exit(&mut self, normal: HashMap<WindowId, Rect>, animations_enabled: bool) { fn exit(&mut self, normal: HashMap<WindowId, Rect>, animations_enabled: bool) {
self.motion.active = false; self.motion.active = false;
self.motion.exit_transition = animations_enabled.then_some((0.0, self.motion.opacity.current));
self.motion.opacity.retarget_preserving_motion(0.0); self.motion.opacity.retarget_preserving_motion(0.0);
if !animations_enabled { if !animations_enabled {
self.motion.opacity.snap(); self.motion.opacity.snap();
@@ -605,6 +598,17 @@ impl Ferese {
pub(crate) fn retarget_overview(&mut self) { pub(crate) fn retarget_overview(&mut self) {
if !self.overview.is_active() { if !self.overview.is_active() {
if self.overview.is_presenting() {
let normal = self.normal_window_rects();
self.overview.motion.presentations.retain(|id, presentation| {
if let Some(target) = normal.get(id) {
presentation.set_target(*target);
true
} else {
false
}
});
}
return; return;
} }
@@ -643,17 +647,26 @@ impl Ferese {
presented.x += x; presented.x += x;
presented.y += y; presented.y += y;
} }
Some(presented) 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)> {
let geometry = self.windows.geometry(&id)?; let geometry = self.windows.geometry(&id)?;
if !self.overview.is_presenting() { if !self.overview.is_presenting() && self.windows.record(id)?.opening.is_none() {
let presented = self.presented_window_rect(id)?; let presented = self.presented_window_rect(id)?;
return Some((x - presented.x, y - presented.y)); return Some((x - presented.x, y - presented.y));
} }
let source = geometry.client.committed_size?; let source = if self.overview.is_presenting() {
let presented = self.overview.presented_rect(id, geometry.visual.current); geometry.client.committed_size?
} else {
ferese_animation::ClientSize::from_rect(geometry.visual.current)
};
let presented = self.presented_window_rect(id)?;
if source.width <= 0 || source.height <= 0 || presented.width <= 0.0 || presented.height <= 0.0 { if source.width <= 0 || source.height <= 0 || presented.width <= 0.0 || presented.height <= 0.0 {
return None; return None;
@@ -989,15 +1002,13 @@ impl Default for OverviewMotion {
active: false, active: false,
presentations: HashMap::new(), presentations: HashMap::new(),
opacity: AnimatedValue::new(0.0), opacity: AnimatedValue::new(0.0),
exit_transition: None,
} }
} }
} }
impl OverviewMotion { impl OverviewMotion {
pub(crate) fn is_animating(&self, spring: SpringConfig) -> bool { pub(crate) fn is_animating(&self, spring: SpringConfig) -> bool {
self.exit_transition.is_some() (self.opacity.current - self.opacity.target).abs() > 0.001
|| (self.opacity.current - self.opacity.target).abs() > 0.001
|| self.opacity.velocity.abs() > 0.005 || self.opacity.velocity.abs() > 0.005
|| self.presentations.values().any(|rect| !rect.is_settled(spring)) || self.presentations.values().any(|rect| !rect.is_settled(spring))
} }
@@ -1012,43 +1023,17 @@ impl OverviewMotion {
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); let preview = self.presentations.get(&id).map_or(normal, |p| p.current);
if let Some((elapsed, _)) = self.exit_transition {
let t = 1.0 - (1.0 - (elapsed / EXIT_DURATION).clamp(0.0, 1.0)).powi(3);
return Rect::new(
preview.x + (normal.x - preview.x) * t,
preview.y + (normal.y - preview.y) * t,
preview.width + (normal.width - preview.width) * t,
preview.height + (normal.height - preview.height) * t,
);
}
preview preview
} }
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 {
let delta = delta.mul_f64(OVERVIEW_MOTION_SPEED); let delta = delta.mul_f64(OVERVIEW_MOTION_SPEED);
if let Some((elapsed, initial_opacity)) = self.exit_transition {
let elapsed = if animations_enabled {
elapsed + delta.as_secs_f64()
} else {
EXIT_DURATION
};
let remaining = (1.0 - elapsed / EXIT_DURATION).clamp(0.0, 1.0);
self.opacity.current = initial_opacity * remaining.powi(3);
if elapsed >= EXIT_DURATION {
self.exit_transition = None;
self.presentations.clear();
self.opacity.snap();
return false;
}
self.exit_transition = Some((elapsed, initial_opacity));
return true;
}
let mut active_animation = false; let mut active_animation = false;
for presentation in self.presentations.values_mut() { for presentation in self.presentations.values_mut() {
if animations_enabled { if animations_enabled {
active_animation |= presentation.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing); active_animation |= presentation.advance(delta, spring);
} else { } else {
presentation.snap(); presentation.snap();
} }
@@ -1197,7 +1182,7 @@ mod tests {
let before = overview.presented_rect(id, normal); let before = overview.presented_rect(id, normal);
let opacity = overview.opacity(); let opacity = overview.opacity();
let finished = overview.sample(Duration::from_millis(24), SpringConfig::default()); let finished = overview.sample(Duration::from_secs(2), SpringConfig::default());
let earlier = overview.sample(Duration::from_millis(4), SpringConfig::default()); let earlier = overview.sample(Duration::from_millis(4), SpringConfig::default());
assert!(!finished.is_presenting()); assert!(!finished.is_presenting());
@@ -1351,7 +1336,7 @@ mod tests {
} }
#[test] #[test]
fn exiting_overview_tracks_a_moving_layout_and_finishes_in_300ms() { fn exiting_overview_retargets_without_resetting_motion() {
let id = WindowId(1); let id = WindowId(1);
let initial = Rect::new(-400.0, 0.0, 800.0, 600.0); let initial = Rect::new(-400.0, 0.0, 800.0, 600.0);
let preview = Rect::new(100.0, 100.0, 400.0, 300.0); let preview = Rect::new(100.0, 100.0, 400.0, 300.0);
@@ -1359,10 +1344,15 @@ mod tests {
let mut overview = OverviewState::default(); let mut overview = OverviewState::default();
overview.enter(HashMap::from([(id, (initial, preview))]), Some(id), false); overview.enter(HashMap::from([(id, (initial, preview))]), Some(id), false);
overview.exit(HashMap::from([(id, initial)]), true); overview.exit(HashMap::from([(id, initial)]), true);
overview.advance(Duration::from_millis(150), SpringConfig::default(), true); overview.advance(Duration::from_millis(60), SpringConfig::default(), true);
let shown = overview.presented_rect(id, moved); let before = overview.motion.presentations[&id];
assert!((shown.x - 187.5).abs() < 0.001); overview.exit(HashMap::from([(id, moved)]), true);
overview.advance(Duration::from_millis(150), SpringConfig::default(), true); let after = overview.motion.presentations[&id];
assert_eq!(before.current, after.current);
assert_eq!(before.velocity, after.velocity);
for _ in 0..300 {
overview.advance(Duration::from_millis(8), SpringConfig::default(), true);
}
assert!(!overview.is_presenting()); assert!(!overview.is_presenting());
assert_eq!(overview.presented_rect(id, moved), moved); assert_eq!(overview.presented_rect(id, moved), moved);
} }
@@ -1378,7 +1368,9 @@ mod tests {
overview.exit(HashMap::from([(id, normal)]), true); overview.exit(HashMap::from([(id, normal)]), true);
overview.advance(Duration::from_millis(60), SpringConfig::default(), true); overview.advance(Duration::from_millis(60), SpringConfig::default(), true);
let shown = overview.presented_rect(id, normal); let shown = overview.presented_rect(id, normal);
let velocity = overview.motion.presentations[&id].velocity;
overview.enter(targets, Some(id), true); overview.enter(targets, Some(id), true);
assert_eq!(overview.motion.presentations[&id].velocity, velocity);
assert_eq!(overview.presented_rect(id, normal), shown); assert_eq!(overview.presented_rect(id, normal), shown);
} }
+60
View File
@@ -523,3 +523,63 @@ mod tests {
assert!(handoff_alpha(Duration::from_millis(1)) > 0.99); assert!(handoff_alpha(Duration::from_millis(1)) > 0.99);
} }
} }
pub(crate) fn scaled_visual_rect(rect: ferese_layout::Rect, scale: f64) -> ferese_layout::Rect {
let scale = scale.max(0.0);
let width = rect.width * scale;
let height = rect.height * scale;
ferese_layout::Rect::new(
rect.x + (rect.width - width) / 2.0,
rect.y + (rect.height - height) / 2.0,
width,
height,
)
}
pub(crate) fn scaled_visual_velocity(
rect: ferese_layout::Rect,
velocity: ferese_animation::RectVelocity,
scale: f64,
scale_velocity: f64,
) -> ferese_animation::RectVelocity {
ferese_animation::RectVelocity {
x: velocity.x + ((1.0 - scale) * velocity.width - rect.width * scale_velocity) * 0.5,
y: velocity.y + ((1.0 - scale) * velocity.height - rect.height * scale_velocity) * 0.5,
width: velocity.width * scale + rect.width * scale_velocity,
height: velocity.height * scale + rect.height * scale_velocity,
}
}
#[cfg(test)]
mod motion_tests {
use super::*;
#[test]
fn center_scale_velocity_matches_the_visible_rectangle() {
let rect = ferese_layout::Rect::new(12.25, 18.75, 400.5, 300.25);
let velocity = ferese_animation::RectVelocity {
x: 80.0,
y: -40.0,
width: 100.0,
height: 20.0,
};
let v = scaled_visual_velocity(rect, velocity, 0.98, 0.07);
let before = scaled_visual_rect(rect, 0.98);
let dt = 1e-5;
let next = ferese_layout::Rect::new(
rect.x + velocity.x * dt,
rect.y + velocity.y * dt,
rect.width + velocity.width * dt,
rect.height + velocity.height * dt,
);
let after = scaled_visual_rect(next, 0.98 + 0.07 * dt);
for (measured, expected) in [
((after.x - before.x) / dt, v.x),
((after.y - before.y) / dt, v.y),
((after.width - before.width) / dt, v.width),
((after.height - before.height) / dt, v.height),
] {
assert!((measured - expected).abs() < 0.0001);
}
}
}
+191
View File
@@ -0,0 +1,191 @@
use super::*;
use ferese_animation::{AnimatedRect, AnimatedValue, CrossingPolicy, SpringConfig};
use ferese_core::OutputId;
use ferese_layout::WindowId;
/// Detached presentation only: no live window, input, focus, or layout entry.
#[derive(Clone)]
pub(crate) struct ClosedWindow {
pub id: WindowId,
pub output: OutputId,
pub below: Option<WindowId>,
pub bounds: AnimatedRect,
pub opacity: AnimatedValue,
pub snapshot: ResizeSnapshot,
pub source: Rectangle<f64, Buffer>,
pub radius: f64,
pub shape: CornerShape,
pub focus: f64,
pub decorations: f64,
pub dim: f64,
pub fill: Option<[f32; 4]>,
pub material: Option<(
smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
crate::effects::SemanticRole,
u64,
f32,
)>,
}
impl ClosedWindow {
pub fn advance(&mut self, delta: Duration, spring: SpringConfig) -> bool {
self.bounds.advance(delta, spring);
let opacity = self.opacity.advance_with_policy(
delta,
SpringConfig {
position_tolerance: 0.001,
velocity_tolerance: 0.001,
..spring
},
CrossingPolicy::NoCrossing,
);
self.snapshot.commit.increment();
// Once invisible there is no reason to keep a texture (including with
// intentionally undamped, overshooting geometry configurations).
opacity
}
}
pub(super) fn elements(
state: &mut Ferese,
renderer: &mut GlesRenderer,
output: &Output,
below: Option<WindowId>,
live: &std::collections::HashSet<WindowId>,
delta: Duration,
) -> Vec<AnimatedWindowRenderElement> {
if state.session_lock.active() {
return Vec::new();
}
let Some(output_id) = state.output_id(output) else {
return Vec::new();
};
let Some(output_geometry) = state.space.output_geometry(output) else {
return Vec::new();
};
let scale = output.current_scale().fractional_scale();
let windows: Vec<_> = state
.render
.closing
.iter()
.filter(|window| {
window.output == output_id
&& window.below.filter(|id| live.contains(id)) == below
&& window.snapshot.context == renderer.context_id().erased()
})
.cloned()
.collect();
let mut elements = Vec::new();
for mut window in windows {
if !delta.is_zero() {
window.advance(delta, state.spring_config);
}
let visual = window.bounds.current;
let corners = RoundedRect::new(visual, output_geometry.loc, scale, window.radius).with_shape(window.shape);
let constrain = rounded_visual_rect(visual, output_geometry.loc);
let Some(programs) = corner_program(&mut state.render, renderer, window.shape) else {
continue;
};
let alpha = window.opacity.current.clamp(0.0, 1.0) as f32;
if let Some(dim) = window_tint_element(
&mut state.render,
renderer,
window.id,
constrain,
corners,
[0.0, 0.0, 0.0, window.dim as f32 * alpha],
false,
output,
) {
elements.push(dim.into());
}
let theme = &state.theme_settings;
if let Some(border) = window_border_element(
&mut state.render,
theme,
renderer,
window.id,
constrain,
corners,
scale,
theme.border_width + (theme.focus_ring_width - theme.border_width) * window.focus,
theme.border_color.0,
theme.border_gradient,
window.focus as f32,
alpha * window.decorations as f32,
output,
&programs,
) {
elements.push(border.into());
}
let clip = framebuffer_clip_rect(
corners.rect,
output.current_mode().unwrap().size,
output.current_transform().invert(),
);
elements.push(
NativeTextureElement {
id: window.snapshot.id.clone(),
commit: window.snapshot.commit,
texture: window.snapshot.texture.clone(),
geometry: corners.rect,
source: window.source,
alpha,
program: Some(programs.texture.clone()),
uniforms: vec![Uniform::new("clip_rect", clip), Uniform::new("radius", corners.radius)],
}
.into(),
);
if let Some(mut fill) = window.fill {
fill[3] = alpha;
if let Some(fill) = window_tint_element(
&mut state.render,
renderer,
window.id,
constrain,
corners,
fill,
true,
output,
) {
elements.push(fill.into());
}
}
if let Some((surface, role, generation, opacity)) = &window.material {
if let Some((material, _)) = material_element_with_role(
state,
renderer,
output,
surface,
MaterialSurface {
geometry: constrain,
corners,
index: 0,
capture_geometry: constrain,
alpha,
},
(*role, *generation, *opacity),
) {
elements.push(material);
}
}
let theme = &state.theme_settings;
if let Some(shadow) = window_shadow_element(
&mut state.render,
renderer,
window.id,
constrain,
corners,
scale,
theme.shadow_offset_y,
theme.shadow_blur,
theme.shadow_opacity * f64::from(alpha) * window.decorations,
theme.shadow_color.0,
output,
&programs,
) {
elements.push(shadow.into());
}
}
elements
}
+10 -13
View File
@@ -380,6 +380,7 @@ pub(super) fn rounded_window_elements(
output: &Output, output: &Output,
programs: RoundedClipPrograms, programs: RoundedClipPrograms,
behavior: ConstrainScaleBehavior, behavior: ConstrainScaleBehavior,
native_size: Option<ferese_animation::ClientSize>,
) -> Vec<AnimatedWindowRenderElement> { ) -> Vec<AnimatedWindowRenderElement> {
let Some(toplevel) = window.toplevel() else { let Some(toplevel) = window.toplevel() else {
return Vec::new(); return Vec::new();
@@ -390,7 +391,13 @@ pub(super) fn rounded_window_elements(
let physical_constrain = corners.rect; let physical_constrain = corners.rect;
let geometry = window.geometry(); let geometry = window.geometry();
let reference = geometry.to_physical_precise_round(scale); let mut reference = geometry.to_physical_precise_round(scale);
let behavior = if let Some(size) = native_size {
reference.size = Size::<i32, Logical>::from((size.width, size.height)).to_physical_precise_round(scale);
ConstrainScaleBehavior::Stretch
} else {
behavior
};
let location = physical_constrain.loc - geometry.loc.to_physical_precise_round(scale); let location = physical_constrain.loc - geometry.loc.to_physical_precise_round(scale);
let clip = framebuffer_clip_rect(physical_constrain, mode.size, output.current_transform().invert()); let clip = framebuffer_clip_rect(physical_constrain, mode.size, output.current_transform().invert());
let radius = corners.radius; let radius = corners.radius;
@@ -462,18 +469,8 @@ pub(super) fn resize_content_behavior(intentional_scale: bool) -> ConstrainScale
} }
} }
pub(super) fn scaled_visual_rect(rect: ferese_layout::Rect, scale: f64) -> ferese_layout::Rect { #[cfg(test)]
let scale = scale.clamp(0.0, 1.0); pub(super) use crate::presentation::scaled_visual_rect;
let width = rect.width * scale;
let height = rect.height * scale;
ferese_layout::Rect::new(
rect.x + (rect.width - width) / 2.0,
rect.y + (rect.height - height) / 2.0,
width,
height,
)
}
pub(super) fn color_with_alpha(mut color: [f32; 4], alpha: f32) -> [f32; 4] { pub(super) fn color_with_alpha(mut color: [f32; 4], alpha: f32) -> [f32; 4] {
color[3] *= alpha; color[3] *= alpha;
+20 -2
View File
@@ -128,6 +128,25 @@ pub(super) fn material_element(
output: &Output, output: &Output,
surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface, surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
surface_geometry: MaterialSurface, surface_geometry: MaterialSurface,
) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> {
let (role, generation) = crate::effects::surface_role(surface)?;
material_element_with_role(
state,
renderer,
output,
surface,
surface_geometry,
(role, generation, crate::effects::surface_opacity(surface)),
)
}
pub(super) fn material_element_with_role(
state: &mut Ferese,
renderer: &mut GlesRenderer,
output: &Output,
surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
surface_geometry: MaterialSurface,
(role, generation, opacity): (crate::effects::SemanticRole, u64, f32),
) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> { ) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> {
let MaterialSurface { let MaterialSurface {
geometry, geometry,
@@ -136,13 +155,12 @@ pub(super) fn material_element(
capture_geometry, capture_geometry,
alpha, alpha,
} = surface_geometry; } = surface_geometry;
let (role, generation) = crate::effects::surface_role(surface)?;
let material = crate::effects::resolve_material( let material = crate::effects::resolve_material(
role, role,
state.theme_settings.material_style, state.theme_settings.material_style,
state.theme_settings.shell_opacity as f32, state.theme_settings.shell_opacity as f32,
); );
let presentation_alpha = crate::effects::surface_opacity(surface) * alpha; let presentation_alpha = opacity * alpha;
let mode = output.current_mode()?; let mode = output.current_mode()?;
let scale = output.current_scale().fractional_scale(); let scale = output.current_scale().fractional_scale();
let transform = output.current_transform().invert(); let transform = output.current_transform().invert();
+4 -2
View File
@@ -1,5 +1,7 @@
mod backdrop; mod backdrop;
mod capture; mod capture;
mod closing;
pub(crate) use closing::ClosedWindow;
#[cfg(test)] #[cfg(test)]
mod corner_tests; mod corner_tests;
mod decorations; mod decorations;
@@ -78,7 +80,7 @@ render_elements! {
Native=NativeTextureElement, Native=NativeTextureElement,
} }
#[derive(Debug)] #[derive(Clone, Debug)]
pub(crate) struct ResizeSnapshot { pub(crate) struct ResizeSnapshot {
pub texture: GlesTexture, pub texture: GlesTexture,
pub context: ErasedContextId, pub context: ErasedContextId,
@@ -134,7 +136,7 @@ fn corner_shader_for(source: &str, shape: CornerShape) -> String {
} }
} }
fn window_corner_shape(window: &smithay::desktop::Window) -> CornerShape { pub(crate) fn window_corner_shape(window: &smithay::desktop::Window) -> CornerShape {
// Semantic shell surfaces keep their client-matching circular outlines. // Semantic shell surfaces keep their client-matching circular outlines.
corner_shape_for_role( corner_shape_for_role(
window window
+1
View File
@@ -241,6 +241,7 @@ pub(super) fn overview_strip_elements(
output, output,
programs.clone(), programs.clone(),
ConstrainScaleBehavior::Stretch, ConstrainScaleBehavior::Stretch,
None,
)); ));
} }
} }
+21
View File
@@ -8,6 +8,7 @@ use super::*;
/// context. Desktop policy and animation values do not live in these caches. /// context. Desktop policy and animation values do not live in these caches.
#[derive(Default)] #[derive(Default)]
pub(crate) struct RenderResources { pub(crate) struct RenderResources {
pub closing: Vec<ClosedWindow>,
pub(super) windows: HashMap<WindowId, WindowBuffers>, pub(super) windows: HashMap<WindowId, WindowBuffers>,
pub(super) contexts: HashMap<ErasedContextId, ContextPrograms>, pub(super) contexts: HashMap<ErasedContextId, ContextPrograms>,
pub(super) outputs: HashMap<OutputId, OverviewScrim>, pub(super) outputs: HashMap<OutputId, OverviewScrim>,
@@ -71,6 +72,16 @@ impl RenderResources {
} }
pub fn remove_output(&mut self, id: OutputId) { pub fn remove_output(&mut self, id: OutputId) {
let removed: Vec<_> = self
.closing
.iter()
.filter(|window| window.output == id)
.map(|window| window.id)
.collect();
self.closing.retain(|window| window.output != id);
for id in removed {
self.remove_window(id);
}
self.outputs.remove(&id); self.outputs.remove(&id);
for window in self.windows.values_mut() { for window in self.windows.values_mut() {
@@ -124,6 +135,16 @@ impl RenderResources {
} }
pub fn forget_context(&mut self, context: &ErasedContextId) { pub fn forget_context(&mut self, context: &ErasedContextId) {
let removed: Vec<_> = self
.closing
.iter()
.filter(|window| &window.snapshot.context == context)
.map(|window| window.id)
.collect();
self.closing.retain(|window| &window.snapshot.context != context);
for id in removed {
self.remove_window(id);
}
self.contexts.remove(context); self.contexts.remove(context);
for window in self.windows.values_mut() { for window in self.windows.values_mut() {
+54 -9
View File
@@ -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 = scaled_visual_rect(sample.rect, sample.close_scale); let visual = sample.rect;
let close_alpha = sample.close_alpha; let close_alpha = sample.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,7 +145,16 @@ pub(crate) fn sampled_output_elements(
.collect::<Vec<_>>() .collect::<Vec<_>>()
}; };
let live = windows.iter().map(|(_, id, ..)| *id).collect();
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(
state,
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()
@@ -165,7 +174,7 @@ pub(crate) fn sampled_output_elements(
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; let pixels = corners.rect;
// Only overview/close intentionally scale the complete application. // Only overview/close intentionally scale the complete application.
let scale_content = frame.overview.is_presenting() || sample.close_scale != 1.0; let scale_content = sample.scale_content;
let behavior = resize_content_behavior(scale_content); let behavior = resize_content_behavior(scale_content);
let dim = sample.dim; let dim = sample.dim;
@@ -227,13 +236,33 @@ pub(crate) fn sampled_output_elements(
output, output,
&programs, &programs,
); );
if !scale_content if let Some(snapshot) = state.render.snapshot(&id)
&& 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 size = snapshot.texture.size(); let size = snapshot.texture.size();
let visible = Rectangle::new(pixels.loc, (size.w, size.h).into()).intersection(pixels); let (sx, sy) = if scale_content {
(
f64::from(pixels.size.w) / f64::from(size.w),
f64::from(pixels.size.h) / f64::from(size.h),
)
} else if let Some(native) = sample.native_size {
(
f64::from(pixels.size.w) / (f64::from(native.width) * scale).round().max(1.0),
f64::from(pixels.size.h) / (f64::from(native.height) * scale).round().max(1.0),
)
} else {
(1.0, 1.0)
};
let visible = Rectangle::new(
pixels.loc,
(
(f64::from(size.w) * sx).round() as i32,
(f64::from(size.h) * sy).round() as i32,
)
.into(),
)
.intersection(pixels);
if let Some(visible) = visible { if let Some(visible) = visible {
let clip = framebuffer_clip_rect( let clip = framebuffer_clip_rect(
pixels, pixels,
@@ -247,8 +276,8 @@ pub(crate) fn sampled_output_elements(
texture: snapshot.texture.clone(), texture: snapshot.texture.clone(),
geometry: visible, geometry: visible,
source: Rectangle::from_size(Size::from(( source: Rectangle::from_size(Size::from((
f64::from(visible.size.w), f64::from(visible.size.w) / sx,
f64::from(visible.size.h), f64::from(visible.size.h) / sy,
))), ))),
alpha: crate::presentation::handoff_alpha(snapshot.elapsed) * close_alpha, alpha: crate::presentation::handoff_alpha(snapshot.elapsed) * close_alpha,
program: Some(programs.texture.clone()), program: Some(programs.texture.clone()),
@@ -268,6 +297,7 @@ pub(crate) fn sampled_output_elements(
output, output,
programs.clone(), programs.clone(),
behavior, behavior,
sample.native_size,
)); ));
let source = window.geometry().size; let source = window.geometry().size;
if material_surface.is_none() if material_surface.is_none()
@@ -322,6 +352,14 @@ pub(crate) fn sampled_output_elements(
)); ));
} }
} }
elements.extend(super::closing::elements(
state,
renderer,
output,
None,
&live,
frame.delta,
));
elements.extend(layer_elements( elements.extend(layer_elements(
state, state,
renderer, renderer,
@@ -374,7 +412,14 @@ pub(super) fn layer_elements(
.collect::<Vec<_>>() .collect::<Vec<_>>()
}; };
state.render.surfaces.retain(|surface, _| surface.is_alive()); state.render.surfaces.retain(|surface, _| {
surface.is_alive()
|| state
.render
.closing
.iter()
.any(|window| window.material.as_ref().is_some_and(|(key, ..)| key == surface))
});
let mut elements = Vec::new(); let mut elements = Vec::new();
for (geometry, layer) in layers { for (geometry, layer) in layers {
+172 -97
View File
@@ -9,6 +9,7 @@ mod reconciliation;
pub(crate) use reconciliation::{DesktopChanges, DesktopOutput, DesktopTransition}; pub(crate) use reconciliation::{DesktopChanges, DesktopOutput, DesktopTransition};
mod prediction; mod prediction;
pub(crate) use prediction::FrameScene; pub(crate) use prediction::FrameScene;
mod lifecycle;
mod window_registry; mod window_registry;
mod windows; mod windows;
@@ -19,7 +20,7 @@ use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use calloop::LoopHandle; use calloop::LoopHandle;
use ferese_animation::{AnimatedValue, ClientSize, CrossingPolicy, PresentationMode, SpringConfig, WindowGeometry}; use ferese_animation::{AnimatedValue, ClientSize, PresentationMode, SpringConfig, WindowGeometry};
use ferese_core::{ use ferese_core::{
LayoutMode, OutputGeometry, OutputId, OutputWorkspaceMap, WindowPlacement, WorkspaceId, WorkspaceLayout, LayoutMode, OutputGeometry, OutputId, OutputWorkspaceMap, WindowPlacement, WorkspaceId, WorkspaceLayout,
WorkspaceSet, WorkspaceSet,
@@ -98,9 +99,11 @@ use crate::wallpaper::{WallpaperConfig, WallpaperState};
use crate::window_rules::{WindowRule, resolve as resolve_window_rules}; use crate::window_rules::{WindowRule, resolve as resolve_window_rules};
use crate::winit::NestedBackend; use crate::winit::NestedBackend;
const CLOSE_ANIMATION_DURATION: Duration = Duration::from_millis(140); #[cfg(test)]
const WORKSPACE_SLIDE_DURATION: Duration = Duration::from_millis(220); use ferese_animation::CrossingPolicy;
const WORKSPACE_SLIDE_FIRST_FRAME: Duration = Duration::from_millis(8);
#[cfg(test)]
const SLIDE_SETTLE_TEST_BUDGET: Duration = Duration::from_secs(2);
fn workspace_swipe_is_current( fn workspace_swipe_is_current(
outputs: &OutputWorkspaceMap, outputs: &OutputWorkspaceMap,
@@ -153,7 +156,7 @@ struct FocusSwipe {
} }
impl FocusSwipe { impl FocusSwipe {
fn position(&self) -> f64 { fn distance(&self) -> f64 {
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.
@@ -166,7 +169,29 @@ impl FocusSwipe {
} else { } else {
distance distance
}; };
self.start + distance * self.progress distance
}
fn position(&self) -> f64 {
let progress = if self.to == self.from {
ferese_animation::gesture::rubber_band(self.progress.max(0.0), 1.0, 0.55).0
} else {
self.progress.clamp(0.0, 1.0)
};
self.start + self.distance() * progress
}
fn release_velocity(&self, bounded: f64, unbounded: f64) -> f64 {
let velocity = if self.to == self.from {
if self.progress <= 0.0 {
0.0
} else {
unbounded * ferese_animation::gesture::rubber_band(self.progress, 1.0, 0.55).1
}
} else {
bounded
};
self.distance() * velocity
} }
} }
@@ -175,12 +200,13 @@ struct WorkspaceSlideItem {
workspace: WorkspaceId, workspace: WorkspaceId,
start: SlideOffset, start: SlideOffset,
target: SlideOffset, target: SlideOffset,
velocity: SlideOffset,
} }
#[derive(Clone)] #[derive(Clone)]
struct WorkspaceSlide { struct WorkspaceSlide {
items: Vec<WorkspaceSlideItem>, items: Vec<WorkspaceSlideItem>,
elapsed: Duration, spring: SpringConfig,
held_progress: Option<f64>, held_progress: Option<f64>,
gesture: Option<(WorkspaceId, WorkspaceId, SwipeDirection)>, gesture: Option<(WorkspaceId, WorkspaceId, SwipeDirection)>,
} }
@@ -190,12 +216,14 @@ impl WorkspaceSlide {
let movement = SlideOffset::for_swipe(direction); let movement = SlideOffset::for_swipe(direction);
let mut items = previous let mut items = previous
.map(|slide| { .map(|slide| {
let progress = slide.progress(); let progress = slide.held_progress;
slide slide
.items .items
.into_iter() .into_iter()
.map(|mut item| { .map(|mut item| {
item.start = item.start.between(item.target, progress); if let Some(progress) = progress {
item.start = item.start.between(item.target, progress);
}
item item
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
@@ -209,6 +237,7 @@ impl WorkspaceSlide {
workspace: from, workspace: from,
start: SlideOffset::default(), start: SlideOffset::default(),
target: movement, target: movement,
velocity: SlideOffset::default(),
}); });
} }
if let Some(item) = items.iter_mut().find(|item| item.workspace == to) { if let Some(item) = items.iter_mut().find(|item| item.workspace == to) {
@@ -218,30 +247,64 @@ impl WorkspaceSlide {
workspace: to, workspace: to,
start: movement.opposite(), start: movement.opposite(),
target: SlideOffset::default(), target: SlideOffset::default(),
velocity: SlideOffset::default(),
}); });
} }
Self { Self {
items, items,
elapsed: WORKSPACE_SLIDE_FIRST_FRAME, spring: SpringConfig {
stiffness: 320.0,
damping: 2.0 * 320.0_f64.sqrt(),
position_tolerance: 0.00001,
velocity_tolerance: 0.00001,
..SpringConfig::default()
},
held_progress: None, held_progress: None,
gesture: None, gesture: None,
} }
} }
fn progress(&self) -> f64 { fn position(&self, item: &WorkspaceSlideItem, delta: Duration) -> SlideOffset {
self.progress_at(Duration::ZERO) if let Some(progress) = self.held_progress {
return item.start.between(item.target, progress);
}
let mut x = AnimatedValue {
current: item.start.x,
target: item.target.x,
velocity: item.velocity.x,
};
let mut y = AnimatedValue {
current: item.start.y,
target: item.target.y,
velocity: item.velocity.y,
};
x.advance(delta, self.spring);
y.advance(delta, self.spring);
SlideOffset {
x: x.current,
y: y.current,
}
} }
fn progress_at(&self, delta: Duration) -> f64 { fn moving(&self) -> bool {
self.held_progress.unwrap_or_else(|| { self.items
let elapsed = self.elapsed.saturating_add(delta).min(WORKSPACE_SLIDE_DURATION); .iter()
smoothstep(elapsed.as_secs_f64() / WORKSPACE_SLIDE_DURATION.as_secs_f64()) .any(|item| item.start != item.target || item.velocity != SlideOffset::default())
}) }
fn release_with_velocity(&mut self, committed: bool, velocity: f64) {
for item in &mut self.items {
item.velocity = SlideOffset {
x: (item.target.x - item.start.x) * velocity,
y: (item.target.y - item.start.y) * velocity,
};
}
self.release(committed);
} }
fn release(&mut self, committed: bool) { fn release(&mut self, committed: bool) {
let progress = self.progress(); let progress = self.held_progress.unwrap_or(0.0);
for item in &mut self.items { for item in &mut self.items {
let position = item.start.between(item.target, progress); let position = item.start.between(item.target, progress);
if !committed && let Some((from, to, direction)) = self.gesture { if !committed && let Some((from, to, direction)) = self.gesture {
@@ -255,12 +318,36 @@ impl WorkspaceSlide {
} }
self.held_progress = None; self.held_progress = None;
self.gesture = None; self.gesture = None;
self.elapsed = Duration::ZERO;
} }
fn advance(&mut self, delta: Duration) -> bool { fn advance(&mut self, delta: Duration) -> bool {
self.elapsed = (self.elapsed + delta).min(WORKSPACE_SLIDE_DURATION); if self.held_progress.is_some() {
self.elapsed < WORKSPACE_SLIDE_DURATION return true;
}
let mut moving = false;
for item in &mut self.items {
let mut x = AnimatedValue {
current: item.start.x,
target: item.target.x,
velocity: item.velocity.x,
};
let mut y = AnimatedValue {
current: item.start.y,
target: item.target.y,
velocity: item.velocity.y,
};
moving |= x.advance(delta, self.spring);
moving |= y.advance(delta, self.spring);
item.start = SlideOffset {
x: x.current,
y: y.current,
};
item.velocity = SlideOffset {
x: x.velocity,
y: y.velocity,
};
}
moving
} }
fn contains(&self, workspace: WorkspaceId) -> bool { fn contains(&self, workspace: WorkspaceId) -> bool {
@@ -269,43 +356,16 @@ impl WorkspaceSlide {
#[cfg(test)] #[cfg(test)]
fn offset(&self, workspace: WorkspaceId, width: f64, height: f64) -> (f64, f64) { fn offset(&self, workspace: WorkspaceId, width: f64, height: f64) -> (f64, f64) {
let progress = self.progress();
let position = self let position = self
.items .items
.iter() .iter()
.find(|item| item.workspace == workspace) .find(|item| item.workspace == workspace)
.map(|item| item.start.between(item.target, progress)) .map(|item| self.position(item, Duration::ZERO))
.unwrap_or_default(); .unwrap_or_default();
(position.x * width, position.y * height) (position.x * width, position.y * height)
} }
} }
#[derive(Clone, Copy, Debug, Default)]
struct ClosingAnimation {
progress: f64,
close_sent: bool,
}
impl ClosingAnimation {
fn advance(&mut self, delta: Duration, animations_enabled: bool) -> bool {
if self.close_sent {
return false;
}
self.progress = if animations_enabled {
(self.progress + delta.as_secs_f64() / CLOSE_ANIMATION_DURATION.as_secs_f64()).min(1.0)
} else {
1.0
};
if self.progress < 1.0 {
return false;
}
self.close_sent = true;
true
}
}
fn scaled_animation_duration(duration: Duration, enabled: bool, speed: f64) -> Duration { fn scaled_animation_duration(duration: Duration, enabled: bool, speed: f64) -> Duration {
if enabled { if enabled {
duration.div_f64(speed) duration.div_f64(speed)
@@ -373,7 +433,7 @@ pub struct Ferese {
pub(crate) inactive_dim: crate::config::InactiveDimSettings, pub(crate) inactive_dim: crate::config::InactiveDimSettings,
animations_enabled: bool, animations_enabled: bool,
animation_speed: f64, animation_speed: f64,
spring_config: SpringConfig, pub(crate) spring_config: SpringConfig,
viewport_spring_config: SpringConfig, viewport_spring_config: SpringConfig,
pub(crate) output_profiles: Vec<OutputProfile>, pub(crate) output_profiles: Vec<OutputProfile>,
pub(crate) autostart: Vec<DaemonConfig>, pub(crate) autostart: Vec<DaemonConfig>,
@@ -784,6 +844,13 @@ impl Ferese {
self.animation_speed = config.animation_speed; self.animation_speed = config.animation_speed;
self.spring_config = config.spring_config; self.spring_config = config.spring_config;
self.viewport_spring_config = config.viewport_spring_config; self.viewport_spring_config = config.viewport_spring_config;
for slide in self.workspace_slides.values_mut() {
slide.spring = SpringConfig {
position_tolerance: 0.00001,
velocity_tolerance: 0.00001,
..self.viewport_spring_config
};
}
self.output_profiles = config.output_profiles; self.output_profiles = config.output_profiles;
self.autostart = config.autostart; self.autostart = config.autostart;
if daemons_changed && let Some(runner) = self.daemons.clone() { if daemons_changed && let Some(runner) = self.daemons.clone() {
@@ -940,12 +1007,6 @@ fn natural_floating_rect(bounds: Rect, size: ClientSize) -> Rect {
) )
} }
fn smoothstep(progress: f64) -> f64 {
let progress = progress.clamp(0.0, 1.0);
progress * progress * (3.0 - 2.0 * progress)
}
fn restored_scrolling_world_x(had_geometry: bool, visual_x: f64, viewport_x: f64, target_world_x: f64) -> f64 { fn restored_scrolling_world_x(had_geometry: bool, visual_x: f64, viewport_x: f64, target_world_x: f64) -> f64 {
if had_geometry { if had_geometry {
visual_x + viewport_x visual_x + viewport_x
@@ -1044,20 +1105,60 @@ impl ClientData for ClientState {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn workspace_reversal_keeps_position_and_velocity() {
let from = WorkspaceId(1);
let to = WorkspaceId(2);
let mut slide = WorkspaceSlide::new(None, from, to, SwipeDirection::Up);
slide.advance(Duration::from_millis(40));
let before: Vec<_> = slide
.items
.iter()
.map(|item| (item.workspace, item.start, item.velocity))
.collect();
let reversed = WorkspaceSlide::new(Some(slide), to, from, SwipeDirection::Down);
for (id, position, velocity) in before {
let item = reversed.items.iter().find(|item| item.workspace == id).unwrap();
assert_eq!(item.start, position);
assert_eq!(item.velocity, velocity);
assert!(velocity.y < 0.0);
}
}
#[test]
fn viewport_limit_uses_resisted_position_and_release_velocity() {
let swipe = FocusSwipe {
workspace: WorkspaceId(1),
from: WindowId(1),
to: WindowId(1),
direction: Direction::Right,
gesture_direction: SwipeDirection::Left,
start: 20.0,
destination: 20.0,
progress: 2.0,
};
assert!(swipe.position() > 20.0 && swipe.position() < 84.0);
assert!(swipe.release_velocity(0.0, 3.0) > 0.0);
let mut released = AnimatedValue {
current: swipe.position(),
target: swipe.start,
velocity: swipe.release_velocity(0.0, 3.0),
};
let initial = released;
released.advance(Duration::from_micros(1), SpringConfig::default());
assert!((released.current - initial.current - initial.velocity * 1e-6).abs() < 1e-6);
released.advance(Duration::from_secs(2), SpringConfig::default());
assert_eq!(released.current, swipe.start);
}
#[test] #[test]
fn sampling_slide_progress_matches_advancing_without_mutating_the_slide() { fn sampling_slide_progress_matches_advancing_without_mutating_the_slide() {
let slide = WorkspaceSlide::new(None, WorkspaceId(1), WorkspaceId(2), SwipeDirection::Left); let slide = WorkspaceSlide::new(None, WorkspaceId(1), WorkspaceId(2), SwipeDirection::Left);
for delta in [Duration::ZERO, Duration::from_millis(16), Duration::from_millis(100)] {
for delta in [Duration::ZERO, Duration::from_millis(8), Duration::from_secs(1)] {
let mut advanced = slide.clone(); let mut advanced = slide.clone();
advanced.advance(delta); advanced.advance(delta);
assert_eq!(slide.progress_at(delta), advanced.progress()); for (before, after) in slide.items.iter().zip(&advanced.items) {
assert_eq!(slide.elapsed, WORKSPACE_SLIDE_FIRST_FRAME); assert_eq!(slide.position(before, delta), after.start);
}
} }
let mut held = slide;
held.held_progress = Some(0.37);
assert_eq!(held.progress_at(Duration::from_secs(1)), 0.37);
} }
#[test] #[test]
@@ -1080,15 +1181,6 @@ mod tests {
} }
} }
#[test]
fn disabling_motion_finishes_an_in_progress_close_once() {
let mut animation = ClosingAnimation::default();
assert!(!animation.advance(Duration::from_millis(35), true));
assert!(animation.advance(Duration::ZERO, false));
assert_eq!(animation.progress, 1.0);
assert!(!animation.advance(Duration::ZERO, false));
}
#[test] #[test]
fn workspace_drag_cancels_when_focus_or_target_ownership_changes() { fn workspace_drag_cancels_when_focus_or_target_ownership_changes() {
let mut outputs = OutputWorkspaceMap::default(); let mut outputs = OutputWorkspaceMap::default();
@@ -1241,7 +1333,7 @@ mod tests {
slide.release(committed); slide.release(committed);
assert_eq!(slide.offset(from, 1000.0, 1000.0), (0.0, -400.0)); assert_eq!(slide.offset(from, 1000.0, 1000.0), (0.0, -400.0));
assert_eq!(slide.offset(to, 1000.0, 1000.0), (0.0, 600.0)); assert_eq!(slide.offset(to, 1000.0, 1000.0), (0.0, 600.0));
assert!(!slide.advance(WORKSPACE_SLIDE_DURATION)); assert!(!slide.advance(SLIDE_SETTLE_TEST_BUDGET));
let active = if committed { to } else { from }; let active = if committed { to } else { from };
assert_eq!(slide.offset(active, 1000.0, 1000.0), (0.0, 0.0)); assert_eq!(slide.offset(active, 1000.0, 1000.0), (0.0, 0.0));
} }
@@ -1253,14 +1345,14 @@ mod tests {
let second = WorkspaceId(2); let second = WorkspaceId(2);
let third = WorkspaceId(3); let third = WorkspaceId(3);
let mut previous = WorkspaceSlide::new(None, first, second, SwipeDirection::Up); let mut previous = WorkspaceSlide::new(None, first, second, SwipeDirection::Up);
previous.advance(WORKSPACE_SLIDE_DURATION / 3); previous.advance(Duration::from_millis(40));
let mut slide = WorkspaceSlide::new(Some(previous), second, third, SwipeDirection::Up); let mut slide = WorkspaceSlide::new(Some(previous), second, third, SwipeDirection::Up);
slide.gesture = Some((second, third, SwipeDirection::Up)); slide.gesture = Some((second, third, SwipeDirection::Up));
slide.held_progress = Some(0.25); slide.held_progress = Some(0.25);
let position = slide.offset(second, 1000.0, 1000.0); let position = slide.offset(second, 1000.0, 1000.0);
slide.release(false); slide.release(false);
assert_eq!(slide.offset(second, 1000.0, 1000.0), position); assert_eq!(slide.offset(second, 1000.0, 1000.0), position);
slide.advance(WORKSPACE_SLIDE_DURATION); slide.advance(SLIDE_SETTLE_TEST_BUDGET);
assert_eq!(slide.offset(second, 1000.0, 1000.0), (0.0, 0.0)); assert_eq!(slide.offset(second, 1000.0, 1000.0), (0.0, 0.0));
} }
@@ -1279,13 +1371,13 @@ mod tests {
let mut slide = WorkspaceSlide::new(None, from, to, direction); let mut slide = WorkspaceSlide::new(None, from, to, direction);
assert!(slide.contains(from)); assert!(slide.contains(from));
assert!(slide.contains(to)); assert!(slide.contains(to));
assert!(slide.advance(WORKSPACE_SLIDE_DURATION / 2)); assert!(slide.advance(Duration::from_millis(50)));
let outgoing = slide.offset(from, width, height); let outgoing = slide.offset(from, width, height);
let incoming = slide.offset(to, width, height); let incoming = slide.offset(to, width, height);
assert!((incoming.0 - outgoing.0).abs() == outgoing_target.0.abs()); assert!((incoming.0 - outgoing.0).abs() == outgoing_target.0.abs());
assert!((incoming.1 - outgoing.1).abs() == outgoing_target.1.abs()); assert!((incoming.1 - outgoing.1).abs() == outgoing_target.1.abs());
assert!(!slide.advance(WORKSPACE_SLIDE_DURATION)); assert!(!slide.advance(SLIDE_SETTLE_TEST_BUDGET));
assert_eq!(slide.offset(from, width, height), outgoing_target); assert_eq!(slide.offset(from, width, height), outgoing_target);
assert_eq!(slide.offset(to, width, height), (0.0, 0.0)); assert_eq!(slide.offset(to, width, height), (0.0, 0.0));
} }
@@ -1297,7 +1389,7 @@ mod tests {
let second = WorkspaceId(2); let second = WorkspaceId(2);
let third = WorkspaceId(3); let third = WorkspaceId(3);
let mut slide = WorkspaceSlide::new(None, first, second, SwipeDirection::Up); let mut slide = WorkspaceSlide::new(None, first, second, SwipeDirection::Up);
slide.advance(WORKSPACE_SLIDE_DURATION / 3); slide.advance(Duration::from_millis(40));
let first_position = slide.offset(first, 1600.0, 900.0); let first_position = slide.offset(first, 1600.0, 900.0);
let second_position = slide.offset(second, 1600.0, 900.0); let second_position = slide.offset(second, 1600.0, 900.0);
@@ -1404,24 +1496,6 @@ mod tests {
assert_eq!(transient, Rect::new(280.0, 210.0, 640.0, 480.0)); assert_eq!(transient, Rect::new(280.0, 210.0, 640.0, 480.0));
} }
#[test]
fn close_easing_is_bounded_and_symmetric() {
assert_eq!(smoothstep(-1.0), 0.0);
assert_eq!(smoothstep(0.5), 0.5);
assert_eq!(smoothstep(2.0), 1.0);
}
#[test]
fn close_animation_delays_the_protocol_close_until_it_finishes() {
let mut animation = ClosingAnimation::default();
assert!(!animation.advance(Duration::from_millis(70), true));
assert_eq!(animation.progress, 0.5);
assert!(animation.advance(Duration::from_millis(70), true));
assert!(animation.close_sent);
assert!(!animation.advance(Duration::from_secs(1), true));
}
#[test] #[test]
fn coupled_right_column_width_keeps_its_right_edge_stable() { fn coupled_right_column_width_keeps_its_right_edge_stable() {
let spring = SpringConfig { let spring = SpringConfig {
@@ -1430,6 +1504,7 @@ mod tests {
damping: 2.0 * 320.0_f64.sqrt(), damping: 2.0 * 320.0_f64.sqrt(),
position_tolerance: 0.1, position_tolerance: 0.1,
velocity_tolerance: 0.1, velocity_tolerance: 0.1,
..SpringConfig::default()
}; };
let mut width = AnimatedValue::new(980.0); let mut width = AnimatedValue::new(980.0);
let mut viewport = AnimatedValue::new(495.0); let mut viewport = AnimatedValue::new(495.0);
+61 -28
View File
@@ -82,6 +82,7 @@ impl Ferese {
if self.focus_swipe.is_some() if self.focus_swipe.is_some()
|| !self.paused_workspaces.is_empty() || !self.paused_workspaces.is_empty()
|| !self.workspace_slides.is_empty() || !self.workspace_slides.is_empty()
|| !self.render.closing.is_empty()
|| !self.dismissing_popups.is_empty() || !self.dismissing_popups.is_empty()
|| self.render.snapshots().next().is_some() || self.render.snapshots().next().is_some()
|| self.overview.needs_tick() || self.overview.needs_tick()
@@ -192,11 +193,22 @@ impl Ferese {
for (&id, record) in self.windows.records_mut() { for (&id, record) in self.windows.records_mut() {
let selected = selected_window == Some(id); let selected = selected_window == Some(id);
let target = if selected { 1.0 } else { 0.0 }; let target = if selected { 1.0 } else { 0.0 };
let focus = record let focus = record.focus.get_or_insert_with(|| AnimatedValue::new(target));
.focus
.get_or_insert_with(|| crate::dimming::DimAnimation::new(target));
let previous = focus.current; let previous = focus.current;
active_animation |= focus.advance_visual(target, delta, duration); let changed = focus.target != target;
focus.set_target(target);
if self.animations_enabled {
active_animation |= focus.advance(
if changed { Duration::ZERO } else { delta },
SpringConfig {
position_tolerance: 0.001,
velocity_tolerance: 0.001,
..self.spring_config
},
);
} else {
focus.snap();
}
dim_changed |= previous != focus.current; dim_changed |= previous != focus.current;
} }
@@ -217,19 +229,40 @@ impl Ferese {
dim_changed |= previous != dim.current; dim_changed |= previous != dim.current;
} }
let mut ready_to_close = Vec::new(); for (_, record) in self.windows.records_mut() {
for (id, record) in self.windows.records_mut() { if let Some(opening) = &mut record.opening {
let Some(animation) = &mut record.closing else { continue }; let active = if self.animations_enabled {
opening.advance(
if animation.advance(delta, self.animations_enabled) { delta,
ready_to_close.push(*id); SpringConfig {
} else if !animation.close_sent { position_tolerance: 0.00001,
active_animation = true; velocity_tolerance: 0.00001,
..self.spring_config
},
)
} else {
opening.snap();
false
};
active_animation |= active;
if !active {
record.opening = None;
}
} }
} }
let mut finished = Vec::new();
for id in ready_to_close { self.render.closing.retain_mut(|window| {
self.send_window_close(id); let active =
self.animations_enabled && !self.session_lock.active() && window.advance(delta, self.spring_config);
if !active {
finished.push(window.id);
}
active_animation |= active;
active
});
for id in finished {
tracing::debug!(?id, "released close presentation");
self.render.remove_window(id);
} }
// Include transactions expiring on this dispatch: their waiting time // Include transactions expiring on this dispatch: their waiting time
@@ -325,8 +358,7 @@ impl Ferese {
viewport.current = swipe.position(); viewport.current = swipe.position();
viewport.velocity = 0.0; viewport.velocity = 0.0;
} else if self.animations_enabled { } else if self.animations_enabled {
active_animation |= active_animation |= viewport.advance(delta, self.viewport_spring_config);
viewport.advance_with_policy(delta, self.viewport_spring_config, CrossingPolicy::NoCrossing);
} else { } else {
viewport.snap(); viewport.snap();
} }
@@ -379,8 +411,7 @@ impl Ferese {
if zooming { if zooming {
sync_scrolling_coordinates(geometry, world_x, viewport, true); sync_scrolling_coordinates(geometry, world_x, viewport, true);
} else if self.animations_enabled { } else if self.animations_enabled {
active_animation |= active_animation |= world_x.advance(delta, self.spring_config);
world_x.advance_with_policy(delta, self.spring_config, CrossingPolicy::NoCrossing);
} else { } else {
world_x.snap(); world_x.snap();
} }
@@ -392,7 +423,7 @@ impl Ferese {
if let Some((_, width)) = record.coupled_width.as_mut() { if let Some((_, width)) = record.coupled_width.as_mut() {
let width_active = if self.animations_enabled { let width_active = if self.animations_enabled {
width.advance_with_policy(delta, self.viewport_spring_config, CrossingPolicy::NoCrossing) width.advance(delta, self.viewport_spring_config)
} else { } else {
width.snap(); width.snap();
false false
@@ -462,6 +493,9 @@ impl Ferese {
self.send_shell_snapshots(); self.send_shell_snapshots();
active_animation = true; active_animation = true;
} }
if !self.overview.is_active() {
self.retarget_overview();
}
active_animation |= self active_animation |= self
.overview .overview
.advance(delta, self.spring_config, self.animations_enabled); .advance(delta, self.spring_config, self.animations_enabled);
@@ -525,10 +559,9 @@ impl Ferese {
else { else {
continue; continue;
}; };
let progress = slide.progress_at(delta);
for item in &slide.items { for item in &slide.items {
let position = item.start.between(item.target, progress); let position = slide.position(item, delta);
offsets.insert( offsets.insert(
item.workspace, item.workspace,
(position.x * f64::from(size.w), position.y * f64::from(size.h)), (position.x * f64::from(size.w), position.y * f64::from(size.h)),
@@ -573,7 +606,7 @@ fn record_needs_tick(
mapped: impl FnOnce() -> bool, mapped: impl FnOnce() -> bool,
) -> bool { ) -> bool {
if record.resize.is_some() if record.resize.is_some()
|| record.closing.as_ref().is_some_and(|close| !close.close_sent) || 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))
{ {
return true; return true;
@@ -610,7 +643,7 @@ mod tests {
WindowRecord { WindowRecord {
geometry: Some(geometry), geometry: Some(geometry),
focus: Some(DimAnimation::new(1.0)), focus: Some(AnimatedValue::new(1.0)),
dimming: Some(DimAnimation::new(0.0)), dimming: Some(DimAnimation::new(0.0)),
..Default::default() ..Default::default()
} }
@@ -660,11 +693,11 @@ mod tests {
} }
#[test] #[test]
fn close_requests_and_coupled_width_cleanup_still_get_their_final_tick() { fn opening_and_coupled_width_cleanup_still_get_their_final_tick() {
let mut record = settled(); let mut record = settled();
record.closing = Some(ClosingAnimation::default()); record.opening = Some(AnimatedValue::new(0.0));
assert!(record_needs_tick(&record, 1.0, 0.0, || false)); assert!(record_needs_tick(&record, 1.0, 0.0, || false));
record.closing.as_mut().unwrap().close_sent = true; record.opening = None;
assert!(!record_needs_tick(&record, 1.0, 0.0, || true)); assert!(!record_needs_tick(&record, 1.0, 0.0, || true));
record.coupled_width = Some((WorkspaceId(1), AnimatedValue::new(400.0))); record.coupled_width = Some((WorkspaceId(1), AnimatedValue::new(400.0)));
assert!(record_needs_tick(&record, 1.0, 0.0, || true)); assert!(record_needs_tick(&record, 1.0, 0.0, || true));
@@ -871,7 +904,7 @@ mod tests {
// A settled transaction still has to flush its resume guard; otherwise // A settled transaction still has to flush its resume guard; otherwise
// the next unrelated animation would inherit an old paused workspace. // the next unrelated animation would inherit an old paused workspace.
state.viewport_animations.get_mut(&workspace).unwrap().snap(); state.viewport_animations.get_mut(&workspace).unwrap().snap();
state.windows.record_mut(id).unwrap().focus = Some(DimAnimation::new(0.0)); state.windows.record_mut(id).unwrap().focus = Some(AnimatedValue::new(0.0));
state.windows.set_transaction( state.windows.set_transaction(
id, id,
crate::resize_transaction::ResizeTransaction::new(3.into(), Duration::from_millis(800)), crate::resize_transaction::ResizeTransaction::new(3.into(), Duration::from_millis(800)),
+1 -5
View File
@@ -10,17 +10,13 @@ impl Ferese {
(window.toplevel().is_some() (window.toplevel().is_some()
&& self.window_content_ready(window) && self.window_content_ready(window)
&& self.windows.geometry(id).is_some() && self.windows.geometry(id).is_some()
&& self.output_workspaces.focused_output().is_some() && self.output_workspaces.focused_output().is_some())
&& self.windows.record(*id).is_none_or(|record| record.closing.is_none()))
.then_some(*id) .then_some(*id)
}) })
.collect() .collect()
} }
pub(crate) fn focus_preview_output(&self, id: WindowId) -> Option<OutputId> { pub(crate) fn focus_preview_output(&self, id: WindowId) -> Option<OutputId> {
if self.windows.record(id).is_some_and(|record| record.closing.is_some()) {
return None;
}
self.workspaces self.workspaces
.workspace_for_window(id) .workspace_for_window(id)
.and_then(|workspace| self.output_workspaces.output_for_workspace(workspace)) .and_then(|workspace| self.output_workspaces.output_for_workspace(workspace))
+7 -3
View File
@@ -202,12 +202,16 @@ impl Ferese {
} }
pub fn visual_scale_for_window(&self, window: &Window) -> Option<(f64, f64)> { pub fn visual_scale_for_window(&self, window: &Window) -> Option<(f64, f64)> {
if !self.overview.is_presenting() { let id = self.windows.ids().get(window)?;
if !self.overview.is_presenting() && self.windows.record(*id)?.opening.is_none() {
return Some((1.0, 1.0)); return Some((1.0, 1.0));
} }
let id = self.windows.ids().get(window)?;
let geometry = self.windows.geometry(id)?; let geometry = self.windows.geometry(id)?;
let source = geometry.client.committed_size?; let source = if self.overview.is_presenting() {
geometry.client.committed_size?
} else {
ClientSize::from_rect(geometry.visual.current)
};
let presented = self.presented_window_rect(*id)?; let presented = self.presented_window_rect(*id)?;
if source.width <= 0 || source.height <= 0 { if source.width <= 0 || source.height <= 0 {
+174
View File
@@ -0,0 +1,174 @@
use super::*;
use ferese_animation::AnimatedRect;
use smithay::backend::renderer::Texture;
use smithay::wayland::compositor::{BufferAssignment, SurfaceAttributes};
impl Ferese {
pub(crate) fn capture_close_before_commit(&mut self, surface: &WlSurface) {
// At this point cached attributes contain the committed removal, but
// on_commit_buffer_handler has not reset the previous renderer state.
let removed = with_states(surface, |states| {
matches!(
states.cached_state.get::<SurfaceAttributes>().current().buffer,
Some(BufferAssignment::Removed)
)
});
if removed
&& let Some(window) = self
.windows
.id_for_surface(surface)
.and_then(|id| self.windows.window(id))
.cloned()
{
self.retain_closed_window(&window);
}
}
pub(super) fn retain_closed_window(&mut self, window: &Window) {
if !self.animations_enabled || self.session_lock.active() || self.render.closing.len() >= 64 {
return;
}
let Some(&id) = self.windows.ids().get(window) else {
return;
};
if self.render.closing.iter().any(|window| window.id == id) || !self.window_content_ready(window) {
return;
}
let Some(output) = self
.space
.outputs()
.find(|output| self.window_belongs_to_output(id, output))
.cloned()
else {
return;
};
let Some(output_id) = self.output_id(&output) else {
return;
};
let Some(rect) = self.presented_window_rect(id) else {
return;
};
let record = self.windows.record(id).unwrap();
let geometry = record.geometry.unwrap();
let decorations = geometry.decorations.clamp(0.0, 1.0);
let focus = record.focus.as_ref().map_or(0.0, |focus| focus.current.clamp(0.0, 1.0));
let dim = record.dimming.as_ref().map_or(0.0, |dim| dim.current);
let mut opacity = record.opening.unwrap_or_else(|| AnimatedValue::new(1.0));
opacity.set_target(0.0);
let mut bounds = AnimatedRect::new(rect);
bounds.velocity = self.overview.presented_velocity(id).unwrap_or(geometry.visual.velocity);
if !self.overview.is_presenting()
&& let Some(slide) = self.workspace_slides.get(&output_id)
&& let Some(workspace) = self.workspaces.workspace_for_window(id)
&& let Some(item) = slide.items.iter().find(|item| item.workspace == workspace)
&& let Some(area) = self.space.output_geometry(&output)
{
bounds.velocity.x += item.velocity.x * f64::from(area.size.w);
bounds.velocity.y += item.velocity.y * f64::from(area.size.h);
}
if let Some(opening) = record.opening {
bounds.velocity = crate::presentation::scaled_visual_velocity(
self.overview.presented_rect(id, geometry.visual.current),
bounds.velocity,
0.97 + 0.03 * opening.current,
0.03 * opening.velocity,
);
}
bounds.set_target(crate::presentation::scaled_visual_rect(rect, 0.98));
let mut source_geometry = window.geometry();
if !self.overview.is_presenting() {
let size = ClientSize::from_rect(geometry.visual.current);
source_geometry.size = (size.width, size.height).into();
}
let order: Vec<_> = if self.overview.is_active() {
self.windows
.overview_windows()
.filter_map(|window| self.windows.ids().get(window).copied())
.collect()
} else {
self.space
.elements()
.rev()
.filter_map(|window| self.windows.ids().get(window).copied())
.collect()
};
let below = order
.iter()
.position(|candidate| *candidate == id)
.and_then(|index| order.get(index + 1))
.copied();
let used = self.render.snapshots().map(|snapshot| snapshot.bytes()).sum::<usize>()
+ self
.render
.closing
.iter()
.map(|window| window.snapshot.bytes())
.sum::<usize>();
let remaining = crate::presentation::SNAPSHOT_BUDGET.saturating_sub(used);
let scale = output.current_scale().fractional_scale();
let result = if let Some(backend) = &self.nested_backend {
match backend.try_borrow_mut() {
Ok(mut backend) => crate::render::capture_resize_snapshot(
backend.renderer(),
window,
source_geometry,
scale,
remaining,
),
Err(_) => return,
}
} else if let Some(backend) = &mut self.direct_backend {
backend.capture_resize_snapshot(window, source_geometry, &output, remaining)
} else {
return;
};
match result {
Ok(Some(snapshot)) => {
tracing::debug!(?id, bytes = snapshot.bytes(), "retained close presentation");
let size = snapshot.texture.size();
let material = window.toplevel().and_then(|toplevel| {
let surface = toplevel.wl_surface();
let (role, generation) = crate::effects::surface_role(surface)?;
(role == crate::effects::SemanticRole::Modal).then(|| {
(
surface.clone(),
role,
generation,
crate::effects::surface_opacity(surface),
)
})
});
let radius = if material.is_some() {
self.theme_settings.material_radius
} else {
self.theme_settings.window_radius
} * decorations;
let client = window.geometry().size;
let fill = (!self.overview.is_presenting()
&& material.is_none()
&& (client.w < source_geometry.size.w || client.h < source_geometry.size.h))
.then_some(self.theme_settings.surface_base_color.0);
self.render.closing.push(crate::render::ClosedWindow {
id,
output: output_id,
below,
bounds,
opacity,
snapshot,
source: Rectangle::from_size((f64::from(size.w), f64::from(size.h)).into()),
radius,
shape: crate::render::window_corner_shape(window),
focus,
decorations,
dim,
fill,
material,
});
self.last_animation_tick = Instant::now();
self.defer_output_redraw(output);
}
Ok(None) => {}
Err(error) => tracing::debug!(%error, ?id, "close snapshot unavailable"),
}
}
}
+50 -8
View File
@@ -34,9 +34,10 @@ impl Ferese {
if workspace.fullscreen.is_some() || !matches!(workspace.layout, WorkspaceLayout::Scrolling(_)) { if workspace.fullscreen.is_some() || !matches!(workspace.layout, WorkspaceLayout::Scrolling(_)) {
return; return;
} }
let Ok(Some(to)) = workspace.layout.directional_neighbor(from, direction, bounds) else { let Ok(neighbor) = workspace.layout.directional_neighbor(from, direction, bounds) else {
return; return;
}; };
let to = neighbor.unwrap_or(from);
let workspace_id = workspace.id; let workspace_id = workspace.id;
let start = self let start = self
.viewport_animations .viewport_animations
@@ -45,7 +46,8 @@ impl Ferese {
.or_else(|| workspace.layout.viewport_x()) .or_else(|| workspace.layout.viewport_x())
.unwrap_or(0.0); .unwrap_or(0.0);
let mut candidate = workspace.layout.clone(); let mut candidate = workspace.layout.clone();
if let WorkspaceLayout::Scrolling(layout) = &mut candidate if to != from
&& let WorkspaceLayout::Scrolling(layout) = &mut candidate
&& layout.slide_focus_from(from, to).is_err() && layout.slide_focus_from(from, to).is_err()
{ {
return; return;
@@ -63,7 +65,11 @@ impl Ferese {
direction, direction,
gesture_direction, gesture_direction,
start, start,
destination: candidate.viewport_x().unwrap_or(start), destination: if to == from {
start
} else {
candidate.viewport_x().unwrap_or(start)
},
progress, progress,
}); });
self.swipe.mark_preview_started(); self.swipe.mark_preview_started();
@@ -91,7 +97,9 @@ impl Ferese {
// Release from the last input position even if no frame rendered that update. // Release from the last input position even if no frame rendered that update.
if current && let Some(viewport) = self.viewport_animations.get_mut(&swipe.workspace) { if current && let Some(viewport) = self.viewport_animations.get_mut(&swipe.workspace) {
viewport.current = swipe.position(); viewport.current = swipe.position();
viewport.velocity = 0.0; viewport.velocity = swipe
.release_velocity(self.swipe.release_velocity, self.swipe.unbounded_release_velocity)
/ self.animation_speed;
} }
let neighbor = self.output_bounds().and_then(|bounds| { let neighbor = self.output_bounds().and_then(|bounds| {
self.workspaces self.workspaces
@@ -460,6 +468,11 @@ impl Ferese {
}; };
let previous = self.workspace_slides.remove(&output); let previous = self.workspace_slides.remove(&output);
let mut slide = WorkspaceSlide::new(previous, from, to, direction); let mut slide = WorkspaceSlide::new(previous, from, to, direction);
slide.spring = SpringConfig {
position_tolerance: 0.00001,
velocity_tolerance: 0.00001,
..self.viewport_spring_config
};
slide.held_progress = Some(progress); slide.held_progress = Some(progress);
slide.gesture = Some((from, to, direction)); slide.gesture = Some((from, to, direction));
self.workspace_slides.insert(output, slide); self.workspace_slides.insert(output, slide);
@@ -481,7 +494,7 @@ impl Ferese {
&& self.activate_managed_workspace_from_swipe(to, expected); && self.activate_managed_workspace_from_swipe(to, expected);
if !committed { if !committed {
if let Some(slide) = self.workspace_slides.get_mut(&output) { if let Some(slide) = self.workspace_slides.get_mut(&output) {
slide.release(false); slide.release_with_velocity(false, self.swipe.release_velocity / self.animation_speed);
} }
self.last_animation_tick = Instant::now(); self.last_animation_tick = Instant::now();
self.relayout(); self.relayout();
@@ -497,26 +510,55 @@ impl Ferese {
workspace: WorkspaceId, workspace: WorkspaceId,
slide_direction: Option<SwipeDirection>, slide_direction: Option<SwipeDirection>,
) { ) {
if owner != requested_output {
return;
}
let mut previous_slide = self.workspace_slides.remove(&owner); let mut previous_slide = self.workspace_slides.remove(&owner);
if previous == Some(workspace) {
if let Some(slide) = previous_slide {
self.workspace_slides.insert(owner, slide);
}
return;
}
if owner == requested_output if owner == requested_output
&& let Some(slide) = previous_slide.as_mut() && let Some(slide) = previous_slide.as_mut()
&& slide && slide
.gesture .gesture
.is_some_and(|(_, to, direction)| to == workspace && Some(direction) == slide_direction) .is_some_and(|(_, to, direction)| to == workspace && Some(direction) == slide_direction)
{ {
slide.release(true); slide.release_with_velocity(true, self.swipe.release_velocity / self.animation_speed);
self.last_animation_tick = Instant::now(); self.last_animation_tick = Instant::now();
self.workspace_slides.insert(owner, previous_slide.unwrap()); self.workspace_slides.insert(owner, previous_slide.unwrap());
return; return;
} }
let slide_direction = slide_direction.or_else(|| {
let from = previous?;
let ids = self
.output_workspaces
.ordered_workspace_ids(&self.workspaces, owner)
.collect::<Vec<_>>();
let from = ids.iter().position(|id| *id == from)?;
let to = ids.iter().position(|id| *id == workspace)?;
Some(if to > from {
SwipeDirection::Up
} else {
SwipeDirection::Down
})
});
if owner == requested_output if owner == requested_output
&& let (Some(from), Some(direction)) = (previous, slide_direction) && let (Some(from), Some(direction)) = (previous, slide_direction)
&& from != workspace && from != workspace
&& self.animations_enabled && self.animations_enabled
&& !self.overview.is_presenting()
{ {
self.last_animation_tick = Instant::now(); self.last_animation_tick = Instant::now();
self.workspace_slides let mut slide = WorkspaceSlide::new(previous_slide, from, workspace, direction);
.insert(owner, WorkspaceSlide::new(previous_slide, from, workspace, direction)); slide.spring = SpringConfig {
position_tolerance: 0.00001,
velocity_tolerance: 0.00001,
..self.viewport_spring_config
};
self.workspace_slides.insert(owner, slide);
} }
} }
+51 -24
View File
@@ -7,13 +7,15 @@ pub(crate) struct FrameScene {
pub overview: crate::overview::OverviewMotion, pub overview: crate::overview::OverviewMotion,
// Plane selection and scheduling reuse the activity sampled with this scene. // Plane selection and scheduling reuse the activity sampled with this scene.
pub animating: bool, pub animating: bool,
pub delta: Duration,
} }
pub(crate) struct WindowFrame { pub(crate) struct WindowFrame {
pub geometry: WindowGeometry, pub geometry: WindowGeometry,
pub rect: Rect, pub rect: Rect,
pub close_scale: f64, pub scale_content: bool,
pub close_alpha: f32, pub native_size: Option<ClientSize>,
pub alpha: f32,
pub focus: f64, pub focus: f64,
pub dim: f64, pub dim: f64,
} }
@@ -68,7 +70,12 @@ impl Ferese {
} }
pub(crate) fn output_has_animations(&self, output: &Output) -> bool { pub(crate) fn output_has_animations(&self, output: &Output) -> bool {
if self.overview.is_animating(self.spring_config) if self
.render
.closing
.iter()
.any(|window| Some(window.output) == self.output_id(output))
|| self.overview.is_animating(self.spring_config)
|| self || self
.dismissing_popups .dismissing_popups
.iter() .iter()
@@ -80,7 +87,7 @@ impl Ferese {
if self if self
.output_id(output) .output_id(output)
.and_then(|id| self.workspace_slides.get(&id)) .and_then(|id| self.workspace_slides.get(&id))
.is_some_and(|slide| slide.held_progress.is_none() && slide.elapsed < WORKSPACE_SLIDE_DURATION) .is_some_and(|slide| slide.held_progress.is_none() && slide.moving())
{ {
return true; return true;
} }
@@ -95,8 +102,8 @@ impl Ferese {
} }
if record.resize.is_some() if record.resize.is_some()
|| record.opening.is_some()
|| self.render.snapshot(&id).is_some() || self.render.snapshot(&id).is_some()
|| record.closing.as_ref().is_some_and(|close| !close.close_sent)
|| record.focus.as_ref().is_some_and(|focus| focus.is_animating()) || record.focus.as_ref().is_some_and(|focus| focus.is_animating())
|| record.dimming.as_ref().is_some_and(|dim| dim.is_animating()) || record.dimming.as_ref().is_some_and(|dim| dim.is_animating())
{ {
@@ -117,15 +124,10 @@ impl Ferese {
.focus_swipe .focus_swipe
.as_ref() .as_ref()
.is_some_and(|swipe| swipe.workspace == workspace); .is_some_and(|swipe| swipe.workspace == workspace);
!held !held && viewport.advance(Duration::ZERO, self.viewport_spring_config)
&& viewport.advance_with_policy(
Duration::ZERO,
self.viewport_spring_config,
CrossingPolicy::NoCrossing,
)
}) || record.coupled_width.as_ref().is_some_and(|(_, width)| { }) || record.coupled_width.as_ref().is_some_and(|(_, width)| {
let mut width = *width; let mut width = *width;
width.advance_with_policy(Duration::ZERO, self.viewport_spring_config, CrossingPolicy::NoCrossing) width.advance(Duration::ZERO, self.viewport_spring_config)
}) })
}) })
} }
@@ -187,12 +189,19 @@ impl Ferese {
rect.y += y; rect.y += y;
} }
let mut close = record.closing.as_ref().copied().unwrap_or_default(); let mut opening = record.opening.unwrap_or_else(|| AnimatedValue::new(1.0));
if record.closing.is_some() && !delta.is_zero() { if !self.animations_enabled {
close.advance(delta, true); opening.snap();
} else if !delta.is_zero() {
opening.advance(
delta,
SpringConfig {
position_tolerance: 0.00001,
velocity_tolerance: 0.00001,
..self.spring_config
},
);
} }
let eased = smoothstep(close.progress);
let focused = if self.overview.is_active() { let focused = if self.overview.is_active() {
self.overview_selected(id) self.overview_selected(id)
} else { } else {
@@ -211,14 +220,31 @@ impl Ferese {
motion.current 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, rect,
close_scale: 1.0 - eased * 0.02, scale_content: overview.is_presenting(),
close_alpha: (1.0 - eased) as f32, native_size,
focus: sample_dim(record.focus.as_ref(), if focused { 1.0 } else { 0.0 }), 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), dim: sample_dim(record.dimming.as_ref(), 0.0),
}, },
); );
@@ -228,6 +254,7 @@ impl Ferese {
windows, windows,
overview, overview,
animating, animating,
delta,
} }
} }
} }
@@ -252,9 +279,9 @@ fn predict_geometry(
if let Some((mut world, mut viewport, held)) = world if let Some((mut world, mut viewport, held)) = world
&& !zooming && !zooming
{ {
world.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing); world.advance(delta, spring);
if !held { if !held {
viewport.advance_with_policy(delta, viewport_spring, CrossingPolicy::NoCrossing); viewport.advance(delta, viewport_spring);
} }
predicted.visual.current.x = world.current - viewport.current; predicted.visual.current.x = world.current - viewport.current;
@@ -262,7 +289,7 @@ fn predict_geometry(
} }
if let Some(mut width) = width { if let Some(mut width) = width {
width.advance_with_policy(delta, viewport_spring, CrossingPolicy::NoCrossing); width.advance(delta, viewport_spring);
predicted.visual.current.width = width.current; predicted.visual.current.width = width.current;
predicted.visual.velocity.width = width.velocity; predicted.visual.velocity.width = width.velocity;
} }
@@ -454,7 +481,7 @@ mod tests {
state.focused_window = Some(id); state.focused_window = Some(id);
state state
.windows .windows
.update(id, |record| record.focus = Some(crate::dimming::DimAnimation::new(1.0))); .update(id, |record| record.focus = Some(AnimatedValue::new(1.0)));
state.focused_window = Some(other_id); state.focused_window = Some(other_id);
assert!(state.output_has_pending_visual_changes(&output)); assert!(state.output_has_pending_visual_changes(&output));
state.animations_enabled = false; state.animations_enabled = false;
+5 -4
View File
@@ -21,9 +21,10 @@ pub(crate) struct WindowRecord {
pub title: String, pub title: String,
pub metadata_revision: u64, pub metadata_revision: u64,
pub resize: Option<ResizeTransaction>, pub resize: Option<ResizeTransaction>,
pub focus: Option<crate::dimming::DimAnimation>, pub focus: Option<AnimatedValue>,
pub dimming: Option<crate::dimming::DimAnimation>, pub dimming: Option<crate::dimming::DimAnimation>,
pub(super) closing: Option<super::ClosingAnimation>, pub opening: Option<AnimatedValue>,
pub mapped_once: bool,
pub world_x: Option<(WorkspaceId, AnimatedValue)>, pub world_x: Option<(WorkspaceId, AnimatedValue)>,
pub coupled_width: Option<(WorkspaceId, AnimatedValue)>, pub coupled_width: Option<(WorkspaceId, AnimatedValue)>,
pub maximized: bool, pub maximized: bool,
@@ -302,8 +303,8 @@ mod tests {
record.resize_anchor = Some((true, false, Rect::new(0., 0., 800., 600.))); record.resize_anchor = Some((true, false, Rect::new(0., 0., 800., 600.)));
record.column_width_pending = true; record.column_width_pending = true;
record.rules_applied = true; record.rules_applied = true;
record.focus = Some(crate::dimming::DimAnimation::new(1.0)); record.focus = Some(AnimatedValue::new(1.0));
record.closing = Some(super::super::ClosingAnimation::default()); record.opening = Some(AnimatedValue::new(0.0));
record.world_x = Some((WorkspaceId(1), AnimatedValue::new(50.0))); record.world_x = Some((WorkspaceId(1), AnimatedValue::new(50.0)));
record.coupled_width = Some((WorkspaceId(1), AnimatedValue::new(800.0))); record.coupled_width = Some((WorkspaceId(1), AnimatedValue::new(800.0)));
}); });
+24 -22
View File
@@ -417,6 +417,7 @@ impl Ferese {
} }
pub fn remove_tiled_window(&mut self, window: &Window) { pub fn remove_tiled_window(&mut self, window: &Window) {
self.retain_closed_window(window);
// Uncommitted toplevels have not entered a workspace yet. // Uncommitted toplevels have not entered a workspace yet.
self.space.unmap_elem(window); self.space.unmap_elem(window);
let Some(id) = self.windows.remove(window) else { let Some(id) = self.windows.remove(window) else {
@@ -493,7 +494,13 @@ impl Ferese {
return; return;
} }
let used: usize = self.render.snapshots().map(|snapshot| snapshot.bytes()).sum(); let used: usize = self.render.snapshots().map(|snapshot| snapshot.bytes()).sum::<usize>()
+ self
.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;
@@ -557,6 +564,20 @@ impl Ferese {
return; return;
}; };
if let Some(record) = self.windows.record_mut(id)
&& !record.mapped_once
{
record.mapped_once = true;
if self.animations_enabled {
record.opening = Some(AnimatedValue {
current: 0.0,
target: 1.0,
velocity: 0.0,
});
self.last_animation_tick = Instant::now();
}
}
let fullscreen = self let fullscreen = self
.workspaces .workspaces
.workspace_for_window(id) .workspace_for_window(id)
@@ -648,35 +669,16 @@ impl Ferese {
} }
pub fn close_focused_window(&mut self) { pub fn close_focused_window(&mut self) {
let Some(focused) = self.focused_window else { if let Some(focused) = self.focused_window {
return;
};
if !self.animations_enabled {
self.send_window_close(focused); self.send_window_close(focused);
return;
} }
self.windows.update(focused, |record| {
record.closing.get_or_insert_default();
});
crate::backends::direct::render_window(self, focused);
} }
pub(crate) fn close_managed_window(&mut self, id: WindowId) -> bool { pub(crate) fn close_managed_window(&mut self, id: WindowId) -> bool {
if self.windows.window(id).is_none() { if self.windows.window(id).is_none() {
return false; return false;
} }
self.send_window_close(id);
if self.animations_enabled {
self.windows.update(id, |record| {
record.closing.get_or_insert_default();
});
crate::backends::direct::render_window(self, id);
} else {
self.send_window_close(id);
}
true true
} }
+8 -1
View File
@@ -95,6 +95,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
let mut backend = backend.borrow_mut(); let mut backend = backend.borrow_mut();
match event { match event {
WinitEvent::Resized { size, scale_factor } => { WinitEvent::Resized { size, scale_factor } => {
tracing::debug!(target: "ferese::nested_input", ?size, scale_factor, "host resized nested output");
let scale = normalized_scale(scale_factor); let scale = normalized_scale(scale_factor);
let rate = backend let rate = backend
.window() .window()
@@ -115,7 +116,13 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
} }
backend.window().request_redraw(); backend.window().request_redraw();
} }
WinitEvent::Input(event) => state.process_input_event(event), WinitEvent::Input(event) => {
let kind = std::mem::discriminant(&event);
state.process_input_event(event);
tracing::debug!(target: "ferese::nested_input", ?kind,
pointer = ?state.seat.get_pointer().map(|pointer| pointer.current_location()),
"host input in nested output");
}
WinitEvent::Redraw => { WinitEvent::Redraw => {
state.advance_animations(Instant::now()); state.advance_animations(Instant::now());
let age = backend.buffer_age().unwrap_or(0); let age = backend.buffer_age().unwrap_or(0);
+1
View File
@@ -7,3 +7,4 @@ rust-version.workspace = true
[dependencies] [dependencies]
ferese-layout = { path = "../ferese-layout" } ferese-layout = { path = "../ferese-layout" }
serde.workspace = true
+135
View File
@@ -0,0 +1,135 @@
//! Input estimation and destination selection; all post-release motion uses springs.
use std::{collections::VecDeque, time::Duration};
#[derive(Default, Debug)]
pub struct VelocityTracker {
samples: VecDeque<(Duration, f64)>,
}
impl VelocityTracker {
pub fn push(&mut self, time: Duration, position: f64) {
if !position.is_finite() {
return;
}
if let Some(&(last, _)) = self.samples.back() {
if time < last {
return;
}
if time == last {
self.samples.pop_back();
}
}
self.samples.push_back((time, position));
while self
.samples
.front()
.is_some_and(|(t, _)| time.saturating_sub(*t) > Duration::from_millis(100))
|| self.samples.len() > 32
{
self.samples.pop_front();
}
}
pub fn velocity(&self, now: Duration) -> f64 {
let Some(&(last, origin)) = self.samples.back() else {
return 0.0;
};
if now.saturating_sub(last) > Duration::from_millis(40) || self.samples.len() < 2 {
return 0.0;
}
// Fit positions relative to the newest sample, with time normalized to
// the history horizon to avoid ill-conditioned powers of tiny seconds.
let degree = if self.samples.len() >= 3 { 2 } else { 1 };
let mut matrix = [[0.0; 4]; 3];
for &(time, position) in &self.samples {
let t = -last.saturating_sub(time).as_secs_f64() / 0.1;
let powers = [1.0, t, t * t, t * t * t, t * t * t * t];
for row in 0..=degree {
for col in 0..=degree {
matrix[row][col] += powers[row + col];
}
matrix[row][degree + 1] += powers[row] * (position - origin);
}
}
for col in 0..=degree {
let pivot = (col..=degree)
.max_by(|&a, &b| matrix[a][col].abs().total_cmp(&matrix[b][col].abs()))
.unwrap();
matrix.swap(col, pivot);
let divisor = matrix[col][col];
if divisor.abs() < 1e-12 {
let (first, position) = self.samples[0];
let dt = last.saturating_sub(first).as_secs_f64();
return if dt > 0.0 { (origin - position) / dt } else { 0.0 };
}
for entry in col..=degree + 1 {
matrix[col][entry] /= divisor;
}
for row in 0..=degree {
if row == col {
continue;
}
let factor = matrix[row][col];
for entry in col..=degree + 1 {
matrix[row][entry] -= factor * matrix[col][entry];
}
}
}
matrix[1][degree + 1] / 0.1
}
}
pub fn project(position: f64, velocity: f64, rate: f64) -> f64 {
if !velocity.is_finite() || !(0.0..1.0).contains(&rate) || rate == 0.0 {
return position;
}
position - velocity / (1000.0 * rate.ln())
}
/// Nonlinear resistance, bounded by `extent`; the derivative transfers the
/// velocity of the displayed (resisted) position into the release spring.
pub fn rubber_band(distance: f64, extent: f64, resistance: f64) -> (f64, f64) {
if !distance.is_finite() || !extent.is_finite() || extent <= 0.0 || !resistance.is_finite() || resistance <= 0.0 {
return (0.0, 0.0);
}
let denominator = 1.0 + resistance * distance.abs() / extent;
(
distance.signum() * extent * (1.0 - denominator.recip()),
resistance / denominator.powi(2),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rubber_band_is_bounded_symmetric_and_has_matching_velocity() {
for x in [0.0, 1.0, 100.0, 10000.0] {
let (position, derivative) = rubber_band(x, 64.0, 0.55);
assert!((0.0..64.0).contains(&position));
assert_eq!(rubber_band(-x, 64.0, 0.55).0, -position);
let measured = (rubber_band(x + 0.0001, 64.0, 0.55).0 - rubber_band(x - 0.0001, 64.0, 0.55).0) / 0.0002;
assert!((measured - derivative).abs() < 1e-6);
}
}
#[test]
fn quadratic_velocity_and_pause() {
let mut tracker = VelocityTracker::default();
for ms in [0, 7, 19, 40, 60, 90] {
let t = ms as f64 / 1000.0;
tracker.push(Duration::from_millis(ms), 100.0 * t + 200.0 * t * t);
}
assert!((tracker.velocity(Duration::from_millis(90)) - 136.0).abs() < 1e-8);
assert_eq!(tracker.velocity(Duration::from_millis(140)), 0.0);
assert!((project(0.0, 1.0, 0.997) - 0.332833082957).abs() < 1e-10);
}
#[test]
fn duplicate_and_out_of_order_samples() {
let mut tracker = VelocityTracker::default();
tracker.push(Duration::ZERO, 0.0);
tracker.push(Duration::from_millis(10), 1.0);
tracker.push(Duration::from_millis(10), 2.0);
tracker.push(Duration::from_millis(5), 99.0);
assert!((tracker.velocity(Duration::from_millis(10)) - 200.0).abs() < 1e-10);
}
}
+38 -80
View File
@@ -2,7 +2,10 @@ use std::time::Duration;
use ferese_layout::Rect; use ferese_layout::Rect;
mod settings;
mod spring; mod spring;
pub use settings::{MotionSettings, SpringSettings};
pub mod gesture;
/// Crossing behavior belongs to the property, not the physical solver. /// Crossing behavior belongs to the property, not the physical solver.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -20,6 +23,7 @@ pub struct SpringConfig {
pub damping: f64, pub damping: f64,
pub position_tolerance: f64, pub position_tolerance: f64,
pub velocity_tolerance: f64, pub velocity_tolerance: f64,
pub crossing: CrossingPolicy,
} }
impl Default for SpringConfig { impl Default for SpringConfig {
@@ -30,6 +34,7 @@ impl Default for SpringConfig {
damping: 53.0, damping: 53.0,
position_tolerance: 0.1, position_tolerance: 0.1,
velocity_tolerance: 0.1, velocity_tolerance: 0.1,
crossing: CrossingPolicy::NoCrossing,
} }
} }
} }
@@ -44,6 +49,7 @@ impl SpringConfig {
damping: non_negative_or(self.damping, defaults.damping), damping: non_negative_or(self.damping, defaults.damping),
position_tolerance: positive_or(self.position_tolerance, defaults.position_tolerance), position_tolerance: positive_or(self.position_tolerance, defaults.position_tolerance),
velocity_tolerance: positive_or(self.velocity_tolerance, defaults.velocity_tolerance), velocity_tolerance: positive_or(self.velocity_tolerance, defaults.velocity_tolerance),
crossing: self.crossing,
} }
} }
} }
@@ -71,6 +77,14 @@ pub struct AnimatedValue {
} }
impl AnimatedValue { impl AnimatedValue {
pub fn needs_update(&self, target: f64) -> bool {
self.target != target || self.is_animating()
}
pub fn is_animating(&self) -> bool {
self.current != self.target || self.velocity != 0.0
}
pub fn new(value: f64) -> Self { pub fn new(value: f64) -> Self {
let value = finite_or_zero(value); let value = finite_or_zero(value);
@@ -86,13 +100,7 @@ impl AnimatedValue {
} }
pub fn retarget_preserving_motion(&mut self, target: f64) { pub fn retarget_preserving_motion(&mut self, target: f64) {
let target = finite_or_zero(target); self.set_target(target);
let direction = (target - self.current).signum();
if direction != 0.0 && self.velocity.signum() != direction {
self.velocity *= 0.35;
}
self.target = target;
} }
pub fn snap(&mut self) { pub fn snap(&mut self) {
@@ -100,9 +108,9 @@ impl AnimatedValue {
self.velocity = 0.0; self.velocity = 0.0;
} }
/// Compatibility entry point; existing presentation properties do not cross. /// Advance with the spring's configured crossing policy.
pub fn advance(&mut self, delta: Duration, config: SpringConfig) -> bool { pub fn advance(&mut self, delta: Duration, config: SpringConfig) -> bool {
self.advance_with_policy(delta, config, CrossingPolicy::NoCrossing) self.advance_with_policy(delta, config, config.crossing)
} }
pub fn advance_with_policy(&mut self, delta: Duration, config: SpringConfig, policy: CrossingPolicy) -> bool { pub fn advance_with_policy(&mut self, delta: Duration, config: SpringConfig, policy: CrossingPolicy) -> bool {
@@ -156,7 +164,7 @@ impl AnimatedRect {
} }
pub fn advance(&mut self, delta: Duration, config: SpringConfig) -> bool { pub fn advance(&mut self, delta: Duration, config: SpringConfig) -> bool {
self.advance_with_policy(delta, config, CrossingPolicy::NoCrossing) self.advance_with_policy(delta, config, config.crossing)
} }
pub fn advance_with_policy(&mut self, delta: Duration, config: SpringConfig, policy: CrossingPolicy) -> bool { pub fn advance_with_policy(&mut self, delta: Duration, config: SpringConfig, policy: CrossingPolicy) -> bool {
@@ -304,9 +312,7 @@ pub struct WindowGeometry {
/// Maximized/fullscreen geometry and decorations share a clock, including on reversal. /// Maximized/fullscreen geometry and decorations share a clock, including on reversal.
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
struct ZoomTransition { struct ZoomTransition {
from: Rect, decorations: AnimatedValue,
decorations_from: f64,
progress: AnimatedValue,
} }
impl WindowGeometry { impl WindowGeometry {
@@ -359,28 +365,12 @@ impl WindowGeometry {
let rect = normalized_rect(rect); let rect = normalized_rect(rect);
let before = (self.visual.current, self.decorations); let before = (self.visual.current, self.decorations);
if self.mode != mode || (self.zoom.is_some() && self.logical != rect) { if self.mode != mode || (self.zoom.is_some() && self.logical != rect) {
let mut progress = AnimatedValue::new(0.0); let mut decorations = self
progress.set_target(1.0); .zoom
let from = self.visual.current; .map(|zoom| zoom.decorations)
let distance = [ .unwrap_or_else(|| AnimatedValue::new(self.decorations));
rect.x - from.x, decorations.set_target(if mode == PresentationMode::Fullscreen { 0.0 } else { 1.0 });
rect.y - from.y, self.zoom = Some(ZoomTransition { decorations });
rect.width - from.width,
rect.height - from.height,
];
let velocity = self.visual.velocity;
let speed = [velocity.x, velocity.y, velocity.width, velocity.height];
let squared_length = distance.iter().map(|value| value * value).sum::<f64>();
if squared_length > 0.001 {
let projected = distance.iter().zip(speed).map(|(d, v)| d * v).sum::<f64>() / squared_length;
// Keep forward momentum; soften a reversal without jumping position.
progress.velocity = if projected < 0.0 { projected * 0.35 } else { projected };
}
self.zoom = Some(ZoomTransition {
from: self.visual.current,
decorations_from: self.decorations,
progress,
});
} }
self.mode = mode; self.mode = mode;
self.logical = rect; self.logical = rect;
@@ -440,55 +430,23 @@ impl WindowGeometry {
return self.zoom.is_some() || !self.visual.is_settled(config); return self.zoom.is_some() || !self.visual.is_settled(config);
} }
// Retarget the existing rectangle directly: projecting four independent
// velocities onto a new scalar progress loses motion on interruption.
let geometry_active = self.visual.advance(delta, config);
if let Some(zoom) = &mut self.zoom { if let Some(zoom) = &mut self.zoom {
let target = self.visual.target; let decoration_config = SpringConfig {
// Spring tolerances describe pixels and pixels/second, whereas this position_tolerance: 0.00001,
// spring advances unit progress. Without conversion it snaps several velocity_tolerance: 0.00001,
// pixels early on large zooms, visibly shifting the trailing edge.
let travel = rect_distance(zoom.from, target)
.max((zoom.from.x + zoom.from.width - target.x - target.width).abs())
.max((zoom.from.y + zoom.from.height - target.y - target.height).abs())
.max(1.0);
let config = config.normalized();
let progress_config = SpringConfig {
position_tolerance: config.position_tolerance / travel,
velocity_tolerance: config.velocity_tolerance / travel,
..config ..config
}; };
let active = zoom let decorations_active = zoom.decorations.advance(delta, decoration_config);
.progress self.decorations = zoom.decorations.current.clamp(0.0, 1.0);
.advance_with_policy(delta, progress_config, CrossingPolicy::NoCrossing); if !geometry_active && !decorations_active {
let p = zoom.progress.current;
let lerp = |a: f64, b: f64| a + (b - a) * p;
self.visual.current = Rect::new(
lerp(zoom.from.x, target.x),
lerp(zoom.from.y, target.y),
lerp(zoom.from.width, target.width),
lerp(zoom.from.height, target.height),
);
self.visual.velocity = RectVelocity {
x: (target.x - zoom.from.x) * zoom.progress.velocity,
y: (target.y - zoom.from.y) * zoom.progress.velocity,
width: (target.width - zoom.from.width) * zoom.progress.velocity,
height: (target.height - zoom.from.height) * zoom.progress.velocity,
};
self.decorations = lerp(
zoom.decorations_from,
if self.mode == PresentationMode::Fullscreen {
0.0
} else {
1.0
},
);
if !active {
self.zoom = None; self.zoom = None;
self.visual.snap();
} }
return active; return geometry_active || decorations_active;
} }
geometry_active
self.visual
.advance_with_policy(delta, config, CrossingPolicy::NoCrossing)
} }
pub fn inverse_visual_point(&self, x: f64, y: f64) -> Option<(f64, f64)> { pub fn inverse_visual_point(&self, x: f64, y: f64) -> Option<(f64, f64)> {
@@ -931,7 +889,7 @@ mod tests {
} }
#[test] #[test]
fn scalar_spring_softens_opposing_velocity_on_reversal() { fn scalar_spring_preserves_opposing_velocity_on_reversal() {
let mut value = AnimatedValue::new(0.0); let mut value = AnimatedValue::new(0.0);
value.set_target(1_000.0); value.set_target(1_000.0);
value.advance(Duration::from_millis(40), SpringConfig::default()); value.advance(Duration::from_millis(40), SpringConfig::default());
@@ -941,7 +899,7 @@ mod tests {
value.retarget_preserving_motion(-500.0); value.retarget_preserving_motion(-500.0);
assert!(value.velocity > 0.0); assert!(value.velocity > 0.0);
assert_eq!(value.velocity, velocity * 0.35); assert_eq!(value.velocity, velocity);
assert_eq!(value.current, current); assert_eq!(value.current, current);
} }
+298
View File
@@ -0,0 +1,298 @@
use crate::{CrossingPolicy, SpringConfig};
/// One configuration contract for compositor and out-of-process shell motion.
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
#[serde(default)]
pub struct MotionSettings {
pub enabled: bool,
pub reduced_motion: bool,
pub speed: f64,
pub spring: SpringSettings,
pub viewport_spring: SpringSettings,
}
impl Default for MotionSettings {
fn default() -> Self {
Self {
enabled: true,
reduced_motion: false,
speed: 1.0,
spring: SpringSettings::default(),
viewport_spring: SpringSettings::default(),
}
}
}
impl MotionSettings {
pub fn motion_enabled(self) -> bool {
self.enabled && !self.reduced_motion
}
pub fn validate(self) -> Result<(), String> {
if !self.speed.is_finite() || self.speed <= 0.0 {
return Err("animation speed must be finite and positive".into());
}
self.spring.resolve(SpringConfig::default())?;
self.viewport_config()?;
Ok(())
}
pub fn viewport_config(self) -> Result<SpringConfig, String> {
let mut settings = self.viewport_spring;
if settings.duration_ms.is_none() && settings.damping.is_none() && settings.damping_ratio.is_none() {
settings.damping_ratio = Some(1.0);
}
settings.resolve(SpringConfig {
stiffness: 320.0,
damping: 2.0 * 320.0_f64.sqrt(),
..SpringConfig::default()
})
}
/// Only small opacity changes should use a timed duration.
pub fn duration(self, milliseconds: f64) -> std::time::Duration {
if !self.motion_enabled() {
return std::time::Duration::ZERO;
}
std::time::Duration::from_secs_f64(milliseconds / 1000.0 / self.speed)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Deserialize)]
#[serde(default)]
pub struct SpringSettings {
pub mass: Option<f64>,
pub stiffness: Option<f64>,
pub damping: Option<f64>,
pub damping_ratio: Option<f64>,
pub duration_ms: Option<f64>,
pub bounce: Option<f64>,
pub overshoot: bool,
}
impl SpringSettings {
pub fn resolve(self, base: SpringConfig) -> Result<SpringConfig, String> {
let positive = |value: f64| value.is_finite() && value > 0.0;
let mut config = SpringConfig {
mass: self.mass.unwrap_or(base.mass),
stiffness: self.stiffness.unwrap_or(base.stiffness),
damping: self.damping.unwrap_or(base.damping),
crossing: if self.overshoot {
CrossingPolicy::AllowOvershoot
} else {
CrossingPolicy::NoCrossing
},
..base
};
if !positive(config.mass) || !positive(config.stiffness) || !config.damping.is_finite() || config.damping < 0.0
{
return Err("spring mass/stiffness must be positive and damping nonnegative".into());
}
if let Some(duration) = self.duration_ms {
if self.stiffness.is_some() || self.damping.is_some() || self.damping_ratio.is_some() {
return Err("spring duration-ms cannot be combined with stiffness/damping/damping-ratio".into());
}
let bounce = self.bounce.unwrap_or(0.0);
if !positive(duration)
|| !bounce.is_finite()
|| (bounce <= -1.0 || bounce >= 1.0)
|| (bounce > 0.0 && !self.overshoot)
{
return Err("spring duration-ms must be positive; bounce must be between -1 and 1 (exclusive), and positive bounce requires overshoot".into());
}
let ratio = if bounce >= 0.0 {
1.0 - bounce
} else {
1.0 / (1.0 + bounce)
};
let omega = std::f64::consts::TAU / (duration / 1000.0);
config.stiffness = config.mass * omega * omega;
config.damping = 2.0 * config.mass * omega * ratio;
} else {
if self.bounce.is_some() {
return Err("spring bounce requires duration-ms".into());
}
if let Some(ratio) = self.damping_ratio {
if self.damping.is_some() || !ratio.is_finite() || ratio < 0.0 {
return Err("spring damping-ratio must be nonnegative and cannot be combined with damping".into());
}
config.damping = 2.0 * ratio * (config.mass * config.stiffness).sqrt();
}
}
if !positive(config.stiffness) || !config.damping.is_finite() {
return Err("spring coefficients overflow".into());
}
// Presentation lifetimes can depend on settlement. An undamped spring
// only settles when the first-crossing clamp is enabled.
if config.damping == 0.0 && config.crossing == CrossingPolicy::AllowOvershoot {
return Err("spring overshoot requires positive damping so motion can settle".into());
}
Ok(config)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn undamped_overshoot_is_rejected_for_both_motion_properties() {
for spring in [
SpringSettings {
damping: Some(0.0),
overshoot: true,
..Default::default()
},
SpringSettings {
damping_ratio: Some(0.0),
overshoot: true,
..Default::default()
},
] {
assert!(
MotionSettings {
spring,
..Default::default()
}
.validate()
.is_err()
);
assert!(
MotionSettings {
viewport_spring: spring,
..Default::default()
}
.validate()
.is_err()
);
}
assert!(
SpringSettings {
overshoot: true,
..Default::default()
}
.resolve(SpringConfig {
damping: 0.0,
..Default::default()
})
.is_err()
);
}
#[test]
fn undamped_motion_needs_the_crossing_clamp_to_settle() {
use crate::AnimatedValue;
let clamped = SpringSettings {
damping: Some(0.0),
..Default::default()
}
.resolve(SpringConfig::default())
.unwrap();
let mut closing = AnimatedValue {
current: 1.0,
target: 0.0,
velocity: 0.0,
};
let mut free = closing;
for _ in 0..3600 {
let dt = std::time::Duration::from_secs_f64(1.0 / 60.0);
closing.advance(dt, clamped);
free.advance(
dt,
SpringConfig {
crossing: CrossingPolicy::AllowOvershoot,
..clamped
},
);
assert!(free.current != free.target || free.velocity != 0.0);
}
assert_eq!(closing.current, closing.target);
assert_eq!(closing.velocity, 0.0);
}
#[test]
fn viewport_legacy_damping_tracks_overridden_coefficients() {
let settings = MotionSettings {
viewport_spring: SpringSettings {
mass: Some(2.0),
stiffness: Some(500.0),
..Default::default()
},
..Default::default()
};
let spring = settings.viewport_config().unwrap();
assert_eq!(spring.damping, 2.0 * 1000.0_f64.sqrt());
assert_eq!(spring.crossing, CrossingPolicy::NoCrossing);
assert!(settings.validate().is_ok());
}
#[test]
fn policy_applies_speed_and_reduced_motion_consistently() {
let slow = MotionSettings {
speed: 0.5,
..Default::default()
};
assert_eq!(slow.duration(120.0), std::time::Duration::from_millis(240));
assert!(
!MotionSettings {
reduced_motion: true,
..slow
}
.motion_enabled()
);
assert!(
MotionSettings {
reduced_motion: true,
..slow
}
.duration(120.0)
.is_zero()
);
assert!(MotionSettings { speed: 0.0, ..slow }.validate().is_err());
}
#[test]
fn perceptual_conversion_and_legacy_defaults() {
let base = SpringConfig::default();
assert_eq!(SpringSettings::default().resolve(base).unwrap(), base);
for (bounce, ratio) in [(0.0, 1.0), (0.3, 0.7), (-0.5, 2.0)] {
let spring = SpringSettings {
duration_ms: Some(500.0),
bounce: Some(bounce),
overshoot: bounce > 0.0,
..Default::default()
}
.resolve(base)
.unwrap();
assert!((spring.stiffness - 157.913670417).abs() < 1e-8);
assert!((spring.damping / (2.0 * spring.stiffness.sqrt()) - ratio).abs() < 1e-10);
assert!((std::f64::consts::TAU * (spring.mass / spring.stiffness).sqrt() - 0.5).abs() < 1e-10);
}
}
#[test]
fn rejects_ambiguous_or_nonsettling_configuration() {
for settings in [
SpringSettings {
duration_ms: Some(300.0),
stiffness: Some(100.0),
..Default::default()
},
SpringSettings {
duration_ms: Some(300.0),
bounce: Some(0.3),
..Default::default()
},
SpringSettings {
duration_ms: Some(300.0),
bounce: Some(-1.0),
..Default::default()
},
SpringSettings {
duration_ms: Some(300.0),
bounce: Some(1.0),
overshoot: true,
..Default::default()
},
] {
assert!(settings.resolve(SpringConfig::default()).is_err());
}
}
}
+7 -5
View File
@@ -110,7 +110,7 @@ fn capture_old_golden_trace_and_retarget_policy() {
value.retarget_preserving_motion(-500.0); value.retarget_preserving_motion(-500.0);
println!("old reversal: {}, {}", value.current, value.velocity); println!("old reversal: {}, {}", value.current, value.velocity);
assert_eq!(value.current, position); assert_eq!(value.current, position);
assert_eq!(value.velocity, velocity * 0.35); assert_eq!(value.velocity, velocity);
} }
#[test] #[test]
@@ -282,7 +282,7 @@ fn retarget_golden_policy_values() {
old.retarget_preserving_motion(2000.); old.retarget_preserving_motion(2000.);
close(old.velocity, 9837.123292018367, 1e-9); close(old.velocity, 9837.123292018367, 1e-9);
old.retarget_preserving_motion(-500.); old.retarget_preserving_motion(-500.);
close(old.velocity, 3442.993152206428, 1e-9); close(old.velocity, 9837.123292018367, 1e-9);
} }
#[test] #[test]
@@ -342,7 +342,7 @@ fn rapid_retarget_trace_preserves_position_and_the_golden_policy() {
let before = value; let before = value;
value.retarget_preserving_motion(-500.0); value.retarget_preserving_motion(-500.0);
assert_eq!(value.current, before.current); assert_eq!(value.current, before.current);
assert_eq!(value.velocity, before.velocity * 0.35); assert_eq!(value.velocity, before.velocity);
clock.sample(Duration::from_millis(81), &mut value, SpringConfig::default()); clock.sample(Duration::from_millis(81), &mut value, SpringConfig::default());
assert_ne!(value.velocity, 0.0); assert_ne!(value.velocity, 0.0);
@@ -350,11 +350,11 @@ fn rapid_retarget_trace_preserves_position_and_the_golden_policy() {
value.current = 318.94300938241565; value.current = 318.94300938241565;
value.velocity = 9837.123292018367; value.velocity = 9837.123292018367;
value.retarget_preserving_motion(-500.0); value.retarget_preserving_motion(-500.0);
close(value.velocity, 3442.993152206428, 1e-9); close(value.velocity, 9837.123292018367, 1e-9);
} }
#[test] #[test]
fn interrupted_resize_and_zoom_targets_are_c0_continuous() { fn interrupted_resize_and_zoom_targets_preserve_position_and_velocity() {
let initial = Rect::new(0., 0., 400., 300.); let initial = Rect::new(0., 0., 400., 300.);
let mut geometry = WindowGeometry::new(initial, None); let mut geometry = WindowGeometry::new(initial, None);
geometry.set_logical_target(Rect::new(100., 50., 900., 600.), Duration::ZERO); geometry.set_logical_target(Rect::new(100., 50., 900., 600.), Duration::ZERO);
@@ -366,8 +366,10 @@ fn interrupted_resize_and_zoom_targets_are_c0_continuous() {
PresentationMode::Normal, PresentationMode::Normal,
] { ] {
let before = (geometry.visual.current, geometry.decorations); let before = (geometry.visual.current, geometry.decorations);
let velocity = geometry.visual.velocity;
geometry.set_presentation_mode(Rect::new(200., 100., 700., 500.), mode, Duration::from_millis(40)); geometry.set_presentation_mode(Rect::new(200., 100., 700., 500.), mode, Duration::from_millis(40));
assert_eq!((geometry.visual.current, geometry.decorations), before); assert_eq!((geometry.visual.current, geometry.decorations), before);
assert_eq!(geometry.visual.velocity, velocity);
geometry.advance(Duration::ZERO, SpringConfig::default(), true); geometry.advance(Duration::ZERO, SpringConfig::default(), true);
assert_eq!((geometry.visual.current, geometry.decorations), before); assert_eq!((geometry.visual.current, geometry.decorations), before);
geometry.advance(Duration::from_millis(16), SpringConfig::default(), true); geometry.advance(Duration::from_millis(16), SpringConfig::default(), true);
+59 -16
View File
@@ -114,7 +114,8 @@ Changing default width preserves manually resized columns.
## Animations ## Animations
Set animation speed in Settings → Motion or edit the `animations` block in Continuum is Ferese's shared compositor and shell motion system. Set animation
speed in Settings → Motion or edit the `animations` block in
`~/.config/ferese/config.kdl`. Changes apply live. Start with `speed` if you only `~/.config/ferese/config.kdl`. Changes apply live. Start with `speed` if you only
want faster or slower transitions; you do not need to change the springs. want faster or slower transitions; you do not need to change the springs.
@@ -151,7 +152,20 @@ You can omit either spring block to keep its defaults.
| `animations.spring.damping` | number ≥ 0 | `53` | Spring damping | | `animations.spring.damping` | number ≥ 0 | `53` | Spring damping |
| `animations.viewport-spring.mass` | number > 0 | `1` | Scrolling spring mass | | `animations.viewport-spring.mass` | number > 0 | `1` | Scrolling spring mass |
| `animations.viewport-spring.stiffness` | number > 0 | `320` | Scrolling stiffness | | `animations.viewport-spring.stiffness` | number > 0 | `320` | Scrolling stiffness |
| `animations.viewport-spring.damping-ratio` | number > 0 | `1` | `1` is critically damped | | `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:
| Key | Type | Default | Meaning |
| --- | --- | --- | --- |
| `damping-ratio` | number ≥ 0 | Derived from coefficients for `spring`; `1` for `viewport-spring` | Alternative to `damping`; zero requires overshoot disabled |
| `duration-ms` | number > 0 | Unset | Response parameter instead of stiffness/damping; not a completion deadline |
| `bounce` | number strictly between −1 and 1 | `0` with `duration-ms` | Requires `duration-ms`; positive values also require overshoot |
| `overshoot` | boolean | `false` | Allow target crossings for this spring; requires positive damping |
`mass`, `stiffness` and `damping` are accepted in both blocks. The viewport's
default damping ratio tracks changes to mass and stiffness unless you supply
`damping`, `damping-ratio` or `duration-ms` explicitly.
### Speed and reduced motion ### Speed and reduced motion
@@ -166,17 +180,44 @@ 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.
Overview can reverse while moving, and workspace changes slide in their navigation
direction. Workspace and scrolling gestures carry their release velocity into
the settling spring. Scrolling gestures resist at viewport limits and spring back.
Window opening starts when content is available; closing waits for the application
to unmap, then animates its retained image. Input and application close requests
remain responsive while the animations finish.
### 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
overview entrance motion. `viewport-spring` controls the scrolling viewport and overview entrance and dismissal, opening/closing windows, focus emphasis, shell
the column-width animation that runs with it. Shell popups and theme fades use popups and notifications. `viewport-spring` controls the scrolling viewport,
timed transitions, so the spring parameters do not change their curves. workspace slides and the column-width animation that runs with scrolling.
Continuum shares the analytic spring solver and motion settings between the
compositor and shell. Small opacity changes (hover, dimming, theme fades) may
still use timed transitions.
Spring motion has no fixed duration. Its settling time depends on the distance Spring settling time depends on distance, velocity and tolerances. In either
to the target, current velocity and the spring parameters. The `animations` spring block, you can use duration and bounce instead of stiffness and damping:
block has no settings for per-animation durations, custom curves or animation
shaders. ```kdl
animations {
spring {
duration-ms 300.0
bounce 0.0
}
}
```
`duration-ms` is a response parameter, not a fixed completion deadline. With
`T = duration-ms / 1000`, Continuum uses `omega = 2*pi/T`,
`stiffness = mass*omega^2` and `damping = 2*mass*omega*zeta`.
`zeta = 1-bounce` for nonnegative bounce, otherwise `zeta = 1/(1+bounce)`.
Bounce defaults to zero and must be strictly between -1 and 1. Positive bounce
requires `overshoot #true` in that spring block. Do not combine `duration-ms`
with `stiffness`, `damping` or `damping-ratio`; existing physical settings keep
working. This conversion does not guarantee the same perceptual duration or
settling time as Apple's springs.
With the other parameters unchanged, higher stiffness makes motion faster and With the other parameters unchanged, higher stiffness makes motion faster and
higher mass makes it slower. Damping controls how quickly velocity dies away. higher mass makes it slower. Damping controls how quickly velocity dies away.
@@ -184,14 +225,16 @@ The default window spring is slightly overdamped, close to critical damping;
the viewport spring is critically damped. the viewport spring is critically damped.
For `viewport-spring`, `damping-ratio 1.0` is critical damping, values below `1` For `viewport-spring`, `damping-ratio 1.0` is critical damping, values below `1`
are underdamped and values above `1` are overdamped. The window spring uses a are underdamped and values above `1` are overdamped. Both spring blocks accept a
damping coefficient instead: critical damping is damping ratio or a damping coefficient, but not both; critical damping is
`damping = 2 * sqrt(stiffness * mass)`. Ferese stops spring animations `damping = 2 * sqrt(stiffness * mass)`. Ferese stops spring animations
at their first target crossing, so lowering damping does not make them bounce at their first target crossing by default. `overshoot #true` allows that spring
past the target. to move past its target; opacity remains bounded. Overshoot is never enabled globally.
Mass, stiffness, damping ratio and speed must be finite and greater than zero. Mass, stiffness and speed must be finite and greater than zero.
The window spring's damping coefficient may also be zero. Invalid edits leave 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. the last accepted configuration active.
## Appearance ## Appearance
@@ -225,7 +268,7 @@ one appearance.
| `appearance.corner-radius` | number ≥ 0 | unset | Legacy fallback for shell radius | | `appearance.corner-radius` | number ≥ 0 | unset | Legacy fallback for shell radius |
| `appearance.inactive-dim.enabled` | boolean | `false` | Dim unfocused windows | | `appearance.inactive-dim.enabled` | boolean | `false` | Dim unfocused windows |
| `appearance.inactive-dim.amount` | number 0–1 | `0.15` | Darkening strength | | `appearance.inactive-dim.amount` | number 0–1 | `0.15` | Darkening strength |
| `appearance.inactive-dim.duration-ms` | number ≥ 0 | `150` | Dimming and focus-ring transition; 0 snaps | | `appearance.inactive-dim.duration-ms` | number ≥ 0 | `150` | Dimming transition; 0 snaps |
| `theme.typography.font-family` | string | `"Inter"` | Shell, Settings and overview font | | `theme.typography.font-family` | string | `"Inter"` | Shell, Settings and overview font |
| `theme.background.path` | string | bundled Ferese wallpaper | Wallpaper image path; an existing selection overrides the default | | `theme.background.path` | string | bundled Ferese wallpaper | Wallpaper image path; an existing selection overrides the default |
| `theme.background.mode` | `"fill"`, `"fit"` | `"fill"` | Crop or letterbox | | `theme.background.mode` | `"fill"`, `"fit"` | `"fill"` | Crop or letterbox |
+33 -10
View File
@@ -2,7 +2,9 @@
FERESE_TEST_OVERVIEW_DAMAGE=1 FERESE_TEST_BINARY=target/release/ferese \ FERESE_TEST_OVERVIEW_DAMAGE=1 FERESE_TEST_BINARY=target/release/ferese \
FERESE_TEST_CTL=target/release/feresectl python3 scripts/tests/test_overview_damage_isolated.py FERESE_TEST_CTL=target/release/feresectl python3 scripts/tests/test_overview_damage_isolated.py
Requires a Wayland host, foot and dbus-daemon. Never connects clients to the host. Set FERESE_TEST_TERMINAL=alacritty to use Alacritty instead of foot.
Requires a Wayland host, the selected terminal and dbus-daemon.
Never connects clients to the host.
""" """
import errno import errno
import json import json
@@ -22,6 +24,8 @@ class OverviewDamageTest(unittest.TestCase):
repo = Path(__file__).resolve().parents[2] repo = Path(__file__).resolve().parents[2]
binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/debug/ferese") binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/debug/ferese")
control = repo / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl") control = repo / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl")
terminal = os.environ.get("FERESE_TEST_TERMINAL", "foot")
self.assertIn(terminal, ("foot", "alacritty"))
children = [] children = []
with tempfile.TemporaryDirectory(prefix="ferese-overview-damage-") as temporary: with tempfile.TemporaryDirectory(prefix="ferese-overview-damage-") as temporary:
root = Path(temporary) root = Path(temporary)
@@ -36,7 +40,7 @@ class OverviewDamageTest(unittest.TestCase):
env = dict(os.environ, WAYLAND_DISPLAY=str(display), XDG_RUNTIME_DIR=str(runtime), env = dict(os.environ, WAYLAND_DISPLAY=str(display), XDG_RUNTIME_DIR=str(runtime),
XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state"), XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state"),
FERESE_TRACE_PERFORMANCE="1", RUST_LOG="ferese=info") FERESE_TRACE_PERFORMANCE="1", RUST_LOG="ferese=info,ferese::nested_input=debug")
env.pop("WAYLAND_SOCKET", None) env.pop("WAYLAND_SOCKET", None)
env.pop("FERESE_SHELL_CONTROL_SOCKET", None) env.pop("FERESE_SHELL_CONTROL_SOCKET", None)
log_path = root / "compositor.log" log_path = root / "compositor.log"
@@ -71,6 +75,13 @@ class OverviewDamageTest(unittest.TestCase):
wait_for(lambda: len(reports()) >= count + 2) wait_for(lambda: len(reports()) >= count + 2)
return reports()[-1] return reports()[-1]
def assert_quiet(label, report):
print(label + ":", report, flush=True)
with self.subTest(stage=label):
self.assertEqual(report["frames"], 0, str(report) + "\n" + log_path.read_text()[-5000:])
self.assertEqual(report["damaged_pixels"], 0, str(report))
self.assertGreater(report["no_damage_frames"], 0, str(report))
writer = None writer = None
with log_path.open("w") as log: with log_path.open("w") as log:
try: try:
@@ -89,7 +100,9 @@ class OverviewDamageTest(unittest.TestCase):
app = f"ferese.test.overview.{index}" app = f"ferese.test.overview.{index}"
client = (["sh", "-c", 'cat "$1"; sleep 120', "sh", str(fifo)] client = (["sh", "-c", 'cat "$1"; sleep 120', "sh", str(fifo)]
if index == 0 else ["sleep", "120"]) if index == 0 else ["sleep", "120"])
launch(["foot", "--app-id", app, *client], stdout=log, stderr=log) terminal_command = (["alacritty", "--class", app, "--command", *client]
if terminal == "alacritty" else ["foot", "--app-id", app, *client])
launch(terminal_command, stdout=log, stderr=log)
wait_for(lambda: any(window["app_id"] == app and window["width"] > 0 wait_for(lambda: any(window["app_id"] == app and window["width"] > 0
for window in command("get-windows"))) for window in command("get-windows")))
@@ -106,21 +119,31 @@ class OverviewDamageTest(unittest.TestCase):
command("toggle-overview") command("toggle-overview")
time.sleep(2) time.sleep(2)
quiet = stationary_interval() quiet = stationary_interval()
self.assertEqual(quiet["frames"], 0, str(quiet)) assert_quiet("Settled overview", quiet)
self.assertEqual(quiet["damaged_pixels"], 0, str(quiet))
self.assertGreater(quiet["no_damage_frames"], 0, str(quiet))
count = len(reports()) count = len(reports())
os.write(writer, b"Live overview content must still update.\n") os.write(writer, b"Live overview content must still update.\n")
wait_for(lambda: len(reports()) > count) wait_for(lambda: len(reports()) > count)
changed = reports()[-1] changed = reports()[-1]
self.assertGreater(changed["frames"], 0, str(changed)) print("Live content update:", changed, flush=True)
self.assertGreater(changed["damaged_pixels"], 0, str(changed)) with self.subTest(stage="Live content update"):
self.assertGreater(changed["frames"], 0, str(changed))
self.assertGreater(changed["damaged_pixels"], 0, str(changed))
# The content commit must not start another perpetual repaint. # The content commit must not start another perpetual repaint.
quiet = stationary_interval() quiet = stationary_interval()
self.assertEqual(quiet["frames"], 0, str(quiet)) assert_quiet("Settled overview after content update", quiet)
self.assertGreater(quiet["no_damage_frames"], 0, str(quiet))
# Exercise spring dismissal and reverse it while still in
# flight, then dismiss again and check the settled desktop.
command("toggle-overview")
time.sleep(.08)
command("toggle-overview")
time.sleep(.08)
command("toggle-overview")
time.sleep(2)
quiet = stationary_interval()
assert_quiet("Settled desktop after overview reversals", quiet)
finally: finally:
if writer is not None: if writer is not None:
os.close(writer) os.close(writer)
@@ -55,6 +55,7 @@ class UnmappedToplevelTest(unittest.TestCase):
XDG_RUNTIME_DIR=str(runtime), XDG_RUNTIME_DIR=str(runtime),
XDG_CONFIG_HOME=str(root / "config"), XDG_CONFIG_HOME=str(root / "config"),
WAYLAND_DISPLAY=host_display, WAYLAND_DISPLAY=host_display,
RUST_LOG="ferese=info,ferese::state::lifecycle=debug,ferese::state::animation=debug",
) )
compositor_binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/debug/ferese") compositor_binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/debug/ferese")
ctl_binary = repo / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl") ctl_binary = repo / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl")
@@ -117,6 +118,10 @@ class UnmappedToplevelTest(unittest.TestCase):
# Leave a configure/resize outstanding when the buffer is detached. # Leave a configure/resize outstanding when the buffer is detached.
subprocess.run([str(ctl_binary), "toggle-maximized"], env=env, subprocess.run([str(ctl_binary), "toggle-maximized"], env=env,
capture_output=True, check=True) capture_output=True, check=True)
time.sleep(.08)
if phase == "detached":
self.assertIn("retained close presentation", (root / "compositor.log").read_text(),
"unmap lost the old buffer before snapshot capture")
client.stdin.write("\n") client.stdin.write("\n")
client.stdin.flush() client.stdin.flush()
@@ -140,6 +145,11 @@ class UnmappedToplevelTest(unittest.TestCase):
[str(ctl_binary), "get-focused-window"], env=env, text=True [str(ctl_binary), "get-focused-window"], env=env, text=True
)) ))
self.assertIsNone(focused, "focus history must not restore a destroyed window") self.assertIsNone(focused, "focus history must not restore a destroyed window")
deadline = time.monotonic() + 3
while "released close presentation" not in (root / "compositor.log").read_text():
if time.monotonic() >= deadline:
self.fail("retained close texture did not settle and release")
time.sleep(.025)
finally: finally:
if client and client.poll() is None: if client and client.poll() is None:
client.terminate() client.terminate()
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
rust-version.workspace = true rust-version.workspace = true
[dependencies] [dependencies]
ferese-animation = { path = "../../crates/ferese-animation" }
ferese-theme-client = { path = "../../crates/ferese-theme-client" } ferese-theme-client = { path = "../../crates/ferese-theme-client" }
ferese-ipc = { path = "../../crates/ferese-ipc" } ferese-ipc = { path = "../../crates/ferese-ipc" }
ferese-theme = { path = "../../crates/ferese-theme" } ferese-theme = { path = "../../crates/ferese-theme" }
+12
View File
@@ -360,6 +360,7 @@ fn parse_document(
.map_err(|error| ferese_config::Error::from(error.to_string())) .map_err(|error| ferese_config::Error::from(error.to_string()))
{ {
Ok(config) => { Ok(config) => {
config.animations.validate().map_err(ferese_config::Error::from)?;
config.notifications.validate().map_err(ferese_config::Error::from)?; config.notifications.validate().map_err(ferese_config::Error::from)?;
config.desktop_widgets.validate().map_err(ferese_config::Error::from)?; config.desktop_widgets.validate().map_err(ferese_config::Error::from)?;
let mut theme = shell_theme(&config.theme); let mut theme = shell_theme(&config.theme);
@@ -577,6 +578,17 @@ pub(crate) fn config_path() -> Option<PathBuf> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn rejects_undamped_overshoot_before_publication() {
for property in ["spring", "viewport-spring"] {
for coefficient in ["damping", "damping-ratio"] {
let source = format!("animations {{ {property} {{ {coefficient} 0; overshoot #true; }}; }}");
let error = parse_test_source(&source).unwrap_err().to_string();
assert!(error.contains("overshoot requires positive damping"), "{error}");
}
}
}
#[test] #[test]
fn desktop_clock_parses_and_rejects_invalid_reload_values() { fn desktop_clock_parses_and_rejects_invalid_reload_values() {
let clock = parse_test_source( let clock = parse_test_source(
+1
View File
@@ -904,6 +904,7 @@ impl FereseShell {
motion::configure(config.animations, config.theme.material_radius); motion::configure(config.animations, config.theme.material_radius);
if self.config.animations != config.animations { if self.config.animations != config.animations {
self.notifications.update_motion_settings(config.animations);
if let Some(menu) = &mut self.menu { if let Some(menu) = &mut self.menu {
menu.motion.update_settings(config.animations); menu.motion.update_settings(config.animations);
} }
+111 -73
View File
@@ -1,45 +1,12 @@
//! Draw-only popup transform; layout stays stable while its presentation animates.
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};
pub type Regions = std::sync::Arc<std::sync::Mutex<Vec<[f32; 5]>>>; pub type Regions = std::sync::Arc<std::sync::Mutex<Vec<[f32; 5]>>>;
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)] pub(crate) use ferese_animation::MotionSettings as Settings;
#[serde(default)] use ferese_animation::{AnimatedValue, SpringConfig};
pub(crate) struct Settings {
enabled: bool,
reduced_motion: bool,
speed: f64,
}
impl Default for Settings {
fn default() -> Self {
Self {
enabled: true,
reduced_motion: false,
speed: 1.0,
}
}
}
impl Settings {
fn hover_duration(self) -> std::time::Duration {
self.duration(120.0)
}
pub(crate) fn duration(self, milliseconds: f64) -> std::time::Duration {
if !self.enabled || self.reduced_motion {
return std::time::Duration::ZERO;
}
let speed = if self.speed.is_finite() && self.speed > 0.0 {
self.speed.clamp(0.01, 100.0)
} else {
1.0
};
std::time::Duration::from_secs_f64(milliseconds / 1000.0 / speed)
}
}
static SETTINGS: std::sync::OnceLock<std::sync::RwLock<Settings>> = std::sync::OnceLock::new(); static SETTINGS: std::sync::OnceLock<std::sync::RwLock<Settings>> = std::sync::OnceLock::new();
static SHELL_RADIUS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(14.0_f32.to_bits()); static SHELL_RADIUS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(14.0_f32.to_bits());
@@ -56,25 +23,29 @@ pub(crate) fn configure(settings: Settings, radius: f32) {
.unwrap() = settings; .unwrap() = settings;
} }
pub(crate) fn notification_duration() -> std::time::Duration { pub(crate) fn settings() -> Settings {
SETTINGS *SETTINGS
.get_or_init(|| std::sync::RwLock::new(Settings::default())) .get_or_init(|| std::sync::RwLock::new(Settings::default()))
.read() .read()
.unwrap() .unwrap()
.duration(180.0)
} }
pub(crate) struct PopupMotion { pub(crate) struct PopupMotion {
start: f32, value: AnimatedValue,
target: f32,
started: Option<std::time::Instant>, started: Option<std::time::Instant>,
settings: Settings, settings: Settings,
} }
impl PopupMotion { impl PopupMotion {
pub(crate) fn update_settings(&mut self, settings: Settings) { pub(crate) fn update_settings(&mut self, settings: Settings) {
let now = std::time::Instant::now(); self.update_settings_at(settings, std::time::Instant::now());
self.start = self.progress_at(now); }
fn update_settings_at(&mut self, settings: Settings, now: std::time::Instant) {
self.value = self.sample(now);
// Stored velocities use simulation seconds. Preserve visible velocity
// when changing the clock rate during an in-flight transition.
self.value.velocity *= self.settings.speed / settings.speed;
self.settings = settings; self.settings = settings;
if self.started.is_some() { if self.started.is_some() {
self.started = Some(now); self.started = Some(now);
@@ -83,8 +54,11 @@ impl PopupMotion {
pub(crate) fn new(settings: Settings) -> Self { pub(crate) fn new(settings: Settings) -> Self {
Self { Self {
start: 0.0, value: AnimatedValue {
target: 1.0, current: 0.0,
target: 1.0,
velocity: 0.0,
},
started: None, started: None,
settings, settings,
} }
@@ -96,36 +70,49 @@ impl PopupMotion {
} }
} }
pub(crate) fn progress_at(&self, now: std::time::Instant) -> f32 { fn sample(&self, now: std::time::Instant) -> AnimatedValue {
let mut value = self.value;
let Some(started) = self.started else { let Some(started) = self.started else {
return self.start; return value;
}; };
let duration = self.settings.duration(if self.target == 1.0 { 200.0 } else { 140.0 }); if !self.settings.motion_enabled() {
if duration.is_zero() { value.snap();
return self.target; return value;
} }
let t = (now.saturating_duration_since(started).as_secs_f32() / duration.as_secs_f32()).min(1.0); let spring = self
if t == 1.0 { .settings
return self.target; .spring
} .resolve(SpringConfig::default())
self.start + (self.target - self.start) * t * t * (3.0 - 2.0 * t) .expect("validated motion settings");
value.advance(
now.saturating_duration_since(started).mul_f64(self.settings.speed),
SpringConfig {
position_tolerance: 0.00001,
velocity_tolerance: 0.00001,
..spring
},
);
value
}
pub(crate) fn progress_at(&self, now: std::time::Instant) -> f32 {
self.sample(now).current as f32
} }
pub(crate) fn progress(&self) -> f32 { pub(crate) fn progress(&self) -> f32 {
self.progress_at(std::time::Instant::now()) self.progress_at(std::time::Instant::now())
} }
pub(crate) fn closing(&self) -> bool { pub(crate) fn closing(&self) -> bool {
self.target == 0.0 self.value.target == 0.0
} }
pub(crate) fn animating(&self) -> bool { pub(crate) fn animating(&self) -> bool {
self.progress() != self.target let value = self.sample(std::time::Instant::now());
value.current != value.target || value.velocity != 0.0
} }
pub(crate) fn retarget(&mut self, target: f32, now: std::time::Instant) { pub(crate) fn retarget(&mut self, target: f32, now: std::time::Instant) {
self.start = self.progress_at(now); self.value = self.sample(now);
self.target = target; self.value.set_target(f64::from(target));
self.started = Some(now); self.started = Some(now);
} }
} }
@@ -158,7 +145,7 @@ pub(crate) fn button<'a, M: Clone + 'a>(
duration: SETTINGS duration: SETTINGS
.get() .get()
.map_or_else(Settings::default, |settings| *settings.read().unwrap()) .map_or_else(Settings::default, |settings| *settings.read().unwrap())
.hover_duration(), .duration(120.0),
}) })
} }
@@ -506,6 +493,28 @@ impl widget::Operation for CollectRegions {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn zero_damping_with_crossing_clamp_finishes_popup_close() {
let settings = super::Settings {
spring: ferese_animation::SpringSettings {
damping: Some(0.0),
..Default::default()
},
..Default::default()
};
settings.validate().unwrap();
let now = std::time::Instant::now();
let mut motion = super::PopupMotion::new(settings);
motion.begin(now);
let opened = now + std::time::Duration::from_secs(1);
assert_eq!(motion.sample(opened).current, 1.0);
motion.retarget(0.0, opened);
let closed = motion.sample(opened + std::time::Duration::from_secs(1));
assert!(motion.closing());
assert_eq!(closed.current, closed.target);
assert_eq!(closed.velocity, 0.0);
}
use super::*; use super::*;
#[test] #[test]
@@ -529,19 +538,48 @@ mod tests {
} }
#[test] #[test]
fn popup_waits_for_configuration_then_opens_and_closes_faster() { fn popup_waits_for_configuration_and_preserves_reversal_velocity() {
let now = std::time::Instant::now(); let now = std::time::Instant::now();
let mut motion = PopupMotion::new(Settings::default()); let mut motion = PopupMotion::new(Settings::default());
assert_eq!(motion.progress_at(now + std::time::Duration::from_secs(30)), 0.0); assert_eq!(motion.progress_at(now + std::time::Duration::from_secs(30)), 0.0);
motion.begin(now); motion.begin(now);
// Later configure/focus notifications must not restart the clock. let reverse = now + std::time::Duration::from_millis(90);
motion.begin(now + std::time::Duration::from_millis(90)); let before = motion.sample(reverse);
assert!((motion.progress_at(now + std::time::Duration::from_millis(100)) - 0.5).abs() < 0.00001); motion.begin(reverse);
assert_eq!(motion.progress_at(now + std::time::Duration::from_millis(200)), 1.0); assert_eq!(motion.sample(reverse), before);
motion.retarget(0.0, now + std::time::Duration::from_millis(200)); motion.retarget(0.0, reverse);
let after = motion.sample(reverse);
assert_eq!(after.current, before.current);
assert_eq!(after.velocity, before.velocity);
assert!(motion.closing()); assert!(motion.closing());
assert!((motion.progress_at(now + std::time::Duration::from_millis(270)) - 0.5).abs() < 0.00001); assert_eq!(motion.progress_at(reverse + std::time::Duration::from_secs(2)), 0.0);
assert_eq!(motion.progress_at(now + std::time::Duration::from_millis(340)), 0.0); }
#[test]
fn live_speed_change_preserves_visible_velocity_and_reduced_motion_snaps() {
let now = std::time::Instant::now();
let mut motion = PopupMotion::new(Settings::default());
motion.begin(now);
let time = now + std::time::Duration::from_millis(90);
let before = motion.sample(time);
motion.update_settings_at(
Settings {
speed: 2.0,
..Settings::default()
},
time,
);
let after = motion.sample(time);
assert_eq!(after.current, before.current);
assert_eq!(after.velocity * 2.0, before.velocity);
motion.update_settings_at(
Settings {
reduced_motion: true,
..Settings::default()
},
time,
);
assert_eq!(motion.progress_at(time), 1.0);
} }
#[test] #[test]
@@ -560,7 +598,7 @@ mod tests {
let before = motion.progress_at(reopen); let before = motion.progress_at(reopen);
motion.retarget(1.0, reopen); motion.retarget(1.0, reopen);
assert_eq!(motion.progress_at(reopen), before); assert_eq!(motion.progress_at(reopen), before);
assert_eq!(motion.progress_at(reopen + std::time::Duration::from_secs(1)), 1.0); assert_eq!(motion.progress_at(reopen + std::time::Duration::from_secs(2)), 1.0);
for settings in [ for settings in [
Settings { Settings {
reduced_motion: true, reduced_motion: true,
@@ -632,7 +670,7 @@ mod tests {
speed: 0.75, speed: 0.75,
..Settings::default() ..Settings::default()
} }
.hover_duration(), .duration(120.0),
std::time::Duration::from_millis(160) std::time::Duration::from_millis(160)
); );
for settings in [ for settings in [
@@ -645,9 +683,9 @@ mod tests {
..Settings::default() ..Settings::default()
}, },
] { ] {
assert!(settings.hover_duration().is_zero()); assert!(settings.duration(120.0).is_zero());
let mut hover = HoverState::default(); let mut hover = HoverState::default();
assert!(!hover.advance(1.0, std::time::Instant::now(), settings.hover_duration())); assert!(!hover.advance(1.0, std::time::Instant::now(), settings.duration(120.0)));
assert_eq!(hover.current, 1.0); assert_eq!(hover.current, 1.0);
} }
} }
+21 -19
View File
@@ -262,7 +262,7 @@ impl Service {
struct Toast { struct Toast {
id: u32, id: u32,
born: Instant, motion: super::motion::PopupMotion,
closing: Option<Instant>, closing: Option<Instant>,
remaining: Option<Duration>, remaining: Option<Duration>,
hovered: bool, hovered: bool,
@@ -320,30 +320,27 @@ impl Center {
} }
} }
pub fn update_motion_settings(&mut self, settings: super::motion::Settings) {
for toast in &mut self.toasts {
toast.motion.update_settings(settings);
}
}
pub fn unread(&self) -> u32 { pub fn unread(&self) -> u32 {
self.entries.iter().filter(|notice| notice.unread).count() as u32 self.entries.iter().filter(|notice| notice.unread).count() as u32
} }
pub fn visible(&self) -> impl Iterator<Item = (&Notice, f32)> { pub fn visible(&self) -> impl Iterator<Item = (&Notice, f32)> {
let duration = super::motion::notification_duration();
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)?;
let p = if duration.is_zero() { Some((notice, toast.motion.progress().clamp(0.0, 1.0)))
if toast.closing.is_some() { 0.0 } else { 1.0 }
} else {
toast.closing.map_or_else(
|| (toast.born.elapsed().as_secs_f32() / duration.as_secs_f32()).min(1.0),
|closing| (1.0 - closing.elapsed().as_secs_f32() / duration.as_secs_f32()).max(0.0),
)
};
Some((notice, p * p * (3.0 - 2.0 * p)))
}) })
} }
pub fn animating(&self) -> bool { pub fn animating(&self) -> bool {
self.toasts self.toasts
.iter() .iter()
.any(|toast| toast.closing.is_some() || toast.born.elapsed() < super::motion::notification_duration()) .any(|toast| toast.closing.is_some() || toast.motion.animating())
} }
pub fn popup_groups(&self) -> Vec<(&Notice, f32, usize)> { pub fn popup_groups(&self) -> Vec<(&Notice, f32, usize)> {
@@ -433,7 +430,11 @@ impl Center {
} }
} }
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) {
toast.closing.get_or_insert_with(Instant::now); if toast.closing.is_none() {
let now = Instant::now();
toast.closing = Some(now);
toast.motion.retarget(0.0, now);
}
} }
} }
@@ -499,7 +500,11 @@ impl Center {
if show { if show {
self.toasts.push_back(Toast { self.toasts.push_back(Toast {
id: notice.id, id: notice.id,
born: Instant::now(), motion: {
let mut motion = super::motion::PopupMotion::new(super::motion::settings());
motion.begin(Instant::now());
motion
},
closing: None, closing: None,
remaining: notice.timeout, remaining: notice.timeout,
hovered: group_hovered, hovered: group_hovered,
@@ -604,11 +609,8 @@ impl Center {
for id in expired { for id in expired {
self.close(id, 1); self.close(id, 1);
} }
self.toasts.retain(|toast| { self.toasts
toast .retain(|toast| toast.closing.is_none() || toast.motion.animating());
.closing
.is_none_or(|closing| now.duration_since(closing) < super::motion::notification_duration())
});
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(&notice.id)); .retain(|notice| !notice.transient || notice.live || visible.contains(&notice.id));
+35 -25
View File
@@ -26,9 +26,12 @@ impl FereseShell {
self.calendar_offset = 0; self.calendar_offset = 0;
} }
let destroy = self.destroy_menu();
if !kind.available(&self.status) { if !kind.available(&self.status) {
return destroy; return Task::none();
}
if self.menu.as_ref().is_some_and(|menu| menu.kind == Menu::Notifications) {
self.notifications.history_open = false;
self.notifications.hovered = None;
} }
if kind == Menu::Notifications && self.notifications.ready { if kind == Menu::Notifications && self.notifications.ready {
@@ -36,14 +39,6 @@ impl FereseShell {
} }
let notifications = self.sync_notification_surface(); let notifications = self.sync_notification_surface();
let id = window::Id::unique();
self.menu = Some(OpenMenu {
id,
kind,
motion: crate::motion::PopupMotion::new(self.config.animations),
effects: None,
regions: Default::default(),
});
crate::EFFECT_FRAME_PENDING.store(true, std::sync::atomic::Ordering::Relaxed); crate::EFFECT_FRAME_PENDING.store(true, std::sync::atomic::Ordering::Relaxed);
self.status_error = None; self.status_error = None;
let parent = self.bar_surface_id; let parent = self.bar_surface_id;
@@ -70,6 +65,33 @@ impl FereseShell {
} else { } else {
kind.height_limit() kind.height_limit()
}; };
let positioner = SctkPositioner {
anchor_rect: anchor,
anchor: 2u32.try_into().unwrap(),
gravity: 6u32.try_into().unwrap(),
offset: (anchor.width / 2, 8),
// The view's autosize widget supplies the active panel's bounds.
// Keep initial popup limits broad enough for later panel changes.
size_limits: Limits::NONE.max_width(368.0).max_height(720.0_f32.max(height_limit)),
constraint_adjustment: 3,
..Default::default()
};
if let Some(menu) = &mut self.menu {
menu.kind = kind;
menu.motion.retarget(1.0, Instant::now());
return Task::batch([
notifications,
cosmic::iced::platform_specific::shell::commands::popup::reposition(menu.id, positioner),
]);
}
let id = window::Id::unique();
self.menu = Some(OpenMenu {
id,
kind,
motion: crate::motion::PopupMotion::new(self.config.animations),
effects: None,
regions: Default::default(),
});
let action = cosmic::surface::action::app_popup::<Self>( let action = cosmic::surface::action::app_popup::<Self>(
|_| Default::default(), |_| Default::default(),
move |_| SctkPopupSettings { move |_| SctkPopupSettings {
@@ -79,23 +101,11 @@ impl FereseShell {
grab: true, grab: true,
close_with_children: false, close_with_children: false,
input_zone: None, input_zone: None,
positioner: SctkPositioner { positioner: positioner.clone(),
anchor_rect: anchor,
// Numeric protocol values avoid depending on libcosmic's private SCTK reexport.
anchor: 2u32.try_into().unwrap(), // bottom
gravity: 6u32.try_into().unwrap(), // bottom-left
offset: (anchor.width / 2, 8),
size_limits: Limits::NONE
.min_width(kind.width())
.max_width(kind.width())
.max_height(height_limit),
constraint_adjustment: 3, // slide X/Y, never flip above the bar
..Default::default()
},
}, },
Some(Box::new(Self::view_status_menu)), Some(Box::new(Self::view_status_menu)),
); );
Task::batch([destroy, notifications]).chain(cosmic::task::message(cosmic::Action::Surface(action))) notifications.chain(cosmic::task::message(cosmic::Action::Surface(action)))
} }
pub fn destroy_menu(&mut self) -> Task<Message> { pub fn destroy_menu(&mut self) -> Task<Message> {
@@ -136,7 +146,7 @@ impl FereseShell {
pub fn animate_menu(&mut self) -> Task<Message> { pub fn animate_menu(&mut self) -> Task<Message> {
if let Some(menu) = &self.menu { if let Some(menu) = &self.menu {
if let Some(effects) = &menu.effects { if let Some(effects) = &menu.effects {
let _ = effects.set_opacity(menu.progress()); let _ = effects.set_opacity(menu.progress().clamp(0.0, 1.0));
} }
if menu.motion.closing() && !menu.animating() { if menu.motion.closing() && !menu.animating() {
return self.destroy_menu(); return self.destroy_menu();
+1 -1
View File
@@ -295,7 +295,7 @@ impl FereseShell {
if let Some(effects) = &entry.effects { if let Some(effects) = &entry.effects {
let regions = entry.regions.lock().unwrap().clone(); let regions = entry.regions.lock().unwrap().clone();
let _ = effects.set_material_regions(&regions, ferese_surface_effects_v1::Role::Modal); let _ = effects.set_material_regions(&regions, ferese_surface_effects_v1::Role::Modal);
let _ = effects.set_opacity(modal.motion.progress()); let _ = effects.set_opacity(modal.motion.progress().clamp(0.0, 1.0));
} }
} }
} }