fission-layout 0.3.0

Layout engine primitives for Fission widget trees
Documentation
use fission_ir::op::{Color, TextRun, TextStyle};
use fission_ir::{FlexDirection, LayoutOp as IrLayoutOp, NodeId};
use fission_layout::{LayoutEngine, LayoutInputNode, LayoutSize, TextMeasurer};
use std::collections::HashSet;
use std::sync::{
    atomic::{AtomicUsize, Ordering},
    Arc,
};

#[derive(Clone)]
struct CountingMeasurer {
    calls: Arc<AtomicUsize>,
}

impl CountingMeasurer {
    fn new() -> Self {
        Self {
            calls: Arc::new(AtomicUsize::new(0)),
        }
    }

    fn call_count(&self) -> usize {
        self.calls.load(Ordering::SeqCst)
    }
}

impl TextMeasurer for CountingMeasurer {
    fn measure(&self, text: &str, _font_size: f32, available_width: Option<f32>) -> (f32, f32) {
        self.calls.fetch_add(1, Ordering::SeqCst);
        let width = text.len() as f32 * 10.0;
        let height = 20.0;
        if let Some(max_width) = available_width {
            if max_width > 0.0 && width > max_width {
                return (max_width, (width / max_width).ceil() * height);
            }
        }
        (width, height)
    }

    fn measure_rich_text(&self, runs: &[TextRun], available_width: Option<f32>) -> (f32, f32) {
        let text: String = runs.iter().map(|run| run.text.as_str()).collect();
        self.measure(&text, 16.0, available_width)
    }
}

fn flex_root(root_id: NodeId, children_ids: Vec<NodeId>) -> LayoutInputNode {
    LayoutInputNode {
        id: root_id,
        parent_id: None,
        op: IrLayoutOp::Flex {
            direction: FlexDirection::Column,
            wrap: fission_ir::op::FlexWrap::NoWrap,
            flex_grow: 0.0,
            flex_shrink: 1.0,
            padding: [0.0; 4],
            gap: Some(4.0),
            align_items: fission_ir::op::AlignItems::Start,
            justify_content: fission_ir::op::JustifyContent::Start,
        },
        children_ids,
        debug_name: "root".into(),
        width: Some(400.0),
        height: Some(300.0),
        flex_grow: 0.0,
        flex_shrink: 1.0,
        rich_text: None,
    }
}

fn text_node(id: NodeId, parent_id: NodeId, text: &str) -> LayoutInputNode {
    LayoutInputNode {
        id,
        parent_id: Some(parent_id),
        op: IrLayoutOp::Box {
            width: None,
            height: None,
            min_width: None,
            max_width: None,
            min_height: None,
            max_height: None,
            padding: [0.0; 4],
            flex_grow: 0.0,
            flex_shrink: 1.0,
            aspect_ratio: None,
        },
        children_ids: Vec::new(),
        debug_name: format!("text-{}", id.as_u128()),
        width: None,
        height: None,
        flex_grow: 0.0,
        flex_shrink: 1.0,
        rich_text: Some(vec![TextRun {
            text: text.to_string(),
            style: TextStyle {
                font_size: 16.0,
                color: Color::BLACK,
                underline: false,
                font_family: None,
                locale: None,
                font_weight: 400,
                font_style: fission_ir::op::FontStyle::Normal,
                line_height: None,
                letter_spacing: 0.0,
                background_color: None,
            },
        }]),
    }
}

#[test]
fn incremental_layout_reuses_clean_sibling_subtrees() {
    let root_id = NodeId::from_u128(1);
    let first_id = NodeId::from_u128(2);
    let second_id = NodeId::from_u128(3);
    let nodes_v1 = vec![
        flex_root(root_id, vec![first_id, second_id]),
        text_node(first_id, root_id, "alpha"),
        text_node(second_id, root_id, "beta"),
    ];

    let measurer = CountingMeasurer::new();
    let mut engine = LayoutEngine::new().with_measurer(Arc::new(measurer.clone()));
    let first = engine
        .compute_layout(&nodes_v1, root_id, LayoutSize::new(400.0, 300.0), &|_| 0.0)
        .expect("initial layout");
    let initial_calls = measurer.call_count();

    let full_measurer = CountingMeasurer::new();
    let mut full_engine = LayoutEngine::new().with_measurer(Arc::new(full_measurer.clone()));
    let nodes_v2 = vec![
        flex_root(root_id, vec![first_id, second_id]),
        text_node(first_id, root_id, "alpha"),
        text_node(second_id, root_id, "beta beta"),
    ];
    full_engine
        .compute_layout(&nodes_v2, root_id, LayoutSize::new(400.0, 300.0), &|_| 0.0)
        .expect("full recompute layout");
    let full_recompute_calls = full_measurer.call_count();

    let dirty = HashSet::from([second_id]);
    let second = engine
        .compute_layout_incremental(
            &nodes_v2,
            root_id,
            LayoutSize::new(400.0, 300.0),
            &|_| 0.0,
            &first,
            &dirty,
        )
        .expect("incremental layout");

    let incremental_calls = measurer.call_count();
    assert!(incremental_calls > initial_calls);
    assert!(
        incremental_calls - initial_calls < full_recompute_calls,
        "incremental pass should reuse the clean sibling subtree"
    );
    assert_eq!(
        second.get_node_geometry(first_id).unwrap().content_size,
        first.get_node_geometry(first_id).unwrap().content_size,
        "clean sibling subtree should be reused"
    );
    assert!(
        second
            .get_node_geometry(second_id)
            .unwrap()
            .content_size
            .width
            > first
                .get_node_geometry(second_id)
                .unwrap()
                .content_size
                .width
    );
}