Skip to main content

a3s_tui/
layout.rs

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}