cranpose-app-shell 0.9.21

App lifecycle, frame cadence and input dispatch for Cranpose
Documentation
use std::{
    cell::{Cell, RefCell},
    collections::HashMap,
    rc::Rc,
};

use cranpose_app_shell::AppShell;
use cranpose_core::{MutableState, location_key, rememberMutableStateOf};
use cranpose_foundation::lazy::{LazyListScope, LazyListState, rememberLazyListState};
use cranpose_macros::composable;
use cranpose_render_common::{RenderScene, Renderer, graph_scene::Scene};
use cranpose_ui::{
    Box, BoxSpec, LazyColumn, LazyColumnSpec, Modifier, SemanticsNode, Text, TextStyle,
};
use cranpose_ui_graphics::Size;

#[derive(Default)]
struct EmptyRenderer(Scene);

impl Renderer for EmptyRenderer {
    type Scene = Scene;
    type Error = std::convert::Infallible;

    fn scene(&self) -> &Self::Scene {
        &self.0
    }

    fn scene_mut(&mut self) -> &mut Self::Scene {
        &mut self.0
    }

    fn rebuild_scene(
        &mut self,
        _layout_tree: &cranpose_ui::LayoutTree,
        _viewport: Size,
    ) -> Result<(), Self::Error> {
        self.0.clear();
        Ok(())
    }

    fn rebuild_scene_from_applier(
        &mut self,
        _applier: &mut cranpose_core::MemoryApplier,
        _root: cranpose_core::NodeId,
        _viewport: Size,
    ) -> Result<(), Self::Error> {
        self.0.clear();
        Ok(())
    }
}

#[derive(Clone, Default)]
struct Compositions(Rc<RefCell<HashMap<usize, usize>>>);

impl PartialEq for Compositions {
    fn eq(&self, other: &Self) -> bool {
        Rc::ptr_eq(&self.0, &other.0)
    }
}

#[derive(PartialEq)]
struct Handles {
    list: LazyListState,
    frame: MutableState<u32>,
}

#[composable]
fn WidthAnimatedList(handles: Rc<RefCell<Option<Handles>>>, compositions: Compositions) {
    let list = rememberLazyListState();
    let frame = rememberMutableStateOf(|| 0u32);
    *handles.borrow_mut() = Some(Handles { list, frame });
    let width = 300.0 + (frame.get() % 3) as f32 * 10.0;
    Box(
        Modifier::empty().size_points(width, 120.0),
        BoxSpec::default(),
        move || Rows(list, compositions.clone()),
    );
}

#[composable]
fn Rows(list: LazyListState, compositions: Compositions) {
    LazyColumn(
        Modifier::empty().fill_max_size(),
        list,
        LazyColumnSpec::new(),
        move |scope| {
            scope.items(100, move |index| {
                let entered = compositions.clone();
                cranpose_core::DisposableEffect((), move |scope| {
                    *entered.0.borrow_mut().entry(index).or_insert(0) += 1;
                    scope.on_dispose(|| {})
                });
                Text(
                    format!("Row {index}"),
                    Modifier::empty().size_points(280.0, 40.0),
                    TextStyle::default(),
                );
            });
        },
    );
}

fn labels(node: &SemanticsNode, out: &mut Vec<String>) {
    if let Some(label) = node.accessibility_label() {
        out.push(label.into_owned());
    }
    for child in &node.children {
        labels(child, out);
    }
}

#[test]
fn items_beyond_a_scrolling_viewport_compose_once_and_stay_unplaced() {
    let handles = Rc::new(RefCell::new(None));
    let compositions = Compositions::default();
    let mut shell = AppShell::new_with_size(
        EmptyRenderer::default(),
        location_key(file!(), line!(), column!()),
        {
            let handles = Rc::clone(&handles);
            let compositions = compositions.clone();
            move || WidthAnimatedList(Rc::clone(&handles), compositions.clone())
        },
        (320, 120),
        (320.0, 120.0),
    );
    shell.update();
    let (list, frame) = {
        let handles = handles.borrow();
        let handles = handles.as_ref().expect("the list is composed");
        (handles.list, handles.frame)
    };

    for index in 1..=60u32 {
        frame.set(index);
        let _ = list.dispatch_scroll_delta(-7.0);
        shell.update();
        let passes = Cell::new(0);
        shell.run_idle_prefetch(None, |_| {
            passes.set(passes.get() + 1);
            passes.get() > 2
        });
    }

    let recomposed: Vec<_> = compositions
        .0
        .borrow()
        .iter()
        .filter(|(_, count)| **count > 1)
        .map(|(index, count)| (*index, *count))
        .collect();
    assert!(
        recomposed.is_empty(),
        "an item composed ahead of the scroll stays composed while new constraints \
         arrive every frame: (index, compositions) {recomposed:?}"
    );

    assert_only_visible_rows_are_placed(&mut shell, list, &compositions);

    for index in 61..=80u32 {
        frame.set(index);
        let _ = list.dispatch_scroll_delta(7.0);
        shell.update();
    }
    assert_only_visible_rows_are_placed(&mut shell, list, &compositions);
}

fn assert_only_visible_rows_are_placed(
    shell: &mut AppShell<EmptyRenderer>,
    list: LazyListState,
    compositions: &Compositions,
) {
    let visible: Vec<_> = list
        .layout_info()
        .visible_items_info
        .iter()
        .map(|item| item.index)
        .collect();
    let first_beyond = visible.last().expect("visible rows") + 1;
    assert!(
        compositions.0.borrow().contains_key(&first_beyond),
        "the row after the viewport is composed ahead of the scroll"
    );
    shell.set_semantics_enabled(true);
    let mut shown = Vec::new();
    labels(
        shell.semantics_tree().expect("semantics").root(),
        &mut shown,
    );
    let rows: Vec<_> = shown
        .into_iter()
        .filter(|label| label.starts_with("Row "))
        .collect();
    let expected: Vec<_> = visible.iter().map(|index| format!("Row {index}")).collect();
    assert_eq!(rows, expected, "only the rows in the viewport are placed");
}