feat: locked panes for a returning user, and the trigger is persisted

Two panes, two modes. **Locked** is what a returning user gets: the trigger pane shows one line saying
what it is set to, the signal list shows only the signals that are on, and neither offers a control that
changes the configuration — each heading has an `[edit]` button (the Adwaita `emblem-system-symbolic`,
swapping to `emblem-ok-symbolic` in configuration mode) that flips its pane. A **first run** — no session
file for that source — never reaches `apply_layout()` and comes up in configuration mode, because
configuring the host is what a first run is for. The mode is a mode and not a setting, so it is
deliberately not persisted.

The locked signal list drops the rows of every signal that is off, which is the whole of "show active
signals": the list already filters on nothing, so the lock is one `continue`. With the rows go the acquire
tick, the overflow menu (Information, Calibrate…, Delete, rename) and the `MATH` divider's `+` — and the
colour chip stops being clickable, because choosing a colour is configuration too. Dragging a signal onto
a plot stays: that is which view shows what, not what the scope is set up to do.

**The trigger is persisted, settings and state both** — the settings *even while it is off*, which is the
point: a trigger configured once and switched off is still the trigger you come back to. `LayoutState`
gains fifteen flat `trigger_*` keys; the channel is stored by *name* (every rebuild renumbers the
channels, the model's own rule) and the two enumerations as **the words the panel's dropdowns show**,
because an index ties the file to a declaration order — the lesson `math_op_at()` already carries. The
words are shared with the panel now (`trigger_type_text()` and friends build the dropdown arrays), so the
two cannot drift.

Two ordering traps, both found by running it:

- **The channel name resolves twice over.** `rebuild_trigger_signals()` picks up
  `App::pending_trigger_channel` when the stream arrives *after* the load, and `apply_trigger_layout()`
  resolves it itself when the channels are already there — without the second path the name stayed
  pending past the one rebuild that would have used it, and the trigger silently sat on channel 0
  (`summary='pulse width · sine …'` where the session said `square`).
- **The trigger's state is applied by pressing the control, not by setting a flag**: the toggle is set
  *outside* the `updating_panels` window `apply_layout()` uses, because a toggle set inside it emits
  `toggled` into a handler that returns at once and nothing would happen — no arming, no events button,
  no sensitivity.

Verified, in both modes and both directions:

    panes: trigger=locked signals=locked body_visible=0 summary='window · square · 0.3…0.6 · 20 % pre'
    panes: trigger=config signals=config body_visible=1 summary=''
    signals panel: labels=2 list=1 rows=7 signals=2 math=4 divider=1

The locked trigger pane is 42 px tall against 580 unlocked. A hand-written session round-trips: every
value it named came back in the panel's own controls, `trigger=1/1` (the state arrived and the pane's
switch followed it), and the file the app wrote back carried all fifteen keys with the untouched ones at
their defaults. `[side-tab-probe] … advanced=4` on its documented recipe; `make test-ui` 14 files; zero
`Gtk-CRITICAL` and no missing icon.

Docs: the `panes:` reading with what each field means, the corrected `labels=` note (the panel's frame is
counted above the list now, because the heading sits in a heading row), the invariants for both the
persistence and the lock, the README's deviation note, and the removal of its claim that the trigger has
never been persisted — it is now, and the locked view is built from exactly that.
This commit is contained in:
Martino Ferrari committed 2026-10-14 14:31:46 +02:00
1 parent cc8335c514
commit 121abbefae
12 files changed
+574 -28

No files matched your search

+186 -4
View File
@@ -5,6 +5,8 @@
#include "ui_app.hpp"
#include <cstring>
#include <gtk/gtk.h>
#include <string>
@@ -40,6 +42,164 @@ void on_arm_clicked(GtkButton* button, gpointer user) {
/// The trigger type dropdown's order, as a table rather than a cast: the dropdown lists
/// only the conditions that are implemented, so its index is not `TriggerType`'s value.
/// The trigger's four enumerations as words, and back. These are the panel's dropdown labels *and*
/// what the session file stores: an index would tie the file to a declaration order, which is the
/// lesson `math_op_at()` already carries, and two vocabularies (one for the UI, one for the file)
/// drift apart the moment one of them gains a member.
const char* trigger_type_text(TriggerType t) {
switch (t) {
case TriggerType::Edge: return "edge";
case TriggerType::PulseWidth: return "pulse width";
case TriggerType::Window: return "window";
case TriggerType::Dropout: return "dropout";
}
return "edge";
}
TriggerType trigger_type_from_text(const std::string& s) {
if (s == "pulse width") return TriggerType::PulseWidth;
if (s == "window") return TriggerType::Window;
if (s == "dropout") return TriggerType::Dropout;
return TriggerType::Edge;
}
const char* trigger_edge_text(TriggerEdge e) {
switch (e) {
case TriggerEdge::Rising: return "rising";
case TriggerEdge::Falling: return "falling";
case TriggerEdge::Both: return "both";
}
return "rising";
}
TriggerEdge trigger_edge_from_text(const std::string& s) {
if (s == "falling") return TriggerEdge::Falling;
if (s == "both") return TriggerEdge::Both;
return TriggerEdge::Rising;
}
const char* width_cmp_text(WidthCmp c) {
switch (c) {
case WidthCmp::Less: return "<";
case WidthCmp::Greater: return ">";
case WidthCmp::Within: return "within";
case WidthCmp::Outside: return "outside";
}
return ">";
}
WidthCmp width_cmp_from_text(const std::string& s) {
if (s == "<") return WidthCmp::Less;
if (s == "within") return WidthCmp::Within;
if (s == "outside") return WidthCmp::Outside;
return WidthCmp::Greater;
}
const char* trigger_mode_text(TriggerMode m) {
return m == TriggerMode::Single ? "single" : "normal";
}
TriggerMode trigger_mode_from_text(const std::string& s) {
return s == "single" ? TriggerMode::Single : TriggerMode::Normal;
}
/// The dropdown's row for a piece of text, or 0. Asked of the widget's own model, so the mapping from
/// a stored word to a selection is the list the user sees and not a second table to keep in step.
static guint dropdown_index_of(GtkWidget* drop, const char* text) {
GListModel* model = drop != nullptr ? gtk_drop_down_get_model(GTK_DROP_DOWN(drop)) : nullptr;
const guint n = model != nullptr ? g_list_model_get_n_items(model) : 0;
for (guint i = 0; i < n; ++i) {
GtkStringObject* item = GTK_STRING_OBJECT(g_list_model_get_item(model, i));
const bool hit = item != nullptr && std::strcmp(gtk_string_object_get_string(item), text) == 0;
if (item != nullptr) g_object_unref(item);
if (hit) return i;
}
return 0;
}
/// The panel's controls from the model: what a session load has just put in the model, so the pane
/// shows the trigger it is about to run. Guarded, because a control's own handler publishes what it
/// holds and this is the other direction.
void write_trigger_widgets(App& app) {
if (app.trigger_level_spin == nullptr) return;
TriggerConfig tc;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
tc = app.model->trigger();
}
const bool was = app.updating_panels;
app.updating_panels = true;
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_level_spin), tc.condition.level);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_pre_spin), tc.pretrigger_pct);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_holdoff_spin), tc.holdoff_s);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_width_spin), tc.condition.width_s);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_width2_spin), tc.condition.width2_s);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_level2_spin), tc.condition.level2);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_timeout_spin), tc.condition.timeout_s);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_band_spin), tc.condition.noise_band);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_hf_spin), tc.condition.hf_fc);
gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_type_drop),
dropdown_index_of(app.trigger_type_drop,
trigger_type_text(tc.condition.type)));
gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_edge_drop),
dropdown_index_of(app.trigger_edge_drop,
trigger_edge_text(tc.condition.edge)));
gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_cmp_drop),
dropdown_index_of(app.trigger_cmp_drop,
width_cmp_text(tc.condition.cmp)));
gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_mode_drop),
dropdown_index_of(app.trigger_mode_drop,
trigger_mode_text(tc.mode)));
app.updating_panels = was;
sync_trigger_rows(app);
}
/// The compact line: what the pane is set to, in the words the panel's own controls use. Read from the
/// model rather than from the widgets, because the widgets are not on screen in this mode and are not
/// rebuilt when a session loads.
void sync_trigger_summary(App& app) {
if (app.trigger_summary == nullptr) return;
TriggerConfig tc;
std::string channel;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
tc = app.model->trigger();
const std::vector<ChannelInfo>& channels = app.model->channels();
if (tc.channel >= 0 && tc.channel < static_cast<int>(channels.size())) {
channel = channels[static_cast<std::size_t>(tc.channel)].name;
}
}
char buf[192];
std::string text = trigger_type_text(tc.condition.type);
text += " · ";
text += channel.empty() ? "no channel" : channel;
switch (tc.condition.type) {
case TriggerType::PulseWidth:
std::snprintf(buf, sizeof(buf), " · %s %.6g ms", width_cmp_text(tc.condition.cmp),
tc.condition.width_s * 1000.0);
text += buf;
break;
case TriggerType::Window:
std::snprintf(buf, sizeof(buf), " · %.6g…%.6g", tc.condition.level, tc.condition.level2);
text += buf;
break;
case TriggerType::Dropout:
std::snprintf(buf, sizeof(buf), " · %.6g ms", tc.condition.timeout_s * 1000.0);
text += buf;
break;
case TriggerType::Edge:
std::snprintf(buf, sizeof(buf), " · level %.6g", tc.condition.level);
text += buf;
break;
}
if (!(tc.condition.noise_band > 0.0) || tc.condition.type == TriggerType::Dropout) {
// A band is not offered for a dropout, so it is not quoted for one either.
} else {
std::snprintf(buf, sizeof(buf), " · band %.6g", tc.condition.noise_band);
text += buf;
}
if (tc.condition.hf_fc > 0.0) {
std::snprintf(buf, sizeof(buf), " · HF %.6g Hz", tc.condition.hf_fc);
text += buf;
}
std::snprintf(buf, sizeof(buf), " · %.6g %% pre", tc.pretrigger_pct);
text += buf;
gtk_label_set_text(GTK_LABEL(app.trigger_summary), text.c_str());
}
TriggerType trigger_type_at(guint i) {
switch (i) {
case 1:
@@ -248,17 +408,24 @@ GtkWidget* build_trigger_panel(App& app) {
G_CALLBACK(on_trigger_signal_changed), &app);
row_for(ui, "signal_row", app.trigger_signal_drop);
const char* types[] = {"edge", "pulse width", "window", "dropout", nullptr};
const char* types[] = {trigger_type_text(TriggerType::Edge),
trigger_type_text(TriggerType::PulseWidth),
trigger_type_text(TriggerType::Window),
trigger_type_text(TriggerType::Dropout), nullptr};
app.trigger_type_drop = gtk_drop_down_new_from_strings(types);
gtk_widget_set_tooltip_text(app.trigger_type_drop,
"what counts as an event; the fields below change to suit it");
row_for(ui, "condition_row", app.trigger_type_drop);
const char* edges[] = {"rising", "falling", "both", nullptr};
const char* edges[] = {trigger_edge_text(TriggerEdge::Rising),
trigger_edge_text(TriggerEdge::Falling),
trigger_edge_text(TriggerEdge::Both), nullptr};
app.trigger_edge_drop = gtk_drop_down_new_from_strings(edges);
row_for(ui, "edge_row", app.trigger_edge_drop);
const char* cmps[] = {"<", ">", "within", "outside", nullptr};
const char* cmps[] = {width_cmp_text(WidthCmp::Less), width_cmp_text(WidthCmp::Greater),
width_cmp_text(WidthCmp::Within), width_cmp_text(WidthCmp::Outside),
nullptr};
app.trigger_cmp_drop = gtk_drop_down_new_from_strings(cmps);
gtk_widget_set_tooltip_text(app.trigger_cmp_drop,
"how the measured pulse width is compared: '<' is the glitch "
@@ -307,7 +474,8 @@ GtkWidget* build_trigger_panel(App& app) {
app.trigger_hf_spin = spin(0.0, 1.0e7, 1.0, 3, 0.0);
row_for(ui, "hf_row", app.trigger_hf_spin);
const char* modes[] = {"normal", "single", nullptr};
const char* modes[] = {trigger_mode_text(TriggerMode::Normal),
trigger_mode_text(TriggerMode::Single), nullptr};
app.trigger_mode_drop = gtk_drop_down_new_from_strings(modes);
row_for(ui, "mode_row", app.trigger_mode_drop);
@@ -440,6 +608,20 @@ GtkWidget* build_trigger_panel(App& app) {
gtk_widget_set_halign(title, GTK_ALIGN_START);
gtk_widget_set_hexpand(title, TRUE);
gtk_box_append(GTK_BOX(head), title);
// What the pane is set to, for the mode where its settings are not on screen.
app.trigger_summary = gtk_label_new("");
gtk_widget_add_css_class(app.trigger_summary, "dim-label");
gtk_label_set_ellipsize(GTK_LABEL(app.trigger_summary), PANGO_ELLIPSIZE_END);
gtk_widget_set_halign(app.trigger_summary, GTK_ALIGN_START);
gtk_widget_set_hexpand(app.trigger_summary, TRUE);
gtk_widget_set_visible(app.trigger_summary, FALSE);
gtk_box_append(GTK_BOX(head), app.trigger_summary);
app.trigger_edit = gtk_button_new_from_icon_name("emblem-system-symbolic");
gtk_widget_add_css_class(app.trigger_edit, "flat");
gtk_widget_set_valign(app.trigger_edit, GTK_ALIGN_CENTER);
gtk_widget_set_tooltip_text(app.trigger_edit, "configure the trigger");
g_signal_connect(app.trigger_edit, "clicked", G_CALLBACK(on_pane_edit_clicked), &app);
gtk_box_append(GTK_BOX(head), app.trigger_edit);
app.trigger_switch = gtk_switch_new();
gtk_widget_set_valign(app.trigger_switch, GTK_ALIGN_CENTER);
gtk_widget_set_tooltip_text(app.trigger_switch,