From e36f2a202ba327b40a24452e96417d620ab07ebe Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 00:46:43 +0100 Subject: [PATCH] docs: plan for the signal badges (step 2 of the redesign) --- .../plans/2026-11-03-ui-badges-signals.md | 832 ++++++++++++++++++ 1 file changed, 832 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-03-ui-badges-signals.md diff --git a/docs/superpowers/plans/2026-11-03-ui-badges-signals.md b/docs/superpowers/plans/2026-11-03-ui-badges-signals.md new file mode 100644 index 0000000..eed708f --- /dev/null +++ b/docs/superpowers/plans/2026-11-03-ui-badges-signals.md @@ -0,0 +1,832 @@ +# Signal badges -- step 2 of the badge-bar redesign 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:** Put the signals on the badge bar: one badge per enabled signal (a colour dot and the name; up to 6, then one "N signals" badge), each opening a per-signal slide-over page, each a drag handle (to a plot row, or to reorder), plus a trailing `+` that opens the full signal list as a slide-over page. The signal list leaves the dock. + +**Architecture:** The existing `signal_panel` (heading, hint, list, math divider) is *reparented* from the temporary dock into the `signals` slide-over page. A new unit, `src/ui_signal_page.cpp`, owns the lazily-created `signal:` pages (colour, calibration, information, "plot on row N", math edit/delete) and keeps them in step with the model. `src/ui_badges.cpp` grows the signal badges (inside a scroller so the bar's minimum width never depends on how many there are). The drop that today puts a dragged list row on a plot becomes `assign_signal_to_row()`, shared by the drop and by the page's buttons; the badge replaces the list row as the drag source. + +**Tech Stack:** C++20, GTK4, libadwaita >= 1.8, CMake. No unit-test suite: verification is the app's own headless self-check (`xvfb-run`, mock sources) -- extend `src/ui_probe.cpp` / `src/ui_badges.cpp` probes, never a separate harness. + +Spec: `docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md` ("Badge bar", "Slide-over panel", rollout step 2). Step 1 plan: `docs/superpowers/plans/2026-11-02-ui-badges-dock-shell.md`. Branch: `feature/ui-badges-dock` (continue on it). + +## Global Constraints + +Copied from the spec and the repository's rules; every task includes them. + +- **Layout must not depend on content** (`docs/claude/invariants-plot-and-layout.md`): the badge bar keeps its fixed 36 px height and its minimum width must not grow with the number of signals (the signal badges sit in a `GtkScrolledWindow`, `GTK_POLICY_EXTERNAL` / `GTK_POLICY_NEVER`); the slide-over still overlays and never resizes the plot. +- **A `GtkOverlay` child is picked wherever its allocation is**: nothing in this step adds an overlay child. +- Signal badges: "one badge per enabled signal, up to 6; beyond that a single 'N signals' badge. A badge shows a colour dot and the name, is a drag handle (reorder, or drop on a plot row, which replaces today's drag from the signal list), and opens that signal's page (colour, calibration, row, math). A trailing `+` opens the `signals` page (the full list, to enable more; the math editor entry)." (spec, verbatim) +- Page ids: `trigger`, `signal:`, `signals`, `measure`, `cursors`, `events`, `source-`. This step adds `signals` and `signal:`. +- One slide-over page at a time; Esc or a click outside closes it; each page has its own scroller; the open page is not saved. +- "Disable, do not hide": a control that does not apply is insensitive and stays on screen (the one precedent kept is the acquire tick, hidden in a file view -- see `rebuild_signal_list`). +- Text buttons only for new controls (the `+` is the character, not an icon); no icons in badges. The colour dot is the existing `chip_markup()` disc. +- The widget-lifetime trap (`docs/claude/invariants-gtk-and-chrome.md`): a click handler that calls `rebuild_signal_panels()` destroys the page/row that owns the emitting button; hold the emitter with `outlive_the_rebuild(widget)` first. A drop handler never destroys the widgets it was emitted from. +- Every new source file carries the SPDX MIT header (`// SPDX-License-Identifier: MIT` / `// SPDX-FileCopyrightText: 2026 Martino Ferrari`). +- Baselines: a clean build emits exactly **one** warning (the icons' `-Woverlength-strings`), an incremental build **zero**; the dump's `gtk warnings:` reads `0`; no `Gtk-CRITICAL`/`Gtk-WARNING`. +- Verification runs are short and serial; kill every mock you start; do not re-run to chase cosmetics. Headless needs `GDK_BACKEND=x11` and, for screenshots, `GSK_RENDERER=cairo` + `import -window root`. +- Do not push, merge or amend. Commit per task on `feature/ui-badges-dock`. + +## Deliberate step-2 deviations from the spec (state them in the final report) + +- **Reorder is in memory only.** The badge order lives in `App::badge_order` for the session; it is not written to the session file (the spec's session section lists only the dock state). A new signal sorts after the ordered ones, in model order. +- **Beyond 6 enabled signals the "N signals" badge replaces the per-signal badges *and* the `+`** (it opens the same `signals` page, so a second button for it would be a duplicate). Per-signal pages for those signals are reached by activating a row of the `signals` list (new in this step); drag-to-row for them is by the page's "Plot on" buttons. +- **The list's drag source is removed**, as the spec says ("replaces today's drag from the signal list"); `row_drag_sidebar()` goes with it if nothing else calls it. +- A signal page opened for a signal that is not acquired shows its acquire tick (so a list row can be activated without first being ticked). + +## File Structure + +| File | Create/Modify | Responsibility | +| --- | --- | --- | +| `src/ui_signal_page.cpp` | Create | The `signal:` pages: ensure/fill/prune, their buttons, the `signal_pages` probe helpers. | +| `src/ui_badges.cpp` | Modify | Signal badges, the `+` badge, "N signals" overflow, scroller, reorder drop, drag source, `signal_badges_probe`. | +| `src/ui_signals.cpp` | Modify | `listed_signals()`, `assign_signal_to_row()`, `reset_signal_color()`, row-activated -> page, list drag source removed, header comment fixed. | +| `src/ui_slideover.cpp` | Modify | `slideover_remove_page()`. | +| `src/ui_app.hpp` | Modify | `Badge` fields, `BadgeBar` fields, `App::badge_order` / `App::signal_pages`, declarations. | +| `src/ui.cpp` | Modify | Signal panel into the `signals` page; `ShowSignals` opens it; probe env var; `row_drag_sidebar` removal. | +| `src/ui_tour.cpp`, `src/ui_context_help.cpp` | Modify | The signals step / hint re-pointed to the page; badge text. | +| `src/ui_probe.cpp` | Modify | Every probe that measured or walked `signal_panel` / `signal_list` while it lived in the dock. | +| `src/ui_widgets.cpp` | Modify | CSS for the signal badge dot/name if needed. | +| `CMakeLists.txt` | Modify | Add `src/ui_signal_page.cpp` next to `src/ui_badges.cpp`. | +| `README.md`, `docs/claude/*.md` | Modify | Task 5. (`USER-GUIDE.md` and the Mallard manual are spec step 4.) | + +--- + +### Task 1: The `signals` page and the `+` badge + +The list moves out of the dock into a slide-over page; the `+` badge and the `g` key open it. Nothing else changes yet (the list rows keep their menu and their drag source). + +**Files:** +- Modify: `src/ui.cpp`, `src/ui_badges.cpp`, `src/ui_app.hpp`, `src/ui_signals.cpp` (header comment), `src/ui_tour.cpp`, `src/ui_context_help.cpp`, `src/ui_probe.cpp` + +**Interfaces:** +- Consumes (step 1): `slideover_add_page(App&, const std::string& id, const std::string& title, GtkWidget* content) -> bool`, `slideover_open/close/toggle(App&, ...)`, `slideover_page(const App&) -> std::string`, `Badge{button,name,state,page}`, `make_badge(...)`, `BadgeBar{bar,signals_slot,system_slot,trigger,source}`. +- Produces: `BadgeBar::plus` (`Badge`, page `"signals"`, name label `"+"`); the `signals` page registered once in `on_activate`. + +- [ ] **Step 1: Freeze today's self-check** + +Build `HEAD` (`cmake --build build -j`), then: + +```sh +python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$! +sleep 1.5 +xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --server 127.0.0.1 --port 8114 > /tmp/ui-signals-base.log 2>&1 +kill $MOCK +sed 's/[0-9.]\+/N/g' /tmp/ui-signals-base.log > .superpowers/sdd/ui-signals-baseline-udps.txt +grep -c 'Gtk-CRITICAL' /tmp/ui-signals-base.log; grep 'gtk warnings' /tmp/ui-signals-base.log +``` +Expected: `0` and `gtk warnings: 0`. Every later task diffs its own normalised run against this file and must explain every differing line from that task's "allowed baseline changes"; any other difference is a defect. + +- [ ] **Step 2: Fix the stray `\n` in `src/ui_signals.cpp` line 4** + +The header comment reads `...with its colour chip and acquire tick, the panel around it,\n// and the drop that puts a signal on a row.` on one line (a literal backslash-n). Replace it with two comment lines: + +```cpp +// The signal list: one row per signal with its colour chip and acquire tick, the panel around it, +// and the drop that puts a signal on a row. +``` + +- [ ] **Step 3: Reparent the panel into a `signals` page (`src/ui.cpp`, `on_activate`, ~line 5172)** + +Replace + +```cpp + app->signal_panel = build_signal_panel(*app); + gtk_box_append(GTK_BOX(app->dock_page), app->signal_panel); + gtk_box_append(GTK_BOX(app->dock_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + gtk_box_append(GTK_BOX(app->dock_page), app->cursors_panel); +``` +with +```cpp + app->signal_panel = build_signal_panel(*app); + gtk_box_append(GTK_BOX(app->dock_page), app->cursors_panel); +``` +and register the page where the other pages are registered (after `build_slideover`, next to the `"trigger"` page; the panel must exist first, so if that registration happens before line 5172, add the `slideover_add_page(*app, "signals", "Signals", app->signal_panel);` call right after the panel is built instead): + +```cpp + slideover_add_page(*app, "signals", "Signals", app->signal_panel); +``` +Read the surrounding code before editing: the registration must happen once, after `build_slideover(*app)` and after `build_signal_panel`. Update the comments that say the dock holds "the signal list, the cursors and the measurements" (`dock_show_section`, `ui_app.hpp` `dock_page`) to "the cursors and the measurements". + +- [ ] **Step 4: `g` opens the page (`run_app_action`, ~line 4682)** + +```cpp + case AppAction::ShowSignals: slideover_toggle(app, "signals"); break; +``` +and in `dock_show_section` delete the `"signals"` branch (the dock no longer holds the list); keep `"measure"`. + +- [ ] **Step 5: The `+` badge (`src/ui_badges.cpp`, `src/ui_app.hpp`)** + +In `BadgeBar` add `Badge plus;`. In `build_badge_bar`, after the Source badge: + +```cpp + // The full signal list. Insensitive until the stream has declared signals ("disable, do not hide"). + make_badge(app, bb.plus, bb.signals_slot, "+", "signals"); + gtk_widget_set_tooltip_text(bb.plus.button, "all signals: switch more on, add a math channel"); +``` +(`make_badge` appends a second, empty `state` label; for `+` leave it empty, it takes no width.) In `sync_badge_bar`, before the early return at the `bb.source.button == nullptr` check: + +```cpp + if (bb.plus.button != nullptr) { + gtk_widget_set_sensitive(bb.plus.button, !app.model->signals().empty()); + set_open(bb.plus.button, slideover_page(app) == "signals"); + } +``` +Step 2's later tasks insert the signal badges *before* the `+` in `signals_slot`; Task 3 handles the ordering, so do not worry about it here. + +- [ ] **Step 6: Tour and context help** + +`src/ui_tour.cpp`: the `"signals"` step (line ~460) becomes `{"signals", "signals", "The signals", ...}` (second field is the slide-over page; see the `"trigger"` step), text: `"Every signal the source declares, each with the switch that acquires it. The + badge opens this list; a signal has to be switched on before it can be plotted, measured or searched."`. The `"side"` step text becomes `"The dock beside the plot: the cursors, then the measurements. The buttons in the bar above show and hide it."`. Keep `&App::signal_list` as the rect. `src/ui_context_help.cpp` line ~92: replace the last sentence of the `"signals"` hint (`A signal that is dragged onto a plot is drawn there.`) with `A signal's badge, dragged onto a plot, draws it there.`; the `"signals_config"` hint's `which plot a signal is dragged onto` becomes `which plot a signal is drawn on`. + +- [ ] **Step 7: Re-point the probes that treated the list as part of the dock** + +`src/ui_probe.cpp` references `signal_panel`/`signal_list` at ~103-186, ~1828, ~1853, ~3843-3847, ~5827, ~6165, ~6431 and the tour-surface probe ~7024-7097. The page is closed by default, so widgets inside it are unmapped and have no allocation. For each: (a) a probe that measures the panel's allocated size -- open the `signals` page first (`slideover_open(*a, "signals")`) and close it after, or measure with `gtk_widget_measure`; (b) a probe that walks the rows needs nothing (children exist while unmapped); (c) the stress line at ~1853 (`gtk_widget_set_size_request(a->signal_panel, -1, 3000)`) existed to overflow the *dock's* scroller; re-point it to `a->cursors_panel`; (d) the `ShowSignals` check at ~6431 asserts `slideover_page(*a) == "signals"` and then closes it; (e) `~6165` (`gtk_widget_get_parent(app.signal_list)`) -- read what it needs and take it from the page instead. Print the same lines as before wherever the value is still meaningful. + +- [ ] **Step 8: Verify** + +```sh +cmake --build build -j 2>&1 | grep -E 'warning|error'; echo "rc=$?" +python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$! +sleep 1.5 +xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --server 127.0.0.1 --port 8114 > /tmp/ui-signals-t1.log 2>&1 +kill $MOCK +sed 's/[0-9.]\+/N/g' /tmp/ui-signals-t1.log | diff .superpowers/sdd/ui-signals-baseline-udps.txt - ; grep -c Gtk-CRITICAL /tmp/ui-signals-t1.log; grep 'gtk warnings' /tmp/ui-signals-t1.log +``` +Then run `UDPSCOPE_PROBE_SLIDEOVER_MS=1500`, `UDPSCOPE_PROBE_BADGES_MS=1500` and `UDPSCOPE_PROBE_TOUR_SURFACE_MS=` the same way. Expected: zero build warnings; the slide-over and badge probes read as in step 1 (`table_bad=0`, `open_marked=1`, `closed_unmarked=1`); the tour-surface probe shows the `signals` step now opens the `signals` page and `tour_plan_holds(signal_list)=1`. **Allowed baseline changes:** the dock's size/position lines that included the list (re-pointed per Step 7), any `signal_panel` geometry line. Explain each in the report. + +- [ ] **Step 9: Commit** + +```sh +git add -A src docs +git commit -m "ui: the signal list is the signals slide-over page, opened by a + badge or g" +``` + +--- + +### Task 2: Per-signal pages, `assign_signal_to_row()` and `reset_signal_color()` + +Builds `signal:` pages (nothing opens them yet except activating a list row) and extracts the two actions the page shares with the list. + +**Files:** +- Create: `src/ui_signal_page.cpp` +- Modify: `src/ui_signals.cpp`, `src/ui_slideover.cpp`, `src/ui_app.hpp`, `src/ui.cpp` (probe env var), `src/ui_probe.cpp` or `src/ui_signal_page.cpp` (probe), `CMakeLists.txt` + +**Interfaces:** +- Produces (declare in `ui_app.hpp`, namespace `udpscope::ui_impl`): + +```cpp +/// Every plottable signal once (the time base skipped, a group's layers folded to the first), in model order. +/// The one rule the list and the badges share. +std::vector listed_signals(const std::vector& signals); +/// Puts `signal` (an index into `signals()`) on plot `row` in the first free slot, switching it on first if +/// need be. Says why not through `set_notice` and returns false when it cannot. The one path a drop and a +/// signal page's "Plot on" button share. +bool assign_signal_to_row(App& app, std::size_t signal, int row); +/// Back to the palette colour for the signal's position. +void reset_signal_color(App& app, std::size_t signal); +/// Holds `widget` alive across the current emission (was `static` in ui_signals.cpp). +void outlive_the_rebuild(GtkWidget* widget); +bool slideover_remove_page(App& app, const std::string& id); // ui_slideover.cpp +/// Creates the page `signal:` if it is missing, fills it, and returns whether it exists afterwards. +bool signal_page_ensure(App& app, const std::string& name); +/// Called at the end of `rebuild_signal_panels`: refills every existing signal page, drops the pages of +/// signals the model no longer has (closing the slide-over first if one of them is open). +void signal_pages_sync(App& app); +``` +- `App` gains `std::map signal_pages;` (name -> the page's content box). + +- [ ] **Step 1: `listed_signals()`** + +In `src/ui_signals.cpp`, add (before `rebuild_signal_list`): + +```cpp +std::vector listed_signals(const std::vector& signals) { + std::vector out; + for (std::size_t i = 0; i < signals.size(); ++i) { + if (ScopeModel::is_time_signal(signals, i)) continue; // the time base is not plottable + const std::string base = layer_base_name(signals[i].name); + bool seen = false; + for (std::size_t j = 0; j < i && !seen; ++j) { + seen = !ScopeModel::is_time_signal(signals, j) && layer_base_name(signals[j].name) == base; + } + if (!seen) out.push_back(i); + } + return out; +} +``` +and make `rebuild_signal_list` use it: + +```cpp + std::vector rows; + for (std::size_t i : listed_signals(signals)) { + // Locked: a row per signal that is *on* ... (keep the existing comment) + if (app.signals_locked && !app.model->signal_enabled(i)) continue; + rows.push_back(i); + } +``` +Behaviour is unchanged (the dump's `rows == signals + 1 + math` line must not move). + +- [ ] **Step 2: `assign_signal_to_row()`** + +Move lines `on_drop_channel` from `if (signal >= app->model->signals().size()) return FALSE;` through `notice("plotting '" ...)` into the new function, with `app->` -> `app.`; keep the `notice` lambda, the "enable it, then take the channel that appears" branch, the free-slot search and the two refusal messages verbatim, `return false`/`return true`. `on_drop_channel` becomes: + +```cpp +gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, gpointer user) { + App* app = static_cast(user); + if (!G_VALUE_HOLDS_STRING(value)) return FALSE; + const int row = row_at(*app, x, y); // where the drop landed decides the row: one area covers them all + const char* text = g_value_get_string(value); + const std::size_t signal = static_cast(std::atoll(text ? text : "0")); + if (!assign_signal_to_row(*app, signal, row)) return FALSE; + cancel_drag_on(*app, row); + return TRUE; +} +``` +`rebuild_signal_panels(app)` stays inside `assign_signal_to_row` (before the notice), as it is today. + +- [ ] **Step 3: `reset_signal_color()`** + +Extract the body of the `recolor` lambda (`ui_signals.cpp` ~386-401) into: + +```cpp +void reset_signal_color(App& app, std::size_t signal) { + std::string name; + { + std::lock_guard lock(app.model->mutex()); + const std::vector& sigs = app.model->signals(); + if (signal >= sigs.size()) return; + name = sigs[signal].name; + } + app.rows->clear_signal_color(name); + app.relayout_pending = true; + rebuild_signal_panels(app); + schedule_layout_save(app); +} +``` +The lambda keeps `outlive_the_rebuild(GTK_WIDGET(b))` first (the list rebuild destroys it) and then calls this. Remove `static` from `outlive_the_rebuild` and move its definition above `rebuild_signal_list` users unchanged. + +- [ ] **Step 4: `slideover_remove_page()` (`src/ui_slideover.cpp`)** + +Read how `slideover_add_page` records a page (stack child name, any title/page registry on `SlideOver`). Implement the inverse: returns false if the id is unknown; refuses (returns false) if it is the open page; otherwise removes the stack child (`gtk_stack_remove`) and erases whatever registry entry `slideover_add_page` made. + +- [ ] **Step 5: `src/ui_signal_page.cpp`** + +Create the file with the SPDX header, `#include "ui_app.hpp"`, ``, ``, namespaces `udpscope::ui_impl`. Page ids are `"signal:" + name`. Contents (an anonymous namespace plus the two public functions): + +```cpp +namespace { + +std::string page_id(const std::string& name) { return "signal:" + name; } + +/// The index of `name` in the model's signals, or npos. +std::size_t signal_index(App& app, const std::string& name) { + std::lock_guard lock(app.model->mutex()); + const std::vector& s = app.model->signals(); + for (std::size_t i = 0; i < s.size(); ++i) { + if (s[i].name == name) return i; + } + return std::string::npos; +} + +GtkWidget* text_button(const char* label, GtkWidget* parent) { + GtkWidget* b = gtk_button_new_with_label(label); + gtk_box_append(GTK_BOX(parent), b); + return b; +} + +/// Stores the signal's index and the page's name on the button, so a handler resolves identity the way the +/// list does. +void tag(GtkWidget* b, std::size_t index) { + g_object_set_data(G_OBJECT(b), "udpscope-signal", GSIZE_TO_POINTER(index)); +} +std::size_t tagged(GtkButton* b) { + return static_cast(GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(b), "udpscope-signal"))); +} + +void fill(App& app, const std::string& name, GtkWidget* page) { + for (GtkWidget* c = gtk_widget_get_first_child(page); c != nullptr;) { + GtkWidget* next = gtk_widget_get_next_sibling(c); + gtk_box_remove(GTK_BOX(page), c); + c = next; + } + const std::size_t index = signal_index(app, name); + if (index == std::string::npos) return; + // A snapshot, taken under the lock: the chip helper wants the whole vector, and the producer replaces it + // on every CONFIG. + std::vector all; + { + std::lock_guard lock(app.model->mutex()); + all = app.model->signals(); + } + if (index >= all.size()) return; + const SignalInfo sig = all[index]; + const bool enabled = app.model->signal_enabled(index); + const bool plotted = signal_plotted(app, sig.name); + const bool unlocked = !app.signals_locked; + + // Back to the list: a page reached from a list row has no other way home. + GtkWidget* back = text_button("All signals", page); + gtk_widget_set_halign(back, GTK_ALIGN_START); + gtk_widget_add_css_class(back, "flat"); + g_signal_connect(back, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { + slideover_open(*static_cast(user), "signals"); + }), + &app); + + GtkWidget* title = gtk_label_new(sig.name.c_str()); + gtk_widget_add_css_class(title, "title-3"); + gtk_widget_set_halign(title, GTK_ALIGN_START); + gtk_label_set_ellipsize(GTK_LABEL(title), PANGO_ELLIPSIZE_END); + gtk_box_append(GTK_BOX(page), title); + if (!sig.unit.empty()) { + GtkWidget* unit = gtk_label_new(sig.unit.c_str()); + gtk_widget_add_css_class(unit, "dim-label"); + gtk_widget_set_halign(unit, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(page), unit); + } + + // Acquire / compute. The same handler as the list's tick, which reads the signal index off the widget. + GtkWidget* enable = gtk_check_button_new_with_label(sig.math ? "Compute this channel" : "Acquire this signal"); + app.updating_panels = true; + gtk_check_button_set_active(GTK_CHECK_BUTTON(enable), enabled); + app.updating_panels = false; + g_object_set_data(G_OBJECT(enable), "udpscope-signal", GSIZE_TO_POINTER(index)); + g_signal_connect(enable, "notify::active", G_CALLBACK(on_signal_switch), &app); + gtk_widget_set_visible(enable, sig.math || app.waveform_name.empty()); + gtk_box_append(GTK_BOX(page), enable); + + // Colour. + GtkWidget* colour_head = gtk_label_new("Colour"); + gtk_widget_add_css_class(colour_head, "heading"); + gtk_widget_set_halign(colour_head, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(page), colour_head); + GtkWidget* chip = gtk_label_new(nullptr); + gtk_label_set_markup(GTK_LABEL(chip), signal_chip_markup(app, all, index).c_str()); + GtkWidget* colour_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + gtk_box_append(GTK_BOX(colour_row), chip); + GtkWidget* pick = text_button("Change colour...", colour_row); + gtk_widget_set_sensitive(pick, plotted && unlocked); + gtk_widget_set_tooltip_text(pick, plotted ? "the colour this signal is plotted in" + : "on no plot, so there is no trace to colour"); + tag(pick, index); + g_signal_connect(pick, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { + open_signal_color(*static_cast(user), tagged(b)); + }), + &app); + GtkWidget* reset = text_button("Default colour", colour_row); + gtk_widget_set_sensitive(reset, unlocked && app.rows->signal_colors().count(sig.name) != 0); + tag(reset, index); + g_signal_connect(reset, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { + outlive_the_rebuild(GTK_WIDGET(b)); + reset_signal_color(*static_cast(user), tagged(b)); + }), + &app); + gtk_box_append(GTK_BOX(page), colour_row); + + // Row: "Plot on 1 2 3 ..." and the way back off. + GtkWidget* row_head = gtk_label_new("Plot on"); + gtk_widget_add_css_class(row_head, "heading"); + gtk_widget_set_halign(row_head, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(page), row_head); + GtkWidget* rows_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); + for (int r = 0; r < app.rows->row_count(); ++r) { + GtkWidget* b = text_button(std::to_string(r + 1).c_str(), rows_box); + tag(b, index); + g_object_set_data(G_OBJECT(b), "udpscope-row", GINT_TO_POINTER(r)); + gtk_widget_set_tooltip_text(b, "draw this signal on this plot"); + g_signal_connect(b, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { + outlive_the_rebuild(GTK_WIDGET(b)); + assign_signal_to_row(*static_cast(user), tagged(b), + GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), "udpscope-row"))); + }), + &app); + } + gtk_box_append(GTK_BOX(page), rows_box); + GtkWidget* off = text_button("Remove from plots", page); + gtk_widget_set_halign(off, GTK_ALIGN_START); + gtk_widget_set_sensitive(off, plotted); + tag(off, index); + g_signal_connect(off, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { + App* a = static_cast(user); + outlive_the_rebuild(GTK_WIDGET(b)); + std::string n; + { + std::lock_guard lock(a->model->mutex()); + const std::vector& s = a->model->signals(); + if (tagged(b) >= s.size()) return; + n = s[tagged(b)].name; + } + const int gone = remove_signal_from_plots(*a, n); + a->relayout_pending = true; + rebuild_signal_panels(*a); + schedule_layout_save(*a); + set_notice(*a, "'" + n + "' left " + std::to_string(gone) + (gone == 1 ? " plot" : " plots"), 4); + }), + &app); + + // Information (a streamed signal's), calibration (ditto), and a math channel's editor. + GtkWidget* more_head = gtk_label_new(sig.math ? "Math channel" : "Signal"); + gtk_widget_add_css_class(more_head, "heading"); + gtk_widget_set_halign(more_head, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(page), more_head); + GtkWidget* more = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + if (sig.math) { + GtkWidget* edit = text_button("Edit graph...", more); + tag(edit, index); + g_signal_connect(edit, "clicked", G_CALLBACK(on_math_edit_clicked), &app); + GtkWidget* del = text_button("Delete", more); + gtk_widget_add_css_class(del, "destructive-action"); + tag(del, index); + g_signal_connect(del, "clicked", G_CALLBACK(on_math_delete_clicked), &app); + } else { + GtkWidget* info = text_button("Information", more); + tag(info, index); + g_signal_connect(info, "clicked", G_CALLBACK(on_info_clicked), &app); + GtkWidget* cal = text_button("Calibrate...", more); + gtk_widget_set_sensitive(cal, unlocked); + gtk_widget_set_tooltip_text(cal, "gain and offset, applied at ingest"); + tag(cal, index); + g_signal_connect(cal, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { + App* a = static_cast(user); + open_calibration(*a, GTK_WINDOW(a->window), tagged(b)); + }), + &app); + } + gtk_box_append(GTK_BOX(page), more); +} + +} // namespace +``` +`on_math_edit_clicked`, `on_math_delete_clicked`, `on_info_clicked`, `open_calibration`, `open_signal_color`, `signal_plotted` and `signal_chip_markup` are already declared in `ui_app.hpp` (check; if `on_math_edit_clicked` / `on_math_delete_clicked` are not, add the declarations). `on_math_edit_clicked` calls `dismiss_row_menu(button)`, which looks for a `GtkPopover` ancestor and does nothing when there is none -- confirm that by reading it. `on_math_delete_clicked` already calls `outlive_the_rebuild`. Then the two public functions: + +```cpp +bool signal_page_ensure(App& app, const std::string& name) { + if (signal_index(app, name) == std::string::npos) return false; + auto it = app.signal_pages.find(name); + if (it == app.signal_pages.end()) { + GtkWidget* page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_margin_top(page, 12); + gtk_widget_set_margin_bottom(page, 12); + gtk_widget_set_margin_start(page, 12); + gtk_widget_set_margin_end(page, 12); + if (!slideover_add_page(app, page_id(name), name, page)) return false; + it = app.signal_pages.emplace(name, page).first; + } + fill(app, name, it->second); + return true; +} + +void signal_pages_sync(App& app) { + for (auto it = app.signal_pages.begin(); it != app.signal_pages.end();) { + if (signal_index(app, it->first) == std::string::npos) { + if (slideover_page(app) == page_id(it->first)) slideover_close(app); + slideover_remove_page(app, page_id(it->first)); + it = app.signal_pages.erase(it); + } else { + fill(app, it->first, it->second); + ++it; + } + } +} +``` +`slideover_add_page` wraps `content` in a scroller and returns it through the stack, so `page` stays the box `fill` clears. Check that the gtk widget `page` is not destroyed by `slideover_remove_page` before `erase` (it is not used afterwards). Call `signal_pages_sync(app)` at the end of `rebuild_signal_panels` (before `app.signal_generation = ...`). `signal_index` takes the model mutex, so `signal_pages_sync` must not be called with it held (`rebuild_signal_panels` releases it before the list rebuild). + +- [ ] **Step 6: A list row opens its page** + +In `rebuild_signal_list`, tag each row: `g_object_set_data(G_OBJECT(row), "udpscope-signal", GSIZE_TO_POINTER(i));` (the math divider row has none and is not activatable). In `build_signal_panel`, after the null check: + +```cpp + g_signal_connect(app.signal_list, "row-activated", + G_CALLBACK(+[](GtkListBox*, GtkListBoxRow* row, gpointer user) { + App* a = static_cast(user); + const std::size_t index = static_cast( + GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(row), "udpscope-signal"))); + std::string name; + { + std::lock_guard lock(a->model->mutex()); + const std::vector& s = a->model->signals(); + if (index >= s.size()) return; + name = s[index].name; + } + if (signal_page_ensure(*a, name)) slideover_open(*a, "signal:" + name); + }), + &app); +``` +If `signals-panel.ui`'s list box has `activate-on-single-click` off or selection mode that swallows activation, set `activate-on-single-click` to true in code. Read the file; the row's own check button / colour-chip gesture / menu button must still work (they consume their own clicks). + +- [ ] **Step 7: Probe `UDPSCOPE_PROBE_SIGNAL_PAGES_MS`** + +Declare `gboolean signal_page_probe(gpointer)` (put it in `ui_signal_page.cpp`), schedule it in `ui.cpp` next to the badges probe. It takes the first non-math listed signal (from `listed_signals`), calls `signal_page_ensure`, `slideover_open("signal:")` and prints: + +``` +[signalpage] name= open= pages= enable_visible=<0|1> plot_buttons= plot_on_row0=<1 if assign_signal_to_row(...,0) returned true and signal_plotted> removed= back_to_list= page "signals"> pruned=<1 after the page's signal is gone> +``` +To check `pruned` without a CONFIG, call `slideover_close`, `slideover_remove_page` directly is *not* a test of `signal_pages_sync`; instead leave `pruned=-1` when it cannot be exercised, and exercise `signal_pages_sync` by the ordinary reconfiguration paths (adding then deleting a math channel with `add_math_channel` / `a->math.erase(...)`+`apply_math_channels` if the probe harness already does that elsewhere; otherwise `-1`). Close the slide-over at the end. + +- [ ] **Step 8: Verify** + +Build (zero warnings), then the UDPS baseline run as in Task 1 Step 8 diffed against `.superpowers/sdd/ui-signals-baseline-udps.txt` (expected: the Task 1 differences only), then: + +```sh +python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$! +sleep 1.5 +xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=5000 UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SIGNAL_PAGES_MS=1500 \ + ./build/udpscope --server 127.0.0.1 --port 8114 2>&1 | grep -E '\[signalpage\]|\[udpscope\] drop|Gtk-|gtk warnings' +kill $MOCK +``` +Expected: `open=1 plot_on_row0=1 removed=1 back_to_list=1`, `plot_buttons` equal to the figure's row count, `gtk warnings: 0`, no `Gtk-CRITICAL`. The existing drop probes (`[drop]` lines in the baseline) must read unchanged: they now run through `assign_signal_to_row`. + +- [ ] **Step 9: Commit** + +```sh +git add -A src CMakeLists.txt docs +git commit -m "ui: per-signal slide-over pages; the drop's assignment and the colour reset as shared functions" +``` + +--- + +### Task 3: The signal badges, the "N signals" overflow and the order + +**Files:** +- Modify: `src/ui_badges.cpp`, `src/ui_app.hpp`, `src/ui_signals.cpp` (call), `src/ui.cpp` (probe env var), `src/ui_widgets.cpp` (CSS, if any) + +**Interfaces:** +- Consumes: `listed_signals`, `signal_page_ensure`, `signal_chip_markup`, `signal_pages_sync` (Task 2). +- Produces: + +```cpp +// ui_app.hpp +struct Badge { /* existing */ std::string signal_name; std::size_t signal = 0; }; // the two new fields: empty/0 for non-signal badges +struct BadgeBar { + /* existing */ + GtkWidget* signals_row = nullptr; ///< the box inside the scroller: signal badges, then "+" + std::vector> signal_badges; ///< one per shown enabled signal; empty when overflowed + Badge overflow; ///< the "N signals" badge, used only beyond kMaxSignalBadges + Badge plus; ///< (Task 1) +}; +inline constexpr std::size_t kMaxSignalBadges = 6; +// App: std::vector badge_order; ///< names, in the order the user left the badges (memory only) +void sync_signal_badges(App& app); ///< rebuilds the signal badges from the model; ui_badges.cpp +gboolean signal_badges_probe(gpointer user); +``` +Include `` in `ui_app.hpp` if it is not already. + +- [ ] **Step 1: Put the signals in a scroller (`build_badge_bar`)** + +Before creating any badge: + +```cpp + // The signal badges sit in a scroller: a bar whose minimum width grew with the number of signals would + // widen the window (layout must not depend on content). EXTERNAL scrolls by wheel/touch with no bar. + GtkWidget* scroller = gtk_scrolled_window_new(); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_EXTERNAL, GTK_POLICY_NEVER); + gtk_widget_set_hexpand(scroller, TRUE); + bb.signals_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), bb.signals_row); + gtk_box_append(GTK_BOX(bb.signals_slot), scroller); +``` +and change the `+` badge to `make_badge(app, bb.plus, bb.signals_row, "+", "signals")` (it follows the signal badges because they are inserted before it; see Step 3). Move `make_badge`'s parent argument usage accordingly. + +- [ ] **Step 2: `make_signal_badge`** + +In the anonymous namespace of `ui_badges.cpp`: + +```cpp +/// A signal's badge: its colour dot (grey when it has no trace) and its name. Words and a dot, no icon. +void make_signal_badge(App& app, Badge& b, const std::vector& signals, std::size_t index) { + b.signal = index; + b.signal_name = signals[index].name; + b.page = "signal:" + b.signal_name; + b.button = gtk_button_new(); + gtk_widget_add_css_class(b.button, "flat"); + gtk_widget_add_css_class(b.button, "udpscope-badge"); + GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget* dot = gtk_label_new(nullptr); + gtk_label_set_markup(GTK_LABEL(dot), signal_chip_markup(app, signals, index).c_str()); + b.name = gtk_label_new(b.signal_name.c_str()); + gtk_label_set_ellipsize(GTK_LABEL(b.name), PANGO_ELLIPSIZE_END); + gtk_label_set_max_width_chars(GTK_LABEL(b.name), 14); + gtk_box_append(GTK_BOX(row), dot); + gtk_box_append(GTK_BOX(row), b.name); + gtk_button_set_child(GTK_BUTTON(b.button), row); + gtk_widget_set_tooltip_text(b.button, b.signal_name.c_str()); + g_object_set_data(G_OBJECT(b.button), "udpscope-app", &app); + g_signal_connect(b.button, "clicked", G_CALLBACK(on_badge_clicked), &b); +} +``` +`b.state` stays `nullptr`; `sync_badge_bar` never touches it for these. In `on_badge_clicked`, before `slideover_toggle(*app, b->page)`: + +```cpp + // A signal's page is made on first use (and kept in step by `signal_pages_sync`). + if (b->page.rfind("signal:", 0) == 0 && !signal_page_ensure(*app, b->signal_name)) return; +``` + +- [ ] **Step 3: `sync_signal_badges`** + +```cpp +void sync_signal_badges(App& app) { + BadgeBar& bb = app.badges; + if (bb.signals_row == nullptr) return; + std::vector signals; + { + std::lock_guard lock(app.model->mutex()); + signals = app.model->signals(); + } + // The enabled signals, in the user's order: names in `badge_order` first, the rest in model order. + std::vector shown; + for (std::size_t i : listed_signals(signals)) { + if (app.model->signal_enabled(i)) shown.push_back(i); + } + const auto rank = [&](std::size_t i) { + const auto it = std::find(app.badge_order.begin(), app.badge_order.end(), signals[i].name); + return static_cast(it - app.badge_order.begin()); // == size() when unknown: sorts last + }; + std::stable_sort(shown.begin(), shown.end(), [&](std::size_t a, std::size_t b) { return rank(a) < rank(b); }); + + // Clear: every signal badge, and the overflow if it was there. The "+" is kept. + for (GtkWidget* c = gtk_widget_get_first_child(bb.signals_row); c != nullptr;) { + GtkWidget* next = gtk_widget_get_next_sibling(c); + if (c != bb.plus.button) gtk_box_remove(GTK_BOX(bb.signals_row), c); + c = next; + } + bb.signal_badges.clear(); + bb.overflow = Badge{}; + + const bool overflowed = shown.size() > kMaxSignalBadges; + if (overflowed) { + make_badge(app, bb.overflow, bb.signals_row, "", "signals"); + gtk_label_set_text(GTK_LABEL(bb.overflow.name), (std::to_string(shown.size()) + " signals").c_str()); + } else { + for (std::size_t i : shown) { + bb.signal_badges.push_back(std::make_unique()); + make_signal_badge(app, *bb.signal_badges.back(), signals, i); + gtk_box_insert_child_after(GTK_BOX(bb.signals_row), bb.signal_badges.back()->button, + gtk_widget_get_prev_sibling(bb.plus.button)); + } + } + // The overflow badge stands for the "+" (it opens the same page); the "+" is shown only when it is not. + gtk_widget_set_visible(bb.plus.button, !overflowed); + sync_badge_bar(app); +} +``` +`make_badge` appends to its parent; for the overflow badge it lands after the `+`, which is hidden then, so the order is immaterial. `make_badge(..., "", ...)` with an empty name: fine, the text is set right after. (`Badge{}` assignment: `Badge` holds only pointers/strings so this is a plain reset; the overflow button was destroyed by the clear loop.) The "+" being hidden when overflowed is the one deliberate use of hiding: the overflow badge replaces it. In `sync_badge_bar` add, for each signal badge and the overflow, `set_open(button, slideover_page(app) == badge.page)` (the overflow's page is `"signals"`, so it shares the `+`'s rule: both read the same state, only one is visible). + +- [ ] **Step 4: Rebuild when the model's structure or a plot assignment changes** + +At the end of `rebuild_signal_panels` (after `signal_pages_sync(app)` from Task 2) add `sync_signal_badges(app);`. Also call it once at the end of `build_badge_bar` (after the `+` is created) so the first frame is right. Also add `std::vector badge_order;` and `#include ` / `` where needed. Every event that changes a dot (assign, remove, colour, enable) already calls `rebuild_signal_panels`. + +- [ ] **Step 5: Probe `UDPSCOPE_PROBE_SIGNAL_BADGES_MS`** + +Schedule `signal_badges_probe` in `ui.cpp` (like the badges probe). It prints one line: + +``` +[sigbadges] enabled= badges= overflow=<0|1> overflow_text= plus_visible=<0|1> dot_plotted= bar_h= bar_h_fixed=<1 if bar_h before == after toggling one signal off and on> bar_min_w= opens=<1 if emitting "clicked" on badges[0] makes slideover_page == signal:, and a second emit closes it> plus_opens=<1 if the same for plus (or the overflow when overflowed)> +``` +Toggle a signal with `app.model->set_signal_enabled(i, false)` then `rebuild_signal_panels(app)`, and back. `bar_min_w` is only a record: the check is that it is the same with the mock's N signals as `bar_min_w` printed by the E-1608 run in Task 5 (stated there). + +- [ ] **Step 6: Verify** + +Build (zero warnings). UDPS run with `UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SIGNAL_BADGES_MS=1500` against the mock: expected `badges == enabled` (or `overflow=1` if the mock declares more than 6), `bar_h=36` or the bar's measured value with `bar_h_fixed=1`, `opens=1 plus_opens=1`, `gtk warnings: 0`. Then the Task 1 baseline diff and the slide-over/badges/tour-surface probes: **allowed baseline changes:** none beyond Tasks 1-2. A screenshot at 1400x900 (`GSK_RENDERER=cairo`, `import -window root`) to look at the bar once. + +- [ ] **Step 7: Commit** + +```sh +git add -A src docs +git commit -m "ui: one badge per enabled signal, an N signals overflow, and the order kept in memory" +``` + +--- + +### Task 4: Badge drag (to a plot row, and to reorder); the list stops being a drag source + +**Files:** +- Modify: `src/ui_badges.cpp`, `src/ui_signals.cpp`, `src/ui.cpp`, `src/ui_app.hpp`, `src/ui_probe.cpp` + +**Interfaces:** +- Consumes: `on_drag_prepare(GtkDragSource*, double, double, gpointer)` (payload: the signal index as a decimal string), `DragContext{App*, size_t}`, `on_drop_channel` (the plot area's drop target, unchanged), `badge_order`, `signal_badges`. +- Produces: nothing new in `ui_app.hpp` except removals. + +- [ ] **Step 1: The badge is a drag source and a reorder target (`make_signal_badge`)** + +Append to `make_signal_badge`: + +```cpp + // A drag handle: the same payload the list's rows used to carry, so the plot area's drop target takes it + // unchanged. The slide-over steps aside for the drag, since it covers the plot's right edge. + DragContext* ctx = new DragContext{&app, index}; + GtkDragSource* source = gtk_drag_source_new(); + gtk_drag_source_set_actions(source, GDK_ACTION_COPY); + g_signal_connect_data(source, "prepare", G_CALLBACK(on_drag_prepare), ctx, + [](gpointer data, GClosure*) { delete static_cast(data); }, + G_CONNECT_DEFAULT); + g_signal_connect(source, "drag-begin", G_CALLBACK(+[](GtkDragSource*, GdkDrag*, gpointer user) { + slideover_close(*static_cast(user)); + }), + &app); + gtk_widget_add_controller(b.button, GTK_EVENT_CONTROLLER(source)); + + // Dropping another signal's badge on this one moves it in front of this one. + GtkDropTarget* drop = gtk_drop_target_new(G_TYPE_STRING, GDK_ACTION_COPY); + g_signal_connect(drop, "drop", G_CALLBACK(on_badge_drop), &b); + gtk_widget_add_controller(b.button, GTK_EVENT_CONTROLLER(drop)); +``` +and, above it in the anonymous namespace: + +```cpp +/// The reorder: widgets are moved, never rebuilt, so neither the dragged badge nor this target (whose "drop" +/// emission is running) is destroyed under the drag. +gboolean on_badge_drop(GtkDropTarget*, const GValue* value, double, double, gpointer user) { + Badge* target = static_cast(user); + App* app = static_cast(g_object_get_data(G_OBJECT(target->button), "udpscope-app")); + if (app == nullptr || !G_VALUE_HOLDS_STRING(value)) return FALSE; + const char* text = g_value_get_string(value); + const std::size_t signal = static_cast(std::atoll(text ? text : "0")); + Badge* moved = nullptr; + for (auto& b : app->badges.signal_badges) { + if (b->signal == signal) moved = b.get(); + } + if (moved == nullptr) return FALSE; + if (moved == target) return TRUE; + GtkWidget* prev = gtk_widget_get_prev_sibling(target->button); + if (prev != moved->button) { // already directly in front: nothing to move + gtk_box_reorder_child_after(GTK_BOX(app->badges.signals_row), moved->button, prev); + } + // The order, as the widgets now stand. + app->badge_order.clear(); + for (GtkWidget* c = gtk_widget_get_first_child(app->badges.signals_row); c != nullptr; + c = gtk_widget_get_next_sibling(c)) { + for (auto& b : app->badges.signal_badges) { + if (b->button == c) app->badge_order.push_back(b->signal_name); + } + } + return TRUE; +} +``` +`gtk_box_reorder_child_after(box, child, nullptr)` puts the child first, which is what a `nullptr` `prev` means here. + +- [ ] **Step 2: Remove the list's drag source (`rebuild_signal_list`)** + +Delete the `DragContext* ctx ...` block through `gtk_widget_add_controller(row, GTK_EVENT_CONTROLLER(source));` (~454-471). `grep -n row_drag_sidebar src` : if `rebuild_signal_list` was its only caller, delete `row_drag_sidebar`, its declaration and `App::sidebar_hidden_for_drag`; if something else calls it, leave it. Update the comment above the list's row loop that mentions dragging, if any. + +- [ ] **Step 3: The probes that drove the list's drag** + +`src/ui_probe.cpp` ~3843-3847 walks `signal_list` rows (read what it does: if it performs a drag or a drop by index, it now takes the index from `signal_badges[0]->signal` and calls `assign_signal_to_row` / `on_drop_channel` with the same payload). Keep every printed line it printed before. + +- [ ] **Step 4: Extend `signal_badges_probe`** + +After the existing line print a second: + +``` +[sigbadges] drag_sources= drop_targets=<... GtkDropTarget> prepare_ok=<1 if on_drag_prepare for badges[0]'s DragContext returns a non-null provider> reorder_before= reorder_after=<...> reorder_ok=<1 if dropping the last badge on the first moved it to the front and badge_order matches the widget order> payload_drop=<1 if on_drop_channel-equivalent: assign_signal_to_row(badges[0]->signal, 0) leaves it plotted> list_drag= +``` +Controllers are enumerated with `gtk_widget_observe_controllers()` (a `GListModel`) and `G_TYPE_CHECK_INSTANCE_TYPE`/`GTK_IS_DRAG_SOURCE`. With fewer than 2 badges print `reorder_ok=-1`. Drop the reordered badge back into its place at the end so later probes see the original order. For the `GValue`: `GValue v = G_VALUE_INIT; g_value_init(&v, G_TYPE_STRING); g_value_set_string(&v, std::to_string(signal).c_str());` and call `on_badge_drop` directly -- it is in the anonymous namespace of `ui_badges.cpp` where the probe lives, so no export is needed. + +- [ ] **Step 5: Verify** + +Build (zero warnings; `-Wunused` would flag a leftover `row_drag_sidebar`). UDPS run with `UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SIGNAL_BADGES_MS=1500`. Expected: `drag_sources == drop_targets == badges`, `prepare_ok=1`, `reorder_ok=1` (or `-1` if fewer than 2), `payload_drop=1`, `list_drag=0`, `gtk warnings: 0`, no `Gtk-CRITICAL`. Then the Task 1 baseline diff: **allowed changes:** a drag-by-list-row probe line that now reports through the badge (explain it). A real pointer drag cannot be simulated headless: say so in the report, and note that the drop path itself is `assign_signal_to_row` (probed in Task 2). + +- [ ] **Step 6: Commit** + +```sh +git add -A src docs +git commit -m "ui: the signal badge is the drag handle (to a plot row, or to reorder); the list rows stop dragging" +``` + +--- + +### Task 5: Docs, the E-1608 run and the sweep of what the list's move touched + +**Files:** +- Modify: `README.md`, `docs/claude/verification.md`, `docs/claude/probe-readings.md`, `docs/claude/invariants-plot-and-layout.md`, `docs/claude/architecture.md`, `docs/claude/invariants-gtk-and-chrome.md` (only if it names the list as being in the sidebar), `src/ui_tour.cpp` (badges step text), `SPECS.md` is **not** touched. + +- [ ] **Step 1: The E-1608 run (the spec's stress case)** + +```sh +python3 tools/mock_e1608.py & MOCK=$! +sleep 1 +xvfb-run -a -s "-screen 0 1400x900x24" sh -c 'GDK_BACKEND=x11 GSK_RENDERER=cairo UDPSCOPE_EXIT_AFTER_MS=7000 UDPSCOPE_ENABLE_ALL_MS=1500 UDPSCOPE_PROBE_SIGNAL_BADGES_MS=3000 ./build/udpscope --source e1608 --source-opt address=127.0.0.1 > /tmp/ui-signals-e1608.log 2>&1 & sleep 4.5; import -window root /tmp/ui-signals-e1608.png; wait' +kill $MOCK +grep -E '\[sigbadges\]|Gtk-|gtk warnings' /tmp/ui-signals-e1608.log +``` +Expected: `overflow=1 overflow_text= signals plus_visible=0`, `bar_h_fixed=1`, and `bar_min_w` equal to the UDPS run's (the scroller keeps it constant -- if it differs by more than the one badge's rounding, the scroller is not constraining: fix before going on). Look at the screenshot once: the "N signals" badge, Trigger and Source on the right, bar height unchanged. Then open the `signals` page from a probe or by `g`, activate a row (E-1608 has up to ~17): the page for it exists (`[signalpage]` run against the mock too: `UDPSCOPE_PROBE_SIGNAL_PAGES_MS=3000`). Kill the mock. + +- [ ] **Step 2: Docs** + +- `README.md`: the signals section describes the badge bar (signal badges, `+`, the six-badge overflow, drag to a plot or to reorder, the per-signal page) instead of the list in the sidebar; state that the order is kept for the session only. +- `docs/claude/verification.md`: add `UDPSCOPE_PROBE_SIGNAL_PAGES_MS` and `UDPSCOPE_PROBE_SIGNAL_BADGES_MS` next to the badge probes; `docs/claude/probe-readings.md`: the `[signalpage]` line and the two `[sigbadges]` lines, each field with the value it must read and the defect a wrong reading names (the fields above). +- `docs/claude/invariants-plot-and-layout.md`: one rule -- the badge bar's signal badges live in a scroller so the bar's minimum width never depends on the signal count (the E-1608 case), and the bar's height is fixed. +- `docs/claude/architecture.md`: the page ids now implemented (`signals`, `signal:`), `ui_signal_page.cpp`, and that the dock holds the cursors and measurements only. `docs/claude/invariants-gtk-and-chrome.md`: if it describes `outlive_the_rebuild` or the list's drag/sidebar hiding, say it now covers the signal page's buttons and that the list is no longer a drag source. +- `src/ui_tour.cpp`: the `"badges"` step text becomes `"Setup is reached by the thing it configures. A badge per signal opens that signal's colour, calibration and plot; the + opens the full list; Trigger and Source open their own forms. Drag a signal badge onto a plot to draw it there, or onto another badge to reorder. Click again, press Esc or click the plot to close the panel."` + +- [ ] **Step 3: Final run** + +Build from scratch (`rm -rf build/CMakeFiles/udpscope.dir; cmake --build build -j 2>&1 | grep -c warning` -- expected exactly `1`, the icons' warning; if a clean rebuild is too slow, report the incremental result instead), the UDPS baseline diff, and the slide-over / badges / signal-pages / signal-badges / tour-surface probes once each. Report every baseline difference with its explanation. + +- [ ] **Step 4: Commit** + +```sh +git add -A src docs README.md +git commit -m "docs: the signal badges, the signal pages and the six-badge overflow (step 2 of the redesign)" +```