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        flyout_root_position, resolve_length, LayoutEngine, LayoutGraphState, LayoutInputNode,
774        LayoutPoint, LayoutRect, LayoutSize, TextMeasurer, 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 responsive_cases_use_first_match_precedence() {
1088        let root = WidgetId::from_u128(110);
1089        let responsive = WidgetId::from_u128(111);
1090        let first_match = WidgetId::from_u128(112);
1091        let later_match = WidgetId::from_u128(113);
1092        let fallback = WidgetId::from_u128(114);
1093        let nodes = vec![
1094            node(
1095                root,
1096                None,
1097                vec![responsive],
1098                LayoutOp::Box {
1099                    width: Some(500.0),
1100                    height: Some(100.0),
1101                    min_width: None,
1102                    max_width: None,
1103                    min_height: None,
1104                    max_height: None,
1105                    padding: [0.0; 4],
1106                    flex_grow: 0.0,
1107                    flex_shrink: 1.0,
1108                    aspect_ratio: None,
1109                },
1110            ),
1111            node(
1112                responsive,
1113                Some(root),
1114                vec![first_match, later_match, fallback],
1115                LayoutOp::Responsive {
1116                    query: ResponsiveQuery::Viewport,
1117                    cases: vec![
1118                        ResponsiveCondition {
1119                            min_width: None,
1120                            max_width: Some(900.0),
1121                        },
1122                        ResponsiveCondition {
1123                            min_width: None,
1124                            max_width: Some(600.0),
1125                        },
1126                    ],
1127                },
1128            ),
1129            box_node(first_match, Some(responsive), vec![]),
1130            box_node(later_match, Some(responsive), vec![]),
1131            box_node(fallback, Some(responsive), vec![]),
1132        ];
1133        let mut engine = LayoutEngine::new();
1134        let snapshot = engine
1135            .compute_layout(&nodes, root, LayoutSize::new(500.0, 600.0), &|_| 0.0)
1136            .expect("responsive layout");
1137
1138        assert!(snapshot.nodes.contains_key(&first_match));
1139        assert!(!snapshot.nodes.contains_key(&later_match));
1140        assert!(!snapshot.nodes.contains_key(&fallback));
1141    }
1142
1143    #[test]
1144    fn grid_repeat_and_spans_are_applied_by_the_layout_engine() {
1145        let root = WidgetId::from_u128(200);
1146        let first = WidgetId::from_u128(201);
1147        let second = WidgetId::from_u128(202);
1148        let nodes = vec![
1149            node(
1150                root,
1151                None,
1152                vec![first, second],
1153                LayoutOp::Grid {
1154                    columns: vec![GridTrack::repeat(2, vec![GridTrack::Points(50.0)])],
1155                    rows: vec![GridTrack::Points(20.0)],
1156                    column_gap: Some(10.0),
1157                    row_gap: None,
1158                    padding: [0.0; 4],
1159                },
1160            ),
1161            box_node(first, Some(root), vec![]),
1162            box_node(second, Some(root), vec![]),
1163        ];
1164        let mut engine = LayoutEngine::new();
1165        let snapshot = engine
1166            .compute_layout(&nodes, root, LayoutSize::new(110.0, 20.0), &|_| 0.0)
1167            .expect("grid layout");
1168
1169        assert_eq!(snapshot.nodes[&first].rect.x(), 0.0);
1170        assert_eq!(snapshot.nodes[&second].rect.x(), 60.0);
1171    }
1172
1173    #[test]
1174    fn auto_grid_items_advance_past_occupied_spans() {
1175        let root = WidgetId::from_u128(250);
1176        let first = WidgetId::from_u128(251);
1177        let first_child = WidgetId::from_u128(252);
1178        let second = WidgetId::from_u128(253);
1179        let second_child = WidgetId::from_u128(254);
1180        let nodes = vec![
1181            node(
1182                root,
1183                None,
1184                vec![first, second],
1185                LayoutOp::Grid {
1186                    columns: vec![GridTrack::Points(50.0), GridTrack::Points(50.0)],
1187                    rows: vec![],
1188                    column_gap: None,
1189                    row_gap: None,
1190                    padding: [0.0; 4],
1191                },
1192            ),
1193            node(
1194                first,
1195                Some(root),
1196                vec![first_child],
1197                LayoutOp::GridItem {
1198                    row_start: GridPlacement::Auto,
1199                    row_end: GridPlacement::Auto,
1200                    col_start: GridPlacement::Auto,
1201                    col_end: GridPlacement::Span(2),
1202                },
1203            ),
1204            box_node(first_child, Some(first), vec![]),
1205            node(
1206                second,
1207                Some(root),
1208                vec![second_child],
1209                LayoutOp::GridItem {
1210                    row_start: GridPlacement::Auto,
1211                    row_end: GridPlacement::Auto,
1212                    col_start: GridPlacement::Auto,
1213                    col_end: GridPlacement::Auto,
1214                },
1215            ),
1216            box_node(second_child, Some(second), vec![]),
1217        ];
1218        let mut engine = LayoutEngine::new();
1219        let snapshot = engine
1220            .compute_layout(&nodes, root, LayoutSize::new(100.0, 100.0), &|_| 0.0)
1221            .expect("auto grid layout");
1222
1223        assert_eq!(snapshot.nodes[&first].rect.x(), 0.0);
1224        assert_eq!(snapshot.nodes[&first].rect.width(), 100.0);
1225        assert_eq!(snapshot.nodes[&second].rect.x(), 0.0);
1226        assert_eq!(snapshot.nodes[&second].rect.y(), 20.0);
1227    }
1228
1229    #[test]
1230    fn fixed_text_box_retains_natural_size_for_overflow_inspection() {
1231        let root = WidgetId::from_u128(300);
1232        let mut text = node(
1233            root,
1234            None,
1235            vec![],
1236            LayoutOp::StyledBox {
1237                style: BoxStyle {
1238                    width: Some(Length::Points(40.0)),
1239                    height: Some(Length::Points(10.0)),
1240                    ..Default::default()
1241                },
1242                flex_grow: 0.0,
1243                flex_shrink: 1.0,
1244            },
1245        );
1246        text.rich_text = Some(vec![text_run("overflowing text")]);
1247        let nodes = vec![text];
1248        let mut engine = LayoutEngine::new().with_measurer(Arc::new(WrappingMeasurer));
1249        let snapshot = engine
1250            .compute_layout(&nodes, root, LayoutSize::new(100.0, 100.0), &|_| 0.0)
1251            .expect("text layout");
1252        let inspection = engine
1253            .inspect_node(&snapshot, root)
1254            .expect("layout inspection");
1255
1256        assert_eq!(inspection.laid_out.width(), 40.0);
1257        assert_eq!(inspection.laid_out.height(), 10.0);
1258        assert!(inspection.measured.height() > inspection.laid_out.height());
1259        assert!(inspection.overflow_y);
1260        assert_eq!(
1261            inspection.constrained, inspection.laid_out,
1262            "fixed constraints should match final bounds"
1263        );
1264    }
1265
1266    #[test]
1267    fn max_content_box_propagates_unwrapped_text_width() {
1268        let root = WidgetId::from_u128(400);
1269        let text_id = WidgetId::from_u128(401);
1270        let mut text = node(
1271            text_id,
1272            Some(root),
1273            vec![],
1274            LayoutOp::Box {
1275                width: None,
1276                height: None,
1277                min_width: None,
1278                max_width: None,
1279                min_height: None,
1280                max_height: None,
1281                padding: [0.0; 4],
1282                flex_grow: 0.0,
1283                flex_shrink: 1.0,
1284                aspect_ratio: None,
1285            },
1286        );
1287        text.rich_text = Some(vec![text_run("hello world")]);
1288        let nodes = vec![
1289            node(
1290                root,
1291                None,
1292                vec![text_id],
1293                LayoutOp::StyledBox {
1294                    style: BoxStyle {
1295                        width: Some(Length::MaxContent),
1296                        ..Default::default()
1297                    },
1298                    flex_grow: 0.0,
1299                    flex_shrink: 1.0,
1300                },
1301            ),
1302            text,
1303        ];
1304        let mut engine = LayoutEngine::new().with_measurer(Arc::new(WrappingMeasurer));
1305        let snapshot = engine
1306            .compute_layout(&nodes, root, LayoutSize::new(300.0, 100.0), &|_| 0.0)
1307            .expect("max-content layout");
1308
1309        assert_eq!(snapshot.nodes[&root].rect.width(), 110.0);
1310        assert_eq!(snapshot.nodes[&text_id].rect.width(), 110.0);
1311    }
1312
1313    #[test]
1314    fn intrinsic_lengths_participate_in_clamp_expressions() {
1315        let root = WidgetId::from_u128(450);
1316        let mut text = node(
1317            root,
1318            None,
1319            vec![],
1320            LayoutOp::StyledBox {
1321                style: BoxStyle {
1322                    width: Some(Length::clamp(
1323                        Length::points(50.0),
1324                        Length::MaxContent,
1325                        Length::points(80.0),
1326                    )),
1327                    ..Default::default()
1328                },
1329                flex_grow: 0.0,
1330                flex_shrink: 1.0,
1331            },
1332        );
1333        text.rich_text = Some(vec![text_run("hello world")]);
1334        let mut engine = LayoutEngine::new().with_measurer(Arc::new(WrappingMeasurer));
1335        let snapshot = engine
1336            .compute_layout(&[text], root, LayoutSize::new(300.0, 100.0), &|_| 0.0)
1337            .expect("intrinsic clamp layout");
1338
1339        assert_eq!(snapshot.nodes[&root].rect.width(), 80.0);
1340        assert_eq!(snapshot.nodes[&root].rect.height(), 40.0);
1341    }
1342
1343    #[test]
1344    fn margin_wrapper_keeps_percentage_width_relative_to_the_containing_box() {
1345        let outer = WidgetId::from_u128(455);
1346        let inner = WidgetId::from_u128(456);
1347        let nodes = vec![
1348            node(
1349                outer,
1350                None,
1351                vec![inner],
1352                LayoutOp::StyledBox {
1353                    style: BoxStyle {
1354                        width: Some(
1355                            Length::percent(50.0) + Length::points(12.0) + Length::points(12.0),
1356                        ),
1357                        padding: Some(Length::all(Length::points(12.0))),
1358                        alignment: fission_ir::op::BoxAlignment::Stretch,
1359                        ..Default::default()
1360                    },
1361                    flex_grow: 0.0,
1362                    flex_shrink: 1.0,
1363                },
1364            ),
1365            node(
1366                inner,
1367                Some(outer),
1368                vec![],
1369                LayoutOp::StyledBox {
1370                    style: BoxStyle {
1371                        width: Some(Length::percent(100.0)),
1372                        height: Some(Length::points(20.0)),
1373                        ..Default::default()
1374                    },
1375                    flex_grow: 0.0,
1376                    flex_shrink: 1.0,
1377                },
1378            ),
1379        ];
1380        let mut engine = LayoutEngine::new();
1381        let snapshot = engine
1382            .compute_layout(&nodes, outer, LayoutSize::new(200.0, 100.0), &|_| 0.0)
1383            .expect("margin layout");
1384
1385        assert_eq!(snapshot.nodes[&outer].rect.width(), 124.0);
1386        assert_eq!(snapshot.nodes[&inner].rect.width(), 100.0);
1387        assert_eq!(snapshot.nodes[&inner].rect.x(), 12.0);
1388    }
1389
1390    #[test]
1391    fn fit_content_height_preserves_wrapped_text_height() {
1392        let root = WidgetId::from_u128(460);
1393        let mut text = node(
1394            root,
1395            None,
1396            vec![],
1397            LayoutOp::StyledBox {
1398                style: BoxStyle {
1399                    width: Some(Length::points(40.0)),
1400                    height: Some(Length::fit_content(None)),
1401                    ..Default::default()
1402                },
1403                flex_grow: 0.0,
1404                flex_shrink: 1.0,
1405            },
1406        );
1407        text.rich_text = Some(vec![text_run("abcdefgh")]);
1408        let mut engine = LayoutEngine::new().with_measurer(Arc::new(WrappingMeasurer));
1409        let snapshot = engine
1410            .compute_layout(&[text], root, LayoutSize::new(300.0, 100.0), &|_| 0.0)
1411            .expect("fit-content height layout");
1412
1413        assert_eq!(snapshot.nodes[&root].rect.width(), 40.0);
1414        assert_eq!(snapshot.nodes[&root].rect.height(), 40.0);
1415    }
1416
1417    #[test]
1418    fn typed_position_offsets_resolve_against_the_parent_box() {
1419        let root = WidgetId::from_u128(500);
1420        let positioned = WidgetId::from_u128(501);
1421        let child = WidgetId::from_u128(502);
1422        let nodes = vec![
1423            node(root, None, vec![positioned], LayoutOp::ZStack),
1424            node(
1425                positioned,
1426                Some(root),
1427                vec![child],
1428                LayoutOp::PositionedLengths {
1429                    left: Some(Length::Percent(25.0)),
1430                    top: Some(Length::Percent(10.0)),
1431                    right: None,
1432                    bottom: None,
1433                    width: Some(Length::Points(50.0)),
1434                    height: Some(Length::Points(20.0)),
1435                },
1436            ),
1437            node(
1438                child,
1439                Some(positioned),
1440                vec![],
1441                LayoutOp::Box {
1442                    width: None,
1443                    height: None,
1444                    min_width: None,
1445                    max_width: None,
1446                    min_height: None,
1447                    max_height: None,
1448                    padding: [0.0; 4],
1449                    flex_grow: 0.0,
1450                    flex_shrink: 1.0,
1451                    aspect_ratio: None,
1452                },
1453            ),
1454        ];
1455        let mut engine = LayoutEngine::new();
1456        let snapshot = engine
1457            .compute_layout(&nodes, root, LayoutSize::new(200.0, 100.0), &|_| 0.0)
1458            .expect("typed positioned layout");
1459
1460        assert_eq!(snapshot.nodes[&child].rect.x(), 50.0);
1461        assert_eq!(snapshot.nodes[&child].rect.y(), 10.0);
1462        assert_eq!(snapshot.nodes[&child].rect.width(), 50.0);
1463        assert_eq!(snapshot.nodes[&child].rect.height(), 20.0);
1464    }
1465
1466    #[test]
1467    fn spotlight_lays_out_inverse_overlay_around_anchor() {
1468        let root = WidgetId::from_u128(20);
1469        let positioned = WidgetId::from_u128(21);
1470        let anchor = WidgetId::from_u128(22);
1471        let spotlight = WidgetId::from_u128(23);
1472        let panels = (24..=28).map(WidgetId::from_u128).collect::<Vec<_>>();
1473
1474        let mut nodes = vec![
1475            LayoutInputNode {
1476                id: root,
1477                parent_id: None,
1478                op: LayoutOp::ZStack,
1479                children_ids: vec![positioned, spotlight],
1480                debug_name: "root".into(),
1481                width: None,
1482                height: None,
1483                flex_grow: 0.0,
1484                flex_shrink: 1.0,
1485                rich_text: None,
1486            },
1487            LayoutInputNode {
1488                id: positioned,
1489                parent_id: Some(root),
1490                op: LayoutOp::Positioned {
1491                    left: Some(100.0),
1492                    top: Some(100.0),
1493                    right: None,
1494                    bottom: None,
1495                    width: Some(200.0),
1496                    height: Some(80.0),
1497                },
1498                children_ids: vec![anchor],
1499                debug_name: "positioned-anchor".into(),
1500                width: Some(200.0),
1501                height: Some(80.0),
1502                flex_grow: 0.0,
1503                flex_shrink: 0.0,
1504                rich_text: None,
1505            },
1506            box_node(anchor, Some(positioned), vec![]),
1507            LayoutInputNode {
1508                id: spotlight,
1509                parent_id: Some(root),
1510                op: LayoutOp::Spotlight {
1511                    anchor,
1512                    padding: 12.0,
1513                },
1514                children_ids: panels.clone(),
1515                debug_name: "spotlight".into(),
1516                width: None,
1517                height: None,
1518                flex_grow: 0.0,
1519                flex_shrink: 1.0,
1520                rich_text: None,
1521            },
1522        ];
1523        nodes[2].op = LayoutOp::Box {
1524            width: Some(200.0),
1525            height: Some(80.0),
1526            min_width: None,
1527            max_width: None,
1528            min_height: None,
1529            max_height: None,
1530            padding: [0.0; 4],
1531            flex_grow: 0.0,
1532            flex_shrink: 0.0,
1533            aspect_ratio: None,
1534        };
1535        nodes[2].width = Some(200.0);
1536        nodes[2].height = Some(80.0);
1537        nodes.extend(
1538            panels
1539                .iter()
1540                .map(|id| box_node(*id, Some(spotlight), vec![])),
1541        );
1542
1543        let mut engine = LayoutEngine::new();
1544        let snapshot = engine
1545            .compute_layout(&nodes, root, LayoutSize::new(800.0, 600.0), &|_| 0.0)
1546            .expect("spotlight layout");
1547
1548        let expected = [
1549            LayoutRect::new(0.0, 0.0, 800.0, 88.0),
1550            LayoutRect::new(0.0, 192.0, 800.0, 408.0),
1551            LayoutRect::new(0.0, 88.0, 88.0, 104.0),
1552            LayoutRect::new(312.0, 88.0, 488.0, 104.0),
1553            LayoutRect::new(88.0, 88.0, 224.0, 104.0),
1554        ];
1555        for (panel, expected_rect) in panels.iter().zip(expected) {
1556            assert_eq!(snapshot.get_node_rect(*panel), Some(expected_rect));
1557        }
1558    }
1559
1560    #[test]
1561    fn flyout_placement_clamps_rendered_descendants_inside_viewport() {
1562        let position = flyout_root_position(
1563            LayoutSize::new(800.0, 600.0),
1564            LayoutRect::new(700.0, 550.0, 80.0, 32.0),
1565            LayoutRect::new(0.0, 8.0, 440.0, 220.0),
1566        );
1567
1568        assert_eq!(position, LayoutPoint::new(360.0, 322.0));
1569    }
1570
1571    #[test]
1572    fn flyout_placement_prefers_below_when_full_content_fits() {
1573        let position = flyout_root_position(
1574            LayoutSize::new(800.0, 600.0),
1575            LayoutRect::new(100.0, 100.0, 200.0, 80.0),
1576            LayoutRect::new(0.0, 8.0, 320.0, 180.0),
1577        );
1578
1579        assert_eq!(position, LayoutPoint::new(100.0, 172.0));
1580    }
1581}
1582
1583fn layout_input_fingerprint(node: &LayoutInputNode) -> u64 {
1584    let mut hasher = DefaultHasher::new();
1585    format!("{node:?}").hash(&mut hasher);
1586    hasher.finish()
1587}
1588
1589fn intersect_rect(left: LayoutRect, right: LayoutRect) -> LayoutRect {
1590    let x = left.x().max(right.x());
1591    let y = left.y().max(right.y());
1592    let right_edge = left.right().min(right.right());
1593    let bottom_edge = left.bottom().min(right.bottom());
1594    LayoutRect::new(x, y, (right_edge - x).max(0.0), (bottom_edge - y).max(0.0))
1595}
1596
1597fn union_rect(left: LayoutRect, right: LayoutRect) -> LayoutRect {
1598    let x = left.x().min(right.x());
1599    let y = left.y().min(right.y());
1600    let right_edge = left.right().max(right.right());
1601    let bottom_edge = left.bottom().max(right.bottom());
1602    LayoutRect::new(x, y, right_edge - x, bottom_edge - y)
1603}
1604
1605/// An axis-aligned rectangle: an origin point plus a size.
1606///
1607/// `LayoutRect` is the final output for every node after layout: it says exactly
1608/// where the node sits on screen and how large it is.
1609///
1610/// # Example
1611///
1612/// ```rust
1613/// use fission_layout::{LayoutRect, LayoutPoint};
1614///
1615/// let rect = LayoutRect::new(10.0, 20.0, 300.0, 200.0);
1616/// assert_eq!(rect.right(), 310.0);
1617/// assert!(rect.contains(LayoutPoint::new(15.0, 25.0)));
1618/// ```
1619#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1620pub struct LayoutRect {
1621    /// The top-left corner of the rectangle.
1622    pub origin: LayoutPoint,
1623    /// The width and height of the rectangle.
1624    pub size: LayoutSize,
1625}
1626
1627impl LayoutRect {
1628    /// Creates a rectangle from x, y, width, and height.
1629    pub fn new(x: LayoutUnit, y: LayoutUnit, width: LayoutUnit, height: LayoutUnit) -> Self {
1630        Self {
1631            origin: LayoutPoint { x, y },
1632            size: LayoutSize { width, height },
1633        }
1634    }
1635
1636    /// The x coordinate of the left edge.
1637    pub fn x(&self) -> LayoutUnit {
1638        self.origin.x
1639    }
1640    /// The y coordinate of the top edge.
1641    pub fn y(&self) -> LayoutUnit {
1642        self.origin.y
1643    }
1644    /// The width of the rectangle.
1645    pub fn width(&self) -> LayoutUnit {
1646        self.size.width
1647    }
1648    /// The height of the rectangle.
1649    pub fn height(&self) -> LayoutUnit {
1650        self.size.height
1651    }
1652
1653    /// The x coordinate of the right edge (`x + width`).
1654    pub fn right(&self) -> LayoutUnit {
1655        self.origin.x + self.size.width
1656    }
1657    /// The y coordinate of the bottom edge (`y + height`).
1658    pub fn bottom(&self) -> LayoutUnit {
1659        self.origin.y + self.size.height
1660    }
1661
1662    /// Returns `true` if the point `p` lies within this rectangle (inclusive on
1663    /// the left/top edges, exclusive on the right/bottom edges).
1664    pub fn contains(&self, p: LayoutPoint) -> bool {
1665        p.x >= self.x() && p.x < self.right() && p.y >= self.y() && p.y < self.bottom()
1666    }
1667}
1668
1669fn spotlight_regions(
1670    bounds: LayoutRect,
1671    target: Option<LayoutRect>,
1672    padding: LayoutUnit,
1673) -> [LayoutRect; 5] {
1674    let zero = LayoutRect::new(bounds.x(), bounds.y(), 0.0, 0.0);
1675    let Some(target) = target else {
1676        return [bounds, zero, zero, zero, zero];
1677    };
1678
1679    let padding = if padding.is_finite() {
1680        padding.max(0.0)
1681    } else {
1682        0.0
1683    };
1684    let left = (target.x() - padding).clamp(bounds.x(), bounds.right());
1685    let top = (target.y() - padding).clamp(bounds.y(), bounds.bottom());
1686    let right = (target.right() + padding).clamp(bounds.x(), bounds.right());
1687    let bottom = (target.bottom() + padding).clamp(bounds.y(), bounds.bottom());
1688
1689    if right <= left || bottom <= top {
1690        return [bounds, zero, zero, zero, zero];
1691    }
1692
1693    let hole_width = right - left;
1694    let hole_height = bottom - top;
1695    [
1696        LayoutRect::new(bounds.x(), bounds.y(), bounds.width(), top - bounds.y()),
1697        LayoutRect::new(bounds.x(), bottom, bounds.width(), bounds.bottom() - bottom),
1698        LayoutRect::new(bounds.x(), top, left - bounds.x(), hole_height),
1699        LayoutRect::new(left + hole_width, top, bounds.right() - right, hole_height),
1700        LayoutRect::new(left, top, hole_width, hole_height),
1701    ]
1702}
1703
1704fn flyout_root_position(
1705    viewport: LayoutSize,
1706    anchor: LayoutRect,
1707    content_extents: LayoutRect,
1708) -> LayoutPoint {
1709    let min_left = -content_extents.x();
1710    let max_left = viewport.width - content_extents.right();
1711    let desired_left = anchor.x() - content_extents.x();
1712    let left = if max_left >= min_left {
1713        desired_left.clamp(min_left, max_left)
1714    } else {
1715        min_left
1716    };
1717
1718    let below = anchor.bottom() - content_extents.y();
1719    let above = anchor.y() - content_extents.bottom();
1720    let min_top = -content_extents.y();
1721    let max_top = viewport.height - content_extents.bottom();
1722    let top = if below + content_extents.bottom() <= viewport.height {
1723        below
1724    } else if above + content_extents.y() >= 0.0 {
1725        above
1726    } else if max_top >= min_top {
1727        below.clamp(min_top, max_top)
1728    } else {
1729        min_top
1730    };
1731
1732    LayoutPoint::new(left, top)
1733}
1734
1735/// The computed geometry of a single layout node.
1736///
1737/// After layout, every node has a bounding rectangle (its position and size on
1738/// screen) and a content size (how large its content actually is, which may exceed
1739/// the rect for scroll containers).
1740#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1741pub struct LayoutNodeGeometry {
1742    /// The bounding rectangle of this node in absolute (screen) coordinates.
1743    pub rect: LayoutRect,
1744    /// The natural size of the node's content before clipping. For scroll containers,
1745    /// this may be larger than `rect.size`, indicating scrollable overflow.
1746    pub content_size: LayoutSize,
1747}
1748
1749/// A node's geometry at each important stage of the layout pipeline.
1750#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1751pub struct LayoutInspection {
1752    /// Node identity being inspected.
1753    pub node: WidgetId,
1754    /// Natural content bounds before constraints are applied.
1755    pub measured: LayoutRect,
1756    /// Constraints supplied by the parent.
1757    pub constraints: BoxConstraints,
1758    /// Natural content bounds after applying parent and node-local constraints.
1759    pub constrained: LayoutRect,
1760    /// Final bounds assigned by layout.
1761    pub laid_out: LayoutRect,
1762    /// Visible bounds after ancestor clipping.
1763    pub clipped: LayoutRect,
1764    /// Estimated visual bounds including laid-out descendants.
1765    pub painted: LayoutRect,
1766    /// Whether natural content exceeds the assigned width.
1767    pub overflow_x: bool,
1768    /// Whether natural content exceeds the assigned height.
1769    pub overflow_y: bool,
1770}
1771
1772/// The complete output of a layout pass.
1773///
1774/// `LayoutSnapshot` maps every node to its computed geometry and records the
1775/// viewport size that was used. It is the primary interface between the layout
1776/// engine and downstream consumers (the renderer, hit testing, accessibility).
1777///
1778/// # Example
1779///
1780/// ```rust,no_run
1781/// use fission_layout::{LayoutSnapshot, LayoutSize};
1782/// use fission_ir::WidgetId;
1783///
1784/// let snapshot = LayoutSnapshot::new(LayoutSize::new(800.0, 600.0));
1785/// assert_eq!(snapshot.viewport_size.width, 800.0);
1786/// ```
1787#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1788pub struct LayoutSnapshot {
1789    /// Computed geometry for every node, keyed by [`WidgetId`].
1790    pub nodes: HashMap<WidgetId, LayoutNodeGeometry>,
1791    /// The constraints that were passed to each node during layout. Useful for
1792    /// debugging. Skipped during serialization.
1793    #[serde(skip)]
1794    pub constraints: HashMap<WidgetId, BoxConstraints>,
1795    /// The viewport size used for this layout pass.
1796    pub viewport_size: LayoutSize,
1797}
1798
1799impl LayoutSnapshot {
1800    /// Creates an empty snapshot for the given viewport size.
1801    pub fn new(viewport_size: LayoutSize) -> Self {
1802        Self {
1803            nodes: HashMap::new(),
1804            constraints: HashMap::new(),
1805            viewport_size,
1806        }
1807    }
1808
1809    /// Returns the full geometry (rect + content size) for a node, or `None` if
1810    /// the node was not part of this layout pass.
1811    pub fn get_node_geometry(&self, node_id: WidgetId) -> Option<&LayoutNodeGeometry> {
1812        self.nodes.get(&node_id)
1813    }
1814
1815    /// Returns just the bounding rectangle for a node, or `None` if not found.
1816    pub fn get_node_rect(&self, node_id: WidgetId) -> Option<LayoutRect> {
1817        self.nodes.get(&node_id).map(|g| g.rect)
1818    }
1819
1820    /// Returns the constraints that were passed to a node during layout, or `None`
1821    /// if not found. Useful for debugging layout issues.
1822    pub fn get_node_constraints(&self, node_id: WidgetId) -> Option<BoxConstraints> {
1823        self.constraints.get(&node_id).copied()
1824    }
1825}
1826
1827/// A flattened representation of a layout node, ready for the layout engine.
1828///
1829/// The widget compiler produces a list of `LayoutInputNode`s from the IR. Each node
1830/// carries its layout operation, parent/child relationships, flex participation
1831/// parameters, and optional rich text content for text measurement.
1832///
1833/// The layout engine operates on `&[LayoutInputNode]` rather than traversing the
1834/// IR directly, which keeps the engine decoupled from the IR's internal structure.
1835#[derive(Debug, Clone)]
1836pub struct LayoutInputNode {
1837    /// The unique identity of this node.
1838    pub id: WidgetId,
1839    /// The parent node's ID, or `None` for the root.
1840    pub parent_id: Option<WidgetId>,
1841    /// The layout operation this node performs.
1842    pub op: LayoutOp,
1843    /// Ordered list of child node IDs.
1844    pub children_ids: Vec<WidgetId>,
1845    /// A human-readable name for debugging and diagnostics.
1846    pub debug_name: String,
1847    /// Explicit width override, or `None` to derive from constraints.
1848    pub width: Option<LayoutUnit>,
1849    /// Explicit height override, or `None` to derive from constraints.
1850    pub height: Option<LayoutUnit>,
1851    /// How much extra main-axis space this node claims from its flex parent.
1852    pub flex_grow: LayoutUnit,
1853    /// How much this node shrinks when its flex parent overflows.
1854    pub flex_shrink: LayoutUnit,
1855    /// Optional rich text content. When present, the layout engine uses the
1856    /// [`TextMeasurer`] to determine the node's intrinsic size from the text.
1857    pub rich_text: Option<Vec<TextRun>>,
1858}
1859
1860fn has_explicit_axis_size(node: &LayoutInputNode, horizontal: bool) -> bool {
1861    let fixed = if horizontal { node.width } else { node.height };
1862    if fixed.is_some() {
1863        return true;
1864    }
1865
1866    let typed_length_is_explicit =
1867        |length: Option<&Length>| length.is_some_and(|length| !matches!(length, Length::Auto));
1868
1869    match &node.op {
1870        LayoutOp::Box { width, height, .. }
1871        | LayoutOp::Scroll { width, height, .. }
1872        | LayoutOp::Embed { width, height, .. }
1873        | LayoutOp::Positioned { width, height, .. } => {
1874            if horizontal {
1875                width.is_some()
1876            } else {
1877                height.is_some()
1878            }
1879        }
1880        LayoutOp::StyledBox { style, .. } => typed_length_is_explicit(if horizontal {
1881            style.width.as_ref()
1882        } else {
1883            style.height.as_ref()
1884        }),
1885        LayoutOp::PositionedLengths { width, height, .. } => {
1886            typed_length_is_explicit(if horizontal {
1887                width.as_ref()
1888            } else {
1889                height.as_ref()
1890            })
1891        }
1892        _ => false,
1893    }
1894}
1895
1896fn has_explicit_cross_axis_size(node: &LayoutInputNode, is_row: bool) -> bool {
1897    has_explicit_axis_size(node, !is_row)
1898}
1899
1900fn has_explicit_main_axis_size(node: &LayoutInputNode, is_row: bool) -> bool {
1901    has_explicit_axis_size(node, is_row)
1902}
1903
1904/// Per-line metrics returned by text measurement.
1905///
1906/// When the layout engine or hit-testing code needs to know about individual lines
1907/// of text (e.g., for cursor positioning in a multi-line text field), it calls
1908/// [`TextMeasurer::get_line_metrics`] and receives a `Vec<LineMetric>`.
1909pub struct LineMetric {
1910    /// Byte index where this line starts in the source string.
1911    pub start_index: usize,
1912    /// Byte index where this line ends in the source string (exclusive).
1913    pub end_index: usize,
1914    /// Distance from the top of the line to its alphabetic baseline, in logical pixels.
1915    pub baseline: f32,
1916    /// Total height of the line (ascent + descent + leading), in logical pixels.
1917    pub height: f32,
1918    /// Measured width of the line's content, in logical pixels.
1919    pub width: f32,
1920}
1921
1922#[derive(Debug, Clone, Copy, PartialEq)]
1923pub struct RichTextInlineBox {
1924    pub id: u64,
1925    pub x: f32,
1926    pub y: f32,
1927    pub width: f32,
1928    pub height: f32,
1929}
1930
1931#[derive(Debug, Clone, PartialEq)]
1932pub struct RichTextLayoutInfo {
1933    pub width: f32,
1934    pub height: f32,
1935    pub inline_boxes: Vec<RichTextInlineBox>,
1936}
1937
1938/// A platform-provided text measurement backend.
1939///
1940/// The layout engine does not shape or measure text itself. Instead, platform
1941/// backends implement `TextMeasurer` to wrap their native text engine (CoreText
1942/// on macOS, DirectWrite on Windows, HarfBuzz + FreeType on Linux, etc.).
1943///
1944/// All methods have default implementations that return zero-sized results, so
1945/// you only need to override the methods your backend supports.
1946///
1947/// # Required
1948///
1949/// * [`measure`](TextMeasurer::measure) -- must be implemented to get correct text layout.
1950///
1951/// # Optional
1952///
1953/// * [`hit_test`](TextMeasurer::hit_test) -- needed for click-to-cursor in text fields.
1954/// * [`get_line_metrics`](TextMeasurer::get_line_metrics) -- needed for multi-line cursor navigation.
1955/// * [`get_caret_position`](TextMeasurer::get_caret_position) -- needed for drawing the text cursor.
1956/// * [`measure_rich_text`](TextMeasurer::measure_rich_text) -- needed for mixed-style text.
1957const DEFAULT_RICH_TEXT_HIT_TEST_FONT_SIZE: f32 = 14.0;
1958
1959pub trait TextMeasurer: Send + Sync {
1960    /// Measures single-style text and returns `(width, height)` in logical pixels.
1961    ///
1962    /// If `available_width` is `Some`, the text should be wrapped at that width.
1963    /// If `None`, the text is measured as a single unwrapped line.
1964    fn measure(&self, text: &str, font_size: f32, available_width: Option<f32>) -> (f32, f32);
1965
1966    /// Returns the byte index of the character closest to the point `(x, y)`,
1967    /// relative to the text's origin. Used for click-to-cursor in text fields.
1968    ///
1969    /// The default implementation returns `0`.
1970    fn hit_test(
1971        &self,
1972        _text: &str,
1973        _font_size: f32,
1974        _available_width: Option<f32>,
1975        _x: f32,
1976        _y: f32,
1977    ) -> usize {
1978        0
1979    }
1980
1981    /// Returns per-line metrics for the given text. Used for multi-line text fields
1982    /// and line-based cursor navigation.
1983    ///
1984    /// The default implementation returns an empty vec.
1985    fn get_line_metrics(
1986        &self,
1987        _text: &str,
1988        _font_size: f32,
1989        _available_width: Option<f32>,
1990    ) -> Vec<LineMetric> {
1991        vec![]
1992    }
1993
1994    /// Returns the `(x, y)` position of the text cursor at `caret_index` (byte offset),
1995    /// relative to the text's origin.
1996    ///
1997    /// The default implementation returns `(0.0, 0.0)`.
1998    fn get_caret_position(
1999        &self,
2000        _text: &str,
2001        _font_size: f32,
2002        _available_width: Option<f32>,
2003        _caret_index: usize,
2004    ) -> (f32, f32) {
2005        (0.0, 0.0)
2006    }
2007
2008    /// Measures multi-style (rich) text and returns `(width, height)` in logical pixels.
2009    ///
2010    /// The default implementation returns `(0.0, 0.0)`.
2011    fn measure_rich_text(&self, _runs: &[TextRun], _available_width: Option<f32>) -> (f32, f32) {
2012        (0.0, 0.0)
2013    }
2014
2015    /// Measures rich text and returns positioned inline-widget boxes, if any.
2016    ///
2017    /// Backends that understand inline rich-text widget markers should override
2018    /// this so layout can place the child widgets at the same coordinates used
2019    /// by text shaping.
2020    fn layout_rich_text(
2021        &self,
2022        runs: &[TextRun],
2023        available_width: Option<f32>,
2024    ) -> RichTextLayoutInfo {
2025        let (width, height) = if runs.len() == 1 {
2026            let run = &runs[0];
2027            self.measure(&run.text, run.style.font_size, available_width)
2028        } else {
2029            self.measure_rich_text(runs, available_width)
2030        };
2031        RichTextLayoutInfo {
2032            width,
2033            height,
2034            inline_boxes: Vec::new(),
2035        }
2036    }
2037
2038    /// Hit-test rich text (styled runs) at the given (x, y) position.
2039    /// Returns the byte offset into the concatenated text of all runs.
2040    /// Default falls back to plain hit_test using the first run's font size.
2041    fn hit_test_rich(
2042        &self,
2043        runs: &[TextRun],
2044        _available_width: Option<f32>,
2045        x: f32,
2046        y: f32,
2047    ) -> usize {
2048        // Preserve the normal body-text fallback when no run is available, so
2049        // fallback hit testing never asks a backend to shape zero-sized text.
2050        let text: String = runs.iter().map(|r| r.text.as_str()).collect();
2051        let font_size = runs
2052            .first()
2053            .map(|r| r.style.font_size)
2054            .unwrap_or(DEFAULT_RICH_TEXT_HIT_TEST_FONT_SIZE);
2055        self.hit_test(&text, font_size, None, x, y)
2056    }
2057
2058    /// Resolves the rich-text annotation at the given point, if any.
2059    ///
2060    /// This is used for interactive rich-text spans that need hit testing
2061    /// against shaped rich text rather than box nodes.
2062    fn resolve_rich_text_annotation_at_point(
2063        &self,
2064        _runs: &[TextRun],
2065        _available_width: Option<f32>,
2066        _x: f32,
2067        _y: f32,
2068        _paragraph_style: TextParagraphStyle,
2069        _annotations: &[RichTextAnnotation],
2070    ) -> Option<RichTextAnnotation> {
2071        None
2072    }
2073}
2074
2075/// The constraint-based layout solver.
2076///
2077/// `LayoutEngine` walks the node tree top-down, passing [`BoxConstraints`] from
2078/// parent to child, and bottom-up, returning [`LayoutSize`] from child to parent.
2079/// The final result is a [`LayoutSnapshot`] that maps every node to its absolute
2080/// screen-space rectangle.
2081///
2082/// The engine optionally holds a [`TextMeasurer`] for sizing text nodes. Without
2083/// one, text nodes are treated as zero-sized.
2084///
2085/// # Example
2086///
2087/// ```rust,no_run
2088/// use fission_layout::*;
2089/// use fission_ir::WidgetId;
2090/// use std::sync::Arc;
2091///
2092/// let mut engine = LayoutEngine::new();
2093/// // engine = engine.with_measurer(my_text_measurer);
2094///
2095/// // let snapshot = engine.compute_layout(&nodes, root_id, viewport, &|_| 0.0).unwrap();
2096/// ```
2097pub struct LayoutEngine {
2098    measurer: Option<Arc<dyn TextMeasurer>>,
2099    graph_state: LayoutGraphState,
2100    next_graph_version: u64,
2101    incremental_reuse: Option<IncrementalLayoutReuseState>,
2102    active_viewport: LayoutSize,
2103}
2104
2105impl LayoutEngine {
2106    const MAX_LAYOUT_RECURSION_DEPTH: usize = 100;
2107
2108    /// Creates a new layout engine with no text measurer.
2109    ///
2110    /// Text nodes will be treated as zero-sized until a measurer is provided
2111    /// via [`with_measurer`](LayoutEngine::with_measurer).
2112    pub fn new() -> Self {
2113        Self {
2114            measurer: None,
2115            graph_state: LayoutGraphState::default(),
2116            next_graph_version: 1,
2117            incremental_reuse: None,
2118            active_viewport: LayoutSize::ZERO,
2119        }
2120    }
2121
2122    /// Returns a new engine with the given text measurer attached.
2123    ///
2124    /// This is a builder-style method that consumes and returns `self`.
2125    pub fn with_measurer(mut self, measurer: Arc<dyn TextMeasurer>) -> Self {
2126        self.measurer = Some(measurer);
2127        self
2128    }
2129
2130    fn allocate_graph_version(&mut self) -> u64 {
2131        let version = self.next_graph_version;
2132        self.next_graph_version = self.next_graph_version.saturating_add(1);
2133        version
2134    }
2135
2136    fn refresh_graph_state(&mut self, input_nodes: &[LayoutInputNode]) {
2137        let version = self.allocate_graph_version();
2138        self.graph_state = LayoutGraphState::from_input_nodes(input_nodes, version);
2139    }
2140
2141    fn ensure_graph_state(&mut self, input_nodes: &[LayoutInputNode]) {
2142        if self.graph_state.is_empty() || !self.graph_state.matches_input_nodes(input_nodes) {
2143            self.refresh_graph_state(input_nodes);
2144        }
2145    }
2146
2147    fn validate_graph_state(&self, root: WidgetId) -> Result<()> {
2148        if let Some(err) = self.graph_state.validation.first_error() {
2149            return Err(err);
2150        }
2151        if !self.graph_state.nodes.contains_key(&root) {
2152            anyhow::bail!("[verify] missing node {:?}", root);
2153        }
2154        if !self.graph_state.roots.contains(&root)
2155            && self
2156                .graph_state
2157                .parents
2158                .get(&root)
2159                .copied()
2160                .flatten()
2161                .is_some()
2162        {
2163            anyhow::bail!("[verify] root {:?} is not a graph root", root);
2164        }
2165        if let Some(last_layout_version) = self.graph_state.last_layout_version {
2166            if last_layout_version > self.graph_state.graph_version {
2167                anyhow::bail!(
2168                    "[verify] cached layout version {} exceeds graph version {}",
2169                    last_layout_version,
2170                    self.graph_state.graph_version
2171                );
2172            }
2173        }
2174        Ok(())
2175    }
2176
2177    /// Refreshes the cached graph state after upstream layout edits.
2178    ///
2179    /// Unchanged nodes keep their cached graph entries while edited topology and
2180    /// fingerprints are synchronized to the latest flattened node list.
2181    pub fn update(&mut self, input_nodes: &[LayoutInputNode]) {
2182        if self.graph_state.is_empty() {
2183            self.refresh_graph_state(input_nodes);
2184            return;
2185        }
2186
2187        if self.graph_state.matches_input_nodes(input_nodes) {
2188            return;
2189        }
2190
2191        let version = self.allocate_graph_version();
2192        self.graph_state.graph_version = version;
2193        self.graph_state.update_nodes(input_nodes);
2194    }
2195
2196    /// Rebuilds internal data structures from the full node list.
2197    pub fn rebuild(&mut self, input_nodes: &[LayoutInputNode]) -> Result<()> {
2198        self.refresh_graph_state(input_nodes);
2199        if let Some(err) = self.graph_state.validation.first_error() {
2200            return Err(err);
2201        }
2202        Ok(())
2203    }
2204
2205    /// Verifies parent-child consistency and checks for cycles in the node graph.
2206    ///
2207    /// Call this during development/testing to catch malformed IR before it causes
2208    /// layout panics. Returns `Err` with a description of the first problem found.
2209    pub fn verify_post_update(
2210        &self,
2211        input_nodes: &[LayoutInputNode],
2212        root: WidgetId,
2213    ) -> Result<()> {
2214        if self.graph_state.matches_input_nodes(input_nodes) {
2215            return self.validate_graph_state(root);
2216        }
2217
2218        let node_map: HashMap<WidgetId, &LayoutInputNode> =
2219            input_nodes.iter().map(|n| (n.id, n)).collect();
2220        // Parent/child consistency
2221        for n in input_nodes {
2222            for child in &n.children_ids {
2223                let child_node = node_map
2224                    .get(child)
2225                    .ok_or_else(|| anyhow::anyhow!("[verify] child {:?} not found", child))?;
2226                if child_node.parent_id != Some(n.id) {
2227                    anyhow::bail!("[verify] parent/child mismatch parent={:?} child={:?} child.parent_id={:?}", n.id, child, child_node.parent_id);
2228                }
2229            }
2230        }
2231        // Cycle via DFS
2232        fn dfs(
2233            id: WidgetId,
2234            map: &HashMap<WidgetId, &LayoutInputNode>,
2235            visited: &mut HashSet<WidgetId>,
2236            stack: &mut HashSet<WidgetId>,
2237        ) -> Result<()> {
2238            if !visited.insert(id) {
2239                return Ok(());
2240            }
2241            stack.insert(id);
2242            let node = map
2243                .get(&id)
2244                .ok_or_else(|| anyhow::anyhow!("[verify] missing node {:?}", id))?;
2245            for child in &node.children_ids {
2246                if stack.contains(child) {
2247                    anyhow::bail!("[verify] cycle detected at {:?} -> {:?}", id, child);
2248                }
2249                dfs(*child, map, visited, stack)?;
2250            }
2251            stack.remove(&id);
2252            Ok(())
2253        }
2254        let mut visited = HashSet::new();
2255        let mut stack = HashSet::new();
2256        dfs(root, &node_map, &mut visited, &mut stack)?;
2257        Ok(())
2258    }
2259
2260    /// Computes layout for the entire node tree and returns a snapshot.
2261    ///
2262    /// This is the main entry point. It runs the constraint-based layout algorithm
2263    /// starting from `root_node_id`, using `viewport_size` as the root constraints,
2264    /// and querying `scroll_source` for scroll offsets. After layout, it emits scroll
2265    /// diagnostics for debugging.
2266    ///
2267    /// # Arguments
2268    ///
2269    /// * `input_nodes` -- The flat list of all layout nodes.
2270    /// * `root_node_id` -- Which node is the root of the tree.
2271    /// * `viewport_size` -- The size of the window/screen.
2272    /// * `scroll_source` -- Provides scroll offsets for scroll containers.
2273    ///
2274    /// # Errors
2275    ///
2276    /// Returns `Err` if a cycle is detected or a required node is missing.
2277    pub fn compute_layout(
2278        &mut self,
2279        input_nodes: &[LayoutInputNode],
2280        root_node_id: WidgetId,
2281        viewport_size: LayoutSize,
2282        scroll_source: &impl ScrollDataSource,
2283    ) -> Result<LayoutSnapshot> {
2284        self.ensure_graph_state(input_nodes);
2285        self.validate_graph_state(root_node_id)?;
2286        let snapshot = self.compute_layout_constraints(
2287            input_nodes,
2288            root_node_id,
2289            viewport_size,
2290            scroll_source,
2291        )?;
2292        self.emit_scroll_diagnostics(&snapshot);
2293        self.emit_overflow_diagnostics(&snapshot);
2294        Ok(snapshot)
2295    }
2296
2297    /// InternalLower-level layout that skips scroll diagnostics.
2298    ///
2299    /// Same as [`compute_layout`](LayoutEngine::compute_layout) but does not emit
2300    /// diagnostic events. Useful when you need the snapshot but not the debug output.
2301    pub fn compute_layout_constraints(
2302        &mut self,
2303        input_nodes: &[LayoutInputNode],
2304        root_node_id: WidgetId,
2305        viewport_size: LayoutSize,
2306        scroll_source: &impl ScrollDataSource,
2307    ) -> Result<LayoutSnapshot> {
2308        self.active_viewport = viewport_size;
2309        self.ensure_graph_state(input_nodes);
2310        self.validate_graph_state(root_node_id)?;
2311
2312        // Root constraints should be tight to the viewport size if no explicit size is given
2313        let mut constraints = BoxConstraints::tight(viewport_size);
2314        if let Some(root) = self.graph_state.node(root_node_id) {
2315            // Only loosen if explicit dimensions are provided for the root node
2316            let styled_dimension = matches!(
2317                &root.op,
2318                LayoutOp::StyledBox { style, .. }
2319                    if style.width.is_some() || style.height.is_some()
2320            );
2321            if root.width.is_some() || root.height.is_some() || styled_dimension {
2322                constraints = BoxConstraints::loose(viewport_size.width, viewport_size.height)
2323                    .tighten(root.width, root.height);
2324            }
2325        }
2326
2327        let mut snapshot = LayoutSnapshot::new(viewport_size);
2328        let mut measure_cache = HashMap::new();
2329        self.layout_node_constraints(
2330            root_node_id,
2331            constraints,
2332            LayoutPoint::ZERO,
2333            &mut snapshot.nodes,
2334            &mut snapshot.constraints,
2335            &mut measure_cache,
2336            scroll_source,
2337            true,
2338            0,
2339        )?;
2340
2341        let visual_location = |node_id: WidgetId| -> Option<LayoutPoint> {
2342            let mut pos = snapshot.nodes.get(&node_id)?.rect.origin;
2343            let mut current = self.graph_state.parent_of(node_id);
2344            while let Some(parent_id) = current {
2345                if let Some(parent) = self.graph_state.node(parent_id) {
2346                    if let LayoutOp::Scroll { direction, .. } = &parent.op {
2347                        let offset = scroll_source.get_offset(parent_id);
2348                        match direction {
2349                            FlexDirection::Row => pos.x -= offset,
2350                            FlexDirection::Column => pos.y -= offset,
2351                        }
2352                    }
2353                    current = self.graph_state.parent_of(parent_id);
2354                } else {
2355                    break;
2356                }
2357            }
2358            Some(pos)
2359        };
2360
2361        let mut spotlight_overrides = Vec::new();
2362        for node in self.graph_state.ordered_nodes() {
2363            let LayoutOp::Spotlight { anchor, padding } = node.op else {
2364                continue;
2365            };
2366            if node.children_ids.len() != 5 {
2367                continue;
2368            }
2369
2370            let Some(bounds) = snapshot.nodes.get(&node.id).map(|geometry| geometry.rect) else {
2371                continue;
2372            };
2373            let target = snapshot.nodes.get(&anchor).and_then(|geometry| {
2374                let origin = visual_location(anchor)?;
2375                Some(LayoutRect::new(
2376                    origin.x,
2377                    origin.y,
2378                    geometry.rect.width(),
2379                    geometry.rect.height(),
2380                ))
2381            });
2382            let regions = spotlight_regions(bounds, target, padding);
2383            spotlight_overrides.push((node.children_ids.clone(), regions));
2384        }
2385
2386        let mut flyout_abs_overrides: HashMap<WidgetId, (f32, f32)> = HashMap::new();
2387        for node in self.graph_state.ordered_nodes() {
2388            if let LayoutOp::Flyout { anchor, content } = node.op {
2389                if let (Some(anchor_geom), Some(content_geom)) =
2390                    (snapshot.nodes.get(&anchor), snapshot.nodes.get(&content))
2391                {
2392                    if let (Some(anchor_abs), Some(content_abs)) =
2393                        (visual_location(anchor), visual_location(content))
2394                    {
2395                        let mut min_x: f32 = 0.0;
2396                        let mut min_y: f32 = 0.0;
2397                        let mut max_x = content_geom.rect.width();
2398                        let mut max_y = content_geom.rect.height();
2399                        let mut stack = vec![content];
2400                        while let Some(current) = stack.pop() {
2401                            if let (Some(geometry), Some(origin)) =
2402                                (snapshot.nodes.get(&current), visual_location(current))
2403                            {
2404                                let relative_x = origin.x - content_abs.x;
2405                                let relative_y = origin.y - content_abs.y;
2406                                min_x = min_x.min(relative_x);
2407                                min_y = min_y.min(relative_y);
2408                                max_x = max_x.max(relative_x + geometry.rect.width());
2409                                max_y = max_y.max(relative_y + geometry.rect.height());
2410                            }
2411                            stack.extend(self.graph_state.children_of(current).iter().copied());
2412                        }
2413                        let anchor_rect = LayoutRect::new(
2414                            anchor_abs.x,
2415                            anchor_abs.y,
2416                            anchor_geom.rect.width(),
2417                            anchor_geom.rect.height(),
2418                        );
2419                        let content_extents =
2420                            LayoutRect::new(min_x, min_y, max_x - min_x, max_y - min_y);
2421                        let position = flyout_root_position(
2422                            snapshot.viewport_size,
2423                            anchor_rect,
2424                            content_extents,
2425                        );
2426                        flyout_abs_overrides.insert(content, (position.x, position.y));
2427                    }
2428                }
2429            }
2430        }
2431
2432        for (children, regions) in spotlight_overrides {
2433            for (child_id, region) in children.into_iter().zip(regions) {
2434                self.layout_node_constraints(
2435                    child_id,
2436                    BoxConstraints::tight(region.size),
2437                    region.origin,
2438                    &mut snapshot.nodes,
2439                    &mut snapshot.constraints,
2440                    &mut measure_cache,
2441                    scroll_source,
2442                    true,
2443                    0,
2444                )?;
2445            }
2446        }
2447
2448        if !flyout_abs_overrides.is_empty() {
2449            for (nid, (abs_x, abs_y)) in flyout_abs_overrides {
2450                if let Some(current) = snapshot.nodes.get(&nid) {
2451                    let dx = abs_x - current.rect.origin.x;
2452                    let dy = abs_y - current.rect.origin.y;
2453                    let mut stack = vec![(nid, 0usize)];
2454                    while let Some((current_id, depth)) = stack.pop() {
2455                        if depth > Self::MAX_LAYOUT_RECURSION_DEPTH {
2456                            return Err(self.layout_depth_overflow(current_id, depth));
2457                        }
2458                        if let Some(geometry) = snapshot.nodes.get_mut(&current_id) {
2459                            geometry.rect.origin.x += dx;
2460                            geometry.rect.origin.y += dy;
2461                        }
2462                        for child_id in self.graph_state.children_of(current_id).iter().rev() {
2463                            stack.push((*child_id, depth + 1));
2464                        }
2465                    }
2466                }
2467            }
2468        }
2469
2470        self.graph_state.mark_layout_complete();
2471        self.incremental_reuse = None;
2472
2473        Ok(snapshot)
2474    }
2475
2476    pub fn compute_layout_incremental(
2477        &mut self,
2478        input_nodes: &[LayoutInputNode],
2479        root_node_id: WidgetId,
2480        viewport_size: LayoutSize,
2481        scroll_source: &impl ScrollDataSource,
2482        previous_snapshot: &LayoutSnapshot,
2483        dirty_nodes: &HashSet<WidgetId>,
2484    ) -> Result<LayoutSnapshot> {
2485        self.ensure_graph_state(input_nodes);
2486        self.validate_graph_state(root_node_id)?;
2487
2488        let mut dirty_ancestors = HashSet::new();
2489        for node_id in dirty_nodes {
2490            let mut current = Some(*node_id);
2491            while let Some(id) = current {
2492                if !dirty_ancestors.insert(id) {
2493                    break;
2494                }
2495                current = self.graph_state.parent_of(id);
2496            }
2497        }
2498        dirty_ancestors.insert(root_node_id);
2499
2500        self.incremental_reuse = Some(IncrementalLayoutReuseState {
2501            previous_snapshot: previous_snapshot.clone(),
2502            dirty_ancestors,
2503        });
2504        let result = self.compute_layout_constraints(
2505            input_nodes,
2506            root_node_id,
2507            viewport_size,
2508            scroll_source,
2509        );
2510        self.incremental_reuse = None;
2511        result
2512    }
2513
2514    fn emit_scroll_diagnostics(&self, snapshot: &LayoutSnapshot) {
2515        use fission_diagnostics::prelude as diag;
2516        let trace_scroll = std::env::var("FISSION_SCROLL_TRACE").ok().as_deref() == Some("1");
2517        for n in self.graph_state.ordered_nodes() {
2518            if let LayoutOp::Scroll { .. } = n.op {
2519                if let Some(g) = snapshot.nodes.get(&n.id) {
2520                    let note = if g.rect.height() <= 0.0 {
2521                        let parent_op = n
2522                            .parent_id
2523                            .and_then(|pid| self.graph_state.node(pid))
2524                            .map(|p| format!("{:?}", p.op));
2525                        let parent_constraints = n
2526                            .parent_id
2527                            .and_then(|pid| snapshot.constraints.get(&pid))
2528                            .copied();
2529                        snapshot
2530                            .constraints
2531                            .get(&n.id)
2532                            .map(|c| {
2533                                format!(
2534                                    "op={:?} parent={:?} parent_op={:?} parent_constraints={:?} constraints={:?}",
2535                                    n.op,
2536                                    n.parent_id,
2537                                    parent_op,
2538                                    parent_constraints,
2539                                    c
2540                                )
2541                            })
2542                    } else {
2543                        None
2544                    };
2545                    diag::emit(
2546                        diag::DiagCategory::Layout,
2547                        diag::DiagLevel::Debug,
2548                        diag::DiagEventKind::ScrollExtent {
2549                            node: n.id.as_u128(),
2550                            viewport_w: g.rect.width(),
2551                            viewport_h: g.rect.height(),
2552                            content_w: g.content_size.width,
2553                            content_h: g.content_size.height,
2554                            note,
2555                        },
2556                    );
2557                    if trace_scroll {
2558                        eprintln!(
2559                            "[scroll-trace] node={} viewport=({:.1},{:.1}) content=({:.1},{:.1})",
2560                            n.id.as_u128(),
2561                            g.rect.width(),
2562                            g.rect.height(),
2563                            g.content_size.width,
2564                            g.content_size.height
2565                        );
2566                    }
2567                }
2568            }
2569        }
2570    }
2571
2572    fn emit_overflow_diagnostics(&self, snapshot: &LayoutSnapshot) {
2573        for node in self.graph_state.ordered_nodes() {
2574            let Some(geometry) = snapshot.nodes.get(&node.id) else {
2575                continue;
2576            };
2577            let overflow_x = geometry.content_size.width > geometry.rect.width() + 0.5;
2578            let overflow_y = geometry.content_size.height > geometry.rect.height() + 0.5;
2579            if !overflow_x && !overflow_y {
2580                continue;
2581            }
2582            let text = node.rich_text.is_some();
2583            diag::emit(
2584                diag::DiagCategory::Layout,
2585                if text {
2586                    diag::DiagLevel::Warn
2587                } else {
2588                    diag::DiagLevel::Debug
2589                },
2590                diag::DiagEventKind::LayoutOverflow {
2591                    node: node.id.as_u128(),
2592                    debug_name: node.debug_name.clone(),
2593                    parent: node.parent_id.map(|parent| parent.as_u128()),
2594                    parent_debug_name: node
2595                        .parent_id
2596                        .and_then(|parent| self.graph_state.node(parent))
2597                        .map(|parent| parent.debug_name.clone()),
2598                    parent_layout: node
2599                        .parent_id
2600                        .and_then(|parent| self.graph_state.node(parent))
2601                        .map(|parent| format!("{:?}", parent.op)),
2602                    text,
2603                    min_w: snapshot
2604                        .constraints
2605                        .get(&node.id)
2606                        .map_or(0.0, |constraints| constraints.min_w),
2607                    max_w: snapshot
2608                        .constraints
2609                        .get(&node.id)
2610                        .map(|constraints| constraints.max_w)
2611                        .filter(|value| value.is_finite()),
2612                    min_h: snapshot
2613                        .constraints
2614                        .get(&node.id)
2615                        .map_or(0.0, |constraints| constraints.min_h),
2616                    max_h: snapshot
2617                        .constraints
2618                        .get(&node.id)
2619                        .map(|constraints| constraints.max_h)
2620                        .filter(|value| value.is_finite()),
2621                    laid_out_w: geometry.rect.width(),
2622                    laid_out_h: geometry.rect.height(),
2623                    content_w: geometry.content_size.width,
2624                    content_h: geometry.content_size.height,
2625                },
2626            );
2627        }
2628    }
2629
2630    /// Returns measured, constrained, laid-out, clipped, and estimated paint bounds.
2631    pub fn inspect_node(
2632        &self,
2633        snapshot: &LayoutSnapshot,
2634        node_id: WidgetId,
2635    ) -> Option<LayoutInspection> {
2636        let geometry = snapshot.nodes.get(&node_id)?;
2637        let constraints = snapshot.constraints.get(&node_id).copied()?;
2638        let measured = LayoutRect::new(
2639            geometry.rect.x(),
2640            geometry.rect.y(),
2641            geometry.content_size.width,
2642            geometry.content_size.height,
2643        );
2644        let effective_constraints = self
2645            .graph_state
2646            .node(node_id)
2647            .map(|node| {
2648                let resolved_style;
2649                let op = match &node.op {
2650                    LayoutOp::StyledBox { style, .. } => {
2651                        resolved_style =
2652                            resolve_box_style(style, constraints, snapshot.viewport_size);
2653                        &resolved_style
2654                    }
2655                    op => op,
2656                };
2657                match op {
2658                    LayoutOp::Box {
2659                        width,
2660                        height,
2661                        min_width,
2662                        max_width,
2663                        min_height,
2664                        max_height,
2665                        ..
2666                    } => constraints
2667                        .apply_min_max(*min_width, *max_width, *min_height, *max_height)
2668                        .tighten(*width, *height),
2669                    _ => constraints,
2670                }
2671            })
2672            .unwrap_or(constraints);
2673        let constrained_size = effective_constraints.constrain(geometry.content_size);
2674        let constrained = LayoutRect::new(
2675            geometry.rect.x(),
2676            geometry.rect.y(),
2677            constrained_size.width,
2678            constrained_size.height,
2679        );
2680        let mut clipped = geometry.rect;
2681        let mut ancestor = self.graph_state.parent_of(node_id);
2682        while let Some(ancestor_id) = ancestor {
2683            let clips = self
2684                .graph_state
2685                .node(ancestor_id)
2686                .is_some_and(|node| match &node.op {
2687                    LayoutOp::Scroll { .. } | LayoutOp::Clip { .. } => true,
2688                    LayoutOp::StyledBox { style, .. } => {
2689                        style.overflow == fission_ir::op::Overflow::Clip
2690                    }
2691                    _ => false,
2692                });
2693            if clips {
2694                if let Some(ancestor_geometry) = snapshot.nodes.get(&ancestor_id) {
2695                    clipped = intersect_rect(clipped, ancestor_geometry.rect);
2696                }
2697            }
2698            ancestor = self.graph_state.parent_of(ancestor_id);
2699        }
2700
2701        let mut painted = geometry.rect;
2702        let mut descendants = self.graph_state.children_of(node_id).to_vec();
2703        while let Some(descendant) = descendants.pop() {
2704            if let Some(descendant_geometry) = snapshot.nodes.get(&descendant) {
2705                painted = union_rect(painted, descendant_geometry.rect);
2706            }
2707            descendants.extend_from_slice(self.graph_state.children_of(descendant));
2708        }
2709        Some(LayoutInspection {
2710            node: node_id,
2711            measured,
2712            constraints,
2713            constrained,
2714            laid_out: geometry.rect,
2715            clipped,
2716            painted,
2717            overflow_x: geometry.content_size.width > geometry.rect.width() + 0.5,
2718            overflow_y: geometry.content_size.height > geometry.rect.height() + 0.5,
2719        })
2720    }
2721
2722    fn layout_depth_overflow(&self, node_id: WidgetId, depth: usize) -> anyhow::Error {
2723        let details = format!(
2724            "layout recursion depth {} exceeded max {} at node {}",
2725            depth,
2726            Self::MAX_LAYOUT_RECURSION_DEPTH,
2727            node_id.as_u128()
2728        );
2729        diag::emit(
2730            diag::DiagCategory::Invariants,
2731            diag::DiagLevel::Error,
2732            diag::DiagEventKind::InvariantViolation {
2733                kind: "layout_recursion_depth".into(),
2734                node: Some(node_id.as_u128()),
2735                details: details.clone(),
2736                dump_ref: None,
2737            },
2738        );
2739        anyhow::anyhow!(details)
2740    }
2741
2742    fn copy_cached_subtree(
2743        &self,
2744        node_id: WidgetId,
2745        origin: LayoutPoint,
2746        current_constraints: BoxConstraints,
2747        out: &mut HashMap<WidgetId, LayoutNodeGeometry>,
2748        constraints_out: &mut HashMap<WidgetId, BoxConstraints>,
2749    ) -> Result<Option<LayoutSize>> {
2750        let Some(reuse) = self.incremental_reuse.as_ref() else {
2751            return Ok(None);
2752        };
2753        if reuse.dirty_ancestors.contains(&node_id) {
2754            return Ok(None);
2755        }
2756
2757        let Some(previous_geometry) = reuse.previous_snapshot.nodes.get(&node_id) else {
2758            return Ok(None);
2759        };
2760        let Some(previous_constraints) = reuse.previous_snapshot.constraints.get(&node_id).copied()
2761        else {
2762            return Ok(None);
2763        };
2764        if previous_constraints != current_constraints {
2765            return Ok(None);
2766        }
2767
2768        let dx = origin.x - previous_geometry.rect.origin.x;
2769        let dy = origin.y - previous_geometry.rect.origin.y;
2770        let mut stack = vec![(node_id, 0usize)];
2771        while let Some((current_id, depth)) = stack.pop() {
2772            if depth > Self::MAX_LAYOUT_RECURSION_DEPTH {
2773                return Err(self.layout_depth_overflow(current_id, depth));
2774            }
2775            let Some(previous_geometry) = reuse.previous_snapshot.nodes.get(&current_id) else {
2776                return Ok(None);
2777            };
2778            let Some(previous_constraints) = reuse
2779                .previous_snapshot
2780                .constraints
2781                .get(&current_id)
2782                .copied()
2783            else {
2784                return Ok(None);
2785            };
2786
2787            let mut geometry = previous_geometry.clone();
2788            geometry.rect.origin.x += dx;
2789            geometry.rect.origin.y += dy;
2790            out.insert(current_id, geometry);
2791            constraints_out.insert(current_id, previous_constraints);
2792
2793            let children = self.graph_state.children_of(current_id);
2794            for child_id in children.iter().rev() {
2795                stack.push((*child_id, depth + 1));
2796            }
2797        }
2798
2799        Ok(Some(previous_geometry.content_size))
2800    }
2801
2802    #[allow(clippy::too_many_arguments)]
2803    fn measure_grid_intrinsic_width(
2804        &self,
2805        node_id: WidgetId,
2806        intrinsic: IntrinsicAxis,
2807        max_height: f32,
2808        out: &mut HashMap<WidgetId, LayoutNodeGeometry>,
2809        constraints_out: &mut HashMap<WidgetId, BoxConstraints>,
2810        measure_cache: &mut HashMap<MeasureCacheKey, LayoutSize>,
2811        scroll_source: &impl ScrollDataSource,
2812        depth: usize,
2813    ) -> Result<f32> {
2814        let Some(node) = self.graph_state.node(node_id) else {
2815            return Ok(0.0);
2816        };
2817        if let (Some(runs), Some(measurer)) = (&node.rich_text, &self.measurer) {
2818            return Ok(match intrinsic {
2819                IntrinsicAxis::Max => measurer.layout_rich_text(runs, None).width,
2820                IntrinsicAxis::Min => runs
2821                    .iter()
2822                    .flat_map(|run| {
2823                        run.text.split_whitespace().map(move |word| {
2824                            measurer.measure(word, run.style.font_size, None).0
2825                                + run.style.letter_spacing
2826                                    * word.chars().count().saturating_sub(1) as f32
2827                        })
2828                    })
2829                    .fold(0.0, f32::max),
2830            });
2831        }
2832
2833        if matches!(node.op, LayoutOp::GridItem { .. } | LayoutOp::Align)
2834            && node.children_ids.len() == 1
2835        {
2836            return self.measure_grid_intrinsic_width(
2837                node.children_ids[0],
2838                intrinsic,
2839                max_height,
2840                out,
2841                constraints_out,
2842                measure_cache,
2843                scroll_source,
2844                depth + 1,
2845            );
2846        }
2847
2848        let constraints = BoxConstraints {
2849            min_w: 0.0,
2850            max_w: f32::INFINITY,
2851            min_h: 0.0,
2852            max_h: if max_height.is_finite() {
2853                max_height
2854            } else {
2855                f32::INFINITY
2856            },
2857        };
2858        Ok(self
2859            .layout_node_constraints(
2860                node_id,
2861                constraints,
2862                LayoutPoint::ZERO,
2863                out,
2864                constraints_out,
2865                measure_cache,
2866                scroll_source,
2867                false,
2868                depth + 1,
2869            )?
2870            .width)
2871    }
2872
2873    fn layout_node_constraints(
2874        &self,
2875        node_id: WidgetId,
2876        constraints: BoxConstraints,
2877        origin: LayoutPoint,
2878        out: &mut HashMap<WidgetId, LayoutNodeGeometry>,
2879        constraints_out: &mut HashMap<WidgetId, BoxConstraints>,
2880        measure_cache: &mut HashMap<MeasureCacheKey, LayoutSize>,
2881        scroll_source: &impl ScrollDataSource,
2882        record: bool,
2883        depth: usize,
2884    ) -> Result<LayoutSize> {
2885        if depth > Self::MAX_LAYOUT_RECURSION_DEPTH {
2886            return Err(self.layout_depth_overflow(node_id, depth));
2887        }
2888        if !record {
2889            let cache_key = MeasureCacheKey::new(node_id, constraints);
2890            if let Some(cached) = measure_cache.get(&cache_key).copied() {
2891                return Ok(cached);
2892            }
2893        }
2894        let node = match self.graph_state.node(node_id) {
2895            Some(node) => node,
2896            None => return Ok(LayoutSize::ZERO),
2897        };
2898
2899        if record {
2900            constraints_out.insert(node_id, constraints);
2901        }
2902
2903        if record {
2904            if let Some(reused) =
2905                self.copy_cached_subtree(node_id, origin, constraints, out, constraints_out)?
2906            {
2907                return Ok(reused);
2908            }
2909        }
2910
2911        let mut flow_children: Vec<WidgetId> = Vec::new();
2912        let mut abs_children: Vec<WidgetId> = Vec::new();
2913        for child_id in self.graph_state.children_of(node_id) {
2914            let is_absolute = matches!(
2915                self.graph_state.node(*child_id).map(|n| &n.op),
2916                Some(LayoutOp::AbsoluteFill)
2917                    | Some(LayoutOp::Positioned { .. })
2918                    | Some(LayoutOp::PositionedLengths { .. })
2919            );
2920            if is_absolute {
2921                abs_children.push(*child_id);
2922            } else {
2923                flow_children.push(*child_id);
2924            }
2925        }
2926        let rich_text_inline_children = node.rich_text.is_some() && !flow_children.is_empty();
2927
2928        let mut resolved_style_op = match &node.op {
2929            LayoutOp::StyledBox {
2930                style,
2931                flex_grow,
2932                flex_shrink,
2933            } => {
2934                let mut op = resolve_box_style(style, constraints, self.active_viewport);
2935                if let LayoutOp::Box {
2936                    flex_grow: resolved_grow,
2937                    flex_shrink: resolved_shrink,
2938                    ..
2939                } = &mut op
2940                {
2941                    *resolved_grow = *flex_grow;
2942                    *resolved_shrink = *flex_shrink;
2943                }
2944                Some(op)
2945            }
2946            _ => None,
2947        };
2948        if let (
2949            LayoutOp::StyledBox { style, .. },
2950            Some(LayoutOp::Box {
2951                width,
2952                min_width,
2953                max_width,
2954                padding,
2955                ..
2956            }),
2957        ) = (&node.op, &mut resolved_style_op)
2958        {
2959            let needs_intrinsic_width = [
2960                style.width.as_ref(),
2961                style.min_width.as_ref(),
2962                style.max_width.as_ref(),
2963            ]
2964            .into_iter()
2965            .flatten()
2966            .any(length_requires_measurement);
2967            if needs_intrinsic_width {
2968                let mut min_content = 0.0f32;
2969                let mut max_content = 0.0f32;
2970                if let (Some(runs), Some(measurer)) = (&node.rich_text, &self.measurer) {
2971                    min_content = runs
2972                        .iter()
2973                        .flat_map(|run| {
2974                            run.text.split_whitespace().map(move |word| {
2975                                measurer.measure(word, run.style.font_size, None).0
2976                                    + run.style.letter_spacing
2977                                        * word.chars().count().saturating_sub(1) as f32
2978                            })
2979                        })
2980                        .fold(0.0, f32::max);
2981                    max_content = measurer.layout_rich_text(runs, None).width;
2982                }
2983                for child_id in &flow_children {
2984                    min_content = min_content.max(self.measure_grid_intrinsic_width(
2985                        *child_id,
2986                        IntrinsicAxis::Min,
2987                        constraints.max_h,
2988                        out,
2989                        constraints_out,
2990                        measure_cache,
2991                        scroll_source,
2992                        depth + 1,
2993                    )?);
2994                    max_content = max_content.max(self.measure_grid_intrinsic_width(
2995                        *child_id,
2996                        IntrinsicAxis::Max,
2997                        constraints.max_h,
2998                        out,
2999                        constraints_out,
3000                        measure_cache,
3001                        scroll_source,
3002                        depth + 1,
3003                    )?);
3004                }
3005                let horizontal_padding = padding[0] + padding[1];
3006                min_content += horizontal_padding;
3007                max_content =
3008                    max_content.max(min_content - horizontal_padding) + horizontal_padding;
3009                let available = if constraints.max_w.is_finite() {
3010                    constraints.max_w
3011                } else {
3012                    max_content
3013                };
3014                let resolve = |length: &Option<Length>| {
3015                    length.as_ref().and_then(|length| {
3016                        resolve_measured_length(
3017                            length,
3018                            available,
3019                            self.active_viewport,
3020                            min_content,
3021                            max_content,
3022                        )
3023                    })
3024                };
3025                *width = resolve(&style.width);
3026                *min_width = resolve(&style.min_width);
3027                *max_width = resolve(&style.max_width);
3028            }
3029        }
3030        let layout_op = resolved_style_op.as_ref().unwrap_or(&node.op);
3031        let box_alignment = match &node.op {
3032            LayoutOp::StyledBox { style, .. } => style.alignment,
3033            // Legacy low-level Box nodes have always stretched an auto-sized
3034            // child across the parent's cross axis. StyledBox carries an
3035            // explicit alignment and may opt into start/center/end instead.
3036            LayoutOp::Box { .. }
3037                if node.rich_text.is_some()
3038                    || node.parent_id.is_some_and(|parent_id| {
3039                        matches!(
3040                            self.graph_state.node(parent_id).map(|parent| &parent.op),
3041                            Some(LayoutOp::Flex { .. })
3042                                | Some(LayoutOp::Align)
3043                                | Some(LayoutOp::StyledBox { flex_grow: 0.0, .. })
3044                        )
3045                    }) =>
3046            {
3047                fission_ir::op::BoxAlignment::Start
3048            }
3049            LayoutOp::Box { .. } => fission_ir::op::BoxAlignment::Stretch,
3050            _ => fission_ir::op::BoxAlignment::Start,
3051        };
3052        let intrinsic_box_width = match &node.op {
3053            LayoutOp::StyledBox { style, .. } => style.width.as_ref(),
3054            _ => None,
3055        };
3056        let intrinsic_box_height = match &node.op {
3057            LayoutOp::StyledBox { style, .. } => style.height.as_ref(),
3058            _ => None,
3059        };
3060
3061        let mut content_size;
3062        let size = match layout_op {
3063            LayoutOp::Box {
3064                width,
3065                height,
3066                min_width,
3067                max_width,
3068                min_height,
3069                max_height,
3070                padding,
3071                aspect_ratio,
3072                ..
3073            } => {
3074                let mut local =
3075                    constraints.apply_min_max(*min_width, *max_width, *min_height, *max_height);
3076                local = local.tighten(*width, *height);
3077                // A measured text node must retain its intrinsic height when
3078                // its parent supplies a loose cross-axis constraint. Applying
3079                // that constraint as a tight height makes tooltips and row
3080                // labels fill the viewport instead of sizing to their lines.
3081                if node.rich_text.is_some() && height.is_none() {
3082                    local.min_h = 0.0;
3083                    local.max_h = f32::INFINITY;
3084                }
3085                if let Some(ratio) = aspect_ratio.filter(|r| *r > 0.0) {
3086                    let mut target_w = *width;
3087                    let mut target_h = *height;
3088
3089                    if target_w.is_some() && target_h.is_none() {
3090                        target_h = target_w.map(|w| w / ratio);
3091                    } else if target_h.is_some() && target_w.is_none() {
3092                        target_w = target_h.map(|h| h * ratio);
3093                    } else if target_w.is_none() && target_h.is_none() {
3094                        if local.is_width_bounded() || local.is_height_bounded() {
3095                            let (mut w, mut h) = if local.is_width_bounded() {
3096                                let w = local.max_w;
3097                                let h = w / ratio;
3098                                (w, h)
3099                            } else {
3100                                let h = local.max_h;
3101                                let w = h * ratio;
3102                                (w, h)
3103                            };
3104                            if local.is_width_bounded()
3105                                && local.is_height_bounded()
3106                                && h > local.max_h
3107                            {
3108                                h = local.max_h;
3109                                w = h * ratio;
3110                            }
3111                            target_w = Some(w);
3112                            target_h = Some(h);
3113                        }
3114                    }
3115
3116                    if target_w.is_some() || target_h.is_some() {
3117                        local = local.tighten(target_w, target_h);
3118                    }
3119                }
3120                let mut base_child_constraints = local.deflate(*padding);
3121                if matches!(intrinsic_box_width, Some(Length::MaxContent)) {
3122                    base_child_constraints.min_w = 0.0;
3123                    base_child_constraints.max_w = f32::INFINITY;
3124                }
3125                // `fit-content` must measure the child's natural block-axis
3126                // extent before the result is clamped to the available box.
3127                // Passing the finite viewport maximum into a column allows
3128                // stretch-aware descendants to report the whole viewport,
3129                // making short dialogs and popovers viewport-height.
3130                if matches!(intrinsic_box_height, Some(Length::FitContent(_))) {
3131                    base_child_constraints.min_h = 0.0;
3132                    base_child_constraints.max_h = f32::INFINITY;
3133                }
3134                if box_alignment != fission_ir::op::BoxAlignment::Stretch {
3135                    base_child_constraints.min_w = 0.0;
3136                    base_child_constraints.min_h = 0.0;
3137                }
3138                let mut max_child = LayoutSize::ZERO;
3139                let mut measured_children: Vec<(WidgetId, BoxConstraints, LayoutSize)> = Vec::new();
3140                if !rich_text_inline_children {
3141                    for child_id in &flow_children {
3142                        let (child_width, child_height, child_max_width, child_max_height) = self
3143                            .graph_state
3144                            .node(*child_id)
3145                            .map(|child| match &child.op {
3146                                LayoutOp::Box {
3147                                    width,
3148                                    height,
3149                                    max_width,
3150                                    max_height,
3151                                    ..
3152                                } => (*width, *height, *max_width, *max_height),
3153                                LayoutOp::Scroll {
3154                                    width,
3155                                    height,
3156                                    max_width,
3157                                    max_height,
3158                                    ..
3159                                } => (*width, *height, *max_width, *max_height),
3160                                LayoutOp::Embed { width, height, .. } => {
3161                                    (*width, *height, None, None)
3162                                }
3163                                LayoutOp::StyledBox { style, .. } => {
3164                                    let resolved = resolve_box_style(
3165                                        style,
3166                                        base_child_constraints,
3167                                        self.active_viewport,
3168                                    );
3169                                    match resolved {
3170                                        LayoutOp::Box {
3171                                            width,
3172                                            height,
3173                                            max_width,
3174                                            max_height,
3175                                            ..
3176                                        } => (width, height, max_width, max_height),
3177                                        _ => unreachable!(),
3178                                    }
3179                                }
3180                                _ => (None, None, None, None),
3181                            })
3182                            .unwrap_or((None, None, None, None));
3183                        let mut child_constraints = base_child_constraints;
3184                        let child_is_align = self
3185                            .graph_state
3186                            .node(*child_id)
3187                            .is_some_and(|child| matches!(&child.op, LayoutOp::Align));
3188                        // Align intentionally fills a bounded constraint. When it
3189                        // is the direct child of an auto-sized, non-stretch box,
3190                        // measure it intrinsically so controls such as Button do
3191                        // not grow to the full loose width or height supplied by
3192                        // a flex line. Other children retain the finite maximum
3193                        // so text wrapping and bounded layout remain intact.
3194                        if box_alignment != fission_ir::op::BoxAlignment::Stretch && child_is_align
3195                        {
3196                            if width.is_none() && local.min_w < local.max_w {
3197                                child_constraints.max_w = f32::INFINITY;
3198                            }
3199                            if height.is_none() && local.min_h < local.max_h {
3200                                child_constraints.max_h = f32::INFINITY;
3201                            }
3202                        }
3203                        if matches!(intrinsic_box_width, Some(Length::MinContent)) {
3204                            let intrinsic_width = self.measure_grid_intrinsic_width(
3205                                *child_id,
3206                                IntrinsicAxis::Min,
3207                                base_child_constraints.max_h,
3208                                out,
3209                                constraints_out,
3210                                measure_cache,
3211                                scroll_source,
3212                                depth + 1,
3213                            )?;
3214                            child_constraints.min_w = intrinsic_width;
3215                            child_constraints.max_w = intrinsic_width;
3216                        }
3217                        let tight_width = child_constraints.min_w == child_constraints.max_w;
3218                        let stretch_width =
3219                            tight_width && child_width.is_none() && child_max_width.is_none();
3220                        if matches!(box_alignment, fission_ir::op::BoxAlignment::Stretch)
3221                            && child_width.is_none()
3222                            && child_max_width.is_none()
3223                            && child_constraints.max_w.is_finite()
3224                        {
3225                            child_constraints.min_w = child_constraints.max_w;
3226                        } else if stretch_width {
3227                            child_constraints.min_w = child_constraints.max_w;
3228                        } else if tight_width
3229                            && (child_width.is_some() || child_max_width.is_some())
3230                        {
3231                            child_constraints.min_w = 0.0;
3232                        }
3233                        let tight_height = child_constraints.min_h == child_constraints.max_h;
3234                        let stretch_height =
3235                            tight_height && child_height.is_none() && child_max_height.is_none();
3236                        if matches!(box_alignment, fission_ir::op::BoxAlignment::Stretch)
3237                            && child_height.is_none()
3238                            && child_max_height.is_none()
3239                            && child_constraints.max_h.is_finite()
3240                        {
3241                            child_constraints.min_h = child_constraints.max_h;
3242                        } else if stretch_height {
3243                            child_constraints.min_h = child_constraints.max_h;
3244                        } else if tight_height
3245                            && (child_height.is_some() || child_max_height.is_some())
3246                        {
3247                            child_constraints.min_h = 0.0;
3248                        }
3249                        let child_size = self.layout_node_constraints(
3250                            *child_id,
3251                            child_constraints,
3252                            LayoutPoint::ZERO,
3253                            out,
3254                            constraints_out,
3255                            measure_cache,
3256                            scroll_source,
3257                            false,
3258                            depth + 1,
3259                        )?;
3260                        max_child.width = max_child.width.max(child_size.width);
3261                        max_child.height = max_child.height.max(child_size.height);
3262                        measured_children.push((*child_id, child_constraints, child_size));
3263                    }
3264                }
3265                let padded = LayoutSize::new(
3266                    max_child.width + padding[0] + padding[1],
3267                    max_child.height + padding[2] + padding[3],
3268                );
3269                if let LayoutOp::StyledBox { style, .. } = &node.op {
3270                    let available = if constraints.max_h.is_finite() {
3271                        constraints.max_h
3272                    } else {
3273                        padded.height
3274                    };
3275                    let resolve_intrinsic_height = |length: &Option<Length>| {
3276                        length
3277                            .as_ref()
3278                            .filter(|length| length_requires_measurement(length))
3279                            .and_then(|length| {
3280                                resolve_measured_length(
3281                                    length,
3282                                    available,
3283                                    self.active_viewport,
3284                                    padded.height,
3285                                    padded.height,
3286                                )
3287                            })
3288                    };
3289                    local = local.apply_min_max(
3290                        None,
3291                        None,
3292                        resolve_intrinsic_height(&style.min_height),
3293                        resolve_intrinsic_height(&style.max_height),
3294                    );
3295                    local = local.tighten(None, resolve_intrinsic_height(&style.height));
3296                }
3297                let size = local.constrain(padded);
3298                if record {
3299                    for (child_id, child_constraints, child_size) in measured_children {
3300                        let inner_width = (size.width - padding[0] - padding[1]).max(0.0);
3301                        let inner_height = (size.height - padding[2] - padding[3]).max(0.0);
3302                        let offset = |available: f32, child: f32| match box_alignment {
3303                            fission_ir::op::BoxAlignment::Start
3304                            | fission_ir::op::BoxAlignment::Stretch => 0.0,
3305                            fission_ir::op::BoxAlignment::Center => {
3306                                ((available - child) / 2.0).max(0.0)
3307                            }
3308                            fission_ir::op::BoxAlignment::End => (available - child).max(0.0),
3309                        };
3310                        self.layout_node_constraints(
3311                            child_id,
3312                            child_constraints,
3313                            LayoutPoint::new(
3314                                origin.x + padding[0] + offset(inner_width, child_size.width),
3315                                origin.y + padding[2] + offset(inner_height, child_size.height),
3316                            ),
3317                            out,
3318                            constraints_out,
3319                            measure_cache,
3320                            scroll_source,
3321                            record,
3322                            depth + 1,
3323                        )?;
3324                    }
3325                    if !abs_children.is_empty() {
3326                        let abs_constraints = BoxConstraints::loose(size.width, size.height);
3327                        for child_id in abs_children {
3328                            self.layout_node_constraints(
3329                                child_id,
3330                                abs_constraints,
3331                                origin,
3332                                out,
3333                                constraints_out,
3334                                measure_cache,
3335                                scroll_source,
3336                                record,
3337                                depth + 1,
3338                            )?;
3339                        }
3340                    }
3341                }
3342                content_size = padded;
3343                size
3344            }
3345            LayoutOp::Flex {
3346                direction,
3347                wrap,
3348                padding,
3349                gap,
3350                align_items,
3351                justify_content,
3352                flex_grow,
3353                ..
3354            } => {
3355                let gap = gap.unwrap_or(0.0);
3356                let local = constraints.tighten(node.width, node.height);
3357                let inner = local.deflate(*padding);
3358                let is_row = matches!(direction, IrFlexDirection::Row);
3359
3360                let max_main = if is_row { inner.max_w } else { inner.max_h };
3361                let max_cross = if is_row { inner.max_h } else { inner.max_w };
3362                let min_main = if is_row { inner.min_w } else { inner.min_h };
3363                let min_cross = if is_row { inner.min_h } else { inner.min_w };
3364                let main_bounded = if is_row {
3365                    inner.is_width_bounded()
3366                } else {
3367                    inner.is_height_bounded()
3368                };
3369                let cross_bounded = if is_row {
3370                    inner.is_height_bounded()
3371                } else {
3372                    inner.is_width_bounded()
3373                };
3374
3375                if matches!(wrap, IrFlexWrap::Wrap | IrFlexWrap::WrapReverse) {
3376                    let mut lines: Vec<(Vec<(WidgetId, LayoutSize, BoxConstraints)>, f32, f32)> =
3377                        Vec::new();
3378                    let mut line_children: Vec<(WidgetId, LayoutSize, BoxConstraints)> = Vec::new();
3379                    let mut line_main = 0.0f32;
3380                    let mut line_cross = 0.0f32;
3381                    let mut max_line_main = 0.0f32;
3382
3383                    for child_id in &flow_children {
3384                        let has_explicit_main = self
3385                            .graph_state
3386                            .node(*child_id)
3387                            .is_some_and(|child| has_explicit_main_axis_size(child, is_row));
3388                        // Measure wrapped children at their intrinsic main-axis size.
3389                        // Giving every auto-sized child the full line width makes legacy
3390                        // Box-backed controls (buttons, switches, tags) expand to one
3391                        // item per line instead of wrapping like CSS flex items.
3392                        let mut child_constraints = if is_row {
3393                            BoxConstraints {
3394                                min_w: 0.0,
3395                                max_w: if main_bounded && has_explicit_main {
3396                                    max_main
3397                                } else {
3398                                    f32::INFINITY
3399                                },
3400                                min_h: 0.0,
3401                                max_h: max_cross,
3402                            }
3403                        } else {
3404                            BoxConstraints {
3405                                min_w: 0.0,
3406                                max_w: max_cross,
3407                                min_h: 0.0,
3408                                max_h: if main_bounded && has_explicit_main {
3409                                    max_main
3410                                } else {
3411                                    f32::INFINITY
3412                                },
3413                            }
3414                        };
3415                        let mut child_size = self.layout_node_constraints(
3416                            *child_id,
3417                            child_constraints,
3418                            LayoutPoint::ZERO,
3419                            out,
3420                            constraints_out,
3421                            measure_cache,
3422                            scroll_source,
3423                            false,
3424                            depth + 1,
3425                        )?;
3426                        let mut child_main = if is_row {
3427                            child_size.width
3428                        } else {
3429                            child_size.height
3430                        };
3431                        if main_bounded && child_main > max_main {
3432                            if is_row {
3433                                child_constraints.max_w = max_main;
3434                            } else {
3435                                child_constraints.max_h = max_main;
3436                            }
3437                            child_size = self.layout_node_constraints(
3438                                *child_id,
3439                                child_constraints,
3440                                LayoutPoint::ZERO,
3441                                out,
3442                                constraints_out,
3443                                measure_cache,
3444                                scroll_source,
3445                                false,
3446                                depth + 1,
3447                            )?;
3448                            child_main = if is_row {
3449                                child_size.width
3450                            } else {
3451                                child_size.height
3452                            };
3453                        }
3454                        let child_cross = if is_row {
3455                            child_size.height
3456                        } else {
3457                            child_size.width
3458                        };
3459                        let next_main = if line_children.is_empty() {
3460                            child_main
3461                        } else {
3462                            line_main + gap + child_main
3463                        };
3464
3465                        if main_bounded && !line_children.is_empty() && next_main > max_main {
3466                            max_line_main = max_line_main.max(line_main);
3467                            lines.push((line_children, line_main, line_cross));
3468                            line_children = Vec::new();
3469                            line_main = 0.0;
3470                            line_cross = 0.0;
3471                        }
3472
3473                        if !line_children.is_empty() {
3474                            line_main += gap;
3475                        }
3476                        line_main += child_main;
3477                        line_cross = line_cross.max(child_cross);
3478                        line_children.push((*child_id, child_size, child_constraints));
3479                    }
3480
3481                    if !line_children.is_empty() {
3482                        max_line_main = max_line_main.max(line_main);
3483                        lines.push((line_children, line_main, line_cross));
3484                    }
3485
3486                    let mut container_main = if main_bounded && *flex_grow > 0.0 {
3487                        max_main
3488                    } else {
3489                        max_line_main
3490                    };
3491                    container_main = container_main.max(min_main);
3492                    let total_lines_cross: f32 =
3493                        lines.iter().map(|(_, _, cross)| *cross).sum::<f32>()
3494                            + gap * lines.len().saturating_sub(1) as f32;
3495                    let container_cross = total_lines_cross.max(min_cross);
3496                    let size = if is_row {
3497                        local.constrain(LayoutSize::new(
3498                            container_main + padding[0] + padding[1],
3499                            container_cross + padding[2] + padding[3],
3500                        ))
3501                    } else {
3502                        local.constrain(LayoutSize::new(
3503                            container_cross + padding[0] + padding[1],
3504                            container_main + padding[2] + padding[3],
3505                        ))
3506                    };
3507
3508                    let inner_main = if is_row {
3509                        size.width - padding[0] - padding[1]
3510                    } else {
3511                        size.height - padding[2] - padding[3]
3512                    };
3513                    let inner_cross = if is_row {
3514                        size.height - padding[2] - padding[3]
3515                    } else {
3516                        size.width - padding[0] - padding[1]
3517                    };
3518
3519                    let mut ordered_lines = lines;
3520                    if matches!(wrap, IrFlexWrap::WrapReverse) {
3521                        ordered_lines.reverse();
3522                    }
3523
3524                    let mut line_cursor = if matches!(wrap, IrFlexWrap::WrapReverse) {
3525                        (inner_cross - total_lines_cross).max(0.0)
3526                    } else {
3527                        0.0
3528                    };
3529
3530                    for (line_children, line_main, line_cross) in ordered_lines {
3531                        let remaining_space = (inner_main - line_main).max(0.0);
3532                        let mut extra_gap = 0.0;
3533                        let mut offset_main = 0.0;
3534                        match justify_content {
3535                            fission_ir::op::JustifyContent::Start => {}
3536                            fission_ir::op::JustifyContent::End => offset_main = remaining_space,
3537                            fission_ir::op::JustifyContent::Center => {
3538                                offset_main = remaining_space / 2.0
3539                            }
3540                            fission_ir::op::JustifyContent::SpaceBetween => {
3541                                if line_children.len() > 1 {
3542                                    extra_gap =
3543                                        remaining_space / (line_children.len() as f32 - 1.0);
3544                                }
3545                            }
3546                            fission_ir::op::JustifyContent::SpaceAround => {
3547                                if !line_children.is_empty() {
3548                                    extra_gap = remaining_space / line_children.len() as f32;
3549                                    offset_main = extra_gap / 2.0;
3550                                }
3551                            }
3552                            fission_ir::op::JustifyContent::SpaceEvenly => {
3553                                if !line_children.is_empty() {
3554                                    extra_gap =
3555                                        remaining_space / (line_children.len() as f32 + 1.0);
3556                                    offset_main = extra_gap;
3557                                }
3558                            }
3559                        }
3560
3561                        let mut cursor = offset_main;
3562                        for (child_id, child_size, mut child_constraints) in line_children {
3563                            let child_main = if is_row {
3564                                child_size.width
3565                            } else {
3566                                child_size.height
3567                            };
3568                            let child_cross = if is_row {
3569                                child_size.height
3570                            } else {
3571                                child_size.width
3572                            };
3573                            let has_explicit_cross = self
3574                                .graph_state
3575                                .node(child_id)
3576                                .is_some_and(|child| has_explicit_cross_axis_size(child, is_row));
3577                            if matches!(align_items, fission_ir::op::AlignItems::Stretch)
3578                                && !has_explicit_cross
3579                            {
3580                                if is_row {
3581                                    child_constraints.min_h = line_cross;
3582                                    child_constraints.max_h = line_cross;
3583                                } else {
3584                                    child_constraints.min_w = line_cross;
3585                                    child_constraints.max_w = line_cross;
3586                                }
3587                            }
3588                            let cross_offset = match align_items {
3589                                fission_ir::op::AlignItems::Start
3590                                | fission_ir::op::AlignItems::Stretch => 0.0,
3591                                fission_ir::op::AlignItems::End => {
3592                                    (line_cross - child_cross).max(0.0)
3593                                }
3594                                fission_ir::op::AlignItems::Center => {
3595                                    ((line_cross - child_cross) / 2.0).max(0.0)
3596                                }
3597                                fission_ir::op::AlignItems::Baseline => 0.0,
3598                            };
3599                            let child_origin = if is_row {
3600                                LayoutPoint::new(
3601                                    origin.x + padding[0] + cursor,
3602                                    origin.y + padding[2] + line_cursor + cross_offset,
3603                                )
3604                            } else {
3605                                LayoutPoint::new(
3606                                    origin.x + padding[0] + line_cursor + cross_offset,
3607                                    origin.y + padding[2] + cursor,
3608                                )
3609                            };
3610                            self.layout_node_constraints(
3611                                child_id,
3612                                child_constraints,
3613                                child_origin,
3614                                out,
3615                                constraints_out,
3616                                measure_cache,
3617                                scroll_source,
3618                                record,
3619                                depth + 1,
3620                            )?;
3621                            cursor += child_main + gap + extra_gap;
3622                        }
3623
3624                        line_cursor += line_cross + gap;
3625                    }
3626
3627                    if record && !abs_children.is_empty() {
3628                        let abs_constraints = BoxConstraints::loose(size.width, size.height);
3629                        for child_id in abs_children {
3630                            self.layout_node_constraints(
3631                                child_id,
3632                                abs_constraints,
3633                                origin,
3634                                out,
3635                                constraints_out,
3636                                measure_cache,
3637                                scroll_source,
3638                                record,
3639                                depth + 1,
3640                            )?;
3641                        }
3642                    }
3643                    content_size = size;
3644                    size
3645                } else {
3646                    struct FlexChildEntry {
3647                        id: WidgetId,
3648                        flex: f32,
3649                        size: LayoutSize,
3650                        constraints: BoxConstraints,
3651                        is_flex: bool,
3652                    }
3653                    let mut measured: Vec<FlexChildEntry> = Vec::new();
3654                    let mut total_flex = 0.0f32;
3655                    let mut nonflex_main = 0.0f32;
3656                    let mut max_child_cross = 0.0f32;
3657                    let treat_flex_as_nonflex = !main_bounded;
3658
3659                    for child_id in &flow_children {
3660                        let child = match self.graph_state.node(*child_id) {
3661                            Some(child) => child,
3662                            None => continue,
3663                        };
3664                        let has_explicit_cross = has_explicit_cross_axis_size(child, is_row);
3665                        let has_explicit_main = has_explicit_main_axis_size(child, is_row);
3666                        let flex = child.flex_grow;
3667                        if flex > 0.0 && !treat_flex_as_nonflex {
3668                            total_flex += flex;
3669                            measured.push(FlexChildEntry {
3670                                id: *child_id,
3671                                flex,
3672                                size: LayoutSize::ZERO,
3673                                constraints: BoxConstraints::loose(0.0, 0.0),
3674                                is_flex: true,
3675                            });
3676                            continue;
3677                        }
3678                        let child_constraints = if is_row {
3679                            let cross =
3680                                if matches!(align_items, fission_ir::op::AlignItems::Stretch)
3681                                    && cross_bounded
3682                                    && !has_explicit_cross
3683                                    && child.rich_text.is_none()
3684                                    && !matches!(
3685                                        child.op,
3686                                        LayoutOp::Box {
3687                                            width: None,
3688                                            height: None,
3689                                            ..
3690                                        }
3691                                    )
3692                                {
3693                                    BoxConstraints {
3694                                        min_w: 0.0,
3695                                        max_w: if main_bounded && has_explicit_main {
3696                                            max_main
3697                                        } else {
3698                                            f32::INFINITY
3699                                        },
3700                                        min_h: max_cross,
3701                                        max_h: max_cross,
3702                                    }
3703                                } else {
3704                                    BoxConstraints {
3705                                        min_w: 0.0,
3706                                        max_w: if main_bounded && has_explicit_main {
3707                                            max_main
3708                                        } else {
3709                                            f32::INFINITY
3710                                        },
3711                                        min_h: 0.0,
3712                                        max_h: max_cross,
3713                                    }
3714                                };
3715                            cross
3716                        } else {
3717                            let cross =
3718                                if matches!(align_items, fission_ir::op::AlignItems::Stretch)
3719                                    && cross_bounded
3720                                    && !has_explicit_cross
3721                                    && child.rich_text.is_none()
3722                                    && !matches!(
3723                                        child.op,
3724                                        LayoutOp::Box {
3725                                            width: None,
3726                                            height: None,
3727                                            ..
3728                                        }
3729                                    )
3730                                {
3731                                    BoxConstraints {
3732                                        min_w: max_cross,
3733                                        max_w: max_cross,
3734                                        min_h: 0.0,
3735                                        max_h: if main_bounded && has_explicit_main {
3736                                            max_main
3737                                        } else {
3738                                            f32::INFINITY
3739                                        },
3740                                    }
3741                                } else {
3742                                    BoxConstraints {
3743                                        min_w: 0.0,
3744                                        max_w: max_cross,
3745                                        min_h: 0.0,
3746                                        max_h: if main_bounded && has_explicit_main {
3747                                            max_main
3748                                        } else {
3749                                            f32::INFINITY
3750                                        },
3751                                    }
3752                                };
3753                            cross
3754                        };
3755                        let child_size = self.layout_node_constraints(
3756                            *child_id,
3757                            child_constraints,
3758                            LayoutPoint::ZERO,
3759                            out,
3760                            constraints_out,
3761                            measure_cache,
3762                            scroll_source,
3763                            false,
3764                            depth + 1,
3765                        )?;
3766                        let child_main = if is_row {
3767                            child_size.width
3768                        } else {
3769                            child_size.height
3770                        };
3771                        let child_cross = if is_row {
3772                            child_size.height
3773                        } else {
3774                            child_size.width
3775                        };
3776                        nonflex_main += child_main;
3777                        max_child_cross = max_child_cross.max(child_cross);
3778                        measured.push(FlexChildEntry {
3779                            id: *child_id,
3780                            flex,
3781                            size: child_size,
3782                            constraints: child_constraints,
3783                            is_flex: false,
3784                        });
3785                    }
3786
3787                    let gap_total = gap * flow_children.len().saturating_sub(1) as f32;
3788                    let remaining = if main_bounded {
3789                        (max_main - nonflex_main - gap_total).max(0.0)
3790                    } else {
3791                        0.0
3792                    };
3793
3794                    for entry in measured.iter_mut().filter(|e| e.is_flex) {
3795                        let flex = entry.flex;
3796                        let has_explicit_cross = self
3797                            .graph_state
3798                            .node(entry.id)
3799                            .is_some_and(|child| has_explicit_cross_axis_size(child, is_row));
3800                        let allocated = if main_bounded && total_flex > 0.0 {
3801                            remaining * (flex / total_flex)
3802                        } else {
3803                            0.0
3804                        };
3805                        let child_constraints = if is_row {
3806                            let cross =
3807                                if matches!(align_items, fission_ir::op::AlignItems::Stretch)
3808                                    && cross_bounded
3809                                    && !has_explicit_cross
3810                                {
3811                                    BoxConstraints {
3812                                        min_w: allocated,
3813                                        max_w: allocated,
3814                                        min_h: max_cross,
3815                                        max_h: max_cross,
3816                                    }
3817                                } else {
3818                                    BoxConstraints {
3819                                        min_w: allocated,
3820                                        max_w: allocated,
3821                                        min_h: 0.0,
3822                                        max_h: max_cross,
3823                                    }
3824                                };
3825                            cross
3826                        } else {
3827                            let cross =
3828                                if matches!(align_items, fission_ir::op::AlignItems::Stretch)
3829                                    && cross_bounded
3830                                    && !has_explicit_cross
3831                                {
3832                                    BoxConstraints {
3833                                        min_w: max_cross,
3834                                        max_w: max_cross,
3835                                        min_h: allocated,
3836                                        max_h: allocated,
3837                                    }
3838                                } else {
3839                                    BoxConstraints {
3840                                        min_w: 0.0,
3841                                        max_w: max_cross,
3842                                        min_h: allocated,
3843                                        max_h: allocated,
3844                                    }
3845                                };
3846                            cross
3847                        };
3848                        let child_size = self.layout_node_constraints(
3849                            entry.id,
3850                            child_constraints,
3851                            LayoutPoint::ZERO,
3852                            out,
3853                            constraints_out,
3854                            measure_cache,
3855                            scroll_source,
3856                            false,
3857                            depth + 1,
3858                        )?;
3859                        let child_cross = if is_row {
3860                            child_size.height
3861                        } else {
3862                            child_size.width
3863                        };
3864                        max_child_cross = max_child_cross.max(child_cross);
3865                        entry.size = child_size;
3866                        entry.constraints = child_constraints;
3867                    }
3868
3869                    let final_children_main: f32 = measured
3870                        .iter()
3871                        .map(|entry| {
3872                            if is_row {
3873                                entry.size.width
3874                            } else {
3875                                entry.size.height
3876                            }
3877                        })
3878                        .sum();
3879
3880                    let mut container_main = if main_bounded && *flex_grow > 0.0 {
3881                        max_main
3882                    } else {
3883                        final_children_main + gap_total
3884                    };
3885                    container_main = container_main.max(min_main);
3886
3887                    if main_bounded && final_children_main + gap_total > max_main {
3888                        // SHRINK logic
3889                        let mut total_shrink_scaled = 0.0f32;
3890                        for entry in &measured {
3891                            let Some(child) = self.graph_state.node(entry.id) else {
3892                                continue;
3893                            };
3894                            let main_size = if is_row {
3895                                entry.size.width
3896                            } else {
3897                                entry.size.height
3898                            };
3899                            total_shrink_scaled += main_size * child.flex_shrink;
3900                        }
3901
3902                        if total_shrink_scaled > 0.0 {
3903                            let overflow = (final_children_main + gap_total) - max_main;
3904                            for entry in &mut measured {
3905                                let Some(child) = self.graph_state.node(entry.id) else {
3906                                    continue;
3907                                };
3908                                let main_size = if is_row {
3909                                    entry.size.width
3910                                } else {
3911                                    entry.size.height
3912                                };
3913                                let shrink_amount = (main_size * child.flex_shrink
3914                                    / total_shrink_scaled)
3915                                    * overflow;
3916                                // Don't shrink below a reasonable minimum. Items with
3917                                // flex_shrink > 0 can shrink but not to zero - preserve at
3918                                // least a small fraction of their natural size.
3919                                let floor = if child.flex_shrink > 0.0 {
3920                                    // Check for explicit min/fixed dimension
3921                                    let explicit_min = match &child.op {
3922                                        LayoutOp::Box {
3923                                            min_width,
3924                                            min_height,
3925                                            height,
3926                                            width,
3927                                            ..
3928                                        } => {
3929                                            if is_row {
3930                                                min_width.or(*width).unwrap_or(0.0)
3931                                            } else {
3932                                                min_height.or(*height).unwrap_or(0.0)
3933                                            }
3934                                        }
3935                                        _ => 0.0,
3936                                    };
3937                                    explicit_min
3938                                } else {
3939                                    main_size // flex_shrink == 0 means don't shrink at all
3940                                };
3941                                let new_main = (main_size - shrink_amount).max(floor);
3942
3943                                let mut child_constraints = entry.constraints;
3944                                if is_row {
3945                                    child_constraints.min_w = new_main;
3946                                    child_constraints.max_w = new_main;
3947                                } else {
3948                                    child_constraints.min_h = new_main;
3949                                    child_constraints.max_h = new_main;
3950                                }
3951                                let new_size = self.layout_node_constraints(
3952                                    entry.id,
3953                                    child_constraints,
3954                                    LayoutPoint::ZERO,
3955                                    out,
3956                                    constraints_out,
3957                                    measure_cache,
3958                                    scroll_source,
3959                                    false,
3960                                    depth + 1,
3961                                )?;
3962                                entry.size = new_size;
3963                                entry.constraints = child_constraints;
3964                            }
3965                        }
3966                    }
3967
3968                    let container_cross = max_child_cross.max(min_cross);
3969                    let size = if is_row {
3970                        local.constrain(LayoutSize::new(
3971                            container_main + padding[0] + padding[1],
3972                            container_cross + padding[2] + padding[3],
3973                        ))
3974                    } else {
3975                        local.constrain(LayoutSize::new(
3976                            container_cross + padding[0] + padding[1],
3977                            container_main + padding[2] + padding[3],
3978                        ))
3979                    };
3980
3981                    let inner_main = if is_row {
3982                        size.width - padding[0] - padding[1]
3983                    } else {
3984                        size.height - padding[2] - padding[3]
3985                    };
3986                    let inner_cross = if is_row {
3987                        size.height - padding[2] - padding[3]
3988                    } else {
3989                        size.width - padding[0] - padding[1]
3990                    };
3991
3992                    let final_children_main: f32 = measured
3993                        .iter()
3994                        .map(|entry| {
3995                            if is_row {
3996                                entry.size.width
3997                            } else {
3998                                entry.size.height
3999                            }
4000                        })
4001                        .sum();
4002
4003                    let remaining_space = (inner_main - final_children_main - gap_total).max(0.0);
4004                    let mut extra_gap = 0.0;
4005                    let mut offset_main = 0.0;
4006                    match justify_content {
4007                        fission_ir::op::JustifyContent::Start => {}
4008                        fission_ir::op::JustifyContent::End => offset_main = remaining_space,
4009                        fission_ir::op::JustifyContent::Center => {
4010                            offset_main = remaining_space / 2.0
4011                        }
4012                        fission_ir::op::JustifyContent::SpaceBetween => {
4013                            if measured.len() > 1 {
4014                                extra_gap = remaining_space / (measured.len() as f32 - 1.0);
4015                            }
4016                        }
4017                        fission_ir::op::JustifyContent::SpaceAround => {
4018                            if !measured.is_empty() {
4019                                extra_gap = remaining_space / measured.len() as f32;
4020                                offset_main = extra_gap / 2.0;
4021                            }
4022                        }
4023                        fission_ir::op::JustifyContent::SpaceEvenly => {
4024                            if !measured.is_empty() {
4025                                extra_gap = remaining_space / (measured.len() as f32 + 1.0);
4026                                offset_main = extra_gap;
4027                            }
4028                        }
4029                    }
4030
4031                    let mut cursor = offset_main;
4032                    for entry in measured {
4033                        let child_main = if is_row {
4034                            entry.size.width
4035                        } else {
4036                            entry.size.height
4037                        };
4038                        let child_cross = if is_row {
4039                            entry.size.height
4040                        } else {
4041                            entry.size.width
4042                        };
4043                        let cross_offset = match align_items {
4044                            fission_ir::op::AlignItems::Start
4045                            | fission_ir::op::AlignItems::Stretch => 0.0,
4046                            fission_ir::op::AlignItems::End => (inner_cross - child_cross).max(0.0),
4047                            fission_ir::op::AlignItems::Center => {
4048                                ((inner_cross - child_cross) / 2.0).max(0.0)
4049                            }
4050                            fission_ir::op::AlignItems::Baseline => 0.0,
4051                        };
4052                        let child_origin = if is_row {
4053                            LayoutPoint::new(
4054                                origin.x + padding[0] + cursor,
4055                                origin.y + padding[2] + cross_offset,
4056                            )
4057                        } else {
4058                            LayoutPoint::new(
4059                                origin.x + padding[0] + cross_offset,
4060                                origin.y + padding[2] + cursor,
4061                            )
4062                        };
4063
4064                        let mut child_constraints = entry.constraints;
4065                        if matches!(align_items, fission_ir::op::AlignItems::Stretch) {
4066                            // Only stretch children that don't have an explicit cross-axis size.
4067                            let child_node = self.graph_state.node(entry.id);
4068                            let has_explicit_cross = child_node
4069                                .is_some_and(|node| has_explicit_cross_axis_size(node, is_row));
4070                            // Text owns its measured height/width; stretching the
4071                            // text layout node would turn a line into the full
4072                            // row height and distort vertical centering.
4073                            let is_measured_text = child_node.is_some_and(|node| {
4074                                node.rich_text.is_some()
4075                                    || matches!(
4076                                        node.op,
4077                                        LayoutOp::Box {
4078                                            width: None,
4079                                            height: None,
4080                                            ..
4081                                        }
4082                                    )
4083                            });
4084                            if !has_explicit_cross && !is_measured_text {
4085                                if is_row {
4086                                    child_constraints.min_h = inner_cross;
4087                                    child_constraints.max_h = inner_cross;
4088                                } else {
4089                                    child_constraints.min_w = inner_cross;
4090                                    child_constraints.max_w = inner_cross;
4091                                }
4092                            }
4093                        }
4094
4095                        self.layout_node_constraints(
4096                            entry.id,
4097                            child_constraints,
4098                            child_origin,
4099                            out,
4100                            constraints_out,
4101                            measure_cache,
4102                            scroll_source,
4103                            record,
4104                            depth + 1,
4105                        )?;
4106                        cursor += child_main + gap + extra_gap;
4107                    }
4108
4109                    if record && !abs_children.is_empty() {
4110                        let abs_constraints = BoxConstraints::loose(size.width, size.height);
4111                        for child_id in abs_children {
4112                            self.layout_node_constraints(
4113                                child_id,
4114                                abs_constraints,
4115                                origin,
4116                                out,
4117                                constraints_out,
4118                                measure_cache,
4119                                scroll_source,
4120                                record,
4121                                depth + 1,
4122                            )?;
4123                        }
4124                    }
4125                    content_size = size;
4126                    size
4127                }
4128            }
4129            LayoutOp::Grid {
4130                columns,
4131                rows,
4132                column_gap,
4133                row_gap,
4134                padding,
4135            } => {
4136                let gap_x = column_gap.unwrap_or(0.0);
4137                let gap_y = row_gap.unwrap_or(0.0);
4138                let inner = constraints.deflate(*padding);
4139                let bounded_w = inner.is_width_bounded();
4140                let bounded_h = inner.is_height_bounded();
4141                let child_count = flow_children.len();
4142                let available_w = bounded_w.then_some(inner.max_w);
4143                let available_h = bounded_h.then_some(inner.max_h);
4144                let mut expanded_columns = expand_tracks(columns, available_w, gap_x, child_count);
4145                if expanded_columns.is_empty() {
4146                    expanded_columns.push(GridTrack::Auto);
4147                }
4148                let mut col_count = expanded_columns.len();
4149
4150                #[derive(Clone, Copy)]
4151                struct GridCell {
4152                    id: WidgetId,
4153                    row: usize,
4154                    col: usize,
4155                    row_span: usize,
4156                    col_span: usize,
4157                }
4158
4159                let mut cell_assignments: Vec<GridCell> = Vec::new();
4160                let mut auto_row = 0;
4161                let mut auto_col = 0;
4162                let mut occupied = HashSet::<(usize, usize)>::new();
4163
4164                for child_id in &flow_children {
4165                    let Some(child) = self.graph_state.node(*child_id) else {
4166                        continue;
4167                    };
4168                    let (row_start, row_end, col_start, col_end) = if let LayoutOp::GridItem {
4169                        row_start,
4170                        row_end,
4171                        col_start,
4172                        col_end,
4173                        ..
4174                    } = &child.op
4175                    {
4176                        (*row_start, *row_end, *col_start, *col_end)
4177                    } else {
4178                        (
4179                            GridPlacement::Auto,
4180                            GridPlacement::Auto,
4181                            GridPlacement::Auto,
4182                            GridPlacement::Auto,
4183                        )
4184                    };
4185                    let explicit_row = match row_start {
4186                        GridPlacement::Line(line) => Some(line.max(1) as usize - 1),
4187                        _ => None,
4188                    };
4189                    let explicit_col = match col_start {
4190                        GridPlacement::Line(line) => Some(line.max(1) as usize - 1),
4191                        _ => None,
4192                    };
4193                    let row_span = match row_end {
4194                        GridPlacement::Span(span) => usize::from(span).max(1),
4195                        GridPlacement::Line(line) => {
4196                            let end = line.max(1) as usize - 1;
4197                            end.saturating_sub(explicit_row.unwrap_or_default()).max(1)
4198                        }
4199                        GridPlacement::Auto => 1,
4200                    };
4201                    let col_span = match col_end {
4202                        GridPlacement::Span(span) => usize::from(span).max(1),
4203                        GridPlacement::Line(line) => {
4204                            let end = line.max(1) as usize - 1;
4205                            end.saturating_sub(explicit_col.unwrap_or_default()).max(1)
4206                        }
4207                        GridPlacement::Auto => 1,
4208                    };
4209                    let fits = |row: usize, col: usize, occupied: &HashSet<(usize, usize)>| {
4210                        (row..row + row_span).all(|row| {
4211                            (col..col + col_span).all(|col| !occupied.contains(&(row, col)))
4212                        })
4213                    };
4214                    let (row, col) = match (explicit_row, explicit_col) {
4215                        (Some(row), Some(col)) => (row, col),
4216                        (Some(row), None) => {
4217                            let mut col = 0;
4218                            while !fits(row, col, &occupied) {
4219                                col += 1;
4220                            }
4221                            (row, col)
4222                        }
4223                        (None, Some(col)) => {
4224                            let mut row = 0;
4225                            while !fits(row, col, &occupied) {
4226                                row += 1;
4227                            }
4228                            (row, col)
4229                        }
4230                        (None, None) => {
4231                            while (col_span <= col_count && auto_col + col_span > col_count)
4232                                || !fits(auto_row, auto_col, &occupied)
4233                            {
4234                                auto_col += 1;
4235                                if auto_col >= col_count {
4236                                    auto_col = 0;
4237                                    auto_row += 1;
4238                                }
4239                            }
4240                            let placement = (auto_row, auto_col);
4241                            if col_span >= col_count {
4242                                auto_col = 0;
4243                                auto_row += 1;
4244                            } else {
4245                                auto_col += col_span;
4246                                if auto_col >= col_count {
4247                                    auto_col = 0;
4248                                    auto_row += 1;
4249                                }
4250                            }
4251                            placement
4252                        }
4253                    };
4254                    for occupied_row in row..row + row_span {
4255                        for occupied_col in col..col + col_span {
4256                            occupied.insert((occupied_row, occupied_col));
4257                        }
4258                    }
4259                    cell_assignments.push(GridCell {
4260                        id: *child_id,
4261                        row,
4262                        col,
4263                        row_span,
4264                        col_span,
4265                    });
4266                }
4267
4268                let required_columns = cell_assignments
4269                    .iter()
4270                    .map(|cell| cell.col + cell.col_span)
4271                    .max()
4272                    .unwrap_or(1);
4273                if required_columns > col_count {
4274                    expanded_columns.resize(required_columns, GridTrack::Auto);
4275                    col_count = expanded_columns.len();
4276                }
4277
4278                let mut column_sizing = expanded_columns
4279                    .iter()
4280                    .map(|track| TrackSizing::from_track(track, available_w))
4281                    .collect::<Vec<_>>();
4282
4283                for cell in &cell_assignments {
4284                    let intrinsic = column_sizing[cell.col..cell.col + cell.col_span]
4285                        .iter()
4286                        .filter_map(|track| track.intrinsic)
4287                        .fold(None, |current, axis| match (current, axis) {
4288                            (Some(IntrinsicAxis::Max), _) | (_, IntrinsicAxis::Max) => {
4289                                Some(IntrinsicAxis::Max)
4290                            }
4291                            _ => Some(IntrinsicAxis::Min),
4292                        });
4293                    let Some(intrinsic) = intrinsic else {
4294                        continue;
4295                    };
4296                    let width = self.measure_grid_intrinsic_width(
4297                        cell.id,
4298                        intrinsic,
4299                        inner.max_h,
4300                        out,
4301                        constraints_out,
4302                        measure_cache,
4303                        scroll_source,
4304                        depth + 1,
4305                    )?;
4306                    distribute_deficit(
4307                        &mut column_sizing,
4308                        cell.col,
4309                        cell.col_span,
4310                        (width - gap_x * cell.col_span.saturating_sub(1) as f32).max(0.0),
4311                    );
4312                }
4313                if let Some(available_w) = available_w {
4314                    distribute_flex(&mut column_sizing, available_w, gap_x);
4315                }
4316                let col_widths = column_sizing
4317                    .iter()
4318                    .map(|track| track.base)
4319                    .collect::<Vec<_>>();
4320
4321                let minimum_rows = cell_assignments
4322                    .iter()
4323                    .map(|cell| cell.row + cell.row_span)
4324                    .max()
4325                    .unwrap_or_else(|| (child_count + col_count - 1) / col_count)
4326                    .max(1);
4327                let mut expanded_rows = expand_tracks(rows, available_h, gap_y, minimum_rows);
4328                if expanded_rows.is_empty() {
4329                    expanded_rows.resize(minimum_rows, GridTrack::Auto);
4330                } else if expanded_rows.len() < minimum_rows {
4331                    expanded_rows.resize(minimum_rows, GridTrack::Auto);
4332                }
4333                let mut row_sizing = expanded_rows
4334                    .iter()
4335                    .map(|track| TrackSizing::from_track(track, available_h))
4336                    .collect::<Vec<_>>();
4337
4338                for cell in &cell_assignments {
4339                    if cell.row >= row_sizing.len() || cell.col >= col_widths.len() {
4340                        continue;
4341                    }
4342                    let col_end = (cell.col + cell.col_span).min(col_widths.len());
4343                    let cell_w = col_widths[cell.col..col_end].iter().sum::<f32>()
4344                        + gap_x * col_end.saturating_sub(cell.col + 1) as f32;
4345                    let cell_constraints = BoxConstraints {
4346                        min_w: 0.0,
4347                        max_w: cell_w,
4348                        min_h: 0.0,
4349                        max_h: f32::INFINITY,
4350                    };
4351                    let child_size = self.layout_node_constraints(
4352                        cell.id,
4353                        cell_constraints,
4354                        LayoutPoint::ZERO,
4355                        out,
4356                        constraints_out,
4357                        measure_cache,
4358                        scroll_source,
4359                        false,
4360                        depth + 1,
4361                    )?;
4362                    distribute_deficit(
4363                        &mut row_sizing,
4364                        cell.row,
4365                        cell.row_span,
4366                        (child_size.height - gap_y * cell.row_span.saturating_sub(1) as f32)
4367                            .max(0.0),
4368                    );
4369                }
4370                if let Some(available_h) = available_h {
4371                    distribute_flex(&mut row_sizing, available_h, gap_y);
4372                }
4373                let row_heights = row_sizing
4374                    .iter()
4375                    .map(|track| track.base)
4376                    .collect::<Vec<_>>();
4377
4378                let grid_w: f32 =
4379                    col_widths.iter().sum::<f32>() + gap_x * (col_count.saturating_sub(1) as f32);
4380                let grid_h: f32 = row_heights.iter().sum::<f32>()
4381                    + gap_y * (row_heights.len().saturating_sub(1) as f32);
4382                let size = constraints.constrain(LayoutSize::new(
4383                    grid_w + padding[0] + padding[1],
4384                    grid_h + padding[2] + padding[3],
4385                ));
4386
4387                if record {
4388                    let padding_origin_x = origin.x + padding[0];
4389                    let padding_origin_y = origin.y + padding[2];
4390                    for cell in &cell_assignments {
4391                        if cell.row >= row_heights.len() || cell.col >= col_widths.len() {
4392                            continue;
4393                        }
4394                        let cell_x = padding_origin_x
4395                            + col_widths[..cell.col].iter().sum::<f32>()
4396                            + gap_x * cell.col as f32;
4397                        let cell_y = padding_origin_y
4398                            + row_heights[..cell.row].iter().sum::<f32>()
4399                            + gap_y * cell.row as f32;
4400                        let col_end = (cell.col + cell.col_span).min(col_widths.len());
4401                        let row_end = (cell.row + cell.row_span).min(row_heights.len());
4402                        let cell_w = col_widths[cell.col..col_end].iter().sum::<f32>()
4403                            + gap_x * col_end.saturating_sub(cell.col + 1) as f32;
4404                        let cell_h = row_heights[cell.row..row_end].iter().sum::<f32>()
4405                            + gap_y * row_end.saturating_sub(cell.row + 1) as f32;
4406                        let child_constraints = BoxConstraints {
4407                            min_w: cell_w,
4408                            max_w: cell_w,
4409                            min_h: cell_h,
4410                            max_h: cell_h,
4411                        };
4412                        self.layout_node_constraints(
4413                            cell.id,
4414                            child_constraints,
4415                            LayoutPoint::new(cell_x, cell_y),
4416                            out,
4417                            constraints_out,
4418                            measure_cache,
4419                            scroll_source,
4420                            record,
4421                            depth + 1,
4422                        )?;
4423                    }
4424                }
4425
4426                if record && !abs_children.is_empty() {
4427                    let abs_constraints = BoxConstraints::loose(size.width, size.height);
4428                    for child_id in abs_children {
4429                        self.layout_node_constraints(
4430                            child_id,
4431                            abs_constraints,
4432                            origin,
4433                            out,
4434                            constraints_out,
4435                            measure_cache,
4436                            scroll_source,
4437                            record,
4438                            depth + 1,
4439                        )?;
4440                    }
4441                }
4442                content_size = size;
4443                size
4444            }
4445            LayoutOp::GridItem { .. } => {
4446                let mut child_size = LayoutSize::ZERO;
4447                if let Some(child_id) = node.children_ids.first() {
4448                    child_size = self.layout_node_constraints(
4449                        *child_id,
4450                        constraints,
4451                        origin,
4452                        out,
4453                        constraints_out,
4454                        measure_cache,
4455                        scroll_source,
4456                        record,
4457                        depth + 1,
4458                    )?;
4459                }
4460                content_size = child_size;
4461                constraints.constrain(child_size)
4462            }
4463            LayoutOp::Responsive { query, cases } => {
4464                let query_width = match query {
4465                    fission_ir::op::ResponsiveQuery::Viewport => self.active_viewport.width,
4466                    fission_ir::op::ResponsiveQuery::Container => {
4467                        if constraints.is_width_bounded() {
4468                            constraints.max_w
4469                        } else {
4470                            self.active_viewport.width
4471                        }
4472                    }
4473                };
4474                let selected_index = cases
4475                    .iter()
4476                    .enumerate()
4477                    .find_map(|(index, condition)| condition.matches(query_width).then_some(index))
4478                    .unwrap_or(cases.len());
4479                let child_size = node
4480                    .children_ids
4481                    .get(selected_index)
4482                    .map(|child_id| {
4483                        self.layout_node_constraints(
4484                            *child_id,
4485                            constraints,
4486                            origin,
4487                            out,
4488                            constraints_out,
4489                            measure_cache,
4490                            scroll_source,
4491                            record,
4492                            depth + 1,
4493                        )
4494                    })
4495                    .transpose()?
4496                    .unwrap_or(LayoutSize::ZERO);
4497                content_size = child_size;
4498                constraints.constrain(child_size)
4499            }
4500            LayoutOp::Scroll {
4501                direction,
4502                width,
4503                height,
4504                min_width,
4505                max_width,
4506                min_height,
4507                max_height,
4508                padding,
4509                ..
4510            } => {
4511                let mut local =
4512                    constraints.apply_min_max(*min_width, *max_width, *min_height, *max_height);
4513                local = local.tighten(*width, *height);
4514                let is_horizontal = matches!(direction, FlexDirection::Row);
4515                let mut child_constraints = local.deflate(*padding);
4516                if is_horizontal {
4517                    child_constraints.min_w = 0.0;
4518                    child_constraints.max_w = f32::INFINITY;
4519                } else {
4520                    child_constraints.min_h = 0.0;
4521                    child_constraints.max_h = f32::INFINITY;
4522                }
4523                let mut child_size = LayoutSize::ZERO;
4524                if let Some(child_id) = flow_children.first() {
4525                    child_size = self.layout_node_constraints(
4526                        *child_id,
4527                        child_constraints,
4528                        LayoutPoint::ZERO,
4529                        out,
4530                        constraints_out,
4531                        measure_cache,
4532                        scroll_source,
4533                        false,
4534                        depth + 1,
4535                    )?;
4536                }
4537                let size = local.constrain(LayoutSize::new(
4538                    child_size.width + padding[0] + padding[1],
4539                    child_size.height + padding[2] + padding[3],
4540                ));
4541                if record {
4542                    if let Some(child_id) = flow_children.first() {
4543                        self.layout_node_constraints(
4544                            *child_id,
4545                            child_constraints,
4546                            LayoutPoint::new(origin.x + padding[0], origin.y + padding[2]),
4547                            out,
4548                            constraints_out,
4549                            measure_cache,
4550                            scroll_source,
4551                            record,
4552                            depth + 1,
4553                        )?;
4554                    }
4555                    if !abs_children.is_empty() {
4556                        let abs_constraints = BoxConstraints::loose(size.width, size.height);
4557                        for child_id in abs_children {
4558                            self.layout_node_constraints(
4559                                child_id,
4560                                abs_constraints,
4561                                origin,
4562                                out,
4563                                constraints_out,
4564                                measure_cache,
4565                                scroll_source,
4566                                record,
4567                                depth + 1,
4568                            )?;
4569                        }
4570                    }
4571                }
4572                content_size = child_size;
4573                size
4574            }
4575            LayoutOp::Align => {
4576                let child_constraints = BoxConstraints::loose(constraints.max_w, constraints.max_h);
4577                let mut child_size = LayoutSize::ZERO;
4578                if let Some(child_id) = flow_children.first() {
4579                    child_size = self.layout_node_constraints(
4580                        *child_id,
4581                        child_constraints,
4582                        LayoutPoint::ZERO,
4583                        out,
4584                        constraints_out,
4585                        measure_cache,
4586                        scroll_source,
4587                        false,
4588                        depth + 1,
4589                    )?;
4590                }
4591                let size = if constraints.is_width_bounded() || constraints.is_height_bounded() {
4592                    constraints.constrain(LayoutSize::new(
4593                        if constraints.is_width_bounded() {
4594                            constraints.max_w
4595                        } else {
4596                            child_size.width
4597                        },
4598                        if constraints.is_height_bounded() {
4599                            constraints.max_h
4600                        } else {
4601                            child_size.height
4602                        },
4603                    ))
4604                } else {
4605                    child_size
4606                };
4607                if let Some(child_id) = flow_children.first() {
4608                    let dx = ((size.width - child_size.width) / 2.0).max(0.0);
4609                    let dy = ((size.height - child_size.height) / 2.0).max(0.0);
4610                    self.layout_node_constraints(
4611                        *child_id,
4612                        child_constraints,
4613                        LayoutPoint::new(origin.x + dx, origin.y + dy),
4614                        out,
4615                        constraints_out,
4616                        measure_cache,
4617                        scroll_source,
4618                        record,
4619                        depth + 1,
4620                    )?;
4621                }
4622                if record && !abs_children.is_empty() {
4623                    let abs_constraints = BoxConstraints::loose(size.width, size.height);
4624                    for child_id in abs_children {
4625                        self.layout_node_constraints(
4626                            child_id,
4627                            abs_constraints,
4628                            origin,
4629                            out,
4630                            constraints_out,
4631                            measure_cache,
4632                            scroll_source,
4633                            record,
4634                            depth + 1,
4635                        )?;
4636                    }
4637                }
4638                content_size = child_size;
4639                size
4640            }
4641            LayoutOp::ZStack => {
4642                let mut max_child = LayoutSize::ZERO;
4643                for child_id in &flow_children {
4644                    let child_size = self.layout_node_constraints(
4645                        *child_id,
4646                        BoxConstraints::loose(constraints.max_w, constraints.max_h),
4647                        LayoutPoint::ZERO,
4648                        out,
4649                        constraints_out,
4650                        measure_cache,
4651                        scroll_source,
4652                        false,
4653                        depth + 1,
4654                    )?;
4655                    max_child.width = max_child.width.max(child_size.width);
4656                    max_child.height = max_child.height.max(child_size.height);
4657                }
4658                let size = if constraints.is_width_bounded() || constraints.is_height_bounded() {
4659                    constraints.constrain(LayoutSize::new(
4660                        if constraints.is_width_bounded() {
4661                            constraints.max_w
4662                        } else {
4663                            max_child.width
4664                        },
4665                        if constraints.is_height_bounded() {
4666                            constraints.max_h
4667                        } else {
4668                            max_child.height
4669                        },
4670                    ))
4671                } else {
4672                    max_child
4673                };
4674                for child_id in &flow_children {
4675                    let child_constraints = BoxConstraints::loose(size.width, size.height);
4676                    let child_origin = LayoutPoint::new(origin.x, origin.y);
4677                    self.layout_node_constraints(
4678                        *child_id,
4679                        child_constraints,
4680                        child_origin,
4681                        out,
4682                        constraints_out,
4683                        measure_cache,
4684                        scroll_source,
4685                        record,
4686                        depth + 1,
4687                    )?;
4688                }
4689                if record && !abs_children.is_empty() {
4690                    let abs_constraints = BoxConstraints::loose(size.width, size.height);
4691                    for child_id in abs_children {
4692                        self.layout_node_constraints(
4693                            child_id,
4694                            abs_constraints,
4695                            origin,
4696                            out,
4697                            constraints_out,
4698                            measure_cache,
4699                            scroll_source,
4700                            record,
4701                            depth + 1,
4702                        )?;
4703                    }
4704                }
4705                content_size = size;
4706                size
4707            }
4708            LayoutOp::Positioned {
4709                top,
4710                left,
4711                bottom,
4712                right,
4713                width,
4714                height,
4715            } => {
4716                let target_w = finite_or(constraints.max_w, finite_or(constraints.min_w, 0.0));
4717                let target_h = finite_or(constraints.max_h, finite_or(constraints.min_h, 0.0));
4718                let size = constraints.constrain(LayoutSize::new(target_w, target_h));
4719                let mut child_constraints = BoxConstraints::loose(size.width, size.height);
4720                if let (Some(l), Some(r)) = (left, right) {
4721                    let w = (size.width - l - r).max(0.0);
4722                    child_constraints = child_constraints.tighten(Some(w), None);
4723                }
4724                if let (Some(t), Some(b)) = (top, bottom) {
4725                    let h = (size.height - t - b).max(0.0);
4726                    child_constraints = child_constraints.tighten(None, Some(h));
4727                }
4728                child_constraints = child_constraints.tighten(*width, *height);
4729                if let Some(child_id) = node.children_ids.first() {
4730                    let child_size = self.layout_node_constraints(
4731                        *child_id,
4732                        child_constraints,
4733                        LayoutPoint::ZERO,
4734                        out,
4735                        constraints_out,
4736                        measure_cache,
4737                        scroll_source,
4738                        false,
4739                        depth + 1,
4740                    )?;
4741                    let x = left.unwrap_or_else(|| {
4742                        right
4743                            .map(|r| (size.width - r - child_size.width).max(0.0))
4744                            .unwrap_or(0.0)
4745                    });
4746                    let y = top.unwrap_or_else(|| {
4747                        bottom
4748                            .map(|b| (size.height - b - child_size.height).max(0.0))
4749                            .unwrap_or(0.0)
4750                    });
4751                    self.layout_node_constraints(
4752                        *child_id,
4753                        child_constraints,
4754                        LayoutPoint::new(origin.x + x, origin.y + y),
4755                        out,
4756                        constraints_out,
4757                        measure_cache,
4758                        scroll_source,
4759                        record,
4760                        depth + 1,
4761                    )?;
4762                }
4763                content_size = size;
4764                size
4765            }
4766            LayoutOp::PositionedLengths {
4767                top,
4768                left,
4769                bottom,
4770                right,
4771                width,
4772                height,
4773            } => {
4774                let target_w = finite_or(constraints.max_w, finite_or(constraints.min_w, 0.0));
4775                let target_h = finite_or(constraints.max_h, finite_or(constraints.min_h, 0.0));
4776                let size = constraints.constrain(LayoutSize::new(target_w, target_h));
4777                let resolve_horizontal = |length: &Option<Length>| {
4778                    length
4779                        .as_ref()
4780                        .and_then(|length| resolve_length(length, size.width, self.active_viewport))
4781                };
4782                let resolve_vertical = |length: &Option<Length>| {
4783                    length.as_ref().and_then(|length| {
4784                        resolve_length(length, size.height, self.active_viewport)
4785                    })
4786                };
4787                let left = resolve_horizontal(left);
4788                let top = resolve_vertical(top);
4789                let right = resolve_horizontal(right);
4790                let bottom = resolve_vertical(bottom);
4791                let width = resolve_horizontal(width);
4792                let height = resolve_vertical(height);
4793                let mut child_constraints = BoxConstraints::loose(size.width, size.height);
4794                if let (Some(left), Some(right)) = (left, right) {
4795                    child_constraints =
4796                        child_constraints.tighten(Some((size.width - left - right).max(0.0)), None);
4797                }
4798                if let (Some(top), Some(bottom)) = (top, bottom) {
4799                    child_constraints = child_constraints
4800                        .tighten(None, Some((size.height - top - bottom).max(0.0)));
4801                }
4802                child_constraints = child_constraints.tighten(width, height);
4803                if let Some(child_id) = node.children_ids.first() {
4804                    let child_size = self.layout_node_constraints(
4805                        *child_id,
4806                        child_constraints,
4807                        LayoutPoint::ZERO,
4808                        out,
4809                        constraints_out,
4810                        measure_cache,
4811                        scroll_source,
4812                        false,
4813                        depth + 1,
4814                    )?;
4815                    let x = left.unwrap_or_else(|| {
4816                        right
4817                            .map(|right| (size.width - right - child_size.width).max(0.0))
4818                            .unwrap_or(0.0)
4819                    });
4820                    let y = top.unwrap_or_else(|| {
4821                        bottom
4822                            .map(|bottom| (size.height - bottom - child_size.height).max(0.0))
4823                            .unwrap_or(0.0)
4824                    });
4825                    self.layout_node_constraints(
4826                        *child_id,
4827                        child_constraints,
4828                        LayoutPoint::new(origin.x + x, origin.y + y),
4829                        out,
4830                        constraints_out,
4831                        measure_cache,
4832                        scroll_source,
4833                        record,
4834                        depth + 1,
4835                    )?;
4836                }
4837                content_size = size;
4838                size
4839            }
4840            LayoutOp::Embed { width, height, .. } => {
4841                let local = constraints.tighten(*width, *height);
4842                let w = if local.is_width_bounded() {
4843                    local.max_w
4844                } else {
4845                    local.min_w
4846                };
4847                let h = if local.is_height_bounded() {
4848                    local.max_h
4849                } else {
4850                    local.min_h
4851                };
4852                let size = local.constrain(LayoutSize::new(w, h));
4853                content_size = size;
4854                size
4855            }
4856            LayoutOp::AbsoluteFill => {
4857                let target_w = finite_or(constraints.max_w, finite_or(constraints.min_w, 0.0));
4858                let target_h = finite_or(constraints.max_h, finite_or(constraints.min_h, 0.0));
4859                let size = constraints.constrain(LayoutSize::new(target_w, target_h));
4860                for child_id in self.graph_state.children_of(node_id) {
4861                    self.layout_node_constraints(
4862                        *child_id,
4863                        BoxConstraints::tight(size),
4864                        origin,
4865                        out,
4866                        constraints_out,
4867                        measure_cache,
4868                        scroll_source,
4869                        record,
4870                        depth + 1,
4871                    )?;
4872                }
4873                content_size = size;
4874                size
4875            }
4876            LayoutOp::Spotlight { .. } => {
4877                let target_w = finite_or(constraints.max_w, finite_or(constraints.min_w, 0.0));
4878                let target_h = finite_or(constraints.max_h, finite_or(constraints.min_h, 0.0));
4879                let size = constraints.constrain(LayoutSize::new(target_w, target_h));
4880                for child_id in self.graph_state.children_of(node_id) {
4881                    self.layout_node_constraints(
4882                        *child_id,
4883                        BoxConstraints::tight(LayoutSize::ZERO),
4884                        origin,
4885                        out,
4886                        constraints_out,
4887                        measure_cache,
4888                        scroll_source,
4889                        record,
4890                        depth + 1,
4891                    )?;
4892                }
4893                content_size = size;
4894                size
4895            }
4896            LayoutOp::Transform { .. } | LayoutOp::Clip { .. } => {
4897                let mut child_size = LayoutSize::ZERO;
4898                if let Some(child_id) = node.children_ids.first() {
4899                    child_size = self.layout_node_constraints(
4900                        *child_id,
4901                        constraints,
4902                        origin,
4903                        out,
4904                        constraints_out,
4905                        measure_cache,
4906                        scroll_source,
4907                        record,
4908                        depth + 1,
4909                    )?;
4910                }
4911                content_size = child_size;
4912                constraints.constrain(child_size)
4913            }
4914            LayoutOp::Flyout { anchor, content: _ } => {
4915                let loose = BoxConstraints::loose(
4916                    if constraints.is_width_bounded() {
4917                        constraints.max_w
4918                    } else {
4919                        f32::INFINITY
4920                    },
4921                    if constraints.is_height_bounded() {
4922                        constraints.max_h
4923                    } else {
4924                        f32::INFINITY
4925                    },
4926                );
4927                let mut child_size = LayoutSize::ZERO;
4928                for child_id in self.graph_state.children_of(node_id) {
4929                    child_size = self.layout_node_constraints(
4930                        *child_id,
4931                        loose,
4932                        origin,
4933                        out,
4934                        constraints_out,
4935                        measure_cache,
4936                        scroll_source,
4937                        false,
4938                        depth + 1,
4939                    )?;
4940                }
4941                if record {
4942                    let anchor_rect = out.get(anchor).map(|g| g.rect);
4943                    let place_x = anchor_rect.map(|r| r.x()).unwrap_or(origin.x);
4944                    let place_y = anchor_rect.map(|r| r.y() + r.height()).unwrap_or(origin.y);
4945                    for child_id in self.graph_state.children_of(node_id) {
4946                        self.layout_node_constraints(
4947                            *child_id,
4948                            loose,
4949                            LayoutPoint::new(place_x, place_y),
4950                            out,
4951                            constraints_out,
4952                            measure_cache,
4953                            scroll_source,
4954                            record,
4955                            depth + 1,
4956                        )?;
4957                    }
4958                }
4959                content_size = child_size;
4960                child_size
4961            }
4962            LayoutOp::StyledBox { .. } => unreachable!("styled boxes are resolved before layout"),
4963        };
4964
4965        if let Some(runs) = &node.rich_text {
4966            if let Some(measurer) = &self.measurer {
4967                let (mut text_constraints, text_padding) = match layout_op {
4968                    LayoutOp::Box {
4969                        width,
4970                        height,
4971                        min_width,
4972                        max_width,
4973                        min_height,
4974                        max_height,
4975                        padding,
4976                        ..
4977                    } => (
4978                        constraints
4979                            .apply_min_max(*min_width, *max_width, *min_height, *max_height)
4980                            .tighten(*width, *height),
4981                        *padding,
4982                    ),
4983                    _ => (constraints, [0.0; 4]),
4984                };
4985                let text_inner_constraints = text_constraints.deflate(text_padding);
4986                let intrinsic_width = match &node.op {
4987                    LayoutOp::StyledBox { style, .. } => style.width.as_ref(),
4988                    _ => None,
4989                };
4990                let avail_w = match intrinsic_width {
4991                    Some(Length::MaxContent) => None,
4992                    Some(Length::MinContent) => Some(
4993                        runs.iter()
4994                            .flat_map(|run| {
4995                                run.text.split_whitespace().map(move |word| {
4996                                    measurer.measure(word, run.style.font_size, None).0
4997                                        + run.style.letter_spacing
4998                                            * word.chars().count().saturating_sub(1) as f32
4999                                })
5000                            })
5001                            .fold(0.0, f32::max),
5002                    ),
5003                    _ => text_inner_constraints
5004                        .is_width_bounded()
5005                        .then_some(text_inner_constraints.max_w),
5006                };
5007                let rich_layout = measurer.layout_rich_text(runs, avail_w);
5008                let text_content = LayoutSize::new(
5009                    rich_layout.width + text_padding[0] + text_padding[1],
5010                    rich_layout.height + text_padding[2] + text_padding[3],
5011                );
5012                if let LayoutOp::StyledBox { style, .. } = &node.op {
5013                    let available = if constraints.max_h.is_finite() {
5014                        constraints.max_h
5015                    } else {
5016                        text_content.height
5017                    };
5018                    let resolve_intrinsic_height = |length: &Option<Length>| {
5019                        length
5020                            .as_ref()
5021                            .filter(|length| length_requires_measurement(length))
5022                            .and_then(|length| {
5023                                resolve_measured_length(
5024                                    length,
5025                                    available,
5026                                    self.active_viewport,
5027                                    text_content.height,
5028                                    text_content.height,
5029                                )
5030                            })
5031                    };
5032                    text_constraints = text_constraints.apply_min_max(
5033                        None,
5034                        None,
5035                        resolve_intrinsic_height(&style.min_height),
5036                        resolve_intrinsic_height(&style.max_height),
5037                    );
5038                    text_constraints =
5039                        text_constraints.tighten(None, resolve_intrinsic_height(&style.height));
5040                }
5041                let measured = text_constraints.constrain(text_content);
5042                if rich_text_inline_children
5043                    && rich_layout.inline_boxes.len() == flow_children.len()
5044                {
5045                    let result =
5046                        self.record_geometry(node_id, origin, measured, text_content, out, record);
5047                    if record {
5048                        let mut inline_boxes = rich_layout.inline_boxes;
5049                        inline_boxes.sort_by_key(|inline_box| inline_box.id);
5050                        for (child_id, inline_box) in flow_children.iter().zip(inline_boxes.iter())
5051                        {
5052                            self.layout_node_constraints(
5053                                *child_id,
5054                                BoxConstraints::tight(LayoutSize::new(
5055                                    inline_box.width,
5056                                    inline_box.height,
5057                                )),
5058                                LayoutPoint::new(
5059                                    origin.x + text_padding[0] + inline_box.x,
5060                                    origin.y + text_padding[2] + inline_box.y,
5061                                ),
5062                                out,
5063                                constraints_out,
5064                                measure_cache,
5065                                scroll_source,
5066                                record,
5067                                depth + 1,
5068                            )?;
5069                        }
5070                    }
5071                    if !record {
5072                        measure_cache.insert(MeasureCacheKey::new(node_id, constraints), result);
5073                    }
5074                    return Ok(result);
5075                }
5076                if node.children_ids.is_empty() {
5077                    let result =
5078                        self.record_geometry(node_id, origin, measured, text_content, out, record);
5079                    if !record {
5080                        measure_cache.insert(MeasureCacheKey::new(node_id, constraints), result);
5081                    }
5082                    return Ok(result);
5083                }
5084                content_size.width = content_size.width.max(text_content.width);
5085                content_size.height = content_size.height.max(text_content.height);
5086            }
5087        }
5088
5089        let result = self.record_geometry(node_id, origin, size, content_size, out, record);
5090        if !record {
5091            measure_cache.insert(MeasureCacheKey::new(node_id, constraints), result);
5092        }
5093        Ok(result)
5094    }
5095
5096    fn record_geometry(
5097        &self,
5098        node_id: WidgetId,
5099        origin: LayoutPoint,
5100        size: LayoutSize,
5101        content_size: LayoutSize,
5102        out: &mut HashMap<WidgetId, LayoutNodeGeometry>,
5103        record: bool,
5104    ) -> LayoutSize {
5105        let mut rect_origin = origin;
5106        let mut rect_size = size;
5107        let mut rect_content = content_size;
5108        let mut had_non_finite = false;
5109
5110        if !rect_origin.x.is_finite() {
5111            rect_origin.x = 0.0;
5112            had_non_finite = true;
5113        }
5114        if !rect_origin.y.is_finite() {
5115            rect_origin.y = 0.0;
5116            had_non_finite = true;
5117        }
5118        if !rect_size.width.is_finite() {
5119            rect_size.width = 0.0;
5120            had_non_finite = true;
5121        }
5122        if !rect_size.height.is_finite() {
5123            rect_size.height = 0.0;
5124            had_non_finite = true;
5125        }
5126        if !rect_content.width.is_finite() {
5127            rect_content.width = 0.0;
5128            had_non_finite = true;
5129        }
5130        if !rect_content.height.is_finite() {
5131            rect_content.height = 0.0;
5132            had_non_finite = true;
5133        }
5134
5135        if had_non_finite {
5136            diag::emit(
5137                diag::DiagCategory::Invariants,
5138                diag::DiagLevel::Error,
5139                diag::DiagEventKind::InvariantViolation {
5140                    kind: "non_finite_layout".into(),
5141                    node: Some(node_id.as_u128()),
5142                    details: format!(
5143                        "origin=({:.2},{:.2}) size=({:.2},{:.2}) content=({:.2},{:.2})",
5144                        origin.x,
5145                        origin.y,
5146                        size.width,
5147                        size.height,
5148                        content_size.width,
5149                        content_size.height
5150                    ),
5151                    dump_ref: None,
5152                },
5153            );
5154        }
5155
5156        if record {
5157            let rect = LayoutRect::new(
5158                rect_origin.x,
5159                rect_origin.y,
5160                rect_size.width,
5161                rect_size.height,
5162            );
5163            out.insert(
5164                node_id,
5165                LayoutNodeGeometry {
5166                    rect,
5167                    content_size: rect_content,
5168                },
5169            );
5170        }
5171        rect_size
5172    }
5173}