scrin 0.1.75

A terminal UI toolkit with panes, widgets, overlays, animations, and Aisling-powered effects/loaders.
Documentation
use crate::core::rect::Rect;

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Constraint {
    Length(u16),
    Min(u16),
    Max(u16),
    Percentage(u16),
    Ratio(u32, u32),
    Fill(u16),
}

impl Constraint {
    pub fn apply(&self, total: u16) -> u16 {
        match *self {
            Constraint::Length(l) => l.min(total),
            Constraint::Min(_) => total,
            Constraint::Max(m) => total.min(m),
            Constraint::Percentage(p) => (total as f32 * p as f32 / 100.0) as u16,
            Constraint::Ratio(a, b) => {
                if b == 0 {
                    0
                } else {
                    (total as f32 * a as f32 / b as f32) as u16
                }
            }
            Constraint::Fill(_) => total,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Direction {
    Horizontal,
    Vertical,
}

#[derive(Debug, Clone, PartialEq)]
pub struct Layout {
    pub direction: Direction,
    pub constraints: Vec<Constraint>,
    pub margin: Rect,
}

impl Layout {
    pub fn default() -> Self {
        Self {
            direction: Direction::Vertical,
            constraints: Vec::new(),
            margin: Rect::ZERO,
        }
    }

    pub fn horizontal(constraints: Vec<Constraint>) -> Self {
        Self {
            direction: Direction::Horizontal,
            constraints,
            margin: Rect::ZERO,
        }
    }

    pub fn vertical(constraints: Vec<Constraint>) -> Self {
        Self {
            direction: Direction::Vertical,
            constraints,
            margin: Rect::ZERO,
        }
    }

    pub fn margin(mut self, margin: Rect) -> Self {
        self.margin = margin;
        self
    }

    pub fn direction(mut self, direction: Direction) -> Self {
        self.direction = direction;
        self
    }

    pub fn constraints<I>(mut self, constraints: I) -> Self
    where
        I: Into<Vec<Constraint>>,
    {
        self.constraints = constraints.into();
        self
    }

    pub fn split(&self, area: Rect) -> Vec<Rect> {
        let inner = area.inner(self.margin);
        match self.direction {
            Direction::Horizontal => self.split_horizontal(inner),
            Direction::Vertical => self.split_vertical(inner),
        }
    }

    fn split_horizontal(&self, area: Rect) -> Vec<Rect> {
        let positions = allocate_sizes(area.width, &self.constraints);
        let mut rects = Vec::with_capacity(self.constraints.len());
        let mut x = area.x;
        for &size in &positions {
            let available = area.right().saturating_sub(x);
            let width = size.min(available);
            rects.push(Rect::new(x, area.y, width, area.height));
            x = x.saturating_add(width);
        }
        rects
    }

    fn split_vertical(&self, area: Rect) -> Vec<Rect> {
        let positions = allocate_sizes(area.height, &self.constraints);
        let mut rects = Vec::with_capacity(self.constraints.len());
        let mut y = area.y;
        for &size in &positions {
            let available = area.bottom().saturating_sub(y);
            let height = size.min(available);
            rects.push(Rect::new(area.x, y, area.width, height));
            y = y.saturating_add(height);
        }
        rects
    }
}

fn allocate_sizes(total: u16, constraints: &[Constraint]) -> Vec<u16> {
    if constraints.is_empty() {
        return Vec::new();
    }

    let total_usize = total as usize;
    let mut sizes = vec![0usize; constraints.len()];
    let mut fixed_total = 0usize;
    let mut flex = Vec::new();

    for (idx, constraint) in constraints.iter().copied().enumerate() {
        match constraint {
            Constraint::Length(length) => {
                sizes[idx] = length as usize;
                fixed_total = fixed_total.saturating_add(sizes[idx]);
            }
            Constraint::Max(max) => {
                sizes[idx] = (max as usize).min(total_usize);
                fixed_total = fixed_total.saturating_add(sizes[idx]);
            }
            Constraint::Percentage(percent) => {
                sizes[idx] = total_usize.saturating_mul(percent as usize) / 100;
                fixed_total = fixed_total.saturating_add(sizes[idx]);
            }
            Constraint::Ratio(numerator, denominator) => {
                sizes[idx] = if denominator == 0 {
                    0
                } else {
                    total_usize.saturating_mul(numerator as usize) / denominator as usize
                };
                fixed_total = fixed_total.saturating_add(sizes[idx]);
            }
            Constraint::Min(min) => flex.push((idx, min as usize, 1usize)),
            Constraint::Fill(weight) => flex.push((idx, 0usize, weight.max(1) as usize)),
        }
    }

    let remaining = total_usize.saturating_sub(fixed_total);
    if !flex.is_empty() {
        let min_total: usize = flex.iter().map(|(_, min, _)| *min).sum();
        if remaining >= min_total {
            for (idx, min, _) in &flex {
                sizes[*idx] = *min;
            }
            let extra = remaining - min_total;
            let weight_total: usize = flex.iter().map(|(_, _, weight)| *weight).sum();
            let mut used_extra = 0usize;
            for (pos, (idx, _, weight)) in flex.iter().enumerate() {
                let add = if pos + 1 == flex.len() {
                    extra.saturating_sub(used_extra)
                } else {
                    extra.saturating_mul(*weight) / weight_total.max(1)
                };
                sizes[*idx] = sizes[*idx].saturating_add(add);
                used_extra = used_extra.saturating_add(add);
            }
        } else {
            let mut used = 0usize;
            for (pos, (idx, min, _)) in flex.iter().enumerate() {
                let size = if pos + 1 == flex.len() {
                    remaining.saturating_sub(used)
                } else {
                    remaining.saturating_mul(*min) / min_total.max(1)
                };
                sizes[*idx] = size;
                used = used.saturating_add(size);
            }
        }
    }

    let mut used = 0usize;
    sizes
        .into_iter()
        .map(|size| {
            let clamped = size.min(total_usize.saturating_sub(used));
            used = used.saturating_add(clamped);
            clamped as u16
        })
        .collect()
}

impl Default for Layout {
    fn default() -> Self {
        Self::default()
    }
}

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

    #[test]
    fn test_constraint_length() {
        assert_eq!(Constraint::Length(5).apply(20), 5);
        assert_eq!(Constraint::Length(25).apply(20), 20);
    }

    #[test]
    fn test_constraint_percentage() {
        assert_eq!(Constraint::Percentage(50).apply(20), 10);
        assert_eq!(Constraint::Percentage(100).apply(20), 20);
    }

    #[test]
    fn test_layout_split_vertical() {
        let layout = Layout::vertical(vec![
            Constraint::Length(3),
            Constraint::Length(5),
            Constraint::Min(0),
        ]);
        let area = Rect::new(0, 0, 40, 20);
        let rects = layout.split(area);
        assert_eq!(rects.len(), 3);
        assert_eq!(rects[0].height, 3);
        assert_eq!(rects[1].height, 5);
        assert_eq!(rects[2].y, 8);
        assert_eq!(rects[2].height, 12);
    }

    #[test]
    fn test_layout_split_horizontal() {
        let layout = Layout::horizontal(vec![
            Constraint::Percentage(33),
            Constraint::Percentage(33),
            Constraint::Min(0),
        ]);
        let area = Rect::new(0, 0, 30, 10);
        let rects = layout.split(area);
        assert_eq!(rects.len(), 3);
        assert_eq!(rects[0].width, 9);
        assert_eq!(rects[0].height, 10);
        assert_eq!(rects[2].width, 12);
    }

    #[test]
    fn test_layout_length_min_length_matches_remaining_space() {
        let layout = Layout::default()
            .direction(Direction::Vertical)
            .constraints(vec![
                Constraint::Length(3),
                Constraint::Min(0),
                Constraint::Length(2),
            ]);
        let rects = layout.split(Rect::new(0, 0, 20, 10));
        assert_eq!(rects[0], Rect::new(0, 0, 20, 3));
        assert_eq!(rects[1], Rect::new(0, 3, 20, 5));
        assert_eq!(rects[2], Rect::new(0, 8, 20, 2));
    }

    #[test]
    fn test_layout_never_overflows_area() {
        let layout = Layout::horizontal(vec![
            Constraint::Length(8),
            Constraint::Length(8),
            Constraint::Min(0),
        ]);
        let rects = layout.split(Rect::new(2, 1, 10, 3));
        assert_eq!(rects.last().unwrap().right(), 12);
    }
}