Skip to main content

rich/
ratio.rs

1//! Ratio-based size resolution.
2//!
3//! Port of `rich/_ratio.py`. [`ratio_resolve`] distributes a total span among
4//! a set of edges, each of which may pin a fixed `size`, or flex by `ratio`
5//! down to a `minimum_size`; [`Layout`](crate::layout::Layout) sizes its split
6//! regions with it. [`ratio_reduce`] and [`ratio_distribute`] size `Table`
7//! columns.
8
9/// One participant in a [`ratio_resolve`] distribution.
10#[derive(Debug, Clone, Copy)]
11pub struct Edge {
12    /// A fixed size, if pinned.
13    pub size: Option<usize>,
14    /// Flex weight when `size` is `None` (defaults to 1 upstream).
15    pub ratio: usize,
16    /// The smallest size a flexible edge may shrink to.
17    pub minimum_size: usize,
18}
19
20impl Edge {
21    pub fn new(size: Option<usize>, ratio: usize, minimum_size: usize) -> Self {
22        Edge {
23            size,
24            ratio,
25            minimum_size,
26        }
27    }
28}
29
30/// Distribute `total` across `edges`, returning a concrete size per edge.
31///
32/// Direct port of `rich._ratio.ratio_resolve`, with its `Fraction`
33/// arithmetic done exactly on integers (`portion = remaining / ratio_sum`
34/// is kept as a numerator over `ratio_sum`), so no float rounding can drop
35/// a cell.
36pub fn ratio_resolve(total: usize, edges: &[Edge]) -> Vec<usize> {
37    // `edge.size or None`: a size of 0 is flexible, like no size.
38    let mut sizes: Vec<Option<usize>> = edges
39        .iter()
40        .map(|e| e.size.filter(|&size| size > 0))
41        .collect();
42
43    // Resolve one flexible edge per pass until all are fixed.
44    while sizes.iter().any(Option::is_none) {
45        let flexible: Vec<usize> = sizes
46            .iter()
47            .enumerate()
48            .filter(|(_, s)| s.is_none())
49            .map(|(i, _)| i)
50            .collect();
51
52        let fixed_sum: i128 = sizes.iter().flatten().map(|&s| s as i128).sum();
53        let remaining = total as i128 - fixed_sum;
54        if remaining <= 0 {
55            // No room for flexible edges: give each its minimum (or its size).
56            return sizes
57                .iter()
58                .zip(edges)
59                .map(|(size, edge)| match size {
60                    Some(s) => *s,
61                    None => edge.minimum_size.max(1),
62                })
63                .collect();
64        }
65
66        // `portion = Fraction(remaining, sum(edge.ratio or 1 ...))`. `u128`
67        // holds any `usize` product, so nothing here overflows.
68        let remaining = remaining as u128;
69        let ratio_sum: u128 = flexible
70            .iter()
71            .map(|&i| edges[i].ratio.max(1) as u128)
72            .fold(0, u128::saturating_add);
73
74        // If any flexible edge would fall below its minimum
75        // (`portion * edge.ratio <= edge.minimum_size`), pin it and retry —
76        // a newly fixed size changes the remaining distribution.
77        let mut pinned = false;
78        for &i in &flexible {
79            let edge = &edges[i];
80            if remaining * edge.ratio as u128
81                <= (edge.minimum_size as u128).saturating_mul(ratio_sum)
82            {
83                sizes[i] = Some(edge.minimum_size);
84                pinned = true;
85                break;
86            }
87        }
88        if !pinned {
89            // `size, remainder = divmod(portion * edge.ratio + remainder, 1)`,
90            // in units of `1 / ratio_sum`.
91            let mut remainder = 0u128;
92            for &i in &flexible {
93                let value = (remaining * edges[i].ratio as u128).saturating_add(remainder);
94                sizes[i] = Some(usize::try_from(value / ratio_sum).unwrap_or(usize::MAX));
95                remainder = value % ratio_sum;
96            }
97            break;
98        }
99    }
100
101    sizes.into_iter().map(|s| s.unwrap_or(0)).collect()
102}
103
104/// Reduce `values` by `total`, distributed across slots by `ratios` (capped by
105/// `maximums`): a slot whose maximum is 0 takes no share. Port of
106/// `rich._ratio.ratio_reduce`.
107pub fn ratio_reduce(total: i64, ratios: &[i64], maximums: &[i64], values: &[i64]) -> Vec<i64> {
108    let ratios: Vec<i64> = ratios
109        .iter()
110        .zip(maximums)
111        .map(|(&r, &m)| if m != 0 { r } else { 0 })
112        .collect();
113    let mut total_ratio: i128 = ratios.iter().map(|&r| i128::from(r)).sum();
114    if total_ratio == 0 {
115        return values.to_vec();
116    }
117    let mut total_remaining = total;
118    let mut result = Vec::with_capacity(values.len());
119    for ((&ratio, &maximum), &value) in ratios.iter().zip(maximums).zip(values) {
120        if ratio != 0 && total_ratio > 0 {
121            // `round(ratio * total_remaining / total_ratio)`: Python rounds
122            // half to even.
123            let distributed = maximum.min(
124                (ratio as f64 * total_remaining as f64 / total_ratio as f64).round_ties_even()
125                    as i64,
126            );
127            result.push(value - distributed);
128            total_remaining -= distributed;
129            total_ratio -= i128::from(ratio);
130        } else {
131            result.push(value);
132        }
133    }
134    result
135}
136
137/// Divide `total` across slots proportionally to `ratios` (ceil each share),
138/// each share floored at the matching `minimums` entry when given. Port of
139/// `rich._ratio.ratio_distribute`; where upstream asserts the ratios sum to
140/// more than 0, the whole total goes to the first slot.
141pub fn ratio_distribute(total: i64, ratios: &[i64], minimums: Option<&[i64]>) -> Vec<i64> {
142    // Upstream zeroes the ratio of any slot whose minimum is 0 (falsy).
143    let ratios: Vec<i64> = match minimums {
144        Some(mins) => ratios
145            .iter()
146            .zip(mins)
147            .map(|(&r, &m)| if m != 0 { r } else { 0 })
148            .collect(),
149        None => ratios.to_vec(),
150    };
151    // Python ints never overflow; `ratio * total_remaining` can exceed i64
152    // for a huge ratio, so the arithmetic runs in i128.
153    let mut total_ratio: i128 = ratios.iter().map(|&r| i128::from(r)).sum();
154    let mut total_remaining = i128::from(total);
155    let mut result = Vec::with_capacity(ratios.len());
156    for (index, &ratio) in ratios.iter().enumerate() {
157        let ratio = i128::from(ratio);
158        let minimum = i128::from(minimums.map_or(0, |m| m[index]));
159        let distributed = if total_ratio > 0 {
160            // `max(minimum, ceil(ratio * total_remaining / total_ratio))`;
161            // the remaining total goes negative once a minimum overshoots.
162            let numerator = ratio * total_remaining;
163            minimum.max(-(-numerator).div_euclid(total_ratio))
164        } else {
165            total_remaining
166        };
167        result.push(i64::try_from(distributed).unwrap_or(if distributed < 0 {
168            i64::MIN
169        } else {
170            i64::MAX
171        }));
172        total_ratio -= ratio;
173        total_remaining -= distributed;
174    }
175    result
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn edges(specs: &[(Option<usize>, usize)]) -> Vec<Edge> {
183        specs
184            .iter()
185            .map(|&(size, ratio)| Edge::new(size, ratio, 1))
186            .collect()
187    }
188
189    #[test]
190    fn even_split_carries_remainder() {
191        // 23 across two ratio-1 edges → 11, 12 (matches upstream's divmod).
192        assert_eq!(
193            ratio_resolve(23, &edges(&[(None, 1), (None, 1)])),
194            vec![11, 12]
195        );
196    }
197
198    #[test]
199    fn even_split_exact() {
200        assert_eq!(
201            ratio_resolve(24, &edges(&[(None, 1), (None, 1)])),
202            vec![12, 12]
203        );
204    }
205
206    #[test]
207    fn fixed_and_flex() {
208        // One flexible (ratio 3) + one fixed size 5, total 24 → 19, 5.
209        assert_eq!(
210            ratio_resolve(24, &edges(&[(None, 3), (Some(5), 1)])),
211            vec![19, 5]
212        );
213    }
214
215    #[test]
216    fn ratio_weighting() {
217        // 3:1 across 24 → 18, 6.
218        assert_eq!(
219            ratio_resolve(24, &edges(&[(None, 3), (None, 1)])),
220            vec![18, 6]
221        );
222    }
223}