aboutsummaryrefslogtreecommitdiff
path: root/src/core/table_renderer.rs
diff options
context:
space:
mode:
authorUMTS at Teleco <crt@teleco.ch>2025-12-13 02:51:15 +0100
committerUMTS at Teleco <crt@teleco.ch>2025-12-13 02:51:15 +0100
commit8323fdd73272a2882781aba3c499ba0be3dff2a6 (patch)
treeffbf86473933e69cfaeef30d5c6ea7e5b494856c /src/core/table_renderer.rs
committing to insanityHEADmaster
Diffstat (limited to 'src/core/table_renderer.rs')
-rw-r--r--src/core/table_renderer.rs739
1 files changed, 739 insertions, 0 deletions
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<String>, field: impl Into<String>) -> 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<String>,
+ 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<usize>,
+ pub selection_anchor: Option<usize>,
+ pub last_click_time: Option<std::time::Instant>,
+ pub last_click_row: Option<usize>,
+}
+
+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<usize> {
+ 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<T> {
+ 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<ColumnConfig>,
+ pub sort_config: SortConfig,
+ pub selection: SelectionManager,
+ pub search_query: String,
+ pub search_fields: Vec<String>,
+}
+
+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<ColumnConfig>) -> 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<String>) -> 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<Value>>,
+ ) -> 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<egui::Response> = 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
+ }
+ }
+ }
+ }
+}