falsegreen-ui-layout 0.1.0

Deterministic geometry observations for FalseGreen UI evidence
Documentation
//! Deterministic geometry observations for a normalized UI tree.

use falsegreen_ui_core::{Rect, UiTree, Viewport};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use thiserror::Error;

pub const LAYOUT_ENGINE_ID: &str = "falsegreen-ui-layout/0.1.0";

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Relation {
    Above,
    Below,
    LeftOf,
    RightOf,
    ContainedBy,
    Overlaps,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LayoutObservation {
    pub id: String,
    pub bounds: Rect,
    pub clip: Option<Rect>,
    pub visible_fraction: f32,
    pub clipped: bool,
    pub viewport_contained: bool,
    pub overlap_ids: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LayoutReport {
    pub engine: String,
    pub viewport: Viewport,
    pub observations: Vec<LayoutObservation>,
    pub unintended_horizontal_overflow: bool,
}

impl LayoutReport {
    pub fn from_tree(tree: &UiTree) -> Result<Self, LayoutError> {
        tree.validate().map_err(LayoutError::InvalidTree)?;
        let viewport_bounds = tree.viewport.bounds();
        let mut observations = Vec::with_capacity(tree.nodes.len());
        for node in &tree.nodes {
            let effective_clip = match node.clip {
                Some(clip) => clip.intersection(viewport_bounds),
                None => Some(viewport_bounds),
            };
            let fraction = node.bounds.visible_fraction(effective_clip);
            let overlap_ids = tree
                .nodes
                .iter()
                .filter(|other| {
                    other.id != node.id
                        && other.parent_id.as_deref() != Some(node.id.as_str())
                        && node.parent_id.as_deref() != Some(other.id.as_str())
                })
                .filter(|other| node.bounds.intersects(other.bounds))
                .map(|other| other.id.clone())
                .collect();
            observations.push(LayoutObservation {
                id: node.id.clone(),
                bounds: node.bounds,
                clip: effective_clip,
                visible_fraction: fraction,
                clipped: fraction < 0.999,
                viewport_contained: viewport_bounds.intersection(node.bounds) == Some(node.bounds),
                overlap_ids,
            });
        }
        let overflow = tree.nodes.iter().any(|node| {
            node.state.visible
                && (node.bounds.x < viewport_bounds.x
                    || node.bounds.right() > viewport_bounds.right())
        });
        Ok(Self {
            engine: LAYOUT_ENGINE_ID.into(),
            viewport: tree.viewport,
            observations,
            unintended_horizontal_overflow: overflow,
        })
    }

    pub fn for_node(&self, id: &str) -> Option<&LayoutObservation> {
        self.observations
            .iter()
            .find(|observation| observation.id == id)
    }

    pub fn relation(&self, a: &str, b: &str) -> Option<Relation> {
        let a = self.for_node(a)?.bounds;
        let b = self.for_node(b)?.bounds;
        if a.intersects(b) {
            Some(Relation::Overlaps)
        } else if a.bottom() <= b.y {
            Some(Relation::Above)
        } else if a.y >= b.bottom() {
            Some(Relation::Below)
        } else if a.right() <= b.x {
            Some(Relation::LeftOf)
        } else if a.x >= b.right() {
            Some(Relation::RightOf)
        } else {
            None
        }
    }
}

/// A small deterministic column/row placer used by fixtures that do not provide final
/// geometry. It is deliberately explicit rather than CSS-compatible: the V1 contract
/// qualifies the exact implementation and does not imply arbitrary browser layout.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FlowDirection {
    Column,
    Row,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Insets {
    pub top: u32,
    pub right: u32,
    pub bottom: u32,
    pub left: u32,
}

impl Insets {
    pub const fn all(value: u32) -> Self {
        Self {
            top: value,
            right: value,
            bottom: value,
            left: value,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct LayoutStyle {
    pub width: Option<u32>,
    pub height: Option<u32>,
    pub padding: Insets,
    pub gap: u32,
    pub direction: FlowDirection,
    pub overflow_hidden: bool,
}

impl Default for LayoutStyle {
    fn default() -> Self {
        Self {
            width: None,
            height: None,
            padding: Insets::all(0),
            gap: 0,
            direction: FlowDirection::Column,
            overflow_hidden: false,
        }
    }
}

#[derive(Debug, Clone, Default)]
pub struct LayoutStyles(pub BTreeMap<String, LayoutStyle>);

impl LayoutStyles {
    pub fn insert(&mut self, id: impl Into<String>, style: LayoutStyle) {
        self.0.insert(id.into(), style);
    }
}

pub fn apply_flow_layout(tree: &UiTree, styles: &LayoutStyles) -> Result<UiTree, LayoutError> {
    tree.validate().map_err(LayoutError::InvalidTree)?;
    let mut output = tree.clone();
    let root_bounds = tree.viewport.bounds();
    place_node(&mut output, styles, &tree.root_id, root_bounds, None)?;
    output.refresh_digest();
    Ok(output)
}

fn place_node(
    tree: &mut UiTree,
    styles: &LayoutStyles,
    id: &str,
    bounds: Rect,
    inherited_clip: Option<Rect>,
) -> Result<(), LayoutError> {
    let style = styles.0.get(id).copied().unwrap_or_default();
    let child_ids = tree
        .node(id)
        .ok_or_else(|| LayoutError::MissingNode(id.into()))?
        .children
        .clone();
    let node = tree
        .nodes
        .iter_mut()
        .find(|node| node.id == id)
        .ok_or_else(|| LayoutError::MissingNode(id.into()))?;
    node.bounds = bounds;
    node.clip = inherited_clip;
    let next_clip = if style.overflow_hidden {
        Some(bounds)
    } else {
        inherited_clip
    };
    let inner_x = bounds.x + style.padding.left as f32;
    let inner_y = bounds.y + style.padding.top as f32;
    let inner_width = (bounds.width - (style.padding.left + style.padding.right) as f32).max(0.0);
    let inner_height = (bounds.height - (style.padding.top + style.padding.bottom) as f32).max(0.0);
    let mut cursor = 0.0_f32;
    for child_id in child_ids {
        let child_style = styles.0.get(&child_id).copied().unwrap_or_default();
        let child_width = child_style
            .width
            .unwrap_or(if style.direction == FlowDirection::Row {
                120
            } else {
                inner_width as u32
            }) as f32;
        let child_height = child_style.height.unwrap_or(24) as f32;
        let child_bounds = match style.direction {
            FlowDirection::Column => Rect::new(
                inner_x,
                inner_y + cursor,
                child_width.min(inner_width),
                child_height,
            ),
            FlowDirection::Row => Rect::new(
                inner_x + cursor,
                inner_y,
                child_width,
                child_height.min(inner_height),
            ),
        };
        cursor += if style.direction == FlowDirection::Column {
            child_height
        } else {
            child_width
        };
        cursor += style.gap as f32;
        place_node(tree, styles, &child_id, child_bounds, next_clip)?;
    }
    Ok(())
}

#[derive(Debug, Error)]
pub enum LayoutError {
    #[error("normalized tree is invalid: {0}")]
    InvalidTree(falsegreen_ui_core::ValidationError),
    #[error("layout refers to missing node {0}")]
    MissingNode(String),
}

#[cfg(test)]
mod tests {
    use super::*;
    use falsegreen_ui_core::{Role, UiNode};

    fn tree() -> UiTree {
        let mut root = UiNode::new("root", Role::Application, Rect::new(0.0, 0.0, 100.0, 100.0));
        root.children = vec!["a".into(), "b".into()];
        let a = UiNode::new("a", Role::Button, Rect::new(0.0, 0.0, 50.0, 20.0)).parent("root");
        let b = UiNode::new("b", Role::Button, Rect::new(0.0, 20.0, 50.0, 20.0)).parent("root");
        UiTree::new(Viewport::new(100, 100), "root", vec![root, a, b])
    }

    #[test]
    fn layout_report_is_repeatable_and_reports_relations() {
        let first = LayoutReport::from_tree(&tree()).unwrap();
        let second = LayoutReport::from_tree(&tree()).unwrap();
        assert_eq!(first, second);
        assert_eq!(first.relation("a", "b"), Some(Relation::Above));
    }

    #[test]
    fn flow_layout_is_deterministic() {
        let mut styles = LayoutStyles::default();
        styles.insert(
            "root",
            LayoutStyle {
                gap: 4,
                padding: Insets::all(8),
                ..Default::default()
            },
        );
        let first = apply_flow_layout(&tree(), &styles).unwrap();
        let second = apply_flow_layout(&tree(), &styles).unwrap();
        assert_eq!(first.tree_digest, second.tree_digest);
        assert_eq!(first.node("b").unwrap().bounds.y, 36.0);
    }
}