rdi-core 0.2.29

Platform-agnostic animation core for rusty-desktop-icons (curves, duration, spec, error, backend trait).
Documentation
use crate::{AnimationCurve, DesktopError, IconAnimationSpec, IconFrame, IconRenderPlan, OverlayRenderOptions, Point};

#[derive(Clone, Debug)]
pub struct CapturedFrame {
    pub width: u32,
    pub height: u32,
    pub seconds: f64,
    pub pixels: Vec<u8>,
}

pub trait SceneRenderer {
    fn render(&mut self, frame: &[IconFrame], seconds: f64) -> Result<CapturedFrame, DesktopError>;
}

pub struct RenderSession {
    pub(crate) tx: crossbeam_channel::Sender<RenderRequest>,
    pub(crate) worker: std::thread::ThreadId,
    pub duration: f64,
}

pub(crate) enum RenderRequest {
    Frame(f64, crossbeam_channel::Sender<Result<CapturedFrame, DesktopError>>),
    Close(crossbeam_channel::Sender<()>),
}

impl RenderSession {
    pub fn render_at(&self, seconds: f64) -> Result<CapturedFrame, DesktopError> {
        if std::thread::current().id() == self.worker {
            return Err(DesktopError::BackendUnavailable("render calls cannot run on the worker".into()));
        }
        let (reply, response) = crossbeam_channel::bounded(1);
        self.tx.send(RenderRequest::Frame(seconds, reply)).map_err(|_| DesktopError::BackendUnavailable("render session closed".into()))?;
        response.recv().map_err(|_| DesktopError::WorkerCrashed("render worker stopped".into()))?
    }

    pub fn close(&self) -> Result<(), DesktopError> {
        if std::thread::current().id() == self.worker {
            return Err(DesktopError::BackendUnavailable("close cannot run on the worker".into()));
        }
        let (reply, response) = crossbeam_channel::bounded(1);
        if self.tx.send(RenderRequest::Close(reply)).is_ok() { let _ = response.recv(); }
        Ok(())
    }
}

impl Drop for RenderSession {
    fn drop(&mut self) {
        let (reply, _) = crossbeam_channel::bounded(1);
        let _ = self.tx.send(RenderRequest::Close(reply));
    }
}

#[derive(Clone, Copy, Debug)]
pub struct Canvas {
    pub width: u32,
    pub height: u32,
    pub dpi_scale: f32,
    pub icon_size: u32,
}

impl Canvas {
    pub fn validate(&self) -> Result<(), DesktopError> {
        if self.width == 0 || self.height == 0 || self.width > 8192 || self.height > 8192
            || u64::from(self.width) * u64::from(self.height) > 33_554_432
            || !self.dpi_scale.is_finite() || !(0.5..=4.0).contains(&self.dpi_scale)
            || !(16..=256).contains(&self.icon_size)
        {
            return Err(DesktopError::InvalidEffect("canvas requires dimensions 1..8192, at most 33554432 pixels, DPI scale 0.5..4 and icon size 16..256 DIP".into()));
        }
        Ok(())
    }
}

#[derive(Clone, Debug)]
pub struct SceneIcon {
    pub origin: Point,
    pub animation: IconAnimationSpec,
}

#[derive(Clone, Debug)]
pub struct Scene {
    pub canvas: Canvas,
    pub icons: Vec<SceneIcon>,
    pub render_options: OverlayRenderOptions,
}

pub(crate) fn position_at(origin: Point, target: Point, horizontal: &crate::Curve, vertical: &crate::Curve, progress: f32) -> Point {
    Point::new(
        (origin.x as f32 + horizontal.eval(progress) * (target.x as f32 - origin.x as f32)).round() as i32,
        (origin.y as f32 + vertical.eval(progress) * (target.y as f32 - origin.y as f32)).round() as i32,
    )
}

pub(crate) fn progress_at(duration: f64, position: f64, elapsed: f64) -> f32 {
    if duration == 0.0 { return if position > 0.0 { 1.0 } else { 0.0 }; }
    (elapsed / duration).clamp(0.0, 1.0) as f32
}

pub(crate) struct EvaluatedScene {
    pub scene: Scene,
    pub duration: f64,
    durations: Vec<f64>,
}

impl EvaluatedScene {
    pub fn new(scene: Scene) -> Result<Self, DesktopError> {
        scene.canvas.validate()?;
        let mut ids = std::collections::HashSet::new();
        let mut durations = Vec::with_capacity(scene.icons.len());
        for icon in &scene.icons {
            if !ids.insert(icon.animation.id.clone()) {
                return Err(DesktopError::InvalidEffect("duplicate scene icon id".into()));
            }
            if let Some(effect) = &icon.animation.effect { effect.validate()?; }
            durations.push(icon.animation.duration.resolve(icon.origin, icon.animation.target)?.as_secs_f64());
        }
        let duration = durations.iter().copied().fold(0.0, f64::max);
        Ok(Self { scene, duration, durations })
    }

    pub fn plans(&self) -> Vec<IconRenderPlan> {
        self.scene.icons.iter().map(|icon| {
            let mut plan = IconRenderPlan::placeholder(icon.animation.id.clone(), icon.origin, icon.animation.target);
            plan.effect = icon.animation.effect.clone();
            plan
        }).collect()
    }

    pub fn sample(&self, seconds: f64) -> Result<Vec<IconFrame>, DesktopError> {
        if !seconds.is_finite() || seconds < 0.0 || seconds > self.duration {
            return Err(DesktopError::InvalidDuration("scene time must be finite and within its duration".into()));
        }
        Ok(self.scene.icons.iter().zip(&self.durations).map(|(icon, duration)| {
            let progress = progress_at(*duration, seconds, seconds);
            let spec = &icon.animation;
            let position = if progress <= 0.0 { icon.origin } else if progress >= 1.0 { spec.target }
                else { position_at(icon.origin, spec.target, &spec.curve_x, &spec.curve_y, progress) };
            IconFrame { id: spec.id.clone(), position, progress, elapsed_seconds: seconds as f32 }
        }).collect())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    struct ReadOnlyBackend;
    struct TestRenderer;

    impl SceneRenderer for TestRenderer {
        fn render(&mut self, frame: &[IconFrame], seconds: f64) -> Result<CapturedFrame, DesktopError> {
            Ok(CapturedFrame { width: 1, height: 1, seconds, pixels: vec![frame[0].position.x as u8, 0, 0, 255] })
        }
    }

    impl crate::DesktopBackend for ReadOnlyBackend {
        fn list_icons(&mut self) -> Result<Vec<crate::IconSnapshot>, DesktopError> {
            Ok(vec![crate::IconSnapshot::new("icon".into(), "Icon", None, false, Point::new(999, 999))])
        }
        fn get_flags(&mut self) -> Result<u32, DesktopError> { Ok(42) }
        fn list_monitors(&mut self) -> Result<Vec<crate::MonitorInfo>, DesktopError> { Ok(vec![]) }
        fn apply_flags(&mut self, _: u32, _: u32) -> Result<(), DesktopError> { panic!("scene wrote flags") }
        fn set_positions(&mut self, _: &[(crate::IconId, Point)]) -> Result<Vec<crate::IconId>, DesktopError> { panic!("scene moved icons") }
        fn begin_overlay_session(&mut self, _: &[IconRenderPlan], _: OverlayRenderOptions) -> Result<(), DesktopError> { panic!("scene opened overlay") }
        fn commit_overlay_frame(&mut self, _: &[(crate::IconId, Point)]) -> Result<(), DesktopError> { panic!("scene committed overlay") }
        fn finalize_overlay_session(&mut self, _: &[(crate::IconId, Point)]) -> Result<crate::FinalCommitOutcome, DesktopError> { panic!("scene finalized overlay") }
        fn prepare_scene_renderer(&mut self, _: Canvas, _: &[IconRenderPlan], _: OverlayRenderOptions) -> Result<Box<dyn SceneRenderer>, DesktopError> { Ok(Box::new(TestRenderer)) }
    }

    fn sample_scene() -> Scene {
        Scene { canvas: Canvas { width: 640, height: 480, dpi_scale: 1.0, icon_size: 48 },
            icons: vec![SceneIcon { origin: Point::new(20, 30), animation: IconAnimationSpec::new("icon".into(), Point::new(220, 30),
                crate::Duration::fixed(std::time::Duration::from_secs(2)), crate::Curve::linear()) }],
            render_options: OverlayRenderOptions::default() }
    }

    #[test]
    fn scene_session_is_read_only_and_releases_on_close_drop_shutdown() {
        let controller = crate::DesktopController::new(ReadOnlyBackend).unwrap();
        let scene = sample_scene();
        let session = controller.prepare_scene(scene.clone()).unwrap();
        assert_eq!(session.render_at(1.0).unwrap().pixels[0], 120);
        assert_eq!(controller.get_flags().unwrap(), 42);
        assert!(matches!(controller.prepare_scene(scene.clone()), Err(DesktopError::AnimationBusy)));
        assert!(matches!(controller.set_positions(vec![]), Err(DesktopError::AnimationBusy)));
        assert!(session.render_at(f64::NAN).is_err());
        assert!(session.render_at(3.0).is_err());
        session.close().unwrap();
        session.close().unwrap();
        assert!(session.render_at(0.0).is_err());
        drop(controller.prepare_scene(scene.clone()).unwrap());
        let reopened = controller.prepare_scene(scene).unwrap();
        assert_eq!(reopened.render_at(0.0).unwrap().pixels[0], 20);
        controller.shutdown();
        assert!(reopened.render_at(0.0).is_err());
    }

    #[test]
    fn canvas_accepts_8k_with_bounded_allocations() {
        let mut canvas = Canvas { width: 7680, height: 4320, dpi_scale: 1.0, icon_size: 96 };
        assert!(canvas.validate().is_ok());
        canvas.width = 8192;
        canvas.height = 8192;
        assert!(canvas.validate().is_err());
    }

    #[test]
    fn scene_rejects_duplicate_ids_and_invalid_canvas() {
        let mut scene = sample_scene();
        scene.icons.push(scene.icons[0].clone());
        assert!(EvaluatedScene::new(scene).is_err());
        let mut scene = sample_scene();
        scene.canvas.width = u32::MAX;
        assert!(EvaluatedScene::new(scene).is_err());
    }

    #[test]
    fn scene_samples_explicit_origins_and_rewinds() {
        let icons = [1.0, 2.0].into_iter().enumerate().map(|(index, seconds)| SceneIcon {
            origin: Point::new(20, 30),
            animation: IconAnimationSpec::new(format!("icon{index}").into(), Point::new(220, 30),
                crate::Duration::fixed(std::time::Duration::from_secs_f64(seconds)), crate::Curve::linear()),
        }).collect();
        let scene = EvaluatedScene::new(Scene {
            canvas: Canvas { width: 640, height: 480, dpi_scale: 1.0, icon_size: 48 },
            icons, render_options: OverlayRenderOptions::default(),
        }).unwrap();
        assert_eq!(scene.duration, 2.0);
        let first = scene.sample(0.5).unwrap();
        assert_eq!(first[0].position, Point::new(120, 30));
        assert_eq!(first[1].position, Point::new(70, 30));
        scene.sample(2.0).unwrap();
        for (before, after) in first.iter().zip(scene.sample(0.5).unwrap()) {
            assert_eq!(before.position, after.position);
            assert_eq!(before.progress, after.progress);
            assert_eq!(before.elapsed_seconds, after.elapsed_seconds);
        }
        assert!(scene.sample(f64::NAN).is_err());
        assert!(scene.sample(2.1).is_err());
    }
}