Files
marte-debug/Tools/gui_client/src/main.rs
2026-02-23 13:17:16 +01:00

570 lines
33 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use eframe::egui;
use egui_plot::{Line, Plot, PlotPoints, MarkerShape, LineStyle, PlotBounds};
use std::collections::{HashMap, VecDeque};
use std::net::{TcpStream, UdpSocket};
use std::io::{Write, BufReader, BufRead};
use std::sync::{Arc, Mutex};
use std::thread;
use serde::{Deserialize, Serialize};
use chrono::Local;
use crossbeam_channel::{unbounded, Receiver, Sender};
use socket2::{Socket, Domain, Type, Protocol};
use regex::Regex;
use once_cell::sync::Lazy;
static APP_START_TIME: Lazy<std::time::Instant> = Lazy::new(std::time::Instant::now);
// --- Models ---
#[derive(Serialize, Deserialize, Clone, Debug)]
struct Signal {
name: String,
id: u32,
#[serde(rename = "type")]
sig_type: String,
}
#[derive(Deserialize)]
struct DiscoverResponse {
#[serde(rename = "Signals")]
signals: Vec<Signal>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
struct TreeItem {
#[serde(rename = "Name")]
name: String,
#[serde(rename = "Class")]
class: String,
#[serde(rename = "Children")]
children: Option<Vec<TreeItem>>,
#[serde(rename = "Type")]
sig_type: Option<String>,
#[serde(rename = "Dimensions")]
dimensions: Option<u8>,
#[serde(rename = "Elements")]
elements: Option<u32>,
}
#[derive(Clone)]
struct LogEntry {
time: String,
level: String,
message: String,
}
struct TraceData {
values: VecDeque<[f64; 2]>,
last_value: f64,
}
struct SignalMetadata {
names: Vec<String>,
sig_type: String,
}
#[derive(Clone)]
struct ConnectionConfig {
ip: String,
tcp_port: String,
udp_port: String,
log_port: String,
version: u64,
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum PlotType {
Normal,
LogicAnalyzer,
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum MarkerType {
None,
Circle,
Square,
}
impl MarkerType {
fn to_shape(&self) -> Option<MarkerShape> {
match self {
MarkerType::None => None,
MarkerType::Circle => Some(MarkerShape::Circle),
MarkerType::Square => Some(MarkerShape::Square),
}
}
}
#[derive(Clone)]
struct SignalPlotConfig {
source_name: String,
label: String,
unit: String,
color: egui::Color32,
line_style: LineStyle,
marker_type: MarkerType,
gain: f64,
offset: f64,
}
struct PlotInstance {
id: String,
plot_type: PlotType,
signals: Vec<SignalPlotConfig>,
auto_bounds: bool,
}
enum InternalEvent {
Log(LogEntry),
Discovery(Vec<Signal>),
Tree(TreeItem),
CommandResponse(String),
NodeInfo(String),
Connected,
Disconnected,
InternalLog(String),
TraceRequested(String),
ClearTrace(String),
UdpStats(u64),
UdpDropped(u32),
}
// --- App State ---
struct ForcingDialog {
signal_path: String,
value: String,
}
struct LogFilters {
show_debug: bool,
show_info: bool,
show_warning: bool,
show_error: bool,
paused: bool,
content_regex: String,
}
struct MarteDebugApp {
connected: bool,
is_breaking: bool,
config: ConnectionConfig,
shared_config: Arc<Mutex<ConnectionConfig>>,
app_tree: Option<TreeItem>,
id_to_meta: Arc<Mutex<HashMap<u32, SignalMetadata>>>,
traced_signals: Arc<Mutex<HashMap<String, TraceData>>>,
plots: Vec<PlotInstance>,
forced_signals: HashMap<String, String>,
logs: VecDeque<LogEntry>,
log_filters: LogFilters,
show_left_panel: bool,
show_right_panel: bool,
show_bottom_panel: bool,
selected_node: String,
node_info: String,
udp_packets: u64,
udp_dropped: u64,
forcing_dialog: Option<ForcingDialog>,
style_editor: Option<(usize, usize)>,
tx_cmd: Sender<String>,
rx_events: Receiver<InternalEvent>,
internal_tx: Sender<InternalEvent>,
shared_x_range: Option<[f64; 2]>,
}
impl MarteDebugApp {
fn new(_cc: &eframe::CreationContext<'_>) -> Self {
let (tx_cmd, rx_cmd_internal) = unbounded::<String>();
let (tx_events, rx_events) = unbounded::<InternalEvent>();
let internal_tx = tx_events.clone();
let config = ConnectionConfig { ip: "127.0.0.1".to_string(), tcp_port: "8080".to_string(), udp_port: "8081".to_string(), log_port: "8082".to_string(), version: 0 };
let shared_config = Arc::new(Mutex::new(config.clone()));
let id_to_meta = Arc::new(Mutex::new(HashMap::new()));
let traced_signals = Arc::new(Mutex::new(HashMap::new()));
let id_to_meta_clone = id_to_meta.clone();
let traced_signals_clone = traced_signals.clone();
let shared_config_cmd = shared_config.clone();
let shared_config_log = shared_config.clone();
let shared_config_udp = shared_config.clone();
let tx_events_c = tx_events.clone();
thread::spawn(move || { tcp_command_worker(shared_config_cmd, rx_cmd_internal, tx_events_c); });
let tx_events_log = tx_events.clone();
thread::spawn(move || { tcp_log_worker(shared_config_log, tx_events_log); });
let tx_events_udp = tx_events.clone();
thread::spawn(move || { udp_worker(shared_config_udp, id_to_meta_clone, traced_signals_clone, tx_events_udp); });
Self {
connected: false, is_breaking: false, config, shared_config, app_tree: None, id_to_meta, traced_signals,
plots: vec![PlotInstance { id: "Plot 1".to_string(), plot_type: PlotType::Normal, signals: Vec::new(), auto_bounds: true }],
forced_signals: HashMap::new(), logs: VecDeque::with_capacity(2000),
log_filters: LogFilters { show_debug: true, show_info: true, show_warning: true, show_error: true, paused: false, content_regex: "".to_string() },
show_left_panel: true, show_right_panel: true, show_bottom_panel: true,
selected_node: "".to_string(), node_info: "".to_string(),
udp_packets: 0, udp_dropped: 0,
forcing_dialog: None, style_editor: None,
tx_cmd, rx_events, internal_tx,
shared_x_range: None,
}
}
fn next_color(idx: usize) -> egui::Color32 {
let colors = [
egui::Color32::from_rgb(100, 200, 255), egui::Color32::from_rgb(255, 100, 100),
egui::Color32::from_rgb(100, 255, 100), egui::Color32::from_rgb(255, 200, 100),
egui::Color32::from_rgb(255, 100, 255), egui::Color32::from_rgb(100, 255, 255),
egui::Color32::from_rgb(200, 255, 100), egui::Color32::WHITE,
];
colors[idx % colors.len()]
}
fn render_tree(&mut self, ui: &mut egui::Ui, item: &TreeItem, path: String) {
let current_path = if path.is_empty() { if item.name == "Root" { "".to_string() } else { item.name.clone() } }
else { if path.is_empty() { item.name.clone() } else { format!("{}.{}", path, item.name) } };
let label = if item.class == "Signal" { format!("📈 {}", item.name) } else { item.name.clone() };
if let Some(children) = &item.children {
let header = egui::CollapsingHeader::new(format!("{} [{}]", label, item.class)).id_salt(&current_path);
header.show(ui, |ui| {
ui.horizontal(|ui| { if !current_path.is_empty() { if ui.selectable_label(self.selected_node == current_path, " Info").clicked() { self.selected_node = current_path.clone(); let _ = self.tx_cmd.send(format!("INFO {}", current_path)); } } });
for child in children { self.render_tree(ui, child, current_path.clone()); }
});
} else {
ui.horizontal(|ui| {
if ui.selectable_label(self.selected_node == current_path, format!("{} [{}]", label, item.class)).clicked() { self.selected_node = current_path.clone(); let _ = self.tx_cmd.send(format!("INFO {}", current_path)); }
if item.class.contains("Signal") {
if ui.button("Trace").clicked() { let _ = self.tx_cmd.send(format!("TRACE {} 1", current_path)); let _ = self.internal_tx.send(InternalEvent::TraceRequested(current_path.clone())); }
if ui.button("⚡ Force").clicked() { self.forcing_dialog = Some(ForcingDialog { signal_path: current_path.clone(), value: "".to_string() }); }
}
});
}
}
}
fn tcp_command_worker(shared_config: Arc<Mutex<ConnectionConfig>>, rx_cmd: Receiver<String>, tx_events: Sender<InternalEvent>) {
let mut current_version = 0;
let mut current_addr = String::new();
loop {
{ let config = shared_config.lock().unwrap(); if config.version != current_version { current_version = config.version; current_addr = format!("{}:{}", config.ip, config.tcp_port); } }
if current_addr.is_empty() || current_addr.starts_with(":") { thread::sleep(std::time::Duration::from_secs(1)); continue; }
if let Ok(mut stream) = TcpStream::connect(&current_addr) {
let _ = stream.set_nodelay(true);
let mut reader = BufReader::new(stream.try_clone().unwrap());
let _ = tx_events.send(InternalEvent::Connected);
let stop_flag = Arc::new(Mutex::new(false));
let stop_flag_reader = stop_flag.clone();
let tx_events_inner = tx_events.clone();
thread::spawn(move || {
let mut line = String::new();
let mut json_acc = String::new();
let mut in_json = false;
while reader.read_line(&mut line).is_ok() {
if *stop_flag_reader.lock().unwrap() { break; }
let trimmed = line.trim();
if trimmed.is_empty() { line.clear(); continue; }
if !in_json && trimmed.starts_with("{") { in_json = true; json_acc.clear(); }
if in_json {
json_acc.push_str(trimmed);
if trimmed == "OK DISCOVER" { in_json = false; let json_clean = json_acc.trim_end_matches("OK DISCOVER").trim(); if let Ok(resp) = serde_json::from_str::<DiscoverResponse>(json_clean) { let _ = tx_events_inner.send(InternalEvent::Discovery(resp.signals)); } json_acc.clear(); }
else if trimmed == "OK TREE" { in_json = false; let json_clean = json_acc.trim_end_matches("OK TREE").trim(); if let Ok(resp) = serde_json::from_str::<TreeItem>(json_clean) { let _ = tx_events_inner.send(InternalEvent::Tree(resp)); } json_acc.clear(); }
else if trimmed == "OK INFO" { in_json = false; let json_clean = json_acc.trim_end_matches("OK INFO").trim(); let _ = tx_events_inner.send(InternalEvent::NodeInfo(json_clean.to_string())); json_acc.clear(); }
} else { let _ = tx_events_inner.send(InternalEvent::CommandResponse(trimmed.to_string())); }
line.clear();
}
});
while let Ok(cmd) = rx_cmd.recv() {
{ let config = shared_config.lock().unwrap(); if config.version != current_version { *stop_flag.lock().unwrap() = true; break; } }
if stream.write_all(format!("{}\n", cmd).as_bytes()).is_err() { break; }
}
let _ = tx_events.send(InternalEvent::Disconnected);
}
thread::sleep(std::time::Duration::from_secs(2));
}
}
fn tcp_log_worker(shared_config: Arc<Mutex<ConnectionConfig>>, tx_events: Sender<InternalEvent>) {
let mut current_version = 0;
let mut current_addr = String::new();
loop {
{ let config = shared_config.lock().unwrap(); if config.version != current_version { current_version = config.version; current_addr = format!("{}:{}", config.ip, config.log_port); } }
if current_addr.is_empty() || current_addr.starts_with(":") { thread::sleep(std::time::Duration::from_secs(1)); continue; }
if let Ok(stream) = TcpStream::connect(&current_addr) {
let mut reader = BufReader::new(stream);
let mut line = String::new();
while reader.read_line(&mut line).is_ok() {
if shared_config.lock().unwrap().version != current_version { break; }
let trimmed = line.trim();
if trimmed.starts_with("LOG ") {
let parts: Vec<&str> = trimmed[4..].splitn(2, ' ').collect();
if parts.len() == 2 { let _ = tx_events.send(InternalEvent::Log(LogEntry { time: Local::now().format("%H:%M:%S%.3f").to_string(), level: parts[0].to_string(), message: parts[1].to_string() })); }
}
line.clear();
}
}
thread::sleep(std::time::Duration::from_secs(2));
}
}
fn udp_worker(shared_config: Arc<Mutex<ConnectionConfig>>, id_to_meta: Arc<Mutex<HashMap<u32, SignalMetadata>>>, traced_data: Arc<Mutex<HashMap<String, TraceData>>>, tx_events: Sender<InternalEvent>) {
let mut current_version = 0;
let mut socket: Option<UdpSocket> = None;
let mut last_seq: Option<u32> = None;
loop {
let (ver, port) = { let config = shared_config.lock().unwrap(); (config.version, config.udp_port.clone()) };
if ver != current_version || socket.is_none() {
current_version = ver;
if port.is_empty() { socket = None; continue; }
let port_num: u16 = port.parse().unwrap_or(8081);
let s = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP)).ok();
let mut bound = false;
if let Some(sock) = s {
let _ = sock.set_reuse_address(true);
#[cfg(all(unix, not(target_os = "solaris"), not(target_os = "illumos")))]
let _ = sock.set_reuse_port(true);
let _ = sock.set_recv_buffer_size(10 * 1024 * 1024);
let addr = format!("0.0.0.0:{}", port_num).parse::<std::net::SocketAddr>().unwrap();
if sock.bind(&addr.into()).is_ok() { socket = Some(sock.into()); bound = true; }
}
if !bound { thread::sleep(std::time::Duration::from_secs(5)); continue; }
let _ = socket.as_ref().unwrap().set_read_timeout(Some(std::time::Duration::from_millis(500)));
last_seq = None;
}
let s = if let Some(sock) = socket.as_ref() { sock } else { thread::sleep(std::time::Duration::from_secs(1)); continue; };
let mut buf = [0u8; 4096];
let mut total_packets = 0u64;
loop {
if shared_config.lock().unwrap().version != current_version { break; }
if let Ok(n) = s.recv(&mut buf) {
total_packets += 1;
if (total_packets % 500) == 0 { let _ = tx_events.send(InternalEvent::UdpStats(total_packets)); }
if n < 20 { continue; }
let mut magic_buf = [0u8; 4]; magic_buf.copy_from_slice(&buf[0..4]);
if u32::from_le_bytes(magic_buf) != 0xDA7A57AD { continue; }
let mut seq_buf = [0u8; 4]; seq_buf.copy_from_slice(&buf[4..8]);
let seq = u32::from_le_bytes(seq_buf);
if let Some(last) = last_seq { if seq != last + 1 && seq > last { let _ = tx_events.send(InternalEvent::UdpDropped(seq - last - 1)); } }
last_seq = Some(seq);
let count = u32::from_le_bytes(buf[16..20].try_into().unwrap());
let now = APP_START_TIME.elapsed().as_secs_f64();
let mut offset = 20;
let mut local_updates: HashMap<String, Vec<[f64; 2]>> = HashMap::new();
let mut last_values: HashMap<String, f64> = HashMap::new();
let metas = id_to_meta.lock().unwrap();
for _ in 0..count {
if offset + 8 > n { break; }
let id = u32::from_le_bytes(buf[offset..offset+4].try_into().unwrap());
let size = u32::from_le_bytes(buf[offset+4..offset+8].try_into().unwrap());
offset += 8;
if offset + size as usize > n { break; }
let data_slice = &buf[offset..offset + size as usize];
if let Some(meta) = metas.get(&id) {
let t = meta.sig_type.as_str();
let val = match size {
1 => { if t.contains('u') { data_slice[0] as f64 } else { (data_slice[0] as i8) as f64 } },
2 => { let b = data_slice[0..2].try_into().unwrap(); if t.contains('u') { u16::from_le_bytes(b) as f64 } else { i16::from_le_bytes(b) as f64 } },
4 => { let b = data_slice[0..4].try_into().unwrap(); if t.contains("float") { f32::from_le_bytes(b) as f64 } else if t.contains('u') { u32::from_le_bytes(b) as f64 } else { i32::from_le_bytes(b) as f64 } },
8 => { let b = data_slice[0..8].try_into().unwrap(); if t.contains("float") { f64::from_le_bytes(b) } else if t.contains('u') { u64::from_le_bytes(b) as f64 } else { i64::from_le_bytes(b) as f64 } },
_ => 0.0,
};
for name in &meta.names { local_updates.entry(name.clone()).or_default().push([now, val]); last_values.insert(name.clone(), val); }
}
offset += size as usize;
}
drop(metas);
if !local_updates.is_empty() {
let mut data_map = traced_data.lock().unwrap();
for (name, new_points) in local_updates {
if let Some(entry) = data_map.get_mut(&name) {
for point in new_points { entry.values.push_back(point); }
if let Some(lv) = last_values.get(&name) { entry.last_value = *lv; }
while entry.values.len() > 10000 { entry.values.pop_front(); }
}
}
}
}
}
}
}
impl eframe::App for MarteDebugApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
while let Ok(event) = self.rx_events.try_recv() {
match event {
InternalEvent::Log(log) => { if !self.log_filters.paused { self.logs.push_back(log); if self.logs.len() > 2000 { self.logs.pop_front(); } } }
InternalEvent::Discovery(signals) => { let mut metas = self.id_to_meta.lock().unwrap(); metas.clear(); for s in &signals { let meta = metas.entry(s.id).or_insert_with(|| SignalMetadata { names: Vec::new(), sig_type: s.sig_type.clone() }); if !meta.names.contains(&s.name) { meta.names.push(s.name.clone()); } } }
InternalEvent::Tree(tree) => { self.app_tree = Some(tree); }
InternalEvent::NodeInfo(info) => { self.node_info = info; }
InternalEvent::TraceRequested(name) => { let mut data_map = self.traced_signals.lock().unwrap(); data_map.entry(name).or_insert_with(|| TraceData { values: VecDeque::with_capacity(10000), last_value: 0.0 }); }
InternalEvent::ClearTrace(name) => { let mut data_map = self.traced_signals.lock().unwrap(); data_map.remove(&name); for plot in &mut self.plots { plot.signals.retain(|s| s.source_name != name); } }
InternalEvent::UdpStats(count) => { self.udp_packets = count; }
InternalEvent::UdpDropped(dropped) => { self.udp_dropped += dropped as u64; }
InternalEvent::Connected => { self.connected = true; let _ = self.tx_cmd.send("TREE".to_string()); let _ = self.tx_cmd.send("DISCOVER".to_string()); }
InternalEvent::Disconnected => { self.connected = false; }
InternalEvent::InternalLog(msg) => { self.logs.push_back(LogEntry { time: Local::now().format("%H:%M:%S").to_string(), level: "GUI_ERROR".to_string(), message: msg }); }
InternalEvent::CommandResponse(resp) => { self.logs.push_back(LogEntry { time: Local::now().format("%H:%M:%S").to_string(), level: "CMD_RESP".to_string(), message: resp }); }
}
}
if let Some(dragged_name) = ctx.data_mut(|d| d.get_temp::<String>(egui::Id::new("drag_signal"))) {
egui::Area::new(egui::Id::new("drag_ghost")).fixed_pos(ctx.input(|i| i.pointer.hover_pos().unwrap_or(egui::Pos2::ZERO))).order(egui::Order::Tooltip).show(ctx, |ui| { ui.group(|ui| { ui.label(format!("📈 {}", dragged_name)); }); });
}
if let Some(dialog) = &mut self.forcing_dialog {
let mut close = false;
egui::Window::new("Force Signal").show(ctx, |ui| { ui.label(&dialog.signal_path); ui.text_edit_singleline(&mut dialog.value); ui.horizontal(|ui| { if ui.button("Apply").clicked() { let _ = self.tx_cmd.send(format!("FORCE {} {}", dialog.signal_path, dialog.value)); self.forced_signals.insert(dialog.signal_path.clone(), dialog.value.clone()); close = true; } if ui.button("Cancel").clicked() { close = true; } }); });
if close { self.forcing_dialog = None; }
}
if let Some((p_idx, s_idx)) = self.style_editor {
let mut close = false;
egui::Window::new("Signal Style").show(ctx, |ui| {
if let Some(plot) = self.plots.get_mut(p_idx) {
if let Some(sig) = plot.signals.get_mut(s_idx) {
ui.horizontal(|ui| { ui.label("Label:"); ui.text_edit_singleline(&mut sig.label); });
ui.horizontal(|ui| { ui.label("Unit:"); ui.text_edit_singleline(&mut sig.unit); });
ui.horizontal(|ui| { ui.label("Color:"); let mut color = sig.color.to_array(); if ui.color_edit_button_srgba_unmultiplied(&mut color).changed() { sig.color = egui::Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]); } });
ui.horizontal(|ui| { ui.label("Gain:"); ui.add(egui::DragValue::new(&mut sig.gain).speed(0.1)); });
ui.horizontal(|ui| { ui.label("Offset:"); ui.add(egui::DragValue::new(&mut sig.offset).speed(1.0)); });
if ui.button("Close").clicked() { close = true; }
}
}
});
if close { self.style_editor = None; }
}
egui::TopBottomPanel::top("top").show(ctx, |ui| {
ui.horizontal(|ui| {
ui.toggle_value(&mut self.show_left_panel, "🗂 Tree");
ui.toggle_value(&mut self.show_right_panel, "📊 Signals");
ui.toggle_value(&mut self.show_bottom_panel, "📜 Logs");
ui.separator();
if ui.button(" Plot").clicked() { self.plots.push(PlotInstance { id: format!("Plot {}", self.plots.len()+1), plot_type: PlotType::Normal, signals: Vec::new(), auto_bounds: true }); }
ui.separator();
let (btn_text, btn_color) = if self.is_breaking { ("▶ Resume", egui::Color32::GREEN) } else { ("⏸ Pause", egui::Color32::YELLOW) };
if ui.button(egui::RichText::new(btn_text).color(btn_color)).clicked() { self.is_breaking = !self.is_breaking; let _ = self.tx_cmd.send(if self.is_breaking { "PAUSE".to_string() } else { "RESUME".to_string() }); }
ui.separator();
ui.menu_button("🔌 Connection", |ui| {
egui::Grid::new("conn_grid").num_columns(2).show(ui, |ui| {
ui.label("IP:"); ui.text_edit_singleline(&mut self.config.ip); ui.end_row();
ui.label("Control:"); ui.text_edit_singleline(&mut self.config.tcp_port); ui.end_row();
ui.label("Telemetry:"); ui.text_edit_singleline(&mut self.config.udp_port); ui.end_row();
ui.label("Logs:"); ui.text_edit_singleline(&mut self.config.log_port); ui.end_row();
});
ui.separator();
if ui.button("🔄 Apply & Reconnect").clicked() { self.config.version += 1; *self.shared_config.lock().unwrap() = self.config.clone(); ui.close_menu(); }
if ui.button("❌ Disconnect").clicked() { self.config.version += 1; let mut cfg = self.config.clone(); cfg.ip = "".to_string(); *self.shared_config.lock().unwrap() = cfg; ui.close_menu(); }
});
let status_color = if self.connected { egui::Color32::GREEN } else { egui::Color32::RED };
ui.label(egui::RichText::new(if self.connected { "● Online" } else { "○ Offline" }).color(status_color));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label(format!("UDP: OK[{}] DROP[{}]", self.udp_packets, self.udp_dropped)); });
});
});
if self.show_left_panel { egui::SidePanel::left("left").resizable(true).width_range(200.0..=500.0).show(ctx, |ui| { egui::ScrollArea::vertical().show(ui, |ui| { if let Some(tree) = self.app_tree.clone() { self.render_tree(ui, &tree, "".to_string()); } }); }); }
if self.show_right_panel {
egui::SidePanel::right("right").resizable(true).width_range(250.0..=400.0).show(ctx, |ui| {
ui.heading("Traced Signals");
let mut names: Vec<_> = { let data_map = self.traced_signals.lock().unwrap(); data_map.keys().cloned().collect() };
names.sort();
egui::ScrollArea::vertical().id_salt("traced_scroll").show(ui, |ui| {
for key in names {
let last_val = { self.traced_signals.lock().unwrap().get(&key).map(|d| d.last_value).unwrap_or(0.0) };
ui.horizontal(|ui| {
let response = ui.add(egui::Label::new(format!("{}: {:.2}", key, last_val)).sense(egui::Sense::drag()));
if response.drag_started() { ctx.data_mut(|d| d.insert_temp(egui::Id::new("drag_signal"), key.clone())); }
if ui.button("").clicked() { let _ = self.tx_cmd.send(format!("TRACE {} 0", key)); let _ = self.internal_tx.send(InternalEvent::ClearTrace(key)); }
});
}
});
ui.separator();
ui.heading("Forced Signals");
for (path, val) in &self.forced_signals { ui.horizontal(|ui| { ui.label(format!("{}: {}", path, val)); if ui.button("").clicked() { let _ = self.tx_cmd.send(format!("UNFORCE {}", path)); } }); }
});
}
if self.show_bottom_panel {
egui::TopBottomPanel::bottom("log_panel").resizable(true).default_height(150.0).show(ctx, |ui| {
ui.horizontal(|ui| {
ui.heading("System Logs"); ui.separator();
ui.checkbox(&mut self.log_filters.show_debug, "Debug"); ui.checkbox(&mut self.log_filters.show_info, "Info"); ui.checkbox(&mut self.log_filters.show_warning, "Warn"); ui.checkbox(&mut self.log_filters.show_error, "Error");
ui.separator();
ui.label("Filter:"); ui.text_edit_singleline(&mut self.log_filters.content_regex);
ui.separator();
ui.toggle_value(&mut self.log_filters.paused, "⏸ Pause");
if ui.button("🗑 Clear").clicked() { self.logs.clear(); }
});
ui.separator();
let regex = if !self.log_filters.content_regex.is_empty() { Regex::new(&self.log_filters.content_regex).ok() } else { None };
egui::ScrollArea::vertical().stick_to_bottom(true).auto_shrink([false, false]).show(ui, |ui| {
for log in &self.logs {
let show = match log.level.as_str() { "Debug" => self.log_filters.show_debug, "Information" => self.log_filters.show_info, "Warning" => self.log_filters.show_warning, "FatalError" | "OSError" | "ParametersError" => self.log_filters.show_error, _ => true };
if !show { continue; }
if let Some(re) = &regex { if !re.is_match(&log.message) && !re.is_match(&log.level) { continue; } }
let color = match log.level.as_str() { "FatalError" | "OSError" | "ParametersError" => egui::Color32::from_rgb(255, 100, 100), "Warning" => egui::Color32::from_rgb(255, 255, 100), "Information" => egui::Color32::from_rgb(100, 255, 100), "Debug" => egui::Color32::from_rgb(100, 100, 255), "GUI_ERROR" => egui::Color32::from_rgb(255, 50, 255), "CMD_RESP" => egui::Color32::from_rgb(255, 255, 255), _ => egui::Color32::WHITE };
ui.horizontal_wrapped(|ui| { ui.label(egui::RichText::new(&log.time).color(egui::Color32::GRAY).monospace()); ui.label(egui::RichText::new(format!("[{}]", log.level)).color(color).strong()); ui.add(egui::Label::new(&log.message).wrap()); });
}
});
});
}
egui::CentralPanel::default().show(ctx, |ui| {
let n_plots = self.plots.len();
if n_plots > 0 {
let plot_height = ui.available_height() / n_plots as f32;
let mut to_remove = None;
let mut current_range = None;
for (p_idx, plot_inst) in self.plots.iter_mut().enumerate() {
ui.group(|ui| {
ui.horizontal(|ui| { ui.label(egui::RichText::new(&plot_inst.id).strong()); ui.selectable_value(&mut plot_inst.plot_type, PlotType::Normal, "Series"); ui.selectable_value(&mut plot_inst.plot_type, PlotType::LogicAnalyzer, "Logic"); if ui.button("🗑").clicked() { to_remove = Some(p_idx); } });
let mut plot = Plot::new(&plot_inst.id).height(plot_height - 40.0).show_axes([true, true]);
if let Some(range) = self.shared_x_range { if !plot_inst.auto_bounds { plot = plot.include_x(range[0]).include_x(range[1]); } }
if plot_inst.auto_bounds { plot = plot.auto_bounds(egui::Vec2b::new(true, true)); }
let plot_resp = plot.show(ui, |plot_ui| {
if !plot_inst.auto_bounds { if let Some(range) = self.shared_x_range { let bounds = plot_ui.plot_bounds(); plot_ui.set_plot_bounds(PlotBounds::from_min_max([range[0], bounds.min()[1]], [range[1], bounds.max()[1]])); } }
let data_map = self.traced_signals.lock().unwrap();
for (s_idx, sig_cfg) in plot_inst.signals.iter().enumerate() {
if let Some(data) = data_map.get(&sig_cfg.source_name) {
let mut points = Vec::new();
for [t, v] in &data.values {
let mut final_v = *v * sig_cfg.gain + sig_cfg.offset;
if plot_inst.plot_type == PlotType::LogicAnalyzer { final_v = (s_idx as f64 * 1.5) + (if final_v > 0.5 { 1.0 } else { 0.0 }); }
points.push([*t, final_v]);
}
plot_ui.line(Line::new(PlotPoints::from(points)).name(&sig_cfg.label).color(sig_cfg.color));
}
}
if p_idx == 0 || current_range.is_none() { let b = plot_ui.plot_bounds(); current_range = Some([b.min()[0], b.max()[0]]); }
});
if plot_resp.response.hovered() && ctx.input(|i| i.pointer.any_released()) {
if let Some(dropped) = ctx.data_mut(|d| d.get_temp::<String>(egui::Id::new("drag_signal"))) {
let color = Self::next_color(plot_inst.signals.len());
plot_inst.signals.push(SignalPlotConfig { source_name: dropped.clone(), label: dropped.clone(), unit: "".to_string(), color, line_style: LineStyle::Solid, marker_type: MarkerType::None, gain: 1.0, offset: 0.0 });
self.logs.push_back(LogEntry { time: Local::now().format("%H:%M:%S").to_string(), level: "GUI".to_string(), message: format!("Dropped {} into plot", dropped) });
ctx.data_mut(|d| d.remove_temp::<String>(egui::Id::new("drag_signal")));
}
}
if plot_resp.response.dragged() || ctx.input(|i| i.pointer.any_click() || i.smooth_scroll_delta.y != 0.0) { if plot_resp.response.hovered() { plot_inst.auto_bounds = false; let b = plot_resp.transform.bounds(); self.shared_x_range = Some([b.min()[0], b.max()[0]]); } }
plot_resp.response.context_menu(|ui| {
if ui.button("🔍 Fit View").clicked() { plot_inst.auto_bounds = true; self.shared_x_range = None; ui.close_menu(); }
ui.separator();
let mut sig_to_remove = None;
for (s_idx, sig) in plot_inst.signals.iter().enumerate() {
ui.horizontal(|ui| { ui.label(&sig.label); if ui.button("🎨").clicked() { self.style_editor = Some((p_idx, s_idx)); ui.close_menu(); } if ui.button("").clicked() { sig_to_remove = Some(s_idx); ui.close_menu(); } });
}
if let Some(idx) = sig_to_remove { plot_inst.signals.remove(idx); }
});
});
}
if let Some(idx) = to_remove { self.plots.remove(idx); }
if let Some(range) = current_range { if self.shared_x_range.is_none() { self.shared_x_range = Some(range); } }
} else { ui.centered_and_justified(|ui| { ui.label("Add a plot panel to begin analysis"); }); }
});
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
fn main() -> Result<(), eframe::Error> {
let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default().with_inner_size([1280.0, 800.0]), ..Default::default() };
eframe::run_native("MARTe2 Debug Explorer", options, Box::new(|cc| Ok(Box::new(MarteDebugApp::new(cc)))))
}