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
}
}
}
#[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);
}
}