Skip to main content

oxicode_vtui/presentation/
allocation.rs

1//! Pressure-driven tool row allocation ladder.
2//!
3//! The ladder is the *automatic* layer that the live region uses to fit
4//! ever-taller blocks (tool calls with diffs, file reads with full
5//! previews, command outputs) into a finite viewport. It is pure:
6//! given a list of per-block natural heights and a row budget, it
7//! returns the rendered row count for each block. The renderer
8//! decides how to draw the quantized shapes (glyph / folded / full);
9//! this module decides how many rows each block gets.
10//!
11//! # Allocation levels
12//!
13//! - `0` — block is hidden entirely (emergency truncation; the
14//!   caller reserves a single banner row instead).
15//! - `1` — glyph row (`▸ tool · activity`), animated wall-clock
16//!   pulse on a shared period so the live region breathes.
17//! - `2` — folded card (`╭─ tool · activity` / `╰─ …`), the static
18//!   "something is here" affordance.
19//! - `natural` — full block at its natural height.
20//!
21//! Allocations are quantized to those four shapes: a block never
22//! receives `3..natural-1` rows, because the live-region renderer
23//! maps any allocation of 3 or more rows to the FULL natural
24//! render — a "3 of 5" allocation would paint 5 rows and overflow
25//! the budget.
26//!
27//! User-set `BlockDisplayMode` overrides happen at the call site
28//! (see `render_transcript`); the ladder applies only to blocks
29//! without a manual override.
30
31/// Row budget assigned to a single block by [`allocate_rows`].
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct BlockAlloc {
34    pub rows: usize,
35}
36
37/// Allocate a row budget across the given per-block natural heights.
38/// Pure function: same `(block_heights, budget)` always yields the
39/// same `Vec<BlockAlloc>`. See module docs for the algorithm.
40pub fn allocate_rows(block_heights: &[usize], budget: usize) -> Vec<BlockAlloc> {
41    let n = block_heights.len();
42    let mut out = vec![BlockAlloc { rows: 0 }; n];
43
44    // Empty input: nothing to allocate, nothing to overflow.
45    if n == 0 {
46        return out;
47    }
48
49    let total: usize = block_heights.iter().sum();
50
51    // Roomy: every block fits in full. Cap each block at its
52    // natural height so excess budget never inflates a row.
53    if total <= budget {
54        for (i, &h) in block_heights.iter().enumerate() {
55            out[i].rows = h;
56        }
57        return out;
58    }
59
60    // Emergency: more blocks than rows. Hide the oldest
61    // `n - budget` blocks entirely. The caller reserves one row
62    // for the `… N earlier blocks hidden` banner in the budget
63    // when `budget >= 1`, so we keep exactly `budget` glyph rows.
64    // A zero budget collapses every block to hidden.
65    if n > budget {
66        let keep = budget;
67        // Indices in `[n - keep, n)` get a glyph row; the rest
68        // are hidden. When `keep == 0` the range is empty and
69        // every slot stays at 0.
70        let first_kept = n.saturating_sub(keep);
71        for (i, slot) in out.iter_mut().enumerate() {
72            slot.rows = if i >= first_kept { 1 } else { 0 };
73        }
74        return out;
75    }
76    // Pressure: every block gets at least 1 row. Surplus
77    // (`budget - n`) is distributed newest-first, quantized to the
78    // shapes the live region can actually render: the block's full
79    // natural height, the 2-row folded card, or the 1-row glyph
80    // floor. Mid-range allocations (`3..natural-1` rows) are never
81    // emitted — the renderer maps any `alloc.rows >= 3` to the full
82    // natural render, so a 3-of-5 allocation would paint 5 rows and
83    // blow the budget.
84    let surplus = budget - n;
85    let mut remaining = surplus;
86    for i in (0..n).rev() {
87        let natural = block_heights[i];
88        // Rows beyond the glyph floor needed to render in full.
89        let full_deficit = natural.saturating_sub(1);
90        if full_deficit > 0 && remaining >= full_deficit {
91            // Full natural height.
92            out[i].rows = natural;
93            remaining -= full_deficit;
94        } else if natural >= 2 && remaining >= 1 {
95            // Folded card (header + ellipsis row).
96            out[i].rows = 2;
97            remaining -= 1;
98        } else {
99            // Glyph floor.
100            out[i].rows = 1;
101        }
102    }
103    out
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn roomy_all_full() {
112        // 3 blocks, heights 4/2/3 = 9 total; budget 12 → all full,
113        // and excess budget is NOT inflated onto any block.
114        let heights = [4, 2, 3];
115        let alloc = allocate_rows(&heights, 12);
116        assert_eq!(alloc.len(), 3);
117        assert_eq!(alloc[0].rows, 4);
118        assert_eq!(alloc[1].rows, 2);
119        assert_eq!(alloc[2].rows, 3);
120        // Exactly-fits budget: no extra rows.
121        let alloc2 = allocate_rows(&[2, 3, 4], 9);
122        assert_eq!(alloc2[0].rows, 2);
123        assert_eq!(alloc2[1].rows, 3);
124        assert_eq!(alloc2[2].rows, 4);
125    }
126
127    #[test]
128    fn pressure_folds_oldest_first() {
129        // Surplus is distributed newest-first. Newest gets filled
130        // up first; once it caps, the next-newest absorbs the rest.
131        let heights = [5, 4, 3];
132        // budget 6 → surplus 3. Newest(h=3) takes 2 → rows=3.
133        // remaining=1 → mid(h=4) takes 1 → rows=2 (folded).
134        // Oldest stays at 1 (glyph).
135        let alloc = allocate_rows(&heights, 6);
136        assert_eq!(alloc[0].rows, 1, "oldest pinned to glyph");
137        assert_eq!(alloc[1].rows, 2, "mid folded card (1 surplus)");
138        assert_eq!(alloc[2].rows, 3, "newest full");
139
140        // 3 blocks, heights 5/4/3 = 12 total; budget 8 → 5 surplus.
141        // Newest gets min(2, 3) = 2 → rows = 3. remaining = 3.
142        // Next gets min(3, 4) = 3 → rows = 4. remaining = 0.
143        // Oldest stays at 1.
144        let alloc2 = allocate_rows(&heights, 8);
145        assert_eq!(alloc2[0].rows, 1, "oldest pinned (no surplus left)");
146        assert_eq!(alloc2[1].rows, 4, "middle full");
147        assert_eq!(alloc2[2].rows, 3, "newest full");
148
149        // 3 blocks, heights 5/4/3; budget 7 → 4 surplus.
150        // Newest gets deficit 2 → full 3. remaining = 2.
151        // Next (h=4): deficit 3 > 2 → folded card 2. remaining = 1.
152        // Oldest (h=5): deficit 4 > 1 → folded card 2. remaining = 0.
153        // No block ever lands in the forbidden 3..natural-1 band.
154        let alloc3 = allocate_rows(&heights, 7);
155        assert_eq!(alloc3[0].rows, 2, "oldest folded card");
156        assert_eq!(alloc3[1].rows, 2, "mid folded card (2-row fold)");
157        assert_eq!(alloc3[2].rows, 3);
158    }
159
160    #[test]
161    fn pressure_never_allocates_mid_range_rows() {
162        // A block's share is always 0, 1, 2, or its natural height —
163        // never 3..natural-1. The renderer treats alloc.rows >= 3 as
164        // "render natural", so a mid-range share would overflow the
165        // budget (final-review finding 5).
166
167        // Single 10-row block, budget 4: the old allocator emitted 4
168        // (mid-range); the renderer would have painted all 10 rows.
169        let alloc = allocate_rows(&[10], 4);
170        assert_eq!(alloc[0].rows, 2, "budget 4 of natural 10 folds");
171
172        // Budget 9 is still mid-range for natural 10 → folded card,
173        // even though 7 budget rows go unused.
174        let alloc = allocate_rows(&[10], 9);
175        assert_eq!(alloc[0].rows, 2);
176
177        // Budget 10 = natural → roomy, full render.
178        let alloc = allocate_rows(&[10], 10);
179        assert_eq!(alloc[0].rows, 10);
180
181        // Exhaustive sweep: for every (heights, budget) combination
182        // every allocation is quantized and the sum stays in budget.
183        for budget in 0..=30usize {
184            for h in 3..=8usize {
185                let heights = [h, h, h];
186                let allocs = allocate_rows(&heights, budget);
187                let mut sum = 0usize;
188                for a in &allocs {
189                    assert!(
190                        a.rows <= 2 || a.rows == h,
191                        "mid-range allocation rows={} for natural={h} (budget {budget})",
192                        a.rows
193                    );
194                    sum += a.rows;
195                }
196                assert!(
197                    sum <= budget.max(heights.iter().sum()),
198                    "sum {sum} exceeds budget {budget} (heights {heights:?})"
199                );
200            }
201        }
202    }
203
204    #[test]
205    fn emergency_hides_oldest_and_banners() {
206        // 5 blocks, budget 3 → emergency. Newest 3 get 1 glyph
207        // row each; oldest 2 hidden. The caller reserves 1 row
208        // for the banner; total painted = 3 glyphs + 1 banner.
209        let heights = [2, 3, 4, 5, 6];
210        let alloc = allocate_rows(&heights, 3);
211        assert_eq!(alloc.len(), 5);
212        assert_eq!(alloc[0].rows, 0, "oldest hidden");
213        assert_eq!(alloc[4].rows, 1, "newest glyph");
214        // Sum of allocated glyph rows equals budget; caller adds
215        // +1 for the banner.
216        assert_eq!(alloc.iter().map(|a| a.rows).sum::<usize>(), 3);
217
218        // n > budget exactly: 4 blocks of height 2, budget 3 →
219        // emergency. Newest 3 glyphs, oldest 1 hidden.
220        let alloc3 = allocate_rows(&[2, 2, 2, 2], 3);
221        assert_eq!(alloc3[0].rows, 0);
222        assert_eq!(alloc3[1].rows, 1);
223        assert_eq!(alloc3[2].rows, 1);
224        assert_eq!(alloc3[3].rows, 1);
225    }
226
227    #[test]
228    fn empty_inputs_no_panic() {
229        // Empty heights: returns empty vec.
230        let alloc = allocate_rows(&[], 10);
231        assert!(alloc.is_empty());
232
233        // Zero budget with non-empty heights: every block hidden
234        // (emergency branch, keep = 0 → all slots 0).
235        let alloc = allocate_rows(&[5, 4, 3], 0);
236        assert_eq!(alloc.len(), 3);
237        assert!(alloc.iter().all(|a| a.rows == 0));
238
239        // Zero budget, empty heights: still empty, no panic.
240        let alloc = allocate_rows(&[], 0);
241        assert!(alloc.is_empty());
242
243        // Heights of 0: roomy wins (sum 0 <= budget); every
244        // block gets 0 rows (which is "nothing to render").
245        let alloc = allocate_rows(&[0, 0, 0], 10);
246        assert_eq!(alloc.len(), 3);
247        assert!(alloc.iter().all(|a| a.rows == 0));
248    }
249
250    #[test]
251    fn single_block_taller_than_budget_folds() {
252        // Single block of height 10, budget 4 → pressure branch
253        // (n=1, budget=4). Surplus 3 < deficit 9, so the share is
254        // quantized down to the 2-row folded card (never 3..9).
255        let alloc = allocate_rows(&[10], 4);
256        assert_eq!(alloc[0].rows, 2);
257
258        // Budget 2: surplus = 1. Newest gets min(9, 1) = 1 →
259        // rows = 2 = folded card.
260        let alloc = allocate_rows(&[10], 2);
261        assert_eq!(alloc[0].rows, 2);
262
263        // Budget 1: surplus = 0. Newest gets 0 extra → rows = 1
264        // = glyph row.
265        let alloc = allocate_rows(&[10], 1);
266        assert_eq!(alloc[0].rows, 1);
267
268        // Budget 0: emergency branch (n > b). All hidden.
269        let alloc = allocate_rows(&[10], 0);
270        assert_eq!(alloc[0].rows, 0);
271
272        // Two blocks both taller than budget: oldest pinned at
273        // glyph if no surplus reaches it.
274        let alloc = allocate_rows(&[10, 10], 3);
275        // n=2, budget=3 → pressure. Surplus = 1. Newest (idx 1)
276        // gets min(9, 1) = 1 → rows = 2. Oldest (idx 0) stays
277        // at 1.
278        assert_eq!(alloc[0].rows, 1);
279        assert_eq!(alloc[1].rows, 2);
280    }
281}