ui: one badge per enabled signal, an N signals overflow, and the order kept in memory

This commit is contained in:
Martino Ferrari committed 2026-11-03 01:19:13 +01:00
1 parent ad5e30f338
commit f27925da59
6 files changed
+213 -3

No files matched your search

+7 -1
View File
@@ -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<guint>(std::atoi(env)), badges_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_SIGNAL_BADGES_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), signal_badges_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_SIGNAL_PAGES_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), signal_page_probe, &app);
}
+14
View File
@@ -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<std::unique_ptr<Badge>> 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<std::string> 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
+173 -2
View File
@@ -11,6 +11,11 @@
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <memory>
#include <mutex>
#include <utility>
#include <string>
#include <vector>
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<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);
}
} // 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<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. 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<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);
}
@@ -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<Badge>& 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<App*>(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<SignalInfo> signals;
{
std::lock_guard<std::mutex> 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<Badge>& 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
+1
View File
@@ -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();
}