cranpose-ui 0.1.111

UI primitives for Cranpose
Documentation
use cranpose_core::{
    __launched_effect_async_impl as launched_effect_async_impl, Composition, MemoryApplier,
    MutableState, Node, TaskSite, location_key,
};
use cranpose_macros::composable;

use crate::{
    Brush, Button, ButtonSpec, Color, Column, ColumnSpec, CornerRadii, Modifier, Row, RowSpec,
    Size, Spacer, Text, TextStyle,
};

#[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)]
#[allow(dead_code)]
struct DummyNode;

impl Node for DummyNode {}

#[composable]
fn async_runtime_full_layout(
    is_running: MutableState<bool>,
    animation: MutableState<AnimationState>,
    stats: MutableState<FrameStats>,
) {
    {
        let animation_state = animation;
        let stats_state = stats;
        let running_state = is_running;
        launched_effect_async_impl(
            location_key(file!(), line!(), column!()),
            TaskSite::new(file!(), line!()),
            (),
            move |scope| {
                let animation = animation_state;
                let stats = stats_state;
                let running = running_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;
                        }

                        let running_now = running.get();
                        if !running_now {
                            last_time = Some(nanos);
                            continue;
                        }

                        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().padding(32.0),
        ColumnSpec::default(),
        move || {
            Text(
                "Async Runtime Demo",
                Modifier::empty().padding(12.0),
                TextStyle::default(),
            );

            Spacer(Size {
                width: 0.0,
                height: 16.0,
            });

            let animation_snapshot = animation.get();
            let stats_snapshot = stats.get();
            let progress_value = animation_snapshot.progress.clamp(0.0, 1.0);

            Column(
                Modifier::empty().padding(8.0),
                ColumnSpec::default(),
                move || {
                    Text(
                        format!("Progress: {:>3}%", (progress_value * 100.0) as i32),
                        Modifier::empty().padding(6.0),
                        TextStyle::default(),
                    );

                    Spacer(Size {
                        width: 0.0,
                        height: 8.0,
                    });

                    Row(
                        Modifier::empty()
                            .height(26.0)
                            .then(Modifier::empty().rounded_corners(13.0)),
                        RowSpec::default(),
                        {
                            let progress_fraction = progress_value;
                            move || {
                                if progress_fraction > 0.0 {
                                    Row(
                                        Modifier::empty()
                                            .fill_max_width_fraction(progress_fraction)
                                            .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(),
                                        || {},
                                    );
                                }
                            }
                        },
                    );
                },
            );

            Spacer(Size {
                width: 0.0,
                height: 12.0,
            });

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

            Spacer(Size {
                width: 0.0,
                height: 16.0,
            });

            {
                let is_running_for_button = is_running;
                Row(
                    Modifier::empty().padding(4.0),
                    RowSpec::default(),
                    move || {
                        let running = is_running_for_button.get();

                        let button_label = if running {
                            "Pause animation"
                        } else {
                            "Resume animation"
                        };
                        Button(
                            Modifier::empty().padding(12.0),
                            ButtonSpec::default(),
                            {
                                let toggle_state = is_running_for_button;
                                move || toggle_state.set(!toggle_state.get())
                            },
                            move || {
                                Text(
                                    button_label,
                                    Modifier::empty().padding(6.0),
                                    TextStyle::default(),
                                );
                            },
                        );
                    },
                );
            }
        },
    );
}

fn drain_all<A: cranpose_core::Applier + 'static>(
    composition: &mut Composition<A>,
) -> Result<(), cranpose_core::NodeError> {
    let mut iterations = 0;
    loop {
        if !composition.process_invalid_scopes()? {
            if iterations > 100 {
                println!("drain_all: Took {} iterations to stabilize", iterations);
            }
            return Ok(());
        }
        iterations += 1;
        if iterations > 1000 {
            eprintln!(
                "drain_all: Exceeded 1000 iterations, giving up. This indicates an infinite recomposition loop. {iterations}"
            );
            return Err(cranpose_core::NodeError::MissingContext {
                id: 0,
                reason: "drain_all: Exceeded 1000 iterations",
            });
        }
    }
}

#[test]
fn async_runtime_full_layout_freezes_after_forward_flip() {
    let _app_context = crate::render_state::app_context_test_scope();
    let mut composition = Composition::new(MemoryApplier::new());
    let runtime = composition.runtime_handle();

    let is_running = MutableState::with_runtime(true, runtime.clone());
    let animation = MutableState::with_runtime(AnimationState::default(), runtime.clone());
    let stats = MutableState::with_runtime(FrameStats::default(), runtime.clone());

    composition
        .render(location_key(file!(), line!(), column!()), &mut || {
            async_runtime_full_layout(is_running, animation, stats);
        })
        .expect("initial render");
    drain_all(&mut composition).expect("initial drain");

    println!("Starting animation loop, looking for forward flip...");

    let mut time = 0u64;
    let mut last_direction = animation.get().direction;
    let mut forward_flip_frame: Option<u32> = None;

    for frame_num in 0..2000 {
        time += 16_666_667;
        runtime.drain_frame_callbacks(time);
        drain_all(&mut composition).expect("drain loop");
        {
            use crate::layout::LayoutEngine;
            let root = composition.root().expect("composition root");
            let compute_result = {
                let mut applier = composition.applier_mut();
                applier.compute_layout(
                    root,
                    crate::modifier::Size {
                        width: 1280.0,
                        height: 720.0,
                    },
                )
            };
            if let Err(err) = compute_result {
                let tree = {
                    let applier = composition.applier_mut();
                    applier.dump_tree(Some(root))
                };
                panic!("layout compute failed: {err:?}\nTree:\n{tree}");
            }
        }

        let anim = animation.get();
        let current_direction = anim.direction;

        if last_direction < 0.0 && current_direction > 0.0 && anim.progress < 0.1 {
            forward_flip_frame = Some(frame_num);
            println!(
                "Forward flip detected at frame {}: progress={:.3}, direction={:.1}",
                frame_num, anim.progress, current_direction
            );
            break;
        }

        last_direction = current_direction;
    }

    assert!(
        forward_flip_frame.is_some(),
        "Should detect forward flip within 2000 frames"
    );

    let frames_at_flip = stats.get().frames;
    println!("Stats frames at flip: {}", frames_at_flip);

    println!("Advancing 100 frames after flip...");
    for _ in 0..100 {
        time += 16_666_667;
        runtime.drain_frame_callbacks(time);
        drain_all(&mut composition).expect("drain post-flip");
        {
            use crate::layout::LayoutEngine;
            let root = composition.root().expect("composition root");
            let compute_result = {
                let mut applier = composition.applier_mut();
                applier.compute_layout(
                    root,
                    crate::modifier::Size {
                        width: 1280.0,
                        height: 720.0,
                    },
                )
            };
            if let Err(err) = compute_result {
                let tree = {
                    let applier = composition.applier_mut();
                    applier.dump_tree(Some(root))
                };
                panic!("layout compute failed: {err:?}\nTree:\n{tree}");
            }
        }
    }

    let frames_after_flip = stats.get().frames;
    let anim_after = animation.get();

    println!("Stats frames after flip: {}", frames_after_flip);
    println!(
        "Animation after flip: progress={:.3}, direction={:.1}",
        anim_after.progress, anim_after.direction
    );

    assert!(
        frames_after_flip > frames_at_flip,
        "BUG REPRODUCED: Frames stopped incrementing after forward flip! \
         Before flip: {}, After flip: {} (should be ~{} if working). \
         Animation: progress={:.3}, direction={:.1}. \
         The LaunchedEffect continues but stats updates don't trigger UI recomposition.",
        frames_at_flip,
        frames_after_flip,
        frames_at_flip + 100,
        anim_after.progress,
        anim_after.direction
    );

    assert!(
        composition.should_render(),
        "BUG: Composition should schedule rerender when stats change, but doesn't"
    );
}