Skip to main content

a2ui_tui/
layout_engine.rs

1//! Layout calculation for Row / Column containers.
2//!
3//! Provides flex-grow–style weighted splitting, justify (main-axis), and
4//! align (cross-axis) helpers that work on [`ratatui::layout::Rect`].
5
6use ratatui::layout::{Direction, Rect};
7
8use a2ui_base::protocol::common_types::{Align, Justify};
9
10/// Content rect inside the standard 1-cell margin, but guaranteed never to collapse
11/// to zero — so a leaf nested in a tight area (e.g. a `Text` label inside a `Button`'s
12/// 1-row content area) still renders instead of vanishing.
13///
14/// - `area` height/width ≥ 3 → shrink by 1 cell on every side (normal margin).
15/// - `area` height/width ≤ 2 → use the full axis (no margin; content fills it).
16pub fn padded_content(area: Rect) -> Rect {
17    let h = if area.height > 2 { area.height - 2 } else { area.height };
18    let w = if area.width > 2 { area.width - 2 } else { area.width };
19    let y = if area.height > 2 { area.y + 1 } else { area.y };
20    let x = if area.width > 2 { area.x + 1 } else { area.x };
21    Rect { x, y, width: w, height: h }
22}
23
24/// Split a [`Rect`] into `n` segments based on optional weights.
25///
26/// * If **all** weights are `None`, the area is split into equal parts.
27/// * If **some** weights are set and others are `None`, the unweighted items
28///   each receive a default size of 1.0 unit. The remaining space is then
29///   distributed proportionally among the weighted items.
30/// * If **all** items have weights, the full area is distributed proportionally.
31///
32/// Returns a `Vec<Rect>` with `weights.len()` entries.
33pub fn weighted_split(
34    direction: Direction,
35    area: Rect,
36    weights: &[Option<f64>],
37) -> Vec<Rect> {
38    let n = weights.len();
39    if n == 0 {
40        return vec![];
41    }
42
43    let total_size = match direction {
44        Direction::Horizontal => area.width as u16,
45        Direction::Vertical => area.height as u16,
46    } as f64;
47
48    // Treat None weights as 1.0 (baseline unit).
49    let effective: Vec<f64> = weights.iter().map(|w| w.unwrap_or(1.0)).collect();
50    let total_weight: f64 = effective.iter().sum();
51
52    if total_weight <= 0.0 {
53        // Degenerate case: return equal splits.
54        return equal_split(direction, area, n);
55    }
56
57    let mut rects = Vec::with_capacity(n);
58    let mut offset: u16 = 0;
59
60    for (i, &w) in effective.iter().enumerate() {
61        let fraction = w / total_weight;
62        let raw = total_size * fraction;
63        let mut size = raw.floor() as u16;
64
65        // Give the last item the remainder to avoid sub-pixel gaps.
66        if i == n - 1 {
67            let used: u16 = rects.iter().map(|r: &Rect| size_axis(r, direction)).sum();
68            size = total_size as u16 - used;
69        }
70
71        let rect = make_rect(direction, area, offset, size);
72        rects.push(rect);
73        offset += size;
74    }
75
76    rects
77}
78
79/// Position items along the main axis according to a [`Justify`] rule.
80///
81/// `items` is a list of `(Rect, u16)` pairs where the `u16` is the
82/// **natural size** (width for Horizontal, height for Vertical) of each item.
83/// The function returns a new `Vec<Rect>` with adjusted x/y offsets.
84pub fn apply_justify(
85    justify: Justify,
86    items: &[(Rect, u16)],
87    total_area: Rect,
88    direction: Direction,
89) -> Vec<Rect> {
90    let container_size = size_from_direction(total_area, direction);
91    let total_item_size: u16 = items.iter().map(|(_, s)| *s).sum();
92
93    match justify {
94        Justify::Start => {
95            // Default layout — items are already packed to the start.
96            items.iter().map(|(rect, _)| *rect).collect()
97        }
98        Justify::Center => {
99            let gap = container_size.saturating_sub(total_item_size);
100            let offset = gap / 2;
101            shift_items(items, total_area, direction, offset)
102        }
103        Justify::End => {
104            let gap = container_size.saturating_sub(total_item_size);
105            shift_items(items, total_area, direction, gap)
106        }
107        Justify::SpaceBetween => {
108            let count = items.len();
109            if count <= 1 {
110                return items.iter().map(|(rect, _)| *rect).collect();
111            }
112            let gap = container_size.saturating_sub(total_item_size);
113            let spacing = gap / (count as u16 - 1);
114            let mut result = Vec::with_capacity(count);
115            let mut offset: u16 = 0;
116            for (rect, size) in items {
117                result.push(set_offset(*rect, total_area, direction, offset));
118                offset += size + spacing;
119            }
120            result
121        }
122        Justify::SpaceAround => {
123            let count = items.len();
124            if count == 0 {
125                return vec![];
126            }
127            let gap = container_size.saturating_sub(total_item_size);
128            let spacing = gap / count as u16;
129            let start_offset = spacing / 2;
130            let mut result = Vec::with_capacity(count);
131            let mut offset: u16 = 0;
132            for (rect, size) in items {
133                offset += if result.is_empty() { start_offset } else { spacing };
134                result.push(set_offset(*rect, total_area, direction, offset));
135                offset += size;
136            }
137            result
138        }
139        Justify::SpaceEvenly => {
140            let count = items.len();
141            if count == 0 {
142                return vec![];
143            }
144            let gap = container_size.saturating_sub(total_item_size);
145            let spacing = gap / (count as u16 + 1);
146            let mut result = Vec::with_capacity(count);
147            let mut offset: u16 = spacing;
148            for (rect, size) in items {
149                result.push(set_offset(*rect, total_area, direction, offset));
150                offset += size + spacing;
151            }
152            result
153        }
154        Justify::Stretch => {
155            let count = items.len();
156            if count == 0 {
157                return vec![];
158            }
159            let each_size = container_size / count as u16;
160            let mut result = Vec::with_capacity(count);
161            let mut offset: u16 = 0;
162            for (i, (_rect, _size)) in items.iter().enumerate() {
163                let size = if i == count - 1 {
164                    container_size - offset
165                } else {
166                    each_size
167                };
168                result.push(make_rect(direction, total_area, offset, size));
169                offset += size;
170            }
171            result
172        }
173    }
174}
175
176/// Position a single item on the cross axis according to an [`Align`] rule.
177///
178/// Returns the adjusted [`Rect`].
179pub fn apply_align(align: Align, item: Rect, container: Rect, direction: Direction) -> Rect {
180    let (cross_size, container_cross) = match direction {
181        Direction::Horizontal => (item.height, container.height),
182        Direction::Vertical => (item.width, container.width),
183    };
184
185    match align {
186        Align::Start => item,
187        Align::Center => {
188            let offset = container_cross.saturating_sub(cross_size) / 2;
189            set_cross_offset(item, container, direction, offset)
190        }
191        Align::End => {
192            let offset = container_cross.saturating_sub(cross_size);
193            set_cross_offset(item, container, direction, offset)
194        }
195        Align::Stretch => {
196            // Expand the item to fill the cross axis, starting at the container origin.
197            match direction {
198                Direction::Horizontal => Rect {
199                    x: item.x,
200                    y: container.y,
201                    width: item.width,
202                    height: container.height,
203                },
204                Direction::Vertical => Rect {
205                    x: container.x,
206                    y: item.y,
207                    width: container.width,
208                    height: item.height,
209                },
210            }
211        }
212    }
213}
214
215/// Flexbox-style layout along the main axis.
216///
217/// Each item is `(natural_height, explicit_weight)`:
218/// - `natural_height = Some(h)` → the component has a content-driven base of `h`.
219/// - `natural_height = None` → "no opinion": treated as a legacy fill participant
220///   (base 0, implicit weight `1.0`), reproducing the old [`weighted_split`] behavior
221///   so unconverted/`None` components are laid out exactly as before.
222///
223/// An explicit `weight` overrides the implicit one and acts as **flex-grow**: leftover
224/// space (after every measured child gets its natural height) is distributed
225/// proportionally to weighted items. Unweighted measured items keep their natural
226/// height. When there are no weights and `justify` is [`Justify::Stretch`], every item
227/// grows equally to fill the axis.
228///
229/// Returns positioned, main-axis-sized `Rect`s. The caller still applies cross-axis
230/// alignment via [`apply_align`]. **When `justify` is [`Justify::Stretch`], the main
231/// axis is fully consumed here — do not also call [`apply_justify`] with `Stretch`.**
232///
233/// Arithmetic uses `i64` internally so that overflow (content taller than the area)
234/// and sub-pixel rounding never underflow `u16`.
235pub fn flex_layout(
236    direction: Direction,
237    area: Rect,
238    items: &[(Option<u16>, Option<f64>)],
239    justify: Justify,
240) -> Vec<Rect> {
241    let n = items.len();
242    if n == 0 {
243        return vec![];
244    }
245
246    let total = size_from_direction(area, direction) as i64;
247
248    // Resolve base and effective weight per item.
249    let bases: Vec<i64> = items.iter().map(|(nat, _)| nat.unwrap_or(0) as i64).collect();
250    let weights: Vec<f64> = items
251        .iter()
252        .map(|(nat, w)| w.unwrap_or(if nat.is_none() { 1.0 } else { 0.0 }))
253        .collect();
254
255    let sum_base: i64 = bases.iter().sum();
256    let sum_weight: f64 = weights.iter().sum();
257    let free = total - sum_base; // may be negative (overflow)
258
259    let mut finals: Vec<i64> = vec![0; n];
260
261    if free > 0 && sum_weight > 0.0 {
262        // Distribute leftover to weighted items (flex-grow); unweighted keep base.
263        for i in 0..n {
264            finals[i] = bases[i];
265            if weights[i] > 0.0 {
266                finals[i] += (free as f64 * weights[i] / sum_weight).round() as i64;
267            }
268        }
269        // Absorb rounding remainder into the last weighted item so the axis is exact.
270        let used: i64 = finals.iter().sum();
271        if let Some(pos) = weights.iter().rposition(|&w| w > 0.0) {
272            finals[pos] += total - used;
273        }
274    } else if free > 0 {
275        // No weights: measured items keep their natural base. Stretch grows all equally.
276        if matches!(justify, Justify::Stretch) {
277            let each = free / n as i64;
278            let mut rem = free - each * n as i64;
279            for i in 0..n {
280                finals[i] = bases[i] + each + (if rem > 0 { rem -= 1; 1 } else { 0 });
281            }
282        } else {
283            for i in 0..n {
284                finals[i] = bases[i];
285            }
286        }
287    } else if free < 0 {
288        // Overflow: shrink. Pure-weight when weights exist (legacy); else proportional
289        // to base, each clamped to ≥1 so items never disappear.
290        if sum_weight > 0.0 {
291            for i in 0..n {
292                finals[i] = (total as f64 * weights[i] / sum_weight).round() as i64;
293            }
294            let used: i64 = finals.iter().sum();
295            if let Some(pos) = weights.iter().rposition(|&w| w > 0.0) {
296                finals[pos] += total - used;
297            }
298        } else if sum_base > 0 {
299            for i in 0..n {
300                finals[i] =
301                    ((total as f64 * bases[i] as f64 / sum_base as f64).round() as i64).max(1);
302            }
303            let used: i64 = finals.iter().sum();
304            finals[n - 1] += total - used;
305        } else {
306            let each = total / n as i64;
307            let mut rem = total - each * n as i64;
308            for i in 0..n {
309                finals[i] = each + (if rem > 0 { rem -= 1; 1 } else { 0 });
310            }
311        }
312    } else {
313        // free == 0: bases exactly fill the axis.
314        for i in 0..n {
315            finals[i] = bases[i];
316        }
317    }
318
319    // Clamp to a valid u16 range (defensive against sub-pixel drift).
320    for f in finals.iter_mut() {
321        if *f < 0 {
322            *f = 0;
323        }
324        if *f > total {
325            *f = total;
326        }
327    }
328
329    // --- Position along the main axis by justify, using the final sizes. ---
330    let total_size: i64 = finals.iter().sum();
331    let sizes = finals;
332
333    let pack_from = |start: i64| -> Vec<i64> {
334        let mut acc = start;
335        let mut out = vec![0i64; n];
336        for i in 0..n {
337            out[i] = acc;
338            acc += sizes[i];
339        }
340        out
341    };
342
343    let offsets: Vec<i64> = match justify {
344        // Start packs from the origin; Stretch already filled the axis.
345        Justify::Start | Justify::Stretch => pack_from(0),
346        Justify::Center => {
347            let gap = (total - total_size).max(0);
348            pack_from(gap / 2)
349        }
350        Justify::End => pack_from((total - total_size).max(0)),
351        Justify::SpaceBetween => {
352            if n <= 1 {
353                pack_from(0)
354            } else {
355                let gap = (total - total_size).max(0);
356                let spacing = gap / (n as i64 - 1);
357                let mut out = vec![0i64; n];
358                let mut acc = 0;
359                for i in 0..n {
360                    out[i] = acc;
361                    acc += sizes[i] + spacing;
362                }
363                out
364            }
365        }
366        Justify::SpaceAround => {
367            let gap = (total - total_size).max(0);
368            let spacing = gap / n as i64;
369            pack_from(spacing / 2)
370                .iter()
371                .enumerate()
372                .map(|(i, &o)| o + spacing * i as i64)
373                .collect()
374        }
375        Justify::SpaceEvenly => {
376            let gap = (total - total_size).max(0);
377            let spacing = gap / (n as i64 + 1);
378            pack_from(spacing)
379        }
380    };
381
382    sizes
383        .iter()
384        .zip(offsets.iter())
385        .map(|(&size, &offset)| {
386            make_rect(direction, area, offset.max(0) as u16, size.max(0) as u16)
387        })
388        .collect()
389}
390
391// ---------------------------------------------------------------------------
392// Internal helpers
393// ---------------------------------------------------------------------------
394
395/// Equal split used as a fallback.
396fn equal_split(direction: Direction, area: Rect, n: usize) -> Vec<Rect> {
397    if n == 0 {
398        return vec![];
399    }
400    let total = match direction {
401        Direction::Horizontal => area.width,
402        Direction::Vertical => area.height,
403    };
404    let each = total / n as u16;
405    let mut rects = Vec::with_capacity(n);
406    let base = match direction {
407        Direction::Horizontal => area.x,
408        Direction::Vertical => area.y,
409    };
410    for i in 0..n {
411        let offset = base + (each * i as u16);
412        // Last item gets the remainder.
413        let size = if i == n - 1 {
414            total - each * (n as u16 - 1)
415        } else {
416            each
417        };
418        rects.push(make_rect(direction, area, offset - base, size));
419    }
420    rects
421}
422
423fn size_axis(rect: &Rect, direction: Direction) -> u16 {
424    match direction {
425        Direction::Horizontal => rect.width,
426        Direction::Vertical => rect.height,
427    }
428}
429
430fn size_from_direction(area: Rect, direction: Direction) -> u16 {
431    match direction {
432        Direction::Horizontal => area.width,
433        Direction::Vertical => area.height,
434    }
435}
436
437fn make_rect(direction: Direction, area: Rect, offset: u16, size: u16) -> Rect {
438    match direction {
439        Direction::Horizontal => Rect {
440            x: area.x + offset,
441            y: area.y,
442            width: size,
443            height: area.height,
444        },
445        Direction::Vertical => Rect {
446            x: area.x,
447            y: area.y + offset,
448            width: area.width,
449            height: size,
450        },
451    }
452}
453
454/// Shift items along the main axis by `start_offset`.
455fn shift_items(
456    items: &[(Rect, u16)],
457    total_area: Rect,
458    direction: Direction,
459    start_offset: u16,
460) -> Vec<Rect> {
461    let base = match direction {
462        Direction::Horizontal => total_area.x,
463        Direction::Vertical => total_area.y,
464    };
465    let mut result = Vec::with_capacity(items.len());
466    let mut pos = base + start_offset;
467    for (rect, size) in items {
468        result.push(set_offset(*rect, total_area, direction, pos - base));
469        pos += size;
470    }
471    result
472}
473
474fn set_offset(rect: Rect, _total_area: Rect, direction: Direction, offset: u16) -> Rect {
475    match direction {
476        Direction::Horizontal => Rect {
477            x: _total_area.x + offset,
478            ..rect
479        },
480        Direction::Vertical => Rect {
481            y: _total_area.y + offset,
482            ..rect
483        },
484    }
485}
486
487fn set_cross_offset(item: Rect, container: Rect, direction: Direction, offset: u16) -> Rect {
488    match direction {
489        Direction::Horizontal => Rect {
490            y: container.y + offset,
491            ..item
492        },
493        Direction::Vertical => Rect {
494            x: container.x + offset,
495            ..item
496        },
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    fn test_area() -> Rect {
505        Rect::new(0, 0, 100, 30)
506    }
507
508    #[test]
509    fn weighted_split_equal_when_no_weights() {
510        let area = test_area();
511        let result = weighted_split(Direction::Horizontal, area, &[None, None, None]);
512        assert_eq!(result.len(), 3);
513        // Third item gets remainder: 34, 33, 33 — or similar.
514        let total_width: u16 = result.iter().map(|r| r.width).sum();
515        assert_eq!(total_width, 100);
516    }
517
518    #[test]
519    fn weighted_split_respects_weights() {
520        let area = test_area();
521        let result = weighted_split(Direction::Vertical, area, &[Some(3.0), Some(1.0)]);
522        assert_eq!(result.len(), 2);
523        assert_eq!(result[0].height, 22); // 30 * 0.75 = 22.5 -> 22
524        assert_eq!(result[1].height, 8); // remainder
525        assert_eq!(result[0].height + result[1].height, 30);
526    }
527
528    #[test]
529    fn weighted_split_mixed_weights() {
530        let area = test_area();
531        // None = 1.0, Some(2.0) = 2.0 -> total 3.0
532        let result = weighted_split(Direction::Horizontal, area, &[None, Some(2.0)]);
533        assert_eq!(result.len(), 2);
534        let total: u16 = result.iter().map(|r| r.width).sum();
535        assert_eq!(total, 100);
536        // First should be ~33, second ~66.
537        assert!(result[0].width < result[1].width);
538    }
539
540    #[test]
541    fn weighted_split_empty() {
542        let area = test_area();
543        let result = weighted_split(Direction::Horizontal, area, &[]);
544        assert!(result.is_empty());
545    }
546
547    #[test]
548    fn apply_align_stretch_horizontal() {
549        let container = Rect::new(0, 0, 100, 30);
550        let item = Rect::new(10, 5, 50, 10);
551        let result = apply_align(Align::Stretch, item, container, Direction::Horizontal);
552        assert_eq!(result.y, 0);
553        assert_eq!(result.height, 30);
554        assert_eq!(result.width, 50);
555    }
556
557    #[test]
558    fn apply_align_center_vertical() {
559        let container = Rect::new(0, 0, 100, 30);
560        let item = Rect::new(0, 0, 10, 10);
561        let result = apply_align(Align::Center, item, container, Direction::Vertical);
562        assert_eq!(result.x, 45); // (100 - 10) / 2
563    }
564
565    #[test]
566    fn apply_justify_space_between() {
567        let container = Rect::new(0, 0, 100, 30);
568        let items: Vec<(Rect, u16)> = vec![
569            (Rect::new(0, 0, 20, 30), 20),
570            (Rect::new(20, 0, 20, 30), 20),
571            (Rect::new(40, 0, 20, 30), 20),
572        ];
573        let result = apply_justify(Justify::SpaceBetween, &items, container, Direction::Horizontal);
574        assert_eq!(result.len(), 3);
575        // 100 - 60 = 40 gap, 40 / 2 = 20 spacing
576        assert_eq!(result[0].x, 0);
577        assert_eq!(result[1].x, 40);
578        assert_eq!(result[2].x, 80);
579    }
580
581    #[test]
582    fn apply_justify_center() {
583        let container = Rect::new(0, 0, 100, 30);
584        let items: Vec<(Rect, u16)> = vec![
585            (Rect::new(0, 0, 20, 30), 20),
586        ];
587        let result = apply_justify(Justify::Center, &items, container, Direction::Horizontal);
588        assert_eq!(result[0].x, 40); // (100 - 20) / 2
589    }
590
591    #[test]
592    fn apply_justify_end_vertical() {
593        let container = Rect::new(0, 0, 100, 30);
594        let items: Vec<(Rect, u16)> = vec![
595            (Rect::new(0, 0, 100, 10), 10),
596        ];
597        let result = apply_justify(Justify::End, &items, container, Direction::Vertical);
598        assert_eq!(result[0].y, 20); // 30 - 10
599    }
600
601    // --- SpaceAround tests ---
602
603    #[test]
604    fn apply_justify_space_around_three_items() {
605        let container = Rect::new(0, 0, 100, 30);
606        let items: Vec<(Rect, u16)> = vec![
607            (Rect::new(0, 0, 20, 30), 20),
608            (Rect::new(20, 0, 20, 30), 20),
609            (Rect::new(40, 0, 20, 30), 20),
610        ];
611        let result = apply_justify(Justify::SpaceAround, &items, container, Direction::Horizontal);
612        assert_eq!(result.len(), 3);
613        // 100 - 60 = 40 gap, spacing = 40 / 3 = 13, start_offset = 13 / 2 = 6
614        // item0: offset = 6
615        // item1: offset = 6 + 20 + 13 = 39
616        // item2: offset = 39 + 20 + 13 = 72
617        assert_eq!(result[0].x, 6);
618        assert_eq!(result[1].x, 39);
619        assert_eq!(result[2].x, 72);
620    }
621
622    #[test]
623    fn apply_justify_space_around_single_item() {
624        let container = Rect::new(0, 0, 100, 30);
625        let items: Vec<(Rect, u16)> = vec![
626            (Rect::new(0, 0, 20, 30), 20),
627        ];
628        let result = apply_justify(Justify::SpaceAround, &items, container, Direction::Horizontal);
629        assert_eq!(result.len(), 1);
630        // 100 - 20 = 80 gap, spacing = 80 / 1 = 80, start_offset = 40
631        assert_eq!(result[0].x, 40);
632    }
633
634    #[test]
635    fn apply_justify_space_around_empty() {
636        let container = Rect::new(0, 0, 100, 30);
637        let items: Vec<(Rect, u16)> = vec![];
638        let result = apply_justify(Justify::SpaceAround, &items, container, Direction::Horizontal);
639        assert!(result.is_empty());
640    }
641
642    // --- SpaceEvenly tests ---
643
644    #[test]
645    fn apply_justify_space_evenly_three_items() {
646        let container = Rect::new(0, 0, 100, 30);
647        let items: Vec<(Rect, u16)> = vec![
648            (Rect::new(0, 0, 20, 30), 20),
649            (Rect::new(20, 0, 20, 30), 20),
650            (Rect::new(40, 0, 20, 30), 20),
651        ];
652        let result = apply_justify(Justify::SpaceEvenly, &items, container, Direction::Horizontal);
653        assert_eq!(result.len(), 3);
654        // 100 - 60 = 40 gap, spacing = 40 / 4 = 10
655        // item0: offset = 10
656        // item1: offset = 10 + 20 + 10 = 40
657        // item2: offset = 40 + 20 + 10 = 70
658        assert_eq!(result[0].x, 10);
659        assert_eq!(result[1].x, 40);
660        assert_eq!(result[2].x, 70);
661    }
662
663    #[test]
664    fn apply_justify_space_evenly_single_item() {
665        let container = Rect::new(0, 0, 100, 30);
666        let items: Vec<(Rect, u16)> = vec![
667            (Rect::new(0, 0, 20, 30), 20),
668        ];
669        let result = apply_justify(Justify::SpaceEvenly, &items, container, Direction::Horizontal);
670        assert_eq!(result.len(), 1);
671        // 100 - 20 = 80 gap, spacing = 80 / 2 = 40
672        assert_eq!(result[0].x, 40);
673    }
674
675    #[test]
676    fn apply_justify_space_evenly_empty() {
677        let container = Rect::new(0, 0, 100, 30);
678        let items: Vec<(Rect, u16)> = vec![];
679        let result = apply_justify(Justify::SpaceEvenly, &items, container, Direction::Horizontal);
680        assert!(result.is_empty());
681    }
682
683    // --- Stretch tests ---
684
685    #[test]
686    fn apply_justify_stretch_three_items() {
687        let container = Rect::new(0, 0, 99, 30);
688        let items: Vec<(Rect, u16)> = vec![
689            (Rect::new(0, 0, 20, 30), 20),
690            (Rect::new(20, 0, 20, 30), 20),
691            (Rect::new(40, 0, 20, 30), 20),
692        ];
693        let result = apply_justify(Justify::Stretch, &items, container, Direction::Horizontal);
694        assert_eq!(result.len(), 3);
695        // each = 99 / 3 = 33, last gets remainder 99 - 66 = 33
696        assert_eq!(result[0].x, 0);
697        assert_eq!(result[0].width, 33);
698        assert_eq!(result[1].x, 33);
699        assert_eq!(result[1].width, 33);
700        assert_eq!(result[2].x, 66);
701        assert_eq!(result[2].width, 33);
702        // Total fills container
703        let total: u16 = result.iter().map(|r| r.width).sum();
704        assert_eq!(total, 99);
705    }
706
707    #[test]
708    fn apply_justify_stretch_with_remainder() {
709        let container = Rect::new(0, 0, 100, 30);
710        let items: Vec<(Rect, u16)> = vec![
711            (Rect::new(0, 0, 10, 30), 10),
712            (Rect::new(10, 0, 10, 30), 10),
713            (Rect::new(20, 0, 10, 30), 10),
714        ];
715        let result = apply_justify(Justify::Stretch, &items, container, Direction::Horizontal);
716        assert_eq!(result.len(), 3);
717        // each = 100 / 3 = 33, last gets remainder 100 - 66 = 34
718        assert_eq!(result[0].width, 33);
719        assert_eq!(result[1].width, 33);
720        assert_eq!(result[2].width, 34);
721        let total: u16 = result.iter().map(|r| r.width).sum();
722        assert_eq!(total, 100);
723    }
724
725    #[test]
726    fn apply_justify_stretch_vertical() {
727        let container = Rect::new(0, 0, 100, 30);
728        let items: Vec<(Rect, u16)> = vec![
729            (Rect::new(0, 0, 100, 5), 5),
730            (Rect::new(0, 5, 100, 5), 5),
731        ];
732        let result = apply_justify(Justify::Stretch, &items, container, Direction::Vertical);
733        assert_eq!(result.len(), 2);
734        // each = 30 / 2 = 15
735        assert_eq!(result[0].y, 0);
736        assert_eq!(result[0].height, 15);
737        assert_eq!(result[1].y, 15);
738        assert_eq!(result[1].height, 15);
739        let total: u16 = result.iter().map(|r| r.height).sum();
740        assert_eq!(total, 30);
741    }
742
743    #[test]
744    fn apply_justify_stretch_empty() {
745        let container = Rect::new(0, 0, 100, 30);
746        let items: Vec<(Rect, u16)> = vec![];
747        let result = apply_justify(Justify::Stretch, &items, container, Direction::Horizontal);
748        assert!(result.is_empty());
749    }
750
751    // --- flex_layout tests ---
752
753    #[test]
754    fn flex_layout_all_none_matches_weighted_split() {
755        // Two legacy (None) children in a 30-tall area → equal split, identical to
756        // the old weighted_split behavior (zero-regression invariant).
757        let area = Rect::new(0, 0, 100, 30);
758        let items = vec![(None, None), (None, None)];
759        let result = flex_layout(Direction::Vertical, area, &items, Justify::Start);
760        assert_eq!(result.len(), 2);
761        assert_eq!(result[0].height, 15);
762        assert_eq!(result[1].height, 15);
763        let total: u16 = result.iter().map(|r| r.height).sum();
764        assert_eq!(total, 30);
765    }
766
767    #[test]
768    fn flex_layout_measured_children_pack_to_natural() {
769        // Two measured children (natural 3 each), no weights, default Start → each
770        // gets exactly 3, packed at the top, rest of the 30-tall area left empty.
771        let area = Rect::new(0, 0, 100, 30);
772        let items = vec![(Some(3u16), None), (Some(3u16), None)];
773        let result = flex_layout(Direction::Vertical, area, &items, Justify::Start);
774        assert_eq!(result[0].y, 0);
775        assert_eq!(result[0].height, 3);
776        assert_eq!(result[1].y, 3);
777        assert_eq!(result[1].height, 3);
778    }
779
780    #[test]
781    fn flex_layout_unmeasured_fills_leftover() {
782        // A measured child (3) keeps its natural height; an unmeasured sibling
783        // (None) absorbs the leftover 27.
784        let area = Rect::new(0, 0, 100, 30);
785        let items = vec![(Some(3u16), None), (None, None)];
786        let result = flex_layout(Direction::Vertical, area, &items, Justify::Start);
787        assert_eq!(result[0].height, 3);
788        assert_eq!(result[1].height, 27);
789        assert_eq!(result[1].y, 3);
790    }
791
792    #[test]
793    fn flex_layout_weight_grows_measured_child() {
794        // A measured child with an explicit weight grows beyond its natural height.
795        let area = Rect::new(0, 0, 100, 30);
796        // (natural 3, weight 2.0) + legacy (None) → bases sum 3, free 27.
797        // weight 2.0 vs implicit 1.0: measured gets 27 * 2/3 = 18 → 21 total;
798        // legacy gets 27 * 1/3 = 9.
799        let items = vec![(Some(3u16), Some(2.0)), (None, None)];
800        let result = flex_layout(Direction::Vertical, area, &items, Justify::Start);
801        assert_eq!(result[0].height, 21);
802        assert_eq!(result[1].height, 9);
803        let total: u16 = result.iter().map(|r| r.height).sum();
804        assert_eq!(total, 30);
805    }
806
807    #[test]
808    fn flex_layout_stretch_fills_axis() {
809        // No weights + Justify::Stretch → all measured children grow equally.
810        let area = Rect::new(0, 0, 100, 30);
811        let items = vec![(Some(3u16), None), (Some(3u16), None)];
812        let result = flex_layout(Direction::Vertical, area, &items, Justify::Stretch);
813        assert_eq!(result.len(), 2);
814        let total: u16 = result.iter().map(|r| r.height).sum();
815        assert_eq!(total, 30);
816        assert_eq!(result[0].height, 15);
817        assert_eq!(result[1].height, 15);
818    }
819
820    #[test]
821    fn flex_layout_center_offsets_packed_items() {
822        // Two natural-3 items in 30: total 6, gap 24, centered → start at 12.
823        let area = Rect::new(0, 0, 100, 30);
824        let items = vec![(Some(3u16), None), (Some(3u16), None)];
825        let result = flex_layout(Direction::Vertical, area, &items, Justify::Center);
826        assert_eq!(result[0].y, 12);
827        assert_eq!(result[1].y, 15);
828        assert_eq!(result[0].height, 3);
829    }
830
831    #[test]
832    fn flex_layout_overflow_shrinks_proportionally() {
833        // Three natural-3 items (sum 9) in a 5-tall area → shrink proportionally,
834        // each at least 1, no panic.
835        let area = Rect::new(0, 0, 100, 5);
836        let items = vec![(Some(3u16), None), (Some(3u16), None), (Some(3u16), None)];
837        let result = flex_layout(Direction::Vertical, area, &items, Justify::Start);
838        let total: u16 = result.iter().map(|r| r.height).sum();
839        assert_eq!(total, 5);
840        assert!(result.iter().all(|r| r.height >= 1));
841    }
842
843    #[test]
844    fn flex_layout_horizontal_uses_width() {
845        // On the horizontal axis, main size = width.
846        let area = Rect::new(0, 0, 100, 30);
847        let items = vec![(Some(10u16), None), (None, None)];
848        let result = flex_layout(Direction::Horizontal, area, &items, Justify::Start);
849        assert_eq!(result[0].width, 10);
850        assert_eq!(result[1].width, 90);
851        assert_eq!(result[1].x, 10);
852    }
853}