Der minimale GUI-Vertical-Slice ist jetzt als eigenes Crate umgesetzt. Die Desktop-App liegt in crates/infinity_host_ui/src/main.rs und crates/infinity_host_ui/src/app.rs: lokales Fenster per eframe, globale Controls für Blackout, Master Brightness und Pattern, eine 6-Node-Übersicht, die 18er Mapping-Tabelle mit Node, top/middle/bottom, physischem Output/Kanal, 106 LEDs, direction, color_order, enable_flag, plus Walk 106-Testtrigger pro Panel und einen Status-/Event-Bereich für online/offline, letzter Kontakt und Fehlerstatus.

Damit die UI sauber getrennt bleibt, habe ich infinity_host zu einer wiederverwendbaren Core-Schicht ausgebaut: crates/infinity_host/src/lib.rs, control.rs und mock.rs definieren Snapshot-/Command-Typen und einen Mock-Simulationsdienst mit Hintergrundthread. Die UI pollt nur Snapshots über HostUiPort; sie treibt keine Realtime-Logik. Den Workspace und die Build-Hinweise habe ich in Cargo.toml, README.md und docs/build_and_deploy.md ergänzt.

Verifikation war hier nur eingeschränkt möglich: cargo und rustc sind in dieser Umgebung nicht installiert, daher konnte ich die App nicht kompilieren oder starten. Der nächste sinnvolle Schritt wäre erst nach deinem Go, den vorhandenen HostUiPort statt des Mock-Backends an echten Transportstatus anzubinden.
This commit is contained in:
2026-04-17 01:45:30 +02:00
parent 1f6543bd7a
commit dde35551be
11 changed files with 816 additions and 11 deletions

View File

@@ -0,0 +1,282 @@
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() {
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_source("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<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() {
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);
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"
}
}

View 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::MockHostService;
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 = MockHostService::spawn(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")
}