use std::collections::VecDeque;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use crate::animation::backend::WindowBackend;
use crate::animation::batch::{AnimationBatch, build_tweens};
use crate::animation::config::{AnimatorConfig, InterruptPolicy};
use crate::animation::types::{
AnimationError, AnimationHandle, Rect, Result, WindowRef, WindowTarget,
};
enum AnimatorCmd {
Animate {
targets: Vec<WindowTarget>,
config: AnimatorConfig,
},
Cancel,
UpdateConfig(AnimatorConfig),
Shutdown,
}
struct ActiveBatch {
batch: AnimationBatch,
config: AnimatorConfig,
}
struct PendingBatch {
targets: Vec<WindowTarget>,
config: AnimatorConfig,
}
struct WorkerState {
config: AnimatorConfig,
active: Option<ActiveBatch>,
queue: VecDeque<PendingBatch>,
}
pub struct WindowAnimator {
config: AnimatorConfig,
cmd_tx: crossbeam_channel::Sender<AnimatorCmd>,
animating: Arc<AtomicBool>,
next_id: Arc<AtomicU64>,
_worker: std::thread::JoinHandle<()>,
}
impl WindowAnimator {
pub fn new(backend: impl WindowBackend, config: AnimatorConfig) -> Self {
let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded::<AnimatorCmd>();
let animating = Arc::new(AtomicBool::new(false));
let next_id = Arc::new(AtomicU64::new(1));
let animating_worker = Arc::clone(&animating);
let initial_config = config.clone();
let _worker = std::thread::Builder::new()
.name("window-animation-worker".into())
.spawn(move || {
run_worker(Box::new(backend), cmd_rx, animating_worker, initial_config);
})
.expect("failed to spawn animation worker thread");
Self {
config,
cmd_tx,
animating,
next_id,
_worker,
}
}
pub fn animate(&mut self, targets: Vec<WindowTarget>) -> Result<AnimationHandle> {
if targets.is_empty() {
return Err(AnimationError::EmptyBatch);
}
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let cmd = AnimatorCmd::Animate {
targets,
config: self.config.clone(),
};
self.cmd_tx
.send(cmd)
.map_err(|_| AnimationError::WorkerDead)?;
Ok(AnimationHandle(id))
}
pub fn update_config(&mut self, config: AnimatorConfig) {
self.config = config.clone();
let _ = self.cmd_tx.send(AnimatorCmd::UpdateConfig(config));
}
pub fn is_animating(&self) -> bool {
self.animating.load(Ordering::Acquire)
}
pub fn cancel(&mut self) {
let _ = self.cmd_tx.send(AnimatorCmd::Cancel);
}
}
impl Drop for WindowAnimator {
fn drop(&mut self) {
let _ = self.cmd_tx.send(AnimatorCmd::Shutdown);
}
}
fn run_worker(
backend: Box<dyn WindowBackend>,
rx: crossbeam_channel::Receiver<AnimatorCmd>,
animating: Arc<AtomicBool>,
initial_config: AnimatorConfig,
) {
let mut state = WorkerState {
config: initial_config,
active: None,
queue: VecDeque::new(),
};
'main: loop {
let cmd_opt: Option<AnimatorCmd> = if state.active.is_some() {
rx.try_recv().ok()
} else {
match rx.recv() {
Ok(cmd) => Some(cmd),
Err(_) => break 'main, }
};
if let Some(cmd) = cmd_opt {
match cmd {
AnimatorCmd::Shutdown => break 'main,
AnimatorCmd::Cancel => {
state.active = None;
state.queue.clear();
animating.store(false, Ordering::Release);
}
AnimatorCmd::UpdateConfig(cfg) => {
state.config = cfg;
}
AnimatorCmd::Animate { targets, config } => {
handle_animate(&mut state, &*backend, targets, config, &animating);
}
}
}
if let Some(active) = &state.active {
let t = active.batch.progress();
let updates: Vec<(WindowRef, Rect)> = active
.batch
.tweens
.iter()
.map(|tween| {
let r = AnimationBatch::interpolated_rect(tween, &active.config, t);
(tween.window_ref, r)
})
.collect();
if let Err(e) = backend.apply_batch(&updates) {
log::warn!("apply_batch error at t={t:.3}: {e}");
}
if t >= 1.0 {
log::trace!("animation complete: batch finished at t={t:.3}");
state.active = None;
animating.store(false, Ordering::Release);
if let Some(pending) = state.queue.pop_front() {
start_batch(&mut state, &*backend, pending, &animating);
}
} else {
let _ = backend.dwm_flush();
}
}
}
}
fn handle_animate(
state: &mut WorkerState,
backend: &dyn WindowBackend,
targets: Vec<WindowTarget>,
config: AnimatorConfig,
animating: &Arc<AtomicBool>,
) {
match config.interrupt_policy {
InterruptPolicy::DropNew => {
if state.active.is_some() {
return;
}
}
InterruptPolicy::QueueAfterCurrent => {
if state.active.is_some() {
state.queue.push_back(PendingBatch { targets, config });
return;
}
}
InterruptPolicy::RetargetFromCurrent => {
}
}
let pending = PendingBatch { targets, config };
start_batch(state, backend, pending, animating);
}
fn start_batch(
state: &mut WorkerState,
backend: &dyn WindowBackend,
pending: PendingBatch,
animating: &Arc<AtomicBool>,
) {
let current_t = state.active.as_ref().map(|a| a.batch.progress());
let from_rects: Vec<(WindowRef, Rect)> = pending
.targets
.iter()
.map(|target| {
let rect = if let (Some(t), Some(active)) = (current_t, &state.active) {
active
.batch
.tweens
.iter()
.find(|tw| tw.window_ref == target.window_ref)
.map(|tw| AnimationBatch::interpolated_rect(tw, &active.config, t))
.unwrap_or_else(|| {
backend
.get_window_rect(target.window_ref)
.unwrap_or_default()
})
} else {
backend
.get_window_rect(target.window_ref)
.unwrap_or_default()
};
(target.window_ref, rect)
})
.collect();
let tweens = build_tweens(&pending.targets, &from_rects, &pending.config);
log::debug!(
"start_batch: {} targets, {} from_rects, {} tweens built, duration={:?}",
pending.targets.len(),
from_rects.len(),
tweens.len(),
pending.config.duration,
);
for (i, tw) in tweens.iter().enumerate() {
log::trace!(
" tween[{}]: win={:?} from=({},{},{},{}) to=({},{},{},{})",
i,
tw.window_ref,
tw.from_rect.x,
tw.from_rect.y,
tw.from_rect.w,
tw.from_rect.h,
tw.to_rect.x,
tw.to_rect.y,
tw.to_rect.w,
tw.to_rect.h,
);
}
if tweens.is_empty() {
log::warn!("start_batch: all tweens are no-ops, skipping animation");
return;
}
let batch = AnimationBatch::new(tweens, pending.config.duration);
state.active = Some(ActiveBatch {
batch,
config: pending.config.clone(),
});
state.config = pending.config;
animating.store(true, Ordering::Release);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::animation::backend::mock::{MockBackend, MockCall, MockState};
use crate::animation::config::AnimatorConfig;
use crate::animation::types::{IVec2, WindowRef, WindowTarget};
use std::sync::{Arc, Mutex};
use std::time::Duration;
fn make_animator_with_mock() -> (WindowAnimator, Arc<Mutex<MockState>>) {
let state = Arc::new(Mutex::new(MockState {
rects: [
(WindowRef(1), Rect::new(0, 0, 100, 100)),
(WindowRef(2), Rect::new(100, 0, 100, 100)),
]
.into(),
..MockState::default()
}));
let backend = MockBackend::with_state(Arc::clone(&state));
let config = AnimatorConfig {
duration: Duration::from_millis(50),
..AnimatorConfig::default()
};
let animator = WindowAnimator::new(backend, config);
(animator, state)
}
fn target_w1() -> Vec<WindowTarget> {
vec![WindowTarget::new(
WindowRef(1),
IVec2::new(500, 500),
IVec2::new(200, 200),
)]
}
#[test]
fn animator_starts_idle() {
let (animator, _state) = make_animator_with_mock();
assert!(
!animator.is_animating(),
"new animator should not be animating"
);
}
#[test]
fn animate_empty_batch_returns_error() {
let (mut animator, _state) = make_animator_with_mock();
let result = animator.animate(vec![]);
assert!(
matches!(result, Err(AnimationError::EmptyBatch)),
"expected EmptyBatch, got {result:?}"
);
}
#[test]
fn animate_returns_handle() {
let (mut animator, _state) = make_animator_with_mock();
let result = animator.animate(target_w1());
assert!(result.is_ok(), "expected Ok(handle), got {result:?}");
let handle = result.unwrap();
assert!(handle.0 >= 1, "handle ID should be >= 1");
}
#[test]
fn cancel_stops_animation() {
let (mut animator, _state) = make_animator_with_mock();
animator.animate(target_w1()).unwrap();
std::thread::sleep(Duration::from_millis(10));
animator.cancel();
std::thread::sleep(Duration::from_millis(20));
assert!(
!animator.is_animating(),
"animator should not be animating after cancel"
);
}
#[test]
fn zero_duration_animation_completes_instantly() {
let state = Arc::new(Mutex::new(MockState {
rects: [
(WindowRef(1), Rect::new(0, 0, 100, 100)),
(WindowRef(2), Rect::new(100, 0, 100, 100)),
]
.into(),
..MockState::default()
}));
let backend = MockBackend::with_state(Arc::clone(&state));
let snap_config = AnimatorConfig {
duration: Duration::ZERO,
..AnimatorConfig::default()
};
let mut animator = WindowAnimator::new(backend, snap_config);
let targets = vec![WindowTarget::new(
WindowRef(1),
IVec2::new(500, 500),
IVec2::new(200, 200),
)];
animator.animate(targets).unwrap();
std::thread::sleep(Duration::from_millis(20));
assert!(
!animator.is_animating(),
"animator with zero-duration config should complete instantly \
and not be animating after a short delay"
);
let locked = state.lock().unwrap();
let batch_calls: Vec<_> = locked
.calls
.iter()
.filter(|c| matches!(c, MockCall::ApplyBatch(_)))
.collect();
assert!(
!batch_calls.is_empty(),
"zero-duration animation should still have applied at least one batch"
);
}
#[test]
fn update_config_switches_to_runtime_duration() {
let state = Arc::new(Mutex::new(MockState {
rects: [
(WindowRef(1), Rect::new(0, 0, 100, 100)),
(WindowRef(2), Rect::new(100, 0, 100, 100)),
]
.into(),
..MockState::default()
}));
let backend = MockBackend::with_state(Arc::clone(&state));
let snap_config = AnimatorConfig {
duration: Duration::ZERO,
..AnimatorConfig::default()
};
let mut animator = WindowAnimator::new(backend, snap_config);
let snap_targets = vec![WindowTarget::new(
WindowRef(1),
IVec2::new(500, 500),
IVec2::new(200, 200),
)];
animator.animate(snap_targets).unwrap();
std::thread::sleep(Duration::from_millis(20));
assert!(
!animator.is_animating(),
"after zero-duration snap, animator should be idle"
);
let runtime_config = AnimatorConfig {
duration: Duration::from_millis(250),
..AnimatorConfig::default()
};
animator.update_config(runtime_config);
let runtime_targets = vec![WindowTarget::new(
WindowRef(1),
IVec2::new(0, 0),
IVec2::new(960, 1080),
)];
animator.animate(runtime_targets).unwrap();
std::thread::sleep(Duration::from_millis(15));
assert!(
animator.is_animating(),
"after update_config to 250ms, subsequent animate should be in-progress \
(not instant)"
);
animator.cancel();
}
#[test]
fn update_config_does_not_affect_inflight_animation() {
let state = Arc::new(Mutex::new(MockState {
rects: [
(WindowRef(1), Rect::new(0, 0, 100, 100)),
(WindowRef(2), Rect::new(100, 0, 100, 100)),
]
.into(),
..MockState::default()
}));
let backend = MockBackend::with_state(Arc::clone(&state));
let config = AnimatorConfig {
duration: Duration::from_millis(200),
..AnimatorConfig::default()
};
let mut animator = WindowAnimator::new(backend, config);
animator.animate(target_w1()).unwrap();
std::thread::sleep(Duration::from_millis(15));
assert!(animator.is_animating());
animator.update_config(AnimatorConfig {
duration: Duration::ZERO,
..AnimatorConfig::default()
});
std::thread::sleep(Duration::from_millis(10));
assert!(
animator.is_animating(),
"changing config mid-flight should not cancel the active animation"
);
animator.cancel();
}
#[test]
fn drop_new_policy_discards_second_animate() {
let state = Arc::new(Mutex::new(MockState {
rects: [
(WindowRef(1), Rect::new(0, 0, 100, 100)),
(WindowRef(2), Rect::new(100, 0, 100, 100)),
]
.into(),
..MockState::default()
}));
let backend = MockBackend::with_state(Arc::clone(&state));
let config = AnimatorConfig {
duration: Duration::from_millis(200), interrupt_policy: InterruptPolicy::DropNew,
..AnimatorConfig::default()
};
let mut animator = WindowAnimator::new(backend, config);
let h1 = animator
.animate(target_w1())
.expect("first animate should succeed");
std::thread::sleep(Duration::from_millis(15));
assert!(
animator.is_animating(),
"should be animating after first call"
);
let second_targets = vec![WindowTarget::new(
WindowRef(2),
IVec2::new(0, 0),
IVec2::new(50, 50),
)];
let h2 = animator
.animate(second_targets)
.expect("second animate should return Ok");
assert_ne!(h1.0, h2.0, "handles should have distinct IDs");
std::thread::sleep(Duration::from_millis(10));
assert!(
animator.is_animating(),
"animation should still be running under DropNew policy"
);
}
}