use crate::api::ApiClient; use crate::core::components::form_builder::FormBuilder; use crate::core::components::interactions::ConfirmDialog; use crate::core::{ColumnConfig, TableEventHandler, TableRenderer}; use crate::core::{EditorField, FieldType}; use crate::ui::ribbon::RibbonUI; use eframe::egui; use serde_json::Value; const SYSTEM_PRINTER_SETTINGS_TEMPLATE: &str = r#"{ "paper_size": "Custom", "orientation": "landscape", "margins": { "top": 0.0, "right": 0.0, "bottom": 0.0, "left": 0.0 }, "color": false, "quality": "high", "copies": 1, "duplex": false, "center": "both", "center_disabled": false, "scale_mode": "fit", "scale_factor": 1.0, "custom_width_mm": 50.8, "custom_height_mm": 76.2, "printer_name": null, "show_dialog_if_unfound": true }"#; const PDF_PRINTER_SETTINGS_TEMPLATE: &str = SYSTEM_PRINTER_SETTINGS_TEMPLATE; const SYSTEM_PRINTER_JSON_HELP: &str = r#"# System printer JSON Use this payload when registering the `System` printer plugin. Leave fields out to fall back to BeepZone's legacy sizing. ## Core fields - `paper_size` *(string)* — Named stock such as `A4`, `Letter`, `A5`, or `Custom`. - `orientation` *(string)* — Either `portrait` or `landscape`. Selecting `landscape` rotates the page 90°; any custom width/height you supply are interpreted in the stock's natural (portrait) orientation and the app flips them automatically while printing. - `margins` *(object in millimetres)* — Trim space on each edge with `top`, `right`, `bottom`, `left` properties. - `scale_mode` *(string)* — Scaling behavior: `fit` (proportional fit), `fit-x` (fit width), `fit-y` (fit height), `max-both`, `max-x`, `max-y`, or `manual`. - `scale_factor` *(number ≥ 0)* — Manual multiplier applied according to scale_mode. - `duplex`, `color`, `quality` *(optional)* — Mirrors the underlying OS print options. - `copies` *(number)* — Number of copies to print. - `custom_width_mm` / `custom_height_mm` *(numbers)* — Provide both to describe bespoke media using the printer's normal portrait orientation. ## Layout control - `center` *("none" | "horizontal" | "vertical" | "both" | null)* — Centers content when not disabled. - `center_disabled` *(bool)* — When `true`, ignores the `center` setting while keeping the last chosen mode for later. ## Direct print (optional) - `printer_name` *(string | null)* — If set, the System plugin will attempt to print directly to this OS printer by name. - `show_dialog_if_unfound` *(bool, default: true)* — When `true` (or omitted) and the named printer can't be resolved, a lightweight popup chooser appears. Set to `false` to skip the chooser and only open the PDF viewer. - `compatibility_mode` *(bool, default: false)* — When `true`, sends NO CUPS job options at all - only the raw PDF. Use this for severely broken printer filters (e.g., Kyocera network printers with crashing filters). The printer will use its default settings. ## Examples ### Custom Label Printer (e.g., ZQ510) ```json { "paper_size": "Custom", "orientation": "landscape", "margins": { "top": 0.0, "right": 0.0, "bottom": 0.0, "left": 0.0 }, "scale_mode": "fit", "scale_factor": 1.0, "color": false, "quality": "high", "copies": 1, "duplex": false, "custom_width_mm": 50.8, "custom_height_mm": 76.2, "center": "both", "center_disabled": false, "printer_name": "ZQ510", "show_dialog_if_unfound": true } ``` ### Standard A4 Office Printer ```json { "paper_size": "A4", "orientation": "portrait", "margins": { "top": 12.7, "right": 12.7, "bottom": 12.7, "left": 12.7 }, "scale_mode": "fit", "scale_factor": 1.0, "color": true, "quality": "high", "copies": 1, "duplex": false, "center": "both", "center_disabled": false, "printer_name": "HP LaserJet Pro", "show_dialog_if_unfound": true } ``` "#; const PDF_PRINTER_JSON_HELP: &str = r#"# PDF export JSON The PDF plugin understands the same shape as the System printer. Use the optional flags only when you want the enhanced layout controls; otherwise omit them for the classic renderer settings. ## Typical usage - Provide `paper_size` / `orientation` or include `custom_width_mm` + `custom_height_mm` for bespoke sheets. Enter the measurements in the stock's natural portrait orientation; landscape output is handled automatically. - Reuse the `margins` block from your system printers so labels line up identically. - `scale_mode`, `scale_factor`, `center`, `center_disabled` behave exactly the same as the System plugin. - The exported file path is still chosen through the PDF save dialog; these settings only influence page geometry. ## Available scale modes - `fit` — Proportionally fit the design within the printable area - `fit-x` — Fit to page width only - `fit-y` — Fit to page height only - `max-both` — Maximum size that fits both dimensions - `max-x` — Maximum width scaling - `max-y` — Maximum height scaling - `manual` — Use exact `scale_factor` value ## Example ```json { "paper_size": "Letter", "orientation": "portrait", "margins": { "top": 5.0, "right": 5.0, "bottom": 5.0, "left": 5.0 }, "scale_mode": "manual", "scale_factor": 0.92, "center": "horizontal", "center_disabled": false } ``` "#; pub struct PrintersView { printers: Vec, is_loading: bool, last_error: Option, initial_load_done: bool, // Table renderer table_renderer: TableRenderer, // Editor dialogs edit_dialog: FormBuilder, add_dialog: FormBuilder, delete_dialog: ConfirmDialog, // Pending operations pending_delete_id: Option, pending_edit_id: Option, // Navigation pub switch_to_print_history: bool, // Track last selected plugin to detect changes last_add_dialog_plugin: Option, } impl PrintersView { pub fn new() -> Self { let edit_dialog = Self::create_edit_dialog(); let add_dialog = Self::create_add_dialog(); // Define columns for printer_settings table let columns = vec![ ColumnConfig::new("ID", "id").with_width(60.0).hidden(), ColumnConfig::new("Printer Name", "printer_name").with_width(150.0), ColumnConfig::new("Description", "description").with_width(200.0), ColumnConfig::new("Plugin", "printer_plugin").with_width(100.0), ColumnConfig::new("Log Prints", "log").with_width(90.0), ColumnConfig::new("Use for Reports", "can_be_used_for_reports").with_width(120.0), ColumnConfig::new("Min Power Level", "min_powerlevel_to_use").with_width(110.0), ColumnConfig::new("Settings JSON", "printer_settings") .with_width(150.0) .hidden(), ]; Self { printers: Vec::new(), is_loading: false, last_error: None, initial_load_done: false, table_renderer: TableRenderer::new() .with_columns(columns) .with_default_sort("printer_name", true) .with_search_fields(vec![ "printer_name".to_string(), "description".to_string(), "printer_plugin".to_string(), ]), edit_dialog, add_dialog, delete_dialog: ConfirmDialog::new( "Delete Printer", "Are you sure you want to delete this printer configuration?", ), pending_delete_id: None, pending_edit_id: None, switch_to_print_history: false, last_add_dialog_plugin: None, } } fn plugin_help_text(plugin: &str) -> Option<&'static str> { match plugin { "System" => Some(SYSTEM_PRINTER_JSON_HELP), "PDF" => Some(PDF_PRINTER_JSON_HELP), _ => None, } } fn apply_plugin_help(editor: &mut FormBuilder, plugin: Option<&str>) { if let Some(plugin) = plugin { if let Some(help) = Self::plugin_help_text(plugin) { editor.form_help_text = Some(help.to_string()); return; } } editor.form_help_text = None; } fn create_edit_dialog() -> FormBuilder { let plugin_options = vec![ ("Ptouch".to_string(), "Brother P-Touch".to_string()), ("Brother".to_string(), "Brother (Generic)".to_string()), ("Zebra".to_string(), "Zebra".to_string()), ("System".to_string(), "System Printer".to_string()), ("PDF".to_string(), "PDF Export".to_string()), ("Network".to_string(), "Network Printer".to_string()), ("Custom".to_string(), "Custom".to_string()), ]; FormBuilder::new( "Edit Printer", vec![ EditorField { name: "id".into(), label: "ID".into(), field_type: FieldType::Text, required: false, read_only: true, }, EditorField { name: "printer_name".into(), label: "Printer Name".into(), field_type: FieldType::Text, required: true, read_only: false, }, EditorField { name: "description".into(), label: "Description".into(), field_type: FieldType::MultilineText, required: false, read_only: false, }, EditorField { name: "printer_plugin".into(), label: "Printer Plugin".into(), field_type: FieldType::Dropdown(plugin_options.clone()), required: true, read_only: false, }, EditorField { name: "log".into(), label: "Log Print Jobs".into(), field_type: FieldType::Checkbox, required: false, read_only: false, }, EditorField { name: "can_be_used_for_reports".into(), label: "Can Print Reports".into(), field_type: FieldType::Checkbox, required: false, read_only: false, }, EditorField { name: "min_powerlevel_to_use".into(), label: "Minimum Power Level".into(), field_type: FieldType::Text, required: true, read_only: false, }, EditorField { name: "printer_settings".into(), label: "Printer Settings Required".into(), field_type: FieldType::MultilineText, required: false, read_only: false, }, ], ) } fn create_add_dialog() -> FormBuilder { let plugin_options = vec![ ("Ptouch".to_string(), "Brother P-Touch".to_string()), ("Brother".to_string(), "Brother (Generic)".to_string()), ("Zebra".to_string(), "Zebra".to_string()), ("System".to_string(), "System Printer".to_string()), ("PDF".to_string(), "PDF Export".to_string()), ("Network".to_string(), "Network Printer".to_string()), ("Custom".to_string(), "Custom".to_string()), ]; FormBuilder::new( "Add Printer", vec![ EditorField { name: "printer_name".into(), label: "Printer Name".into(), field_type: FieldType::Text, required: true, read_only: false, }, EditorField { name: "description".into(), label: "Description".into(), field_type: FieldType::MultilineText, required: false, read_only: false, }, EditorField { name: "printer_plugin".into(), label: "Printer Plugin".into(), field_type: FieldType::Dropdown(plugin_options), required: true, read_only: false, }, EditorField { name: "log".into(), label: "Log Print Jobs".into(), field_type: FieldType::Checkbox, required: false, read_only: false, }, EditorField { name: "can_be_used_for_reports".into(), label: "Can Print Reports".into(), field_type: FieldType::Checkbox, required: false, read_only: false, }, EditorField { name: "min_powerlevel_to_use".into(), label: "Minimum Power Level".into(), field_type: FieldType::Text, required: true, read_only: false, }, EditorField { name: "printer_settings".into(), label: "Printer Settings (JSON)".into(), field_type: FieldType::MultilineText, required: true, read_only: false, }, ], ) } fn ensure_loaded(&mut self, api_client: Option<&ApiClient>) { if self.is_loading || self.initial_load_done { return; } if let Some(client) = api_client { self.load_printers(client); } } fn load_printers(&mut self, api_client: &ApiClient) { use crate::core::tables::get_printers; self.is_loading = true; self.last_error = None; match get_printers(api_client) { Ok(list) => { self.printers = list; self.is_loading = false; self.initial_load_done = true; } Err(e) => { self.last_error = Some(e.to_string()); self.is_loading = false; self.initial_load_done = true; } } } pub fn render( &mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>, ribbon_ui: Option<&mut RibbonUI>, session_manager: &std::sync::Arc>, ) { self.ensure_loaded(api_client); // Get search query from ribbon first (before mutable borrow) let search_query = ribbon_ui .as_ref() .and_then(|r| r.search_texts.get("printers_search")) .map(|s| s.clone()) .unwrap_or_default(); // Apply search to table renderer self.table_renderer.search_query = search_query; // Handle ribbon actions and default printer dropdown if let Some(ribbon) = ribbon_ui { if ribbon .checkboxes .get("printers_action_add") .copied() .unwrap_or(false) { // Provide default values - printer_settings will get plugin-specific template let default_data = serde_json::json!({ "printer_settings": "{}", "log": true, "can_be_used_for_reports": false, "min_powerlevel_to_use": "0" }); self.add_dialog.open(&default_data); } if ribbon .checkboxes .get("printers_action_refresh") .copied() .unwrap_or(false) { if let Some(client) = api_client { self.load_printers(client); } } if ribbon .checkboxes .get("printers_view_print_history") .copied() .unwrap_or(false) { self.switch_to_print_history = true; } // Handle default printer dropdown (will be rendered in Settings group) // Store selected printer ID change flag if ribbon .checkboxes .get("printers_default_changed") .copied() .unwrap_or(false) { if let Some(printer_id_str) = ribbon.search_texts.get("printers_default_id") { if printer_id_str.is_empty() { // Clear default printer if let Ok(mut session) = session_manager.try_lock() { if let Err(e) = session.update_default_printer(None) { log::error!("Failed to clear default printer: {}", e); } else { log::info!("Default printer cleared"); } } } else if let Ok(printer_id) = printer_id_str.parse::() { // Set default printer if let Ok(mut session) = session_manager.try_lock() { if let Err(e) = session.update_default_printer(Some(printer_id)) { log::error!("Failed to update default printer: {}", e); } else { log::info!("Default printer set to ID: {}", printer_id); } } } } } } // Error message let mut clear_error = false; if let Some(err) = &self.last_error { ui.horizontal(|ui| { ui.colored_label(egui::Color32::RED, format!("Error: {}", err)); if ui.button("Close").clicked() { clear_error = true; } }); ui.separator(); } if clear_error { self.last_error = None; } // Loading indicator if self.is_loading { ui.spinner(); ui.label("Loading printers..."); return; } // Render table with event handling self.render_table_with_events(ui, api_client); // Handle dialogs self.handle_dialogs(ui, api_client); // Process deferred actions from context menus self.process_deferred_actions(ui, api_client); } /// Called before rendering to inject printer dropdown data into ribbon pub fn inject_dropdown_into_ribbon( &self, ribbon_ui: &mut RibbonUI, session_manager: &std::sync::Arc>, ) { // Try to get current default printer ID without blocking (avoid Tokio panic) let current_default = session_manager .try_lock() .ok() .and_then(|s| s.get_default_printer_id()); // Store current default for ribbon rendering if let Some(id) = current_default { ribbon_ui .search_texts .insert("_printers_current_default".to_string(), id.to_string()); } else { ribbon_ui .search_texts .insert("_printers_current_default".to_string(), "".to_string()); } // Store printer list as JSON string for ribbon to parse let printers_json = serde_json::to_string(&self.printers).unwrap_or_default(); ribbon_ui .search_texts .insert("_printers_list".to_string(), printers_json); } fn render_table_with_events(&mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>) { let printers_clone = self.printers.clone(); let prepared_data = self.table_renderer.prepare_json_data(&printers_clone); let mut deferred_actions: Vec = Vec::new(); let mut temp_handler = TempPrintersEventHandler { api_client, deferred_actions: &mut deferred_actions, }; self.table_renderer .render_json_table(ui, &prepared_data, Some(&mut temp_handler)); self.process_temp_deferred_actions(deferred_actions, api_client); } fn process_temp_deferred_actions( &mut self, actions: Vec, _api_client: Option<&ApiClient>, ) { for action in actions { match action { DeferredAction::DoubleClick(printer) => { log::info!( "Processing double-click edit for printer: {:?}", printer.get("printer_name") ); self.edit_dialog.open(&printer); if let Some(id) = printer.get("id").and_then(|v| v.as_i64()) { self.pending_edit_id = Some(id); } } DeferredAction::ContextEdit(printer) => { log::info!( "Processing context menu edit for printer: {:?}", printer.get("printer_name") ); self.edit_dialog.open(&printer); if let Some(id) = printer.get("id").and_then(|v| v.as_i64()) { self.pending_edit_id = Some(id); } } DeferredAction::ContextDelete(printer) => { let name = printer .get("printer_name") .and_then(|v| v.as_str()) .unwrap_or("Unknown"); let id = printer.get("id").and_then(|v| v.as_i64()).unwrap_or(-1); log::info!("Processing context menu delete for printer: {}", name); self.pending_delete_id = Some(id); self.delete_dialog.open(name.to_string(), id.to_string()); } DeferredAction::ContextClone(printer) => { log::info!( "Processing context menu clone for printer: {:?}", printer.get("printer_name") ); let mut cloned = crate::core::components::prepare_cloned_value( &printer, &["id"], Some("printer_name"), Some(""), ); if let Some(obj) = cloned.as_object_mut() { if let Some(ps) = obj.get("printer_settings") { let as_str = if ps.is_string() { ps.as_str().unwrap_or("{}").to_string() } else { serde_json::to_string_pretty(ps) .unwrap_or_else(|_| "{}".to_string()) }; obj.insert( "printer_settings".to_string(), serde_json::Value::String(as_str), ); } self.add_dialog.open_new(Some(obj)); } } } } } fn handle_dialogs(&mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>) { // BEFORE showing add dialog, check if printer_plugin changed and auto-populate printer_settings if self.add_dialog.show { let current_plugin = self .add_dialog .data .get("printer_plugin") .map(|s| s.clone()); // Detect if plugin changed to "System" if current_plugin != self.last_add_dialog_plugin { if let Some(ref plugin) = current_plugin { let template = match plugin.as_str() { "System" => Some(SYSTEM_PRINTER_SETTINGS_TEMPLATE), "PDF" => Some(PDF_PRINTER_SETTINGS_TEMPLATE), _ => None, }; if let Some(template) = template { let current_settings = self .add_dialog .data .get("printer_settings") .map(|s| s.as_str()) .unwrap_or("{}"); if current_settings.trim().is_empty() || current_settings.trim() == "{}" { self.add_dialog .data .insert("printer_settings".to_string(), template.to_string()); } } } self.last_add_dialog_plugin = current_plugin.clone(); } Self::apply_plugin_help(&mut self.add_dialog, current_plugin.as_deref()); } else { // Reset tracking when dialog closes self.last_add_dialog_plugin = None; self.add_dialog.form_help_text = None; } if self.edit_dialog.show { let edit_plugin = self .edit_dialog .data .get("printer_plugin") .map(|s| s.clone()); Self::apply_plugin_help(&mut self.edit_dialog, edit_plugin.as_deref()); } else { self.edit_dialog.form_help_text = None; } // Delete confirmation dialog if let Some(confirmed) = self.delete_dialog.show_dialog(ui.ctx()) { if confirmed { if let (Some(id), Some(client)) = (self.pending_delete_id, api_client) { let where_clause = serde_json::json!({"id": id}); match client.delete("printer_settings", where_clause) { Ok(_) => { log::info!("Printer {} deleted successfully", id); self.load_printers(client); } Err(e) => { self.last_error = Some(format!("Failed to delete printer: {}", e)); log::error!("Failed to delete printer: {}", e); } } } self.pending_delete_id = None; } } // Edit dialog if let Some(Some(updated)) = self.edit_dialog.show_editor(ui.ctx()) { if let (Some(id), Some(client)) = (self.pending_edit_id, api_client) { let where_clause = serde_json::json!({"id": id}); // Ensure printer_settings field is valid JSON and send as JSON object let mut to_update = updated; // Remove generic editor metadata keys (avoid backend invalid column errors) let mut meta_keys: Vec = to_update .keys() .filter(|k| k.starts_with("__editor_")) .cloned() .collect(); // Also remove __editor_item_id specifically if to_update.contains_key("__editor_item_id") { meta_keys.push("__editor_item_id".to_string()); } for k in meta_keys { to_update.remove(&k); } if let Some(val) = to_update.get_mut("printer_settings") { if let Some(s) = val.as_str() { match serde_json::from_str::(s) { Ok(json_val) => { // Send as actual JSON object, not base64 string *val = json_val; } Err(e) => { self.last_error = Some(format!("Printer Settings JSON is invalid: {}", e)); return; } } } } match client.update( "printer_settings", serde_json::Value::Object(to_update.clone()), where_clause, ) { Ok(resp) => { if resp.success { log::info!("Printer {} updated successfully", id); self.load_printers(client); } else { self.last_error = Some(format!("Update failed: {:?}", resp.error)); log::error!("Update failed: {:?}", resp.error); } } Err(e) => { self.last_error = Some(format!("Failed to update printer: {}", e)); log::error!("Failed to update printer: {}", e); } } self.pending_edit_id = None; } } // Add dialog if let Some(Some(new_data)) = self.add_dialog.show_editor(ui.ctx()) { if let Some(client) = api_client { // Parse printer_settings JSON and send as JSON object let mut payload = new_data; // Strip any editor metadata that may have leaked in let meta_strip: Vec = payload .keys() .filter(|k| k.starts_with("__editor_")) .cloned() .collect(); for k in meta_strip { payload.remove(&k); } if let Some(val) = payload.get_mut("printer_settings") { if let Some(s) = val.as_str() { match serde_json::from_str::(s) { Ok(json_val) => { // Send as actual JSON object, not base64 string *val = json_val; } Err(e) => { self.last_error = Some(format!("Printer Settings JSON is invalid: {}", e)); return; } } } } match client.insert( "printer_settings", serde_json::Value::Object(payload.clone()), ) { Ok(resp) => { if resp.success { log::info!("Printer added successfully"); self.load_printers(client); } else { self.last_error = Some(format!("Insert failed: {:?}", resp.error)); log::error!("Insert failed: {:?}", resp.error); } } Err(e) => { self.last_error = Some(format!("Failed to add printer: {}", e)); log::error!("Failed to add printer: {}", e); } } } } } fn process_deferred_actions(&mut self, ui: &mut egui::Ui, _api_client: Option<&ApiClient>) { // Handle double-click edit if let Some(printer) = ui .ctx() .data_mut(|d| d.remove_temp::(egui::Id::new("printer_double_click_edit"))) { log::info!( "Processing double-click edit for printer: {:?}", printer.get("printer_name") ); self.edit_dialog.open(&printer); if let Some(id) = printer.get("id").and_then(|v| v.as_i64()) { self.pending_edit_id = Some(id); } } // Handle context menu actions if let Some(printer) = ui .ctx() .data_mut(|d| d.remove_temp::(egui::Id::new("printer_context_menu_edit"))) { log::info!( "Processing context menu edit for printer: {:?}", printer.get("printer_name") ); self.edit_dialog.open(&printer); if let Some(id) = printer.get("id").and_then(|v| v.as_i64()) { self.pending_edit_id = Some(id); } } if let Some(printer) = ui .ctx() .data_mut(|d| d.remove_temp::(egui::Id::new("printer_context_menu_delete"))) { let name = printer .get("printer_name") .and_then(|v| v.as_str()) .unwrap_or("Unknown"); let id = printer.get("id").and_then(|v| v.as_i64()).unwrap_or(-1); log::info!("Processing context menu delete for printer: {}", name); self.pending_delete_id = Some(id); self.delete_dialog.open(name.to_string(), id.to_string()); } } } impl Default for PrintersView { fn default() -> Self { Self::new() } } #[derive(Clone)] enum DeferredAction { DoubleClick(Value), ContextEdit(Value), ContextDelete(Value), ContextClone(Value), } // Temporary event handler that collects actions for later processing struct TempPrintersEventHandler<'a> { #[allow(dead_code)] api_client: Option<&'a ApiClient>, deferred_actions: &'a mut Vec, } impl<'a> TableEventHandler for TempPrintersEventHandler<'a> { fn on_double_click(&mut self, item: &Value, _row_index: usize) { log::info!( "Double-click detected on printer: {:?}", item.get("printer_name") ); self.deferred_actions .push(DeferredAction::DoubleClick(item.clone())); } fn on_context_menu(&mut self, ui: &mut egui::Ui, item: &Value, _row_index: usize) { if ui .button(format!("{} Edit Printer", egui_phosphor::regular::PENCIL)) .clicked() { log::info!( "Context menu edit clicked for printer: {:?}", item.get("printer_name") ); self.deferred_actions .push(DeferredAction::ContextEdit(item.clone())); ui.close(); } ui.separator(); if ui .button(format!("{} Clone Printer", egui_phosphor::regular::COPY)) .clicked() { log::info!( "Context menu clone clicked for printer: {:?}", item.get("printer_name") ); self.deferred_actions .push(DeferredAction::ContextClone(item.clone())); ui.close(); } ui.separator(); if ui .button(format!("{} Delete Printer", egui_phosphor::regular::TRASH)) .clicked() { log::info!( "Context menu delete clicked for printer: {:?}", item.get("printer_name") ); self.deferred_actions .push(DeferredAction::ContextDelete(item.clone())); ui.close(); } } fn on_selection_changed(&mut self, selected_indices: &[usize]) { log::debug!("Printer selection changed: {:?}", selected_indices); } }