// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari // // The per-signal slide-over pages (`signal:`): 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 #include #include #include #include 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 lock(app.model->mutex()); const std::vector& 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(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 all; { std::lock_guard 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(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(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(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(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(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(user); outlive_the_rebuild(GTK_WIDGET(b)); std::string n; { std::lock_guard lock(a->model->mutex()); const std::vector& 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(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 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(user); std::vector all; { std::lock_guard 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(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