Major functionality implemented (missing reconfig).

This commit is contained in:
Martino Ferrari
2026-04-14 01:40:43 +02:00
parent 96d98dfc3d
commit 3a1ecd3aba
6 changed files with 1173 additions and 107 deletions
+681 -79
View File
@@ -153,6 +153,9 @@ struct PlotInstance {
plot_type: PlotType,
signals: Vec<SignalPlotConfig>,
auto_bounds: bool,
max_points: usize,
follow: bool,
reset_view: bool,
}
#[derive(Clone, PartialEq)]
@@ -198,6 +201,8 @@ enum InternalEvent {
RecordingError(String, String), // SignalName, ErrorMessage
TelemMatched(u32),
ServiceConfig { udp_port: String, log_port: String },
StepStatus { paused: bool, paused_at_gam: String, step_remaining: u32, step_thread: String },
SignalValue { path: String, value_text: String, found: bool },
}
// --- App State ---
@@ -212,6 +217,12 @@ struct MonitorDialog {
period_ms: String,
}
struct BreakDialog {
signal_path: String,
op: String, // ">", "<", "==", ">=", "<=", "!="
threshold: String,
}
struct MessageDialog {
destination: String,
function: String,
@@ -219,6 +230,15 @@ struct MessageDialog {
expect_reply: bool,
}
struct InfoDialog {
path: String,
is_signal: bool,
config_text: String,
is_loading: bool,
value_text: Option<String>,
value_loading: bool,
}
struct LogFilters {
show_debug: bool,
show_info: bool,
@@ -253,6 +273,12 @@ struct MarteDebugApp {
traced_signals: Arc<Mutex<HashMap<String, TraceData>>>,
plots: Vec<PlotInstance>,
forced_signals: HashMap<String, String>,
break_conditions: HashMap<String, (String, f64)>, // signal -> (op, threshold)
break_dialog: Option<BreakDialog>,
step_status: Option<(bool, String, u32)>, // (paused, paused_at_gam, step_remaining)
last_step_poll: std::time::Instant,
step_thread: String,
info_dialog: Option<InfoDialog>,
logs: VecDeque<LogEntry>,
log_filters: LogFilters,
show_left_panel: bool,
@@ -329,8 +355,17 @@ impl MarteDebugApp {
plot_type: PlotType::Normal,
signals: Vec::new(),
auto_bounds: true,
max_points: 5000,
follow: true,
reset_view: false,
}],
forced_signals: HashMap::new(),
break_conditions: HashMap::new(),
break_dialog: None,
step_status: None,
last_step_poll: std::time::Instant::now(),
step_thread: String::new(),
info_dialog: None,
logs: VecDeque::with_capacity(2000),
log_filters: LogFilters {
show_debug: true,
@@ -471,6 +506,22 @@ impl MarteDebugApp {
objects
}
fn get_threads(&self) -> Vec<String> {
let mut threads = Vec::new();
if let Some(tree) = &self.app_tree {
fn collect(item: &TreeItem, out: &mut Vec<String>) {
if item.class == "RealTimeThread" {
out.push(item.name.clone());
}
if let Some(children) = &item.children {
for child in children { collect(child, out); }
}
}
collect(tree, &mut threads);
}
threads
}
fn render_tree(&mut self, ui: &mut egui::Ui, item: &TreeItem, path: String) {
let current_path = if path.is_empty() {
if item.name == "Root" {
@@ -502,6 +553,14 @@ impl MarteDebugApp {
{
self.selected_node = current_path.clone();
let _ = self.tx_cmd.send(format!("INFO {}", current_path));
self.info_dialog = Some(InfoDialog {
path: current_path.clone(),
is_signal: false,
config_text: String::new(),
is_loading: true,
value_text: None,
value_loading: false,
});
}
}
});
@@ -511,16 +570,47 @@ impl MarteDebugApp {
});
} else {
ui.horizontal(|ui| {
if ui
.selectable_label(
self.selected_node == current_path,
format!("{} [{}]", label, item.class),
)
.clicked()
{
let resp = ui.selectable_label(
self.selected_node == current_path,
format!("{} [{}]", label, item.class),
);
if resp.clicked() {
self.selected_node = current_path.clone();
let _ = self.tx_cmd.send(format!("INFO {}", current_path));
}
if resp.double_clicked() {
self.selected_node = current_path.clone();
let _ = self.tx_cmd.send(format!("INFO {}", current_path));
let is_sig = item.class.contains("Signal");
let mut value_loading = false;
if is_sig {
let _ = self.tx_cmd.send(format!("VALUE {}", current_path));
value_loading = true;
}
self.info_dialog = Some(InfoDialog {
path: current_path.clone(),
is_signal: is_sig,
config_text: String::new(),
is_loading: true,
value_text: None,
value_loading,
});
}
if !resp.double_clicked() && !resp.clicked() {
// keep the existing Info button for non-signal leaf nodes
if !item.class.contains("Signal") && 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));
self.info_dialog = Some(InfoDialog {
path: current_path.clone(),
is_signal: false,
config_text: String::new(),
is_loading: true,
value_text: None,
value_loading: false,
});
}
}
if item.class.contains("Signal") {
let elements = item.elements.unwrap_or(1);
if elements > 1 {
@@ -572,6 +662,21 @@ impl MarteDebugApp {
value: "".to_string(),
});
}
if traceable {
let has_break = self.break_conditions.contains_key(&current_path);
let btn_text = if has_break { "🔴 Break*" } else { "🔴 Break" };
if ui.button(btn_text).clicked() {
let (op, thr) = self.break_conditions
.get(&current_path)
.cloned()
.unwrap_or_else(|| (">".to_string(), 0.0));
self.break_dialog = Some(BreakDialog {
signal_path: current_path.clone(),
op,
threshold: thr.to_string(),
});
}
}
}
}
});
@@ -740,6 +845,49 @@ fn tcp_command_worker(
let _ = tx_events_inner
.send(InternalEvent::ConfigResponse(text.to_string()));
json_acc.clear();
} else if trimmed.contains("OK STEP_STATUS") {
in_json = false;
let json_clean = json_acc.split("OK STEP_STATUS").next().unwrap_or("").trim();
let paused = json_clean.contains("\"Paused\": true");
let gam = json_clean.split("\"PausedAtGam\": \"")
.nth(1)
.and_then(|s| s.split('"').next())
.unwrap_or("")
.to_string();
let remaining = json_clean.split("\"StepRemaining\": ")
.nth(1)
.and_then(|s| s.split(',').next())
.and_then(|s| s.trim().parse::<u32>().ok())
.unwrap_or(0);
let step_thread = json_clean.split("\"StepThread\": \"")
.nth(1)
.and_then(|s| s.split('"').next())
.unwrap_or("")
.to_string();
let _ = tx_events_inner.send(InternalEvent::StepStatus {
paused,
paused_at_gam: gam,
step_remaining: remaining,
step_thread,
});
json_acc.clear();
} else if trimmed.contains("OK VALUE") {
in_json = false;
let json_clean = json_acc.split("OK VALUE").next().unwrap_or("").trim();
let found = !json_clean.contains("\"Error\"");
let path = json_clean.split("\"Name\": \"")
.nth(1)
.and_then(|s| s.split('"').next())
.unwrap_or("")
.to_string();
// Value is a quoted string: "Value": "..."
let value_text = json_clean.split("\"Value\": \"")
.nth(1)
.and_then(|s| s.split('"').next())
.unwrap_or("")
.to_string();
let _ = tx_events_inner.send(InternalEvent::SignalValue { path, value_text, found });
json_acc.clear();
}
} else {
if trimmed.starts_with("OK SERVICE_INFO") {
@@ -1158,7 +1306,18 @@ impl eframe::App for MarteDebugApp {
self.app_tree = Some(tree);
}
InternalEvent::NodeInfo(info) => {
self.node_info = info;
self.node_info = info.clone();
if let Some(dialog) = &mut self.info_dialog {
// Try to pretty-print as MARTe2 config if it's a JSON object
let display = if info.trim_start().starts_with('{') {
let converted = convert_config_json(&info);
if converted.is_empty() { info } else { converted }
} else {
info
};
dialog.config_text = display;
dialog.is_loading = false;
}
}
InternalEvent::TraceRequested(name, is_monitored) => {
let mut data_map = self.traced_signals.lock().unwrap();
@@ -1255,6 +1414,20 @@ impl eframe::App for MarteDebugApp {
self.app_config_text = convert_config_json(&text);
}
InternalEvent::TelemMatched(_) => {}
InternalEvent::StepStatus { paused, paused_at_gam, step_remaining, step_thread: _ } => {
self.step_status = Some((paused, paused_at_gam, step_remaining));
if paused {
self.is_breaking = true;
}
}
InternalEvent::SignalValue { path, value_text, found } => {
if let Some(dialog) = &mut self.info_dialog {
if dialog.path == path {
dialog.value_loading = false;
dialog.value_text = if found { Some(value_text) } else { None };
}
}
}
InternalEvent::RecordPathChosen(name, path) => {
let mut data_map = self.traced_signals.lock().unwrap();
if let Some(entry) = data_map.get_mut(&name) {
@@ -1277,6 +1450,24 @@ impl eframe::App for MarteDebugApp {
}
}
// Poll STEP_STATUS: fast while debugging, slow background poll to catch conditional breaks
if self.connected {
let elapsed = self.last_step_poll.elapsed();
let interval = if self.is_breaking {
std::time::Duration::from_millis(500)
} else {
std::time::Duration::from_secs(2)
};
if elapsed >= interval {
let _ = self.tx_cmd.send("STEP_STATUS".to_string());
self.last_step_poll = std::time::Instant::now();
}
if self.is_breaking {
ctx.request_repaint_after(std::time::Duration::from_millis(500));
}
}
if self.scope.enabled {
self.apply_trigger_logic();
}
@@ -1318,6 +1509,59 @@ impl eframe::App for MarteDebugApp {
}
}
if self.break_dialog.is_some() {
let mut dialog = self.break_dialog.take().unwrap();
let mut close = false;
let ops = [">", "<", "==", ">=", "<=", "!="];
egui::Window::new("Set Conditional Break").show(ctx, |ui| {
ui.label(egui::RichText::new(&dialog.signal_path).monospace().strong());
ui.separator();
ui.horizontal(|ui| {
ui.label("Condition:");
egui::ComboBox::from_id_salt("break_op_combo")
.selected_text(&dialog.op)
.width(60.0)
.show_ui(ui, |ui| {
for op in &ops {
ui.selectable_value(&mut dialog.op, op.to_string(), *op);
}
});
ui.text_edit_singleline(&mut dialog.threshold);
});
ui.label(egui::RichText::new(format!(
"Pause RT when: signal {} {}",
dialog.op, dialog.threshold
)).small().color(egui::Color32::GRAY));
ui.separator();
ui.horizontal(|ui| {
if ui.button("✔ Apply").clicked() {
if let Ok(thr) = dialog.threshold.parse::<f64>() {
let _ = self.tx_cmd.send(format!(
"BREAK {} {} {}",
dialog.signal_path, dialog.op, thr
));
self.break_conditions.insert(
dialog.signal_path.clone(),
(dialog.op.clone(), thr),
);
}
close = true;
}
if ui.button("🗑 Clear Break").clicked() {
let _ = self.tx_cmd.send(format!("BREAK {} OFF", dialog.signal_path));
self.break_conditions.remove(&dialog.signal_path);
close = true;
}
if ui.button("Cancel").clicked() {
close = true;
}
});
});
if !close {
self.break_dialog = Some(dialog);
}
}
if let Some(dialog) = &mut self.monitoring_dialog {
let mut close = false;
egui::Window::new("Monitor Signal").show(ctx, |ui| {
@@ -1485,6 +1729,78 @@ impl eframe::App for MarteDebugApp {
}
}
if self.info_dialog.is_some() {
let mut close = false;
let mut send_value_cmd: Option<String> = None;
{
let dialog = self.info_dialog.as_mut().unwrap();
let max_h = ctx.available_rect().height() * 0.75;
let title = format!(" {}", dialog.path);
egui::Window::new(title)
.resizable(true)
.default_width(480.0)
.max_height(max_h)
.show(ctx, |ui| {
// Always-visible close button at top-right
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("✖ Close").clicked() {
close = true;
}
});
});
ui.separator();
if dialog.is_signal {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Value:").color(egui::Color32::GRAY));
if dialog.value_loading {
ui.spinner();
} else {
match &dialog.value_text {
Some(v) => {
ui.label(egui::RichText::new(v.as_str())
.monospace().strong()
.color(egui::Color32::from_rgb(100, 220, 255)));
}
None => {
ui.label(egui::RichText::new("").color(egui::Color32::GRAY));
}
}
}
if ui.small_button("🔄").on_hover_text("Refresh value").clicked() {
send_value_cmd = Some(dialog.path.clone());
dialog.value_loading = true;
}
});
ui.separator();
}
if dialog.is_loading {
ui.spinner();
ui.label(egui::RichText::new("Loading config…").italics().color(egui::Color32::GRAY));
} else if dialog.config_text.is_empty() {
ui.label(egui::RichText::new("No config available").italics().color(egui::Color32::GRAY));
} else {
egui::ScrollArea::both()
.auto_shrink([true, true])
.max_height(max_h - 120.0)
.show(ui, |ui| {
ui.add(
egui::Label::new(
egui::RichText::new(&dialog.config_text).monospace().small()
).selectable(true),
);
});
}
});
}
if let Some(path) = send_value_cmd {
let _ = self.tx_cmd.send(format!("VALUE {}", path));
}
if close {
self.info_dialog = None;
}
}
egui::TopBottomPanel::top("top").show(ctx, |ui| {
ui.horizontal(|ui| {
ui.toggle_value(&mut self.show_left_panel, "🗂 Tree");
@@ -1498,6 +1814,9 @@ impl eframe::App for MarteDebugApp {
plot_type: PlotType::Normal,
signals: Vec::new(),
auto_bounds: true,
max_points: 5000,
follow: true,
reset_view: false,
});
}
ui.separator();
@@ -1516,6 +1835,10 @@ impl eframe::App for MarteDebugApp {
} else {
"RESUME".to_string()
});
if !self.is_breaking {
self.step_status = None;
}
self.last_step_poll = std::time::Instant::now() - std::time::Duration::from_secs(1);
}
ui.separator();
ui.checkbox(&mut self.scope.enabled, "🔭 Scope");
@@ -1682,82 +2005,155 @@ impl eframe::App for MarteDebugApp {
.resizable(true)
.width_range(250.0..=400.0)
.show(ctx, |ui| {
// Debug Controls pane — shown when app is paused / breaking
if self.is_breaking {
let paused = self.step_status.as_ref().map(|(p, _, _)| *p).unwrap_or(false);
let gam = self.step_status.as_ref().map(|(_, g, _)| g.clone()).unwrap_or_default();
let remaining = self.step_status.as_ref().map(|(_, _, r)| *r).unwrap_or(0);
let threads = self.get_threads();
ui.group(|ui| {
ui.label(egui::RichText::new("⏸ Debug Controls").strong().color(egui::Color32::YELLOW));
ui.separator();
if paused {
if gam.is_empty() {
ui.label(egui::RichText::new("Paused (waiting for break)").color(egui::Color32::from_rgb(255, 200, 100)));
} else {
ui.label(egui::RichText::new("Paused at GAM:").color(egui::Color32::GRAY).small());
ui.label(egui::RichText::new(&gam).monospace().strong().color(egui::Color32::from_rgb(255, 180, 80)));
}
} else {
ui.label(egui::RichText::new(format!("Running ({} steps left)", remaining)).color(egui::Color32::from_rgb(100, 220, 100)));
}
if !threads.is_empty() {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Thread:").color(egui::Color32::GRAY).small());
let sel = if self.step_thread.is_empty() { "(all)" } else { &self.step_thread };
egui::ComboBox::from_id_salt("step_thread_combo")
.selected_text(sel)
.width(140.0)
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.step_thread, String::new(), "(all)");
for t in &threads {
ui.selectable_value(&mut self.step_thread, t.clone(), t.as_str());
}
});
});
}
ui.add_space(4.0);
ui.horizontal(|ui| {
let thread_arg = if self.step_thread.is_empty() {
String::new()
} else {
format!(" {}", self.step_thread)
};
if ui.button("Step 1").on_hover_text("Run one output broker cycle, then pause").clicked() {
let _ = self.tx_cmd.send(format!("STEP 1{}", thread_arg));
self.step_status = self.step_status.as_ref().map(|(_, g, _)| (false, g.clone(), 1));
}
if ui.button("Step 5").on_hover_text("Run 5 output broker cycles, then pause").clicked() {
let _ = self.tx_cmd.send(format!("STEP 5{}", thread_arg));
self.step_status = self.step_status.as_ref().map(|(_, g, _)| (false, g.clone(), 5));
}
if ui.button(egui::RichText::new("▶ Resume").color(egui::Color32::GREEN)).clicked() {
let _ = self.tx_cmd.send("RESUME".to_string());
self.is_breaking = false;
self.step_status = None;
}
});
});
ui.separator();
}
ui.heading("Traced Signals");
let mut names: Vec<_> = {
let data_map = self.traced_signals.lock().unwrap();
data_map.keys().cloned().collect()
};
names.sort();
let mut open_info_signal: Option<(String, f64)> = None;
egui::ScrollArea::vertical()
.id_salt("traced_scroll")
.show(ui, |ui| {
for key in names {
let mut data_map = self.traced_signals.lock().unwrap();
if let Some(entry) = data_map.get_mut(&key) {
let last_val = entry.last_value;
let is_recording = entry.recording_tx.is_some();
ui.horizontal(|ui| {
if is_recording {
ui.label(
egui::RichText::new("").color(egui::Color32::RED),
);
}
let response = ui.add(
egui::Label::new(format!("{}: {:.2}", key, last_val))
.sense(
egui::Sense::drag().union(egui::Sense::click()),
),
);
if response.drag_started() {
ctx.data_mut(|d| {
d.insert_temp(
egui::Id::new("drag_signal"),
key.clone(),
)
});
}
response.context_menu(|ui| {
if !is_recording {
if ui.button("⏺ Record to Parquet").clicked() {
let tx = self.internal_tx.clone();
let name_clone = key.clone();
thread::spawn(move || {
if let Some(path) = FileDialog::new()
.add_filter("Parquet", &["parquet"])
.save_file()
{
let _ = tx.send(
InternalEvent::RecordPathChosen(
name_clone,
path.to_string_lossy()
.to_string(),
),
);
}
});
ui.close_menu();
}
} else {
if ui.button("⏹ Stop").clicked() {
entry.recording_tx = None;
ui.close_menu();
}
}
let (last_val, is_recording, is_monitored) = {
let dm = self.traced_signals.lock().unwrap();
if let Some(e) = dm.get(&key) {
(e.last_value, e.recording_tx.is_some(), e.is_monitored)
} else {
continue;
}
};
ui.horizontal(|ui| {
if is_recording {
ui.label(egui::RichText::new("").color(egui::Color32::RED));
}
let response = ui.add(
egui::Label::new(format!("{}: {:.2}", key, last_val))
.sense(egui::Sense::drag().union(egui::Sense::click())),
);
if response.drag_started() {
ctx.data_mut(|d| {
d.insert_temp(egui::Id::new("drag_signal"), key.clone())
});
if ui.button("").clicked() {
if entry.is_monitored {
let _ = self.tx_cmd.send(format!("UNMONITOR SIGNAL {}", key));
} else {
let _ = self.tx_cmd.send(format!("TRACE {} 0", key));
}
if response.double_clicked() {
open_info_signal = Some((key.clone(), last_val));
}
if ui.small_button("").on_hover_text("Show info / last value").clicked() {
open_info_signal = Some((key.clone(), last_val));
}
response.context_menu(|ui| {
if !is_recording {
if ui.button("⏺ Record to Parquet").clicked() {
let tx = self.internal_tx.clone();
let name_clone = key.clone();
thread::spawn(move || {
if let Some(path) = FileDialog::new()
.add_filter("Parquet", &["parquet"])
.save_file()
{
let _ = tx.send(InternalEvent::RecordPathChosen(
name_clone,
path.to_string_lossy().to_string(),
));
}
});
ui.close_menu();
}
} else {
if ui.button("⏹ Stop").clicked() {
let mut dm = self.traced_signals.lock().unwrap();
if let Some(e) = dm.get_mut(&key) {
e.recording_tx = None;
}
ui.close_menu();
}
let _ = self
.internal_tx
.send(InternalEvent::ClearTrace(key.clone()));
}
});
}
if ui.button("").clicked() {
if is_monitored {
let _ = self.tx_cmd.send(format!("UNMONITOR SIGNAL {}", key));
} else {
let _ = self.tx_cmd.send(format!("TRACE {} 0", key));
}
let _ = self.internal_tx.send(InternalEvent::ClearTrace(key.clone()));
}
});
}
});
if let Some((sig_path, last_val)) = open_info_signal {
let _ = self.tx_cmd.send(format!("INFO {}", sig_path));
let _ = self.tx_cmd.send(format!("VALUE {}", sig_path));
self.info_dialog = Some(InfoDialog {
path: sig_path,
is_signal: true,
config_text: String::new(),
is_loading: true,
value_text: None,
value_loading: true,
});
}
ui.separator();
ui.heading("Forced Signals");
let mut to_delete = Vec::new();
@@ -1774,6 +2170,45 @@ impl eframe::App for MarteDebugApp {
self.forced_signals.remove(key);
}
ui.separator();
ui.heading("Breakpoints");
if self.break_conditions.is_empty() {
ui.label(egui::RichText::new("No active breakpoints").italics().color(egui::Color32::GRAY));
} else {
let mut to_clear: Vec<String> = Vec::new();
let mut to_edit: Option<String> = None;
let mut sorted_breaks: Vec<_> = self.break_conditions.iter().collect();
sorted_breaks.sort_by_key(|(k, _)| k.as_str());
for (path, (op, thr)) in &sorted_breaks {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!("🔴 {} {} {}", path, op, thr))
.color(egui::Color32::from_rgb(255, 120, 120))
.monospace()
.small(),
);
if ui.small_button("").on_hover_text("Edit").clicked() {
to_edit = Some(path.to_string());
}
if ui.small_button("").on_hover_text("Clear break").clicked() {
to_clear.push(path.to_string());
}
});
}
for path in to_clear {
let _ = self.tx_cmd.send(format!("BREAK {} OFF", path));
self.break_conditions.remove(&path);
}
if let Some(path) = to_edit {
let (op, thr) = self.break_conditions[&path].clone();
self.break_dialog = Some(BreakDialog {
signal_path: path,
op,
threshold: thr.to_string(),
});
}
}
if self.show_message_history {
ui.separator();
ui.horizontal(|ui| {
@@ -1973,16 +2408,34 @@ impl eframe::App for MarteDebugApp {
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",
);
ui.selectable_value(&mut plot_inst.plot_type, PlotType::Normal, "Series");
ui.selectable_value(&mut plot_inst.plot_type, PlotType::LogicAnalyzer, "Logic");
ui.separator();
let follow_text = if plot_inst.follow {
egui::RichText::new("▶ Follow").color(egui::Color32::from_rgb(100, 220, 100))
} else {
egui::RichText::new("▶ Follow").color(egui::Color32::GRAY)
};
if ui.toggle_value(&mut plot_inst.follow, follow_text)
.on_hover_text("Follow latest data (auto-scroll X axis)")
.changed()
{
if plot_inst.follow {
plot_inst.auto_bounds = true;
self.shared_x_range = None;
}
}
if ui.button("↺ Reset").on_hover_text("Reset view to fit all data").clicked() {
plot_inst.auto_bounds = true;
plot_inst.follow = true;
plot_inst.reset_view = true;
self.shared_x_range = None;
}
ui.separator();
ui.label(egui::RichText::new("Pts:").color(egui::Color32::GRAY).small());
ui.add(egui::DragValue::new(&mut plot_inst.max_points).range(100..=100000).speed(100.0))
.on_hover_text("Max points per line (lower = faster rendering)");
ui.separator();
if ui.button("🗑").clicked() {
to_remove = Some(p_idx);
}
@@ -2039,7 +2492,47 @@ impl eframe::App for MarteDebugApp {
}
}
// Pre-compute fit bounds if a reset was requested (reset_view flag).
// Must be done before plot.show() so we can call set_plot_bounds inside.
let fit_bounds: Option<PlotBounds> = if plot_inst.reset_view {
let mut min_t = f64::INFINITY;
let mut max_t = f64::NEG_INFINITY;
let mut min_v = f64::INFINITY;
let mut max_v = f64::NEG_INFINITY;
let max_pts = plot_inst.max_points;
for (s_idx, sig_cfg) in plot_inst.signals.iter().enumerate() {
if let Some(data) = data_map.get(&sig_cfg.source_name) {
for [t, v] in data.values.iter().rev().take(max_pts) {
let mut fv = *v * sig_cfg.gain + sig_cfg.offset;
if plot_inst.plot_type == PlotType::LogicAnalyzer {
fv = (s_idx as f64 * 1.5) + (if fv > 0.5 { 1.0 } else { 0.0 });
}
if *t < min_t { min_t = *t; }
if *t > max_t { max_t = *t; }
if fv < min_v { min_v = fv; }
if fv > max_v { max_v = fv; }
}
}
}
if min_t.is_finite() && max_t.is_finite() && min_v.is_finite() && max_v.is_finite() {
let pad_t = (max_t - min_t).abs() * 0.02 + 0.01;
let pad_v = (max_v - min_v).abs() * 0.05 + 0.01;
Some(PlotBounds::from_min_max(
[min_t - pad_t, min_v - pad_v],
[max_t + pad_t, max_v + pad_v],
))
} else {
None
}
} else {
None
};
let plot_resp = plot.show(ui, |plot_ui| {
if let Some(bounds) = fit_bounds {
plot_ui.set_plot_bounds(bounds);
plot_inst.reset_view = false;
}
if !self.scope.enabled && !plot_inst.auto_bounds {
if let Some(range) = self.shared_x_range {
let bounds = plot_ui.plot_bounds();
@@ -2060,10 +2553,11 @@ impl eframe::App for MarteDebugApp {
);
}
let max_pts = plot_inst.max_points;
for (s_idx, sig_cfg) in plot_inst.signals.iter().enumerate() {
if let Some(data) = data_map.get(&sig_cfg.source_name) {
let points_iter =
data.values.iter().rev().take(5000).rev().map(|[t, v]| {
data.values.iter().rev().take(max_pts).rev().map(|[t, v]| {
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)
@@ -2110,13 +2604,20 @@ impl eframe::App for MarteDebugApp {
{
if plot_resp.response.hovered() {
plot_inst.auto_bounds = false;
plot_inst.follow = false;
let b = plot_resp.transform.bounds();
self.shared_x_range = Some([b.min()[0], b.max()[0]]);
}
}
// Re-apply follow on each frame when follow is active
if plot_inst.follow && !self.scope.enabled {
plot_inst.auto_bounds = true;
}
plot_resp.response.context_menu(|ui| {
if ui.button("🔍 Fit View").clicked() {
plot_inst.auto_bounds = true;
plot_inst.follow = true;
plot_inst.reset_view = true;
self.shared_x_range = None;
ui.close_menu();
}
@@ -2394,6 +2895,107 @@ mod tests {
// ExportData() does not re-emit them after ConfigureApplication().
assert_classes_match(&expected_tree, &actual_tree, "");
}
// ----- Unit tests for STEP_STATUS / VALUE JSON parsing -----
// These mirror the parsing logic inside tcp_command_worker's reader thread.
fn parse_step_status(json: &str) -> (bool, String, u32, String) {
let paused = json.contains("\"Paused\": true");
let gam = json.split("\"PausedAtGam\": \"")
.nth(1)
.and_then(|s| s.split('"').next())
.unwrap_or("")
.to_string();
let remaining = json.split("\"StepRemaining\": ")
.nth(1)
.and_then(|s| s.split(',').next())
.and_then(|s| s.trim().parse::<u32>().ok())
.unwrap_or(0);
let thread = json.split("\"StepThread\": \"")
.nth(1)
.and_then(|s| s.split('"').next())
.unwrap_or("")
.to_string();
(paused, gam, remaining, thread)
}
fn parse_value_response(json: &str) -> (bool, String, String) {
let found = !json.contains("\"Error\"");
let path = json.split("\"Name\": \"")
.nth(1)
.and_then(|s| s.split('"').next())
.unwrap_or("")
.to_string();
// Value is a quoted string: "Value": "..."
let value_text = json.split("\"Value\": \"")
.nth(1)
.and_then(|s| s.split('"').next())
.unwrap_or("")
.to_string();
(found, path, value_text)
}
#[test]
fn test_step_status_paused_with_thread() {
let json = r#"{"Paused": true, "PausedAtGam": "App.Functions.GAM2", "StepRemaining": 0, "StepThread": "Thread1"}"#;
let (paused, gam, remaining, thread) = parse_step_status(json);
assert!(paused);
assert_eq!(gam, "App.Functions.GAM2");
assert_eq!(remaining, 0);
assert_eq!(thread, "Thread1");
}
#[test]
fn test_step_status_running_no_thread() {
let json = r#"{"Paused": false, "PausedAtGam": "", "StepRemaining": 3, "StepThread": ""}"#;
let (paused, gam, remaining, thread) = parse_step_status(json);
assert!(!paused);
assert_eq!(gam, "");
assert_eq!(remaining, 3);
assert_eq!(thread, "");
}
#[test]
fn test_step_status_step_remaining_not_confused_with_thread_port() {
// "StepRemaining" must not accidentally parse digits from "StepThread"
let json = r#"{"Paused": false, "PausedAtGam": "", "StepRemaining": 7, "StepThread": "RT1"}"#;
let (_, _, remaining, thread) = parse_step_status(json);
assert_eq!(remaining, 7);
assert_eq!(thread, "RT1");
}
#[test]
fn test_value_response_found() {
let json = r#"{"Name": "App.Data.DDB.Counter", "Value": "42.5", "Elements": 1}"#;
let (found, path, value_text) = parse_value_response(json);
assert!(found);
assert_eq!(path, "App.Data.DDB.Counter");
assert_eq!(value_text, "42.5");
}
#[test]
fn test_value_response_not_found() {
let json = r#"{"Error": "Signal not found: BadSignal"}"#;
let (found, _path, _value_text) = parse_value_response(json);
assert!(!found);
}
#[test]
fn test_value_response_array() {
let json = r#"{"Name": "App.Data.DDB.Vec", "Value": "1, 2, 3", "Elements": 3}"#;
let (found, path, value_text) = parse_value_response(json);
assert!(found);
assert_eq!(path, "App.Data.DDB.Vec");
assert_eq!(value_text, "1, 2, 3");
}
#[test]
fn test_value_response_negative() {
let json = r#"{"Name": "App.Data.DDB.Err", "Value": "-3.14159", "Elements": 1}"#;
let (found, _, value_text) = parse_value_response(json);
assert!(found);
assert_eq!(value_text, "-3.14159");
}
}
fn main() -> Result<(), eframe::Error> {