1use crate::style::{
2 next_display_cell_boundary, split_lines_preserving_trailing_blank, visible_len,
3};
4
5#[derive(Debug, Clone, Copy)]
6pub enum Constraint {
7 Fixed(u16),
8 Percentage(u16),
9 Fill,
10 Min(u16),
11 Max(u16),
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Direction {
16 Horizontal,
17 Vertical,
18}
19
20pub struct Layout {
21 direction: Direction,
22 items: Vec<(String, Constraint)>,
23}
24
25impl Layout {
26 pub fn horizontal() -> Self {
27 Self {
28 direction: Direction::Horizontal,
29 items: Vec::new(),
30 }
31 }
32
33 pub fn vertical() -> Self {
34 Self {
35 direction: Direction::Vertical,
36 items: Vec::new(),
37 }
38 }
39
40 pub fn item(mut self, content: &str, constraint: Constraint) -> Self {
41 self.items.push((content.to_string(), constraint));
42 self
43 }
44
45 pub fn render(&self, total: u16) -> String {
46 let sizes = self.resolve_sizes(total);
47
48 match self.direction {
49 Direction::Vertical => self.render_vertical(&sizes),
50 Direction::Horizontal => self.render_horizontal(&sizes),
51 }
52 }
53
54 fn resolve_sizes(&self, total: u16) -> Vec<u16> {
55 let count = self.items.len();
56 let mut sizes = vec![0u16; count];
57 let mut remaining = total;
58 let mut fill_indices = Vec::new();
59
60 for (i, (_, constraint)) in self.items.iter().enumerate() {
61 match constraint {
62 Constraint::Fixed(n) => {
63 sizes[i] = (*n).min(remaining);
64 remaining = remaining.saturating_sub(sizes[i]);
65 }
66 Constraint::Percentage(p) => {
67 let s = (total as usize)
68 .saturating_mul(*p as usize)
69 .saturating_div(100)
70 .min(remaining as usize) as u16;
71 sizes[i] = s;
72 remaining = remaining.saturating_sub(sizes[i]);
73 }
74 Constraint::Min(n) => {
75 sizes[i] = (*n).min(remaining);
76 remaining = remaining.saturating_sub(sizes[i]);
77 fill_indices.push(i);
78 }
79 Constraint::Max(n) => {
80 sizes[i] = (*n).min(remaining);
81 remaining = remaining.saturating_sub(sizes[i]);
82 }
83 Constraint::Fill => {
84 fill_indices.push(i);
85 }
86 }
87 }
88
89 if !fill_indices.is_empty() {
90 let fill_count = fill_indices.len();
91 let share = remaining as usize / fill_count;
92 let extra = remaining as usize % fill_count;
93 for (j, &idx) in fill_indices.iter().enumerate() {
94 let add = share + if j == 0 { extra } else { 0 };
95 sizes[idx] = sizes[idx].saturating_add(add as u16);
96 }
97 }
98
99 sizes
100 }
101
102 fn render_vertical(&self, sizes: &[u16]) -> String {
103 let mut result = Vec::new();
104
105 for (i, (content, _)) in self.items.iter().enumerate() {
106 let height = sizes[i] as usize;
107 let lines = split_lines_preserving_trailing_blank(content);
108
109 for row in 0..height {
110 if row < lines.len() {
111 result.push(lines[row].to_string());
112 } else {
113 result.push(String::new());
114 }
115 }
116 }
117
118 result.join("\n")
119 }
120
121 fn render_horizontal(&self, sizes: &[u16]) -> String {
122 let max_height = self
123 .items
124 .iter()
125 .map(|(content, _)| split_lines_preserving_trailing_blank(content).len())
126 .max()
127 .unwrap_or(1);
128
129 let columns: Vec<Vec<String>> = self
130 .items
131 .iter()
132 .enumerate()
133 .map(|(i, (content, _))| {
134 let width = sizes[i] as usize;
135 let lines = split_lines_preserving_trailing_blank(content);
136 (0..max_height)
137 .map(|row| {
138 if row < lines.len() {
139 pad_or_truncate(lines[row], width)
140 } else {
141 " ".repeat(width)
142 }
143 })
144 .collect()
145 })
146 .collect();
147
148 let mut result = Vec::new();
149 for row in 0..max_height {
150 let line: String = columns.iter().map(|col| col[row].as_str()).collect();
151 result.push(line);
152 }
153
154 result.join("\n")
155 }
156}
157
158fn pad_or_truncate(s: &str, width: usize) -> String {
159 let vis_width = visible_len(s);
160 if vis_width >= width {
161 truncate_to_width(s, width)
162 } else {
163 format!("{}{}", s, " ".repeat(width - vis_width))
164 }
165}
166
167fn truncate_to_width(s: &str, width: usize) -> String {
168 let mut out = String::new();
169 let mut current_width = 0;
170 let mut saw_escape = false;
171 let mut truncated = false;
172 let mut index = 0usize;
173
174 while index < s.len() {
175 if s[index..].starts_with("\x1b[") {
176 saw_escape = true;
177 let escape_start = index;
178 index += "\x1b[".len();
179 for next in s[index..].chars() {
180 index += next.len_utf8();
181 if next.is_ascii_alphabetic() {
182 break;
183 }
184 }
185 out.push_str(&s[escape_start..index]);
186 continue;
187 }
188
189 let Some((end, cw)) = next_display_cell_boundary(s, index) else {
190 break;
191 };
192 if current_width + cw > width {
193 truncated = true;
194 break;
195 }
196 current_width += cw;
197 out.push_str(&s[index..end]);
198 index = end;
199 }
200
201 if truncated && saw_escape {
202 out.push_str("\x1b[0m");
203 }
204 if current_width < width {
205 out.push_str(&" ".repeat(width - current_width));
206 }
207 out
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn fixed_constraint() {
216 let layout = Layout::horizontal()
217 .item("A", Constraint::Fixed(10))
218 .item("B", Constraint::Fixed(20));
219 let sizes = layout.resolve_sizes(80);
220 assert_eq!(sizes, vec![10, 20]);
221 }
222
223 #[test]
224 fn percentage_constraint() {
225 let layout = Layout::horizontal()
226 .item("A", Constraint::Percentage(50))
227 .item("B", Constraint::Percentage(50));
228 let sizes = layout.resolve_sizes(80);
229 assert_eq!(sizes, vec![40, 40]);
230 }
231
232 #[test]
233 fn oversized_percentage_clamps_to_remaining_space() {
234 let layout = Layout::horizontal()
235 .item("A", Constraint::Percentage(u16::MAX))
236 .item("B", Constraint::Fill);
237 let sizes = layout.resolve_sizes(u16::MAX);
238
239 assert_eq!(sizes, vec![u16::MAX, 0]);
240 }
241
242 #[test]
243 fn fill_distributes_remaining() {
244 let layout = Layout::horizontal()
245 .item("A", Constraint::Fixed(20))
246 .item("B", Constraint::Fill)
247 .item("C", Constraint::Fixed(10));
248 let sizes = layout.resolve_sizes(80);
249 assert_eq!(sizes[0], 20);
250 assert_eq!(sizes[1], 50);
251 assert_eq!(sizes[2], 10);
252 }
253
254 #[test]
255 fn multiple_fills_share_equally() {
256 let layout = Layout::horizontal()
257 .item("A", Constraint::Fill)
258 .item("B", Constraint::Fill);
259 let sizes = layout.resolve_sizes(80);
260 assert_eq!(sizes[0], 40);
261 assert_eq!(sizes[1], 40);
262 }
263
264 #[test]
265 fn many_fills_do_not_overflow_share_count() {
266 let layout = (0..u16::MAX as usize + 1).fold(Layout::horizontal(), |layout, _| {
267 layout.item("", Constraint::Fill)
268 });
269 let sizes = layout.resolve_sizes(3);
270
271 assert_eq!(sizes.iter().copied().map(u32::from).sum::<u32>(), 3);
272 assert_eq!(sizes[0], 3);
273 assert!(sizes[1..].iter().all(|size| *size == 0));
274 }
275
276 #[test]
277 fn render_horizontal_basic() {
278 let layout = Layout::horizontal()
279 .item("left", Constraint::Fixed(6))
280 .item("right", Constraint::Fixed(6));
281 let output = layout.render(12);
282 assert!(output.contains("left"));
283 assert!(output.contains("right"));
284 }
285
286 #[test]
287 fn render_horizontal_preserves_trailing_blank_rows() {
288 let layout = Layout::horizontal()
289 .item("A\n", Constraint::Fixed(2))
290 .item("B", Constraint::Fixed(2));
291
292 assert_eq!(layout.render(4), "A B \n ");
293 }
294
295 #[test]
296 fn render_vertical_basic() {
297 let layout = Layout::vertical()
298 .item("top", Constraint::Fixed(1))
299 .item("bottom", Constraint::Fixed(1));
300 let output = layout.render(2);
301 assert!(output.contains("top"));
302 assert!(output.contains("bottom"));
303 }
304
305 #[test]
306 fn pad_or_truncate_pads() {
307 assert_eq!(pad_or_truncate("hi", 5), "hi ");
308 }
309
310 #[test]
311 fn pad_or_truncate_truncates() {
312 let result = pad_or_truncate("hello world", 5);
313 assert_eq!(result.len(), 5);
314 }
315
316 #[test]
317 fn pad_or_truncate_keeps_zero_width_marks_with_base_glyph() {
318 let result = pad_or_truncate("e\u{0301}xyz", 2);
319
320 assert_eq!(result, "e\u{0301}x");
321 assert_eq!(visible_len(&result), 2);
322 }
323
324 #[test]
325 fn pad_or_truncate_resets_ansi_after_truncating_styled_text() {
326 let result = pad_or_truncate("\x1b[31mhello\x1b[0m", 3);
327
328 assert_eq!(visible_len(&result), 3);
329 assert!(result.ends_with("\x1b[0m"), "{result:?}");
330 }
331
332 #[test]
333 fn pad_or_truncate_skips_ansi_reset_between_segments() {
334 let result = pad_or_truncate("\x1b[32mok\x1b[0mabcdef", 5);
335
336 assert_eq!(visible_len(&result), 5);
337 assert_eq!(crate::style::strip_ansi(&result), "okabc");
338 assert!(result.contains("\x1b[0mabc"), "{result:?}");
339 }
340}