Skip to main content

fission_layout/
lib.rs

1//! Constraint-based layout engine for the Fission UI framework.
2//!
3//! This crate takes a flat list of [`LayoutInputNode`]s (produced from the
4//! [`fission-ir`](fission_ir) intermediate representation) and computes the
5//! absolute position and size of every node on screen. It implements:
6//!
7//! * **Box layout** -- constrained containers with padding, min/max, and aspect ratio.
8//! * **Flexbox** -- single-axis distribution with grow, shrink, wrap, alignment, and justification.
9//! * **CSS Grid** -- two-dimensional track-based layout with `fr`, `%`, and fixed sizing.
10//! * **Scroll containers** -- clipped viewports with infinite content axes.
11//! * **Absolute positioning** -- `top`/`left`/`right`/`bottom` offsets.
12//! * **ZStack** -- overlapping children.
13//! * **Flyout anchoring** -- popups positioned relative to an anchor node.
14//!
15//! The engine is pure computation with no platform dependencies. Give it nodes and
16//! a viewport size, and it returns a [`LayoutSnapshot`] mapping every
17//! [`WidgetId`](fission_ir::WidgetId) to a [`LayoutRect`].
18//!
19//! # Example
20//!
21//! ```rust,no_run
22//! use fission_layout::*;
23//! use fission_ir::{WidgetId, LayoutOp};
24//!
25//! let mut engine = LayoutEngine::new();
26//! let root_id = WidgetId::explicit("root");
27//! // ... build LayoutInputNode list ...
28//! // let snapshot = engine.compute_layout(&nodes, root_id, viewport, &|_| 0.0).unwrap();
29//! ```
30
31use anyhow::Result;
32use fission_diagnostics::prelude as diag;
33use fission_ir::op::{BoxStyle, Length, RichTextAnnotation, TextParagraphStyle, TextRun};
34use fission_ir::{FlexDirection as IrFlexDirection, FlexWrap as IrFlexWrap, WidgetId};
35use serde::{Deserialize, Serialize};
36use std::collections::hash_map::DefaultHasher;
37use std::collections::{HashMap, HashSet};
38use std::hash::{Hash, Hasher};
39use std::sync::Arc;
40
41mod grid_tracks;
42
43use grid_tracks::{distribute_deficit, distribute_flex, expand_tracks, IntrinsicAxis, TrackSizing};
44
45pub use fission_ir::{FlexDirection, GridPlacement, GridTrack, LayoutOp};
46
47/// A source of scroll offsets for scroll containers.
48///
49/// The layout engine calls [`get_offset`](ScrollDataSource::get_offset) for each
50/// [`LayoutOp::Scroll`] node to learn how far the user has scrolled. Platform
51/// backends implement this trait (or pass a closure, which also implements it).
52///
53/// # Example
54///
55/// ```rust
56/// use fission_layout::ScrollDataSource;
57/// use fission_ir::WidgetId;
58///
59/// // A closure works as a ScrollDataSource:
60/// let source = |_node: WidgetId| -> f32 { 0.0 };
61/// assert_eq!(source.get_offset(WidgetId::explicit("scroll")), 0.0);
62/// ```
63pub trait ScrollDataSource {
64    /// Returns the current scroll offset for the given scroll container node.
65    fn get_offset(&self, node_id: WidgetId) -> f32;
66}
67
68impl<F> ScrollDataSource for F
69where
70    F: Fn(WidgetId) -> f32,
71{
72    fn get_offset(&self, node_id: WidgetId) -> f32 {
73        self(node_id)
74    }
75}
76
77/// The scalar type used for all layout measurements.
78///
79/// Currently `f32`. Matches [`fission_ir::op::LayoutUnit`].
80pub type LayoutUnit = f32;
81
82/// Returns `value` if it is finite, otherwise `fallback`.
83fn finite_or(value: LayoutUnit, fallback: LayoutUnit) -> LayoutUnit {
84    if value.is_finite() {
85        value
86    } else {
87        fallback
88    }
89}
90
91fn resolve_length(
92    length: &Length,
93    reference: LayoutUnit,
94    viewport: LayoutSize,
95) -> Option<LayoutUnit> {
96    length
97        .resolve(reference, viewport.width, viewport.height)
98        .map(|value| value.max(0.0))
99}
100
101fn length_requires_measurement(length: &Length) -> bool {
102    match length {
103        Length::FitContent(_) | Length::MinContent | Length::MaxContent => true,
104        Length::Add(left, right) | Length::Subtract(left, right) => {
105            length_requires_measurement(left) || length_requires_measurement(right)
106        }
107        Length::Min(values) | Length::Max(values) => values.iter().any(length_requires_measurement),
108        Length::Clamp {
109            min,
110            preferred,
111            max,
112        } => {
113            length_requires_measurement(min)
114                || length_requires_measurement(preferred)
115                || length_requires_measurement(max)
116        }
117        Length::Points(_)
118        | Length::Percent(_)
119        | Length::ViewportWidth(_)
120        | Length::ViewportHeight(_)
121        | Length::Auto => false,
122    }
123}
124
125fn resolve_measured_length(
126    length: &Length,
127    reference: LayoutUnit,
128    viewport: LayoutSize,
129    min_content: LayoutUnit,
130    max_content: LayoutUnit,
131) -> Option<LayoutUnit> {
132    let resolved = match length {
133        Length::MinContent => min_content,
134        Length::MaxContent => max_content,
135        Length::FitContent(limit) => {
136            let limit = limit
137                .as_deref()
138                .and_then(|limit| {
139                    resolve_measured_length(limit, reference, viewport, min_content, max_content)
140                })
141                .unwrap_or(reference);
142            max_content.min(min_content.max(limit))
143        }
144        Length::Add(left, right) => {
145            resolve_measured_length(left, reference, viewport, min_content, max_content)?
146                + resolve_measured_length(right, reference, viewport, min_content, max_content)?
147        }
148        Length::Subtract(left, right) => {
149            resolve_measured_length(left, reference, viewport, min_content, max_content)?
150                - resolve_measured_length(right, reference, viewport, min_content, max_content)?
151        }
152        Length::Min(values) => values
153            .iter()
154            .map(|value| {
155                resolve_measured_length(value, reference, viewport, min_content, max_content)
156            })
157            .collect::<Option<Vec<_>>>()?
158            .into_iter()
159            .reduce(LayoutUnit::min)?,
160        Length::Max(values) => values
161            .iter()
162            .map(|value| {
163                resolve_measured_length(value, reference, viewport, min_content, max_content)
164            })
165            .collect::<Option<Vec<_>>>()?
166            .into_iter()
167            .reduce(LayoutUnit::max)?,
168        Length::Clamp {
169            min,
170            preferred,
171            max,
172        } => {
173            let minimum =
174                resolve_measured_length(min, reference, viewport, min_content, max_content)?;
175            let maximum =
176                resolve_measured_length(max, reference, viewport, min_content, max_content)?;
177            resolve_measured_length(preferred, reference, viewport, min_content, max_content)?
178                .clamp(minimum.min(maximum), minimum.max(maximum))
179        }
180        Length::Auto => return None,
181        Length::Points(_)
182        | Length::Percent(_)
183        | Length::ViewportWidth(_)
184        | Length::ViewportHeight(_) => {
185            length.resolve(reference, viewport.width, viewport.height)?
186        }
187    };
188    resolved.is_finite().then_some(resolved.max(0.0))
189}
190
191fn resolve_box_style(
192    style: &BoxStyle,
193    constraints: BoxConstraints,
194    viewport: LayoutSize,
195) -> LayoutOp {
196    let horizontal_reference = constraints.max_w;
197    let vertical_reference = constraints.max_h;
198    let padding = style
199        .padding
200        .as_ref()
201        .map(|padding| {
202            [
203                resolve_length(&padding[0], horizontal_reference, viewport).unwrap_or(0.0),
204                resolve_length(&padding[1], horizontal_reference, viewport).unwrap_or(0.0),
205                resolve_length(&padding[2], vertical_reference, viewport).unwrap_or(0.0),
206                resolve_length(&padding[3], vertical_reference, viewport).unwrap_or(0.0),
207            ]
208        })
209        .unwrap_or([0.0; 4]);
210    let fit_content_limit = |length: &Option<Length>, reference| match length {
211        Some(Length::FitContent(Some(limit))) => resolve_length(limit, reference, viewport),
212        _ => None,
213    };
214    let resolved_max_width = style
215        .max_width
216        .as_ref()
217        .and_then(|value| resolve_length(value, horizontal_reference, viewport));
218    let resolved_max_height = style
219        .max_height
220        .as_ref()
221        .and_then(|value| resolve_length(value, vertical_reference, viewport));
222    LayoutOp::Box {
223        width: style.width.as_ref().and_then(|value| {
224            (!matches!(value, Length::FitContent(_)))
225                .then(|| resolve_length(value, horizontal_reference, viewport))
226                .flatten()
227        }),
228        height: style.height.as_ref().and_then(|value| {
229            (!matches!(value, Length::FitContent(_)))
230                .then(|| resolve_length(value, vertical_reference, viewport))
231                .flatten()
232        }),
233        min_width: style
234            .min_width
235            .as_ref()
236            .and_then(|value| resolve_length(value, horizontal_reference, viewport)),
237        max_width: match (
238            resolved_max_width,
239            fit_content_limit(&style.width, horizontal_reference),
240        ) {
241            (Some(maximum), Some(fit)) => Some(maximum.min(fit)),
242            (maximum, fit) => maximum.or(fit),
243        },
244        min_height: style
245            .min_height
246            .as_ref()
247            .and_then(|value| resolve_length(value, vertical_reference, viewport)),
248        max_height: match (
249            resolved_max_height,
250            fit_content_limit(&style.height, vertical_reference),
251        ) {
252            (Some(maximum), Some(fit)) => Some(maximum.min(fit)),
253            (maximum, fit) => maximum.or(fit),
254        },
255        padding,
256        flex_grow: style.flex_grow.map(|value| value.0).unwrap_or(0.0),
257        flex_shrink: style.flex_shrink.map(|value| value.0).unwrap_or(1.0),
258        aspect_ratio: style.aspect_ratio.map(|value| value.0),
259    }
260}
261
262/// A 2D point in layout coordinate space.
263///
264/// Represents an (x, y) position in logical pixels. Used for node origins and
265/// coordinate calculations throughout the layout engine.
266#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
267pub struct LayoutPoint {
268    /// Horizontal position in logical pixels.
269    pub x: LayoutUnit,
270    /// Vertical position in logical pixels.
271    pub y: LayoutUnit,
272}
273
274impl LayoutPoint {
275    /// The origin point: `(0.0, 0.0)`.
276    pub const ZERO: Self = Self { x: 0.0, y: 0.0 };
277
278    /// Creates a new point from x and y coordinates.
279    pub fn new(x: LayoutUnit, y: LayoutUnit) -> Self {
280        Self { x, y }
281    }
282}
283
284/// A 2D size in layout coordinate space.
285///
286/// Represents a width and height in logical pixels. Used as the output of layout
287/// measurement and as input to constraints.
288#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
289pub struct LayoutSize {
290    /// Width in logical pixels.
291    pub width: LayoutUnit,
292    /// Height in logical pixels.
293    pub height: LayoutUnit,
294}
295
296impl LayoutSize {
297    /// A zero-sized size: `(0.0, 0.0)`.
298    pub const ZERO: Self = Self {
299        width: 0.0,
300        height: 0.0,
301    };
302
303    /// Creates a new size from width and height values.
304    pub fn new(width: LayoutUnit, height: LayoutUnit) -> Self {
305        Self { width, height }
306    }
307}
308
309/// Minimum and maximum width/height bounds passed from parent to child during layout.
310///
311/// `BoxConstraints` is the fundamental mechanism for top-down size negotiation. A
312/// parent creates constraints describing the space available to a child, and the
313/// child returns a [`LayoutSize`] that satisfies those constraints.
314///
315/// There are two common patterns:
316///
317/// * **Tight constraints** -- `min == max`, forcing the child to a specific size.
318///   Created with [`BoxConstraints::tight`].
319/// * **Loose constraints** -- `min == 0`, giving the child freedom to be smaller
320///   than the max. Created with [`BoxConstraints::loose`].
321///
322/// # Example
323///
324/// ```rust
325/// use fission_layout::{BoxConstraints, LayoutSize};
326///
327/// let constraints = BoxConstraints::loose(800.0, 600.0);
328/// assert_eq!(constraints.min_w, 0.0);
329///
330/// let child_wants = LayoutSize::new(300.0, 200.0);
331/// let actual = constraints.constrain(child_wants);
332/// assert_eq!(actual, child_wants); // fits within the constraints
333/// ```
334#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
335pub struct BoxConstraints {
336    /// Minimum width the child must occupy.
337    pub min_w: LayoutUnit,
338    /// Maximum width the child may occupy. Can be `f32::INFINITY` for unbounded.
339    pub max_w: LayoutUnit,
340    /// Minimum height the child must occupy.
341    pub min_h: LayoutUnit,
342    /// Maximum height the child may occupy. Can be `f32::INFINITY` for unbounded.
343    pub max_h: LayoutUnit,
344}
345
346impl BoxConstraints {
347    /// Creates tight constraints that force a child to exactly `size`.
348    ///
349    /// Both min and max are set to the given width/height.
350    pub fn tight(size: LayoutSize) -> Self {
351        Self {
352            min_w: size.width,
353            max_w: size.width,
354            min_h: size.height,
355            max_h: size.height,
356        }
357    }
358
359    /// Creates loose constraints: min is zero, max is the given values.
360    ///
361    /// The child can be anywhere from zero to `max_w` x `max_h`.
362    pub fn loose(max_w: LayoutUnit, max_h: LayoutUnit) -> Self {
363        Self {
364            min_w: 0.0,
365            max_w,
366            min_h: 0.0,
367            max_h,
368        }
369    }
370
371    /// Returns `true` if the maximum width is finite (not `f32::INFINITY`).
372    pub fn is_width_bounded(&self) -> bool {
373        self.max_w.is_finite()
374    }
375
376    /// Returns `true` if the maximum height is finite (not `f32::INFINITY`).
377    pub fn is_height_bounded(&self) -> bool {
378        self.max_h.is_finite()
379    }
380
381    /// Clamps `size` so it falls within these constraints.
382    ///
383    /// The returned width is `max(min_w, min(size.width, max_w))`, and likewise
384    /// for height.
385    pub fn constrain(&self, size: LayoutSize) -> LayoutSize {
386        LayoutSize {
387            width: size.width.max(self.min_w).min(self.max_w),
388            height: size.height.max(self.min_h).min(self.max_h),
389        }
390    }
391
392    /// Returns the smallest size that satisfies these constraints: `(min_w, min_h)`.
393    pub fn smallest(&self) -> LayoutSize {
394        LayoutSize::new(self.min_w, self.min_h)
395    }
396
397    /// Returns new constraints shrunk inward by `padding`.
398    ///
399    /// Padding is `[left, right, top, bottom]`. Horizontal padding reduces the
400    /// width bounds; vertical padding reduces the height bounds. Bounds are
401    /// clamped to zero.
402    pub fn deflate(&self, padding: [LayoutUnit; 4]) -> Self {
403        let horiz = padding[0] + padding[1];
404        let vert = padding[2] + padding[3];
405        let max_w = (self.max_w - horiz).max(0.0);
406        let max_h = (self.max_h - vert).max(0.0);
407        let min_w = (self.min_w - horiz).max(0.0).min(max_w);
408        let min_h = (self.min_h - vert).max(0.0).min(max_h);
409        Self {
410            min_w,
411            max_w,
412            min_h,
413            max_h,
414        }
415    }
416
417    /// Makes the constraints tighter by fixing the width and/or height.
418    ///
419    /// If `width` is `Some`, both `min_w` and `max_w` are set to that value
420    /// (clamped to the current bounds). Same for `height`.
421    pub fn tighten(&self, width: Option<LayoutUnit>, height: Option<LayoutUnit>) -> Self {
422        let mut out = *self;
423        if let Some(w) = width {
424            let clamped = w.min(out.max_w).max(out.min_w);
425            out.min_w = clamped;
426            out.max_w = clamped;
427        }
428        if let Some(h) = height {
429            let clamped = h.min(out.max_h).max(out.min_h);
430            out.min_h = clamped;
431            out.max_h = clamped;
432        }
433        if out.max_w < out.min_w {
434            out.max_w = out.min_w;
435        }
436        if out.max_h < out.min_h {
437            out.max_h = out.min_h;
438        }
439        out
440    }
441
442    /// Applies additional min/max constraints on top of the current ones.
443    ///
444    /// Each `Some` value further restricts the corresponding bound. `None` values
445    /// leave the bound unchanged. After adjustment, max is clamped to be at least
446    /// min.
447    pub fn apply_min_max(
448        &self,
449        min_w: Option<LayoutUnit>,
450        max_w: Option<LayoutUnit>,
451        min_h: Option<LayoutUnit>,
452        max_h: Option<LayoutUnit>,
453    ) -> Self {
454        let mut out = *self;
455        if let Some(w) = min_w {
456            out.min_w = out.min_w.max(w);
457        }
458        if let Some(h) = min_h {
459            out.min_h = out.min_h.max(h);
460        }
461        if let Some(w) = max_w {
462            out.max_w = out.max_w.min(w);
463        }
464        if let Some(h) = max_h {
465            out.max_h = out.max_h.min(h);
466        }
467        if out.max_w < out.min_w {
468            out.max_w = out.min_w;
469        }
470        if out.max_h < out.min_h {
471            out.max_h = out.min_h;
472        }
473        out
474    }
475
476    /// Returns loose constraints with the same maximums but zeroed minimums.
477    ///
478    /// Useful when a parent wants to let a child be as small as it likes while
479    /// still capping its maximum size.
480    pub fn loosen(&self) -> Self {
481        Self {
482            min_w: 0.0,
483            max_w: self.max_w,
484            min_h: 0.0,
485            max_h: self.max_h,
486        }
487    }
488}
489
490#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
491struct MeasureCacheKey {
492    node_id: u128,
493    min_w: u32,
494    max_w: u32,
495    min_h: u32,
496    max_h: u32,
497}
498
499impl MeasureCacheKey {
500    fn new(node_id: WidgetId, constraints: BoxConstraints) -> Self {
501        Self {
502            node_id: node_id.as_u128(),
503            min_w: constraints.min_w.to_bits(),
504            max_w: constraints.max_w.to_bits(),
505            min_h: constraints.min_h.to_bits(),
506            max_h: constraints.max_h.to_bits(),
507        }
508    }
509}
510
511#[derive(Debug, Clone, Default)]
512struct LayoutGraphValidationState {
513    duplicate_nodes: Vec<WidgetId>,
514    missing_parent_refs: Vec<(WidgetId, WidgetId)>,
515    missing_child_refs: Vec<(WidgetId, WidgetId)>,
516    parent_child_mismatches: Vec<(WidgetId, WidgetId, Option<WidgetId>)>,
517    cycle_nodes: Vec<WidgetId>,
518    root_nodes: Vec<WidgetId>,
519}
520
521impl LayoutGraphValidationState {
522    fn first_error(&self) -> Option<anyhow::Error> {
523        if let Some(node_id) = self.duplicate_nodes.first() {
524            return Some(anyhow::anyhow!(
525                "[layout] duplicate node id encountered during graph build: {:?}",
526                node_id
527            ));
528        }
529        if let Some((node_id, parent_id)) = self.missing_parent_refs.first() {
530            return Some(anyhow::anyhow!(
531                "[layout] node {:?} references missing parent {:?}",
532                node_id,
533                parent_id
534            ));
535        }
536        if let Some((node_id, child_id)) = self.missing_child_refs.first() {
537            return Some(anyhow::anyhow!(
538                "[layout] node {:?} references missing child {:?}",
539                node_id,
540                child_id
541            ));
542        }
543        if let Some((parent_id, child_id, actual_parent)) = self.parent_child_mismatches.first() {
544            return Some(anyhow::anyhow!(
545                "[layout] parent/child mismatch parent={:?} child={:?} child.parent_id={:?}",
546                parent_id,
547                child_id,
548                actual_parent
549            ));
550        }
551        if let Some(node_id) = self.cycle_nodes.first() {
552            return Some(anyhow::anyhow!(
553                "[layout] cycle detected while rebuilding graph at {:?}",
554                node_id
555            ));
556        }
557        None
558    }
559}
560
561#[derive(Debug, Clone, Default)]
562struct LayoutGraphState {
563    graph_version: u64,
564    last_layout_version: Option<u64>,
565    node_order: Vec<WidgetId>,
566    node_fingerprints: HashMap<WidgetId, u64>,
567    nodes: HashMap<WidgetId, LayoutInputNode>,
568    parents: HashMap<WidgetId, Option<WidgetId>>,
569    children: HashMap<WidgetId, Vec<WidgetId>>,
570    roots: Vec<WidgetId>,
571    validation: LayoutGraphValidationState,
572}
573
574#[derive(Debug, Clone, Default)]
575struct IncrementalLayoutReuseState {
576    previous_snapshot: LayoutSnapshot,
577    dirty_ancestors: HashSet<WidgetId>,
578}
579
580impl LayoutGraphState {
581    fn is_empty(&self) -> bool {
582        self.nodes.is_empty()
583    }
584
585    fn mark_layout_complete(&mut self) {
586        self.last_layout_version = Some(self.graph_version);
587    }
588
589    fn matches_input_nodes(&self, input_nodes: &[LayoutInputNode]) -> bool {
590        if self.nodes.len() != input_nodes.len() || self.node_order.len() != input_nodes.len() {
591            return false;
592        }
593
594        for (expected_id, node) in self.node_order.iter().zip(input_nodes.iter()) {
595            if *expected_id != node.id {
596                return false;
597            }
598            let Some(existing) = self.node_fingerprints.get(&node.id) else {
599                return false;
600            };
601            if *existing != layout_input_fingerprint(node) {
602                return false;
603            }
604        }
605
606        true
607    }
608
609    fn from_input_nodes(input_nodes: &[LayoutInputNode], version: u64) -> Self {
610        let mut state = Self {
611            graph_version: version,
612            ..Self::default()
613        };
614        state.replace_all_nodes(input_nodes);
615        state
616    }
617
618    fn replace_all_nodes(&mut self, input_nodes: &[LayoutInputNode]) {
619        self.node_order.clear();
620        self.node_fingerprints.clear();
621        self.nodes.clear();
622        self.last_layout_version = None;
623
624        let mut validation = LayoutGraphValidationState::default();
625        let mut seen = HashSet::new();
626        for node in input_nodes {
627            if !seen.insert(node.id) {
628                validation.duplicate_nodes.push(node.id);
629            } else {
630                self.node_order.push(node.id);
631            }
632            self.node_fingerprints
633                .insert(node.id, layout_input_fingerprint(node));
634            self.nodes.insert(node.id, node.clone());
635        }
636
637        self.rebuild_topology(validation);
638    }
639
640    fn update_nodes(&mut self, input_nodes: &[LayoutInputNode]) {
641        let mut validation = LayoutGraphValidationState::default();
642        let mut seen = HashSet::new();
643        let mut next_order = Vec::with_capacity(input_nodes.len());
644        let mut next_fingerprints = HashMap::with_capacity(input_nodes.len());
645        let mut next_nodes = HashMap::with_capacity(input_nodes.len());
646
647        for node in input_nodes {
648            if !seen.insert(node.id) {
649                validation.duplicate_nodes.push(node.id);
650                continue;
651            }
652            next_order.push(node.id);
653            next_fingerprints.insert(node.id, layout_input_fingerprint(node));
654            next_nodes.insert(node.id, node.clone());
655        }
656
657        self.node_order = next_order;
658        self.node_fingerprints = next_fingerprints;
659        self.nodes = next_nodes;
660        self.last_layout_version = None;
661        self.rebuild_topology(validation);
662    }
663
664    fn rebuild_topology(&mut self, mut validation: LayoutGraphValidationState) {
665        self.parents.clear();
666        self.children.clear();
667        self.roots.clear();
668
669        for node_id in &self.node_order {
670            let Some(node) = self.nodes.get(node_id) else {
671                continue;
672            };
673            self.parents.insert(*node_id, node.parent_id);
674            self.children.insert(*node_id, node.children_ids.clone());
675            if node.parent_id.is_none() {
676                self.roots.push(*node_id);
677            } else if let Some(parent_id) = node.parent_id {
678                if !self.nodes.contains_key(&parent_id) {
679                    validation.missing_parent_refs.push((*node_id, parent_id));
680                }
681            }
682        }
683
684        for node_id in &self.node_order {
685            let Some(node) = self.nodes.get(node_id) else {
686                continue;
687            };
688            for child_id in &node.children_ids {
689                let Some(child) = self.nodes.get(child_id) else {
690                    validation.missing_child_refs.push((*node_id, *child_id));
691                    continue;
692                };
693                if child.parent_id != Some(*node_id) {
694                    validation
695                        .parent_child_mismatches
696                        .push((*node_id, *child_id, child.parent_id));
697                }
698            }
699        }
700
701        validation.root_nodes = self.roots.clone();
702        validation.cycle_nodes = self.detect_cycle_nodes();
703        self.validation = validation;
704    }
705
706    fn node(&self, node_id: WidgetId) -> Option<&LayoutInputNode> {
707        self.nodes.get(&node_id)
708    }
709
710    fn children_of(&self, node_id: WidgetId) -> &[WidgetId] {
711        self.children
712            .get(&node_id)
713            .map(Vec::as_slice)
714            .unwrap_or(&[])
715    }
716
717    fn parent_of(&self, node_id: WidgetId) -> Option<WidgetId> {
718        self.parents.get(&node_id).copied().flatten()
719    }
720
721    fn ordered_nodes(&self) -> impl Iterator<Item = &LayoutInputNode> {
722        self.node_order
723            .iter()
724            .filter_map(|node_id| self.nodes.get(node_id))
725    }
726
727    fn detect_cycle_nodes(&self) -> Vec<WidgetId> {
728        fn dfs(
729            node_id: WidgetId,
730            children: &HashMap<WidgetId, Vec<WidgetId>>,
731            visited: &mut HashSet<WidgetId>,
732            stack: &mut HashSet<WidgetId>,
733            cycle_nodes: &mut Vec<WidgetId>,
734        ) {
735            if stack.contains(&node_id) {
736                cycle_nodes.push(node_id);
737                return;
738            }
739            if !visited.insert(node_id) {
740                return;
741            }
742
743            stack.insert(node_id);
744            if let Some(child_nodes) = children.get(&node_id) {
745                for child_id in child_nodes {
746                    dfs(*child_id, children, visited, stack, cycle_nodes);
747                }
748            }
749            stack.remove(&node_id);
750        }
751
752        let mut visited = HashSet::new();
753        let mut stack = HashSet::new();
754        let mut cycle_nodes = Vec::new();
755        for node_id in &self.node_order {
756            dfs(
757                *node_id,
758                &self.children,
759                &mut visited,
760                &mut stack,
761                &mut cycle_nodes,
762            );
763        }
764        cycle_nodes.sort_by_key(|node_id| node_id.as_u128());
765        cycle_nodes.dedup();
766        cycle_nodes
767    }
768}
769
770#[cfg(test)]
771mod tests {
772    use super::{
773        resolve_length, LayoutEngine, LayoutGraphState, LayoutInputNode, LayoutSize, TextMeasurer,
774        DEFAULT_RICH_TEXT_HIT_TEST_FONT_SIZE,
775    };
776    use fission_ir::op::{
777        BoxStyle, Color, FontStyle, GridTrack, Length, ResponsiveCondition, ResponsiveQuery,
778        TextRun, TextStyle,
779    };
780    use fission_ir::{GridPlacement, LayoutOp, WidgetId};
781    use std::sync::atomic::{AtomicU32, Ordering};
782    use std::sync::Arc;
783
784    fn box_node(
785        id: WidgetId,
786        parent_id: Option<WidgetId>,
787        children_ids: Vec<WidgetId>,
788    ) -> LayoutInputNode {
789        LayoutInputNode {
790            id,
791            parent_id,
792            op: LayoutOp::Box {
793                width: Some(40.0),
794                height: Some(20.0),
795                min_width: None,
796                max_width: None,
797                min_height: None,
798                max_height: None,
799                padding: [0.0; 4],
800                flex_grow: 0.0,
801                flex_shrink: 0.0,
802                aspect_ratio: None,
803            },
804            children_ids,
805            debug_name: format!("node-{}", id.as_u128()),
806            width: Some(40.0),
807            height: Some(20.0),
808            flex_grow: 0.0,
809            flex_shrink: 0.0,
810            rich_text: None,
811        }
812    }
813
814    struct RecordingMeasurer {
815        last_font_size_bits: AtomicU32,
816    }
817
818    struct WrappingMeasurer;
819
820    impl TextMeasurer for WrappingMeasurer {
821        fn measure(&self, text: &str, _font_size: f32, available_width: Option<f32>) -> (f32, f32) {
822            let natural_width = text.chars().count() as f32 * 10.0;
823            match available_width.filter(|width| *width > 0.0 && natural_width > *width) {
824                Some(width) => (width, (natural_width / width).ceil() * 20.0),
825                None => (natural_width, 20.0),
826            }
827        }
828    }
829
830    fn node(
831        id: WidgetId,
832        parent_id: Option<WidgetId>,
833        children_ids: Vec<WidgetId>,
834        op: LayoutOp,
835    ) -> LayoutInputNode {
836        let (width, height, flex_grow, flex_shrink) = match &op {
837            LayoutOp::Box {
838                width,
839                height,
840                flex_grow,
841                flex_shrink,
842                ..
843            } => (*width, *height, *flex_grow, *flex_shrink),
844            LayoutOp::StyledBox {
845                flex_grow,
846                flex_shrink,
847                ..
848            } => (None, None, *flex_grow, *flex_shrink),
849            _ => (None, None, 0.0, 1.0),
850        };
851        LayoutInputNode {
852            id,
853            parent_id,
854            op,
855            children_ids,
856            debug_name: format!("node-{}", id.as_u128()),
857            width,
858            height,
859            flex_grow,
860            flex_shrink,
861            rich_text: None,
862        }
863    }
864
865    fn text_run(text: &str) -> TextRun {
866        TextRun {
867            text: text.to_owned(),
868            style: TextStyle {
869                font_size: 16.0,
870                color: Color::BLACK,
871                underline: false,
872                font_family: None,
873                locale: None,
874                font_weight: 400,
875                font_style: FontStyle::Normal,
876                line_height: None,
877                letter_spacing: 0.0,
878                background_color: None,
879            },
880        }
881    }
882
883    impl RecordingMeasurer {
884        fn new() -> Self {
885            Self {
886                last_font_size_bits: AtomicU32::new(f32::NAN.to_bits()),
887            }
888        }
889
890        fn last_font_size(&self) -> f32 {
891            f32::from_bits(self.last_font_size_bits.load(Ordering::SeqCst))
892        }
893    }
894
895    impl TextMeasurer for RecordingMeasurer {
896        fn measure(
897            &self,
898            _text: &str,
899            _font_size: f32,
900            _available_width: Option<f32>,
901        ) -> (f32, f32) {
902            (0.0, 0.0)
903        }
904
905        fn hit_test(
906            &self,
907            _text: &str,
908            font_size: f32,
909            _available_width: Option<f32>,
910            _x: f32,
911            _y: f32,
912        ) -> usize {
913            self.last_font_size_bits
914                .store(font_size.to_bits(), Ordering::SeqCst);
915            0
916        }
917    }
918
919    #[test]
920    fn matches_input_nodes_rejects_reordered_flattened_inputs() {
921        let root = WidgetId::from_u128(1);
922        let first = WidgetId::from_u128(2);
923        let second = WidgetId::from_u128(3);
924        let canonical = vec![
925            box_node(root, None, vec![first, second]),
926            box_node(first, Some(root), vec![]),
927            box_node(second, Some(root), vec![]),
928        ];
929        let reordered = vec![
930            box_node(root, None, vec![first, second]),
931            box_node(second, Some(root), vec![]),
932            box_node(first, Some(root), vec![]),
933        ];
934
935        let state = LayoutGraphState::from_input_nodes(&canonical, 1);
936        assert!(!state.matches_input_nodes(&reordered));
937    }
938
939    #[test]
940    fn update_refreshes_node_order_for_reordered_flattened_inputs() {
941        let root = WidgetId::from_u128(10);
942        let first = WidgetId::from_u128(11);
943        let second = WidgetId::from_u128(12);
944        let canonical = vec![
945            box_node(root, None, vec![first, second]),
946            box_node(first, Some(root), vec![]),
947            box_node(second, Some(root), vec![]),
948        ];
949        let reordered = vec![
950            box_node(root, None, vec![first, second]),
951            box_node(second, Some(root), vec![]),
952            box_node(first, Some(root), vec![]),
953        ];
954
955        let mut engine = LayoutEngine::new();
956        engine.update(&canonical);
957        engine.update(&reordered);
958
959        let ordered = engine
960            .graph_state
961            .ordered_nodes()
962            .map(|node| node.id)
963            .collect::<Vec<_>>();
964        assert_eq!(ordered, vec![root, second, first]);
965    }
966
967    #[test]
968    fn rich_text_hit_test_uses_body_font_size_when_runs_are_empty() {
969        let measurer = RecordingMeasurer::new();
970
971        measurer.hit_test_rich(&[], None, 4.0, 2.0);
972
973        assert_eq!(
974            measurer.last_font_size(),
975            DEFAULT_RICH_TEXT_HIT_TEST_FONT_SIZE
976        );
977    }
978
979    #[test]
980    fn rich_text_hit_test_uses_first_run_font_size_when_present() {
981        let measurer = RecordingMeasurer::new();
982        let runs = vec![TextRun {
983            text: "Hello".to_string(),
984            style: TextStyle {
985                font_size: 18.0,
986                color: Color::BLACK,
987                underline: false,
988                font_family: None,
989                locale: None,
990                font_weight: 400,
991                font_style: FontStyle::Normal,
992                line_height: None,
993                letter_spacing: 0.0,
994                background_color: None,
995            },
996        }];
997
998        measurer.hit_test_rich(&runs, None, 4.0, 2.0);
999
1000        assert_eq!(measurer.last_font_size(), 18.0);
1001    }
1002
1003    #[test]
1004    fn typed_lengths_resolve_calc_clamp_and_viewport_units() {
1005        let viewport = LayoutSize::new(1200.0, 800.0);
1006        let calculated = Length::percent(50.0) - Length::points(24.0);
1007        let clamped = Length::clamp(Length::points(100.0), calculated, Length::vw(40.0));
1008
1009        assert_eq!(resolve_length(&clamped, 600.0, viewport), Some(276.0));
1010        assert_eq!(
1011            resolve_length(&Length::vh(25.0), 0.0, viewport),
1012            Some(200.0)
1013        );
1014        assert_eq!(
1015            Length::points(10.0).resolve(0.0, viewport.width, viewport.height),
1016            Some(10.0)
1017        );
1018        assert_eq!(
1019            (Length::points(10.0) - Length::points(24.0)).resolve(
1020                0.0,
1021                viewport.width,
1022                viewport.height
1023            ),
1024            Some(-14.0),
1025            "signed expressions remain available to typed positioning"
1026        );
1027        assert_eq!(
1028            Length::min(vec![Length::points(10.0), Length::MaxContent]).resolve(
1029                100.0,
1030                viewport.width,
1031                viewport.height
1032            ),
1033            None,
1034            "intrinsic expressions must be measured rather than partially resolved"
1035        );
1036    }
1037
1038    #[test]
1039    fn responsive_container_query_selects_from_parent_constraints() {
1040        let root = WidgetId::from_u128(100);
1041        let responsive = WidgetId::from_u128(101);
1042        let compact = WidgetId::from_u128(102);
1043        let wide = WidgetId::from_u128(103);
1044        let nodes = vec![
1045            node(
1046                root,
1047                None,
1048                vec![responsive],
1049                LayoutOp::Box {
1050                    width: Some(240.0),
1051                    height: Some(100.0),
1052                    min_width: None,
1053                    max_width: None,
1054                    min_height: None,
1055                    max_height: None,
1056                    padding: [0.0; 4],
1057                    flex_grow: 0.0,
1058                    flex_shrink: 1.0,
1059                    aspect_ratio: None,
1060                },
1061            ),
1062            node(
1063                responsive,
1064                Some(root),
1065                vec![compact, wide],
1066                LayoutOp::Responsive {
1067                    query: ResponsiveQuery::Container,
1068                    cases: vec![ResponsiveCondition {
1069                        min_width: None,
1070                        max_width: Some(300.0),
1071                    }],
1072                },
1073            ),
1074            box_node(compact, Some(responsive), vec![]),
1075            box_node(wide, Some(responsive), vec![]),
1076        ];
1077        let mut engine = LayoutEngine::new();
1078        let snapshot = engine
1079            .compute_layout(&nodes, root, LayoutSize::new(800.0, 600.0), &|_| 0.0)
1080            .expect("responsive layout");
1081
1082        assert!(snapshot.nodes.contains_key(&compact));
1083        assert!(!snapshot.nodes.contains_key(&wide));
1084    }
1085
1086    #[test]
1087    fn grid_repeat_and_spans_are_applied_by_the_layout_engine() {
1088        let root = WidgetId::from_u128(200);
1089        let first = WidgetId::from_u128(201);
1090        let second = WidgetId::from_u128(202);
1091        let nodes = vec![
1092            node(
1093                root,
1094                None,
1095                vec![first, second],
1096                LayoutOp::Grid {
1097                    columns: vec![GridTrack::repeat(2, vec![GridTrack::Points(50.0)])],
1098                    rows: vec![GridTrack::Points(20.0)],
1099                    column_gap: Some(10.0),
1100                    row_gap: None,
1101                    padding: [0.0; 4],
1102                },
1103            ),
1104            box_node(first, Some(root), vec![]),
1105            box_node(second, Some(root), vec![]),
1106        ];
1107        let mut engine = LayoutEngine::new();
1108        let snapshot = engine
1109            .compute_layout(&nodes, root, LayoutSize::new(110.0, 20.0), &|_| 0.0)
1110            .expect("grid layout");
1111
1112        assert_eq!(snapshot.nodes[&first].rect.x(), 0.0);
1113        assert_eq!(snapshot.nodes[&second].rect.x(), 60.0);
1114    }
1115
1116    #[test]
1117    fn auto_grid_items_advance_past_occupied_spans() {
1118        let root = WidgetId::from_u128(250);
1119        let first = WidgetId::from_u128(251);
1120        let first_child = WidgetId::from_u128(252);
1121        let second = WidgetId::from_u128(253);
1122        let second_child = WidgetId::from_u128(254);
1123        let nodes = vec![
1124            node(
1125                root,
1126                None,
1127                vec![first, second],
1128                LayoutOp::Grid {
1129                    columns: vec![GridTrack::Points(50.0), GridTrack::Points(50.0)],
1130                    rows: vec![],
1131                    column_gap: None,
1132                    row_gap: None,
1133                    padding: [0.0; 4],
1134                },
1135            ),
1136            node(
1137                first,
1138                Some(root),
1139                vec![first_child],
1140                LayoutOp::GridItem {
1141                    row_start: GridPlacement::Auto,
1142                    row_end: GridPlacement::Auto,
1143                    col_start: GridPlacement::Auto,
1144                    col_end: GridPlacement::Span(2),
1145                },
1146            ),
1147            box_node(first_child, Some(first), vec![]),
1148            node(
1149                second,
1150                Some(root),
1151                vec![second_child],
1152                LayoutOp::GridItem {
1153                    row_start: GridPlacement::Auto,
1154                    row_end: GridPlacement::Auto,
1155                    col_start: GridPlacement::Auto,
1156                    col_end: GridPlacement::Auto,
1157                },
1158            ),
1159            box_node(second_child, Some(second), vec![]),
1160        ];
1161        let mut engine = LayoutEngine::new();
1162        let snapshot = engine
1163            .compute_layout(&nodes, root, LayoutSize::new(100.0, 100.0), &|_| 0.0)
1164            .expect("auto grid layout");
1165
1166        assert_eq!(snapshot.nodes[&first].rect.x(), 0.0);
1167        assert_eq!(snapshot.nodes[&first].rect.width(), 100.0);
1168        assert_eq!(snapshot.nodes[&second].rect.x(), 0.0);
1169        assert_eq!(snapshot.nodes[&second].rect.y(), 20.0);
1170    }
1171
1172    #[test]
1173    fn fixed_text_box_retains_natural_size_for_overflow_inspection() {
1174        let root = WidgetId::from_u128(300);
1175        let mut text = node(
1176            root,
1177            None,
1178            vec![],
1179            LayoutOp::StyledBox {
1180                style: BoxStyle {
1181                    width: Some(Length::Points(40.0)),
1182                    height: Some(Length::Points(10.0)),
1183                    ..Default::default()
1184                },
1185                flex_grow: 0.0,
1186                flex_shrink: 1.0,
1187            },
1188        );
1189        text.rich_text = Some(vec![text_run("overflowing text")]);
1190        let nodes = vec![text];
1191        let mut engine = LayoutEngine::new().with_measurer(Arc::new(WrappingMeasurer));
1192        let snapshot = engine
1193            .compute_layout(&nodes, root, LayoutSize::new(100.0, 100.0), &|_| 0.0)
1194            .expect("text layout");
1195        let inspection = engine
1196            .inspect_node(&snapshot, root)
1197            .expect("layout inspection");
1198
1199        assert_eq!(inspection.laid_out.width(), 40.0);
1200        assert_eq!(inspection.laid_out.height(), 10.0);
1201        assert!(inspection.measured.height() > inspection.laid_out.height());
1202        assert!(inspection.overflow_y);
1203        assert_eq!(
1204            inspection.constrained, inspection.laid_out,
1205            "fixed constraints should match final bounds"
1206        );
1207    }
1208
1209    #[test]
1210    fn max_content_box_propagates_unwrapped_text_width() {
1211        let root = WidgetId::from_u128(400);
1212        let text_id = WidgetId::from_u128(401);
1213        let mut text = node(
1214            text_id,
1215            Some(root),
1216            vec![],
1217            LayoutOp::Box {
1218                width: None,
1219                height: None,
1220                min_width: None,
1221                max_width: None,
1222                min_height: None,
1223                max_height: None,
1224                padding: [0.0; 4],
1225                flex_grow: 0.0,
1226                flex_shrink: 1.0,
1227                aspect_ratio: None,
1228            },
1229        );
1230        text.rich_text = Some(vec![text_run("hello world")]);
1231        let nodes = vec![
1232            node(
1233                root,
1234                None,
1235                vec![text_id],
1236                LayoutOp::StyledBox {
1237                    style: BoxStyle {
1238                        width: Some(Length::MaxContent),
1239                        ..Default::default()
1240                    },
1241                    flex_grow: 0.0,
1242                    flex_shrink: 1.0,
1243                },
1244            ),
1245            text,
1246        ];
1247        let mut engine = LayoutEngine::new().with_measurer(Arc::new(WrappingMeasurer));
1248        let snapshot = engine
1249            .compute_layout(&nodes, root, LayoutSize::new(300.0, 100.0), &|_| 0.0)
1250            .expect("max-content layout");
1251
1252        assert_eq!(snapshot.nodes[&root].rect.width(), 110.0);
1253        assert_eq!(snapshot.nodes[&text_id].rect.width(), 110.0);
1254    }
1255
1256    #[test]
1257    fn intrinsic_lengths_participate_in_clamp_expressions() {
1258        let root = WidgetId::from_u128(450);
1259        let mut text = node(
1260            root,
1261            None,
1262            vec![],
1263            LayoutOp::StyledBox {
1264                style: BoxStyle {
1265                    width: Some(Length::clamp(
1266                        Length::points(50.0),
1267                        Length::MaxContent,
1268                        Length::points(80.0),
1269                    )),
1270                    ..Default::default()
1271                },
1272                flex_grow: 0.0,
1273                flex_shrink: 1.0,
1274            },
1275        );
1276        text.rich_text = Some(vec![text_run("hello world")]);
1277        let mut engine = LayoutEngine::new().with_measurer(Arc::new(WrappingMeasurer));
1278        let snapshot = engine
1279            .compute_layout(&[text], root, LayoutSize::new(300.0, 100.0), &|_| 0.0)
1280            .expect("intrinsic clamp layout");
1281
1282        assert_eq!(snapshot.nodes[&root].rect.width(), 80.0);
1283        assert_eq!(snapshot.nodes[&root].rect.height(), 40.0);
1284    }
1285
1286    #[test]
1287    fn margin_wrapper_keeps_percentage_width_relative_to_the_containing_box() {
1288        let outer = WidgetId::from_u128(455);
1289        let inner = WidgetId::from_u128(456);
1290        let nodes = vec![
1291            node(
1292                outer,
1293                None,
1294                vec![inner],
1295                LayoutOp::StyledBox {
1296                    style: BoxStyle {
1297                        width: Some(
1298                            Length::percent(50.0) + Length::points(12.0) + Length::points(12.0),
1299                        ),
1300                        padding: Some(Length::all(Length::points(12.0))),
1301                        alignment: fission_ir::op::BoxAlignment::Stretch,
1302                        ..Default::default()
1303                    },
1304                    flex_grow: 0.0,
1305                    flex_shrink: 1.0,
1306                },
1307            ),
1308            node(
1309                inner,
1310                Some(outer),
1311                vec![],
1312                LayoutOp::StyledBox {
1313                    style: BoxStyle {
1314                        width: Some(Length::percent(100.0)),
1315                        height: Some(Length::points(20.0)),
1316                        ..Default::default()
1317                    },
1318                    flex_grow: 0.0,
1319                    flex_shrink: 1.0,
1320                },
1321            ),
1322        ];
1323        let mut engine = LayoutEngine::new();
1324        let snapshot = engine
1325            .compute_layout(&nodes, outer, LayoutSize::new(200.0, 100.0), &|_| 0.0)
1326            .expect("margin layout");
1327
1328        assert_eq!(snapshot.nodes[&outer].rect.width(), 124.0);
1329        assert_eq!(snapshot.nodes[&inner].rect.width(), 100.0);
1330        assert_eq!(snapshot.nodes[&inner].rect.x(), 12.0);
1331    }
1332
1333    #[test]
1334    fn fit_content_height_preserves_wrapped_text_height() {
1335        let root = WidgetId::from_u128(460);
1336        let mut text = node(
1337            root,
1338            None,
1339            vec![],
1340            LayoutOp::StyledBox {
1341                style: BoxStyle {
1342                    width: Some(Length::points(40.0)),
1343                    height: Some(Length::fit_content(None)),
1344                    ..Default::default()
1345                },
1346                flex_grow: 0.0,
1347                flex_shrink: 1.0,
1348            },
1349        );
1350        text.rich_text = Some(vec![text_run("abcdefgh")]);
1351        let mut engine = LayoutEngine::new().with_measurer(Arc::new(WrappingMeasurer));
1352        let snapshot = engine
1353            .compute_layout(&[text], root, LayoutSize::new(300.0, 100.0), &|_| 0.0)
1354            .expect("fit-content height layout");
1355
1356        assert_eq!(snapshot.nodes[&root].rect.width(), 40.0);
1357        assert_eq!(snapshot.nodes[&root].rect.height(), 40.0);
1358    }
1359
1360    #[test]
1361    fn typed_position_offsets_resolve_against_the_parent_box() {
1362        let root = WidgetId::from_u128(500);
1363        let positioned = WidgetId::from_u128(501);
1364        let child = WidgetId::from_u128(502);
1365        let nodes = vec![
1366            node(root, None, vec![positioned], LayoutOp::ZStack),
1367            node(
1368                positioned,
1369                Some(root),
1370                vec![child],
1371                LayoutOp::PositionedLengths {
1372                    left: Some(Length::Percent(25.0)),
1373                    top: Some(Length::Percent(10.0)),
1374                    right: None,
1375                    bottom: None,
1376                    width: Some(Length::Points(50.0)),
1377                    height: Some(Length::Points(20.0)),
1378                },
1379            ),
1380            node(
1381                child,
1382                Some(positioned),
1383                vec![],
1384                LayoutOp::Box {
1385                    width: None,
1386                    height: None,
1387                    min_width: None,
1388                    max_width: None,
1389                    min_height: None,
1390                    max_height: None,
1391                    padding: [0.0; 4],
1392                    flex_grow: 0.0,
1393                    flex_shrink: 1.0,
1394                    aspect_ratio: None,
1395                },
1396            ),
1397        ];
1398        let mut engine = LayoutEngine::new();
1399        let snapshot = engine
1400            .compute_layout(&nodes, root, LayoutSize::new(200.0, 100.0), &|_| 0.0)
1401            .expect("typed positioned layout");
1402
1403        assert_eq!(snapshot.nodes[&child].rect.x(), 50.0);
1404        assert_eq!(snapshot.nodes[&child].rect.y(), 10.0);
1405        assert_eq!(snapshot.nodes[&child].rect.width(), 50.0);
1406        assert_eq!(snapshot.nodes[&child].rect.height(), 20.0);
1407    }
1408}
1409
1410fn layout_input_fingerprint(node: &LayoutInputNode) -> u64 {
1411    let mut hasher = DefaultHasher::new();
1412    format!("{node:?}").hash(&mut hasher);
1413    hasher.finish()
1414}
1415
1416fn intersect_rect(left: LayoutRect, right: LayoutRect) -> LayoutRect {
1417    let x = left.x().max(right.x());
1418    let y = left.y().max(right.y());
1419    let right_edge = left.right().min(right.right());
1420    let bottom_edge = left.bottom().min(right.bottom());
1421    LayoutRect::new(x, y, (right_edge - x).max(0.0), (bottom_edge - y).max(0.0))
1422}
1423
1424fn union_rect(left: LayoutRect, right: LayoutRect) -> LayoutRect {
1425    let x = left.x().min(right.x());
1426    let y = left.y().min(right.y());
1427    let right_edge = left.right().max(right.right());
1428    let bottom_edge = left.bottom().max(right.bottom());
1429    LayoutRect::new(x, y, right_edge - x, bottom_edge - y)
1430}
1431
1432/// An axis-aligned rectangle: an origin point plus a size.
1433///
1434/// `LayoutRect` is the final output for every node after layout: it says exactly
1435/// where the node sits on screen and how large it is.
1436///
1437/// # Example
1438///
1439/// ```rust
1440/// use fission_layout::{LayoutRect, LayoutPoint};
1441///
1442/// let rect = LayoutRect::new(10.0, 20.0, 300.0, 200.0);
1443/// assert_eq!(rect.right(), 310.0);
1444/// assert!(rect.contains(LayoutPoint::new(15.0, 25.0)));
1445/// ```
1446#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1447pub struct LayoutRect {
1448    /// The top-left corner of the rectangle.
1449    pub origin: LayoutPoint,
1450    /// The width and height of the rectangle.
1451    pub size: LayoutSize,
1452}
1453
1454impl LayoutRect {
1455    /// Creates a rectangle from x, y, width, and height.
1456    pub fn new(x: LayoutUnit, y: LayoutUnit, width: LayoutUnit, height: LayoutUnit) -> Self {
1457        Self {
1458            origin: LayoutPoint { x, y },
1459            size: LayoutSize { width, height },
1460        }
1461    }
1462
1463    /// The x coordinate of the left edge.
1464    pub fn x(&self) -> LayoutUnit {
1465        self.origin.x
1466    }
1467    /// The y coordinate of the top edge.
1468    pub fn y(&self) -> LayoutUnit {
1469        self.origin.y
1470    }
1471    /// The width of the rectangle.
1472    pub fn width(&self) -> LayoutUnit {
1473        self.size.width
1474    }
1475    /// The height of the rectangle.
1476    pub fn height(&self) -> LayoutUnit {
1477        self.size.height
1478    }
1479
1480    /// The x coordinate of the right edge (`x + width`).
1481    pub fn right(&self) -> LayoutUnit {
1482        self.origin.x + self.size.width
1483    }
1484    /// The y coordinate of the bottom edge (`y + height`).
1485    pub fn bottom(&self) -> LayoutUnit {
1486        self.origin.y + self.size.height
1487    }
1488
1489    /// Returns `true` if the point `p` lies within this rectangle (inclusive on
1490    /// the left/top edges, exclusive on the right/bottom edges).
1491    pub fn contains(&self, p: LayoutPoint) -> bool {
1492        p.x >= self.x() && p.x < self.right() && p.y >= self.y() && p.y < self.bottom()
1493    }
1494}
1495
1496/// The computed geometry of a single layout node.
1497///
1498/// After layout, every node has a bounding rectangle (its position and size on
1499/// screen) and a content size (how large its content actually is, which may exceed
1500/// the rect for scroll containers).
1501#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1502pub struct LayoutNodeGeometry {
1503    /// The bounding rectangle of this node in absolute (screen) coordinates.
1504    pub rect: LayoutRect,
1505    /// The natural size of the node's content before clipping. For scroll containers,
1506    /// this may be larger than `rect.size`, indicating scrollable overflow.
1507    pub content_size: LayoutSize,
1508}
1509
1510/// A node's geometry at each important stage of the layout pipeline.
1511#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1512pub struct LayoutInspection {
1513    /// Node identity being inspected.
1514    pub node: WidgetId,
1515    /// Natural content bounds before constraints are applied.
1516    pub measured: LayoutRect,
1517    /// Constraints supplied by the parent.
1518    pub constraints: BoxConstraints,
1519    /// Natural content bounds after applying parent and node-local constraints.
1520    pub constrained: LayoutRect,
1521    /// Final bounds assigned by layout.
1522    pub laid_out: LayoutRect,
1523    /// Visible bounds after ancestor clipping.
1524    pub clipped: LayoutRect,
1525    /// Estimated visual bounds including laid-out descendants.
1526    pub painted: LayoutRect,
1527    /// Whether natural content exceeds the assigned width.
1528    pub overflow_x: bool,
1529    /// Whether natural content exceeds the assigned height.
1530    pub overflow_y: bool,
1531}
1532
1533/// The complete output of a layout pass.
1534///
1535/// `LayoutSnapshot` maps every node to its computed geometry and records the
1536/// viewport size that was used. It is the primary interface between the layout
1537/// engine and downstream consumers (the renderer, hit testing, accessibility).
1538///
1539/// # Example
1540///
1541/// ```rust,no_run
1542/// use fission_layout::{LayoutSnapshot, LayoutSize};
1543/// use fission_ir::WidgetId;
1544///
1545/// let snapshot = LayoutSnapshot::new(LayoutSize::new(800.0, 600.0));
1546/// assert_eq!(snapshot.viewport_size.width, 800.0);
1547/// ```
1548#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1549pub struct LayoutSnapshot {
1550    /// Computed geometry for every node, keyed by [`WidgetId`].
1551    pub nodes: HashMap<WidgetId, LayoutNodeGeometry>,
1552    /// The constraints that were passed to each node during layout. Useful for
1553    /// debugging. Skipped during serialization.
1554    #[serde(skip)]
1555    pub constraints: HashMap<WidgetId, BoxConstraints>,
1556    /// The viewport size used for this layout pass.
1557    pub viewport_size: LayoutSize,
1558}
1559
1560impl LayoutSnapshot {
1561    /// Creates an empty snapshot for the given viewport size.
1562    pub fn new(viewport_size: LayoutSize) -> Self {
1563        Self {
1564            nodes: HashMap::new(),
1565            constraints: HashMap::new(),
1566            viewport_size,
1567        }
1568    }
1569
1570    /// Returns the full geometry (rect + content size) for a node, or `None` if
1571    /// the node was not part of this layout pass.
1572    pub fn get_node_geometry(&self, node_id: WidgetId) -> Option<&LayoutNodeGeometry> {
1573        self.nodes.get(&node_id)
1574    }
1575
1576    /// Returns just the bounding rectangle for a node, or `None` if not found.
1577    pub fn get_node_rect(&self, node_id: WidgetId) -> Option<LayoutRect> {
1578        self.nodes.get(&node_id).map(|g| g.rect)
1579    }
1580
1581    /// Returns the constraints that were passed to a node during layout, or `None`
1582    /// if not found. Useful for debugging layout issues.
1583    pub fn get_node_constraints(&self, node_id: WidgetId) -> Option<BoxConstraints> {
1584        self.constraints.get(&node_id).copied()
1585    }
1586}
1587
1588/// A flattened representation of a layout node, ready for the layout engine.
1589///
1590/// The widget compiler produces a list of `LayoutInputNode`s from the IR. Each node
1591/// carries its layout operation, parent/child relationships, flex participation
1592/// parameters, and optional rich text content for text measurement.
1593///
1594/// The layout engine operates on `&[LayoutInputNode]` rather than traversing the
1595/// IR directly, which keeps the engine decoupled from the IR's internal structure.
1596#[derive(Debug, Clone)]
1597pub struct LayoutInputNode {
1598    /// The unique identity of this node.
1599    pub id: WidgetId,
1600    /// The parent node's ID, or `None` for the root.
1601    pub parent_id: Option<WidgetId>,
1602    /// The layout operation this node performs.
1603    pub op: LayoutOp,
1604    /// Ordered list of child node IDs.
1605    pub children_ids: Vec<WidgetId>,
1606    /// A human-readable name for debugging and diagnostics.
1607    pub debug_name: String,
1608    /// Explicit width override, or `None` to derive from constraints.
1609    pub width: Option<LayoutUnit>,
1610    /// Explicit height override, or `None` to derive from constraints.
1611    pub height: Option<LayoutUnit>,
1612    /// How much extra main-axis space this node claims from its flex parent.
1613    pub flex_grow: LayoutUnit,
1614    /// How much this node shrinks when its flex parent overflows.
1615    pub flex_shrink: LayoutUnit,
1616    /// Optional rich text content. When present, the layout engine uses the
1617    /// [`TextMeasurer`] to determine the node's intrinsic size from the text.
1618    pub rich_text: Option<Vec<TextRun>>,
1619}
1620
1621/// Per-line metrics returned by text measurement.
1622///
1623/// When the layout engine or hit-testing code needs to know about individual lines
1624/// of text (e.g., for cursor positioning in a multi-line text field), it calls
1625/// [`TextMeasurer::get_line_metrics`] and receives a `Vec<LineMetric>`.
1626pub struct LineMetric {
1627    /// Byte index where this line starts in the source string.
1628    pub start_index: usize,
1629    /// Byte index where this line ends in the source string (exclusive).
1630    pub end_index: usize,
1631    /// Distance from the top of the line to its alphabetic baseline, in logical pixels.
1632    pub baseline: f32,
1633    /// Total height of the line (ascent + descent + leading), in logical pixels.
1634    pub height: f32,
1635    /// Measured width of the line's content, in logical pixels.
1636    pub width: f32,
1637}
1638
1639#[derive(Debug, Clone, Copy, PartialEq)]
1640pub struct RichTextInlineBox {
1641    pub id: u64,
1642    pub x: f32,
1643    pub y: f32,
1644    pub width: f32,
1645    pub height: f32,
1646}
1647
1648#[derive(Debug, Clone, PartialEq)]
1649pub struct RichTextLayoutInfo {
1650    pub width: f32,
1651    pub height: f32,
1652    pub inline_boxes: Vec<RichTextInlineBox>,
1653}
1654
1655/// A platform-provided text measurement backend.
1656///
1657/// The layout engine does not shape or measure text itself. Instead, platform
1658/// backends implement `TextMeasurer` to wrap their native text engine (CoreText
1659/// on macOS, DirectWrite on Windows, HarfBuzz + FreeType on Linux, etc.).
1660///
1661/// All methods have default implementations that return zero-sized results, so
1662/// you only need to override the methods your backend supports.
1663///
1664/// # Required
1665///
1666/// * [`measure`](TextMeasurer::measure) -- must be implemented to get correct text layout.
1667///
1668/// # Optional
1669///
1670/// * [`hit_test`](TextMeasurer::hit_test) -- needed for click-to-cursor in text fields.
1671/// * [`get_line_metrics`](TextMeasurer::get_line_metrics) -- needed for multi-line cursor navigation.
1672/// * [`get_caret_position`](TextMeasurer::get_caret_position) -- needed for drawing the text cursor.
1673/// * [`measure_rich_text`](TextMeasurer::measure_rich_text) -- needed for mixed-style text.
1674const DEFAULT_RICH_TEXT_HIT_TEST_FONT_SIZE: f32 = 14.0;
1675
1676pub trait TextMeasurer: Send + Sync {
1677    /// Measures single-style text and returns `(width, height)` in logical pixels.
1678    ///
1679    /// If `available_width` is `Some`, the text should be wrapped at that width.
1680    /// If `None`, the text is measured as a single unwrapped line.
1681    fn measure(&self, text: &str, font_size: f32, available_width: Option<f32>) -> (f32, f32);
1682
1683    /// Returns the byte index of the character closest to the point `(x, y)`,
1684    /// relative to the text's origin. Used for click-to-cursor in text fields.
1685    ///
1686    /// The default implementation returns `0`.
1687    fn hit_test(
1688        &self,
1689        _text: &str,
1690        _font_size: f32,
1691        _available_width: Option<f32>,
1692        _x: f32,
1693        _y: f32,
1694    ) -> usize {
1695        0
1696    }
1697
1698    /// Returns per-line metrics for the given text. Used for multi-line text fields
1699    /// and line-based cursor navigation.
1700    ///
1701    /// The default implementation returns an empty vec.
1702    fn get_line_metrics(
1703        &self,
1704        _text: &str,
1705        _font_size: f32,
1706        _available_width: Option<f32>,
1707    ) -> Vec<LineMetric> {
1708        vec![]
1709    }
1710
1711    /// Returns the `(x, y)` position of the text cursor at `caret_index` (byte offset),
1712    /// relative to the text's origin.
1713    ///
1714    /// The default implementation returns `(0.0, 0.0)`.
1715    fn get_caret_position(
1716        &self,
1717        _text: &str,
1718        _font_size: f32,
1719        _available_width: Option<f32>,
1720        _caret_index: usize,
1721    ) -> (f32, f32) {
1722        (0.0, 0.0)
1723    }
1724
1725    /// Measures multi-style (rich) text and returns `(width, height)` in logical pixels.
1726    ///
1727    /// The default implementation returns `(0.0, 0.0)`.
1728    fn measure_rich_text(&self, _runs: &[TextRun], _available_width: Option<f32>) -> (f32, f32) {
1729        (0.0, 0.0)
1730    }
1731
1732    /// Measures rich text and returns positioned inline-widget boxes, if any.
1733    ///
1734    /// Backends that understand inline rich-text widget markers should override
1735    /// this so layout can place the child widgets at the same coordinates used
1736    /// by text shaping.
1737    fn layout_rich_text(
1738        &self,
1739        runs: &[TextRun],
1740        available_width: Option<f32>,
1741    ) -> RichTextLayoutInfo {
1742        let (width, height) = if runs.len() == 1 {
1743            let run = &runs[0];
1744            self.measure(&run.text, run.style.font_size, available_width)
1745        } else {
1746            self.measure_rich_text(runs, available_width)
1747        };
1748        RichTextLayoutInfo {
1749            width,
1750            height,
1751            inline_boxes: Vec::new(),
1752        }
1753    }
1754
1755    /// Hit-test rich text (styled runs) at the given (x, y) position.
1756    /// Returns the byte offset into the concatenated text of all runs.
1757    /// Default falls back to plain hit_test using the first run's font size.
1758    fn hit_test_rich(
1759        &self,
1760        runs: &[TextRun],
1761        _available_width: Option<f32>,
1762        x: f32,
1763        y: f32,
1764    ) -> usize {
1765        // Preserve the normal body-text fallback when no run is available, so
1766        // fallback hit testing never asks a backend to shape zero-sized text.
1767        let text: String = runs.iter().map(|r| r.text.as_str()).collect();
1768        let font_size = runs
1769            .first()
1770            .map(|r| r.style.font_size)
1771            .unwrap_or(DEFAULT_RICH_TEXT_HIT_TEST_FONT_SIZE);
1772        self.hit_test(&text, font_size, None, x, y)
1773    }
1774
1775    /// Resolves the rich-text annotation at the given point, if any.
1776    ///
1777    /// This is used for interactive rich-text spans that need hit testing
1778    /// against shaped rich text rather than box nodes.
1779    fn resolve_rich_text_annotation_at_point(
1780        &self,
1781        _runs: &[TextRun],
1782        _available_width: Option<f32>,
1783        _x: f32,
1784        _y: f32,
1785        _paragraph_style: TextParagraphStyle,
1786        _annotations: &[RichTextAnnotation],
1787    ) -> Option<RichTextAnnotation> {
1788        None
1789    }
1790}
1791
1792/// The constraint-based layout solver.
1793///
1794/// `LayoutEngine` walks the node tree top-down, passing [`BoxConstraints`] from
1795/// parent to child, and bottom-up, returning [`LayoutSize`] from child to parent.
1796/// The final result is a [`LayoutSnapshot`] that maps every node to its absolute
1797/// screen-space rectangle.
1798///
1799/// The engine optionally holds a [`TextMeasurer`] for sizing text nodes. Without
1800/// one, text nodes are treated as zero-sized.
1801///
1802/// # Example
1803///
1804/// ```rust,no_run
1805/// use fission_layout::*;
1806/// use fission_ir::WidgetId;
1807/// use std::sync::Arc;
1808///
1809/// let mut engine = LayoutEngine::new();
1810/// // engine = engine.with_measurer(my_text_measurer);
1811///
1812/// // let snapshot = engine.compute_layout(&nodes, root_id, viewport, &|_| 0.0).unwrap();
1813/// ```
1814pub struct LayoutEngine {
1815    measurer: Option<Arc<dyn TextMeasurer>>,
1816    graph_state: LayoutGraphState,
1817    next_graph_version: u64,
1818    incremental_reuse: Option<IncrementalLayoutReuseState>,
1819    active_viewport: LayoutSize,
1820}
1821
1822impl LayoutEngine {
1823    const MAX_LAYOUT_RECURSION_DEPTH: usize = 100;
1824
1825    /// Creates a new layout engine with no text measurer.
1826    ///
1827    /// Text nodes will be treated as zero-sized until a measurer is provided
1828    /// via [`with_measurer`](LayoutEngine::with_measurer).
1829    pub fn new() -> Self {
1830        Self {
1831            measurer: None,
1832            graph_state: LayoutGraphState::default(),
1833            next_graph_version: 1,
1834            incremental_reuse: None,
1835            active_viewport: LayoutSize::ZERO,
1836        }
1837    }
1838
1839    /// Returns a new engine with the given text measurer attached.
1840    ///
1841    /// This is a builder-style method that consumes and returns `self`.
1842    pub fn with_measurer(mut self, measurer: Arc<dyn TextMeasurer>) -> Self {
1843        self.measurer = Some(measurer);
1844        self
1845    }
1846
1847    fn allocate_graph_version(&mut self) -> u64 {
1848        let version = self.next_graph_version;
1849        self.next_graph_version = self.next_graph_version.saturating_add(1);
1850        version
1851    }
1852
1853    fn refresh_graph_state(&mut self, input_nodes: &[LayoutInputNode]) {
1854        let version = self.allocate_graph_version();
1855        self.graph_state = LayoutGraphState::from_input_nodes(input_nodes, version);
1856    }
1857
1858    fn ensure_graph_state(&mut self, input_nodes: &[LayoutInputNode]) {
1859        if self.graph_state.is_empty() || !self.graph_state.matches_input_nodes(input_nodes) {
1860            self.refresh_graph_state(input_nodes);
1861        }
1862    }
1863
1864    fn validate_graph_state(&self, root: WidgetId) -> Result<()> {
1865        if let Some(err) = self.graph_state.validation.first_error() {
1866            return Err(err);
1867        }
1868        if !self.graph_state.nodes.contains_key(&root) {
1869            anyhow::bail!("[verify] missing node {:?}", root);
1870        }
1871        if !self.graph_state.roots.contains(&root)
1872            && self
1873                .graph_state
1874                .parents
1875                .get(&root)
1876                .copied()
1877                .flatten()
1878                .is_some()
1879        {
1880            anyhow::bail!("[verify] root {:?} is not a graph root", root);
1881        }
1882        if let Some(last_layout_version) = self.graph_state.last_layout_version {
1883            if last_layout_version > self.graph_state.graph_version {
1884                anyhow::bail!(
1885                    "[verify] cached layout version {} exceeds graph version {}",
1886                    last_layout_version,
1887                    self.graph_state.graph_version
1888                );
1889            }
1890        }
1891        Ok(())
1892    }
1893
1894    /// Refreshes the cached graph state after upstream layout edits.
1895    ///
1896    /// Unchanged nodes keep their cached graph entries while edited topology and
1897    /// fingerprints are synchronized to the latest flattened node list.
1898    pub fn update(&mut self, input_nodes: &[LayoutInputNode]) {
1899        if self.graph_state.is_empty() {
1900            self.refresh_graph_state(input_nodes);
1901            return;
1902        }
1903
1904        if self.graph_state.matches_input_nodes(input_nodes) {
1905            return;
1906        }
1907
1908        let version = self.allocate_graph_version();
1909        self.graph_state.graph_version = version;
1910        self.graph_state.update_nodes(input_nodes);
1911    }
1912
1913    /// Rebuilds internal data structures from the full node list.
1914    pub fn rebuild(&mut self, input_nodes: &[LayoutInputNode]) -> Result<()> {
1915        self.refresh_graph_state(input_nodes);
1916        if let Some(err) = self.graph_state.validation.first_error() {
1917            return Err(err);
1918        }
1919        Ok(())
1920    }
1921
1922    /// Verifies parent-child consistency and checks for cycles in the node graph.
1923    ///
1924    /// Call this during development/testing to catch malformed IR before it causes
1925    /// layout panics. Returns `Err` with a description of the first problem found.
1926    pub fn verify_post_update(
1927        &self,
1928        input_nodes: &[LayoutInputNode],
1929        root: WidgetId,
1930    ) -> Result<()> {
1931        if self.graph_state.matches_input_nodes(input_nodes) {
1932            return self.validate_graph_state(root);
1933        }
1934
1935        let node_map: HashMap<WidgetId, &LayoutInputNode> =
1936            input_nodes.iter().map(|n| (n.id, n)).collect();
1937        // Parent/child consistency
1938        for n in input_nodes {
1939            for child in &n.children_ids {
1940                let child_node = node_map
1941                    .get(child)
1942                    .ok_or_else(|| anyhow::anyhow!("[verify] child {:?} not found", child))?;
1943                if child_node.parent_id != Some(n.id) {
1944                    anyhow::bail!("[verify] parent/child mismatch parent={:?} child={:?} child.parent_id={:?}", n.id, child, child_node.parent_id);
1945                }
1946            }
1947        }
1948        // Cycle via DFS
1949        fn dfs(
1950            id: WidgetId,
1951            map: &HashMap<WidgetId, &LayoutInputNode>,
1952            visited: &mut HashSet<WidgetId>,
1953            stack: &mut HashSet<WidgetId>,
1954        ) -> Result<()> {
1955            if !visited.insert(id) {
1956                return Ok(());
1957            }
1958            stack.insert(id);
1959            let node = map
1960                .get(&id)
1961                .ok_or_else(|| anyhow::anyhow!("[verify] missing node {:?}", id))?;
1962            for child in &node.children_ids {
1963                if stack.contains(child) {
1964                    anyhow::bail!("[verify] cycle detected at {:?} -> {:?}", id, child);
1965                }
1966                dfs(*child, map, visited, stack)?;
1967            }
1968            stack.remove(&id);
1969            Ok(())
1970        }
1971        let mut visited = HashSet::new();
1972        let mut stack = HashSet::new();
1973        dfs(root, &node_map, &mut visited, &mut stack)?;
1974        Ok(())
1975    }
1976
1977    /// Computes layout for the entire node tree and returns a snapshot.
1978    ///
1979    /// This is the main entry point. It runs the constraint-based layout algorithm
1980    /// starting from `root_node_id`, using `viewport_size` as the root constraints,
1981    /// and querying `scroll_source` for scroll offsets. After layout, it emits scroll
1982    /// diagnostics for debugging.
1983    ///
1984    /// # Arguments
1985    ///
1986    /// * `input_nodes` -- The flat list of all layout nodes.
1987    /// * `root_node_id` -- Which node is the root of the tree.
1988    /// * `viewport_size` -- The size of the window/screen.
1989    /// * `scroll_source` -- Provides scroll offsets for scroll containers.
1990    ///
1991    /// # Errors
1992    ///
1993    /// Returns `Err` if a cycle is detected or a required node is missing.
1994    pub fn compute_layout(
1995        &mut self,
1996        input_nodes: &[LayoutInputNode],
1997        root_node_id: WidgetId,
1998        viewport_size: LayoutSize,
1999        scroll_source: &impl ScrollDataSource,
2000    ) -> Result<LayoutSnapshot> {
2001        self.ensure_graph_state(input_nodes);
2002        self.validate_graph_state(root_node_id)?;
2003        let snapshot = self.compute_layout_constraints(
2004            input_nodes,
2005            root_node_id,
2006            viewport_size,
2007            scroll_source,
2008        )?;
2009        self.emit_scroll_diagnostics(&snapshot);
2010        self.emit_overflow_diagnostics(&snapshot);
2011        Ok(snapshot)
2012    }
2013
2014    /// InternalLower-level layout that skips scroll diagnostics.
2015    ///
2016    /// Same as [`compute_layout`](LayoutEngine::compute_layout) but does not emit
2017    /// diagnostic events. Useful when you need the snapshot but not the debug output.
2018    pub fn compute_layout_constraints(
2019        &mut self,
2020        input_nodes: &[LayoutInputNode],
2021        root_node_id: WidgetId,
2022        viewport_size: LayoutSize,
2023        scroll_source: &impl ScrollDataSource,
2024    ) -> Result<LayoutSnapshot> {
2025        self.active_viewport = viewport_size;
2026        self.ensure_graph_state(input_nodes);
2027        self.validate_graph_state(root_node_id)?;
2028
2029        // Root constraints should be tight to the viewport size if no explicit size is given
2030        let mut constraints = BoxConstraints::tight(viewport_size);
2031        if let Some(root) = self.graph_state.node(root_node_id) {
2032            // Only loosen if explicit dimensions are provided for the root node
2033            let styled_dimension = matches!(
2034                &root.op,
2035                LayoutOp::StyledBox { style, .. }
2036                    if style.width.is_some() || style.height.is_some()
2037            );
2038            if root.width.is_some() || root.height.is_some() || styled_dimension {
2039                constraints = BoxConstraints::loose(viewport_size.width, viewport_size.height)
2040                    .tighten(root.width, root.height);
2041            }
2042        }
2043
2044        let mut snapshot = LayoutSnapshot::new(viewport_size);
2045        let mut measure_cache = HashMap::new();
2046        self.layout_node_constraints(
2047            root_node_id,
2048            constraints,
2049            LayoutPoint::ZERO,
2050            &mut snapshot.nodes,
2051            &mut snapshot.constraints,
2052            &mut measure_cache,
2053            scroll_source,
2054            true,
2055            0,
2056        )?;
2057
2058        let visual_location = |node_id: WidgetId| -> Option<LayoutPoint> {
2059            let mut pos = snapshot.nodes.get(&node_id)?.rect.origin;
2060            let mut current = self.graph_state.parent_of(node_id);
2061            while let Some(parent_id) = current {
2062                if let Some(parent) = self.graph_state.node(parent_id) {
2063                    if let LayoutOp::Scroll { direction, .. } = &parent.op {
2064                        let offset = scroll_source.get_offset(parent_id);
2065                        match direction {
2066                            FlexDirection::Row => pos.x -= offset,
2067                            FlexDirection::Column => pos.y -= offset,
2068                        }
2069                    }
2070                    current = self.graph_state.parent_of(parent_id);
2071                } else {
2072                    break;
2073                }
2074            }
2075            Some(pos)
2076        };
2077
2078        let mut flyout_abs_overrides: HashMap<WidgetId, (f32, f32)> = HashMap::new();
2079        for node in self.graph_state.ordered_nodes() {
2080            if let LayoutOp::Flyout { anchor, content } = node.op {
2081                if let (Some(anchor_geom), Some(content_geom)) =
2082                    (snapshot.nodes.get(&anchor), snapshot.nodes.get(&content))
2083                {
2084                    if let Some(anchor_abs) = visual_location(anchor) {
2085                        let content_w = content_geom.rect.width();
2086                        let content_h = content_geom.rect.height();
2087                        let anchor_h = anchor_geom.rect.height();
2088                        let max_left = (snapshot.viewport_size.width - content_w).max(0.0);
2089                        let left_rel = anchor_abs.x.clamp(0.0, max_left);
2090
2091                        let below_top = anchor_abs.y + anchor_h;
2092                        let max_top = (snapshot.viewport_size.height - content_h).max(0.0);
2093                        let top_rel = if below_top + content_h <= snapshot.viewport_size.height {
2094                            below_top
2095                        } else {
2096                            let above_top = anchor_abs.y - content_h;
2097                            if above_top >= 0.0 {
2098                                above_top
2099                            } else {
2100                                below_top.clamp(0.0, max_top)
2101                            }
2102                        };
2103                        flyout_abs_overrides.insert(content, (left_rel, top_rel));
2104                    }
2105                }
2106            }
2107        }
2108
2109        if !flyout_abs_overrides.is_empty() {
2110            for (nid, (abs_x, abs_y)) in flyout_abs_overrides {
2111                if let Some(current) = snapshot.nodes.get(&nid) {
2112                    let dx = abs_x - current.rect.origin.x;
2113                    let dy = abs_y - current.rect.origin.y;
2114                    let mut stack = vec![(nid, 0usize)];
2115                    while let Some((current_id, depth)) = stack.pop() {
2116                        if depth > Self::MAX_LAYOUT_RECURSION_DEPTH {
2117                            return Err(self.layout_depth_overflow(current_id, depth));
2118                        }
2119                        if let Some(geometry) = snapshot.nodes.get_mut(&current_id) {
2120                            geometry.rect.origin.x += dx;
2121                            geometry.rect.origin.y += dy;
2122                        }
2123                        for child_id in self.graph_state.children_of(current_id).iter().rev() {
2124                            stack.push((*child_id, depth + 1));
2125                        }
2126                    }
2127                }
2128            }
2129        }
2130
2131        self.graph_state.mark_layout_complete();
2132        self.incremental_reuse = None;
2133
2134        Ok(snapshot)
2135    }
2136
2137    pub fn compute_layout_incremental(
2138        &mut self,
2139        input_nodes: &[LayoutInputNode],
2140        root_node_id: WidgetId,
2141        viewport_size: LayoutSize,
2142        scroll_source: &impl ScrollDataSource,
2143        previous_snapshot: &LayoutSnapshot,
2144        dirty_nodes: &HashSet<WidgetId>,
2145    ) -> Result<LayoutSnapshot> {
2146        self.ensure_graph_state(input_nodes);
2147        self.validate_graph_state(root_node_id)?;
2148
2149        let mut dirty_ancestors = HashSet::new();
2150        for node_id in dirty_nodes {
2151            let mut current = Some(*node_id);
2152            while let Some(id) = current {
2153                if !dirty_ancestors.insert(id) {
2154                    break;
2155                }
2156                current = self.graph_state.parent_of(id);
2157            }
2158        }
2159        dirty_ancestors.insert(root_node_id);
2160
2161        self.incremental_reuse = Some(IncrementalLayoutReuseState {
2162            previous_snapshot: previous_snapshot.clone(),
2163            dirty_ancestors,
2164        });
2165        let result = self.compute_layout_constraints(
2166            input_nodes,
2167            root_node_id,
2168            viewport_size,
2169            scroll_source,
2170        );
2171        self.incremental_reuse = None;
2172        result
2173    }
2174
2175    fn emit_scroll_diagnostics(&self, snapshot: &LayoutSnapshot) {
2176        use fission_diagnostics::prelude as diag;
2177        let trace_scroll = std::env::var("FISSION_SCROLL_TRACE").ok().as_deref() == Some("1");
2178        for n in self.graph_state.ordered_nodes() {
2179            if let LayoutOp::Scroll { .. } = n.op {
2180                if let Some(g) = snapshot.nodes.get(&n.id) {
2181                    let note = if g.rect.height() <= 0.0 {
2182                        let parent_op = n
2183                            .parent_id
2184                            .and_then(|pid| self.graph_state.node(pid))
2185                            .map(|p| format!("{:?}", p.op));
2186                        let parent_constraints = n
2187                            .parent_id
2188                            .and_then(|pid| snapshot.constraints.get(&pid))
2189                            .copied();
2190                        snapshot
2191                            .constraints
2192                            .get(&n.id)
2193                            .map(|c| {
2194                                format!(
2195                                    "op={:?} parent={:?} parent_op={:?} parent_constraints={:?} constraints={:?}",
2196                                    n.op,
2197                                    n.parent_id,
2198                                    parent_op,
2199                                    parent_constraints,
2200                                    c
2201                                )
2202                            })
2203                    } else {
2204                        None
2205                    };
2206                    diag::emit(
2207                        diag::DiagCategory::Layout,
2208                        diag::DiagLevel::Debug,
2209                        diag::DiagEventKind::ScrollExtent {
2210                            node: n.id.as_u128(),
2211                            viewport_w: g.rect.width(),
2212                            viewport_h: g.rect.height(),
2213                            content_w: g.content_size.width,
2214                            content_h: g.content_size.height,
2215                            note,
2216                        },
2217                    );
2218                    if trace_scroll {
2219                        eprintln!(
2220                            "[scroll-trace] node={} viewport=({:.1},{:.1}) content=({:.1},{:.1})",
2221                            n.id.as_u128(),
2222                            g.rect.width(),
2223                            g.rect.height(),
2224                            g.content_size.width,
2225                            g.content_size.height
2226                        );
2227                    }
2228                }
2229            }
2230        }
2231    }
2232
2233    fn emit_overflow_diagnostics(&self, snapshot: &LayoutSnapshot) {
2234        for node in self.graph_state.ordered_nodes() {
2235            let Some(geometry) = snapshot.nodes.get(&node.id) else {
2236                continue;
2237            };
2238            let overflow_x = geometry.content_size.width > geometry.rect.width() + 0.5;
2239            let overflow_y = geometry.content_size.height > geometry.rect.height() + 0.5;
2240            if !overflow_x && !overflow_y {
2241                continue;
2242            }
2243            let text = node.rich_text.is_some();
2244            diag::emit(
2245                diag::DiagCategory::Layout,
2246                if text {
2247                    diag::DiagLevel::Warn
2248                } else {
2249                    diag::DiagLevel::Debug
2250                },
2251                diag::DiagEventKind::LayoutOverflow {
2252                    node: node.id.as_u128(),
2253                    debug_name: node.debug_name.clone(),
2254                    parent: node.parent_id.map(|parent| parent.as_u128()),
2255                    parent_debug_name: node
2256                        .parent_id
2257                        .and_then(|parent| self.graph_state.node(parent))
2258                        .map(|parent| parent.debug_name.clone()),
2259                    parent_layout: node
2260                        .parent_id
2261                        .and_then(|parent| self.graph_state.node(parent))
2262                        .map(|parent| format!("{:?}", parent.op)),
2263                    text,
2264                    min_w: snapshot
2265                        .constraints
2266                        .get(&node.id)
2267                        .map_or(0.0, |constraints| constraints.min_w),
2268                    max_w: snapshot
2269                        .constraints
2270                        .get(&node.id)
2271                        .map(|constraints| constraints.max_w)
2272                        .filter(|value| value.is_finite()),
2273                    min_h: snapshot
2274                        .constraints
2275                        .get(&node.id)
2276                        .map_or(0.0, |constraints| constraints.min_h),
2277                    max_h: snapshot
2278                        .constraints
2279                        .get(&node.id)
2280                        .map(|constraints| constraints.max_h)
2281                        .filter(|value| value.is_finite()),
2282                    laid_out_w: geometry.rect.width(),
2283                    laid_out_h: geometry.rect.height(),
2284                    content_w: geometry.content_size.width,
2285                    content_h: geometry.content_size.height,
2286                },
2287            );
2288        }
2289    }
2290
2291    /// Returns measured, constrained, laid-out, clipped, and estimated paint bounds.
2292    pub fn inspect_node(
2293        &self,
2294        snapshot: &LayoutSnapshot,
2295        node_id: WidgetId,
2296    ) -> Option<LayoutInspection> {
2297        let geometry = snapshot.nodes.get(&node_id)?;
2298        let constraints = snapshot.constraints.get(&node_id).copied()?;
2299        let measured = LayoutRect::new(
2300            geometry.rect.x(),
2301            geometry.rect.y(),
2302            geometry.content_size.width,
2303            geometry.content_size.height,
2304        );
2305        let effective_constraints = self
2306            .graph_state
2307            .node(node_id)
2308            .map(|node| {
2309                let resolved_style;
2310                let op = match &node.op {
2311                    LayoutOp::StyledBox { style, .. } => {
2312                        resolved_style =
2313                            resolve_box_style(style, constraints, snapshot.viewport_size);
2314                        &resolved_style
2315                    }
2316                    op => op,
2317                };
2318                match op {
2319                    LayoutOp::Box {
2320                        width,
2321                        height,
2322                        min_width,
2323                        max_width,
2324                        min_height,
2325                        max_height,
2326                        ..
2327                    } => constraints
2328                        .apply_min_max(*min_width, *max_width, *min_height, *max_height)
2329                        .tighten(*width, *height),
2330                    _ => constraints,
2331                }
2332            })
2333            .unwrap_or(constraints);
2334        let constrained_size = effective_constraints.constrain(geometry.content_size);
2335        let constrained = LayoutRect::new(
2336            geometry.rect.x(),
2337            geometry.rect.y(),
2338            constrained_size.width,
2339            constrained_size.height,
2340        );
2341        let mut clipped = geometry.rect;
2342        let mut ancestor = self.graph_state.parent_of(node_id);
2343        while let Some(ancestor_id) = ancestor {
2344            let clips = self
2345                .graph_state
2346                .node(ancestor_id)
2347                .is_some_and(|node| match &node.op {
2348                    LayoutOp::Scroll { .. } | LayoutOp::Clip { .. } => true,
2349                    LayoutOp::StyledBox { style, .. } => {
2350                        style.overflow == fission_ir::op::Overflow::Clip
2351                    }
2352                    _ => false,
2353                });
2354            if clips {
2355                if let Some(ancestor_geometry) = snapshot.nodes.get(&ancestor_id) {
2356                    clipped = intersect_rect(clipped, ancestor_geometry.rect);
2357                }
2358            }
2359            ancestor = self.graph_state.parent_of(ancestor_id);
2360        }
2361
2362        let mut painted = geometry.rect;
2363        let mut descendants = self.graph_state.children_of(node_id).to_vec();
2364        while let Some(descendant) = descendants.pop() {
2365            if let Some(descendant_geometry) = snapshot.nodes.get(&descendant) {
2366                painted = union_rect(painted, descendant_geometry.rect);
2367            }
2368            descendants.extend_from_slice(self.graph_state.children_of(descendant));
2369        }
2370        Some(LayoutInspection {
2371            node: node_id,
2372            measured,
2373            constraints,
2374            constrained,
2375            laid_out: geometry.rect,
2376            clipped,
2377            painted,
2378            overflow_x: geometry.content_size.width > geometry.rect.width() + 0.5,
2379            overflow_y: geometry.content_size.height > geometry.rect.height() + 0.5,
2380        })
2381    }
2382
2383    fn layout_depth_overflow(&self, node_id: WidgetId, depth: usize) -> anyhow::Error {
2384        let details = format!(
2385            "layout recursion depth {} exceeded max {} at node {}",
2386            depth,
2387            Self::MAX_LAYOUT_RECURSION_DEPTH,
2388            node_id.as_u128()
2389        );
2390        diag::emit(
2391            diag::DiagCategory::Invariants,
2392            diag::DiagLevel::Error,
2393            diag::DiagEventKind::InvariantViolation {
2394                kind: "layout_recursion_depth".into(),
2395                node: Some(node_id.as_u128()),
2396                details: details.clone(),
2397                dump_ref: None,
2398            },
2399        );
2400        anyhow::anyhow!(details)
2401    }
2402
2403    fn copy_cached_subtree(
2404        &self,
2405        node_id: WidgetId,
2406        origin: LayoutPoint,
2407        current_constraints: BoxConstraints,
2408        out: &mut HashMap<WidgetId, LayoutNodeGeometry>,
2409        constraints_out: &mut HashMap<WidgetId, BoxConstraints>,
2410    ) -> Result<Option<LayoutSize>> {
2411        let Some(reuse) = self.incremental_reuse.as_ref() else {
2412            return Ok(None);
2413        };
2414        if reuse.dirty_ancestors.contains(&node_id) {
2415            return Ok(None);
2416        }
2417
2418        let Some(previous_geometry) = reuse.previous_snapshot.nodes.get(&node_id) else {
2419            return Ok(None);
2420        };
2421        let Some(previous_constraints) = reuse.previous_snapshot.constraints.get(&node_id).copied()
2422        else {
2423            return Ok(None);
2424        };
2425        if previous_constraints != current_constraints {
2426            return Ok(None);
2427        }
2428
2429        let dx = origin.x - previous_geometry.rect.origin.x;
2430        let dy = origin.y - previous_geometry.rect.origin.y;
2431        let mut stack = vec![(node_id, 0usize)];
2432        while let Some((current_id, depth)) = stack.pop() {
2433            if depth > Self::MAX_LAYOUT_RECURSION_DEPTH {
2434                return Err(self.layout_depth_overflow(current_id, depth));
2435            }
2436            let Some(previous_geometry) = reuse.previous_snapshot.nodes.get(&current_id) else {
2437                return Ok(None);
2438            };
2439            let Some(previous_constraints) = reuse
2440                .previous_snapshot
2441                .constraints
2442                .get(&current_id)
2443                .copied()
2444            else {
2445                return Ok(None);
2446            };
2447
2448            let mut geometry = previous_geometry.clone();
2449            geometry.rect.origin.x += dx;
2450            geometry.rect.origin.y += dy;
2451            out.insert(current_id, geometry);
2452            constraints_out.insert(current_id, previous_constraints);
2453
2454            let children = self.graph_state.children_of(current_id);
2455            for child_id in children.iter().rev() {
2456                stack.push((*child_id, depth + 1));
2457            }
2458        }
2459
2460        Ok(Some(previous_geometry.content_size))
2461    }
2462
2463    #[allow(clippy::too_many_arguments)]
2464    fn measure_grid_intrinsic_width(
2465        &self,
2466        node_id: WidgetId,
2467        intrinsic: IntrinsicAxis,
2468        max_height: f32,
2469        out: &mut HashMap<WidgetId, LayoutNodeGeometry>,
2470        constraints_out: &mut HashMap<WidgetId, BoxConstraints>,
2471        measure_cache: &mut HashMap<MeasureCacheKey, LayoutSize>,
2472        scroll_source: &impl ScrollDataSource,
2473        depth: usize,
2474    ) -> Result<f32> {
2475        let Some(node) = self.graph_state.node(node_id) else {
2476            return Ok(0.0);
2477        };
2478        if let (Some(runs), Some(measurer)) = (&node.rich_text, &self.measurer) {
2479            return Ok(match intrinsic {
2480                IntrinsicAxis::Max => measurer.layout_rich_text(runs, None).width,
2481                IntrinsicAxis::Min => runs
2482                    .iter()
2483                    .flat_map(|run| {
2484                        run.text.split_whitespace().map(move |word| {
2485                            measurer.measure(word, run.style.font_size, None).0
2486                                + run.style.letter_spacing
2487                                    * word.chars().count().saturating_sub(1) as f32
2488                        })
2489                    })
2490                    .fold(0.0, f32::max),
2491            });
2492        }
2493
2494        if matches!(node.op, LayoutOp::GridItem { .. } | LayoutOp::Align)
2495            && node.children_ids.len() == 1
2496        {
2497            return self.measure_grid_intrinsic_width(
2498                node.children_ids[0],
2499                intrinsic,
2500                max_height,
2501                out,
2502                constraints_out,
2503                measure_cache,
2504                scroll_source,
2505                depth + 1,
2506            );
2507        }
2508
2509        let constraints = BoxConstraints {
2510            min_w: 0.0,
2511            max_w: f32::INFINITY,
2512            min_h: 0.0,
2513            max_h: if max_height.is_finite() {
2514                max_height
2515            } else {
2516                f32::INFINITY
2517            },
2518        };
2519        Ok(self
2520            .layout_node_constraints(
2521                node_id,
2522                constraints,
2523                LayoutPoint::ZERO,
2524                out,
2525                constraints_out,
2526                measure_cache,
2527                scroll_source,
2528                false,
2529                depth + 1,
2530            )?
2531            .width)
2532    }
2533
2534    fn layout_node_constraints(
2535        &self,
2536        node_id: WidgetId,
2537        constraints: BoxConstraints,
2538        origin: LayoutPoint,
2539        out: &mut HashMap<WidgetId, LayoutNodeGeometry>,
2540        constraints_out: &mut HashMap<WidgetId, BoxConstraints>,
2541        measure_cache: &mut HashMap<MeasureCacheKey, LayoutSize>,
2542        scroll_source: &impl ScrollDataSource,
2543        record: bool,
2544        depth: usize,
2545    ) -> Result<LayoutSize> {
2546        if depth > Self::MAX_LAYOUT_RECURSION_DEPTH {
2547            return Err(self.layout_depth_overflow(node_id, depth));
2548        }
2549        if !record {
2550            let cache_key = MeasureCacheKey::new(node_id, constraints);
2551            if let Some(cached) = measure_cache.get(&cache_key).copied() {
2552                return Ok(cached);
2553            }
2554        }
2555        let node = match self.graph_state.node(node_id) {
2556            Some(node) => node,
2557            None => return Ok(LayoutSize::ZERO),
2558        };
2559
2560        if record {
2561            constraints_out.insert(node_id, constraints);
2562        }
2563
2564        if record {
2565            if let Some(reused) =
2566                self.copy_cached_subtree(node_id, origin, constraints, out, constraints_out)?
2567            {
2568                return Ok(reused);
2569            }
2570        }
2571
2572        let mut flow_children: Vec<WidgetId> = Vec::new();
2573        let mut abs_children: Vec<WidgetId> = Vec::new();
2574        for child_id in self.graph_state.children_of(node_id) {
2575            let is_absolute = matches!(
2576                self.graph_state.node(*child_id).map(|n| &n.op),
2577                Some(LayoutOp::AbsoluteFill)
2578                    | Some(LayoutOp::Positioned { .. })
2579                    | Some(LayoutOp::PositionedLengths { .. })
2580            );
2581            if is_absolute {
2582                abs_children.push(*child_id);
2583            } else {
2584                flow_children.push(*child_id);
2585            }
2586        }
2587        let rich_text_inline_children = node.rich_text.is_some() && !flow_children.is_empty();
2588
2589        let mut resolved_style_op = match &node.op {
2590            LayoutOp::StyledBox {
2591                style,
2592                flex_grow,
2593                flex_shrink,
2594            } => {
2595                let mut op = resolve_box_style(style, constraints, self.active_viewport);
2596                if let LayoutOp::Box {
2597                    flex_grow: resolved_grow,
2598                    flex_shrink: resolved_shrink,
2599                    ..
2600                } = &mut op
2601                {
2602                    *resolved_grow = *flex_grow;
2603                    *resolved_shrink = *flex_shrink;
2604                }
2605                Some(op)
2606            }
2607            _ => None,
2608        };
2609        if let (
2610            LayoutOp::StyledBox { style, .. },
2611            Some(LayoutOp::Box {
2612                width,
2613                min_width,
2614                max_width,
2615                padding,
2616                ..
2617            }),
2618        ) = (&node.op, &mut resolved_style_op)
2619        {
2620            let needs_intrinsic_width = [
2621                style.width.as_ref(),
2622                style.min_width.as_ref(),
2623                style.max_width.as_ref(),
2624            ]
2625            .into_iter()
2626            .flatten()
2627            .any(length_requires_measurement);
2628            if needs_intrinsic_width {
2629                let mut min_content = 0.0f32;
2630                let mut max_content = 0.0f32;
2631                if let (Some(runs), Some(measurer)) = (&node.rich_text, &self.measurer) {
2632                    min_content = runs
2633                        .iter()
2634                        .flat_map(|run| {
2635                            run.text.split_whitespace().map(move |word| {
2636                                measurer.measure(word, run.style.font_size, None).0
2637                                    + run.style.letter_spacing
2638                                        * word.chars().count().saturating_sub(1) as f32
2639                            })
2640                        })
2641                        .fold(0.0, f32::max);
2642                    max_content = measurer.layout_rich_text(runs, None).width;
2643                }
2644                for child_id in &flow_children {
2645                    min_content = min_content.max(self.measure_grid_intrinsic_width(
2646                        *child_id,
2647                        IntrinsicAxis::Min,
2648                        constraints.max_h,
2649                        out,
2650                        constraints_out,
2651                        measure_cache,
2652                        scroll_source,
2653                        depth + 1,
2654                    )?);
2655                    max_content = max_content.max(self.measure_grid_intrinsic_width(
2656                        *child_id,
2657                        IntrinsicAxis::Max,
2658                        constraints.max_h,
2659                        out,
2660                        constraints_out,
2661                        measure_cache,
2662                        scroll_source,
2663                        depth + 1,
2664                    )?);
2665                }
2666                let horizontal_padding = padding[0] + padding[1];
2667                min_content += horizontal_padding;
2668                max_content =
2669                    max_content.max(min_content - horizontal_padding) + horizontal_padding;
2670                let available = if constraints.max_w.is_finite() {
2671                    constraints.max_w
2672                } else {
2673                    max_content
2674                };
2675                let resolve = |length: &Option<Length>| {
2676                    length.as_ref().and_then(|length| {
2677                        resolve_measured_length(
2678                            length,
2679                            available,
2680                            self.active_viewport,
2681                            min_content,
2682                            max_content,
2683                        )
2684                    })
2685                };
2686                *width = resolve(&style.width);
2687                *min_width = resolve(&style.min_width);
2688                *max_width = resolve(&style.max_width);
2689            }
2690        }
2691        let layout_op = resolved_style_op.as_ref().unwrap_or(&node.op);
2692        let box_alignment = match &node.op {
2693            LayoutOp::StyledBox { style, .. } => style.alignment,
2694            // Legacy low-level Box nodes have always stretched an auto-sized
2695            // child across the parent's cross axis. StyledBox carries an
2696            // explicit alignment and may opt into start/center/end instead.
2697            LayoutOp::Box { .. }
2698                if node.rich_text.is_some()
2699                    || node.parent_id.is_some_and(|parent_id| {
2700                        matches!(
2701                            self.graph_state.node(parent_id).map(|parent| &parent.op),
2702                            Some(LayoutOp::Flex { .. })
2703                                | Some(LayoutOp::StyledBox { flex_grow: 0.0, .. })
2704                        )
2705                    }) =>
2706            {
2707                fission_ir::op::BoxAlignment::Start
2708            }
2709            LayoutOp::Box { .. } => fission_ir::op::BoxAlignment::Stretch,
2710            _ => fission_ir::op::BoxAlignment::Start,
2711        };
2712        let intrinsic_box_width = match &node.op {
2713            LayoutOp::StyledBox { style, .. } => style.width.as_ref(),
2714            _ => None,
2715        };
2716
2717        let mut content_size;
2718        let size = match layout_op {
2719            LayoutOp::Box {
2720                width,
2721                height,
2722                min_width,
2723                max_width,
2724                min_height,
2725                max_height,
2726                padding,
2727                aspect_ratio,
2728                ..
2729            } => {
2730                let mut local =
2731                    constraints.apply_min_max(*min_width, *max_width, *min_height, *max_height);
2732                local = local.tighten(*width, *height);
2733                // A measured text node must retain its intrinsic height when
2734                // its parent supplies a loose cross-axis constraint. Applying
2735                // that constraint as a tight height makes tooltips and row
2736                // labels fill the viewport instead of sizing to their lines.
2737                if node.rich_text.is_some() && height.is_none() {
2738                    local.min_h = 0.0;
2739                    local.max_h = f32::INFINITY;
2740                }
2741                if let Some(ratio) = aspect_ratio.filter(|r| *r > 0.0) {
2742                    let mut target_w = *width;
2743                    let mut target_h = *height;
2744
2745                    if target_w.is_some() && target_h.is_none() {
2746                        target_h = target_w.map(|w| w / ratio);
2747                    } else if target_h.is_some() && target_w.is_none() {
2748                        target_w = target_h.map(|h| h * ratio);
2749                    } else if target_w.is_none() && target_h.is_none() {
2750                        if local.is_width_bounded() || local.is_height_bounded() {
2751                            let (mut w, mut h) = if local.is_width_bounded() {
2752                                let w = local.max_w;
2753                                let h = w / ratio;
2754                                (w, h)
2755                            } else {
2756                                let h = local.max_h;
2757                                let w = h * ratio;
2758                                (w, h)
2759                            };
2760                            if local.is_width_bounded()
2761                                && local.is_height_bounded()
2762                                && h > local.max_h
2763                            {
2764                                h = local.max_h;
2765                                w = h * ratio;
2766                            }
2767                            target_w = Some(w);
2768                            target_h = Some(h);
2769                        }
2770                    }
2771
2772                    if target_w.is_some() || target_h.is_some() {
2773                        local = local.tighten(target_w, target_h);
2774                    }
2775                }
2776                let mut base_child_constraints = local.deflate(*padding);
2777                if matches!(intrinsic_box_width, Some(Length::MaxContent)) {
2778                    base_child_constraints.min_w = 0.0;
2779                    base_child_constraints.max_w = f32::INFINITY;
2780                }
2781                if box_alignment != fission_ir::op::BoxAlignment::Stretch {
2782                    base_child_constraints.min_w = 0.0;
2783                    base_child_constraints.min_h = 0.0;
2784                }
2785                let mut max_child = LayoutSize::ZERO;
2786                let mut measured_children: Vec<(WidgetId, BoxConstraints, LayoutSize)> = Vec::new();
2787                if !rich_text_inline_children {
2788                    for child_id in &flow_children {
2789                        let (child_width, child_height, child_max_width, child_max_height) = self
2790                            .graph_state
2791                            .node(*child_id)
2792                            .map(|child| match &child.op {
2793                                LayoutOp::Box {
2794                                    width,
2795                                    height,
2796                                    max_width,
2797                                    max_height,
2798                                    ..
2799                                } => (*width, *height, *max_width, *max_height),
2800                                LayoutOp::Scroll {
2801                                    width,
2802                                    height,
2803                                    max_width,
2804                                    max_height,
2805                                    ..
2806                                } => (*width, *height, *max_width, *max_height),
2807                                LayoutOp::Embed { width, height, .. } => {
2808                                    (*width, *height, None, None)
2809                                }
2810                                LayoutOp::StyledBox { style, .. } => {
2811                                    let resolved = resolve_box_style(
2812                                        style,
2813                                        base_child_constraints,
2814                                        self.active_viewport,
2815                                    );
2816                                    match resolved {
2817                                        LayoutOp::Box {
2818                                            width,
2819                                            height,
2820                                            max_width,
2821                                            max_height,
2822                                            ..
2823                                        } => (width, height, max_width, max_height),
2824                                        _ => unreachable!(),
2825                                    }
2826                                }
2827                                _ => (None, None, None, None),
2828                            })
2829                            .unwrap_or((None, None, None, None));
2830                        let mut child_constraints = base_child_constraints;
2831                        if matches!(intrinsic_box_width, Some(Length::MinContent)) {
2832                            let intrinsic_width = self.measure_grid_intrinsic_width(
2833                                *child_id,
2834                                IntrinsicAxis::Min,
2835                                base_child_constraints.max_h,
2836                                out,
2837                                constraints_out,
2838                                measure_cache,
2839                                scroll_source,
2840                                depth + 1,
2841                            )?;
2842                            child_constraints.min_w = intrinsic_width;
2843                            child_constraints.max_w = intrinsic_width;
2844                        }
2845                        let tight_width = child_constraints.min_w == child_constraints.max_w;
2846                        let stretch_width =
2847                            tight_width && child_width.is_none() && child_max_width.is_none();
2848                        if matches!(box_alignment, fission_ir::op::BoxAlignment::Stretch)
2849                            && child_width.is_none()
2850                            && child_max_width.is_none()
2851                            && child_constraints.max_w.is_finite()
2852                        {
2853                            child_constraints.min_w = child_constraints.max_w;
2854                        } else if stretch_width {
2855                            child_constraints.min_w = child_constraints.max_w;
2856                        } else if tight_width
2857                            && (child_width.is_some() || child_max_width.is_some())
2858                        {
2859                            child_constraints.min_w = 0.0;
2860                        }
2861                        let tight_height = child_constraints.min_h == child_constraints.max_h;
2862                        let stretch_height =
2863                            tight_height && child_height.is_none() && child_max_height.is_none();
2864                        if matches!(box_alignment, fission_ir::op::BoxAlignment::Stretch)
2865                            && child_height.is_none()
2866                            && child_max_height.is_none()
2867                            && child_constraints.max_h.is_finite()
2868                        {
2869                            child_constraints.min_h = child_constraints.max_h;
2870                        } else if stretch_height {
2871                            child_constraints.min_h = child_constraints.max_h;
2872                        } else if tight_height
2873                            && (child_height.is_some() || child_max_height.is_some())
2874                        {
2875                            child_constraints.min_h = 0.0;
2876                        }
2877                        let child_size = self.layout_node_constraints(
2878                            *child_id,
2879                            child_constraints,
2880                            LayoutPoint::ZERO,
2881                            out,
2882                            constraints_out,
2883                            measure_cache,
2884                            scroll_source,
2885                            false,
2886                            depth + 1,
2887                        )?;
2888                        max_child.width = max_child.width.max(child_size.width);
2889                        max_child.height = max_child.height.max(child_size.height);
2890                        measured_children.push((*child_id, child_constraints, child_size));
2891                    }
2892                }
2893                let padded = LayoutSize::new(
2894                    max_child.width + padding[0] + padding[1],
2895                    max_child.height + padding[2] + padding[3],
2896                );
2897                if let LayoutOp::StyledBox { style, .. } = &node.op {
2898                    let available = if constraints.max_h.is_finite() {
2899                        constraints.max_h
2900                    } else {
2901                        padded.height
2902                    };
2903                    let resolve_intrinsic_height = |length: &Option<Length>| {
2904                        length
2905                            .as_ref()
2906                            .filter(|length| length_requires_measurement(length))
2907                            .and_then(|length| {
2908                                resolve_measured_length(
2909                                    length,
2910                                    available,
2911                                    self.active_viewport,
2912                                    padded.height,
2913                                    padded.height,
2914                                )
2915                            })
2916                    };
2917                    local = local.apply_min_max(
2918                        None,
2919                        None,
2920                        resolve_intrinsic_height(&style.min_height),
2921                        resolve_intrinsic_height(&style.max_height),
2922                    );
2923                    local = local.tighten(None, resolve_intrinsic_height(&style.height));
2924                }
2925                let size = local.constrain(padded);
2926                if record {
2927                    for (child_id, child_constraints, child_size) in measured_children {
2928                        let inner_width = (size.width - padding[0] - padding[1]).max(0.0);
2929                        let inner_height = (size.height - padding[2] - padding[3]).max(0.0);
2930                        let offset = |available: f32, child: f32| match box_alignment {
2931                            fission_ir::op::BoxAlignment::Start
2932                            | fission_ir::op::BoxAlignment::Stretch => 0.0,
2933                            fission_ir::op::BoxAlignment::Center => {
2934                                ((available - child) / 2.0).max(0.0)
2935                            }
2936                            fission_ir::op::BoxAlignment::End => (available - child).max(0.0),
2937                        };
2938                        self.layout_node_constraints(
2939                            child_id,
2940                            child_constraints,
2941                            LayoutPoint::new(
2942                                origin.x + padding[0] + offset(inner_width, child_size.width),
2943                                origin.y + padding[2] + offset(inner_height, child_size.height),
2944                            ),
2945                            out,
2946                            constraints_out,
2947                            measure_cache,
2948                            scroll_source,
2949                            record,
2950                            depth + 1,
2951                        )?;
2952                    }
2953                    if !abs_children.is_empty() {
2954                        let abs_constraints = BoxConstraints::loose(size.width, size.height);
2955                        for child_id in abs_children {
2956                            self.layout_node_constraints(
2957                                child_id,
2958                                abs_constraints,
2959                                origin,
2960                                out,
2961                                constraints_out,
2962                                measure_cache,
2963                                scroll_source,
2964                                record,
2965                                depth + 1,
2966                            )?;
2967                        }
2968                    }
2969                }
2970                content_size = padded;
2971                size
2972            }
2973            LayoutOp::Flex {
2974                direction,
2975                wrap,
2976                padding,
2977                gap,
2978                align_items,
2979                justify_content,
2980                flex_grow,
2981                ..
2982            } => {
2983                let gap = gap.unwrap_or(0.0);
2984                let local = constraints.tighten(node.width, node.height);
2985                let inner = local.deflate(*padding);
2986                let is_row = matches!(direction, IrFlexDirection::Row);
2987
2988                let max_main = if is_row { inner.max_w } else { inner.max_h };
2989                let max_cross = if is_row { inner.max_h } else { inner.max_w };
2990                let min_main = if is_row { inner.min_w } else { inner.min_h };
2991                let min_cross = if is_row { inner.min_h } else { inner.min_w };
2992                let main_bounded = if is_row {
2993                    inner.is_width_bounded()
2994                } else {
2995                    inner.is_height_bounded()
2996                };
2997                let cross_bounded = if is_row {
2998                    inner.is_height_bounded()
2999                } else {
3000                    inner.is_width_bounded()
3001                };
3002
3003                if matches!(wrap, IrFlexWrap::Wrap | IrFlexWrap::WrapReverse) {
3004                    let mut lines: Vec<(Vec<(WidgetId, LayoutSize, BoxConstraints)>, f32, f32)> =
3005                        Vec::new();
3006                    let mut line_children: Vec<(WidgetId, LayoutSize, BoxConstraints)> = Vec::new();
3007                    let mut line_main = 0.0f32;
3008                    let mut line_cross = 0.0f32;
3009                    let mut max_line_main = 0.0f32;
3010
3011                    for child_id in &flow_children {
3012                        let child_constraints = if is_row {
3013                            BoxConstraints {
3014                                min_w: 0.0,
3015                                max_w: max_main,
3016                                min_h: 0.0,
3017                                max_h: max_cross,
3018                            }
3019                        } else {
3020                            BoxConstraints {
3021                                min_w: 0.0,
3022                                max_w: max_cross,
3023                                min_h: 0.0,
3024                                max_h: max_main,
3025                            }
3026                        };
3027                        let child_size = self.layout_node_constraints(
3028                            *child_id,
3029                            child_constraints,
3030                            LayoutPoint::ZERO,
3031                            out,
3032                            constraints_out,
3033                            measure_cache,
3034                            scroll_source,
3035                            false,
3036                            depth + 1,
3037                        )?;
3038                        let child_main = if is_row {
3039                            child_size.width
3040                        } else {
3041                            child_size.height
3042                        };
3043                        let child_cross = if is_row {
3044                            child_size.height
3045                        } else {
3046                            child_size.width
3047                        };
3048                        let next_main = if line_children.is_empty() {
3049                            child_main
3050                        } else {
3051                            line_main + gap + child_main
3052                        };
3053
3054                        if main_bounded && !line_children.is_empty() && next_main > max_main {
3055                            max_line_main = max_line_main.max(line_main);
3056                            lines.push((line_children, line_main, line_cross));
3057                            line_children = Vec::new();
3058                            line_main = 0.0;
3059                            line_cross = 0.0;
3060                        }
3061
3062                        if !line_children.is_empty() {
3063                            line_main += gap;
3064                        }
3065                        line_main += child_main;
3066                        line_cross = line_cross.max(child_cross);
3067                        line_children.push((*child_id, child_size, child_constraints));
3068                    }
3069
3070                    if !line_children.is_empty() {
3071                        max_line_main = max_line_main.max(line_main);
3072                        lines.push((line_children, line_main, line_cross));
3073                    }
3074
3075                    let mut container_main = if main_bounded && *flex_grow > 0.0 {
3076                        max_main
3077                    } else {
3078                        max_line_main
3079                    };
3080                    container_main = container_main.max(min_main);
3081                    let total_lines_cross: f32 =
3082                        lines.iter().map(|(_, _, cross)| *cross).sum::<f32>()
3083                            + gap * lines.len().saturating_sub(1) as f32;
3084                    let container_cross = total_lines_cross.max(min_cross);
3085                    let size = if is_row {
3086                        local.constrain(LayoutSize::new(
3087                            container_main + padding[0] + padding[1],
3088                            container_cross + padding[2] + padding[3],
3089                        ))
3090                    } else {
3091                        local.constrain(LayoutSize::new(
3092                            container_cross + padding[0] + padding[1],
3093                            container_main + padding[2] + padding[3],
3094                        ))
3095                    };
3096
3097                    let inner_main = if is_row {
3098                        size.width - padding[0] - padding[1]
3099                    } else {
3100                        size.height - padding[2] - padding[3]
3101                    };
3102                    let inner_cross = if is_row {
3103                        size.height - padding[2] - padding[3]
3104                    } else {
3105                        size.width - padding[0] - padding[1]
3106                    };
3107
3108                    let mut ordered_lines = lines;
3109                    if matches!(wrap, IrFlexWrap::WrapReverse) {
3110                        ordered_lines.reverse();
3111                    }
3112
3113                    let mut line_cursor = if matches!(wrap, IrFlexWrap::WrapReverse) {
3114                        (inner_cross - total_lines_cross).max(0.0)
3115                    } else {
3116                        0.0
3117                    };
3118
3119                    for (line_children, line_main, line_cross) in ordered_lines {
3120                        let remaining_space = (inner_main - line_main).max(0.0);
3121                        let mut extra_gap = 0.0;
3122                        let mut offset_main = 0.0;
3123                        match justify_content {
3124                            fission_ir::op::JustifyContent::Start => {}
3125                            fission_ir::op::JustifyContent::End => offset_main = remaining_space,
3126                            fission_ir::op::JustifyContent::Center => {
3127                                offset_main = remaining_space / 2.0
3128                            }
3129                            fission_ir::op::JustifyContent::SpaceBetween => {
3130                                if line_children.len() > 1 {
3131                                    extra_gap =
3132                                        remaining_space / (line_children.len() as f32 - 1.0);
3133                                }
3134                            }
3135                            fission_ir::op::JustifyContent::SpaceAround => {
3136                                if !line_children.is_empty() {
3137                                    extra_gap = remaining_space / line_children.len() as f32;
3138                                    offset_main = extra_gap / 2.0;
3139                                }
3140                            }
3141                            fission_ir::op::JustifyContent::SpaceEvenly => {
3142                                if !line_children.is_empty() {
3143                                    extra_gap =
3144                                        remaining_space / (line_children.len() as f32 + 1.0);
3145                                    offset_main = extra_gap;
3146                                }
3147                            }
3148                        }
3149
3150                        let mut cursor = offset_main;
3151                        for (child_id, child_size, mut child_constraints) in line_children {
3152                            let child_main = if is_row {
3153                                child_size.width
3154                            } else {
3155                                child_size.height
3156                            };
3157                            let child_cross = if is_row {
3158                                child_size.height
3159                            } else {
3160                                child_size.width
3161                            };
3162                            if matches!(align_items, fission_ir::op::AlignItems::Stretch) {
3163                                if is_row {
3164                                    child_constraints.min_h = line_cross;
3165                                    child_constraints.max_h = line_cross;
3166                                } else {
3167                                    child_constraints.min_w = line_cross;
3168                                    child_constraints.max_w = line_cross;
3169                                }
3170                            }
3171                            let cross_offset = match align_items {
3172                                fission_ir::op::AlignItems::Start
3173                                | fission_ir::op::AlignItems::Stretch => 0.0,
3174                                fission_ir::op::AlignItems::End => {
3175                                    (line_cross - child_cross).max(0.0)
3176                                }
3177                                fission_ir::op::AlignItems::Center => {
3178                                    ((line_cross - child_cross) / 2.0).max(0.0)
3179                                }
3180                                fission_ir::op::AlignItems::Baseline => 0.0,
3181                            };
3182                            let child_origin = if is_row {
3183                                LayoutPoint::new(
3184                                    origin.x + padding[0] + cursor,
3185                                    origin.y + padding[2] + line_cursor + cross_offset,
3186                                )
3187                            } else {
3188                                LayoutPoint::new(
3189                                    origin.x + padding[0] + line_cursor + cross_offset,
3190                                    origin.y + padding[2] + cursor,
3191                                )
3192                            };
3193                            self.layout_node_constraints(
3194                                child_id,
3195                                child_constraints,
3196                                child_origin,
3197                                out,
3198                                constraints_out,
3199                                measure_cache,
3200                                scroll_source,
3201                                record,
3202                                depth + 1,
3203                            )?;
3204                            cursor += child_main + gap + extra_gap;
3205                        }
3206
3207                        line_cursor += line_cross + gap;
3208                    }
3209
3210                    if record && !abs_children.is_empty() {
3211                        let abs_constraints = BoxConstraints::loose(size.width, size.height);
3212                        for child_id in abs_children {
3213                            self.layout_node_constraints(
3214                                child_id,
3215                                abs_constraints,
3216                                origin,
3217                                out,
3218                                constraints_out,
3219                                measure_cache,
3220                                scroll_source,
3221                                record,
3222                                depth + 1,
3223                            )?;
3224                        }
3225                    }
3226                    content_size = size;
3227                    size
3228                } else {
3229                    struct FlexChildEntry {
3230                        id: WidgetId,
3231                        flex: f32,
3232                        size: LayoutSize,
3233                        constraints: BoxConstraints,
3234                        is_flex: bool,
3235                    }
3236                    let mut measured: Vec<FlexChildEntry> = Vec::new();
3237                    let mut total_flex = 0.0f32;
3238                    let mut nonflex_main = 0.0f32;
3239                    let mut max_child_cross = 0.0f32;
3240                    let treat_flex_as_nonflex = !main_bounded;
3241
3242                    for child_id in &flow_children {
3243                        let child = match self.graph_state.node(*child_id) {
3244                            Some(child) => child,
3245                            None => continue,
3246                        };
3247                        let flex = child.flex_grow;
3248                        if flex > 0.0 && !treat_flex_as_nonflex {
3249                            total_flex += flex;
3250                            measured.push(FlexChildEntry {
3251                                id: *child_id,
3252                                flex,
3253                                size: LayoutSize::ZERO,
3254                                constraints: BoxConstraints::loose(0.0, 0.0),
3255                                is_flex: true,
3256                            });
3257                            continue;
3258                        }
3259                        let child_constraints = if is_row {
3260                            let cross =
3261                                if matches!(align_items, fission_ir::op::AlignItems::Stretch)
3262                                    && cross_bounded
3263                                    && child.rich_text.is_none()
3264                                    && !matches!(
3265                                        child.op,
3266                                        LayoutOp::Box {
3267                                            width: None,
3268                                            height: None,
3269                                            ..
3270                                        }
3271                                    )
3272                                {
3273                                    BoxConstraints {
3274                                        min_w: 0.0,
3275                                        max_w: f32::INFINITY,
3276                                        min_h: max_cross,
3277                                        max_h: max_cross,
3278                                    }
3279                                } else {
3280                                    BoxConstraints {
3281                                        min_w: 0.0,
3282                                        max_w: f32::INFINITY,
3283                                        min_h: 0.0,
3284                                        max_h: max_cross,
3285                                    }
3286                                };
3287                            cross
3288                        } else {
3289                            let cross =
3290                                if matches!(align_items, fission_ir::op::AlignItems::Stretch)
3291                                    && cross_bounded
3292                                    && child.rich_text.is_none()
3293                                    && !matches!(
3294                                        child.op,
3295                                        LayoutOp::Box {
3296                                            width: None,
3297                                            height: None,
3298                                            ..
3299                                        }
3300                                    )
3301                                {
3302                                    BoxConstraints {
3303                                        min_w: max_cross,
3304                                        max_w: max_cross,
3305                                        min_h: 0.0,
3306                                        max_h: f32::INFINITY,
3307                                    }
3308                                } else {
3309                                    BoxConstraints {
3310                                        min_w: 0.0,
3311                                        max_w: max_cross,
3312                                        min_h: 0.0,
3313                                        max_h: f32::INFINITY,
3314                                    }
3315                                };
3316                            cross
3317                        };
3318                        let child_size = self.layout_node_constraints(
3319                            *child_id,
3320                            child_constraints,
3321                            LayoutPoint::ZERO,
3322                            out,
3323                            constraints_out,
3324                            measure_cache,
3325                            scroll_source,
3326                            false,
3327                            depth + 1,
3328                        )?;
3329                        let child_main = if is_row {
3330                            child_size.width
3331                        } else {
3332                            child_size.height
3333                        };
3334                        let child_cross = if is_row {
3335                            child_size.height
3336                        } else {
3337                            child_size.width
3338                        };
3339                        nonflex_main += child_main;
3340                        max_child_cross = max_child_cross.max(child_cross);
3341                        measured.push(FlexChildEntry {
3342                            id: *child_id,
3343                            flex,
3344                            size: child_size,
3345                            constraints: child_constraints,
3346                            is_flex: false,
3347                        });
3348                    }
3349
3350                    let gap_total = gap * flow_children.len().saturating_sub(1) as f32;
3351                    let remaining = if main_bounded {
3352                        (max_main - nonflex_main - gap_total).max(0.0)
3353                    } else {
3354                        0.0
3355                    };
3356
3357                    for entry in measured.iter_mut().filter(|e| e.is_flex) {
3358                        let flex = entry.flex;
3359                        let allocated = if main_bounded && total_flex > 0.0 {
3360                            remaining * (flex / total_flex)
3361                        } else {
3362                            0.0
3363                        };
3364                        let child_constraints = if is_row {
3365                            let cross =
3366                                if matches!(align_items, fission_ir::op::AlignItems::Stretch)
3367                                    && cross_bounded
3368                                {
3369                                    BoxConstraints {
3370                                        min_w: allocated,
3371                                        max_w: allocated,
3372                                        min_h: max_cross,
3373                                        max_h: max_cross,
3374                                    }
3375                                } else {
3376                                    BoxConstraints {
3377                                        min_w: allocated,
3378                                        max_w: allocated,
3379                                        min_h: 0.0,
3380                                        max_h: max_cross,
3381                                    }
3382                                };
3383                            cross
3384                        } else {
3385                            let cross =
3386                                if matches!(align_items, fission_ir::op::AlignItems::Stretch)
3387                                    && cross_bounded
3388                                {
3389                                    BoxConstraints {
3390                                        min_w: max_cross,
3391                                        max_w: max_cross,
3392                                        min_h: allocated,
3393                                        max_h: allocated,
3394                                    }
3395                                } else {
3396                                    BoxConstraints {
3397                                        min_w: 0.0,
3398                                        max_w: max_cross,
3399                                        min_h: allocated,
3400                                        max_h: allocated,
3401                                    }
3402                                };
3403                            cross
3404                        };
3405                        let child_size = self.layout_node_constraints(
3406                            entry.id,
3407                            child_constraints,
3408                            LayoutPoint::ZERO,
3409                            out,
3410                            constraints_out,
3411                            measure_cache,
3412                            scroll_source,
3413                            false,
3414                            depth + 1,
3415                        )?;
3416                        let child_cross = if is_row {
3417                            child_size.height
3418                        } else {
3419                            child_size.width
3420                        };
3421                        max_child_cross = max_child_cross.max(child_cross);
3422                        entry.size = child_size;
3423                        entry.constraints = child_constraints;
3424                    }
3425
3426                    let final_children_main: f32 = measured
3427                        .iter()
3428                        .map(|entry| {
3429                            if is_row {
3430                                entry.size.width
3431                            } else {
3432                                entry.size.height
3433                            }
3434                        })
3435                        .sum();
3436
3437                    let mut container_main = if main_bounded && *flex_grow > 0.0 {
3438                        max_main
3439                    } else {
3440                        final_children_main + gap_total
3441                    };
3442                    container_main = container_main.max(min_main);
3443
3444                    if main_bounded && final_children_main + gap_total > max_main {
3445                        // SHRINK logic
3446                        let mut total_shrink_scaled = 0.0f32;
3447                        for entry in &measured {
3448                            let Some(child) = self.graph_state.node(entry.id) else {
3449                                continue;
3450                            };
3451                            let main_size = if is_row {
3452                                entry.size.width
3453                            } else {
3454                                entry.size.height
3455                            };
3456                            total_shrink_scaled += main_size * child.flex_shrink;
3457                        }
3458
3459                        if total_shrink_scaled > 0.0 {
3460                            let overflow = (final_children_main + gap_total) - max_main;
3461                            for entry in &mut measured {
3462                                let Some(child) = self.graph_state.node(entry.id) else {
3463                                    continue;
3464                                };
3465                                let main_size = if is_row {
3466                                    entry.size.width
3467                                } else {
3468                                    entry.size.height
3469                                };
3470                                let shrink_amount = (main_size * child.flex_shrink
3471                                    / total_shrink_scaled)
3472                                    * overflow;
3473                                // Don't shrink below a reasonable minimum. Items with
3474                                // flex_shrink > 0 can shrink but not to zero - preserve at
3475                                // least a small fraction of their natural size.
3476                                let floor = if child.flex_shrink > 0.0 {
3477                                    // Check for explicit min/fixed dimension
3478                                    let explicit_min = match &child.op {
3479                                        LayoutOp::Box {
3480                                            min_width,
3481                                            min_height,
3482                                            height,
3483                                            width,
3484                                            ..
3485                                        } => {
3486                                            if is_row {
3487                                                min_width.or(*width).unwrap_or(0.0)
3488                                            } else {
3489                                                min_height.or(*height).unwrap_or(0.0)
3490                                            }
3491                                        }
3492                                        _ => 0.0,
3493                                    };
3494                                    explicit_min
3495                                } else {
3496                                    main_size // flex_shrink == 0 means don't shrink at all
3497                                };
3498                                let new_main = (main_size - shrink_amount).max(floor);
3499
3500                                let mut child_constraints = entry.constraints;
3501                                if is_row {
3502                                    child_constraints.min_w = new_main;
3503                                    child_constraints.max_w = new_main;
3504                                } else {
3505                                    child_constraints.min_h = new_main;
3506                                    child_constraints.max_h = new_main;
3507                                }
3508                                let new_size = self.layout_node_constraints(
3509                                    entry.id,
3510                                    child_constraints,
3511                                    LayoutPoint::ZERO,
3512                                    out,
3513                                    constraints_out,
3514                                    measure_cache,
3515                                    scroll_source,
3516                                    false,
3517                                    depth + 1,
3518                                )?;
3519                                entry.size = new_size;
3520                                entry.constraints = child_constraints;
3521                            }
3522                        }
3523                    }
3524
3525                    let container_cross = max_child_cross.max(min_cross);
3526                    let size = if is_row {
3527                        local.constrain(LayoutSize::new(
3528                            container_main + padding[0] + padding[1],
3529                            container_cross + padding[2] + padding[3],
3530                        ))
3531                    } else {
3532                        local.constrain(LayoutSize::new(
3533                            container_cross + padding[0] + padding[1],
3534                            container_main + padding[2] + padding[3],
3535                        ))
3536                    };
3537
3538                    let inner_main = if is_row {
3539                        size.width - padding[0] - padding[1]
3540                    } else {
3541                        size.height - padding[2] - padding[3]
3542                    };
3543                    let inner_cross = if is_row {
3544                        size.height - padding[2] - padding[3]
3545                    } else {
3546                        size.width - padding[0] - padding[1]
3547                    };
3548
3549                    let final_children_main: f32 = measured
3550                        .iter()
3551                        .map(|entry| {
3552                            if is_row {
3553                                entry.size.width
3554                            } else {
3555                                entry.size.height
3556                            }
3557                        })
3558                        .sum();
3559
3560                    let remaining_space = (inner_main - final_children_main - gap_total).max(0.0);
3561                    let mut extra_gap = 0.0;
3562                    let mut offset_main = 0.0;
3563                    match justify_content {
3564                        fission_ir::op::JustifyContent::Start => {}
3565                        fission_ir::op::JustifyContent::End => offset_main = remaining_space,
3566                        fission_ir::op::JustifyContent::Center => {
3567                            offset_main = remaining_space / 2.0
3568                        }
3569                        fission_ir::op::JustifyContent::SpaceBetween => {
3570                            if measured.len() > 1 {
3571                                extra_gap = remaining_space / (measured.len() as f32 - 1.0);
3572                            }
3573                        }
3574                        fission_ir::op::JustifyContent::SpaceAround => {
3575                            if !measured.is_empty() {
3576                                extra_gap = remaining_space / measured.len() as f32;
3577                                offset_main = extra_gap / 2.0;
3578                            }
3579                        }
3580                        fission_ir::op::JustifyContent::SpaceEvenly => {
3581                            if !measured.is_empty() {
3582                                extra_gap = remaining_space / (measured.len() as f32 + 1.0);
3583                                offset_main = extra_gap;
3584                            }
3585                        }
3586                    }
3587
3588                    let mut cursor = offset_main;
3589                    for entry in measured {
3590                        let child_main = if is_row {
3591                            entry.size.width
3592                        } else {
3593                            entry.size.height
3594                        };
3595                        let child_cross = if is_row {
3596                            entry.size.height
3597                        } else {
3598                            entry.size.width
3599                        };
3600                        let cross_offset = match align_items {
3601                            fission_ir::op::AlignItems::Start
3602                            | fission_ir::op::AlignItems::Stretch => 0.0,
3603                            fission_ir::op::AlignItems::End => (inner_cross - child_cross).max(0.0),
3604                            fission_ir::op::AlignItems::Center => {
3605                                ((inner_cross - child_cross) / 2.0).max(0.0)
3606                            }
3607                            fission_ir::op::AlignItems::Baseline => 0.0,
3608                        };
3609                        let child_origin = if is_row {
3610                            LayoutPoint::new(
3611                                origin.x + padding[0] + cursor,
3612                                origin.y + padding[2] + cross_offset,
3613                            )
3614                        } else {
3615                            LayoutPoint::new(
3616                                origin.x + padding[0] + cross_offset,
3617                                origin.y + padding[2] + cursor,
3618                            )
3619                        };
3620
3621                        let mut child_constraints = entry.constraints;
3622                        if matches!(align_items, fission_ir::op::AlignItems::Stretch) {
3623                            // Only stretch children that don't have an explicit cross-axis size.
3624                            let child_node = self.graph_state.node(entry.id);
3625                            let has_explicit_cross = child_node
3626                                .map(|n| match &n.op {
3627                                    LayoutOp::Box { width, height, .. } => {
3628                                        if is_row {
3629                                            height.is_some()
3630                                        } else {
3631                                            width.is_some()
3632                                        }
3633                                    }
3634                                    _ => false,
3635                                })
3636                                .unwrap_or(false);
3637                            // Text owns its measured height/width; stretching the
3638                            // text layout node would turn a line into the full
3639                            // row height and distort vertical centering.
3640                            let is_measured_text = child_node.is_some_and(|node| {
3641                                node.rich_text.is_some()
3642                                    || matches!(
3643                                        node.op,
3644                                        LayoutOp::Box {
3645                                            width: None,
3646                                            height: None,
3647                                            ..
3648                                        }
3649                                    )
3650                            });
3651                            if !has_explicit_cross && !is_measured_text {
3652                                if is_row {
3653                                    child_constraints.min_h = inner_cross;
3654                                    child_constraints.max_h = inner_cross;
3655                                } else {
3656                                    child_constraints.min_w = inner_cross;
3657                                    child_constraints.max_w = inner_cross;
3658                                }
3659                            }
3660                        }
3661
3662                        self.layout_node_constraints(
3663                            entry.id,
3664                            child_constraints,
3665                            child_origin,
3666                            out,
3667                            constraints_out,
3668                            measure_cache,
3669                            scroll_source,
3670                            record,
3671                            depth + 1,
3672                        )?;
3673                        cursor += child_main + gap + extra_gap;
3674                    }
3675
3676                    if record && !abs_children.is_empty() {
3677                        let abs_constraints = BoxConstraints::loose(size.width, size.height);
3678                        for child_id in abs_children {
3679                            self.layout_node_constraints(
3680                                child_id,
3681                                abs_constraints,
3682                                origin,
3683                                out,
3684                                constraints_out,
3685                                measure_cache,
3686                                scroll_source,
3687                                record,
3688                                depth + 1,
3689                            )?;
3690                        }
3691                    }
3692                    content_size = size;
3693                    size
3694                }
3695            }
3696            LayoutOp::Grid {
3697                columns,
3698                rows,
3699                column_gap,
3700                row_gap,
3701                padding,
3702            } => {
3703                let gap_x = column_gap.unwrap_or(0.0);
3704                let gap_y = row_gap.unwrap_or(0.0);
3705                let inner = constraints.deflate(*padding);
3706                let bounded_w = inner.is_width_bounded();
3707                let bounded_h = inner.is_height_bounded();
3708                let child_count = flow_children.len();
3709                let available_w = bounded_w.then_some(inner.max_w);
3710                let available_h = bounded_h.then_some(inner.max_h);
3711                let mut expanded_columns = expand_tracks(columns, available_w, gap_x, child_count);
3712                if expanded_columns.is_empty() {
3713                    expanded_columns.push(GridTrack::Auto);
3714                }
3715                let mut col_count = expanded_columns.len();
3716
3717                #[derive(Clone, Copy)]
3718                struct GridCell {
3719                    id: WidgetId,
3720                    row: usize,
3721                    col: usize,
3722                    row_span: usize,
3723                    col_span: usize,
3724                }
3725
3726                let mut cell_assignments: Vec<GridCell> = Vec::new();
3727                let mut auto_row = 0;
3728                let mut auto_col = 0;
3729                let mut occupied = HashSet::<(usize, usize)>::new();
3730
3731                for child_id in &flow_children {
3732                    let Some(child) = self.graph_state.node(*child_id) else {
3733                        continue;
3734                    };
3735                    let (row_start, row_end, col_start, col_end) = if let LayoutOp::GridItem {
3736                        row_start,
3737                        row_end,
3738                        col_start,
3739                        col_end,
3740                        ..
3741                    } = &child.op
3742                    {
3743                        (*row_start, *row_end, *col_start, *col_end)
3744                    } else {
3745                        (
3746                            GridPlacement::Auto,
3747                            GridPlacement::Auto,
3748                            GridPlacement::Auto,
3749                            GridPlacement::Auto,
3750                        )
3751                    };
3752                    let explicit_row = match row_start {
3753                        GridPlacement::Line(line) => Some(line.max(1) as usize - 1),
3754                        _ => None,
3755                    };
3756                    let explicit_col = match col_start {
3757                        GridPlacement::Line(line) => Some(line.max(1) as usize - 1),
3758                        _ => None,
3759                    };
3760                    let row_span = match row_end {
3761                        GridPlacement::Span(span) => usize::from(span).max(1),
3762                        GridPlacement::Line(line) => {
3763                            let end = line.max(1) as usize - 1;
3764                            end.saturating_sub(explicit_row.unwrap_or_default()).max(1)
3765                        }
3766                        GridPlacement::Auto => 1,
3767                    };
3768                    let col_span = match col_end {
3769                        GridPlacement::Span(span) => usize::from(span).max(1),
3770                        GridPlacement::Line(line) => {
3771                            let end = line.max(1) as usize - 1;
3772                            end.saturating_sub(explicit_col.unwrap_or_default()).max(1)
3773                        }
3774                        GridPlacement::Auto => 1,
3775                    };
3776                    let fits = |row: usize, col: usize, occupied: &HashSet<(usize, usize)>| {
3777                        (row..row + row_span).all(|row| {
3778                            (col..col + col_span).all(|col| !occupied.contains(&(row, col)))
3779                        })
3780                    };
3781                    let (row, col) = match (explicit_row, explicit_col) {
3782                        (Some(row), Some(col)) => (row, col),
3783                        (Some(row), None) => {
3784                            let mut col = 0;
3785                            while !fits(row, col, &occupied) {
3786                                col += 1;
3787                            }
3788                            (row, col)
3789                        }
3790                        (None, Some(col)) => {
3791                            let mut row = 0;
3792                            while !fits(row, col, &occupied) {
3793                                row += 1;
3794                            }
3795                            (row, col)
3796                        }
3797                        (None, None) => {
3798                            while (col_span <= col_count && auto_col + col_span > col_count)
3799                                || !fits(auto_row, auto_col, &occupied)
3800                            {
3801                                auto_col += 1;
3802                                if auto_col >= col_count {
3803                                    auto_col = 0;
3804                                    auto_row += 1;
3805                                }
3806                            }
3807                            let placement = (auto_row, auto_col);
3808                            if col_span >= col_count {
3809                                auto_col = 0;
3810                                auto_row += 1;
3811                            } else {
3812                                auto_col += col_span;
3813                                if auto_col >= col_count {
3814                                    auto_col = 0;
3815                                    auto_row += 1;
3816                                }
3817                            }
3818                            placement
3819                        }
3820                    };
3821                    for occupied_row in row..row + row_span {
3822                        for occupied_col in col..col + col_span {
3823                            occupied.insert((occupied_row, occupied_col));
3824                        }
3825                    }
3826                    cell_assignments.push(GridCell {
3827                        id: *child_id,
3828                        row,
3829                        col,
3830                        row_span,
3831                        col_span,
3832                    });
3833                }
3834
3835                let required_columns = cell_assignments
3836                    .iter()
3837                    .map(|cell| cell.col + cell.col_span)
3838                    .max()
3839                    .unwrap_or(1);
3840                if required_columns > col_count {
3841                    expanded_columns.resize(required_columns, GridTrack::Auto);
3842                    col_count = expanded_columns.len();
3843                }
3844
3845                let mut column_sizing = expanded_columns
3846                    .iter()
3847                    .map(|track| TrackSizing::from_track(track, available_w))
3848                    .collect::<Vec<_>>();
3849
3850                for cell in &cell_assignments {
3851                    let intrinsic = column_sizing[cell.col..cell.col + cell.col_span]
3852                        .iter()
3853                        .filter_map(|track| track.intrinsic)
3854                        .fold(None, |current, axis| match (current, axis) {
3855                            (Some(IntrinsicAxis::Max), _) | (_, IntrinsicAxis::Max) => {
3856                                Some(IntrinsicAxis::Max)
3857                            }
3858                            _ => Some(IntrinsicAxis::Min),
3859                        });
3860                    let Some(intrinsic) = intrinsic else {
3861                        continue;
3862                    };
3863                    let width = self.measure_grid_intrinsic_width(
3864                        cell.id,
3865                        intrinsic,
3866                        inner.max_h,
3867                        out,
3868                        constraints_out,
3869                        measure_cache,
3870                        scroll_source,
3871                        depth + 1,
3872                    )?;
3873                    distribute_deficit(
3874                        &mut column_sizing,
3875                        cell.col,
3876                        cell.col_span,
3877                        (width - gap_x * cell.col_span.saturating_sub(1) as f32).max(0.0),
3878                    );
3879                }
3880                if let Some(available_w) = available_w {
3881                    distribute_flex(&mut column_sizing, available_w, gap_x);
3882                }
3883                let col_widths = column_sizing
3884                    .iter()
3885                    .map(|track| track.base)
3886                    .collect::<Vec<_>>();
3887
3888                let minimum_rows = cell_assignments
3889                    .iter()
3890                    .map(|cell| cell.row + cell.row_span)
3891                    .max()
3892                    .unwrap_or_else(|| (child_count + col_count - 1) / col_count)
3893                    .max(1);
3894                let mut expanded_rows = expand_tracks(rows, available_h, gap_y, minimum_rows);
3895                if expanded_rows.is_empty() {
3896                    expanded_rows.resize(minimum_rows, GridTrack::Auto);
3897                } else if expanded_rows.len() < minimum_rows {
3898                    expanded_rows.resize(minimum_rows, GridTrack::Auto);
3899                }
3900                let mut row_sizing = expanded_rows
3901                    .iter()
3902                    .map(|track| TrackSizing::from_track(track, available_h))
3903                    .collect::<Vec<_>>();
3904
3905                for cell in &cell_assignments {
3906                    if cell.row >= row_sizing.len() || cell.col >= col_widths.len() {
3907                        continue;
3908                    }
3909                    let col_end = (cell.col + cell.col_span).min(col_widths.len());
3910                    let cell_w = col_widths[cell.col..col_end].iter().sum::<f32>()
3911                        + gap_x * col_end.saturating_sub(cell.col + 1) as f32;
3912                    let cell_constraints = BoxConstraints {
3913                        min_w: 0.0,
3914                        max_w: cell_w,
3915                        min_h: 0.0,
3916                        max_h: f32::INFINITY,
3917                    };
3918                    let child_size = self.layout_node_constraints(
3919                        cell.id,
3920                        cell_constraints,
3921                        LayoutPoint::ZERO,
3922                        out,
3923                        constraints_out,
3924                        measure_cache,
3925                        scroll_source,
3926                        false,
3927                        depth + 1,
3928                    )?;
3929                    distribute_deficit(
3930                        &mut row_sizing,
3931                        cell.row,
3932                        cell.row_span,
3933                        (child_size.height - gap_y * cell.row_span.saturating_sub(1) as f32)
3934                            .max(0.0),
3935                    );
3936                }
3937                if let Some(available_h) = available_h {
3938                    distribute_flex(&mut row_sizing, available_h, gap_y);
3939                }
3940                let row_heights = row_sizing
3941                    .iter()
3942                    .map(|track| track.base)
3943                    .collect::<Vec<_>>();
3944
3945                let grid_w: f32 =
3946                    col_widths.iter().sum::<f32>() + gap_x * (col_count.saturating_sub(1) as f32);
3947                let grid_h: f32 = row_heights.iter().sum::<f32>()
3948                    + gap_y * (row_heights.len().saturating_sub(1) as f32);
3949                let size = constraints.constrain(LayoutSize::new(
3950                    grid_w + padding[0] + padding[1],
3951                    grid_h + padding[2] + padding[3],
3952                ));
3953
3954                if record {
3955                    let padding_origin_x = origin.x + padding[0];
3956                    let padding_origin_y = origin.y + padding[2];
3957                    for cell in &cell_assignments {
3958                        if cell.row >= row_heights.len() || cell.col >= col_widths.len() {
3959                            continue;
3960                        }
3961                        let cell_x = padding_origin_x
3962                            + col_widths[..cell.col].iter().sum::<f32>()
3963                            + gap_x * cell.col as f32;
3964                        let cell_y = padding_origin_y
3965                            + row_heights[..cell.row].iter().sum::<f32>()
3966                            + gap_y * cell.row as f32;
3967                        let col_end = (cell.col + cell.col_span).min(col_widths.len());
3968                        let row_end = (cell.row + cell.row_span).min(row_heights.len());
3969                        let cell_w = col_widths[cell.col..col_end].iter().sum::<f32>()
3970                            + gap_x * col_end.saturating_sub(cell.col + 1) as f32;
3971                        let cell_h = row_heights[cell.row..row_end].iter().sum::<f32>()
3972                            + gap_y * row_end.saturating_sub(cell.row + 1) as f32;
3973                        let child_constraints = BoxConstraints {
3974                            min_w: cell_w,
3975                            max_w: cell_w,
3976                            min_h: cell_h,
3977                            max_h: cell_h,
3978                        };
3979                        self.layout_node_constraints(
3980                            cell.id,
3981                            child_constraints,
3982                            LayoutPoint::new(cell_x, cell_y),
3983                            out,
3984                            constraints_out,
3985                            measure_cache,
3986                            scroll_source,
3987                            record,
3988                            depth + 1,
3989                        )?;
3990                    }
3991                }
3992
3993                if record && !abs_children.is_empty() {
3994                    let abs_constraints = BoxConstraints::loose(size.width, size.height);
3995                    for child_id in abs_children {
3996                        self.layout_node_constraints(
3997                            child_id,
3998                            abs_constraints,
3999                            origin,
4000                            out,
4001                            constraints_out,
4002                            measure_cache,
4003                            scroll_source,
4004                            record,
4005                            depth + 1,
4006                        )?;
4007                    }
4008                }
4009                content_size = size;
4010                size
4011            }
4012            LayoutOp::GridItem { .. } => {
4013                let mut child_size = LayoutSize::ZERO;
4014                if let Some(child_id) = node.children_ids.first() {
4015                    child_size = self.layout_node_constraints(
4016                        *child_id,
4017                        constraints,
4018                        origin,
4019                        out,
4020                        constraints_out,
4021                        measure_cache,
4022                        scroll_source,
4023                        record,
4024                        depth + 1,
4025                    )?;
4026                }
4027                content_size = child_size;
4028                constraints.constrain(child_size)
4029            }
4030            LayoutOp::Responsive { query, cases } => {
4031                let query_width = match query {
4032                    fission_ir::op::ResponsiveQuery::Viewport => self.active_viewport.width,
4033                    fission_ir::op::ResponsiveQuery::Container => {
4034                        if constraints.is_width_bounded() {
4035                            constraints.max_w
4036                        } else {
4037                            self.active_viewport.width
4038                        }
4039                    }
4040                };
4041                let selected_index = cases
4042                    .iter()
4043                    .enumerate()
4044                    .rev()
4045                    .find_map(|(index, condition)| condition.matches(query_width).then_some(index))
4046                    .unwrap_or(cases.len());
4047                let child_size = node
4048                    .children_ids
4049                    .get(selected_index)
4050                    .map(|child_id| {
4051                        self.layout_node_constraints(
4052                            *child_id,
4053                            constraints,
4054                            origin,
4055                            out,
4056                            constraints_out,
4057                            measure_cache,
4058                            scroll_source,
4059                            record,
4060                            depth + 1,
4061                        )
4062                    })
4063                    .transpose()?
4064                    .unwrap_or(LayoutSize::ZERO);
4065                content_size = child_size;
4066                constraints.constrain(child_size)
4067            }
4068            LayoutOp::Scroll {
4069                direction,
4070                width,
4071                height,
4072                min_width,
4073                max_width,
4074                min_height,
4075                max_height,
4076                padding,
4077                ..
4078            } => {
4079                let mut local =
4080                    constraints.apply_min_max(*min_width, *max_width, *min_height, *max_height);
4081                local = local.tighten(*width, *height);
4082                let is_horizontal = matches!(direction, FlexDirection::Row);
4083                let mut child_constraints = local.deflate(*padding);
4084                if is_horizontal {
4085                    child_constraints.min_w = 0.0;
4086                    child_constraints.max_w = f32::INFINITY;
4087                } else {
4088                    child_constraints.min_h = 0.0;
4089                    child_constraints.max_h = f32::INFINITY;
4090                }
4091                let mut child_size = LayoutSize::ZERO;
4092                if let Some(child_id) = flow_children.first() {
4093                    child_size = self.layout_node_constraints(
4094                        *child_id,
4095                        child_constraints,
4096                        LayoutPoint::ZERO,
4097                        out,
4098                        constraints_out,
4099                        measure_cache,
4100                        scroll_source,
4101                        false,
4102                        depth + 1,
4103                    )?;
4104                }
4105                let size = local.constrain(LayoutSize::new(
4106                    child_size.width + padding[0] + padding[1],
4107                    child_size.height + padding[2] + padding[3],
4108                ));
4109                if record {
4110                    if let Some(child_id) = flow_children.first() {
4111                        self.layout_node_constraints(
4112                            *child_id,
4113                            child_constraints,
4114                            LayoutPoint::new(origin.x + padding[0], origin.y + padding[2]),
4115                            out,
4116                            constraints_out,
4117                            measure_cache,
4118                            scroll_source,
4119                            record,
4120                            depth + 1,
4121                        )?;
4122                    }
4123                    if !abs_children.is_empty() {
4124                        let abs_constraints = BoxConstraints::loose(size.width, size.height);
4125                        for child_id in abs_children {
4126                            self.layout_node_constraints(
4127                                child_id,
4128                                abs_constraints,
4129                                origin,
4130                                out,
4131                                constraints_out,
4132                                measure_cache,
4133                                scroll_source,
4134                                record,
4135                                depth + 1,
4136                            )?;
4137                        }
4138                    }
4139                }
4140                content_size = child_size;
4141                size
4142            }
4143            LayoutOp::Align => {
4144                let child_constraints = BoxConstraints::loose(constraints.max_w, constraints.max_h);
4145                let mut child_size = LayoutSize::ZERO;
4146                if let Some(child_id) = flow_children.first() {
4147                    child_size = self.layout_node_constraints(
4148                        *child_id,
4149                        child_constraints,
4150                        LayoutPoint::ZERO,
4151                        out,
4152                        constraints_out,
4153                        measure_cache,
4154                        scroll_source,
4155                        false,
4156                        depth + 1,
4157                    )?;
4158                }
4159                let size = if constraints.is_width_bounded() || constraints.is_height_bounded() {
4160                    constraints.constrain(LayoutSize::new(
4161                        if constraints.is_width_bounded() {
4162                            constraints.max_w
4163                        } else {
4164                            child_size.width
4165                        },
4166                        if constraints.is_height_bounded() {
4167                            constraints.max_h
4168                        } else {
4169                            child_size.height
4170                        },
4171                    ))
4172                } else {
4173                    child_size
4174                };
4175                if let Some(child_id) = flow_children.first() {
4176                    let dx = ((size.width - child_size.width) / 2.0).max(0.0);
4177                    let dy = ((size.height - child_size.height) / 2.0).max(0.0);
4178                    self.layout_node_constraints(
4179                        *child_id,
4180                        child_constraints,
4181                        LayoutPoint::new(origin.x + dx, origin.y + dy),
4182                        out,
4183                        constraints_out,
4184                        measure_cache,
4185                        scroll_source,
4186                        record,
4187                        depth + 1,
4188                    )?;
4189                }
4190                if record && !abs_children.is_empty() {
4191                    let abs_constraints = BoxConstraints::loose(size.width, size.height);
4192                    for child_id in abs_children {
4193                        self.layout_node_constraints(
4194                            child_id,
4195                            abs_constraints,
4196                            origin,
4197                            out,
4198                            constraints_out,
4199                            measure_cache,
4200                            scroll_source,
4201                            record,
4202                            depth + 1,
4203                        )?;
4204                    }
4205                }
4206                content_size = child_size;
4207                size
4208            }
4209            LayoutOp::ZStack => {
4210                let mut max_child = LayoutSize::ZERO;
4211                for child_id in &flow_children {
4212                    let child_size = self.layout_node_constraints(
4213                        *child_id,
4214                        BoxConstraints::loose(constraints.max_w, constraints.max_h),
4215                        LayoutPoint::ZERO,
4216                        out,
4217                        constraints_out,
4218                        measure_cache,
4219                        scroll_source,
4220                        false,
4221                        depth + 1,
4222                    )?;
4223                    max_child.width = max_child.width.max(child_size.width);
4224                    max_child.height = max_child.height.max(child_size.height);
4225                }
4226                let size = if constraints.is_width_bounded() || constraints.is_height_bounded() {
4227                    constraints.constrain(LayoutSize::new(
4228                        if constraints.is_width_bounded() {
4229                            constraints.max_w
4230                        } else {
4231                            max_child.width
4232                        },
4233                        if constraints.is_height_bounded() {
4234                            constraints.max_h
4235                        } else {
4236                            max_child.height
4237                        },
4238                    ))
4239                } else {
4240                    max_child
4241                };
4242                for child_id in &flow_children {
4243                    let child_constraints = BoxConstraints::loose(size.width, size.height);
4244                    let child_origin = LayoutPoint::new(origin.x, origin.y);
4245                    self.layout_node_constraints(
4246                        *child_id,
4247                        child_constraints,
4248                        child_origin,
4249                        out,
4250                        constraints_out,
4251                        measure_cache,
4252                        scroll_source,
4253                        record,
4254                        depth + 1,
4255                    )?;
4256                }
4257                if record && !abs_children.is_empty() {
4258                    let abs_constraints = BoxConstraints::loose(size.width, size.height);
4259                    for child_id in abs_children {
4260                        self.layout_node_constraints(
4261                            child_id,
4262                            abs_constraints,
4263                            origin,
4264                            out,
4265                            constraints_out,
4266                            measure_cache,
4267                            scroll_source,
4268                            record,
4269                            depth + 1,
4270                        )?;
4271                    }
4272                }
4273                content_size = size;
4274                size
4275            }
4276            LayoutOp::Positioned {
4277                top,
4278                left,
4279                bottom,
4280                right,
4281                width,
4282                height,
4283            } => {
4284                let target_w = finite_or(constraints.max_w, finite_or(constraints.min_w, 0.0));
4285                let target_h = finite_or(constraints.max_h, finite_or(constraints.min_h, 0.0));
4286                let size = constraints.constrain(LayoutSize::new(target_w, target_h));
4287                let mut child_constraints = BoxConstraints::loose(size.width, size.height);
4288                if let (Some(l), Some(r)) = (left, right) {
4289                    let w = (size.width - l - r).max(0.0);
4290                    child_constraints = child_constraints.tighten(Some(w), None);
4291                }
4292                if let (Some(t), Some(b)) = (top, bottom) {
4293                    let h = (size.height - t - b).max(0.0);
4294                    child_constraints = child_constraints.tighten(None, Some(h));
4295                }
4296                child_constraints = child_constraints.tighten(*width, *height);
4297                if let Some(child_id) = node.children_ids.first() {
4298                    let child_size = self.layout_node_constraints(
4299                        *child_id,
4300                        child_constraints,
4301                        LayoutPoint::ZERO,
4302                        out,
4303                        constraints_out,
4304                        measure_cache,
4305                        scroll_source,
4306                        false,
4307                        depth + 1,
4308                    )?;
4309                    let x = left.unwrap_or_else(|| {
4310                        right
4311                            .map(|r| (size.width - r - child_size.width).max(0.0))
4312                            .unwrap_or(0.0)
4313                    });
4314                    let y = top.unwrap_or_else(|| {
4315                        bottom
4316                            .map(|b| (size.height - b - child_size.height).max(0.0))
4317                            .unwrap_or(0.0)
4318                    });
4319                    self.layout_node_constraints(
4320                        *child_id,
4321                        child_constraints,
4322                        LayoutPoint::new(origin.x + x, origin.y + y),
4323                        out,
4324                        constraints_out,
4325                        measure_cache,
4326                        scroll_source,
4327                        record,
4328                        depth + 1,
4329                    )?;
4330                }
4331                content_size = size;
4332                size
4333            }
4334            LayoutOp::PositionedLengths {
4335                top,
4336                left,
4337                bottom,
4338                right,
4339                width,
4340                height,
4341            } => {
4342                let target_w = finite_or(constraints.max_w, finite_or(constraints.min_w, 0.0));
4343                let target_h = finite_or(constraints.max_h, finite_or(constraints.min_h, 0.0));
4344                let size = constraints.constrain(LayoutSize::new(target_w, target_h));
4345                let resolve_horizontal = |length: &Option<Length>| {
4346                    length
4347                        .as_ref()
4348                        .and_then(|length| resolve_length(length, size.width, self.active_viewport))
4349                };
4350                let resolve_vertical = |length: &Option<Length>| {
4351                    length.as_ref().and_then(|length| {
4352                        resolve_length(length, size.height, self.active_viewport)
4353                    })
4354                };
4355                let left = resolve_horizontal(left);
4356                let top = resolve_vertical(top);
4357                let right = resolve_horizontal(right);
4358                let bottom = resolve_vertical(bottom);
4359                let width = resolve_horizontal(width);
4360                let height = resolve_vertical(height);
4361                let mut child_constraints = BoxConstraints::loose(size.width, size.height);
4362                if let (Some(left), Some(right)) = (left, right) {
4363                    child_constraints =
4364                        child_constraints.tighten(Some((size.width - left - right).max(0.0)), None);
4365                }
4366                if let (Some(top), Some(bottom)) = (top, bottom) {
4367                    child_constraints = child_constraints
4368                        .tighten(None, Some((size.height - top - bottom).max(0.0)));
4369                }
4370                child_constraints = child_constraints.tighten(width, height);
4371                if let Some(child_id) = node.children_ids.first() {
4372                    let child_size = self.layout_node_constraints(
4373                        *child_id,
4374                        child_constraints,
4375                        LayoutPoint::ZERO,
4376                        out,
4377                        constraints_out,
4378                        measure_cache,
4379                        scroll_source,
4380                        false,
4381                        depth + 1,
4382                    )?;
4383                    let x = left.unwrap_or_else(|| {
4384                        right
4385                            .map(|right| (size.width - right - child_size.width).max(0.0))
4386                            .unwrap_or(0.0)
4387                    });
4388                    let y = top.unwrap_or_else(|| {
4389                        bottom
4390                            .map(|bottom| (size.height - bottom - child_size.height).max(0.0))
4391                            .unwrap_or(0.0)
4392                    });
4393                    self.layout_node_constraints(
4394                        *child_id,
4395                        child_constraints,
4396                        LayoutPoint::new(origin.x + x, origin.y + y),
4397                        out,
4398                        constraints_out,
4399                        measure_cache,
4400                        scroll_source,
4401                        record,
4402                        depth + 1,
4403                    )?;
4404                }
4405                content_size = size;
4406                size
4407            }
4408            LayoutOp::Embed { width, height, .. } => {
4409                let local = constraints.tighten(*width, *height);
4410                let w = if local.is_width_bounded() {
4411                    local.max_w
4412                } else {
4413                    local.min_w
4414                };
4415                let h = if local.is_height_bounded() {
4416                    local.max_h
4417                } else {
4418                    local.min_h
4419                };
4420                let size = local.constrain(LayoutSize::new(w, h));
4421                content_size = size;
4422                size
4423            }
4424            LayoutOp::AbsoluteFill => {
4425                let target_w = finite_or(constraints.max_w, finite_or(constraints.min_w, 0.0));
4426                let target_h = finite_or(constraints.max_h, finite_or(constraints.min_h, 0.0));
4427                let size = constraints.constrain(LayoutSize::new(target_w, target_h));
4428                for child_id in self.graph_state.children_of(node_id) {
4429                    self.layout_node_constraints(
4430                        *child_id,
4431                        BoxConstraints::tight(size),
4432                        origin,
4433                        out,
4434                        constraints_out,
4435                        measure_cache,
4436                        scroll_source,
4437                        record,
4438                        depth + 1,
4439                    )?;
4440                }
4441                content_size = size;
4442                size
4443            }
4444            LayoutOp::Transform { .. } | LayoutOp::Clip { .. } => {
4445                let mut child_size = LayoutSize::ZERO;
4446                if let Some(child_id) = node.children_ids.first() {
4447                    child_size = self.layout_node_constraints(
4448                        *child_id,
4449                        constraints,
4450                        origin,
4451                        out,
4452                        constraints_out,
4453                        measure_cache,
4454                        scroll_source,
4455                        record,
4456                        depth + 1,
4457                    )?;
4458                }
4459                content_size = child_size;
4460                constraints.constrain(child_size)
4461            }
4462            LayoutOp::Flyout { anchor, content: _ } => {
4463                let loose = BoxConstraints::loose(
4464                    if constraints.is_width_bounded() {
4465                        constraints.max_w
4466                    } else {
4467                        f32::INFINITY
4468                    },
4469                    if constraints.is_height_bounded() {
4470                        constraints.max_h
4471                    } else {
4472                        f32::INFINITY
4473                    },
4474                );
4475                let mut child_size = LayoutSize::ZERO;
4476                for child_id in self.graph_state.children_of(node_id) {
4477                    child_size = self.layout_node_constraints(
4478                        *child_id,
4479                        loose,
4480                        origin,
4481                        out,
4482                        constraints_out,
4483                        measure_cache,
4484                        scroll_source,
4485                        false,
4486                        depth + 1,
4487                    )?;
4488                }
4489                if record {
4490                    let anchor_rect = out.get(anchor).map(|g| g.rect);
4491                    let place_x = anchor_rect.map(|r| r.x()).unwrap_or(origin.x);
4492                    let place_y = anchor_rect.map(|r| r.y() + r.height()).unwrap_or(origin.y);
4493                    for child_id in self.graph_state.children_of(node_id) {
4494                        self.layout_node_constraints(
4495                            *child_id,
4496                            loose,
4497                            LayoutPoint::new(place_x, place_y),
4498                            out,
4499                            constraints_out,
4500                            measure_cache,
4501                            scroll_source,
4502                            record,
4503                            depth + 1,
4504                        )?;
4505                    }
4506                }
4507                content_size = child_size;
4508                child_size
4509            }
4510            LayoutOp::StyledBox { .. } => unreachable!("styled boxes are resolved before layout"),
4511        };
4512
4513        if let Some(runs) = &node.rich_text {
4514            if let Some(measurer) = &self.measurer {
4515                let (mut text_constraints, text_padding) = match layout_op {
4516                    LayoutOp::Box {
4517                        width,
4518                        height,
4519                        min_width,
4520                        max_width,
4521                        min_height,
4522                        max_height,
4523                        padding,
4524                        ..
4525                    } => (
4526                        constraints
4527                            .apply_min_max(*min_width, *max_width, *min_height, *max_height)
4528                            .tighten(*width, *height),
4529                        *padding,
4530                    ),
4531                    _ => (constraints, [0.0; 4]),
4532                };
4533                let text_inner_constraints = text_constraints.deflate(text_padding);
4534                let intrinsic_width = match &node.op {
4535                    LayoutOp::StyledBox { style, .. } => style.width.as_ref(),
4536                    _ => None,
4537                };
4538                let avail_w = match intrinsic_width {
4539                    Some(Length::MaxContent) => None,
4540                    Some(Length::MinContent) => Some(
4541                        runs.iter()
4542                            .flat_map(|run| {
4543                                run.text.split_whitespace().map(move |word| {
4544                                    measurer.measure(word, run.style.font_size, None).0
4545                                        + run.style.letter_spacing
4546                                            * word.chars().count().saturating_sub(1) as f32
4547                                })
4548                            })
4549                            .fold(0.0, f32::max),
4550                    ),
4551                    _ => text_inner_constraints
4552                        .is_width_bounded()
4553                        .then_some(text_inner_constraints.max_w),
4554                };
4555                let rich_layout = measurer.layout_rich_text(runs, avail_w);
4556                let text_content = LayoutSize::new(
4557                    rich_layout.width + text_padding[0] + text_padding[1],
4558                    rich_layout.height + text_padding[2] + text_padding[3],
4559                );
4560                if let LayoutOp::StyledBox { style, .. } = &node.op {
4561                    let available = if constraints.max_h.is_finite() {
4562                        constraints.max_h
4563                    } else {
4564                        text_content.height
4565                    };
4566                    let resolve_intrinsic_height = |length: &Option<Length>| {
4567                        length
4568                            .as_ref()
4569                            .filter(|length| length_requires_measurement(length))
4570                            .and_then(|length| {
4571                                resolve_measured_length(
4572                                    length,
4573                                    available,
4574                                    self.active_viewport,
4575                                    text_content.height,
4576                                    text_content.height,
4577                                )
4578                            })
4579                    };
4580                    text_constraints = text_constraints.apply_min_max(
4581                        None,
4582                        None,
4583                        resolve_intrinsic_height(&style.min_height),
4584                        resolve_intrinsic_height(&style.max_height),
4585                    );
4586                    text_constraints =
4587                        text_constraints.tighten(None, resolve_intrinsic_height(&style.height));
4588                }
4589                let measured = text_constraints.constrain(text_content);
4590                if rich_text_inline_children
4591                    && rich_layout.inline_boxes.len() == flow_children.len()
4592                {
4593                    let result =
4594                        self.record_geometry(node_id, origin, measured, text_content, out, record);
4595                    if record {
4596                        let mut inline_boxes = rich_layout.inline_boxes;
4597                        inline_boxes.sort_by_key(|inline_box| inline_box.id);
4598                        for (child_id, inline_box) in flow_children.iter().zip(inline_boxes.iter())
4599                        {
4600                            self.layout_node_constraints(
4601                                *child_id,
4602                                BoxConstraints::tight(LayoutSize::new(
4603                                    inline_box.width,
4604                                    inline_box.height,
4605                                )),
4606                                LayoutPoint::new(
4607                                    origin.x + text_padding[0] + inline_box.x,
4608                                    origin.y + text_padding[2] + inline_box.y,
4609                                ),
4610                                out,
4611                                constraints_out,
4612                                measure_cache,
4613                                scroll_source,
4614                                record,
4615                                depth + 1,
4616                            )?;
4617                        }
4618                    }
4619                    if !record {
4620                        measure_cache.insert(MeasureCacheKey::new(node_id, constraints), result);
4621                    }
4622                    return Ok(result);
4623                }
4624                if node.children_ids.is_empty() {
4625                    let result =
4626                        self.record_geometry(node_id, origin, measured, text_content, out, record);
4627                    if !record {
4628                        measure_cache.insert(MeasureCacheKey::new(node_id, constraints), result);
4629                    }
4630                    return Ok(result);
4631                }
4632                content_size.width = content_size.width.max(text_content.width);
4633                content_size.height = content_size.height.max(text_content.height);
4634            }
4635        }
4636
4637        let result = self.record_geometry(node_id, origin, size, content_size, out, record);
4638        if !record {
4639            measure_cache.insert(MeasureCacheKey::new(node_id, constraints), result);
4640        }
4641        Ok(result)
4642    }
4643
4644    fn record_geometry(
4645        &self,
4646        node_id: WidgetId,
4647        origin: LayoutPoint,
4648        size: LayoutSize,
4649        content_size: LayoutSize,
4650        out: &mut HashMap<WidgetId, LayoutNodeGeometry>,
4651        record: bool,
4652    ) -> LayoutSize {
4653        let mut rect_origin = origin;
4654        let mut rect_size = size;
4655        let mut rect_content = content_size;
4656        let mut had_non_finite = false;
4657
4658        if !rect_origin.x.is_finite() {
4659            rect_origin.x = 0.0;
4660            had_non_finite = true;
4661        }
4662        if !rect_origin.y.is_finite() {
4663            rect_origin.y = 0.0;
4664            had_non_finite = true;
4665        }
4666        if !rect_size.width.is_finite() {
4667            rect_size.width = 0.0;
4668            had_non_finite = true;
4669        }
4670        if !rect_size.height.is_finite() {
4671            rect_size.height = 0.0;
4672            had_non_finite = true;
4673        }
4674        if !rect_content.width.is_finite() {
4675            rect_content.width = 0.0;
4676            had_non_finite = true;
4677        }
4678        if !rect_content.height.is_finite() {
4679            rect_content.height = 0.0;
4680            had_non_finite = true;
4681        }
4682
4683        if had_non_finite {
4684            diag::emit(
4685                diag::DiagCategory::Invariants,
4686                diag::DiagLevel::Error,
4687                diag::DiagEventKind::InvariantViolation {
4688                    kind: "non_finite_layout".into(),
4689                    node: Some(node_id.as_u128()),
4690                    details: format!(
4691                        "origin=({:.2},{:.2}) size=({:.2},{:.2}) content=({:.2},{:.2})",
4692                        origin.x,
4693                        origin.y,
4694                        size.width,
4695                        size.height,
4696                        content_size.width,
4697                        content_size.height
4698                    ),
4699                    dump_ref: None,
4700                },
4701            );
4702        }
4703
4704        if record {
4705            let rect = LayoutRect::new(
4706                rect_origin.x,
4707                rect_origin.y,
4708                rect_size.width,
4709                rect_size.height,
4710            );
4711            out.insert(
4712                node_id,
4713                LayoutNodeGeometry {
4714                    rect,
4715                    content_size: rect_content,
4716                },
4717            );
4718        }
4719        rect_size
4720    }
4721}