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