Files
udpscope/src/ui_signal_page.cpp
T
Martino FerrariandClaude Opus 4.6 b98a3478ff badges: source badge shows the name only; drop the signals lock; the signals page proposes what can be added
The link state is the status bar's, so the Source badge loses its state text. The signals have no lock
(the badges and signal pages are direct manipulation), and the signals page lists only unacquired
signals and uncomputed math channels (plus the acquired ones while the badge bar has overflowed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 02:14:37 +01:00

452 lines
19 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The per-signal slide-over pages (`signal:<name>`): one signal's acquire switch, colour, the plot it is on,
// its information and calibration (a math channel's editor instead). Opened from a row of the signal list;
// kept in step with the model by `signal_pages_sync()`, which `rebuild_signal_panels()` ends with.
#include "ui_app.hpp"
#include <gtk/gtk.h>
#include <cstdio>
#include <mutex>
#include <string>
#include <vector>
namespace udpscope {
namespace ui_impl {
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);
// Back to the list: a page reached from a list row has no other way home.
GtkWidget* back = text_button("Back to 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);
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, 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);
gtk_widget_set_name(rows_box, "udpscope-plot-on"); // the probe counts its buttons
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));
// A button for a plot the signal is already on would add it to a second slot there.
bool on_this_row = false;
for (int slot = 0; slot < RowSet::kSlotsPerRow; ++slot) {
if (app.rows->row(r).signal_name[static_cast<std::size_t>(slot)] == sig.name) on_this_row = true;
}
gtk_widget_set_sensitive(b, !on_this_row);
gtk_widget_set_tooltip_text(b, on_this_row ? "already on this plot" : "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_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
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);
// The stack keeps showing the dying page through the close animation; park it on the list so that
// removing the page does not let another signal's page take its place for those frames. Only when it
// is the page on show: parking while the slide-over is open on another page would switch the stack
// away from the page that slideover_page(), the title and the badge marker still name.
const char* shown = gtk_stack_get_visible_child_name(GTK_STACK(app.slideover.stack));
if (shown != nullptr && std::string(shown) == page_id(it->first) && slideover_has_page(app, "signals")) {
gtk_stack_set_visible_child_name(GTK_STACK(app.slideover.stack), "signals");
}
slideover_remove_page(app, page_id(it->first));
it = app.signal_pages.erase(it);
} else {
fill(app, it->first, it->second);
++it;
}
}
}
// --- the probe ---------------------------------------------------------------------------------
namespace {
/// The first descendant of `root` for which `want` holds, or null.
template <typename Pred>
GtkWidget* find_descendant(GtkWidget* root, Pred want) {
for (GtkWidget* c = gtk_widget_get_first_child(root); c != nullptr; c = gtk_widget_get_next_sibling(c)) {
if (want(c)) return c;
if (GtkWidget* deeper = find_descendant(c, want)) return deeper;
}
return nullptr;
}
bool is_button_labelled(GtkWidget* w, const char* label) {
if (!GTK_IS_BUTTON(w)) return false;
const char* l = gtk_button_get_label(GTK_BUTTON(w));
return l != nullptr && std::string(l) == label;
}
} // namespace
gboolean signal_page_probe(gpointer user) {
App& a = *static_cast<App*>(user);
std::vector<SignalInfo> all;
{
std::lock_guard<std::mutex> lock(a.model->mutex());
all = a.model->signals();
}
std::size_t index = std::string::npos;
for (std::size_t i : listed_signals(all)) {
if (!all[i].math) {
index = i;
break;
}
}
if (index == std::string::npos) {
std::fprintf(stderr, "[signalpage] no listed signal\n");
return G_SOURCE_REMOVE;
}
const std::string name = all[index].name;
const std::string id = page_id(name);
const bool made = signal_page_ensure(a, name);
slideover_open(a, id);
const int open = slideover_page(a) == id ? 1 : 0;
const int pages = static_cast<int>(a.signal_pages.size());
int enable_visible = -1;
int plot_buttons = -1;
if (made) {
GtkWidget* page = a.signal_pages[name];
GtkWidget* enable = find_descendant(page, [](GtkWidget* w) { return GTK_IS_CHECK_BUTTON(w); });
if (enable != nullptr) enable_visible = gtk_widget_get_visible(enable) ? 1 : 0;
GtkWidget* rows_box = find_descendant(page, [](GtkWidget* w) {
return std::string(gtk_widget_get_name(w)) == "udpscope-plot-on";
});
if (rows_box != nullptr) {
plot_buttons = 0;
for (GtkWidget* c = gtk_widget_get_first_child(rows_box); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
++plot_buttons;
}
}
}
// Start from a signal that is on no plot (the layout may have it somewhere), through the page's own button.
auto page_it = a.signal_pages.find(name);
if (signal_plotted(a, name) && page_it != a.signal_pages.end()) {
GtkWidget* off0 = find_descendant(page_it->second, [](GtkWidget* w) {
return is_button_labelled(w, "Remove from plots");
});
if (off0 != nullptr) g_signal_emit_by_name(off0, "clicked");
}
// Plot on row 0 through the function the "Plot on" buttons call, then take it off through the page's own
// button (outlive_the_rebuild + remove_signal_from_plots + the page's refill).
const bool assigned = assign_signal_to_row(a, index, 0);
const int plot_on_row0 = assigned && signal_plotted(a, name) ? 1 : 0;
// The refilled page disables the button of the plot the signal is now on.
int plot_on_row0_disabled = -1;
page_it = a.signal_pages.find(name);
GtkWidget* rows_box_on = page_it == a.signal_pages.end() ? nullptr : find_descendant(page_it->second, [](GtkWidget* w) {
return std::string(gtk_widget_get_name(w)) == "udpscope-plot-on";
});
if (rows_box_on != nullptr && gtk_widget_get_first_child(rows_box_on) != nullptr) {
plot_on_row0_disabled = gtk_widget_get_sensitive(gtk_widget_get_first_child(rows_box_on)) ? 0 : 1;
}
int removed = -1;
page_it = a.signal_pages.find(name);
GtkWidget* off = page_it == a.signal_pages.end()
? nullptr
: find_descendant(page_it->second, [](GtkWidget* w) {
return is_button_labelled(w, "Remove from plots");
});
if (off != nullptr) {
g_signal_emit_by_name(off, "clicked");
removed = signal_plotted(a, name) ? 0 : 1;
}
// The page's own "Plot on 1" button (the user's path), then off again.
int btn_row0 = -1;
page_it = a.signal_pages.find(name);
GtkWidget* rows_box_now =
page_it == a.signal_pages.end() ? nullptr : find_descendant(page_it->second, [](GtkWidget* w) {
return std::string(gtk_widget_get_name(w)) == "udpscope-plot-on";
});
if (rows_box_now != nullptr && gtk_widget_get_first_child(rows_box_now) != nullptr) {
g_signal_emit_by_name(gtk_widget_get_first_child(rows_box_now), "clicked");
btn_row0 = signal_plotted(a, name) ? 1 : 0;
page_it = a.signal_pages.find(name);
off = page_it == a.signal_pages.end()
? nullptr
: find_descendant(page_it->second, [](GtkWidget* w) { return is_button_labelled(w, "Remove from plots"); });
if (off != nullptr) g_signal_emit_by_name(off, "clicked");
}
slideover_open(a, "signals");
const int back_to_list = slideover_page(a) == "signals" ? 1 : 0;
// Activating the signal's row in the list opens its page (the path a click takes). The list proposes what
// can be added, so the signal has a row only while it is not acquired: switch it off for the leg and back
// on after it. (With more than six acquired signals the rows list the acquired ones too.)
slideover_close(a);
const bool was_enabled = a.model->signal_enabled(index);
if (was_enabled) {
a.model->set_signal_enabled(index, false);
rebuild_signal_panels(a);
}
int row_opens = -1;
for (GtkWidget* r = gtk_widget_get_first_child(a.signal_list); r != nullptr; r = gtk_widget_get_next_sibling(r)) {
if (GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(r), "udpscope-signal")) == index && GTK_IS_LIST_BOX_ROW(r)) {
g_signal_emit_by_name(r, "activate");
row_opens = slideover_page(a) == id ? 1 : 0;
break;
}
}
if (was_enabled) {
slideover_close(a);
a.model->set_signal_enabled(index, true);
rebuild_signal_panels(a);
}
// Pruning: a math channel's page, open, then the channel deleted; the sync must close the panel and drop
// the page. Left at -1 when no channel can be added (nothing acquired to build it from).
int pruned = -1;
const std::size_t math_before = a.math.size();
add_math_channel(a);
if (a.math.size() == math_before + 1) {
const std::string mname = a.math.back().name;
if (signal_page_ensure(a, mname)) {
slideover_open(a, page_id(mname));
// Through the page's own Delete button: the click destroys the page it came from.
GtkWidget* del = find_descendant(a.signal_pages[mname],
[](GtkWidget* w) { return is_button_labelled(w, "Delete"); });
if (del != nullptr) {
g_signal_emit_by_name(del, "clicked");
} else {
a.math.pop_back();
apply_math_channels(a);
}
pruned = a.signal_pages.count(mname) == 0 && !slideover_has_page(a, page_id(mname)) &&
slideover_page(a).empty()
? 1
: 0;
} else {
a.math.pop_back();
apply_math_channels(a);
}
}
slideover_close(a);
// A page that is not on show is pruned while another panel is open: the slide-over stays on that panel.
int other_page_kept = -1;
if (slideover_has_page(a, "trigger")) {
slideover_open(a, "trigger");
const std::size_t before = a.math.size();
add_math_channel(a);
if (a.math.size() == before + 1) {
const std::string mname = a.math.back().name;
if (signal_page_ensure(a, mname)) {
const bool on_trigger = slideover_page(a) == "trigger";
a.math.pop_back();
apply_math_channels(a);
const char* shown = gtk_stack_get_visible_child_name(GTK_STACK(a.slideover.stack));
other_page_kept = on_trigger && a.signal_pages.count(mname) == 0 && slideover_page(a) == "trigger" &&
shown != nullptr && std::string(shown) == "trigger"
? 1
: 0;
} else {
a.math.pop_back();
apply_math_channels(a);
}
}
slideover_close(a);
}
std::fprintf(stderr,
"[signalpage] name=%s open=%d pages=%d enable_visible=%d plot_buttons=%d plot_on_row0=%d "
"plot_on_row0_disabled=%d "
"removed=%d btn_row0=%d back_to_list=%d row_opens=%d pruned=%d other_page_kept=%d rows=%d\n",
name.c_str(), open, pages, enable_visible, plot_buttons, plot_on_row0, plot_on_row0_disabled,
removed, btn_row0, back_to_list, row_opens, pruned,
other_page_kept, a.rows->row_count());
return G_SOURCE_REMOVE;
}
} // namespace ui_impl
} // namespace udpscope