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    // We do this by traversing top-down to ensure parent constraints are resolved before children.
52    resolve_constraints_top_down(graph, graph.root, &mut bounds, viewport);
53
54    // Final pass: re-compute group auto-sizes after constraints shifted children.
55    // This ensures free-layout groups correctly contain children with Position constraints.
56    recompute_group_auto_sizes(graph, graph.root, &mut bounds);
57
58    bounds
59}
60
61/// Re-resolve only the children of `parent_idx`, using its current bounds.
62///
63/// This is a lightweight version of `resolve_layout` scoped to one subtree.
64/// Used by `ResizeNode` to re-flow column/row/grid children and re-center
65/// text during drag, without re-resolving the entire graph.
66pub fn resolve_subtree(
67    graph: &SceneGraph,
68    parent_idx: NodeIndex,
69    bounds: &mut HashMap<NodeIndex, ResolvedBounds>,
70    viewport: Viewport,
71) {
72    resolve_children(graph, parent_idx, bounds, viewport);
73    resolve_constraints_top_down(graph, parent_idx, bounds, viewport);
74    recompute_group_auto_sizes(graph, parent_idx, bounds);
75}
76
77fn resolve_constraints_top_down(
78    graph: &SceneGraph,
79    node_idx: NodeIndex,
80    bounds: &mut HashMap<NodeIndex, ResolvedBounds>,
81    viewport: Viewport,
82) {
83    let node = &graph.graph[node_idx];
84    let parent_managed = is_parent_managed(graph, node_idx);
85    for constraint in &node.constraints {
86        // Skip Position constraints for children inside managed layouts —
87        // the Column/Row/Grid layout mode owns child positioning.
88        if parent_managed && matches!(constraint, Constraint::Position { .. }) {
89            continue;
90        }
91        apply_constraint(graph, node_idx, constraint, bounds, viewport);
92    }
93
94    for child_idx in graph.children(node_idx) {
95        resolve_constraints_top_down(graph, child_idx, bounds, viewport);
96    }
97}
98
99/// Check whether a node's parent uses a managed layout (Column/Row/Grid).
100pub fn is_parent_managed(graph: &SceneGraph, node_idx: NodeIndex) -> bool {
101    let parent_idx = match graph.parent(node_idx) {
102        Some(p) => p,
103        None => return false,
104    };
105    let parent_node = &graph.graph[parent_idx];
106    match &parent_node.kind {
107        NodeKind::Frame { layout, .. } => !matches!(layout, LayoutMode::Free),
108        _ => false,
109    }
110}
111
112/// Bottom-up re-computation of group auto-sizes after all constraints are applied.
113fn recompute_group_auto_sizes(
114    graph: &SceneGraph,
115    node_idx: NodeIndex,
116    bounds: &mut HashMap<NodeIndex, ResolvedBounds>,
117) {
118    // Recurse into children first (bottom-up)
119    for child_idx in graph.children(node_idx) {
120        recompute_group_auto_sizes(graph, child_idx, bounds);
121    }
122
123    let node = &graph.graph[node_idx];
124    // Only groups auto-size — frames use declared dimensions
125    if !matches!(node.kind, NodeKind::Group) {
126        return;
127    }
128
129    let children = graph.children(node_idx);
130    if children.is_empty() {
131        return;
132    }
133
134    let mut min_x = f32::MAX;
135    let mut min_y = f32::MAX;
136    let mut max_x = f32::MIN;
137    let mut max_y = f32::MIN;
138
139    for &child_idx in &children {
140        if let Some(cb) = bounds.get(&child_idx) {
141            min_x = min_x.min(cb.x);
142            min_y = min_y.min(cb.y);
143            max_x = max_x.max(cb.x + cb.width);
144            max_y = max_y.max(cb.y + cb.height);
145        }
146    }
147
148    if min_x < f32::MAX {
149        bounds.insert(
150            node_idx,
151            ResolvedBounds {
152                x: min_x,
153                y: min_y,
154                width: max_x - min_x,
155                height: max_y - min_y,
156            },
157        );
158    }
159}
160
161#[allow(clippy::only_used_in_recursion)]
162fn resolve_children(
163    graph: &SceneGraph,
164    parent_idx: NodeIndex,
165    bounds: &mut HashMap<NodeIndex, ResolvedBounds>,
166    viewport: Viewport,
167) {
168    let parent_bounds = bounds[&parent_idx];
169    let parent_node = &graph.graph[parent_idx];
170
171    let children: Vec<NodeIndex> = graph.children(parent_idx);
172    if children.is_empty() {
173        return;
174    }
175
176    // Determine layout mode
177    let layout = match &parent_node.kind {
178        NodeKind::Group => LayoutMode::Free, // Group is always Free
179        NodeKind::Frame { layout, .. } => layout.clone(),
180        _ => LayoutMode::Free,
181    };
182
183    match layout {
184        LayoutMode::Column { gap, pad } => {
185            let content_width = parent_bounds.width - 2.0 * pad;
186            // Pass 1: initialize children at parent origin + pad, recurse to resolve nested groups
187            for &child_idx in &children {
188                let child_node = &graph.graph[child_idx];
189                let child_size = intrinsic_size(child_node);
190                // Stretch text nodes to fill column width (like CSS align-items: stretch)
191                let w = if matches!(child_node.kind, NodeKind::Text { .. }) {
192                    content_width.max(child_size.0)
193                } else {
194                    child_size.0
195                };
196                bounds.insert(
197                    child_idx,
198                    ResolvedBounds {
199                        x: parent_bounds.x + pad,
200                        y: parent_bounds.y + pad,
201                        width: w,
202                        height: child_size.1,
203                    },
204                );
205                resolve_children(graph, child_idx, bounds, viewport);
206            }
207            // Pass 2: reposition using resolved sizes, shifting entire subtrees
208            let mut y = parent_bounds.y + pad;
209            for &child_idx in &children {
210                let resolved = bounds[&child_idx];
211                let dx = (parent_bounds.x + pad) - resolved.x;
212                let dy = y - resolved.y;
213                if dx.abs() > 0.001 || dy.abs() > 0.001 {
214                    shift_subtree(graph, child_idx, dx, dy, bounds);
215                }
216                y += bounds[&child_idx].height + gap;
217            }
218        }
219        LayoutMode::Row { gap, pad } => {
220            // Pass 1: initialize and recurse
221            for &child_idx in &children {
222                let child_size = intrinsic_size(&graph.graph[child_idx]);
223                bounds.insert(
224                    child_idx,
225                    ResolvedBounds {
226                        x: parent_bounds.x + pad,
227                        y: parent_bounds.y + pad,
228                        width: child_size.0,
229                        height: child_size.1,
230                    },
231                );
232                resolve_children(graph, child_idx, bounds, viewport);
233            }
234            // Pass 2: reposition using resolved widths, shifting subtrees
235            let mut x = parent_bounds.x + pad;
236            for &child_idx in &children {
237                let resolved = bounds[&child_idx];
238                let dx = x - resolved.x;
239                let dy = (parent_bounds.y + pad) - resolved.y;
240                if dx.abs() > 0.001 || dy.abs() > 0.001 {
241                    shift_subtree(graph, child_idx, dx, dy, bounds);
242                }
243                x += bounds[&child_idx].width + gap;
244            }
245        }
246        LayoutMode::Grid { cols, gap, pad } => {
247            // Pass 1: initialize and recurse
248            for &child_idx in &children {
249                let child_size = intrinsic_size(&graph.graph[child_idx]);
250                bounds.insert(
251                    child_idx,
252                    ResolvedBounds {
253                        x: parent_bounds.x + pad,
254                        y: parent_bounds.y + pad,
255                        width: child_size.0,
256                        height: child_size.1,
257                    },
258                );
259                resolve_children(graph, child_idx, bounds, viewport);
260            }
261            // Pass 2: reposition using resolved sizes, shifting subtrees
262            let mut x = parent_bounds.x + pad;
263            let mut y = parent_bounds.y + pad;
264            let mut col = 0u32;
265            let mut row_height = 0.0f32;
266
267            for &child_idx in &children {
268                let resolved = bounds[&child_idx];
269                let dx = x - resolved.x;
270                let dy = y - resolved.y;
271                if dx.abs() > 0.001 || dy.abs() > 0.001 {
272                    shift_subtree(graph, child_idx, dx, dy, bounds);
273                }
274
275                let resolved = bounds[&child_idx];
276                row_height = row_height.max(resolved.height);
277                col += 1;
278                if col >= cols {
279                    col = 0;
280                    x = parent_bounds.x + pad;
281                    y += row_height + gap;
282                    row_height = 0.0;
283                } else {
284                    x += resolved.width + gap;
285                }
286            }
287        }
288        LayoutMode::Free => {
289            // Each child positioned at parent origin by default
290            for &child_idx in &children {
291                let child_size = intrinsic_size(&graph.graph[child_idx]);
292                bounds.insert(
293                    child_idx,
294                    ResolvedBounds {
295                        x: parent_bounds.x,
296                        y: parent_bounds.y,
297                        width: child_size.0,
298                        height: child_size.1,
299                    },
300                );
301            }
302
303            let parent_is_shape = matches!(
304                parent_node.kind,
305                NodeKind::Rect { .. } | NodeKind::Ellipse { .. } | NodeKind::Frame { .. }
306            );
307
308            for &child_idx in &children {
309                let child_node = &graph.graph[child_idx];
310                let has_position = child_node
311                    .constraints
312                    .iter()
313                    .any(|c| matches!(c, Constraint::Position { .. }));
314
315                // Priority 1: explicit `place:` property
316                if let Some((h, v)) = child_node.place {
317                    if !has_position && let Some(cb) = bounds.get(&child_idx).copied() {
318                        let x = match h {
319                            HPlace::Left => parent_bounds.x,
320                            HPlace::Center => {
321                                parent_bounds.x + (parent_bounds.width - cb.width) / 2.0
322                            }
323                            HPlace::Right => parent_bounds.x + parent_bounds.width - cb.width,
324                        };
325                        let y = match v {
326                            VPlace::Top => parent_bounds.y,
327                            VPlace::Middle => {
328                                parent_bounds.y + (parent_bounds.height - cb.height) / 2.0
329                            }
330                            VPlace::Bottom => parent_bounds.y + parent_bounds.height - cb.height,
331                        };
332                        bounds.insert(child_idx, ResolvedBounds { x, y, ..cb });
333                    }
334                    continue;
335                }
336
337                // Priority 2: auto-center text children in shape parents
338                // (no explicit place: and no Position constraint)
339                if parent_is_shape
340                    && matches!(child_node.kind, NodeKind::Text { .. })
341                    && !has_position
342                    && let Some(child_b) = bounds.get(&child_idx).copied()
343                {
344                    let cx = parent_bounds.x + (parent_bounds.width - child_b.width) / 2.0;
345                    let cy = parent_bounds.y + (parent_bounds.height - child_b.height) / 2.0;
346                    bounds.insert(
347                        child_idx,
348                        ResolvedBounds {
349                            x: cx,
350                            y: cy,
351                            width: child_b.width,
352                            height: child_b.height,
353                        },
354                    );
355                }
356            }
357        }
358    }
359
360    // Recurse into children (only for Free mode — Column/Row/Grid already recursed in pass 1)
361    if matches!(layout, LayoutMode::Free) {
362        for &child_idx in &children {
363            resolve_children(graph, child_idx, bounds, viewport);
364        }
365    }
366
367    // Auto-size groups to the union bounding box of their children
368    if matches!(parent_node.kind, NodeKind::Group) && !children.is_empty() {
369        let mut min_x = f32::MAX;
370        let mut min_y = f32::MAX;
371        let mut max_x = f32::MIN;
372        let mut max_y = f32::MIN;
373
374        for &child_idx in &children {
375            if let Some(cb) = bounds.get(&child_idx) {
376                min_x = min_x.min(cb.x);
377                min_y = min_y.min(cb.y);
378                max_x = max_x.max(cb.x + cb.width);
379                max_y = max_y.max(cb.y + cb.height);
380            }
381        }
382
383        if min_x < f32::MAX {
384            bounds.insert(
385                parent_idx,
386                ResolvedBounds {
387                    x: min_x,
388                    y: min_y,
389                    width: max_x - min_x,
390                    height: max_y - min_y,
391                },
392            );
393        }
394    }
395}
396
397/// Recursively shift a node and all its descendants by (dx, dy).
398/// Used after pass 2 repositioning to keep subtree positions consistent.
399fn shift_subtree(
400    graph: &SceneGraph,
401    node_idx: NodeIndex,
402    dx: f32,
403    dy: f32,
404    bounds: &mut HashMap<NodeIndex, ResolvedBounds>,
405) {
406    if let Some(b) = bounds.get(&node_idx).copied() {
407        bounds.insert(
408            node_idx,
409            ResolvedBounds {
410                x: b.x + dx,
411                y: b.y + dy,
412                ..b
413            },
414        );
415    }
416    for child_idx in graph.children(node_idx) {
417        shift_subtree(graph, child_idx, dx, dy, bounds);
418    }
419}
420
421/// Get the intrinsic (declared) size of a node.
422fn intrinsic_size(node: &SceneNode) -> (f32, f32) {
423    match &node.kind {
424        NodeKind::Rect { width, height } => (*width, *height),
425        NodeKind::Ellipse { rx, ry } => (*rx * 2.0, *ry * 2.0),
426        NodeKind::Text { content, .. } => {
427            let font_size = node.style.font.as_ref().map_or(14.0, |f| f.size);
428            let char_width = font_size * 0.6;
429            (content.chars().count() as f32 * char_width, font_size * 1.4)
430        }
431        NodeKind::Group => (0.0, 0.0), // Auto-sized: computed after children resolve
432        NodeKind::Frame { width, height, .. } => (*width, *height),
433        NodeKind::Path { .. } => (100.0, 100.0), // Computed from path bounds
434        NodeKind::Generic => (120.0, 40.0),      // Placeholder label box
435        NodeKind::Root => (0.0, 0.0),
436    }
437}
438
439fn apply_constraint(
440    graph: &SceneGraph,
441    node_idx: NodeIndex,
442    constraint: &Constraint,
443    bounds: &mut HashMap<NodeIndex, ResolvedBounds>,
444    viewport: Viewport,
445) {
446    let node_bounds = match bounds.get(&node_idx) {
447        Some(b) => *b,
448        None => return,
449    };
450
451    match constraint {
452        Constraint::CenterIn(target_id) => {
453            let container = if target_id.as_str() == "canvas" {
454                ResolvedBounds {
455                    x: 0.0,
456                    y: 0.0,
457                    width: viewport.width,
458                    height: viewport.height,
459                }
460            } else {
461                match graph.index_of(*target_id).and_then(|i| bounds.get(&i)) {
462                    Some(b) => *b,
463                    None => return,
464                }
465            };
466
467            let cx = container.x + (container.width - node_bounds.width) / 2.0;
468            let cy = container.y + (container.height - node_bounds.height) / 2.0;
469            let dx = cx - node_bounds.x;
470            let dy = cy - node_bounds.y;
471
472            shift_subtree(graph, node_idx, dx, dy, bounds);
473        }
474        Constraint::Offset { from, dx, dy } => {
475            let from_bounds = match graph.index_of(*from).and_then(|i| bounds.get(&i)) {
476                Some(b) => *b,
477                None => return,
478            };
479            let target_x = from_bounds.x + dx;
480            let target_y = from_bounds.y + dy;
481            let sdx = target_x - node_bounds.x;
482            let sdy = target_y - node_bounds.y;
483
484            shift_subtree(graph, node_idx, sdx, sdy, bounds);
485        }
486        Constraint::FillParent { pad } => {
487            // Find parent in graph
488            let parent_idx = graph
489                .graph
490                .neighbors_directed(node_idx, petgraph::Direction::Incoming)
491                .next();
492
493            if let Some(parent) = parent_idx.and_then(|p| bounds.get(&p).copied()) {
494                let target_x = parent.x + pad;
495                let target_y = parent.y + pad;
496                let new_w = parent.width - 2.0 * pad;
497                let new_h = parent.height - 2.0 * pad;
498                let dx = target_x - node_bounds.x;
499                let dy = target_y - node_bounds.y;
500
501                // Move children with the position shift
502                shift_subtree(graph, node_idx, dx, dy, bounds);
503
504                // Apply the resize to the node itself (children keep their sizes)
505                if let Some(nb) = bounds.get_mut(&node_idx) {
506                    nb.width = new_w;
507                    nb.height = new_h;
508                }
509            }
510        }
511        Constraint::Position { x, y } => {
512            let (px, py) = match graph.parent(node_idx).and_then(|p| bounds.get(&p)) {
513                Some(p_bounds) => (p_bounds.x, p_bounds.y),
514                None => (0.0, 0.0),
515            };
516            let target_x = px + *x;
517            let target_y = py + *y;
518            let dx = target_x - node_bounds.x;
519            let dy = target_y - node_bounds.y;
520
521            shift_subtree(graph, node_idx, dx, dy, bounds);
522        }
523    }
524}
525
526#[cfg(test)]
527#[path = "layout_tests.rs"]
528mod tests;