Files
Infinity_Vis_Rust/crates/infinity_host_ui/src/app.rs
JFly02 9457666fd6 **Core**
Die Host-Seite ist jetzt auf eine gemeinsame software-first API ausgerichtet. In [control.rs](</c:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/control.rs>) steckt jetzt das stabile gemeinsame Modell für Snapshots, Commands, Pattern-Katalog, Presets, Gruppen, Parameter, Preview und Übergänge. Darauf sitzen die neue Szenen-/Pattern-Schicht in [scene.rs](</c:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/scene.rs>) und der simulationsbasierte Host-Service in [simulation.rs](</c:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/simulation.rs>).

Der neue Core kann jetzt softwareseitig schon:
- Pattern-Katalog mit `solid_color`, `gradient`, `chase`, `pulse`, `noise`, `walking_pixel`
- Preset-Recall, Gruppen-Targeting, Parameteränderungen und Übergänge
- simulierte Preview-Daten für alle 18 Outputs
- denselben API-Zugriff für CLI, Engineering-GUI und später Web-UI / grandMA-Adapter

Zusätzlich gibt es im Host-CLI jetzt `snapshot`, also eine direkte JSON-Sicht auf den gemeinsamen Host-State über [main.rs](</c:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/main.rs>).

**Oberflächen**
Die technische lokale GUI bleibt bestehen und hängt jetzt auf der neuen gemeinsamen API. In [app.rs](</c:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_ui/src/app.rs>) zeigt sie weiter Mapping/Status/Testmuster, ergänzt um Engine-/Szene-/Übergangsstatus. Sie bleibt bewusst Engineering-orientiert und ist nicht zur kreativen Hauptoberfläche aufgeblasen worden.

Die Beispielkonfiguration in [project.example.toml](</c:/Users/janni/Documents/RFP/Infinity_Vis _Rust/config/project.example.toml>) ist jetzt auch als Software-Spielwiese brauchbarer: mehr Gruppen, mehr kreative Presets und bessere Basis für Look-Entwicklung ohne echte Node-Aktivierung. Die neue API-Ausrichtung ist in [host_api.md](</c:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/host_api.md>) und [architecture.md](</c:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/architecture.md>) dokumentiert.

**Verifikation**
`cargo check` und `cargo test -q` laufen erfolgreich. Zusätzlich läuft `cargo run -p infinity_host -- snapshot --config config/project.example.toml` und liefert den gemeinsamen Host-Snapshot mit Katalog, aktiver Szene, Preview, Node- und Panelstatus.

Der nächste sinnvolle Schritt ist jetzt ein echter API-Adapter fuer die kommende Web-UI, also HTTP/WebSocket auf genau diesem Host-Core statt einer frontend-spezifischen Parallelarchitektur.
2026-04-17 11:39:56 +02:00

318 lines
11 KiB
Rust

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_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<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);
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"
}
}