Software-only show-control readiness baseline
This commit is contained in:
319
crates/infinity_host_ui/src/app.rs
Normal file
319
crates/infinity_host_ui/src/app.rs
Normal file
@@ -0,0 +1,319 @@
|
||||
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<dyn HostUiPort>,
|
||||
snapshot: HostSnapshot,
|
||||
}
|
||||
|
||||
impl HostUiApp {
|
||||
pub fn new(service: Arc<dyn HostUiPort>) -> 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<dyn HostUiPort>) {
|
||||
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() {
|
||||
let _ = 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()
|
||||
{
|
||||
let _ = 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()
|
||||
{
|
||||
let _ = 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<dyn HostUiPort>) {
|
||||
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() {
|
||||
let _ = 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"
|
||||
}
|
||||
}
|
||||
46
crates/infinity_host_ui/src/main.rs
Normal file
46
crates/infinity_host_ui/src/main.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
mod app;
|
||||
|
||||
use app::HostUiApp;
|
||||
use clap::Parser;
|
||||
use eframe::egui;
|
||||
use infinity_config::{load_project_from_path, ProjectConfig};
|
||||
use infinity_host::SimulationHostService;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(author, version, about = "Infinity Vis native host UI")]
|
||||
struct Cli {
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let project = load_project(cli.config.as_deref());
|
||||
let service = SimulationHostService::spawn_shared(project);
|
||||
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("Infinity Vis Host UI")
|
||||
.with_inner_size([1500.0, 940.0])
|
||||
.with_min_inner_size([1200.0, 760.0]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"Infinity Vis Host UI",
|
||||
options,
|
||||
Box::new(move |_creation_context| Ok(Box::new(HostUiApp::new(service.clone())))),
|
||||
)
|
||||
}
|
||||
|
||||
fn load_project(path: Option<&std::path::Path>) -> ProjectConfig {
|
||||
if let Some(path) = path {
|
||||
if let Ok(project) = load_project_from_path(path) {
|
||||
return project;
|
||||
}
|
||||
}
|
||||
|
||||
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
|
||||
.expect("embedded example config must parse")
|
||||
}
|
||||
Reference in New Issue
Block a user