modcli/output/
layout.rs

1use console::measure_text_width;
2use terminal_size::{terminal_size, Width};
3
4#[derive(Clone, Copy, Debug, Default)]
5pub enum WidthSpec {
6    Fixed(usize),
7    Percent(u16),
8    #[default]
9    Auto,
10}
11
12#[derive(Debug, Default)]
13pub struct Column {
14    pub width: WidthSpec,
15    pub gap: usize,
16    pub content: Vec<String>,
17}
18
19#[derive(Debug, Default)]
20pub struct Row {
21    pub cols: Vec<Column>,
22}
23
24#[derive(Debug, Default)]
25pub struct Layout {
26    pub rows: Vec<Row>,
27    pub hgap: usize,
28    pub vgap: usize,
29    pub border: bool,
30}
31
32pub struct Builder {
33    layout: Layout,
34    current: Row,
35}
36
37pub fn build() -> Builder {
38    Builder {
39        layout: Layout::default(),
40        current: Row::default(),
41    }
42}
43
44impl Builder {
45    pub fn row(mut self) -> Self {
46        self.current = Row::default();
47        self
48    }
49
50    pub fn col_fixed(mut self, width: usize) -> Self {
51        self.current.cols.push(Column {
52            width: WidthSpec::Fixed(width),
53            gap: 1,
54            content: Vec::new(),
55        });
56        self
57    }
58
59    pub fn col_percent(mut self, pct: u16) -> Self {
60        self.current.cols.push(Column {
61            width: WidthSpec::Percent(pct.min(100)),
62            gap: 1,
63            content: Vec::new(),
64        });
65        self
66    }
67
68    pub fn col_auto(mut self) -> Self {
69        self.current.cols.push(Column {
70            width: WidthSpec::Auto,
71            gap: 1,
72            content: Vec::new(),
73        });
74        self
75    }
76
77    pub fn content<I: IntoIterator<Item = String>>(mut self, lines: I) -> Self {
78        if let Some(col) = self.current.cols.last_mut() {
79            col.content.extend(lines);
80        }
81        self
82    }
83
84    pub fn hgap(mut self, gap: usize) -> Self {
85        self.layout.hgap = gap;
86        self
87    }
88    pub fn vgap(mut self, gap: usize) -> Self {
89        self.layout.vgap = gap;
90        self
91    }
92    pub fn border(mut self, yes: bool) -> Self {
93        self.layout.border = yes;
94        self
95    }
96
97    pub fn end_row(mut self) -> Self {
98        if !self.current.cols.is_empty() {
99            self.layout.rows.push(std::mem::take(&mut self.current));
100        }
101        self
102    }
103
104    pub fn finish(mut self) -> Layout {
105        if !self.current.cols.is_empty() {
106            self.layout.rows.push(self.current);
107        }
108        self.layout
109    }
110}
111
112pub fn render(layout: &Layout) -> String {
113    let term_width = terminal_size()
114        .map(|(Width(w), _)| w as usize)
115        .unwrap_or(80);
116    let mut out = String::new();
117
118    for (ri, row) in layout.rows.iter().enumerate() {
119        if ri > 0 {
120            out.push_str(&"\n".repeat(layout.vgap.max(0)));
121        }
122
123        // Compute column widths
124        let mut fixed_total = 0usize;
125        let mut pct_total = 0u16;
126        let mut auto_count = 0usize;
127        for c in &row.cols {
128            match c.width {
129                WidthSpec::Fixed(w) => fixed_total += w,
130                WidthSpec::Percent(p) => pct_total = pct_total.saturating_add(p),
131                WidthSpec::Auto => auto_count += 1,
132            }
133        }
134        let gaps_total = layout.hgap.saturating_mul(row.cols.len().saturating_sub(1));
135        let base_rem = term_width.saturating_sub(fixed_total + gaps_total);
136        let _pct_pixels = ((base_rem as u128) * (pct_total as u128) / 100u128) as usize;
137        let mut widths: Vec<usize> = Vec::with_capacity(row.cols.len());
138
139        // First pass: assign fixed + percent
140        for c in &row.cols {
141            match c.width {
142                WidthSpec::Fixed(w) => widths.push(w),
143                WidthSpec::Percent(p) => {
144                    widths.push(((base_rem as u128) * (p as u128) / 100u128) as usize)
145                }
146                WidthSpec::Auto => widths.push(0),
147            }
148        }
149        // Remaining for autos
150        let used_except_auto: usize = widths.iter().sum();
151        let remaining = term_width.saturating_sub(used_except_auto + gaps_total);
152        let auto_share = if auto_count > 0 {
153            remaining / auto_count
154        } else {
155            0
156        };
157        for (i, c) in row.cols.iter().enumerate() {
158            if matches!(c.width, WidthSpec::Auto) {
159                widths[i] = auto_share;
160            }
161        }
162
163        // Prepare columns as wrapped lines
164        let mut prepared: Vec<Vec<String>> = Vec::with_capacity(row.cols.len());
165        let mut max_lines = 0usize;
166        for (i, c) in row.cols.iter().enumerate() {
167            let w = widths[i].max(1);
168            let mut lines: Vec<String> = Vec::new();
169            for line in &c.content {
170                lines.extend(wrap_to_width(line, w));
171            }
172            max_lines = max_lines.max(lines.len());
173            prepared.push(lines);
174        }
175        // Pad shorter cols
176        for lines in prepared.iter_mut() {
177            while lines.len() < max_lines {
178                lines.push(String::new());
179            }
180        }
181
182        // Optional border top
183        if layout.border {
184            out.push_str(&render_border_line(&widths, '┌', '┬', '┐', '─'));
185            out.push('\n');
186        }
187
188        // Emit lines
189        for li in 0..max_lines {
190            if layout.border {
191                out.push('│');
192            }
193            for (ci, w) in widths.iter().enumerate() {
194                let cell = prepared[ci][li].clone();
195                out.push_str(&pad_right(&cell, *w));
196                if ci < widths.len() - 1 {
197                    if layout.border {
198                        out.push('│');
199                    }
200                    out.push_str(&" ".repeat(layout.hgap));
201                    if layout.border {
202                        out.push('│');
203                    }
204                }
205            }
206            if layout.border {
207                out.push('│');
208            }
209            out.push('\n');
210        }
211
212        // Optional border bottom
213        if layout.border {
214            out.push_str(&render_border_line(&widths, '└', '┴', '┘', '─'));
215        }
216    }
217
218    out
219}
220
221fn render_border_line(widths: &[usize], left: char, cross: char, right: char, h: char) -> String {
222    let mut s = String::new();
223    s.push(left);
224    for (i, w) in widths.iter().enumerate() {
225        s.push_str(&h.to_string().repeat(*w));
226        if i < widths.len() - 1 {
227            s.push(cross);
228        }
229    }
230    s.push(right);
231    s
232}
233
234fn pad_right(s: &str, width: usize) -> String {
235    let vis = measure_text_width(s);
236    let pad = width.saturating_sub(vis);
237    format!("{s}{}", " ".repeat(pad))
238}
239
240fn wrap_to_width(s: &str, width: usize) -> Vec<String> {
241    if width == 0 {
242        return vec![String::new()];
243    }
244    let mut lines = Vec::new();
245    let mut cur = String::new();
246    for ch in s.chars() {
247        let next = format!("{cur}{ch}");
248        if measure_text_width(&next) > width {
249            if cur.is_empty() {
250                lines.push(ch.to_string());
251            } else {
252                lines.push(std::mem::take(&mut cur));
253                cur.push(ch);
254            }
255        } else {
256            cur.push(ch);
257        }
258    }
259    if !cur.is_empty() {
260        lines.push(cur);
261    }
262    if lines.is_empty() {
263        lines.push(String::new());
264    }
265    lines
266}