cranpose-ui 0.1.74

UI primitives for Cranpose
Documentation
//! The frame pump under the robot's exact-interval keyframe clock.
//!
//! The deterministic capture command advances the animation clock by exact
//! deltas, which lands frame callbacks EXACTLY on tween duration boundaries
//! and completes several animations in one drain — the failure mode observed
//! live was every later recomposition/animation dying after such a frame.

use crate::{run_test_composition, TestComposition};
use cranpose_animation::{Animatable, AnimationSpec, AnimationType};
use cranpose_core::with_current_composer;
use cranpose_macros::composable;
use std::cell::{Cell, RefCell};
use std::rc::Rc;

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

/// One update exactly like the app shell's: frame callbacks then UI drains,
/// wrapped in the deferred-state-release guard, at an EXACT frame time.
fn update_at(composition: &mut TestComposition, frame_time: u64) {
    let runtime = composition.runtime_handle();
    composition.with_app_context(|| {
        runtime.with_deferred_state_releases(|| {
            runtime.drain_frame_callbacks(frame_time);
            runtime.drain_ui();
        });
    });
    drain(composition);
}

impl PartialEq for PumpProbe {
    fn eq(&self, other: &Self) -> bool {
        std::ptr::eq(self, other)
    }
}

struct PumpProbe {
    gate: RefCell<Animatable<f32>>,
    tail: RefCell<Animatable<f32>>,
    composes: Cell<u32>,
    observed_gate: Cell<f32>,
    observed_tail: Cell<f32>,
}

#[composable]
#[allow(non_snake_case)]
fn PumpHost(probe: Rc<PumpProbe>) {
    probe.composes.set(probe.composes.get() + 1);
    probe.observed_gate.set(probe.gate.borrow().state().value());
    probe.observed_tail.set(probe.tail.borrow().state().value());
}

#[test]
fn frame_pump_survives_exact_boundary_completions() {
    let probe_slot: Rc<RefCell<Option<Rc<PumpProbe>>>> = Rc::new(RefCell::new(None));
    let probe_for_host = Rc::clone(&probe_slot);
    let mut composition = run_test_composition(move || {
        let probe = cranpose_core::remember(|| {
            let runtime = with_current_composer(|composer| composer.runtime_handle());
            Rc::new(PumpProbe {
                gate: RefCell::new(Animatable::new(0.0, runtime.clone())),
                tail: RefCell::new(Animatable::new(0.0, runtime)),
                composes: Cell::new(0),
                observed_gate: Cell::new(0.0),
                observed_tail: Cell::new(0.0),
            })
        })
        .with(Rc::clone);
        *probe_for_host.borrow_mut() = Some(Rc::clone(&probe));
        PumpHost(probe);
    });
    drain(&mut composition);
    let probe = probe_slot.borrow().clone().expect("probe captured");

    let ms = |v: u64| v * 1_000_000;
    // Two tweens started in the same frame, one 120 ms and one 70 ms — the
    // loupe birth gate and the menu dissolve of the live repro.
    composition.with_app_context(|| {
        probe
            .gate
            .borrow_mut()
            .animateTo(1.0, AnimationType::Tween(AnimationSpec::linear(120)));
        probe
            .tail
            .borrow_mut()
            .animateTo(1.0, AnimationType::Tween(AnimationSpec::linear(70)));
    });

    update_at(&mut composition, ms(10)); // both stamp start=10ms
    update_at(&mut composition, ms(70)); // gate 0.5, tail 60/70
    assert!((probe.observed_gate.get() - 0.5).abs() < 1e-3);

    // The killer frame: BOTH tweens land exactly on/beyond their duration in
    // ONE drain (gate at exactly 120 ms elapsed) and complete together.
    update_at(&mut composition, ms(130));
    assert_eq!(
        probe.observed_gate.get(),
        1.0,
        "the completion write must recompose the subscribed scope"
    );
    assert_eq!(probe.observed_tail.get(), 1.0);

    // The pump must still be alive: a NEW animation after the boundary
    // completion has to tick and recompose.
    let composes_before = probe.composes.get();
    composition.with_app_context(|| {
        probe
            .tail
            .borrow_mut()
            .animateTo(0.0, AnimationType::Tween(AnimationSpec::linear(50)));
    });
    update_at(&mut composition, ms(140)); // stamps start
    update_at(&mut composition, ms(165)); // half-way back
    assert!(
        probe.composes.get() > composes_before,
        "recomposition died after the boundary-completion frame"
    );
    assert!(
        (probe.observed_tail.get() - 0.5).abs() < 0.02,
        "the post-boundary animation must actually animate, got {}",
        probe.observed_tail.get()
    );
    update_at(&mut composition, ms(200));
    assert_eq!(probe.observed_tail.get(), 0.0);
}