Skip to main content

fd_core/
layout.rs

1//! Constraint-based layout solver.
2//!
3//! Converts relative constraints (center_in, offset, fill_parent) into
4//! absolute `ResolvedBounds` for each node. Also handles Column/Row/Grid
5//! layout modes for groups.
6
7use crate::model::*;
8use petgraph::graph::NodeIndex;
9use std::collections::HashMap;
10
11/// The canvas (viewport) dimensions.
12#[derive(Debug, Clone, Copy)]
13pub struct Viewport {
14    pub width: f32,
15    pub height: f32,
16}
17
18impl Default for Viewport {
19    fn default() -> Self {
20        Self {
21            width: 800.0,
22            height: 600.0,
23        }
24    }
25}
26
27/// Resolve all node positions in the scene graph.
28///
29/// Returns a map from `NodeIndex` → `ResolvedBounds` with absolute positions.
30pub fn resolve_layout(
31    graph: &SceneGraph,
32    viewport: Viewport,
33) -> HashMap<NodeIndex, ResolvedBounds> {
34    let mut bounds: HashMap<NodeIndex, ResolvedBounds> = HashMap::new();
35
36    // Root fills the viewport
37    bounds.insert(
38        graph.root,
39        ResolvedBounds {
40            x: 0.0,
41            y: 0.0,
42            width: viewport.width,
43            height: viewport.height,
44        },
45    );
46
47    // Resolve recursively from root
48    resolve_children(graph, graph.root, &mut bounds, viewport);
49
50    // Apply top-level constraints (may override layout-computed positions)
51    for idx in graph.graph.node_indices() {
52        let node = &graph.graph[idx];
53        for constraint in &node.constraints {
54            apply_constraint(graph, idx, constraint, &mut bounds, viewport);
55        }
56    }
57
58    bounds
59}
60
61#[allow(clippy::only_used_in_recursion)]
62fn resolve_children(
63    graph: &SceneGraph,
64    parent_idx: NodeIndex,
65    bounds: &mut HashMap<NodeIndex, ResolvedBounds>,
66    viewport: Viewport,
67) {
68    let parent_bounds = bounds[&parent_idx];
69    let parent_node = &graph.graph[parent_idx];
70
71    let children: Vec<NodeIndex> = graph.children(parent_idx);
72    if children.is_empty() {
73        return;
74    }
75
76    // Determine layout mode
77    let layout = match &parent_node.kind {
78        NodeKind::Group { layout } => layout.clone(),
79        _ => LayoutMode::Free,
80    };
81
82    match layout {
83        LayoutMode::Column { gap, pad } => {
84            let mut y = parent_bounds.y + pad;
85            for &child_idx in &children {
86                let child_size = intrinsic_size(&graph.graph[child_idx]);
87                bounds.insert(
88                    child_idx,
89                    ResolvedBounds {
90                        x: parent_bounds.x + pad,
91                        y,
92                        width: child_size.0,
93                        height: child_size.1,
94                    },
95                );
96                y += child_size.1 + gap;
97            }
98        }
99        LayoutMode::Row { gap, pad } => {
100            let mut x = parent_bounds.x + pad;
101            for &child_idx in &children {
102                let child_size = intrinsic_size(&graph.graph[child_idx]);
103                bounds.insert(
104                    child_idx,
105                    ResolvedBounds {
106                        x,
107                        y: parent_bounds.y + pad,
108                        width: child_size.0,
109                        height: child_size.1,
110                    },
111                );
112                x += child_size.0 + gap;
113            }
114        }
115        LayoutMode::Grid { cols, gap, pad } => {
116            let mut x = parent_bounds.x + pad;
117            let mut y = parent_bounds.y + pad;
118            let mut col = 0u32;
119            let mut row_height = 0.0f32;
120
121            for &child_idx in &children {
122                let child_size = intrinsic_size(&graph.graph[child_idx]);
123                bounds.insert(
124                    child_idx,
125                    ResolvedBounds {
126                        x,
127                        y,
128                        width: child_size.0,
129                        height: child_size.1,
130                    },
131                );
132
133                row_height = row_height.max(child_size.1);
134                col += 1;
135                if col >= cols {
136                    col = 0;
137                    x = parent_bounds.x + pad;
138                    y += row_height + gap;
139                    row_height = 0.0;
140                } else {
141                    x += child_size.0 + gap;
142                }
143            }
144        }
145        LayoutMode::Free => {
146            // Each child positioned at parent origin by default
147            for &child_idx in &children {
148                let child_size = intrinsic_size(&graph.graph[child_idx]);
149                bounds.insert(
150                    child_idx,
151                    ResolvedBounds {
152                        x: parent_bounds.x,
153                        y: parent_bounds.y,
154                        width: child_size.0,
155                        height: child_size.1,
156                    },
157                );
158            }
159        }
160    }
161
162    // Recurse into children
163    for &child_idx in &children {
164        resolve_children(graph, child_idx, bounds, viewport);
165    }
166}
167
168/// Get the intrinsic (declared) size of a node.
169fn intrinsic_size(node: &SceneNode) -> (f32, f32) {
170    match &node.kind {
171        NodeKind::Rect { width, height } => (*width, *height),
172        NodeKind::Ellipse { rx, ry } => (*rx * 2.0, *ry * 2.0),
173        NodeKind::Text { content } => {
174            // Rough estimate: 8px per char, 20px height. Real text layout comes later.
175            (content.len() as f32 * 8.0, 20.0)
176        }
177        NodeKind::Group { .. } => (200.0, 200.0), // Groups size to content eventually
178        NodeKind::Path { .. } => (100.0, 100.0),  // Computed from path bounds
179        NodeKind::Root => (0.0, 0.0),
180    }
181}
182
183fn apply_constraint(
184    graph: &SceneGraph,
185    node_idx: NodeIndex,
186    constraint: &Constraint,
187    bounds: &mut HashMap<NodeIndex, ResolvedBounds>,
188    viewport: Viewport,
189) {
190    let node_bounds = match bounds.get(&node_idx) {
191        Some(b) => *b,
192        None => return,
193    };
194
195    match constraint {
196        Constraint::CenterIn(target_id) => {
197            let container = if target_id.as_str() == "canvas" {
198                ResolvedBounds {
199                    x: 0.0,
200                    y: 0.0,
201                    width: viewport.width,
202                    height: viewport.height,
203                }
204            } else {
205                match graph.index_of(*target_id).and_then(|i| bounds.get(&i)) {
206                    Some(b) => *b,
207                    None => return,
208                }
209            };
210
211            let cx = container.x + (container.width - node_bounds.width) / 2.0;
212            let cy = container.y + (container.height - node_bounds.height) / 2.0;
213
214            bounds.insert(
215                node_idx,
216                ResolvedBounds {
217                    x: cx,
218                    y: cy,
219                    ..node_bounds
220                },
221            );
222        }
223        Constraint::Offset { from, dx, dy } => {
224            let from_bounds = match graph.index_of(*from).and_then(|i| bounds.get(&i)) {
225                Some(b) => *b,
226                None => return,
227            };
228            bounds.insert(
229                node_idx,
230                ResolvedBounds {
231                    x: from_bounds.x + dx,
232                    y: from_bounds.y + dy,
233                    ..node_bounds
234                },
235            );
236        }
237        Constraint::FillParent { pad } => {
238            // Find parent in graph
239            let parent_idx = graph
240                .graph
241                .neighbors_directed(node_idx, petgraph::Direction::Incoming)
242                .next();
243
244            if let Some(parent) = parent_idx.and_then(|p| bounds.get(&p)) {
245                bounds.insert(
246                    node_idx,
247                    ResolvedBounds {
248                        x: parent.x + pad,
249                        y: parent.y + pad,
250                        width: parent.width - 2.0 * pad,
251                        height: parent.height - 2.0 * pad,
252                    },
253                );
254            }
255        }
256        Constraint::Absolute { x, y } => {
257            bounds.insert(
258                node_idx,
259                ResolvedBounds {
260                    x: *x,
261                    y: *y,
262                    ..node_bounds
263                },
264            );
265        }
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use crate::id::NodeId;
273    use crate::parser::parse_document;
274
275    #[test]
276    fn layout_column() {
277        let input = r#"
278group @form {
279  layout: column gap=10 pad=20
280
281  rect @a { w: 100 h: 40 }
282  rect @b { w: 100 h: 30 }
283}
284"#;
285        let graph = parse_document(input).unwrap();
286        let viewport = Viewport {
287            width: 800.0,
288            height: 600.0,
289        };
290        let bounds = resolve_layout(&graph, viewport);
291
292        let a_idx = graph.index_of(NodeId::intern("a")).unwrap();
293        let b_idx = graph.index_of(NodeId::intern("b")).unwrap();
294
295        let a = bounds[&a_idx];
296        let b = bounds[&b_idx];
297
298        // Both should be at x = pad (20)
299        assert!(
300            (a.x - 20.0).abs() < 0.01,
301            "a.x should be 20 (pad), got {}",
302            a.x
303        );
304        assert!(
305            (b.x - 20.0).abs() < 0.01,
306            "b.x should be 20 (pad), got {}",
307            b.x
308        );
309
310        // The two children should be exactly (height_of_first + gap) apart
311        let gap_plus_height = (b.y - a.y).abs();
312        // Either a is first (gap = 40 + 10 = 50) or b is first (gap = 30 + 10 = 40)
313        assert!(
314            (gap_plus_height - 50.0).abs() < 0.01 || (gap_plus_height - 40.0).abs() < 0.01,
315            "children should be height+gap apart, got diff = {gap_plus_height}"
316        );
317    }
318
319    #[test]
320    fn layout_center_in_canvas() {
321        let input = r#"
322rect @box {
323  w: 200
324  h: 100
325}
326
327@box -> center_in: canvas
328"#;
329        let graph = parse_document(input).unwrap();
330        let viewport = Viewport {
331            width: 800.0,
332            height: 600.0,
333        };
334        let bounds = resolve_layout(&graph, viewport);
335
336        let idx = graph.index_of(NodeId::intern("box")).unwrap();
337        let b = bounds[&idx];
338
339        assert!((b.x - 300.0).abs() < 0.01); // (800 - 200) / 2
340        assert!((b.y - 250.0).abs() < 0.01); // (600 - 100) / 2
341    }
342}