Skip to main content

ftui_layout/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Layout primitives and solvers.
4//!
5//! This crate provides layout components for terminal UIs:
6//!
7//! - [`Flex`] - 1D constraint-based layout (rows or columns)
8//! - [`Grid`] - 2D constraint-based layout with cell spanning
9//! - [`Constraint`] - Size constraints (Fixed, Percentage, Min, Max, Ratio, FitContent)
10//! - [`debug`] - Layout constraint debugging and introspection
11//! - [`cache`] - Layout result caching for memoization
12//!
13//! # Role in FrankenTUI
14//! `ftui-layout` is the geometry solver for widgets and screens. It converts
15//! constraints into concrete rectangles, with support for intrinsic sizing and
16//! caching to keep layout deterministic and fast.
17//!
18//! # How it fits in the system
19//! The runtime and widgets call into this crate to split a `Rect` into nested
20//! regions. Those regions are then passed to widgets or custom renderers, which
21//! ultimately draw into `ftui-render` frames.
22//!
23//! # Intrinsic Sizing
24//!
25//! The layout system supports content-aware sizing via [`LayoutSizeHint`] and
26//! [`Flex::split_with_measurer`]:
27//!
28//! ```ignore
29//! use ftui_layout::{Flex, Constraint, LayoutSizeHint};
30//!
31//! let flex = Flex::horizontal()
32//!     .constraints([Constraint::FitContent, Constraint::Fill]);
33//!
34//! let rects = flex.split_with_measurer(area, |idx, available| {
35//!     match idx {
36//!         0 => LayoutSizeHint { min: 5, preferred: 20, max: None },
37//!         _ => LayoutSizeHint::ZERO,
38//!     }
39//! });
40//! ```
41
42pub mod cache;
43pub mod debug;
44pub mod dep_graph;
45pub mod direction;
46pub mod egraph;
47pub mod grid;
48pub mod incremental;
49pub mod pane;
50#[cfg(test)]
51mod repro_max_constraint;
52#[cfg(test)]
53mod repro_space_around;
54pub mod responsive;
55pub mod responsive_layout;
56pub mod veb_tree;
57pub mod visibility;
58pub mod workspace;
59
60pub use cache::{
61    CoherenceCache, CoherenceId, LayoutCache, LayoutCacheKey, LayoutCacheStats, S3FifoLayoutCache,
62};
63pub use direction::{FlowDirection, LogicalAlignment, LogicalSides, mirror_rects_horizontal};
64pub use ftui_core::geometry::{Rect, Sides, Size};
65pub use grid::{Grid, GridArea, GridLayout};
66pub use pane::{
67    PANE_DEFAULT_MARGIN_CELLS, PANE_DEFAULT_PADDING_CELLS, PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS,
68    PANE_DRAG_RESIZE_DEFAULT_THRESHOLD, PANE_EDGE_GRIP_INSET_CELLS, PANE_MAGNETIC_FIELD_CELLS,
69    PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION, PANE_SEMANTIC_INPUT_TRACE_SCHEMA_VERSION,
70    PANE_SNAP_DEFAULT_HYSTERESIS_BPS, PANE_SNAP_DEFAULT_STEP_BPS, PANE_TREE_SCHEMA_VERSION,
71    PaneCancelReason, PaneConstraints, PaneCoordinateNormalizationError, PaneCoordinateNormalizer,
72    PaneCoordinateRoundingPolicy, PaneDockPreview, PaneDockZone, PaneDragBehaviorTuning,
73    PaneDragResizeEffect, PaneDragResizeMachine, PaneDragResizeMachineError,
74    PaneDragResizeNoopReason, PaneDragResizeState, PaneDragResizeTransition, PaneEdgeResizePlan,
75    PaneEdgeResizePlanError, PaneGroupTransformPlan, PaneId, PaneIdAllocator, PaneInertialThrow,
76    PaneInputCoordinate, PaneInteractionPolicyError, PaneInteractionTimeline,
77    PaneInteractionTimelineCheckpointDecision, PaneInteractionTimelineEntry,
78    PaneInteractionTimelineError, PaneInteractionTimelineReplayDiagnostics,
79    PaneInteractionTimelineRetentionDiagnostics, PaneInvariantCode, PaneInvariantIssue,
80    PaneInvariantReport, PaneInvariantSeverity, PaneLayout, PaneLayoutIntelligenceMode, PaneLeaf,
81    PaneModelError, PaneModifierSnapshot, PaneMotionVector, PaneNodeKind, PaneNodeRecord,
82    PaneNormalizedCoordinate, PaneOperation, PaneOperationError, PaneOperationFailure,
83    PaneOperationJournalEntry, PaneOperationJournalResult, PaneOperationKind, PaneOperationOutcome,
84    PanePlacement, PanePointerButton, PanePointerPosition, PanePrecisionMode, PanePrecisionPolicy,
85    PanePressureSnapProfile, PaneReflowMovePlan, PaneReflowPlanError, PaneRepairAction,
86    PaneRepairError, PaneRepairFailure, PaneRepairOutcome, PaneResizeDirection, PaneResizeGrip,
87    PaneResizeTarget, PaneScaleFactor, PaneSelectionState, PaneSemanticInputEvent,
88    PaneSemanticInputEventError, PaneSemanticInputEventKind, PaneSemanticInputTrace,
89    PaneSemanticInputTraceError, PaneSemanticInputTraceMetadata,
90    PaneSemanticReplayConformanceArtifact, PaneSemanticReplayDiffArtifact,
91    PaneSemanticReplayDiffKind, PaneSemanticReplayError, PaneSemanticReplayFixture,
92    PaneSemanticReplayOutcome, PaneSnapDecision, PaneSnapReason, PaneSnapTuning, PaneSplit,
93    PaneSplitRatio, PaneTransaction, PaneTransactionOutcome, PaneTree, PaneTreeSnapshot, SplitAxis,
94};
95pub use responsive::Responsive;
96pub use responsive_layout::{ResponsiveLayout, ResponsiveSplit};
97pub use smallvec;
98use smallvec::SmallVec;
99use std::cmp::min;
100pub use visibility::Visibility;
101pub use workspace::{
102    MigrationResult, WORKSPACE_SCHEMA_VERSION, WorkspaceMetadata, WorkspaceMigrationError,
103    WorkspaceSnapshot, WorkspaceSnapshotJsonError, WorkspaceValidationError,
104    canonicalize_workspace_snapshot, decode_workspace_snapshot_json, migrate_workspace,
105    needs_migration, to_canonical_workspace_snapshot_json,
106};
107
108/// Inline capacity for layout result vectors.
109///
110/// Most layouts use ≤8 constraints, so inlining avoids heap allocation in the
111/// common case. The `SmallVec` spills to the heap transparently when needed.
112const LAYOUT_INLINE_CAP: usize = 8;
113
114/// Stack-inlined vector of rectangles returned by layout split operations.
115pub type Rects = SmallVec<[Rect; LAYOUT_INLINE_CAP]>;
116
117/// Stack-inlined vector of sizes returned by the constraint solver.
118type Sizes = SmallVec<[u16; LAYOUT_INLINE_CAP]>;
119
120/// Stack-inlined vector of flex constraints.
121type Constraints = SmallVec<[Constraint; LAYOUT_INLINE_CAP]>;
122
123/// A constraint on the size of a layout area.
124#[derive(Debug, Clone, Copy, PartialEq)]
125pub enum Constraint {
126    /// An exact size in cells.
127    Fixed(u16),
128    /// A percentage of the total available size (0.0 to 100.0).
129    Percentage(f32),
130    /// A minimum size in cells.
131    Min(u16),
132    /// A maximum size in cells.
133    Max(u16),
134    /// A ratio of the total available space (numerator, denominator).
135    Ratio(u32, u32),
136    /// Fill remaining space (like Min(0) but semantically clearer).
137    Fill,
138    /// Size to fit content using widget's preferred size from [`LayoutSizeHint`].
139    ///
140    /// When used with [`Flex::split_with_measurer`], the measurer callback provides
141    /// the size hints. Defaults to zero size if no measurer is provided.
142    FitContent,
143    /// Fit content but clamp to explicit bounds.
144    ///
145    /// The allocated size will be between `min` and `max`, using the widget's
146    /// preferred size when within range.
147    FitContentBounded {
148        /// Minimum allocation regardless of content size.
149        min: u16,
150        /// Maximum allocation regardless of content size.
151        max: u16,
152    },
153    /// Use widget's minimum size (shrink-to-fit).
154    ///
155    /// Allocates only the minimum space the widget requires.
156    FitMin,
157}
158
159/// Size hint returned by measurer callbacks for intrinsic sizing.
160///
161/// This is a 1D projection of a widget's size constraints along the layout axis.
162/// Use with [`Flex::split_with_measurer`] for content-aware layouts.
163///
164/// # Example
165///
166/// ```
167/// use ftui_layout::LayoutSizeHint;
168///
169/// // A label that needs 5-20 cells, ideally 15
170/// let hint = LayoutSizeHint {
171///     min: 5,
172///     preferred: 15,
173///     max: Some(20),
174/// };
175///
176/// // Clamp allocation to hint bounds
177/// assert_eq!(hint.clamp(10), 10); // Within range
178/// assert_eq!(hint.clamp(3), 5);   // Below min
179/// assert_eq!(hint.clamp(30), 20); // Above max
180/// ```
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
182pub struct LayoutSizeHint {
183    /// Minimum size (widget clips below this).
184    pub min: u16,
185    /// Preferred size (ideal for content).
186    pub preferred: u16,
187    /// Maximum useful size (None = unbounded).
188    pub max: Option<u16>,
189}
190
191impl LayoutSizeHint {
192    /// Zero hint (no minimum, no preferred, unbounded).
193    pub const ZERO: Self = Self {
194        min: 0,
195        preferred: 0,
196        max: None,
197    };
198
199    /// Create an exact size hint (min = preferred = max).
200    #[inline]
201    pub const fn exact(size: u16) -> Self {
202        Self {
203            min: size,
204            preferred: size,
205            max: Some(size),
206        }
207    }
208
209    /// Create a hint with minimum and preferred size, unbounded max.
210    #[inline]
211    pub const fn at_least(min: u16, preferred: u16) -> Self {
212        Self {
213            min,
214            preferred,
215            max: None,
216        }
217    }
218
219    /// Clamp a value to this hint's bounds.
220    #[inline]
221    pub fn clamp(&self, value: u16) -> u16 {
222        let max = self.max.unwrap_or(u16::MAX);
223        value.min(max).max(self.min)
224    }
225}
226
227/// The direction to layout items.
228#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
229pub enum Direction {
230    /// Top to bottom.
231    #[default]
232    Vertical,
233    /// Left to right.
234    Horizontal,
235}
236
237/// Alignment of items within the layout.
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
239pub enum Alignment {
240    /// Align items to the start (left/top).
241    #[default]
242    Start,
243    /// Center items within available space.
244    Center,
245    /// Align items to the end (right/bottom).
246    End,
247    /// Distribute space evenly around each item.
248    SpaceAround,
249    /// Distribute space evenly between items (no outer space).
250    SpaceBetween,
251}
252
253/// How a layout container handles content that exceeds available space.
254///
255/// This enum models the CSS `overflow` property for terminal layouts.
256/// The actual clipping or scrolling is performed by the render layer;
257/// this value acts as a declarative hint attached to [`Flex`] or [`Grid`]
258/// so that widgets and the renderer know how to treat overflow regions.
259///
260/// # Migration rationale
261///
262/// Web components routinely set `overflow: hidden`, `overflow: scroll`, etc.
263/// Without an explicit model the migration code emitter cannot faithfully
264/// translate these semantics. This enum bridges that gap.
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
266pub enum OverflowBehavior {
267    /// Content that exceeds the container is clipped at the boundary.
268    /// This is the safe default for terminals where drawing outside
269    /// an allocated region corrupts neighbouring widgets.
270    #[default]
271    Clip,
272    /// Content is allowed to overflow visually (useful for popovers,
273    /// tooltips, and hit-test regions that extend beyond their container).
274    Visible,
275    /// Content is clipped but a scrollbar region is reserved.
276    /// The `max_content` field, when set, tells the scrollbar how
277    /// large the virtual content area is.
278    Scroll {
279        /// Size of the virtual content area in the overflow direction.
280        /// `None` means "determine from content measurement".
281        max_content: Option<u16>,
282    },
283    /// Items that don't fit are wrapped to the next row/column.
284    /// Only meaningful for [`Flex`] containers.
285    Wrap,
286}
287
288/// Responsive breakpoint tiers for terminal widths.
289///
290/// Ordered from smallest to largest. Each variant represents a width
291/// range determined by [`Breakpoints`].
292///
293/// | Breakpoint | Default Min Width | Typical Use               |
294/// |-----------|-------------------|---------------------------|
295/// | `Xs`      | < 60 cols         | Minimal / ultra-narrow    |
296/// | `Sm`      | 60–89 cols        | Compact layouts           |
297/// | `Md`      | 90–119 cols       | Standard terminal width   |
298/// | `Lg`      | 120–159 cols      | Wide terminals            |
299/// | `Xl`      | 160+ cols         | Ultra-wide / tiled        |
300#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
301pub enum Breakpoint {
302    /// Extra small: narrowest tier.
303    Xs,
304    /// Small: compact layouts.
305    Sm,
306    /// Medium: standard terminal width.
307    Md,
308    /// Large: wide terminals.
309    Lg,
310    /// Extra large: ultra-wide or tiled layouts.
311    Xl,
312}
313
314impl Breakpoint {
315    /// All breakpoints in ascending order.
316    pub const ALL: [Breakpoint; 5] = [
317        Breakpoint::Xs,
318        Breakpoint::Sm,
319        Breakpoint::Md,
320        Breakpoint::Lg,
321        Breakpoint::Xl,
322    ];
323
324    /// Ordinal index (0–4).
325    #[inline]
326    const fn index(self) -> u8 {
327        match self {
328            Breakpoint::Xs => 0,
329            Breakpoint::Sm => 1,
330            Breakpoint::Md => 2,
331            Breakpoint::Lg => 3,
332            Breakpoint::Xl => 4,
333        }
334    }
335
336    /// Short label for display.
337    #[must_use]
338    pub const fn label(self) -> &'static str {
339        match self {
340            Breakpoint::Xs => "xs",
341            Breakpoint::Sm => "sm",
342            Breakpoint::Md => "md",
343            Breakpoint::Lg => "lg",
344            Breakpoint::Xl => "xl",
345        }
346    }
347}
348
349impl std::fmt::Display for Breakpoint {
350    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
351        f.write_str(self.label())
352    }
353}
354
355/// Breakpoint thresholds for responsive layouts.
356///
357/// Each field is the minimum width (in terminal columns) for that breakpoint.
358/// Xs implicitly starts at width 0.
359#[derive(Debug, Clone, Copy, PartialEq, Eq)]
360pub struct Breakpoints {
361    /// Minimum width for Sm.
362    pub sm: u16,
363    /// Minimum width for Md.
364    pub md: u16,
365    /// Minimum width for Lg.
366    pub lg: u16,
367    /// Minimum width for Xl.
368    pub xl: u16,
369}
370
371impl Breakpoints {
372    /// Default breakpoints: 60 / 90 / 120 / 160 columns.
373    pub const DEFAULT: Self = Self {
374        sm: 60,
375        md: 90,
376        lg: 120,
377        xl: 160,
378    };
379
380    /// Create breakpoints with explicit thresholds.
381    ///
382    /// Values are sanitized to be monotonically non-decreasing.
383    pub const fn new(sm: u16, md: u16, lg: u16) -> Self {
384        let md = if md < sm { sm } else { md };
385        let lg = if lg < md { md } else { lg };
386        // Default xl to lg + 40 if not specified via new_with_xl.
387        let xl = match lg.checked_add(40) {
388            Some(v) => v,
389            None => u16::MAX,
390        };
391        Self { sm, md, lg, xl }
392    }
393
394    /// Create breakpoints with all four explicit thresholds.
395    ///
396    /// Values are sanitized to be monotonically non-decreasing.
397    pub const fn new_with_xl(sm: u16, md: u16, lg: u16, xl: u16) -> Self {
398        let md = if md < sm { sm } else { md };
399        let lg = if lg < md { md } else { lg };
400        let xl = if xl < lg { lg } else { xl };
401        Self { sm, md, lg, xl }
402    }
403
404    /// Classify a width into a breakpoint bucket.
405    #[inline]
406    pub const fn classify_width(self, width: u16) -> Breakpoint {
407        if width >= self.xl {
408            Breakpoint::Xl
409        } else if width >= self.lg {
410            Breakpoint::Lg
411        } else if width >= self.md {
412            Breakpoint::Md
413        } else if width >= self.sm {
414            Breakpoint::Sm
415        } else {
416            Breakpoint::Xs
417        }
418    }
419
420    /// Classify a Size (uses width).
421    #[inline]
422    pub const fn classify_size(self, size: Size) -> Breakpoint {
423        self.classify_width(size.width)
424    }
425
426    /// Check if width is at least a given breakpoint.
427    #[inline]
428    pub const fn at_least(self, width: u16, min: Breakpoint) -> bool {
429        self.classify_width(width).index() >= min.index()
430    }
431
432    /// Check if width is between two breakpoints (inclusive).
433    #[inline]
434    pub const fn between(self, width: u16, min: Breakpoint, max: Breakpoint) -> bool {
435        let idx = self.classify_width(width).index();
436        idx >= min.index() && idx <= max.index()
437    }
438
439    /// Get the minimum width threshold for a given breakpoint.
440    #[must_use]
441    pub const fn threshold(self, bp: Breakpoint) -> u16 {
442        match bp {
443            Breakpoint::Xs => 0,
444            Breakpoint::Sm => self.sm,
445            Breakpoint::Md => self.md,
446            Breakpoint::Lg => self.lg,
447            Breakpoint::Xl => self.xl,
448        }
449    }
450
451    /// Get all thresholds as `(Breakpoint, min_width)` pairs.
452    #[must_use]
453    pub const fn thresholds(self) -> [(Breakpoint, u16); 5] {
454        [
455            (Breakpoint::Xs, 0),
456            (Breakpoint::Sm, self.sm),
457            (Breakpoint::Md, self.md),
458            (Breakpoint::Lg, self.lg),
459            (Breakpoint::Xl, self.xl),
460        ]
461    }
462}
463
464/// Size negotiation hints for layout.
465#[derive(Debug, Clone, Copy, Default)]
466pub struct Measurement {
467    /// Minimum width in columns.
468    pub min_width: u16,
469    /// Minimum height in rows.
470    pub min_height: u16,
471    /// Maximum width (None = unbounded).
472    pub max_width: Option<u16>,
473    /// Maximum height (None = unbounded).
474    pub max_height: Option<u16>,
475}
476
477impl Measurement {
478    /// Create a fixed-size measurement (min == max).
479    #[must_use]
480    pub fn fixed(width: u16, height: u16) -> Self {
481        Self {
482            min_width: width,
483            min_height: height,
484            max_width: Some(width),
485            max_height: Some(height),
486        }
487    }
488
489    /// Create a flexible measurement with minimum size and no maximum.
490    #[must_use]
491    pub fn flexible(min_width: u16, min_height: u16) -> Self {
492        Self {
493            min_width,
494            min_height,
495            max_width: None,
496            max_height: None,
497        }
498    }
499}
500
501/// A flexible layout container.
502#[derive(Debug, Clone, Default)]
503pub struct Flex {
504    direction: Direction,
505    constraints: Constraints,
506    margin: Sides,
507    gap: u16,
508    alignment: Alignment,
509    flow_direction: direction::FlowDirection,
510    overflow: OverflowBehavior,
511}
512
513impl Flex {
514    /// Create a new vertical flex layout.
515    #[must_use]
516    pub fn vertical() -> Self {
517        Self {
518            direction: Direction::Vertical,
519            ..Default::default()
520        }
521    }
522
523    /// Create a new horizontal flex layout.
524    #[must_use]
525    pub fn horizontal() -> Self {
526        Self {
527            direction: Direction::Horizontal,
528            ..Default::default()
529        }
530    }
531
532    /// Set the layout direction.
533    #[must_use]
534    pub fn direction(mut self, direction: Direction) -> Self {
535        self.direction = direction;
536        self
537    }
538
539    /// Set the constraints.
540    #[must_use]
541    pub fn constraints(mut self, constraints: impl IntoIterator<Item = Constraint>) -> Self {
542        self.constraints = constraints.into_iter().collect();
543        self
544    }
545
546    /// Set the margin.
547    #[must_use]
548    pub fn margin(mut self, margin: Sides) -> Self {
549        self.margin = margin;
550        self
551    }
552
553    /// Set the gap between items.
554    #[must_use]
555    pub fn gap(mut self, gap: u16) -> Self {
556        self.gap = gap;
557        self
558    }
559
560    /// Set the alignment.
561    #[must_use]
562    pub fn alignment(mut self, alignment: Alignment) -> Self {
563        self.alignment = alignment;
564        self
565    }
566
567    /// Set the horizontal flow direction (LTR or RTL).
568    ///
569    /// When set to [`FlowDirection::Rtl`],
570    /// horizontal layouts are mirrored: the first child appears at the right
571    /// edge instead of the left. Vertical layouts are not affected.
572    #[must_use]
573    pub fn flow_direction(mut self, flow: direction::FlowDirection) -> Self {
574        self.flow_direction = flow;
575        self
576    }
577
578    /// Set the overflow behavior for this container.
579    #[must_use]
580    pub fn overflow(mut self, overflow: OverflowBehavior) -> Self {
581        self.overflow = overflow;
582        self
583    }
584
585    /// Get the current overflow behavior.
586    #[must_use]
587    pub fn overflow_behavior(&self) -> OverflowBehavior {
588        self.overflow
589    }
590
591    /// Number of constraints (and thus output rects from [`split`](Self::split)).
592    #[must_use]
593    pub fn constraint_count(&self) -> usize {
594        self.constraints.len()
595    }
596
597    /// Split the given area into smaller rectangles according to the configuration.
598    pub fn split(&self, area: Rect) -> Rects {
599        // Apply margin
600        let inner = area.inner(self.margin);
601        if inner.is_empty() {
602            return self.constraints.iter().map(|_| Rect::default()).collect();
603        }
604
605        let total_size = match self.direction {
606            Direction::Horizontal => inner.width,
607            Direction::Vertical => inner.height,
608        };
609
610        let count = self.constraints.len();
611        if count == 0 {
612            return Rects::new();
613        }
614
615        // Calculate gaps safely
616        let gap_count = count - 1;
617        let total_gap = (gap_count as u64 * self.gap as u64).min(u16::MAX as u64) as u16;
618        let available_size = total_size.saturating_sub(total_gap);
619
620        // Solve constraints to get sizes
621        let sizes = solve_constraints(&self.constraints, available_size);
622
623        // Convert sizes to rects
624        let mut rects = self.sizes_to_rects(inner, &sizes);
625
626        // Mirror horizontally for RTL horizontal layouts.
627        if self.flow_direction.is_rtl() && self.direction == Direction::Horizontal {
628            direction::mirror_rects_horizontal(&mut rects, inner);
629        }
630
631        rects
632    }
633
634    fn sizes_to_rects(&self, area: Rect, sizes: &[u16]) -> Rects {
635        let mut rects = SmallVec::with_capacity(sizes.len());
636        if sizes.is_empty() {
637            return rects;
638        }
639
640        let total_items_size: u16 = sizes.iter().fold(0u16, |acc, &s| acc.saturating_add(s));
641        let total_available = match self.direction {
642            Direction::Horizontal => area.width,
643            Direction::Vertical => area.height,
644        };
645
646        // Determine offsets strategy
647        let (start_shift, use_formula) = match self.alignment {
648            Alignment::Start => (0, None),
649            Alignment::End => {
650                let gap_space = (sizes.len().saturating_sub(1) as u64 * self.gap as u64)
651                    .min(u16::MAX as u64) as u16;
652                let used = total_items_size.saturating_add(gap_space);
653                (total_available.saturating_sub(used), None)
654            }
655            Alignment::Center => {
656                let gap_space = (sizes.len().saturating_sub(1) as u64 * self.gap as u64)
657                    .min(u16::MAX as u64) as u16;
658                let used = total_items_size.saturating_add(gap_space);
659                (total_available.saturating_sub(used) / 2, None)
660            }
661            Alignment::SpaceBetween => {
662                let gap_space = (sizes.len().saturating_sub(1) as u64 * self.gap as u64)
663                    .min(u16::MAX as u64) as u16;
664                let used = total_items_size.saturating_add(gap_space);
665                let leftover = total_available.saturating_sub(used);
666                let slots = sizes.len().saturating_sub(1);
667                if slots > 0 {
668                    (0, Some((leftover, slots, 0))) // 0 = Between
669                } else {
670                    (0, None)
671                }
672            }
673            Alignment::SpaceAround => {
674                let gap_space = (sizes.len().saturating_sub(1) as u64 * self.gap as u64)
675                    .min(u16::MAX as u64) as u16;
676                let used = total_items_size.saturating_add(gap_space);
677                let leftover = total_available.saturating_sub(used);
678                let slots = sizes.len() * 2;
679                if slots > 0 {
680                    (0, Some((leftover, slots, 1))) // 1 = Around
681                } else {
682                    (0, None)
683                }
684            }
685        };
686
687        let mut accumulated_size = 0;
688
689        for (i, &size) in sizes.iter().enumerate() {
690            let explicit_gap_so_far = if i > 0 {
691                (i as u64 * self.gap as u64).min(u16::MAX as u64) as u16
692            } else {
693                0
694            };
695
696            let gap_offset = if let Some((leftover, slots, mode)) = use_formula {
697                if mode == 0 {
698                    // Between: (Leftover * i) / slots + explicit gaps
699                    if i == 0 {
700                        0
701                    } else {
702                        explicit_gap_so_far
703                            .saturating_add((leftover as u64 * i as u64 / slots as u64) as u16)
704                    }
705                } else {
706                    // Around: nearest-integer rounding + explicit gaps
707                    let numerator = leftover as u64 * (2 * i as u64 + 1);
708                    let denominator = slots as u64;
709                    let raw = (numerator + (denominator / 2)) / denominator;
710                    explicit_gap_so_far.saturating_add(raw.min(u64::from(u16::MAX)) as u16)
711                }
712            } else {
713                // Fixed gap
714                explicit_gap_so_far
715            };
716
717            let pos = match self.direction {
718                Direction::Horizontal => area
719                    .x
720                    .saturating_add(start_shift)
721                    .saturating_add(accumulated_size)
722                    .saturating_add(gap_offset),
723                Direction::Vertical => area
724                    .y
725                    .saturating_add(start_shift)
726                    .saturating_add(accumulated_size)
727                    .saturating_add(gap_offset),
728            };
729
730            let rect = match self.direction {
731                Direction::Horizontal => Rect {
732                    x: pos,
733                    y: area.y,
734                    width: size.min(area.right().saturating_sub(pos)),
735                    height: area.height,
736                },
737                Direction::Vertical => Rect {
738                    x: area.x,
739                    y: pos,
740                    width: area.width,
741                    height: size.min(area.bottom().saturating_sub(pos)),
742                },
743            };
744            rects.push(rect);
745            accumulated_size = accumulated_size.saturating_add(size);
746        }
747
748        rects
749    }
750
751    /// Split area using intrinsic sizing from a measurer callback.
752    ///
753    /// This method enables content-aware layout with [`Constraint::FitContent`],
754    /// [`Constraint::FitContentBounded`], and [`Constraint::FitMin`].
755    ///
756    /// # Arguments
757    ///
758    /// - `area`: Available rectangle
759    /// - `measurer`: Callback that returns [`LayoutSizeHint`] for item at index
760    ///
761    /// # Example
762    ///
763    /// ```ignore
764    /// let flex = Flex::horizontal()
765    ///     .constraints([Constraint::FitContent, Constraint::Fill]);
766    ///
767    /// let rects = flex.split_with_measurer(area, |idx, available| {
768    ///     match idx {
769    ///         0 => LayoutSizeHint { min: 5, preferred: 20, max: None },
770    ///         _ => LayoutSizeHint::ZERO,
771    ///     }
772    /// });
773    /// ```
774    pub fn split_with_measurer<F>(&self, area: Rect, measurer: F) -> Rects
775    where
776        F: Fn(usize, u16) -> LayoutSizeHint,
777    {
778        // Apply margin
779        let inner = area.inner(self.margin);
780        if inner.is_empty() {
781            return self.constraints.iter().map(|_| Rect::default()).collect();
782        }
783
784        let total_size = match self.direction {
785            Direction::Horizontal => inner.width,
786            Direction::Vertical => inner.height,
787        };
788
789        let count = self.constraints.len();
790        if count == 0 {
791            return Rects::new();
792        }
793
794        // Calculate gaps safely
795        let gap_count = count - 1;
796        let total_gap = (gap_count as u64 * self.gap as u64).min(u16::MAX as u64) as u16;
797        let available_size = total_size.saturating_sub(total_gap);
798
799        // Solve constraints with hints from measurer
800        let sizes =
801            solve_constraints_with_hints(&self.constraints, available_size, &measurer, None);
802
803        // Convert sizes to rects
804        let mut rects = self.sizes_to_rects(inner, &sizes);
805
806        // Mirror horizontally for RTL horizontal layouts.
807        if self.flow_direction.is_rtl() && self.direction == Direction::Horizontal {
808            direction::mirror_rects_horizontal(&mut rects, inner);
809        }
810
811        rects
812    }
813    /// Split area using intrinsic sizing and temporal coherence.
814    ///
815    /// Combines the content-aware sizing of [`split_with_measurer`](Self::split_with_measurer)
816    /// with stability across small geometry perturbations.
817    pub fn split_with_measurer_stably<F>(
818        &self,
819        area: Rect,
820        measurer: F,
821        cache: &mut CoherenceCache,
822    ) -> Rects
823    where
824        F: Fn(usize, u16) -> LayoutSizeHint,
825    {
826        // Apply margin
827        let inner = area.inner(self.margin);
828        if inner.is_empty() {
829            return self.constraints.iter().map(|_| Rect::default()).collect();
830        }
831
832        let total_size = match self.direction {
833            Direction::Horizontal => inner.width,
834            Direction::Vertical => inner.height,
835        };
836
837        let count = self.constraints.len();
838        if count == 0 {
839            return Rects::new();
840        }
841
842        // Calculate gaps safely
843        let gap_count = count - 1;
844        let total_gap = (gap_count as u64 * self.gap as u64).min(u16::MAX as u64) as u16;
845        let available_size = total_size.saturating_sub(total_gap);
846
847        // Solve constraints with hints and coherence
848        let id = CoherenceId::new(&self.constraints, self.direction);
849        let sizes = solve_constraints_with_hints(
850            &self.constraints,
851            available_size,
852            &measurer,
853            Some((cache, id)),
854        );
855
856        // Convert sizes to rects
857        let mut rects = self.sizes_to_rects(inner, &sizes);
858
859        // Mirror horizontally for RTL horizontal layouts.
860        if self.flow_direction.is_rtl() && self.direction == Direction::Horizontal {
861            direction::mirror_rects_horizontal(&mut rects, inner);
862        }
863
864        rects
865    }
866}
867
868/// Solve 1D constraints to determine sizes.
869///
870/// This shared logic is used by both Flex and Grid layouts.
871/// For intrinsic sizing support, use [`solve_constraints_with_hints`].
872pub(crate) fn solve_constraints(constraints: &[Constraint], available_size: u16) -> Sizes {
873    // Use the with_hints version with a no-op measurer and no coherence
874    solve_constraints_with_hints(
875        constraints,
876        available_size,
877        &|_, _| LayoutSizeHint::ZERO,
878        None,
879    )
880}
881
882/// Solve 1D constraints with intrinsic sizing support.
883///
884/// The measurer callback provides size hints for FitContent, FitContentBounded, and FitMin
885/// constraints. It receives the constraint index and remaining available space.
886pub(crate) fn solve_constraints_with_hints<F>(
887    constraints: &[Constraint],
888    available_size: u16,
889    measurer: &F,
890    mut coherence: Option<(&mut CoherenceCache, CoherenceId)>,
891) -> Sizes
892where
893    F: Fn(usize, u16) -> LayoutSizeHint,
894{
895    const WEIGHT_SCALE: u64 = 10_000;
896
897    let mut sizes: Sizes = smallvec::smallvec![0u16; constraints.len()];
898    let mut remaining = available_size;
899    let mut grow_indices: SmallVec<[usize; LAYOUT_INLINE_CAP]> = SmallVec::new();
900
901    let grow_weight = |constraint: Constraint| -> u64 {
902        match constraint {
903            Constraint::Min(_) | Constraint::Max(_) | Constraint::Fill => WEIGHT_SCALE,
904            _ => 0,
905        }
906    };
907
908    // Pass 1: Allocate hard minimums (Fixed, Min, FitMin, FitContentBounded min).
909    // These constraints are non-negotiable and take precedence over relative/soft constraints.
910    for (i, &constraint) in constraints.iter().enumerate() {
911        match constraint {
912            Constraint::Fixed(size) => {
913                let size = min(size, remaining);
914                sizes[i] = size;
915                remaining = remaining.saturating_sub(size);
916            }
917            Constraint::Min(min_size) => {
918                let size = min(min_size, remaining);
919                sizes[i] = size;
920                remaining = remaining.saturating_sub(size);
921                // Min will also be added to grow_indices in Pass 2
922            }
923            Constraint::FitMin => {
924                let hint = measurer(i, remaining);
925                let size = min(hint.min, remaining);
926                sizes[i] = size;
927                remaining = remaining.saturating_sub(size);
928            }
929            Constraint::FitContent => {
930                let hint = measurer(i, remaining);
931                let size = min(hint.min, remaining);
932                sizes[i] = size;
933                remaining = remaining.saturating_sub(size);
934            }
935            Constraint::FitContentBounded { min: min_bound, .. } => {
936                // Reserve the minimum bound immediately
937                let size = min(min_bound, remaining);
938                sizes[i] = size;
939                remaining = remaining.saturating_sub(size);
940            }
941            _ => {} // Soft constraints handled in Pass 2
942        }
943    }
944
945    // Pass 2: Allocate soft/relative constraints (Percentage, Ratio, FitContent preferred).
946    // These fill remaining space after hard minimums.
947    for (i, &constraint) in constraints.iter().enumerate() {
948        match constraint {
949            Constraint::Percentage(p) => {
950                let target = (available_size as f32 * p / 100.0)
951                    .round()
952                    .min(u16::MAX as f32) as u16;
953                let needed = target.saturating_sub(sizes[i]);
954                let alloc = min(needed, remaining);
955                sizes[i] = sizes[i].saturating_add(alloc);
956                remaining = remaining.saturating_sub(alloc);
957            }
958            Constraint::Ratio(n, d) => {
959                let target = if d == 0 {
960                    0
961                } else {
962                    (u64::from(available_size) * u64::from(n) / u64::from(d)).min(u16::MAX as u64)
963                        as u16
964                };
965                let needed = target.saturating_sub(sizes[i]);
966                let alloc = min(needed, remaining);
967                sizes[i] = sizes[i].saturating_add(alloc);
968                remaining = remaining.saturating_sub(alloc);
969            }
970            Constraint::FitContent => {
971                let hint = measurer(i, remaining);
972                let preferred = hint
973                    .preferred
974                    .max(sizes[i])
975                    .min(hint.max.unwrap_or(u16::MAX));
976                let needed = preferred.saturating_sub(sizes[i]);
977                let alloc = min(needed, remaining);
978                sizes[i] = sizes[i].saturating_add(alloc);
979                remaining = remaining.saturating_sub(alloc);
980            }
981            Constraint::FitContentBounded { max: max_bound, .. } => {
982                let hint = measurer(i, remaining);
983                let preferred = hint.preferred.max(sizes[i]).min(max_bound);
984                let needed = preferred.saturating_sub(sizes[i]);
985                let alloc = min(needed, remaining);
986                sizes[i] = sizes[i].saturating_add(alloc);
987                remaining = remaining.saturating_sub(alloc);
988            }
989            Constraint::Min(_) => {
990                grow_indices.push(i);
991            }
992            Constraint::Max(_) => {
993                grow_indices.push(i);
994            }
995            Constraint::Fill => {
996                grow_indices.push(i);
997            }
998            _ => {} // Hard constraints handled in Pass 1
999        }
1000    }
1001
1002    // 3. Iterative distribution to flexible constraints
1003    loop {
1004        if remaining == 0 || grow_indices.is_empty() {
1005            break;
1006        }
1007
1008        let mut total_weight = 0u128;
1009        for &i in &grow_indices {
1010            let weight = grow_weight(constraints[i]);
1011            if weight > 0 {
1012                total_weight = total_weight.saturating_add(u128::from(weight));
1013            }
1014        }
1015
1016        if total_weight == 0 {
1017            break;
1018        }
1019
1020        let space_to_distribute = remaining;
1021        let mut shares: SmallVec<[u16; LAYOUT_INLINE_CAP]> =
1022            smallvec::smallvec![0u16; constraints.len()];
1023
1024        // Calculate float targets for fair distribution (Largest Remainder Method)
1025        let targets: Vec<f64> = grow_indices
1026            .iter()
1027            .map(|&i| {
1028                let weight = grow_weight(constraints[i]);
1029                (space_to_distribute as f64 * weight as f64) / total_weight as f64
1030            })
1031            .collect();
1032
1033        // Get previous allocation if coherence is enabled
1034        let prev_alloc = coherence
1035            .as_ref()
1036            .and_then(|(cache, id)| cache.get(id))
1037            .map(|full_prev| {
1038                // Extract only the shares for the current grow_indices
1039                grow_indices
1040                    .iter()
1041                    .map(|&i| full_prev.get(i).copied().unwrap_or(0))
1042                    .collect()
1043            });
1044
1045        // Distribute space with stable rounding to minimize jitter and error
1046        let distributed = round_layout_stable(&targets, space_to_distribute, prev_alloc);
1047
1048        for (k, &i) in grow_indices.iter().enumerate() {
1049            shares[i] = distributed[k];
1050        }
1051
1052        // Check for Max constraint violations
1053        let mut violations = Vec::new();
1054        for &i in &grow_indices {
1055            if let Constraint::Max(max_val) = constraints[i]
1056                && sizes[i].saturating_add(shares[i]) > max_val
1057            {
1058                violations.push(i);
1059            }
1060        }
1061
1062        if violations.is_empty() {
1063            // No violations, commit shares and exit
1064            for &i in &grow_indices {
1065                sizes[i] = sizes[i].saturating_add(shares[i]);
1066            }
1067            if let Some((cache, id)) = coherence.as_mut() {
1068                // Store full-sized vector mapping constraint index -> share.
1069                // We must inflate the dense `distributed` vector to the sparse constraint space.
1070                if distributed.len() == targets.len() {
1071                    let mut full_shares: Sizes = smallvec::smallvec![0u16; constraints.len()];
1072                    for (k, &i) in grow_indices.iter().enumerate() {
1073                        full_shares[i] = distributed[k];
1074                    }
1075                    cache.store(*id, full_shares);
1076                }
1077            }
1078            break;
1079        }
1080
1081        // Handle violations: clamp to Max and remove from grow pool
1082        for i in violations {
1083            if let Constraint::Max(max_val) = constraints[i] {
1084                // Calculate how much space this item *actually* consumes from remaining
1085                // which is (max - current_size)
1086                let consumed = max_val.saturating_sub(sizes[i]);
1087                sizes[i] = max_val;
1088                remaining = remaining.saturating_sub(consumed);
1089
1090                // Remove from grow indices
1091                if let Some(pos) = grow_indices.iter().position(|&x| x == i) {
1092                    grow_indices.remove(pos);
1093                }
1094            }
1095        }
1096    }
1097
1098    sizes
1099}
1100
1101// ---------------------------------------------------------------------------
1102// Stable Layout Rounding: Min-Displacement with Temporal Coherence
1103// ---------------------------------------------------------------------------
1104
1105/// Previous frame's allocation, used as tie-breaker for temporal stability.
1106///
1107/// Pass `None` for the first frame or when no history is available.
1108/// When provided, the rounding algorithm prefers allocations that
1109/// minimize change from the previous frame, reducing visual jitter.
1110pub type PreviousAllocation = Option<Sizes>;
1111
1112/// Round real-valued layout targets to integer cells with exact sum conservation.
1113///
1114/// # Mathematical Model
1115///
1116/// Given real-valued targets `r_i` (from the constraint solver) and a required
1117/// integer total, find integer allocations `x_i` that:
1118///
1119/// ```text
1120/// minimize   Σ_i |x_i − r_i|  +  μ · Σ_i |x_i − x_i_prev|
1121/// subject to Σ_i x_i = total
1122///            x_i ≥ 0
1123/// ```
1124///
1125/// where `x_i_prev` is the previous frame's allocation and `μ` is the temporal
1126/// stability weight (default 0.1).
1127///
1128/// # Algorithm: Largest Remainder with Temporal Tie-Breaking
1129///
1130/// This uses a variant of the Largest Remainder Method (Hamilton's method),
1131/// which provides optimal bounded displacement (|x_i − r_i| < 1 for all i):
1132///
1133/// 1. **Floor phase**: Set `x_i = floor(r_i)` for each element.
1134/// 2. **Deficit**: Compute `D = total − Σ floor(r_i)` extra cells to distribute.
1135/// 3. **Priority sort**: Rank elements by remainder `r_i − floor(r_i)` (descending).
1136///    Break ties using a composite key:
1137///    a. Prefer elements where `x_i_prev = ceil(r_i)` (temporal stability).
1138///    b. Prefer elements with smaller index (determinism).
1139/// 4. **Distribute**: Award one extra cell to each of the top `D` elements.
1140///
1141/// # Properties
1142///
1143/// 1. **Sum conservation**: `Σ x_i = total` exactly (proven by construction).
1144/// 2. **Bounded displacement**: `|x_i − r_i| < 1` for all `i` (since each x_i
1145///    is either `floor(r_i)` or `ceil(r_i)`).
1146/// 3. **Deterministic**: Same inputs → identical outputs (temporal tie-break +
1147///    index tie-break provide total ordering).
1148/// 4. **Temporal coherence**: When targets change slightly, allocations tend to
1149///    stay the same (preferring the previous frame's rounding direction).
1150/// 5. **Optimal displacement**: Among all integer allocations summing to `total`
1151///    with `floor(r_i) ≤ x_i ≤ ceil(r_i)`, the Largest Remainder Method
1152///    minimizes total absolute displacement.
1153///
1154/// # Failure Modes
1155///
1156/// - **All-zero targets**: Returns all zeros. Harmless (empty layout).
1157/// - **Negative deficit**: Can occur if targets sum to less than `total` after
1158///   flooring. The algorithm handles this via the clamp in step 2.
1159/// - **Very large N**: O(N log N) due to sorting. Acceptable for typical
1160///   layout counts (< 100 items).
1161///
1162/// # Example
1163///
1164/// ```
1165/// use ftui_layout::round_layout_stable;
1166///
1167/// // Targets: [10.4, 20.6, 9.0] must sum to 40
1168/// let result = round_layout_stable(&[10.4, 20.6, 9.0], 40, None);
1169/// assert_eq!(result.iter().sum::<u16>(), 40);
1170/// // 10.4 → 10, 20.6 → 21, 9.0 → 9 = 40 ✓
1171/// assert_eq!(result.as_slice(), &[10, 21, 9]);
1172/// ```
1173pub fn round_layout_stable(targets: &[f64], total: u16, prev: PreviousAllocation) -> Sizes {
1174    let n = targets.len();
1175    if n == 0 {
1176        return Sizes::new();
1177    }
1178
1179    // Step 1: Floor all targets
1180    let floors: Sizes = targets
1181        .iter()
1182        .map(|&r| (r.max(0.0).floor() as u64).min(u16::MAX as u64) as u16)
1183        .collect();
1184
1185    let floor_sum: u64 = floors.iter().map(|&x| u64::from(x)).sum();
1186    let total_u64 = u64::from(total);
1187
1188    // Step 2: Compute deficit (extra cells to distribute)
1189    if floor_sum > total_u64 {
1190        return redistribute_overflow(&floors, total);
1191    }
1192
1193    let deficit = (total_u64 - floor_sum) as u16;
1194
1195    if deficit == 0 {
1196        // Exact fit — no rounding needed
1197        return floors;
1198    }
1199
1200    // Step 3: Compute remainders and build priority list
1201    let mut priority: SmallVec<[(usize, f64, bool); LAYOUT_INLINE_CAP]> = targets
1202        .iter()
1203        .enumerate()
1204        .map(|(i, &r)| {
1205            let remainder = r - (floors[i] as f64);
1206            let ceil_val = floors[i].saturating_add(1);
1207            // Temporal stability: did previous allocation use ceil?
1208            let prev_used_ceil = prev
1209                .as_ref()
1210                .is_some_and(|p| p.get(i).copied() == Some(ceil_val));
1211            (i, remainder, prev_used_ceil)
1212        })
1213        .collect();
1214
1215    // Sort by: remainder descending, then temporal preference, then index ascending
1216    priority.sort_by(|a, b| {
1217        b.1.partial_cmp(&a.1)
1218            .unwrap_or(std::cmp::Ordering::Equal)
1219            .then_with(|| {
1220                // Prefer items where prev used ceil (true > false)
1221                b.2.cmp(&a.2)
1222            })
1223            .then_with(|| {
1224                // Deterministic tie-break: smaller index first
1225                a.0.cmp(&b.0)
1226            })
1227    });
1228
1229    // Step 4: Distribute deficit
1230    let mut result = floors;
1231    let mut remaining_deficit = deficit;
1232
1233    // We award at most one extra cell per item to maintain the invariant
1234    // |x_i - r_i| < 1 (bounded displacement). Hamilton's method only
1235    // handles D < n. If D >= n, it implies sum(floors) + n <= total,
1236    // which means sum(targets) was significantly less than total.
1237    // In this case, we distribute the surplus as evenly as possible.
1238    if remaining_deficit as usize >= n {
1239        let per_item = remaining_deficit / (n as u16);
1240        for val in result.iter_mut() {
1241            *val = val.saturating_add(per_item);
1242        }
1243        remaining_deficit %= n as u16;
1244    }
1245
1246    if remaining_deficit > 0 {
1247        for &(i, _, _) in priority.iter().take(remaining_deficit as usize) {
1248            result[i] = result[i].saturating_add(1);
1249        }
1250    }
1251
1252    result
1253}
1254
1255/// Handle the edge case where floored values exceed total.
1256///
1257/// This can happen with very small totals and many items. We greedily
1258/// reduce the largest items by 1 until the sum matches.
1259fn redistribute_overflow(floors: &[u16], total: u16) -> Sizes {
1260    let mut result: Sizes = floors.iter().copied().collect();
1261    let current_sum: u64 = result.iter().map(|&x| u64::from(x)).sum();
1262    let total_u64 = u64::from(total);
1263    let n = result.len();
1264
1265    if current_sum <= total_u64 || n == 0 {
1266        return result;
1267    }
1268
1269    let mut overflow = current_sum - total_u64;
1270
1271    while overflow > 0 {
1272        let &max_val = result.iter().max().unwrap_or(&0);
1273        if max_val == 0 {
1274            // Cannot reduce further, even though overflow persists.
1275            // This happens if total < 0 (impossible for u16) or some other
1276            // degenerate state. Force sum to 0.
1277            for val in result.iter_mut() {
1278                *val = 0;
1279            }
1280            break;
1281        }
1282
1283        let count_max = result.iter().filter(|&&v| v == max_val).count() as u64;
1284        let &next_max = result.iter().filter(|&&v| v < max_val).max().unwrap_or(&0);
1285
1286        let delta = (max_val - next_max) as u64;
1287        let required_per_item = overflow.div_ceil(count_max);
1288        let reduce_per_item = delta.min(required_per_item).max(1) as u16;
1289
1290        let mut reduced_any = false;
1291        for val in result.iter_mut() {
1292            if *val == max_val {
1293                let amount = u64::from(*val)
1294                    .min(u64::from(reduce_per_item))
1295                    .min(overflow) as u16;
1296                if amount > 0 {
1297                    *val -= amount;
1298                    overflow -= u64::from(amount);
1299                    reduced_any = true;
1300                }
1301                if overflow == 0 {
1302                    break;
1303                }
1304            }
1305        }
1306
1307        if !reduced_any {
1308            // Hard fallback: should not happen if max_val > 0.
1309            for val in result.iter_mut() {
1310                if overflow == 0 {
1311                    break;
1312                }
1313                if *val > 0 {
1314                    *val -= 1;
1315                    overflow -= 1;
1316                }
1317            }
1318            break;
1319        }
1320    }
1321
1322    result
1323}
1324
1325#[cfg(test)]
1326mod tests {
1327    use super::*;
1328
1329    #[test]
1330    fn fixed_split() {
1331        let flex = Flex::horizontal().constraints([Constraint::Fixed(10), Constraint::Fixed(20)]);
1332        let rects = flex.split(Rect::new(0, 0, 100, 10));
1333        assert_eq!(rects.len(), 2);
1334        assert_eq!(rects[0], Rect::new(0, 0, 10, 10));
1335        assert_eq!(rects[1], Rect::new(10, 0, 20, 10)); // Gap is 0 by default
1336    }
1337
1338    #[test]
1339    fn percentage_split() {
1340        let flex = Flex::horizontal()
1341            .constraints([Constraint::Percentage(50.0), Constraint::Percentage(50.0)]);
1342        let rects = flex.split(Rect::new(0, 0, 100, 10));
1343        assert_eq!(rects[0].width, 50);
1344        assert_eq!(rects[1].width, 50);
1345    }
1346
1347    #[test]
1348    fn gap_handling() {
1349        let flex = Flex::horizontal()
1350            .gap(5)
1351            .constraints([Constraint::Fixed(10), Constraint::Fixed(10)]);
1352        let rects = flex.split(Rect::new(0, 0, 100, 10));
1353        // Item 1: 0..10
1354        // Gap: 10..15
1355        // Item 2: 15..25
1356        assert_eq!(rects[0], Rect::new(0, 0, 10, 10));
1357        assert_eq!(rects[1], Rect::new(15, 0, 10, 10));
1358    }
1359
1360    #[test]
1361    fn mixed_constraints() {
1362        let flex = Flex::horizontal().constraints([
1363            Constraint::Fixed(10),
1364            Constraint::Min(10), // Should take half of remaining (90/2 = 45) + base 10? No, logic is simplified.
1365            Constraint::Percentage(10.0), // 10% of 100 = 10
1366        ]);
1367
1368        // Available: 100
1369        // Fixed(10) -> 10. Rem: 90.
1370        // Percent(10%) -> 10. Rem: 80.
1371        // Min(10) -> 10. Rem: 70.
1372        // Grow candidates: Min(10).
1373        // Distribute 70 to Min(10). Size = 10 + 70 = 80.
1374
1375        let rects = flex.split(Rect::new(0, 0, 100, 1));
1376        assert_eq!(rects[0].width, 10); // Fixed
1377        assert_eq!(rects[2].width, 10); // Percent
1378        assert_eq!(rects[1].width, 80); // Min + Remainder
1379    }
1380
1381    #[test]
1382    fn measurement_fixed_constraints() {
1383        let fixed = Measurement::fixed(5, 7);
1384        assert_eq!(fixed.min_width, 5);
1385        assert_eq!(fixed.min_height, 7);
1386        assert_eq!(fixed.max_width, Some(5));
1387        assert_eq!(fixed.max_height, Some(7));
1388    }
1389
1390    #[test]
1391    fn measurement_flexible_constraints() {
1392        let flexible = Measurement::flexible(2, 3);
1393        assert_eq!(flexible.min_width, 2);
1394        assert_eq!(flexible.min_height, 3);
1395        assert_eq!(flexible.max_width, None);
1396        assert_eq!(flexible.max_height, None);
1397    }
1398
1399    #[test]
1400    fn breakpoints_classify_defaults() {
1401        let bp = Breakpoints::DEFAULT;
1402        assert_eq!(bp.classify_width(20), Breakpoint::Xs);
1403        assert_eq!(bp.classify_width(60), Breakpoint::Sm);
1404        assert_eq!(bp.classify_width(90), Breakpoint::Md);
1405        assert_eq!(bp.classify_width(120), Breakpoint::Lg);
1406    }
1407
1408    #[test]
1409    fn breakpoints_at_least_and_between() {
1410        let bp = Breakpoints::new(50, 80, 110);
1411        assert!(bp.at_least(85, Breakpoint::Sm));
1412        assert!(bp.between(85, Breakpoint::Sm, Breakpoint::Md));
1413        assert!(!bp.between(85, Breakpoint::Lg, Breakpoint::Lg));
1414    }
1415
1416    #[test]
1417    fn alignment_end() {
1418        let flex = Flex::horizontal()
1419            .alignment(Alignment::End)
1420            .constraints([Constraint::Fixed(10), Constraint::Fixed(10)]);
1421        let rects = flex.split(Rect::new(0, 0, 100, 10));
1422        // Items should be pushed to the end: leftover = 100 - 20 = 80
1423        assert_eq!(rects[0], Rect::new(80, 0, 10, 10));
1424        assert_eq!(rects[1], Rect::new(90, 0, 10, 10));
1425    }
1426
1427    #[test]
1428    fn alignment_center() {
1429        let flex = Flex::horizontal()
1430            .alignment(Alignment::Center)
1431            .constraints([Constraint::Fixed(20), Constraint::Fixed(20)]);
1432        let rects = flex.split(Rect::new(0, 0, 100, 10));
1433        // Items should be centered: leftover = 100 - 40 = 60, offset = 30
1434        assert_eq!(rects[0], Rect::new(30, 0, 20, 10));
1435        assert_eq!(rects[1], Rect::new(50, 0, 20, 10));
1436    }
1437
1438    #[test]
1439    fn alignment_space_between() {
1440        let flex = Flex::horizontal()
1441            .alignment(Alignment::SpaceBetween)
1442            .constraints([
1443                Constraint::Fixed(10),
1444                Constraint::Fixed(10),
1445                Constraint::Fixed(10),
1446            ]);
1447        let rects = flex.split(Rect::new(0, 0, 100, 10));
1448        // Items: 30 total, leftover = 70, 2 gaps, 35 per gap
1449        assert_eq!(rects[0].x, 0);
1450        assert_eq!(rects[1].x, 45); // 10 + 35
1451        assert_eq!(rects[2].x, 90); // 45 + 10 + 35
1452    }
1453
1454    #[test]
1455    fn vertical_alignment() {
1456        let flex = Flex::vertical()
1457            .alignment(Alignment::End)
1458            .constraints([Constraint::Fixed(5), Constraint::Fixed(5)]);
1459        let rects = flex.split(Rect::new(0, 0, 10, 100));
1460        // Vertical: leftover = 100 - 10 = 90
1461        assert_eq!(rects[0], Rect::new(0, 90, 10, 5));
1462        assert_eq!(rects[1], Rect::new(0, 95, 10, 5));
1463    }
1464
1465    #[test]
1466    fn nested_flex_support() {
1467        // Outer horizontal split
1468        let outer = Flex::horizontal()
1469            .constraints([Constraint::Percentage(50.0), Constraint::Percentage(50.0)]);
1470        let outer_rects = outer.split(Rect::new(0, 0, 100, 100));
1471
1472        // Inner vertical split on the first half
1473        let inner = Flex::vertical().constraints([Constraint::Fixed(30), Constraint::Min(10)]);
1474        let inner_rects = inner.split(outer_rects[0]);
1475
1476        assert_eq!(inner_rects[0], Rect::new(0, 0, 50, 30));
1477        assert_eq!(inner_rects[1], Rect::new(0, 30, 50, 70));
1478    }
1479
1480    // Property-like invariant tests
1481    #[test]
1482    fn invariant_total_size_does_not_exceed_available() {
1483        // Test that constraint solving never allocates more than available
1484        for total in [10u16, 50, 100, 255] {
1485            let flex = Flex::horizontal().constraints([
1486                Constraint::Fixed(30),
1487                Constraint::Percentage(50.0),
1488                Constraint::Min(20),
1489            ]);
1490            let rects = flex.split(Rect::new(0, 0, total, 10));
1491            let total_width: u16 = rects.iter().map(|r| r.width).sum();
1492            assert!(
1493                total_width <= total,
1494                "Total width {} exceeded available {} for constraints",
1495                total_width,
1496                total
1497            );
1498        }
1499    }
1500
1501    #[test]
1502    fn invariant_empty_area_produces_empty_rects() {
1503        let flex = Flex::horizontal().constraints([Constraint::Fixed(10), Constraint::Fixed(10)]);
1504        let rects = flex.split(Rect::new(0, 0, 0, 0));
1505        assert!(rects.iter().all(|r| r.is_empty()));
1506    }
1507
1508    #[test]
1509    fn invariant_no_constraints_produces_empty_vec() {
1510        let flex = Flex::horizontal().constraints([]);
1511        let rects = flex.split(Rect::new(0, 0, 100, 100));
1512        assert!(rects.is_empty());
1513    }
1514
1515    #[test]
1516    fn flex_constraints_stay_inline_for_common_layouts() {
1517        let flex = Flex::horizontal().constraints([Constraint::Fixed(1); LAYOUT_INLINE_CAP]);
1518        assert_eq!(flex.constraint_count(), LAYOUT_INLINE_CAP);
1519        assert!(!flex.constraints.spilled());
1520
1521        let rects = flex.split(Rect::new(0, 0, LAYOUT_INLINE_CAP as u16, 1));
1522        assert_eq!(rects.len(), LAYOUT_INLINE_CAP);
1523        assert!(rects.iter().all(|rect| rect.width == 1));
1524    }
1525
1526    // --- Ratio constraint ---
1527
1528    #[test]
1529    fn ratio_constraint_splits_proportionally() {
1530        let flex =
1531            Flex::horizontal().constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]);
1532        let rects = flex.split(Rect::new(0, 0, 90, 10));
1533        assert_eq!(rects[0].width, 30);
1534        assert_eq!(rects[1].width, 60);
1535    }
1536
1537    #[test]
1538    fn ratio_constraint_with_zero_denominator() {
1539        // Zero denominator should not panic (max(1) guard)
1540        let flex = Flex::horizontal().constraints([Constraint::Ratio(1, 0)]);
1541        let rects = flex.split(Rect::new(0, 0, 100, 10));
1542        assert_eq!(rects.len(), 1);
1543    }
1544
1545    #[test]
1546    fn ratio_is_absolute_fraction() {
1547        let area = Rect::new(0, 0, 100, 1);
1548
1549        // Percentage is absolute against the total available.
1550        let rects = Flex::horizontal()
1551            .constraints([Constraint::Percentage(25.0)])
1552            .split(area);
1553        assert_eq!(rects[0].width, 25);
1554
1555        // Ratio(1, 4) should also be absolute (25% of 100 = 25).
1556        // It does NOT grow to fill remaining space.
1557        let rects = Flex::horizontal()
1558            .constraints([Constraint::Ratio(1, 4)])
1559            .split(area);
1560        assert_eq!(rects[0].width, 25);
1561    }
1562
1563    #[test]
1564    fn ratio_is_independent_of_grow_items() {
1565        let area = Rect::new(0, 0, 100, 1);
1566
1567        // Ratio(1, 4) takes 25 fixed. Fill takes remaining 75.
1568        let rects = Flex::horizontal()
1569            .constraints([Constraint::Ratio(1, 4), Constraint::Fill])
1570            .split(area);
1571        assert_eq!(rects[0].width, 25);
1572        assert_eq!(rects[1].width, 75);
1573    }
1574
1575    #[test]
1576    fn ratio_zero_numerator_should_be_zero() {
1577        // Ratio(0, 1) should logically get 0 space.
1578        // Test with Fill first to expose "last item gets remainder" logic artifact
1579        let flex = Flex::horizontal().constraints([Constraint::Fill, Constraint::Ratio(0, 1)]);
1580        let rects = flex.split(Rect::new(0, 0, 100, 1));
1581
1582        // Fill should get 100, Ratio should get 0
1583        assert_eq!(rects[0].width, 100, "Fill should take all space");
1584        assert_eq!(rects[1].width, 0, "Ratio(0, 1) should be width 0");
1585    }
1586
1587    // --- Max constraint ---
1588
1589    #[test]
1590    fn max_constraint_clamps_size() {
1591        let flex = Flex::horizontal().constraints([Constraint::Max(20), Constraint::Fixed(30)]);
1592        let rects = flex.split(Rect::new(0, 0, 100, 10));
1593        assert!(rects[0].width <= 20);
1594        assert_eq!(rects[1].width, 30);
1595    }
1596
1597    #[test]
1598    fn percentage_rounding_never_exceeds_available() {
1599        let constraints = [
1600            Constraint::Percentage(33.4),
1601            Constraint::Percentage(33.3),
1602            Constraint::Percentage(33.3),
1603        ];
1604        let sizes = solve_constraints(&constraints, 7);
1605        let total: u16 = sizes.iter().sum();
1606        assert!(total <= 7, "percent rounding overflowed: {sizes:?}");
1607        assert!(sizes.iter().all(|size| *size <= 7));
1608    }
1609
1610    #[test]
1611    fn tiny_area_saturates_fixed_and_min() {
1612        let constraints = [Constraint::Fixed(5), Constraint::Min(3), Constraint::Max(2)];
1613        let sizes = solve_constraints(&constraints, 2);
1614        assert_eq!(sizes[0], 2);
1615        assert_eq!(sizes[1], 0);
1616        assert_eq!(sizes[2], 0);
1617        assert_eq!(sizes.iter().sum::<u16>(), 2);
1618    }
1619
1620    #[test]
1621    fn ratio_distribution_sums_to_available() {
1622        // Since Ratio is absolute, 1/3 of 5 is 1, and 2/3 of 5 is 3.
1623        let constraints = [Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)];
1624        let sizes = solve_constraints(&constraints, 5);
1625        assert_eq!(sizes.iter().sum::<u16>(), 4);
1626        assert_eq!(sizes[0], 1);
1627        assert_eq!(sizes[1], 3);
1628    }
1629
1630    #[test]
1631    fn flex_gap_exceeds_area_yields_zero_widths() {
1632        let flex = Flex::horizontal()
1633            .gap(5)
1634            .constraints([Constraint::Fixed(1), Constraint::Fixed(1)]);
1635        let rects = flex.split(Rect::new(0, 0, 3, 1));
1636        assert_eq!(rects.len(), 2);
1637        assert_eq!(rects[0].width, 0);
1638        assert_eq!(rects[1].width, 0);
1639    }
1640
1641    // --- SpaceAround alignment ---
1642
1643    #[test]
1644    fn alignment_space_around() {
1645        let flex = Flex::horizontal()
1646            .alignment(Alignment::SpaceAround)
1647            .constraints([Constraint::Fixed(10), Constraint::Fixed(10)]);
1648        let rects = flex.split(Rect::new(0, 0, 100, 10));
1649
1650        // SpaceAround: leftover = 80, space_unit = 80/(2*2) = 20
1651        // First item starts at 20, second at 20+10+40=70
1652        assert_eq!(rects[0].x, 20);
1653        assert_eq!(rects[1].x, 70);
1654    }
1655
1656    // --- Vertical with gap ---
1657
1658    #[test]
1659    fn vertical_gap() {
1660        let flex = Flex::vertical()
1661            .gap(5)
1662            .constraints([Constraint::Fixed(10), Constraint::Fixed(10)]);
1663        let rects = flex.split(Rect::new(0, 0, 50, 100));
1664        assert_eq!(rects[0], Rect::new(0, 0, 50, 10));
1665        assert_eq!(rects[1], Rect::new(0, 15, 50, 10));
1666    }
1667
1668    // --- Vertical center alignment ---
1669
1670    #[test]
1671    fn vertical_center() {
1672        let flex = Flex::vertical()
1673            .alignment(Alignment::Center)
1674            .constraints([Constraint::Fixed(10)]);
1675        let rects = flex.split(Rect::new(0, 0, 50, 100));
1676        // leftover = 90, offset = 45
1677        assert_eq!(rects[0].y, 45);
1678        assert_eq!(rects[0].height, 10);
1679    }
1680
1681    // --- Single constraint gets all space ---
1682
1683    #[test]
1684    fn single_min_takes_all() {
1685        let flex = Flex::horizontal().constraints([Constraint::Min(5)]);
1686        let rects = flex.split(Rect::new(0, 0, 80, 24));
1687        assert_eq!(rects[0].width, 80);
1688    }
1689
1690    // --- Fixed exceeds available ---
1691
1692    #[test]
1693    fn fixed_exceeds_available_clamped() {
1694        let flex = Flex::horizontal().constraints([Constraint::Fixed(60), Constraint::Fixed(60)]);
1695        let rects = flex.split(Rect::new(0, 0, 100, 10));
1696        // First gets 60, second gets remaining 40 (clamped)
1697        assert_eq!(rects[0].width, 60);
1698        assert_eq!(rects[1].width, 40);
1699    }
1700
1701    // --- Percentage that sums beyond 100% ---
1702
1703    #[test]
1704    fn percentage_overflow_clamped() {
1705        let flex = Flex::horizontal()
1706            .constraints([Constraint::Percentage(80.0), Constraint::Percentage(80.0)]);
1707        let rects = flex.split(Rect::new(0, 0, 100, 10));
1708        assert_eq!(rects[0].width, 80);
1709        assert_eq!(rects[1].width, 20); // clamped to remaining
1710    }
1711
1712    // --- Margin reduces available space ---
1713
1714    #[test]
1715    fn margin_reduces_split_area() {
1716        let flex = Flex::horizontal()
1717            .margin(Sides::all(10))
1718            .constraints([Constraint::Fixed(20), Constraint::Min(0)]);
1719        let rects = flex.split(Rect::new(0, 0, 100, 100));
1720        // Inner: 10,10,80,80
1721        assert_eq!(rects[0].x, 10);
1722        assert_eq!(rects[0].y, 10);
1723        assert_eq!(rects[0].width, 20);
1724        assert_eq!(rects[0].height, 80);
1725    }
1726
1727    // --- Builder chain ---
1728
1729    #[test]
1730    fn builder_methods_chain() {
1731        let flex = Flex::vertical()
1732            .direction(Direction::Horizontal)
1733            .gap(3)
1734            .margin(Sides::all(1))
1735            .alignment(Alignment::End)
1736            .constraints([Constraint::Fixed(10)]);
1737        let rects = flex.split(Rect::new(0, 0, 50, 50));
1738        assert_eq!(rects.len(), 1);
1739    }
1740
1741    // --- SpaceBetween with single item ---
1742
1743    #[test]
1744    fn space_between_single_item() {
1745        let flex = Flex::horizontal()
1746            .alignment(Alignment::SpaceBetween)
1747            .constraints([Constraint::Fixed(10)]);
1748        let rects = flex.split(Rect::new(0, 0, 100, 10));
1749        // Single item: starts at 0, no extra spacing
1750        assert_eq!(rects[0].x, 0);
1751        assert_eq!(rects[0].width, 10);
1752    }
1753
1754    #[test]
1755    fn invariant_rects_within_bounds() {
1756        let area = Rect::new(10, 20, 80, 60);
1757        let flex = Flex::horizontal()
1758            .margin(Sides::all(5))
1759            .gap(2)
1760            .constraints([
1761                Constraint::Fixed(15),
1762                Constraint::Percentage(30.0),
1763                Constraint::Min(10),
1764            ]);
1765        let rects = flex.split(area);
1766
1767        // All rects should be within the inner area (after margin)
1768        let inner = area.inner(Sides::all(5));
1769        for rect in &rects {
1770            assert!(
1771                rect.x >= inner.x && rect.right() <= inner.right(),
1772                "Rect {:?} exceeds horizontal bounds of {:?}",
1773                rect,
1774                inner
1775            );
1776            assert!(
1777                rect.y >= inner.y && rect.bottom() <= inner.bottom(),
1778                "Rect {:?} exceeds vertical bounds of {:?}",
1779                rect,
1780                inner
1781            );
1782        }
1783    }
1784
1785    // --- Fill constraint ---
1786
1787    #[test]
1788    fn fill_takes_remaining_space() {
1789        let flex = Flex::horizontal().constraints([Constraint::Fixed(20), Constraint::Fill]);
1790        let rects = flex.split(Rect::new(0, 0, 100, 10));
1791        assert_eq!(rects[0].width, 20);
1792        assert_eq!(rects[1].width, 80); // Fill gets remaining
1793    }
1794
1795    #[test]
1796    fn multiple_fills_share_space() {
1797        let flex = Flex::horizontal().constraints([Constraint::Fill, Constraint::Fill]);
1798        let rects = flex.split(Rect::new(0, 0, 100, 10));
1799        assert_eq!(rects[0].width, 50);
1800        assert_eq!(rects[1].width, 50);
1801    }
1802
1803    // --- FitContent constraint ---
1804
1805    #[test]
1806    fn fit_content_uses_preferred_size() {
1807        let flex = Flex::horizontal().constraints([Constraint::FitContent, Constraint::Fill]);
1808        let rects = flex.split_with_measurer(Rect::new(0, 0, 100, 10), |idx, _| {
1809            if idx == 0 {
1810                LayoutSizeHint {
1811                    min: 5,
1812                    preferred: 30,
1813                    max: None,
1814                }
1815            } else {
1816                LayoutSizeHint::ZERO
1817            }
1818        });
1819        assert_eq!(rects[0].width, 30); // FitContent gets preferred
1820        assert_eq!(rects[1].width, 70); // Fill gets remainder
1821    }
1822
1823    #[test]
1824    fn fit_content_clamps_to_available() {
1825        let flex = Flex::horizontal().constraints([Constraint::FitContent, Constraint::FitContent]);
1826        let rects = flex.split_with_measurer(Rect::new(0, 0, 100, 10), |_, _| LayoutSizeHint {
1827            min: 5,
1828            preferred: 80,
1829            max: None,
1830        });
1831        // First FitContent takes 80, second gets remaining 20
1832        assert_eq!(rects[0].width, 80);
1833        assert_eq!(rects[1].width, 20);
1834    }
1835
1836    #[test]
1837    fn fit_content_without_measurer_gets_zero() {
1838        // Without measurer (via split()), FitContent gets zero from default hint
1839        let flex = Flex::horizontal().constraints([Constraint::FitContent, Constraint::Fill]);
1840        let rects = flex.split(Rect::new(0, 0, 100, 10));
1841        assert_eq!(rects[0].width, 0); // No preferred size
1842        assert_eq!(rects[1].width, 100); // Fill gets all
1843    }
1844
1845    #[test]
1846    fn fit_content_zero_area_returns_empty_rects() {
1847        let flex = Flex::horizontal().constraints([Constraint::FitContent, Constraint::Fill]);
1848        let rects = flex.split_with_measurer(Rect::new(0, 0, 0, 0), |_, _| LayoutSizeHint {
1849            min: 5,
1850            preferred: 10,
1851            max: None,
1852        });
1853        assert_eq!(rects.len(), 2);
1854        assert_eq!(rects[0].width, 0);
1855        assert_eq!(rects[0].height, 0);
1856        assert_eq!(rects[1].width, 0);
1857        assert_eq!(rects[1].height, 0);
1858    }
1859
1860    #[test]
1861    fn fit_content_tiny_available_clamps_to_remaining() {
1862        let flex = Flex::horizontal().constraints([Constraint::FitContent, Constraint::Fill]);
1863        let rects = flex.split_with_measurer(Rect::new(0, 0, 1, 1), |_, _| LayoutSizeHint {
1864            min: 5,
1865            preferred: 10,
1866            max: None,
1867        });
1868        assert_eq!(rects[0].width, 1);
1869        assert_eq!(rects[1].width, 0);
1870    }
1871
1872    // --- FitContentBounded constraint ---
1873
1874    #[test]
1875    fn fit_content_bounded_clamps_to_min() {
1876        let flex = Flex::horizontal().constraints([
1877            Constraint::FitContentBounded { min: 20, max: 50 },
1878            Constraint::Fill,
1879        ]);
1880        let rects = flex.split_with_measurer(Rect::new(0, 0, 100, 10), |_, _| LayoutSizeHint {
1881            min: 5,
1882            preferred: 10, // Below min bound
1883            max: None,
1884        });
1885        assert_eq!(rects[0].width, 20); // Clamped to min bound
1886        assert_eq!(rects[1].width, 80);
1887    }
1888
1889    #[test]
1890    fn fit_content_bounded_respects_small_available() {
1891        let flex = Flex::horizontal().constraints([
1892            Constraint::FitContentBounded { min: 20, max: 50 },
1893            Constraint::Fill,
1894        ]);
1895        let rects = flex.split_with_measurer(Rect::new(0, 0, 5, 2), |_, _| LayoutSizeHint {
1896            min: 5,
1897            preferred: 10,
1898            max: None,
1899        });
1900        // Available is 5 total, so FitContentBounded must clamp to remaining.
1901        assert_eq!(rects[0].width, 5);
1902        assert_eq!(rects[1].width, 0);
1903    }
1904
1905    #[test]
1906    fn fit_content_bounded_clamps_to_max() {
1907        let flex = Flex::horizontal().constraints([
1908            Constraint::FitContentBounded { min: 10, max: 30 },
1909            Constraint::Fill,
1910        ]);
1911        let rects = flex.split_with_measurer(Rect::new(0, 0, 100, 10), |_, _| LayoutSizeHint {
1912            min: 5,
1913            preferred: 50, // Above max bound
1914            max: None,
1915        });
1916        assert_eq!(rects[0].width, 30); // Clamped to max bound
1917        assert_eq!(rects[1].width, 70);
1918    }
1919
1920    #[test]
1921    fn fit_content_bounded_uses_preferred_when_in_range() {
1922        let flex = Flex::horizontal().constraints([
1923            Constraint::FitContentBounded { min: 10, max: 50 },
1924            Constraint::Fill,
1925        ]);
1926        let rects = flex.split_with_measurer(Rect::new(0, 0, 100, 10), |_, _| LayoutSizeHint {
1927            min: 5,
1928            preferred: 35, // Within bounds
1929            max: None,
1930        });
1931        assert_eq!(rects[0].width, 35);
1932        assert_eq!(rects[1].width, 65);
1933    }
1934
1935    // --- FitMin constraint ---
1936
1937    #[test]
1938    fn fit_min_uses_minimum_size() {
1939        let flex = Flex::horizontal().constraints([Constraint::FitMin, Constraint::Fill]);
1940        let rects = flex.split_with_measurer(Rect::new(0, 0, 100, 10), |idx, _| {
1941            if idx == 0 {
1942                LayoutSizeHint {
1943                    min: 15,
1944                    preferred: 40,
1945                    max: None,
1946                }
1947            } else {
1948                LayoutSizeHint::ZERO
1949            }
1950        });
1951        // FitMin gets minimum (15) and DOES NOT grow.
1952        // Fill gets the remaining 85.
1953        assert_eq!(rects[0].width, 15, "FitMin should strict size to min");
1954        assert_eq!(rects[1].width, 85, "Fill should take remaining space");
1955    }
1956
1957    #[test]
1958    fn fit_min_without_measurer_gets_zero() {
1959        let flex = Flex::horizontal().constraints([Constraint::FitMin, Constraint::Fill]);
1960        let rects = flex.split(Rect::new(0, 0, 100, 10));
1961        // Without measurer, min is 0. FitMin gets 0 and does not grow.
1962        // Fill takes all 100.
1963        assert_eq!(rects[0].width, 0);
1964        assert_eq!(rects[1].width, 100);
1965    }
1966
1967    // --- LayoutSizeHint tests ---
1968
1969    #[test]
1970    fn layout_size_hint_zero_is_default() {
1971        assert_eq!(LayoutSizeHint::default(), LayoutSizeHint::ZERO);
1972    }
1973
1974    #[test]
1975    fn layout_size_hint_exact() {
1976        let h = LayoutSizeHint::exact(25);
1977        assert_eq!(h.min, 25);
1978        assert_eq!(h.preferred, 25);
1979        assert_eq!(h.max, Some(25));
1980    }
1981
1982    #[test]
1983    fn layout_size_hint_at_least() {
1984        let h = LayoutSizeHint::at_least(10, 30);
1985        assert_eq!(h.min, 10);
1986        assert_eq!(h.preferred, 30);
1987        assert_eq!(h.max, None);
1988    }
1989
1990    #[test]
1991    fn layout_size_hint_clamp() {
1992        let h = LayoutSizeHint {
1993            min: 10,
1994            preferred: 20,
1995            max: Some(30),
1996        };
1997        assert_eq!(h.clamp(5), 10); // Below min
1998        assert_eq!(h.clamp(15), 15); // In range
1999        assert_eq!(h.clamp(50), 30); // Above max
2000    }
2001
2002    #[test]
2003    fn layout_size_hint_clamp_unbounded() {
2004        let h = LayoutSizeHint::at_least(5, 10);
2005        assert_eq!(h.clamp(3), 5); // Below min
2006        assert_eq!(h.clamp(1000), 1000); // No max, stays as-is
2007    }
2008
2009    #[test]
2010    fn layout_size_hint_clamp_min_greater_than_max() {
2011        // When min > max, min should win (strict lower bound)
2012        let h = LayoutSizeHint {
2013            min: 20,
2014            preferred: 20,
2015            max: Some(10),
2016        };
2017        assert_eq!(h.clamp(5), 20); // 20 > 5, clamped to min
2018        assert_eq!(h.clamp(15), 20); // 20 > 15, clamped to min
2019        assert_eq!(h.clamp(25), 20); // 20 > 10, clamped to min
2020    }
2021
2022    // --- Integration: FitContent with other constraints ---
2023
2024    #[test]
2025    fn fit_content_with_fixed_and_fill() {
2026        let flex = Flex::horizontal().constraints([
2027            Constraint::Fixed(20),
2028            Constraint::FitContent,
2029            Constraint::Fill,
2030        ]);
2031        let rects = flex.split_with_measurer(Rect::new(0, 0, 100, 10), |idx, _| {
2032            if idx == 1 {
2033                LayoutSizeHint {
2034                    min: 5,
2035                    preferred: 25,
2036                    max: None,
2037                }
2038            } else {
2039                LayoutSizeHint::ZERO
2040            }
2041        });
2042        assert_eq!(rects[0].width, 20); // Fixed
2043        assert_eq!(rects[1].width, 25); // FitContent preferred
2044        assert_eq!(rects[2].width, 55); // Fill gets remainder
2045    }
2046
2047    #[test]
2048    fn total_allocation_never_exceeds_available_with_fit_content() {
2049        for available in [10u16, 50, 100, 255] {
2050            let flex = Flex::horizontal().constraints([
2051                Constraint::FitContent,
2052                Constraint::FitContent,
2053                Constraint::Fill,
2054            ]);
2055            let rects =
2056                flex.split_with_measurer(Rect::new(0, 0, available, 10), |_, _| LayoutSizeHint {
2057                    min: 10,
2058                    preferred: 40,
2059                    max: None,
2060                });
2061            let total: u16 = rects.iter().map(|r| r.width).sum();
2062            assert!(
2063                total <= available,
2064                "Total {} exceeded available {} with FitContent",
2065                total,
2066                available
2067            );
2068        }
2069    }
2070
2071    // -----------------------------------------------------------------------
2072    // Stable Layout Rounding Tests (bd-4kq0.4.1)
2073    // -----------------------------------------------------------------------
2074
2075    mod rounding_tests {
2076        use super::super::*;
2077
2078        // --- Sum conservation (REQUIRED) ---
2079
2080        #[test]
2081        fn rounding_conserves_sum_exact() {
2082            let result = round_layout_stable(&[10.0, 20.0, 10.0], 40, None);
2083            assert_eq!(result.iter().copied().sum::<u16>(), 40);
2084            assert_eq!(result.as_slice(), &[10u16, 20u16, 10u16]);
2085        }
2086
2087        #[test]
2088        fn rounding_conserves_sum_fractional() {
2089            let result = round_layout_stable(&[10.4, 20.6, 9.0], 40, None);
2090            assert_eq!(
2091                result.iter().copied().sum::<u16>(),
2092                40,
2093                "Sum must equal total: {:?}",
2094                result
2095            );
2096        }
2097
2098        #[test]
2099        fn rounding_conserves_sum_many_fractions() {
2100            let targets = vec![20.2, 20.2, 20.2, 20.2, 19.2];
2101            let result = round_layout_stable(&targets, 100, None);
2102            assert_eq!(
2103                result.iter().copied().sum::<u16>(),
2104                100,
2105                "Sum must be exactly 100: {:?}",
2106                result
2107            );
2108        }
2109
2110        #[test]
2111        fn rounding_conserves_sum_all_half() {
2112            let targets = vec![10.5, 10.5, 10.5, 10.5];
2113            let result = round_layout_stable(&targets, 42, None);
2114            assert_eq!(
2115                result.iter().copied().sum::<u16>(),
2116                42,
2117                "Sum must be exactly 42: {:?}",
2118                result
2119            );
2120        }
2121
2122        // --- Bounded displacement ---
2123
2124        #[test]
2125        fn rounding_displacement_bounded() {
2126            let targets = vec![33.33, 33.33, 33.34];
2127            let result = round_layout_stable(&targets, 100, None);
2128            assert_eq!(result.iter().copied().sum::<u16>(), 100);
2129
2130            for (i, (&x, &r)) in result.iter().zip(targets.iter()).enumerate() {
2131                let floor = r.floor() as u16;
2132                let ceil = floor + 1;
2133                assert!(
2134                    x == floor || x == ceil,
2135                    "Element {} = {} not in {{floor={}, ceil={}}} of target {}",
2136                    i,
2137                    x,
2138                    floor,
2139                    ceil,
2140                    r
2141                );
2142            }
2143        }
2144
2145        // --- Temporal tie-break (REQUIRED) ---
2146
2147        #[test]
2148        fn temporal_tiebreak_stable_when_unchanged() {
2149            let targets = vec![10.5, 10.5, 10.5, 10.5];
2150            let first = round_layout_stable(&targets, 42, None);
2151            let second = round_layout_stable(&targets, 42, Some(first.clone()));
2152            assert_eq!(
2153                first, second,
2154                "Identical targets should produce identical results"
2155            );
2156        }
2157
2158        #[test]
2159        fn temporal_tiebreak_prefers_previous_direction() {
2160            let targets = vec![10.5, 10.5];
2161            let total = 21;
2162            let first = round_layout_stable(&targets, total, None);
2163            assert_eq!(first.iter().copied().sum::<u16>(), total);
2164            let second = round_layout_stable(&targets, total, Some(first.clone()));
2165            assert_eq!(first, second, "Should maintain rounding direction");
2166        }
2167
2168        #[test]
2169        fn temporal_tiebreak_adapts_to_changed_targets() {
2170            let targets_a = vec![10.5, 10.5];
2171            let result_a = round_layout_stable(&targets_a, 21, None);
2172            let targets_b = vec![15.7, 5.3];
2173            let result_b = round_layout_stable(&targets_b, 21, Some(result_a));
2174            assert_eq!(result_b.iter().copied().sum::<u16>(), 21);
2175            assert!(result_b[0] > result_b[1], "Should follow larger target");
2176        }
2177
2178        // --- Property: min displacement (REQUIRED) ---
2179
2180        #[test]
2181        fn property_min_displacement_brute_force_small() {
2182            let targets = vec![3.3, 3.3, 3.4];
2183            let total: u16 = 10;
2184            let result = round_layout_stable(&targets, total, None);
2185            let our_displacement: f64 = result
2186                .iter()
2187                .zip(targets.iter())
2188                .map(|(&x, &r)| (x as f64 - r).abs())
2189                .sum();
2190
2191            let mut min_displacement = f64::MAX;
2192            let floors: Vec<u16> = targets.iter().map(|&r| r.floor() as u16).collect();
2193            let ceils: Vec<u16> = targets.iter().map(|&r| r.floor() as u16 + 1).collect();
2194
2195            for a in floors[0]..=ceils[0] {
2196                for b in floors[1]..=ceils[1] {
2197                    for c in floors[2]..=ceils[2] {
2198                        if a + b + c == total {
2199                            let disp = (a as f64 - targets[0]).abs()
2200                                + (b as f64 - targets[1]).abs()
2201                                + (c as f64 - targets[2]).abs();
2202                            if disp < min_displacement {
2203                                min_displacement = disp;
2204                            }
2205                        }
2206                    }
2207                }
2208            }
2209
2210            assert!(
2211                (our_displacement - min_displacement).abs() < 1e-10,
2212                "Our displacement {} should match optimal {}: {:?}",
2213                our_displacement,
2214                min_displacement,
2215                result
2216            );
2217        }
2218
2219        // --- Determinism ---
2220
2221        #[test]
2222        fn rounding_deterministic() {
2223            let targets = vec![7.7, 8.3, 14.0];
2224            let a = round_layout_stable(&targets, 30, None);
2225            let b = round_layout_stable(&targets, 30, None);
2226            assert_eq!(a, b, "Same inputs must produce identical outputs");
2227        }
2228
2229        // --- Edge cases ---
2230
2231        #[test]
2232        fn rounding_empty_targets() {
2233            let result = round_layout_stable(&[], 0, None);
2234            assert!(result.is_empty());
2235        }
2236
2237        #[test]
2238        fn rounding_single_element() {
2239            let result = round_layout_stable(&[10.7], 11, None);
2240            assert_eq!(result.as_slice(), &[11u16]);
2241        }
2242
2243        #[test]
2244        fn rounding_zero_total() {
2245            let result = round_layout_stable(&[5.0, 5.0], 0, None);
2246            assert_eq!(result.iter().copied().sum::<u16>(), 0);
2247        }
2248
2249        #[test]
2250        fn rounding_zero_total_with_large_overflow_reaches_zero() {
2251            let result = round_layout_stable(&[65535.0, 65535.0], 0, None);
2252            assert_eq!(result.as_slice(), &[0u16, 0u16]);
2253            assert_eq!(result.iter().copied().sum::<u16>(), 0);
2254        }
2255
2256        #[test]
2257        fn rounding_all_zeros() {
2258            let result = round_layout_stable(&[0.0, 0.0, 0.0], 0, None);
2259            assert_eq!(result.as_slice(), &[0u16, 0u16, 0u16]);
2260        }
2261
2262        #[test]
2263        fn rounding_integer_targets() {
2264            let result = round_layout_stable(&[10.0, 20.0, 30.0], 60, None);
2265            assert_eq!(result.as_slice(), &[10u16, 20u16, 30u16]);
2266        }
2267
2268        #[test]
2269        fn rounding_large_deficit() {
2270            let result = round_layout_stable(&[0.9, 0.9, 0.9], 3, None);
2271            assert_eq!(result.iter().copied().sum::<u16>(), 3);
2272            assert_eq!(result.as_slice(), &[1u16, 1u16, 1u16]);
2273        }
2274
2275        #[test]
2276        fn rounding_with_prev_different_length() {
2277            let result = round_layout_stable(
2278                &[10.5, 10.5],
2279                21,
2280                Some(smallvec::smallvec![11u16, 10u16, 5u16]),
2281            );
2282            assert_eq!(result.iter().copied().sum::<u16>(), 21);
2283        }
2284
2285        #[test]
2286        fn rounding_very_small_fractions() {
2287            let targets = vec![10.001, 20.001, 9.998];
2288            let result = round_layout_stable(&targets, 40, None);
2289            assert_eq!(result.iter().copied().sum::<u16>(), 40);
2290        }
2291
2292        #[test]
2293        fn rounding_conserves_sum_stress() {
2294            let n = 50;
2295            let targets: Vec<f64> = (0..n).map(|i| 2.0 + (i as f64 * 0.037)).collect();
2296            let total = 120u16;
2297            let result = round_layout_stable(&targets, total, None);
2298            assert_eq!(
2299                result.iter().copied().sum::<u16>(),
2300                total,
2301                "Sum must be exactly {} for {} items: {:?}",
2302                total,
2303                n,
2304                result
2305            );
2306        }
2307    }
2308
2309    // -----------------------------------------------------------------------
2310    // Property Tests: Constraint Satisfaction (bd-4kq0.4.3)
2311    // -----------------------------------------------------------------------
2312
2313    mod property_constraint_tests {
2314        use super::super::*;
2315
2316        /// Deterministic LCG pseudo-random number generator (no external deps).
2317        struct Lcg(u64);
2318
2319        impl Lcg {
2320            fn new(seed: u64) -> Self {
2321                Self(seed)
2322            }
2323            fn next_u32(&mut self) -> u32 {
2324                self.0 = self
2325                    .0
2326                    .wrapping_mul(6_364_136_223_846_793_005)
2327                    .wrapping_add(1);
2328                (self.0 >> 33) as u32
2329            }
2330            fn next_u16_range(&mut self, lo: u16, hi: u16) -> u16 {
2331                if lo >= hi {
2332                    return lo;
2333                }
2334                lo + (self.next_u32() % (hi - lo) as u32) as u16
2335            }
2336            fn next_f32(&mut self) -> f32 {
2337                (self.next_u32() & 0x00FF_FFFF) as f32 / 16_777_216.0
2338            }
2339        }
2340
2341        /// Generate a random constraint from the LCG.
2342        fn random_constraint(rng: &mut Lcg) -> Constraint {
2343            match rng.next_u32() % 7 {
2344                0 => Constraint::Fixed(rng.next_u16_range(1, 80)),
2345                1 => Constraint::Percentage(rng.next_f32() * 100.0),
2346                2 => Constraint::Min(rng.next_u16_range(0, 40)),
2347                3 => Constraint::Max(rng.next_u16_range(5, 120)),
2348                4 => {
2349                    let n = rng.next_u32() % 5 + 1;
2350                    let d = rng.next_u32() % 5 + 1;
2351                    Constraint::Ratio(n, d)
2352                }
2353                5 => Constraint::Fill,
2354                _ => Constraint::FitContent,
2355            }
2356        }
2357
2358        #[test]
2359        fn property_constraints_respected_fixed() {
2360            let mut rng = Lcg::new(0xDEAD_BEEF);
2361            for _ in 0..200 {
2362                let fixed_val = rng.next_u16_range(1, 60);
2363                let avail = rng.next_u16_range(10, 200);
2364                let flex = Flex::horizontal().constraints([Constraint::Fixed(fixed_val)]);
2365                let rects = flex.split(Rect::new(0, 0, avail, 10));
2366                assert!(
2367                    rects[0].width <= fixed_val.min(avail),
2368                    "Fixed({}) in avail {} -> width {}",
2369                    fixed_val,
2370                    avail,
2371                    rects[0].width
2372                );
2373            }
2374        }
2375
2376        #[test]
2377        fn property_constraints_respected_max() {
2378            let mut rng = Lcg::new(0xCAFE_BABE);
2379            for _ in 0..200 {
2380                let max_val = rng.next_u16_range(5, 80);
2381                let avail = rng.next_u16_range(10, 200);
2382                let flex =
2383                    Flex::horizontal().constraints([Constraint::Max(max_val), Constraint::Fill]);
2384                let rects = flex.split(Rect::new(0, 0, avail, 10));
2385                assert!(
2386                    rects[0].width <= max_val,
2387                    "Max({}) in avail {} -> width {}",
2388                    max_val,
2389                    avail,
2390                    rects[0].width
2391                );
2392            }
2393        }
2394
2395        #[test]
2396        fn property_constraints_respected_min() {
2397            let mut rng = Lcg::new(0xBAAD_F00D);
2398            for _ in 0..200 {
2399                let min_val = rng.next_u16_range(0, 40);
2400                let avail = rng.next_u16_range(min_val.max(1), 200);
2401                let flex = Flex::horizontal().constraints([Constraint::Min(min_val)]);
2402                let rects = flex.split(Rect::new(0, 0, avail, 10));
2403                assert!(
2404                    rects[0].width >= min_val,
2405                    "Min({}) in avail {} -> width {}",
2406                    min_val,
2407                    avail,
2408                    rects[0].width
2409                );
2410            }
2411        }
2412
2413        #[test]
2414        fn property_constraints_respected_ratio_proportional() {
2415            let mut rng = Lcg::new(0x1234_5678);
2416            for _ in 0..200 {
2417                let n1 = rng.next_u32() % 5 + 1;
2418                let n2 = rng.next_u32() % 5 + 1;
2419                let d = n1 + n2;
2420                let avail = rng.next_u16_range(20, 200);
2421                let flex = Flex::horizontal()
2422                    .constraints([Constraint::Ratio(n1, d), Constraint::Ratio(n2, d)]);
2423                let rects = flex.split(Rect::new(0, 0, avail, 10));
2424                let w1 = rects[0].width as f64;
2425                let w2 = rects[1].width as f64;
2426                let total = w1 + w2;
2427                if total > 0.0 {
2428                    let expected_ratio = n1 as f64 / d as f64;
2429                    let actual_ratio = w1 / total;
2430                    assert!(
2431                        (actual_ratio - expected_ratio).abs() < 0.15 || total < 4.0,
2432                        "Ratio({},{})/({}+{}) avail={}: ~{:.2} got {:.2} (w1={}, w2={})",
2433                        n1,
2434                        d,
2435                        n1,
2436                        n2,
2437                        avail,
2438                        expected_ratio,
2439                        actual_ratio,
2440                        w1,
2441                        w2
2442                    );
2443                }
2444            }
2445        }
2446
2447        #[test]
2448        fn property_total_allocation_never_exceeds_available() {
2449            let mut rng = Lcg::new(0xFACE_FEED);
2450            for _ in 0..500 {
2451                let n = (rng.next_u32() % 6 + 1) as usize;
2452                let constraints: Vec<Constraint> =
2453                    (0..n).map(|_| random_constraint(&mut rng)).collect();
2454                let avail = rng.next_u16_range(5, 200);
2455                let dir = if rng.next_u32().is_multiple_of(2) {
2456                    Direction::Horizontal
2457                } else {
2458                    Direction::Vertical
2459                };
2460                let flex = Flex::default().direction(dir).constraints(constraints);
2461                let area = Rect::new(0, 0, avail, avail);
2462                let rects = flex.split(area);
2463                let total: u16 = rects
2464                    .iter()
2465                    .map(|r| match dir {
2466                        Direction::Horizontal => r.width,
2467                        Direction::Vertical => r.height,
2468                    })
2469                    .sum();
2470                assert!(
2471                    total <= avail,
2472                    "Total {} exceeded available {} with {} constraints",
2473                    total,
2474                    avail,
2475                    n
2476                );
2477            }
2478        }
2479
2480        #[test]
2481        fn property_no_overlap_horizontal() {
2482            let mut rng = Lcg::new(0xABCD_1234);
2483            for _ in 0..300 {
2484                let n = (rng.next_u32() % 5 + 2) as usize;
2485                let constraints: Vec<Constraint> =
2486                    (0..n).map(|_| random_constraint(&mut rng)).collect();
2487                let avail = rng.next_u16_range(20, 200);
2488                let flex = Flex::horizontal().constraints(constraints);
2489                let rects = flex.split(Rect::new(0, 0, avail, 10));
2490
2491                for i in 1..rects.len() {
2492                    let prev_end = rects[i - 1].x + rects[i - 1].width;
2493                    assert!(
2494                        rects[i].x >= prev_end,
2495                        "Overlap at {}: prev ends {}, next starts {}",
2496                        i,
2497                        prev_end,
2498                        rects[i].x
2499                    );
2500                }
2501            }
2502        }
2503
2504        #[test]
2505        fn property_deterministic_across_runs() {
2506            let mut rng = Lcg::new(0x9999_8888);
2507            for _ in 0..100 {
2508                let n = (rng.next_u32() % 5 + 1) as usize;
2509                let constraints: Vec<Constraint> =
2510                    (0..n).map(|_| random_constraint(&mut rng)).collect();
2511                let avail = rng.next_u16_range(10, 200);
2512                let r1 = Flex::horizontal()
2513                    .constraints(constraints.clone())
2514                    .split(Rect::new(0, 0, avail, 10));
2515                let r2 = Flex::horizontal()
2516                    .constraints(constraints)
2517                    .split(Rect::new(0, 0, avail, 10));
2518                assert_eq!(r1, r2, "Determinism violation at avail={}", avail);
2519            }
2520        }
2521    }
2522
2523    // -----------------------------------------------------------------------
2524    // Property Tests: Temporal Stability (bd-4kq0.4.3)
2525    // -----------------------------------------------------------------------
2526
2527    mod property_temporal_tests {
2528        use super::super::*;
2529        use crate::cache::{CoherenceCache, CoherenceId};
2530
2531        /// Deterministic LCG.
2532        struct Lcg(u64);
2533
2534        impl Lcg {
2535            fn new(seed: u64) -> Self {
2536                Self(seed)
2537            }
2538            fn next_u32(&mut self) -> u32 {
2539                self.0 = self
2540                    .0
2541                    .wrapping_mul(6_364_136_223_846_793_005)
2542                    .wrapping_add(1);
2543                (self.0 >> 33) as u32
2544            }
2545        }
2546
2547        #[test]
2548        fn property_temporal_stability_small_resize() {
2549            let constraints = [
2550                Constraint::Percentage(33.3),
2551                Constraint::Percentage(33.3),
2552                Constraint::Fill,
2553            ];
2554            let mut coherence = CoherenceCache::new(64);
2555            let id = CoherenceId::new(&constraints, Direction::Horizontal);
2556
2557            for total in [80u16, 100, 120] {
2558                let flex = Flex::horizontal().constraints(constraints);
2559                let rects = flex.split(Rect::new(0, 0, total, 10));
2560                let widths: Vec<u16> = rects.iter().map(|r| r.width).collect();
2561
2562                let targets: Vec<f64> = widths.iter().map(|&w| w as f64).collect();
2563                let prev = coherence.get(&id);
2564                let rounded = round_layout_stable(&targets, total, prev);
2565
2566                if let Some(old) = coherence.get(&id) {
2567                    let (sum_disp, max_disp) = coherence.displacement(&id, &rounded);
2568                    assert!(
2569                        max_disp <= total.abs_diff(old.iter().copied().sum()) as u32 + 1,
2570                        "max_disp={} too large for size change {} -> {}",
2571                        max_disp,
2572                        old.iter().copied().sum::<u16>(),
2573                        total
2574                    );
2575                    let _ = sum_disp;
2576                }
2577                coherence.store(id, rounded);
2578            }
2579        }
2580
2581        #[test]
2582        fn property_temporal_stability_random_walk() {
2583            let constraints = [
2584                Constraint::Ratio(1, 3),
2585                Constraint::Ratio(1, 3),
2586                Constraint::Ratio(1, 3),
2587            ];
2588            let id = CoherenceId::new(&constraints, Direction::Horizontal);
2589            let mut coherence = CoherenceCache::new(64);
2590            let mut rng = Lcg::new(0x5555_AAAA);
2591            let mut total: u16 = 90;
2592
2593            for step in 0..200 {
2594                let prev_total = total;
2595                let delta = (rng.next_u32() % 7) as i32 - 3;
2596                total = (total as i32 + delta).clamp(10, 250) as u16;
2597
2598                let flex = Flex::horizontal().constraints(constraints);
2599                let rects = flex.split(Rect::new(0, 0, total, 10));
2600                let widths: Vec<u16> = rects.iter().map(|r| r.width).collect();
2601
2602                let targets: Vec<f64> = widths.iter().map(|&w| w as f64).collect();
2603                let prev = coherence.get(&id);
2604                let rounded = round_layout_stable(&targets, total, prev);
2605
2606                if coherence.get(&id).is_some() {
2607                    let (_, max_disp) = coherence.displacement(&id, &rounded);
2608                    let size_change = total.abs_diff(prev_total);
2609                    assert!(
2610                        max_disp <= size_change as u32 + 2,
2611                        "step {}: max_disp={} exceeds size_change={} + 2",
2612                        step,
2613                        max_disp,
2614                        size_change
2615                    );
2616                }
2617                coherence.store(id, rounded);
2618            }
2619        }
2620
2621        #[test]
2622        fn property_temporal_stability_identical_frames() {
2623            let constraints = [
2624                Constraint::Fixed(20),
2625                Constraint::Fill,
2626                Constraint::Fixed(15),
2627            ];
2628            let id = CoherenceId::new(&constraints, Direction::Horizontal);
2629            let mut coherence = CoherenceCache::new(64);
2630
2631            let flex = Flex::horizontal().constraints(constraints);
2632            let rects = flex.split(Rect::new(0, 0, 100, 10));
2633            let widths: Vec<u16> = rects.iter().map(|r| r.width).collect();
2634            coherence.store(id, widths.iter().copied().collect());
2635
2636            for _ in 0..10 {
2637                let targets: Vec<f64> = widths.iter().map(|&w| w as f64).collect();
2638                let prev = coherence.get(&id);
2639                let rounded = round_layout_stable(&targets, 100, prev);
2640                let (sum_disp, _) = coherence.displacement(&id, &rounded);
2641                assert_eq!(sum_disp, 0, "Identical frames: zero displacement");
2642                coherence.store(id, rounded);
2643            }
2644        }
2645
2646        #[test]
2647        fn property_temporal_coherence_sweep() {
2648            let constraints = [
2649                Constraint::Percentage(25.0),
2650                Constraint::Percentage(50.0),
2651                Constraint::Fill,
2652            ];
2653            let id = CoherenceId::new(&constraints, Direction::Horizontal);
2654            let mut coherence = CoherenceCache::new(64);
2655            let mut total_displacement: u64 = 0;
2656
2657            for total in 60u16..=140 {
2658                let flex = Flex::horizontal().constraints(constraints);
2659                let rects = flex.split(Rect::new(0, 0, total, 10));
2660                let widths: Vec<u16> = rects.iter().map(|r| r.width).collect();
2661
2662                let targets: Vec<f64> = widths.iter().map(|&w| w as f64).collect();
2663                let prev = coherence.get(&id);
2664                let rounded = round_layout_stable(&targets, total, prev);
2665
2666                if coherence.get(&id).is_some() {
2667                    let (sum_disp, _) = coherence.displacement(&id, &rounded);
2668                    total_displacement += sum_disp;
2669                }
2670                coherence.store(id, rounded);
2671            }
2672
2673            assert!(
2674                total_displacement <= 80 * 3,
2675                "Total displacement {} exceeds bound for 80-step sweep",
2676                total_displacement
2677            );
2678        }
2679    }
2680
2681    // -----------------------------------------------------------------------
2682    // Snapshot Regression: Canonical Flex/Grid Layouts (bd-4kq0.4.3)
2683    // -----------------------------------------------------------------------
2684
2685    mod snapshot_layout_tests {
2686        use super::super::*;
2687        use crate::grid::{Grid, GridArea};
2688
2689        fn snapshot_flex(
2690            constraints: &[Constraint],
2691            dir: Direction,
2692            width: u16,
2693            height: u16,
2694        ) -> String {
2695            let flex = Flex::default()
2696                .direction(dir)
2697                .constraints(constraints.iter().copied());
2698            let rects = flex.split(Rect::new(0, 0, width, height));
2699            let mut out = format!(
2700                "Flex {:?} {}x{} ({} constraints)\n",
2701                dir,
2702                width,
2703                height,
2704                constraints.len()
2705            );
2706            for (i, r) in rects.iter().enumerate() {
2707                out.push_str(&format!(
2708                    "  [{}] x={} y={} w={} h={}\n",
2709                    i, r.x, r.y, r.width, r.height
2710                ));
2711            }
2712            let total: u16 = rects
2713                .iter()
2714                .map(|r| match dir {
2715                    Direction::Horizontal => r.width,
2716                    Direction::Vertical => r.height,
2717                })
2718                .sum();
2719            out.push_str(&format!("  total={}\n", total));
2720            out
2721        }
2722
2723        fn snapshot_grid(
2724            rows: &[Constraint],
2725            cols: &[Constraint],
2726            areas: &[(&str, GridArea)],
2727            width: u16,
2728            height: u16,
2729        ) -> String {
2730            let mut grid = Grid::new()
2731                .rows(rows.iter().copied())
2732                .columns(cols.iter().copied());
2733            for &(name, area) in areas {
2734                grid = grid.area(name, area);
2735            }
2736            let layout = grid.split(Rect::new(0, 0, width, height));
2737
2738            let mut out = format!(
2739                "Grid {}x{} ({}r x {}c)\n",
2740                width,
2741                height,
2742                rows.len(),
2743                cols.len()
2744            );
2745            for r in 0..rows.len() {
2746                for c in 0..cols.len() {
2747                    let rect = layout.cell(r, c);
2748                    out.push_str(&format!(
2749                        "  [{},{}] x={} y={} w={} h={}\n",
2750                        r, c, rect.x, rect.y, rect.width, rect.height
2751                    ));
2752                }
2753            }
2754            for &(name, _) in areas {
2755                if let Some(rect) = layout.area(name) {
2756                    out.push_str(&format!(
2757                        "  area({}) x={} y={} w={} h={}\n",
2758                        name, rect.x, rect.y, rect.width, rect.height
2759                    ));
2760                }
2761            }
2762            out
2763        }
2764
2765        // --- Flex snapshots: 80x24 ---
2766
2767        #[test]
2768        fn snapshot_flex_thirds_80x24() {
2769            let snap = snapshot_flex(
2770                &[
2771                    Constraint::Ratio(1, 3),
2772                    Constraint::Ratio(1, 3),
2773                    Constraint::Ratio(1, 3),
2774                ],
2775                Direction::Horizontal,
2776                80,
2777                24,
2778            );
2779            assert_eq!(
2780                snap,
2781                "\
2782Flex Horizontal 80x24 (3 constraints)
2783  [0] x=0 y=0 w=26 h=24
2784  [1] x=26 y=0 w=26 h=24
2785  [2] x=52 y=0 w=26 h=24
2786  total=78
2787"
2788            );
2789        }
2790
2791        #[test]
2792        fn snapshot_flex_sidebar_content_80x24() {
2793            let snap = snapshot_flex(
2794                &[Constraint::Fixed(20), Constraint::Fill],
2795                Direction::Horizontal,
2796                80,
2797                24,
2798            );
2799            assert_eq!(
2800                snap,
2801                "\
2802Flex Horizontal 80x24 (2 constraints)
2803  [0] x=0 y=0 w=20 h=24
2804  [1] x=20 y=0 w=60 h=24
2805  total=80
2806"
2807            );
2808        }
2809
2810        #[test]
2811        fn snapshot_flex_header_body_footer_80x24() {
2812            let snap = snapshot_flex(
2813                &[Constraint::Fixed(3), Constraint::Fill, Constraint::Fixed(1)],
2814                Direction::Vertical,
2815                80,
2816                24,
2817            );
2818            assert_eq!(
2819                snap,
2820                "\
2821Flex Vertical 80x24 (3 constraints)
2822  [0] x=0 y=0 w=80 h=3
2823  [1] x=0 y=3 w=80 h=20
2824  [2] x=0 y=23 w=80 h=1
2825  total=24
2826"
2827            );
2828        }
2829
2830        // --- Flex snapshots: 120x40 ---
2831
2832        #[test]
2833        fn snapshot_flex_thirds_120x40() {
2834            let snap = snapshot_flex(
2835                &[
2836                    Constraint::Ratio(1, 3),
2837                    Constraint::Ratio(1, 3),
2838                    Constraint::Ratio(1, 3),
2839                ],
2840                Direction::Horizontal,
2841                120,
2842                40,
2843            );
2844            assert_eq!(
2845                snap,
2846                "\
2847Flex Horizontal 120x40 (3 constraints)
2848  [0] x=0 y=0 w=40 h=40
2849  [1] x=40 y=0 w=40 h=40
2850  [2] x=80 y=0 w=40 h=40
2851  total=120
2852"
2853            );
2854        }
2855
2856        #[test]
2857        fn snapshot_flex_sidebar_content_120x40() {
2858            let snap = snapshot_flex(
2859                &[Constraint::Fixed(20), Constraint::Fill],
2860                Direction::Horizontal,
2861                120,
2862                40,
2863            );
2864            assert_eq!(
2865                snap,
2866                "\
2867Flex Horizontal 120x40 (2 constraints)
2868  [0] x=0 y=0 w=20 h=40
2869  [1] x=20 y=0 w=100 h=40
2870  total=120
2871"
2872            );
2873        }
2874
2875        #[test]
2876        fn snapshot_flex_percentage_mix_120x40() {
2877            let snap = snapshot_flex(
2878                &[
2879                    Constraint::Percentage(25.0),
2880                    Constraint::Percentage(50.0),
2881                    Constraint::Fill,
2882                ],
2883                Direction::Horizontal,
2884                120,
2885                40,
2886            );
2887            assert_eq!(
2888                snap,
2889                "\
2890Flex Horizontal 120x40 (3 constraints)
2891  [0] x=0 y=0 w=30 h=40
2892  [1] x=30 y=0 w=60 h=40
2893  [2] x=90 y=0 w=30 h=40
2894  total=120
2895"
2896            );
2897        }
2898
2899        // --- Grid snapshots: 80x24 ---
2900
2901        #[test]
2902        fn snapshot_grid_2x2_80x24() {
2903            let snap = snapshot_grid(
2904                &[Constraint::Fixed(3), Constraint::Fill],
2905                &[Constraint::Fixed(20), Constraint::Fill],
2906                &[
2907                    ("header", GridArea::span(0, 0, 1, 2)),
2908                    ("sidebar", GridArea::span(1, 0, 1, 1)),
2909                    ("content", GridArea::cell(1, 1)),
2910                ],
2911                80,
2912                24,
2913            );
2914            assert_eq!(
2915                snap,
2916                "\
2917Grid 80x24 (2r x 2c)
2918  [0,0] x=0 y=0 w=20 h=3
2919  [0,1] x=20 y=0 w=60 h=3
2920  [1,0] x=0 y=3 w=20 h=21
2921  [1,1] x=20 y=3 w=60 h=21
2922  area(header) x=0 y=0 w=80 h=3
2923  area(sidebar) x=0 y=3 w=20 h=21
2924  area(content) x=20 y=3 w=60 h=21
2925"
2926            );
2927        }
2928
2929        #[test]
2930        fn snapshot_grid_3x3_80x24() {
2931            let snap = snapshot_grid(
2932                &[Constraint::Fixed(1), Constraint::Fill, Constraint::Fixed(1)],
2933                &[
2934                    Constraint::Fixed(10),
2935                    Constraint::Fill,
2936                    Constraint::Fixed(10),
2937                ],
2938                &[],
2939                80,
2940                24,
2941            );
2942            assert_eq!(
2943                snap,
2944                "\
2945Grid 80x24 (3r x 3c)
2946  [0,0] x=0 y=0 w=10 h=1
2947  [0,1] x=10 y=0 w=60 h=1
2948  [0,2] x=70 y=0 w=10 h=1
2949  [1,0] x=0 y=1 w=10 h=22
2950  [1,1] x=10 y=1 w=60 h=22
2951  [1,2] x=70 y=1 w=10 h=22
2952  [2,0] x=0 y=23 w=10 h=1
2953  [2,1] x=10 y=23 w=60 h=1
2954  [2,2] x=70 y=23 w=10 h=1
2955"
2956            );
2957        }
2958
2959        // --- Grid snapshots: 120x40 ---
2960
2961        #[test]
2962        fn snapshot_grid_2x2_120x40() {
2963            let snap = snapshot_grid(
2964                &[Constraint::Fixed(3), Constraint::Fill],
2965                &[Constraint::Fixed(20), Constraint::Fill],
2966                &[
2967                    ("header", GridArea::span(0, 0, 1, 2)),
2968                    ("sidebar", GridArea::span(1, 0, 1, 1)),
2969                    ("content", GridArea::cell(1, 1)),
2970                ],
2971                120,
2972                40,
2973            );
2974            assert_eq!(
2975                snap,
2976                "\
2977Grid 120x40 (2r x 2c)
2978  [0,0] x=0 y=0 w=20 h=3
2979  [0,1] x=20 y=0 w=100 h=3
2980  [1,0] x=0 y=3 w=20 h=37
2981  [1,1] x=20 y=3 w=100 h=37
2982  area(header) x=0 y=0 w=120 h=3
2983  area(sidebar) x=0 y=3 w=20 h=37
2984  area(content) x=20 y=3 w=100 h=37
2985"
2986            );
2987        }
2988
2989        #[test]
2990        fn snapshot_grid_dashboard_120x40() {
2991            let snap = snapshot_grid(
2992                &[
2993                    Constraint::Fixed(3),
2994                    Constraint::Percentage(60.0),
2995                    Constraint::Fill,
2996                ],
2997                &[Constraint::Percentage(30.0), Constraint::Fill],
2998                &[
2999                    ("nav", GridArea::span(0, 0, 1, 2)),
3000                    ("chart", GridArea::cell(1, 0)),
3001                    ("detail", GridArea::cell(1, 1)),
3002                    ("log", GridArea::span(2, 0, 1, 2)),
3003                ],
3004                120,
3005                40,
3006            );
3007            assert_eq!(
3008                snap,
3009                "\
3010Grid 120x40 (3r x 2c)
3011  [0,0] x=0 y=0 w=36 h=3
3012  [0,1] x=36 y=0 w=84 h=3
3013  [1,0] x=0 y=3 w=36 h=24
3014  [1,1] x=36 y=3 w=84 h=24
3015  [2,0] x=0 y=27 w=36 h=13
3016  [2,1] x=36 y=27 w=84 h=13
3017  area(nav) x=0 y=0 w=120 h=3
3018  area(chart) x=0 y=3 w=36 h=24
3019  area(detail) x=36 y=3 w=84 h=24
3020  area(log) x=0 y=27 w=120 h=13
3021"
3022            );
3023        }
3024    }
3025}