Files
udpscope/docs/superpowers/plans/2026-11-03-ui-badges-signals.md
T

52 KiB

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:<name> 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:<name>, signals, measure, cursors, events, source-<id>. This step adds signals and signal:<name>.
  • 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:<name> 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:

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:

// 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

  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

  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):

  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)
    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:

  // 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:

  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
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=<as in verification.md> 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
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:<name> 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):
/// 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<std::size_t> listed_signals(const std::vector<SignalInfo>& 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:<name>` 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<std::string, GtkWidget*> signal_pages; (name -> the page's content box).

  • Step 1: listed_signals()

In src/ui_signals.cpp, add (before rebuild_signal_list):

std::vector<std::size_t> listed_signals(const std::vector<SignalInfo>& signals) {
  std::vector<std::size_t> 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:

  std::vector<std::size_t> 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:

gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, gpointer user) {
  App* app = static_cast<App*>(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::size_t>(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:

void reset_signal_color(App& app, std::size_t signal) {
  std::string name;
  {
    std::lock_guard<std::mutex> lock(app.model->mutex());
    const std::vector<SignalInfo>& 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", <gtk/gtk.h>, <string>, namespaces udpscope::ui_impl. Page ids are "signal:" + name. Contents (an anonymous namespace plus the two public functions):

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<std::mutex> lock(app.model->mutex());
  const std::vector<SignalInfo>& 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<std::size_t>(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<SignalInfo> all;
  {
    std::lock_guard<std::mutex> 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<App*>(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<App*>(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<App*>(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<App*>(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<App*>(user);
                     outlive_the_rebuild(GTK_WIDGET(b));
                     std::string n;
                     {
                       std::lock_guard<std::mutex> lock(a->model->mutex());
                       const std::vector<SignalInfo>& 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<App*>(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:

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:

  g_signal_connect(app.signal_list, "row-activated",
                   G_CALLBACK(+[](GtkListBox*, GtkListBoxRow* row, gpointer user) {
                     App* a = static_cast<App*>(user);
                     const std::size_t index = static_cast<std::size_t>(
                         GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(row), "udpscope-signal")));
                     std::string name;
                     {
                       std::lock_guard<std::mutex> lock(a->model->mutex());
                       const std::vector<SignalInfo>& 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:<name>") and prints:

[signalpage] name=<n> open=<slideover_page==id> pages=<signal_pages.size()> enable_visible=<0|1> plot_buttons=<count of the "Plot on" buttons> plot_on_row0=<1 if assign_signal_to_row(...,0) returned true and signal_plotted> removed=<remove button click leaves signal_plotted false> back_to_list=<slideover_open("signals") -> 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:

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
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:
// 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<std::unique_ptr<Badge>> 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<std::string> 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 <memory> in ui_app.hpp if it is not already.

  • Step 1: Put the signals in a scroller (build_badge_bar)

Before creating any badge:

  // 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:

/// 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<SignalInfo>& 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):

  // 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
void sync_signal_badges(App& app) {
  BadgeBar& bb = app.badges;
  if (bb.signals_row == nullptr) return;
  std::vector<SignalInfo> signals;
  {
    std::lock_guard<std::mutex> 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<std::size_t> 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<std::size_t>(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<Badge>());
      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<std::string> badge_order; and #include <algorithm> / <memory> 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=<shown count> badges=<signal_badges.size()> overflow=<0|1> overflow_text=<text|-> plus_visible=<0|1> dot_plotted=<number of badges whose dot is not the unplotted grey; compute by signal_plotted> bar_h=<gtk_widget_get_height(bar)> bar_h_fixed=<1 if bar_h before == after toggling one signal off and on> bar_min_w=<gtk_widget_measure min width of badge_bar> opens=<1 if emitting "clicked" on badges[0] makes slideover_page == signal:<name>, 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
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:

  // 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<DragContext*>(data); },
                        G_CONNECT_DEFAULT);
  g_signal_connect(source, "drag-begin", G_CALLBACK(+[](GtkDragSource*, GdkDrag*, gpointer user) {
                     slideover_close(*static_cast<App*>(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:

/// 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<Badge*>(user);
  App* app = static_cast<App*>(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::size_t>(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=<number of badges with a GtkDragSource controller> drop_targets=<... GtkDropTarget> prepare_ok=<1 if on_drag_prepare for badges[0]'s DragContext returns a non-null provider> reorder_before=<names comma-separated> 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=<number of GtkDragSource controllers on the first list row; must be 0>

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
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)

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=<N> 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:<name>), 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
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)"