diff --git a/CMakeLists.txt b/CMakeLists.txt index db2b8f6..eaeff16 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -276,6 +276,7 @@ add_executable(udpscope src/ui_badges.cpp src/ui_trigger.cpp src/ui_signals.cpp + src/ui_signal_page.cpp src/ui_context_help.cpp src/ui_tour.cpp ) diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index c50259e..62ba959 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -2634,6 +2634,19 @@ loads a file as the previous version wrote it (`side_tab = "measure"`) into a `L leave `dock_shown` at the default (`true`), so `ok=1` requires `dock_shown == default` -- the parser ignores unknown keys, and this is what says the old key still does not break a session. A load that left the caller's `false` alone would read `dock_shown=0 default=1 ok=0`. The probe writes only to a temp path. +## `[signalpage]` + +``` +[signalpage] name=sine open=1 pages=1 enable_visible=1 plot_buttons=1 plot_on_row0=1 removed=1 btn_row0=1 back_to_list=1 row_opens=1 pruned=1 rows=1 +``` + +`UDPSCOPE_PROBE_SIGNAL_PAGES_MS` builds and drives the page of the first streamed signal. `open=1` (the slide-over shows `signal:`), `pages=1` +(one page in `App::signal_pages`), `enable_visible=1` (the acquire tick is shown in live mode; `0` in a file view), `plot_buttons=` equal to +`rows=` (one "Plot on" button per figure row), `plot_on_row0=1` (`assign_signal_to_row()` returned true and the signal is plotted), `removed=1` +(the page's "Remove from plots" took it off), `btn_row0=1` (the "Plot on 1" button, the user's path, put it back), `back_to_list=1` ("All signals" +target), `row_opens=1` (activating the list row opens the page), and `pruned=1`: a math channel's page was opened, its **Delete** button clicked, and +the slide-over closed and the page was dropped by `signal_pages_sync()`. `pruned=-1` means no math channel could be added; `0` is a stale page. + ## `[badges]` ``` diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 6308e23..9a5184f 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -447,6 +447,10 @@ precedent, not an exception. read `click_inside_closes=0 still_open=1 click_outside_closes=1 esc_closes=1 closed=1`. For a screenshot add `UDPSCOPE_PROBE_SLIDEOVER_HOLD=1`, which leaves the page open after line 1 (run with `GSK_RENDERER=cairo` and `import -window root`): the panel must be drawn over the traces, opaque. +- `UDPSCOPE_PROBE_SIGNAL_PAGES_MS=1500` with `UDPSCOPE_EXIT_AFTER_MS=5000` and `UDPSCOPE_ENABLE_ALL_MS=500` -- the + 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_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 1bf285e..5dacc3b 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -5887,6 +5887,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_PAGES_MS")) { + g_timeout_add(static_cast(std::atoi(env)), signal_page_probe, &app); + } if (const char* env = g_getenv("UDPSCOPE_PROBE_SEARCH_MS")) { g_timeout_add(static_cast(std::atoi(env)), search_probe, &app); } diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 6c1690b..c6cf45c 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -44,6 +44,7 @@ #include #include #include +#include #include #include #include @@ -885,6 +886,8 @@ struct App { guint layout_save_source = 0; bool layout_loaded = false; unsigned signal_generation = ~0u; + /// Signal name -> the content box of its `signal:` slide-over page (src/ui_signal_page.cpp). + std::map signal_pages; bool updating_panels = false; ///< guards signal handlers during a rebuild bool relayout_pending = false; /// True once the renderer's `Style` has been stated (see `tick()`): the renderer does not @@ -1669,6 +1672,24 @@ std::string signal_chip_markup(App& app, const std::vector& signals, void on_signal_color_chosen(GObject* source, GAsyncResult* res, gpointer user); void open_signal_color(App& app, std::size_t signal); void rebuild_signal_list(App& app, const std::vector& signals); +/// Every plottable signal once (the time base skipped, a group's layers folded to the first), in model order. +/// The one rule the list and the badges share. +std::vector listed_signals(const std::vector& signals); +/// Puts `signal` (an index into `signals()`) on plot `row` in the first free slot, switching it on first if +/// need be. Says why not through `set_notice` and returns false when it cannot. The one path a drop and a +/// signal page's "Plot on" button share. +bool assign_signal_to_row(App& app, std::size_t signal, int row); +/// Back to the palette colour for the signal's position. +void reset_signal_color(App& app, std::size_t signal); +/// Holds `widget` alive across the current emission: a handler that rebuilds the list or a page destroys the +/// widget it was emitted from. +void outlive_the_rebuild(GtkWidget* widget); +/// Creates the page `signal:` if it is missing, fills it, and returns whether it exists afterwards. +bool signal_page_ensure(App& app, const std::string& name); +/// Called at the end of `rebuild_signal_panels`: refills every existing signal page, drops the pages of +/// signals the model no longer has (closing the slide-over first if one of them is open). +void signal_pages_sync(App& app); +gboolean signal_page_probe(gpointer user); // UDPSCOPE_PROBE_SIGNAL_PAGES_MS /// The `MATH` divider row in the one channel list, and the `+` it carries. GtkWidget* make_math_divider(App& app, bool with_add); void on_math_add_clicked(GtkButton* button, gpointer user); @@ -1915,6 +1936,8 @@ gboolean dock_session_probe(gpointer user); // UDPSCOPE_PROBE_DOCK_SESSION_MS void build_slideover(App& app); // once, after the plot_overlay exists bool slideover_add_page(App& app, const std::string& id, const std::string& title, GtkWidget* content); bool slideover_has_page(const App& app, const std::string& id); +/// The inverse of `slideover_add_page`: false when the id is unknown or is the open page. +bool slideover_remove_page(App& app, const std::string& id); bool slideover_open(App& app, const std::string& id); // false when the id is unknown void slideover_close(App& app); bool slideover_toggle(App& app, const std::string& id); // open if closed or on another page, else close diff --git a/src/ui_signal_page.cpp b/src/ui_signal_page.cpp new file mode 100644 index 0000000..894ac16 --- /dev/null +++ b/src/ui_signal_page.cpp @@ -0,0 +1,380 @@ +// 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); + 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(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 && 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(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(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)); + gtk_widget_set_tooltip_text(b, "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_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(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); + 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; + } + } + } + + // 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; + int removed = -1; + auto 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 " + "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, removed, btn_row0, + back_to_list, row_opens, pruned, a.rows->row_count()); + return G_SOURCE_REMOVE; +} + +} // namespace ui_impl +} // namespace udpscope diff --git a/src/ui_signals.cpp b/src/ui_signals.cpp index 7339904..d55a71e 100644 --- a/src/ui_signals.cpp +++ b/src/ui_signals.cpp @@ -200,7 +200,7 @@ GtkWidget* make_math_divider(App& app, bool with_add) { /// emission, and a button's own code keeps running after the handler returns, so the last reference /// has to outlive the emission: hold one here and drop it from an idle. This is the same repair /// `on_signal_switch()` makes, for the same reason. -static void outlive_the_rebuild(GtkWidget* widget) { +void outlive_the_rebuild(GtkWidget* widget) { g_object_ref(widget); g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, +[](gpointer) -> gboolean { return G_SOURCE_REMOVE; }, widget, g_object_unref); @@ -257,6 +257,34 @@ void on_math_delete_clicked(GtkButton* button, gpointer user) { apply_math_channels(*a); } +std::vector listed_signals(const std::vector& signals) { + std::vector out; + for (std::size_t i = 0; i < signals.size(); ++i) { + if (ScopeModel::is_time_signal(signals, i)) continue; // the time base is not plottable + const std::string base = layer_base_name(signals[i].name); + bool seen = false; + for (std::size_t j = 0; j < i && !seen; ++j) { + seen = !ScopeModel::is_time_signal(signals, j) && layer_base_name(signals[j].name) == base; + } + if (!seen) out.push_back(i); + } + return out; +} + +void reset_signal_color(App& app, std::size_t signal) { + std::string name; + { + std::lock_guard lock(app.model->mutex()); + const std::vector& sigs = app.model->signals(); + if (signal >= sigs.size()) return; + name = sigs[signal].name; + } + app.rows->clear_signal_color(name); + app.relayout_pending = true; + rebuild_signal_panels(app); + schedule_layout_save(app); +} + /// One line per signal: colour chip, checkbox, name, unit, and a menu for the things /// you touch once (information, calibration). A `GtkSwitch` and two always-visible /// buttons made every row about twice this tall, which is what turned a four-signal @@ -282,18 +310,11 @@ void rebuild_signal_list(App& app, const std::vector& signals) { bool math_divider_done = false; // Streamed signals, counted as they are drawn: the dump reads `rows == signals + 1 + math`. std::size_t plain = 0; - for (std::size_t i = 0; i < signals.size(); ++i) { - if (ScopeModel::is_time_signal(signals, i)) continue; // the time base is not plottable + for (std::size_t i : listed_signals(signals)) { // Locked: a row per signal that is *on*, which is the list a user reads rather than the list they // configure. Turning one on is what the pane's [edit] button is for. if (app.signals_locked && !app.model->signal_enabled(i)) continue; - const std::string base = layer_base_name(signals[i].name); - bool seen = false; - for (std::size_t j = 0; j < i && !seen; ++j) { - seen = !ScopeModel::is_time_signal(signals, j) && - layer_base_name(signals[j].name) == base; - } - if (!seen) rows.push_back(i); + rows.push_back(i); } for (std::size_t i : rows) { @@ -385,20 +406,12 @@ void rebuild_signal_list(App& app, const std::vector& signals) { g_object_set_data(G_OBJECT(recolor), "udpscope-signal", GSIZE_TO_POINTER(i)); g_signal_connect(recolor, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { - App* a = static_cast(user); - const std::size_t idx = static_cast( - GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(b), "udpscope-signal"))); - std::string name; - { - std::lock_guard lock(a->model->mutex()); - const std::vector& sigs = a->model->signals(); - if (idx >= sigs.size()) return; - name = sigs[idx].name; - } - a->rows->clear_signal_color(name); - a->relayout_pending = true; - rebuild_signal_panels(*a); - schedule_layout_save(*a); + // The rebuild destroys this button (it is in the list it rebuilds). + outlive_the_rebuild(GTK_WIDGET(b)); + reset_signal_color( + *static_cast(user), + static_cast(GPOINTER_TO_SIZE( + g_object_get_data(G_OBJECT(b), "udpscope-signal")))); }), &app); if (sig.math) { @@ -451,6 +464,8 @@ void rebuild_signal_list(App& app, const std::vector& signals) { if (!app.signals_locked) gtk_box_append(GTK_BOX(box), more); gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box); + // Activating the row opens the signal's page; the row resolves its identity the way its widgets do. + g_object_set_data(G_OBJECT(row), "udpscope-signal", GSIZE_TO_POINTER(i)); DragContext* ctx = new DragContext{&app, i}; GtkDragSource* source = gtk_drag_source_new(); @@ -487,26 +502,18 @@ void rebuild_signal_list(App& app, const std::vector& signals) { app.updating_panels = false; } -gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, - gpointer user) { - App* app = static_cast(user); - if (!G_VALUE_HOLDS_STRING(value)) return FALSE; - // Where the drop landed decides the row: one area covers them all. - const int row = row_at(*app, x, y); - - const char* text = g_value_get_string(value); - const std::size_t signal = static_cast(std::atoll(text ? text : "0")); - if (signal >= app->model->signals().size()) return FALSE; +bool assign_signal_to_row(App& app, std::size_t signal, int row) { + if (signal >= app.model->signals().size()) return false; const auto notice = [&](const std::string& what) { - set_notice(*app, what, 4); + set_notice(app, what, 4); std::fprintf(stderr, "[udpscope] drop: %s\n", what.c_str()); std::fflush(stderr); }; // The dropped signal's channels are the ones the model built for it; take the // first, or the first element if the signal is a vector. - const std::vector& channels = app->model->channels(); + const std::vector& channels = app.model->channels(); int channel = -1; std::string name; for (std::size_t i = 0; i < channels.size(); ++i) { @@ -518,8 +525,8 @@ gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y } if (channel < 0) { // The signal is not enabled: enable it, then take the channel that appears. - app->model->set_signal_enabled(signal, true); - const std::vector& again = app->model->channels(); + app.model->set_signal_enabled(signal, true); + const std::vector& again = app.model->channels(); for (std::size_t i = 0; i < again.size(); ++i) { if (again[i].signal == signal) { channel = static_cast(i); @@ -529,14 +536,14 @@ gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y } } if (channel < 0) { - notice("cannot acquire '" + app->model->signals()[signal].name + + notice("cannot acquire '" + app.model->signals()[signal].name + "' (multi-element signals are not acquired)"); - return FALSE; + return false; } int slot = -1; for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { - if (!app->rows->slot_bound(row, s)) { + if (!app.rows->slot_bound(row, s)) { slot = s; break; } @@ -547,18 +554,30 @@ gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y notice("plot " + std::to_string(row + 1) + " holds four signals already: drop it on another plot, or add a row with the layout " "editor (L)"); - return FALSE; + return false; } // The slot remembers the *signal* (and element), never the channel index: the // channel list is renumbered whenever a signal is enabled or disabled. - const std::vector& signals = app->model->signals(); + const std::vector& signals = app.model->signals(); const SignalInfo& sig = signals[signal]; - app->rows->assign(row, slot, sig.name, name, signal_color_index(signals, signal)); - schedule_layout_save(*app); - app->relayout_pending = true; - rebuild_signal_panels(*app); // the row's colour chip now has a trace to name - cancel_drag_on(*app, row); + app.rows->assign(row, slot, sig.name, name, signal_color_index(signals, signal)); + schedule_layout_save(app); + app.relayout_pending = true; + rebuild_signal_panels(app); // the row's colour chip now has a trace to name notice("plotting '" + name + "' on plot " + std::to_string(row + 1)); + return true; +} + +gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, + gpointer user) { + App* app = static_cast(user); + if (!G_VALUE_HOLDS_STRING(value)) return FALSE; + // Where the drop landed decides the row: one area covers them all. + const int row = row_at(*app, x, y); + const char* text = g_value_get_string(value); + const std::size_t signal = static_cast(std::atoll(text ? text : "0")); + if (!assign_signal_to_row(*app, signal, row)) return FALSE; + cancel_drag_on(*app, row); return TRUE; } @@ -582,6 +601,7 @@ void rebuild_signal_panels(App& app) { if (!have_signals) { gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.trigger_toggle), FALSE); } + signal_pages_sync(app); app.signal_generation = app.model->generation(); } @@ -607,6 +627,24 @@ GtkWidget* build_signal_panel(App& app) { // it, and `[udpscope] signals panel:` reads the difference. return box != nullptr ? box : gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); } + // Activating a row (a click on the row's own space; its tick, chip and menu consume their own clicks) + // opens that signal's page. + gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(app.signal_list), TRUE); + g_signal_connect(app.signal_list, "row-activated", + G_CALLBACK(+[](GtkListBox*, GtkListBoxRow* row, gpointer user) { + App* a = static_cast(user); + const std::size_t index = static_cast( + GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(row), "udpscope-signal"))); + std::string name; + { + std::lock_guard lock(a->model->mutex()); + const std::vector& s = a->model->signals(); + if (index >= s.size()) return; + name = s[index].name; + } + if (signal_page_ensure(*a, name)) slideover_open(*a, "signal:" + name); + }), + &app); return box; } diff --git a/src/ui_slideover.cpp b/src/ui_slideover.cpp index 40beeae..ee3330a 100644 --- a/src/ui_slideover.cpp +++ b/src/ui_slideover.cpp @@ -8,6 +8,7 @@ #include +#include #include #include #include @@ -122,6 +123,24 @@ bool slideover_has_page(const App& app, const std::string& id) { gtk_stack_get_child_by_name(GTK_STACK(app.slideover.stack), id.c_str()) != nullptr; } +bool slideover_remove_page(App& app, const std::string& id) { + SlideOver& s = app.slideover; + if (!slideover_has_page(app, id) || s.page == id) return false; + GtkWidget* child = gtk_stack_get_child_by_name(GTK_STACK(s.stack), id.c_str()); + if (is_source_page(id)) { + s.source_pages.erase(std::remove(s.source_pages.begin(), s.source_pages.end(), id), + s.source_pages.end()); + AdwToggle* t = adw_toggle_group_get_toggle_by_name(ADW_TOGGLE_GROUP(s.source_switch), id.c_str()); + if (t != nullptr) { + s.syncing = true; + adw_toggle_group_remove(ADW_TOGGLE_GROUP(s.source_switch), t); + s.syncing = false; + } + } + gtk_stack_remove(GTK_STACK(s.stack), child); + return true; +} + bool slideover_open(App& app, const std::string& id) { SlideOver& s = app.slideover; if (!slideover_has_page(app, id)) return false;