use eframe::egui::{self, Color32, RichText}; use infinity_config::{ColorOrder, LedDirection, PanelPosition, ValidationState}; use infinity_host::{ HostCommand, HostSnapshot, HostUiPort, NodeConnectionState, PanelTarget, TestPatternKind, }; use std::{sync::Arc, time::Duration}; pub struct HostUiApp { service: Arc, snapshot: HostSnapshot, } impl HostUiApp { pub fn new(service: Arc) -> Self { let snapshot = service.snapshot(); Self { service, snapshot } } } impl eframe::App for HostUiApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.snapshot = self.service.snapshot(); ctx.request_repaint_after(Duration::from_millis(100)); egui::TopBottomPanel::top("global_controls").show(ctx, |ui| { draw_global_controls(ui, &self.snapshot, &self.service); }); egui::SidePanel::right("status_panel") .default_width(320.0) .resizable(true) .show(ctx, |ui| { draw_status_panel(ui, &self.snapshot); }); egui::CentralPanel::default().show(ctx, |ui| { draw_node_overview(ui, &self.snapshot); ui.add_space(12.0); draw_panel_mapping(ui, &self.snapshot, &self.service); }); } } fn draw_global_controls(ui: &mut egui::Ui, snapshot: &HostSnapshot, service: &Arc) { ui.horizontal_wrapped(|ui| { ui.heading("Infinity Vis Host UI"); ui.label( RichText::new(format!("backend: {}", snapshot.backend_label)) .color(Color32::from_rgb(110, 169, 255)), ); ui.separator(); ui.label(format!("nodes: {}", snapshot.nodes.len())); ui.label(format!("outputs: {}", snapshot.panels.len())); ui.label(format!("schema time: {} ms", snapshot.generated_at_millis)); }); ui.add_space(8.0); ui.horizontal_wrapped(|ui| { let blackout = snapshot.global.blackout; let blackout_button = egui::Button::new(if blackout { RichText::new("Blackout ACTIVE").strong().color(Color32::WHITE) } else { RichText::new("Blackout").strong() }) .fill(if blackout { Color32::from_rgb(190, 32, 32) } else { Color32::from_rgb(70, 24, 24) }); if ui.add(blackout_button).clicked() { service.send_command(HostCommand::SetBlackout(!blackout)); } let mut brightness = snapshot.global.master_brightness; if ui .add( egui::Slider::new(&mut brightness, 0.0..=1.0) .text("Master Brightness"), ) .changed() { service.send_command(HostCommand::SetMasterBrightness(brightness)); } let selected_pattern = snapshot.global.selected_pattern.clone(); egui::ComboBox::from_id_salt("pattern_selector") .width(180.0) .selected_text(selected_pattern.clone()) .show_ui(ui, |ui| { for pattern in &snapshot.available_patterns { if ui .selectable_label(selected_pattern == *pattern, pattern) .clicked() { service.send_command(HostCommand::SelectPattern(pattern.clone())); } } }); }); } fn draw_node_overview(ui: &mut egui::Ui, snapshot: &HostSnapshot) { ui.heading("Node Overview"); ui.label("Simulation snapshots are shown here without making the UI thread the timing master."); ui.add_space(6.0); ui.columns(3, |columns| { for (index, node) in snapshot.nodes.iter().enumerate() { let column = &mut columns[index % 3]; column.group(|ui| { ui.horizontal(|ui| { ui.label(RichText::new(&node.display_name).strong()); ui.label(connection_badge(node.connection)); }); ui.label(format!("Node ID: {}", node.node_id)); ui.label(format!( "IP: {}", node.reserved_ip.as_deref().unwrap_or("unassigned") )); ui.label(format!("Panels: {}", node.panel_count)); ui.label(format!("Last contact: {} ms", node.last_contact_ms)); if let Some(error) = &node.error_status { ui.label(RichText::new(error).color(Color32::from_rgb(255, 140, 140))); } else { ui.label(RichText::new("No active errors").color(Color32::from_rgb(120, 204, 142))); } }); } }); } fn draw_panel_mapping( ui: &mut egui::Ui, snapshot: &HostSnapshot, service: &Arc, ) { ui.separator(); ui.heading("Panel Mapping"); ui.label("Each row is a real output slot in the fixed 6 x 3 hardware topology."); ui.add_space(6.0); egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| { egui::Grid::new("panel_mapping_grid") .num_columns(11) .striped(true) .min_col_width(72.0) .show(ui, |ui| { header_cell(ui, "Node"); header_cell(ui, "Panel"); header_cell(ui, "Output"); header_cell(ui, "GPIO / Channel"); header_cell(ui, "LEDs"); header_cell(ui, "Direction"); header_cell(ui, "Color"); header_cell(ui, "Enabled"); header_cell(ui, "Validation"); header_cell(ui, "Status"); header_cell(ui, "Test"); ui.end_row(); for panel in &snapshot.panels { ui.label(&panel.target.node_id); ui.label(panel_position_label(&panel.target.panel_position)); ui.label(&panel.physical_output_name); ui.label(&panel.driver_reference); ui.label(panel.led_count.to_string()); ui.label(direction_label(&panel.direction)); ui.label(color_order_label(&panel.color_order)); ui.label(bool_flag(panel.enabled)); ui.label(validation_label(&panel.validation_state)); ui.vertical(|ui| { ui.label(connection_badge(panel.connection)); if let Some(last_test_ms) = panel.last_test_trigger_ms { ui.label(format!("last test: {} ms", snapshot.generated_at_millis.saturating_sub(last_test_ms))); } if let Some(error) = &panel.error_status { ui.label( RichText::new(error).color(Color32::from_rgb(255, 140, 140)), ); } }); if ui.button("Walk 106").clicked() { service.send_command(HostCommand::TriggerPanelTest { target: PanelTarget { node_id: panel.target.node_id.clone(), panel_position: panel.target.panel_position.clone(), }, pattern: TestPatternKind::WalkingPixel106, }); } ui.end_row(); } }); }); } fn draw_status_panel(ui: &mut egui::Ui, snapshot: &HostSnapshot) { ui.heading("Status"); ui.label("Node connectivity, last contact, and recent control activity."); ui.add_space(8.0); ui.group(|ui| { ui.label(RichText::new("Engine").strong()); ui.label(format!("Pattern: {}", snapshot.active_scene.pattern_id)); ui.label(format!( "Preset: {}", snapshot .active_scene .preset_id .as_deref() .unwrap_or("custom") )); ui.label(format!( "Group: {}", snapshot .active_scene .target_group .as_deref() .unwrap_or("all_panels") )); ui.label(format!( "Frames: {} at {} Hz", snapshot.engine.frame_index, snapshot.engine.frame_hz )); if let Some(transition) = &snapshot.engine.active_transition { ui.label(format!( "Transition: {:?} {:.0}%", transition.style, transition.progress * 100.0 )); } else { ui.label("Transition: idle"); } }); ui.add_space(8.0); for node in &snapshot.nodes { ui.group(|ui| { ui.horizontal(|ui| { ui.label(RichText::new(&node.display_name).strong()); ui.label(connection_badge(node.connection)); }); ui.label(format!("Last contact: {} ms", node.last_contact_ms)); ui.label(format!( "Error status: {}", node.error_status.as_deref().unwrap_or("none") )); }); ui.add_space(6.0); } ui.separator(); ui.heading("Recent Events"); egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { for event in &snapshot.recent_events { ui.label(format!("[{} ms] {}", event.at_millis, event.message)); } }); } fn header_cell(ui: &mut egui::Ui, label: &str) { ui.label(RichText::new(label).strong()); } fn connection_badge(state: NodeConnectionState) -> RichText { let (label, color) = match state { NodeConnectionState::Online => ("online", Color32::from_rgb(78, 194, 120)), NodeConnectionState::Degraded => ("degraded", Color32::from_rgb(242, 182, 59)), NodeConnectionState::Offline => ("offline", Color32::from_rgb(220, 76, 76)), }; RichText::new(label).strong().color(color) } fn panel_position_label(position: &PanelPosition) -> &'static str { match position { PanelPosition::Top => "top", PanelPosition::Middle => "middle", PanelPosition::Bottom => "bottom", } } fn direction_label(direction: &LedDirection) -> &'static str { match direction { LedDirection::Forward => "forward", LedDirection::Reverse => "reverse", } } fn color_order_label(color_order: &ColorOrder) -> &'static str { match color_order { ColorOrder::Rgb => "RGB", ColorOrder::Rbg => "RBG", ColorOrder::Grb => "GRB", ColorOrder::Gbr => "GBR", ColorOrder::Brg => "BRG", ColorOrder::Bgr => "BGR", } } fn validation_label(state: &ValidationState) -> &'static str { match state { ValidationState::PendingHardwareValidation => "pending_validation", ValidationState::Validated => "validated", ValidationState::Retired => "retired", } } fn bool_flag(value: bool) -> &'static str { if value { "true" } else { "false" } }