cranpose-ui 0.1.106

UI primitives for Cranpose
Documentation
use std::{
    cell::{Cell, RefCell},
    rc::Rc,
};

use cranpose_core::{Composition, MemoryApplier, MutableState, NodeId, location_key};
use cranpose_foundation::lazy::{LazyListScope, LazyListState, rememberLazyListState};
use cranpose_ui_graphics::Size as ViewportSize;

use super::*;

#[test]
fn lazy_row_unbounded_width_matches_effective_viewport() {
    let captured_state = Rc::new(RefCell::new(None));
    let mut composition = run_test_composition({
        let captured_state = Rc::clone(&captured_state);
        move || {
            let list_state = rememberLazyListState();
            *captured_state.borrow_mut() = Some(list_state);

            LazyRow(
                Modifier::empty(),
                list_state,
                LazyRowSpec::default(),
                |scope| {
                    scope.items(100, |_| {
                        Spacer(Size {
                            width: 100.0,
                            height: 0.0,
                        });
                    });
                },
            );
        }
    });

    let root = composition.root().expect("lazy row root");
    let handle = composition.runtime_handle();
    let measurements = {
        let mut applier = composition.applier_mut();
        applier.set_runtime_handle(handle);
        let result = measure_layout(
            &mut applier,
            root,
            ViewportSize {
                width: f32::INFINITY,
                height: 320.0,
            },
        )
        .expect("layout measurement");
        applier.clear_runtime_handle();
        result
    };

    let list_state = (*captured_state.borrow()).expect("state captured");
    let expected_width = list_state.layout_info().viewport_size;
    let actual_width = measurements.root_size().width;

    assert!(actual_width.is_finite());
    assert!(
        (actual_width - expected_width).abs() < 0.01,
        "expected lazy row width to match effective viewport {expected_width}, got {actual_width}"
    );
}

#[composable]
#[allow(non_snake_case)]
fn GrowingLazyRow(
    item_count: MutableState<usize>,
    captured_state: Rc<RefCell<Option<LazyListState>>>,
    call_count: Rc<Cell<usize>>,
) {
    let list_state = rememberLazyListState();
    *captured_state.borrow_mut() = Some(list_state);
    let count = item_count.value();
    call_count.set(call_count.get() + 1);

    Column(Modifier::empty(), ColumnSpec::default(), move || {
        Text(
            format!("Count {count}"),
            Modifier::empty(),
            TextStyle::default(),
        );
        LazyRow(
            Modifier::empty().height(120.0),
            list_state,
            LazyRowSpec::new().horizontal_arrangement(LinearArrangement::SpacedBy(8.0)),
            |scope| {
                scope.items(count, |index| {
                    Row(
                        Modifier::empty().height(48.0).width(96.0),
                        RowSpec::default(),
                        move || {
                            Text(
                                format!("Item {}", index),
                                Modifier::empty(),
                                TextStyle::default(),
                            );
                        },
                    );
                });
            },
        );
    });
}

#[test]
fn lazy_row_updates_scroll_bounds_when_item_count_grows_without_scrolling() {
    let _app_context = crate::render_state::app_context_test_scope();
    let mut composition = Composition::new(MemoryApplier::new());
    let runtime = composition.runtime_handle();
    let item_count = MutableState::with_runtime(2usize, runtime.clone());
    let captured_state = Rc::new(RefCell::new(None));
    let call_count = Rc::new(Cell::new(0));

    let key = location_key(file!(), line!(), column!());
    composition
        .render(key, {
            let captured_state = Rc::clone(&captured_state);
            let call_count = Rc::clone(&call_count);
            move || {
                GrowingLazyRow(
                    item_count,
                    Rc::clone(&captured_state),
                    Rc::clone(&call_count),
                );
            }
        })
        .expect("initial render");

    let root = composition.root().expect("lazy row root");
    let viewport = Size {
        width: 260.0,
        height: 320.0,
    };
    measure_root(&mut composition, root, viewport);

    let list_state = (*captured_state.borrow()).expect("state captured");
    assert_eq!(list_state.layout_info().total_items_count, 2);
    assert!(
        !list_state.can_scroll_forward(),
        "initial short row should not scroll"
    );

    item_count.set(24);
    let mut recomposed = false;
    while composition
        .process_invalid_scopes()
        .expect("recompose after item count growth")
    {
        recomposed = true;
    }
    assert!(
        recomposed,
        "expected composition to re-run after item count growth"
    );
    assert!(
        call_count.get() >= 2,
        "expected LazyRow parent composable to execute again after item count growth"
    );
    measure_root(&mut composition, root, viewport);

    assert_eq!(list_state.layout_info().total_items_count, 24);
    assert!(
        list_state.can_scroll_forward(),
        "lazy row should become scrollable when item count grows beyond viewport"
    );
    let texts = render_texts(&mut composition, root);
    assert!(
        texts.iter().any(|text| text == "Count 24"),
        "expected parent composition to observe the grown item count"
    );
}

#[composable]
#[allow(non_snake_case)]
fn HorizontalScrollIndicatorLazyRow(captured_state: Rc<RefCell<Option<LazyListState>>>) {
    let list_state = rememberLazyListState();
    *captured_state.borrow_mut() = Some(list_state);

    Column(Modifier::empty(), ColumnSpec::default(), move || {
        Text(
            format!("First visible {}", list_state.first_visible_item_index()),
            Modifier::empty(),
            TextStyle::default(),
        );
        Text(
            format!("Can scroll back {}", list_state.can_scroll_backward()),
            Modifier::empty(),
            TextStyle::default(),
        );
        LazyRow(
            Modifier::empty().fill_max_height().width(240.0),
            list_state,
            LazyRowSpec::new().horizontal_arrangement(LinearArrangement::SpacedBy(8.0)),
            |scope| {
                scope.items(80, |index| {
                    Text(
                        format!("Item {}", index),
                        Modifier::empty().width(48.0),
                        TextStyle::default(),
                    );
                });
            },
        );
    });
}

#[test]
fn lazy_row_gesture_scroll_moves_rendered_items_along_the_horizontal_axis() {
    let _app_context = crate::render_state::app_context_test_scope();
    let mut composition = Composition::new(MemoryApplier::new());
    let captured_state = Rc::new(RefCell::new(None));

    composition
        .render(location_key(file!(), line!(), column!()), {
            let captured_state = Rc::clone(&captured_state);
            move || {
                HorizontalScrollIndicatorLazyRow(Rc::clone(&captured_state));
            }
        })
        .expect("initial render");

    let root = composition.root().expect("lazy row root");
    let viewport = Size {
        width: 320.0,
        height: 320.0,
    };
    measure_root(&mut composition, root, viewport);
    let initial_texts = render_texts(&mut composition, root);
    assert!(
        initial_texts.iter().any(|text| text == "First visible 0"),
        "test must subscribe a composition scope to the scroll position"
    );
    assert!(
        initial_texts
            .iter()
            .any(|text| text == "Can scroll back false"),
        "test must subscribe a composition scope to lazy scroll bounds"
    );
    let initial_records = render_text_records(&mut composition, root);
    let initial_item_0_x = text_x(&initial_records, "Item 0");
    let initial_item_0_y = text_y(&initial_records, "Item 0");

    let list_state = (*captured_state.borrow()).expect("state captured");
    list_state.dispatch_scroll_delta(-40.0);
    measure_root(&mut composition, root, viewport);

    assert!(
        list_state.can_scroll_backward_non_reactive(),
        "gesture scroll should update retained lazy scroll bounds"
    );
    assert!(
        composition
            .process_invalid_scopes()
            .expect("gesture scroll invalidation processing"),
        "gesture scroll must recompose scopes that observe public scroll position"
    );
    measure_root(&mut composition, root, viewport);
    let scrolled_texts = render_texts(&mut composition, root);
    assert!(
        scrolled_texts
            .iter()
            .any(|text| text == "Can scroll back true"),
        "scroll observer text did not track gesture-updated backward capability; got {scrolled_texts:?}"
    );

    let scrolled_records = render_text_records(&mut composition, root);
    let scrolled_item_0_x = text_x(&scrolled_records, "Item 0");
    let scrolled_item_0_y = text_y(&scrolled_records, "Item 0");
    assert!(
        (initial_item_0_x - scrolled_item_0_x - 40.0).abs() < 0.5,
        "scrolling a lazy row should move its rendered items along x by the scroll delta: \
         initial_x={initial_item_0_x}, scrolled_x={scrolled_item_0_x}"
    );
    assert!(
        (initial_item_0_y - scrolled_item_0_y).abs() < 0.01,
        "scrolling a lazy row must not move its rendered items along y: \
         initial_y={initial_item_0_y}, scrolled_y={scrolled_item_0_y}"
    );
}

#[derive(Debug)]
struct RenderedText {
    value: String,
    x: f32,
    y: f32,
}

fn render_texts(composition: &mut Composition<MemoryApplier>, root: NodeId) -> Vec<String> {
    render_text_records(composition, root)
        .into_iter()
        .map(|record| record.value)
        .collect()
}

fn render_text_records(
    composition: &mut Composition<MemoryApplier>,
    root: NodeId,
) -> Vec<RenderedText> {
    let handle = composition.runtime_handle();
    let mut applier = composition.applier_mut();
    applier.set_runtime_handle(handle);
    let layout = applier
        .compute_layout(
            root,
            Size {
                width: 320.0,
                height: 320.0,
            },
        )
        .expect("layout");
    applier.clear_runtime_handle();
    let renderer = HeadlessRenderer::new();
    let scene = renderer.render(&layout);
    scene
        .operations()
        .iter()
        .filter_map(|op| match op {
            RenderOp::Text { rect, value, .. } => Some(RenderedText {
                value: value.clone(),
                x: rect.x,
                y: rect.y,
            }),
            _ => None,
        })
        .collect()
}

fn text_x(records: &[RenderedText], value: &str) -> f32 {
    records
        .iter()
        .find(|record| record.value == value)
        .unwrap_or_else(|| panic!("expected rendered text {value:?}, got {records:?}"))
        .x
}

fn text_y(records: &[RenderedText], value: &str) -> f32 {
    records
        .iter()
        .find(|record| record.value == value)
        .unwrap_or_else(|| panic!("expected rendered text {value:?}, got {records:?}"))
        .y
}

fn measure_root(composition: &mut Composition<MemoryApplier>, root: NodeId, size: Size) {
    let handle = composition.runtime_handle();
    let mut applier = composition.applier_mut();
    applier.set_runtime_handle(handle);
    let _ = applier.compute_layout(root, size).expect("layout");
    applier.clear_runtime_handle();
}