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