Add spawn-in window animations
This commit is contained in:
parent
7575f851fe
commit
18ffaef64d
6 changed files with 157 additions and 12 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
28
src/state.rs
28
src/state.rs
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue