Task 1's review found two user-facing files that no task owned: src/ui/shortcuts.ui still describes four accelerators as showing and hiding panes, and src/help/01-overview.txt still lists the five-revealer column. Both were made false by the tabbed sidebar and neither is caught by a probe, so give them an owner.
67 KiB
Tabbed side panel and one channel list: implementation plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the sidebar's five stacked GtkRevealer sections with a two-page
AdwViewStack (Measure: cursors + measurements; Signals: trigger + the channel list), fold
the math pane into the signal list, and make the trigger and cursors toggles disable their pane
instead of hiding it.
Architecture: The sidebar child of side_view becomes a vertical GtkBox holding an
AdwViewSwitcher above an AdwViewStack; each stack page owns its own GtkScrolledWindow. The
four existing build_*_panel() functions are unchanged and are appended to a page's box instead
of to a revealer. The math pane is deleted outright because ScopeModel::append_math_signals_locked()
already puts every math channel into signals(), so every math channel is already a row in the
signal list — the merge adds a MATH divider row carrying a +, and a second menu for math rows.
The current page is persisted in the session file as one new top-level key.
Tech Stack: C++17, GTK4, libadwaita ≥ 1.8 (1.10.0 installed), blueprint-less .ui XML +
GResource, CMake. No unit-test framework: the automated checks are make test-ui (a .ui/GIR
round trip plus "every .ui is in the gresource xml") and the application's own headless
self-check under xvfb-run, driven by UDPSCOPE_* environment variables against
tools/mock_udpstreamer.py.
Global Constraints
Values below are copied verbatim from docs/superpowers/specs/2026-10-14-sidebar-tabs-design.md
and from docs/claude/verification.md. Every task's requirements implicitly include this section.
- Every new or edited source file keeps its SPDX MIT header:
// SPDX-License-Identifier: MIT+// SPDX-FileCopyrightText: 2026 Martino Ferrari(<!-- ... -->in XML/Markdown). - Build baselines, re-read on every run: exactly four warnings on a clean build (two
-Wmissing-field-initializersonMathNode::source, two-Woverlength-stringson the help-pages' and the icons' generated GResource.c), two on an incremental build, and zeroGtk-CRITICALin any headless run. Task 6 deliberately changes the icon warning and re-measures it. make test-uimust pass at every commit. It reports a file count forsrc/ui: 15 until task 5 deletesmath-panel.ui, 14 after.- No
gtk_widget_*call may appear in the dump. The dump runs after the window is destroyed; every size, state and sensitivity it prints is captured on the tick or in a probe into anApp::dbg_*field. - Two threads only — a producer owning the UDP client, and the GTK main thread owning every widget. Nothing in this plan touches the producer thread.
- Exactly two tabs, named and titled
measure/ "Measure" andsignals/ "Signals". The switcher shows titles only; neither page gets anicon-name. - The switcher never scrolls: it is a sibling above the stack, not inside a page's scroller.
- A scroller per page, never one shared scroller around the stack.
- Nothing inside a tab collapses. No
GtkRevealerand noGtkPanedmay be reintroduced in the sidebar. - Trigger-off and cursors-off disable their pane (
gtk_widget_set_sensitive), and never hide, collapse or un-reveal it. A readout whose instruments are off blanks its values. - The blank value is
"--", two ASCII hyphens — the spellingupdate_cursors_pane()already writes for an unavailable cell (src/ui_cursors.cpp:69-72). The spec's—is the same state; two spellings of one state in one grid would be the defect. App::mathis the copy the session file is written from. Any control that changes a math channel's enabled state must mirror it intoApp::mathbeforeschedule_layout_save().- The statistics accumulator keeps running on the hidden page. No frame-rate code may be gated on which tab is current, and no functional code may read a sidebar widget's allocation.
- Out of scope: the events pane left of the plot, export, the node editor's internals, the status bar.
File Structure
Modified — markup and resources
| File | Responsibility after the change |
|---|---|
src/ui/main-window.ui |
The sidebar child becomes side_column (vertical box) → side_switcher + side_stack with two AdwViewStackPages, each a scroller around a page box. side_scroll is gone. |
src/ui/header-bar.ui |
Five controls at the far end: trigger, events, pause, cursors, sidebar, menu. The math_toggle and measures_toggle children are deleted. |
src/ui/math-panel.ui |
Deleted (task 5). |
src/ui/udpscope-ui.gresource.xml |
math-panel.ui entry deleted (task 5). |
src/icons/udpscope.gresource.xml |
The measure and math symbolic entries deleted (task 6). |
src/icons/scalable/actions/udpscope-measure-symbolic.svg |
Deleted (task 6). |
src/icons/scalable/actions/udpscope-math-symbolic.svg |
Deleted (task 6). |
Modified — code
| File | Responsibility after the change |
|---|---|
src/ui_app.hpp |
Owns the new side_stack, side_switcher, cursors_panel, side_tab fields and the select_side_tab / math_index_for_signal declarations; loses the five revealer fields, three toggle fields, side_scroll, math_list, measures_revealed, signals_panel_initialised. |
src/ui.cpp |
Builds the two pages in on_activate; select_side_tab(); the stack's notify::visible-child-name handler; update_side_visibility(), note_panel_controls(), run_app_action() and every dump line that named a revealer or a deleted toggle. |
src/ui_trigger.cpp |
on_trigger_toggled() sets the trigger panel's sensitivity. |
src/ui_cursors.cpp |
on_cursors_toggled() sets the cursors panel's sensitivity; update_cursors_pane() blanks to "--" when the cursors are off; build_cursors_panel() stores App::cursors_panel. |
src/ui_signals.cpp |
The one channel list: the MATH divider with its +, the per-kind menu, the App::math enabled mirror, and the nullptr guard that lets apply_math_channels() call it before the list exists. on_signals_toggled() is deleted. |
src/ui_math.cpp |
apply_math_channels() rebuilds the signal list; build_math_panel() deleted (task 5). |
src/shortcuts.hpp, src/shortcuts.cpp |
AppAction::SideTabMeasure / SideTabSignals; the four rewritten accelerator descriptions. |
src/layout_store.hpp, src/layout_store.cpp |
LayoutState::side_tab, its parser branch and its writer line. |
src/ui_layout.cpp |
Saves app.side_tab; restores it inside the updating_panels window. |
src/ui_probe.cpp |
The changed captures in exit_probe_step1/exit_probe_step3, cursors_probe_report, measures_probe_step, keys_probe; the new side_tab_probe (task 7). |
Modified — docs (task 6 and task 7)
docs/claude/probe-readings.md, docs/claude/verification.md,
docs/claude/invariants-gtk-and-chrome.md, docs/claude/invariants-math-channels.md,
docs/claude/invariants-readouts-and-cursors.md, README.md.
Task order, and why it is not negotiable
- The tabbed container and the deletion of
measures_toggle/math_toggle/signal_toggleare one task: their handlers only drove revealers, so the code does not compile with the revealers gone and the toggles kept. A reviewer cannot approve one half without the other. - The math pane stays on the signals page through tasks 1-4. Deleting it before the list has
the
+and the Edit/Delete menu would leave two commits in which a math channel cannot be created or edited at all. - So that no call is added and then removed, task 1 makes
apply_math_channels()call bothrebuild_math_pane(app)andrebuild_signal_panels(app); task 5 deletes only the first line. That needs an earlynullptrguard inrebuild_signal_list(), added in task 1.
Task 1: The tabbed sidebar, and toggles that disable
The big one, and it is indivisible: the container, the field churn, the two deleted toggles and the sensitivity switch all have to land together or the build breaks.
Files:
- Modify:
src/ui/main-window.ui:50-64(the sidebar property) and:20-24(the header comment) - Modify:
src/ui/header-bar.ui— delete themath_toggleandmeasures_togglechildren, correct the header comment - Modify:
src/ui_app.hpp— fields and declarations - Modify:
src/ui.cpp—on_activateassembly,update_side_visibility(),note_panel_controls(),run_app_action(), the toolbar builder, the dump - Modify:
src/ui_trigger.cpp:198 - Modify:
src/ui_cursors.cpp:16-17,:80-114,:116-132 - Modify:
src/ui_signals.cpp:139(the guard),:303-307(delete),:384-413 - Modify:
src/ui_math.cpp:17-27 - Modify:
src/shortcuts.hpp:8-21,src/shortcuts.cpp:11-34 - Modify:
src/ui_probe.cpp:70-108,:2189-2190,:2233-2234,:2501,:4028-4047 - Test:
make test-ui, then the exit probe underxvfb-run
Interfaces:
- Consumes:
build_trigger_panel(App&),build_cursors_panel(App&),build_measures_panel(App&),build_signal_panel(App&)— allGtkWidget*, all unchanged. - Produces, for tasks 2-7:
GtkWidget* App::side_stack— theAdwViewStack;App::side_boxnow points at the same widget's parent box, i.e.side_column.GtkWidget* App::side_switcher— theAdwViewSwitcher.GtkWidget* App::cursors_panel— the root returned bybuild_cursors_panel().std::string App::side_tab—"measure"or"signals"; initialised"signals".void select_side_tab(App& app, const char* name)— sets the stack's visible child by name and updatesApp::side_tab; a no-op ifside_stackis null.AppAction::SideTabMeasure,AppAction::SideTabSignals.int App::dbg_reset_sensitive,int App::dbg_signal_page_current,int App::dbg_trigger_sensitive,int App::dbg_switcher_visible.
Steps
- Step 1: Replace the sidebar markup
In src/ui/main-window.ui, replace lines 50-64 (the whole <property name="sidebar"> block)
with:
<property name="sidebar">
<!-- A switcher above a stack, and a scroller *per page*: the two pages are different
heights, so one shared scroller around the stack would leave a tab switch scrolled
to somewhere the new page never asked for. The switcher is the sibling above, not a
child of either scroller: the one control that chooses what you are looking at must
not be the first thing to scroll away. -->
<object class="GtkBox" id="side_column">
<property name="orientation">vertical</property>
<property name="width-request">300</property>
<child>
<object class="AdwViewSwitcher" id="side_switcher">
<property name="stack">side_stack</property>
<property name="policy">wide</property>
<property name="margin-top">4</property>
<property name="margin-bottom">4</property>
<property name="margin-start">6</property>
<property name="margin-end">6</property>
</object>
</child>
<child>
<object class="AdwViewStack" id="side_stack">
<property name="vexpand">1</property>
<child>
<object class="AdwViewStackPage">
<property name="name">measure</property>
<property name="title" translatable="yes">Measure</property>
<property name="child">
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property>
<property name="vscrollbar-policy">automatic</property>
<property name="child">
<object class="GtkBox" id="measure_page">
<property name="orientation">vertical</property>
</object>
</property>
</object>
</property>
</object>
</child>
<child>
<object class="AdwViewStackPage">
<property name="name">signals</property>
<property name="title" translatable="yes">Signals</property>
<property name="child">
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property>
<property name="vscrollbar-policy">automatic</property>
<property name="child">
<object class="GtkBox" id="signals_page">
<property name="orientation">vertical</property>
</object>
</property>
</object>
</property>
</object>
</child>
</object>
</child>
</object>
</property>
Then correct the file's header comment: on line 6 replace and the sidebar's scroller. with
and the sidebar's tab stack.; on line 23 replace
the sidebar's panels, appended to \side_column`;withthe sidebar's panels, appended to `measure_page` and `signals_page`;`. On lines 51-52 the old
"The column scrolls as a whole…" comment is replaced by the new one above.
- Step 2: Run the markup check to see it fail
Run: make -C build test-ui
Expected: FAIL — header-bar.ui still declares math_toggle/measures_toggle and that is fine,
but the run reports no error for those; the real failure to look for is any
AdwViewStackPage/AdwViewSwitcher property the installed GIR rejects. If it reports
15 files in src/ui and no errors, the markup is accepted — move on; the compile is the failing
test for the rest of this task (side_scroll no longer resolves).
- Step 3: Delete the two header-bar toggles
In src/ui/header-bar.ui, delete the <child> block whose object id is math_toggle and the
<child> block whose object id is measures_toggle (currently lines 67-78). In the file's header
comment, change the count from six controls to five and the list to
cursors, measurements, math, sidebar, menu → cursors, sidebar, menu, i.e. the bar's far end is
now trigger, events, pause, cursors, sidebar, menu.
- Step 4: Churn the
Appfields
In src/ui_app.hpp, delete these members: trigger_revealer, signal_toggle,
signal_revealer, side_scroll, cursors_revealer, measures_revealer, measures_toggle,
math_toggle, math_revealer, measures_revealed, signals_panel_initialised. Keep math_list
(task 5 deletes it).
Add, next to side_box:
/// The sidebar's tab stack and its switcher. The stack is what chooses which panels are on
/// screen; a feature's own on/off never does (it sets its panel's sensitivity instead).
GtkWidget* side_stack = nullptr;
GtkWidget* side_switcher = nullptr;
/// The page name the stack is showing, and the one value the session file persists about the
/// sidebar. "signals" on a first run.
std::string side_tab = "signals";
and next to cursors_body:
/// The cursors panel's root, kept because cursors-off disables it rather than hiding it.
GtkWidget* cursors_panel = nullptr;
Rename the debug fields: dbg_reset_visible → dbg_reset_sensitive, dbg_signal_revealed →
dbg_signal_page_current, dbg_trigger_revealed → dbg_trigger_sensitive,
dbg_trigger_revealed_default → dbg_trigger_sensitive_default, dbg_toggles_visible →
dbg_switcher_visible, dbg_side_scroll_after_hide → dbg_side_column_after_hide. Declare
void select_side_tab(App& app, const char* name); beside update_side_visibility.
- Step 5: Assemble the two pages
In src/ui.cpp, replace the assembly at :4717-4769 with:
// The right-hand side is two tabs, not five stacked panels: one tab is on screen at a time, so
// no section competes with four others for height. Each page scrolls on its own -- the two are
// different heights, and one shared scroller would leave a switch scrolled where the new page
// never asked to be.
// `side` is `side_column` from the `.ui`, resolved above with the rest of the skeleton; the app
// keeps it because `update_side_visibility()` still asks about the column as a whole.
app->side_box = side;
app->side_stack = GTK_WIDGET(gtk_builder_get_object(builder, "side_stack"));
app->side_switcher = GTK_WIDGET(gtk_builder_get_object(builder, "side_switcher"));
GtkWidget* measure_page = GTK_WIDGET(gtk_builder_get_object(builder, "measure_page"));
GtkWidget* signals_page = GTK_WIDGET(gtk_builder_get_object(builder, "signals_page"));
// Measure: what the cursors bracket, and the figures derived from it.
app->cursors_panel = build_cursors_panel(*app);
gtk_box_append(GTK_BOX(measure_page), app->cursors_panel);
gtk_box_append(GTK_BOX(measure_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
gtk_box_append(GTK_BOX(measure_page), build_measures_panel(*app));
// Signals: the trigger picks a channel from the list beside it.
app->trigger_panel = build_trigger_panel(*app);
gtk_box_append(GTK_BOX(signals_page), app->trigger_panel);
gtk_box_append(GTK_BOX(signals_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
// Still on the signals page until the list grows the `+` and the per-row menu that replace it.
gtk_box_append(GTK_BOX(signals_page), build_math_panel(*app));
gtk_box_append(GTK_BOX(signals_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
rebuild_math_pane(*app);
app->signal_panel = build_signal_panel(*app);
gtk_box_append(GTK_BOX(signals_page), app->signal_panel);
// Both instruments start off, so both panels start insensitive -- stated here by the one place
// that knows the default, rather than left to whichever widget happened to be built first.
gtk_widget_set_sensitive(app->trigger_panel, FALSE);
gtk_widget_set_sensitive(app->cursors_panel, FALSE);
gtk_widget_set_sensitive(app->cursors_reset, FALSE);
if (app->side_stack != nullptr) {
adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(app->side_stack), "signals");
g_signal_connect(app->side_stack, "notify::visible-child-name",
G_CALLBACK(on_side_tab_changed), app);
}
Replace builder with whatever the local GtkBuilder* for main-window.ui is named at that
point in on_activate (the same one side and events_divider were resolved from a few lines
above). Add #include <adwaita.h> only if ui.cpp does not already have it — it does, for
AdwOverlaySplitView.
- Step 6: Add the tab handler and the selector
In src/ui.cpp, just above update_side_visibility(), add:
void on_side_tab_changed(GObject* o, GParamSpec*, gpointer user) {
App* app = static_cast<App*>(user);
const char* name = adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(o));
app->side_tab = name != nullptr ? name : "signals";
// Not while the layout is being applied: the restore would schedule a save of what it just read.
if (!app->updating_panels) schedule_layout_save(*app);
}
void select_side_tab(App& app, const char* name) {
if (app.side_stack == nullptr || name == nullptr) return;
// An unknown name leaves the stack where it is -- a session file from a later version must not
// be able to blank the sidebar.
if (adw_view_stack_get_child_by_name(ADW_VIEW_STACK(app.side_stack), name) == nullptr) return;
adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(app.side_stack), name);
app.side_tab = name;
}
Declare void on_side_tab_changed(GObject*, GParamSpec*, gpointer user); in src/ui_app.hpp
beside the other notify:: handlers. The three-argument signature is not optional — a
notify:: handler with the wrong arity is the trap invariants-gtk-and-chrome.md records.
- Step 7: Switch the trigger toggle to sensitivity
In src/ui_trigger.cpp, replace line 198:
gtk_revealer_set_reveal_child(GTK_REVEALER(app->trigger_revealer), on);
with:
// Disabled, not hidden: a panel you can still see is a panel you can read, and the settings you
// are about to switch back on must not vanish while you look for them.
gtk_widget_set_sensitive(app->trigger_panel, on);
Nothing else in on_trigger_toggled() changes — set_trigger_enabled, tc.armed = on, the epoch
rule, set_time_window(), set_signal_enabled, sync_events_availability() and the Arm/Disarm
label all stay exactly as they are.
- Step 8: Switch the cursors toggle, and blank the readout
In src/ui_cursors.cpp, replace lines 120-126 of on_cursors_toggled():
// Disabled, not hidden, and the same for the reset beside it: with the cursors off there is
// nothing to reset, but the button must stay where the user will look for it.
gtk_widget_set_sensitive(app->cursors_reset, on);
if (app->cursors_panel != nullptr) gtk_widget_set_sensitive(app->cursors_panel, on);
if (!on) update_cursors_pane(*app);
and replace the early return at line 17:
void update_cursors_pane(App& app) {
if (app.cursors_body == nullptr) return;
if (!app.rows->cursors_visible()) {
// A readout quotes a stored sample; with the instruments off there is no sample it quotes, so
// the cells blank rather than keeping the last ones. `"--"` is the same spelling an
// unavailable cell already uses -- two spellings of one state in one grid would be the defect.
for (const App::CursorLine& line : app.cursors_lines) {
if (line.a != nullptr) gtk_label_set_text(GTK_LABEL(line.a), "--");
if (line.b != nullptr) gtk_label_set_text(GTK_LABEL(line.b), "--");
if (line.d != nullptr) gtk_label_set_text(GTK_LABEL(line.d), "--");
}
return;
}
In build_cursors_panel(), the root is already returned; App::cursors_panel is set by the caller
in step 5, so this function is otherwise unchanged.
- Step 9: Delete the two toggles' code and the signal-list toggle
In src/ui.cpp:
- delete the
app.measures_toggle/app.math_toggleid resolutions (:4514-4515) and their null checks (:4518-4519); - delete
on_measures_toggled()andon_math_toggled()and theirg_signal_connects (:4541-4558), and their declarations insrc/ui_app.hpp; - delete the overflow-menu row at
:4452-4455(the four lines buildingapp.signal_toggle).
In src/ui_signals.cpp, delete on_signals_toggled() (:303-307) and its declaration, and
replace the block at :384-413 with:
if (!have_signals) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.trigger_toggle), FALSE);
}
(the signals_panel_initialised bookkeeping went with the toggle it drove — the list is a page
now, so there is nothing to reveal on the first signal).
- Step 10: Repoint
update_side_visibility()andnote_panel_controls()
In src/ui.cpp:2830-2850, change the guard from if (app.side_scroll == nullptr) return; to
if (app.side_stack == nullptr) return;, change every other app.side_scroll in the function to
app.side_box, and delete the line
gtk_widget_set_visible(app.signal_toggle, have_signals);. The trigger_toggle line below it
stays.
In note_panel_controls() (:2923-2990): change in_cursors_pane's comparison from
app.cursors_revealer to app.cursors_panel; change the snprintf to
std::snprintf(app.dbg_panel_controls, sizeof(app.dbg_panel_controls),
"cursors=%d in the toolbar, switcher=%d, reset=%d in the cursors pane",
in_bar(app.cursors_toggle), app.side_switcher != nullptr ? 1 : 0,
in_cursors_pane(app.cursors_reset));
and remove the measures/math entries from the named[] array (which also shortens
dbg_toolbar_icons to trigger, events, pause, cursors, sidebar, menu).
- Step 11: Rename the two tab actions
In src/shortcuts.hpp:8-21, rename MeasuresPane → SideTabMeasure and MathPane →
SideTabSignals. In src/shortcuts.cpp:11-34, replace the four affected rows with:
{GDK_KEY_t, 0, AppAction::TriggerPanel, "t", "Enable or disable trigger mode", "Acquisition"},
{GDK_KEY_c, 0, AppAction::CursorsPane, "c", "Enable or disable the cursors", "Panes and editors"},
{GDK_KEY_m, 0, AppAction::SideTabMeasure, "m", "Show the Measure tab", "Panes and editors"},
{GDK_KEY_g, 0, AppAction::SideTabSignals, "g", "Show the Signals tab", "Panes and editors"},
In src/ui.cpp:4300-4324, replace the two cases:
case AppAction::SideTabMeasure: select_side_tab(app, "measure"); break;
case AppAction::SideTabSignals: select_side_tab(app, "signals"); break;
- Step 12: Let
apply_math_channels()rebuild both lists
In src/ui_math.cpp:17-27, add one line after rebuild_math_pane(app);:
app.model->set_math_channels(app.math);
reload_file_view(app);
rebuild_math_pane(app);
// The math channels are rows of the signal list too -- `append_math_signals_locked()` puts them
// in `signals()` -- so the list is stale until it is rebuilt.
rebuild_signal_panels(app);
schedule_layout_save(app);
and guard rebuild_signal_list() in src/ui_signals.cpp:139, which can now be reached before the
list exists:
void rebuild_signal_list(App& app) {
if (app.signal_list == nullptr) return;
- Step 13: Repoint every probe capture
In src/ui_probe.cpp, rewrite the captures in exit_probe_step3() (:70-108):
a->dbg_signal_w = gtk_widget_get_width(a->signal_panel);
a->dbg_signal_h = gtk_widget_get_height(a->signal_panel);
a->dbg_trigger_w = gtk_widget_get_width(a->trigger_panel);
a->dbg_trigger_h = gtk_widget_get_height(a->trigger_panel);
a->dbg_reset_sensitive = gtk_widget_get_sensitive(a->cursors_reset) ? 1 : 0;
// A page, not a revealer: a stack's hidden page measures 0x0 the way an unrevealed revealer
// did, so the reading asks about *state* -- is this page the current one -- and never about size.
const char* page = a->side_stack != nullptr
? adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(a->side_stack))
: nullptr;
const int on_signals = page != nullptr && std::strcmp(page, "signals") == 0 ? 1 : 0;
a->dbg_signal_page_current =
on_signals && gtk_widget_get_first_child(a->signal_list) != nullptr ? 1 : 0;
a->dbg_trigger_sensitive = on_signals && gtk_widget_get_sensitive(a->trigger_panel) ? 1 : 0;
a->dbg_side_column_after_hide = gtk_widget_get_visible(a->side_box) ? 1 : 0;
a->dbg_switcher_visible =
a->side_switcher != nullptr && gtk_widget_get_visible(a->side_switcher) ? 1 : 0;
Then:
-
exit_probe_step1(:2189-2190):a->dbg_trigger_sensitive_default = gtk_widget_get_sensitive(a->trigger_panel) ? 1 : 0; -
cursors_probe_report(:2233-2234): readpane=fromgtk_widget_get_sensitive(a->cursors_panel)instead of the revealer, and rename the field in the format string topane_sensitive=. -
measures_probe_stepcase 0 (:2501): replace thegtk_toggle_button_set_active(a->measures_toggle, true)withselect_side_tab(*a, "measure");. -
keys_probe(:4028-4047): the'm'and'g'before_after(...)calls now reada->side_tabrather than a toggle's active state — change them to compare the page name before and after the synthesised key, and delete the "'m' refused" check (there is no toggle to refuse). -
Step 14: Repoint the dump lines
In src/ui.cpp:
-
:5607"reset button visible: %d"→"reset button sensitive: %d", readingapp.dbg_reset_sensitive. -
:5673-5681thepanels:line → replace(revealed=%d)with(page_current=%d)for signals,(revealed=%d)with(sensitive=%d)for trigger,toggles=%dwithswitcher=%d, and theside=%dx%d (visible=%d)reading now comes fromdbg_side_column_after_hide. -
:5844-5848→"trigger panel sensitive before the probe toggled it: %d; signal list items=%d selected=%d; column visible after sidebar toggle off=%d". -
:6081-6092themeasures pane:line → delete therevealed=%dfield and itsapp.measures_revealedargument. -
Step 15: Build, and read it against the baselines
Run: cmake --build build -j4 2>&1 | grep -E 'warning|error' | sort | uniq -c
Expected: compiles; the warning set is the four from the baseline (two
-Wmissing-field-initializers, two -Woverlength-strings) and nothing new. Any
'side_scroll' is not a member, 'measures_toggle' is not a member or unused-function error
names a step above that was missed.
Run: make -C build test-ui
Expected: PASS, 15 files in src/ui.
- Step 16: Run the exit probe and read every changed line
Run, from the repo root, one short serial run:
python3 tools/mock_udpstreamer.py --port 9100 &
MOCK=$!
UDPSCOPE_PROBE_EXIT_MS=4000 UDPSCOPE_EXIT_AFTER_MS=9000 \
xvfb-run -a ./build/udpscope --port 9100 2>&1 | tail -80
kill $MOCK
Expected, read against docs/claude/probe-readings.md:
-
panels:readssignals=WxH row_h=N (page_current=1) trigger=WxH (sensitive=0) switcher=1, with non-zero signal and trigger sizes (the signals page is current) andvisible=0after the sidebar toggle; -
reset button sensitive: 0(the cursors start off); -
trigger panel sensitive before the probe toggled it: 0; -
panel controls: cursors=1 in the toolbar, switcher=1, reset=1 in the cursors pane; -
toolbar icons:lists six controls, withoutmeasuresandmath; -
zero
Gtk-CRITICALanywhere in the output. -
Step 17: Commit
git add src/ui/main-window.ui src/ui/header-bar.ui src/ui_app.hpp src/ui.cpp \
src/ui_trigger.cpp src/ui_cursors.cpp src/ui_signals.cpp src/ui_math.cpp \
src/shortcuts.hpp src/shortcuts.cpp src/ui_probe.cpp
git commit -m "ui: the sidebar is two tabs, and a toggle disables its pane"
Task 2: side_tab in the session file
Files:
- Modify:
src/layout_store.hpp— addside_tabtoLayoutState - Modify:
src/layout_store.cpp:462-509(parser),:647(writer) - Modify:
src/ui_layout.cpp:60(save),:327-340(restore) - Test: a write-then-read round trip under
xvfb-run
Interfaces:
- Consumes:
select_side_tab(App&, const char*)andApp::side_tabfrom task 1. - Produces:
std::string LayoutState::side_tab— defaulted"signals".
Steps
- Step 1: State the expected file content, and see it absent
Run:
python3 tools/mock_udpstreamer.py --port 9100 &
MOCK=$!
UDPSCOPE_SESSION=/tmp/side-tab.toml UDPSCOPE_EXIT_AFTER_MS=6000 \
xvfb-run -a ./build/udpscope --port 9100 >/dev/null 2>&1
kill $MOCK
grep side_tab /tmp/side-tab.toml
Expected: FAIL — grep prints nothing and exits 1, because no such key is written yet. (If
UDPSCOPE_SESSION is not the variable this build reads for the session path, take the path from
docs/claude/build-and-run.md's command-line table and use --session.)
- Step 2: Add the field
In src/layout_store.hpp, beside measure_stats (:67):
/// Which sidebar tab was showing: "measure" or "signals". The one thing the panel persists --
/// no revealer states remain, and the 320 px width is the `.ui`'s.
std::string side_tab = "signals";
- Step 3: Parse it
In src/layout_store.cpp, beside the measure_stats branch:
} else if (key == "side_tab") {
st.side_tab = strip_quote(value);
- Step 4: Write it
In src/layout_store.cpp:647, after the measure_stats line:
o << "side_tab = \"" << state.side_tab << "\"\n";
- Step 5: Save from, and restore into, the app
In src/ui_layout.cpp:60, beside st.measure_stats = app.measure_stats_on;:
st.side_tab = app.side_tab;
In src/ui_layout.cpp:327-340, inside the existing app.updating_panels = true; … = false;
window — so the stack's notify::visible-child-name handler does not schedule a save of what it
has just read:
app.updating_panels = true;
select_side_tab(app, st.side_tab.c_str());
if (app.measures_stats_check != nullptr) { /* unchanged */ }
- Step 6: Build and run the round trip
Run: cmake --build build -j4 && make -C build test-ui
Expected: compiles with at most the two incremental-build warnings; test-ui PASS, 15 files.
Run:
python3 tools/mock_udpstreamer.py --port 9100 &
MOCK=$!
UDPSCOPE_SESSION=/tmp/side-tab.toml UDPSCOPE_EXIT_AFTER_MS=6000 \
xvfb-run -a ./build/udpscope --port 9100 >/dev/null 2>&1
grep side_tab /tmp/side-tab.toml
sed -i 's/^side_tab = .*/side_tab = "measure"/' /tmp/side-tab.toml
UDPSCOPE_SESSION=/tmp/side-tab.toml UDPSCOPE_PROBE_EXIT_MS=4000 UDPSCOPE_EXIT_AFTER_MS=9000 \
xvfb-run -a ./build/udpscope --port 9100 2>&1 | grep -E 'panels:|Gtk-CRITICAL'
kill $MOCK
grep side_tab /tmp/side-tab.toml
Expected: PASS — the first grep prints side_tab = "signals"; after the edit the panels: line
reads trigger=0x0 and signals=0x0 … (page_current=0) because the measure page is current;
the final grep prints side_tab = "measure", i.e. the restored value was saved back unchanged;
no Gtk-CRITICAL.
Also check an unknown value is ignored:
sed -i 's/^side_tab = .*/side_tab = "nonsense"/' /tmp/side-tab.toml
UDPSCOPE_SESSION=/tmp/side-tab.toml UDPSCOPE_PROBE_EXIT_MS=4000 UDPSCOPE_EXIT_AFTER_MS=9000 \
xvfb-run -a ./build/udpscope --port 9100 2>&1 | grep -cE 'Gtk-CRITICAL|WARNING'
rm -f /tmp/side-tab.toml
Expected: 0, and the stack stays on signals.
- Step 7: Commit
git add src/layout_store.hpp src/layout_store.cpp src/ui_layout.cpp
git commit -m "feat: the session file remembers which sidebar tab was showing"
Task 3: The MATH divider row and its +
The list already contains every math channel. What it lacks is the boundary between the two kinds and the one control that creates a channel.
Files:
- Modify:
src/ui_signals.cpp—rebuild_signal_list()(:139-301) - Modify:
src/ui_app.hpp— declare the new click handler - Test:
make test-ui(unchanged), then the signal-panel probe's row count
Interfaces:
- Consumes:
add_math_channel(App&)(src/ui.cpp:1840),open_math_dialog(App&, int edit)(src/ui.cpp:1927),rebuild_signal_panels(App&). - Produces: nothing new for later tasks; task 4 edits the same loop.
Steps
- Step 1: State the expected row count, and see it wrong
Run:
python3 tools/mock_udpstreamer.py --port 9100 &
MOCK=$!
UDPSCOPE_PROBE_MATH_MS=3000 UDPSCOPE_PROBE_EXIT_MS=6000 UDPSCOPE_EXIT_AFTER_MS=11000 \
xvfb-run -a ./build/udpscope --port 9100 2>&1 | grep -E 'signals panel:|signal list items'
kill $MOCK
Expected: FAIL against the target — signal list items=N where N is signals + math channels,
with no divider counted. The target after this task is signals + 1 + math.
- Step 2: Insert the divider before the first math row
In src/ui_signals.cpp, inside rebuild_signal_list()'s per-row loop, before the row's own
widgets are built, add:
// One list, with a boundary in it: a derived channel *is* a channel, and
// `append_math_signals_locked()` has already put every math channel in `signals()`. What the
// deleted math pane contributed was this divider and the `+` on it.
if (sig.math && !math_divider_done) {
math_divider_done = true;
gtk_list_box_append(GTK_LIST_BOX(app.signal_list), make_math_divider(app));
}
with bool math_divider_done = false; declared beside rows, and after the loop:
// 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(app.signal_list), make_math_divider(app));
- Step 3: Build the divider
Above rebuild_signal_list() in src/ui_signals.cpp:
/// 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* 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);
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_margin_top(box, 6);
gtk_widget_set_margin_start(box, 6);
gtk_widget_set_margin_end(box, 6);
GtkWidget* label = gtk_label_new("MATH");
gtk_widget_add_css_class(label, "dim-label");
gtk_widget_add_css_class(label, "caption-heading");
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);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box);
return row;
}
/// Create a channel, then open the editor on it: a new channel is a single `Source` node with a
/// default name, which is of no use until it is given a graph.
void on_math_add_clicked(GtkButton*, gpointer user) {
App* a = static_cast<App*>(user);
add_math_channel(*a);
if (a->math.empty()) return;
// `add_math_channel()` appended, so the new channel is the last one; `apply_math_channels()`
// inside it has already rebuilt this list, which is why the index is taken after the call.
open_math_dialog(*a, static_cast<int>(a->math.size()) - 1);
}
Declare both in src/ui_app.hpp beside rebuild_signal_list.
- Step 4: Build and read the count
Run: cmake --build build -j4 && make -C build test-ui
Expected: compiles, at most two incremental warnings; test-ui PASS, 15 files.
Run the same command as step 1.
Expected: PASS — signal list items is now one higher than it was in step 1, and signals panel:
reports a list whose last-but-k row is the divider. With the math probe having created one
channel, the count is signals + 1 + 1.
- Step 5: Confirm the
+creates and opens
Run:
python3 tools/mock_udpstreamer.py --port 9100 &
MOCK=$!
UDPSCOPE_PROBE_MATH_MS=3000 UDPSCOPE_EXIT_AFTER_MS=9000 \
xvfb-run -a ./build/udpscope --port 9100 2>&1 | grep -E 'math-probe|Gtk-CRITICAL'
kill $MOCK
Expected: the math probe's own lines unchanged from before this task, and no Gtk-CRITICAL — the
divider's + is not clicked by any probe, so this run proves only that adding the row broke
nothing. The + itself is exercised by hand once at the end of task 4.
- Step 6: Commit
git add src/ui_signals.cpp src/ui_app.hpp
git commit -m "ui: a MATH divider in the signal list, carrying the + that adds a channel"
Task 4: A math row's own menu, and the enabled mirror
Files:
- Modify:
src/ui_signals.cpp— the menu block inrebuild_signal_list(), the tick's visibility,on_signal_switch()(:15-51) - Modify:
src/ui_app.hpp— declare the helper - Test: the math probe plus a session round trip proving the tick is saved
Interfaces:
- Consumes:
open_math_dialog(App&, int),apply_math_channels(App&),App::math(std::vector<MathChannel>),MathChannel::name,MathChannel::enabled. - Produces:
int math_index_for_signal(App& app, const std::string& name)— the index intoApp::mathwhose channel has that name, or-1.
Steps
- Step 1: State the defect, and see it
A tick on a math row today changes the model but not App::math, so the session file saves the
stale value. Run:
python3 tools/mock_udpstreamer.py --port 9100 &
MOCK=$!
UDPSCOPE_SESSION=/tmp/math-tick.toml UDPSCOPE_PROBE_MATH_MS=3000 UDPSCOPE_EXIT_AFTER_MS=9000 \
xvfb-run -a ./build/udpscope --port 9100 >/dev/null 2>&1
kill $MOCK
grep -A3 '\[\[math\]\]' /tmp/math-tick.toml
Expected: the channel is written with enabled = true. Note the value; step 5 unticks it from the
list and the written value must follow.
- Step 2: Add the name → index helper
In src/ui_signals.cpp, above rebuild_signal_list():
/// `App::math` is indexed by position and `signals()` by name, and the mapping between them is
/// the name: `append_math_signals_locked()` names each synthetic signal after its channel and
/// `enable_locked()` matches it back the same way.
int math_index_for_signal(App& app, const std::string& name) {
for (std::size_t i = 0; i < app.math.size(); ++i) {
if (app.math[i].name == name) return static_cast<int>(i);
}
return -1;
}
Declare it in src/ui_app.hpp.
- Step 3: Branch the menu on the kind of channel
In src/ui_signals.cpp, replace the three menu_button(...) calls in the row loop with:
if (sig.math) {
// Information and Calibrate... are a *streamed* signal's: calibration is applied at ingest,
// and a derived channel has no stream to describe. No rename either -- the node editor owns
// a math channel's name and its unit, and a second place to change them would be a second
// answer to the same question.
GtkWidget* edit = menu_button("Edit graph...", nullptr);
g_signal_connect(edit, "clicked", G_CALLBACK(on_math_edit_clicked), ctx);
gtk_box_append(GTK_BOX(menu_box), edit);
gtk_box_append(GTK_BOX(menu_box), colour_item);
GtkWidget* del = menu_button("Delete", nullptr);
g_signal_connect(del, "clicked", G_CALLBACK(on_math_delete_clicked), ctx);
gtk_box_append(GTK_BOX(menu_box), del);
} else {
GtkWidget* info = menu_button("Information", nullptr);
g_signal_connect(info, "clicked", G_CALLBACK(on_info_clicked), ctx);
gtk_box_append(GTK_BOX(menu_box), info);
gtk_box_append(GTK_BOX(menu_box), colour_item);
GtkWidget* cal = menu_button("Calibrate...", nullptr);
g_signal_connect(cal, "clicked", G_CALLBACK(open_calibration), ctx);
gtk_box_append(GTK_BOX(menu_box), cal);
}
colour_item is the existing "Default colour" button with its existing sensitivity rule
(app.rows->signal_colors().count(sig.name) != 0), hoisted above the branch so both kinds share
it unchanged. ctx is whatever per-row context the existing on_info_clicked /
open_calibration connections already pass — reuse it verbatim rather than inventing a second
one.
- Step 4: The two new row handlers
In src/ui_signals.cpp, beside on_math_add_clicked:
void on_math_edit_clicked(GtkButton* b, gpointer user) {
// The same context the Information item uses, so the row's identity is resolved the one way.
App* a = signal_row_app(user);
const int k = math_index_for_signal(*a, signal_row_name(user));
if (k < 0) return;
gtk_widget_set_visible(gtk_widget_get_ancestor(GTK_WIDGET(b), GTK_TYPE_POPOVER), FALSE);
open_math_dialog(*a, k);
}
void on_math_delete_clicked(GtkButton* b, gpointer user) {
App* a = signal_row_app(user);
const int k = math_index_for_signal(*a, signal_row_name(user));
if (k < 0) return;
gtk_widget_set_visible(gtk_widget_get_ancestor(GTK_WIDGET(b), GTK_TYPE_POPOVER), FALSE);
a->math.erase(a->math.begin() + k);
// Rebuilds the model's signal list, the plots and this list, and schedules the save.
apply_math_channels(*a);
}
signal_row_app() / signal_row_name() stand for however the existing row context exposes the
App* and the signal name — read on_info_clicked and use the same two accessors or the same
struct members. Declare both handlers in src/ui_app.hpp.
- Step 5: The tick, and the mirror
In the row loop, replace the tick's visibility line:
// A math channel's tick is its own switch in every view: it is computed here, not streamed,
// so a file view has one to offer even though it has no acquisition to start.
gtk_widget_set_visible(enable, sig.math || app.waveform_name.empty());
In on_signal_switch() (src/ui_signals.cpp:15-51), immediately after
app->model->set_signal_enabled(index, on); and before the if (!on) block:
// `App::math` is the copy the session file is written from, so the tick has to reach it. Here
// and not after the block below: `apply_math_channels()` down there pushes `App::math` into the
// model, and a stale `enabled = true` would switch the channel straight back on.
const int k = math_index_for_signal(*app, name);
if (k >= 0) app->math[static_cast<std::size_t>(k)].enabled = on;
- Step 6: Build and read it
Run: cmake --build build -j4 && make -C build test-ui
Expected: compiles, at most two incremental warnings; test-ui PASS, 15 files.
Run the step-1 command again, with the tick turned off by the enable-all probe's inverse — or, if no probe unticks a math row, confirm by hand:
python3 tools/mock_udpstreamer.py --port 9100 &
MOCK=$!
UDPSCOPE_SESSION=/tmp/math-tick.toml UDPSCOPE_PROBE_MATH_MS=3000 \
UDPSCOPE_PROBE_EXIT_MS=6000 UDPSCOPE_EXIT_AFTER_MS=11000 \
xvfb-run -a ./build/udpscope --port 9100 2>&1 | grep -E 'math-probe|signal list items|Gtk-CRITICAL'
kill $MOCK
Expected: the math probe's lines unchanged, signal list items unchanged from task 3, no
Gtk-CRITICAL.
Then one interactive check — the only one in this plan, because no probe drives a popover: run
./build/udpscope --port 9100 against the mock, click + on the MATH divider (the editor opens
on a new channel), close it, open the new row's menu (three items: Edit graph…, Default colour,
Delete), untick the row, quit, and confirm the session file now records enabled = false for
that channel. Then re-run and Delete it.
- Step 7: Commit
git add src/ui_signals.cpp src/ui_app.hpp
git commit -m "ui: a math row carries Edit graph and Delete, and its tick reaches the session"
Task 5: Delete the math pane
Now that the list can create, edit, enable and delete a math channel, the pane is the same list drawn twice.
Files:
- Delete:
src/ui/math-panel.ui - Modify:
src/ui/udpscope-ui.gresource.xml— delete themath-panel.uientry - Modify:
src/ui_math.cpp— deletebuild_math_panel()(:1044-1062) and therebuild_math_pane(app)call inapply_math_channels() - Modify:
src/ui.cpp— deleterebuild_math_pane()(:2251-2319), the twogtk_box_appendlines and therebuild_math_pane(*app)call added in task 1, and themath panel:dump block (:5639-5656) - Modify:
src/ui_app.hpp— deletemath_listand the two deleted functions' declarations - Test:
make test-uimust now report 14 files
Interfaces:
- Consumes: nothing new.
- Produces: nothing. This task only removes.
Steps
- Step 1: Delete the pane's own code
In src/ui.cpp, delete rebuild_math_pane() entirely (:2251-2319) and the math panel: dump
block (:5639-5656, which reads gtk_widget_get_parent(app.math_list)). In the task-1 assembly,
delete these three lines:
gtk_box_append(GTK_BOX(signals_page), build_math_panel(*app));
gtk_box_append(GTK_BOX(signals_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
rebuild_math_pane(*app);
In src/ui_math.cpp, delete build_math_panel() (:1044-1062) and the rebuild_math_pane(app);
line from apply_math_channels(), leaving:
app.model->set_math_channels(app.math);
reload_file_view(app);
// The math channels are rows of the signal list -- `append_math_signals_locked()` puts them in
// `signals()` -- so the list is stale until it is rebuilt.
rebuild_signal_panels(app);
schedule_layout_save(app);
In src/ui_app.hpp, delete math_list and the build_math_panel / rebuild_math_pane
declarations.
- Step 2: Delete the markup and its resource entry
git rm src/ui/math-panel.ui
and delete the <file ...>math-panel.ui</file> line from src/ui/udpscope-ui.gresource.xml.
Both, not one: tools/check_ui.py fails a .ui on disk that is not listed, and a listed file
that is not on disk.
- Step 3: Build and read the file count
Run: cmake --build build -j4 2>&1 | grep -E 'warning|error'
Expected: no error; at most the two incremental warnings. An unused function or
'math_list' is not a member names a line step 1 missed.
Run: make -C build test-ui
Expected: PASS, and the count is now 14 files in src/ui.
- Step 4: Run the math probe to prove nothing depended on the pane
python3 tools/mock_udpstreamer.py --port 9100 &
MOCK=$!
UDPSCOPE_PROBE_MATH_MS=3000 UDPSCOPE_PROBE_EXIT_MS=6000 UDPSCOPE_EXIT_AFTER_MS=11000 \
xvfb-run -a ./build/udpscope --port 9100 2>&1 | grep -E 'math-probe|signals panel:|signal list items|Gtk-CRITICAL'
kill $MOCK
Expected: the math probe's own lines unchanged, signal list items unchanged from task 4, no
math panel: line at all, and no Gtk-CRITICAL.
- Step 5: Commit
git add src/ui.cpp src/ui_math.cpp src/ui_app.hpp src/ui/math-panel.ui \
src/ui/udpscope-ui.gresource.xml
git commit -m "refactor: delete the math pane, which was the signal list drawn twice"
Task 6: Delete the two orphaned icons, and re-measure the warning baseline
udpscope-measure-symbolic and udpscope-math-symbolic lost their last user with the toggles
that used them. Removing them changes a documented count and possibly a documented warning
count, so the doc corrections belong in this commit.
Files:
- Delete:
src/icons/scalable/actions/udpscope-measure-symbolic.svg,src/icons/scalable/actions/udpscope-math-symbolic.svg - Modify:
src/icons/udpscope.gresource.xml:22-23 - Modify:
docs/claude/verification.md:16-27 - Modify:
docs/claude/invariants-gtk-and-chrome.md:50-66 - Test: a clean build, counted
Interfaces:
- Consumes: nothing. Both icons are already unreferenced after task 1.
- Produces: the measured clean-build warning count, which every later run is read against.
Steps
- Step 1: Prove they are unreferenced
Run: grep -rn 'udpscope-measure-symbolic\|udpscope-math-symbolic' src/ docs/ README.md
Expected: matches only in src/icons/udpscope.gresource.xml,
docs/claude/invariants-gtk-and-chrome.md and the two SVG filenames themselves. A match in any
.cpp, .hpp or .ui means a task-1 step was missed — stop and fix that first.
- Step 2: Delete them
git rm src/icons/scalable/actions/udpscope-measure-symbolic.svg \
src/icons/scalable/actions/udpscope-math-symbolic.svg
and delete the two matching <file ...> lines from src/icons/udpscope.gresource.xml, leaving
four action icons (trigger, cursors, events, resize-rows) and the two app icons.
- Step 3: Measure the clean build
rm -rf build && cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build -j4 2>&1 | grep -c 'warning:'
cmake --build build -j4 2>&1 | grep 'warning:' | sed 's/.*warning: //' | sort | uniq -c
Expected: either 4 (the two -Wmissing-field-initializers plus two -Woverlength-strings) or
3 — the icons' GResource blob only crossed C99's 4095-byte string limit when the two app icons
joined the four action ones, and two fewer action icons may put it back under. Write down which
number this build produces; it is the figure step 4 records and every later run is read against.
Use the -DCMAKE_BUILD_TYPE the repo's own clean-build instruction in
docs/claude/build-and-run.md names if it differs from the above.
- Step 4: Correct the two docs with the measured number
In docs/claude/verification.md:16-27, change the clean-build figure to the number measured in
step 3, and rewrite the sentence tying the icons' -Woverlength-strings to "when the two app
icons joined the four action ones": if the warning survived, say the blob is still over the limit
with four action icons; if it did not, say the warning is gone because two action icons left and
record the new count.
In docs/claude/invariants-gtk-and-chrome.md:50-66, correct the icon census: it currently reads
"Seven of this app's icons are its own" and names
"udpscope-{trigger,cursors,measure,math,events}-symbolic.svg are the five action icons", then
"All six are bundled". The truth after this change is four action icons —
udpscope-{trigger,cursors,events,resize-rows}-symbolic.svg — plus the two app icons, six
bundled in total. (resize-rows was already undocumented; name it now.) Also correct
":50-52"'s "the two editors that are not panes (Layout, Signal list)" — the signal list is a tab,
not an editor.
- Step 5: Verify the bundle still loads
Run: make -C build test-ui
Expected: PASS, 14 files.
python3 tools/mock_udpstreamer.py --port 9100 &
MOCK=$!
UDPSCOPE_PROBE_EXIT_MS=4000 UDPSCOPE_EXIT_AFTER_MS=9000 \
xvfb-run -a ./build/udpscope --port 9100 2>&1 | grep -cE 'Gtk-CRITICAL|icon.*not present|toolbar icons'
kill $MOCK
Expected: exactly 1 — the toolbar icons: line, and no missing-icon warning or
Gtk-CRITICAL. A Icon 'udpscope-measure-symbolic' not present line means a .ui or a .cpp
still names it.
- Step 6: Commit
git add src/icons/udpscope.gresource.xml src/icons/scalable/actions \
docs/claude/verification.md docs/claude/invariants-gtk-and-chrome.md
git commit -m "chore: drop the two icons the deleted toggles used, and correct the counts"
Task 7: The tab-switch probe, and the documentation
The §5 rule — the accumulator keeps running on the hidden page — is the one claim in this design that nothing yet tests. The defect it names is a statistics pane that silently restarts its history every time you look at the trigger.
Files:
- Modify:
src/ui_probe.cpp— addside_tab_probeandside_tab_probe_report - Modify:
src/ui_app.hpp— declare both - Modify:
src/ui.cpp:5401area — registerUDPSCOPE_PROBE_SIDE_TAB_MS - Modify:
docs/claude/probe-readings.md,docs/claude/verification.md,docs/claude/invariants-gtk-and-chrome.md,docs/claude/invariants-math-channels.md,docs/claude/invariants-readouts-and-cursors.md,README.md - Modify:
src/ui/shortcuts.uiandsrc/help/01-overview.txt— the user-facing text Task 1 made false (added after Task 1's review found it owned by no task) - Test: the new probe itself
Interfaces:
- Consumes:
select_side_tab(App&, const char*);App::side_tab;App::measures(std::vector),App::measure_stats(std::vector<MeasureStatsAccum>),MeasureStatsAccum::n(std::uint64_t, a count of passes);App::measures_stats_check;update_measures(App&, bool force, bool accumulate);rebuild_measures_pane(App&);measure_probe's channel-picking pattern. - Produces: the
UDPSCOPE_PROBE_SIDE_TAB_MSreading documented inprobe-readings.md.
Steps
- Step 1: Write the probe
In src/ui_probe.cpp, beside stats_probe, add — the file-static g_side_tab_n_before mirrors
g_stats_n_before's pattern exactly:
std::vector<std::uint64_t> g_side_tab_n_before;
/// Phase two: the hidden page's accumulator must have advanced. A statistics readout is history,
/// so min/max/avg would gain a gap for exactly as long as the trigger was on screen -- the one
/// defect the design's "what runs on a hidden page" rule is there to prevent.
gboolean side_tab_probe_report(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
std::uint64_t advanced = 0;
std::uint64_t min_n = 0;
bool first = true;
for (std::size_t i = 0; i < a->measure_stats.size(); ++i) {
const std::uint64_t n = a->measure_stats[i].n;
if (i < g_side_tab_n_before.size() && n > g_side_tab_n_before[i]) ++advanced;
if (first || n < min_n) {
min_n = n;
first = false;
}
}
std::fprintf(stderr,
"[side-tab-probe] page=%s rows=%zu accums=%zu advanced=%" G_GUINT64_FORMAT
" min_n=%" G_GUINT64_FORMAT "\n",
a->side_tab.c_str(), a->measures.size(), a->measure_stats.size(), advanced, min_n);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
/// Phase one: four measurements on the first channel, statistics on, then switch *away* from the
/// Measure tab and come back in two seconds to read what ran while it was not on screen.
gboolean side_tab_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
std::string name;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
// A *channel*, not `signals().front()`: the first signal of a stream is its timestamp, which
// is not acquired and has nothing to measure.
if (a->model->channels().empty()) return G_SOURCE_REMOVE;
name = a->model->channels().front().name;
}
if (a->measures.empty()) {
for (const MeasureType t : {MeasureType::Average, MeasureType::Top, MeasureType::PeakToPeak,
MeasureType::Amplitude}) {
MeasureOptions options;
options.show_on_plot = measure_can_show_on_plot(t);
a->measures.push_back({name, t, options});
}
rebuild_measures_pane(*a);
}
if (a->measures_stats_check != nullptr) {
gtk_check_button_set_active(GTK_CHECK_BUTTON(a->measures_stats_check), TRUE);
}
select_side_tab(*a, "measure");
update_measures(*a, /*force=*/true, /*accumulate=*/true);
g_side_tab_n_before.clear();
for (const MeasureStatsAccum& s : a->measure_stats) g_side_tab_n_before.push_back(s.n);
// Away from the page the measurements are on: its widgets are now unrealized and measure 0x0,
// and the tick keeps calling `update_measures()` regardless.
select_side_tab(*a, "signals");
std::fprintf(stderr, "[side-tab-probe] page=%s accums=%zu baseline recorded\n",
a->side_tab.c_str(), g_side_tab_n_before.size());
std::fflush(stderr);
// Long enough for several throttled recomputes (one per 100 ms while the window moves).
g_timeout_add(2000, side_tab_probe_report, a);
return G_SOURCE_REMOVE;
}
Declare both in src/ui_app.hpp beside stats_probe.
- Step 2: Register the environment variable
In src/ui.cpp, beside the UDPSCOPE_PROBE_STATS_MS block (:5401-5403):
if (const char* env = g_getenv("UDPSCOPE_PROBE_SIDE_TAB_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), side_tab_probe, &app);
}
- Step 3: Run it to see it fail, then pass
Run: cmake --build build -j4 && make -C build test-ui
Expected: compiles, at most two incremental warnings; test-ui PASS, 14 files.
python3 tools/mock_udpstreamer.py --port 9100 &
MOCK=$!
UDPSCOPE_PROBE_SIDE_TAB_MS=3000 UDPSCOPE_EXIT_AFTER_MS=11000 \
xvfb-run -a ./build/udpscope --port 9100 2>&1 | grep -E 'side-tab-probe|Gtk-CRITICAL'
kill $MOCK
Expected: two lines —
[side-tab-probe] page=signals accums=4 baseline recorded
[side-tab-probe] page=signals rows=4 accums=4 advanced=4 min_n=...
advanced=4 is the assertion: every accumulator gained passes while its page was off screen.
advanced=0 is the §5 defect — something gated the accumulation on the visible tab. No
Gtk-CRITICAL: a Gtk-CRITICAL here would be a label or allocation read on the unrealized page.
- Step 4: Add the merged list's row-count reading to the signal-panel probe
In the existing signal-panel dump block (src/ui.cpp:5637, the signals panel: line), add the
two numbers the merged list must satisfy — the row count and the math count it is made of, so the
reading can be checked without counting by hand:
std::fprintf(stderr, "signals panel: rows=%d signals=%zu math=%zu divider=%d\n",
app.dbg_signal_rows, app.dbg_signal_plain, app.math.size(),
app.dbg_signal_rows ==
static_cast<int>(app.dbg_signal_plain + 1 + app.math.size())
? 1
: 0);
Capture dbg_signal_rows and dbg_signal_plain at the end of rebuild_signal_list() — the
former by walking signal_list's children, the latter as the number of non-math entries the
loop drew. Add both as int / std::size_t fields in src/ui_app.hpp beside the other dbg_
members. divider=0 is a list that lost its +.
Re-run the step-3 command with UDPSCOPE_PROBE_EXIT_MS=6000 added and read
signals panel: … divider=1.
- Step 5: Update
probe-readings.md
In docs/claude/probe-readings.md:
-
:198(measures pane: n=N revealed=D …) — delete therevealedfield's entry. -
:310-320/:345-355(thepanels:andtoolbar icons:entries) —signals=(revealed=D)becomes(page_current=D)and means the signals page is current and its list is non-empty;trigger=(revealed=D)becomes(sensitive=D)and means the page is current and the trigger body is sensitive;toggles=Dbecomesswitcher=Dand means the switcher is on screen, which is the one control the two deleted toggles became; and note that the signal and trigger sizes read 0 on the page that is not current and are read as such — a stack's hidden page measures 0×0, so no functional code may depend on those numbers. -
:1252-1262(signals panel:) — the newrows/signals/math/dividerfields, withrows == signals + 1 + mathas the invariant anddivider=0named as a list that lost its+. -
:1263-1272(math panel:) — delete the whole entry; the pane is gone. -
the
reset button visibleentry — nowreset button sensitive, and the defect it names is a reset button that vanishes while the cursors are off instead of greying out. -
the
trigger panel revealed before the probe toggled itentry — nowtrigger panel sensitive before the probe toggled it. -
the
[cursors]entry'spane=field — nowpane_sensitive=. -
a new entry for
[side-tab-probe], with the two lines from step 3,advancedas the assertion andadvanced=0as "the statistics pane restarts its history whenever you look at the trigger". -
Step 6: Update the invariants and the README
-
docs/claude/verification.md: add a bullet forUDPSCOPE_PROBE_SIDE_TAB_MS=3000withUDPSCOPE_EXIT_AFTER_MS=11000and what it asserts, in the list's existing form. -
docs/claude/invariants-gtk-and-chrome.md: the sidebar is anAdwViewSwitcherabove anAdwViewStackwith a scroller per page; at:174-181, extend the unrevealed-GtkRevealertrap — a stack's hidden page measures 0×0 and is still given the leftover width, so the rule it teaches (ask about state, not about size) carries over unchanged to the stack. State that noGtkRevealerremains in the sidebar and that a feature's on/off sets sensitivity, never visibility. -
docs/claude/invariants-math-channels.md: the math channels live in the signal panel's one list, behind aMATHdivider; there is no math pane; the node editor owns a channel's name and unit, which is why no row offers a rename; and a tick on a math row must reachApp::mathbecause that is the copy the session file is written from. -
docs/claude/invariants-readouts-and-cursors.md: a readout quotes a stored sample, so with the instruments off it blanks every cell to"--"rather than keeping the last values; the panel itself stays on screen, insensitive. -
src/ui/shortcuts.ui: the four descriptions Task 1 made false —:49tand:69cstill say "Show or hide…" where both now enable/disable a feature, and:76m/:83gstill name the measurements and math panes where both now select a tab.src/shortcuts.cpp's table is the application's dispatch and was corrected in Task 1; this file is the presentation the user reads, and the[help]probe compares row counts only, so nothing catches the drift. -
src/help/01-overview.txt::34lists "the pane switches -- signal list, cursors, measurements, math, side panel" and:38"the trigger, cursors, measurements and math panes at the top". Both describe the five-revealer column. Rewrite as the two tabs and the controls that survive. -
README.md: a note under the deviations recording thatSPECS.mdnames the panes as separate collapsible sections, and the built sidebar is two tabs with the math list folded into the signal list — with the reason (five sections in one 320 px column left every one of them short of room). -
Step 7: Re-read the whole self-check once, serially
python3 tools/mock_udpstreamer.py --port 9100 &
MOCK=$!
UDPSCOPE_PROBE_SIDE_TAB_MS=3000 UDPSCOPE_PROBE_EXIT_MS=7000 UDPSCOPE_EXIT_AFTER_MS=12000 \
xvfb-run -a ./build/udpscope --port 9100 2>&1 | tail -100
kill $MOCK
Expected: side-tab-probe as in step 3; panels: / signals panel: / panel controls: /
reset button sensitive: as in task 1 step 16 plus divider=1; zero Gtk-CRITICAL; and the
build's warning count still the number task 6 recorded.
- Step 8: Commit
git add src/ui_probe.cpp src/ui_app.hpp src/ui.cpp docs/claude README.md
git commit -m "test: a probe reads the hidden tab's accumulator, and the docs follow the tabs"
Self-review
Spec coverage. Every section of
docs/superpowers/specs/2026-10-14-sidebar-tabs-design.md maps to a task: §1 the container →
task 1 steps 1-6; §2 state instead of visibility → task 1 steps 3, 7-11; §3 one channel list →
tasks 3, 4, 5; §4 persistence → task 2; §5 what runs on a hidden page → task 7 steps 1-3; §6
verification → task 1 steps 13-16, task 7. The icon removal the spec folds into §1 is task 6,
because it changes a measured baseline and so needs its own clean build.
Gaps in the spec that this plan closes, and the call taken. §6 lists four changed readings and
misses four more that the same edits invalidate: dbg_reset_visible (task 1 step 13 — it becomes
a sensitivity reading the moment on_cursors_toggled stops calling gtk_widget_set_visible),
dbg_trigger_revealed_default (same step), cursors_probe_report's pane= field (same step),
and the measures pane: dump's revealed= field (task 1 step 14). The spec is also silent on the
keyboard, where AppAction::MeasuresPane/MathPane and the m/g accelerators would otherwise
dangle: task 1 step 11 renames them SideTabMeasure/SideTabSignals, keeps both keys, and
rewrites the t/c descriptions from "Show or hide…" to enable/disable, which is what those
toggles now do. Finally the spec's — for a blanked cursor cell is rendered "--", the spelling
the pane already uses — recorded in Global Constraints with the reason.
Placeholders. None. Two steps name an existing identifier generically rather than guessing at
it — task 1 step 5's local GtkBuilder* and task 4 step 4's signal_row_app() /
signal_row_name() row-context accessors — and each says exactly which existing function to read
it off (on_info_clicked, and the side/events_divider resolutions a few lines above). Task 6
step 3 deliberately measures a number instead of asserting one, because whether the icons'
-Woverlength-strings survives depends on a byte count no one can read off the source.
Type consistency. side_tab is std::string in both App and LayoutState;
select_side_tab(App&, const char*) takes const char* and is called with a literal (task 1,
task 7) and with .c_str() (task 2). math_index_for_signal(App&, const std::string&) returns
int with -1 for absent, and every caller checks < 0 before casting to std::size_t.
MeasureStatsAccum::n is std::uint64_t and is printed with G_GUINT64_FORMAT throughout, as
stats_probe_report already does. on_side_tab_changed has the three-argument notify::
signature. App::side_box keeps its meaning — the sidebar column — and App::side_stack is the
stack inside it; the two are not interchangeable and update_side_visibility() uses the column.