Skip to main content

ftui_layout/
lib.rs

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