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