cranpose-app-shell 0.9.21

App lifecycle, frame cadence and input dispatch for Cranpose
Documentation
use std::{
    alloc::System, cell::RefCell, collections::hash_map::DefaultHasher, hash::Hasher, rc::Rc,
    time::Instant,
};

use cranpose_app_shell::AppShell;
use cranpose_core::{MemoryApplier, NodeId, location_key};
use cranpose_render_common::{
    Renderer, SceneUpdates,
    graph::{LayerNode, RenderNode},
    graph_scene::Scene,
    scene_builder::{
        build_graph_from_applier, build_graph_from_layout_tree, update_graph_from_applier,
    },
};
use cranpose_ui::{
    Box, BoxSpec, Color, LayoutTree, LazyColumn, LazyColumnSpec, LazyItems, LazyListScope,
    LazyListState, Modifier, Size, composable, rememberLazyListState,
};
use stats_alloc::{INSTRUMENTED_SYSTEM, Region, StatsAlloc};

#[global_allocator]
static GLOBAL: &StatsAlloc<System> = &INSTRUMENTED_SYSTEM;

const WARMUP: usize = 32;
const FRAMES: usize = 512;
const ROW_HEIGHT: f32 = 36.0;

#[derive(Default)]
struct GraphRenderer {
    scene: Scene,
    rebuilds: usize,
    updates: usize,
    dirty_ids: usize,
}

impl Renderer for GraphRenderer {
    type Scene = Scene;
    type Error = ();

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

    fn scene_mut(&mut self) -> &mut Scene {
        &mut self.scene
    }

    fn rebuild_scene(&mut self, tree: &LayoutTree, _: Size) -> Result<(), ()> {
        self.rebuilds += 1;
        self.scene
            .replace_graph(build_graph_from_layout_tree(tree.root(), 1.0));
        Ok(())
    }

    fn rebuild_scene_from_applier(
        &mut self,
        applier: &mut MemoryApplier,
        root: NodeId,
        _: Size,
    ) -> Result<(), ()> {
        self.rebuilds += 1;
        let graph = build_graph_from_applier(applier, root, 1.0).ok_or(())?;
        self.scene.replace_graph(graph);
        Ok(())
    }

    fn update_scene_from_applier(
        &mut self,
        applier: &mut MemoryApplier,
        root: NodeId,
        viewport: Size,
        updates: SceneUpdates<'_>,
    ) -> Result<(), ()> {
        self.updates += 1;
        self.dirty_ids += updates.content.len() + updates.layers.len();
        let updated = self
            .scene
            .graph
            .as_mut()
            .is_some_and(|graph| update_graph_from_applier(applier, graph, updates, 1.0));
        if !updated {
            self.rebuild_scene_from_applier(applier, root, viewport)?;
        }
        Ok(())
    }
}

#[composable]
fn ScrollContent(capture: Rc<RefCell<Option<LazyListState>>>, roots: usize) {
    let state = rememberLazyListState();
    *capture.borrow_mut() = Some(state);
    LazyColumn(
        Modifier::empty().fill_max_size(),
        state,
        LazyColumnSpec::default(),
        move |scope| {
            scope.items(
                LazyItems::new(512).key(|index| index as u64),
                move |index| {
                    for _ in 0..roots {
                        Box(
                            Modifier::empty()
                                .fill_max_width()
                                .height(ROW_HEIGHT / roots as f32)
                                .background(Color((index % 7) as f32 / 7.0, 0.4, 0.8, 1.0)),
                            BoxSpec::default(),
                            || {},
                        );
                    }
                },
            );
        },
    );
}

fn hash_layer(layer: &LayerNode, hash: &mut DefaultHasher) {
    hash.write(
        format!(
            "{:?}{:?}{:?}{:?}{:?}{:?}",
            layer.local_bounds,
            layer.transform_to_parent,
            layer.content_offset,
            layer.translated_content_offset,
            layer.origin_in_parent,
            layer.clip_to_bounds,
        )
        .as_bytes(),
    );
    for child in &layer.children {
        match child {
            RenderNode::Layer(child) => hash_layer(child, hash),
            RenderNode::DrawRun(run) => hash.write_u64(run.recording.fingerprint()),
            RenderNode::Primitive(primitive) => hash.write(format!("{primitive:?}").as_bytes()),
        }
    }
}

fn fingerprint(shell: &AppShell<GraphRenderer>) -> u64 {
    let mut hash = DefaultHasher::new();
    hash_layer(
        &shell.scene().graph.as_ref().expect("scene graph").root,
        &mut hash,
    );
    hash.finish()
}

fn run_case(roots: usize, phase: &str, magnitude: f32) {
    let capture = Rc::new(RefCell::new(None));
    let content_capture = Rc::clone(&capture);
    let mut shell = AppShell::new(
        GraphRenderer::default(),
        location_key(file!(), line!(), column!()),
        move || ScrollContent(Rc::clone(&content_capture), roots),
    );
    shell.set_buffer_size(360, 360);
    shell.set_viewport(360.0, 360.0);
    shell.update();
    let state = (*capture.borrow()).expect("lazy list state");
    let context = Rc::clone(shell.app_context());
    let initial = fingerprint(&shell);
    let frame = |tick: usize, shell: &mut AppShell<GraphRenderer>| {
        let delta = if tick.is_multiple_of(2) {
            -magnitude
        } else {
            magnitude
        };
        let consumed = context.enter(|| state.dispatch_scroll_delta(delta));
        assert!(consumed.abs() > 0.0, "each frame must move the list");
        shell.update();
    };
    frame(0, &mut shell);
    assert_ne!(initial, fingerprint(&shell), "scroll must change the graph");
    for tick in 1..WARMUP {
        frame(tick, &mut shell);
    }
    let renderer = shell.renderer();
    renderer.rebuilds = 0;
    renderer.updates = 0;
    renderer.dirty_ids = 0;
    let region = Region::new(GLOBAL);
    let started = Instant::now();
    for tick in WARMUP..WARMUP + FRAMES {
        frame(tick, &mut shell);
    }
    let elapsed = started.elapsed().as_nanos();
    let stats = region.change();
    let fingerprint = fingerprint(&shell);
    let renderer = shell.renderer();
    assert_eq!(renderer.rebuilds, 0, "scroll must use scoped scene updates");
    assert_eq!(renderer.updates, FRAMES, "every frame updates the scene");
    println!(
        "{{\"benchmark\":\"scroll_frame\",\"roots_per_item\":{roots},\"phase\":\"{phase}\",\"frames\":{FRAMES},\"allocations\":{},\"reallocations\":{},\"bytes_allocated\":{},\"ns_per_frame\":{},\"dirty_ids\":{},\"fingerprint\":{fingerprint}}}",
        stats.allocations,
        stats.reallocations,
        stats.bytes_allocated,
        elapsed / FRAMES as u128,
        renderer.dirty_ids,
    );
}

fn main() {
    for roots in [1, 9] {
        run_case(roots, "stable_retained", 0.125);
        run_case(roots, "one_row_entering", ROW_HEIGHT);
    }
}