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    for constraint in &node.constraints {
85        // Position constraints now apply even inside managed layouts —
86        // a moved child becomes "absolutely positioned" within the frame
87        // (like Figma's Absolute Position toggle).
88        apply_constraint(graph, node_idx, constraint, bounds, viewport);
89    }
90
91    for child_idx in graph.children(node_idx) {
92        resolve_constraints_top_down(graph, child_idx, bounds, viewport);
93    }
94}
95
96/// Check whether a node's parent uses a managed layout (Column/Row/Grid).
97pub fn is_parent_managed(graph: &SceneGraph, node_idx: NodeIndex) -> bool {
98    let parent_idx = match graph.parent(node_idx) {
99        Some(p) => p,
100        None => return false,
101    };
102    let parent_node = &graph.graph[parent_idx];
103    match &parent_node.kind {
104        NodeKind::Frame { layout, .. } => !matches!(layout, LayoutMode::Free { .. }),
105        _ => false,
106    }
107}
108
109/// Bottom-up re-computation of group auto-sizes after all constraints are applied.
110fn recompute_group_auto_sizes(
111    graph: &SceneGraph,
112    node_idx: NodeIndex,
113    bounds: &mut HashMap<NodeIndex, ResolvedBounds>,
114) {
115    // Recurse into children first (bottom-up)
116    for child_idx in graph.children(node_idx) {
117        recompute_group_auto_sizes(graph, child_idx, bounds);
118    }
119
120    let node = &graph.graph[node_idx];
121    // Only groups auto-size — frames use declared dimensions
122    if !matches!(node.kind, NodeKind::Group) {
123        return;
124    }
125
126    let children = graph.children(node_idx);
127    if children.is_empty() {
128        return;
129    }
130
131    let mut min_x = f32::MAX;
132    let mut min_y = f32::MAX;
133    let mut max_x = f32::MIN;
134    let mut max_y = f32::MIN;
135
136    for &child_idx in &children {
137        if let Some(cb) = bounds.get(&child_idx) {
138            min_x = min_x.min(cb.x);
139            min_y = min_y.min(cb.y);
140            max_x = max_x.max(cb.x + cb.width);
141            max_y = max_y.max(cb.y + cb.height);
142        }
143    }
144
145    if min_x < f32::MAX {
146        bounds.insert(
147            node_idx,
148            ResolvedBounds {
149                x: min_x,
150                y: min_y,
151                width: max_x - min_x,
152                height: max_y - min_y,
153            },
154        );
155    }
156}
157
158#[allow(clippy::only_used_in_recursion)]
159fn resolve_children(
160    graph: &SceneGraph,
161    parent_idx: NodeIndex,
162    bounds: &mut HashMap<NodeIndex, ResolvedBounds>,
163    viewport: Viewport,
164) {
165    let parent_bounds = bounds[&parent_idx];
166    let parent_node = &graph.graph[parent_idx];
167
168    let children: Vec<NodeIndex> = graph.children(parent_idx);
169    if children.is_empty() {
170        return;
171    }
172
173    // Determine layout mode
174    let layout = match &parent_node.kind {
175        NodeKind::Group => LayoutMode::Free { pad: 0.0 }, // Group is always Free
176        NodeKind::Frame { layout, .. } => layout.clone(),
177        _ => LayoutMode::Free { pad: 0.0 },
178    };
179
180    match layout {
181        LayoutMode::Column { gap, pad } => {
182            let content_width = parent_bounds.width - 2.0 * pad;
183            // Filter: children with Position constraints are absolutely positioned
184            // within the frame — they don't participate in the column flow.
185            let flow_children: Vec<NodeIndex> = children
186                .iter()
187                .copied()
188                .filter(|&ci| {
189                    !graph.graph[ci]
190                        .constraints
191                        .iter()
192                        .any(|c| matches!(c, Constraint::Position { .. }))
193                })
194                .collect();
195            // Pass 1: initialize flow children at parent origin + pad
196            for &child_idx in &flow_children {
197                let child_node = &graph.graph[child_idx];
198                let child_size = intrinsic_size(child_node);
199                // Stretch text nodes to fill column width (like CSS align-items: stretch)
200                let w = if matches!(child_node.kind, NodeKind::Text { .. }) {
201                    content_width.max(child_size.0)
202                } else {
203                    child_size.0
204                };
205                bounds.insert(
206                    child_idx,
207                    ResolvedBounds {
208                        x: parent_bounds.x + pad,
209                        y: parent_bounds.y + pad,
210                        width: w,
211                        height: child_size.1,
212                    },
213                );
214                resolve_children(graph, child_idx, bounds, viewport);
215            }
216            // Absolutely-positioned children: initialize from intrinsic_size
217            for &child_idx in &children {
218                if !flow_children.contains(&child_idx) {
219                    let child_size = intrinsic_size(&graph.graph[child_idx]);
220                    bounds.entry(child_idx).or_insert(ResolvedBounds {
221                        x: parent_bounds.x,
222                        y: parent_bounds.y,
223                        width: child_size.0,
224                        height: child_size.1,
225                    });
226                    resolve_children(graph, child_idx, bounds, viewport);
227                }
228            }
229            // Pass 2: reposition flow children using resolved sizes
230            let mut y = parent_bounds.y + pad;
231            for &child_idx in &flow_children {
232                let resolved = bounds[&child_idx];
233                let dx = (parent_bounds.x + pad) - resolved.x;
234                let dy = y - resolved.y;
235                if dx.abs() > 0.001 || dy.abs() > 0.001 {
236                    shift_subtree(graph, child_idx, dx, dy, bounds);
237                }
238                y += bounds[&child_idx].height + gap;
239            }
240        }
241        LayoutMode::Row { gap, pad } => {
242            // Filter: absolutely-positioned children skip the row flow
243            let flow_children: Vec<NodeIndex> = children
244                .iter()
245                .copied()
246                .filter(|&ci| {
247                    !graph.graph[ci]
248                        .constraints
249                        .iter()
250                        .any(|c| matches!(c, Constraint::Position { .. }))
251                })
252                .collect();
253            // Pass 1: initialize flow children
254            for &child_idx in &flow_children {
255                let child_size = intrinsic_size(&graph.graph[child_idx]);
256                bounds.insert(
257                    child_idx,
258                    ResolvedBounds {
259                        x: parent_bounds.x + pad,
260                        y: parent_bounds.y + pad,
261                        width: child_size.0,
262                        height: child_size.1,
263                    },
264                );
265                resolve_children(graph, child_idx, bounds, viewport);
266            }
267            // Absolutely-positioned children
268            for &child_idx in &children {
269                if !flow_children.contains(&child_idx) {
270                    let child_size = intrinsic_size(&graph.graph[child_idx]);
271                    bounds.entry(child_idx).or_insert(ResolvedBounds {
272                        x: parent_bounds.x,
273                        y: parent_bounds.y,
274                        width: child_size.0,
275                        height: child_size.1,
276                    });
277                    resolve_children(graph, child_idx, bounds, viewport);
278                }
279            }
280            // Pass 2: reposition flow children
281            let mut x = parent_bounds.x + pad;
282            for &child_idx in &flow_children {
283                let resolved = bounds[&child_idx];
284                let dx = x - resolved.x;
285                let dy = (parent_bounds.y + pad) - resolved.y;
286                if dx.abs() > 0.001 || dy.abs() > 0.001 {
287                    shift_subtree(graph, child_idx, dx, dy, bounds);
288                }
289                x += bounds[&child_idx].width + gap;
290            }
291        }
292        LayoutMode::Grid { cols, gap, pad } => {
293            // Filter: absolutely-positioned children skip the grid flow
294            let flow_children: Vec<NodeIndex> = children
295                .iter()
296                .copied()
297                .filter(|&ci| {
298                    !graph.graph[ci]
299                        .constraints
300                        .iter()
301                        .any(|c| matches!(c, Constraint::Position { .. }))
302                })
303                .collect();
304            // Pass 1: initialize flow children
305            for &child_idx in &flow_children {
306                let child_size = intrinsic_size(&graph.graph[child_idx]);
307                bounds.insert(
308                    child_idx,
309                    ResolvedBounds {
310                        x: parent_bounds.x + pad,
311                        y: parent_bounds.y + pad,
312                        width: child_size.0,
313                        height: child_size.1,
314                    },
315                );
316                resolve_children(graph, child_idx, bounds, viewport);
317            }
318            // Absolutely-positioned children
319            for &child_idx in &children {
320                if !flow_children.contains(&child_idx) {
321                    let child_size = intrinsic_size(&graph.graph[child_idx]);
322                    bounds.entry(child_idx).or_insert(ResolvedBounds {
323                        x: parent_bounds.x,
324                        y: parent_bounds.y,
325                        width: child_size.0,
326                        height: child_size.1,
327                    });
328                    resolve_children(graph, child_idx, bounds, viewport);
329                }
330            }
331            // Pass 2: reposition flow children
332            let mut x = parent_bounds.x + pad;
333            let mut y = parent_bounds.y + pad;
334            let mut col = 0u32;
335            let mut row_height = 0.0f32;
336
337            for &child_idx in &flow_children {
338                let resolved = bounds[&child_idx];
339                let dx = x - resolved.x;
340                let dy = y - resolved.y;
341                if dx.abs() > 0.001 || dy.abs() > 0.001 {
342                    shift_subtree(graph, child_idx, dx, dy, bounds);
343                }
344
345                let resolved = bounds[&child_idx];
346                row_height = row_height.max(resolved.height);
347                col += 1;
348                if col >= cols {
349                    col = 0;
350                    x = parent_bounds.x + pad;
351                    y += row_height + gap;
352                    row_height = 0.0;
353                } else {
354                    x += resolved.width + gap;
355                }
356            }
357        }
358        LayoutMode::Free { pad } => {
359            // Compute padded content area
360            let content_x = parent_bounds.x + pad;
361            let content_y = parent_bounds.y + pad;
362            let content_w = (parent_bounds.width - 2.0 * pad).max(0.0);
363            let content_h = (parent_bounds.height - 2.0 * pad).max(0.0);
364
365            // Each child positioned at padded origin by default.
366            // Use or_insert to preserve existing cached bounds (e.g. JS-measured
367            // text sizes, explicit positions) during resolve_subtree calls.
368            for &child_idx in &children {
369                let child_size = intrinsic_size(&graph.graph[child_idx]);
370                bounds.entry(child_idx).or_insert(ResolvedBounds {
371                    x: content_x,
372                    y: content_y,
373                    width: child_size.0,
374                    height: child_size.1,
375                });
376            }
377
378            let parent_is_shape = matches!(
379                parent_node.kind,
380                NodeKind::Rect { .. } | NodeKind::Ellipse { .. } | NodeKind::Frame { .. }
381            );
382
383            for &child_idx in &children {
384                let child_node = &graph.graph[child_idx];
385                let has_position = child_node
386                    .constraints
387                    .iter()
388                    .any(|c| matches!(c, Constraint::Position { .. }));
389
390                // Priority 1: explicit `place:` property (uses padded area)
391                if let Some((h, v)) = child_node.place {
392                    if !has_position && let Some(cb) = bounds.get(&child_idx).copied() {
393                        let x = match h {
394                            HPlace::Left => content_x,
395                            HPlace::Center => content_x + (content_w - cb.width) / 2.0,
396                            HPlace::Right => content_x + content_w - cb.width,
397                        };
398                        let y = match v {
399                            VPlace::Top => content_y,
400                            VPlace::Middle => content_y + (content_h - cb.height) / 2.0,
401                            VPlace::Bottom => content_y + content_h - cb.height,
402                        };
403                        bounds.insert(child_idx, ResolvedBounds { x, y, ..cb });
404                    }
405                    continue;
406                }
407
408                // Priority 2: auto-center text children in shape parents
409                // (no explicit place: and no Position constraint)
410                // Uses padded bounds for centering area
411                if parent_is_shape
412                    && matches!(child_node.kind, NodeKind::Text { .. })
413                    && !has_position
414                    && let Some(child_b) = bounds.get(&child_idx).copied()
415                {
416                    let cx = content_x + (content_w - child_b.width) / 2.0;
417                    let cy = content_y + (content_h - child_b.height) / 2.0;
418                    bounds.insert(
419                        child_idx,
420                        ResolvedBounds {
421                            x: cx,
422                            y: cy,
423                            width: child_b.width,
424                            height: child_b.height,
425                        },
426                    );
427                }
428            }
429        }
430    }
431
432    // Recurse into children (only for Free mode — Column/Row/Grid already recursed in pass 1)
433    if matches!(layout, LayoutMode::Free { .. }) {
434        for &child_idx in &children {
435            resolve_children(graph, child_idx, bounds, viewport);
436        }
437    }
438
439    // Auto-size groups to the union bounding box of their children
440    if matches!(parent_node.kind, NodeKind::Group) && !children.is_empty() {
441        let mut min_x = f32::MAX;
442        let mut min_y = f32::MAX;
443        let mut max_x = f32::MIN;
444        let mut max_y = f32::MIN;
445
446        for &child_idx in &children {
447            if let Some(cb) = bounds.get(&child_idx) {
448                min_x = min_x.min(cb.x);
449                min_y = min_y.min(cb.y);
450                max_x = max_x.max(cb.x + cb.width);
451                max_y = max_y.max(cb.y + cb.height);
452            }
453        }
454
455        if min_x < f32::MAX {
456            bounds.insert(
457                parent_idx,
458                ResolvedBounds {
459                    x: min_x,
460                    y: min_y,
461                    width: max_x - min_x,
462                    height: max_y - min_y,
463                },
464            );
465        }
466    }
467}
468
469/// Recursively shift a node and all its descendants by (dx, dy).
470/// Used after pass 2 repositioning to keep subtree positions consistent.
471fn shift_subtree(
472    graph: &SceneGraph,
473    node_idx: NodeIndex,
474    dx: f32,
475    dy: f32,
476    bounds: &mut HashMap<NodeIndex, ResolvedBounds>,
477) {
478    if let Some(b) = bounds.get(&node_idx).copied() {
479        bounds.insert(
480            node_idx,
481            ResolvedBounds {
482                x: b.x + dx,
483                y: b.y + dy,
484                ..b
485            },
486        );
487    }
488    for child_idx in graph.children(node_idx) {
489        shift_subtree(graph, child_idx, dx, dy, bounds);
490    }
491}
492
493/// Get the intrinsic (declared) size of a node.
494fn intrinsic_size(node: &SceneNode) -> (f32, f32) {
495    match &node.kind {
496        NodeKind::Rect { width, height } => (*width, *height),
497        NodeKind::Ellipse { rx, ry } => (*rx * 2.0, *ry * 2.0),
498        NodeKind::Text {
499            content, max_width, ..
500        } => {
501            let font_size = node.props.font.as_ref().map_or(14.0, |f| f.size);
502            let char_width = font_size * 0.6;
503            let total_w = content.chars().count() as f32 * char_width;
504            let line_height = font_size * 1.4;
505            match max_width {
506                // With max_width: return single-line placeholder height.
507                // The accurate wrapped height is set by JS measureText()
508                // round-trip (KI Lesson #9: heuristics must not fight
509                // high-fidelity measurements).
510                Some(mw) => (*mw, line_height),
511                None => (total_w, line_height),
512            }
513        }
514        NodeKind::Group => (0.0, 0.0), // Auto-sized: computed after children resolve
515        NodeKind::Frame { width, height, .. } => (*width, *height),
516        NodeKind::Path { .. } => (100.0, 100.0), // Computed from path bounds
517        NodeKind::Image { width, height, .. } => (*width, *height),
518        NodeKind::Generic => (120.0, 40.0), // Placeholder label box
519        NodeKind::Root => (0.0, 0.0),
520    }
521}
522
523fn apply_constraint(
524    graph: &SceneGraph,
525    node_idx: NodeIndex,
526    constraint: &Constraint,
527    bounds: &mut HashMap<NodeIndex, ResolvedBounds>,
528    viewport: Viewport,
529) {
530    let node_bounds = match bounds.get(&node_idx) {
531        Some(b) => *b,
532        None => return,
533    };
534
535    match constraint {
536        Constraint::CenterIn(target_id) => {
537            let container = if target_id.as_str() == "canvas" {
538                ResolvedBounds {
539                    x: 0.0,
540                    y: 0.0,
541                    width: viewport.width,
542                    height: viewport.height,
543                }
544            } else {
545                match graph.index_of(*target_id).and_then(|i| bounds.get(&i)) {
546                    Some(b) => *b,
547                    None => return,
548                }
549            };
550
551            let cx = container.x + (container.width - node_bounds.width) / 2.0;
552            let cy = container.y + (container.height - node_bounds.height) / 2.0;
553            let dx = cx - node_bounds.x;
554            let dy = cy - node_bounds.y;
555
556            shift_subtree(graph, node_idx, dx, dy, bounds);
557        }
558        Constraint::Offset { from, dx, dy } => {
559            let from_bounds = match graph.index_of(*from).and_then(|i| bounds.get(&i)) {
560                Some(b) => *b,
561                None => return,
562            };
563            let target_x = from_bounds.x + dx;
564            let target_y = from_bounds.y + dy;
565            let sdx = target_x - node_bounds.x;
566            let sdy = target_y - node_bounds.y;
567
568            shift_subtree(graph, node_idx, sdx, sdy, bounds);
569        }
570        Constraint::FillParent { pad } => {
571            // Find parent in graph
572            let parent_idx = graph
573                .graph
574                .neighbors_directed(node_idx, petgraph::Direction::Incoming)
575                .next();
576
577            if let Some(parent) = parent_idx.and_then(|p| bounds.get(&p).copied()) {
578                let target_x = parent.x + pad;
579                let target_y = parent.y + pad;
580                let new_w = parent.width - 2.0 * pad;
581                let new_h = parent.height - 2.0 * pad;
582                let dx = target_x - node_bounds.x;
583                let dy = target_y - node_bounds.y;
584
585                // Move children with the position shift
586                shift_subtree(graph, node_idx, dx, dy, bounds);
587
588                // Apply the resize to the node itself (children keep their sizes)
589                if let Some(nb) = bounds.get_mut(&node_idx) {
590                    nb.width = new_w;
591                    nb.height = new_h;
592                }
593            }
594        }
595        Constraint::Position { x, y } => {
596            let (px, py) = match graph.parent(node_idx).and_then(|p| bounds.get(&p)) {
597                Some(p_bounds) => (p_bounds.x, p_bounds.y),
598                None => (0.0, 0.0),
599            };
600            let target_x = px + *x;
601            let target_y = py + *y;
602            let dx = target_x - node_bounds.x;
603            let dy = target_y - node_bounds.y;
604
605            shift_subtree(graph, node_idx, dx, dy, bounds);
606        }
607    }
608}
609
610#[cfg(test)]
611#[path = "layout_tests.rs"]
612mod tests;