polars_view/
data_container.rs

1use egui::{Id, TextStyle, Ui};
2use egui_extras::{Column, TableBuilder, TableRow};
3use polars::prelude::Column as PColumn;
4use polars::prelude::*;
5use std::sync::Arc;
6
7use crate::polars::transforms::{
8    AddRowIndexTransform, DataFrameTransform, DropColumnsTransform, NormalizeTransform,
9    RemoveNullColumnsTransform, ReplaceNullsTransform, SqlTransform,
10};
11use crate::{
12    DataFilter, DataFormat, FileExtension, HeaderSortState, PolarsViewError, PolarsViewResult,
13    SortBy, SortableHeaderRenderer, get_decimal_and_layout,
14};
15
16/// Internal struct holding calculated configuration for `TableBuilder`.
17/// Generated by `prepare_table_build_config`.
18struct TableBuildConfig {
19    text_height: f32,
20    num_columns: usize,
21    header_height: f32,
22    column_sizing_strategy: Column, // Use 'static as Column doesn't take a lifetime here
23    table_id: Id,
24}
25
26/// Container for the Polars DataFrame and its associated display and filter state.
27///
28/// ## State Management:
29/// - Holds the core data (`df`, `df_original`) and related settings.
30/// - **`df`**: The currently displayed DataFrame, potentially sorted based on `self.sort`.
31/// - **`df_original`**: The DataFrame state immediately after loading/querying, before UI sorts.
32/// - **`filter`**: Configuration used for *loading* the data (path, delimiter, SQL query, etc.). **Does NOT contain sorting information.**
33/// - **`format`**: Configuration for *displaying* the data (alignment, decimals, etc.).
34/// - **`sort`**: `Vec<SortBy>` defining the active sort order applied to `df`. An empty Vec means `df` reflects `df_original`.
35/// - All key components are wrapped in `Arc` for efficient cloning and sharing between UI and async tasks.
36/// - Updates (load, format, sort) typically create *new* `DataContainer` instances via async methods.
37///
38/// ## Interaction with `layout.rs`:
39/// - `PolarsViewApp` holds the current state as `Option<Arc<DataContainer>>`.
40/// - UI actions trigger async methods here (`load_data`, `update_format`, `apply_sort`).
41/// - Async methods return `PolarsViewResult<DataContainer>` via a channel.
42/// - `layout.rs` updates the app state with the received new container.
43#[derive(Debug, Clone)]
44pub struct DataContainer {
45    /// The currently displayed Polars DataFrame. May be sorted according to `self.sort`.
46    pub df: Arc<DataFrame>,
47
48    /// A reference to the DataFrame state *before* any UI-driven sort was applied.
49    /// Allows resetting the view efficiently.
50    pub df_original: Arc<DataFrame>,
51
52    /// Detected file extension of the originally loaded data.
53    pub extension: Arc<FileExtension>,
54
55    /// Filters and loading configurations (path, query, delimiter) that resulted
56    /// in the initial `df_original`. **This does NOT store the current sort state.**
57    pub filter: Arc<DataFilter>,
58
59    /// Applied data formatting settings (decimal places, alignment, column sizing, header style).
60    pub format: Arc<DataFormat>,
61
62    /// **The active sort criteria (column name and direction) applied to `df`.**
63    /// An empty vector signifies that `df` should be the same as `df_original`.
64    /// Order in the vector determines sort precedence.
65    pub sort: Vec<SortBy>,
66}
67
68// Default implementation initializes with an empty sort vector.
69impl Default for DataContainer {
70    /// Creates an empty `DataContainer` with default settings.
71    fn default() -> Self {
72        let default_df = Arc::new(DataFrame::default());
73        DataContainer {
74            df: default_df.clone(),
75            df_original: default_df,
76            extension: Arc::new(FileExtension::Missing),
77            filter: Arc::new(DataFilter::default()), // Filters has no sort field
78            format: Arc::new(DataFormat::default()),
79            sort: Vec::new(), // Initialize sort as empty Vec
80        }
81    }
82}
83
84impl DataContainer {
85    /// Asynchronously prepares the initial DataFrame for processing.
86    /// Reads from file if `filter.read_data_from_file` is true (validating path and updating self.extension, self.df_original),
87    /// or clones data from `self.df_original` if false.
88    ///
89    /// ### Arguments:
90    /// * `filter`: The DataFilter, modified (e.g. `read_data_from_file` reset).
91    ///
92    /// ### Returns:
93    /// * A DataFrame value representing the initial data to begin the transformation pipeline.
94    async fn prepare_initial_dataframe(
95        &mut self,               // Mutate self for initial load (extension, df_original)
96        filter: &mut DataFilter, // Mutate filter (read_data_from_file)
97    ) -> PolarsViewResult<DataFrame> {
98        if filter.read_data_from_file {
99            // --- Path Validation ---
100            if !filter.absolute_path.exists() {
101                tracing::error!("load_data: File not found: {:?}", filter.absolute_path);
102                return Err(PolarsViewError::FileNotFound(filter.absolute_path.clone()));
103            }
104
105            // --- Data Reading ---
106            let (new_df, extension) = filter.get_df_and_extension().await?; // Reads, may update filter.csv_delimiter
107            tracing::debug!(
108                "prepare_initial_dataframe: read data from file. Dims: {}x{}, Ext: {:?}, Delimiter: '{}'",
109                new_df.height(),
110                new_df.width(),
111                extension,
112                filter.csv_delimiter
113            );
114
115            // Update self with the new data's initial state and extension
116            self.extension = Arc::new(extension);
117            self.df_original = Arc::new(new_df.clone()); // Store original DataFrame (deep copy data here)
118
119            filter.read_data_from_file = false; // Reset flag in filter
120
121            Ok(new_df) // Return the DataFrame value from the file
122        } else {
123            tracing::debug!(
124                "prepare_initial_dataframe: starting from df_original (filter change)."
125            );
126            // Clone the DataFrame value from the Arc held by self.df_original
127            // This is a deep copy, but necessary to get a modifiable value for the pipeline.
128            Ok(self.df_original.as_ref().clone())
129        }
130    }
131
132    /// Asynchronously loads data or applies transformations based on `DataFilter`.
133    /// Returns a **new** `DataContainer` state (taken and returned by value).
134    /// This function coordinates the sequence of transformations using the Strategy pattern.
135    ///
136    /// ## Flow:
137    /// 1. Get the initial DataFrame value (either by reading file or cloning `df_original`) via `prepare_initial_dataframe`.
138    ///    This also updates `self.extension`, `self.df_original`, and `filter.schema_without_index` if a file was read.
139    /// 2. Build the pipeline vector `transformations` by pushing concrete strategy instances based on flags in the `filter`.
140    /// 3. Reset `filter.apply_sql` flag if SQL transformation was included in the pipeline.
141    /// 4. Execute the pipeline: Iterate through the `transformations` vector, calling `apply` on each, chaining the output DataFrame.
142    /// 5. Update the final `filter.schema` based on the DataFrame's state after all transformations.
143    /// 6. Update `self.df`, `self.filter`, `self.format`, and reset `self.sort` with the results of this operation.
144    /// 7. Return the modified `self`.
145    pub async fn load_data(
146        mut self,
147        mut filter: DataFilter,
148        format: DataFormat,
149    ) -> PolarsViewResult<Self> {
150        // 1. Get Initial DataFrame value & Update self (df_original, extension)
151        let mut data_frame = self.prepare_initial_dataframe(&mut filter).await?;
152
153        // 2. Build the Transformation Pipeline based on the filter configuration.
154        // Create a vector of trait objects representing the transformations to apply.
155        let mut transformations: Vec<Box<dyn DataFrameTransform + Send + Sync>> = Vec::new();
156
157        // 2a. Drop/Remove Columns by (Regex) if flag is set
158        if filter.drop {
159            transformations.push(Box::new(DropColumnsTransform));
160        }
161
162        // 2b. Normalize String Columns (Regex) if flag is set
163        if filter.normalize {
164            transformations.push(Box::new(NormalizeTransform));
165        }
166
167        // 2c. Replace specific Values with Null
168        transformations.push(Box::new(ReplaceNullsTransform));
169
170        // 2d. SQL Execution if flag is set
171        if filter.apply_sql {
172            transformations.push(Box::new(SqlTransform));
173            filter.apply_sql = false; // Reset flag
174        }
175
176        // 2e. Null Column Removal if flag is set
177        if filter.exclude_null_cols {
178            transformations.push(Box::new(RemoveNullColumnsTransform));
179        }
180
181        // 2f. Add Row Index Column (Conditional) if flag is set
182        // This must run relatively late as its name conflict check uses the *current* schema.
183        if filter.add_row_index {
184            transformations.push(Box::new(AddRowIndexTransform));
185        }
186
187        // 3. Execute the Pipeline: Apply each selected transformation sequentially.
188        for transform in transformations {
189            data_frame = transform.apply(data_frame, &filter)?;
190        }
191
192        // 4. Update filter's `schema` with the final schema after all transformations are applied.
193        filter.schema = data_frame.schema().clone();
194
195        tracing::debug!("Load/transform pipeline successfully applied!");
196        tracing::debug!("Final filter state after load: {:#?}", filter);
197
198        // 5. Update self fields with the final results.
199        self.df = Arc::new(data_frame);
200        self.filter = Arc::new(filter);
201        self.format = Arc::new(format);
202        self.sort = Vec::new();
203
204        // 6. Return the modified container value.
205        Ok(self)
206    }
207
208    /// Asynchronously creates a *new* `DataContainer` with updated format settings.
209    /// Preserves the existing data (`df`, `df_original`) and sort criteria (`sort`).
210    ///
211    /// Triggered by `layout.rs` when format UI elements change. This is a very fast operation.
212    pub async fn update_format(
213        mut self,
214        format: DataFormat, // NEW format settings
215    ) -> PolarsViewResult<Self> {
216        tracing::debug!("update_format: Updating format to {:#?}", format);
217        self.format = Arc::new(format); // update format
218
219        Ok(self)
220    }
221
222    /// Asynchronously creates a *new* `DataContainer` with the `df` sorted according
223    /// to the provided `new_sort_criteria`.
224    ///
225    /// Triggered by `layout.rs` after a user clicks a sortable header, resulting in new criteria.
226    /// Handles multi-column sorting based on the order, direction, and nulls_last settings
227    /// in `new_sort_criteria`.
228    /// If `new_sort_criteria` is empty, it resets the view by setting `df` to `df_original`.
229    ///
230    /// ## Logic & State Update:
231    /// 1. Check if `new_sort_criteria` is empty.
232    /// 2. **Handle Empty (Reset):** If empty, create new container:
233    ///    *   `df`: Cloned `Arc` of the input `df_original`.
234    ///    *   `df_original`: Cloned `Arc` of the input `df_original`.
235    ///    *   `sort`: The empty `new_sort_criteria` vector.
236    ///    *   Other fields cloned from input container.
237    /// 3. **Handle Non-Empty (Apply Sort):** If not empty:
238    ///    a. Extract column names, descending flags, and **nulls_last flags** from `new_sort_criteria`.
239    ///    b. Configure Polars `SortMultipleOptions`.
240    ///    c. Call `data_container.df.sort()` using the *currently displayed* df as input.
241    ///    d. Create new container:
242    ///    *   `df`: *New* `Arc` wrapping the **sorted** DataFrame.
243    ///    *   `df_original`: *Cloned* `Arc` of the **input** `df_original`.
244    ///    *   `sort`: The `new_sort_criteria` that *caused* this sort.
245    ///    *   Other fields cloned from input container.
246    /// 4. Return `Ok(new_container)`.
247    pub async fn apply_sort(
248        mut self,                       // Current container state
249        new_sort_criteria: Vec<SortBy>, // The *desired* new sort state
250    ) -> PolarsViewResult<Self> {
251        if new_sort_criteria.is_empty() {
252            // --- 2. Handle Empty (Reset) ---
253            tracing::debug!(
254                "apply_sort: Sort criteria list is empty. Resetting df to df_original."
255            );
256
257            // Get filter and format
258            let format = self.format.as_ref().clone();
259            let mut filter = self.filter.as_ref().clone();
260            filter.apply_sql = true;
261
262            // Apply transformations
263            // self.sort = Vec::new(); // Store the empty Vec as the current state
264            self = self.load_data(filter, format).await?;
265
266            return Ok(self);
267        }
268
269        // --- 3. Handle Non-Empty (Apply Sort) ---
270        tracing::debug!(
271            "apply_sort: Applying cumulative sort. Criteria: {:#?}",
272            new_sort_criteria
273        );
274
275        // 3a. Extract sort parameters
276        let column_names: Vec<PlSmallStr> = new_sort_criteria
277            .iter()
278            .map(|sort| sort.column_name.clone().into()) // PlSmallStr is efficient here
279            .collect();
280
281        let descending_flags: Vec<bool> = new_sort_criteria
282            .iter()
283            .map(|sort| !sort.ascending)
284            .collect();
285
286        let nulls_last_flags: Vec<bool> = new_sort_criteria
287            .iter()
288            .map(|sort| sort.nulls_last)
289            .collect();
290
291        // 3b. Configure Polars Sort Options
292        // Set descending flags and **nulls_last flags** for multi-column sort.
293        let sort_options = SortMultipleOptions::default()
294            .with_order_descending_multi(descending_flags)
295            .with_nulls_last_multi(nulls_last_flags) // Use the extracted flags
296            .with_maintain_order(true) // Maintain relative order of equal elements
297            .with_multithreaded(true);
298
299        // 3c. Perform Sorting on the *current* df
300        // NOTE: Sorting based on the *new cumulative* criteria.
301        let df_sorted = self.df.sort(column_names, sort_options)?;
302        tracing::debug!("apply_sort: Polars multi-column sort successful.");
303
304        self.df = Arc::new(df_sorted); // Use the newly sorted DataFrame
305        self.sort = new_sort_criteria; // Store the criteria that produced this state
306
307        // 3d. Create New Container with sorted data and new criteria
308        Ok(self)
309    }
310
311    // --- UI Rendering Methods ---
312
313    /// Renders the main data table using `egui_extras::TableBuilder`.
314    /// Handles sort interactions via `render_table_header`.
315    ///
316    /// Returns `Some(new_sort_criteria)` if a header click requires a sort state update.
317    pub fn render_table(&self, ui: &mut Ui) -> Option<Vec<SortBy>> {
318        // Variable to capture the new sort criteria if a header is clicked.
319        let mut updated_sort_criteria: Option<Vec<SortBy>> = None;
320
321        // Closure to render the header row. Captures `self` and the output Option.
322        let analyze_header = |mut table_row: TableRow<'_, '_>| {
323            self.render_table_header(
324                &mut table_row,
325                &mut updated_sort_criteria, // Pass mutable ref to capture signal
326            );
327        };
328
329        // Closure to render data rows.
330        let analyze_rows = |mut table_row: TableRow<'_, '_>| {
331            self.render_table_row(&mut table_row);
332        };
333
334        // Configure and build the table.
335        self.build_configured_table(ui, analyze_header, analyze_rows);
336
337        // Return the signal from header interactions.
338        updated_sort_criteria
339    }
340
341    /// Renders the header row, creating clickable cells for sorting.
342    /// Reads the current sort state (`self.sort`), including nulls_last. On click,
343    /// calculates the *next* sort state (cycling through 4 sorted states + NotSorted),
344    /// modifies a *cloned* sort criteria `Vec`, and signals this *new `Vec`* back
345    /// via the `sort_signal` output parameter.
346    ///
347    /// ### Arguments
348    /// * `table_row`: Egui context for the header row.
349    /// * `sort_signal`: Output parameter (`&mut Option<Vec<SortBy>>`). Set to `Some(new_criteria)`
350    ///   if a click occurred that requires updating the sort state.
351    fn render_table_header(
352        &self,
353        table_row: &mut TableRow<'_, '_>,
354        sort_signal: &mut Option<Vec<SortBy>>,
355    ) {
356        for column_name in self.df.get_column_names() {
357            table_row.col(|ui| {
358                // 1. Determine current interaction state based on `ascending` and `nulls_last`.
359                let (current_interaction_state, sort_index) = self
360                    .sort
361                    .iter()
362                    .position(|criterion| criterion.column_name == *column_name)
363                    .map_or((HeaderSortState::NotSorted, None), |index| {
364                        let criterion = &self.sort[index];
365                        // ** Map to the correct 4-state enum based on both bools **
366                        let state = match (criterion.ascending, criterion.nulls_last) {
367                            (false, false) => HeaderSortState::DescendingNullsFirst,
368                            (true, false) => HeaderSortState::AscendingNullsFirst,
369                            (false, true) => HeaderSortState::DescendingNullsLast,
370                            (true, true) => HeaderSortState::AscendingNullsLast,
371                        };
372                        (state, Some(index))
373                    });
374
375                // 2. Render the sortable header widget (uses the new state and get_icon).
376                let response = ui.render_sortable_header(
377                    column_name,
378                    &current_interaction_state,
379                    sort_index, // Pass index for display (e.g., "1▼")
380                    self.format.use_enhanced_header,
381                );
382
383                // 3. Handle Click Response.
384                if response.clicked() {
385                    tracing::debug!(
386                        "Header clicked: '{}'. Current state: {:?}, Index: {:?}",
387                        column_name,
388                        current_interaction_state,
389                        sort_index
390                    );
391                    // Calculate the next state in the 5-state cycle
392                    let next_interaction_state = current_interaction_state.cycle_next();
393                    tracing::debug!("Next interaction state: {:#?}", next_interaction_state);
394
395                    // 4. Prepare the *new* list of sort criteria based on the click outcome.
396                    let mut new_sort_criteria = self.sort.clone(); // Start with current criteria
397                    let column_name_string = column_name.to_string();
398                    let current_pos = new_sort_criteria
399                        .iter()
400                        .position(|c| c.column_name == *column_name);
401
402                    // 5. Modify the cloned vector based on the next interaction state.
403                    match next_interaction_state {
404                        HeaderSortState::NotSorted => {
405                            // Remove the sort criterion for this column if it exists.
406                            if let Some(pos) = current_pos {
407                                new_sort_criteria.remove(pos);
408                            }
409                        }
410                        // Handle the 4 sorted states: update existing or add new.
411                        _ => {
412                            // ** Determine new ascending and nulls_last from the next state **
413                            let (new_ascending, new_nulls_last) = match next_interaction_state {
414                                HeaderSortState::DescendingNullsFirst => (false, false),
415                                HeaderSortState::AscendingNullsFirst => (true, false),
416                                HeaderSortState::DescendingNullsLast => (false, true),
417                                HeaderSortState::AscendingNullsLast => (true, true),
418                                // NotSorted case is handled above, this is exhaustive for sorted states
419                                HeaderSortState::NotSorted => {
420                                    unreachable!("NotSorted case already handled")
421                                }
422                            };
423
424                            if let Some(pos) = current_pos {
425                                // Update existing criterion in place.
426                                new_sort_criteria[pos].ascending = new_ascending;
427                                new_sort_criteria[pos].nulls_last = new_nulls_last;
428                            } else {
429                                // Add new criterion to the end of the vector.
430                                new_sort_criteria.push(SortBy {
431                                    column_name: column_name_string,
432                                    ascending: new_ascending,
433                                    nulls_last: new_nulls_last,
434                                });
435                            }
436                        }
437                    } // end match next_interaction_state
438
439                    tracing::debug!(
440                        "Signaling new sort criteria for async update: {:#?}",
441                        new_sort_criteria
442                    );
443
444                    // 6. Set the output parameter to signal the required action and the new sort state.
445                    *sort_signal = Some(new_sort_criteria);
446                } // end if response.clicked()
447            }); // End cell definition
448        } // End loop over columns
449    }
450
451    /// Renders a single data row in the table body.
452    ///
453    /// Called by the `analyze_rows` closure (defined in `render_table`) for each row index
454    /// provided by the `egui_extras::TableBuilder`.
455    ///
456    /// For each cell in the row:
457    /// 1. Calls `get_decimal_and_layout` (using `self.format`) to determine the `egui::Layout` (for alignment)
458    ///    and `Option<usize>` (for decimal places, if applicable) based on the column's `DataType`.
459    /// 2. Calls `Self::format_cell_value` to retrieve the `AnyValue` from the DataFrame and format it
460    ///    into a `String`, applying decimal rounding if needed.
461    /// 3. Adds a cell to the `egui` row (`table_row.col`) and renders the formatted string as a `Label`
462    ///    within the determined `Layout`.
463    ///
464    /// ### Arguments
465    /// * `table_row`: The `egui_extras::TableRow` context providing the `row_index` and cell adding methods.
466    fn render_table_row(&self, table_row: &mut TableRow<'_, '_>) {
467        let row_index = table_row.index(); // Get the 0-based data row index.
468
469        // Iterate through each column (Polars Series) in the DataFrame.
470        for column_series in self.df.get_columns() {
471            // Determine alignment and decimal places using the feature-flagged helper.
472            // Passes the Series and the current format settings Arc.
473            let (opt_decimal, layout) = get_decimal_and_layout(column_series, &self.format);
474
475            // Get the raw AnyValue and format it into a display String.
476            let value_str = self.format_cell_value(column_series, row_index, opt_decimal);
477
478            // Add a cell to the egui row.
479            table_row.col(|ui| {
480                // Apply the determined layout (alignment) to the cell content. Prevent wrapping.
481                ui.with_layout(layout.with_main_wrap(false), |ui| {
482                    ui.label(value_str); // Display the formatted value.
483                });
484            });
485        }
486    }
487
488    /// Retrieves and formats a single cell's `AnyValue` into a displayable `String`.
489    /// Called repeatedly by `render_table_row`.
490    ///
491    /// Logic:
492    /// 1. Get `AnyValue` from `column` at `row_index` using `column.get()`.
493    /// 2. Handle `Result`: Return error string on `Err`.
494    /// 3. On `Ok(any_value)`:
495    ///    - Match on `(any_value, opt_decimal)`:
496    ///      - Floats with `Some(decimal)`: Format using `format!("{:.*}", decimal, f)`.
497    ///      - `AnyValue::Null`: Return `""`.
498    ///      - `AnyValue::String(s)`: Return `s.to_string()`.
499    ///      - Other types (Ints, Bool, Date, etc.) or Floats with `None` decimal: Use `any_value.to_string()`.
500    ///
501    /// ### Arguments
502    /// * `column`: Reference to the Polars `Series` (`PColumn`).
503    /// * `row_index`: Row index within the series.
504    /// * `opt_decimal`: `Option<usize>` specifying decimal places for floats (from `get_decimal_and_layout`).
505    ///
506    /// ### Returns
507    /// `String`: The formatted cell value.
508    fn format_cell_value(
509        &self,
510        column: &PColumn,
511        row_index: usize,
512        opt_decimal: Option<usize>, // Info comes from get_decimal_and_layout which uses self.format
513    ) -> String {
514        match column.get(row_index) {
515            Ok(any_value) => {
516                // Format based on the AnyValue variant and decimal setting.
517                match (any_value, opt_decimal) {
518                    // Float with specific decimal request: Apply precision formatting.
519                    (AnyValue::Float32(value), Some(decimal)) => format!("{value:.decimal$}"),
520                    (AnyValue::Float64(value), Some(decimal)) => format!("{value:.decimal$}"),
521
522                    // Null value: Display as empty string.
523                    (AnyValue::Null, _) => String::new(),
524
525                    // String value: Convert inner &str to String.
526                    (AnyValue::String(value), _) => value.to_string(), // Handle StringOwned too if necessary.
527
528                    // Other AnyValue types OR Float without specific decimal: Use default Polars to_string().
529                    (other_anyvalue, _) => other_anyvalue.to_string(),
530                }
531            }
532            Err(e) => {
533                // Handle error retrieving value (e.g., index out of bounds, though unlikely with TableBuilder).
534                tracing::warn!(
535                    "format_cell_value: Failed get value col '{}' row {}: {}",
536                    column.name(),
537                    row_index,
538                    e
539                );
540                "⚠ Err".to_string() // Return placeholder error string for display.
541            }
542        }
543    }
544
545    /// Prepares configuration values needed for `TableBuilder`.
546    /// Encapsulates calculations for sizes, strategies, and IDs based on current format and UI state.
547    ///
548    /// Called by `build_configured_table`.
549    fn prepare_table_build_config(&self, ui: &Ui) -> TableBuildConfig {
550        // --- Calculate Style and Dimensions ---
551        let style = ui.style();
552        let text_height = TextStyle::Body.resolve(style).size; // Standard row height
553        let num_columns = self.df.width().max(1); // Ensure at least 1 column logically
554        let suggested_width = 150.0; // A sensible starting point for auto/initial width
555
556        // --- Calculate Column Widths ---
557        // Base available width excluding spacings and potential scrollbar
558        let available_width = ui.available_width()
559            - ((num_columns + 1) as f32 * style.spacing.item_spacing.x) // Account for inter-column spacing
560            - style.spacing.scroll.bar_width; // Assume scrollbar might be present
561
562        // Initial width used in non-auto mode, ensure it's not too small
563        let initial_col_width = (available_width / num_columns as f32).max(suggested_width);
564
565        // Minimum width any column can be resized to
566        let min_col_width = style.spacing.interact_size.x.max(20.0);
567
568        // --- Calculate Header Height ---
569        // Determine padding based on header style setting
570        let padding = if self.format.use_enhanced_header {
571            self.format.header_padding
572        } else {
573            self.format.get_default_padding()
574        };
575
576        // Calculate height: base interact size + internal spacing + custom padding
577        let header_height = style.spacing.interact_size.y // Base height for clickable elements
578                           + 2.0 * style.spacing.item_spacing.y // Top/bottom internal spacing
579                           + padding; // Add configured extra padding
580
581        // --- Determine Column Sizing Strategy ---
582        let column_sizing_strategy = if self.format.auto_col_width {
583            // Automatic: sizes based on content, potentially slower
584            tracing::trace!(
585                "prepare_table_build_config: Using Column::auto_with_initial_suggestion({})",
586                suggested_width
587            );
588            Column::auto_with_initial_suggestion(suggested_width)
589        } else {
590            // Fixed initial: faster, uses calculated width
591            tracing::trace!(
592                "prepare_table_build_config: Using Column::initial({})",
593                initial_col_width
594            );
595            Column::initial(initial_col_width)
596        }
597        // Common constraints applied to either strategy
598        .at_least(min_col_width) // Min resize width
599        .resizable(true) // Allow user resizing
600        .clip(true); // Clip content within cell bounds
601
602        // --- Generate Table ID ---
603        // **Key**: ID incorporates `auto_col_width`. Changing this flag results in a *different* ID,
604        // forcing egui to discard cached layout state (like manually resized widths)
605        // and recompute the layout using the new column sizing strategy.
606        let table_id = Id::new("data_table_view").with(self.format.auto_col_width);
607        tracing::trace!(
608            "prepare_table_build_config: Using table_id: {:?} based on auto_col_width={}",
609            table_id,
610            self.format.auto_col_width
611        );
612
613        // --- Log Calculated Values ---
614        tracing::trace!(
615            "prepare_table_build_config: text_height={}, num_cols={}, header_height={}, auto_width={}, table_id={:?}",
616            text_height,
617            num_columns,
618            header_height,
619            self.format.auto_col_width,
620            table_id
621        );
622
623        // --- Return the configuration struct ---
624        TableBuildConfig {
625            text_height,
626            num_columns,
627            header_height,
628            column_sizing_strategy,
629            table_id,
630        }
631    }
632
633    /// Configures and builds the `egui_extras::Table` using `TableBuilder` and pre-calculated configuration.
634    ///
635    /// ## Configuration Source
636    /// Relies on `prepare_table_build_config` to provide layout parameters, sizing strategies,
637    /// and the crucial `table_id` for layout persistence control.
638    ///
639    /// ### Arguments
640    /// * `ui`: The `egui::Ui` context for drawing.
641    /// * `analyze_header`: Closure for rendering the header row content.
642    /// * `analyze_rows`: Closure for rendering data row content.
643    fn build_configured_table(
644        &self,
645        ui: &mut Ui,
646        analyze_header: impl FnMut(TableRow<'_, '_>), // Closure to draw the header.
647        analyze_rows: impl FnMut(TableRow<'_, '_>),   // Closure to draw data rows.
648    ) {
649        // 1. Get the calculated configuration values.
650        let config = self.prepare_table_build_config(ui);
651
652        // 2. Configure and Build the Table using values from `config`.
653        TableBuilder::new(ui)
654            // Set the ID controlling layout persistence (crucial for `auto_col_width` toggle).
655            .id_salt(config.table_id)
656            .striped(true) // Alternate row backgrounds.
657            // Define sizing strategy for data columns using config.
658            .columns(config.column_sizing_strategy, config.num_columns)
659            // Add a final 'remainder' column to fill unused space.
660            .column(Column::remainder())
661            .resizable(true) // Allow resizing via separators.
662            .auto_shrink([false, false]) // Don't shrink horizontally or vertically.
663            // Define the header section using calculated height and the provided closure.
664            .header(config.header_height, analyze_header)
665            // Define the body section.
666            .body(|body| {
667                let num_rows = self.df.height(); // Get total rows from the DataFrame.
668                // Use `body.rows` for efficient virtual scrolling.
669                // Provide row height, total rows, and the row drawing closure.
670                body.rows(config.text_height, num_rows, analyze_rows);
671            }); // End table configuration. Egui draws the table.
672    }
673}