Skip to main content

eui_core/
bench.rs

1//! A synthetic ~n-node surface for the timing measurement (the VAPP-4 bench
2//! tree in the core vocabulary): cards in a grid with headings, text, pills
3//! and buttons, so a pass exercises flex, grid, wrapping and controls.
4
5use serde_json::json;
6
7use crate::types::NestedNode;
8
9/// A nested-form surface with about `n` layout nodes (never fewer).
10pub fn bench_tree(n: usize) -> NestedNode {
11    let cards = n.div_ceil(7).max(1);
12    let mut children = Vec::with_capacity(cards + 1);
13    children.push(json!({"id": "heading", "component": "Text", "props": {"text": "Bench", "variant": "title"}}));
14    for i in 0..cards {
15        children.push(json!({
16            "id": format!("card-{i}"), "component": "Box",
17            "style": {"display": "flex", "flexDirection": "column", "gap": 8, "padding": 12, "borderRadius": 8, "backgroundColor": "$color.card"},
18            "children": [
19                {"id": format!("card-{i}-title"), "component": "Text", "props": {"text": format!("Card {i} · a title that may wrap"), "variant": "label", "lines": 1}},
20                {"id": format!("card-{i}-body"), "component": "Text", "props": {"text": "Body copy with enough words to wrap at a phone width and exercise the second measure phase.", "variant": "muted"}},
21                {"id": format!("card-{i}-row"), "component": "Box", "style": {"display": "flex", "flexDirection": "row", "gap": 8, "alignItems": "center", "flexWrap": "wrap"},
22                 "children": [
23                    {"id": format!("card-{i}-pill-a"), "component": "Button", "props": {"label": "Alpha", "variant": "outline", "size": "sm"}},
24                    {"id": format!("card-{i}-pill-b"), "component": "Button", "props": {"label": "Beta", "variant": "outline", "size": "sm"}},
25                    {"id": format!("card-{i}-go"), "component": "Button", "props": {"label": "Open", "variant": "default"}, "style": {"marginLeft": "auto"}}
26                 ]}
27            ]
28        }));
29    }
30    let tree = json!({
31        "id": "root", "component": "Box",
32        "style": {"display": "grid", "gridTemplateColumns": "1fr", "gap": 12, "padding": 16, "width": "100%", "@media (min-width: 600px)": {"gridTemplateColumns": "repeat(3, minmax(0, 1fr))"}},
33        "children": children
34    });
35    serde_json::from_value::<NestedNode>(tree).expect("bench tree")
36}
37
38/// A surface around a WINDOWED list of `rows` rows (each a pressable row
39/// with an avatar-sized icon, a title, a muted line and a trailing badge,
40/// a `:hover` block): the scroll + hover-churn bench.
41pub fn bench_list_tree(rows: usize) -> NestedNode {
42    let items: Vec<serde_json::Value> = (0..rows)
43        .map(|i| {
44            json!({
45                "id": format!("row-{i}"), "component": "Box", "props": {"pressable": true},
46                "style": {"display": "flex", "flexDirection": "row", "alignItems": "center", "gap": 8, "paddingHorizontal": 12, "paddingVertical": 8, ":hover": {"backgroundColor": "$color.accent"}},
47                "children": [
48                    {"id": format!("row-{i}-icon"), "component": "Icon", "props": {"name": "ui-check", "size": "sm"}},
49                    {"id": format!("row-{i}-text"), "component": "Box", "style": {"display": "flex", "flexDirection": "column", "flexGrow": 1, "minWidth": 0},
50                     "children": [
51                        {"id": format!("row-{i}-title"), "component": "Text", "props": {"text": format!("Issue {i}: a title long enough to wrap on a phone"), "variant": "label", "lines": 1}},
52                        {"id": format!("row-{i}-sub"), "component": "Text", "props": {"text": "Updated a minute ago", "variant": "muted"}}
53                     ]},
54                    {"id": format!("row-{i}-badge"), "component": "Badge", "props": {"text": "Todo", "variant": "secondary"}}
55                ]
56            })
57        })
58        .collect();
59    let tree = json!({
60        "id": "root", "component": "Box", "style": {"display": "flex", "flexDirection": "column", "gap": 12, "padding": 16, "width": "100%"},
61        "children": [
62            {"id": "title", "component": "Text", "props": {"text": "Inbox", "variant": "title"}},
63            {"id": "filters", "component": "Box", "style": {"display": "flex", "flexDirection": "row", "gap": 8},
64             "children": [
65                {"id": "f-all", "component": "Button", "props": {"label": "All", "variant": "secondary", "size": "sm"}},
66                {"id": "f-mine", "component": "Button", "props": {"label": "Mine", "variant": "ghost", "size": "sm"}}
67             ]},
68            {"id": "list", "component": "List", "props": {"divided": true}, "style": {"height": 640}, "children": items}
69        ]
70    });
71    serde_json::from_value::<NestedNode>(tree).expect("bench list tree")
72}
73
74/// The same as JSON text (for the FFI).
75pub fn bench_tree_json(n: usize) -> String {
76    serde_json::to_string(&serde_json::to_value(bench_tree(n)).expect("json")).expect("json")
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use crate::measure::FixedMeasure;
83    use crate::surface::{Surface, SurfaceOptions};
84
85    #[test]
86    fn two_hundred_nodes_lay_out_in_three_upcalls_and_well_under_two_milliseconds() {
87        let mut surface = Surface::new("bench", SurfaceOptions::default());
88        let outcome = surface.set_nested(bench_tree(200));
89        assert!(outcome.issues.is_empty(), "{:?}", outcome.issues);
90        surface.set_viewport(390.0, 0.0, None);
91        let mut measure = FixedMeasure { sizes: Default::default(), wrap: true };
92        let first = surface.layout(&mut measure);
93        assert!(surface.node_count() >= 200, "{} nodes", surface.node_count());
94        assert!(first.upcalls <= 3, "{} upcalls", first.upcalls);
95        // Warm passes at a new width: the memo answers, no upcall for widths
96        // already seen; the engine's own time is what the phone pays.
97        surface.set_viewport(900.0, 0.0, None);
98        let wide = surface.layout(&mut measure);
99        assert!(wide.upcalls <= 2);
100        let mut best = u64::MAX;
101        for i in 0..20 {
102            surface.set_viewport(if i % 2 == 0 { 390.0 } else { 900.0 }, 0.0, None);
103            let out = surface.layout(&mut measure);
104            best = best.min(out.layout_ns);
105        }
106        // Debug builds are ~10× slower than release; the release number is
107        // recorded in the PR. Keep a loose guard so a regression shows.
108        assert!(best < 50_000_000, "best pass {best} ns");
109        eprintln!("bench 200 nodes: {} layout nodes, best pass {} µs (debug)", surface.node_count(), best / 1000);
110    }
111
112    #[test]
113    fn a_2000_row_list_scrolls_and_churns_hover_in_bounded_work() {
114        let mut surface = Surface::new("bench-list", SurfaceOptions::default());
115        assert!(surface.set_nested(bench_list_tree(2_000)).issues.is_empty());
116        surface.set_viewport(390.0, 844.0, None);
117        let mut measure = FixedMeasure { sizes: Default::default(), wrap: true };
118        for _ in 0..3 {
119            surface.layout(&mut measure);
120        }
121        let live = surface.node_count();
122        assert!(live < 600, "{live} live nodes for 2,000 rows");
123        let mut worst_restyle = 0;
124        for step in 0..40u32 {
125            surface.scroll("list", step as f32 * 300.0);
126            let out = surface.layout(&mut measure);
127            assert!(out.upcalls <= 3);
128            worst_restyle = worst_restyle.max(out.restyled);
129            let row = format!("row-{}", step * 5 + 3);
130            if surface.index_of(&row).is_some() {
131                surface.set_states(&row, vec!["hover".into()]);
132                let hover = surface.layout(&mut measure);
133                assert!(hover.restyled <= 2, "a hover restyled {}", hover.restyled);
134                assert!(!hover.rebuilt);
135                surface.set_states(&row, vec![]);
136            }
137        }
138        assert!(worst_restyle < 200, "a scroll step restyled {worst_restyle}");
139    }
140}