The per-signal page bypassed the signals lock for the acquire tick and the math Edit/Delete buttons, and offered "Plot on r" for a plot the signal was already on. A removed page also let another signal's page show during the close animation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
432 lines
18 KiB
C++
432 lines
18 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);
|
|
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());
|
|
// The signal lock is the list's lock too: the page may not do what the locked list refuses.
|
|
gtk_widget_set_sensitive(enable, unlocked);
|
|
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);
|
|
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);
|
|
gtk_widget_set_sensitive(edit, unlocked);
|
|
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");
|
|
gtk_widget_set_sensitive(del, unlocked);
|
|
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
|
|
|
|
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.
|
|
if (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;
|
|
}
|
|
|
|
// The signal lock: with it on, the page's acquire tick is insensitive after the sync (the lock change
|
|
// re-runs it through rebuild_signal_panels); restored afterwards.
|
|
int locked_enable_sensitive = -1;
|
|
const bool was_locked = a.signals_locked;
|
|
a.signals_locked = true;
|
|
signal_pages_sync(a);
|
|
page_it = a.signal_pages.find(name);
|
|
if (page_it != a.signal_pages.end()) {
|
|
GtkWidget* enable_now = find_descendant(page_it->second, [](GtkWidget* w) { return GTK_IS_CHECK_BUTTON(w); });
|
|
if (enable_now != nullptr) locked_enable_sensitive = gtk_widget_get_sensitive(enable_now) ? 1 : 0;
|
|
}
|
|
a.signals_locked = was_locked;
|
|
signal_pages_sync(a);
|
|
|
|
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).
|
|
slideover_close(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;
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
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 locked_enable_sensitive=%d "
|
|
"removed=%d btn_row0=%d back_to_list=%d row_opens=%d pruned=%d rows=%d\n",
|
|
name.c_str(), open, pages, enable_visible, plot_buttons, plot_on_row0, plot_on_row0_disabled,
|
|
locked_enable_sensitive, removed, btn_row0, back_to_list, row_opens, pruned,
|
|
a.rows->row_count());
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
|
|
} // namespace ui_impl
|
|
} // namespace udpscope
|