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 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 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 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 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 for lines in prepared.iter_mut() {
177 while lines.len() < max_lines {
178 lines.push(String::new());
179 }
180 }
181
182 if layout.border {
184 out.push_str(&render_border_line(&widths, '┌', '┬', '┐', '─'));
185 out.push('\n');
186 }
187
188 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 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}