From 8323fdd73272a2882781aba3c499ba0be3dff2a6 Mon Sep 17 00:00:00 2001 From: UMTS at Teleco Date: Sat, 13 Dec 2025 02:51:15 +0100 Subject: committing to insanity --- src/core/table_renderer.rs | 739 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 739 insertions(+) create mode 100644 src/core/table_renderer.rs (limited to 'src/core/table_renderer.rs') diff --git a/src/core/table_renderer.rs b/src/core/table_renderer.rs new file mode 100644 index 0000000..ca16fd4 --- /dev/null +++ b/src/core/table_renderer.rs @@ -0,0 +1,739 @@ +use eframe::egui; +use egui_phosphor::variants::regular as icons; +use serde_json::Value; +use std::collections::HashSet; + +/// Column configuration for table rendering +#[derive(Clone)] +pub struct ColumnConfig { + pub name: String, + pub field: String, + pub visible: bool, + pub width: f32, + #[allow(dead_code)] + pub min_width: f32, +} + +impl ColumnConfig { + pub fn new(name: impl Into, field: impl Into) -> Self { + Self { + name: name.into(), + field: field.into(), + visible: true, + width: 100.0, + min_width: 50.0, + } + } + + pub fn with_width(mut self, width: f32) -> Self { + self.width = width; + self + } + + #[allow(dead_code)] + pub fn with_min_width(mut self, min_width: f32) -> Self { + self.min_width = min_width; + self + } + + pub fn hidden(mut self) -> Self { + self.visible = false; + self + } +} + +/// Sorting configuration +#[derive(Clone)] +pub struct SortConfig { + pub field: Option, + pub ascending: bool, +} + +impl Default for SortConfig { + fn default() -> Self { + Self { + field: None, + ascending: true, + } + } +} + +/// Multi-selection state management +pub struct SelectionManager { + pub selected_rows: HashSet, + pub selection_anchor: Option, + pub last_click_time: Option, + pub last_click_row: Option, +} + +impl Default for SelectionManager { + fn default() -> Self { + Self { + selected_rows: HashSet::new(), + selection_anchor: None, + last_click_time: None, + last_click_row: None, + } + } +} + +impl SelectionManager { + pub fn new() -> Self { + Self::default() + } + + pub fn is_selected(&self, row: usize) -> bool { + self.selected_rows.contains(&row) + } + + pub fn select_all(&mut self, row_count: usize) { + self.selected_rows = (0..row_count).collect(); + } + + pub fn clear_selection(&mut self) { + self.selected_rows.clear(); + self.selection_anchor = None; + } + + pub fn toggle_row(&mut self, row: usize, modifier: SelectionModifier) { + match modifier { + SelectionModifier::None => { + self.selected_rows.clear(); + self.selected_rows.insert(row); + self.selection_anchor = Some(row); + } + SelectionModifier::Ctrl => { + if self.selected_rows.contains(&row) { + self.selected_rows.remove(&row); + } else { + self.selected_rows.insert(row); + } + self.selection_anchor = Some(row); + } + SelectionModifier::Shift => { + let anchor = self.selection_anchor.unwrap_or(row); + let (start, end) = if anchor <= row { + (anchor, row) + } else { + (row, anchor) + }; + for i in start..=end { + self.selected_rows.insert(i); + } + } + } + } + + pub fn get_selected_count(&self) -> usize { + self.selected_rows.len() + } + + pub fn get_selected_indices(&self) -> Vec { + let mut indices: Vec<_> = self.selected_rows.iter().cloned().collect(); + indices.sort(); + indices + } +} + +pub enum SelectionModifier { + None, + Ctrl, + Shift, +} + +/// Callbacks for table events +pub trait TableEventHandler { + fn on_double_click(&mut self, item: &T, row_index: usize); + fn on_context_menu(&mut self, ui: &mut egui::Ui, item: &T, row_index: usize); + fn on_selection_changed(&mut self, selected_indices: &[usize]); +} + +/// Generic table renderer that can display any data with configurable columns +pub struct TableRenderer { + pub columns: Vec, + pub sort_config: SortConfig, + pub selection: SelectionManager, + pub search_query: String, + pub search_fields: Vec, +} + +impl Default for TableRenderer { + fn default() -> Self { + Self::new() + } +} + +impl TableRenderer { + pub fn new() -> Self { + Self { + columns: Vec::new(), + sort_config: SortConfig::default(), + selection: SelectionManager::new(), + search_query: String::new(), + search_fields: vec![ + // Default search fields for assets/inventory + "name".to_string(), + "asset_tag".to_string(), + "manufacturer".to_string(), + "model".to_string(), + "serial_number".to_string(), + "first_name".to_string(), + "last_name".to_string(), + "email".to_string(), + ], + } + } + + pub fn with_columns(mut self, columns: Vec) -> Self { + self.columns = columns; + self + } + + pub fn with_default_sort(mut self, field: &str, ascending: bool) -> Self { + self.sort_config = SortConfig { + field: Some(field.to_string()), + ascending, + }; + self + } + + #[allow(dead_code)] + pub fn add_column(mut self, column: ColumnConfig) -> Self { + self.columns.push(column); + self + } + + #[allow(dead_code)] + pub fn set_search_query(&mut self, query: String) { + self.search_query = query; + } + + pub fn with_search_fields(mut self, fields: Vec) -> Self { + self.search_fields = fields; + self + } + + /// Filter and sort JSON values based on current configuration + pub fn prepare_json_data<'a>(&self, data: &'a [Value]) -> Vec<(usize, &'a Value)> { + let mut filtered: Vec<(usize, &Value)> = data + .iter() + .enumerate() + .filter(|(_, item)| { + if self.search_query.is_empty() { + true + } else { + // Simple search across configured fields + let search_lower = self.search_query.to_lowercase(); + self.search_fields.iter().any(|field| { + item.get(field) + .and_then(|v| v.as_str()) + .map(|s| s.to_lowercase().contains(&search_lower)) + .unwrap_or(false) + }) + } + }) + .collect(); + + // Sort if configured + if let Some(ref field) = self.sort_config.field { + let field = field.clone(); + let ascending = self.sort_config.ascending; + filtered.sort_by(|a, b| { + let val_a = a.1.get(&field); + let val_b = b.1.get(&field); + + let cmp = match (val_a, val_b) { + (Some(a), Some(b)) => { + // Try to compare as strings first + match (a.as_str(), b.as_str()) { + (Some(s_a), Some(s_b)) => s_a.cmp(s_b), + _ => { + // Try to compare as numbers + match (a.as_i64(), b.as_i64()) { + (Some(n_a), Some(n_b)) => n_a.cmp(&n_b), + _ => std::cmp::Ordering::Equal, + } + } + } + } + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + }; + + if ascending { + cmp + } else { + cmp.reverse() + } + }); + } + + filtered + } + + /// Render the table with JSON data + pub fn render_json_table<'a>( + &mut self, + ui: &mut egui::Ui, + data: &'a [(usize, &'a Value)], + mut event_handler: Option<&mut dyn TableEventHandler>, + ) -> egui::Vec2 { + use egui_extras::{Column, TableBuilder}; + + let visible_columns: Vec<_> = self.columns.iter().filter(|c| c.visible).collect(); + + let mut table = TableBuilder::new(ui) + .striped(true) + .resizable(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .max_scroll_height(f32::MAX); + + // Add selection checkbox column first, then remainder columns + table = table.column(Column::initial(28.0)); + for _column in &visible_columns { + table = table.column(Column::remainder().resizable(true).clip(true)); + } + + table + .header(24.0, |mut header| { + // Select-all checkbox header + header.col(|ui| { + let all_selected = data.len() > 0 + && (0..data.len()).all(|i| self.selection.selected_rows.contains(&i)); + let mut chk = all_selected; + if ui + .checkbox(&mut chk, "") + .on_hover_text("Select All") + .clicked() + { + if chk { + self.selection.select_all(data.len()); + } else { + self.selection.clear_selection(); + } + if let Some(ref mut handler) = event_handler { + handler.on_selection_changed(&self.selection.get_selected_indices()); + } + } + }); + + // Column headers with sorting + for column in &visible_columns { + header.col(|ui| { + let is_sorted = self.sort_config.field.as_ref() == Some(&column.field); + let label = if is_sorted { + if self.sort_config.ascending { + format!("{} {}", column.name, icons::ARROW_UP) + } else { + format!("{} {}", column.name, icons::ARROW_DOWN) + } + } else { + column.name.clone() + }; + let button = egui::Button::new(label).frame(false); + if ui.add(button).clicked() { + if is_sorted { + self.sort_config.ascending = !self.sort_config.ascending; + } else { + self.sort_config.field = Some(column.field.clone()); + self.sort_config.ascending = true; + } + } + }); + } + }) + .body(|mut body| { + for (idx, (_orig_idx, item)) in data.iter().enumerate() { + let _item_clone = (*item).clone(); + let is_selected = self.selection.is_selected(idx); + + body.row(20.0, |mut row| { + // Apply selection highlight + if is_selected { + row.set_selected(true); + } + + // Checkbox column + row.col(|ui| { + let mut checked = self.selection.is_selected(idx); + let resp = ui.checkbox(&mut checked, ""); + if resp.changed() { + let mods = ui.input(|i| i.modifiers); + let modifier = if mods.shift { + SelectionModifier::Shift + } else if mods.command || mods.ctrl { + SelectionModifier::Ctrl + } else { + SelectionModifier::None + }; + + if checked { + self.selection.toggle_row(idx, modifier); + } else { + self.selection.selected_rows.remove(&idx); + } + + if let Some(ref mut handler) = event_handler { + handler.on_selection_changed( + &self.selection.get_selected_indices(), + ); + } + } + }); + + // Render data cells and collect their responses + let mut combined_cell_response: Option = None; + for column in &visible_columns { + row.col(|ui| { + let resp = JsonCellRenderer::render_cell(ui, item, &column.field); + combined_cell_response = + Some(match combined_cell_response.take() { + Some(prev) => prev.union(resp), + None => resp, + }); + }); + } + + // Handle row interactions + let mut row_response = row.response(); + if let Some(cell_resp) = combined_cell_response { + row_response = row_response.union(cell_resp); + } + + // Handle clicks + if row_response.clicked() { + // Double-click detection + let now = std::time::Instant::now(); + let is_double_click = if let (Some(last_time), Some(last_row)) = ( + self.selection.last_click_time, + self.selection.last_click_row, + ) { + last_row == idx && now.duration_since(last_time).as_millis() < 500 + } else { + false + }; + + if is_double_click { + if let Some(ref mut handler) = event_handler { + handler.on_double_click(item, idx); + } + self.selection.last_click_row = None; + self.selection.last_click_time = None; + } else { + // Single click selection + let mods = row_response.ctx.input(|i| i.modifiers); + let modifier = if mods.shift { + SelectionModifier::Shift + } else if mods.command || mods.ctrl { + SelectionModifier::Ctrl + } else { + SelectionModifier::None + }; + + self.selection.toggle_row(idx, modifier); + self.selection.last_click_row = Some(idx); + self.selection.last_click_time = Some(now); + + if let Some(ref mut handler) = event_handler { + handler.on_selection_changed( + &self.selection.get_selected_indices(), + ); + } + row_response.ctx.request_repaint(); + } + } + + // Handle right-click context menu + if let Some(ref mut handler) = event_handler { + row_response.context_menu(|ui| { + handler.on_context_menu(ui, item, idx); + }); + } + }); + } + }); + + ui.available_size() + } + + /// Show column selector panel + pub fn show_column_selector(&mut self, ui: &mut egui::Ui, _id_suffix: &str) { + ui.heading("Column Visibility"); + ui.separator(); + + egui::ScrollArea::vertical() + .max_height(400.0) + .show(ui, |ui| { + for col in &mut self.columns { + ui.checkbox(&mut col.visible, &col.name); + } + }); + } +} + +/// JSON-specific cell renderer for asset-like data +pub struct JsonCellRenderer; + +impl JsonCellRenderer { + pub fn render_cell(ui: &mut egui::Ui, data: &Value, field: &str) -> egui::Response { + let json_value = data.get(field); + + // Handle null values + if json_value.is_none() || json_value.unwrap().is_null() { + return ui.add(egui::Label::new("-").sense(egui::Sense::click())); + } + + let json_value = json_value.unwrap(); + + match field { + // Integer fields + "id" + | "asset_numeric_id" + | "category_id" + | "zone_id" + | "supplier_id" + | "current_borrower_id" + | "previous_borrower_id" + | "created_by" + | "last_modified_by" => { + let text = json_value + .as_i64() + .map(|n| n.to_string()) + .unwrap_or_else(|| "-".to_string()); + ui.add(egui::Label::new(text).sense(egui::Sense::click())) + } + + // Quantity fields + "quantity_available" | "quantity_total" | "quantity_used" => { + let text = json_value + .as_i64() + .map(|n| n.to_string()) + .unwrap_or_else(|| "0".to_string()); + ui.add(egui::Label::new(text).sense(egui::Sense::click())) + } + + // Price field + "price" => { + let text = if let Some(num) = json_value.as_f64() { + format!("${:.2}", num) + } else if let Some(num) = json_value.as_i64() { + format!("${:.2}", num as f64) + } else { + "-".to_string() + }; + ui.add(egui::Label::new(text).sense(egui::Sense::click())) + } + + // Boolean lendable field (normalize bool/number/string) + "lendable" => { + let is_lendable = match json_value { + serde_json::Value::Bool(b) => *b, + serde_json::Value::Number(n) => n.as_i64() == Some(1) || n.as_u64() == Some(1), + serde_json::Value::String(s) => { + let s = s.to_lowercase(); + s == "true" || s == "1" || s == "yes" || s == "y" + } + _ => false, + }; + let (text, color) = if is_lendable { + ("Yes", egui::Color32::from_rgb(76, 175, 80)) + } else { + ("No", egui::Color32::GRAY) + }; + ui.add( + egui::Label::new(egui::RichText::new(text).color(color)) + .sense(egui::Sense::click()), + ) + } + + // Boolean banned field + "banned" => { + let is_banned = json_value.as_bool().unwrap_or(false); + let (text, color) = if is_banned { + ("YES!", egui::Color32::from_rgb(244, 67, 54)) + } else { + ("No", egui::Color32::from_rgb(76, 175, 80)) + }; + ui.add( + egui::Label::new(egui::RichText::new(text).color(color)) + .sense(egui::Sense::click()), + ) + } + + // Asset type enum + "asset_type" => { + let value = json_value.as_str().unwrap_or(""); + let (display, color) = match value { + "N" => ("Normal", egui::Color32::from_rgb(33, 150, 243)), + "B" => ("Basic", egui::Color32::from_rgb(76, 175, 80)), + "L" => ("License", egui::Color32::from_rgb(156, 39, 176)), + "C" => ("Consumable", egui::Color32::from_rgb(255, 152, 0)), + _ => ("Unknown", ui.visuals().text_color()), + }; + ui.add( + egui::Label::new(egui::RichText::new(display).color(color)) + .sense(egui::Sense::click()), + ) + } + + // Status enum (supports both asset and audit statuses) + "status" => { + let value = json_value.as_str().unwrap_or(""); + let (display, color) = match value { + // Audit status values + "in-progress" => ("in-progress", egui::Color32::from_rgb(66, 133, 244)), + "attention" => ("attention", egui::Color32::from_rgb(255, 152, 0)), + "timeout" => ("timeout", egui::Color32::from_rgb(244, 67, 54)), + "cancelled" => ("cancelled", egui::Color32::from_rgb(158, 158, 158)), + "all-good" => ("all-good", egui::Color32::from_rgb(76, 175, 80)), + + // Asset status values + "Good" => (value, egui::Color32::from_rgb(76, 175, 80)), + "Attention" => (value, egui::Color32::from_rgb(255, 193, 7)), + // Faulty should be strong red to indicate severe issues + "Faulty" => (value, egui::Color32::from_rgb(244, 67, 54)), + "Missing" => (value, egui::Color32::from_rgb(244, 67, 54)), + "Retired" => (value, egui::Color32::GRAY), + "In Repair" => (value, egui::Color32::from_rgb(156, 39, 176)), + "In Transit" => (value, egui::Color32::from_rgb(33, 150, 243)), + "Expired" => (value, egui::Color32::from_rgb(183, 28, 28)), + "Unmanaged" => (value, egui::Color32::DARK_GRAY), + _ => (value, ui.visuals().text_color()), + }; + ui.add( + egui::Label::new(egui::RichText::new(display).color(color)) + .sense(egui::Sense::click()), + ) + } + + // Audit log specific status field + "status_found" => { + let value = json_value.as_str().unwrap_or(""); + let color = match value { + "Good" => egui::Color32::from_rgb(76, 175, 80), + "Attention" => egui::Color32::from_rgb(255, 152, 0), + "Faulty" | "Missing" => egui::Color32::from_rgb(244, 67, 54), + "In Repair" | "In Transit" => egui::Color32::from_rgb(66, 133, 244), + _ => ui.visuals().text_color(), + }; + ui.add( + egui::Label::new(egui::RichText::new(value).color(color)) + .sense(egui::Sense::click()), + ) + } + + // Lending status enum + "lending_status" => { + let value = json_value.as_str().unwrap_or(""); + let color = match value { + "Available" => egui::Color32::from_rgb(76, 175, 80), + "Borrowed" => egui::Color32::from_rgb(255, 152, 0), + "Overdue" => egui::Color32::from_rgb(244, 67, 54), + "Deployed" => egui::Color32::from_rgb(33, 150, 243), + "Illegally Handed Out" => egui::Color32::from_rgb(183, 28, 28), + "Stolen" => egui::Color32::from_rgb(136, 14, 79), + _ => egui::Color32::GRAY, + }; + if !value.is_empty() { + ui.add( + egui::Label::new(egui::RichText::new(value).color(color)) + .sense(egui::Sense::click()), + ) + } else { + ui.add(egui::Label::new("-").sense(egui::Sense::click())) + } + } + + // Zone plus enum + "zone_plus" => { + let value = json_value.as_str().unwrap_or("-"); + let color = match value { + "Floating Local" => egui::Color32::from_rgb(33, 150, 243), + "Floating Global" => egui::Color32::from_rgb(156, 39, 176), + "Clarify" => egui::Color32::from_rgb(255, 152, 0), + _ => ui.visuals().text_color(), + }; + ui.add( + egui::Label::new(egui::RichText::new(value).color(color)) + .sense(egui::Sense::click()), + ) + } + + // No scan enum + "no_scan" => { + let value = json_value.as_str().unwrap_or("No"); + let color = match value { + "Yes" => egui::Color32::from_rgb(244, 67, 54), + "Ask" => egui::Color32::from_rgb(255, 152, 0), + "No" => egui::Color32::from_rgb(76, 175, 80), + _ => ui.visuals().text_color(), + }; + ui.add( + egui::Label::new(egui::RichText::new(value).color(color)) + .sense(egui::Sense::click()), + ) + } + + // Date fields + "purchase_date" | "warranty_until" | "expiry_date" | "due_date" | "last_audit" + | "checkout_date" | "return_date" => { + if let Some(date_str) = json_value.as_str() { + let text = + if let Ok(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") { + date.format("%b %d, %Y").to_string() + } else { + date_str.to_string() + }; + ui.add(egui::Label::new(text).sense(egui::Sense::click())) + } else { + ui.add(egui::Label::new("-").sense(egui::Sense::click())) + } + } + + // DateTime fields + "created_date" | "last_modified_date" => { + if let Some(datetime_str) = json_value.as_str() { + let text = if let Ok(dt) = + chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%d %H:%M:%S") + { + dt.format("%b %d, %Y %H:%M").to_string() + } else if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(datetime_str) { + dt.format("%b %d, %Y %H:%M").to_string() + } else { + datetime_str.to_string() + }; + ui.add(egui::Label::new(text).sense(egui::Sense::click())) + } else { + ui.add(egui::Label::new("-").sense(egui::Sense::click())) + } + } + + // Default text/string fields + _ => { + let (display, hover) = if let Some(text) = json_value.as_str() { + if text.is_empty() { + ("-".to_string(), None) + } else if text.len() > 50 { + (format!("{}...", &text[..47]), Some(text.to_string())) + } else { + (text.to_string(), None) + } + } else if let Some(num) = json_value.as_i64() { + (num.to_string(), None) + } else if let Some(num) = json_value.as_f64() { + (format!("{:.2}", num), None) + } else { + ("-".to_string(), None) + }; + + let resp = ui.add(egui::Label::new(display).sense(egui::Sense::click())); + if let Some(h) = hover { + resp.on_hover_text(h) + } else { + resp + } + } + } + } +} -- cgit v1.2.3-70-g09d2