1use 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#[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}