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            let mut data_samples = 0u32;
210
211            // Sample visible rows (limit sampling for performance)
212            let sample_size = 100.min(viewport_rows.len());
213            let sample_step = if viewport_rows.len() > sample_size {
214                viewport_rows.len() / sample_size
215            } else {
216                1
217            };
218
219            for (i, row_idx) in viewport_rows.clone().enumerate() {
220                // Sample every nth row for performance
221                if i % sample_step != 0 && i != 0 && i != viewport_rows.len() - 1 {
222                    continue;
223                }
224
225                if let Some(row) = dataview.get_row(row_idx) {
226                    if col_idx < row.values.len() {
227                        let cell_str = row.values[col_idx].to_string();
228                        let cell_width = cell_str.len() as u16;
229                        max_data_width = max_data_width.max(cell_width);
230                        data_samples += 1;
231                    }
232                }
233            }
234
235            max_data_widths.push(max_data_width);
236
237            // Calculate ideal width (full content + padding)
238            let ideal_width = header_width.max(max_data_width) + COLUMN_PADDING;
239            ideal_widths.push(ideal_width);
240        }
241
242        // Now decide on final widths based on packing mode
243        for col_idx in 0..col_count {
244            let header_width = header_widths[col_idx];
245            let max_data_width = max_data_widths[col_idx];
246            let ideal_width = ideal_widths[col_idx];
247
248            // For short columns (like "id" with values "1", "2"), always use ideal width
249            // This prevents unnecessary truncation when we have space
250            let final_width = if ideal_width <= 10 {
251                // Short columns should always show at full width
252                ideal_width
253            } else {
254                // For longer columns, use the mode-based calculation
255                let data_samples = u32::from(max_data_width > 0);
256                let optimal_width = self.calculate_optimal_width_for_mode(
257                    header_width,
258                    max_data_width,
259                    data_samples,
260                );
261
262                // Apply constraints based on mode
263                let (min_width, max_width) = match self.packing_mode {
264                    ColumnPackingMode::DataFocus => (MIN_COL_WIDTH, MAX_COL_WIDTH_DATA_FOCUS),
265                    _ => (MIN_COL_WIDTH, MAX_COL_WIDTH),
266                };
267
268                optimal_width.clamp(min_width, max_width)
269            };
270
271            self.column_widths[col_idx] = final_width;
272
273            // Store debug info
274            let column_name = headers
275                .get(col_idx)
276                .cloned()
277                .unwrap_or_else(|| format!("col_{col_idx}"));
278            self.column_width_debug.push((
279                column_name,
280                header_width,
281                max_data_width,
282                final_width,
283                1, // data_samples simplified
284            ));
285        }
286
287        self.cache_dirty = false;
288    }
289
290    /// Calculate optimal width for a column based on the current packing mode
291    fn calculate_optimal_width_for_mode(
292        &self,
293        header_width: u16,
294        max_data_width: u16,
295        data_samples: u32,
296    ) -> u16 {
297        match self.packing_mode {
298            ColumnPackingMode::DataFocus => {
299                // Aggressively prioritize showing full data values
300                if data_samples > 0 {
301                    // ULTRA AGGRESSIVE for very short data (2-3 chars)
302                    // This handles currency codes (USD), country codes (US), etc.
303                    if max_data_width <= 3 {
304                        // For 2-3 char data, just use data width + padding
305                        // Don't enforce minimum header width - let it truncate heavily
306                        max_data_width + COLUMN_PADDING
307                    } else if max_data_width <= 10 && header_width > max_data_width * 2 {
308                        // Short data (4-10 chars) with long header - still aggressive
309                        // but ensure at least 5 chars for some header visibility
310                        (max_data_width + COLUMN_PADDING).max(MIN_HEADER_WIDTH_DATA_FOCUS)
311                    } else {
312                        // Normal data - use full width but don't exceed limit
313                        let data_width =
314                            (max_data_width + COLUMN_PADDING).min(MAX_COL_WIDTH_DATA_FOCUS);
315
316                        // Ensure at least minimum header visibility
317                        data_width.max(MIN_HEADER_WIDTH_DATA_FOCUS)
318                    }
319                } else {
320                    // No data samples - use header width but constrain it
321                    header_width
322                        .min(DEFAULT_COL_WIDTH)
323                        .max(MIN_HEADER_WIDTH_DATA_FOCUS)
324                }
325            }
326            ColumnPackingMode::HeaderFocus => {
327                // Prioritize showing full headers
328                let header_with_padding = header_width + COLUMN_PADDING;
329
330                if data_samples > 0 {
331                    // Ensure we show the full header, but respect data if it's wider
332                    header_with_padding.max(max_data_width.min(MAX_COL_WIDTH))
333                } else {
334                    header_with_padding
335                }
336            }
337            ColumnPackingMode::Balanced => {
338                // Original balanced approach
339                if data_samples > 0 {
340                    let data_based_width = max_data_width + COLUMN_PADDING;
341
342                    if header_width > max_data_width {
343                        let max_allowed_header =
344                            (f32::from(max_data_width) * MAX_HEADER_TO_DATA_RATIO) as u16;
345                        data_based_width.max(header_width.min(max_allowed_header))
346                    } else {
347                        data_based_width.max(header_width)
348                    }
349                } else {
350                    header_width.max(DEFAULT_COL_WIDTH)
351                }
352            }
353        }
354    }
355}
356
357impl Default for ColumnWidthCalculator {
358    fn default() -> Self {
359        Self::new()
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use crate::data::datatable::{DataColumn, DataRow, DataTable, DataValue};
367    use std::sync::Arc;
368
369    fn create_test_dataview() -> DataView {
370        let mut table = DataTable::new("test");
371        table.add_column(DataColumn::new("short"));
372        table.add_column(DataColumn::new("very_long_header_name"));
373        table.add_column(DataColumn::new("normal"));
374
375        // Add test data
376        for i in 0..5 {
377            let values = vec![
378                DataValue::String("A".to_string()),     // Short data
379                DataValue::String("X".to_string()),     // Short data, long header
380                DataValue::String(format!("Value{i}")), // Normal data
381            ];
382            table.add_row(DataRow::new(values)).unwrap();
383        }
384
385        DataView::new(Arc::new(table))
386    }
387
388    #[test]
389    fn test_column_width_calculator_creation() {
390        let calculator = ColumnWidthCalculator::new();
391        assert_eq!(calculator.get_packing_mode(), ColumnPackingMode::Balanced);
392        assert!(calculator.cache_dirty);
393    }
394
395    #[test]
396    fn test_packing_mode_cycle() {
397        let mut calculator = ColumnWidthCalculator::new();
398
399        assert_eq!(calculator.get_packing_mode(), ColumnPackingMode::Balanced);
400
401        calculator.cycle_packing_mode();
402        assert_eq!(calculator.get_packing_mode(), ColumnPackingMode::DataFocus);
403
404        calculator.cycle_packing_mode();
405        assert_eq!(
406            calculator.get_packing_mode(),
407            ColumnPackingMode::HeaderFocus
408        );
409
410        calculator.cycle_packing_mode();
411        assert_eq!(calculator.get_packing_mode(), ColumnPackingMode::Balanced);
412    }
413
414    #[test]
415    fn test_width_calculation_different_modes() {
416        let dataview = create_test_dataview();
417        let viewport_rows = 0..5;
418        let mut calculator = ColumnWidthCalculator::new();
419
420        // Test balanced mode
421        calculator.set_packing_mode(ColumnPackingMode::Balanced);
422        let balanced_widths = calculator
423            .get_all_column_widths(&dataview, &viewport_rows)
424            .to_vec();
425
426        // Test data focus mode
427        calculator.set_packing_mode(ColumnPackingMode::DataFocus);
428        let data_focus_widths = calculator
429            .get_all_column_widths(&dataview, &viewport_rows)
430            .to_vec();
431
432        // Test header focus mode
433        calculator.set_packing_mode(ColumnPackingMode::HeaderFocus);
434        let header_focus_widths = calculator
435            .get_all_column_widths(&dataview, &viewport_rows)
436            .to_vec();
437
438        // Verify we get different widths for different modes
439        // (exact values depend on the algorithm, but they should differ)
440        assert_eq!(balanced_widths.len(), 3);
441        assert_eq!(data_focus_widths.len(), 3);
442        assert_eq!(header_focus_widths.len(), 3);
443
444        // Column 1 has a very long header but short data
445        // HeaderFocus should be wider than DataFocus for this column
446        assert!(header_focus_widths[1] >= data_focus_widths[1]);
447    }
448}