# 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 `AdwViewStackPage`s, 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 `` block) with: ```xml vertical 300 side_stack wide 4 4 6 6 1 measure Measure never automatic vertical signals Signals never automatic vertical ``` 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\`;` with `the 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 `` block whose object id is `math_toggle` and the `` 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`: ```cpp /// 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`: ```cpp /// 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: ```cpp // 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 ` 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: ```cpp void on_side_tab_changed(GObject* o, GParamSpec*, gpointer user) { App* app = static_cast(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: ```cpp gtk_revealer_set_reveal_child(GTK_REVEALER(app->trigger_revealer), on); ``` with: ```cpp // 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()`: ```cpp // 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: ```cpp 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_connect`s (`: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: ```cpp 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 ```cpp 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: ```cpp {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 `case`s: ```cpp 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);`: ```cpp 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: ```cpp 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`): ```cpp 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: ```bash 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** ```bash 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: ```bash 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`): ```cpp /// 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: ```cpp } 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: ```cpp 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;`: ```cpp 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: ```cpp 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: ```bash 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: ```bash 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** ```bash 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: ```bash 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: ```cpp // 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: ```cpp // 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`: ```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(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(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: ```bash 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** ```bash 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::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: ```bash 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()`: ```cpp /// `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(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: ```cpp 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`: ```cpp 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: ```cpp // 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: ```cpp // `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(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: ```bash 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** ```bash 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: ```cpp 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: ```cpp 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** ```bash git rm src/ui/math-panel.ui ``` and delete the `math-panel.ui` 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** ```bash 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** ```bash 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** ```bash git rm src/icons/scalable/actions/udpscope-measure-symbolic.svg \ src/icons/scalable/actions/udpscope-math-symbolic.svg ``` and delete the two matching `` 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** ```bash 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. ```bash 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** ```bash 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` - Modify: `src/ui/shortcuts.ui` and `src/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::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: ```cpp std::vector 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(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(user); if (a->window == nullptr) return G_SOURCE_REMOVE; std::string name; { std::lock_guard 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`): ```cpp if (const char* env = g_getenv("UDPSCOPE_PROBE_SIDE_TAB_MS")) { g_timeout_add(static_cast(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. ```bash 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: ```cpp 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(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. - `src/ui/shortcuts.ui`: the four descriptions Task 1 made false — `:49` `t` and `:69` `c` still say "Show or hide…" where both now enable/disable a feature, and `:76` `m` / `:83` `g` still 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`: `:34` lists "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 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** ```bash 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** ```bash 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.