1
0
Fork 0
forked from wry/wry

feat: add window animations

This commit is contained in:
atagen 2026-05-21 15:20:46 +10:00
parent a29937ebe8
commit ce14169d6b
29 changed files with 6957 additions and 114 deletions

View file

@ -1023,6 +1023,26 @@ impl ConfigClient {
self.send(&ClientMessage::SetUiDragThreshold { threshold });
}
pub fn set_animations_enabled(&self, enabled: bool) {
self.send(&ClientMessage::SetAnimationsEnabled { enabled });
}
pub fn set_animation_duration_ms(&self, duration_ms: u32) {
self.send(&ClientMessage::SetAnimationDurationMs { duration_ms });
}
pub fn set_animation_curve(&self, curve: u32) {
self.send(&ClientMessage::SetAnimationCurve { curve });
}
pub fn set_animation_style(&self, style: u32) {
self.send(&ClientMessage::SetAnimationStyle { style });
}
pub fn set_animation_cubic_bezier(&self, x1: f32, y1: f32, x2: f32, y2: f32) {
self.send(&ClientMessage::SetAnimationCubicBezier { x1, y1, x2, y2 });
}
pub fn set_color_management_enabled(&self, enabled: bool) {
self.send(&ClientMessage::SetColorManagementEnabled { enabled });
}

View file

@ -545,6 +545,24 @@ pub enum ClientMessage<'a> {
SetUiDragThreshold {
threshold: i32,
},
SetAnimationsEnabled {
enabled: bool,
},
SetAnimationDurationMs {
duration_ms: u32,
},
SetAnimationCurve {
curve: u32,
},
SetAnimationStyle {
style: u32,
},
SetAnimationCubicBezier {
x1: f32,
y1: f32,
x2: f32,
y2: f32,
},
SetXScalingMode {
mode: XScalingMode,
},

View file

@ -103,6 +103,27 @@ impl Axis {
}
}
/// The curve used for tiled window animations.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct AnimationCurve(pub u32);
impl AnimationCurve {
pub const LINEAR: Self = Self(0);
pub const EASE: Self = Self(1);
pub const EASE_IN: Self = Self(2);
pub const EASE_OUT: Self = Self(3);
pub const EASE_IN_OUT: Self = Self(4);
}
/// The presentation style used for tiled window movement animations.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct AnimationStyle(pub u32);
impl AnimationStyle {
pub const PLAIN: Self = Self(0);
pub const MULTIPHASE: Self = Self(1);
}
/// Exits the compositor.
pub fn quit() {
get!().quit()
@ -287,6 +308,42 @@ pub fn set_ui_drag_threshold(threshold: i32) {
get!().set_ui_drag_threshold(threshold);
}
/// Enables or disables tiled window animations.
///
/// The default is `false`.
pub fn set_animations_enabled(enabled: bool) {
get!().set_animations_enabled(enabled);
}
/// Sets the duration of tiled window animations in milliseconds.
///
/// The default is `160`.
pub fn set_animation_duration_ms(duration_ms: u32) {
get!().set_animation_duration_ms(duration_ms);
}
/// Sets the curve used by tiled window animations.
///
/// The default is [`AnimationCurve::EASE_OUT`].
pub fn set_animation_curve(curve: AnimationCurve) {
get!().set_animation_curve(curve.0);
}
/// Sets the presentation style used for tiled window movement animations.
///
/// The default is [`AnimationStyle::MULTIPHASE`].
pub fn set_animation_style(style: AnimationStyle) {
get!().set_animation_style(style.0);
}
/// Sets a custom cubic-bezier curve used by tiled window animations.
///
/// `x1` and `x2` must be between `0.0` and `1.0`. The curve starts at `(0, 0)`
/// and ends at `(1, 1)`.
pub fn set_animation_cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) {
get!().set_animation_cubic_bezier(x1, y1, x2, y2);
}
/// Enables or disables the color-management protocol.
///
/// The default is `false`.

1233
src/animation.rs Normal file

File diff suppressed because it is too large Load diff

3405
src/animation/multiphase.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -360,6 +360,13 @@ fn start_compositor2(
cpu_worker,
ui_drag_enabled: Cell::new(true),
ui_drag_threshold_squared: Cell::new(10),
animations: Default::default(),
layout_animations_requested: Default::default(),
layout_animations_active: Default::default(),
layout_animation_curve_override: Default::default(),
layout_animation_style_override: Default::default(),
layout_animation_batch: Default::default(),
suppress_animations_for_next_layout: Default::default(),
toplevels: Default::default(),
const_40hz_latch: Default::default(),
tray_item_ids: Default::default(),

View file

@ -658,17 +658,23 @@ impl ConfigProxyHandler {
}
fn handle_seat_move(&self, seat: Seat, direction: Direction) -> Result<(), CphError> {
self.state.with_layout_animations(|| {
let seat = self.get_seat(seat)?;
seat.move_focused(direction.into());
Ok(())
})
}
fn handle_window_move(&self, window: Window, direction: Direction) -> Result<(), CphError> {
self.state.with_layout_animations(|| {
let window = self.get_window(window)?;
if let Some(c) = toplevel_parent_container(&*window) {
if let Some(float) = window.tl_data().float.get() {
float.move_by_direction(direction.into());
} else if let Some(c) = toplevel_parent_container(&*window) {
c.move_child(window, direction.into());
}
Ok(())
})
}
fn handle_get_repeat_rate(&self, seat: Seat) -> Result<(), CphError> {
@ -986,6 +992,31 @@ impl ConfigProxyHandler {
self.state.set_ui_drag_threshold(threshold.max(1));
}
fn handle_set_animations_enabled(&self, enabled: bool) {
self.state.set_animations_enabled(enabled);
}
fn handle_set_animation_duration_ms(&self, duration_ms: u32) {
self.state
.set_animation_duration_ms(duration_ms.min(10_000));
}
fn handle_set_animation_curve(&self, curve: u32) {
self.state.set_animation_curve(curve);
}
fn handle_set_animation_style(&self, style: u32) {
if !self.state.set_animation_style(style) {
log::warn!("Ignoring invalid animation style");
}
}
fn handle_set_animation_cubic_bezier(&self, x1: f32, y1: f32, x2: f32, y2: f32) {
if !self.state.set_animation_cubic_bezier(x1, y1, x2, y2) {
log::warn!("Ignoring invalid animation cubic-bezier curve");
}
}
fn handle_set_direct_scanout_enabled(
&self,
device: Option<DrmDevice>,
@ -1724,9 +1755,11 @@ impl ConfigProxyHandler {
}
fn handle_set_seat_mono(&self, seat: Seat, mono: bool) -> Result<(), CphError> {
self.state.with_layout_animations(|| {
let seat = self.get_seat(seat)?;
seat.set_mono(mono);
Ok(())
})
}
fn handle_get_window_mono(&self, window: Window) -> Result<(), CphError> {
@ -1740,11 +1773,13 @@ impl ConfigProxyHandler {
}
fn handle_set_window_mono(&self, window: Window, mono: bool) -> Result<(), CphError> {
self.state.with_layout_animations(|| {
let window = self.get_window(window)?;
if let Some(c) = toplevel_parent_container(&*window) {
c.set_mono(mono.then_some(window.as_ref()));
}
Ok(())
})
}
fn handle_get_seat_split(&self, seat: Seat) -> Result<(), CphError> {
@ -1759,15 +1794,19 @@ impl ConfigProxyHandler {
}
fn handle_set_seat_split(&self, seat: Seat, axis: Axis) -> Result<(), CphError> {
self.state.with_layout_animations(|| {
let seat = self.get_seat(seat)?;
seat.set_split(axis.into());
Ok(())
})
}
fn handle_seat_toggle_tab(&self, seat: Seat) -> Result<(), CphError> {
self.state.with_layout_animations(|| {
let seat = self.get_seat(seat)?;
seat.toggle_tab();
Ok(())
})
}
fn handle_seat_make_group(
@ -1776,27 +1815,35 @@ impl ConfigProxyHandler {
axis: Axis,
ephemeral: bool,
) -> Result<(), CphError> {
self.state.with_layout_animations(|| {
let seat = self.get_seat(seat)?;
seat.make_group(axis.into(), ephemeral);
Ok(())
})
}
fn handle_seat_change_group_opposite(&self, seat: Seat) -> Result<(), CphError> {
self.state.with_layout_animations(|| {
let seat = self.get_seat(seat)?;
seat.change_group_opposite();
Ok(())
})
}
fn handle_seat_equalize(&self, seat: Seat, recursive: bool) -> Result<(), CphError> {
self.state.with_layout_animations(|| {
let seat = self.get_seat(seat)?;
seat.equalize(recursive);
Ok(())
})
}
fn handle_seat_move_tab(&self, seat: Seat, right: bool) -> Result<(), CphError> {
self.state.with_layout_animations(|| {
let seat = self.get_seat(seat)?;
seat.move_tab(right);
Ok(())
})
}
fn handle_get_window_split(&self, window: Window) -> Result<(), CphError> {
@ -1811,11 +1858,13 @@ impl ConfigProxyHandler {
}
fn handle_set_window_split(&self, window: Window, axis: Axis) -> Result<(), CphError> {
self.state.with_layout_animations(|| {
let window = self.get_window(window)?;
if let Some(c) = toplevel_parent_container(&*window) {
c.set_split(axis.into());
}
Ok(())
})
}
fn handle_add_shortcut(
@ -1955,9 +2004,11 @@ impl ConfigProxyHandler {
}
fn handle_set_seat_floating(&self, seat: Seat, floating: bool) -> Result<(), CphError> {
self.state.with_linear_layout_animations(|| {
let seat = self.get_seat(seat)?;
seat.set_floating(floating);
Ok(())
})
}
fn handle_get_window_floating(&self, window: Window) -> Result<(), CphError> {
@ -1969,9 +2020,11 @@ impl ConfigProxyHandler {
}
fn handle_set_window_floating(&self, window: Window, floating: bool) -> Result<(), CphError> {
self.state.with_linear_layout_animations(|| {
let window = self.get_window(window)?;
toplevel_set_floating(&self.state, window, floating);
Ok(())
})
}
fn handle_add_pollable(self: &Rc<Self>, fd: i32) -> Result<(), CphError> {
@ -2721,8 +2774,10 @@ impl ConfigProxyHandler {
dx2: i32,
dy2: i32,
) -> Result<(), CphError> {
self.state.with_layout_animations(|| {
self.get_window(window)?.tl_resize(dx1, dy1, dx2, dy2);
Ok(())
})
}
fn handle_window_exists(&self, window: Window) {
@ -3193,6 +3248,17 @@ impl ConfigProxyHandler {
ClientMessage::SetUiDragThreshold { threshold } => {
self.handle_set_ui_drag_threshold(threshold)
}
ClientMessage::SetAnimationsEnabled { enabled } => {
self.handle_set_animations_enabled(enabled)
}
ClientMessage::SetAnimationDurationMs { duration_ms } => {
self.handle_set_animation_duration_ms(duration_ms)
}
ClientMessage::SetAnimationCurve { curve } => self.handle_set_animation_curve(curve),
ClientMessage::SetAnimationStyle { style } => self.handle_set_animation_style(style),
ClientMessage::SetAnimationCubicBezier { x1, y1, x2, y2 } => {
self.handle_set_animation_cubic_bezier(x1, y1, x2, y2)
}
ClientMessage::SetXScalingMode { mode } => self
.handle_set_x_scaling_mode(mode)
.wrn("set_x_scaling_mode")?,

View file

@ -936,6 +936,9 @@ impl WlSeatGlobal {
{
c.move_child(tl, direction);
self.maybe_schedule_warp_mouse_to_focus();
} else if let Some(float) = data.float.get() {
float.move_by_direction(direction);
self.maybe_schedule_warp_mouse_to_focus();
}
}

View file

@ -628,6 +628,11 @@ fn schedule_async_upload(
{
back_tex_opt = None;
}
if let Some(back_tex) = &back_tex_opt
&& Rc::strong_count(back_tex) > 1
{
back_tex_opt = None;
}
let damage_full = || {
back.damage.clear();
back.damage.damage(slice::from_ref(&buf.rect));

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

@ -1,5 +1,6 @@
use {
crate::{
animation::RetainedToplevel,
client::Client,
cursor::KnownCursor,
fixed::Fixed,
@ -252,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,
@ -274,6 +280,7 @@ impl Xwindow {
match map_change {
Change::None => return,
Change::Unmap => {
self.queue_spawn_out();
self.data
.info
.pending_extents
@ -514,6 +521,10 @@ impl ToplevelNodeBase for Xwindow {
Some(self.x.surface.clone())
}
fn tl_animation_snapshot(&self) -> Option<Rc<RetainedToplevel>> {
RetainedToplevel::capture_surface(&self.x.surface, (0, 0))
}
fn tl_admits_children(&self) -> bool {
false
}

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

@ -2,6 +2,7 @@ pub mod xdg_dialog_v1;
use {
crate::{
animation::RetainedToplevel,
bugs,
bugs::Bugs,
client::{Client, ClientError},
@ -259,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();
{
@ -398,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>,
@ -779,6 +786,11 @@ impl ToplevelNodeBase for XdgToplevel {
Some(self.xdg.surface.clone())
}
fn tl_animation_snapshot(&self) -> Option<Rc<RetainedToplevel>> {
let geo = self.xdg.geometry();
RetainedToplevel::capture_surface(&self.xdg.surface, (-geo.x1(), -geo.y1()))
}
fn tl_restack_popups(&self) {
self.xdg.restack_popups();
}
@ -818,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

@ -48,6 +48,7 @@ mod leaks;
mod tracy;
mod acceptor;
mod allocator;
mod animation;
mod async_engine;
mod backend;
mod backends;

View file

@ -1,7 +1,11 @@
use {
crate::{
animation::{
RetainedContent, RetainedExitFrame, RetainedExitLayer, RetainedSurface,
RetainedToplevel,
},
cmm::cmm_render_intent::RenderIntent,
gfx_api::{AcquireSync, AlphaMode, GfxApiOpt, ReleaseSync, SampleRect},
gfx_api::{AcquireSync, AlphaMode, BufferResv, GfxApiOpt, ReleaseSync, SampleRect},
ifs::wl_surface::{
SurfaceBuffer, WlSurface,
x_surface::xwindow::Xwindow,
@ -14,8 +18,8 @@ use {
state::State,
theme::{Color, CornerRadius},
tree::{
ContainerNode, DisplayNode, FloatNode, OutputNode, PlaceholderNode, ToplevelData,
ToplevelNodeBase, WorkspaceNode, tab_bar::TabBar,
ContainerNode, DisplayNode, FloatNode, Node, OutputNode, PlaceholderNode, ToplevelData,
ToplevelNode, ToplevelNodeBase, WorkspaceNode, tab_bar::TabBar,
},
},
std::{ops::Deref, rc::Rc, slice},
@ -200,14 +204,22 @@ 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() {
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);
}
}
@ -215,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();
@ -453,6 +466,265 @@ impl Renderer<'_> {
.fill_boxes2(&rd.border_rects, &c, srgb, perceptual, x, y);
}
fn presentation_child_body(
&self,
container: &ContainerNode,
child: &Rc<dyn ToplevelNode>,
body: Rect,
) -> Rect {
let abs = body.move_(container.abs_x1.get(), container.abs_y1.get());
let visual = self
.state
.animations
.visual_rect(child.node_id(), abs, self.state.now_nsec());
visual.move_(-container.abs_x1.get(), -container.abs_y1.get())
}
fn render_child_or_snapshot(
&mut self,
child: &Rc<dyn ToplevelNode>,
x: i32,
y: i32,
bounds: Option<&Rect>,
) {
if let Some(retained) = self
.state
.animations
.retained_snapshot(child.node_id(), self.state.now_nsec())
{
self.render_retained_toplevel(&retained, x, y, bounds);
} else {
child.node_render(self, x, y, bounds);
}
}
fn render_retained_toplevel(
&mut self,
retained: &RetainedToplevel,
x: i32,
y: i32,
bounds: Option<&Rect>,
) {
let (x, y) = self
.base
.scale_point(x + retained.offset.0, y + retained.offset.1);
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;
}
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_window_body_background(body);
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
};
self.render_retained_toplevel(&frame.retained, body.x1(), body.y1(), Some(&bounds));
self.stretch = None;
self.corner_radius = None;
}
fn render_window_body_background(&mut self, body: Rect) {
if body.is_empty() {
return;
}
let color = self.state.theme.colors.background.get();
let srgb_srgb = self.state.color_manager.srgb_gamma22();
let srgb = &srgb_srgb.linear;
let perceptual = RenderIntent::Perceptual;
self.base.sync();
if let Some(cr) = self.corner_radius
&& !cr.is_zero()
{
self.base
.fill_rounded_rect(body, &color, None, srgb, perceptual, cr, 0.0);
} else {
let bounds = self.base.scale_rect(body);
self.base
.fill_scaled_boxes(slice::from_ref(&bounds), &color, None, srgb, perceptual);
}
}
fn render_retained_surface_scaled(
&mut self,
retained: &RetainedSurface,
x: i32,
y: i32,
pos_rel: Option<(i32, i32)>,
bounds: Option<&Rect>,
) {
let stretch = self.stretch.take();
let corner_radius = self.corner_radius.take();
let mut size = retained.size;
if let Some((x_rel, y_rel)) = pos_rel {
let (x, y) = self.base.scale_point(x_rel, y_rel);
let (w, h) = self.base.scale_point(x_rel + size.0, y_rel + size.1);
size = (w - x, h - y);
} else {
size = self.base.scale_point(size.0, size.1);
}
let mut stretched_source = None;
if let Some(s) = stretch {
if let RetainedContent::Texture { source, .. } = &retained.content {
let mut source = *source;
if size.0 > 0 && size.1 > 0 {
let sx = s.0 as f32 / size.0 as f32;
let sy = s.1 as f32 / size.1 as f32;
source.x2 *= sx;
source.y2 *= sy;
}
stretched_source = Some(source);
}
size = s;
}
for child in &retained.below {
let (x1, y1) = self.base.scale_point(child.offset.0, child.offset.1);
self.render_retained_surface_scaled(child, x + x1, y + y1, Some(child.offset), bounds);
}
self.corner_radius = corner_radius;
self.render_retained_content(retained, stretched_source, x, y, size, bounds);
for child in &retained.above {
let (x1, y1) = self.base.scale_point(child.offset.0, child.offset.1);
self.render_retained_surface_scaled(child, x + x1, y + y1, Some(child.offset), bounds);
}
}
fn render_retained_content(
&mut self,
retained: &RetainedSurface,
stretched_source: Option<SampleRect>,
x: i32,
y: i32,
size: (i32, i32),
bounds: Option<&Rect>,
) {
let corner_radius = self.corner_radius.take();
match &retained.content {
RetainedContent::Texture {
texture,
buffer,
source,
alpha,
color_description,
render_intent,
alpha_mode,
opaque,
} => {
let source = stretched_source.unwrap_or(*source);
if let Some(cr) = corner_radius {
self.base.render_rounded_texture(
texture,
*alpha,
x,
y,
Some(source),
Some(size),
self.base.scale,
bounds,
Some(buffer.clone() as Rc<dyn BufferResv>),
AcquireSync::Unnecessary,
buffer.release_sync,
color_description,
*render_intent,
*alpha_mode,
cr,
);
} else {
self.base.render_texture(
texture,
*alpha,
x,
y,
Some(source),
Some(size),
self.base.scale,
bounds,
Some(buffer.clone() as Rc<dyn BufferResv>),
AcquireSync::Unnecessary,
buffer.release_sync,
*opaque,
color_description,
*render_intent,
*alpha_mode,
);
}
}
RetainedContent::Color {
color,
alpha,
color_description,
render_intent,
} => {
if let Some(rect) = Rect::new_sized(x, y, size.0, size.1) {
let rect = match bounds {
None => rect,
Some(bounds) => rect.intersect(*bounds),
};
if !rect.is_empty() {
self.base.sync();
self.base.fill_scaled_boxes(
&[rect],
color,
*alpha,
&color_description.linear,
*render_intent,
);
}
}
}
}
}
pub fn render_container(&mut self, container: &ContainerNode, x: i32, y: i32) {
self.render_container_decorations(container, x, y);
@ -465,6 +737,7 @@ impl Renderer<'_> {
}
}
let mb = container.mono_body.get();
let visual_mb = self.presentation_child_body(container, &child.node, mb);
if self.state.theme.sizes.gap.get() != 0 {
let bw = self.state.theme.sizes.border_width.get();
let border_color = self.state.theme.colors.border.get();
@ -476,10 +749,10 @@ impl Renderer<'_> {
};
if !child.node.node_is_container() {
let frame = Rect::new_sized_saturating(
mb.x1() - bw,
mb.y1() - bw,
mb.width() + 2 * bw,
mb.height() + 2 * bw,
visual_mb.x1() - bw,
visual_mb.y1() - bw,
visual_mb.width() + 2 * bw,
visual_mb.height() + 2 * bw,
);
self.render_rounded_frame(
frame,
@ -491,11 +764,14 @@ impl Renderer<'_> {
);
}
}
let body = mb.move_(x, y);
let body = self.base.scale_rect(body);
let content = container.mono_content.get();
self.stretch = if content.width() != mb.width() || content.height() != mb.height() {
Some(self.base.scale_point(mb.width(), mb.height()))
let body = visual_mb.move_(x, y);
let content = container
.mono_content
.get()
.at_point(visual_mb.x1(), visual_mb.y1());
self.stretch =
if content.width() != visual_mb.width() || content.height() != visual_mb.height() {
Some(self.base.scale_point(visual_mb.width(), visual_mb.height()))
} else {
None
};
@ -507,9 +783,16 @@ impl Renderer<'_> {
self.corner_radius = Some(inner_cr);
}
}
child
.node
.node_render(self, x + content.x1(), y + content.y1(), Some(&body));
if !child.node.node_is_container() {
self.render_window_body_background(body);
}
let body = self.base.scale_rect(body);
self.render_child_or_snapshot(
&child.node,
x + content.x1(),
y + content.y1(),
Some(&body),
);
self.stretch = None;
self.corner_radius = None;
} else {
@ -524,10 +807,13 @@ impl Renderer<'_> {
};
let cr = self.state.theme.corner_radius.get();
for child in container.children.iter() {
let body = child.body.get();
if body.x1() >= container.width.get() || body.y1() >= container.height.get() {
let layout_body = child.body.get();
if layout_body.x1() >= container.width.get()
|| layout_body.y1() >= container.height.get()
{
break;
}
let body = self.presentation_child_body(container, &child.node, layout_body);
if gap != 0 {
let c = if child.border_color_is_focused.get() {
&focused_border_color
@ -544,7 +830,7 @@ impl Renderer<'_> {
self.render_rounded_frame(frame, c, cr, bw, x, y);
}
}
let content = child.content.get();
let content = child.content.get().at_point(body.x1(), body.y1());
self.stretch =
if content.width() != body.width() || content.height() != body.height() {
Some(self.base.scale_point(body.width(), body.height()))
@ -556,10 +842,16 @@ impl Renderer<'_> {
self.corner_radius = Some(inner_cr);
}
let body = body.move_(x, y);
if !child.node.node_is_container() {
self.render_window_body_background(body);
}
let body = self.base.scale_rect(body);
child
.node
.node_render(self, x + content.x1(), y + content.y1(), Some(&body));
self.render_child_or_snapshot(
&child.node,
x + content.x1(),
y + content.y1(),
Some(&body),
);
self.stretch = None;
self.corner_radius = None;
}
@ -793,6 +1085,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() {
@ -801,16 +1097,27 @@ 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_window_body_background(body);
self.render_child_or_snapshot(&child, body.x1(), body.y1(), Some(&scissor_body));
self.stretch = None;
self.corner_radius = None;
}

View file

@ -2,6 +2,17 @@ use {
crate::{
acceptor::Acceptor,
allocator::BufferObject,
animation::{
AnimationCurve, AnimationState, AnimationStyle, AnimationTick, RetainedExitLayer,
RetainedToplevel,
expand_damage_rect,
multiphase::{
MultiphasePhase, MultiphasePlan, MultiphasePlanFailure, MultiphaseRequest,
MultiphaseWindow, MultiphaseWindowHierarchy,
partition_motion_groups, plan_no_overlap_with_diagnostics, validate_phase_paths,
},
spawn_in_start_rect,
},
async_engine::{AsyncEngine, SpawnedFuture},
backend::{
Backend, BackendConnectorState, BackendConnectorStateSerials, BackendDrmDevice,
@ -102,11 +113,10 @@ use {
time::Time,
tree::{
ContainerNode, ContainerSplit, Direction, DisplayNode, FindTreeUsecase, FloatNode,
FoundNode, LatchListener, Node, NodeIds, NodeVisitorBase, OutputNode, PlaceholderNode,
TearingMode, TileState, ToplevelData, ToplevelIdentifier, ToplevelNode,
ToplevelNodeBase, Transform, VrrMode, WorkspaceDisplayOrder, WorkspaceNode,
WorkspaceNodeId,
WsMoveConfig, generic_node_visitor, move_ws_to_output,
FoundNode, LatchListener, Node, NodeId, NodeIds, NodeVisitorBase, OutputNode,
PlaceholderNode, TearingMode, TileState, ToplevelData, ToplevelIdentifier,
ToplevelNode, ToplevelNodeBase, Transform, VrrMode, WorkspaceDisplayOrder,
WorkspaceNode, WorkspaceNodeId, WsMoveConfig, generic_node_visitor, move_ws_to_output,
},
udmabuf::UdmabufHolder,
utils::{
@ -154,6 +164,98 @@ use {
uapi::{OwnedFd, c},
};
#[derive(Clone)]
pub(crate) struct LayoutAnimationCandidate {
node_id: NodeId,
old: Rect,
new: Rect,
curve: AnimationCurve,
style: AnimationStyle,
hierarchy: MultiphaseWindowHierarchy,
}
fn coalesce_layout_animation_candidates(
candidates: Vec<LayoutAnimationCandidate>,
) -> Vec<LayoutAnimationCandidate> {
let mut merged: Vec<LayoutAnimationCandidate> = vec![];
for candidate in candidates {
if let Some(existing) = merged
.iter_mut()
.find(|existing| existing.node_id == candidate.node_id)
{
existing.new = candidate.new;
existing.curve = candidate.curve;
existing.style = candidate.style;
existing.hierarchy = MultiphaseWindowHierarchy::new(
existing.hierarchy.source,
candidate.hierarchy.target,
);
} else {
merged.push(candidate);
}
}
merged
}
fn layout_animation_group_uses_plain(
candidates: &[LayoutAnimationCandidate],
group: &[usize],
) -> bool {
group
.iter()
.any(|&idx| candidates[idx].style == AnimationStyle::Plain)
}
fn bridged_retarget_plan(
request: &MultiphaseRequest,
candidates: &[LayoutAnimationCandidate],
group: &[usize],
bridge_paths: &[Vec<(Rect, Rect)>],
bridge_phase_count: usize,
follow_phases: &[MultiphasePhase],
) -> Result<MultiphasePlan, MultiphasePlanFailure> {
let mut paths = vec![];
for (group_pos, &idx) in group.iter().enumerate() {
let candidate = &candidates[idx];
let window = request.windows[group_pos];
let Some(bridge_path) = bridge_paths.get(group_pos) else {
return Err(MultiphasePlanFailure::NoPattern);
};
let mut path = bridge_path.clone();
let mut current = path
.last()
.map(|(_, to)| *to)
.unwrap_or(window.from);
while path.len() < bridge_phase_count {
path.push((current, current));
}
if current != candidate.old {
return Err(MultiphasePlanFailure::NoPattern);
}
for phase in follow_phases {
match phase
.steps
.iter()
.find(|step| step.node_id == candidate.node_id)
{
Some(step) => {
if step.from != current {
return Err(MultiphasePlanFailure::NoPattern);
}
path.push((step.from, step.to));
current = step.to;
}
None => path.push((current, current)),
}
}
if current != window.to {
return Err(MultiphasePlanFailure::NoPattern);
}
paths.push(path);
}
validate_phase_paths(request, &paths)
}
pub struct State {
pub pid: c::pid_t,
pub kb_ctx: KbvmContext,
@ -264,6 +366,13 @@ pub struct State {
pub cpu_worker: Rc<CpuWorker>,
pub ui_drag_enabled: Cell<bool>,
pub ui_drag_threshold_squared: Cell<i32>,
pub animations: AnimationState,
pub layout_animations_requested: Cell<bool>,
pub layout_animations_active: Cell<bool>,
pub layout_animation_curve_override: Cell<Option<AnimationCurve>>,
pub layout_animation_style_override: Cell<Option<AnimationStyle>>,
pub(crate) layout_animation_batch: RefCell<Option<Vec<LayoutAnimationCandidate>>>,
pub suppress_animations_for_next_layout: Cell<bool>,
pub toplevels: CopyHashMap<ToplevelIdentifier, Weak<dyn ToplevelNode>>,
pub const_40hz_latch: EventSource<dyn LatchListener>,
pub tray_item_ids: TrayItemIds,
@ -812,7 +921,14 @@ impl State {
pub fn map_tiled(self: &Rc<Self>, node: Rc<dyn ToplevelNode>) {
let seat = self.seat_queue.last();
let animate_new_app_map = node.tl_data().parent.is_none()
&& node.tl_data().kind.is_app_window()
&& !node.tl_data().visible.get();
if animate_new_app_map {
self.with_layout_animations(|| self.do_map_tiled(seat.as_deref(), node.clone()));
} else {
self.do_map_tiled(seat.as_deref(), node.clone());
}
self.focus_after_map(node, seat.as_deref());
}
@ -847,7 +963,7 @@ impl State {
mut height: i32,
workspace: &Rc<WorkspaceNode>,
abs_pos: Option<(i32, i32)>,
) {
) -> Rc<FloatNode> {
width += 2 * self.theme.sizes.border_width.get();
height +=
2 * self.theme.sizes.border_width.get() + self.theme.title_plus_underline_height();
@ -878,8 +994,9 @@ impl State {
}
Rect::new_sized_saturating(x1, y1, width, height)
};
FloatNode::new(self, workspace, position, node.clone());
let float = FloatNode::new(self, workspace, position, node.clone());
self.focus_after_map(node, self.seat_queue.last().as_deref());
float
}
fn focus_after_map(&self, node: Rc<dyn ToplevelNode>, seat: Option<&Rc<WlSeatGlobal>>) {
@ -1115,6 +1232,12 @@ impl State {
self.pending_screencast_reallocs_or_reconfigures.clear();
self.pending_placeholder_render_textures.clear();
self.pending_container_tab_render_textures.clear();
self.animations.clear();
self.layout_animations_requested.set(false);
self.layout_animations_active.set(false);
self.layout_animation_curve_override.set(None);
self.layout_animation_style_override.set(None);
self.suppress_animations_for_next_layout.set(false);
self.render_ctx_watchers.clear();
self.workspace_watchers.clear();
self.toplevel_lists.clear();
@ -1461,6 +1584,532 @@ impl State {
self.eng.now().msec()
}
pub fn queue_tiled_animation(
self: &Rc<Self>,
node_id: NodeId,
old: Rect,
new: Rect,
) {
let curve = self
.layout_animation_curve_override
.get()
.unwrap_or_else(|| self.animations.curve.get());
self.queue_layout_animation(
node_id,
old,
new,
curve,
MultiphaseWindowHierarchy::default(),
);
}
pub fn queue_tiled_animation_with_hierarchy(
self: &Rc<Self>,
node_id: NodeId,
old: Rect,
new: Rect,
hierarchy: MultiphaseWindowHierarchy,
) {
let curve = self
.layout_animation_curve_override
.get()
.unwrap_or_else(|| self.animations.curve.get());
self.queue_layout_animation(node_id, old, new, curve, hierarchy);
}
pub fn queue_linear_layout_animation(
self: &Rc<Self>,
node_id: NodeId,
old: Rect,
new: Rect,
) {
self.queue_layout_animation(
node_id,
old,
new,
AnimationCurve::Linear,
MultiphaseWindowHierarchy::default(),
);
}
fn queue_layout_animation(
self: &Rc<Self>,
node_id: NodeId,
old: Rect,
new: Rect,
curve: AnimationCurve,
hierarchy: MultiphaseWindowHierarchy,
) {
if !self.animations.enabled.get()
|| !self.layout_animations_active.get()
|| self.suppress_animations_for_next_layout.get()
{
return;
}
let (old_output, old_scale) = {
let (x, y) = old.center();
let (output, _, _) = self.find_closest_output(x, y);
(output.id, output.global.persistent.scale.get())
};
let (new_output, new_scale) = {
let (x, y) = new.center();
let (output, _, _) = self.find_closest_output(x, y);
(output.id, output.global.persistent.scale.get())
};
if old_output != new_output || old_scale != new_scale {
return;
}
let candidate = LayoutAnimationCandidate {
node_id,
old,
new,
curve,
style: self
.layout_animation_style_override
.get()
.unwrap_or_else(|| self.animations.style.get()),
hierarchy,
};
if let Some(batch) = self.layout_animation_batch.borrow_mut().as_mut() {
batch.push(candidate);
return;
}
self.start_layout_animation_candidate(candidate, self.now_nsec());
}
fn start_layout_animation_candidate(
self: &Rc<Self>,
candidate: LayoutAnimationCandidate,
now_nsec: u64,
) {
let started = self.animations.set_target(
candidate.node_id,
candidate.old,
candidate.new,
None,
now_nsec,
self.animations.duration_ms.get(),
candidate.curve,
);
if started {
self.damage(expand_damage_rect(
candidate.old.union(candidate.new),
self.theme.sizes.border_width.get().max(0),
));
self.ensure_animation_tick();
}
}
pub fn begin_layout_animation_batch(&self) {
self.layout_animation_batch
.borrow_mut()
.get_or_insert_with(Vec::new);
}
pub fn finish_layout_animation_batch(self: &Rc<Self>) {
let Some(candidates) = self.layout_animation_batch.borrow_mut().take() else {
return;
};
let candidates = coalesce_layout_animation_candidates(candidates);
if candidates.is_empty() {
return;
}
let now = self.now_nsec();
let windows: Vec<_> = candidates
.iter()
.map(|candidate| {
MultiphaseWindow::with_hierarchy(
candidate.node_id,
self.animations
.visual_rect(candidate.node_id, candidate.old, now),
candidate.new,
candidate.hierarchy,
)
})
.collect();
for group in partition_motion_groups(&windows, self.layout_animation_clearance()) {
if layout_animation_group_uses_plain(&candidates, &group) {
for idx in group {
self.start_layout_animation_candidate(candidates[idx].clone(), now);
}
continue;
}
if self.start_multiphase_layout_animation(&candidates, &windows, &group, now) {
continue;
}
for idx in group {
self.start_layout_animation_candidate(candidates[idx].clone(), now);
}
}
}
fn layout_animation_clearance(&self) -> i32 {
let border = self.theme.sizes.border_width.get().max(0);
let gap = self.theme.sizes.gap.get().max(0);
if gap == 0 { border } else { gap + 2 * border }
}
fn start_multiphase_layout_animation(
self: &Rc<Self>,
candidates: &[LayoutAnimationCandidate],
windows: &[MultiphaseWindow],
group: &[usize],
now_nsec: u64,
) -> bool {
let request_windows: Vec<_> = group.iter().map(|&idx| windows[idx]).collect();
let Some(first) = request_windows.first() else {
return false;
};
let mut bounds = first.from.union(first.to);
for window in &request_windows[1..] {
bounds = bounds.union(window.from).union(window.to);
}
let request = MultiphaseRequest {
bounds,
windows: request_windows,
clearance: self.layout_animation_clearance(),
};
if self.start_existing_phased_retarget(candidates, windows, group, &request, now_nsec) {
return true;
}
if self.start_bridged_phased_retarget(candidates, windows, group, &request, now_nsec) {
return true;
}
let plan = match plan_no_overlap_with_diagnostics(&request) {
Ok(plan) => plan,
Err(diagnostic) => {
log::debug!(
"falling back to plain layout animation for group {:?}: {:?}",
group,
diagnostic
);
return false;
}
};
self.start_multiphase_plan(candidates, windows, group, &plan.phases, now_nsec)
}
fn start_existing_phased_retarget(
self: &Rc<Self>,
candidates: &[LayoutAnimationCandidate],
windows: &[MultiphaseWindow],
group: &[usize],
request: &MultiphaseRequest,
now_nsec: u64,
) -> bool {
let mut paths = vec![];
for &idx in group {
let candidate = &candidates[idx];
let window = windows[idx];
let Some(path) =
self.animations
.phased_route_to(candidate.node_id, window.to, now_nsec)
else {
return false;
};
paths.push(path);
}
let plan = match validate_phase_paths(request, &paths) {
Ok(plan) => plan,
Err(error) => {
log::debug!(
"existing phased retarget rejected for group {:?}: {:?}",
group,
error
);
return false;
}
};
log::debug!("retargeting active phased animation for group {:?}", group);
self.start_multiphase_plan(candidates, windows, group, &plan.phases, now_nsec)
}
fn start_bridged_phased_retarget(
self: &Rc<Self>,
candidates: &[LayoutAnimationCandidate],
windows: &[MultiphaseWindow],
group: &[usize],
request: &MultiphaseRequest,
now_nsec: u64,
) -> bool {
let mut bridge_paths = vec![];
let mut bridge_phase_count = 0;
let mut has_bridge = false;
for &idx in group {
let candidate = &candidates[idx];
let window = windows[idx];
if window.from == candidate.old {
bridge_paths.push(vec![]);
continue;
}
let Some(path) =
self.animations
.phased_route_to(candidate.node_id, candidate.old, now_nsec)
else {
return false;
};
if !path.is_empty() {
has_bridge = true;
bridge_phase_count = bridge_phase_count.max(path.len());
}
bridge_paths.push(path);
}
if !has_bridge {
return false;
}
let settled_windows: Vec<_> = group
.iter()
.map(|&idx| {
let candidate = &candidates[idx];
MultiphaseWindow::with_hierarchy(
candidate.node_id,
candidate.old,
candidate.new,
candidate.hierarchy,
)
})
.collect();
let Some(first) = settled_windows.first() else {
return false;
};
let mut bounds = first.from.union(first.to);
for window in &settled_windows[1..] {
bounds = bounds.union(window.from).union(window.to);
}
let settled_request = MultiphaseRequest {
bounds,
windows: settled_windows,
clearance: self.layout_animation_clearance(),
};
let follow_plan = match plan_no_overlap_with_diagnostics(&settled_request) {
Ok(plan) => plan,
Err(diagnostic) => {
log::debug!(
"bridged phased retarget follow-up rejected for group {:?}: {:?}",
group,
diagnostic
);
return false;
}
};
let plan = match bridged_retarget_plan(
request,
candidates,
group,
&bridge_paths,
bridge_phase_count,
&follow_plan.phases,
) {
Ok(plan) => plan,
Err(error) => {
log::debug!(
"bridged phased retarget rejected for group {:?}: {:?}",
group,
error
);
return false;
}
};
log::debug!("bridging active phased animation for group {:?}", group);
self.start_multiphase_plan(candidates, windows, group, &plan.phases, now_nsec)
}
fn start_multiphase_plan(
self: &Rc<Self>,
candidates: &[LayoutAnimationCandidate],
windows: &[MultiphaseWindow],
group: &[usize],
plan_phases: &[crate::animation::multiphase::MultiphasePhase],
now_nsec: u64,
) -> bool {
if plan_phases.is_empty() {
return false;
}
let mut entries = vec![];
for &idx in group {
let candidate = &candidates[idx];
let window = windows[idx];
let mut current = window.from;
let mut damage = current.union(window.to);
let mut phases = vec![];
for phase in plan_phases {
match phase
.steps
.iter()
.find(|step| step.node_id == candidate.node_id)
{
Some(step) => {
phases.push((step.from, step.to));
damage = damage.union(step.from).union(step.to);
current = step.to;
}
None => phases.push((current, current)),
}
}
if current != window.to {
return false;
}
entries.push((candidate.clone(), phases, damage));
}
let mut started_any = false;
for (candidate, phases, damage) in entries {
if self.animations.set_phased_target(
candidate.node_id,
phases,
None,
now_nsec,
self.animations.duration_ms.get(),
candidate.curve,
) {
started_any = true;
self.damage(expand_damage_rect(
damage,
self.theme.sizes.border_width.get().max(0),
));
}
}
if started_any {
self.ensure_animation_tick();
}
started_any
}
pub fn queue_spawn_in_animation(
self: &Rc<Self>,
node_id: NodeId,
target: Rect,
) {
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,
None,
now,
self.animations.duration_ms.get(),
self.animations.curve.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 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(),
self.animations.curve.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();
self.damage(self.root.extents.get());
}
}
pub fn set_animation_duration_ms(&self, duration_ms: u32) {
self.animations.duration_ms.set(duration_ms);
}
pub fn set_animation_curve(&self, curve: u32) {
self.animations
.curve
.set(AnimationCurve::from_config(curve));
}
pub fn set_animation_style(&self, style: u32) -> bool {
let Some(style) = AnimationStyle::from_config(style) else {
return false;
};
self.animations.style.set(style);
true
}
pub fn set_animation_cubic_bezier(&self, x1: f32, y1: f32, x2: f32, y2: f32) -> bool {
let Some(curve) = AnimationCurve::from_cubic_bezier(x1, y1, x2, y2) else {
return false;
};
self.animations.curve.set(curve);
true
}
pub fn with_layout_animations<T>(&self, f: impl FnOnce() -> T) -> T {
let prev_requested = self.layout_animations_requested.replace(true);
let prev_active = self.layout_animations_active.replace(true);
let res = f();
self.layout_animations_requested.set(prev_requested);
self.layout_animations_active.set(prev_active);
res
}
pub fn with_linear_layout_animations<T>(&self, f: impl FnOnce() -> T) -> T {
let prev_requested = self.layout_animations_requested.replace(true);
let prev_active = self.layout_animations_active.replace(true);
let prev_curve = self
.layout_animation_curve_override
.replace(Some(AnimationCurve::Linear));
let prev_style = self
.layout_animation_style_override
.replace(Some(AnimationStyle::Plain));
let res = f();
self.layout_animations_requested.set(prev_requested);
self.layout_animations_active.set(prev_active);
self.layout_animation_curve_override.set(prev_curve);
self.layout_animation_style_override.set(prev_style);
res
}
fn ensure_animation_tick(self: &Rc<Self>) {
if self.animations.tick_is_active() {
return;
}
let outputs: Vec<_> = self.root.outputs.lock().values().cloned().collect();
if outputs.is_empty() {
return;
}
let tick = Rc::new_cyclic(|weak| AnimationTick::new(self, weak));
for output in &outputs {
tick.attach(output);
}
self.animations.set_tick(tick);
for output in &outputs {
self.damage(output.global.pos.get());
}
}
pub fn output_extents_changed(&self) {
self.root.update_extents();
for seat in self.globals.seats.lock().values() {
@ -1989,6 +2638,227 @@ impl State {
}
}
#[cfg(test)]
mod tests {
use {
super::*,
crate::animation::multiphase::MultiphaseHierarchyPosition,
};
fn rect(x1: i32, y1: i32, x2: i32, y2: i32) -> Rect {
Rect::new_saturating(x1, y1, x2, y2)
}
fn hierarchy(
source: MultiphaseHierarchyPosition,
target: MultiphaseHierarchyPosition,
) -> MultiphaseWindowHierarchy {
MultiphaseWindowHierarchy::new(source, target)
}
fn candidate(node_id: u32, style: AnimationStyle) -> LayoutAnimationCandidate {
candidate_rects(
node_id,
rect(0, 0, 100, 100),
rect(100, 0, 200, 100),
style,
)
}
fn candidate_rects(
node_id: u32,
old: Rect,
new: Rect,
style: AnimationStyle,
) -> LayoutAnimationCandidate {
LayoutAnimationCandidate {
node_id: NodeId(node_id),
old,
new,
curve: AnimationCurve::Linear,
style,
hierarchy: MultiphaseWindowHierarchy::default(),
}
}
#[test]
fn plain_style_candidate_forces_group_plain() {
let candidates = vec![
candidate(1, AnimationStyle::Multiphase),
candidate(2, AnimationStyle::Plain),
];
assert!(!layout_animation_group_uses_plain(&candidates, &[0]));
assert!(layout_animation_group_uses_plain(&candidates, &[0, 1]));
}
#[test]
fn bridged_retarget_handles_second_rotation_interrupt() {
let a_left = rect(0, 0, 100, 100);
let c_mid = rect(100, 0, 200, 100);
let c_left = a_left;
let a_mid = c_mid;
let c_current = rect(150, 50, 250, 100);
let c_mid_lane = rect(100, 50, 200, 100);
let candidates = vec![
candidate_rects(1, a_left, a_mid, AnimationStyle::Multiphase),
candidate_rects(3, c_mid, c_left, AnimationStyle::Multiphase),
];
let request = MultiphaseRequest {
bounds: rect(0, 0, 250, 100),
windows: vec![
MultiphaseWindow::new(NodeId(1), a_left, a_mid),
MultiphaseWindow::new(NodeId(3), c_current, c_left),
],
clearance: 0,
};
let settled_request = MultiphaseRequest {
bounds: rect(0, 0, 200, 100),
windows: vec![
MultiphaseWindow::new(NodeId(1), a_left, a_mid),
MultiphaseWindow::new(NodeId(3), c_mid, c_left),
],
clearance: 0,
};
let follow_plan = plan_no_overlap_with_diagnostics(&settled_request).unwrap();
let bridge_paths = vec![vec![], vec![(c_current, c_mid_lane), (c_mid_lane, c_mid)]];
let plan = bridged_retarget_plan(
&request,
&candidates,
&[0, 1],
&bridge_paths,
2,
&follow_plan.phases,
)
.unwrap();
assert!(plan
.phases
.iter()
.any(|phase| phase.steps.iter().any(|step| step.node_id == NodeId(1))));
assert!(plan
.phases
.iter()
.any(|phase| phase.steps.iter().any(|step| step.node_id == NodeId(3))));
}
#[test]
fn layout_animation_candidates_coalesce_duplicate_nodes() {
let source = MultiphaseHierarchyPosition {
parent: Some(NodeId(10)),
depth: 2,
sibling_index: Some(1),
..Default::default()
};
let intermediate = MultiphaseHierarchyPosition {
parent: Some(NodeId(11)),
depth: 1,
sibling_index: Some(0),
..Default::default()
};
let target = MultiphaseHierarchyPosition {
parent: Some(NodeId(12)),
depth: 0,
sibling_index: Some(2),
..Default::default()
};
let second_source = MultiphaseHierarchyPosition {
parent: Some(NodeId(20)),
depth: 1,
sibling_index: Some(0),
..Default::default()
};
let second_target = MultiphaseHierarchyPosition {
parent: Some(NodeId(20)),
depth: 1,
sibling_index: Some(1),
..Default::default()
};
let candidates = vec![
LayoutAnimationCandidate {
node_id: NodeId(1),
old: rect(0, 0, 100, 100),
new: rect(0, 0, 80, 100),
curve: AnimationCurve::Linear,
style: AnimationStyle::Multiphase,
hierarchy: hierarchy(source, intermediate),
},
LayoutAnimationCandidate {
node_id: NodeId(2),
old: rect(100, 0, 200, 100),
new: rect(120, 0, 220, 100),
curve: AnimationCurve::Linear,
style: AnimationStyle::Multiphase,
hierarchy: hierarchy(second_source, second_target),
},
LayoutAnimationCandidate {
node_id: NodeId(1),
old: rect(0, 0, 80, 100),
new: rect(0, 0, 60, 100),
curve: AnimationCurve::from_config(4),
style: AnimationStyle::Plain,
hierarchy: hierarchy(intermediate, target),
},
];
let merged = coalesce_layout_animation_candidates(candidates);
assert_eq!(merged.len(), 2);
assert_eq!(merged[0].node_id, NodeId(1));
assert_eq!(merged[0].old, rect(0, 0, 100, 100));
assert_eq!(merged[0].new, rect(0, 0, 60, 100));
assert_eq!(merged[0].curve, AnimationCurve::from_config(4));
assert_eq!(merged[0].style, AnimationStyle::Plain);
assert_eq!(merged[0].hierarchy, hierarchy(source, target));
assert_eq!(merged[1].node_id, NodeId(2));
assert_eq!(merged[1].old, rect(100, 0, 200, 100));
assert_eq!(merged[1].new, rect(120, 0, 220, 100));
assert_eq!(merged[1].hierarchy, hierarchy(second_source, second_target));
}
#[test]
fn layout_animation_candidates_keep_coalesced_layout_noops() {
let hierarchy = MultiphaseWindowHierarchy::default();
let candidates = vec![
LayoutAnimationCandidate {
node_id: NodeId(1),
old: rect(0, 0, 100, 100),
new: rect(0, 0, 80, 100),
curve: AnimationCurve::Linear,
style: AnimationStyle::Multiphase,
hierarchy,
},
LayoutAnimationCandidate {
node_id: NodeId(1),
old: rect(0, 0, 80, 100),
new: rect(0, 0, 100, 100),
curve: AnimationCurve::Linear,
style: AnimationStyle::Plain,
hierarchy,
},
LayoutAnimationCandidate {
node_id: NodeId(2),
old: rect(100, 0, 200, 100),
new: rect(120, 0, 220, 100),
curve: AnimationCurve::Linear,
style: AnimationStyle::Multiphase,
hierarchy,
},
];
let merged = coalesce_layout_animation_candidates(candidates);
assert_eq!(merged.len(), 2);
assert_eq!(merged[0].node_id, NodeId(1));
assert_eq!(merged[0].old, rect(0, 0, 100, 100));
assert_eq!(merged[0].new, rect(0, 0, 100, 100));
assert_eq!(merged[0].style, AnimationStyle::Plain);
assert_eq!(merged[1].node_id, NodeId(2));
}
}
#[derive(Debug, Error)]
pub enum ShmScreencopyError {
#[error("There is no render context")]

View file

@ -131,6 +131,8 @@ pub struct ContainerNode {
pub content_height: Cell<i32>,
pub sum_factors: Cell<f64>,
pub layout_scheduled: Cell<bool>,
animate_next_layout: Cell<bool>,
pub mono_transition_animation_pending: Cell<bool>,
compute_render_positions_scheduled: Cell<bool>,
num_children: NumCell<usize>,
pub children: LinkedList<ContainerChild>,
@ -238,6 +240,8 @@ impl ContainerNode {
content_height: Cell::new(0),
sum_factors: Cell::new(1.0),
layout_scheduled: Cell::new(false),
animate_next_layout: Cell::new(false),
mono_transition_animation_pending: Cell::new(false),
compute_render_positions_scheduled: Cell::new(false),
num_children: NumCell::new(1),
children,
@ -436,6 +440,10 @@ impl ContainerNode {
}
fn schedule_layout(self: &Rc<Self>) {
if self.state.layout_animations_requested.get() || self.state.layout_animations_active.get()
{
self.animate_next_layout.set(true);
}
if !self.layout_scheduled.replace(true) {
self.state.pending_container_layout.push(self.clone());
}
@ -467,6 +475,7 @@ impl ContainerNode {
fn perform_layout(self: &Rc<Self>) {
self.layout_scheduled.set(false);
if self.num_children.get() == 0 {
self.mono_transition_animation_pending.set(false);
return;
}
if let Some(child) = self.mono_child.get() {
@ -484,6 +493,7 @@ impl ContainerNode {
self.damage();
}
}
self.mono_transition_animation_pending.set(false);
}
fn perform_mono_layout(self: &Rc<Self>, child: &ContainerChild) {
@ -656,6 +666,7 @@ impl ContainerNode {
op.child.factor.set(child_factor);
self.sum_factors.set(sum_factors);
// log::info!("pointer_move");
self.state.suppress_animations_for_next_layout.set(true);
self.schedule_layout_immediate();
}
}
@ -816,6 +827,7 @@ impl ContainerNode {
}
}
self.mono_child.set(child.clone());
self.mono_transition_animation_pending.set(true);
if child.is_some() {
self.rebuild_tab_bar();
} else {
@ -1759,10 +1771,42 @@ enum SeatOpKind {
pub async fn container_layout(state: Rc<State>) {
loop {
let container = state.pending_container_layout.pop().await;
if container.layout_scheduled.get() {
let first = state.pending_container_layout.pop().await;
let mut containers = vec![first];
while let Some(container) = state.pending_container_layout.try_pop() {
containers.push(container);
}
let mut animated = vec![];
let mut immediate = vec![];
for container in containers {
if !container.layout_scheduled.get() {
continue;
}
let animate = container.animate_next_layout.replace(false)
&& !state.suppress_animations_for_next_layout.get();
if animate {
animated.push(container);
} else {
immediate.push(container);
}
}
if !animated.is_empty() {
let prev_active = state.layout_animations_active.replace(true);
state.begin_layout_animation_batch();
for container in animated {
container.perform_layout();
}
state.finish_layout_animation_batch();
state.layout_animations_active.set(prev_active);
}
if !immediate.is_empty() {
let prev_active = state.layout_animations_active.replace(false);
for container in immediate {
container.perform_layout();
}
state.layout_animations_active.set(prev_active);
}
state.suppress_animations_for_next_layout.set(false);
}
}
@ -2259,6 +2303,11 @@ impl ContainingNode for ContainerNode {
}
// log::info!("cnode_remove_child2");
self.rebuild_tab_bar();
if self.state.animations.enabled.get()
&& !self.state.suppress_animations_for_next_layout.get()
{
self.animate_next_layout.set(true);
}
self.schedule_layout();
self.cancel_seat_ops();
self.child_removed.trigger();

View file

@ -31,6 +31,9 @@ use {
};
tree_id!(FloatNodeId);
const COMMAND_MOVE_DELTA: i32 = 100;
pub struct FloatNode {
pub id: FloatNodeId,
pub state: Rc<State>,
@ -153,6 +156,13 @@ 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);
}
let theme = &self.state.theme;
let bw = theme.sizes.border_width.get();
let cpos = Rect::new_sized_saturating(
@ -363,6 +373,50 @@ impl FloatNode {
y2 += y1 - pos.y1();
}
let new_pos = Rect::new_saturating(x1, y1, x2, y2);
self.set_position(new_pos);
}
pub fn move_by_direction(self: &Rc<Self>, direction: Direction) {
let (dx, dy) = match direction {
Direction::Left => (-COMMAND_MOVE_DELTA, 0),
Direction::Down => (0, COMMAND_MOVE_DELTA),
Direction::Up => (0, -COMMAND_MOVE_DELTA),
Direction::Right => (COMMAND_MOVE_DELTA, 0),
Direction::Unspecified => return,
};
self.set_position(self.position.get().move_(dx, dy));
}
fn body_for_outer(&self, outer: Rect) -> Rect {
let bw = self.state.theme.sizes.border_width.get();
Rect::new_sized_saturating(
outer.x1() + bw,
outer.y1() + bw,
outer.width() - 2 * bw,
outer.height() - 2 * bw,
)
}
fn queue_position_animation(&self, old_pos: Rect, new_pos: Rect) {
self.state
.clone()
.queue_tiled_animation(self.id.into(), old_pos, new_pos);
let Some(child) = self.child.get() else {
return;
};
self.state.clone().queue_tiled_animation(
child.node_id(),
self.body_for_outer(old_pos),
self.body_for_outer(new_pos),
);
}
fn set_position(self: &Rc<Self>, new_pos: Rect) {
let pos = self.position.get();
if new_pos == pos {
return;
}
self.queue_position_animation(pos, new_pos);
self.position.set(new_pos);
if self.visible.get() {
self.state.damage(pos);
@ -791,13 +845,7 @@ impl ContainingNode for FloatNode {
let bw = theme.sizes.border_width.get();
let (x, y) = (x - bw, y - bw);
let pos = self.position.get();
if pos.position() != (x, y) {
let new_pos = pos.at_point(x, y);
self.position.set(new_pos);
self.state.damage(pos);
self.state.damage(new_pos);
self.schedule_layout();
}
self.set_position(pos.at_point(x, y));
}
fn cnode_resize_child(
@ -828,14 +876,7 @@ impl ContainingNode for FloatNode {
y2 = (v + bw).max(y1 + bw + bw);
}
let new_pos = Rect::new_saturating(x1, y1, x2, y2);
if new_pos != pos {
self.position.set(new_pos);
if self.visible.get() {
self.state.damage(pos);
self.state.damage(new_pos);
}
self.schedule_layout();
}
self.set_position(new_pos);
}
fn cnode_pinned(&self) -> bool {

View file

@ -1,5 +1,12 @@
use {
crate::{
animation::{
RetainedExitLayer, RetainedToplevel,
multiphase::{
MultiphaseHierarchyPosition, MultiphaseHierarchyTransition,
MultiphaseWindowHierarchy, PhaseAxis,
},
},
client::{Client, ClientId},
criteria::{
CritDestroyListener, CritMatcherId,
@ -117,6 +124,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();
@ -184,6 +192,57 @@ 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 target_hierarchy = self.tl_multiphase_hierarchy_position();
let hierarchy = MultiphaseWindowHierarchy::new(
data.layout_animation_position.replace(target_hierarchy),
target_hierarchy,
);
let spawn_in_pending = data.spawn_in_pending.get();
let spawn_in_eligible = spawn_in_pending
&& !rect.is_empty()
&& data.visible.get()
&& !data.is_fullscreen.get()
&& data.kind.is_app_window()
&& !self.node_is_container();
let parent_container = data
.parent
.get()
.and_then(|parent| parent.node_into_container());
let parent_is_mono = parent_container
.as_ref()
.is_some_and(|container| container.mono_child.is_some());
let parent_mono_transition = parent_container
.as_ref()
.is_some_and(|container| container.mono_transition_animation_pending.get());
let active_mono_boundary = matches!(
hierarchy.transition,
MultiphaseHierarchyTransition::EnteringMono
| MultiphaseHierarchyTransition::ExitingMono
) && parent_mono_transition
&& (hierarchy.source.mono_active || hierarchy.target.mono_active);
if prev != *rect
&& !prev.is_empty()
&& !rect.is_empty()
&& data.visible.get()
&& !data.parent_is_float.get()
&& !self.node_is_container()
&& (!parent_is_mono || active_mono_boundary)
{
data.state.clone().queue_tiled_animation_with_hierarchy(
data.node_id,
prev,
*rect,
hierarchy,
);
}
if spawn_in_eligible {
data.state
.clone()
.queue_spawn_in_animation(data.node_id, *rect);
}
if spawn_in_eligible {
data.spawn_in_pending.set(false);
}
if prev.size() != rect.size() {
for sc in data.jay_screencasts.lock().values() {
sc.schedule_realloc_or_reconfigure();
@ -275,6 +334,35 @@ pub trait ToplevelNodeBase: Node {
true
}
fn tl_multiphase_hierarchy_position(&self) -> MultiphaseHierarchyPosition {
let data = self.tl_data();
let Some(parent) = data.parent.get() else {
return Default::default();
};
let mut position = MultiphaseHierarchyPosition {
parent: Some(parent.node_id()),
..Default::default()
};
populate_multiphase_ancestor_splits(&mut position, Some(parent.clone()));
if let Some(container) = parent.node_into_container() {
position.split_axis = Some(match container.split.get() {
ContainerSplit::Horizontal => PhaseAxis::Horizontal,
ContainerSplit::Vertical => PhaseAxis::Vertical,
});
if let Some(mono) = container.mono_child.get() {
position.parent_is_mono = true;
position.mono_active = mono.node.node_id() == data.node_id;
}
for (idx, child) in container.children.iter().enumerate() {
if child.node.node_id() == data.node_id {
position.sibling_index = Some(idx.min(u16::MAX as usize) as u16);
break;
}
}
}
position
}
fn tl_set_active(&self, active: bool) {
let _ = active;
}
@ -299,6 +387,11 @@ pub trait ToplevelNodeBase: Node {
fn tl_scanout_surface(&self) -> Option<Rc<WlSurface>> {
None
}
fn tl_animation_snapshot(&self) -> Option<Rc<RetainedToplevel>> {
None
}
fn tl_restack_popups(&self) {
// nothing
}
@ -339,6 +432,31 @@ pub trait ToplevelNodeBase: Node {
}
}
fn populate_multiphase_ancestor_splits(
position: &mut MultiphaseHierarchyPosition,
mut parent: Option<Rc<dyn ContainingNode>>,
) {
let mut depth = 0u16;
while let Some(node) = parent {
let Some(toplevel) = node.clone().node_into_toplevel() else {
break;
};
depth = depth.saturating_add(1);
if let Some(container) = node.node_into_container() {
match container.split.get() {
ContainerSplit::Horizontal => {
position.nearest_horizontal_split_depth.get_or_insert(depth);
}
ContainerSplit::Vertical => {
position.nearest_vertical_split_depth.get_or_insert(depth);
}
}
}
parent = toplevel.tl_data().parent.get();
}
position.depth = depth;
}
pub struct FullscreenedData {
pub placeholder: Rc<PlaceholderNode>,
pub workspace: Rc<WorkspaceNode>,
@ -377,6 +495,13 @@ impl ToplevelType {
ToplevelType::XWindow { .. } => window::X_WINDOW,
}
}
pub fn is_app_window(&self) -> bool {
matches!(
self,
ToplevelType::XdgToplevel(_) | ToplevelType::XWindow(_)
)
}
}
pub struct ToplevelData {
@ -399,8 +524,10 @@ 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 layout_animation_position: Cell<MultiphaseHierarchyPosition>,
pub seat_state: NodeSeatState,
pub wants_attention: Cell<bool>,
pub requested_attention: Cell<bool>,
@ -462,8 +589,10 @@ 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(),
layout_animation_position: Default::default(),
seat_state: Default::default(),
wants_attention: Cell::new(false),
requested_attention: Cell::new(false),
@ -935,6 +1064,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);
@ -1043,6 +1228,26 @@ pub fn toplevel_create_split(state: &Rc<State>, tl: Rc<dyn ToplevelNode>, axis:
}
}
fn float_outer_for_body(state: &State, body: Rect) -> Rect {
let bw = state.theme.sizes.border_width.get();
Rect::new_sized_saturating(
body.x1() - bw,
body.y1() - bw,
body.width() + 2 * bw,
body.height() + 2 * bw,
)
}
fn float_body_for_outer(state: &State, outer: Rect) -> Rect {
let bw = state.theme.sizes.border_width.get();
Rect::new_sized_saturating(
outer.x1() + bw,
outer.y1() + bw,
outer.width() - 2 * bw,
outer.height() - 2 * bw,
)
}
pub fn toplevel_set_floating(state: &Rc<State>, tl: Rc<dyn ToplevelNode>, floating: bool) {
let data = tl.tl_data();
if data.is_fullscreen.get() {
@ -1059,9 +1264,19 @@ pub fn toplevel_set_floating(state: &Rc<State>, tl: Rc<dyn ToplevelNode>, floati
parent.cnode_remove_child2(&*tl, true);
state.map_tiled(tl);
} else if let Some(ws) = data.workspace.get() {
let node_id = data.node_id;
let old_body =
state
.animations
.visual_rect(node_id, tl.node_absolute_position(), state.now_nsec());
let old_outer = float_outer_for_body(state, old_body);
parent.cnode_remove_child2(&*tl, true);
let (width, height) = data.float_size(&ws);
state.map_floating(tl, width, height, &ws, None);
let floater = state.map_floating(tl, width, height, &ws, None);
let new_outer = floater.position.get();
let new_body = float_body_for_outer(state, new_outer);
state.queue_linear_layout_animation(floater.node_id(), old_outer, new_outer);
state.queue_linear_layout_animation(node_id, old_body, new_body);
}
}

View file

@ -197,10 +197,10 @@ impl WorkspaceNode {
}
self.pull_child_properties(&**container);
let pos = self.position.get();
container.clone().tl_change_extents(&pos);
container.tl_set_parent(self.clone());
container.tl_set_visible(self.container_visible());
self.container.set(Some(container.clone()));
container.clone().tl_change_extents(&pos);
self.state.damage(self.position.get());
}

View file

@ -2034,6 +2034,7 @@ impl Wm {
self.windows_by_surface_serial.remove(&serial);
}
if let Some(window) = data.window.take() {
window.queue_spawn_out();
window.destroy();
}
if let Some(parent) = data.parent.take() {

View file

@ -266,6 +266,20 @@ pub struct UiDrag {
pub threshold: Option<i32>,
}
#[derive(Debug, Clone, Default)]
pub struct Animations {
pub enabled: Option<bool>,
pub duration_ms: Option<u32>,
pub style: Option<String>,
pub curve: Option<AnimationCurveConfig>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum AnimationCurveConfig {
Preset(String),
CubicBezier([f32; 4]),
}
#[derive(Debug, Clone)]
pub enum OutputMatch {
Any(Vec<OutputMatch>),
@ -567,6 +581,7 @@ pub struct Config {
pub tearing: Option<Tearing>,
pub libei: Libei,
pub ui_drag: UiDrag,
pub animations: Animations,
pub xwayland: Option<Xwayland>,
pub color_management: Option<ColorManagement>,
pub float: Option<Float>,
@ -651,3 +666,26 @@ fn default_config_parses() {
let input = include_bytes!("default-config.toml");
parse_config(input, &Default::default(), |_| ()).unwrap();
}
#[test]
fn custom_animation_curve_parses() {
let input = b"
[animations]
curve = [0.25, 0.1, 0.25, 1.0]
";
let config = parse_config(input, &Default::default(), |_| ()).unwrap();
assert_eq!(
config.animations.curve,
Some(AnimationCurveConfig::CubicBezier([0.25, 0.1, 0.25, 1.0]))
);
}
#[test]
fn animation_style_parses() {
let input = b"
[animations]
style = \"plain\"
";
let config = parse_config(input, &Default::default(), |_| ()).unwrap();
assert_eq!(config.animations.style.as_deref(), Some("plain"));
}

View file

@ -8,6 +8,7 @@ use {
pub mod action;
mod actions;
mod animations;
mod capabilities;
mod clean_logs_older_than;
mod client_match;

View file

@ -0,0 +1,99 @@
use {
crate::{
config::{
AnimationCurveConfig, Animations,
context::Context,
extractor::{Extractor, ExtractorError, bol, n32, opt, recover, str, val},
parser::{DataType, ParseResult, Parser, UnexpectedDataType},
},
toml::{
toml_span::{DespanExt, Span, Spanned, SpannedExt},
toml_value::Value,
},
},
indexmap::IndexMap,
thiserror::Error,
};
#[derive(Debug, Error)]
pub enum AnimationsParserError {
#[error(transparent)]
Expected(#[from] UnexpectedDataType),
#[error(transparent)]
Extract(#[from] ExtractorError),
#[error("Expected animation curve to be a string or an array")]
CurveType,
#[error("Cubic-bezier animation curves must contain exactly four values")]
CubicBezierLen,
#[error("Cubic-bezier animation curve entries must be finite floats or integers")]
CubicBezierValue,
#[error("Cubic-bezier x control points must be between 0 and 1")]
CubicBezierXRange,
}
pub struct AnimationsParser<'a>(pub &'a Context<'a>);
impl Parser for AnimationsParser<'_> {
type Value = Animations;
type Error = AnimationsParserError;
const EXPECTED: &'static [DataType] = &[DataType::Table];
fn parse_table(
&mut self,
span: Span,
table: &IndexMap<Spanned<String>, Spanned<Value>>,
) -> ParseResult<Self> {
let mut ext = Extractor::new(self.0, span, table);
let (enabled, duration_ms, style, curve) = ext.extract((
recover(opt(bol("enabled"))),
recover(opt(n32("duration-ms"))),
recover(opt(str("style"))),
opt(val("curve")),
))?;
let curve = match curve {
Some(curve) => Some(parse_curve(curve)?),
None => None,
};
Ok(Animations {
enabled: enabled.despan(),
duration_ms: duration_ms.despan(),
style: style.despan().map(|style| style.to_string()),
curve,
})
}
}
fn parse_curve(
curve: Spanned<&Value>,
) -> Result<AnimationCurveConfig, Spanned<AnimationsParserError>> {
match curve.value {
Value::String(s) => Ok(AnimationCurveConfig::Preset(s.clone())),
Value::Array(values) => parse_cubic_bezier(curve.span, values),
_ => Err(AnimationsParserError::CurveType.spanned(curve.span)),
}
}
fn parse_cubic_bezier(
span: Span,
values: &[Spanned<Value>],
) -> Result<AnimationCurveConfig, Spanned<AnimationsParserError>> {
if values.len() != 4 {
return Err(AnimationsParserError::CubicBezierLen.spanned(span));
}
let mut points = [0.0; 4];
for (idx, value) in values.iter().enumerate() {
let f = match value.value {
Value::Float(f) => f,
Value::Integer(i) => i as f64,
_ => return Err(AnimationsParserError::CubicBezierValue.spanned(value.span)),
};
if !f.is_finite() {
return Err(AnimationsParserError::CubicBezierValue.spanned(value.span));
}
points[idx] = f as f32;
}
if !(0.0..=1.0).contains(&points[0]) || !(0.0..=1.0).contains(&points[2]) {
return Err(AnimationsParserError::CubicBezierXRange.spanned(span));
}
Ok(AnimationCurveConfig::CubicBezier(points))
}

View file

@ -1,13 +1,14 @@
use {
crate::{
config::{
Action, Config, Libei, Theme, UiDrag,
Action, Animations, Config, Libei, Theme, UiDrag,
context::Context,
extractor::{Extractor, ExtractorError, arr, bol, int, opt, recover, str, val},
parser::{DataType, ParseResult, Parser, UnexpectedDataType},
parsers::{
action::ActionParser,
actions::ActionsParser,
animations::AnimationsParser,
clean_logs_older_than::CleanLogsOlderThanParser,
client_rule::ClientRulesParser,
color_management::ColorManagementParser,
@ -153,6 +154,7 @@ impl Parser for ConfigParser<'_> {
fallback_output_mode_val,
clean_logs_older_than_val,
mouse_follows_focus,
animations_val,
),
) = ext.extract((
(
@ -213,6 +215,7 @@ impl Parser for ConfigParser<'_> {
opt(val("fallback-output-mode")),
opt(val("clean-logs-older-than")),
recover(opt(bol("unstable-mouse-follows-focus"))),
opt(val("animations")),
),
))?;
let mut keymap = None;
@ -429,6 +432,15 @@ impl Parser for ConfigParser<'_> {
}
}
}
let mut animations = Animations::default();
if let Some(value) = animations_val {
match value.parse(&mut AnimationsParser(self.0)) {
Ok(v) => animations = v,
Err(e) => {
log::warn!("Could not parse animations setting: {}", self.0.error(e));
}
}
}
let mut xwayland = None;
if let Some(value) = xwayland_val {
match value.parse(&mut XwaylandParser(self.0)) {
@ -587,6 +599,7 @@ impl Parser for ConfigParser<'_> {
tearing,
libei,
ui_drag,
animations,
xwayland,
color_management,
float,

View file

@ -13,9 +13,9 @@ mod toml;
use {
crate::{
config::{
Action, ClientRule, Config, ConfigConnector, ConfigDrmDevice, ConfigKeymap,
ConnectorMatch, DrmDeviceMatch, Exec, Input, InputMatch, Output, OutputMatch,
SimpleCommand, Status, Theme, WindowRule, parse_config,
Action, AnimationCurveConfig, ClientRule, Config, ConfigConnector, ConfigDrmDevice,
ConfigKeymap, ConnectorMatch, DrmDeviceMatch, Exec, Input, InputMatch, Output,
OutputMatch, SimpleCommand, Status, Theme, WindowRule, parse_config,
},
rules::{MatcherTemp, RuleMapper},
shortcuts::ModeState,
@ -23,7 +23,7 @@ use {
ahash::{AHashMap, AHashSet},
error_reporter::Report,
jay_config::{
Axis,
AnimationCurve, AnimationStyle, Axis,
client::Client,
config, config_dir,
exec::{Command, set_env, unset_env},
@ -37,8 +37,10 @@ use {
is_reload,
keyboard::Keymap,
logging::{clean_logs_older_than, set_log_level},
on_devices_enumerated, on_idle, on_unload, quit, reload, set_autotile,
set_color_management_enabled, set_corner_radius, set_default_workspace_capture,
on_devices_enumerated, on_idle, on_unload, quit, reload, set_animation_cubic_bezier,
set_animation_curve, set_animation_duration_ms, set_animation_style,
set_animations_enabled, set_autotile, set_color_management_enabled, set_corner_radius,
set_default_workspace_capture,
set_explicit_sync_enabled, set_float_above_fullscreen, set_floating_titles, set_idle,
set_idle_grace_period, set_middle_click_paste_enabled, set_show_bar,
set_show_float_pin_icon, set_show_titles, set_tab_title_align, set_ui_drag_enabled,
@ -1649,6 +1651,38 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc<Persistent
if let Some(threshold) = config.ui_drag.threshold {
set_ui_drag_threshold(threshold);
}
set_animations_enabled(config.animations.enabled.unwrap_or(false));
set_animation_duration_ms(config.animations.duration_ms.unwrap_or(160));
match config.animations.style.as_deref().unwrap_or("multiphase") {
"plain" => set_animation_style(AnimationStyle::PLAIN),
"multiphase" => set_animation_style(AnimationStyle::MULTIPHASE),
style_name => log::warn!("Unknown animation style: {style_name}"),
}
match config
.animations
.curve
.unwrap_or_else(|| AnimationCurveConfig::Preset("ease-out".to_string()))
{
AnimationCurveConfig::Preset(curve_name) => {
let curve = match curve_name.as_str() {
"linear" => Some(AnimationCurve::LINEAR),
"ease" => Some(AnimationCurve::EASE),
"ease-in" => Some(AnimationCurve::EASE_IN),
"ease-out" => Some(AnimationCurve::EASE_OUT),
"ease-in-out" => Some(AnimationCurve::EASE_IN_OUT),
_ => {
log::warn!("Unknown animation curve: {curve_name}");
None
}
};
if let Some(curve) = curve {
set_animation_curve(curve);
}
}
AnimationCurveConfig::CubicBezier([x1, y1, x2, y2]) => {
set_animation_cubic_bezier(x1, y1, x2, y2);
}
}
if let Some(xwayland) = config.xwayland {
if let Some(enabled) = xwayland.enabled {
set_x_wayland_enabled(enabled);

View file

@ -641,6 +641,61 @@
}
]
},
"AnimationCurve": {
"description": "Describes a window animation curve.\n",
"anyOf": [
{
"type": "string",
"description": "One of the supported curve presets.\n",
"enum": [
"linear",
"ease",
"ease-in",
"ease-out",
"ease-in-out"
]
},
{
"type": "array",
"description": "A custom CSS-style cubic-bezier curve as four numbers:\n`x1`, `y1`, `x2`, and `y2`.\n\nThe implicit endpoints are `(0, 0)` and `(1, 1)`. `x1` and `x2` must\nbe between `0` and `1`.\n",
"items": {
"type": "number",
"description": ""
}
}
]
},
"AnimationStyle": {
"type": "string",
"description": "Describes a tiled window movement animation style.\n",
"enum": [
"plain",
"multiphase"
]
},
"Animations": {
"description": "Describes window animation settings.\n\n- Example:\n\n ```toml\n [animations]\n enabled = true\n duration-ms = 160\n style = \"multiphase\"\n curve = [0.25, 0.1, 0.25, 1.0]\n ```\n",
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "Enables or disables window animations.\n\nThe default is `false`.\n"
},
"duration-ms": {
"type": "integer",
"description": "Sets the animation duration in milliseconds.\n\nThe default is `160`.\n"
},
"style": {
"description": "Sets the animation style used for tiled window movement animations.\n\nThe default is `multiphase`.\n",
"$ref": "#/$defs/AnimationStyle"
},
"curve": {
"description": "Sets the animation curve.\n\nThe default is `ease-out`.\n",
"$ref": "#/$defs/AnimationCurve"
}
},
"required": []
},
"BarPosition": {
"type": "string",
"description": "The position of the bar.",
@ -1085,6 +1140,10 @@
"description": "Configures the ui-drag settings.\n\n- Example:\n\n ```toml\n ui-drag = { enabled = false, threshold = 20 }\n ```\n",
"$ref": "#/$defs/UiDrag"
},
"animations": {
"description": "Configures window animations.\n\nAnimations are disabled by default.\n\n- Example:\n\n ```toml\n [animations]\n enabled = true\n duration-ms = 160\n style = \"multiphase\"\n curve = \"ease-out\"\n ```\n",
"$ref": "#/$defs/Animations"
},
"xwayland": {
"description": "Configures the Xwayland settings.\n\n- Example:\n\n ```toml\n xwayland = { scaling-mode = \"downscaled\" }\n ```\n",
"$ref": "#/$defs/Xwayland"

View file

@ -942,6 +942,125 @@ This table is a tagged union. The variant is determined by the `type` field. It
The numbers should be integers.
<a name="types-AnimationCurve"></a>
### `AnimationCurve`
Describes a window animation curve.
Values of this type should have one of the following forms:
#### A string
One of the supported curve presets.
The string should have one of the following values:
- `linear`:
No easing.
- `ease`:
The CSS `ease` curve.
- `ease-in`:
The CSS `ease-in` curve.
- `ease-out`:
The CSS `ease-out` curve.
- `ease-in-out`:
The CSS `ease-in-out` curve.
#### An array
A custom CSS-style cubic-bezier curve as four numbers:
`x1`, `y1`, `x2`, and `y2`.
The implicit endpoints are `(0, 0)` and `(1, 1)`. `x1` and `x2` must
be between `0` and `1`.
Each element of this array should be a number.
<a name="types-AnimationStyle"></a>
### `AnimationStyle`
Describes a tiled window movement animation style.
Values of this type should be strings.
The string should have one of the following values:
- `plain`:
Uses a single interpolated movement from each window's current visual
rectangle to its destination rectangle.
- `multiphase`:
Uses the no-overlap multiphase planner for tiled window movement when a
supported plan exists.
<a name="types-Animations"></a>
### `Animations`
Describes window animation settings.
- Example:
```toml
[animations]
enabled = true
duration-ms = 160
style = "multiphase"
curve = [0.25, 0.1, 0.25, 1.0]
```
Values of this type should be tables.
The table has the following fields:
- `enabled` (optional):
Enables or disables window animations.
The default is `false`.
The value of this field should be a boolean.
- `duration-ms` (optional):
Sets the animation duration in milliseconds.
The default is `160`.
The value of this field should be a number.
The numbers should be integers.
- `style` (optional):
Sets the animation style used for tiled window movement animations.
The default is `multiphase`.
The value of this field should be a [AnimationStyle](#types-AnimationStyle).
- `curve` (optional):
Sets the animation curve.
The default is `ease-out`.
The value of this field should be a [AnimationCurve](#types-AnimationCurve).
<a name="types-BarPosition"></a>
### `BarPosition`
@ -2169,6 +2288,24 @@ The table has the following fields:
The value of this field should be a [UiDrag](#types-UiDrag).
- `animations` (optional):
Configures window animations.
Animations are disabled by default.
- Example:
```toml
[animations]
enabled = true
duration-ms = 160
style = "multiphase"
curve = "ease-out"
```
The value of this field should be a [Animations](#types-Animations).
- `xwayland` (optional):
Configures the Xwayland settings.
@ -5670,4 +5807,3 @@ The table has the following fields:
The value of this field should be a [XScalingMode](#types-XScalingMode).

View file

@ -2942,6 +2942,23 @@ Config:
```toml
ui-drag = { enabled = false, threshold = 20 }
```
animations:
ref: Animations
required: false
description: |
Configures window animations.
Animations are disabled by default.
- Example:
```toml
[animations]
enabled = true
duration-ms = 160
style = "multiphase"
curve = "ease-out"
```
xwayland:
ref: Xwayland
required: false
@ -3655,6 +3672,97 @@ UiDrag:
The default is `10`.
Animations:
kind: table
description: |
Describes window animation settings.
- Example:
```toml
[animations]
enabled = true
duration-ms = 160
style = "multiphase"
curve = [0.25, 0.1, 0.25, 1.0]
```
fields:
enabled:
kind: boolean
required: false
description: |
Enables or disables window animations.
The default is `false`.
duration-ms:
kind: number
integer_only: true
required: false
description: |
Sets the animation duration in milliseconds.
The default is `160`.
style:
ref: AnimationStyle
required: false
description: |
Sets the animation style used for tiled window movement animations.
The default is `multiphase`.
curve:
ref: AnimationCurve
required: false
description: |
Sets the animation curve.
The default is `ease-out`.
AnimationStyle:
kind: string
description: |
Describes a tiled window movement animation style.
values:
- value: plain
description: |
Uses a single interpolated movement from each window's current visual
rectangle to its destination rectangle.
- value: multiphase
description: |
Uses the no-overlap multiphase planner for tiled window movement when a
supported plan exists.
AnimationCurve:
kind: variable
description: |
Describes a window animation curve.
variants:
- kind: string
description: |
One of the supported curve presets.
values:
- value: linear
description: No easing.
- value: ease
description: The CSS `ease` curve.
- value: ease-in
description: The CSS `ease-in` curve.
- value: ease-out
description: The CSS `ease-out` curve.
- value: ease-in-out
description: The CSS `ease-in-out` curve.
- kind: array
items:
kind: number
description: |
A custom CSS-style cubic-bezier curve as four numbers:
`x1`, `y1`, `x2`, and `y2`.
The implicit endpoints are `(0, 0)` and `(1, 1)`. `x1` and `x2` must
be between `0` and `1`.
Xwayland:
kind: table
description: |