Skip to main content

standout_render/tabular/
resolve.rs

1//! Width resolution algorithm for table columns.
2//!
3//! This module handles calculating the actual display width for each column
4//! based on the column specifications and available space.
5
6use super::types::{FlatDataSpec, Width};
7use super::util::display_width;
8
9/// Resolved widths for all columns in a table.
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct ResolvedWidths {
12    /// Width for each column in display columns.
13    pub widths: Vec<usize>,
14}
15
16impl ResolvedWidths {
17    /// Get the width of a specific column.
18    pub fn get(&self, index: usize) -> Option<usize> {
19        self.widths.get(index).copied()
20    }
21
22    /// Get the total width of all columns (without decorations).
23    pub fn total(&self) -> usize {
24        self.widths.iter().sum()
25    }
26
27    /// Number of columns.
28    pub fn len(&self) -> usize {
29        self.widths.len()
30    }
31
32    /// Check if there are no columns.
33    pub fn is_empty(&self) -> bool {
34        self.widths.is_empty()
35    }
36}
37
38impl FlatDataSpec {
39    /// Resolve column widths without examining data.
40    ///
41    /// This uses minimum widths for Bounded columns and allocates remaining
42    /// space to Fill columns. Use `resolve_widths_from_data` for data-driven
43    /// width calculation.
44    ///
45    /// # Arguments
46    ///
47    /// * `total_width` - Total available width including decorations
48    pub fn resolve_widths(&self, total_width: usize) -> ResolvedWidths {
49        self.resolve_widths_impl(total_width, None)
50    }
51
52    /// Resolve column widths by examining data to determine optimal widths.
53    ///
54    /// For Bounded columns, scans the data to find the actual maximum width
55    /// needed, then clamps to the specified bounds. Fill columns receive
56    /// remaining space after all other columns are resolved.
57    ///
58    /// # Arguments
59    ///
60    /// * `total_width` - Total available width including decorations
61    /// * `data` - Row data where each row is a slice of cell values
62    ///
63    /// # Example
64    ///
65    /// ```rust
66    /// use standout_render::tabular::{FlatDataSpec, Column, Width};
67    ///
68    /// let spec = FlatDataSpec::builder()
69    ///     .column(Column::new(Width::Bounded { min: Some(5), max: Some(20) }))
70    ///     .column(Column::new(Width::Fill))
71    ///     .separator("  ")
72    ///     .build();
73    ///
74    /// let data: Vec<Vec<&str>> = vec![
75    ///     vec!["short", "description"],
76    ///     vec!["longer value", "another"],
77    /// ];
78    /// let widths = spec.resolve_widths_from_data(80, &data);
79    /// ```
80    pub fn resolve_widths_from_data<S: AsRef<str>>(
81        &self,
82        total_width: usize,
83        data: &[Vec<S>],
84    ) -> ResolvedWidths {
85        // Calculate max width for each column from data
86        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    /// Internal implementation of width resolution.
101    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(); // (index, weight) for Fill/Fraction
115        let mut used_width: usize = 0;
116
117        // First pass: resolve Fixed and Bounded columns, collect flex columns
118        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                    // If we have data widths, use them; otherwise use minimum
129                    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); // Placeholder, will be filled later
137                    flex_indices.push((i, 1)); // Fill has weight 1
138                }
139                Width::Fraction(n) => {
140                    widths.push(0); // Placeholder, will be filled later
141                    flex_indices.push((i, *n)); // Fraction has weight n
142                }
143            }
144        }
145
146        // Second pass: allocate remaining space to Fill/Fraction columns proportionally
147        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                    // Last flex column gets all remaining space to avoid rounding errors
156                    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 no Fill columns, distribute remaining space to the rightmost Bounded column
168            // This ensures the table tries to fill the available width if possible
169            if let Some(idx) = self
170                .columns
171                .iter()
172                .rposition(|c| matches!(c.width, Width::Bounded { .. }))
173            {
174                // We expand the column beyond its current calculated width
175                // Note: We deliberately ignore 'max' here because this is an
176                // explicit layout expansion step, similar to how Fill works.
177                // If the user wanted it strictly bounded, they wouldn't provide
178                // extra space in total_width without a Fill column.
179                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("  ") // 2 chars * 2 separators = 4
219            .build();
220
221        // Total: 80, overhead: 4, available: 76
222        // Fixed: 10 + 10 = 20, remaining: 56
223        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        // Total: 100, no overhead, available: 100
236        // Fixed: 10, remaining: 90, split between 2 fills: 45 each
237        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        // Total: 10, no overhead, split 3 ways: 3, 3, 4 (last gets remainder)
250        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        // Rightmost bounded absorbs all remaining space
265        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            // Add a fixed column at the end to prevent the Bounded one from being rightmost-bounded if we cared about position
277            // But wait, the logic finds *rightmost Bounded*.
278            // Here: [Bounded, Fixed]. Rightmost Bounded is index 0.
279            // So index 0 will expand.
280            .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        // "longer text here" is 16 chars. Fixed is 10. Used: 26. Remaining: 54.
287        // Index 0 is rightmost bounded. It gets +54.
288        // 16 + 54 = 70.
289        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        // To test clamping without expansion, we ensure there is no remaining space
296        // OR we make sure it's not the rightmost bounded column?
297        // Or we add a Fill column to soak up space.
298        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)) // Takes remaining space
304            .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); // Clamped to max, Fill takes the rest
310        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)) // Ensure no expansion occurs
321            .build();
322
323        let data: Vec<Vec<&str>> = vec![vec!["hi"]]; // Only 2 chars
324
325        let resolved = spec.resolve_widths_from_data(80, &data);
326        assert_eq!(resolved.widths[0], 10); // Raised to min
327        assert_eq!(resolved.widths[1], 70);
328    }
329
330    // ... (other tests unchanged) ...
331
332    #[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(" | ") // 3 chars
338            .prefix("│ ") // 2 chars
339            .suffix(" │") // 2 chars
340            .build();
341
342        // Total: 50
343        // Overhead: prefix(2) + suffix(2) + separator(3) = 7
344        // Available: 43
345        // Fixed: 10, remaining for fill: 33
346        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        // Total width less than needed
360        // Overhead: 4, fixed: 20, available: 20-4=16
361        // Fill gets max(0, 16-20) = 0
362        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        // Without data, bounded uses min (5)
377        // Total: 50, available: 50, used: 15
378        // No Fill column, remaining 35 is added to Bounded column (ignoring max)
379        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        // Total: 100, no overhead
408        // Weights: 1 + 2 + 1 = 4
409        // Column 1: 100/4 * 1 = 25
410        // Column 2: 100/4 * 2 = 50
411        // Column 3: 25 (remaining)
412        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        // Total: 10, no overhead
426        // Weights: 1 + 1 + 1 = 3
427        // Column 1: 10/3 = 3
428        // Column 2: 10/3 = 3
429        // Column 3: 4 (remaining)
430        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)) // Weight 1
439            .column(Column::new(Width::Fraction(2))) // Weight 2
440            .column(Column::new(Width::Fill)) // Weight 1
441            .build();
442
443        // Total: 100, no overhead
444        // Weights: 1 + 2 + 1 = 4
445        // Column 1: 100/4 * 1 = 25
446        // Column 2: 100/4 * 2 = 50
447        // Column 3: 25 (remaining)
448        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        // Total: 100, no overhead, fixed: 20, remaining: 80
462        // Weights: 1 + 3 = 4
463        // Fraction 1: 80/4 * 1 = 20
464        // Fraction 3: 60 (remaining)
465        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            // Fill columns should make total equal available (or less if fixed exceeds)
507            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            // Create fake data with specific width
539            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            // It should respect max ONLY if it's not expanding into empty space
552            // It expands into empty space if fill_indices is empty (i.e. !has_fill)
553            // AND it is the rightmost bounded column (which it is, as index 0)
554            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            // Total should equal available width
577            prop_assert_eq!(
578                resolved.total(),
579                total_width,
580                "Fraction columns should fill entire width"
581            );
582
583            // Verify proportions approximately hold
584            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                // Allow +-1 for rounding
589                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            // Should fill entire width
617            prop_assert_eq!(
618                resolved.total(),
619                total_width,
620                "Mixed Fill/Fraction should fill entire width"
621            );
622        }
623    }
624}