polars_view/
traits.rs

1//! Defines custom traits, trait implementations for `egui` types, and general utility traits.
2//!
3//! This module centralizes extensions to existing types (`egui::Context`, `egui::Ui`, `std::path::Path`, `Vec`)
4//! and defines interfaces (`Notification`, `SortableHeaderRenderer`) for common UI patterns.
5//! It interacts primarily with `layout.rs` (for styling, notifications) and `container.rs` (for header rendering).
6
7use crate::HeaderSortState; // Use the interaction enum for UI state
8
9use egui::{
10    Color32, Context,
11    FontFamily::Proportional,
12    FontId, Frame, Response, RichText, Sense, Spacing, Stroke, Style,
13    TextStyle::{self, Body, Button, Heading, Monospace, Small},
14    Ui, Vec2, Visuals, Window,
15    style::ScrollStyle,
16};
17use polars::prelude::*;
18use std::{collections::HashSet, ffi::OsStr, hash::Hash, path::Path};
19
20/// Defines custom text styles for the egui context.
21/// Overrides default `egui` font sizes for different logical text styles (Heading, Body, etc.).
22/// Used by `MyStyle::set_style_init`.
23pub const CUSTOM_TEXT_STYLE: [(egui::TextStyle, egui::FontId); 5] = [
24    (Heading, FontId::new(18.0, Proportional)),
25    (Body, FontId::new(16.0, Proportional)),
26    (Button, FontId::new(16.0, Proportional)),
27    (Monospace, FontId::new(16.0, Proportional)), // Adjusted size for Proportional font
28    (Small, FontId::new(14.0, Proportional)),
29];
30
31/// A trait for applying custom styling to the `egui` context (`Context`).
32/// Used once at startup by `layout.rs::PolarsViewApp::new`.
33pub trait MyStyle {
34    /// Applies a pre-defined application style to the `egui` context.
35    fn set_style_init(&self, visuals: Visuals);
36}
37
38impl MyStyle for Context {
39    /// Configures the application's look and feel (theme, spacing, text styles) by modifying `egui::Style`.
40    ///
41    /// ### Logic
42    /// 1. Define custom scrollbar settings (`ScrollStyle`).
43    /// 2. Define custom widget spacing (`Spacing`).
44    /// 3. Create a full `Style` struct incorporating `Visuals` (theme), `Spacing`, and `CUSTOM_TEXT_STYLE`.
45    /// 4. Apply the constructed `Style` to the `egui::Context`.
46    fn set_style_init(&self, visuals: Visuals) {
47        // 1. Define ScrollStyle.
48        let scroll = ScrollStyle {
49            handle_min_length: 32.0,
50            ..ScrollStyle::default()
51        };
52
53        // 2. Define Spacing.
54        let spacing = Spacing {
55            scroll,
56            item_spacing: [8.0, 6.0].into(),
57            ..Spacing::default()
58        };
59
60        // 3. Create the main Style struct.
61        let style = Style {
62            visuals,                               // Apply provided theme (Light/Dark).
63            spacing,                               // Apply custom spacing.
64            text_styles: CUSTOM_TEXT_STYLE.into(), // Apply custom text styles.
65            ..Style::default()
66        };
67
68        // 4. Set the style on the egui Context.
69        self.set_style(style);
70    }
71}
72
73/// Trait for modal Notification windows (like errors or settings dialogs).
74/// Allows `layout.rs` to manage different notification types polymorphically via `Box<dyn Notification>`.
75pub trait Notification: Send + Sync + 'static {
76    /// Renders the notification window using `egui::Window`.
77    /// Called repeatedly by `layout.rs::check_notification` while the notification is active.
78    ///
79    /// ### Returns
80    /// `true` if the window should remain open, `false` if closed.
81    fn show(&mut self, ctx: &Context) -> bool;
82}
83
84/// Placeholder Notification struct for future Settings dialog. Implements `Notification`.
85pub struct Settings {}
86
87impl Notification for Settings {
88    /// Renders the placeholder Settings window.
89    ///
90    /// ### Logic
91    /// 1. Define `open` state (initially `true`).
92    /// 2. Create `egui::Window` bound to `open`.
93    /// 3. Configure window (e.g., non-collapsible).
94    /// 4. Define content (currently disabled).
95    /// 5. Return the `open` state (whether the window is still visible).
96    fn show(&mut self, ctx: &Context) -> bool {
97        let mut open = true; // 1. Window starts open.
98
99        // 2. Create window.
100        Window::new("Settings")
101            .collapsible(false) // 3. Configure.
102            .open(&mut open)
103            .show(ctx, |ui| {
104                ctx.style_ui(ui, egui::Theme::Dark);
105                ui.disable(); // 4. Placeholder content.
106            });
107
108        open // 5. Return state.
109    }
110}
111
112/// Notification struct for displaying error messages. Implements `Notification`.
113pub struct Error {
114    /// The error message content. Set by the caller in `layout.rs`.
115    pub message: String,
116}
117
118impl Notification for Error {
119    /// Renders the Error notification window.
120    fn show(&mut self, ctx: &Context) -> bool {
121        let mut open = true; // Window starts open.
122        let width_min = 500.0; // Minimum width for the error window content.
123
124        // Create window.
125        Window::new("Error")
126            .collapsible(false) // Configure
127            .resizable(true) // Allow resizing if needed for long messages
128            .min_width(width_min)
129            .open(&mut open)
130            .show(ctx, |ui| {
131                // Add styled frame.
132                Frame::default()
133                    .fill(Color32::from_rgb(255, 200, 200)) // Light red bg
134                    .stroke(Stroke::new(1.0, Color32::DARK_RED)) // Dark red border
135                    .inner_margin(10.0) // Add padding inside the frame, around the content.
136                    .show(ui, |ui| {
137                        ui.set_max_width(ui.available_width()); // Allow text to wrap within frame
138                        ui.colored_label(Color32::BLACK, &self.message);
139                    });
140            });
141
142        open // Return state.
143    }
144}
145
146/// Trait defining a widget for rendering a sortable table header cell.
147/// Provides a consistent interface for `container.rs::render_table_header`.
148pub trait SortableHeaderRenderer {
149    /// Renders a table header cell with sort indicator (including index if sorted) and name.
150    ///
151    /// ### Arguments
152    /// * `column_name`: The text label for the column.
153    /// * `interaction_state`: The `HeaderSortState` for *this* column (NotSorted, Ascending, Descending).
154    /// * `sort_index`: `Option<usize>` (0-based) indicating sort precedence if this column is currently sorted.
155    /// * `use_enhanced_style`: Controls visual appearance (wrapping, color).
156    ///
157    /// ### Returns
158    /// * `egui::Response`: Interaction response from the clickable sort icon/indicator. The caller handles clicks.
159    fn render_sortable_header(
160        &mut self,
161        column_name: &str,
162        interaction_state: &HeaderSortState, // Input: How this header should look based on clicks
163        sort_index: Option<usize>,           // Input: 1-based index if part of sort criteria
164        use_enhanced_style: bool,
165    ) -> Response;
166}
167
168impl SortableHeaderRenderer for Ui {
169    /// Implements header rendering for `egui::Ui`. Displays icon (with optional index) and text label horizontally.
170    /// Icon/index is drawn centered within a pre-calculated sized container to minimize text shifting.
171    ///
172    /// ### Logic
173    /// 1. Get styling info: text color based on theme, combined icon/index string using `interaction_state.get_icon(sort_index)`. Define base `TextStyle`.
174    /// 2. Calculate size needed for the icon/index container using `calculate_icon_container_size_for_string` with a sample wide string (e.g., "10↕").
175    /// 3. Use `ui.horizontal` for the overall cell layout.
176    /// 4. Add a sized container (`ui.add_sized`) for the icon/index:
177    ///    - Inside the closure, draw a centered, clickable `Label` using the icon/index string from step 1.
178    ///    - Return the `Label`'s `Response` from the closure.
179    /// 5. Add hover text to the `Response` captured from `add_sized`.
180    /// 6. Add the column name `Label` (styling depends on `use_enhanced_style`).
181    /// 7. Return the icon/index label's `Response`.
182    fn render_sortable_header(
183        &mut self,
184        column_name: &str,
185        interaction_state: &HeaderSortState, // Use the interaction enum
186        sort_index: Option<usize>,           // Receive the 0-based index
187        use_enhanced_style: bool,
188    ) -> Response {
189        // 1. Get styling info and icon string.
190        let column_name_color = get_column_header_text_color(self.visuals());
191        // Get icon possibly including index number (e.g., "1▲", "↕"). get_icon handles None index.
192        let icon_string = interaction_state.get_icon(sort_index);
193        let text_style = TextStyle::Button; // Base style for consistency
194
195        // 2. Calculate required container size for the potentially wider icon+index string.
196        let max_potential_icon_str = "99⇧"; // Estimate max width needed (adjust if sort criteria > 99 expected)
197        let icon_container_size =
198            calculate_icon_container_size_for_string(self, &text_style, max_potential_icon_str);
199
200        // 3. Use horizontal layout.
201        let outer_response = self.horizontal_centered(|ui| {
202            ui.style_mut().override_text_style = Some(text_style.clone());
203            let msg1 = format!("Click to sort by: {column_name:#?}");
204            let msg2 = "↕ Not Sorted";
205            let msg3 = "Sort with Nulls First:";
206            let msg4 = "    ⏷ Sort in Descending order";
207            let msg5 = "    ⏶ Sort in Ascending order";
208            let msg6 = "Sort with Nulls Last:";
209            let msg7 = "    ⬇ Sort in Descending order";
210            let msg8 = "    ⬆ Sort in Ascending order";
211            let msg = [&msg1, "", msg2, msg3, msg4, msg5, msg6, msg7, msg8].join("\n");
212
213            // 4. Add sized container and draw the icon/index string inside.
214            let icon_response = ui
215                .add_sized(icon_container_size, |ui: &mut Ui| {
216                    // Draw centered label with combined icon/index, make it clickable.
217                    ui.centered_and_justified(|ui| {
218                        ui.add(egui::Label::new(&icon_string).sense(Sense::click()))
219                    })
220                    .inner // Return the Label's Response
221                })
222                // 5. Add hover text to the response from the sized container (which is the Label's response).
223                .on_hover_text(msg);
224
225            // 6. Add column name label.
226            ui.add(if use_enhanced_style {
227                // Enhanced: Use color and enable text wrapping.
228                egui::Label::new(RichText::new(column_name).color(column_name_color)).wrap()
229            } else {
230                // Simple: Default color, no explicit wrapping (might wrap based on outer container).
231                egui::Label::new(RichText::new(column_name))
232            });
233
234            // Return the captured icon Response from the horizontal closure.
235            icon_response
236        }); // End horizontal layout
237
238        // 7. Extract and return the icon's response from the horizontal layout's inner result.
239        outer_response.inner
240    }
241}
242
243/// Helper: Determines header text color based on theme for contrast.
244/// Called by `render_sortable_header`.
245fn get_column_header_text_color(visuals: &Visuals) -> Color32 {
246    if visuals.dark_mode {
247        Color32::from_rgb(160, 200, 255) // Lighter blue for dark mode
248    } else {
249        Color32::from_rgb(0, 80, 160) // Darker blue for light mode
250    }
251}
252
253/// Helper: Calculates size needed for the icon container, using a sample string for max width.
254/// Ensures enough space for icons potentially combined with sort order index numbers.
255/// Called by `render_sortable_header`.
256///
257/// ### Logic
258/// 1. Get text height from the provided `TextStyle`.
259/// 2. Layout the `sample_str` using the `TextStyle`'s font to get its width.
260/// 3. Add a small horizontal buffer to the calculated width.
261/// 4. Return `Vec2` with buffered width and original height.
262fn calculate_icon_container_size_for_string(
263    ui: &Ui,
264    text_style: &TextStyle,
265    sample_str: &str,
266) -> Vec2 {
267    // 1. Get height.
268    let text_height = ui.text_style_height(text_style);
269
270    // 2. Calculate width based on the sample string.
271    let max_width = {
272        let font_id = text_style.resolve(ui.style());
273        // Layout the sample string to find its rendered width.
274        let galley = ui
275            .fonts_mut(|f| f.layout_no_wrap(sample_str.to_string(), font_id, Color32::PLACEHOLDER));
276        // 3. Add buffer.
277        (galley.size().x + 2.0).ceil() // Use ceiling to ensure enough space.
278    };
279
280    // 4. Return size.
281    Vec2::new(max_width, text_height)
282}
283
284/// Trait to extend `Path` with a convenient method for getting the lowercase file extension.
285/// Used by `extension.rs`, `file_dialog.rs`, `filters.rs`.
286pub trait PathExtension {
287    /// Returns the file extension as a lowercase `String`, or `None`.
288    fn extension_as_lowercase(&self) -> Option<String>;
289}
290
291impl PathExtension for Path {
292    /// Implementation for `Path`. Gets extension, converts to &str (lossy), then lowercases.
293    ///
294    /// ### Logic
295    /// 1. Call `self.extension()` -> `Option<&OsStr>`.
296    /// 2. Convert `OsStr` to `&str` via `to_str` -> `Option<&str>`.
297    /// 3. Map `&str` to lowercase `String` -> `Option<String>`.
298    fn extension_as_lowercase(&self) -> Option<String> {
299        self.extension() // 1. Get OsStr extension.
300            .and_then(OsStr::to_str) // 2. Try converting to &str.
301            .map(str::to_lowercase) // 3. Convert to lowercase String if successful.
302    }
303}
304
305/// A trait for deduplicating vectors while preserving the original order of elements.
306/// Added to `Vec<T>`. Used by `filters.rs` for delimiter guessing.
307pub trait UniqueElements<T> {
308    /// Removes duplicate elements in place, keeping the first occurrence.
309    fn unique(&mut self)
310    where
311        T: Eq + Hash + Clone;
312}
313
314impl<T> UniqueElements<T> for Vec<T> {
315    /// Implementation using `HashSet` for efficiency.
316    ///
317    /// ### Logic
318    /// 1. Create an empty `HashSet` to track seen elements.
319    /// 2. Use `Vec::retain` to iterate and filter the vector in place.
320    /// 3. Inside `retain`, try inserting a clone of the current element into the `seen` set.
321    /// 4. `HashSet::insert` returns `true` if the element was *newly* inserted (i.e., first time seen).
322    /// 5. Keep the element (`retain` closure returns `true`) only if `insert` returned `true`.
323    fn unique(&mut self)
324    where
325        T: Eq + Hash + Clone, // Constraints required for HashSet.
326    {
327        let mut seen = HashSet::new(); // 1. Track seen elements.
328        self.retain(|x| {
329            // 2. Filter in place.
330            seen.insert(x.clone()) // 3, 4, 5: Keep if insert succeeds (element is new).
331        });
332    }
333}
334
335/// Trait extension for `LazyFrame` to provide additional functionalities.
336pub trait LazyFrameExtension {
337    /// Rounds float columns (Float32 and Float64) in a LazyFrame to a specified
338    /// number of decimal places using optimized Polars expressions.
339    ///
340    /// Columns of other data types remain unchanged.
341    fn round_float_columns(self, decimals: u32) -> Self;
342}
343
344impl LazyFrameExtension for LazyFrame {
345    fn round_float_columns(self, decimals: u32) -> Self {
346        // Select columns with Float32 or Float64 data types
347        let float_cols_selector = dtype_cols(&[DataType::Float32, DataType::Float64])
348            .as_selector()
349            .as_expr();
350
351        self.with_columns([
352            // Apply the round expression directly to the selected float columns
353            float_cols_selector
354                .round(decimals, RoundMode::HalfAwayFromZero)
355                .name()
356                .keep(), // Keep original column name
357        ])
358    }
359}
360
361//----------------------------------------------------------------------------//
362//                                   Tests                                    //
363//----------------------------------------------------------------------------//
364
365/// Run tests with:
366/// `cargo test -- --show-output tests_path_extension`
367#[cfg(test)]
368mod tests_path_extension {
369    use super::*;
370    use std::path::PathBuf;
371
372    #[test]
373    fn test_extension_as_lowercase_some() {
374        let path = PathBuf::from("my_file.TXT");
375        assert_eq!(path.extension_as_lowercase(), Some("txt".to_string()));
376    }
377
378    // ... other path extension tests ...
379    #[test]
380    fn test_extension_as_lowercase_none() {
381        let path = PathBuf::from("myfile");
382        assert_eq!(path.extension_as_lowercase(), None);
383    }
384    #[test]
385    fn test_extension_as_lowercase_no_final_part() {
386        let path = PathBuf::from("path/to/directory/."); // Current directory in path.
387        assert_eq!(path.extension_as_lowercase(), None);
388    }
389    #[test]
390    fn test_extension_as_lowercase_multiple_dots() {
391        let path = PathBuf::from("file.name.with.multiple.dots.ext");
392        assert_eq!(path.extension_as_lowercase(), Some("ext".to_string()));
393    }
394}
395
396/// Run tests with:
397/// `cargo test -- --show-output tests_unique`
398#[cfg(test)]
399mod tests_unique {
400    use super::*;
401
402    #[test]
403    fn test_unique() {
404        let mut vec = vec![1, 2, 2, 3, 1, 4, 3, 2, 5];
405        vec.unique();
406        assert_eq!(vec, vec![1, 2, 3, 4, 5]);
407    }
408
409    // ... other unique tests ...
410    #[test]
411    fn test_unique_empty() {
412        let mut vec: Vec<i32> = vec![];
413        vec.unique();
414        assert_eq!(vec, Vec::<i32>::new());
415    }
416    #[test]
417    fn test_unique_all_same() {
418        let mut vec = vec![1, 1, 1, 1, 1];
419        vec.unique();
420        assert_eq!(vec, vec![1]);
421    }
422    #[test]
423    fn test_unique_strings() {
424        let mut vec = vec!["a", "b", "b", "c", "a", "d", "c", "b", "e"];
425        vec.unique();
426        assert_eq!(vec, vec!["a", "b", "c", "d", "e"]);
427    }
428}
429
430/// Run tests with:
431/// `cargo test -- --show-output tests_format_columns`
432#[cfg(test)]
433mod tests_format_columns {
434    use super::*;
435
436    /// `cargo test -- --show-output test_format_col`
437    #[test]
438    fn round_float_columns() -> PolarsResult<()> {
439        let df_input = df!(
440            "int_col" => &[Some(1), Some(2), None],
441            "f32_col" => &[Some(1.2345f32), None, Some(3.9876f32)],
442            "f64_col" => &[None, Some(10.11111), Some(-5.55555)],
443            "str_col" => &[Some("a"), Some("b"), Some("c")],
444            "float_col" => &[1.1234, 2.5650001, 3.965000],
445            "opt_float" => &[Some(1.0), None, Some(3.45677)],
446        )?;
447        let df_expected = df!(
448            "int_col" => &[Some(1), Some(2), None],
449            "f32_col" => &[Some(1.23f32), None, Some(3.99f32)],
450            "f64_col" => &[None, Some(10.11), Some(-5.56)],
451            "str_col" => &[Some("a"), Some("b"), Some("c")],
452            "float_col" => &[1.12, 2.57, 3.97],
453            "opt_float" => &[Some(1.0), None, Some(3.46)],
454        )?;
455        let decimals = 2;
456
457        dbg!(&df_input);
458        dbg!(&decimals);
459        let df_output = df_input.lazy().round_float_columns(decimals).collect()?;
460        dbg!(&df_output);
461
462        assert!(
463            df_output.equals_missing(&df_expected),
464            "Failed round float columns.\nOutput:\n{df_output:?}\nExpected:\n{df_expected:?}"
465        );
466
467        Ok(())
468    }
469
470    #[test]
471    fn round_no_float_columns() -> PolarsResult<()> {
472        let df_input = df!(
473            "int_col" => &[1, 2, 3],
474            "str_col" => &["x", "y", "z"]
475        )?;
476        let df_expected = df_input.clone();
477        let decimals = 2;
478
479        dbg!(&df_input);
480        dbg!(&decimals);
481        let df_output = df_input.lazy().round_float_columns(decimals).collect()?;
482        dbg!(&df_output);
483
484        assert!(df_output.equals(&df_expected)); // equals is fine here as no nulls involved
485        Ok(())
486    }
487
488    #[test]
489    fn round_with_zero_decimals() -> PolarsResult<()> {
490        let df_input = df!(
491            "f64_col" => &[1.2, 1.8, -0.4, -0.9]
492        )?;
493        let df_expected = df!(
494            "f64_col" => &[1.0, 2.0, 0.0, -1.0] // Rounding 0.5 up, -0.5 towards zero (check Polars convention)
495                                                // Note: Standard rounding (>= .5 rounds away from zero) means 1.8 -> 2.0, -0.9 -> -1.0
496                                                // -0.4 -> 0.0. Need to confirm Polars specific behavior if critical.
497                                                // It usually follows standard round half away from zero.
498        )?;
499        let decimals = 0;
500
501        dbg!(&df_input);
502        dbg!(&decimals);
503        let df_output = df_input.lazy().round_float_columns(decimals).collect()?;
504        dbg!(&df_output);
505
506        assert!(df_output.equals_missing(&df_expected));
507        Ok(())
508    }
509}