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>
1433 lines
66 KiB
Markdown
1433 lines
66 KiB
Markdown
<!-- 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.
|