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

+30
View File
@@ -73,6 +73,36 @@ struct LayoutState {
/// The measurements pane, by signal *name* and with the type and options a row was built with.
std::vector<Measure> measures;
/// The trigger: its settings *and* whether trigger mode was on. Both, and the settings even while
/// it is off, because a trigger set up once and switched off is still the trigger you come back to
/// -- and the pane's locked view is built from exactly this.
///
/// The signal and the two enumerations are stored as *text*: the signal by name, because every
/// rebuild renumbers the channels (the model's own rule), and the shapes by the words the panel's
/// dropdowns show, because an index would tie this file to a declaration order -- which the math
/// channel's op index already taught this store once.
struct Trigger {
bool enabled = false;
std::string channel; ///< the acquired channel's name; empty is "none chosen"
std::string type = "edge"; ///< edge | pulse width | window | dropout
std::string edge = "rising"; ///< rising | falling | both
std::string cmp = "greater"; ///< pulse width: `<` | `>` | within | outside
std::string mode = "normal"; ///< normal | single
double level = 0.0;
double level2 = 0.0; ///< window: the band's other edge
double width_s = 1.0e-3;
double width2_s = 2.0e-3;
double timeout_s = 1.0e-3;
double pretrigger_pct = 50.0;
double holdoff_s = 0.010;
double noise_band = 0.0;
double hf_fc = 0.0;
/// Whether the file said anything about the level. A session that did keeps it; one that did not
/// leaves the panel's own "follow the channel's range" default alone.
bool level_given = false;
};
Trigger trigger;
/// Free text the user attached to this configuration: what it is for, what to look at first.
/// A configuration is a *named* session, and the name alone rarely says what makes it different
/// from its neighbour.