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