lv-tui 0.3.0

A reactive TUI framework for Rust, inspired by Textual and React
Documentation
use crate::geom::{Rect, Size};
use crate::style::Length;

/// Layout constraint passed to `Component::measure`.
///
/// Specifies the minimum and maximum size a component may occupy.
#[derive(Debug, Clone, Copy)]
pub struct Constraint {
    /// Minimum acceptable size.
    pub min: Size,
    /// Maximum allowed size.
    pub max: Size,
}

impl Constraint {
    /// Creates a constraint with zero minimum and the given maximum.
    pub fn loose(max_width: u16, max_height: u16) -> Self {
        Self {
            min: Size::default(),
            max: Size {
                width: max_width,
                height: max_height,
            },
        }
    }
}

/// Describes a single item in a layout pass.
///
/// Used by [`layout_vertical`] and [`layout_horizontal`] to allocate space.
pub struct LayoutItem {
    /// Width constraint for this item.
    pub width: Length,
    /// Height constraint for this item.
    pub height: Length,
    /// Margin to subtract from available space around this item.
    pub margin: crate::geom::Insets,
    /// Flex grow factor for remaining space allocation.
    pub flex_grow: u16,
    /// Whether this item can shrink below its intrinsic size.
    pub flex_shrink: bool,
}

/// 垂直布局:分配每个子项的 Rect
///
/// 分配顺序:Fixed → Percent → Fraction → Auto
/// 考虑 margin 偏移。flex_grow > 0 的项参与 Fraction 空间分配。
pub fn layout_vertical(rect: Rect, items: &[LayoutItem], gap: u16) -> Vec<Rect> {
    let mut rects = Vec::with_capacity(items.len());

    if items.is_empty() {
        return rects;
    }

    let total_gap = gap.saturating_mul(items.len().saturating_sub(1) as u16);
    // Subtract vertical margins from available height
    let margin_v: u16 = items.iter().map(|item| item.margin.top.saturating_add(item.margin.bottom)).sum();
    let available_height = rect.height.saturating_sub(total_gap).saturating_sub(margin_v);

    let mut fixed_total: u16 = 0;
    let mut fraction_total: u16 = 0;
    let mut auto_count: u16 = 0;

    for item in items {
        let h = if item.flex_grow > 0 { Length::Fraction(item.flex_grow) } else { item.height };
        match h {
            Length::Fixed(h) => fixed_total = fixed_total.saturating_add(h),
            Length::Fraction(w) => fraction_total = fraction_total.saturating_add(w),
            Length::Percent(_) | Length::Auto => auto_count = auto_count.saturating_add(1),
        }
    }

    let fixed_total = fixed_total.min(available_height);
    let auto_total = auto_count;
    let fraction_space = available_height.saturating_sub(fixed_total).saturating_sub(auto_total);
    let fraction_unit = if fraction_total > 0 { fraction_space / fraction_total } else { 0 };
    let mut fraction_remaining = fraction_total;

    let mut y_offset = rect.y;

    for item in items {
        // Apply top margin
        y_offset = y_offset.saturating_add(item.margin.top);

        let h = if item.flex_grow > 0 { Length::Fraction(item.flex_grow) } else { item.height };
        let height = match h {
            Length::Fixed(h) => h.min(available_height),
            Length::Percent(p) => available_height.saturating_mul(p) / 100,
            Length::Fraction(w) => {
                let base = w.saturating_mul(fraction_unit);
                fraction_remaining = fraction_remaining.saturating_sub(w);
                if fraction_remaining == 0 {
                    rect.y.saturating_add(available_height).saturating_sub(y_offset)
                } else {
                    base
                }
            }
            Length::Auto => 1,
        };

        let height = if y_offset.saturating_add(height) > rect.y.saturating_add(available_height) {
            rect.y.saturating_add(available_height).saturating_sub(y_offset)
        } else {
            height
        };

        rects.push(Rect {
            x: rect.x.saturating_add(item.margin.left),
            y: y_offset,
            width: rect.width.saturating_sub(item.margin.left.saturating_add(item.margin.right)),
            height,
        });

        y_offset = y_offset.saturating_add(height).saturating_add(item.margin.bottom).saturating_add(gap);
    }

    rects
}

/// 水平布局:分配每个子项的 Rect
///
/// 分配顺序:Fixed → Percent → Fraction → Auto
/// 考虑 margin 偏移。flex_grow > 0 的项参与 Fraction 空间分配。
pub fn layout_horizontal(rect: Rect, items: &[LayoutItem], gap: u16) -> Vec<Rect> {
    let mut rects = Vec::with_capacity(items.len());

    if items.is_empty() {
        return rects;
    }

    let total_gap = gap.saturating_mul(items.len().saturating_sub(1) as u16);
    let margin_h: u16 = items.iter().map(|item| item.margin.left.saturating_add(item.margin.right)).sum();
    let available_width = rect.width.saturating_sub(total_gap).saturating_sub(margin_h);

    let mut fixed_total: u16 = 0;
    let mut fraction_total: u16 = 0;
    let mut auto_count: u16 = 0;

    for item in items {
        let w = if item.flex_grow > 0 { Length::Fraction(item.flex_grow) } else { item.width };
        match w {
            Length::Fixed(w) => fixed_total = fixed_total.saturating_add(w),
            Length::Fraction(w) => fraction_total = fraction_total.saturating_add(w),
            Length::Percent(_) | Length::Auto => auto_count = auto_count.saturating_add(1),
        }
    }

    let fixed_total = fixed_total.min(available_width);
    let auto_total = auto_count;
    let fraction_space = available_width.saturating_sub(fixed_total).saturating_sub(auto_total);
    let fraction_unit = if fraction_total > 0 { fraction_space / fraction_total } else { 0 };
    let mut fraction_remaining = fraction_total;

    let mut x_offset = rect.x;

    for item in items {
        x_offset = x_offset.saturating_add(item.margin.left);

        let w = if item.flex_grow > 0 { Length::Fraction(item.flex_grow) } else { item.width };
        let width = match w {
            Length::Fixed(w) => w.min(available_width),
            Length::Percent(p) => available_width.saturating_mul(p) / 100,
            Length::Fraction(w) => {
                let base = w.saturating_mul(fraction_unit);
                fraction_remaining = fraction_remaining.saturating_sub(w);
                if fraction_remaining == 0 {
                    rect.x.saturating_add(available_width).saturating_sub(x_offset)
                } else {
                    base
                }
            }
            Length::Auto => 1,
        };

        let width = if x_offset.saturating_add(width) > rect.x.saturating_add(available_width) {
            rect.x.saturating_add(available_width).saturating_sub(x_offset)
        } else {
            width
        };

        rects.push(Rect {
            x: x_offset,
            y: rect.y.saturating_add(item.margin.top),
            width,
            height: rect.height.saturating_sub(item.margin.top.saturating_add(item.margin.bottom)),
        });

        x_offset = x_offset.saturating_add(width).saturating_add(item.margin.right).saturating_add(gap);
    }

    rects
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_layout_vertical_fixed() {
        let rect = Rect { x: 0, y: 0, width: 10, height: 10 };
        let items = [
            LayoutItem { width: Length::Auto, height: Length::Fixed(3), margin: crate::geom::Insets::ZERO, flex_grow: 0, flex_shrink: true },
            LayoutItem { width: Length::Auto, height: Length::Fixed(4), margin: crate::geom::Insets::ZERO, flex_grow: 0, flex_shrink: true },
        ];
        let result = layout_vertical(rect, &items, 1);
        assert_eq!(result.len(), 2);
        assert_eq!(result[0], Rect { x: 0, y: 0, width: 10, height: 3 });
        assert_eq!(result[1], Rect { x: 0, y: 4, width: 10, height: 4 });
    }

    #[test]
    fn test_layout_horizontal_fixed() {
        let rect = Rect { x: 0, y: 0, width: 10, height: 5 };
        let items = [
            LayoutItem { width: Length::Fixed(3), height: Length::Auto, margin: crate::geom::Insets::ZERO, flex_grow: 0, flex_shrink: true },
            LayoutItem { width: Length::Fixed(4), height: Length::Auto, margin: crate::geom::Insets::ZERO, flex_grow: 0, flex_shrink: true },
        ];
        let result = layout_horizontal(rect, &items, 1);
        assert_eq!(result.len(), 2);
        assert_eq!(result[0], Rect { x: 0, y: 0, width: 3, height: 5 });
        assert_eq!(result[1], Rect { x: 4, y: 0, width: 4, height: 5 });
    }

    #[test]
    fn test_layout_horizontal_fraction() {
        let rect = Rect { x: 0, y: 0, width: 10, height: 5 };
        let items = [
            LayoutItem { width: Length::Fraction(1), height: Length::Auto, margin: crate::geom::Insets::ZERO, flex_grow: 0, flex_shrink: true },
            LayoutItem { width: Length::Fraction(1), height: Length::Auto, margin: crate::geom::Insets::ZERO, flex_grow: 0, flex_shrink: true },
        ];
        let result = layout_horizontal(rect, &items, 0);
        assert_eq!(result.len(), 2);
        assert_eq!(result[0].width, 5);
        assert_eq!(result[1].width, 5);
    }

    #[test]
    fn bench_layout_large() {
        let rect = Rect { x: 0, y: 0, width: 200, height: 200 };
        let items: Vec<LayoutItem> = (0..100).map(|_| LayoutItem {
            width: Length::Fraction(1), height: Length::Fixed(1),
            margin: crate::geom::Insets::ZERO, flex_grow: 0, flex_shrink: true,
        }).collect();
        let result = layout_vertical(rect, &items, 0);
        assert_eq!(result.len(), 100);
        // All items should have non-zero height
        for r in &result { assert!(r.height > 0); }
    }
}