Skip to main content

liveplot/panels/
triggers_ui.rs

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    /// When `true`, the next click on the plot will set the builder's level to the Y coordinate.
18    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        // Add one default trigger on startup (disabled)
31        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        // Reset all triggers to disabled and clear last-trigger times
114        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            // Draw horizontal trigger level line
152            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                // Legend label can include info text
164                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            // Marker at last trigger time for each enabled trigger.
178            if let Some(t) = trig.last_trigger_time() {
179                if t >= xmin && t <= xmax {
180                    // Emphasize when currently edited
181                    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                        // Draw a point at the trigger level position
187                        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                        // Draw a vertical line at trigger time
200                        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        // Handle "Pick Y level" from plot click
215        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                    // Skip single-shot triggers that have already fired
233                    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        // Global actions
259        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                // Resume if paused due to any trigger, then clear their state
266                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                // Resume stream and enable+start all triggers
277                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        // List existing triggers with enable toggle, quick info, and Remove button
287        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            // Main row: enable toggle, name/info, remove button
296            let row = ui.horizontal(|ui| {
297                // Enable toggle
298                ui.checkbox(&mut tr.enabled, "");
299
300                // Clickable name to edit
301                let name_resp =
302                    ui.add(egui::Label::new(tr.name.clone()).sense(egui::Sense::click()));
303                // Short info text
304
305                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                    // Open editor with a copy of current settings
312                    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                    // Highlight target trace when hovering the name
331                    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            // Hovering the whole row also highlights target trace
344            if row.response.hovered() {
345                if !tr.target.0.is_empty() {
346                    data.traces.hover_trace = Some(tr.target.clone());
347                }
348            }
349
350            // Stage removal (will be applied after the loop)
351            if to_remove {
352                removals.push(name_str.clone());
353            }
354
355            // Second row: Last info + Reset + Start/Stop
356            let mut do_reset = false;
357            let mut toggle_start: Option<bool> = None; // Some(true)=Start, Some(false)=Stop
358            let (last_text, last_exists) = if let Some(t) = tr.last_trigger_time() {
359                // Use x-axis formatter for time display
360                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        // Apply removals after iteration to avoid mutable borrow conflicts
416        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        // New button
427        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        // Editor for creating/editing
438        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            // Name
449            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            // Target trace selection
470            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            // Level and slope
494            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                        // Enable measurement_active on scopes so click sets clicked_point
513                        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            // Trigger behavior
553
554            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            // Style
576            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            // Save/Add + cancel
584            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                    // Stage a copy of the builder for saving after this UI block
600                    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            // Apply staged actions now that we are outside of the builder borrow scope
623            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}