use super::panel_trait::{Panel, PanelState};
use crate::data::data::LivePlotData;
use crate::data::scope::ScopeData;
use crate::data::traces::TraceRef;
use crate::data::traces::TracesCollection;
use crate::data::triggers::{Trigger, TriggerSlope};
use crate::panels::trace_look_ui::render_trace_look_editor;
use egui::Ui;
use egui_plot::{HLine, Points, VLine};
use std::collections::HashMap;
pub struct TriggersPanel {
pub state: PanelState,
pub triggers: HashMap<String, Trigger>,
pub builder: Option<Trigger>,
pub editing: Option<String>,
pub pick_level_pending: bool,
}
impl Default for TriggersPanel {
fn default() -> Self {
let mut panel = Self {
state: PanelState::new("Triggers", "🔔"),
triggers: HashMap::new(),
builder: None,
editing: None,
pick_level_pending: false,
};
let mut t = Trigger::default();
t.name = "Trigger".to_string();
t.enabled = false;
panel.triggers.insert(t.name.clone(), t);
panel
}
}
impl Panel for TriggersPanel {
fn state(&self) -> &PanelState {
&self.state
}
fn state_mut(&mut self) -> &mut PanelState {
&mut self.state
}
fn hotkey_name(&self) -> Option<crate::data::hotkeys::HotkeyName> {
Some(crate::data::hotkeys::HotkeyName::Triggers)
}
fn render_menu(
&mut self,
ui: &mut Ui,
_data: &mut LivePlotData<'_>,
collapsed: bool,
tooltip: &str,
) {
let label = if collapsed {
self.icon_only()
.map(|s| s.to_string())
.unwrap_or_else(|| self.title().to_string())
} else {
self.title_and_icon()
};
let menu_cfg = egui::containers::menu::MenuConfig::new()
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside);
let mr = egui::containers::menu::MenuButton::new(label)
.config(menu_cfg)
.ui(ui, |ui| {
if ui.button("Show Triggers").clicked() {
let st = self.state_mut();
st.visible = true;
st.request_focus = true;
ui.close();
}
ui.separator();
if ui.button("New").clicked() {
let mut t = crate::data::triggers::Trigger::default();
let idx = self.triggers.len() + 1;
t.name = format!("Trigger{}", idx);
t.enabled = false;
self.triggers.insert(t.name.clone(), t);
let st = self.state_mut();
st.visible = true;
st.detached = false;
st.request_docket = true;
ui.close();
}
if ui
.button(egui_phosphor::regular::PLAY.to_string() + " Start all")
.clicked()
{
for (_n, trig) in self.triggers.iter_mut() {
trig.start();
}
ui.close();
}
if ui
.button(egui_phosphor::regular::STOP.to_string() + " Stop all")
.clicked()
{
for (_n, trig) in self.triggers.iter_mut() {
trig.stop();
}
ui.close();
}
if ui
.button(egui_phosphor::regular::ARROW_CLOCKWISE.to_string() + " Reset all")
.clicked()
{
for (_n, trig) in self.triggers.iter_mut() {
trig.reset_runtime_state();
}
ui.close();
}
});
if !tooltip.is_empty() {
mr.0.on_hover_text(tooltip);
}
}
fn clear_all(&mut self) {
for (_name, trig) in self.triggers.iter_mut() {
trig.enabled = false;
trig.reset_runtime_state();
}
self.builder = None;
self.editing = None;
}
fn draw(
&mut self,
plot_ui: &mut egui_plot::PlotUi,
scope: &ScopeData,
traces: &TracesCollection,
) {
if self.triggers.is_empty() {
return;
}
let bounds = plot_ui.plot_bounds();
let xr = bounds.range_x();
let xmin = *xr.start();
let xmax = *xr.end();
for (name, trig) in self.triggers.iter() {
if !trig.enabled {
continue;
}
let Some(tr) = traces.get_trace(&trig.target) else {
continue;
};
if !tr.look.visible {
continue;
}
let color = trig.look.color;
let mut width = trig.look.width.max(0.1);
let style = trig.look.style;
let y_lin = trig.level + tr.offset;
let y_plot = if scope.y_axis.log_scale {
if y_lin > 0.0 {
y_lin.log10()
} else {
f64::NAN
}
} else {
y_lin
};
if y_plot.is_finite() {
let info = trig.get_info(&scope.y_axis);
let label = if scope.show_info_in_legend {
format!("{} — {}", trig.name, info)
} else {
trig.name.clone()
};
let h = HLine::new(label, y_plot)
.color(color)
.width(width)
.style(style);
plot_ui.hline(h);
}
if let Some(t) = trig.last_trigger_time() {
if t >= xmin && t <= xmax {
if self.editing.as_deref() == Some(name) {
width *= 1.4;
}
let label = trig.name.clone();
if trig.look.show_points {
if y_plot.is_finite() {
let mut radius = trig.look.point_size;
if self.editing.as_deref() == Some(name) {
radius *= 1.2;
}
let p = Points::new(label, vec![[t, y_plot]])
.radius(radius)
.shape(trig.look.marker)
.color(color);
plot_ui.points(p);
}
} else {
let v = VLine::new(label, t).color(color).width(width).style(style);
plot_ui.vline(v);
}
}
}
}
}
fn update_data(&mut self, data: &mut LivePlotData<'_>) {
if data.pending_requests.clear_triggers {
self.clear_all();
data.pending_requests.clear_triggers = false;
}
if self.pick_level_pending {
for scope in data.scope_data.iter_mut() {
if let Some(point) = scope.clicked_point.take() {
if let Some(builder) = &mut self.builder {
builder.level = point[1];
}
self.pick_level_pending = false;
scope.measurement_active = false;
break;
}
}
}
{
let scope_ids: Vec<usize> = data.scope_data.iter().map(|scope| (**scope).id).collect();
for scope_id in scope_ids {
for (_name, tr) in self.triggers.iter_mut() {
if tr.single_shot && tr.is_triggered() {
continue;
}
if tr.check_trigger(data) && tr.is_triggered() {
if let Some(scope) = data.scope_by_id_mut(scope_id) {
let tr_time = tr.last_trigger_time().unwrap();
let time_window = scope.x_axis.bounds.1 - scope.x_axis.bounds.0;
let tr_pos = tr.trigger_position;
scope.x_axis.bounds = (
tr_time - time_window * tr_pos,
tr_time + time_window * (1.0 - tr_pos),
);
}
}
}
}
}
}
fn render_panel(&mut self, ui: &mut Ui, data: &mut LivePlotData<'_>) {
ui.label("Trigger when a trace crosses a level; optionally pause after N samples.");
ui.separator();
ui.horizontal(|ui| {
if ui
.button(egui_phosphor::regular::ARROW_CLOCKWISE.to_string() + " Reset all")
.on_hover_text("Reset all triggers")
.clicked()
{
data.resume_all();
for (_name, tr) in self.triggers.iter_mut() {
tr.reset();
}
}
if ui
.button(egui_phosphor::regular::PLAY.to_string() + " Start all")
.on_hover_text("Enable and start all triggers")
.clicked()
{
data.resume_all();
for (_name, tr) in self.triggers.iter_mut() {
tr.enabled = true;
tr.start();
}
}
});
ui.separator();
ui.add_space(6.0);
let mut removals: Vec<String> = Vec::new();
for (name, tr) in self.triggers.iter_mut() {
let name_str = name.clone();
let mut to_remove = false;
let scope_axes = data
.scope_containing_trace(&tr.target)
.map(|scope| (scope.x_axis.clone(), scope.y_axis.clone()));
let row = ui.horizontal(|ui| {
ui.checkbox(&mut tr.enabled, "");
let name_resp =
ui.add(egui::Label::new(tr.name.clone()).sense(egui::Sense::click()));
let info = scope_axes
.as_ref()
.map(|(_, y_axis)| tr.get_info_without_slope(y_axis))
.unwrap_or_default();
let info_resp = ui.horizontal(|ui| {
if let Some(img) = super::edge_icons::edge_icon_image(ui.ctx(), tr.slope_icon(), 12.0) {
ui.add(img);
}
ui.add(egui::Label::new(info).sense(egui::Sense::click()))
}).inner;
if name_resp.clicked() || info_resp.clicked() {
let mut t = Trigger::default();
t.name = tr.name.clone();
t.target = TraceRef(tr.target.0.clone());
t.enabled = tr.enabled;
t.level = tr.level;
t.slope = match tr.slope {
TriggerSlope::Rising => TriggerSlope::Rising,
TriggerSlope::Falling => TriggerSlope::Falling,
TriggerSlope::Any => TriggerSlope::Any,
};
t.single_shot = tr.single_shot;
t.trigger_position = tr.trigger_position;
t.holdoff_secs = tr.holdoff_secs;
t.look = tr.look.clone();
self.builder = Some(t);
self.editing = Some(name_str.clone());
}
if name_resp.hovered() || info_resp.hovered() {
if !tr.target.0.is_empty() {
data.traces.hover_trace = Some(vec![tr.target.clone()]);
}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.button(egui_phosphor::regular::TRASH)
.on_hover_text("Remove")
.clicked()
{
to_remove = true;
}
});
});
if row.response.hovered() {
if !tr.target.0.is_empty() {
data.traces.hover_trace = Some(vec![tr.target.clone()]);
}
}
if to_remove {
removals.push(name_str.clone());
}
let mut do_reset = false;
let mut toggle_start: Option<bool> = None; let (last_text, last_exists) = if let Some(t) = tr.last_trigger_time() {
let start_fmt = if let Some((x_axis, _)) = scope_axes.as_ref() {
x_axis.format_value(t, None)
} else {
format!("{:.4}", t)
};
(format!("Last: {}", start_fmt), true)
} else {
(String::from("Last: –"), false)
};
let is_active = tr.is_active();
let enabled_flag = tr.enabled;
ui.horizontal(|ui| {
ui.label(last_text);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add_enabled(
last_exists,
egui::Button::new(egui_phosphor::regular::ARROW_CLOCKWISE),
)
.clicked()
{
do_reset = true;
}
if is_active {
if ui
.add_enabled(
enabled_flag,
egui::Button::new(egui_phosphor::regular::STOP),
)
.clicked()
{
toggle_start = Some(false);
}
} else {
if ui
.add_enabled(
enabled_flag,
egui::Button::new(egui_phosphor::regular::PLAY),
)
.clicked()
{
toggle_start = Some(true);
}
}
});
});
if do_reset {
if tr.is_triggered() {
data.resume_all();
}
tr.reset();
}
if let Some(start) = toggle_start {
if start {
data.resume_all();
tr.start();
} else {
tr.stop();
}
}
}
if !removals.is_empty() {
for n in removals {
self.triggers.remove(&n);
if self.editing.as_deref() == Some(&n) {
self.builder = None;
self.editing = None;
}
}
}
ui.add_space(6.0);
let new_clicked = ui
.add_sized([ui.available_width(), 24.0], egui::Button::new("âž• New"))
.on_hover_text("Create a new trigger")
.clicked();
if new_clicked {
self.builder = Some(Trigger::default());
self.editing = None;
}
if let Some(builder) = &mut self.builder {
ui.add_space(12.0);
ui.separator();
if self.editing.is_some() {
ui.strong("Edit trigger");
} else {
ui.strong("New trigger");
}
ui.add_space(3.0);
let duplicate_name = self.triggers.contains_key(&builder.name)
&& self.editing.as_deref() != Some(builder.name.as_str());
ui.horizontal(|ui| {
ui.label("Name");
if duplicate_name {
egui::Frame::default()
.stroke(egui::Stroke::new(1.5_f32, egui::Color32::RED))
.show(ui, |ui| {
let resp = ui.add(egui::TextEdit::singleline(&mut builder.name));
let _resp = resp.on_hover_text(
"A trigger with this name already exists. Please choose another.",
);
});
} else {
let resp = ui.add(egui::TextEdit::singleline(&mut builder.name));
let _resp = resp.on_hover_text("Enter a unique name for this trigger");
}
});
let trace_names = data.traces.all_trace_names();
let mut target_idx = trace_names
.iter()
.position(|n| n == &builder.target)
.unwrap_or(0);
egui::ComboBox::from_label("Trace")
.selected_text(
trace_names
.get(target_idx)
.map(|t| t.to_string())
.unwrap_or_default(),
)
.show_ui(ui, |ui| {
for (i, n) in trace_names.iter().enumerate() {
if ui.selectable_label(target_idx == i, n.as_str()).clicked() {
target_idx = i;
}
}
});
if let Some(sel_name) = trace_names.get(target_idx) {
builder.target = sel_name.clone();
}
ui.horizontal(|ui| {
ui.label("Level");
ui.add(egui::DragValue::new(&mut builder.level).speed(0.1));
if self.pick_level_pending {
if ui
.button("⌖ Picking…")
.on_hover_text("Click on the plot to set the Y level")
.clicked()
{
self.pick_level_pending = false;
}
} else {
if ui
.button("⌖")
.on_hover_text("Pick Y level from plot (click on plot)")
.clicked()
{
self.pick_level_pending = true;
for scope in data.scope_data.iter_mut() {
scope.measurement_active = true;
}
}
}
});
ui.horizontal(|ui| {
ui.label("Slope");
ui.horizontal(|ui| {
let icon_size = 14.0;
let ctx = ui.ctx().clone();
let rising_selected = matches!(builder.slope, TriggerSlope::Rising);
if let Some(img) = super::edge_icons::edge_icon_image(&ctx, super::edge_icons::EdgeIcon::Rising, icon_size) {
if ui.add(egui::Button::selectable(rising_selected, (img, "Rising"))).clicked() {
builder.slope = TriggerSlope::Rising;
}
}
let falling_selected = matches!(builder.slope, TriggerSlope::Falling);
if let Some(img) = super::edge_icons::edge_icon_image(&ctx, super::edge_icons::EdgeIcon::Falling, icon_size) {
if ui.add(egui::Button::selectable(falling_selected, (img, "Falling"))).clicked() {
builder.slope = TriggerSlope::Falling;
}
}
let any_selected = matches!(builder.slope, TriggerSlope::Any);
if let Some(img) = super::edge_icons::edge_icon_image(&ctx, super::edge_icons::EdgeIcon::Both, icon_size) {
if ui.add(egui::Button::selectable(any_selected, (img, "Any"))).clicked() {
builder.slope = TriggerSlope::Any;
}
}
});
});
ui.checkbox(&mut builder.enabled, "Enabled");
ui.checkbox(&mut builder.single_shot, "Single shot");
ui.horizontal(|ui| {
ui.label("Trigger position (0..1)")
.on_hover_text("0 = pause now, 1 = pause after max_points");
ui.add(egui::Slider::new(&mut builder.trigger_position, 0.0..=1.0).smart_aim(true))
.on_hover_text("0 = pause now, 1 = pause after max_points");
});
ui.horizontal(|ui| {
ui.label("Holdoff (s)")
.on_hover_text("Minimum time between consecutive triggers. 0 = no holdoff.");
ui.add(
egui::DragValue::new(&mut builder.holdoff_secs)
.speed(0.001)
.range(0.0..=f64::MAX)
.suffix(" s"),
);
});
ui.add_space(5.0);
egui::CollapsingHeader::new("Style")
.default_open(false)
.show(ui, |ui| {
render_trace_look_editor(&mut builder.look, ui, false);
});
ui.add_space(10.0);
let mut cancel_clicked = false;
let mut save_trigger: Option<Trigger> = None;
ui.horizontal(|ui| {
let save_label = if self.editing.is_some() {
"Save"
} else {
"Add trigger"
};
let can_save =
!builder.name.is_empty() && !builder.target.0.is_empty() && !duplicate_name;
let enter_pressed = can_save && ui.ctx().input(|i| i.key_pressed(egui::Key::Enter));
if ui
.add_enabled(can_save, egui::Button::new(save_label))
.clicked()
|| enter_pressed
{
let mut staged = Trigger::default();
staged.name = builder.name.clone();
staged.target = TraceRef(builder.target.0.clone());
staged.enabled = builder.enabled;
staged.level = builder.level;
staged.slope = match builder.slope {
TriggerSlope::Rising => TriggerSlope::Rising,
TriggerSlope::Falling => TriggerSlope::Falling,
TriggerSlope::Any => TriggerSlope::Any,
};
staged.single_shot = builder.single_shot;
staged.trigger_position = builder.trigger_position;
staged.holdoff_secs = builder.holdoff_secs;
staged.look = builder.look.clone();
save_trigger = Some(staged);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Cancel").clicked() {
cancel_clicked = true;
}
});
});
if cancel_clicked {
self.builder = None;
self.editing = None;
}
if let Some(staged) = save_trigger {
let key_old = self.editing.clone();
let key_new = staged.name.clone();
let entry = self
.triggers
.entry(key_new.clone())
.or_insert_with(|| Trigger::default());
*entry = staged;
if let Some(old) = key_old {
if old != key_new {
self.triggers.remove(&old);
}
}
self.builder = None;
self.editing = None;
}
}
}
fn settings_snapshot(&self, _data: &LivePlotData<'_>) -> Option<String> {
let mut sorted: Vec<_> = self.triggers.values().collect();
sorted.sort_by(|a, b| a.name.cmp(&b.name));
let ser: Vec<crate::persistence::TriggerSerde> =
sorted.iter().map(|t| crate::persistence::TriggerSerde::from_trigger(t)).collect();
serde_json::to_string(&ser).ok()
}
}
impl TriggersPanel {
pub fn reset_all(&mut self) {
for (_name, tr) in self.triggers.iter_mut() {
tr.reset();
}
}
}