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 ¤t_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}