diff --git a/docs/claude/invariants-trigger-and-pinning.md b/docs/claude/invariants-trigger-and-pinning.md
index f43d389..b9fdd31 100644
--- a/docs/claude/invariants-trigger-and-pinning.md
+++ b/docs/claude/invariants-trigger-and-pinning.md
@@ -18,6 +18,23 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
reports it as `zoom ... span=.. window=.. limited=1`.
Its pan clamping is likewise gated on the data being wider than the view, or the empty
left-hand side drags the live edge off the right-hand edge.
+- **The compact pane's arm control is built in the heading, and *one* function states both arm
+ buttons.** Arming is a runtime decision and the locked pane is exactly where it is made, so the
+ control cannot live in the body — that is the part which goes away. `sync_arm_buttons()` is what
+ states the label, the sensitivity and the visibility of both buttons, and it is called from the four
+ places any of its three inputs can change (the click, the toolbar toggle, the curator's re-read,
+ `apply_pane_lock()`). Visibility is `enabled && locked`: with trigger mode off the line beside it
+ already says "stream (trigger off)" and there is no engine to arm, and with the pane open the body's
+ button is the one on screen — **two arm buttons at once would be two controls for one state**, which
+ is why the probe reads the body's allocation as `-1` while the compact one is up.
+- **A widget's allocation is not readable in the tick it is first shown.** The compact arm control is
+ created hidden and shown for the first time inside a signal handler, so `gtk_widget_get_width()` in
+ that same tick reads `0` while `gtk_widget_is_visible()` already says `1` — and its sibling's stale
+ allocation (270 px, from when the *body* was still on screen) reads as a live one, because
+ `gtk_widget_get_visible()` is the widget's *own* flag and stays true under a hidden parent. The width
+ only becomes the proof that a control is on the surface **one frame later**, which is why the probe
+ has a `compact_arm_step()` of its own; and the control to compare against must be measured with
+ `is_visible()` or not at all.
- **Signals start disabled** — enabling is what starts acquisition. A trigger on a
signal nobody acquires can never fire, so selecting/arming enables it.
- **The trigger's event window is separate from the display window**, and
diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md
index 480694a..83b8103 100644
--- a/docs/claude/probe-readings.md
+++ b/docs/claude/probe-readings.md
@@ -1207,6 +1207,16 @@ its pane.
a trigger is configured before it is switched on — so this reads `1` always, and a `0` means the
settings have been made unreachable again. The *cursors'* body is disabled with the cursors, and that
is the pane whose readout has nothing to show.
+- **`compact arm with the trigger off: visible=0` / `with the trigger on and the pane locked:
+ visible=1 sensitive=1 width=51 label=Disarm`** is the compact pane's arm control, in the two states
+ that define it. The first says it is *not* on the line when there is no engine to arm; the second says
+ it is, and that it is live (`sensitive=1`) and laid out (`width` > 0 — a width can only be read the
+ frame *after* it is first shown, see the invariants). `label` must agree with `model->trigger_armed()`
+ rather than with a fixed word, because a single-shot latch may have disarmed it by the time the probe
+ looks. `[udpscope] compact arm probe: locked=1 enabled=1 own=1 effective=1 width=51 panel_width=-1
+ label=Disarm` prints the inputs beside the outcome, and **`panel_width=-1` is the assertion that the
+ body's own Arm button is not on screen at the same time** — two arm controls for one state is the
+ defect it exists to catch.
- **`arm sensitive with the trigger off=0`** is the one exception inside that body: `Arm` is a *runtime*
control, so with trigger mode off there is no engine to arm and it is disabled (and labelled `Arm`
again). `1` here is an Arm button that does nothing when clicked.
diff --git a/docs/claude/verification.md b/docs/claude/verification.md
index 51110a9..f0aa18b 100644
--- a/docs/claude/verification.md
+++ b/docs/claude/verification.md
@@ -15,7 +15,7 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
Two baselines every run is read against. A clean build emits **four** warnings and no more: two
`-Wmissing-field-initializers` on `MathNode::source` and `-Woverlength-strings` on **both**
-generated GResource `.c` files — the help pages' (**515700** bytes) and the icons', whose blob is
+generated GResource `.c` files — the help pages' (**516044** bytes) and the icons', whose blob is
**12352** against C99's 4095-byte string-literal limit with the seven action icons and the two app icons
that remain (a note about a generated file, which GCC itself supports) -- re-measured when
the tab work changed the set (that icon left, the two tab glyphs arrived, and this file's
diff --git a/src/help/08-trigger.txt b/src/help/08-trigger.txt
index 8c4a654..a05247f 100644
--- a/src/help/08-trigger.txt
+++ b/src/help/08-trigger.txt
@@ -9,7 +9,11 @@ The captured window is frozen on screen until you re-arm.
The trigger button in the toolbar (t) reveals the trigger panel: the signal, the level,
the shape, the edge (rising, falling, either), the mode (Normal or Single) and the pre-trigger
-share. Arm starts watching; a arms and disarms from the keyboard.
+share. Arm starts watching; a arms and disarms from the keyboard. When the pane is
+locked with its [edit] button — so that it states what the trigger is set to instead of showing
+the controls — Arm/Disarm is in its heading, and the trigger can be armed or disarmed without
+unfolding the panel. It is offered only while trigger mode is on, because a trigger that is off has no
+engine to arm.
Search the buffer is at the foot of the same panel.
Worth knowing
diff --git a/src/ui.cpp b/src/ui.cpp
index 4941deb..26b194b 100644
--- a/src/ui.cpp
+++ b/src/ui.cpp
@@ -3659,7 +3659,7 @@ gboolean tick(gpointer user) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->trigger_toggle), trigger_on);
}
const bool armed = model.trigger_armed().load(std::memory_order_relaxed);
- gtk_button_set_label(GTK_BUTTON(app->arm_button), armed ? "Disarm" : "Arm");
+ sync_arm_buttons(*app);
if (new_capture) {
// The edge has been accepted, which means the pre window was already full, so the
// whole event window can be framed now and the post side filled in view.
@@ -4442,6 +4442,9 @@ void apply_pane_lock(App& app) {
};
edit_button(app.trigger_edit, app.trigger_locked, "the trigger");
edit_button(app.signals_edit, app.signals_locked, "the signals");
+ // The lock decides whether the compact line's arm control is the one on screen: with the body shown the
+ // body's own button is there, and two arm buttons at once would be two controls for one state.
+ sync_arm_buttons(app);
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);
@@ -4898,8 +4901,9 @@ void on_activate(GtkApplication* application, gpointer user) {
// switch that turns it back on. The trigger's body is deliberately *not* disabled: a trigger is
// configured before it is switched on, so its settings have to stay editable while it is off.
gtk_widget_set_sensitive(app->cursors_body, FALSE);
- // The trigger's Arm button is the one control in its pane that needs trigger mode running.
- if (app->arm_button != nullptr) gtk_widget_set_sensitive(app->arm_button, FALSE);
+ // The arm controls are the one part of the trigger pane that needs trigger mode running: both of them,
+ // and the compact line's visibility, are stated from the toggle by `sync_arm_buttons()`.
+ sync_arm_buttons(*app);
gtk_widget_set_sensitive(app->cursors_reset, FALSE);
if (app->side_stack != nullptr) {
@@ -6014,10 +6018,14 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
app.dbg_badge_frames[3]);
std::printf("[udpscope] trigger panel sensitive before the probe toggled it: %d; "
"signal list items=%d selected=%d; column visible after sidebar toggle off=%d; "
- "pane switches live while their panes are off=%d; arm sensitive with the trigger off=%d\n",
+ "pane switches live while their panes are off=%d; arm sensitive with the trigger off=%d; "
+ "compact arm with the trigger off: visible=%d; with the trigger on and the pane "
+ "locked: visible=%d sensitive=%d width=%d label=%s\n",
app.dbg_trigger_sensitive_default, app.dbg_trigger_items,
app.dbg_trigger_selected, app.dbg_side_column_after_hide,
- app.dbg_panel_switch_live, app.dbg_arm_sensitive);
+ app.dbg_panel_switch_live, app.dbg_arm_sensitive, app.dbg_summary_arm_off,
+ app.dbg_summary_arm_visible, app.dbg_summary_arm_sensitive, app.dbg_summary_arm_width,
+ app.dbg_summary_arm_label.c_str());
{
// The panel's *frame* read back: the row boxes the `.ui` holds, and that every control the code built
// ended up inside the panel rather than in a row that is not there. A control is created in code and
diff --git a/src/ui_app.hpp b/src/ui_app.hpp
index a794b57..fb24e85 100644
--- a/src/ui_app.hpp
+++ b/src/ui_app.hpp
@@ -281,9 +281,18 @@ struct App {
static constexpr int kTriggerSummaryRows = 8;
GtkWidget* trigger_summary_keys[kTriggerSummaryRows] = {};
GtkWidget* trigger_summary_values[kTriggerSummaryRows] = {};
+ /// The compact line's arm control. It lives in the pane's *heading*, the part that stays on screen when
+ /// the body is hidden, and `sync_arm_buttons()` states it: present only while trigger mode is on and the
+ /// pane is locked, which is the one combination where the body's own Arm button has gone with the body.
+ GtkWidget* trigger_summary_arm = nullptr;
GtkWidget* trigger_edit = nullptr;
GtkWidget* signals_edit = nullptr;
int dbg_trigger_locked = -1;
+ int dbg_summary_arm_off = -1; ///< the compact line's arm control with the trigger *off*
+ int dbg_summary_arm_visible = -1; ///< the compact line's arm control, locked and with the trigger on
+ int dbg_summary_arm_sensitive = -1;
+ std::string dbg_summary_arm_label; ///< its text, which is the armed state in the user's words
+ int dbg_summary_arm_width = -1; ///< its allocation a frame later: 0 means "not laid out"
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
@@ -1476,6 +1485,14 @@ void row_drag_sidebar(App& app, bool dragging);
/// bodies, their [edit] buttons and the signal list's rows all follow from `trigger_locked` and
/// `signals_locked`.
void sync_trigger_summary(App& app);
+
+/// Both arm buttons -- the panel's and the compact line's -- from the one armed state the producer keeps in
+/// `model->trigger_armed()`. The label was written from four places (the click, the toolbar toggle, the
+/// curator's re-read, the start-up default) and a second button would have needed a fifth copy of the same
+/// two lines; the drift that costs is a button reading "Arm" while the engine is armed. Sensitivity, and
+/// the compact button's visibility, are stated here too and read from the toolbar toggle -- the control
+/// that owns `enabled`, per the restore rules -- so no caller needs the model lock to state any of it.
+void sync_arm_buttons(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);
diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp
index 61f437f..96d97ff 100644
--- a/src/ui_probe.cpp
+++ b/src/ui_probe.cpp
@@ -206,6 +206,44 @@ gboolean exit_probe_step3(gpointer user) {
return G_SOURCE_REMOVE;
}
+/// The frame after the pane was locked: read the compact arm control's *allocation*, then leave the
+/// configuration mode and trigger mode as step 2 used to, and hand the chain back its 400 ms. The button
+/// is created hidden and this is the first tick it is shown, so its width in the same tick would always
+/// read 0 -- a number that says "not yet laid out" rather than "not on the surface".
+gboolean compact_arm_step(gpointer user) {
+ App* a = static_cast(user);
+ GtkWidget* b = a->trigger_summary_arm;
+ a->dbg_summary_arm_width = b != nullptr ? gtk_widget_get_width(b) : -1;
+ std::fprintf(stderr,
+ "[udpscope] compact arm probe: locked=%d enabled=%d own=%d effective=%d "
+ "width=%d panel_width=%d label=%s\n",
+ a->trigger_locked ? 1 : 0,
+ a->trigger_toggle != nullptr &&
+ gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->trigger_toggle)) != FALSE
+ ? 1 : 0,
+ b != nullptr && gtk_widget_get_visible(b) ? 1 : 0,
+ b != nullptr && gtk_widget_is_visible(b) ? 1 : 0, a->dbg_summary_arm_width,
+ // The body's own Arm button, read only if it is itself on screen: with the pane locked it
+ // is not, and its stale allocation would read as if it were. -1 is "not the one on screen".
+ // `is_visible`, not `get_visible`: the flag is the widget's own and stays true while its
+ // *body* is hidden, which reported the body button's stale allocation as a live one.
+ a->arm_button != nullptr && gtk_widget_is_visible(a->arm_button)
+ ? gtk_widget_get_width(a->arm_button)
+ : -1,
+ a->dbg_summary_arm_label.c_str());
+ std::fflush(stderr);
+ if (a->trigger_edit != nullptr) {
+ g_signal_emit_by_name(a->trigger_edit, "clicked"); // and back to the view only mode
+ }
+ if (a->trigger_toggle != nullptr) {
+ gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), FALSE);
+ }
+ // The tick keeps running for these 400 ms, so by step 3 the status line reads
+ // the state the app is actually in after leaving trigger mode.
+ g_timeout_add(400, exit_probe_step3, a);
+ return G_SOURCE_REMOVE;
+}
+
gboolean exit_probe_step2(gpointer user) {
App* a = static_cast(user);
a->dbg_mode_triggered = a->status_detail;
@@ -215,12 +253,21 @@ gboolean exit_probe_step2(gpointer user) {
a->dbg_trigger_time = a->model->trigger_time().load();
a->dbg_trigger_time_valid = a->model->trigger_time_valid().load() ? 1 : 0;
}
- if (a->trigger_toggle != nullptr) {
- gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), FALSE);
+ // The combination the compact control exists for: the trigger *on*, the pane *locked*, so the body's
+ // own Arm button has gone with the body. Locking is the [edit] button's click, because that is what a
+ // user does, and the two properties read are the two the user sees. The label is checked against the
+ // engine's armed state rather than against a word: a single-shot latch can have disarmed it by now.
+ if (a->trigger_edit != nullptr) {
+ g_signal_emit_by_name(a->trigger_edit, "clicked"); // into configuration mode
+ GtkWidget* b = a->trigger_summary_arm;
+ a->dbg_summary_arm_visible = b != nullptr && gtk_widget_get_visible(b) ? 1 : 0;
+ a->dbg_summary_arm_sensitive = b != nullptr && gtk_widget_get_sensitive(b) ? 1 : 0;
+ const char* text = b != nullptr && GTK_IS_BUTTON(b) ? gtk_button_get_label(GTK_BUTTON(b)) : nullptr;
+ a->dbg_summary_arm_label = text != nullptr ? text : "-";
}
- // The tick keeps running for these 400 ms, so by step 3 the status line reads
- // the state the app is actually in after leaving trigger mode.
- g_timeout_add(400, exit_probe_step3, a);
+ // The pane stays locked for one more frame: the allocation the next step reads is only meaningful once
+ // a frame has run with the control shown. Leaving trigger mode is that step's job now.
+ g_timeout_add(400, compact_arm_step, a);
return G_SOURCE_REMOVE;
}
@@ -2400,6 +2447,11 @@ gboolean exit_probe_step1(gpointer user) {
// and asking it for a label's text was itself a critical.
a->dbg_trigger_summary_early = a->dbg_trigger_summary; // as found, before this probe touches it
a->dbg_arm_sensitive = a->arm_button != nullptr && gtk_widget_get_sensitive(a->arm_button) ? 1 : 0;
+ // The compact line's arm control must be *absent* here: the trigger is off, the line beside it reads
+ // "stream (trigger off)", and a trigger that is off has no engine to arm. Read before the toggle, which
+ // is where that absence is the assertion -- and it is the pair to the reading in step 2.
+ a->dbg_summary_arm_off =
+ a->trigger_summary_arm != nullptr && gtk_widget_get_visible(a->trigger_summary_arm) ? 1 : 0;
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)
diff --git a/src/ui_trigger.cpp b/src/ui_trigger.cpp
index 6ed5e92..f5a9511 100644
--- a/src/ui_trigger.cpp
+++ b/src/ui_trigger.cpp
@@ -14,6 +14,31 @@
namespace udpscope {
namespace ui_impl {
+/// Both arm buttons from the one state, so the panel's and the compact line's cannot disagree about what
+/// the engine is doing. Three properties, three sources, and they are deliberately not the same source:
+/// the *label* is the producer's atomic (no lock), the *sensitivity* and the compact button's
+/// *visibility* are the toolbar toggle's state -- the toggle is what owns `enabled`, and reading it
+/// instead of the model keeps this callable from a signal handler that does not hold, and must not take,
+/// the model lock. Visibility is `enabled && locked`: with trigger mode off the line's own text says
+/// "stream (trigger off)" and there is no engine to arm, and with the pane open the body's button is the
+/// one on screen -- never both at once.
+void sync_arm_buttons(App& app) {
+ const bool armed = app.model->trigger_armed().load(std::memory_order_relaxed);
+ const bool enabled =
+ app.trigger_toggle != nullptr &&
+ gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.trigger_toggle)) != FALSE;
+ const char* label = armed ? "Disarm" : "Arm";
+ if (app.arm_button != nullptr) {
+ gtk_button_set_label(GTK_BUTTON(app.arm_button), label);
+ gtk_widget_set_sensitive(app.arm_button, enabled);
+ }
+ if (app.trigger_summary_arm != nullptr) {
+ gtk_button_set_label(GTK_BUTTON(app.trigger_summary_arm), label);
+ gtk_widget_set_sensitive(app.trigger_summary_arm, enabled);
+ gtk_widget_set_visible(app.trigger_summary_arm, enabled && app.trigger_locked);
+ }
+}
+
void on_arm_clicked(GtkButton* button, gpointer user) {
App* app = static_cast(user);
(void)button;
@@ -37,7 +62,7 @@ void on_arm_clicked(GtkButton* button, gpointer user) {
// is switched on with it -- otherwise the trigger could never see a sample.
// Outside the lock: set_signal_enabled() takes it.
if (armed && signal >= 0) app->model->set_signal_enabled(static_cast(signal), true);
- gtk_button_set_label(GTK_BUTTON(app->arm_button), armed ? "Disarm" : "Arm");
+ sync_arm_buttons(*app);
}
/// The trigger type dropdown's order, as a table rather than a cast: the dropdown lists
@@ -409,10 +434,10 @@ void on_trigger_toggled(GtkToggleButton* button, gpointer user) {
// column of stale events beside the plot. Recording is the other half of that answer, so this is
// `sync_events_availability()`'s call and not the trigger's alone.
sync_events_availability(*app);
- gtk_button_set_label(GTK_BUTTON(app->arm_button), on ? "Disarm" : "Arm");
- // Arming is a *runtime* control: with trigger mode off there is no engine to arm, and the button is the
- // one part of this pane that is not a setting. (The body itself stays live -- see above.)
- if (app->arm_button != nullptr) gtk_widget_set_sensitive(app->arm_button, on);
+ // Arming is a *runtime* control: with trigger mode off there is no engine to arm, and the arm buttons
+ // are the one part of this pane that is not a setting. (The body itself stays live -- see above.) Both
+ // of them are stated from this switch's new state in one place, so they cannot disagree with each other.
+ sync_arm_buttons(*app);
// The pane's own switch shows the same state, whichever of the two was clicked, and the compact line
// changes shape with it: with trigger mode off it says "stream" and nothing else.
sync_panel_switches(*app);
@@ -664,6 +689,16 @@ GtkWidget* build_trigger_panel(App& app) {
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);
+ // The compact line's own arm control, built here beside the [edit] and the switch for the same reason
+ // the switch is: this row stays on screen when the body is hidden. A locked pane is exactly where
+ // arming is decided -- the body's button left with the body -- and the heading is where it has to be.
+ app.trigger_summary_arm = gtk_button_new_with_label("Arm");
+ gtk_widget_set_valign(app.trigger_summary_arm, GTK_ALIGN_CENTER);
+ gtk_widget_set_tooltip_text(app.trigger_summary_arm,
+ "arm or disarm the trigger; disarming freezes the view where it stands");
+ gtk_widget_set_visible(app.trigger_summary_arm, FALSE);
+ g_signal_connect(app.trigger_summary_arm, "clicked", G_CALLBACK(on_arm_clicked), &app);
+ gtk_box_append(GTK_BOX(head), app.trigger_summary_arm);
app.trigger_switch = gtk_switch_new();
gtk_widget_set_valign(app.trigger_switch, GTK_ALIGN_CENTER);
gtk_widget_set_tooltip_text(app.trigger_switch,