diff --git a/CMakeLists.txt b/CMakeLists.txt index 9080f6b..08c2d6d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -263,6 +263,7 @@ add_executable(udpscope src/ui_cursors.cpp src/ui_math.cpp src/ui_layout.cpp + src/ui_source.cpp src/ui_trigger.cpp src/ui_signals.cpp src/ui_context_help.cpp diff --git a/src/source/probe_source.cpp b/src/source/probe_source.cpp index 218c126..e89acc8 100644 --- a/src/source/probe_source.cpp +++ b/src/source/probe_source.cpp @@ -128,6 +128,34 @@ class ProbeKind final : public SourceKind { f.on_change = [cb](std::function fn) { *cb = std::move(fn); }; return f; } + + std::vector make_panels(DataSource& source) const override { + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_widget_set_margin_top(box, 12); + gtk_widget_set_margin_bottom(box, 12); + gtk_widget_set_margin_start(box, 12); + gtk_widget_set_margin_end(box, 12); + GtkWidget* label = gtk_label_new("Test panel"); + gtk_widget_set_halign(label, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(box), label); + // The probe finds the button as the box's last child. It reaches the source only through + // `post()`: the command runs inside the next `poll()`, on the producer thread. + GtkWidget* poke = gtk_button_new_with_label("Poke"); + gtk_box_append(GTK_BOX(box), poke); + DataSource* src = &source; + g_signal_connect_data( + poke, "clicked", + G_CALLBACK(+[](GtkButton*, gpointer user) { + DataSource* s = static_cast(user); + s->post([s] { static_cast(s)->note_applied(); }); + }), + src, nullptr, static_cast(0)); + SourcePanel p; + p.id = "poke"; + p.title = "Probe"; + p.widget = box; + return {p}; + } }; } // namespace diff --git a/src/ui.cpp b/src/ui.cpp index 60a8cb6..7780283 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -2978,9 +2978,14 @@ std::string icon_report(GtkIconTheme* theme, const char* name, const char* resou /// The words the pills do not draw. The pages' `title` is empty on purpose (see the `.ui`), so the /// human name of a tab lives here and is put on the pill — its tooltip and its accessible name — by /// `name_side_tabs()`. That keeps an icon-only pill a *named* control for a screen reader. -static const char* side_tab_title(const char* name) { +static const char* side_tab_title(const App& app, const char* name) { if (name != nullptr && std::strcmp(name, "measure") == 0) return "Measure"; if (name != nullptr && std::strcmp(name, "signals") == 0) return "Signals"; + if (name != nullptr) { + for (const SourcePanelPage& p : app.source_panels) { + if (p.name == name) return p.title.c_str(); + } + } return ""; } @@ -3000,7 +3005,7 @@ void name_side_tabs(App& app) { for (guint i = 0; pages != nullptr && pill != nullptr && i < g_list_model_get_n_items(pages); pill = gtk_widget_get_next_sibling(pill), ++i) { AdwViewStackPage* page = ADW_VIEW_STACK_PAGE(g_list_model_get_item(pages, i)); - const char* title = side_tab_title(adw_view_stack_page_get_name(page)); + const char* title = side_tab_title(app, adw_view_stack_page_get_name(page)); g_object_unref(page); if (title[0] == '\0') continue; gtk_widget_set_tooltip_text(pill, title); @@ -3457,6 +3462,7 @@ gboolean tick(gpointer user) { // no text and no name either. name_side_tabs(*app); note_panel_controls(*app); + sync_source_panels(*app); // The tour, re-measured every frame: the target's allocation is 0 on the frame it is first shown // (see the allocation bullets in `docs/claude/invariants-trigger-and-pinning.md`) and the widget // moves under the spotlight on a window resize, so a one-shot measurement is wrong both ways. @@ -4551,7 +4557,8 @@ gboolean tick(gpointer user) { } if (app->capture_button != nullptr) { // Insensitive until connected: the capture's path is keyed on the source. - gtk_widget_set_sensitive(app->capture_button, snap.connected ? TRUE : FALSE); + gtk_widget_set_sensitive(app->capture_button, + snap.connected && app->source_has_datagrams ? TRUE : FALSE); if (app->capture_want_ui && !app->acq->capture_want()) { // The producer refused the open (a full disk, a read-only directory) and cleared // the flag; the button follows the flag rather than lying about the state. Going @@ -5282,6 +5289,9 @@ void on_activate(GtkApplication* application, gpointer user) { g_signal_connect(app->side_stack, "notify::visible-child-name", G_CALLBACK(on_side_tab_changed), app); } + // The source kind's own tabs (a generator, digital I/O). Before the first tick: that is when + // `name_side_tabs()` puts the words on the pills, and it must see these pages too. + build_source_panels(*app); // The left side is the events pane *and* the plot, in one row: the pane is a column of its own // (the item's "collapsable pane on the left"), revealed by trigger mode. @@ -5926,6 +5936,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit } else { app.source_title = app.source_host + ":" + std::to_string(app.source_port); } + if (SourceKind* k = find_kind(cfg.source_kind)) app.source_has_datagrams = k->has_datagrams(); // The event cache is keyed on the source, like the packet capture. A waveform has no source, so a // file-mode run has no cache either -- there are no latches in a file. if (!app.source_host.empty() && app.source_port != 0) { @@ -6098,6 +6109,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit if (const char* env = g_getenv("UDPSCOPE_PROBE_DENSITY_MS")) { g_timeout_add(static_cast(std::atoi(env)), density_probe, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_SOURCE_KINDS")) { + g_timeout_add(static_cast(std::atoi(env)), source_kinds_probe, &app); + } // Our own options were consumed by parse_args(); GTK would reject them, so only // the program name is handed to g_application_run(). diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 144e5a6..9fc6106 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -109,6 +109,14 @@ struct TourView { void* done_user = nullptr; }; +/// One capability panel a source kind contributed to the side stack. `content` is the kind's own widget +/// (made insensitive while the source is disconnected); the page wrapping it is named `name`. +struct SourcePanelPage { + std::string name; ///< stack page name: "source-" + the kind's panel id + std::string title; ///< the pill's tooltip and accessible name; the page's own title stays empty + GtkWidget* content = nullptr; +}; + struct App { ScopeModel* model = nullptr; Acquisition* acq = nullptr; @@ -843,6 +851,9 @@ struct App { std::string source_kind = "udps"; SourceConfig source_opts; std::string source_title; + /// The panels the source kind added to `side_stack`, in tab order. Empty for UDPS and in file mode. + std::vector source_panels; + bool source_has_datagrams = true; ///< the kind reports raw datagrams; false leaves the capture button disabled guint layout_save_source = 0; bool layout_loaded = false; unsigned signal_generation = ~0u; @@ -1558,6 +1569,14 @@ void on_config_save_as(GtkButton*, gpointer user); void config_dialog_refresh(ConfigDialog& d); void open_configurations(App& app); void save_layout_now(App& app); + +/// Capability panels (`src/ui_source.cpp`). `build_source_panels` adds the source kind's panels as +/// pages of `side_stack`, once, before the first tick. `apply_source_panels` sets every panel's +/// sensitivity from `connected` -- disabled, never hidden: the tab switcher owns visibility. +/// `sync_source_panels` is the per-tick call: it applies the model's own connection flag. +void build_source_panels(App& app); +void apply_source_panels(App& app, bool connected); +void sync_source_panels(App& app); bool save_configuration_as(App& app, const std::string& name, const std::string& comment); gboolean layout_save_tick(gpointer user); void schedule_layout_save(App& app); @@ -1855,6 +1874,8 @@ gboolean stats_probe_report(gpointer user); gboolean stats_probe(gpointer user); gboolean side_tab_probe_report(gpointer user); gboolean side_tab_probe(gpointer user); +gboolean source_kinds_probe_report(gpointer user); +gboolean source_kinds_probe(gpointer user); gboolean pan_probe(gpointer user); gboolean arm_probe(gpointer user); gboolean trigrenum_probe_step3(gpointer user); diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 9dca011..64c0fe7 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -6,6 +6,8 @@ // automated check, and they are code of their own -- a third of what used to be `ui.cpp` -- so they // live in a translation unit of their own. +#include "source/probe_source.hpp" +#include "source/registry.hpp" #include "ui_app.hpp" #include @@ -1887,6 +1889,97 @@ gboolean side_tab_probe(gpointer user) { return G_SOURCE_REMOVE; } +namespace { +std::uint64_t g_source_kinds_before = 0; +} + +/// Phase two: the command the Poke button posted must have been run by `poll()` on the producer. +gboolean source_kinds_probe_report(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + const ProbeSource* src = a->acq != nullptr ? dynamic_cast(a->acq->source()) + : nullptr; + const std::uint64_t after = src != nullptr ? src->commands_applied() : 0; + std::fprintf(stderr, "[source-kinds] command: before=%" G_GUINT64_FORMAT " after=%" G_GUINT64_FORMAT + " drained=%d\n", + g_source_kinds_before, after, after == g_source_kinds_before + 1 ? 1 : 0); + std::fflush(stderr); + return G_SOURCE_REMOVE; +} + +/// What the registry offers and what a source kind brings: the kinds, each config form's +/// round trip, the panel page (insensitive while disconnected, still present), and a posted command. +gboolean source_kinds_probe(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + + std::string ids; + for (const SourceKind* k : kinds()) ids += (ids.empty() ? "" : ",") + k->id(); + std::fprintf(stderr, "[source-kinds] kinds=%s\n", ids.c_str()); + + // Each kind's form must hand back what it was given. + const auto sample = [](const std::string& id) { + SourceConfig c; + if (id == "udps") { + c.set("server", "10.0.0.5"); + c.set("port", "9001"); + c.set("multicast", "239.1.2.3"); + c.set("interface", "10.0.0.9"); + } else if (id == "probe") { + c.set("rate", "750"); + } + return c; + }; + for (const SourceKind* k : kinds()) { + ConfigForm f = k->make_config_form(); + const SourceConfig in = sample(k->id()); + f.write(in); + const SourceConfig out = f.read(); + bool same = !in.empty(); + for (const auto& kv : in.items()) same = same && out.get(kv.first) == kv.second; + std::fprintf(stderr, "[source-kinds] form %s: round_trip=%d valid=%d\n", k->id().c_str(), + same ? 1 : 0, f.valid() ? 1 : 0); + // The rows were never parented; sink and release them so the form leaves nothing behind. + for (GtkWidget* row : f.rows) { + g_object_ref_sink(row); + g_object_unref(row); + } + } + + // The panel page: built, insensitive while disconnected, present either way. + const SourcePanelPage* page = a->source_panels.empty() ? nullptr : &a->source_panels.front(); + const auto report = [&](const char* state) { + const bool present = + page != nullptr && a->side_stack != nullptr && + adw_view_stack_get_child_by_name(ADW_VIEW_STACK(a->side_stack), page->name.c_str()) != + nullptr; + std::fprintf(stderr, "[source-kinds] panel %s: panels=%zu present=%d sensitive=%d\n", state, + a->source_panels.size(), present ? 1 : 0, + page != nullptr && gtk_widget_get_sensitive(page->content) ? 1 : 0); + }; + apply_source_panels(*a, false); + report("disconnected"); + apply_source_panels(*a, true); + report("connected"); + + // The probe kind has no datagrams, so the packet-capture button stays disabled, and is still there. + std::fprintf(stderr, "[source-kinds] capture button: present=%d sensitive=%d\n", + a->capture_button != nullptr ? 1 : 0, + a->capture_button != nullptr && gtk_widget_get_sensitive(a->capture_button) ? 1 : 0); + + // Click the panel's button: it posts a command, and a later `poll()` runs it. + ProbeSource* src = a->acq != nullptr ? dynamic_cast(a->acq->source()) : nullptr; + g_source_kinds_before = src != nullptr ? src->commands_applied() : 0; + if (page != nullptr) { + if (GtkWidget* poke = gtk_widget_get_last_child(page->content)) { + g_signal_emit_by_name(poke, "clicked"); + } + } + std::fflush(stderr); + g_timeout_add(500, source_kinds_probe_report, a); + return G_SOURCE_REMOVE; +} + gboolean pan_probe(gpointer user) { App* a = static_cast(user); if (a->window == nullptr || a->rows->row_count() <= 0) return G_SOURCE_REMOVE; diff --git a/src/ui_source.cpp b/src/ui_source.cpp new file mode 100644 index 0000000..90927af --- /dev/null +++ b/src/ui_source.cpp @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/registry.hpp" +#include "ui_app.hpp" + +#include + +namespace udpscope { +namespace ui_impl { + +void build_source_panels(App& app) { + if (app.side_stack == nullptr || app.acq == nullptr || app.acq->source() == nullptr) return; + SourceKind* kind = find_kind(app.source_kind); + if (kind == nullptr) return; + for (const SourcePanel& p : kind->make_panels(*app.acq->source())) { + if (p.widget == nullptr || p.id.empty()) continue; + // Each page scrolls on its own, like Measure and Signals: panels differ in height. + GtkWidget* scroller = gtk_scrolled_window_new(); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER, + GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), p.widget); + const std::string name = "source-" + p.id; + // An empty title on purpose, as for the built-in tabs: the pill is an icon, and + // `name_side_tabs()` puts `title` on it as tooltip and accessible name. + adw_view_stack_add_titled_with_icon( + ADW_VIEW_STACK(app.side_stack), scroller, name.c_str(), "", + p.icon.empty() ? "applications-engineering-symbolic" : p.icon.c_str()); + app.source_panels.push_back({name, p.title, p.widget}); + } + // Disconnected until the first Connected event says otherwise. + apply_source_panels(app, false); +} + +void apply_source_panels(App& app, bool connected) { + for (const SourcePanelPage& p : app.source_panels) { + if (gtk_widget_get_sensitive(p.content) != (connected ? TRUE : FALSE)) { + gtk_widget_set_sensitive(p.content, connected); + } + } +} + +void sync_source_panels(App& app) { + if (app.source_panels.empty() || app.model == nullptr) return; + apply_source_panels(app, app.model->stats().connected.load(std::memory_order_relaxed)); +} + +} // namespace ui_impl +} // namespace udpscope