1use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
16
17const SEPARATOR_WIDTH: usize = 3;
19const DEFAULT_MIN_COL_WIDTH: usize = 4;
21const DEFAULT_MAX_COL_WIDTH: usize = 40;
23
24#[derive(Debug, Clone)]
27pub struct ColumnMeasure {
28 pub header_width: usize,
30 pub max_content_width: usize,
32 pub min_content_width: usize,
35}
36
37#[derive(Debug, Clone, Default)]
40pub struct LayoutCache {
41 pub columns: Vec<ColumnMeasure>,
42}
43
44impl LayoutCache {
45 pub fn prepare(headers: &[String], rows: &[Vec<String>]) -> Self {
49 let col_count = headers.len();
50 let mut columns = Vec::with_capacity(col_count);
51
52 for (col, header) in headers.iter().enumerate() {
53 let header_width = UnicodeWidthStr::width(header.as_str());
54
55 let mut max_content_width: usize = 0;
56 let mut min_content_width: usize = 0;
57
58 for row in rows {
59 if let Some(cell) = row.get(col) {
60 let cell_w = UnicodeWidthStr::width(cell.as_str());
61 max_content_width = max_content_width.max(cell_w);
62
63 let min_w = cell
64 .split_whitespace()
65 .map(UnicodeWidthStr::width)
66 .max()
67 .unwrap_or(0);
68 min_content_width = min_content_width.max(min_w);
69 }
70 }
71
72 columns.push(ColumnMeasure {
73 header_width,
74 max_content_width,
75 min_content_width,
76 });
77 }
78
79 Self { columns }
80 }
81
82 pub fn is_empty(&self) -> bool {
83 self.columns.is_empty()
84 }
85
86 pub fn col_count(&self) -> usize {
87 self.columns.len()
88 }
89}
90
91#[derive(Debug, Clone)]
93pub struct ColumnLayout {
94 pub index: usize,
96 pub resolved_width: usize,
98 pub truncated: bool,
100}
101
102pub struct LayoutEngine {
104 pub min_col_width: usize,
105 pub max_col_width: usize,
106}
107
108impl Default for LayoutEngine {
109 fn default() -> Self {
110 Self {
111 min_col_width: DEFAULT_MIN_COL_WIDTH,
112 max_col_width: DEFAULT_MAX_COL_WIDTH,
113 }
114 }
115}
116
117impl LayoutEngine {
118 pub fn new() -> Self {
119 Self::default()
120 }
121
122 pub fn with_bounds(min_col_width: usize, max_col_width: usize) -> Self {
123 Self {
124 min_col_width,
125 max_col_width,
126 }
127 }
128
129 pub fn resolve(&self, cache: &LayoutCache, terminal_width: usize) -> Vec<ColumnLayout> {
131 let col_count = cache.col_count();
132 if col_count == 0 {
133 return Vec::new();
134 }
135
136 let separator_budget = SEPARATOR_WIDTH * col_count.saturating_sub(1);
137 let available = terminal_width.saturating_sub(separator_budget);
138
139 let mut widths: Vec<usize> = cache
140 .columns
141 .iter()
142 .map(|m| {
143 let ideal = m.header_width.max(m.max_content_width);
144 ideal.clamp(self.min_col_width, self.max_col_width)
145 })
146 .collect();
147
148 if widths.iter().sum::<usize>() > available {
149 self.shrink(&mut widths, available, cache);
150 }
151
152 widths
153 .iter()
154 .enumerate()
155 .map(|(i, &w)| {
156 let m = &cache.columns[i];
157 let ideal = m
158 .header_width
159 .max(m.max_content_width)
160 .clamp(self.min_col_width, self.max_col_width);
161 ColumnLayout {
162 index: i,
163 resolved_width: w,
164 truncated: w < ideal,
165 }
166 })
167 .collect()
168 }
169
170 fn shrink(&self, widths: &mut [usize], available: usize, cache: &LayoutCache) {
172 for use_soft_floor in [true, false] {
173 loop {
174 let total: usize = widths.iter().sum();
175 if total <= available {
176 return;
177 }
178
179 let floor = |i: usize| -> usize {
180 if use_soft_floor {
181 cache.columns[i].min_content_width.max(self.min_col_width)
182 } else {
183 self.min_col_width
184 }
185 };
186
187 let shrinkable: Vec<usize> = widths
188 .iter()
189 .enumerate()
190 .filter(|&(i, &w)| w > floor(i))
191 .map(|(i, _)| i)
192 .collect();
193
194 if shrinkable.is_empty() {
195 break;
196 }
197
198 let widest = shrinkable
199 .iter()
200 .copied()
201 .max_by_key(|&i| widths[i])
202 .unwrap();
203
204 let excess = total - available;
205 let room = widths[widest] - floor(widest);
206 widths[widest] -= room.min(excess);
207 }
208 }
209 }
210}
211
212pub fn fit_cell(value: &str, width: usize) -> String {
214 if width == 0 {
215 return String::new();
216 }
217
218 let display_width = UnicodeWidthStr::width(value);
219 if display_width <= width {
220 let pad = width - display_width;
221 let mut s = value.to_string();
222 s.push_str(&" ".repeat(pad));
223 return s;
224 }
225
226 if width == 1 {
227 return "~".to_string();
228 }
229
230 let target = width - 1;
231 let mut out = String::new();
232 let mut used = 0;
233
234 for ch in value.chars() {
235 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
236 if used + ch_width > target {
237 break;
238 }
239 out.push(ch);
240 used += ch_width;
241 }
242
243 out.push('~');
244 out
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 fn make_headers() -> Vec<String> {
252 vec!["Name".into(), "Description".into(), "Count".into()]
253 }
254
255 fn make_rows() -> Vec<Vec<String>> {
256 vec![
257 vec!["apple".into(), "small red fruit".into(), "10".into()],
258 vec!["banana".into(), "long yellow fruit".into(), "200".into()],
259 ]
260 }
261
262 #[test]
263 fn fit_cell_pads_short_value() {
264 assert_eq!(fit_cell("abc", 5), "abc ");
265 }
266
267 #[test]
268 fn fit_cell_exact_width() {
269 assert_eq!(fit_cell("abcd", 4), "abcd");
270 }
271
272 #[test]
273 fn fit_cell_truncates_long_value() {
274 assert_eq!(fit_cell("abcdef", 4), "abc~");
275 }
276
277 #[test]
278 fn fit_cell_handles_wide_chars() {
279 assert_eq!(fit_cell("表計算", 5), "表計~");
280 }
281
282 #[test]
283 fn fit_cell_zero_width() {
284 assert_eq!(fit_cell("abc", 0), "");
285 }
286
287 #[test]
288 fn fit_cell_empty_value() {
289 assert_eq!(fit_cell("", 3), " ");
290 }
291
292 #[test]
293 fn cache_prepare_measures_headers() {
294 let cache = LayoutCache::prepare(&make_headers(), &[]);
295 assert_eq!(cache.columns[0].header_width, 4);
296 assert_eq!(cache.columns[1].header_width, 11);
297 }
298
299 #[test]
300 fn cache_prepare_measures_content() {
301 let cache = LayoutCache::prepare(&make_headers(), &make_rows());
302 assert_eq!(cache.columns[0].max_content_width, 6);
303 assert_eq!(cache.columns[1].max_content_width, 17);
304 }
305
306 #[test]
307 fn cache_prepare_min_content_width() {
308 let cache = LayoutCache::prepare(&make_headers(), &make_rows());
309 assert_eq!(cache.columns[1].min_content_width, 6);
310 }
311
312 #[test]
313 fn cache_is_empty_on_default() {
314 assert!(LayoutCache::default().is_empty());
315 }
316
317 #[test]
318 fn engine_resolve_empty_cache() {
319 let engine = LayoutEngine::new();
320 assert!(engine.resolve(&LayoutCache::default(), 80).is_empty());
321 }
322
323 #[test]
324 fn engine_resolve_fits_comfortably() {
325 let cache = LayoutCache::prepare(&make_headers(), &make_rows());
326 let engine = LayoutEngine::new();
327 let layouts = engine.resolve(&cache, 80);
328
329 assert_eq!(layouts.len(), 3);
330 assert!(layouts.iter().all(|layout| !layout.truncated));
331 }
332
333 #[test]
334 fn engine_resolve_shrinks_on_narrow_terminal() {
335 let cache = LayoutCache::prepare(&make_headers(), &make_rows());
336 let engine = LayoutEngine::new();
337 let layouts = engine.resolve(&cache, 20);
338
339 assert_eq!(layouts.len(), 3);
340 assert!(layouts.iter().any(|layout| layout.truncated));
341 }
342
343 #[test]
344 fn engine_never_shrinks_below_min() {
345 let cache = LayoutCache::prepare(&make_headers(), &make_rows());
346 let engine = LayoutEngine::with_bounds(4, 40);
347 let layouts = engine.resolve(&cache, 5);
348
349 assert!(layouts.iter().all(|layout| layout.resolved_width >= 4));
350 }
351
352 #[test]
353 fn engine_caps_at_max_col_width() {
354 let headers = vec!["Description".to_string()];
355 let rows = vec![vec!["x".repeat(120)]];
356 let cache = LayoutCache::prepare(&headers, &rows);
357 let engine = LayoutEngine::with_bounds(4, 20);
358 let layouts = engine.resolve(&cache, 80);
359
360 assert_eq!(layouts[0].resolved_width, 20);
361 assert!(!layouts[0].truncated);
362 }
363
364 #[test]
365 fn engine_resolve_indices_are_correct() {
366 let cache = LayoutCache::prepare(&make_headers(), &make_rows());
367 let engine = LayoutEngine::new();
368 let layouts = engine.resolve(&cache, 80);
369
370 assert_eq!(layouts[0].index, 0);
371 assert_eq!(layouts[1].index, 1);
372 assert_eq!(layouts[2].index, 2);
373 }
374}