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    #[test]
1246    fn percentage_rounding_never_exceeds_available() {
1247        let constraints = [
1248            Constraint::Percentage(33.4),
1249            Constraint::Percentage(33.3),
1250            Constraint::Percentage(33.3),
1251        ];
1252        let sizes = solve_constraints(&constraints, 7);
1253        let total: u16 = sizes.iter().sum();
1254        assert!(total <= 7, "percent rounding overflowed: {sizes:?}");
1255        assert!(sizes.iter().all(|size| *size <= 7));
1256    }
1257
1258    #[test]
1259    fn tiny_area_saturates_fixed_and_min() {
1260        let constraints = [Constraint::Fixed(5), Constraint::Min(3), Constraint::Max(2)];
1261        let sizes = solve_constraints(&constraints, 2);
1262        assert_eq!(sizes[0], 2);
1263        assert_eq!(sizes[1], 0);
1264        assert_eq!(sizes[2], 0);
1265        assert_eq!(sizes.iter().sum::<u16>(), 2);
1266    }
1267
1268    #[test]
1269    fn ratio_distribution_sums_to_available() {
1270        let constraints = [Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)];
1271        let sizes = solve_constraints(&constraints, 5);
1272        assert_eq!(sizes.iter().sum::<u16>(), 5);
1273        assert_eq!(sizes[0], 1);
1274        assert_eq!(sizes[1], 4);
1275    }
1276
1277    #[test]
1278    fn flex_gap_exceeds_area_yields_zero_widths() {
1279        let flex = Flex::horizontal()
1280            .gap(5)
1281            .constraints([Constraint::Fixed(1), Constraint::Fixed(1)]);
1282        let rects = flex.split(Rect::new(0, 0, 3, 1));
1283        assert_eq!(rects.len(), 2);
1284        assert_eq!(rects[0].width, 0);
1285        assert_eq!(rects[1].width, 0);
1286    }
1287
1288    // --- SpaceAround alignment ---
1289
1290    #[test]
1291    fn alignment_space_around() {
1292        let flex = Flex::horizontal()
1293            .alignment(Alignment::SpaceAround)
1294            .constraints([Constraint::Fixed(10), Constraint::Fixed(10)]);
1295        let rects = flex.split(Rect::new(0, 0, 100, 10));
1296
1297        // SpaceAround: leftover = 80, space_unit = 80/(2*2) = 20
1298        // First item starts at 20, second at 20+10+40=70
1299        assert_eq!(rects[0].x, 20);
1300        assert_eq!(rects[1].x, 70);
1301    }
1302
1303    // --- Vertical with gap ---
1304
1305    #[test]
1306    fn vertical_gap() {
1307        let flex = Flex::vertical()
1308            .gap(5)
1309            .constraints([Constraint::Fixed(10), Constraint::Fixed(10)]);
1310        let rects = flex.split(Rect::new(0, 0, 50, 100));
1311        assert_eq!(rects[0], Rect::new(0, 0, 50, 10));
1312        assert_eq!(rects[1], Rect::new(0, 15, 50, 10));
1313    }
1314
1315    // --- Vertical center alignment ---
1316
1317    #[test]
1318    fn vertical_center() {
1319        let flex = Flex::vertical()
1320            .alignment(Alignment::Center)
1321            .constraints([Constraint::Fixed(10)]);
1322        let rects = flex.split(Rect::new(0, 0, 50, 100));
1323        // leftover = 90, offset = 45
1324        assert_eq!(rects[0].y, 45);
1325        assert_eq!(rects[0].height, 10);
1326    }
1327
1328    // --- Single constraint gets all space ---
1329
1330    #[test]
1331    fn single_min_takes_all() {
1332        let flex = Flex::horizontal().constraints([Constraint::Min(5)]);
1333        let rects = flex.split(Rect::new(0, 0, 80, 24));
1334        assert_eq!(rects[0].width, 80);
1335    }
1336
1337    // --- Fixed exceeds available ---
1338
1339    #[test]
1340    fn fixed_exceeds_available_clamped() {
1341        let flex = Flex::horizontal().constraints([Constraint::Fixed(60), Constraint::Fixed(60)]);
1342        let rects = flex.split(Rect::new(0, 0, 100, 10));
1343        // First gets 60, second gets remaining 40 (clamped)
1344        assert_eq!(rects[0].width, 60);
1345        assert_eq!(rects[1].width, 40);
1346    }
1347
1348    // --- Percentage that sums beyond 100% ---
1349
1350    #[test]
1351    fn percentage_overflow_clamped() {
1352        let flex = Flex::horizontal()
1353            .constraints([Constraint::Percentage(80.0), Constraint::Percentage(80.0)]);
1354        let rects = flex.split(Rect::new(0, 0, 100, 10));
1355        assert_eq!(rects[0].width, 80);
1356        assert_eq!(rects[1].width, 20); // clamped to remaining
1357    }
1358
1359    // --- Margin reduces available space ---
1360
1361    #[test]
1362    fn margin_reduces_split_area() {
1363        let flex = Flex::horizontal()
1364            .margin(Sides::all(10))
1365            .constraints([Constraint::Fixed(20), Constraint::Min(0)]);
1366        let rects = flex.split(Rect::new(0, 0, 100, 100));
1367        // Inner: 10,10,80,80
1368        assert_eq!(rects[0].x, 10);
1369        assert_eq!(rects[0].y, 10);
1370        assert_eq!(rects[0].width, 20);
1371        assert_eq!(rects[0].height, 80);
1372    }
1373
1374    // --- Builder chain ---
1375
1376    #[test]
1377    fn builder_methods_chain() {
1378        let flex = Flex::vertical()
1379            .direction(Direction::Horizontal)
1380            .gap(3)
1381            .margin(Sides::all(1))
1382            .alignment(Alignment::End)
1383            .constraints([Constraint::Fixed(10)]);
1384        let rects = flex.split(Rect::new(0, 0, 50, 50));
1385        assert_eq!(rects.len(), 1);
1386    }
1387
1388    // --- SpaceBetween with single item ---
1389
1390    #[test]
1391    fn space_between_single_item() {
1392        let flex = Flex::horizontal()
1393            .alignment(Alignment::SpaceBetween)
1394            .constraints([Constraint::Fixed(10)]);
1395        let rects = flex.split(Rect::new(0, 0, 100, 10));
1396        // Single item: starts at 0, no extra spacing
1397        assert_eq!(rects[0].x, 0);
1398        assert_eq!(rects[0].width, 10);
1399    }
1400
1401    #[test]
1402    fn invariant_rects_within_bounds() {
1403        let area = Rect::new(10, 20, 80, 60);
1404        let flex = Flex::horizontal()
1405            .margin(Sides::all(5))
1406            .gap(2)
1407            .constraints([
1408                Constraint::Fixed(15),
1409                Constraint::Percentage(30.0),
1410                Constraint::Min(10),
1411            ]);
1412        let rects = flex.split(area);
1413
1414        // All rects should be within the inner area (after margin)
1415        let inner = area.inner(Sides::all(5));
1416        for rect in &rects {
1417            assert!(
1418                rect.x >= inner.x && rect.right() <= inner.right(),
1419                "Rect {:?} exceeds horizontal bounds of {:?}",
1420                rect,
1421                inner
1422            );
1423            assert!(
1424                rect.y >= inner.y && rect.bottom() <= inner.bottom(),
1425                "Rect {:?} exceeds vertical bounds of {:?}",
1426                rect,
1427                inner
1428            );
1429        }
1430    }
1431
1432    // --- Fill constraint ---
1433
1434    #[test]
1435    fn fill_takes_remaining_space() {
1436        let flex = Flex::horizontal().constraints([Constraint::Fixed(20), Constraint::Fill]);
1437        let rects = flex.split(Rect::new(0, 0, 100, 10));
1438        assert_eq!(rects[0].width, 20);
1439        assert_eq!(rects[1].width, 80); // Fill gets remaining
1440    }
1441
1442    #[test]
1443    fn multiple_fills_share_space() {
1444        let flex = Flex::horizontal().constraints([Constraint::Fill, Constraint::Fill]);
1445        let rects = flex.split(Rect::new(0, 0, 100, 10));
1446        assert_eq!(rects[0].width, 50);
1447        assert_eq!(rects[1].width, 50);
1448    }
1449
1450    // --- FitContent constraint ---
1451
1452    #[test]
1453    fn fit_content_uses_preferred_size() {
1454        let flex = Flex::horizontal().constraints([Constraint::FitContent, Constraint::Fill]);
1455        let rects = flex.split_with_measurer(Rect::new(0, 0, 100, 10), |idx, _| {
1456            if idx == 0 {
1457                LayoutSizeHint {
1458                    min: 5,
1459                    preferred: 30,
1460                    max: None,
1461                }
1462            } else {
1463                LayoutSizeHint::ZERO
1464            }
1465        });
1466        assert_eq!(rects[0].width, 30); // FitContent gets preferred
1467        assert_eq!(rects[1].width, 70); // Fill gets remainder
1468    }
1469
1470    #[test]
1471    fn fit_content_clamps_to_available() {
1472        let flex = Flex::horizontal().constraints([Constraint::FitContent, Constraint::FitContent]);
1473        let rects = flex.split_with_measurer(Rect::new(0, 0, 100, 10), |_, _| LayoutSizeHint {
1474            min: 10,
1475            preferred: 80,
1476            max: None,
1477        });
1478        // First FitContent takes 80, second gets remaining 20
1479        assert_eq!(rects[0].width, 80);
1480        assert_eq!(rects[1].width, 20);
1481    }
1482
1483    #[test]
1484    fn fit_content_without_measurer_gets_zero() {
1485        // Without measurer (via split()), FitContent gets zero from default hint
1486        let flex = Flex::horizontal().constraints([Constraint::FitContent, Constraint::Fill]);
1487        let rects = flex.split(Rect::new(0, 0, 100, 10));
1488        assert_eq!(rects[0].width, 0); // No preferred size
1489        assert_eq!(rects[1].width, 100); // Fill gets all
1490    }
1491
1492    #[test]
1493    fn fit_content_zero_area_returns_empty_rects() {
1494        let flex = Flex::horizontal().constraints([Constraint::FitContent, Constraint::Fill]);
1495        let rects = flex.split_with_measurer(Rect::new(0, 0, 0, 0), |_, _| LayoutSizeHint {
1496            min: 5,
1497            preferred: 10,
1498            max: None,
1499        });
1500        assert_eq!(rects.len(), 2);
1501        assert_eq!(rects[0].width, 0);
1502        assert_eq!(rects[0].height, 0);
1503        assert_eq!(rects[1].width, 0);
1504        assert_eq!(rects[1].height, 0);
1505    }
1506
1507    #[test]
1508    fn fit_content_tiny_available_clamps_to_remaining() {
1509        let flex = Flex::horizontal().constraints([Constraint::FitContent, Constraint::Fill]);
1510        let rects = flex.split_with_measurer(Rect::new(0, 0, 1, 1), |_, _| LayoutSizeHint {
1511            min: 5,
1512            preferred: 10,
1513            max: None,
1514        });
1515        assert_eq!(rects[0].width, 1);
1516        assert_eq!(rects[1].width, 0);
1517    }
1518
1519    // --- FitContentBounded constraint ---
1520
1521    #[test]
1522    fn fit_content_bounded_clamps_to_min() {
1523        let flex = Flex::horizontal().constraints([
1524            Constraint::FitContentBounded { min: 20, max: 50 },
1525            Constraint::Fill,
1526        ]);
1527        let rects = flex.split_with_measurer(Rect::new(0, 0, 100, 10), |_, _| LayoutSizeHint {
1528            min: 5,
1529            preferred: 10, // Below min bound
1530            max: None,
1531        });
1532        assert_eq!(rects[0].width, 20); // Clamped to min bound
1533        assert_eq!(rects[1].width, 80);
1534    }
1535
1536    #[test]
1537    fn fit_content_bounded_respects_small_available() {
1538        let flex = Flex::horizontal().constraints([
1539            Constraint::FitContentBounded { min: 20, max: 50 },
1540            Constraint::Fill,
1541        ]);
1542        let rects = flex.split_with_measurer(Rect::new(0, 0, 5, 2), |_, _| LayoutSizeHint {
1543            min: 5,
1544            preferred: 10,
1545            max: None,
1546        });
1547        // Available is 5 total, so FitContentBounded must clamp to remaining.
1548        assert_eq!(rects[0].width, 5);
1549        assert_eq!(rects[1].width, 0);
1550    }
1551
1552    #[test]
1553    fn fit_content_vertical_uses_preferred_height() {
1554        let flex = Flex::vertical().constraints([Constraint::FitContent, Constraint::Fill]);
1555        let rects = flex.split_with_measurer(Rect::new(0, 0, 10, 10), |idx, _| {
1556            if idx == 0 {
1557                LayoutSizeHint {
1558                    min: 1,
1559                    preferred: 4,
1560                    max: None,
1561                }
1562            } else {
1563                LayoutSizeHint::ZERO
1564            }
1565        });
1566        assert_eq!(rects[0].height, 4);
1567        assert_eq!(rects[1].height, 6);
1568    }
1569
1570    #[test]
1571    fn fit_content_bounded_clamps_to_max() {
1572        let flex = Flex::horizontal().constraints([
1573            Constraint::FitContentBounded { min: 10, max: 30 },
1574            Constraint::Fill,
1575        ]);
1576        let rects = flex.split_with_measurer(Rect::new(0, 0, 100, 10), |_, _| LayoutSizeHint {
1577            min: 5,
1578            preferred: 50, // Above max bound
1579            max: None,
1580        });
1581        assert_eq!(rects[0].width, 30); // Clamped to max bound
1582        assert_eq!(rects[1].width, 70);
1583    }
1584
1585    #[test]
1586    fn fit_content_bounded_uses_preferred_when_in_range() {
1587        let flex = Flex::horizontal().constraints([
1588            Constraint::FitContentBounded { min: 10, max: 50 },
1589            Constraint::Fill,
1590        ]);
1591        let rects = flex.split_with_measurer(Rect::new(0, 0, 100, 10), |_, _| LayoutSizeHint {
1592            min: 5,
1593            preferred: 35, // Within bounds
1594            max: None,
1595        });
1596        assert_eq!(rects[0].width, 35);
1597        assert_eq!(rects[1].width, 65);
1598    }
1599
1600    // --- FitMin constraint ---
1601
1602    #[test]
1603    fn fit_min_uses_minimum_size() {
1604        let flex = Flex::horizontal().constraints([Constraint::FitMin, Constraint::Fill]);
1605        let rects = flex.split_with_measurer(Rect::new(0, 0, 100, 10), |idx, _| {
1606            if idx == 0 {
1607                LayoutSizeHint {
1608                    min: 15,
1609                    preferred: 40,
1610                    max: None,
1611                }
1612            } else {
1613                LayoutSizeHint::ZERO
1614            }
1615        });
1616        // FitMin gets minimum (15) + grows with remaining
1617        // Since Fill is also a grow candidate, they share the 85 remaining
1618        // FitMin base: 15, grows by (85/2) = 42.5 rounded to 42
1619        // Actually: FitMin gets 15 initially, remaining = 85
1620        // Then both FitMin and Fill compete for 85 with equal weight
1621        // FitMin gets 15 + 42 = 57, Fill gets 43
1622        // Wait, let me trace through the logic more carefully.
1623        //
1624        // After first pass: FitMin gets 15, remaining = 85. FitMin added to grow_indices.
1625        // Fill gets 0, added to grow_indices.
1626        // In grow loop: 85 distributed evenly (weight 1 each) = 42.5 each
1627        // FitMin: 15 + 42 = 57 (or 58 if rounding gives it the extra)
1628        // Actually the last item gets remainder to ensure exact sum
1629        let total: u16 = rects.iter().map(|r| r.width).sum();
1630        assert_eq!(total, 100);
1631        assert!(rects[0].width >= 15, "FitMin should get at least minimum");
1632    }
1633
1634    #[test]
1635    fn fit_min_without_measurer_gets_zero() {
1636        let flex = Flex::horizontal().constraints([Constraint::FitMin, Constraint::Fill]);
1637        let rects = flex.split(Rect::new(0, 0, 100, 10));
1638        // Without measurer, min is 0, so FitMin gets 0 initially, then grows
1639        // Both FitMin and Fill share 100 evenly
1640        assert_eq!(rects[0].width, 50);
1641        assert_eq!(rects[1].width, 50);
1642    }
1643
1644    // --- LayoutSizeHint tests ---
1645
1646    #[test]
1647    fn layout_size_hint_zero_is_default() {
1648        assert_eq!(LayoutSizeHint::default(), LayoutSizeHint::ZERO);
1649    }
1650
1651    #[test]
1652    fn layout_size_hint_exact() {
1653        let h = LayoutSizeHint::exact(25);
1654        assert_eq!(h.min, 25);
1655        assert_eq!(h.preferred, 25);
1656        assert_eq!(h.max, Some(25));
1657    }
1658
1659    #[test]
1660    fn layout_size_hint_at_least() {
1661        let h = LayoutSizeHint::at_least(10, 30);
1662        assert_eq!(h.min, 10);
1663        assert_eq!(h.preferred, 30);
1664        assert_eq!(h.max, None);
1665    }
1666
1667    #[test]
1668    fn layout_size_hint_clamp() {
1669        let h = LayoutSizeHint {
1670            min: 10,
1671            preferred: 20,
1672            max: Some(30),
1673        };
1674        assert_eq!(h.clamp(5), 10); // Below min
1675        assert_eq!(h.clamp(15), 15); // In range
1676        assert_eq!(h.clamp(50), 30); // Above max
1677    }
1678
1679    #[test]
1680    fn layout_size_hint_clamp_unbounded() {
1681        let h = LayoutSizeHint::at_least(5, 10);
1682        assert_eq!(h.clamp(3), 5); // Below min
1683        assert_eq!(h.clamp(1000), 1000); // No max, stays as-is
1684    }
1685
1686    // --- Integration: FitContent with other constraints ---
1687
1688    #[test]
1689    fn fit_content_with_fixed_and_fill() {
1690        let flex = Flex::horizontal().constraints([
1691            Constraint::Fixed(20),
1692            Constraint::FitContent,
1693            Constraint::Fill,
1694        ]);
1695        let rects = flex.split_with_measurer(Rect::new(0, 0, 100, 10), |idx, _| {
1696            if idx == 1 {
1697                LayoutSizeHint {
1698                    min: 5,
1699                    preferred: 25,
1700                    max: None,
1701                }
1702            } else {
1703                LayoutSizeHint::ZERO
1704            }
1705        });
1706        assert_eq!(rects[0].width, 20); // Fixed
1707        assert_eq!(rects[1].width, 25); // FitContent preferred
1708        assert_eq!(rects[2].width, 55); // Fill gets remainder
1709    }
1710
1711    #[test]
1712    fn total_allocation_never_exceeds_available_with_fit_content() {
1713        for available in [10u16, 50, 100, 255] {
1714            let flex = Flex::horizontal().constraints([
1715                Constraint::FitContent,
1716                Constraint::FitContent,
1717                Constraint::Fill,
1718            ]);
1719            let rects =
1720                flex.split_with_measurer(Rect::new(0, 0, available, 10), |_, _| LayoutSizeHint {
1721                    min: 10,
1722                    preferred: 40,
1723                    max: None,
1724                });
1725            let total: u16 = rects.iter().map(|r| r.width).sum();
1726            assert!(
1727                total <= available,
1728                "Total {} exceeded available {} with FitContent",
1729                total,
1730                available
1731            );
1732        }
1733    }
1734
1735    // -----------------------------------------------------------------------
1736    // Stable Layout Rounding Tests (bd-4kq0.4.1)
1737    // -----------------------------------------------------------------------
1738
1739    mod rounding_tests {
1740        use super::super::*;
1741
1742        // --- Sum conservation (REQUIRED) ---
1743
1744        #[test]
1745        fn rounding_conserves_sum_exact() {
1746            let result = round_layout_stable(&[10.0, 20.0, 10.0], 40, None);
1747            assert_eq!(result.iter().copied().sum::<u16>(), 40);
1748            assert_eq!(result, vec![10, 20, 10]);
1749        }
1750
1751        #[test]
1752        fn rounding_conserves_sum_fractional() {
1753            let result = round_layout_stable(&[10.4, 20.6, 9.0], 40, None);
1754            assert_eq!(
1755                result.iter().copied().sum::<u16>(),
1756                40,
1757                "Sum must equal total: {:?}",
1758                result
1759            );
1760        }
1761
1762        #[test]
1763        fn rounding_conserves_sum_many_fractions() {
1764            let targets = vec![20.2, 20.2, 20.2, 20.2, 19.2];
1765            let result = round_layout_stable(&targets, 100, None);
1766            assert_eq!(
1767                result.iter().copied().sum::<u16>(),
1768                100,
1769                "Sum must be exactly 100: {:?}",
1770                result
1771            );
1772        }
1773
1774        #[test]
1775        fn rounding_conserves_sum_all_half() {
1776            let targets = vec![10.5, 10.5, 10.5, 10.5];
1777            let result = round_layout_stable(&targets, 42, None);
1778            assert_eq!(
1779                result.iter().copied().sum::<u16>(),
1780                42,
1781                "Sum must be exactly 42: {:?}",
1782                result
1783            );
1784        }
1785
1786        // --- Bounded displacement ---
1787
1788        #[test]
1789        fn rounding_displacement_bounded() {
1790            let targets = vec![33.33, 33.33, 33.34];
1791            let result = round_layout_stable(&targets, 100, None);
1792            assert_eq!(result.iter().copied().sum::<u16>(), 100);
1793
1794            for (i, (&x, &r)) in result.iter().zip(targets.iter()).enumerate() {
1795                let floor = r.floor() as u16;
1796                let ceil = floor + 1;
1797                assert!(
1798                    x == floor || x == ceil,
1799                    "Element {} = {} not in {{floor={}, ceil={}}} of target {}",
1800                    i,
1801                    x,
1802                    floor,
1803                    ceil,
1804                    r
1805                );
1806            }
1807        }
1808
1809        // --- Temporal tie-break (REQUIRED) ---
1810
1811        #[test]
1812        fn temporal_tiebreak_stable_when_unchanged() {
1813            let targets = vec![10.5, 10.5, 10.5, 10.5];
1814            let first = round_layout_stable(&targets, 42, None);
1815            let second = round_layout_stable(&targets, 42, Some(first.clone()));
1816            assert_eq!(
1817                first, second,
1818                "Identical targets should produce identical results"
1819            );
1820        }
1821
1822        #[test]
1823        fn temporal_tiebreak_prefers_previous_direction() {
1824            let targets = vec![10.5, 10.5];
1825            let total = 21;
1826            let first = round_layout_stable(&targets, total, None);
1827            assert_eq!(first.iter().copied().sum::<u16>(), total);
1828            let second = round_layout_stable(&targets, total, Some(first.clone()));
1829            assert_eq!(first, second, "Should maintain rounding direction");
1830        }
1831
1832        #[test]
1833        fn temporal_tiebreak_adapts_to_changed_targets() {
1834            let targets_a = vec![10.5, 10.5];
1835            let result_a = round_layout_stable(&targets_a, 21, None);
1836            let targets_b = vec![15.7, 5.3];
1837            let result_b = round_layout_stable(&targets_b, 21, Some(result_a));
1838            assert_eq!(result_b.iter().copied().sum::<u16>(), 21);
1839            assert!(result_b[0] > result_b[1], "Should follow larger target");
1840        }
1841
1842        // --- Property: min displacement (REQUIRED) ---
1843
1844        #[test]
1845        fn property_min_displacement_brute_force_small() {
1846            let targets = vec![3.3, 3.3, 3.4];
1847            let total: u16 = 10;
1848            let result = round_layout_stable(&targets, total, None);
1849            let our_displacement: f64 = result
1850                .iter()
1851                .zip(targets.iter())
1852                .map(|(&x, &r)| (x as f64 - r).abs())
1853                .sum();
1854
1855            let mut min_displacement = f64::MAX;
1856            let floors: Vec<u16> = targets.iter().map(|&r| r.floor() as u16).collect();
1857            let ceils: Vec<u16> = targets.iter().map(|&r| r.floor() as u16 + 1).collect();
1858
1859            for a in floors[0]..=ceils[0] {
1860                for b in floors[1]..=ceils[1] {
1861                    for c in floors[2]..=ceils[2] {
1862                        if a + b + c == total {
1863                            let disp = (a as f64 - targets[0]).abs()
1864                                + (b as f64 - targets[1]).abs()
1865                                + (c as f64 - targets[2]).abs();
1866                            if disp < min_displacement {
1867                                min_displacement = disp;
1868                            }
1869                        }
1870                    }
1871                }
1872            }
1873
1874            assert!(
1875                (our_displacement - min_displacement).abs() < 1e-10,
1876                "Our displacement {} should match optimal {}: {:?}",
1877                our_displacement,
1878                min_displacement,
1879                result
1880            );
1881        }
1882
1883        // --- Determinism ---
1884
1885        #[test]
1886        fn rounding_deterministic() {
1887            let targets = vec![7.7, 8.3, 14.0];
1888            let a = round_layout_stable(&targets, 30, None);
1889            let b = round_layout_stable(&targets, 30, None);
1890            assert_eq!(a, b, "Same inputs must produce identical outputs");
1891        }
1892
1893        // --- Edge cases ---
1894
1895        #[test]
1896        fn rounding_empty_targets() {
1897            let result = round_layout_stable(&[], 0, None);
1898            assert!(result.is_empty());
1899        }
1900
1901        #[test]
1902        fn rounding_single_element() {
1903            let result = round_layout_stable(&[10.7], 11, None);
1904            assert_eq!(result, vec![11]);
1905        }
1906
1907        #[test]
1908        fn rounding_zero_total() {
1909            let result = round_layout_stable(&[5.0, 5.0], 0, None);
1910            assert_eq!(result.iter().copied().sum::<u16>(), 0);
1911        }
1912
1913        #[test]
1914        fn rounding_all_zeros() {
1915            let result = round_layout_stable(&[0.0, 0.0, 0.0], 0, None);
1916            assert_eq!(result, vec![0, 0, 0]);
1917        }
1918
1919        #[test]
1920        fn rounding_integer_targets() {
1921            let result = round_layout_stable(&[10.0, 20.0, 30.0], 60, None);
1922            assert_eq!(result, vec![10, 20, 30]);
1923        }
1924
1925        #[test]
1926        fn rounding_large_deficit() {
1927            let result = round_layout_stable(&[0.9, 0.9, 0.9], 3, None);
1928            assert_eq!(result.iter().copied().sum::<u16>(), 3);
1929            assert_eq!(result, vec![1, 1, 1]);
1930        }
1931
1932        #[test]
1933        fn rounding_with_prev_different_length() {
1934            let result = round_layout_stable(&[10.5, 10.5], 21, Some(vec![11, 10, 5]));
1935            assert_eq!(result.iter().copied().sum::<u16>(), 21);
1936        }
1937
1938        #[test]
1939        fn rounding_very_small_fractions() {
1940            let targets = vec![10.001, 20.001, 9.998];
1941            let result = round_layout_stable(&targets, 40, None);
1942            assert_eq!(result.iter().copied().sum::<u16>(), 40);
1943        }
1944
1945        #[test]
1946        fn rounding_conserves_sum_stress() {
1947            let n = 50;
1948            let targets: Vec<f64> = (0..n).map(|i| 2.0 + (i as f64 * 0.037)).collect();
1949            let total = 120u16;
1950            let result = round_layout_stable(&targets, total, None);
1951            assert_eq!(
1952                result.iter().copied().sum::<u16>(),
1953                total,
1954                "Sum must be exactly {} for {} items: {:?}",
1955                total,
1956                n,
1957                result
1958            );
1959        }
1960    }
1961
1962    // -----------------------------------------------------------------------
1963    // Property Tests: Constraint Satisfaction (bd-4kq0.4.3)
1964    // -----------------------------------------------------------------------
1965
1966    mod property_constraint_tests {
1967        use super::super::*;
1968
1969        /// Deterministic LCG pseudo-random number generator (no external deps).
1970        struct Lcg(u64);
1971
1972        impl Lcg {
1973            fn new(seed: u64) -> Self {
1974                Self(seed)
1975            }
1976            fn next_u32(&mut self) -> u32 {
1977                self.0 = self
1978                    .0
1979                    .wrapping_mul(6_364_136_223_846_793_005)
1980                    .wrapping_add(1);
1981                (self.0 >> 33) as u32
1982            }
1983            fn next_u16_range(&mut self, lo: u16, hi: u16) -> u16 {
1984                if lo >= hi {
1985                    return lo;
1986                }
1987                lo + (self.next_u32() % (hi - lo) as u32) as u16
1988            }
1989            fn next_f32(&mut self) -> f32 {
1990                (self.next_u32() & 0x00FF_FFFF) as f32 / 16_777_216.0
1991            }
1992        }
1993
1994        /// Generate a random constraint from the LCG.
1995        fn random_constraint(rng: &mut Lcg) -> Constraint {
1996            match rng.next_u32() % 7 {
1997                0 => Constraint::Fixed(rng.next_u16_range(1, 80)),
1998                1 => Constraint::Percentage(rng.next_f32() * 100.0),
1999                2 => Constraint::Min(rng.next_u16_range(0, 40)),
2000                3 => Constraint::Max(rng.next_u16_range(5, 120)),
2001                4 => {
2002                    let n = rng.next_u32() % 5 + 1;
2003                    let d = rng.next_u32() % 5 + 1;
2004                    Constraint::Ratio(n, d)
2005                }
2006                5 => Constraint::Fill,
2007                _ => Constraint::FitContent,
2008            }
2009        }
2010
2011        #[test]
2012        fn property_constraints_respected_fixed() {
2013            let mut rng = Lcg::new(0xDEAD_BEEF);
2014            for _ in 0..200 {
2015                let fixed_val = rng.next_u16_range(1, 60);
2016                let avail = rng.next_u16_range(10, 200);
2017                let flex = Flex::horizontal().constraints([Constraint::Fixed(fixed_val)]);
2018                let rects = flex.split(Rect::new(0, 0, avail, 10));
2019                assert!(
2020                    rects[0].width <= fixed_val.min(avail),
2021                    "Fixed({}) in avail {} -> width {}",
2022                    fixed_val,
2023                    avail,
2024                    rects[0].width
2025                );
2026            }
2027        }
2028
2029        #[test]
2030        fn property_constraints_respected_max() {
2031            let mut rng = Lcg::new(0xCAFE_BABE);
2032            for _ in 0..200 {
2033                let max_val = rng.next_u16_range(5, 80);
2034                let avail = rng.next_u16_range(10, 200);
2035                let flex =
2036                    Flex::horizontal().constraints([Constraint::Max(max_val), Constraint::Fill]);
2037                let rects = flex.split(Rect::new(0, 0, avail, 10));
2038                assert!(
2039                    rects[0].width <= max_val,
2040                    "Max({}) in avail {} -> width {}",
2041                    max_val,
2042                    avail,
2043                    rects[0].width
2044                );
2045            }
2046        }
2047
2048        #[test]
2049        fn property_constraints_respected_min() {
2050            let mut rng = Lcg::new(0xBAAD_F00D);
2051            for _ in 0..200 {
2052                let min_val = rng.next_u16_range(0, 40);
2053                let avail = rng.next_u16_range(min_val.max(1), 200);
2054                let flex = Flex::horizontal().constraints([Constraint::Min(min_val)]);
2055                let rects = flex.split(Rect::new(0, 0, avail, 10));
2056                assert!(
2057                    rects[0].width >= min_val,
2058                    "Min({}) in avail {} -> width {}",
2059                    min_val,
2060                    avail,
2061                    rects[0].width
2062                );
2063            }
2064        }
2065
2066        #[test]
2067        fn property_constraints_respected_ratio_proportional() {
2068            let mut rng = Lcg::new(0x1234_5678);
2069            for _ in 0..200 {
2070                let n1 = rng.next_u32() % 5 + 1;
2071                let n2 = rng.next_u32() % 5 + 1;
2072                let d = rng.next_u32() % 5 + 1;
2073                let avail = rng.next_u16_range(20, 200);
2074                let flex = Flex::horizontal()
2075                    .constraints([Constraint::Ratio(n1, d), Constraint::Ratio(n2, d)]);
2076                let rects = flex.split(Rect::new(0, 0, avail, 10));
2077                let w1 = rects[0].width as f64;
2078                let w2 = rects[1].width as f64;
2079                let total = w1 + w2;
2080                if total > 0.0 {
2081                    let expected_ratio = n1 as f64 / (n1 + n2) as f64;
2082                    let actual_ratio = w1 / total;
2083                    assert!(
2084                        (actual_ratio - expected_ratio).abs() < 0.15 || total < 4.0,
2085                        "Ratio({},{})/({}+{}) avail={}: ~{:.2} got {:.2} (w1={}, w2={})",
2086                        n1,
2087                        d,
2088                        n1,
2089                        n2,
2090                        avail,
2091                        expected_ratio,
2092                        actual_ratio,
2093                        w1,
2094                        w2
2095                    );
2096                }
2097            }
2098        }
2099
2100        #[test]
2101        fn property_total_allocation_never_exceeds_available() {
2102            let mut rng = Lcg::new(0xFACE_FEED);
2103            for _ in 0..500 {
2104                let n = (rng.next_u32() % 6 + 1) as usize;
2105                let constraints: Vec<Constraint> =
2106                    (0..n).map(|_| random_constraint(&mut rng)).collect();
2107                let avail = rng.next_u16_range(5, 200);
2108                let dir = if rng.next_u32().is_multiple_of(2) {
2109                    Direction::Horizontal
2110                } else {
2111                    Direction::Vertical
2112                };
2113                let flex = Flex::default().direction(dir).constraints(constraints);
2114                let area = Rect::new(0, 0, avail, avail);
2115                let rects = flex.split(area);
2116                let total: u16 = rects
2117                    .iter()
2118                    .map(|r| match dir {
2119                        Direction::Horizontal => r.width,
2120                        Direction::Vertical => r.height,
2121                    })
2122                    .sum();
2123                assert!(
2124                    total <= avail,
2125                    "Total {} exceeded available {} with {} constraints",
2126                    total,
2127                    avail,
2128                    n
2129                );
2130            }
2131        }
2132
2133        #[test]
2134        fn property_no_overlap_horizontal() {
2135            let mut rng = Lcg::new(0xABCD_1234);
2136            for _ in 0..300 {
2137                let n = (rng.next_u32() % 5 + 2) as usize;
2138                let constraints: Vec<Constraint> =
2139                    (0..n).map(|_| random_constraint(&mut rng)).collect();
2140                let avail = rng.next_u16_range(20, 200);
2141                let flex = Flex::horizontal().constraints(constraints);
2142                let rects = flex.split(Rect::new(0, 0, avail, 10));
2143
2144                for i in 1..rects.len() {
2145                    let prev_end = rects[i - 1].x + rects[i - 1].width;
2146                    assert!(
2147                        rects[i].x >= prev_end,
2148                        "Overlap at {}: prev ends {}, next starts {}",
2149                        i,
2150                        prev_end,
2151                        rects[i].x
2152                    );
2153                }
2154            }
2155        }
2156
2157        #[test]
2158        fn property_deterministic_across_runs() {
2159            let mut rng = Lcg::new(0x9999_8888);
2160            for _ in 0..100 {
2161                let n = (rng.next_u32() % 5 + 1) as usize;
2162                let constraints: Vec<Constraint> =
2163                    (0..n).map(|_| random_constraint(&mut rng)).collect();
2164                let avail = rng.next_u16_range(10, 200);
2165                let r1 = Flex::horizontal()
2166                    .constraints(constraints.clone())
2167                    .split(Rect::new(0, 0, avail, 10));
2168                let r2 = Flex::horizontal()
2169                    .constraints(constraints)
2170                    .split(Rect::new(0, 0, avail, 10));
2171                assert_eq!(r1, r2, "Determinism violation at avail={}", avail);
2172            }
2173        }
2174    }
2175
2176    // -----------------------------------------------------------------------
2177    // Property Tests: Temporal Stability (bd-4kq0.4.3)
2178    // -----------------------------------------------------------------------
2179
2180    mod property_temporal_tests {
2181        use super::super::*;
2182        use crate::cache::{CoherenceCache, CoherenceId};
2183
2184        /// Deterministic LCG.
2185        struct Lcg(u64);
2186
2187        impl Lcg {
2188            fn new(seed: u64) -> Self {
2189                Self(seed)
2190            }
2191            fn next_u32(&mut self) -> u32 {
2192                self.0 = self
2193                    .0
2194                    .wrapping_mul(6_364_136_223_846_793_005)
2195                    .wrapping_add(1);
2196                (self.0 >> 33) as u32
2197            }
2198        }
2199
2200        #[test]
2201        fn property_temporal_stability_small_resize() {
2202            let constraints = [
2203                Constraint::Percentage(33.3),
2204                Constraint::Percentage(33.3),
2205                Constraint::Fill,
2206            ];
2207            let mut coherence = CoherenceCache::new(64);
2208            let id = CoherenceId::new(&constraints, Direction::Horizontal);
2209
2210            for total in [80u16, 100, 120] {
2211                let flex = Flex::horizontal().constraints(constraints);
2212                let rects = flex.split(Rect::new(0, 0, total, 10));
2213                let widths: Vec<u16> = rects.iter().map(|r| r.width).collect();
2214
2215                let targets: Vec<f64> = widths.iter().map(|&w| w as f64).collect();
2216                let prev = coherence.get(&id);
2217                let rounded = round_layout_stable(&targets, total, prev);
2218
2219                if let Some(old) = coherence.get(&id) {
2220                    let (sum_disp, max_disp) = coherence.displacement(&id, &rounded);
2221                    assert!(
2222                        max_disp <= total.abs_diff(old.iter().copied().sum()) as u32 + 1,
2223                        "max_disp={} too large for size change {} -> {}",
2224                        max_disp,
2225                        old.iter().copied().sum::<u16>(),
2226                        total
2227                    );
2228                    let _ = sum_disp;
2229                }
2230                coherence.store(id, rounded);
2231            }
2232        }
2233
2234        #[test]
2235        fn property_temporal_stability_random_walk() {
2236            let constraints = [
2237                Constraint::Ratio(1, 3),
2238                Constraint::Ratio(1, 3),
2239                Constraint::Ratio(1, 3),
2240            ];
2241            let id = CoherenceId::new(&constraints, Direction::Horizontal);
2242            let mut coherence = CoherenceCache::new(64);
2243            let mut rng = Lcg::new(0x5555_AAAA);
2244            let mut total: u16 = 90;
2245
2246            for step in 0..200 {
2247                let prev_total = total;
2248                let delta = (rng.next_u32() % 7) as i32 - 3;
2249                total = (total as i32 + delta).clamp(10, 250) as u16;
2250
2251                let flex = Flex::horizontal().constraints(constraints);
2252                let rects = flex.split(Rect::new(0, 0, total, 10));
2253                let widths: Vec<u16> = rects.iter().map(|r| r.width).collect();
2254
2255                let targets: Vec<f64> = widths.iter().map(|&w| w as f64).collect();
2256                let prev = coherence.get(&id);
2257                let rounded = round_layout_stable(&targets, total, prev);
2258
2259                if coherence.get(&id).is_some() {
2260                    let (_, max_disp) = coherence.displacement(&id, &rounded);
2261                    let size_change = total.abs_diff(prev_total);
2262                    assert!(
2263                        max_disp <= size_change as u32 + 2,
2264                        "step {}: max_disp={} exceeds size_change={} + 2",
2265                        step,
2266                        max_disp,
2267                        size_change
2268                    );
2269                }
2270                coherence.store(id, rounded);
2271            }
2272        }
2273
2274        #[test]
2275        fn property_temporal_stability_identical_frames() {
2276            let constraints = [
2277                Constraint::Fixed(20),
2278                Constraint::Fill,
2279                Constraint::Fixed(15),
2280            ];
2281            let id = CoherenceId::new(&constraints, Direction::Horizontal);
2282            let mut coherence = CoherenceCache::new(64);
2283
2284            let flex = Flex::horizontal().constraints(constraints);
2285            let rects = flex.split(Rect::new(0, 0, 100, 10));
2286            let widths: Vec<u16> = rects.iter().map(|r| r.width).collect();
2287            coherence.store(id, widths.clone());
2288
2289            for _ in 0..10 {
2290                let targets: Vec<f64> = widths.iter().map(|&w| w as f64).collect();
2291                let prev = coherence.get(&id);
2292                let rounded = round_layout_stable(&targets, 100, prev);
2293                let (sum_disp, max_disp) = coherence.displacement(&id, &rounded);
2294                assert_eq!(sum_disp, 0, "Identical frames: zero displacement");
2295                assert_eq!(max_disp, 0);
2296                coherence.store(id, rounded);
2297            }
2298        }
2299
2300        #[test]
2301        fn property_temporal_coherence_sweep() {
2302            let constraints = [
2303                Constraint::Percentage(25.0),
2304                Constraint::Percentage(50.0),
2305                Constraint::Fill,
2306            ];
2307            let id = CoherenceId::new(&constraints, Direction::Horizontal);
2308            let mut coherence = CoherenceCache::new(64);
2309            let mut total_displacement: u64 = 0;
2310
2311            for total in 60u16..=140 {
2312                let flex = Flex::horizontal().constraints(constraints);
2313                let rects = flex.split(Rect::new(0, 0, total, 10));
2314                let widths: Vec<u16> = rects.iter().map(|r| r.width).collect();
2315
2316                let targets: Vec<f64> = widths.iter().map(|&w| w as f64).collect();
2317                let prev = coherence.get(&id);
2318                let rounded = round_layout_stable(&targets, total, prev);
2319
2320                if coherence.get(&id).is_some() {
2321                    let (sum_disp, _) = coherence.displacement(&id, &rounded);
2322                    total_displacement += sum_disp;
2323                }
2324                coherence.store(id, rounded);
2325            }
2326
2327            assert!(
2328                total_displacement <= 80 * 3,
2329                "Total displacement {} exceeds bound for 80-step sweep",
2330                total_displacement
2331            );
2332        }
2333    }
2334
2335    // -----------------------------------------------------------------------
2336    // Snapshot Regression: Canonical Flex/Grid Layouts (bd-4kq0.4.3)
2337    // -----------------------------------------------------------------------
2338
2339    mod snapshot_layout_tests {
2340        use super::super::*;
2341        use crate::grid::{Grid, GridArea};
2342
2343        fn snapshot_flex(
2344            constraints: &[Constraint],
2345            dir: Direction,
2346            width: u16,
2347            height: u16,
2348        ) -> String {
2349            let flex = Flex::default()
2350                .direction(dir)
2351                .constraints(constraints.iter().copied());
2352            let rects = flex.split(Rect::new(0, 0, width, height));
2353            let mut out = format!(
2354                "Flex {:?} {}x{} ({} constraints)\n",
2355                dir,
2356                width,
2357                height,
2358                constraints.len()
2359            );
2360            for (i, r) in rects.iter().enumerate() {
2361                out.push_str(&format!(
2362                    "  [{}] x={} y={} w={} h={}\n",
2363                    i, r.x, r.y, r.width, r.height
2364                ));
2365            }
2366            let total: u16 = rects
2367                .iter()
2368                .map(|r| match dir {
2369                    Direction::Horizontal => r.width,
2370                    Direction::Vertical => r.height,
2371                })
2372                .sum();
2373            out.push_str(&format!("  total={}\n", total));
2374            out
2375        }
2376
2377        fn snapshot_grid(
2378            rows: &[Constraint],
2379            cols: &[Constraint],
2380            areas: &[(&str, GridArea)],
2381            width: u16,
2382            height: u16,
2383        ) -> String {
2384            let mut grid = Grid::new()
2385                .rows(rows.iter().copied())
2386                .columns(cols.iter().copied());
2387            for &(name, area) in areas {
2388                grid = grid.area(name, area);
2389            }
2390            let layout = grid.split(Rect::new(0, 0, width, height));
2391
2392            let mut out = format!(
2393                "Grid {}x{} ({}r x {}c)\n",
2394                width,
2395                height,
2396                rows.len(),
2397                cols.len()
2398            );
2399            for r in 0..rows.len() {
2400                for c in 0..cols.len() {
2401                    let rect = layout.cell(r, c);
2402                    out.push_str(&format!(
2403                        "  [{},{}] x={} y={} w={} h={}\n",
2404                        r, c, rect.x, rect.y, rect.width, rect.height
2405                    ));
2406                }
2407            }
2408            for &(name, _) in areas {
2409                if let Some(rect) = layout.area(name) {
2410                    out.push_str(&format!(
2411                        "  area({}) x={} y={} w={} h={}\n",
2412                        name, rect.x, rect.y, rect.width, rect.height
2413                    ));
2414                }
2415            }
2416            out
2417        }
2418
2419        // --- Flex snapshots: 80x24 ---
2420
2421        #[test]
2422        fn snapshot_flex_thirds_80x24() {
2423            let snap = snapshot_flex(
2424                &[
2425                    Constraint::Ratio(1, 3),
2426                    Constraint::Ratio(1, 3),
2427                    Constraint::Ratio(1, 3),
2428                ],
2429                Direction::Horizontal,
2430                80,
2431                24,
2432            );
2433            assert_eq!(
2434                snap,
2435                "\
2436Flex Horizontal 80x24 (3 constraints)
2437  [0] x=0 y=0 w=26 h=24
2438  [1] x=26 y=0 w=26 h=24
2439  [2] x=52 y=0 w=28 h=24
2440  total=80
2441"
2442            );
2443        }
2444
2445        #[test]
2446        fn snapshot_flex_sidebar_content_80x24() {
2447            let snap = snapshot_flex(
2448                &[Constraint::Fixed(20), Constraint::Fill],
2449                Direction::Horizontal,
2450                80,
2451                24,
2452            );
2453            assert_eq!(
2454                snap,
2455                "\
2456Flex Horizontal 80x24 (2 constraints)
2457  [0] x=0 y=0 w=20 h=24
2458  [1] x=20 y=0 w=60 h=24
2459  total=80
2460"
2461            );
2462        }
2463
2464        #[test]
2465        fn snapshot_flex_header_body_footer_80x24() {
2466            let snap = snapshot_flex(
2467                &[Constraint::Fixed(3), Constraint::Fill, Constraint::Fixed(1)],
2468                Direction::Vertical,
2469                80,
2470                24,
2471            );
2472            assert_eq!(
2473                snap,
2474                "\
2475Flex Vertical 80x24 (3 constraints)
2476  [0] x=0 y=0 w=80 h=3
2477  [1] x=0 y=3 w=80 h=20
2478  [2] x=0 y=23 w=80 h=1
2479  total=24
2480"
2481            );
2482        }
2483
2484        // --- Flex snapshots: 120x40 ---
2485
2486        #[test]
2487        fn snapshot_flex_thirds_120x40() {
2488            let snap = snapshot_flex(
2489                &[
2490                    Constraint::Ratio(1, 3),
2491                    Constraint::Ratio(1, 3),
2492                    Constraint::Ratio(1, 3),
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=40 h=40
2503  [1] x=40 y=0 w=40 h=40
2504  [2] x=80 y=0 w=40 h=40
2505  total=120
2506"
2507            );
2508        }
2509
2510        #[test]
2511        fn snapshot_flex_sidebar_content_120x40() {
2512            let snap = snapshot_flex(
2513                &[Constraint::Fixed(20), Constraint::Fill],
2514                Direction::Horizontal,
2515                120,
2516                40,
2517            );
2518            assert_eq!(
2519                snap,
2520                "\
2521Flex Horizontal 120x40 (2 constraints)
2522  [0] x=0 y=0 w=20 h=40
2523  [1] x=20 y=0 w=100 h=40
2524  total=120
2525"
2526            );
2527        }
2528
2529        #[test]
2530        fn snapshot_flex_percentage_mix_120x40() {
2531            let snap = snapshot_flex(
2532                &[
2533                    Constraint::Percentage(25.0),
2534                    Constraint::Percentage(50.0),
2535                    Constraint::Fill,
2536                ],
2537                Direction::Horizontal,
2538                120,
2539                40,
2540            );
2541            assert_eq!(
2542                snap,
2543                "\
2544Flex Horizontal 120x40 (3 constraints)
2545  [0] x=0 y=0 w=30 h=40
2546  [1] x=30 y=0 w=60 h=40
2547  [2] x=90 y=0 w=30 h=40
2548  total=120
2549"
2550            );
2551        }
2552
2553        // --- Grid snapshots: 80x24 ---
2554
2555        #[test]
2556        fn snapshot_grid_2x2_80x24() {
2557            let snap = snapshot_grid(
2558                &[Constraint::Fixed(3), Constraint::Fill],
2559                &[Constraint::Fixed(20), Constraint::Fill],
2560                &[
2561                    ("header", GridArea::span(0, 0, 1, 2)),
2562                    ("sidebar", GridArea::span(1, 0, 1, 1)),
2563                    ("content", GridArea::cell(1, 1)),
2564                ],
2565                80,
2566                24,
2567            );
2568            assert_eq!(
2569                snap,
2570                "\
2571Grid 80x24 (2r x 2c)
2572  [0,0] x=0 y=0 w=20 h=3
2573  [0,1] x=20 y=0 w=60 h=3
2574  [1,0] x=0 y=3 w=20 h=21
2575  [1,1] x=20 y=3 w=60 h=21
2576  area(header) x=0 y=0 w=80 h=3
2577  area(sidebar) x=0 y=3 w=20 h=21
2578  area(content) x=20 y=3 w=60 h=21
2579"
2580            );
2581        }
2582
2583        #[test]
2584        fn snapshot_grid_3x3_80x24() {
2585            let snap = snapshot_grid(
2586                &[Constraint::Fixed(1), Constraint::Fill, Constraint::Fixed(1)],
2587                &[
2588                    Constraint::Fixed(10),
2589                    Constraint::Fill,
2590                    Constraint::Fixed(10),
2591                ],
2592                &[],
2593                80,
2594                24,
2595            );
2596            assert_eq!(
2597                snap,
2598                "\
2599Grid 80x24 (3r x 3c)
2600  [0,0] x=0 y=0 w=10 h=1
2601  [0,1] x=10 y=0 w=60 h=1
2602  [0,2] x=70 y=0 w=10 h=1
2603  [1,0] x=0 y=1 w=10 h=22
2604  [1,1] x=10 y=1 w=60 h=22
2605  [1,2] x=70 y=1 w=10 h=22
2606  [2,0] x=0 y=23 w=10 h=1
2607  [2,1] x=10 y=23 w=60 h=1
2608  [2,2] x=70 y=23 w=10 h=1
2609"
2610            );
2611        }
2612
2613        // --- Grid snapshots: 120x40 ---
2614
2615        #[test]
2616        fn snapshot_grid_2x2_120x40() {
2617            let snap = snapshot_grid(
2618                &[Constraint::Fixed(3), Constraint::Fill],
2619                &[Constraint::Fixed(20), Constraint::Fill],
2620                &[
2621                    ("header", GridArea::span(0, 0, 1, 2)),
2622                    ("sidebar", GridArea::span(1, 0, 1, 1)),
2623                    ("content", GridArea::cell(1, 1)),
2624                ],
2625                120,
2626                40,
2627            );
2628            assert_eq!(
2629                snap,
2630                "\
2631Grid 120x40 (2r x 2c)
2632  [0,0] x=0 y=0 w=20 h=3
2633  [0,1] x=20 y=0 w=100 h=3
2634  [1,0] x=0 y=3 w=20 h=37
2635  [1,1] x=20 y=3 w=100 h=37
2636  area(header) x=0 y=0 w=120 h=3
2637  area(sidebar) x=0 y=3 w=20 h=37
2638  area(content) x=20 y=3 w=100 h=37
2639"
2640            );
2641        }
2642
2643        #[test]
2644        fn snapshot_grid_dashboard_120x40() {
2645            let snap = snapshot_grid(
2646                &[
2647                    Constraint::Fixed(3),
2648                    Constraint::Percentage(60.0),
2649                    Constraint::Fill,
2650                ],
2651                &[Constraint::Percentage(30.0), Constraint::Fill],
2652                &[
2653                    ("nav", GridArea::span(0, 0, 1, 2)),
2654                    ("chart", GridArea::cell(1, 0)),
2655                    ("detail", GridArea::cell(1, 1)),
2656                    ("log", GridArea::span(2, 0, 1, 2)),
2657                ],
2658                120,
2659                40,
2660            );
2661            assert_eq!(
2662                snap,
2663                "\
2664Grid 120x40 (3r x 2c)
2665  [0,0] x=0 y=0 w=36 h=3
2666  [0,1] x=36 y=0 w=84 h=3
2667  [1,0] x=0 y=3 w=36 h=24
2668  [1,1] x=36 y=3 w=84 h=24
2669  [2,0] x=0 y=27 w=36 h=13
2670  [2,1] x=36 y=27 w=84 h=13
2671  area(nav) x=0 y=0 w=120 h=3
2672  area(chart) x=0 y=3 w=36 h=24
2673  area(detail) x=36 y=3 w=84 h=24
2674  area(log) x=0 y=27 w=120 h=13
2675"
2676            );
2677        }
2678    }
2679}