1
0
Fork 0
forked from wry/wry

Add spawn-in window animations

This commit is contained in:
atagen 2026-05-21 16:06:33 +10:00
parent 7575f851fe
commit 18ffaef64d
6 changed files with 157 additions and 12 deletions

View file

@ -12,8 +12,10 @@ be handled deliberately.
- Pointer drag and resize initiated by the mouse or tablet do not animate.
- Linear animations restart only for windows whose destination changes. Other
in-flight windows keep their existing timelines.
- Spawn-in uses scale and position. Spawn-out requires retained visual content
and is deferred until the freezing layer exists.
- 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.
- 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
@ -80,8 +82,8 @@ Implementation shape:
Initial scope:
- Tiled reflow animation.
- Floating command-driven moves, tile-to-float, float-to-tile, and spawn-in are
deferred until after tiled reflow is validated.
- Floating command-driven moves, tile-to-float, and float-to-tile are deferred
until after tiled reflow and spawn-in are validated.
- Cross-output and cross-scale movements snap for now.
- Linear mode may overlap windows during swaps. That is expected for the classic
interpolation mode; no-overlap is Phase 3.
@ -89,7 +91,6 @@ Initial scope:
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.
- No content freezing.
- No multiphase no-overlap planner.
Tests:
@ -107,6 +108,8 @@ Goal: freeze visual contents during movement and enable spawn-out.
Initial retained-record implementation status:
- Tiled animation can retain GPU/dmabuf-backed XDG and Xwayland surface trees.
- Spawn-in animation can retain GPU/dmabuf-backed XDG and Xwayland surface trees
for both tiled windows and floating child contents.
- 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

@ -17,6 +17,8 @@ use {
};
const DEFAULT_DURATION_MS: u32 = 160;
const SPAWN_IN_INITIAL_SCALE_NUMERATOR: i32 = 4;
const SPAWN_IN_INITIAL_SCALE_DENOMINATOR: i32 = 5;
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum AnimationCurve {
@ -224,6 +226,26 @@ impl AnimationState {
true
}
pub fn set_spawn_in(
&self,
node_id: NodeId,
target: Rect,
retained: Option<Rc<RetainedToplevel>>,
now_nsec: u64,
duration_ms: u32,
) -> bool {
let start = spawn_in_start_rect(target);
self.set_target(
node_id,
start,
target,
retained,
now_nsec,
duration_ms,
AnimationCurve::Linear,
)
}
pub fn visual_rect(&self, node_id: NodeId, layout: Rect, now_nsec: u64) -> Rect {
let windows = self.windows.borrow();
match windows.get(&node_id) {
@ -350,6 +372,23 @@ impl LatchListener for AnimationTick {
}
}
pub(crate) fn spawn_in_start_rect(target: Rect) -> Rect {
fn scaled_dimension(value: i32) -> i32 {
let scaled = (value as i64 * SPAWN_IN_INITIAL_SCALE_NUMERATOR as i64
/ SPAWN_IN_INITIAL_SCALE_DENOMINATOR as i64) as i32;
scaled.clamp(1, value.max(1))
}
let width = scaled_dimension(target.width());
let height = scaled_dimension(target.height());
Rect::new_sized_saturating(
target.x1() + (target.width() - width) / 2,
target.y1() + (target.height() - height) / 2,
width,
height,
)
}
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
@ -437,4 +476,25 @@ mod tests {
Rect::new_sized_saturating(125, 0, 100, 100)
);
}
#[test]
fn spawn_in_start_rect_is_centered_and_non_empty() {
let target = Rect::new_sized_saturating(10, 20, 100, 50);
assert_eq!(
spawn_in_start_rect(target),
Rect::new_sized_saturating(20, 25, 80, 40)
);
}
#[test]
fn spawn_in_uses_linear_curve() {
let state = AnimationState::default();
let id = NodeId(1);
let target = Rect::new_sized_saturating(10, 20, 100, 50);
assert!(state.set_spawn_in(id, target, None, 0, 160));
assert_eq!(
state.visual_rect(id, target, 80_000_000),
Rect::new_sized_saturating(15, 23, 90, 45)
);
}
}

View file

@ -15,7 +15,7 @@ use {
state::State,
theme::{Color, CornerRadius},
tree::{
ContainerNode, DisplayNode, FloatNode, OutputNode, PlaceholderNode, ToplevelData,
ContainerNode, DisplayNode, FloatNode, Node, OutputNode, PlaceholderNode, ToplevelData,
ToplevelNode, ToplevelNodeBase, WorkspaceNode, tab_bar::TabBar,
},
},
@ -207,8 +207,13 @@ impl Renderer<'_> {
if stacked.node_visible() {
self.base.sync();
let pos = stacked.node_absolute_position();
if pos.intersects(&opos) {
let (x, y) = opos.translate(pos.x1(), pos.y1());
let visual = self.state.animations.visual_rect(
stacked.node_id(),
pos,
self.state.now_nsec(),
);
if visual.intersects(&opos) {
let (x, y) = opos.translate(visual.x1(), visual.y1());
stacked.node_render(self, x, y, None);
}
}
@ -983,6 +988,10 @@ impl Renderer<'_> {
_ => return,
};
let pos = floating.position.get();
let visual =
self.state
.animations
.visual_rect(floating.node_id(), pos, self.state.now_nsec());
let theme = &self.state.theme;
let bw = theme.sizes.border_width.get();
let bc = if floating.active.get() {
@ -991,16 +1000,26 @@ impl Renderer<'_> {
theme.colors.border.get()
};
let cr = theme.corner_radius.get();
let outer = Rect::new_sized_saturating(0, 0, pos.width(), pos.height());
let outer = Rect::new_sized_saturating(0, 0, visual.width(), visual.height());
self.render_rounded_frame(outer, &bc, cr, bw, x, y);
let body =
Rect::new_sized_saturating(x + bw, y + bw, pos.width() - 2 * bw, pos.height() - 2 * bw);
let body = Rect::new_sized_saturating(
x + bw,
y + bw,
visual.width() - 2 * bw,
visual.height() - 2 * bw,
);
let scissor_body = self.base.scale_rect(body);
self.stretch = if pos.width() != visual.width() || pos.height() != visual.height() {
Some(self.base.scale_point(body.width(), body.height()))
} else {
None
};
if !cr.is_zero() {
let inner_cr = self.scale_corner_radius(cr.expanded_by(-(bw as f32)));
self.corner_radius = Some(inner_cr);
}
child.node_render(self, body.x1(), body.y1(), Some(&scissor_body));
self.render_child_or_snapshot(&child, body.x1(), body.y1(), Some(&scissor_body));
self.stretch = None;
self.corner_radius = None;
}

View file

@ -4,6 +4,7 @@ use {
allocator::BufferObject,
animation::{
AnimationCurve, AnimationState, AnimationTick, RetainedToplevel, expand_damage_rect,
spawn_in_start_rect,
},
async_engine::{AsyncEngine, SpawnedFuture},
backend::{
@ -1516,6 +1517,33 @@ impl State {
}
}
pub fn queue_spawn_in_animation(
self: &Rc<Self>,
node_id: NodeId,
target: Rect,
retained: Option<Rc<RetainedToplevel>>,
) {
if !self.animations.enabled.get() || target.is_empty() {
return;
}
let start = spawn_in_start_rect(target);
let now = self.now_nsec();
let started = self.animations.set_spawn_in(
node_id,
target,
retained,
now,
self.animations.duration_ms.get(),
);
if started {
self.damage(expand_damage_rect(
start.union(target),
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

@ -153,6 +153,14 @@ impl FloatNode {
_ => return,
};
let pos = self.position.get();
let spawn_in_pending = {
let data = child.tl_data();
data.spawn_in_pending.get() && data.kind.is_app_window() && !data.is_fullscreen.get()
};
if spawn_in_pending && self.visible.get() {
self.state
.queue_spawn_in_animation(self.id.into(), pos, None);
}
let theme = &self.state.theme;
let bw = theme.sizes.border_width.get();
let cpos = Rect::new_sized_saturating(

View file

@ -118,6 +118,7 @@ impl<T: ToplevelNodeBase> ToplevelNode for T {
let parent_was_none = data.parent.set(Some(parent.clone())).is_none();
if parent_was_none {
data.mapped_during_iteration.set(data.state.eng.iteration());
data.spawn_in_pending.set(data.kind.is_app_window());
data.property_changed(TL_CHANGED_NEW);
}
let was_floating = data.parent_is_float.get();
@ -185,6 +186,7 @@ impl<T: ToplevelNodeBase> ToplevelNode for T {
fn tl_change_extents(self: Rc<Self>, rect: &Rect) {
let data = self.tl_data();
let prev = data.desired_extents.replace(*rect);
let spawn_in_pending = data.spawn_in_pending.get();
let parent_is_mono = data
.parent
.get()
@ -205,6 +207,22 @@ impl<T: ToplevelNodeBase> ToplevelNode for T {
self.tl_animation_snapshot(),
);
}
if spawn_in_pending
&& !rect.is_empty()
&& data.visible.get()
&& !data.is_fullscreen.get()
&& data.kind.is_app_window()
&& !self.node_is_container()
{
data.state.clone().queue_spawn_in_animation(
data.node_id,
*rect,
self.tl_animation_snapshot(),
);
}
if spawn_in_pending && !rect.is_empty() {
data.spawn_in_pending.set(false);
}
if prev.size() != rect.size() {
for sc in data.jay_screencasts.lock().values() {
sc.schedule_realloc_or_reconfigure();
@ -403,6 +421,13 @@ impl ToplevelType {
ToplevelType::XWindow { .. } => window::X_WINDOW,
}
}
pub fn is_app_window(&self) -> bool {
matches!(
self,
ToplevelType::XdgToplevel(_) | ToplevelType::XWindow(_)
)
}
}
pub struct ToplevelData {
@ -425,6 +450,7 @@ pub struct ToplevelData {
pub title: RefCell<String>,
pub parent: CloneCell<Option<Rc<dyn ContainingNode>>>,
pub mapped_during_iteration: Cell<u64>,
pub spawn_in_pending: Cell<bool>,
pub pos: Cell<Rect>,
pub desired_extents: Cell<Rect>,
pub seat_state: NodeSeatState,
@ -488,6 +514,7 @@ impl ToplevelData {
title: RefCell::new(title),
parent: Default::default(),
mapped_during_iteration: Cell::new(0),
spawn_in_pending: Cell::new(false),
pos: Default::default(),
desired_extents: Default::default(),
seat_state: Default::default(),