1
0
Fork 0
forked from wry/wry

Add retained spawn-out animations

This commit is contained in:
atagen 2026-05-21 17:09:06 +10:00
parent d0cc5dc3c7
commit fa5c28ca3d
9 changed files with 331 additions and 8 deletions

View file

@ -14,8 +14,9 @@ be handled deliberately.
in-flight windows keep their existing timelines.
- Spawn-in uses scale and position for newly mapped tiled and floating app
windows. Layer-shell, overlay, override-redirect, and fullscreen surfaces do
not use this path. Spawn-out requires retained visual content after the live
node is gone and remains deferred.
not use this path. Spawn-out uses retained visual content after the live node
is gone, when a stable retained surface tree can be captured before unmap or
destroy.
- Command-driven tile-to-float and float-to-tile transitions may animate.
Protocol drag/drop paths do not.
- The no-overlap multiphase system is a separate phase after the linear path is
@ -90,7 +91,8 @@ Initial scope:
- Live client buffers are rendered in Phase 1. Retained content freezing is
deferred, but animated windows must still be clipped to their presentation
bounds and must preserve the existing stretch behavior for undersized contents.
- No spawn-out.
- Spawn-out is retained-content-only. If the surface cannot be retained safely
the window snaps out instead of animating an empty frame.
- No multiphase no-overlap planner.
Tests:
@ -116,6 +118,9 @@ Initial retained-record implementation status:
for both tiled windows and floating child contents.
- Tile-to-float and float-to-tile transitions retain GPU/dmabuf-backed child
contents while the presentation geometry changes.
- Spawn-out captures retained app-window contents before XDG/Xwayland unmap or
destroy, then renders a detached shrinking presentation record until the
animation completes.
- Retained records hold both `GfxTexture` and `SurfaceBuffer` references so the
existing buffer release/sync path remains authoritative.
- Single-pixel buffers can be retained as color records.

View file

@ -57,6 +57,7 @@ pub struct AnimationState {
pub duration_ms: Cell<u32>,
pub curve: Cell<AnimationCurve>,
windows: RefCell<AHashMap<NodeId, WindowAnimation>>,
exits: RefCell<Vec<ExitAnimation>>,
tick: CloneCell<Option<Rc<AnimationTick>>>,
}
@ -92,6 +93,21 @@ pub enum RetainedContent {
},
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum RetainedExitLayer {
Tiled,
Floating,
}
pub struct RetainedExitFrame {
pub rect: Rect,
pub retained: Rc<RetainedToplevel>,
pub frame_inset: i32,
pub source_body_size: (i32, i32),
pub active: bool,
pub layer: RetainedExitLayer,
}
impl RetainedToplevel {
pub fn capture_surface(surface: &WlSurface, offset: (i32, i32)) -> Option<Rc<Self>> {
Some(Rc::new(Self {
@ -173,6 +189,7 @@ impl Default for AnimationState {
duration_ms: Cell::new(DEFAULT_DURATION_MS),
curve: Cell::new(AnimationCurve::EaseOut),
windows: Default::default(),
exits: Default::default(),
tick: Default::default(),
}
}
@ -181,6 +198,7 @@ impl Default for AnimationState {
impl AnimationState {
pub fn clear(&self) {
self.windows.borrow_mut().clear();
self.exits.borrow_mut().clear();
if let Some(tick) = self.tick.take() {
tick.detach();
}
@ -246,6 +264,42 @@ impl AnimationState {
)
}
pub fn set_spawn_out(
&self,
from: Rect,
frame_inset: i32,
retained: Rc<RetainedToplevel>,
active: bool,
layer: RetainedExitLayer,
now_nsec: u64,
duration_ms: u32,
) -> bool {
if from.is_empty() || duration_ms == 0 {
return false;
}
let to = spawn_in_start_rect(from);
if to == from || to.is_empty() {
return false;
}
let source_body_size = body_size_for_frame(from, frame_inset);
if source_body_size.0 <= 0 || source_body_size.1 <= 0 {
return false;
}
self.exits.borrow_mut().push(ExitAnimation {
from,
to,
start_nsec: now_nsec,
duration_nsec: duration_ms as u64 * 1_000_000,
last_damage: from,
retained,
frame_inset,
source_body_size,
active,
layer,
});
true
}
pub fn visual_rect(&self, node_id: NodeId, layout: Rect, now_nsec: u64) -> Rect {
let windows = self.windows.borrow();
match windows.get(&node_id) {
@ -266,6 +320,22 @@ impl AnimationState {
}
}
pub fn exit_frames(&self, now_nsec: u64) -> Vec<RetainedExitFrame> {
self.exits
.borrow()
.iter()
.filter(|exit| !exit.done(now_nsec))
.map(|exit| RetainedExitFrame {
rect: exit.rect_at(now_nsec),
retained: exit.retained.clone(),
frame_inset: exit.frame_inset,
source_body_size: exit.source_body_size,
active: exit.active,
layer: exit.layer,
})
.collect()
}
fn damage_active(&self, state: &State, now_nsec: u64) -> bool {
let mut damages = vec![];
let mut any_active = false;
@ -283,6 +353,18 @@ impl AnimationState {
any_active |= active;
active
});
self.exits.borrow_mut().retain_mut(|exit| {
let current = exit.rect_at(now_nsec);
let damage = exit.last_damage.union(current).union(exit.to);
damages.push(expand_damage_rect(
damage,
state.theme.sizes.border_width.get().max(0),
));
exit.last_damage = current;
let active = !exit.done(now_nsec);
any_active |= active;
active
});
}
for damage in damages {
state.damage(damage);
@ -329,6 +411,34 @@ impl WindowAnimation {
}
}
struct ExitAnimation {
from: Rect,
to: Rect,
start_nsec: u64,
duration_nsec: u64,
last_damage: Rect,
retained: Rc<RetainedToplevel>,
frame_inset: i32,
source_body_size: (i32, i32),
active: bool,
layer: RetainedExitLayer,
}
impl ExitAnimation {
fn done(&self, now_nsec: u64) -> bool {
now_nsec.saturating_sub(self.start_nsec) >= self.duration_nsec
}
fn rect_at(&self, now_nsec: u64) -> Rect {
if self.duration_nsec == 0 {
return self.to;
}
let elapsed = now_nsec.saturating_sub(self.start_nsec);
let t = (elapsed as f64 / self.duration_nsec as f64).clamp(0.0, 1.0);
lerp_rect(self.from, self.to, t)
}
}
pub struct AnimationTick {
state: Weak<State>,
slf: Weak<dyn LatchListener>,
@ -389,6 +499,13 @@ pub(crate) fn spawn_in_start_rect(target: Rect) -> Rect {
)
}
fn body_size_for_frame(rect: Rect, frame_inset: i32) -> (i32, i32) {
(
rect.width().saturating_sub(2 * frame_inset),
rect.height().saturating_sub(2 * frame_inset),
)
}
fn lerp_rect(from: Rect, to: Rect, t: f64) -> Rect {
fn lerp(from: i32, to: i32, t: f64) -> i32 {
(from as f64 + (to as f64 - from as f64) * t).round() as i32

View file

@ -1,7 +1,7 @@
use {
crate::{
ifs::wl_surface::{
SurfaceExt, WlSurface, WlSurfaceError,
PendingState, SurfaceExt, WlSurface, WlSurfaceError,
x_surface::{xwayland_surface_v1::XwaylandSurfaceV1, xwindow::Xwindow},
},
leaks::Tracker,
@ -30,6 +30,22 @@ impl SurfaceExt for XSurface {
win.node_layer()
}
fn before_apply_commit(
self: Rc<Self>,
pending: &mut PendingState,
) -> Result<(), WlSurfaceError> {
if pending
.buffer
.as_ref()
.is_some_and(|buffer| buffer.is_none())
&& self.surface.buffer.is_some()
&& let Some(xwindow) = self.xwindow.get()
{
xwindow.queue_spawn_out();
}
Ok(())
}
fn after_apply_commit(self: Rc<Self>) {
if let Some(xwindow) = self.xwindow.get() {
xwindow.map_status_changed();
@ -45,6 +61,7 @@ impl SurfaceExt for XSurface {
}
self.surface.unset_ext();
if let Some(xwindow) = self.xwindow.take() {
xwindow.queue_spawn_out();
xwindow.tl_destroy();
xwindow.data.window.set(None);
xwindow.data.surface_id.set(None);

View file

@ -253,6 +253,11 @@ impl Xwindow {
self.x.surface.buffer.is_some() && self.data.info.mapped.get()
}
pub fn queue_spawn_out(&self) {
self.toplevel_data
.queue_spawn_out(self, self.tl_animation_snapshot());
}
fn map_change(&self) -> Change {
match (self.may_be_mapped(), self.is_mapped()) {
(true, false) => Change::Map,
@ -275,6 +280,7 @@ impl Xwindow {
match map_change {
Change::None => return,
Change::Unmap => {
self.queue_spawn_out();
self.data
.info
.pending_extents

View file

@ -226,6 +226,10 @@ pub trait XdgSurfaceExt: Debug {
// nothing
}
fn prepare_unmap(&self) {
// nothing
}
fn extents_changed(&self) {
// nothing
}
@ -664,6 +668,15 @@ impl SurfaceExt for XdgSurface {
if let Some(serial) = pending.serial.take() {
self.applied_serial.set(serial);
}
if pending
.buffer
.as_ref()
.is_some_and(|buffer| buffer.is_none())
&& self.surface.buffer.is_some()
&& let Some(ext) = self.ext.get()
{
ext.prepare_unmap();
}
Ok(())
}

View file

@ -260,6 +260,7 @@ impl XdgToplevelRequestHandler for XdgToplevel {
type Error = XdgToplevelError;
fn destroy(&self, _req: Destroy, _slf: &Rc<Self>) -> Result<(), Self::Error> {
self.queue_spawn_out();
self.tl_destroy();
self.xdg.unset_ext();
{
@ -399,6 +400,11 @@ impl XdgToplevelRequestHandler for XdgToplevel {
}
impl XdgToplevel {
fn queue_spawn_out(&self) {
self.toplevel_data
.queue_spawn_out(self, self.tl_animation_snapshot());
}
fn map(
self: &Rc<Self>,
parent: Option<&XdgToplevel>,
@ -824,6 +830,10 @@ impl XdgSurfaceExt for XdgToplevel {
self.after_commit(None);
}
fn prepare_unmap(&self) {
self.queue_spawn_out();
}
fn extents_changed(&self) {
self.toplevel_data.pos.set(self.xdg.extents.get());
self.tl_extents_changed();

View file

@ -1,6 +1,9 @@
use {
crate::{
animation::{RetainedContent, RetainedSurface, RetainedToplevel},
animation::{
RetainedContent, RetainedExitFrame, RetainedExitLayer, RetainedSurface,
RetainedToplevel,
},
cmm::cmm_render_intent::RenderIntent,
gfx_api::{AcquireSync, AlphaMode, BufferResv, GfxApiOpt, ReleaseSync, SampleRect},
ifs::wl_surface::{
@ -201,6 +204,9 @@ impl Renderer<'_> {
self.render_workspace(&ws, x, y);
}
}
let now = self.state.now_nsec();
let exit_frames = self.state.animations.exit_frames(now);
self.render_exit_frames(&exit_frames, RetainedExitLayer::Tiled, &opos);
macro_rules! render_stacked {
($stack:expr) => {
for stacked in $stack.iter() {
@ -221,6 +227,7 @@ impl Renderer<'_> {
};
}
render_stacked!(self.state.root.stacked);
self.render_exit_frames(&exit_frames, RetainedExitLayer::Floating, &opos);
// Flush RoundedFillRect ops from container/float borders so they don't
// sort after (and render on top of) layer-shell CopyTexture ops.
self.base.sync();
@ -504,6 +511,68 @@ impl Renderer<'_> {
self.render_retained_surface_scaled(&retained.surface, x, y, None, bounds);
}
fn render_exit_frames(
&mut self,
frames: &[RetainedExitFrame],
layer: RetainedExitLayer,
output_rect: &Rect,
) {
for frame in frames {
if frame.layer != layer || !frame.rect.intersects(output_rect) {
continue;
}
self.render_exit_frame(frame, output_rect);
}
}
fn render_exit_frame(&mut self, frame: &RetainedExitFrame, output_rect: &Rect) {
let (x, y) = output_rect.translate(frame.rect.x1(), frame.rect.y1());
let inset = frame.frame_inset;
if inset > 0 {
let color = if frame.active {
self.state.theme.colors.active_border.get()
} else {
self.state.theme.colors.border.get()
};
self.render_rounded_frame(
Rect::new_sized_saturating(0, 0, frame.rect.width(), frame.rect.height()),
&color,
self.state.theme.corner_radius.get(),
inset,
x,
y,
);
}
let body = Rect::new_sized_saturating(
x + inset,
y + inset,
frame.rect.width() - 2 * inset,
frame.rect.height() - 2 * inset,
);
if body.is_empty() {
return;
}
let bounds = self.base.scale_rect(body);
self.stretch = if frame.source_body_size != body.size() {
Some(self.base.scale_point(body.width(), body.height()))
} else {
None
};
if inset > 0 && !self.state.theme.corner_radius.get().is_zero() {
let inner_cr = self.scale_corner_radius(
self.state
.theme
.corner_radius
.get()
.expanded_by(-(inset as f32)),
);
self.corner_radius = Some(inner_cr);
}
self.render_retained_toplevel(&frame.retained, body.x1(), body.y1(), Some(&bounds));
self.stretch = None;
self.corner_radius = None;
}
fn render_retained_surface_scaled(
&mut self,
retained: &RetainedSurface,

View file

@ -3,8 +3,8 @@ use {
acceptor::Acceptor,
allocator::BufferObject,
animation::{
AnimationCurve, AnimationState, AnimationTick, RetainedToplevel, expand_damage_rect,
spawn_in_start_rect,
AnimationCurve, AnimationState, AnimationTick, RetainedExitLayer, RetainedToplevel,
expand_damage_rect, spawn_in_start_rect,
},
async_engine::{AsyncEngine, SpawnedFuture},
backend::{
@ -1572,6 +1572,36 @@ impl State {
}
}
pub fn queue_spawn_out_animation(
self: &Rc<Self>,
from: Rect,
frame_inset: i32,
retained: Rc<RetainedToplevel>,
active: bool,
layer: RetainedExitLayer,
) {
if !self.animations.enabled.get() || from.is_empty() {
return;
}
let now = self.now_nsec();
let started = self.animations.set_spawn_out(
from,
frame_inset,
retained,
active,
layer,
now,
self.animations.duration_ms.get(),
);
if started {
self.damage(expand_damage_rect(
from,
self.theme.sizes.border_width.get().max(0),
));
self.ensure_animation_tick();
}
}
pub fn set_animations_enabled(&self, enabled: bool) {
if self.animations.enabled.replace(enabled) && !enabled {
self.animations.clear();

View file

@ -1,6 +1,6 @@
use {
crate::{
animation::RetainedToplevel,
animation::{RetainedExitLayer, RetainedToplevel},
client::{Client, ClientId},
criteria::{
CritDestroyListener, CritMatcherId,
@ -988,6 +988,62 @@ impl ToplevelData {
self.mapped_during_iteration.get() == self.state.eng.iteration()
}
pub fn queue_spawn_out(&self, node: &dyn ToplevelNode, retained: Option<Rc<RetainedToplevel>>) {
if !self.kind.is_app_window()
|| !self.visible.get()
|| self.is_fullscreen.get()
|| node.node_is_container()
{
return;
}
let Some(retained) = retained else {
return;
};
let bw = self.state.theme.sizes.border_width.get().max(0);
let now = self.state.now_nsec();
let (outer, frame_inset, layer) = if self.parent_is_float.get() {
let Some(float) = self.float.get() else {
return;
};
(
self.state
.animations
.visual_rect(float.node_id(), float.position.get(), now),
bw,
RetainedExitLayer::Floating,
)
} else {
let body =
self.state
.animations
.visual_rect(self.node_id, node.node_absolute_position(), now);
if body.is_empty() {
return;
}
if self.state.theme.sizes.gap.get() != 0 {
(
Rect::new_sized_saturating(
body.x1() - bw,
body.y1() - bw,
body.width() + 2 * bw,
body.height() + 2 * bw,
),
bw,
RetainedExitLayer::Tiled,
)
} else {
(body, 0, RetainedExitLayer::Tiled)
}
};
self.state.clone().queue_spawn_out_animation(
outer,
frame_inset,
retained,
self.active(),
layer,
);
}
pub fn set_content_type(&self, content_type: Option<ContentType>) {
if self.content_type.replace(content_type) != content_type {
self.property_changed(TL_CHANGED_CONTENT_TY);