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

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