diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md new file mode 100644 index 00000000..1c99c60e --- /dev/null +++ b/docs/window-animations-plan.md @@ -0,0 +1,200 @@ +# Window Animations Plan + +This document is the working plan for Wry's window animation system. It records +the decisions already made, the implementation phases, and the risks that must +be handled deliberately. + +## Accepted Decisions + +- The first landed slice is linear interpolation only, disabled by default. +- Animation is presentation-only. Logical layout, input hit testing, focus, and + Wayland configure state use final geometry immediately. +- 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. +- 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 + working and testable. +- Content freezing will use retained per-surface texture references, not a full + offscreen snapshot as the default design. +- The multiphase animation concept is original to Wry. Hy3 is relevant only as + partial inspiration for tiling style and titlebar/grouping behavior. +- Mono mode should mostly avoid animations. Exceptions are windows entering or + exiting mono mode, where a visual transition can clarify the hierarchy change. + +## Texture Freezing Decision + +The approved freezing design is to capture a renderable surface tree at animation +start: + +- texture references +- source sample rects +- target sizes +- alpha and color metadata +- subsurface offsets and stacking order +- enough synchronization/release state to keep referenced buffers alive safely + +This is lighter than rendering every toplevel into a compositor-owned offscreen +texture, and it should handle normal GPU-backed windows without an extra full +window copy. It also gives spawn-out a path: capture the surface tree before the +toplevel is logically destroyed, then animate the retained records after the live +node is gone. + +Tradeoffs: + +- Retained references can delay buffer release. For dmabuf clients this can + increase memory pressure or throttle clients if many large windows animate. +- SHM buffers still matter for simple clients, fallback paths, some utilities, + and cursor-like surfaces. They are probably not the common case for large app + windows, but the implementation must still treat SHM texture flipping as a + correctness issue. +- The release/sync contract must be explicit. A retained texture must not be + released back to the client while the compositor may still render it. +- True offscreen snapshots remain a possible fallback for cases where retained + references cannot safely preserve the rendered content. + +## Phase 1: Linear Presentation Animations + +Goal: add the smallest correct animation layer without changing layout semantics. + +Implementation shape: + +- Add animation state owned by `State`. +- Track per-toplevel animation entries keyed by `NodeId`. +- Store logical target rect, current presentation rect, previous damaged rect, + start time, duration, and curve. +- On command-driven tiled layout geometry changes, animate from current + presentation rect to new final rect. +- On interruption, restart only the affected window from its current + presentation rect. +- Drive frames from the existing output latch/presentation event flow. +- Damage the union of previous presentation rect, current presentation rect, and + final logical rect. + +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. +- 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. +- Live client buffers are rendered in Phase 1. Retained content freezing is + deferred, but animated windows must still be clipped to their presentation + bounds and must preserve the existing stretch behavior for undersized contents. +- No spawn-out. +- No content freezing. +- No multiphase no-overlap planner. + +Tests: + +- rect interpolation is direction-independent +- interruption restarts only changed windows +- unchanged in-flight windows keep their original timeline +- drag-driven floating movement bypasses animation +- damage includes old, current, and final rects + +## Phase 2: Retained Texture Freezing + +Goal: freeze visual contents during movement and enable spawn-out. + +Implementation shape: + +- Add a retained render-record tree for toplevel surfaces. +- Capture records before movement animations that require freezing. +- Capture records before destroy/unmap for spawn-out. +- Render retained records through the normal renderer primitives where possible. +- Extend event/sync handling so retained buffers remain valid until the animation + is complete. + +Open design work: + +- Exact lifetime model for retained `SurfaceBuffer` / `GfxTexture` references. +- Whether retained records participate in frame callbacks or presentation + feedback. Default assumption: no, because they are compositor animation frames, + not client commits. +- How to fall back when a buffer cannot be safely retained. + +## Phase 3: Multiphase No-Overlap Animations + +Goal: implement Wry's staged no-overlap planner while preserving the rule that +windows never overlap. + +Core rules: + +- Each phase is a discrete animation using the full curve. +- A phase performs only one action kind per window: move or scale. +- Movement and scaling are split by axis. +- No diagonal motion. +- A window or synchronized group owns its own timeline. +- New layout changes interrupt only windows/groups with changed destinations. +- Current hierarchy and target hierarchy both matter. The planner must know + whether a window is ascending toward a higher-level/toplevel position, + descending into a container, or moving between containers at the same depth. +- If some child windows require fewer phases than their parent/container + context, parent/container-space changes generally happen first so space exists + before the child moves into it. This rule can be overridden only when the + non-overlap invariant still clearly holds. +- Windows that become peers in the target hierarchy may synchronize later + phases even if they were not peers in the source hierarchy. + +Important parent/child synchronization issue: + +The planner must not let a parent container and child window animate independent +axes at the same time in a way that violates the visual rules. For example, a +parent scaling horizontally while a child scales vertically can accidentally +produce a diagonal or multi-axis motion in screen space. + +Preferred approach: + +- Plan in terms of leaf toplevel visual rectangles first. +- Treat containers as constraints and grouping boundaries, not as independently + animated visual actors. +- Derive every leaf's per-phase rect from one phase schedule so parent and child + effects cannot compose into forbidden motion. +- Add container-level grouping only after the leaf planner proves correct. +- Include hierarchy-transition metadata in the planner input: source parent, + target parent, source depth, target depth, and whether the window is ascending, + descending, or staying at the same hierarchy level. +- For mono containers, suppress ordinary in-mono focus/tab changes. Animate only + transitions into mono, out of mono, or across the mono boundary. + +Tests: + +- horizontal swaps shrink, move, then grow without overlap +- extraction from a stack creates space before moving the extracted window +- nested containers do not produce simultaneous cross-axis motion +- interruption restarts only affected phase groups +- reversing direction produces equivalent motion in reverse +- child waits for parent/container-space phases when moving upward into a + toplevel peer position +- mono-mode tab switches do not animate, while entering/exiting mono can animate + +## Configuration + +Phase 1 should expose a disabled-by-default setting for: + +- enabled/disabled +- duration +- curve preset or cubic bezier + +Initial TOML shape: + +```toml +[animations] +enabled = false +duration-ms = 160 +curve = "ease-out" +``` + +Bezier curves should be analyzed at configuration time and stored in a form that +is cheap to evaluate during rendering. + +## Existing Note + +`docs/animation-integration.md` appears to document a prior animation attempt +whose `src/animation/` implementation is not present in this checkout. Treat +this plan as the current source of truth until implementation docs are updated. diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 8ef87476..721b5097 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -1023,6 +1023,18 @@ 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_color_management_enabled(&self, enabled: bool) { self.send(&ClientMessage::SetColorManagementEnabled { enabled }); } diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index acb5ad81..d090ba0c 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -545,6 +545,15 @@ pub enum ClientMessage<'a> { SetUiDragThreshold { threshold: i32, }, + SetAnimationsEnabled { + enabled: bool, + }, + SetAnimationDurationMs { + duration_ms: u32, + }, + SetAnimationCurve { + curve: u32, + }, SetXScalingMode { mode: XScalingMode, }, diff --git a/jay-config/src/lib.rs b/jay-config/src/lib.rs index e25710f9..44546ce0 100644 --- a/jay-config/src/lib.rs +++ b/jay-config/src/lib.rs @@ -103,6 +103,18 @@ 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); +} + /// Exits the compositor. pub fn quit() { get!().quit() @@ -287,6 +299,27 @@ 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); +} + /// Enables or disables the color-management protocol. /// /// The default is `false`. diff --git a/src/animation.rs b/src/animation.rs new file mode 100644 index 00000000..b0091933 --- /dev/null +++ b/src/animation.rs @@ -0,0 +1,315 @@ +use { + crate::{ + rect::Rect, + state::State, + tree::{LatchListener, NodeId, OutputNode}, + utils::{clonecell::CloneCell, event_listener::EventListener}, + }, + ahash::AHashMap, + std::{ + cell::{Cell, RefCell}, + rc::{Rc, Weak}, + }, +}; + +const DEFAULT_DURATION_MS: u32 = 160; + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum AnimationCurve { + Linear, + Ease, + EaseIn, + EaseOut, + EaseInOut, +} + +impl AnimationCurve { + pub fn from_config(value: u32) -> Self { + match value { + 0 => Self::Linear, + 1 => Self::Ease, + 2 => Self::EaseIn, + 4 => Self::EaseInOut, + _ => Self::EaseOut, + } + } + + fn sample(self, t: f64) -> f64 { + let t = t.clamp(0.0, 1.0); + match self { + Self::Linear => t, + Self::Ease => cubic_bezier(0.25, 0.1, 0.25, 1.0, t), + Self::EaseIn => cubic_bezier(0.42, 0.0, 1.0, 1.0, t), + Self::EaseOut => cubic_bezier(0.0, 0.0, 0.58, 1.0, t), + Self::EaseInOut => cubic_bezier(0.42, 0.0, 0.58, 1.0, t), + } + } +} + +pub struct AnimationState { + pub enabled: Cell, + pub duration_ms: Cell, + pub curve: Cell, + windows: RefCell>, + tick: CloneCell>>, +} + +impl Default for AnimationState { + fn default() -> Self { + Self { + enabled: Cell::new(false), + duration_ms: Cell::new(DEFAULT_DURATION_MS), + curve: Cell::new(AnimationCurve::EaseOut), + windows: Default::default(), + tick: Default::default(), + } + } +} + +impl AnimationState { + pub fn clear(&self) { + self.windows.borrow_mut().clear(); + if let Some(tick) = self.tick.take() { + tick.detach(); + } + } + + pub fn set_target( + &self, + node_id: NodeId, + old: Rect, + new: Rect, + now_nsec: u64, + duration_ms: u32, + curve: AnimationCurve, + ) -> bool { + if old == new || old.is_empty() || new.is_empty() || duration_ms == 0 { + self.windows.borrow_mut().remove(&node_id); + return false; + } + let duration_nsec = duration_ms as u64 * 1_000_000; + let mut windows = self.windows.borrow_mut(); + let from = match windows.get(&node_id) { + Some(anim) if anim.to == new => return false, + Some(anim) => anim.rect_at(now_nsec), + None => old, + }; + if from == new { + windows.remove(&node_id); + return false; + } + windows.insert( + node_id, + WindowAnimation { + from, + to: new, + start_nsec: now_nsec, + duration_nsec, + curve, + last_damage: from, + }, + ); + true + } + + pub fn visual_rect(&self, node_id: NodeId, layout: Rect, now_nsec: u64) -> Rect { + let windows = self.windows.borrow(); + match windows.get(&node_id) { + Some(anim) if !anim.done(now_nsec) => anim.rect_at(now_nsec), + _ => layout, + } + } + + fn damage_active(&self, state: &State, now_nsec: u64) -> bool { + let mut damages = vec![]; + let mut any_active = false; + { + let mut windows = self.windows.borrow_mut(); + windows.retain(|_, anim| { + let current = anim.rect_at(now_nsec); + let damage = anim.last_damage.union(current).union(anim.to); + damages.push(expand_damage_rect( + damage, + state.theme.sizes.border_width.get().max(0), + )); + anim.last_damage = current; + let active = !anim.done(now_nsec); + any_active |= active; + active + }); + } + for damage in damages { + state.damage(damage); + } + any_active + } + + pub(crate) fn tick_is_active(&self) -> bool { + self.tick.is_some() + } + + pub(crate) fn set_tick(&self, tick: Rc) { + self.tick.set(Some(tick)); + } + + pub(crate) fn clear_tick(&self) { + self.tick.take(); + } +} + +struct WindowAnimation { + from: Rect, + to: Rect, + start_nsec: u64, + duration_nsec: u64, + curve: AnimationCurve, + last_damage: Rect, +} + +impl WindowAnimation { + fn done(&self, now_nsec: u64) -> bool { + now_nsec.saturating_sub(self.start_nsec) >= self.duration_nsec + } + + fn rect_at(&self, now_nsec: u64) -> Rect { + if self.duration_nsec == 0 { + return self.to; + } + let elapsed = now_nsec.saturating_sub(self.start_nsec); + let t = (elapsed as f64 / self.duration_nsec as f64).clamp(0.0, 1.0); + let t = self.curve.sample(t); + lerp_rect(self.from, self.to, t) + } +} + +pub struct AnimationTick { + state: Weak, + slf: Weak, + latch_listeners: RefCell>>, +} + +impl AnimationTick { + pub fn new(state: &Rc, slf: &Weak) -> Self { + let slf: Weak = slf.clone(); + Self { + state: Rc::downgrade(state), + slf, + latch_listeners: Default::default(), + } + } + + pub fn attach(&self, output: &OutputNode) { + let listener = EventListener::new(self.slf.clone()); + listener.attach(&output.latch_event); + self.latch_listeners.borrow_mut().push(listener); + } + + pub fn detach(&self) { + for listener in self.latch_listeners.borrow_mut().drain(..) { + listener.detach(); + } + } +} + +impl LatchListener for AnimationTick { + fn after_latch(self: Rc, _on: &OutputNode, _tearing: bool) { + let Some(state) = self.state.upgrade() else { + self.detach(); + return; + }; + let active = state.animations.damage_active(&state, state.now_nsec()); + if !active { + self.detach(); + state.animations.clear_tick(); + } + } +} + +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 + } + Rect::new_saturating( + lerp(from.x1(), to.x1(), t), + lerp(from.y1(), to.y1(), t), + lerp(from.x2(), to.x2(), t), + lerp(from.y2(), to.y2(), t), + ) +} + +pub(crate) fn expand_damage_rect(rect: Rect, width: i32) -> Rect { + Rect::new_saturating( + rect.x1().saturating_sub(width), + rect.y1().saturating_sub(width), + rect.x2().saturating_add(width), + rect.y2().saturating_add(width), + ) +} + +fn cubic_bezier(x1: f64, y1: f64, x2: f64, y2: f64, x: f64) -> f64 { + fn bezier(a: f64, b: f64, t: f64) -> f64 { + let inv = 1.0 - t; + 3.0 * inv * inv * t * a + 3.0 * inv * t * t * b + t * t * t + } + let mut lo = 0.0; + let mut hi = 1.0; + let mut t = x; + for _ in 0..12 { + let bx = bezier(x1, x2, t); + if (bx - x).abs() < 0.000_001 { + break; + } + if bx < x { + lo = t; + } else { + hi = t; + } + t = (lo + hi) * 0.5; + } + bezier(y1, y2, t) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn linear_rect_interpolation_is_symmetric() { + let a = Rect::new_sized_saturating(0, 0, 100, 100); + let b = Rect::new_sized_saturating(100, 40, 200, 80); + assert_eq!(lerp_rect(a, b, 0.25), lerp_rect(b, a, 0.75)); + } + + #[test] + fn unchanged_target_does_not_restart() { + let state = AnimationState::default(); + let id = NodeId(1); + let a = Rect::new_sized_saturating(0, 0, 100, 100); + let b = Rect::new_sized_saturating(100, 0, 100, 100); + assert!(state.set_target(id, a, b, 0, 160, AnimationCurve::Linear)); + assert!(!state.set_target(id, a, b, 80_000_000, 160, AnimationCurve::Linear)); + assert_eq!( + state.visual_rect(id, b, 80_000_000), + Rect::new_sized_saturating(50, 0, 100, 100) + ); + } + + #[test] + fn changed_target_restarts_from_current_visual_rect() { + let state = AnimationState::default(); + let id = NodeId(1); + let a = Rect::new_sized_saturating(0, 0, 100, 100); + let b = Rect::new_sized_saturating(100, 0, 100, 100); + let c = Rect::new_sized_saturating(200, 0, 100, 100); + assert!(state.set_target(id, a, b, 0, 160, AnimationCurve::Linear)); + assert!(state.set_target(id, a, c, 80_000_000, 160, AnimationCurve::Linear)); + assert_eq!( + state.visual_rect(id, c, 80_000_000), + Rect::new_sized_saturating(50, 0, 100, 100) + ); + assert_eq!( + state.visual_rect(id, c, 160_000_000), + Rect::new_sized_saturating(125, 0, 100, 100) + ); + } +} diff --git a/src/compositor.rs b/src/compositor.rs index 45d2a018..d8fb4027 100644 --- a/src/compositor.rs +++ b/src/compositor.rs @@ -360,6 +360,10 @@ 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(), + suppress_animations_for_next_layout: Default::default(), toplevels: Default::default(), const_40hz_latch: Default::default(), tray_item_ids: Default::default(), diff --git a/src/config/handler.rs b/src/config/handler.rs index 526c1cde..0e9436c5 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -658,17 +658,21 @@ impl ConfigProxyHandler { } fn handle_seat_move(&self, seat: Seat, direction: Direction) -> Result<(), CphError> { - let seat = self.get_seat(seat)?; - seat.move_focused(direction.into()); - Ok(()) + 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> { - let window = self.get_window(window)?; - if let Some(c) = toplevel_parent_container(&*window) { - c.move_child(window, direction.into()); - } - Ok(()) + self.state.with_layout_animations(|| { + let window = self.get_window(window)?; + 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 +990,19 @@ 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_direct_scanout_enabled( &self, device: Option, @@ -1724,9 +1741,11 @@ impl ConfigProxyHandler { } fn handle_set_seat_mono(&self, seat: Seat, mono: bool) -> Result<(), CphError> { - let seat = self.get_seat(seat)?; - seat.set_mono(mono); - Ok(()) + 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 +1759,13 @@ impl ConfigProxyHandler { } fn handle_set_window_mono(&self, window: Window, mono: bool) -> Result<(), CphError> { - let window = self.get_window(window)?; - if let Some(c) = toplevel_parent_container(&*window) { - c.set_mono(mono.then_some(window.as_ref())); - } - Ok(()) + 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 +1780,19 @@ impl ConfigProxyHandler { } fn handle_set_seat_split(&self, seat: Seat, axis: Axis) -> Result<(), CphError> { - let seat = self.get_seat(seat)?; - seat.set_split(axis.into()); - Ok(()) + 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> { - let seat = self.get_seat(seat)?; - seat.toggle_tab(); - Ok(()) + self.state.with_layout_animations(|| { + let seat = self.get_seat(seat)?; + seat.toggle_tab(); + Ok(()) + }) } fn handle_seat_make_group( @@ -1776,27 +1801,35 @@ impl ConfigProxyHandler { axis: Axis, ephemeral: bool, ) -> Result<(), CphError> { - let seat = self.get_seat(seat)?; - seat.make_group(axis.into(), ephemeral); - Ok(()) + 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> { - let seat = self.get_seat(seat)?; - seat.change_group_opposite(); - Ok(()) + 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> { - let seat = self.get_seat(seat)?; - seat.equalize(recursive); - Ok(()) + 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> { - let seat = self.get_seat(seat)?; - seat.move_tab(right); - Ok(()) + 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 +1844,13 @@ impl ConfigProxyHandler { } fn handle_set_window_split(&self, window: Window, axis: Axis) -> Result<(), CphError> { - let window = self.get_window(window)?; - if let Some(c) = toplevel_parent_container(&*window) { - c.set_split(axis.into()); - } - Ok(()) + 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( @@ -2721,8 +2756,10 @@ impl ConfigProxyHandler { dx2: i32, dy2: i32, ) -> Result<(), CphError> { - self.get_window(window)?.tl_resize(dx1, dy1, dx2, dy2); - Ok(()) + 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 +3230,13 @@ 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::SetXScalingMode { mode } => self .handle_set_x_scaling_mode(mode) .wrn("set_x_scaling_mode")?, diff --git a/src/main.rs b/src/main.rs index 5a566f9b..161d3d99 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,6 +48,7 @@ mod leaks; mod tracy; mod acceptor; mod allocator; +mod animation; mod async_engine; mod backend; mod backends; diff --git a/src/renderer.rs b/src/renderer.rs index e601a0e0..f8174883 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -15,7 +15,7 @@ use { theme::{Color, CornerRadius}, tree::{ ContainerNode, DisplayNode, FloatNode, OutputNode, PlaceholderNode, ToplevelData, - ToplevelNodeBase, WorkspaceNode, tab_bar::TabBar, + ToplevelNode, ToplevelNodeBase, WorkspaceNode, tab_bar::TabBar, }, }, std::{ops::Deref, rc::Rc, slice}, @@ -453,6 +453,20 @@ impl Renderer<'_> { .fill_boxes2(&rd.border_rects, &c, srgb, perceptual, x, y); } + fn presentation_child_body( + &self, + container: &ContainerNode, + child: &Rc, + 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()) + } + pub fn render_container(&mut self, container: &ContainerNode, x: i32, y: i32) { self.render_container_decorations(container, x, y); @@ -465,6 +479,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 +491,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,14 +506,18 @@ impl Renderer<'_> { ); } } - let body = mb.move_(x, y); + let body = visual_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())) - } else { - None - }; + 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 + }; if self.state.theme.sizes.gap.get() != 0 && !child.node.node_is_container() { let cr = self.state.theme.corner_radius.get(); if !cr.is_zero() { @@ -524,10 +543,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 +566,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())) diff --git a/src/state.rs b/src/state.rs index 4ae761a0..f12a1745 100644 --- a/src/state.rs +++ b/src/state.rs @@ -2,6 +2,7 @@ use { crate::{ acceptor::Acceptor, allocator::BufferObject, + animation::{AnimationCurve, AnimationState, AnimationTick, expand_damage_rect}, async_engine::{AsyncEngine, SpawnedFuture}, backend::{ Backend, BackendConnectorState, BackendConnectorStateSerials, BackendDrmDevice, @@ -102,11 +103,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::{ @@ -264,6 +264,10 @@ pub struct State { pub cpu_worker: Rc, pub ui_drag_enabled: Cell, pub ui_drag_threshold_squared: Cell, + pub animations: AnimationState, + pub layout_animations_requested: Cell, + pub layout_animations_active: Cell, + pub suppress_animations_for_next_layout: Cell, pub toplevels: CopyHashMap>, pub const_40hz_latch: EventSource, pub tray_item_ids: TrayItemIds, @@ -1115,6 +1119,10 @@ 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.suppress_animations_for_next_layout.set(false); self.render_ctx_watchers.clear(); self.workspace_watchers.clear(); self.toplevel_lists.clear(); @@ -1461,6 +1469,88 @@ impl State { self.eng.now().msec() } + pub fn queue_tiled_animation(self: &Rc, node_id: NodeId, old: Rect, new: Rect) { + 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 now = self.now_nsec(); + let started = self.animations.set_target( + node_id, + old, + new, + now, + self.animations.duration_ms.get(), + self.animations.curve.get(), + ); + if started { + self.damage(expand_damage_rect( + old.union(new), + 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 with_layout_animations(&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 + } + + fn ensure_animation_tick(self: &Rc) { + 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() { diff --git a/src/tree/container.rs b/src/tree/container.rs index 61ec00d1..3a6db3a2 100644 --- a/src/tree/container.rs +++ b/src/tree/container.rs @@ -131,6 +131,7 @@ pub struct ContainerNode { pub content_height: Cell, pub sum_factors: Cell, pub layout_scheduled: Cell, + animate_next_layout: Cell, compute_render_positions_scheduled: Cell, num_children: NumCell, pub children: LinkedList, @@ -238,6 +239,7 @@ impl ContainerNode { content_height: Cell::new(0), sum_factors: Cell::new(1.0), layout_scheduled: Cell::new(false), + animate_next_layout: Cell::new(false), compute_render_positions_scheduled: Cell::new(false), num_children: NumCell::new(1), children, @@ -436,6 +438,10 @@ impl ContainerNode { } fn schedule_layout(self: &Rc) { + 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()); } @@ -656,6 +662,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(); } } @@ -1761,8 +1768,13 @@ pub async fn container_layout(state: Rc) { loop { let container = state.pending_container_layout.pop().await; if container.layout_scheduled.get() { + let animate = container.animate_next_layout.replace(false) + && !state.suppress_animations_for_next_layout.get(); + let prev_active = state.layout_animations_active.replace(animate); container.perform_layout(); + state.layout_animations_active.set(prev_active); } + state.suppress_animations_for_next_layout.set(false); } } diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index 02bba848..34d637dd 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -184,6 +184,23 @@ impl ToplevelNode for T { fn tl_change_extents(self: Rc, rect: &Rect) { let data = self.tl_data(); let prev = data.desired_extents.replace(*rect); + let parent_is_mono = data + .parent + .get() + .and_then(|parent| parent.node_into_container()) + .is_some_and(|container| container.mono_child.is_some()); + if prev != *rect + && !prev.is_empty() + && !rect.is_empty() + && data.visible.get() + && !data.parent_is_float.get() + && !self.node_is_container() + && !parent_is_mono + { + data.state + .clone() + .queue_tiled_animation(data.node_id, prev, *rect); + } if prev.size() != rect.size() { for sc in data.jay_screencasts.lock().values() { sc.schedule_realloc_or_reconfigure(); diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index ba71c585..d860d656 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -266,6 +266,13 @@ pub struct UiDrag { pub threshold: Option, } +#[derive(Debug, Clone, Default)] +pub struct Animations { + pub enabled: Option, + pub duration_ms: Option, + pub curve: Option, +} + #[derive(Debug, Clone)] pub enum OutputMatch { Any(Vec), @@ -567,6 +574,7 @@ pub struct Config { pub tearing: Option, pub libei: Libei, pub ui_drag: UiDrag, + pub animations: Animations, pub xwayland: Option, pub color_management: Option, pub float: Option, diff --git a/toml-config/src/config/parsers.rs b/toml-config/src/config/parsers.rs index 4c5e337b..e353a2f8 100644 --- a/toml-config/src/config/parsers.rs +++ b/toml-config/src/config/parsers.rs @@ -8,6 +8,7 @@ use { pub mod action; mod actions; +mod animations; mod capabilities; mod clean_logs_older_than; mod client_match; diff --git a/toml-config/src/config/parsers/animations.rs b/toml-config/src/config/parsers/animations.rs new file mode 100644 index 00000000..a8abdf89 --- /dev/null +++ b/toml-config/src/config/parsers/animations.rs @@ -0,0 +1,50 @@ +use { + crate::{ + config::{ + Animations, + context::Context, + extractor::{Extractor, ExtractorError, bol, n32, opt, recover, str}, + parser::{DataType, ParseResult, Parser, UnexpectedDataType}, + }, + toml::{ + toml_span::{DespanExt, Span, Spanned}, + toml_value::Value, + }, + }, + indexmap::IndexMap, + thiserror::Error, +}; + +#[derive(Debug, Error)] +pub enum AnimationsParserError { + #[error(transparent)] + Expected(#[from] UnexpectedDataType), + #[error(transparent)] + Extract(#[from] ExtractorError), +} + +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>, + ) -> ParseResult { + let mut ext = Extractor::new(self.0, span, table); + let (enabled, duration_ms, curve) = ext.extract(( + recover(opt(bol("enabled"))), + recover(opt(n32("duration-ms"))), + recover(opt(str("curve"))), + ))?; + Ok(Animations { + enabled: enabled.despan(), + duration_ms: duration_ms.despan(), + curve: curve.despan().map(|s| s.to_string()), + }) + } +} diff --git a/toml-config/src/config/parsers/config.rs b/toml-config/src/config/parsers/config.rs index b9d34e74..d82be95b 100644 --- a/toml-config/src/config/parsers/config.rs +++ b/toml-config/src/config/parsers/config.rs @@ -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, diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index 391bcee9..1b057985 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -23,7 +23,7 @@ use { ahash::{AHashMap, AHashSet}, error_reporter::Report, jay_config::{ - Axis, + AnimationCurve, Axis, client::Client, config, config_dir, exec::{Command, set_env, unset_env}, @@ -37,7 +37,8 @@ use { is_reload, keyboard::Keymap, logging::{clean_logs_older_than, set_log_level}, - on_devices_enumerated, on_idle, on_unload, quit, reload, set_autotile, + on_devices_enumerated, on_idle, on_unload, quit, reload, set_animation_curve, + set_animation_duration_ms, 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, @@ -1649,6 +1650,23 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc 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); + } if let Some(xwayland) = config.xwayland { if let Some(enabled) = xwayland.enabled { set_x_wayland_enabled(enabled);