docs: plan for the signal badges (step 2 of the redesign)
This commit is contained in:
1 parent
54d7f63f35
commit
e36f2a202b
1 file changed
+832
@@ -0,0 +1,832 @@
|
||||
# Signal badges -- step 2 of the badge-bar redesign Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Put the signals on the badge bar: one badge per enabled signal (a colour dot and the name; up to 6, then one "N signals" badge), each opening a per-signal slide-over page, each a drag handle (to a plot row, or to reorder), plus a trailing `+` that opens the full signal list as a slide-over page. The signal list leaves the dock.
|
||||
|
||||
**Architecture:** The existing `signal_panel` (heading, hint, list, math divider) is *reparented* from the temporary dock into the `signals` slide-over page. A new unit, `src/ui_signal_page.cpp`, owns the lazily-created `signal:<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:
|
||||
|
||||
```sh
|
||||
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
|
||||
sleep 1.5
|
||||
xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||||
./build/udpscope --server 127.0.0.1 --port 8114 > /tmp/ui-signals-base.log 2>&1
|
||||
kill $MOCK
|
||||
sed 's/[0-9.]\+/N/g' /tmp/ui-signals-base.log > .superpowers/sdd/ui-signals-baseline-udps.txt
|
||||
grep -c 'Gtk-CRITICAL' /tmp/ui-signals-base.log; grep 'gtk warnings' /tmp/ui-signals-base.log
|
||||
```
|
||||
Expected: `0` and `gtk warnings: 0`. Every later task diffs its own normalised run against this file and must explain every differing line from that task's "allowed baseline changes"; any other difference is a defect.
|
||||
|
||||
- [ ] **Step 2: Fix the stray `\n` in `src/ui_signals.cpp` line 4**
|
||||
|
||||
The header comment reads `...with its colour chip and acquire tick, the panel around it,\n// and the drop that puts a signal on a row.` on one line (a literal backslash-n). Replace it with two comment lines:
|
||||
|
||||
```cpp
|
||||
// The signal list: one row per signal with its colour chip and acquire tick, the panel around it,
|
||||
// and the drop that puts a signal on a row.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Reparent the panel into a `signals` page (`src/ui.cpp`, `on_activate`, ~line 5172)**
|
||||
|
||||
Replace
|
||||
|
||||
```cpp
|
||||
app->signal_panel = build_signal_panel(*app);
|
||||
gtk_box_append(GTK_BOX(app->dock_page), app->signal_panel);
|
||||
gtk_box_append(GTK_BOX(app->dock_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
|
||||
gtk_box_append(GTK_BOX(app->dock_page), app->cursors_panel);
|
||||
```
|
||||
with
|
||||
```cpp
|
||||
app->signal_panel = build_signal_panel(*app);
|
||||
gtk_box_append(GTK_BOX(app->dock_page), app->cursors_panel);
|
||||
```
|
||||
and register the page where the other pages are registered (after `build_slideover`, next to the `"trigger"` page; the panel must exist first, so if that registration happens before line 5172, add the `slideover_add_page(*app, "signals", "Signals", app->signal_panel);` call right after the panel is built instead):
|
||||
|
||||
```cpp
|
||||
slideover_add_page(*app, "signals", "Signals", app->signal_panel);
|
||||
```
|
||||
Read the surrounding code before editing: the registration must happen once, after `build_slideover(*app)` and after `build_signal_panel`. Update the comments that say the dock holds "the signal list, the cursors and the measurements" (`dock_show_section`, `ui_app.hpp` `dock_page`) to "the cursors and the measurements".
|
||||
|
||||
- [ ] **Step 4: `g` opens the page (`run_app_action`, ~line 4682)**
|
||||
|
||||
```cpp
|
||||
case AppAction::ShowSignals: slideover_toggle(app, "signals"); break;
|
||||
```
|
||||
and in `dock_show_section` delete the `"signals"` branch (the dock no longer holds the list); keep `"measure"`.
|
||||
|
||||
- [ ] **Step 5: The `+` badge (`src/ui_badges.cpp`, `src/ui_app.hpp`)**
|
||||
|
||||
In `BadgeBar` add `Badge plus;`. In `build_badge_bar`, after the Source badge:
|
||||
|
||||
```cpp
|
||||
// The full signal list. Insensitive until the stream has declared signals ("disable, do not hide").
|
||||
make_badge(app, bb.plus, bb.signals_slot, "+", "signals");
|
||||
gtk_widget_set_tooltip_text(bb.plus.button, "all signals: switch more on, add a math channel");
|
||||
```
|
||||
(`make_badge` appends a second, empty `state` label; for `+` leave it empty, it takes no width.) In `sync_badge_bar`, before the early return at the `bb.source.button == nullptr` check:
|
||||
|
||||
```cpp
|
||||
if (bb.plus.button != nullptr) {
|
||||
gtk_widget_set_sensitive(bb.plus.button, !app.model->signals().empty());
|
||||
set_open(bb.plus.button, slideover_page(app) == "signals");
|
||||
}
|
||||
```
|
||||
Step 2's later tasks insert the signal badges *before* the `+` in `signals_slot`; Task 3 handles the ordering, so do not worry about it here.
|
||||
|
||||
- [ ] **Step 6: Tour and context help**
|
||||
|
||||
`src/ui_tour.cpp`: the `"signals"` step (line ~460) becomes `{"signals", "signals", "The signals", ...}` (second field is the slide-over page; see the `"trigger"` step), text: `"Every signal the source declares, each with the switch that acquires it. The + badge opens this list; a signal has to be switched on before it can be plotted, measured or searched."`. The `"side"` step text becomes `"The dock beside the plot: the cursors, then the measurements. The buttons in the bar above show and hide it."`. Keep `&App::signal_list` as the rect. `src/ui_context_help.cpp` line ~92: replace the last sentence of the `"signals"` hint (`A signal that is dragged onto a plot is drawn there.`) with `A signal's badge, dragged onto a plot, draws it there.`; the `"signals_config"` hint's `which plot a signal is dragged onto` becomes `which plot a signal is drawn on`.
|
||||
|
||||
- [ ] **Step 7: Re-point the probes that treated the list as part of the dock**
|
||||
|
||||
`src/ui_probe.cpp` references `signal_panel`/`signal_list` at ~103-186, ~1828, ~1853, ~3843-3847, ~5827, ~6165, ~6431 and the tour-surface probe ~7024-7097. The page is closed by default, so widgets inside it are unmapped and have no allocation. For each: (a) a probe that measures the panel's allocated size -- open the `signals` page first (`slideover_open(*a, "signals")`) and close it after, or measure with `gtk_widget_measure`; (b) a probe that walks the rows needs nothing (children exist while unmapped); (c) the stress line at ~1853 (`gtk_widget_set_size_request(a->signal_panel, -1, 3000)`) existed to overflow the *dock's* scroller; re-point it to `a->cursors_panel`; (d) the `ShowSignals` check at ~6431 asserts `slideover_page(*a) == "signals"` and then closes it; (e) `~6165` (`gtk_widget_get_parent(app.signal_list)`) -- read what it needs and take it from the page instead. Print the same lines as before wherever the value is still meaningful.
|
||||
|
||||
- [ ] **Step 8: Verify**
|
||||
|
||||
```sh
|
||||
cmake --build build -j 2>&1 | grep -E 'warning|error'; echo "rc=$?"
|
||||
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
|
||||
sleep 1.5
|
||||
xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||||
./build/udpscope --server 127.0.0.1 --port 8114 > /tmp/ui-signals-t1.log 2>&1
|
||||
kill $MOCK
|
||||
sed 's/[0-9.]\+/N/g' /tmp/ui-signals-t1.log | diff .superpowers/sdd/ui-signals-baseline-udps.txt - ; grep -c Gtk-CRITICAL /tmp/ui-signals-t1.log; grep 'gtk warnings' /tmp/ui-signals-t1.log
|
||||
```
|
||||
Then run `UDPSCOPE_PROBE_SLIDEOVER_MS=1500`, `UDPSCOPE_PROBE_BADGES_MS=1500` and `UDPSCOPE_PROBE_TOUR_SURFACE_MS=<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**
|
||||
|
||||
```sh
|
||||
git add -A src docs
|
||||
git commit -m "ui: the signal list is the signals slide-over page, opened by a + badge or g"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Per-signal pages, `assign_signal_to_row()` and `reset_signal_color()`
|
||||
|
||||
Builds `signal:<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`):
|
||||
|
||||
```cpp
|
||||
/// Every plottable signal once (the time base skipped, a group's layers folded to the first), in model order.
|
||||
/// The one rule the list and the badges share.
|
||||
std::vector<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`):
|
||||
|
||||
```cpp
|
||||
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:
|
||||
|
||||
```cpp
|
||||
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:
|
||||
|
||||
```cpp
|
||||
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:
|
||||
|
||||
```cpp
|
||||
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):
|
||||
|
||||
```cpp
|
||||
namespace {
|
||||
|
||||
std::string page_id(const std::string& name) { return "signal:" + name; }
|
||||
|
||||
/// The index of `name` in the model's signals, or npos.
|
||||
std::size_t signal_index(App& app, const std::string& name) {
|
||||
std::lock_guard<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:
|
||||
|
||||
```cpp
|
||||
bool signal_page_ensure(App& app, const std::string& name) {
|
||||
if (signal_index(app, name) == std::string::npos) return false;
|
||||
auto it = app.signal_pages.find(name);
|
||||
if (it == app.signal_pages.end()) {
|
||||
GtkWidget* page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
|
||||
gtk_widget_set_margin_top(page, 12);
|
||||
gtk_widget_set_margin_bottom(page, 12);
|
||||
gtk_widget_set_margin_start(page, 12);
|
||||
gtk_widget_set_margin_end(page, 12);
|
||||
if (!slideover_add_page(app, page_id(name), name, page)) return false;
|
||||
it = app.signal_pages.emplace(name, page).first;
|
||||
}
|
||||
fill(app, name, it->second);
|
||||
return true;
|
||||
}
|
||||
|
||||
void signal_pages_sync(App& app) {
|
||||
for (auto it = app.signal_pages.begin(); it != app.signal_pages.end();) {
|
||||
if (signal_index(app, it->first) == std::string::npos) {
|
||||
if (slideover_page(app) == page_id(it->first)) slideover_close(app);
|
||||
slideover_remove_page(app, page_id(it->first));
|
||||
it = app.signal_pages.erase(it);
|
||||
} else {
|
||||
fill(app, it->first, it->second);
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
`slideover_add_page` wraps `content` in a scroller and returns it through the stack, so `page` stays the box `fill` clears. Check that the gtk widget `page` is not destroyed by `slideover_remove_page` before `erase` (it is not used afterwards). Call `signal_pages_sync(app)` at the end of `rebuild_signal_panels` (before `app.signal_generation = ...`). `signal_index` takes the model mutex, so `signal_pages_sync` must not be called with it held (`rebuild_signal_panels` releases it before the list rebuild).
|
||||
|
||||
- [ ] **Step 6: A list row opens its page**
|
||||
|
||||
In `rebuild_signal_list`, tag each row: `g_object_set_data(G_OBJECT(row), "udpscope-signal", GSIZE_TO_POINTER(i));` (the math divider row has none and is not activatable). In `build_signal_panel`, after the null check:
|
||||
|
||||
```cpp
|
||||
g_signal_connect(app.signal_list, "row-activated",
|
||||
G_CALLBACK(+[](GtkListBox*, GtkListBoxRow* row, gpointer user) {
|
||||
App* a = static_cast<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:
|
||||
|
||||
```sh
|
||||
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
|
||||
sleep 1.5
|
||||
xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=5000 UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SIGNAL_PAGES_MS=1500 \
|
||||
./build/udpscope --server 127.0.0.1 --port 8114 2>&1 | grep -E '\[signalpage\]|\[udpscope\] drop|Gtk-|gtk warnings'
|
||||
kill $MOCK
|
||||
```
|
||||
Expected: `open=1 plot_on_row0=1 removed=1 back_to_list=1`, `plot_buttons` equal to the figure's row count, `gtk warnings: 0`, no `Gtk-CRITICAL`. The existing drop probes (`[drop]` lines in the baseline) must read unchanged: they now run through `assign_signal_to_row`.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```sh
|
||||
git add -A src CMakeLists.txt docs
|
||||
git commit -m "ui: per-signal slide-over pages; the drop's assignment and the colour reset as shared functions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: The signal badges, the "N signals" overflow and the order
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ui_badges.cpp`, `src/ui_app.hpp`, `src/ui_signals.cpp` (call), `src/ui.cpp` (probe env var), `src/ui_widgets.cpp` (CSS, if any)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `listed_signals`, `signal_page_ensure`, `signal_chip_markup`, `signal_pages_sync` (Task 2).
|
||||
- Produces:
|
||||
|
||||
```cpp
|
||||
// ui_app.hpp
|
||||
struct Badge { /* existing */ std::string signal_name; std::size_t signal = 0; }; // the two new fields: empty/0 for non-signal badges
|
||||
struct BadgeBar {
|
||||
/* existing */
|
||||
GtkWidget* signals_row = nullptr; ///< the box inside the scroller: signal badges, then "+"
|
||||
std::vector<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:
|
||||
|
||||
```cpp
|
||||
// The signal badges sit in a scroller: a bar whose minimum width grew with the number of signals would
|
||||
// widen the window (layout must not depend on content). EXTERNAL scrolls by wheel/touch with no bar.
|
||||
GtkWidget* scroller = gtk_scrolled_window_new();
|
||||
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_EXTERNAL, GTK_POLICY_NEVER);
|
||||
gtk_widget_set_hexpand(scroller, TRUE);
|
||||
bb.signals_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||||
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), bb.signals_row);
|
||||
gtk_box_append(GTK_BOX(bb.signals_slot), scroller);
|
||||
```
|
||||
and change the `+` badge to `make_badge(app, bb.plus, bb.signals_row, "+", "signals")` (it follows the signal badges because they are inserted before it; see Step 3). Move `make_badge`'s parent argument usage accordingly.
|
||||
|
||||
- [ ] **Step 2: `make_signal_badge`**
|
||||
|
||||
In the anonymous namespace of `ui_badges.cpp`:
|
||||
|
||||
```cpp
|
||||
/// A signal's badge: its colour dot (grey when it has no trace) and its name. Words and a dot, no icon.
|
||||
void make_signal_badge(App& app, Badge& b, const std::vector<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)`:
|
||||
|
||||
```cpp
|
||||
// A signal's page is made on first use (and kept in step by `signal_pages_sync`).
|
||||
if (b->page.rfind("signal:", 0) == 0 && !signal_page_ensure(*app, b->signal_name)) return;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `sync_signal_badges`**
|
||||
|
||||
```cpp
|
||||
void sync_signal_badges(App& app) {
|
||||
BadgeBar& bb = app.badges;
|
||||
if (bb.signals_row == nullptr) return;
|
||||
std::vector<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**
|
||||
|
||||
```sh
|
||||
git add -A src docs
|
||||
git commit -m "ui: one badge per enabled signal, an N signals overflow, and the order kept in memory"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Badge drag (to a plot row, and to reorder); the list stops being a drag source
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ui_badges.cpp`, `src/ui_signals.cpp`, `src/ui.cpp`, `src/ui_app.hpp`, `src/ui_probe.cpp`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `on_drag_prepare(GtkDragSource*, double, double, gpointer)` (payload: the signal index as a decimal string), `DragContext{App*, size_t}`, `on_drop_channel` (the plot area's drop target, unchanged), `badge_order`, `signal_badges`.
|
||||
- Produces: nothing new in `ui_app.hpp` except removals.
|
||||
|
||||
- [ ] **Step 1: The badge is a drag source and a reorder target (`make_signal_badge`)**
|
||||
|
||||
Append to `make_signal_badge`:
|
||||
|
||||
```cpp
|
||||
// A drag handle: the same payload the list's rows used to carry, so the plot area's drop target takes it
|
||||
// unchanged. The slide-over steps aside for the drag, since it covers the plot's right edge.
|
||||
DragContext* ctx = new DragContext{&app, index};
|
||||
GtkDragSource* source = gtk_drag_source_new();
|
||||
gtk_drag_source_set_actions(source, GDK_ACTION_COPY);
|
||||
g_signal_connect_data(source, "prepare", G_CALLBACK(on_drag_prepare), ctx,
|
||||
[](gpointer data, GClosure*) { delete static_cast<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:
|
||||
|
||||
```cpp
|
||||
/// The reorder: widgets are moved, never rebuilt, so neither the dragged badge nor this target (whose "drop"
|
||||
/// emission is running) is destroyed under the drag.
|
||||
gboolean on_badge_drop(GtkDropTarget*, const GValue* value, double, double, gpointer user) {
|
||||
Badge* target = static_cast<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**
|
||||
|
||||
```sh
|
||||
git add -A src docs
|
||||
git commit -m "ui: the signal badge is the drag handle (to a plot row, or to reorder); the list rows stop dragging"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Docs, the E-1608 run and the sweep of what the list's move touched
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`, `docs/claude/verification.md`, `docs/claude/probe-readings.md`, `docs/claude/invariants-plot-and-layout.md`, `docs/claude/architecture.md`, `docs/claude/invariants-gtk-and-chrome.md` (only if it names the list as being in the sidebar), `src/ui_tour.cpp` (badges step text), `SPECS.md` is **not** touched.
|
||||
|
||||
- [ ] **Step 1: The E-1608 run (the spec's stress case)**
|
||||
|
||||
```sh
|
||||
python3 tools/mock_e1608.py & MOCK=$!
|
||||
sleep 1
|
||||
xvfb-run -a -s "-screen 0 1400x900x24" sh -c 'GDK_BACKEND=x11 GSK_RENDERER=cairo UDPSCOPE_EXIT_AFTER_MS=7000 UDPSCOPE_ENABLE_ALL_MS=1500 UDPSCOPE_PROBE_SIGNAL_BADGES_MS=3000 ./build/udpscope --source e1608 --source-opt address=127.0.0.1 > /tmp/ui-signals-e1608.log 2>&1 & sleep 4.5; import -window root /tmp/ui-signals-e1608.png; wait'
|
||||
kill $MOCK
|
||||
grep -E '\[sigbadges\]|Gtk-|gtk warnings' /tmp/ui-signals-e1608.log
|
||||
```
|
||||
Expected: `overflow=1 overflow_text=<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**
|
||||
|
||||
```sh
|
||||
git add -A src docs README.md
|
||||
git commit -m "docs: the signal badges, the signal pages and the six-badge overflow (step 2 of the redesign)"
|
||||
```
|
||||
Reference in new issue
Block a user