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. - Pointer drag and resize initiated by the mouse or tablet do not animate.
- Linear animations restart only for windows whose destination changes. Other - Linear animations restart only for windows whose destination changes. Other
in-flight windows keep their existing timelines. in-flight windows keep their existing timelines.
- Spawn-in uses scale and position. Spawn-out requires retained visual content - Spawn-in uses scale and position for newly mapped tiled and floating app
and is deferred until the freezing layer exists. 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. - Command-driven tile-to-float and float-to-tile transitions may animate.
Protocol drag/drop paths do not. Protocol drag/drop paths do not.
- The no-overlap multiphase system is a separate phase after the linear path is - The no-overlap multiphase system is a separate phase after the linear path is
@ -80,8 +82,8 @@ Implementation shape:
Initial scope: Initial scope:
- Tiled reflow animation. - Tiled reflow animation.
- Floating command-driven moves, tile-to-float, float-to-tile, and spawn-in are - Floating command-driven moves, tile-to-float, and float-to-tile are deferred
deferred until after tiled reflow is validated. until after tiled reflow and spawn-in are validated.
- Cross-output and cross-scale movements snap for now. - Cross-output and cross-scale movements snap for now.
- Linear mode may overlap windows during swaps. That is expected for the classic - Linear mode may overlap windows during swaps. That is expected for the classic
interpolation mode; no-overlap is Phase 3. interpolation mode; no-overlap is Phase 3.
@ -89,7 +91,6 @@ Initial scope:
deferred, but animated windows must still be clipped to their presentation deferred, but animated windows must still be clipped to their presentation
bounds and must preserve the existing stretch behavior for undersized contents. bounds and must preserve the existing stretch behavior for undersized contents.
- No spawn-out. - No spawn-out.
- No content freezing.
- No multiphase no-overlap planner. - No multiphase no-overlap planner.
Tests: Tests:
@ -107,6 +108,8 @@ Goal: freeze visual contents during movement and enable spawn-out.
Initial retained-record implementation status: Initial retained-record implementation status:
- Tiled animation can retain GPU/dmabuf-backed XDG and Xwayland surface trees. - 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 - Retained records hold both `GfxTexture` and `SurfaceBuffer` references so the
existing buffer release/sync path remains authoritative. existing buffer release/sync path remains authoritative.
- Single-pixel buffers can be retained as color records. - Single-pixel buffers can be retained as color records.

View file

@ -17,6 +17,8 @@ use {
}; };
const DEFAULT_DURATION_MS: u32 = 160; 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)] #[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum AnimationCurve { pub enum AnimationCurve {
@ -224,6 +226,26 @@ impl AnimationState {
true 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 { pub fn visual_rect(&self, node_id: NodeId, layout: Rect, now_nsec: u64) -> Rect {
let windows = self.windows.borrow(); let windows = self.windows.borrow();
match windows.get(&node_id) { 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_rect(from: Rect, to: Rect, t: f64) -> Rect {
fn lerp(from: i32, to: i32, t: f64) -> i32 { fn lerp(from: i32, to: i32, t: f64) -> i32 {
(from as f64 + (to as f64 - from as f64) * t).round() as 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) 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, state::State,
theme::{Color, CornerRadius}, theme::{Color, CornerRadius},
tree::{ tree::{
ContainerNode, DisplayNode, FloatNode, OutputNode, PlaceholderNode, ToplevelData, ContainerNode, DisplayNode, FloatNode, Node, OutputNode, PlaceholderNode, ToplevelData,
ToplevelNode, ToplevelNodeBase, WorkspaceNode, tab_bar::TabBar, ToplevelNode, ToplevelNodeBase, WorkspaceNode, tab_bar::TabBar,
}, },
}, },
@ -207,8 +207,13 @@ impl Renderer<'_> {
if stacked.node_visible() { if stacked.node_visible() {
self.base.sync(); self.base.sync();
let pos = stacked.node_absolute_position(); let pos = stacked.node_absolute_position();
if pos.intersects(&opos) { let visual = self.state.animations.visual_rect(
let (x, y) = opos.translate(pos.x1(), pos.y1()); 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); stacked.node_render(self, x, y, None);
} }
} }
@ -983,6 +988,10 @@ impl Renderer<'_> {
_ => return, _ => return,
}; };
let pos = floating.position.get(); 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 theme = &self.state.theme;
let bw = theme.sizes.border_width.get(); let bw = theme.sizes.border_width.get();
let bc = if floating.active.get() { let bc = if floating.active.get() {
@ -991,16 +1000,26 @@ impl Renderer<'_> {
theme.colors.border.get() theme.colors.border.get()
}; };
let cr = theme.corner_radius.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); self.render_rounded_frame(outer, &bc, cr, bw, x, y);
let body = let body = Rect::new_sized_saturating(
Rect::new_sized_saturating(x + bw, y + bw, pos.width() - 2 * bw, pos.height() - 2 * bw); x + bw,
y + bw,
visual.width() - 2 * bw,
visual.height() - 2 * bw,
);
let scissor_body = self.base.scale_rect(body); 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() { if !cr.is_zero() {
let inner_cr = self.scale_corner_radius(cr.expanded_by(-(bw as f32))); let inner_cr = self.scale_corner_radius(cr.expanded_by(-(bw as f32)));
self.corner_radius = Some(inner_cr); 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; self.corner_radius = None;
} }

View file

@ -4,6 +4,7 @@ use {
allocator::BufferObject, allocator::BufferObject,
animation::{ animation::{
AnimationCurve, AnimationState, AnimationTick, RetainedToplevel, expand_damage_rect, AnimationCurve, AnimationState, AnimationTick, RetainedToplevel, expand_damage_rect,
spawn_in_start_rect,
}, },
async_engine::{AsyncEngine, SpawnedFuture}, async_engine::{AsyncEngine, SpawnedFuture},
backend::{ 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) { pub fn set_animations_enabled(&self, enabled: bool) {
if self.animations.enabled.replace(enabled) && !enabled { if self.animations.enabled.replace(enabled) && !enabled {
self.animations.clear(); self.animations.clear();

View file

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