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