cranpose-ui 0.1.50

UI primitives for Cranpose
Documentation
use crate::{run_test_composition, Crossfade, TestComposition};
use cranpose_animation::{tween, Easing};
use cranpose_core::{DisposableEffectResult, MutableState};
use cranpose_macros::composable;
use std::cell::RefCell;
use std::rc::Rc;

const FRAME_NANOS: u64 = 16_666_667; // ~60 FPS
const CROSSFADE_MILLIS: u64 = 160;

fn drain(composition: &mut TestComposition) {
    while composition
        .process_invalid_scopes()
        .expect("process invalid scopes")
    {}
}

fn pump_frames(composition: &mut TestComposition, frame_time: &mut u64, frames: usize) {
    let runtime = composition.runtime_handle();
    for _ in 0..frames {
        *frame_time += FRAME_NANOS;
        composition.with_app_context(|| {
            runtime.drain_frame_callbacks(*frame_time);
            runtime.drain_ui();
        });
        drain(composition);
    }
}

fn settle(composition: &mut TestComposition, frame_time: &mut u64) {
    for _ in 0..120 {
        if !composition.should_render() {
            break;
        }
        pump_frames(composition, frame_time, 1);
    }
}

#[composable]
#[allow(non_snake_case)]
fn CrossfadeHost(target: MutableState<u32>, alive: Rc<RefCell<Vec<u32>>>) {
    let current = target.value();
    let alive_for_content = Rc::clone(&alive);
    Crossfade(
        current,
        tween(CROSSFADE_MILLIS, Easing::LinearEasing),
        move |value: u32| {
            let alive = Rc::clone(&alive_for_content);
            cranpose_core::DisposableEffect!(value, move |_scope| {
                alive.borrow_mut().push(value);
                let alive = Rc::clone(&alive);
                DisposableEffectResult::new(move || {
                    alive.borrow_mut().retain(|item| *item != value);
                })
            });
        },
    );
}

fn crossfade_composition(
    initial: u32,
) -> (TestComposition, MutableState<u32>, Rc<RefCell<Vec<u32>>>) {
    let alive = Rc::new(RefCell::new(Vec::<u32>::new()));
    let target_slot = Rc::new(RefCell::new(None::<MutableState<u32>>));

    let composition = {
        let alive = Rc::clone(&alive);
        let target_slot = Rc::clone(&target_slot);
        run_test_composition(move || {
            let target = cranpose_core::useState(move || initial);
            target_slot.borrow_mut().replace(target);
            CrossfadeHost(target, Rc::clone(&alive));
        })
    };

    let target = target_slot
        .borrow()
        .as_ref()
        .copied()
        .expect("target state captured");
    (composition, target, alive)
}

#[test]
fn crossfade_shows_initial_content_immediately() {
    let (mut composition, _target, alive) = crossfade_composition(1);
    let mut frame_time = 0u64;

    assert_eq!(
        alive.borrow().as_slice(),
        &[1],
        "initial content should be composed right away"
    );

    settle(&mut composition, &mut frame_time);
    assert_eq!(
        alive.borrow().as_slice(),
        &[1],
        "initial content should stay composed with no transition running"
    );
    assert!(!composition.should_render());
}

#[test]
fn crossfade_keeps_both_contents_during_transition_and_removes_old_after() {
    let (mut composition, target, alive) = crossfade_composition(1);
    let mut frame_time = 0u64;
    settle(&mut composition, &mut frame_time);

    composition.with_app_context(|| target.set_value(2));
    drain(&mut composition);
    assert_eq!(
        alive.borrow().as_slice(),
        &[1, 2],
        "both contents should be composed as soon as the transition starts"
    );

    // A few frames into the crossfade both compositions must still exist.
    pump_frames(&mut composition, &mut frame_time, 3);
    assert_eq!(
        alive.borrow().as_slice(),
        &[1, 2],
        "both contents should stay composed mid-transition"
    );

    // Run well past the crossfade duration: the old content must leave the
    // composition once its fade-out completes.
    settle(&mut composition, &mut frame_time);
    assert_eq!(
        alive.borrow().as_slice(),
        &[2],
        "old content should be removed after its fade-out completes"
    );
    assert!(
        !composition.should_render(),
        "crossfade should settle with no pending animation frames"
    );
}

#[test]
fn crossfade_retargeting_mid_transition_restores_previous_content() {
    let (mut composition, target, alive) = crossfade_composition(1);
    let mut frame_time = 0u64;
    settle(&mut composition, &mut frame_time);

    composition.with_app_context(|| target.set_value(2));
    drain(&mut composition);
    pump_frames(&mut composition, &mut frame_time, 2);
    assert_eq!(alive.borrow().as_slice(), &[1, 2]);

    // Flip back to the original state while the crossfade is still running:
    // the original content fades back in, the interrupted one fades out.
    composition.with_app_context(|| target.set_value(1));
    drain(&mut composition);
    assert_eq!(
        alive.borrow().as_slice(),
        &[1, 2],
        "both contents remain composed after retargeting mid-transition"
    );

    settle(&mut composition, &mut frame_time);
    assert_eq!(
        alive.borrow().as_slice(),
        &[1],
        "interrupted content should be removed once its fade-out completes"
    );
}