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}