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

+85
View File
@@ -59,6 +59,30 @@ LayoutState collect_layout(App& app) {
st.measures = app.measures;
st.measure_stats = app.measure_stats_on;
st.side_tab = app.side_tab;
// The trigger, its settings *and* whether it was on. The channel goes by name, like every identity
// in this file: every rebuild renumbers the channels.
{
const TriggerConfig& tc = app.model->trigger();
const std::vector<ChannelInfo>& channels = app.model->channels();
st.trigger.enabled = tc.enabled;
if (tc.channel >= 0 && tc.channel < static_cast<int>(channels.size())) {
st.trigger.channel = channels[static_cast<std::size_t>(tc.channel)].name;
}
st.trigger.type = trigger_type_text(tc.condition.type);
st.trigger.edge = trigger_edge_text(tc.condition.edge);
st.trigger.cmp = width_cmp_text(tc.condition.cmp);
st.trigger.mode = trigger_mode_text(tc.mode);
st.trigger.level = tc.condition.level;
st.trigger.level2 = tc.condition.level2;
st.trigger.width_s = tc.condition.width_s;
st.trigger.width2_s = tc.condition.width2_s;
st.trigger.timeout_s = tc.condition.timeout_s;
st.trigger.pretrigger_pct = tc.pretrigger_pct;
st.trigger.holdoff_s = tc.holdoff_s;
st.trigger.noise_band = tc.condition.noise_band;
st.trigger.hf_fc = tc.condition.hf_fc;
st.trigger.level_given = true; // written means given, so a load keeps it
}
// The connection is the session's too: the file's name carries only host and port.
st.multicast = app.source_multicast;
st.interface_addr = app.source_interface;
@@ -289,6 +313,56 @@ void schedule_layout_save(App& app) {
/// Applies a loaded layout: rows, slot assignments, calibration, window. Names
/// that no longer exist in the stream are skipped rather than freeing their slot
/// (a stream that renamed a signal must not blank the user's layout).
/// The session's trigger into the model and the panel, called from `apply_layout()`. The *channel* it
/// names is resolved where the channels exist -- `rebuild_trigger_signals()` picks up
/// `App::pending_trigger_channel` -- because at this point the stream may not have described its
/// signals yet, and a name that resolves to nothing would silently move the trigger to channel 0.
void apply_trigger_layout(App& app, const LayoutState::Trigger& t) {
{
std::lock_guard<std::mutex> lock(app.model->mutex());
TriggerConfig& tc = app.model->trigger();
tc.condition.type = trigger_type_from_text(t.type);
tc.condition.edge = trigger_edge_from_text(t.edge);
tc.condition.cmp = width_cmp_from_text(t.cmp);
tc.mode = trigger_mode_from_text(t.mode);
tc.condition.level = t.level;
tc.condition.level2 = t.level2;
tc.condition.width_s = t.width_s;
tc.condition.width2_s = t.width2_s;
tc.condition.timeout_s = t.timeout_s;
tc.condition.noise_band = t.noise_band;
tc.condition.hf_fc = t.hf_fc;
tc.pretrigger_pct = t.pretrigger_pct;
tc.holdoff_s = t.holdoff_s;
}
// A file that carried a level keeps it: the panel's own default follows the channel's range, and
// `trigger_level_touched` is what says the user has a level of their own.
if (t.level_given) app.trigger_level_touched = true;
app.pending_trigger_channel = t.channel;
{
// If the stream has already described its channels, resolve the name *now*: the signals rebuild that
// would otherwise do it has already been and gone, so a pending name would leave the trigger on
// channel 0 -- which is a trigger on whichever signal happens to be first.
const std::vector<ChannelInfo>& channels = app.model->channels();
for (std::size_t c = 0; c < channels.size(); ++c) {
if (channels[c].name != app.pending_trigger_channel) continue;
std::lock_guard<std::mutex> lock(app.model->mutex());
app.model->trigger().channel = static_cast<int>(c);
app.pending_trigger_channel.clear();
break;
}
}
write_trigger_widgets(app);
// The state is applied by *pressing the same control a user would*: the handler is what arms, tells
// the events button, greys the body and syncs the pane's own switch. Deliberately outside the
// `updating_panels` window `apply_layout()` uses for the panels, because a toggle set inside it
// would emit `toggled` into a handler that returns at once and nothing would happen.
if (app.trigger_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.trigger_toggle), t.enabled);
}
sync_panel_switches(app);
}
void apply_layout(App& app, const LayoutState& st) {
ScopeModel& model = *app.model;
const auto& signals = model.signals();
@@ -410,6 +484,17 @@ void apply_layout(App& app, const LayoutState& st) {
if (app.area != nullptr) app.area->set_figure(&app.rows->figure());
app.relayout_pending = true;
rebuild_signal_panels(app);
// The trigger last: its channel names an *acquired* channel, so it is resolved once the enables
// above have built the channel list.
apply_trigger_layout(app, st.trigger);
// A session that loaded is a user who has been here before, and that is what the lock means: the two
// panes come up showing what they are set to rather than showing every control. A first run -- no
// file for this source -- never reaches this function and stays in configuration mode, which is what
// a first run is for.
app.trigger_locked = true;
app.signals_locked = true;
apply_pane_lock(app);
}
void on_layout_row_remove(GtkButton* button, gpointer user) {