cranpose-ui 0.0.60

UI primitives for Cranpose
Documentation
use crate::{
    Brush, Color, Column, ColumnSpec, CornerRadii, Modifier, Row, RowSpec, Text, TextStyle,
};
use cranpose_core::{
    __launched_effect_async_impl as launched_effect_async_impl, location_key, Composition,
    MemoryApplier, MutableState, Node, NodeError,
};
use cranpose_macros::composable;

#[derive(Clone, Copy, Debug)]
struct AnimationState {
    progress: f32,
    direction: f32,
}

impl Default for AnimationState {
    fn default() -> Self {
        Self {
            progress: 0.0,
            direction: 1.0,
        }
    }
}

#[derive(Clone, Copy, Debug)]
struct FrameStats {
    frames: u32,
    last_frame_ms: f32,
}

impl Default for FrameStats {
    fn default() -> Self {
        Self {
            frames: 0,
            last_frame_ms: 0.0,
        }
    }
}

#[derive(Default)]
struct DummyNode;

impl Node for DummyNode {}

#[composable]
fn async_runtime_demo(animation: MutableState<AnimationState>, stats: MutableState<FrameStats>) {
    {
        let animation_state = animation;
        let stats_state = stats;
        launched_effect_async_impl(
            location_key(file!(), line!(), column!()),
            (),
            move |scope| {
                let animation = animation_state;
                let stats = stats_state;
                Box::pin(async move {
                    let clock = scope.runtime().frame_clock();
                    let mut last_time: Option<u64> = None;
                    while scope.is_active() {
                        let nanos = clock.next_frame().await;
                        if !scope.is_active() {
                            break;
                        }
                        if let Some(previous) = last_time {
                            let mut delta_nanos = nanos.saturating_sub(previous);
                            if delta_nanos == 0 {
                                delta_nanos = 16_666_667;
                            }
                            let dt_ms = delta_nanos as f32 / 1_000_000.0;
                            stats.update(|state| {
                                state.frames = state.frames.wrapping_add(1);
                                state.last_frame_ms = dt_ms;
                            });
                            animation.update(|anim| {
                                let next = anim.progress + 0.1 * anim.direction * (dt_ms / 600.0);
                                if next >= 1.0 {
                                    anim.progress = 1.0;
                                    anim.direction = -1.0;
                                } else if next <= 0.0 {
                                    anim.progress = 0.0;
                                    anim.direction = 1.0;
                                } else {
                                    anim.progress = next;
                                }
                            });
                        }
                        last_time = Some(nanos);
                    }
                })
            },
        );
    }

    Column(Modifier::empty(), ColumnSpec::default(), {
        let animation_snapshot = animation.value();
        let stats_snapshot = stats.value();
        let progress_value = animation_snapshot.progress.clamp(0.0, 1.0);
        let fill_width = 320.0 * progress_value;
        move || {
            let progress_width = fill_width;
            cranpose_core::with_current_composer(|composer| {
                composer.with_group(location_key(file!(), line!(), column!()), |composer| {
                    if progress_width > 0.0 {
                        composer.with_group(
                            location_key(file!(), line!(), column!()),
                            |composer| {
                                composer.emit_node(|| DummyNode);
                            },
                        );
                    }
                });
            });

            Text(
                format!(
                    "Frames advanced: {} (last frame {:.2} ms, direction: {})",
                    stats_snapshot.frames,
                    stats_snapshot.last_frame_ms,
                    if animation_snapshot.direction >= 0.0 {
                        "forward"
                    } else {
                        "reverse"
                    }
                ),
                Modifier::empty(),
                TextStyle::default(),
            );
        }
    });
}

fn drain_all(composition: &mut Composition<MemoryApplier>) -> Result<(), NodeError> {
    loop {
        if !composition.process_invalid_scopes()? {
            break;
        }
    }
    Ok(())
}

#[test]
fn async_runtime_freezes_without_conditional_key() {
    let mut composition = Composition::new(MemoryApplier::new());
    let runtime = composition.runtime_handle();
    let animation = MutableState::with_runtime(AnimationState::default(), runtime.clone());
    let stats = MutableState::with_runtime(FrameStats::default(), runtime.clone());

    let mut render = { move || async_runtime_demo(animation, stats) };

    composition
        .render(location_key(file!(), line!(), column!()), &mut render)
        .expect("initial render");
    drain_all(&mut composition).expect("initial drain");

    let mut last_direction = animation.value().direction;
    let mut frames_before = None;
    let mut frames_after = None;
    let mut forward_flip = false;
    let mut time = 0u64;

    for _ in 0..800 {
        time += 16_666_667;
        runtime.drain_frame_callbacks(time);
        drain_all(&mut composition).expect("drain after frame");

        let anim = animation.value();
        if last_direction < 0.0 && anim.direction > 0.0 {
            forward_flip = true;
            frames_before = Some(stats.value().frames);

            for _ in 0..8 {
                time += 16_666_667;
                runtime.drain_frame_callbacks(time);
                drain_all(&mut composition).expect("post-flip drain");
            }

            frames_after = Some(stats.value().frames);
            break;
        }

        last_direction = anim.direction;
    }

    assert!(forward_flip, "no backward->forward transition observed");
    let before = frames_before.expect("frames before flip recorded");
    let after = frames_after.expect("frames after flip recorded");

    assert!(
        after > before,
        "frames should increase after forward flip without manual with_key workaround (before {before}, after {after})"
    );
}

#[composable]
fn progress_demo(animation: MutableState<AnimationState>, stats: MutableState<FrameStats>) {
    Column(Modifier::empty().padding(12.0), ColumnSpec::default(), {
        move || {
            let animation_snapshot = animation.value();
            let stats_snapshot = stats.value();
            let progress_value = animation_snapshot.progress.clamp(0.0, 1.0);
            let fill_width = 320.0 * progress_value;

            Row(
                Modifier::empty()
                    .fill_max_width()
                    .then(Modifier::empty().height(26.0))
                    .then(Modifier::empty().rounded_corners(13.0))
                    .then(Modifier::empty().draw_behind(|scope| {
                        scope.draw_round_rect(
                            Brush::solid(Color(0.12, 0.16, 0.30, 1.0)),
                            CornerRadii::uniform(13.0),
                        );
                    })),
                RowSpec::default(),
                {
                    let progress_width = fill_width;
                    move || {
                        if progress_width > 0.0 {
                            Row(
                                Modifier::empty()
                                    .width(progress_width.min(360.0))
                                    .then(Modifier::empty().height(26.0))
                                    .then(Modifier::empty().rounded_corners(13.0))
                                    .then(Modifier::empty().draw_behind(|scope| {
                                        scope.draw_round_rect(
                                            Brush::linear_gradient(vec![
                                                Color(0.25, 0.55, 0.95, 1.0),
                                                Color(0.15, 0.35, 0.80, 1.0),
                                            ]),
                                            CornerRadii::uniform(13.0),
                                        );
                                    })),
                                RowSpec::default(),
                                || {},
                            );
                        }
                    }
                },
            );

            Text(
                format!(
                    "Frames advanced: {} (dir: {})",
                    stats_snapshot.frames,
                    if animation_snapshot.direction >= 0.0 {
                        "forward"
                    } else {
                        "reverse"
                    }
                ),
                Modifier::empty().padding(8.0),
                TextStyle::default(),
            );
        }
    });
}

#[composable]
fn keyed_progress_demo(progress: MutableState<f32>) {
    Column(Modifier::empty().padding(12.0), ColumnSpec::default(), {
        move || {
            let value = progress.value();

            cranpose_core::with_key(&(value > 0.0), move || {
                if value > 0.0 {
                    Row(
                        Modifier::empty()
                            .width(200.0 * value)
                            .then(Modifier::empty().height(12.0)),
                        RowSpec::default(),
                        || {},
                    );
                }
            });
        }
    });
}

#[test]
fn stats_state_invalidates_after_direction_flip() {
    let mut composition = Composition::new(MemoryApplier::new());
    let runtime = composition.runtime_handle();
    let animation = MutableState::with_runtime(AnimationState::default(), runtime.clone());
    let stats = MutableState::with_runtime(FrameStats::default(), runtime.clone());

    animation.update(|anim| anim.progress = 0.5);

    let mut render = { move || progress_demo(animation, stats) };

    let key = location_key(file!(), line!(), column!());
    composition
        .render(key, &mut render)
        .expect("initial render");
    drain_all(&mut composition).expect("initial drain");
    assert!(
        !composition.should_render(),
        "initial render should leave composition idle"
    );

    animation.update(|anim| {
        anim.progress = 1.0;
        anim.direction = -1.0;
    });
    composition
        .render(key, &mut render)
        .expect("render descending");
    drain_all(&mut composition).expect("drain descending");

    animation.update(|anim| {
        anim.progress = 0.0;
        anim.direction = 1.0;
    });
    composition
        .render(key, &mut render)
        .expect("render ascending");
    drain_all(&mut composition).expect("drain ascending");

    stats.update(|state| state.frames = state.frames.wrapping_add(1));
    assert!(
        composition.should_render(),
        "stats update should still schedule render without manual with_key workaround"
    );
}

#[test]
fn keyed_progress_branch_does_not_grow_slots_across_toggle_cycles() {
    let mut composition = Composition::new(MemoryApplier::new());
    let runtime = composition.runtime_handle();
    let progress = MutableState::with_runtime(0.8f32, runtime);
    let key = location_key(file!(), line!(), column!());

    composition
        .render(key, &mut || keyed_progress_demo(progress))
        .expect("initial render");
    drain_all(&mut composition).expect("initial drain");

    let baseline_slots = composition.debug_dump_slot_entries().len();
    for cycle in 0..12 {
        progress.set_value(0.0);
        drain_all(&mut composition).expect("drain after collapse");

        progress.set_value(0.8);
        drain_all(&mut composition).expect("drain after restore");

        let current_slots = composition.debug_dump_slot_entries();
        assert!(
            current_slots.len() <= baseline_slots + 8,
            "keyed progress branch leaked slots on cycle {cycle}: baseline={} current={} slots={current_slots:#?}",
            baseline_slots,
            current_slots.len(),
        );
    }
}