sql_cli/ui/viewport/
column_width_calculator.rs

1use crate::data::data_view::DataView;
2
3// Constants moved from viewport_manager.rs
4pub const DEFAULT_COL_WIDTH: u16 = 15;
5pub const MIN_COL_WIDTH: u16 = 3;
6pub const MAX_COL_WIDTH: u16 = 50;
7pub const MAX_COL_WIDTH_DATA_FOCUS: u16 = 100;
8pub const COLUMN_PADDING: u16 = 2;
9pub const MIN_HEADER_WIDTH_DATA_FOCUS: u16 = 5;
10pub const MAX_HEADER_TO_DATA_RATIO: f32 = 1.5;
11
12/// Column packing modes for different width calculation strategies
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ColumnPackingMode {
15    /// Focus on showing full data values (up to reasonable limit)
16    /// Headers may be truncated if needed to show more data
17    DataFocus,
18    /// Focus on showing full headers
19    /// Data may be truncated if needed to show complete column names
20    HeaderFocus,
21    /// Balanced approach - compromise between header and data visibility
22    Balanced,
23}
24
25impl ColumnPackingMode {
26    /// Cycle to the next mode
27    #[must_use]
28    pub fn cycle(&self) -> Self {
29        match self {
30            ColumnPackingMode::Balanced => ColumnPackingMode::DataFocus,
31            ColumnPackingMode::DataFocus => ColumnPackingMode::HeaderFocus,
32            ColumnPackingMode::HeaderFocus => ColumnPackingMode::Balanced,
33        }
34    }
35
36    /// Get display name for the mode
37    #[must_use]
38    pub fn display_name(&self) -> &'static str {
39        match self {
40            ColumnPackingMode::Balanced => "Balanced",
41            ColumnPackingMode::DataFocus => "Data Focus",
42            ColumnPackingMode::HeaderFocus => "Header Focus",
43        }
44    }
45}
46
47/// Debug information for column width calculations
48pub type ColumnWidthDebugInfo = (String, u16, u16, u16, u32);
49
50/// Handles all column width calculations for the viewport
51/// Extracted from `ViewportManager` to improve maintainability and testability
52pub struct ColumnWidthCalculator {
53    /// Cached column widths for current viewport
54    column_widths: Vec<u16>,
55    /// Column packing mode for width calculation
56    packing_mode: ColumnPackingMode,
57    /// Debug info for column width calculations
58    /// (`column_name`, `header_width`, `max_data_width_sampled`, `final_width`, `sample_count`)
59    column_width_debug: Vec<ColumnWidthDebugInfo>,
60    /// Whether cache needs recalculation
61    cache_dirty: bool,
62}
63
64impl ColumnWidthCalculator {
65    /// Create a new column width calculator
66    #[must_use]
67    pub fn new() -> Self {
68        Self {
69            column_widths: Vec::new(),
70            packing_mode: ColumnPackingMode::Balanced,
71            column_width_debug: Vec::new(),
72            cache_dirty: true,
73        }
74    }
75
76    /// Get current packing mode
77    #[must_use]
78    pub fn get_packing_mode(&self) -> ColumnPackingMode {
79        self.packing_mode
80    }
81
82    /// Set packing mode and mark cache as dirty
83    pub fn set_packing_mode(&mut self, mode: ColumnPackingMode) {
84        if self.packing_mode != mode {
85            self.packing_mode = mode;
86            self.cache_dirty = true;
87        }
88    }
89
90    /// Cycle to the next packing mode
91    pub fn cycle_packing_mode(&mut self) {
92        self.set_packing_mode(self.packing_mode.cycle());
93    }
94
95    /// Get debug information about column width calculations
96    #[must_use]
97    pub fn get_debug_info(&self) -> &[ColumnWidthDebugInfo] {
98        &self.column_width_debug
99    }
100
101    /// Mark cache as dirty (needs recalculation)
102    pub fn mark_dirty(&mut self) {
103        self.cache_dirty = true;
104    }
105
106    /// Calculate optimal widths with terminal width awareness
107    pub fn calculate_with_terminal_width(
108        &mut self,
109        dataview: &DataView,
110        viewport_rows: &std::ops::Range<usize>,
111        terminal_width: u16,
112    ) {
113        // First calculate normal widths
114        self.recalculate_column_widths(dataview, viewport_rows);
115
116        // Check if we can auto-expand small columns
117        let total_ideal_width: u16 = self.column_widths.iter().sum();
118        let separators_width = (self.column_widths.len() as u16).saturating_sub(1);
119        let borders_width = 4u16; // Left and right borders plus margins
120
121        let total_needed = total_ideal_width + separators_width + borders_width;
122
123        // If everything fits comfortably, we're good
124        // If not, we might want to expand short columns that were truncated
125        if total_needed < terminal_width {
126            // We have extra space - check if any short columns can be expanded
127            let extra_space = terminal_width - total_needed;
128            let num_columns = self.column_widths.len() as u16;
129            let space_per_column = if num_columns > 0 {
130                extra_space / num_columns
131            } else {
132                0
133            };
134
135            // Find columns that might benefit from expansion
136            for (idx, width) in self.column_widths.iter_mut().enumerate() {
137                if *width <= 10 && idx < self.column_width_debug.len() {
138                    let (_, header_w, data_w, _, _) = &self.column_width_debug[idx];
139                    let ideal = (*header_w).max(*data_w) + COLUMN_PADDING;
140
141                    // If this column was truncated, expand it
142                    if ideal > *width && ideal <= 15 {
143                        *width = ideal.min(*width + space_per_column);
144                    }
145                }
146            }
147        }
148    }
149
150    /// Get cached column width for a specific `DataTable` column index
151    pub fn get_column_width(
152        &mut self,
153        dataview: &DataView,
154        viewport_rows: &std::ops::Range<usize>,
155        col_idx: usize,
156    ) -> u16 {
157        if self.cache_dirty {
158            self.recalculate_column_widths(dataview, viewport_rows);
159        }
160
161        self.column_widths
162            .get(col_idx)
163            .copied()
164            .unwrap_or(DEFAULT_COL_WIDTH)
165    }
166
167    /// Get all cached column widths, ensuring they're up to date
168    pub fn get_all_column_widths(
169        &mut self,
170        dataview: &DataView,
171        viewport_rows: &std::ops::Range<usize>,
172    ) -> &[u16] {
173        if self.cache_dirty {
174            self.recalculate_column_widths(dataview, viewport_rows);
175        }
176
177        &self.column_widths
178    }
179
180    /// Calculate optimal column widths for all columns
181    /// This is the core method extracted from `ViewportManager`
182    fn recalculate_column_widths(
183        &mut self,
184        dataview: &DataView,
185        viewport_rows: &std::ops::Range<usize>,
186    ) {
187        let col_count = dataview.column_count();
188        self.column_widths.resize(col_count, DEFAULT_COL_WIDTH);
189
190        // Clear debug info
191        self.column_width_debug.clear();
192
193        // Get column headers for width calculation
194        let headers = dataview.column_names();
195
196        // First pass: calculate ideal widths for all columns
197        let mut ideal_widths = Vec::with_capacity(col_count);
198        let mut header_widths = Vec::with_capacity(col_count);
199        let mut max_data_widths = Vec::with_capacity(col_count);
200
201        // First pass: collect all column metrics
202        for col_idx in 0..col_count {
203            // Track header width
204            let header_width = headers.get(col_idx).map_or(0, |h| h.len() as u16);
205            header_widths.push(header_width);
206
207            // Track actual data width
208            let mut max_data_width = 0u16;
209
210            // Sample visible rows (limit sampling for performance)
211            let sample_size = 100.min(viewport_rows.len());
212            let sample_step = if viewport_rows.len() > sample_size {
213                viewport_rows.len() / sample_size
214            } else {
215                1
216            };
217
218            for (i, row_idx) in viewport_rows.clone().enumerate() {
219                // Sample every nth row for performance
220                if i % sample_step != 0 && i != 0 && i != viewport_rows.len() - 1 {
221                    continue;
222                }
223
224                if let Some(row) = dataview.get_row(row_idx) {
225                    if col_idx < row.values.len() {
226                        let cell_str = row.values[col_idx].to_string();
227                        let cell_width = cell_str.len() as u16;
228                        max_data_width = max_data_width.max(cell_width);
229                    }
230                }
231            }
232
233            max_data_widths.push(max_data_width);
234
235            // Calculate ideal width (full content + padding)
236            let ideal_width = header_width.max(max_data_width) + COLUMN_PADDING;
237            ideal_widths.push(ideal_width);
238        }
239
240        // Now decide on final widths based on packing mode
241        for col_idx in 0..col_count {
242            let header_width = header_widths[col_idx];
243            let max_data_width = max_data_widths[col_idx];
244            let ideal_width = ideal_widths[col_idx];
245
246            // For short columns (like "id" with values "1", "2"), always use ideal width
247            // This prevents unnecessary truncation when we have space
248            let final_width = if ideal_width <= 10 {
249                // Short columns should always show at full width
250                ideal_width
251            } else {
252                // For longer columns, use the mode-based calculation
253                let data_samples = u32::from(max_data_width > 0);
254                let optimal_width = self.calculate_optimal_width_for_mode(
255                    header_width,
256                    max_data_width,
257                    data_samples,
258                );
259
260                // Apply constraints based on mode
261                let (min_width, max_width) = match self.packing_mode {
262                    ColumnPackingMode::DataFocus => (MIN_COL_WIDTH, MAX_COL_WIDTH_DATA_FOCUS),
263                    _ => (MIN_COL_WIDTH, MAX_COL_WIDTH),
264                };
265
266                optimal_width.clamp(min_width, max_width)
267            };
268
269            self.column_widths[col_idx] = final_width;
270
271            // Store debug info
272            let column_name = headers
273                .get(col_idx)
274                .cloned()
275                .unwrap_or_else(|| format!("col_{col_idx}"));
276            self.column_width_debug.push((
277                column_name,
278                header_width,
279                max_data_width,
280                final_width,
281                1, // data_samples simplified
282            ));
283        }
284
285        self.cache_dirty = false;
286    }
287
288    /// Calculate optimal width for a column based on the current packing mode
289    fn calculate_optimal_width_for_mode(
290        &self,
291        header_width: u16,
292        max_data_width: u16,
293        data_samples: u32,
294    ) -> u16 {
295        match self.packing_mode {
296            ColumnPackingMode::DataFocus => {
297                // Aggressively prioritize showing full data values
298                if data_samples > 0 {
299                    // ULTRA AGGRESSIVE for very short data (2-3 chars)
300                    // This handles currency codes (USD), country codes (US), etc.
301                    if max_data_width <= 3 {
302                        // For 2-3 char data, just use data width + padding
303                        // Don't enforce minimum header width - let it truncate heavily
304                        max_data_width + COLUMN_PADDING
305                    } else if max_data_width <= 10 && header_width > max_data_width * 2 {
306                        // Short data (4-10 chars) with long header - still aggressive
307                        // but ensure at least 5 chars for some header visibility
308                        (max_data_width + COLUMN_PADDING).max(MIN_HEADER_WIDTH_DATA_FOCUS)
309                    } else {
310                        // Normal data - use full width but don't exceed limit
311                        let data_width =
312                            (max_data_width + COLUMN_PADDING).min(MAX_COL_WIDTH_DATA_FOCUS);
313
314                        // Ensure at least minimum header visibility
315                        data_width.max(MIN_HEADER_WIDTH_DATA_FOCUS)
316                    }
317                } else {
318                    // No data samples - use header width but constrain it
319                    header_width
320                        .min(DEFAULT_COL_WIDTH)
321                        .max(MIN_HEADER_WIDTH_DATA_FOCUS)
322                }
323            }
324            ColumnPackingMode::HeaderFocus => {
325                // Prioritize showing full headers
326                let header_with_padding = header_width + COLUMN_PADDING;
327
328                if data_samples > 0 {
329                    // Ensure we show the full header, but respect data if it's wider
330                    header_with_padding.max(max_data_width.min(MAX_COL_WIDTH))
331                } else {
332                    header_with_padding
333                }
334            }
335            ColumnPackingMode::Balanced => {
336                // Original balanced approach
337                if data_samples > 0 {
338                    let data_based_width = max_data_width + COLUMN_PADDING;
339
340                    if header_width > max_data_width {
341                        let max_allowed_header =
342                            (f32::from(max_data_width) * MAX_HEADER_TO_DATA_RATIO) as u16;
343                        data_based_width.max(header_width.min(max_allowed_header))
344                    } else {
345                        data_based_width.max(header_width)
346                    }
347                } else {
348                    header_width.max(DEFAULT_COL_WIDTH)
349                }
350            }
351        }
352    }
353}
354
355impl Default for ColumnWidthCalculator {
356    fn default() -> Self {
357        Self::new()
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::data::datatable::{DataColumn, DataRow, DataTable, DataValue};
365    use std::sync::Arc;
366
367    fn create_test_dataview() -> DataView {
368        let mut table = DataTable::new("test");
369        table.add_column(DataColumn::new("short"));
370        table.add_column(DataColumn::new("very_long_header_name"));
371        table.add_column(DataColumn::new("normal"));
372
373        // Add test data
374        for i in 0..5 {
375            let values = vec![
376                DataValue::String("A".to_string()),     // Short data
377                DataValue::String("X".to_string()),     // Short data, long header
378                DataValue::String(format!("Value{i}")), // Normal data
379            ];
380            table.add_row(DataRow::new(values)).unwrap();
381        }
382
383        DataView::new(Arc::new(table))
384    }
385
386    #[test]
387    fn test_column_width_calculator_creation() {
388        let calculator = ColumnWidthCalculator::new();
389        assert_eq!(calculator.get_packing_mode(), ColumnPackingMode::Balanced);
390        assert!(calculator.cache_dirty);
391    }
392
393    #[test]
394    fn test_packing_mode_cycle() {
395        let mut calculator = ColumnWidthCalculator::new();
396
397        assert_eq!(calculator.get_packing_mode(), ColumnPackingMode::Balanced);
398
399        calculator.cycle_packing_mode();
400        assert_eq!(calculator.get_packing_mode(), ColumnPackingMode::DataFocus);
401
402        calculator.cycle_packing_mode();
403        assert_eq!(
404            calculator.get_packing_mode(),
405            ColumnPackingMode::HeaderFocus
406        );
407
408        calculator.cycle_packing_mode();
409        assert_eq!(calculator.get_packing_mode(), ColumnPackingMode::Balanced);
410    }
411
412    #[test]
413    fn test_width_calculation_different_modes() {
414        let dataview = create_test_dataview();
415        let viewport_rows = 0..5;
416        let mut calculator = ColumnWidthCalculator::new();
417
418        // Test balanced mode
419        calculator.set_packing_mode(ColumnPackingMode::Balanced);
420        let balanced_widths = calculator
421            .get_all_column_widths(&dataview, &viewport_rows)
422            .to_vec();
423
424        // Test data focus mode
425        calculator.set_packing_mode(ColumnPackingMode::DataFocus);
426        let data_focus_widths = calculator
427            .get_all_column_widths(&dataview, &viewport_rows)
428            .to_vec();
429
430        // Test header focus mode
431        calculator.set_packing_mode(ColumnPackingMode::HeaderFocus);
432        let header_focus_widths = calculator
433            .get_all_column_widths(&dataview, &viewport_rows)
434            .to_vec();
435
436        // Verify we get different widths for different modes
437        // (exact values depend on the algorithm, but they should differ)
438        assert_eq!(balanced_widths.len(), 3);
439        assert_eq!(data_focus_widths.len(), 3);
440        assert_eq!(header_focus_widths.len(), 3);
441
442        // Column 1 has a very long header but short data
443        // HeaderFocus should be wider than DataFocus for this column
444        assert!(header_focus_widths[1] >= data_focus_widths[1]);
445    }
446}