1use super::types::{FlatDataSpec, Width};
7use super::util::display_width;
8
9#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct ResolvedWidths {
12 pub widths: Vec<usize>,
14}
15
16impl ResolvedWidths {
17 pub fn get(&self, index: usize) -> Option<usize> {
19 self.widths.get(index).copied()
20 }
21
22 pub fn total(&self) -> usize {
24 self.widths.iter().sum()
25 }
26
27 pub fn len(&self) -> usize {
29 self.widths.len()
30 }
31
32 pub fn is_empty(&self) -> bool {
34 self.widths.is_empty()
35 }
36}
37
38impl FlatDataSpec {
39 pub fn resolve_widths(&self, total_width: usize) -> ResolvedWidths {
49 self.resolve_widths_impl(total_width, None)
50 }
51
52 pub fn resolve_widths_from_data<S: AsRef<str>>(
81 &self,
82 total_width: usize,
83 data: &[Vec<S>],
84 ) -> ResolvedWidths {
85 let mut max_data_widths: Vec<usize> = vec![0; self.columns.len()];
87
88 for row in data {
89 for (i, cell) in row.iter().enumerate() {
90 if i < max_data_widths.len() {
91 let cell_width = display_width(cell.as_ref());
92 max_data_widths[i] = max_data_widths[i].max(cell_width);
93 }
94 }
95 }
96
97 self.resolve_widths_impl(total_width, Some(&max_data_widths))
98 }
99
100 fn resolve_widths_impl(
102 &self,
103 total_width: usize,
104 data_widths: Option<&[usize]>,
105 ) -> ResolvedWidths {
106 if self.columns.is_empty() {
107 return ResolvedWidths { widths: vec![] };
108 }
109
110 let overhead = self.decorations.overhead(self.columns.len());
111 let available = total_width.saturating_sub(overhead);
112
113 let mut widths: Vec<usize> = Vec::with_capacity(self.columns.len());
114 let mut flex_indices: Vec<(usize, usize)> = Vec::new(); let mut used_width: usize = 0;
116
117 for (i, col) in self.columns.iter().enumerate() {
119 match &col.width {
120 Width::Fixed(w) => {
121 widths.push(*w);
122 used_width += w;
123 }
124 Width::Bounded { min, max } => {
125 let min_w = min.unwrap_or(0);
126 let max_w = max.unwrap_or(usize::MAX);
127
128 let data_w = data_widths.and_then(|dw| dw.get(i).copied()).unwrap_or(0);
130 let width = data_w.max(min_w).min(max_w);
131
132 widths.push(width);
133 used_width += width;
134 }
135 Width::Fill => {
136 widths.push(0); flex_indices.push((i, 1)); }
139 Width::Fraction(n) => {
140 widths.push(0); flex_indices.push((i, *n)); }
143 }
144 }
145
146 let remaining = available.saturating_sub(used_width);
148
149 if !flex_indices.is_empty() {
150 let total_weight: usize = flex_indices.iter().map(|(_, w)| w).sum();
151 if total_weight > 0 {
152 let mut remaining_space = remaining;
153
154 for (i, (idx, weight)) in flex_indices.iter().enumerate() {
155 let width = if i == flex_indices.len() - 1 {
157 remaining_space
158 } else {
159 let share = (remaining * weight) / total_weight;
160 remaining_space = remaining_space.saturating_sub(share);
161 share
162 };
163 widths[*idx] = width;
164 }
165 }
166 } else if remaining > 0 {
167 if let Some(idx) = self
170 .columns
171 .iter()
172 .rposition(|c| matches!(c.width, Width::Bounded { .. }))
173 {
174 widths[idx] += remaining;
180 }
181 }
182
183 ResolvedWidths { widths }
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use crate::tabular::{Column, Width};
191
192 #[test]
193 fn resolve_empty_spec() {
194 let spec = FlatDataSpec::builder().build();
195 let resolved = spec.resolve_widths(80);
196 assert!(resolved.is_empty());
197 }
198
199 #[test]
200 fn resolve_fixed_columns() {
201 let spec = FlatDataSpec::builder()
202 .column(Column::new(Width::Fixed(10)))
203 .column(Column::new(Width::Fixed(20)))
204 .column(Column::new(Width::Fixed(15)))
205 .build();
206
207 let resolved = spec.resolve_widths(100);
208 assert_eq!(resolved.widths, vec![10, 20, 15]);
209 assert_eq!(resolved.total(), 45);
210 }
211
212 #[test]
213 fn resolve_fill_column() {
214 let spec = FlatDataSpec::builder()
215 .column(Column::new(Width::Fixed(10)))
216 .column(Column::new(Width::Fill))
217 .column(Column::new(Width::Fixed(10)))
218 .separator(" ") .build();
220
221 let resolved = spec.resolve_widths(80);
224 assert_eq!(resolved.widths, vec![10, 56, 10]);
225 }
226
227 #[test]
228 fn resolve_multiple_fill_columns() {
229 let spec = FlatDataSpec::builder()
230 .column(Column::new(Width::Fixed(10)))
231 .column(Column::new(Width::Fill))
232 .column(Column::new(Width::Fill))
233 .build();
234
235 let resolved = spec.resolve_widths(100);
238 assert_eq!(resolved.widths, vec![10, 45, 45]);
239 }
240
241 #[test]
242 fn resolve_fill_columns_uneven_split() {
243 let spec = FlatDataSpec::builder()
244 .column(Column::new(Width::Fill))
245 .column(Column::new(Width::Fill))
246 .column(Column::new(Width::Fill))
247 .build();
248
249 let resolved = spec.resolve_widths(10);
251 assert_eq!(resolved.widths, vec![3, 3, 4]);
252 assert_eq!(resolved.total(), 10);
253 }
254
255 #[test]
256 fn resolve_bounded_with_min() {
257 let spec = FlatDataSpec::builder()
258 .column(Column::new(Width::Bounded {
259 min: Some(10),
260 max: None,
261 }))
262 .build();
263
264 let resolved = spec.resolve_widths(80);
266 assert_eq!(resolved.widths, vec![80]);
267 }
268
269 #[test]
270 fn resolve_bounded_from_data() {
271 let spec = FlatDataSpec::builder()
272 .column(Column::new(Width::Bounded {
273 min: Some(5),
274 max: Some(20),
275 }))
276 .column(Column::new(Width::Fixed(10)))
281 .build();
282
283 let data: Vec<Vec<&str>> = vec![vec!["short", "value"], vec!["longer text here", "x"]];
284
285 let resolved = spec.resolve_widths_from_data(80, &data);
286 assert_eq!(resolved.widths[0], 70);
290 assert_eq!(resolved.widths[1], 10);
291 }
292
293 #[test]
294 fn resolve_bounded_clamps_to_max_if_not_expanding() {
295 let spec = FlatDataSpec::builder()
299 .column(Column::new(Width::Bounded {
300 min: Some(5),
301 max: Some(10),
302 }))
303 .column(Column::new(Width::Fill)) .build();
305
306 let data: Vec<Vec<&str>> = vec![vec!["this is a very long string that exceeds max"]];
307
308 let resolved = spec.resolve_widths_from_data(80, &data);
309 assert_eq!(resolved.widths[0], 10); assert_eq!(resolved.widths[1], 70);
311 }
312
313 #[test]
314 fn resolve_bounded_respects_min() {
315 let spec = FlatDataSpec::builder()
316 .column(Column::new(Width::Bounded {
317 min: Some(10),
318 max: Some(20),
319 }))
320 .column(Column::new(Width::Fill)) .build();
322
323 let data: Vec<Vec<&str>> = vec![vec!["hi"]]; let resolved = spec.resolve_widths_from_data(80, &data);
326 assert_eq!(resolved.widths[0], 10); assert_eq!(resolved.widths[1], 70);
328 }
329
330 #[test]
333 fn resolve_with_decorations() {
334 let spec = FlatDataSpec::builder()
335 .column(Column::new(Width::Fixed(10)))
336 .column(Column::new(Width::Fill))
337 .separator(" | ") .prefix("│ ") .suffix(" │") .build();
341
342 let resolved = spec.resolve_widths(50);
347 assert_eq!(resolved.widths, vec![10, 33]);
348 }
349
350 #[test]
351 fn resolve_tight_space() {
352 let spec = FlatDataSpec::builder()
353 .column(Column::new(Width::Fixed(10)))
354 .column(Column::new(Width::Fill))
355 .column(Column::new(Width::Fixed(10)))
356 .separator(" ")
357 .build();
358
359 let resolved = spec.resolve_widths(24);
363 assert_eq!(resolved.widths, vec![10, 0, 10]);
364 }
365
366 #[test]
367 fn resolve_no_fill_expands_rightmost_bounded() {
368 let spec = FlatDataSpec::builder()
369 .column(Column::new(Width::Fixed(10)))
370 .column(Column::new(Width::Bounded {
371 min: Some(5),
372 max: Some(30),
373 }))
374 .build();
375
376 let resolved = spec.resolve_widths(50);
380 assert_eq!(resolved.widths, vec![10, 40]);
381 assert_eq!(resolved.total(), 50);
382 }
383
384 #[test]
385 fn resolved_widths_accessors() {
386 let resolved = ResolvedWidths {
387 widths: vec![10, 20, 30],
388 };
389
390 assert_eq!(resolved.get(0), Some(10));
391 assert_eq!(resolved.get(1), Some(20));
392 assert_eq!(resolved.get(2), Some(30));
393 assert_eq!(resolved.get(3), None);
394 assert_eq!(resolved.total(), 60);
395 assert_eq!(resolved.len(), 3);
396 assert!(!resolved.is_empty());
397 }
398
399 #[test]
400 fn resolve_fraction_columns() {
401 let spec = FlatDataSpec::builder()
402 .column(Column::new(Width::Fraction(1)))
403 .column(Column::new(Width::Fraction(2)))
404 .column(Column::new(Width::Fraction(1)))
405 .build();
406
407 let resolved = spec.resolve_widths(100);
413 assert_eq!(resolved.widths, vec![25, 50, 25]);
414 assert_eq!(resolved.total(), 100);
415 }
416
417 #[test]
418 fn resolve_fraction_uneven_split() {
419 let spec = FlatDataSpec::builder()
420 .column(Column::new(Width::Fraction(1)))
421 .column(Column::new(Width::Fraction(1)))
422 .column(Column::new(Width::Fraction(1)))
423 .build();
424
425 let resolved = spec.resolve_widths(10);
431 assert_eq!(resolved.widths, vec![3, 3, 4]);
432 assert_eq!(resolved.total(), 10);
433 }
434
435 #[test]
436 fn resolve_mixed_fill_and_fraction() {
437 let spec = FlatDataSpec::builder()
438 .column(Column::new(Width::Fill)) .column(Column::new(Width::Fraction(2))) .column(Column::new(Width::Fill)) .build();
442
443 let resolved = spec.resolve_widths(100);
449 assert_eq!(resolved.widths, vec![25, 50, 25]);
450 assert_eq!(resolved.total(), 100);
451 }
452
453 #[test]
454 fn resolve_fraction_with_fixed() {
455 let spec = FlatDataSpec::builder()
456 .column(Column::new(Width::Fixed(20)))
457 .column(Column::new(Width::Fraction(1)))
458 .column(Column::new(Width::Fraction(3)))
459 .build();
460
461 let resolved = spec.resolve_widths(100);
466 assert_eq!(resolved.widths, vec![20, 20, 60]);
467 assert_eq!(resolved.total(), 100);
468 }
469}
470
471#[cfg(test)]
472mod proptests {
473 use super::*;
474 use crate::tabular::{Column, Width};
475 use proptest::prelude::*;
476
477 proptest! {
478 #[test]
479 fn resolved_widths_fit_available_space(
480 num_fixed in 0usize..4,
481 fixed_width in 1usize..20,
482 has_fill in prop::bool::ANY,
483 total_width in 20usize..200,
484 ) {
485 let mut builder = FlatDataSpec::builder();
486
487 for _ in 0..num_fixed {
488 builder = builder.column(Column::new(Width::Fixed(fixed_width)));
489 }
490
491 if has_fill {
492 builder = builder.column(Column::new(Width::Fill));
493 }
494
495 builder = builder.separator(" ");
496 let spec = builder.build();
497
498 if spec.columns.is_empty() {
499 return Ok(());
500 }
501
502 let resolved = spec.resolve_widths(total_width);
503 let overhead = spec.decorations.overhead(spec.num_columns());
504 let available = total_width.saturating_sub(overhead);
505
506 if has_fill {
508 let fixed_total: usize = (0..num_fixed).map(|_| fixed_width).sum();
509 if fixed_total <= available {
510 prop_assert_eq!(
511 resolved.total(),
512 available,
513 "With fill column, total should equal available space"
514 );
515 }
516 }
517 }
518
519 #[test]
520 fn bounded_columns_respect_bounds(
521 min_width in 1usize..10,
522 max_width in 10usize..30,
523 data_width in 0usize..50,
524 has_fill in prop::bool::ANY,
525 ) {
526 let mut builder = FlatDataSpec::builder()
527 .column(Column::new(Width::Bounded {
528 min: Some(min_width),
529 max: Some(max_width),
530 }));
531
532 if has_fill {
533 builder = builder.column(Column::new(Width::Fill));
534 }
535
536 let spec = builder.build();
537
538 let data_str = "x".repeat(data_width);
540 let data = vec![vec![data_str.as_str()]];
541
542 let resolved = spec.resolve_widths_from_data(100, &data);
543 let width = resolved.widths[0];
544
545 prop_assert!(
546 width >= min_width,
547 "Width {} should be >= min {}",
548 width, min_width
549 );
550
551 if has_fill {
555 prop_assert!(
556 width <= max_width,
557 "Width {} should be <= max {} (when fill exists)",
558 width, max_width
559 );
560 }
561 }
562
563 #[test]
564 fn fraction_columns_proportional(
565 fractions in proptest::collection::vec(1usize..5, 1..5),
566 total_width in 50usize..200,
567 ) {
568 let mut builder = FlatDataSpec::builder();
569 for f in &fractions {
570 builder = builder.column(Column::new(Width::Fraction(*f)));
571 }
572 let spec = builder.build();
573
574 let resolved = spec.resolve_widths(total_width);
575
576 prop_assert_eq!(
578 resolved.total(),
579 total_width,
580 "Fraction columns should fill entire width"
581 );
582
583 let total_weight: usize = fractions.iter().sum();
585 for (i, &fraction) in fractions.iter().enumerate() {
586 let expected = (total_width * fraction) / total_weight;
587 let actual = resolved.widths[i];
588 prop_assert!(
590 actual >= expected.saturating_sub(1) && actual <= expected + fractions.len(),
591 "Column {} with weight {} should be ~{}, got {}",
592 i, fraction, expected, actual
593 );
594 }
595 }
596
597 #[test]
598 fn mixed_fraction_and_fill_fills_space(
599 num_fill in 1usize..3,
600 num_fraction in 1usize..3,
601 fraction_weight in 1usize..5,
602 total_width in 50usize..200,
603 ) {
604 let mut builder = FlatDataSpec::builder();
605
606 for _ in 0..num_fill {
607 builder = builder.column(Column::new(Width::Fill));
608 }
609 for _ in 0..num_fraction {
610 builder = builder.column(Column::new(Width::Fraction(fraction_weight)));
611 }
612
613 let spec = builder.build();
614 let resolved = spec.resolve_widths(total_width);
615
616 prop_assert_eq!(
618 resolved.total(),
619 total_width,
620 "Mixed Fill/Fraction should fill entire width"
621 );
622 }
623 }
624}