Files
udpscope/docs/superpowers/plans/2026-10-14-sidebar-tabs.md
T
Martino FerrariandClaude Opus 4.6 e10a92fc52 docs: correct the sidebar spec, and plan its implementation
The spec claimed the math pane and the signal list were two lists of the same
channels; they are one. `append_math_signals_locked()` already puts every math
channel into `signals()` and nothing filters `sig.math`, so the merge is mostly
a deletion -- the list gains a `MATH` divider and a second menu, and the pane
goes. §2, §3 and §6 say so now.

The plan is seven tasks, each ending in a build, `make test-ui` and one short
headless probe read.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-14 10:48:21 +02:00

66 KiB
Raw Blame History

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-initializers on MathNode::source, two -Woverlength-strings on the help-pages' and the icons' generated GResource .c), two on an incremental build, and zero Gtk-CRITICAL in any headless run. Task 6 deliberately changes the icon warning and re-measures it.
  • make test-ui must pass at every commit. It reports a file count for src/ui: 15 until task 5 deletes math-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 an App::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" and signals / "Signals". The switcher shows titles only; neither page gets an icon-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 GtkRevealer and no GtkPaned may 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 spelling update_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::math is the copy the session file is written from. Any control that changes a math channel's enabled state must mirror it into App::math before schedule_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

  1. The tabbed container and the deletion of measures_toggle/math_toggle/signal_toggle are 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.
  2. 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.
  3. So that no call is added and then removed, task 1 makes apply_math_channels() call both rebuild_math_pane(app) and rebuild_signal_panels(app); task 5 deletes only the first line. That needs an early nullptr guard in rebuild_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 the math_toggle and measures_toggle children, correct the header comment
  • Modify: src/ui_app.hpp — fields and declarations
  • Modify: src/ui.cpp — on_activate assembly, 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 under xvfb-run

Interfaces:

  • Consumes: build_trigger_panel(App&), build_cursors_panel(App&), build_measures_panel(App&), build_signal_panel(App&) — all GtkWidget*, all unchanged.
  • Produces, for tasks 2-7:
    • GtkWidget* App::side_stack — the AdwViewStack; App::side_box now points at the same widget's parent box, i.e. side_column.
    • GtkWidget* App::side_switcher — the AdwViewSwitcher.
    • GtkWidget* App::cursors_panel — the root returned by build_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 updates App::side_tab; a no-op if side_stack is 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 App fields

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_toggle id resolutions (:4514-4515) and their null checks (:4518-4519);
  • delete on_measures_toggled() and on_math_toggled() and their g_signal_connects (:4541-4558), and their declarations in src/ui_app.hpp;
  • delete the overflow-menu row at :4452-4455 (the four lines building app.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() and note_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): read pane= from gtk_widget_get_sensitive(a->cursors_panel) instead of the revealer, and rename the field in the format string to pane_sensitive=.

  • measures_probe_step case 0 (:2501): replace the gtk_toggle_button_set_active(a->measures_toggle, true) with select_side_tab(*a, "measure");.

  • keys_probe (:4028-4047): the 'm' and 'g' before_after(...) calls now read a->side_tab rather 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", reading app.dbg_reset_sensitive.

  • :5673-5681 the panels: line → replace (revealed=%d) with (page_current=%d) for signals, (revealed=%d) with (sensitive=%d) for trigger, toggles=%d with switcher=%d, and the side=%dx%d (visible=%d) reading now comes from dbg_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-6092 the measures pane: line → delete the revealed=%d field and its app.measures_revealed argument.

  • 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: reads signals=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) and visible=0 after 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, without measures and math;

  • zero Gtk-CRITICAL anywhere 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 — add side_tab to LayoutState
  • 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*) and App::side_tab from 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 in rebuild_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 into App::math whose 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 the math-panel.ui entry
  • Modify: src/ui_math.cpp — delete build_math_panel() (:1044-1062) and the rebuild_math_pane(app) call in apply_math_channels()
  • Modify: src/ui.cpp — delete rebuild_math_pane() (:2251-2319), the two gtk_box_append lines and the rebuild_math_pane(*app) call added in task 1, and the math panel: dump block (:5639-5656)
  • Modify: src/ui_app.hpp — delete math_list and the two deleted functions' declarations
  • Test: make test-ui must 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 — add side_tab_probe and side_tab_probe_report
  • Modify: src/ui_app.hpp — declare both
  • Modify: src/ui.cpp:5401 area — register UDPSCOPE_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
  • 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_MS reading documented in probe-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 the revealed field's entry.

  • :310-320 / :345-355 (the panels: and toolbar 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=D becomes switcher=D and 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 new rows/signals/math/divider fields, with rows == signals + 1 + math as the invariant and divider=0 named as a list that lost its +.

  • :1263-1272 (math panel:) — delete the whole entry; the pane is gone.

  • the reset button visible entry — now reset 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 it entry — now trigger panel sensitive before the probe toggled it.

  • the [cursors] entry's pane= field — now pane_sensitive=.

  • a new entry for [side-tab-probe], with the two lines from step 3, advanced as the assertion and advanced=0 as "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 for UDPSCOPE_PROBE_SIDE_TAB_MS=3000 with UDPSCOPE_EXIT_AFTER_MS=11000 and what it asserts, in the list's existing form.

  • docs/claude/invariants-gtk-and-chrome.md: the sidebar is an AdwViewSwitcher above an AdwViewStack with a scroller per page; at :174-181, extend the unrevealed-GtkRevealer trap — 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 no GtkRevealer remains 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 a MATH divider; 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 reach App::math because 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.

  • README.md: a note under the deviations recording that SPECS.md names 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.