cranpose-app-shell 0.22.0

App lifecycle, frame cadence and input dispatch for Cranpose
Documentation
use super::*;

#[cfg(any(test, feature = "test-support"))]
type SubcomposeScopeIds = Vec<(NodeId, Vec<(u64, Vec<usize>)>)>;
#[cfg(any(test, feature = "test-support"))]
type SlotGroupEntry = (usize, cranpose_core::Key, Option<usize>, usize);

#[expect(clippy::multiple_inherent_impl, reason = "one file per shell concern")]
impl<R> AppShell<R>
where
    R: Renderer,
    R::Error: Debug,
{
    /// Returns a text report of the first surface: its layout tree, its headless render scene and a
    /// summary.
    pub fn debug_info_report(&mut self) -> String {
        debug_info_report(&mut self.app, &mut self.surfaces[0])
    }

    /// Logs the report from `debug_info_report` at info level under `cranpose::debug::screen` and
    /// returns it.
    pub fn log_debug_info(&mut self) -> String {
        let report = self.debug_info_report();
        log::info!(target: "cranpose::debug::screen", "\n{report}");
        report
    }

    /// Sets how long a frame composes before pausable content waits for a
    /// later frame, as [`cranpose_core::Composition::set_pausable_budget`]
    /// does: tests that compare frames give `None`, so content that a change
    /// brings composes in its frame whatever the machine's speed.
    #[cfg(any(test, feature = "test-support"))]
    pub fn set_pausable_budget(&mut self, budget: Option<std::time::Duration>) {
        self.app.composition.set_pausable_budget(budget);
    }

    /// Get the current layout tree (for robot/testing)
    pub fn layout_tree(&mut self) -> Option<&LayoutTree> {
        let _app_context = self.app.app_context.enter_scope();
        self.surfaces[0].layout_tree_in_context(&mut self.app)
    }

    #[doc(hidden)]
    pub fn with_layout_tree<T>(&mut self, block: impl FnOnce(Option<&LayoutTree>) -> T) -> T {
        let _app_context = self.app.app_context.enter_scope();
        let layout_tree = self.surfaces[0].layout_tree_in_context(&mut self.app);
        block(layout_tree)
    }

    /// Whether a semantics snapshot could contain anything at all. Reading the
    /// flag costs nothing, unlike collecting the layout-bounds map only to find
    /// semantics tracking disabled.
    pub fn semantics_active(&self) -> bool {
        self.app.semantics_enabled
    }

    /// Monotonic revision of the accessibility-relevant state. It moves when a
    /// layout pass invalidated the cached snapshots, when semantics tracking is
    /// toggled, when any node marked its semantics dirty since the last look,
    /// and when a scene update moved a layer, which moves where the nodes in it
    /// show. A bridge that stored the revision it last projected can skip the
    /// whole snapshot-and-compare while this still reads the same — which on a
    /// frame that only draws is every frame.
    pub fn semantics_snapshot_revision(&mut self) -> u64 {
        if self.app.semantics_enabled
            || self
                .surfaces
                .iter()
                .any(|surface| surface.inspector.state.open)
        {
            let semantics_dirty = {
                let _app_context = self.app.app_context.enter_scope();
                self.app.flush_semantics_invalidations();
                self.app.composition.root().is_some_and(|root| {
                    let mut applier = self.app.composition.applier_mut();
                    cranpose_ui::tree_needs_semantics(&mut *applier, root).unwrap_or(true)
                })
            };
            let mut layers_moved = false;
            for surface in &mut self.surfaces {
                layers_moved |= surface.layers_moved_since_look();
            }
            if semantics_dirty || layers_moved {
                self.app.semantics_snapshot_revision =
                    self.app.semantics_snapshot_revision.wrapping_add(1);
            }
        }
        self.app.semantics_snapshot_revision
    }

    /// Get the current semantics tree (for robot/testing)
    pub fn semantics_tree(&mut self) -> Option<&SemanticsTree> {
        let _app_context = self.app.app_context.enter_scope();
        self.surfaces[0].semantics_tree_in_context(&mut self.app)
    }

    /// Returns the width and height of the first surface's root layout box in logical pixels, or
    /// `None` before a layout exists.
    pub fn root_layout_size(&mut self) -> Option<(f32, f32)> {
        self.layout_tree().map(|tree| {
            let root = tree.root();
            (root.rect.width, root.rect.height)
        })
    }

    /// Returns `(x, y, width, height)` of the layout box of `target` in logical pixels, or `None`
    /// when the node is not in the layout tree.
    pub fn node_layout_bounds(&mut self, target: NodeId) -> Option<(f32, f32, f32, f32)> {
        self.layout_tree()
            .and_then(|tree| find_layout_box(tree.root(), target))
            .map(layout_box_bounds)
    }

    #[cfg(any(test, feature = "test-support"))]
    #[doc(hidden)]
    pub fn debug_runtime_leak_stats(&mut self) -> RuntimeLeakDebugStats {
        let runtime = self.app.composition.runtime_handle();
        let (applier_stats, live_node_heap_bytes, recycled_node_heap_bytes) = {
            let applier = self.app.composition.applier_mut();
            (
                applier.debug_stats(),
                applier.debug_live_node_heap_bytes(),
                applier.debug_recycled_node_heap_bytes(),
            )
        };
        RuntimeLeakDebugStats {
            applier_stats,
            live_node_heap_bytes,
            recycled_node_heap_bytes,
            slot_table_heap_bytes: self.app.composition.slot_table_heap_bytes(),
            pass_stats: self.app.composition.debug_last_pass_stats(),
            slot_stats: self.app.composition.debug_slot_table_stats(),
            runtime_stats: runtime.debug_stats(),
            state_arena_stats: runtime.state_arena_debug_stats(),
            recompose_scope_stats: debug_recompose_scope_registry_stats(),
            snapshot_v2_stats: debug_snapshot_v2_stats(),
            snapshot_pinning_stats: debug_snapshot_pinning_stats(),
        }
    }

    #[cfg(any(test, feature = "test-support"))]
    #[doc(hidden)]
    pub fn debug_slot_table_groups(&self) -> Vec<SlotGroupEntry> {
        self.app.composition.debug_dump_slot_table_groups()
    }

    #[cfg(any(test, feature = "test-support"))]
    #[doc(hidden)]
    pub fn debug_slot_entries(&self) -> Vec<cranpose_core::SlotDebugEntry> {
        self.app.composition.debug_dump_slot_entries()
    }

    #[cfg(any(test, feature = "test-support"))]
    #[doc(hidden)]
    pub fn runtime_handle(&self) -> cranpose_core::RuntimeHandle {
        self.app.composition.runtime_handle()
    }

    #[cfg(any(test, feature = "test-support"))]
    #[doc(hidden)]
    pub fn debug_live_subcompose_scope_ids(&mut self) -> SubcomposeScopeIds {
        fn collect_node_ids(layout: &LayoutBox, out: &mut Vec<NodeId>) {
            out.push(layout.node_id);
            for child in &layout.children {
                collect_node_ids(child, out);
            }
        }

        let mut node_ids = Vec::new();
        if let Some(tree) = self.layout_tree() {
            collect_node_ids(tree.root(), &mut node_ids);
        }

        let mut applier = self.app.composition.applier_mut();
        let mut result = Vec::new();
        for node_id in node_ids {
            if let Ok(scope_ids) = applier.with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
                node.debug_scope_ids_by_slot()
            }) {
                result.push((node_id, scope_ids));
            }
        }
        result
    }

    #[cfg(any(test, feature = "test-support"))]
    #[doc(hidden)]
    pub fn debug_subcompose_slot_table(
        &mut self,
        node_id: NodeId,
        slot_id: u64,
    ) -> Option<Vec<cranpose_core::SlotDebugEntry>> {
        let mut applier = self.app.composition.applier_mut();
        applier
            .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
                node.debug_slot_table_for_slot(SlotId::new(slot_id))
            })
            .ok()
            .flatten()
    }

    #[cfg(any(test, feature = "test-support"))]
    #[doc(hidden)]
    pub fn debug_subcompose_slot_groups(
        &mut self,
        node_id: NodeId,
        slot_id: u64,
    ) -> Option<Vec<SlotGroupEntry>> {
        let mut applier = self.app.composition.applier_mut();
        applier
            .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
                node.debug_slot_table_groups_for_slot(SlotId::new(slot_id))
            })
            .ok()
            .flatten()
    }
}

fn find_layout_box(layout_box: &LayoutBox, target: NodeId) -> Option<&LayoutBox> {
    if layout_box.node_id == target {
        return Some(layout_box);
    }

    layout_box
        .children
        .iter()
        .find_map(|child| find_layout_box(child, target))
}

fn layout_box_bounds(layout_box: &LayoutBox) -> (f32, f32, f32, f32) {
    (
        layout_box.rect.x,
        layout_box.rect.y,
        layout_box.rect.width,
        layout_box.rect.height,
    )
}

fn debug_info_report<R: Renderer>(app: &mut ShellApp, surface: &mut RootSurface<R>) -> String {
    let _app_context = app.app_context.enter_scope();
    let mut report = String::new();
    writeln!(report, "=== DEBUG: CURRENT SCREEN STATE ===").ok();
    if let Some(layout_tree) = surface.layout_tree_in_context(app) {
        let renderer = HeadlessRenderer::new();
        let render_scene = renderer.render(layout_tree);
        writeln!(report, "{}", format_layout_tree(layout_tree)).ok();
        writeln!(report, "{}", format_render_scene(&render_scene)).ok();
        writeln!(
            report,
            "{}",
            format_screen_summary(layout_tree, &render_scene)
        )
        .ok();
    } else {
        writeln!(report, "No layout available").ok();
    }
    report
}