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