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"
dependencies = [
"ferese-layout",
"serde",
]
[[package]]
@@ -1784,6 +1785,7 @@ dependencies = [
name = "ferese-shell"
version = "0.1.0"
dependencies = [
"ferese-animation",
"ferese-config",
"ferese-ipc",
"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
- Workspace overview and recent-window switching
- 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
- Themes, wallpapers and blur that update live, with Light/Dark/Auto modes and custom KDL themes
- 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 |
| Floating windows | Built in | Built in | 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 |
| 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) |
-11
View File
@@ -1058,17 +1058,6 @@ fn cursor_affected_outputs(state: &Ferese) -> Vec<Output> {
.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]
pub(crate) fn render_surface(
state: &mut Ferese,
+11 -28
View File
@@ -346,34 +346,6 @@ const fn default_repeat_delay() -> i32 {
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 {
true
}
@@ -384,6 +356,17 @@ pub(crate) fn config_path() -> Option<PathBuf> {
#[cfg(test)]
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::*;
#[test]
+7 -96
View File
@@ -44,7 +44,7 @@ impl Config {
}
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> {
@@ -52,39 +52,14 @@ impl Config {
}
pub fn spring_config(&self) -> Result<SpringConfig, ConfigError> {
let mass = positive_animation_value(self.animations.spring.mass, "spring.mass")?;
let stiffness = positive_animation_value(self.animations.spring.stiffness, "spring.stiffness")?;
let damping = self.animations.spring.damping;
if !damping.is_finite() || damping < 0.0 {
return Err(ConfigError::AnimationValue {
field: "spring.damping",
value: damping,
});
}
Ok(SpringConfig {
mass,
stiffness,
damping,
..SpringConfig::default()
})
self.animations
.spring
.resolve(SpringConfig::default())
.map_err(ConfigError::Binding)
}
pub fn viewport_spring_config(&self) -> Result<SpringConfig, ConfigError> {
let mass = positive_animation_value(self.animations.viewport_spring.mass, "viewport_spring.mass")?;
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()
})
self.animations.viewport_config().map_err(ConfigError::Binding)
}
}
@@ -178,68 +153,4 @@ impl Default for TouchpadConfig {
}
}
#[derive(Debug, Deserialize)]
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(),
}
}
}
pub(super) type AnimationsConfig = ferese_animation::MotionSettings;
+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)]
#[repr(u32)]
pub(crate) enum SwipeDirection {
@@ -12,10 +10,16 @@ pub(crate) enum SwipeDirection {
#[derive(Default)]
pub(crate) struct Swipe {
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,
fingers: u32,
direction: Option<SwipeDirection>,
preview_started: bool,
momentum_navigation: bool,
}
impl Swipe {
@@ -25,6 +29,59 @@ impl Swipe {
self.fingers = fingers;
self.direction = None;
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 {
@@ -49,11 +106,20 @@ impl Swipe {
self.preview_started = true;
}
pub fn mark_momentum_navigation(&mut self) {
self.momentum_navigation = true;
}
pub fn preview_started(&self) -> bool {
self.preview_started
}
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?;
if !x.is_finite() || !y.is_finite() {
return None;
@@ -65,7 +131,7 @@ impl Swipe {
SwipeDirection::Up => -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> {
@@ -103,6 +169,47 @@ fn dominant_direction(x: f64, y: f64, threshold: f64) -> Option<SwipeDirection>
mod tests {
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]
fn cancellation_and_new_gestures_reset_preview_state() {
let mut swipe = Swipe::default();
+1
View File
@@ -37,6 +37,7 @@ impl CompositorHandler for Ferese {
fn commit(&mut self, surface: &WlSurface) {
self.update_surface_preferences(surface);
self.capture_resize_before_commit(surface);
self.capture_close_before_commit(surface);
on_commit_buffer_handler::<Self>(surface);
if !is_sync_subsurface(surface) {
let mut root = surface.clone();
+13 -3
View File
@@ -170,6 +170,7 @@ impl Ferese {
.any(|binding| binding.swipe_fingers(event.fingers())),
self.input_settings.touchpad.swipe_threshold,
);
self.swipe.start_time(std::time::Duration::from_micros(event.time()));
if self.swipe.active() {
self.focus_output_at(pointer.current_location());
} else {
@@ -185,7 +186,11 @@ impl Ferese {
}
InputEvent::GestureSwipeUpdate { event } => {
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() {
self.finish_workspace_swipe(None);
self.finish_focus_swipe(None);
@@ -198,10 +203,13 @@ impl Ferese {
{
match action {
BindingAction::SwitchRelativeWorkspace(next) => {
self.swipe.mark_momentum_navigation();
self.preview_workspace_swipe(next, direction, progress);
}
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() {
let fingers = self.swipe.fingers();
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 focus_handled = self.finish_focus_swipe(direction);
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;
// Applied after the configured animation speed, in both live and predicted motion.
const OVERVIEW_MOTION_SPEED: f64 = 0.6;
const EXIT_DURATION: f64 = 0.18;
const WORKSPACE_CARD_GAP: f64 = 8.0;
const MAX_PREVIEW_SCALE: f64 = 0.82;
@@ -117,7 +116,6 @@ pub(crate) struct OverviewMotion {
active: bool,
presentations: HashMap<WindowId, AnimatedRect>,
opacity: AnimatedValue,
exit_transition: Option<(f64, f64)>,
}
impl Default for OverviewState {
@@ -179,8 +177,7 @@ fn centered_row_start(strip: Rect, count: usize, card_width: f64) -> f64 {
impl OverviewState {
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
@@ -366,25 +363,22 @@ impl OverviewState {
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(
&mut self,
targets: HashMap<WindowId, (Rect, Rect)>,
selected: Option<WindowId>,
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.motion.active = true;
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) {
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);
if !animations_enabled {
self.motion.opacity.snap();
@@ -605,6 +598,17 @@ impl Ferese {
pub(crate) fn retarget_overview(&mut self) {
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;
}
@@ -643,17 +647,26 @@ impl Ferese {
presented.x += x;
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)> {
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)?;
return Some((x - presented.x, y - presented.y));
}
let source = geometry.client.committed_size?;
let presented = self.overview.presented_rect(id, geometry.visual.current);
let source = if self.overview.is_presenting() {
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 {
return None;
@@ -989,15 +1002,13 @@ impl Default for OverviewMotion {
active: false,
presentations: HashMap::new(),
opacity: AnimatedValue::new(0.0),
exit_transition: None,
}
}
}
impl OverviewMotion {
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.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 {
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
}
pub(crate) fn advance(&mut self, delta: Duration, spring: SpringConfig, animations_enabled: bool) -> bool {
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;
for presentation in self.presentations.values_mut() {
if animations_enabled {
active_animation |= presentation.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing);
active_animation |= presentation.advance(delta, spring);
} else {
presentation.snap();
}
@@ -1197,7 +1182,7 @@ mod tests {
let before = overview.presented_rect(id, normal);
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());
assert!(!finished.is_presenting());
@@ -1351,7 +1336,7 @@ mod tests {
}
#[test]
fn exiting_overview_tracks_a_moving_layout_and_finishes_in_300ms() {
fn exiting_overview_retargets_without_resetting_motion() {
let id = WindowId(1);
let initial = Rect::new(-400.0, 0.0, 800.0, 600.0);
let preview = Rect::new(100.0, 100.0, 400.0, 300.0);
@@ -1359,10 +1344,15 @@ mod tests {
let mut overview = OverviewState::default();
overview.enter(HashMap::from([(id, (initial, preview))]), Some(id), false);
overview.exit(HashMap::from([(id, initial)]), true);
overview.advance(Duration::from_millis(150), SpringConfig::default(), true);
let shown = overview.presented_rect(id, moved);
assert!((shown.x - 187.5).abs() < 0.001);
overview.advance(Duration::from_millis(150), SpringConfig::default(), true);
overview.advance(Duration::from_millis(60), SpringConfig::default(), true);
let before = overview.motion.presentations[&id];
overview.exit(HashMap::from([(id, moved)]), 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_eq!(overview.presented_rect(id, moved), moved);
}
@@ -1378,7 +1368,9 @@ mod tests {
overview.exit(HashMap::from([(id, normal)]), true);
overview.advance(Duration::from_millis(60), SpringConfig::default(), true);
let shown = overview.presented_rect(id, normal);
let velocity = overview.motion.presentations[&id].velocity;
overview.enter(targets, Some(id), true);
assert_eq!(overview.motion.presentations[&id].velocity, velocity);
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);
}
}
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,
programs: RoundedClipPrograms,
behavior: ConstrainScaleBehavior,
native_size: Option<ferese_animation::ClientSize>,
) -> Vec<AnimatedWindowRenderElement> {
let Some(toplevel) = window.toplevel() else {
return Vec::new();
@@ -390,7 +391,13 @@ pub(super) fn rounded_window_elements(
let physical_constrain = corners.rect;
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 clip = framebuffer_clip_rect(physical_constrain, mode.size, output.current_transform().invert());
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 {
let scale = scale.clamp(0.0, 1.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,
)
}
#[cfg(test)]
pub(super) use crate::presentation::scaled_visual_rect;
pub(super) fn color_with_alpha(mut color: [f32; 4], alpha: f32) -> [f32; 4] {
color[3] *= alpha;
+20 -2
View File
@@ -128,6 +128,25 @@ pub(super) fn material_element(
output: &Output,
surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
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)> {
let MaterialSurface {
geometry,
@@ -136,13 +155,12 @@ pub(super) fn material_element(
capture_geometry,
alpha,
} = surface_geometry;
let (role, generation) = crate::effects::surface_role(surface)?;
let material = crate::effects::resolve_material(
role,
state.theme_settings.material_style,
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 scale = output.current_scale().fractional_scale();
let transform = output.current_transform().invert();
+4 -2
View File
@@ -1,5 +1,7 @@
mod backdrop;
mod capture;
mod closing;
pub(crate) use closing::ClosedWindow;
#[cfg(test)]
mod corner_tests;
mod decorations;
@@ -78,7 +80,7 @@ render_elements! {
Native=NativeTextureElement,
}
#[derive(Debug)]
#[derive(Clone, Debug)]
pub(crate) struct ResizeSnapshot {
pub texture: GlesTexture,
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.
corner_shape_for_role(
window
+1
View File
@@ -241,6 +241,7 @@ pub(super) fn overview_strip_elements(
output,
programs.clone(),
ConstrainScaleBehavior::Stretch,
None,
));
}
}
+21
View File
@@ -8,6 +8,7 @@ use super::*;
/// context. Desktop policy and animation values do not live in these caches.
#[derive(Default)]
pub(crate) struct RenderResources {
pub closing: Vec<ClosedWindow>,
pub(super) windows: HashMap<WindowId, WindowBuffers>,
pub(super) contexts: HashMap<ErasedContextId, ContextPrograms>,
pub(super) outputs: HashMap<OutputId, OverviewScrim>,
@@ -71,6 +72,16 @@ impl RenderResources {
}
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);
for window in self.windows.values_mut() {
@@ -124,6 +135,16 @@ impl RenderResources {
}
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);
for window in self.windows.values_mut() {
+54 -9
View File
@@ -124,8 +124,8 @@ pub(crate) fn sampled_output_elements(
return None;
}
let sample = frame.windows.get(&id)?;
let visual = scaled_visual_rect(sample.rect, sample.close_scale);
let close_alpha = sample.close_alpha;
let visual = sample.rect;
let close_alpha = sample.alpha;
let decoration_progress = sample.geometry.decorations.clamp(0.0, 1.0);
Some((window.clone(), id, sample, visual, decoration_progress, close_alpha))
@@ -145,7 +145,16 @@ pub(crate) fn sampled_output_elements(
.collect::<Vec<_>>()
};
let live = windows.iter().map(|(_, id, ..)| *id).collect();
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 material_surface = window
.toplevel()
@@ -165,7 +174,7 @@ pub(crate) fn sampled_output_elements(
let rounded_clip_program = corner_program(&mut state.render, renderer, shape);
let pixels = corners.rect;
// Only overview/close intentionally scale the complete application.
let scale_content = frame.overview.is_presenting() || sample.close_scale != 1.0;
let scale_content = sample.scale_content;
let behavior = resize_content_behavior(scale_content);
let dim = sample.dim;
@@ -227,13 +236,33 @@ pub(crate) fn sampled_output_elements(
output,
&programs,
);
if !scale_content
&& let Some(snapshot) = state.render.snapshot(&id)
if let Some(snapshot) = state.render.snapshot(&id)
&& snapshot.context == renderer.context_id().erased()
&& (snapshot.scale - scale).abs() < 0.001
{
let size = snapshot.texture.size();
let 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 {
let clip = framebuffer_clip_rect(
pixels,
@@ -247,8 +276,8 @@ pub(crate) fn sampled_output_elements(
texture: snapshot.texture.clone(),
geometry: visible,
source: Rectangle::from_size(Size::from((
f64::from(visible.size.w),
f64::from(visible.size.h),
f64::from(visible.size.w) / sx,
f64::from(visible.size.h) / sy,
))),
alpha: crate::presentation::handoff_alpha(snapshot.elapsed) * close_alpha,
program: Some(programs.texture.clone()),
@@ -268,6 +297,7 @@ pub(crate) fn sampled_output_elements(
output,
programs.clone(),
behavior,
sample.native_size,
));
let source = window.geometry().size;
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(
state,
renderer,
@@ -374,7 +412,14 @@ pub(super) fn layer_elements(
.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();
for (geometry, layer) in layers {
+172 -97
View File
@@ -9,6 +9,7 @@ mod reconciliation;
pub(crate) use reconciliation::{DesktopChanges, DesktopOutput, DesktopTransition};
mod prediction;
pub(crate) use prediction::FrameScene;
mod lifecycle;
mod window_registry;
mod windows;
@@ -19,7 +20,7 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use calloop::LoopHandle;
use ferese_animation::{AnimatedValue, ClientSize, CrossingPolicy, PresentationMode, SpringConfig, WindowGeometry};
use ferese_animation::{AnimatedValue, ClientSize, PresentationMode, SpringConfig, WindowGeometry};
use ferese_core::{
LayoutMode, OutputGeometry, OutputId, OutputWorkspaceMap, WindowPlacement, WorkspaceId, WorkspaceLayout,
WorkspaceSet,
@@ -98,9 +99,11 @@ use crate::wallpaper::{WallpaperConfig, WallpaperState};
use crate::window_rules::{WindowRule, resolve as resolve_window_rules};
use crate::winit::NestedBackend;
const CLOSE_ANIMATION_DURATION: Duration = Duration::from_millis(140);
const WORKSPACE_SLIDE_DURATION: Duration = Duration::from_millis(220);
const WORKSPACE_SLIDE_FIRST_FRAME: Duration = Duration::from_millis(8);
#[cfg(test)]
use ferese_animation::CrossingPolicy;
#[cfg(test)]
const SLIDE_SETTLE_TEST_BUDGET: Duration = Duration::from_secs(2);
fn workspace_swipe_is_current(
outputs: &OutputWorkspaceMap,
@@ -153,7 +156,7 @@ struct FocusSwipe {
}
impl FocusSwipe {
fn position(&self) -> f64 {
fn distance(&self) -> f64 {
let distance = self.destination - self.start;
// When both columns already fit, give bounded drag feedback and settle
// back to the unchanged viewport after selecting the next window.
@@ -166,7 +169,29 @@ impl FocusSwipe {
} else {
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,
start: SlideOffset,
target: SlideOffset,
velocity: SlideOffset,
}
#[derive(Clone)]
struct WorkspaceSlide {
items: Vec<WorkspaceSlideItem>,
elapsed: Duration,
spring: SpringConfig,
held_progress: Option<f64>,
gesture: Option<(WorkspaceId, WorkspaceId, SwipeDirection)>,
}
@@ -190,12 +216,14 @@ impl WorkspaceSlide {
let movement = SlideOffset::for_swipe(direction);
let mut items = previous
.map(|slide| {
let progress = slide.progress();
let progress = slide.held_progress;
slide
.items
.into_iter()
.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
})
.collect::<Vec<_>>()
@@ -209,6 +237,7 @@ impl WorkspaceSlide {
workspace: from,
start: SlideOffset::default(),
target: movement,
velocity: SlideOffset::default(),
});
}
if let Some(item) = items.iter_mut().find(|item| item.workspace == to) {
@@ -218,30 +247,64 @@ impl WorkspaceSlide {
workspace: to,
start: movement.opposite(),
target: SlideOffset::default(),
velocity: SlideOffset::default(),
});
}
Self {
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,
gesture: None,
}
}
fn progress(&self) -> f64 {
self.progress_at(Duration::ZERO)
fn position(&self, item: &WorkspaceSlideItem, delta: Duration) -> SlideOffset {
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 {
self.held_progress.unwrap_or_else(|| {
let elapsed = self.elapsed.saturating_add(delta).min(WORKSPACE_SLIDE_DURATION);
smoothstep(elapsed.as_secs_f64() / WORKSPACE_SLIDE_DURATION.as_secs_f64())
})
fn moving(&self) -> bool {
self.items
.iter()
.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) {
let progress = self.progress();
let progress = self.held_progress.unwrap_or(0.0);
for item in &mut self.items {
let position = item.start.between(item.target, progress);
if !committed && let Some((from, to, direction)) = self.gesture {
@@ -255,12 +318,36 @@ impl WorkspaceSlide {
}
self.held_progress = None;
self.gesture = None;
self.elapsed = Duration::ZERO;
}
fn advance(&mut self, delta: Duration) -> bool {
self.elapsed = (self.elapsed + delta).min(WORKSPACE_SLIDE_DURATION);
self.elapsed < WORKSPACE_SLIDE_DURATION
if self.held_progress.is_some() {
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 {
@@ -269,43 +356,16 @@ impl WorkspaceSlide {
#[cfg(test)]
fn offset(&self, workspace: WorkspaceId, width: f64, height: f64) -> (f64, f64) {
let progress = self.progress();
let position = self
.items
.iter()
.find(|item| item.workspace == workspace)
.map(|item| item.start.between(item.target, progress))
.map(|item| self.position(item, Duration::ZERO))
.unwrap_or_default();
(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 {
if enabled {
duration.div_f64(speed)
@@ -373,7 +433,7 @@ pub struct Ferese {
pub(crate) inactive_dim: crate::config::InactiveDimSettings,
animations_enabled: bool,
animation_speed: f64,
spring_config: SpringConfig,
pub(crate) spring_config: SpringConfig,
viewport_spring_config: SpringConfig,
pub(crate) output_profiles: Vec<OutputProfile>,
pub(crate) autostart: Vec<DaemonConfig>,
@@ -784,6 +844,13 @@ impl Ferese {
self.animation_speed = config.animation_speed;
self.spring_config = config.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.autostart = config.autostart;
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 {
if had_geometry {
visual_x + viewport_x
@@ -1044,20 +1105,60 @@ impl ClientData for ClientState {
#[cfg(test)]
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]
fn sampling_slide_progress_matches_advancing_without_mutating_the_slide() {
let slide = WorkspaceSlide::new(None, WorkspaceId(1), WorkspaceId(2), SwipeDirection::Left);
for delta in [Duration::ZERO, Duration::from_millis(8), Duration::from_secs(1)] {
for delta in [Duration::ZERO, Duration::from_millis(16), Duration::from_millis(100)] {
let mut advanced = slide.clone();
advanced.advance(delta);
assert_eq!(slide.progress_at(delta), advanced.progress());
assert_eq!(slide.elapsed, WORKSPACE_SLIDE_FIRST_FRAME);
for (before, after) in slide.items.iter().zip(&advanced.items) {
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]
@@ -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]
fn workspace_drag_cancels_when_focus_or_target_ownership_changes() {
let mut outputs = OutputWorkspaceMap::default();
@@ -1241,7 +1333,7 @@ mod tests {
slide.release(committed);
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!(!slide.advance(WORKSPACE_SLIDE_DURATION));
assert!(!slide.advance(SLIDE_SETTLE_TEST_BUDGET));
let active = if committed { to } else { from };
assert_eq!(slide.offset(active, 1000.0, 1000.0), (0.0, 0.0));
}
@@ -1253,14 +1345,14 @@ mod tests {
let second = WorkspaceId(2);
let third = WorkspaceId(3);
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);
slide.gesture = Some((second, third, SwipeDirection::Up));
slide.held_progress = Some(0.25);
let position = slide.offset(second, 1000.0, 1000.0);
slide.release(false);
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));
}
@@ -1279,13 +1371,13 @@ mod tests {
let mut slide = WorkspaceSlide::new(None, from, to, direction);
assert!(slide.contains(from));
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 incoming = slide.offset(to, width, height);
assert!((incoming.0 - outgoing.0).abs() == outgoing_target.0.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(to, width, height), (0.0, 0.0));
}
@@ -1297,7 +1389,7 @@ mod tests {
let second = WorkspaceId(2);
let third = WorkspaceId(3);
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 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));
}
#[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]
fn coupled_right_column_width_keeps_its_right_edge_stable() {
let spring = SpringConfig {
@@ -1430,6 +1504,7 @@ mod tests {
damping: 2.0 * 320.0_f64.sqrt(),
position_tolerance: 0.1,
velocity_tolerance: 0.1,
..SpringConfig::default()
};
let mut width = AnimatedValue::new(980.0);
let mut viewport = AnimatedValue::new(495.0);
+61 -28
View File
@@ -82,6 +82,7 @@ impl Ferese {
if self.focus_swipe.is_some()
|| !self.paused_workspaces.is_empty()
|| !self.workspace_slides.is_empty()
|| !self.render.closing.is_empty()
|| !self.dismissing_popups.is_empty()
|| self.render.snapshots().next().is_some()
|| self.overview.needs_tick()
@@ -192,11 +193,22 @@ impl Ferese {
for (&id, record) in self.windows.records_mut() {
let selected = selected_window == Some(id);
let target = if selected { 1.0 } else { 0.0 };
let focus = record
.focus
.get_or_insert_with(|| crate::dimming::DimAnimation::new(target));
let focus = record.focus.get_or_insert_with(|| AnimatedValue::new(target));
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;
}
@@ -217,19 +229,40 @@ impl Ferese {
dim_changed |= previous != dim.current;
}
let mut ready_to_close = Vec::new();
for (id, record) in self.windows.records_mut() {
let Some(animation) = &mut record.closing else { continue };
if animation.advance(delta, self.animations_enabled) {
ready_to_close.push(*id);
} else if !animation.close_sent {
active_animation = true;
for (_, record) in self.windows.records_mut() {
if let Some(opening) = &mut record.opening {
let active = if self.animations_enabled {
opening.advance(
delta,
SpringConfig {
position_tolerance: 0.00001,
velocity_tolerance: 0.00001,
..self.spring_config
},
)
} else {
opening.snap();
false
};
active_animation |= active;
if !active {
record.opening = None;
}
}
}
for id in ready_to_close {
self.send_window_close(id);
let mut finished = Vec::new();
self.render.closing.retain_mut(|window| {
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
@@ -325,8 +358,7 @@ impl Ferese {
viewport.current = swipe.position();
viewport.velocity = 0.0;
} else if self.animations_enabled {
active_animation |=
viewport.advance_with_policy(delta, self.viewport_spring_config, CrossingPolicy::NoCrossing);
active_animation |= viewport.advance(delta, self.viewport_spring_config);
} else {
viewport.snap();
}
@@ -379,8 +411,7 @@ impl Ferese {
if zooming {
sync_scrolling_coordinates(geometry, world_x, viewport, true);
} else if self.animations_enabled {
active_animation |=
world_x.advance_with_policy(delta, self.spring_config, CrossingPolicy::NoCrossing);
active_animation |= world_x.advance(delta, self.spring_config);
} else {
world_x.snap();
}
@@ -392,7 +423,7 @@ impl Ferese {
if let Some((_, width)) = record.coupled_width.as_mut() {
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 {
width.snap();
false
@@ -462,6 +493,9 @@ impl Ferese {
self.send_shell_snapshots();
active_animation = true;
}
if !self.overview.is_active() {
self.retarget_overview();
}
active_animation |= self
.overview
.advance(delta, self.spring_config, self.animations_enabled);
@@ -525,10 +559,9 @@ impl Ferese {
else {
continue;
};
let progress = slide.progress_at(delta);
for item in &slide.items {
let position = item.start.between(item.target, progress);
let position = slide.position(item, delta);
offsets.insert(
item.workspace,
(position.x * f64::from(size.w), position.y * f64::from(size.h)),
@@ -573,7 +606,7 @@ fn record_needs_tick(
mapped: impl FnOnce() -> bool,
) -> bool {
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))
{
return true;
@@ -610,7 +643,7 @@ mod tests {
WindowRecord {
geometry: Some(geometry),
focus: Some(DimAnimation::new(1.0)),
focus: Some(AnimatedValue::new(1.0)),
dimming: Some(DimAnimation::new(0.0)),
..Default::default()
}
@@ -660,11 +693,11 @@ mod tests {
}
#[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();
record.closing = Some(ClosingAnimation::default());
record.opening = Some(AnimatedValue::new(0.0));
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));
record.coupled_width = Some((WorkspaceId(1), AnimatedValue::new(400.0)));
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
// the next unrelated animation would inherit an old paused workspace.
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(
id,
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()
&& self.window_content_ready(window)
&& self.windows.geometry(id).is_some()
&& self.output_workspaces.focused_output().is_some()
&& self.windows.record(*id).is_none_or(|record| record.closing.is_none()))
&& self.output_workspaces.focused_output().is_some())
.then_some(*id)
})
.collect()
}
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
.workspace_for_window(id)
.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)> {
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));
}
let id = self.windows.ids().get(window)?;
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)?;
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(_)) {
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;
};
let to = neighbor.unwrap_or(from);
let workspace_id = workspace.id;
let start = self
.viewport_animations
@@ -45,7 +46,8 @@ impl Ferese {
.or_else(|| workspace.layout.viewport_x())
.unwrap_or(0.0);
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()
{
return;
@@ -63,7 +65,11 @@ impl Ferese {
direction,
gesture_direction,
start,
destination: candidate.viewport_x().unwrap_or(start),
destination: if to == from {
start
} else {
candidate.viewport_x().unwrap_or(start)
},
progress,
});
self.swipe.mark_preview_started();
@@ -91,7 +97,9 @@ impl Ferese {
// 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) {
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| {
self.workspaces
@@ -460,6 +468,11 @@ impl Ferese {
};
let previous = self.workspace_slides.remove(&output);
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.gesture = Some((from, to, direction));
self.workspace_slides.insert(output, slide);
@@ -481,7 +494,7 @@ impl Ferese {
&& self.activate_managed_workspace_from_swipe(to, expected);
if !committed {
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.relayout();
@@ -497,26 +510,55 @@ impl Ferese {
workspace: WorkspaceId,
slide_direction: Option<SwipeDirection>,
) {
if owner != requested_output {
return;
}
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
&& let Some(slide) = previous_slide.as_mut()
&& slide
.gesture
.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.workspace_slides.insert(owner, previous_slide.unwrap());
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
&& let (Some(from), Some(direction)) = (previous, slide_direction)
&& from != workspace
&& self.animations_enabled
&& !self.overview.is_presenting()
{
self.last_animation_tick = Instant::now();
self.workspace_slides
.insert(owner, WorkspaceSlide::new(previous_slide, from, workspace, direction));
let mut slide = 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,
// Plane selection and scheduling reuse the activity sampled with this scene.
pub animating: bool,
pub delta: Duration,
}
pub(crate) struct WindowFrame {
pub geometry: WindowGeometry,
pub rect: Rect,
pub close_scale: f64,
pub close_alpha: f32,
pub scale_content: bool,
pub native_size: Option<ClientSize>,
pub alpha: f32,
pub focus: f64,
pub dim: f64,
}
@@ -68,7 +70,12 @@ impl Ferese {
}
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
.dismissing_popups
.iter()
@@ -80,7 +87,7 @@ impl Ferese {
if self
.output_id(output)
.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;
}
@@ -95,8 +102,8 @@ impl Ferese {
}
if record.resize.is_some()
|| record.opening.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.dimming.as_ref().is_some_and(|dim| dim.is_animating())
{
@@ -117,15 +124,10 @@ impl Ferese {
.focus_swipe
.as_ref()
.is_some_and(|swipe| swipe.workspace == workspace);
!held
&& viewport.advance_with_policy(
Duration::ZERO,
self.viewport_spring_config,
CrossingPolicy::NoCrossing,
)
!held && viewport.advance(Duration::ZERO, self.viewport_spring_config)
}) || record.coupled_width.as_ref().is_some_and(|(_, 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;
}
let mut close = record.closing.as_ref().copied().unwrap_or_default();
if record.closing.is_some() && !delta.is_zero() {
close.advance(delta, true);
let mut opening = record.opening.unwrap_or_else(|| AnimatedValue::new(1.0));
if !self.animations_enabled {
opening.snap();
} else if !delta.is_zero() {
opening.advance(
delta,
SpringConfig {
position_tolerance: 0.00001,
velocity_tolerance: 0.00001,
..self.spring_config
},
);
}
let eased = smoothstep(close.progress);
let focused = if self.overview.is_active() {
self.overview_selected(id)
} else {
@@ -211,14 +220,31 @@ impl Ferese {
motion.current
};
let mut focus = record
.focus
.unwrap_or_else(|| AnimatedValue::new(if focused { 1.0 } else { 0.0 }));
if !delta.is_zero() {
focus.advance(
delta,
SpringConfig {
position_tolerance: 0.001,
velocity_tolerance: 0.001,
..self.spring_config
},
);
}
let presence_scale = 0.97 + 0.03 * opening.current;
let native_size = (!overview.is_presenting() && presence_scale != 1.0).then(|| ClientSize::from_rect(rect));
let rect = crate::presentation::scaled_visual_rect(rect, presence_scale);
windows.insert(
id,
WindowFrame {
geometry,
rect,
close_scale: 1.0 - eased * 0.02,
close_alpha: (1.0 - eased) as f32,
focus: sample_dim(record.focus.as_ref(), if focused { 1.0 } else { 0.0 }),
scale_content: overview.is_presenting(),
native_size,
alpha: opening.current.clamp(0.0, 1.0) as f32,
focus: focus.current.clamp(0.0, 1.0),
dim: sample_dim(record.dimming.as_ref(), 0.0),
},
);
@@ -228,6 +254,7 @@ impl Ferese {
windows,
overview,
animating,
delta,
}
}
}
@@ -252,9 +279,9 @@ fn predict_geometry(
if let Some((mut world, mut viewport, held)) = world
&& !zooming
{
world.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing);
world.advance(delta, spring);
if !held {
viewport.advance_with_policy(delta, viewport_spring, CrossingPolicy::NoCrossing);
viewport.advance(delta, viewport_spring);
}
predicted.visual.current.x = world.current - viewport.current;
@@ -262,7 +289,7 @@ fn predict_geometry(
}
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.velocity.width = width.velocity;
}
@@ -454,7 +481,7 @@ mod tests {
state.focused_window = Some(id);
state
.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);
assert!(state.output_has_pending_visual_changes(&output));
state.animations_enabled = false;
+5 -4
View File
@@ -21,9 +21,10 @@ pub(crate) struct WindowRecord {
pub title: String,
pub metadata_revision: u64,
pub resize: Option<ResizeTransaction>,
pub focus: Option<crate::dimming::DimAnimation>,
pub focus: Option<AnimatedValue>,
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 coupled_width: Option<(WorkspaceId, AnimatedValue)>,
pub maximized: bool,
@@ -302,8 +303,8 @@ mod tests {
record.resize_anchor = Some((true, false, Rect::new(0., 0., 800., 600.)));
record.column_width_pending = true;
record.rules_applied = true;
record.focus = Some(crate::dimming::DimAnimation::new(1.0));
record.closing = Some(super::super::ClosingAnimation::default());
record.focus = Some(AnimatedValue::new(1.0));
record.opening = Some(AnimatedValue::new(0.0));
record.world_x = Some((WorkspaceId(1), AnimatedValue::new(50.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) {
self.retain_closed_window(window);
// Uncommitted toplevels have not entered a workspace yet.
self.space.unmap_elem(window);
let Some(id) = self.windows.remove(window) else {
@@ -493,7 +494,13 @@ impl Ferese {
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 result = if let Some(backend) = &self.nested_backend {
// Commit dispatch does not run inside the Winit event callback;
@@ -557,6 +564,20 @@ impl Ferese {
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
.workspaces
.workspace_for_window(id)
@@ -648,35 +669,16 @@ impl Ferese {
}
pub fn close_focused_window(&mut self) {
let Some(focused) = self.focused_window else {
return;
};
if !self.animations_enabled {
if let Some(focused) = self.focused_window {
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 {
if self.windows.window(id).is_none() {
return false;
}
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);
}
self.send_window_close(id);
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();
match event {
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 rate = backend
.window()
@@ -115,7 +116,13 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
}
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 => {
state.advance_animations(Instant::now());
let age = backend.buffer_age().unwrap_or(0);
+1
View File
@@ -7,3 +7,4 @@ rust-version.workspace = true
[dependencies]
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;
mod settings;
mod spring;
pub use settings::{MotionSettings, SpringSettings};
pub mod gesture;
/// Crossing behavior belongs to the property, not the physical solver.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -20,6 +23,7 @@ pub struct SpringConfig {
pub damping: f64,
pub position_tolerance: f64,
pub velocity_tolerance: f64,
pub crossing: CrossingPolicy,
}
impl Default for SpringConfig {
@@ -30,6 +34,7 @@ impl Default for SpringConfig {
damping: 53.0,
position_tolerance: 0.1,
velocity_tolerance: 0.1,
crossing: CrossingPolicy::NoCrossing,
}
}
}
@@ -44,6 +49,7 @@ impl SpringConfig {
damping: non_negative_or(self.damping, defaults.damping),
position_tolerance: positive_or(self.position_tolerance, defaults.position_tolerance),
velocity_tolerance: positive_or(self.velocity_tolerance, defaults.velocity_tolerance),
crossing: self.crossing,
}
}
}
@@ -71,6 +77,14 @@ pub struct 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 {
let value = finite_or_zero(value);
@@ -86,13 +100,7 @@ impl AnimatedValue {
}
pub fn retarget_preserving_motion(&mut self, target: f64) {
let target = finite_or_zero(target);
let direction = (target - self.current).signum();
if direction != 0.0 && self.velocity.signum() != direction {
self.velocity *= 0.35;
}
self.target = target;
self.set_target(target);
}
pub fn snap(&mut self) {
@@ -100,9 +108,9 @@ impl AnimatedValue {
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 {
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 {
@@ -156,7 +164,7 @@ impl AnimatedRect {
}
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 {
@@ -304,9 +312,7 @@ pub struct WindowGeometry {
/// Maximized/fullscreen geometry and decorations share a clock, including on reversal.
#[derive(Clone, Copy, Debug, PartialEq)]
struct ZoomTransition {
from: Rect,
decorations_from: f64,
progress: AnimatedValue,
decorations: AnimatedValue,
}
impl WindowGeometry {
@@ -359,28 +365,12 @@ impl WindowGeometry {
let rect = normalized_rect(rect);
let before = (self.visual.current, self.decorations);
if self.mode != mode || (self.zoom.is_some() && self.logical != rect) {
let mut progress = AnimatedValue::new(0.0);
progress.set_target(1.0);
let from = self.visual.current;
let distance = [
rect.x - from.x,
rect.y - from.y,
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,
});
let mut decorations = self
.zoom
.map(|zoom| zoom.decorations)
.unwrap_or_else(|| AnimatedValue::new(self.decorations));
decorations.set_target(if mode == PresentationMode::Fullscreen { 0.0 } else { 1.0 });
self.zoom = Some(ZoomTransition { decorations });
}
self.mode = mode;
self.logical = rect;
@@ -440,55 +430,23 @@ impl WindowGeometry {
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 {
let target = self.visual.target;
// Spring tolerances describe pixels and pixels/second, whereas this
// spring advances unit progress. Without conversion it snaps several
// 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,
let decoration_config = SpringConfig {
position_tolerance: 0.00001,
velocity_tolerance: 0.00001,
..config
};
let active = zoom
.progress
.advance_with_policy(delta, progress_config, CrossingPolicy::NoCrossing);
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 {
let decorations_active = zoom.decorations.advance(delta, decoration_config);
self.decorations = zoom.decorations.current.clamp(0.0, 1.0);
if !geometry_active && !decorations_active {
self.zoom = None;
self.visual.snap();
}
return active;
return geometry_active || decorations_active;
}
self.visual
.advance_with_policy(delta, config, CrossingPolicy::NoCrossing)
geometry_active
}
pub fn inverse_visual_point(&self, x: f64, y: f64) -> Option<(f64, f64)> {
@@ -931,7 +889,7 @@ mod tests {
}
#[test]
fn scalar_spring_softens_opposing_velocity_on_reversal() {
fn scalar_spring_preserves_opposing_velocity_on_reversal() {
let mut value = AnimatedValue::new(0.0);
value.set_target(1_000.0);
value.advance(Duration::from_millis(40), SpringConfig::default());
@@ -941,7 +899,7 @@ mod tests {
value.retarget_preserving_motion(-500.0);
assert!(value.velocity > 0.0);
assert_eq!(value.velocity, velocity * 0.35);
assert_eq!(value.velocity, velocity);
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);
println!("old reversal: {}, {}", value.current, value.velocity);
assert_eq!(value.current, position);
assert_eq!(value.velocity, velocity * 0.35);
assert_eq!(value.velocity, velocity);
}
#[test]
@@ -282,7 +282,7 @@ fn retarget_golden_policy_values() {
old.retarget_preserving_motion(2000.);
close(old.velocity, 9837.123292018367, 1e-9);
old.retarget_preserving_motion(-500.);
close(old.velocity, 3442.993152206428, 1e-9);
close(old.velocity, 9837.123292018367, 1e-9);
}
#[test]
@@ -342,7 +342,7 @@ fn rapid_retarget_trace_preserves_position_and_the_golden_policy() {
let before = value;
value.retarget_preserving_motion(-500.0);
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());
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.velocity = 9837.123292018367;
value.retarget_preserving_motion(-500.0);
close(value.velocity, 3442.993152206428, 1e-9);
close(value.velocity, 9837.123292018367, 1e-9);
}
#[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 mut geometry = WindowGeometry::new(initial, None);
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,
] {
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));
assert_eq!((geometry.visual.current, geometry.decorations), before);
assert_eq!(geometry.visual.velocity, velocity);
geometry.advance(Duration::ZERO, SpringConfig::default(), true);
assert_eq!((geometry.visual.current, geometry.decorations), before);
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
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
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.viewport-spring.mass` | number > 0 | `1` | Scrolling spring mass |
| `animations.viewport-spring.stiffness` | number > 0 | `320` | Scrolling stiffness |
| `animations.viewport-spring.damping-ratio` | number > 0 | `1` | `1` is critically damped |
| `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
@@ -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
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` controls window position and size motion, fullscreen/maximize zoom and
overview entrance motion. `viewport-spring` controls the scrolling viewport and
the column-width animation that runs with it. Shell popups and theme fades use
timed transitions, so the spring parameters do not change their curves.
overview entrance and dismissal, opening/closing windows, focus emphasis, shell
popups and notifications. `viewport-spring` controls the scrolling viewport,
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
to the target, current velocity and the spring parameters. The `animations`
block has no settings for per-animation durations, custom curves or animation
shaders.
Spring settling time depends on distance, velocity and tolerances. In either
spring block, you can use duration and bounce instead of stiffness and damping:
```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
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.
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
damping coefficient instead: critical damping is
are underdamped and values above `1` are overdamped. Both spring blocks accept a
damping ratio or a damping coefficient, but not both; critical damping is
`damping = 2 * sqrt(stiffness * mass)`. Ferese stops spring animations
at their first target crossing, so lowering damping does not make them bounce
past the target.
at their first target crossing by default. `overshoot #true` allows that spring
to move past its target; opacity remains bounded. Overshoot is never enabled globally.
Mass, stiffness, damping ratio and speed must be finite and greater than zero.
The window spring's damping coefficient may also be zero. Invalid edits leave
Mass, stiffness and speed must be finite and greater than zero.
Damping coefficients and damping ratios may be zero only with overshoot disabled,
so the first-crossing clamp can settle the animation. Overshoot requires positive
damping; otherwise closing popups could wait indefinitely for settlement. Invalid edits leave
the last accepted configuration active.
## Appearance
@@ -225,7 +268,7 @@ one appearance.
| `appearance.corner-radius` | number ≥ 0 | unset | Legacy fallback for shell radius |
| `appearance.inactive-dim.enabled` | boolean | `false` | Dim unfocused windows |
| `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.background.path` | string | bundled Ferese wallpaper | Wallpaper image path; an existing selection overrides the default |
| `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_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 json
@@ -22,6 +24,8 @@ class OverviewDamageTest(unittest.TestCase):
repo = Path(__file__).resolve().parents[2]
binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/debug/ferese")
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 = []
with tempfile.TemporaryDirectory(prefix="ferese-overview-damage-") as 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),
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("FERESE_SHELL_CONTROL_SOCKET", None)
log_path = root / "compositor.log"
@@ -71,6 +75,13 @@ class OverviewDamageTest(unittest.TestCase):
wait_for(lambda: len(reports()) >= count + 2)
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
with log_path.open("w") as log:
try:
@@ -89,7 +100,9 @@ class OverviewDamageTest(unittest.TestCase):
app = f"ferese.test.overview.{index}"
client = (["sh", "-c", 'cat "$1"; sleep 120', "sh", str(fifo)]
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
for window in command("get-windows")))
@@ -106,21 +119,31 @@ class OverviewDamageTest(unittest.TestCase):
command("toggle-overview")
time.sleep(2)
quiet = stationary_interval()
self.assertEqual(quiet["frames"], 0, str(quiet))
self.assertEqual(quiet["damaged_pixels"], 0, str(quiet))
self.assertGreater(quiet["no_damage_frames"], 0, str(quiet))
assert_quiet("Settled overview", quiet)
count = len(reports())
os.write(writer, b"Live overview content must still update.\n")
wait_for(lambda: len(reports()) > count)
changed = reports()[-1]
self.assertGreater(changed["frames"], 0, str(changed))
self.assertGreater(changed["damaged_pixels"], 0, str(changed))
print("Live content update:", changed, flush=True)
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.
quiet = stationary_interval()
self.assertEqual(quiet["frames"], 0, str(quiet))
self.assertGreater(quiet["no_damage_frames"], 0, str(quiet))
assert_quiet("Settled overview after content update", 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:
if writer is not None:
os.close(writer)
@@ -55,6 +55,7 @@ class UnmappedToplevelTest(unittest.TestCase):
XDG_RUNTIME_DIR=str(runtime),
XDG_CONFIG_HOME=str(root / "config"),
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")
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.
subprocess.run([str(ctl_binary), "toggle-maximized"], env=env,
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.flush()
@@ -140,6 +145,11 @@ class UnmappedToplevelTest(unittest.TestCase):
[str(ctl_binary), "get-focused-window"], env=env, text=True
))
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:
if client and client.poll() is None:
client.terminate()
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
rust-version.workspace = true
[dependencies]
ferese-animation = { path = "../../crates/ferese-animation" }
ferese-theme-client = { path = "../../crates/ferese-theme-client" }
ferese-ipc = { path = "../../crates/ferese-ipc" }
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()))
{
Ok(config) => {
config.animations.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)?;
let mut theme = shell_theme(&config.theme);
@@ -577,6 +578,17 @@ pub(crate) fn config_path() -> Option<PathBuf> {
#[cfg(test)]
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]
fn desktop_clock_parses_and_rejects_invalid_reload_values() {
let clock = parse_test_source(
+1
View File
@@ -904,6 +904,7 @@ impl FereseShell {
motion::configure(config.animations, config.theme.material_radius);
if self.config.animations != config.animations {
self.notifications.update_motion_settings(config.animations);
if let Some(menu) = &mut self.menu {
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::{Event, Length, Rectangle, Size, Transformation, Vector};
use cosmic::{Element, Theme};
pub type Regions = std::sync::Arc<std::sync::Mutex<Vec<[f32; 5]>>>;
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
#[serde(default)]
pub(crate) struct Settings {
enabled: bool,
reduced_motion: bool,
speed: f64,
}
pub(crate) use ferese_animation::MotionSettings as Settings;
use ferese_animation::{AnimatedValue, SpringConfig};
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 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;
}
pub(crate) fn notification_duration() -> std::time::Duration {
SETTINGS
pub(crate) fn settings() -> Settings {
*SETTINGS
.get_or_init(|| std::sync::RwLock::new(Settings::default()))
.read()
.unwrap()
.duration(180.0)
}
pub(crate) struct PopupMotion {
start: f32,
target: f32,
value: AnimatedValue,
started: Option<std::time::Instant>,
settings: Settings,
}
impl PopupMotion {
pub(crate) fn update_settings(&mut self, settings: Settings) {
let now = std::time::Instant::now();
self.start = self.progress_at(now);
self.update_settings_at(settings, std::time::Instant::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;
if self.started.is_some() {
self.started = Some(now);
@@ -83,8 +54,11 @@ impl PopupMotion {
pub(crate) fn new(settings: Settings) -> Self {
Self {
start: 0.0,
target: 1.0,
value: AnimatedValue {
current: 0.0,
target: 1.0,
velocity: 0.0,
},
started: None,
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 {
return self.start;
return value;
};
let duration = self.settings.duration(if self.target == 1.0 { 200.0 } else { 140.0 });
if duration.is_zero() {
return self.target;
if !self.settings.motion_enabled() {
value.snap();
return value;
}
let t = (now.saturating_duration_since(started).as_secs_f32() / duration.as_secs_f32()).min(1.0);
if t == 1.0 {
return self.target;
}
self.start + (self.target - self.start) * t * t * (3.0 - 2.0 * t)
let spring = self
.settings
.spring
.resolve(SpringConfig::default())
.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 {
self.progress_at(std::time::Instant::now())
}
pub(crate) fn closing(&self) -> bool {
self.target == 0.0
self.value.target == 0.0
}
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) {
self.start = self.progress_at(now);
self.target = target;
self.value = self.sample(now);
self.value.set_target(f64::from(target));
self.started = Some(now);
}
}
@@ -158,7 +145,7 @@ pub(crate) fn button<'a, M: Clone + 'a>(
duration: SETTINGS
.get()
.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)]
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::*;
#[test]
@@ -529,19 +538,48 @@ mod tests {
}
#[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 mut motion = PopupMotion::new(Settings::default());
assert_eq!(motion.progress_at(now + std::time::Duration::from_secs(30)), 0.0);
motion.begin(now);
// Later configure/focus notifications must not restart the clock.
motion.begin(now + std::time::Duration::from_millis(90));
assert!((motion.progress_at(now + std::time::Duration::from_millis(100)) - 0.5).abs() < 0.00001);
assert_eq!(motion.progress_at(now + std::time::Duration::from_millis(200)), 1.0);
motion.retarget(0.0, now + std::time::Duration::from_millis(200));
let reverse = now + std::time::Duration::from_millis(90);
let before = motion.sample(reverse);
motion.begin(reverse);
assert_eq!(motion.sample(reverse), before);
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.progress_at(now + std::time::Duration::from_millis(270)) - 0.5).abs() < 0.00001);
assert_eq!(motion.progress_at(now + std::time::Duration::from_millis(340)), 0.0);
assert_eq!(motion.progress_at(reverse + std::time::Duration::from_secs(2)), 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]
@@ -560,7 +598,7 @@ mod tests {
let before = motion.progress_at(reopen);
motion.retarget(1.0, reopen);
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 [
Settings {
reduced_motion: true,
@@ -632,7 +670,7 @@ mod tests {
speed: 0.75,
..Settings::default()
}
.hover_duration(),
.duration(120.0),
std::time::Duration::from_millis(160)
);
for settings in [
@@ -645,9 +683,9 @@ mod tests {
..Settings::default()
},
] {
assert!(settings.hover_duration().is_zero());
assert!(settings.duration(120.0).is_zero());
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);
}
}
+21 -19
View File
@@ -262,7 +262,7 @@ impl Service {
struct Toast {
id: u32,
born: Instant,
motion: super::motion::PopupMotion,
closing: Option<Instant>,
remaining: Option<Duration>,
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 {
self.entries.iter().filter(|notice| notice.unread).count() as u32
}
pub fn visible(&self) -> impl Iterator<Item = (&Notice, f32)> {
let duration = super::motion::notification_duration();
self.toasts.iter().rev().filter_map(move |toast| {
let notice = self.entries.iter().find(|notice| notice.id == toast.id)?;
let p = if duration.is_zero() {
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)))
Some((notice, toast.motion.progress().clamp(0.0, 1.0)))
})
}
pub fn animating(&self) -> bool {
self.toasts
.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)> {
@@ -433,7 +430,11 @@ impl Center {
}
}
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 {
self.toasts.push_back(Toast {
id: notice.id,
born: Instant::now(),
motion: {
let mut motion = super::motion::PopupMotion::new(super::motion::settings());
motion.begin(Instant::now());
motion
},
closing: None,
remaining: notice.timeout,
hovered: group_hovered,
@@ -604,11 +609,8 @@ impl Center {
for id in expired {
self.close(id, 1);
}
self.toasts.retain(|toast| {
toast
.closing
.is_none_or(|closing| now.duration_since(closing) < super::motion::notification_duration())
});
self.toasts
.retain(|toast| toast.closing.is_none() || toast.motion.animating());
let visible: HashSet<_> = self.toasts.iter().map(|toast| toast.id).collect();
self.entries
.retain(|notice| !notice.transient || notice.live || visible.contains(&notice.id));
+35 -25
View File
@@ -26,9 +26,12 @@ impl FereseShell {
self.calendar_offset = 0;
}
let destroy = self.destroy_menu();
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 {
@@ -36,14 +39,6 @@ impl FereseShell {
}
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);
self.status_error = None;
let parent = self.bar_surface_id;
@@ -70,6 +65,33 @@ impl FereseShell {
} else {
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>(
|_| Default::default(),
move |_| SctkPopupSettings {
@@ -79,23 +101,11 @@ impl FereseShell {
grab: true,
close_with_children: false,
input_zone: None,
positioner: SctkPositioner {
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()
},
positioner: positioner.clone(),
},
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> {
@@ -136,7 +146,7 @@ impl FereseShell {
pub fn animate_menu(&mut self) -> Task<Message> {
if let Some(menu) = &self.menu {
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() {
return self.destroy_menu();
+1 -1
View File
@@ -295,7 +295,7 @@ impl FereseShell {
if let Some(effects) = &entry.effects {
let regions = entry.regions.lock().unwrap().clone();
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));
}
}
}