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 aGtkScrolledWindow,GTK_POLICY_EXTERNAL/GTK_POLICY_NEVER); the slide-over still overlays and never resizes the plot. - A
GtkOverlaychild 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 thesignalspage (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 addssignalsandsignal:<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 existingchip_markup()disc. - The widget-lifetime trap (
docs/claude/invariants-gtk-and-chrome.md): a click handler that callsrebuild_signal_panels()destroys the page/row that owns the emitting button; hold the emitter withoutlive_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'sgtk warnings:reads0; noGtk-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=x11and, 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_orderfor 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 samesignalspage, so a second button for it would be a duplicate). Per-signal pages for those signals are reached by activating a row of thesignalslist (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"+"); thesignalspage registered once inon_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
\ninsrc/ui_signals.cppline 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
signalspage (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:
gopens 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.cpporsrc/ui_signal_page.cpp(probe),CMakeLists.txt
Interfaces:
- Produces (declare in
ui_app.hpp, namespaceudpscope::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);
-
Appgainsstd::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.hppexcept 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.mdis 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: addUDPSCOPE_PROBE_SIGNAL_PAGES_MSandUDPSCOPE_PROBE_SIGNAL_BADGES_MSnext 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 describesoutlive_the_rebuildor 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)"