Skip to main content

falsegreen_ui_layout/
lib.rs

1//! Deterministic geometry observations for a normalized UI tree.
2
3use falsegreen_ui_core::{Rect, UiTree, Viewport};
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6use thiserror::Error;
7
8pub const LAYOUT_ENGINE_ID: &str = "falsegreen-ui-layout/0.1.0";
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "kebab-case")]
12pub enum Relation {
13    Above,
14    Below,
15    LeftOf,
16    RightOf,
17    ContainedBy,
18    Overlaps,
19}
20
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct LayoutObservation {
23    pub id: String,
24    pub bounds: Rect,
25    pub clip: Option<Rect>,
26    pub visible_fraction: f32,
27    pub clipped: bool,
28    pub viewport_contained: bool,
29    pub overlap_ids: Vec<String>,
30}
31
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct LayoutReport {
34    pub engine: String,
35    pub viewport: Viewport,
36    pub observations: Vec<LayoutObservation>,
37    pub unintended_horizontal_overflow: bool,
38}
39
40impl LayoutReport {
41    pub fn from_tree(tree: &UiTree) -> Result<Self, LayoutError> {
42        tree.validate().map_err(LayoutError::InvalidTree)?;
43        let viewport_bounds = tree.viewport.bounds();
44        let mut observations = Vec::with_capacity(tree.nodes.len());
45        for node in &tree.nodes {
46            let effective_clip = match node.clip {
47                Some(clip) => clip.intersection(viewport_bounds),
48                None => Some(viewport_bounds),
49            };
50            let fraction = node.bounds.visible_fraction(effective_clip);
51            let overlap_ids = tree
52                .nodes
53                .iter()
54                .filter(|other| {
55                    other.id != node.id
56                        && other.parent_id.as_deref() != Some(node.id.as_str())
57                        && node.parent_id.as_deref() != Some(other.id.as_str())
58                })
59                .filter(|other| node.bounds.intersects(other.bounds))
60                .map(|other| other.id.clone())
61                .collect();
62            observations.push(LayoutObservation {
63                id: node.id.clone(),
64                bounds: node.bounds,
65                clip: effective_clip,
66                visible_fraction: fraction,
67                clipped: fraction < 0.999,
68                viewport_contained: viewport_bounds.intersection(node.bounds) == Some(node.bounds),
69                overlap_ids,
70            });
71        }
72        let overflow = tree.nodes.iter().any(|node| {
73            node.state.visible
74                && (node.bounds.x < viewport_bounds.x
75                    || node.bounds.right() > viewport_bounds.right())
76        });
77        Ok(Self {
78            engine: LAYOUT_ENGINE_ID.into(),
79            viewport: tree.viewport,
80            observations,
81            unintended_horizontal_overflow: overflow,
82        })
83    }
84
85    pub fn for_node(&self, id: &str) -> Option<&LayoutObservation> {
86        self.observations
87            .iter()
88            .find(|observation| observation.id == id)
89    }
90
91    pub fn relation(&self, a: &str, b: &str) -> Option<Relation> {
92        let a = self.for_node(a)?.bounds;
93        let b = self.for_node(b)?.bounds;
94        if a.intersects(b) {
95            Some(Relation::Overlaps)
96        } else if a.bottom() <= b.y {
97            Some(Relation::Above)
98        } else if a.y >= b.bottom() {
99            Some(Relation::Below)
100        } else if a.right() <= b.x {
101            Some(Relation::LeftOf)
102        } else if a.x >= b.right() {
103            Some(Relation::RightOf)
104        } else {
105            None
106        }
107    }
108}
109
110/// A small deterministic column/row placer used by fixtures that do not provide final
111/// geometry. It is deliberately explicit rather than CSS-compatible: the V1 contract
112/// qualifies the exact implementation and does not imply arbitrary browser layout.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "kebab-case")]
115pub enum FlowDirection {
116    Column,
117    Row,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
121pub struct Insets {
122    pub top: u32,
123    pub right: u32,
124    pub bottom: u32,
125    pub left: u32,
126}
127
128impl Insets {
129    pub const fn all(value: u32) -> Self {
130        Self {
131            top: value,
132            right: value,
133            bottom: value,
134            left: value,
135        }
136    }
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
140pub struct LayoutStyle {
141    pub width: Option<u32>,
142    pub height: Option<u32>,
143    pub padding: Insets,
144    pub gap: u32,
145    pub direction: FlowDirection,
146    pub overflow_hidden: bool,
147}
148
149impl Default for LayoutStyle {
150    fn default() -> Self {
151        Self {
152            width: None,
153            height: None,
154            padding: Insets::all(0),
155            gap: 0,
156            direction: FlowDirection::Column,
157            overflow_hidden: false,
158        }
159    }
160}
161
162#[derive(Debug, Clone, Default)]
163pub struct LayoutStyles(pub BTreeMap<String, LayoutStyle>);
164
165impl LayoutStyles {
166    pub fn insert(&mut self, id: impl Into<String>, style: LayoutStyle) {
167        self.0.insert(id.into(), style);
168    }
169}
170
171pub fn apply_flow_layout(tree: &UiTree, styles: &LayoutStyles) -> Result<UiTree, LayoutError> {
172    tree.validate().map_err(LayoutError::InvalidTree)?;
173    let mut output = tree.clone();
174    let root_bounds = tree.viewport.bounds();
175    place_node(&mut output, styles, &tree.root_id, root_bounds, None)?;
176    output.refresh_digest();
177    Ok(output)
178}
179
180fn place_node(
181    tree: &mut UiTree,
182    styles: &LayoutStyles,
183    id: &str,
184    bounds: Rect,
185    inherited_clip: Option<Rect>,
186) -> Result<(), LayoutError> {
187    let style = styles.0.get(id).copied().unwrap_or_default();
188    let child_ids = tree
189        .node(id)
190        .ok_or_else(|| LayoutError::MissingNode(id.into()))?
191        .children
192        .clone();
193    let node = tree
194        .nodes
195        .iter_mut()
196        .find(|node| node.id == id)
197        .ok_or_else(|| LayoutError::MissingNode(id.into()))?;
198    node.bounds = bounds;
199    node.clip = inherited_clip;
200    let next_clip = if style.overflow_hidden {
201        Some(bounds)
202    } else {
203        inherited_clip
204    };
205    let inner_x = bounds.x + style.padding.left as f32;
206    let inner_y = bounds.y + style.padding.top as f32;
207    let inner_width = (bounds.width - (style.padding.left + style.padding.right) as f32).max(0.0);
208    let inner_height = (bounds.height - (style.padding.top + style.padding.bottom) as f32).max(0.0);
209    let mut cursor = 0.0_f32;
210    for child_id in child_ids {
211        let child_style = styles.0.get(&child_id).copied().unwrap_or_default();
212        let child_width = child_style
213            .width
214            .unwrap_or(if style.direction == FlowDirection::Row {
215                120
216            } else {
217                inner_width as u32
218            }) as f32;
219        let child_height = child_style.height.unwrap_or(24) as f32;
220        let child_bounds = match style.direction {
221            FlowDirection::Column => Rect::new(
222                inner_x,
223                inner_y + cursor,
224                child_width.min(inner_width),
225                child_height,
226            ),
227            FlowDirection::Row => Rect::new(
228                inner_x + cursor,
229                inner_y,
230                child_width,
231                child_height.min(inner_height),
232            ),
233        };
234        cursor += if style.direction == FlowDirection::Column {
235            child_height
236        } else {
237            child_width
238        };
239        cursor += style.gap as f32;
240        place_node(tree, styles, &child_id, child_bounds, next_clip)?;
241    }
242    Ok(())
243}
244
245#[derive(Debug, Error)]
246pub enum LayoutError {
247    #[error("normalized tree is invalid: {0}")]
248    InvalidTree(falsegreen_ui_core::ValidationError),
249    #[error("layout refers to missing node {0}")]
250    MissingNode(String),
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use falsegreen_ui_core::{Role, UiNode};
257
258    fn tree() -> UiTree {
259        let mut root = UiNode::new("root", Role::Application, Rect::new(0.0, 0.0, 100.0, 100.0));
260        root.children = vec!["a".into(), "b".into()];
261        let a = UiNode::new("a", Role::Button, Rect::new(0.0, 0.0, 50.0, 20.0)).parent("root");
262        let b = UiNode::new("b", Role::Button, Rect::new(0.0, 20.0, 50.0, 20.0)).parent("root");
263        UiTree::new(Viewport::new(100, 100), "root", vec![root, a, b])
264    }
265
266    #[test]
267    fn layout_report_is_repeatable_and_reports_relations() {
268        let first = LayoutReport::from_tree(&tree()).unwrap();
269        let second = LayoutReport::from_tree(&tree()).unwrap();
270        assert_eq!(first, second);
271        assert_eq!(first.relation("a", "b"), Some(Relation::Above));
272    }
273
274    #[test]
275    fn flow_layout_is_deterministic() {
276        let mut styles = LayoutStyles::default();
277        styles.insert(
278            "root",
279            LayoutStyle {
280                gap: 4,
281                padding: Insets::all(8),
282                ..Default::default()
283            },
284        );
285        let first = apply_flow_layout(&tree(), &styles).unwrap();
286        let second = apply_flow_layout(&tree(), &styles).unwrap();
287        assert_eq!(first.tree_digest, second.tree_digest);
288        assert_eq!(first.node("b").unwrap().bounds.y, 36.0);
289    }
290}