Implement Continuum compositor and shell motion
This commit is contained in:
1 parent
259f7e93b6
commit
f8bc17e913
42 files changed
+1873
-647
No files matched your search
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -241,6 +241,7 @@ pub(super) fn overview_strip_elements(
|
||||
output,
|
||||
programs.clone(),
|
||||
ConstrainScaleBehavior::Stretch,
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,3 +7,4 @@ rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ferese-layout = { path = "../ferese-layout" }
|
||||
serde.workspace = true
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¬ice.id));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -295,7 +295,7 @@ impl FereseShell {
|
||||
if let Some(effects) = &entry.effects {
|
||||
let regions = entry.regions.lock().unwrap().clone();
|
||||
let _ = effects.set_material_regions(®ions, 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user