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

1433 lines
66 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
# 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 `<property name="sidebar">` block)
with:
```xml
<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\`;` 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 `<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`:
```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 <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:
```cpp
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:
```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<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:
```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>`), `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<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:
```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<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:
```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 `<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**
```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 `<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**
```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`
- 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:
```cpp
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`):
```cpp
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.
```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<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**
```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.