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

-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);