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}