From f27925da59bf75d33f91bf0b750a69ee7b10625e Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 01:19:13 +0100 Subject: [PATCH] ui: one badge per enabled signal, an N signals overflow, and the order kept in memory --- docs/claude/probe-readings.md | 14 +++ docs/claude/verification.md | 4 + src/ui.cpp | 8 +- src/ui_app.hpp | 14 +++ src/ui_badges.cpp | 175 +++++++++++++++++++++++++++++++++- src/ui_signals.cpp | 1 + 6 files changed, 213 insertions(+), 3 deletions(-) diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index f75f734..f0e80b5 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -2652,6 +2652,20 @@ the slide-over closed and the page was dropped by `signal_pages_sync()`. `pruned channel removed) and the slide-over was still on `trigger`, both by `slideover_page()` and by the stack's visible child; `0` is `signal_pages_sync()` parking the stack on the signals list while the title and the badge marker still name `trigger`; `-1` when there is no `trigger` page or no math channel could be added. +## `[sigbadges]` + +``` +[sigbadges] enabled=4 badges=4 overflow=0 overflow_text=- plus_visible=1 dot_plotted=4 bar_h=36 bar_h_fixed=1 bar_min_w=336 opens=1 plus_opens=1 +``` + +`UDPSCOPE_PROBE_SIGNAL_BADGES_MS` reads the signal badges. `badges` must equal `enabled` (the enabled, listed signals) while `enabled` is at most 6 +(`kMaxSignalBadges`); beyond that `badges=0 overflow=1 overflow_text= signals plus_visible=0` (the "N signals" badge replaces the badges and the "+"). `dot_plotted` is the number of +badges whose signal is on a plot (its dot is then the trace colour, not the unplotted grey). `bar_h` is the bar's allocated height and `bar_h_fixed=1` that neither it nor its natural +height moved while one signal was switched off and on again (`rebuild_signal_panels()` each time), and that the badge count followed; `0` is a bar whose height depends on its content. +`bar_min_w` is only a record: the scroller must keep it the same however many signals there are (it reads 336 with the mock's 4 and with the overflow forced on). `opens=1`: clicking the +first signal badge opens `signal:` with the badge marked open and a second click closes it (`-1` when overflowed, there being no signal badges); `plus_opens=1` the same for the "+" +(the overflow badge when overflowed) and the `signals` page. + ## `[badges]` ``` diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 9a5184f..03001da 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -451,6 +451,10 @@ precedent, not an exception. per-signal `signal:` slide-over pages. **Against a config that has already seen the tour** (a first run's auto-tour closes the slide-over at about 1.5 s, which is when this probe opens its page). One `[signalpage]` line; see [probe-readings.md](probe-readings.md). +- `UDPSCOPE_PROBE_SIGNAL_BADGES_MS=1500` with `UDPSCOPE_EXIT_AFTER_MS=5000` and `UDPSCOPE_ENABLE_ALL_MS=500` -- the + signal badges in the badge bar. **Against a config that has already seen the tour**, as above (the probe clicks + the badges, and the auto-tour would close the page). One `[sigbadges]` line; see + [probe-readings.md](probe-readings.md). - `UDPSCOPE_PROBE_BADGES_MS=2000` with `UDPSCOPE_EXIT_AFTER_MS=5000` and `UDPSCOPE_ENABLE_ALL_MS=500` -- the badge bar under the plot. One `[badges]` line, which must read `table_bad=0 fixed=1 open_marked=1 page_open=1 closed_unmarked=1`: `table_bad` counts the rows of the 16-input `trigger_badge_state()` table the probe diff --git a/src/ui.cpp b/src/ui.cpp index 5dacc3b..e6705fb 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -4673,7 +4673,10 @@ void run_app_action(App& app, AppAction action) { toggle(app.cursors_toggle); break; case AppAction::ShowMeasure: dock_show_section(app, "measure"); break; - case AppAction::ShowSignals: slideover_toggle(app, "signals"); break; + case AppAction::ShowSignals: + // The `+` badge is insensitive without signals; so is its shortcut. + if (!app.model->signals().empty()) slideover_toggle(app, "signals"); + break; case AppAction::LayoutEditor: toggle(app.layout_toggle); break; @@ -5887,6 +5890,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit if (const char* env = g_getenv("UDPSCOPE_PROBE_BADGES_MS")) { g_timeout_add(static_cast(std::atoi(env)), badges_probe, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_SIGNAL_BADGES_MS")) { + g_timeout_add(static_cast(std::atoi(env)), signal_badges_probe, &app); + } if (const char* env = g_getenv("UDPSCOPE_PROBE_SIGNAL_PAGES_MS")) { g_timeout_add(static_cast(std::atoi(env)), signal_page_probe, &app); } diff --git a/src/ui_app.hpp b/src/ui_app.hpp index c6cf45c..85d5896 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -149,7 +149,11 @@ struct Badge { GtkWidget* name = nullptr; GtkWidget* state = nullptr; std::string page; + std::string signal_name; ///< a signal badge: the signal it stands for; empty for the others + std::size_t signal = 0; ///< a signal badge: its index in `ScopeModel::signals()` when it was built }; +/// The most signal badges the bar shows; beyond that one "N signals" badge stands for them all. +inline constexpr std::size_t kMaxSignalBadges = 6; /// The badge bar under the plot (src/ui_badges.cpp). struct BadgeBar { GtkWidget* bar = nullptr; ///< main-window.ui `badge_bar`, fixed height @@ -157,6 +161,11 @@ struct BadgeBar { GtkWidget* system_slot = nullptr; ///< right: Trigger, Source Badge trigger; Badge source; ///< opens the first source page; a read-out when the source has none + GtkWidget* signals_row = nullptr; ///< the box inside the scroller: signal badges, then the "+" + /// One per shown enabled signal; empty when overflowed. `unique_ptr`: each button's "clicked" handler holds + /// a pointer to its `Badge`, which must not move when the vector does. + std::vector> signal_badges; + Badge overflow; ///< the "N signals" badge, used only beyond `kMaxSignalBadges` Badge plus; ///< the "+" in the signals slot: opens the `signals` page }; @@ -174,6 +183,8 @@ struct App { BadgeBar badges; GtkWidget* badge_bar = nullptr; GtkWidget* badge_signals = nullptr; + /// Signal names in the order the user left the badges (memory only; names not in it sort last). + std::vector badge_order; GtkWidget* badge_system = nullptr; /// The one GL area, drawing the `RowSet`'s figure: every row is a row of that /// figure, so there is one widget, one context and one `::render` whatever the row @@ -1952,6 +1963,9 @@ const char* trigger_badge_state(bool enabled, bool armed, bool pending, bool has void build_badge_bar(App& app); // after build_slideover, the toolbar (it reads trigger_toggle) and build_source_panels (the Source badge's page) void sync_badge_bar(App& app); // per tick and on every slide-over open/close; only writes what changed gboolean badges_probe(gpointer user); // UDPSCOPE_PROBE_BADGES_MS +/// Rebuilds the signal badges (one per enabled signal, or the "N signals" overflow) from the model. +void sync_signal_badges(App& app); +gboolean signal_badges_probe(gpointer user); // UDPSCOPE_PROBE_SIGNAL_BADGES_MS gboolean source_kinds_probe_report(gpointer user); gboolean source_kinds_probe(gpointer user); /// MCC E-1608 end to end against `tools/mock_e1608.py`: the stream, the calibration, the panels and diff --git a/src/ui_badges.cpp b/src/ui_badges.cpp index dfdf1c4..6bc5fe5 100644 --- a/src/ui_badges.cpp +++ b/src/ui_badges.cpp @@ -11,6 +11,11 @@ #include #include #include +#include +#include +#include +#include +#include namespace udpscope { namespace ui_impl { @@ -49,6 +54,9 @@ void on_badge_clicked(GtkButton*, gpointer user) { slideover_close(*app); return; } + // A signal's page is made on first use (and kept in step by `signal_pages_sync`). Nothing here rebuilds the + // badges (the page's fill and the slide-over open/close only restyle them), so the badge outlives this call. + if (b->page.rfind("signal:", 0) == 0 && !signal_page_ensure(*app, b->signal_name)) return; slideover_toggle(*app, b->page); } @@ -70,6 +78,28 @@ void make_badge(App& app, Badge& b, GtkWidget* parent, const char* name, const s gtk_box_append(GTK_BOX(parent), b.button); } +/// A signal's badge: its colour dot (grey when it has no trace) and its name. Words and a dot, no icon. +void make_signal_badge(App& app, Badge& b, const std::vector& signals, std::size_t index) { + b.signal = index; + b.signal_name = signals[index].name; + b.page = "signal:" + b.signal_name; + b.button = gtk_button_new(); + gtk_widget_add_css_class(b.button, "flat"); + gtk_widget_add_css_class(b.button, "udpscope-badge"); + GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget* dot = gtk_label_new(nullptr); + gtk_label_set_markup(GTK_LABEL(dot), signal_chip_markup(app, signals, index).c_str()); + b.name = gtk_label_new(b.signal_name.c_str()); + gtk_label_set_ellipsize(GTK_LABEL(b.name), PANGO_ELLIPSIZE_END); + gtk_label_set_max_width_chars(GTK_LABEL(b.name), 14); + gtk_box_append(GTK_BOX(row), dot); + gtk_box_append(GTK_BOX(row), b.name); + gtk_button_set_child(GTK_BUTTON(b.button), row); + gtk_widget_set_tooltip_text(b.button, b.signal_name.c_str()); + g_object_set_data(G_OBJECT(b.button), "udpscope-app", &app); + g_signal_connect(b.button, "clicked", G_CALLBACK(on_badge_clicked), &b); +} + } // namespace void build_badge_bar(App& app) { @@ -86,9 +116,67 @@ void build_badge_bar(App& app) { gtk_widget_set_can_target(bb.source.button, FALSE); gtk_widget_set_focusable(bb.source.button, FALSE); } - // The full signal list. Insensitive until the stream has declared signals ("disable, do not hide"). - make_badge(app, bb.plus, bb.signals_slot, "+", "signals"); + // 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); + // The full signal list. Insensitive until the stream has declared signals ("disable, do not hide"). It + // follows the signal badges, which are inserted before it. It has no state, so its empty label is hidden + // rather than leaving a dead gap in the pill. + make_badge(app, bb.plus, bb.signals_row, "+", "signals"); + gtk_widget_set_visible(bb.plus.state, FALSE); gtk_widget_set_tooltip_text(bb.plus.button, "all signals: switch more on, add a math channel"); + sync_signal_badges(app); +} + +void sync_signal_badges(App& app) { + BadgeBar& bb = app.badges; + if (bb.signals_row == nullptr || bb.plus.button == nullptr) return; + std::vector signals; + { + std::lock_guard lock(app.model->mutex()); + signals = app.model->signals(); + } + // The enabled signals, in the user's order: names in `badge_order` first, the rest in model order. + std::vector shown; + for (std::size_t i : listed_signals(signals)) { + if (app.model->signal_enabled(i)) shown.push_back(i); + } + const auto rank = [&](std::size_t i) { + const auto it = std::find(app.badge_order.begin(), app.badge_order.end(), signals[i].name); + return static_cast(it - app.badge_order.begin()); // == size() when unknown: sorts last + }; + std::stable_sort(shown.begin(), shown.end(), [&](std::size_t a, std::size_t b) { return rank(a) < rank(b); }); + + // Clear: every signal badge, and the overflow if it was there. The "+" is kept. The badges' buttons die + // with the removal (their handlers with them), so the `Badge` records they point to can go too. + 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_widget_set_visible(bb.overflow.state, FALSE); + gtk_label_set_text(GTK_LABEL(bb.overflow.name), (std::to_string(shown.size()) + " signals").c_str()); + } else { + for (std::size_t i : shown) { + bb.signal_badges.push_back(std::make_unique()); + make_signal_badge(app, *bb.signal_badges.back(), signals, i); + gtk_box_insert_child_after(GTK_BOX(bb.signals_row), bb.signal_badges.back()->button, + gtk_widget_get_prev_sibling(bb.plus.button)); + } + } + // The overflow badge stands for the "+" (it opens the same page); the "+" is shown only when it is not. + gtk_widget_set_visible(bb.plus.button, !overflowed); sync_badge_bar(app); } @@ -107,6 +195,13 @@ void sync_badge_bar(App& app) { gtk_widget_set_sensitive(bb.plus.button, !app.model->signals().empty()); set_open(bb.plus.button, slideover_page(app) == "signals"); } + if (bb.overflow.button != nullptr) { + gtk_widget_set_sensitive(bb.overflow.button, !app.model->signals().empty()); + set_open(bb.overflow.button, slideover_page(app) == bb.overflow.page); + } + for (const std::unique_ptr& sb : bb.signal_badges) { + set_open(sb->button, slideover_page(app) == sb->page); + } if (bb.source.button == nullptr) return; // The source's name and link state. A file has neither a link nor a kind worth naming. const bool file_mode = app.replaying || !app.model->config().open_waveform.empty(); @@ -192,5 +287,81 @@ gboolean badges_probe(gpointer user) { return G_SOURCE_REMOVE; } +gboolean signal_badges_probe(gpointer user) { + App* a = static_cast(user); + BadgeBar& bb = a->badges; + const auto bar_height = [&] { + int min_h = 0, nat_h = 0; + gtk_widget_measure(bb.bar, GTK_ORIENTATION_VERTICAL, -1, &min_h, &nat_h, nullptr, nullptr); + return std::make_pair(gtk_widget_get_height(bb.bar), nat_h); + }; + const auto before = bar_height(); + int bar_min_w = 0; + gtk_widget_measure(bb.bar, GTK_ORIENTATION_HORIZONTAL, -1, &bar_min_w, nullptr, nullptr, nullptr); + + // The enabled, listed signals the bar must stand for. + std::vector signals; + { + std::lock_guard lock(a->model->mutex()); + signals = a->model->signals(); + } + std::size_t enabled = 0; + std::size_t toggle = signals.size(); + for (std::size_t i : listed_signals(signals)) { + if (!a->model->signal_enabled(i)) continue; + ++enabled; + if (toggle == signals.size()) toggle = i; + } + const bool overflowed = bb.overflow.button != nullptr; + int dot_plotted = 0; + for (const std::unique_ptr& sb : bb.signal_badges) { + if (signal_plotted(*a, sb->signal_name)) ++dot_plotted; + } + // A copy: the toggling below rebuilds the badges, and the label with them. + const std::string overflow_text = overflowed ? gtk_label_get_text(GTK_LABEL(bb.overflow.name)) : "-"; + const int plus_visible = gtk_widget_get_visible(bb.plus.button) != FALSE ? 1 : 0; + const std::size_t badges = bb.signal_badges.size(); + + // One signal off and on again: the bar's height does not move, and the badges come back. + int bar_h_fixed = -1; + if (toggle != signals.size()) { + a->model->set_signal_enabled(toggle, false); + rebuild_signal_panels(*a); + const auto off = bar_height(); + const std::size_t badges_off = bb.signal_badges.size(); + a->model->set_signal_enabled(toggle, true); + rebuild_signal_panels(*a); + const auto on = bar_height(); + bar_h_fixed = before == off && before == on && (overflowed || badges_off + 1 == badges || badges == 0) ? 1 : 0; + } + + // A signal badge opens its page and the second click closes it; the "+" (the overflow when overflowed) + // does the same with the list. + int opens = -1; + if (!bb.signal_badges.empty()) { + Badge& first = *bb.signal_badges.front(); + const std::string want = "signal:" + first.signal_name; + g_signal_emit_by_name(first.button, "clicked"); + const bool open1 = slideover_page(*a) == want && + gtk_widget_has_css_class(first.button, "udpscope-badge-open") != FALSE; + g_signal_emit_by_name(first.button, "clicked"); + const bool closed = slideover_page(*a).empty() && + gtk_widget_has_css_class(first.button, "udpscope-badge-open") == FALSE; + opens = open1 && closed ? 1 : 0; + } + Badge& list_badge = bb.overflow.button != nullptr ? bb.overflow : bb.plus; + g_signal_emit_by_name(list_badge.button, "clicked"); + const bool plus1 = slideover_page(*a) == "signals"; + g_signal_emit_by_name(list_badge.button, "clicked"); + const int plus_opens = plus1 && slideover_page(*a).empty() ? 1 : 0; + + std::fprintf(stderr, + "[sigbadges] enabled=%zu badges=%zu overflow=%d overflow_text=%s plus_visible=%d dot_plotted=%d " + "bar_h=%d bar_h_fixed=%d bar_min_w=%d opens=%d plus_opens=%d\n", + enabled, badges, overflowed ? 1 : 0, overflow_text.c_str(), plus_visible, dot_plotted, before.first, + bar_h_fixed, bar_min_w, opens, plus_opens); + return G_SOURCE_REMOVE; +} + } // namespace ui_impl } // namespace udpscope diff --git a/src/ui_signals.cpp b/src/ui_signals.cpp index d55a71e..93ee7fa 100644 --- a/src/ui_signals.cpp +++ b/src/ui_signals.cpp @@ -602,6 +602,7 @@ void rebuild_signal_panels(App& app) { gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.trigger_toggle), FALSE); } signal_pages_sync(app); + sync_signal_badges(app); app.signal_generation = app.model->generation(); }