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

+10 -3
View File
@@ -1111,8 +1111,11 @@ assertion, being a stream stamp rebased by whichever rebuild last dropped histor
`probe-readings.md`.
A shape's own rows are the only ones shown; the rest are hidden, so the panel never offers a
width to a window trigger. The condition is **not** persisted in the session file — the session
has never persisted the trigger — so a restart comes up on an edge trigger.
width to a window trigger. The condition **is** persisted in the session file, together with trigger
mode itself: the settings are written even while the trigger is off, because a trigger set up once and
switched off is still the trigger you come back to. The signal it names is stored by *name* — every
rebuild renumbers the channels — and the shapes by the words the panel's dropdowns show, so a
reordered enumeration cannot reinterpret a file.
### Searching the buffer
@@ -2397,7 +2400,11 @@ now — they are listed so they are not mistaken for anything else:
cursors and the ruler for Measure, a sine crossing its trigger level and the channel list
for Signals (the same picture the toolbar's trigger button carries), drawn as a
32x16 pair so both motifs are legible at a glance — with the words kept as each pill's tooltip and
accessible name. Each pane's heading also carries its **own switch**, the same control as the
accessible name. Both panes also have a **locked** mode, which 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
to switch to the full pane, and back. A **first run** (no session file for that source) comes up
unlocked, because configuring the host is what a first run is for. Each pane's heading also carries its **own switch**, the same control as the
header bar's button — clicking either moves both — because a pane that is *disabled* while its
feature is off is a pane whose controls you would otherwise have to leave to turn it back on. The
design spec had the switcher showing *titles* and neither page an
@@ -4,6 +4,21 @@ What a session persists and in what order, when a save is refused, and when an e
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
- **The trigger is persisted, settings and state both.** Its condition, its channel (by *name*), its
pre-trigger share, hold-off and mode, and whether trigger mode was on — written even while it is off,
because a trigger configured once and switched off is still the trigger you come back to. The two
enumerations are stored as the *words the panel's dropdowns show*, not as their indexes: an index ties
the file to a declaration order, which is the lesson `math_op_at()` carries, and the words are shared
with the panel so the two cannot drift. The channel resolves when the acquired channels exist
(`rebuild_trigger_signals()` picks up `App::pending_trigger_channel`, or `apply_trigger_layout()`
resolves it itself when they are already there) — a name that resolved to nothing would silently move
the trigger to channel 0.
- **A session that loaded is what locks the two panes.** Locked is the returning user's mode: the
trigger pane shows one compact line, the signal list only the signals that are on, and neither offers
a control that changes the configuration. A *first run* — no session file for that source — never
reaches `apply_layout()` and stays in configuration mode, which is what a first run is for. The mode
is a mode and not a setting: it is deliberately not persisted, so a scope reopened tomorrow starts
where a returning user expects to start.
- **The session is the layout.** `LayoutState` persists what makes a reopened scope look like the
one that was closed: the window, the rows and their slot signals, the calibration, **the enabled
signals**, **the chosen trace colours** (`[[color]]`, `name` + `rgb = "#RRGGBB"` — only the
+25 -1
View File
@@ -1139,6 +1139,29 @@ library measures and draws the rect in tests of its own.
defect this reading exists for, and it was invisible from the frame alone: the file view pinning its
axis from the ring each frame is what swallowed the gesture.
## `panes:` — the locked and configuration modes
```
[udpscope] panes: trigger=locked signals=locked body_visible=0 summary='window · square · 0.25…0.5 · 20 % pre'
[udpscope] panes: trigger=config signals=config body_visible=1 summary=''
```
Both panes have two modes. **Locked** is what a returning user gets — the trigger pane shows the one line
in `summary`, the signal list shows only the signals that are on, and neither offers a control that
changes the configuration — and **config** is the full pane, which is what a *first run* gets, because a
session file that loaded is what "we have been here before" means. Each heading's `[edit]` button flips
its pane.
- **`summary`** is the locked trigger pane's whole content, so it is read rather than assumed: empty here
is a locked pane showing nothing at all. It quotes the shape, the channel, the figures that shape
actually reads (a level, two levels, a width plus its comparison, or a timeout) and the pre-trigger
share — never a figure the shape ignores.
- **`body_visible=0`** is the settings being off screen, which is what makes the pane 42 px tall instead
of 580 in `panels:`.
- **The locked signal list drops the rows of the signals that are off**, so `rows` counts what is on:
`signals panel: … rows=3 signals=2 divider=1` is two on signals and the `MATH` divider, not four
signals. Its `+` goes with them — a `+` on a locked list opens the editor the lock exists to keep shut.
## `panel switches:` and the trigger panel's default sensitivity
```
@@ -1330,7 +1353,8 @@ The signals panel's frame read from the window, and in the same line the *shape*
the heading and the hint the `.ui` gives it, the list the rows go in, how many rows that list holds
(`rows`), how many of them are streamed signals (`signals`), how many math channels the app has (`math`),
and `divider`, which is 1 when `rows == signals + 1 + math` and 0 otherwise. `labels=1` is a `.ui` that lost
one of the two labels, `list=0` one that lost the list -- both of which otherwise show as a panel quietly
one of the two labels — counted over the panel's subtree *above* the list, because the rows' own name
and unit labels are the code's and the heading sits in a heading row beside the pane's `[edit]` button — `list=0` one that lost the list -- both of which otherwise show as a panel quietly
missing a piece -- and **`divider=0` is the merged list that lost its `+`**, the one control that can create
a math channel. The first line above is a run with no math channels at all: the divider is there anyway,
because a `+` that only appeared once a channel existed could never create the first one. `panels: …
+62
View File
@@ -476,6 +476,48 @@ bool load_layout(const std::string& path, LayoutState& out) {
return v;
};
/// One `trigger_*` key, by its name after the prefix. Text for the two enumerations and for the
/// signal, numbers for the rest; `value` arrives unquoted.
const auto trigger_key = [](LayoutState::Trigger& t, const std::string& k, const std::string& v) {
const auto num = [&](double& out) {
try {
out = std::stod(v);
} catch (...) {
}
};
if (k == "enabled") {
t.enabled = v == "true";
} else if (k == "signal") {
t.channel = v;
} else if (k == "type") {
t.type = v;
} else if (k == "edge") {
t.edge = v;
} else if (k == "cmp") {
t.cmp = v;
} else if (k == "mode") {
t.mode = v;
} else if (k == "level") {
num(t.level);
t.level_given = true;
} else if (k == "level2") {
num(t.level2);
} else if (k == "width") {
num(t.width_s);
} else if (k == "width2") {
num(t.width2_s);
} else if (k == "timeout") {
num(t.timeout_s);
} else if (k == "pre") {
num(t.pretrigger_pct);
} else if (k == "holdoff") {
num(t.holdoff_s);
} else if (k == "band") {
num(t.noise_band);
} else if (k == "hf") {
num(t.hf_fc);
}
};
if (section == Section::Top) {
if (key == "window_s") {
try {
@@ -498,6 +540,8 @@ bool load_layout(const std::string& path, LayoutState& out) {
st.measure_stats = value == "true";
} else if (key == "side_tab") {
st.side_tab = strip_quote(value);
} else if (key.rfind("trigger_", 0) == 0) {
trigger_key(st.trigger, key.substr(8), strip_quote(value));
} else if (key == "multicast") {
st.multicast = strip_quote(value);
} else if (key == "interface") {
@@ -648,6 +692,24 @@ bool save_layout(const std::string& path, const LayoutState& state) {
}
o << "measure_stats = " << (state.measure_stats ? "true" : "false") << "\n";
o << "side_tab = \"" << state.side_tab << "\"\n";
// The trigger, settings and state both. Written even when it is off: that is the point of it.
o << "trigger_enabled = " << (state.trigger.enabled ? "true" : "false") << "\n";
if (!state.trigger.channel.empty()) {
o << "trigger_signal = " << toml_escape(state.trigger.channel) << "\n";
}
o << "trigger_type = " << toml_escape(state.trigger.type) << "\n";
o << "trigger_edge = " << toml_escape(state.trigger.edge) << "\n";
o << "trigger_cmp = " << toml_escape(state.trigger.cmp) << "\n";
o << "trigger_mode = " << toml_escape(state.trigger.mode) << "\n";
o << "trigger_level = " << state.trigger.level << "\n";
o << "trigger_level2 = " << state.trigger.level2 << "\n";
o << "trigger_width = " << state.trigger.width_s << "\n";
o << "trigger_width2 = " << state.trigger.width2_s << "\n";
o << "trigger_timeout = " << state.trigger.timeout_s << "\n";
o << "trigger_pre = " << state.trigger.pretrigger_pct << "\n";
o << "trigger_holdoff = " << state.trigger.holdoff_s << "\n";
o << "trigger_band = " << state.trigger.noise_band << "\n";
o << "trigger_hf = " << state.trigger.hf_fc << "\n";
if (!state.comment.empty()) o << "comment = " << toml_escape(state.comment) << "\n";
if (state.buffer_mb > 0.0) o << "buffer_mb = " << state.buffer_mb << "\n";
o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n";
+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.
+64 -5
View File
@@ -982,6 +982,18 @@ void rebuild_trigger_signals(App& app) {
g_object_unref(list);
TriggerConfig& tc = app.model->trigger();
// A session stores the trigger's channel by *name*: resolved here, where the acquired channels
// exist. The pending name is cleared either way, so a name this stream does not have falls through
// to the default below rather than being retried on every rebuild.
if (!app.pending_trigger_channel.empty()) {
for (std::size_t c = 0; c < channels.size(); ++c) {
if (channels[c].name == app.pending_trigger_channel) {
tc.channel = static_cast<int>(c);
break;
}
}
app.pending_trigger_channel.clear();
}
if (tc.channel < 0 && !channels.empty()) tc.channel = 0;
const int sel = tc.channel;
gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_signal_drop),
@@ -4388,6 +4400,39 @@ void on_panel_switch_toggled(GObject* obj, GParamSpec*, gpointer user) {
sync_panel_switches(*app);
}
/// Both panes' modes, applied together: the trigger's body against its summary line, the two [edit]
/// buttons against what they would do, and the signal list rebuilt because the lock decides what a row
/// carries. Idempotent, so a caller only flips a flag and calls this.
void apply_pane_lock(App& app) {
if (app.trigger_body != nullptr) gtk_widget_set_visible(app.trigger_body, !app.trigger_locked);
if (app.trigger_summary != nullptr) {
gtk_widget_set_visible(app.trigger_summary, app.trigger_locked);
}
const auto edit_button = [](GtkWidget* button, bool locked, const char* what) {
if (button == nullptr) return;
gtk_button_set_icon_name(GTK_BUTTON(button),
locked ? "emblem-system-symbolic" : "emblem-ok-symbolic");
const std::string tip = std::string(locked ? "configure " : "keep this configuration of ") + what;
gtk_widget_set_tooltip_text(button, tip.c_str());
};
edit_button(app.trigger_edit, app.trigger_locked, "the trigger");
edit_button(app.signals_edit, app.signals_locked, "the signals");
sync_trigger_summary(app);
// The list, because the lock is what decides whether a row carries a tick, a menu and the `+`.
if (app.signal_list != nullptr) rebuild_signal_panels(app);
}
/// Either pane's [edit] button: one handler, because the two differ only in which flag they flip.
void on_pane_edit_clicked(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user);
if (GTK_WIDGET(button) == app->trigger_edit) {
app->trigger_locked = !app->trigger_locked;
} else if (GTK_WIDGET(button) == app->signals_edit) {
app->signals_locked = !app->signals_locked;
}
apply_pane_lock(*app);
}
void run_app_action(App& app, AppAction action) {
const auto toggle = [](GtkWidget* button) {
if (button == nullptr) return;
@@ -5677,13 +5722,23 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
// list the file gives it. A `.ui` that lost one of them reads here -- `labels=1` or `list=0` -- rather
// than showing as a panel quietly missing its hint.
GtkWidget* panel = app.signal_list != nullptr ? gtk_widget_get_parent(app.signal_list) : nullptr;
// Counted over the panel's *subtree*, not its direct children: the heading lives in a heading row
// beside the pane's [edit] button, and a count of children would read 1 whatever the file lost.
int labels = 0;
int lists = 0;
for (GtkWidget* c = panel != nullptr ? gtk_widget_get_first_child(panel) : nullptr; c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
if (GTK_IS_LABEL(c)) ++labels;
if (GTK_IS_LIST_BOX(c)) ++lists;
}
const std::function<void(GtkWidget*)> count = [&](GtkWidget* w) {
// The list stops the walk: its rows are the code's, and this reading is about the file's frame.
if (GTK_IS_LIST_BOX(w)) {
++lists;
return;
}
if (GTK_IS_LABEL(w)) ++labels;
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
count(c);
}
};
if (panel != nullptr) count(panel);
// The list's shape, beside the frame: `rows` is what the list holds, `signals` the streamed
// ones, and the `MATH` divider plus the math channels account for the rest -- so
// `rows == signals + 1 + math` is the invariant, and `divider=0` is a list that lost its `+`.
@@ -5720,6 +5775,10 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
app.dbg_trigger_sensitive, app.dbg_switcher_visible,
rows.cursors_visible() ? 1 : 0, rows.time_window(),
app.window_limit_s);
std::printf("[udpscope] panes: trigger=%s signals=%s body_visible=%d summary='%s'\n",
app.dbg_trigger_locked ? "locked" : "config",
app.dbg_signals_locked ? "locked" : "config", app.dbg_trigger_body_visible,
app.dbg_trigger_summary.c_str());
std::printf("[udpscope] panel switches: trigger=%d/%d cursors=%d/%d drove_toolbar=%d "
"(pane switch / header bar button; `drove_toolbar` is the pane's own switch having "
"moved the button)\n",
+21 -3
View File
@@ -21,9 +21,27 @@
<property name="margin-start">8</property>
<property name="margin-end">8</property>
<child>
<object class="GtkLabel" id="signals_heading">
<property name="label" translatable="yes">Signals</property>
<property name="halign">start</property>
<!-- The heading row, with the button that switches this pane between its two modes: locked, where
the list shows the signals that are on and nothing else, and configuration, which is the list
with every control on it. It is the code that swaps the icon and the tooltip, because the mode
is the code's. -->
<object class="GtkBox" id="signals_head">
<property name="orientation">horizontal</property>
<property name="spacing">8</property>
<child>
<object class="GtkLabel" id="signals_heading">
<property name="label" translatable="yes">Signals</property>
<property name="halign">start</property>
<property name="hexpand">1</property>
</object>
</child>
<child>
<object class="GtkButton" id="signals_edit">
<property name="icon-name">emblem-system-symbolic</property>
<property name="valign">center</property>
<property name="css-classes">flat</property>
</object>
</child>
</object>
</child>
<child>
+40 -1
View File
@@ -258,6 +258,26 @@ struct App {
/// True while `sync_panel_switches()` is writing the switches: setting a `GtkSwitch` emits its own
/// `notify::active`, and without this the program's own write would come back as a click.
bool panel_switch_sync = false;
/// The channel a session's trigger names, until `rebuild_trigger_signals()` can resolve it: the
/// file stores a *name* and the trigger needs an index into the acquired channels, which do not
/// exist until the stream has been described. Empty means "nothing pending".
std::string pending_trigger_channel;
/// The two panes' modes. *Locked* is the one a returning user gets: the trigger pane shows a line
/// that says what it is set to, the signal list shows only the signals that are on, and neither
/// offers a control that changes the configuration. A first run -- no session file for this source
/// -- starts in configuration mode, because that is what a first run is for. The flags last for the
/// session: a lock is a mode, not a setting, so it is not persisted.
bool trigger_locked = false;
bool signals_locked = false;
GtkWidget* trigger_summary = nullptr;
GtkWidget* trigger_edit = nullptr;
GtkWidget* signals_edit = nullptr;
int dbg_trigger_locked = -1;
int dbg_signals_locked = -1;
int dbg_trigger_body_visible = -1;
/// The compact line the locked trigger pane shows. It is the pane's whole content in that mode, so it
/// is read rather than assumed: empty here is a locked pane showing nothing at all.
std::string dbg_trigger_summary;
/// The cursors pane: listing what the instruments read -- T0/T1/dT always, per row an Y1/Y2/dY
/// where rulers are up, and in `mix` mode the value each cursor crosses each trace at.
GtkWidget* cursors_mode_drop = nullptr;
@@ -1279,7 +1299,7 @@ void on_signal_color_chosen(GObject* source, GAsyncResult* res, gpointer user);
void open_signal_color(App& app, std::size_t signal);
void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals);
/// The `MATH` divider row in the one channel list, and the `+` it carries.
GtkWidget* make_math_divider(App& app);
GtkWidget* make_math_divider(App& app, bool with_add);
void on_math_add_clicked(GtkButton* button, gpointer user);
/// The index in `App::math` of the channel a synthetic signal is named after, or `-1`.
int math_index_for_signal(App& app, const std::string& name);
@@ -1413,6 +1433,25 @@ void select_side_tab(App& app, const char* name);
/// Reads each pane's own switch back off the header bar's button, and the other way round: the two are
/// one control in two places, and `panel_switch_sync` keeps the write from looking like a click.
void sync_panel_switches(App& app);
/// The trigger's four enumerations as words: the panel's dropdown labels and the session file's
/// vocabulary, in one place so the two cannot drift.
const char* trigger_type_text(TriggerType t);
TriggerType trigger_type_from_text(const std::string& s);
const char* trigger_edge_text(TriggerEdge e);
TriggerEdge trigger_edge_from_text(const std::string& s);
const char* width_cmp_text(WidthCmp c);
WidthCmp width_cmp_from_text(const std::string& s);
const char* trigger_mode_text(TriggerMode m);
TriggerMode trigger_mode_from_text(const std::string& s);
/// The panel's controls from the model, and the session's trigger into both.
void write_trigger_widgets(App& app);
/// The compact line the trigger pane shows while it is locked, and the lock itself: the panes'
/// bodies, their [edit] buttons and the signal list's rows all follow from `trigger_locked` and
/// `signals_locked`.
void sync_trigger_summary(App& app);
void apply_pane_lock(App& app);
void on_pane_edit_clicked(GtkButton* button, gpointer user);
void apply_trigger_layout(App& app, const LayoutState::Trigger& t);
void on_panel_switch_toggled(GObject* obj, GParamSpec*, gpointer user);
void update_side_visibility(App& app);
void on_sidebar_toggled(GtkToggleButton* button, gpointer user);
+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) {
+7
View File
@@ -2322,6 +2322,13 @@ gboolean exit_probe_step1(gpointer user) {
App* a = static_cast<App*>(user);
// Record the panel states before the toggles are exercised.
a->dbg_trigger_sensitive_default = gtk_widget_get_sensitive(a->trigger_body) ? 1 : 0;
a->dbg_trigger_locked = a->trigger_locked ? 1 : 0;
a->dbg_signals_locked = a->signals_locked ? 1 : 0;
a->dbg_trigger_body_visible =
a->trigger_body != nullptr && gtk_widget_get_visible(a->trigger_body) ? 1 : 0;
if (a->trigger_summary != nullptr) {
a->dbg_trigger_summary = gtk_label_get_text(GTK_LABEL(a->trigger_summary));
}
a->dbg_panel_switch_live =
a->trigger_switch != nullptr && gtk_widget_get_sensitive(a->trigger_switch) &&
a->cursors_switch != nullptr && gtk_widget_get_sensitive(a->cursors_switch)
+29 -11
View File
@@ -166,7 +166,7 @@ int math_index_for_signal(App& app, const std::string& name) {
/// The `MATH` boundary in the one channel list, carrying the `+` that creates a channel. Not
/// selectable and not activatable: it is a heading, and a heading that highlights under the
/// pointer reads as a row you can plot.
GtkWidget* make_math_divider(App& app) {
GtkWidget* make_math_divider(App& app, bool with_add) {
GtkWidget* row = gtk_list_box_row_new();
gtk_list_box_row_set_selectable(GTK_LIST_BOX_ROW(row), FALSE);
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), FALSE);
@@ -180,11 +180,16 @@ GtkWidget* make_math_divider(App& app) {
gtk_widget_set_halign(label, GTK_ALIGN_START);
gtk_widget_set_hexpand(label, TRUE);
gtk_box_append(GTK_BOX(box), label);
GtkWidget* add = gtk_button_new_from_icon_name("list-add-symbolic");
gtk_widget_add_css_class(add, "flat");
gtk_widget_set_tooltip_text(add, "add a math channel");
g_signal_connect(add, "clicked", G_CALLBACK(on_math_add_clicked), &app);
gtk_box_append(GTK_BOX(box), add);
// Locked: the heading stays, because the list it divides is the one being read, but the `+` that
// creates a channel does not. A `+` on a locked list is a button that opens the editor the lock
// exists to keep shut.
if (with_add) {
GtkWidget* add = gtk_button_new_from_icon_name("list-add-symbolic");
gtk_widget_add_css_class(add, "flat");
gtk_widget_set_tooltip_text(add, "add a math channel");
g_signal_connect(add, "clicked", G_CALLBACK(on_math_add_clicked), &app);
gtk_box_append(GTK_BOX(box), add);
}
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box);
return row;
}
@@ -278,6 +283,9 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
std::size_t plain = 0;
for (std::size_t i = 0; i < signals.size(); ++i) {
if (ScopeModel::is_time_signal(signals, i)) continue; // the time base is not plottable
// Locked: a row per signal that is *on*, which is the list a user reads rather than the list they
// configure. Turning one on is what the pane's [edit] button is for.
if (app.signals_locked && !app.model->signal_enabled(i)) continue;
const std::string base = layer_base_name(signals[i].name);
bool seen = false;
for (std::size_t j = 0; j < i && !seen; ++j) {
@@ -293,7 +301,7 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
// `+` that creates the first one.
if (sig.math && !math_divider_done) {
math_divider_done = true;
gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app));
gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app, !app.signals_locked));
}
if (!sig.math) ++plain;
GtkWidget* row = gtk_list_box_row_new();
@@ -309,7 +317,7 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
chip, !app.model->signal_enabled(i) ? "not acquired"
: plotted ? "the colour this signal is plotted in -- click to change it"
: "on no plot, so there is no trace to colour");
if (plotted) {
if (plotted && !app.signals_locked) {
// A gesture on the label rather than a `GtkButton` around it: a button brings its own
// padding, and padding on this row is what made the list twice as tall once already (see
// this function's note). The chip also has to stay exactly one disc wide, or the names
@@ -343,7 +351,9 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
gtk_widget_set_visible(enable, sig.math || app.waveform_name.empty());
g_object_set_data(G_OBJECT(enable), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(enable, "notify::active", G_CALLBACK(on_signal_switch), &app);
gtk_box_append(GTK_BOX(box), enable);
// Locked: the switch decides what is acquired, so it is a configuration control and it goes with
// the rest of them.
if (!app.signals_locked) gtk_box_append(GTK_BOX(box), enable);
GtkWidget* name = gtk_label_new(sig.name.c_str());
gtk_widget_set_hexpand(name, true);
@@ -435,7 +445,9 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
gtk_menu_button_set_popover(GTK_MENU_BUTTON(more), popover);
gtk_widget_add_css_class(more, "flat");
gtk_widget_set_tooltip_text(more, "information and calibration");
gtk_box_append(GTK_BOX(box), more);
// Locked: the menu holds calibration, deletion and a rename, which are exactly what this mode is
// not for. The popover is left unattached, so nothing can reach it.
if (!app.signals_locked) gtk_box_append(GTK_BOX(box), more);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box);
@@ -451,7 +463,9 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
}
// Appended when there are no math channels at all: the `+` is the only way to create the first
// one, so it cannot be conditional on one already existing.
if (!math_divider_done) gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app));
if (!math_divider_done) {
gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app, !app.signals_locked));
}
app.dbg_signal_plain = plain;
app.dbg_signal_rows = 0;
for (GtkWidget* c = gtk_widget_get_first_child(list); c != nullptr;
@@ -567,6 +581,10 @@ GtkWidget* build_signal_panel(App& app) {
GtkBuilder* ui = ui_load(app, "/org/udpscope/ui/signals-panel.ui");
GtkWidget* box = GTK_WIDGET(gtk_builder_get_object(ui, "signal_panel"));
app.signal_list = GTK_WIDGET(gtk_builder_get_object(ui, "signal_list"));
app.signals_edit = GTK_WIDGET(gtk_builder_get_object(ui, "signals_edit"));
if (app.signals_edit != nullptr) {
g_signal_connect(app.signals_edit, "clicked", G_CALLBACK(on_pane_edit_clicked), &app);
}
if (box == nullptr || app.signal_list == nullptr) {
// A missing id is a mistake in the `.ui`: the panel comes up empty rather than taking the window with
// it, and `[udpscope] signals panel:` reads the difference.
+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,