1use super::panel_trait::{Panel, PanelState};
2use crate::data::data::LivePlotData;
3use crate::data::scope::ScopeData;
4use crate::data::traces::TraceRef;
5use crate::data::traces::TracesCollection;
6use crate::data::triggers::{Trigger, TriggerSlope};
7use crate::panels::trace_look_ui::render_trace_look_editor;
8use egui::Ui;
9use egui_plot::{HLine, Points, VLine};
10use std::collections::HashMap;
11
12pub struct TriggersPanel {
13 pub state: PanelState,
14 pub triggers: HashMap<String, Trigger>,
15 pub builder: Option<Trigger>,
16 pub editing: Option<String>,
17 pub pick_level_pending: bool,
19}
20
21impl Default for TriggersPanel {
22 fn default() -> Self {
23 let mut panel = Self {
24 state: PanelState::new("Triggers", "🔔"),
25 triggers: HashMap::new(),
26 builder: None,
27 editing: None,
28 pick_level_pending: false,
29 };
30 let mut t = Trigger::default();
32 t.name = "Trigger".to_string();
33 t.enabled = false;
34 panel.triggers.insert(t.name.clone(), t);
35 panel
36 }
37}
38
39impl Panel for TriggersPanel {
40 fn state(&self) -> &PanelState {
41 &self.state
42 }
43
44 fn state_mut(&mut self) -> &mut PanelState {
45 &mut self.state
46 }
47
48 fn hotkey_name(&self) -> Option<crate::data::hotkeys::HotkeyName> {
49 Some(crate::data::hotkeys::HotkeyName::Triggers)
50 }
51
52 fn render_menu(
53 &mut self,
54 ui: &mut Ui,
55 _data: &mut LivePlotData<'_>,
56 collapsed: bool,
57 tooltip: &str,
58 ) {
59 let label = if collapsed {
60 self.icon_only()
61 .map(|s| s.to_string())
62 .unwrap_or_else(|| self.title().to_string())
63 } else {
64 self.title_and_icon()
65 };
66 let mr = ui.menu_button(label, |ui| {
67 if ui.button("Show Triggers").clicked() {
68 let st = self.state_mut();
69 st.visible = true;
70 st.request_focus = true;
71 ui.close();
72 }
73
74 ui.separator();
75
76 if ui.button("New").clicked() {
77 let mut t = crate::data::triggers::Trigger::default();
78 let idx = self.triggers.len() + 1;
79 t.name = format!("Trigger{}", idx);
80 t.enabled = false;
81 self.triggers.insert(t.name.clone(), t);
82 let st = self.state_mut();
83 st.visible = true;
84 st.detached = false;
85 st.request_docket = true;
86 ui.close();
87 }
88 if ui.button("Start all").clicked() {
89 for (_n, trig) in self.triggers.iter_mut() {
90 trig.start();
91 }
92 ui.close();
93 }
94 if ui.button("Stop all").clicked() {
95 for (_n, trig) in self.triggers.iter_mut() {
96 trig.stop();
97 }
98 ui.close();
99 }
100 if ui.button("Reset all").clicked() {
101 for (_n, trig) in self.triggers.iter_mut() {
102 trig.reset_runtime_state();
103 }
104 ui.close();
105 }
106 });
107 if !tooltip.is_empty() {
108 mr.response.on_hover_text(tooltip);
109 }
110 }
111
112 fn clear_all(&mut self) {
113 for (_name, trig) in self.triggers.iter_mut() {
115 trig.enabled = false;
116 trig.reset_runtime_state();
117 }
118 self.builder = None;
119 self.editing = None;
120 }
121
122 fn draw(
123 &mut self,
124 plot_ui: &mut egui_plot::PlotUi,
125 scope: &ScopeData,
126 traces: &TracesCollection,
127 ) {
128 if self.triggers.is_empty() {
129 return;
130 }
131 let bounds = plot_ui.plot_bounds();
132 let xr = bounds.range_x();
133 let xmin = *xr.start();
134 let xmax = *xr.end();
135
136 for (name, trig) in self.triggers.iter() {
137 if !trig.enabled {
138 continue;
139 }
140 let Some(tr) = traces.get_trace(&trig.target) else {
141 continue;
142 };
143 if !tr.look.visible {
144 continue;
145 }
146
147 let color = trig.look.color;
148 let mut width = trig.look.width.max(0.1);
149 let style = trig.look.style;
150
151 let y_lin = trig.level + tr.offset;
153 let y_plot = if scope.y_axis.log_scale {
154 if y_lin > 0.0 {
155 y_lin.log10()
156 } else {
157 f64::NAN
158 }
159 } else {
160 y_lin
161 };
162 if y_plot.is_finite() {
163 let info = trig.get_info(&scope.y_axis);
165 let label = if scope.show_info_in_legend {
166 format!("{} — {}", trig.name, info)
167 } else {
168 trig.name.clone()
169 };
170 let h = HLine::new(label, y_plot)
171 .color(color)
172 .width(width)
173 .style(style);
174 plot_ui.hline(h);
175 }
176
177 if let Some(t) = trig.last_trigger_time() {
179 if t >= xmin && t <= xmax {
180 if self.editing.as_deref() == Some(name) {
182 width *= 1.4;
183 }
184 let label = trig.name.clone();
185 if trig.look.show_points {
186 if y_plot.is_finite() {
188 let mut radius = trig.look.point_size;
189 if self.editing.as_deref() == Some(name) {
190 radius *= 1.2;
191 }
192 let p = Points::new(label, vec![[t, y_plot]])
193 .radius(radius)
194 .shape(trig.look.marker)
195 .color(color);
196 plot_ui.points(p);
197 }
198 } else {
199 let v = VLine::new(label, t).color(color).width(width).style(style);
201 plot_ui.vline(v);
202 }
203 }
204 }
205 }
206 }
207
208 fn update_data(&mut self, data: &mut LivePlotData<'_>) {
209 if data.pending_requests.clear_triggers {
210 self.clear_all();
211 data.pending_requests.clear_triggers = false;
212 }
213
214 if self.pick_level_pending {
216 for scope in data.scope_data.iter_mut() {
217 if let Some(point) = scope.clicked_point.take() {
218 if let Some(builder) = &mut self.builder {
219 builder.level = point[1];
220 }
221 self.pick_level_pending = false;
222 scope.measurement_active = false;
223 break;
224 }
225 }
226 }
227
228 {
229 let scope_ids: Vec<usize> = data.scope_data.iter().map(|scope| (**scope).id).collect();
230 for scope_id in scope_ids {
231 for (_name, tr) in self.triggers.iter_mut() {
232 if tr.single_shot && tr.is_triggered() {
234 continue;
235 }
236 if tr.check_trigger(data) && tr.is_triggered() {
237 if let Some(scope) = data.scope_by_id_mut(scope_id) {
238 let tr_time = tr.last_trigger_time().unwrap();
239 let time_window = scope.x_axis.bounds.1 - scope.x_axis.bounds.0;
240
241 let tr_pos = tr.trigger_position;
242 scope.x_axis.bounds = (
243 tr_time - time_window * tr_pos,
244 tr_time + time_window * (1.0 - tr_pos),
245 );
246 }
247 }
248 }
249 }
250 }
251 }
252
253 fn render_panel(&mut self, ui: &mut Ui, data: &mut LivePlotData<'_>) {
254 ui.label("Trigger when a trace crosses a level; optionally pause after N samples.");
255
256 ui.separator();
257
258 ui.horizontal(|ui| {
260 if ui
261 .button("♻ Reset all")
262 .on_hover_text("Clear last trigger state for all triggers")
263 .clicked()
264 {
265 data.resume_all();
267 for (_name, tr) in self.triggers.iter_mut() {
268 tr.reset();
269 }
270 }
271 if ui
272 .button("▶ Start all")
273 .on_hover_text("Enable and start all triggers")
274 .clicked()
275 {
276 data.resume_all();
278 for (_name, tr) in self.triggers.iter_mut() {
279 tr.enabled = true;
280 tr.start();
281 }
282 }
283 });
284 ui.add_space(6.0);
285
286 let mut removals: Vec<String> = Vec::new();
288 for (name, tr) in self.triggers.iter_mut() {
289 let name_str = name.clone();
290 let mut to_remove = false;
291 let scope_axes = data
292 .scope_containing_trace(&tr.target)
293 .map(|scope| (scope.x_axis.clone(), scope.y_axis.clone()));
294
295 let row = ui.horizontal(|ui| {
297 ui.checkbox(&mut tr.enabled, "");
299
300 let name_resp =
302 ui.add(egui::Label::new(tr.name.clone()).sense(egui::Sense::click()));
303 let info = scope_axes
306 .as_ref()
307 .map(|(_, y_axis)| tr.get_info(y_axis))
308 .unwrap_or_default();
309 let info_resp = ui.add(egui::Label::new(info).sense(egui::Sense::click()));
310 if name_resp.clicked() || info_resp.clicked() {
311 let mut t = Trigger::default();
313 t.name = tr.name.clone();
314 t.target = TraceRef(tr.target.0.clone());
315 t.enabled = tr.enabled;
316 t.level = tr.level;
317 t.slope = match tr.slope {
318 TriggerSlope::Rising => TriggerSlope::Rising,
319 TriggerSlope::Falling => TriggerSlope::Falling,
320 TriggerSlope::Any => TriggerSlope::Any,
321 };
322 t.single_shot = tr.single_shot;
323 t.trigger_position = tr.trigger_position;
324 t.holdoff_secs = tr.holdoff_secs;
325 t.look = tr.look.clone();
326 self.builder = Some(t);
327 self.editing = Some(name_str.clone());
328 }
329 if name_resp.hovered() || info_resp.hovered() {
330 if !tr.target.0.is_empty() {
332 data.traces.hover_trace = Some(tr.target.clone());
333 }
334 }
335
336 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
337 if ui.button("🗑 Remove").clicked() {
338 to_remove = true;
339 }
340 });
341 });
342
343 if row.response.hovered() {
345 if !tr.target.0.is_empty() {
346 data.traces.hover_trace = Some(tr.target.clone());
347 }
348 }
349
350 if to_remove {
352 removals.push(name_str.clone());
353 }
354
355 let mut do_reset = false;
357 let mut toggle_start: Option<bool> = None; let (last_text, last_exists) = if let Some(t) = tr.last_trigger_time() {
359 let start_fmt = if let Some((x_axis, _)) = scope_axes.as_ref() {
361 x_axis.format_value(t, 4, 1.0)
362 } else {
363 format!("{:.4}", t)
364 };
365 (format!("Last: {}", start_fmt), true)
366 } else {
367 (String::from("Last: –"), false)
368 };
369 let is_active = tr.is_active();
370 let enabled_flag = tr.enabled;
371
372 ui.horizontal(|ui| {
373 ui.label(last_text);
374 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
375 if ui
376 .add_enabled(last_exists, egui::Button::new("↺ Reset"))
377 .clicked()
378 {
379 do_reset = true;
380 }
381 if is_active {
382 if ui
383 .add_enabled(enabled_flag, egui::Button::new("⏹ Stop"))
384 .clicked()
385 {
386 toggle_start = Some(false);
387 }
388 } else {
389 if ui
390 .add_enabled(enabled_flag, egui::Button::new("▶ Start"))
391 .clicked()
392 {
393 toggle_start = Some(true);
394 }
395 }
396 });
397 });
398
399 if do_reset {
400 if tr.is_triggered() {
401 data.resume_all();
402 }
403 tr.reset();
404 }
405 if let Some(start) = toggle_start {
406 if start {
407 data.resume_all();
408 tr.start();
409 } else {
410 tr.stop();
411 }
412 }
413 }
414
415 if !removals.is_empty() {
417 for n in removals {
418 self.triggers.remove(&n);
419 if self.editing.as_deref() == Some(&n) {
420 self.builder = None;
421 self.editing = None;
422 }
423 }
424 }
425
426 ui.add_space(6.0);
428 let new_clicked = ui
429 .add_sized([ui.available_width(), 24.0], egui::Button::new("➕ New"))
430 .on_hover_text("Create a new trigger")
431 .clicked();
432 if new_clicked {
433 self.builder = Some(Trigger::default());
434 self.editing = None;
435 }
436
437 if let Some(builder) = &mut self.builder {
439 ui.add_space(12.0);
440 ui.separator();
441 if self.editing.is_some() {
442 ui.strong("Edit trigger");
443 } else {
444 ui.strong("New trigger");
445 }
446 ui.add_space(3.0);
447
448 let duplicate_name = self.triggers.contains_key(&builder.name)
450 && self.editing.as_deref() != Some(builder.name.as_str());
451
452 ui.horizontal(|ui| {
453 ui.label("Name");
454 if duplicate_name {
455 egui::Frame::default()
456 .stroke(egui::Stroke::new(1.5, egui::Color32::RED))
457 .show(ui, |ui| {
458 let resp = ui.add(egui::TextEdit::singleline(&mut builder.name));
459 let _resp = resp.on_hover_text(
460 "A trigger with this name already exists. Please choose another.",
461 );
462 });
463 } else {
464 let resp = ui.add(egui::TextEdit::singleline(&mut builder.name));
465 let _resp = resp.on_hover_text("Enter a unique name for this trigger");
466 }
467 });
468
469 let trace_names = data.traces.all_trace_names();
471 let mut target_idx = trace_names
472 .iter()
473 .position(|n| n == &builder.target)
474 .unwrap_or(0);
475 egui::ComboBox::from_label("Trace")
476 .selected_text(
477 trace_names
478 .get(target_idx)
479 .map(|t| t.to_string())
480 .unwrap_or_default(),
481 )
482 .show_ui(ui, |ui| {
483 for (i, n) in trace_names.iter().enumerate() {
484 if ui.selectable_label(target_idx == i, n.as_str()).clicked() {
485 target_idx = i;
486 }
487 }
488 });
489 if let Some(sel_name) = trace_names.get(target_idx) {
490 builder.target = sel_name.clone();
491 }
492
493 ui.horizontal(|ui| {
495 ui.label("Level");
496 ui.add(egui::DragValue::new(&mut builder.level).speed(0.1));
497 if self.pick_level_pending {
498 if ui
499 .button("⌖ Picking…")
500 .on_hover_text("Click on the plot to set the Y level")
501 .clicked()
502 {
503 self.pick_level_pending = false;
504 }
505 } else {
506 if ui
507 .button("⌖")
508 .on_hover_text("Pick Y level from plot (click on plot)")
509 .clicked()
510 {
511 self.pick_level_pending = true;
512 for scope in data.scope_data.iter_mut() {
514 scope.measurement_active = true;
515 }
516 }
517 }
518 egui::ComboBox::from_label("Slope")
519 .selected_text(match builder.slope {
520 TriggerSlope::Rising => "Rising",
521 TriggerSlope::Falling => "Falling",
522 TriggerSlope::Any => "Any",
523 })
524 .show_ui(ui, |ui| {
525 if ui
526 .selectable_label(
527 matches!(builder.slope, TriggerSlope::Rising),
528 "Rising",
529 )
530 .clicked()
531 {
532 builder.slope = TriggerSlope::Rising;
533 }
534 if ui
535 .selectable_label(
536 matches!(builder.slope, TriggerSlope::Falling),
537 "Falling",
538 )
539 .clicked()
540 {
541 builder.slope = TriggerSlope::Falling;
542 }
543 if ui
544 .selectable_label(matches!(builder.slope, TriggerSlope::Any), "Any")
545 .clicked()
546 {
547 builder.slope = TriggerSlope::Any;
548 }
549 });
550 });
551
552 ui.checkbox(&mut builder.enabled, "Enabled");
555 ui.checkbox(&mut builder.single_shot, "Single shot");
556
557 ui.horizontal(|ui| {
558 ui.label("Trigger position (0..1)")
559 .on_hover_text("0 = pause now, 1 = pause after max_points");
560 ui.add(egui::Slider::new(&mut builder.trigger_position, 0.0..=1.0).smart_aim(true))
561 .on_hover_text("0 = pause now, 1 = pause after max_points");
562 });
563
564 ui.horizontal(|ui| {
565 ui.label("Holdoff (s)")
566 .on_hover_text("Minimum time between consecutive triggers. 0 = no holdoff.");
567 ui.add(
568 egui::DragValue::new(&mut builder.holdoff_secs)
569 .speed(0.001)
570 .range(0.0..=f64::MAX)
571 .suffix(" s"),
572 );
573 });
574
575 ui.add_space(5.0);
577 egui::CollapsingHeader::new("Style")
578 .default_open(false)
579 .show(ui, |ui| {
580 render_trace_look_editor(&mut builder.look, ui, false);
581 });
582
583 ui.add_space(10.0);
585 let mut cancel_clicked = false;
586 let mut save_trigger: Option<Trigger> = None;
587 ui.horizontal(|ui| {
588 let save_label = if self.editing.is_some() {
589 "Save"
590 } else {
591 "Add trigger"
592 };
593 let can_save =
594 !builder.name.is_empty() && !builder.target.0.is_empty() && !duplicate_name;
595 if ui
596 .add_enabled(can_save, egui::Button::new(save_label))
597 .clicked()
598 {
599 let mut staged = Trigger::default();
601 staged.name = builder.name.clone();
602 staged.target = TraceRef(builder.target.0.clone());
603 staged.enabled = builder.enabled;
604 staged.level = builder.level;
605 staged.slope = match builder.slope {
606 TriggerSlope::Rising => TriggerSlope::Rising,
607 TriggerSlope::Falling => TriggerSlope::Falling,
608 TriggerSlope::Any => TriggerSlope::Any,
609 };
610 staged.single_shot = builder.single_shot;
611 staged.trigger_position = builder.trigger_position;
612 staged.holdoff_secs = builder.holdoff_secs;
613 staged.look = builder.look.clone();
614 save_trigger = Some(staged);
615 }
616 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
617 if ui.button("Cancel").clicked() {
618 cancel_clicked = true;
619 }
620 });
621 });
622 if cancel_clicked {
624 self.builder = None;
625 self.editing = None;
626 }
627 if let Some(staged) = save_trigger {
628 let key_old = self.editing.clone();
629 let key_new = staged.name.clone();
630 let entry = self
631 .triggers
632 .entry(key_new.clone())
633 .or_insert_with(|| Trigger::default());
634 *entry = staged;
635 if let Some(old) = key_old {
636 if old != key_new {
637 self.triggers.remove(&old);
638 }
639 }
640 self.builder = None;
641 self.editing = None;
642 }
643 }
644 }
645}
646
647impl TriggersPanel {
648 pub fn reset_all(&mut self) {
649 for (_name, tr) in self.triggers.iter_mut() {
650 tr.reset();
651 }
652 }
653}