source: capability panels as side-stack pages, with a probe for the path

This commit is contained in:
Martino Ferrari committed 2026-10-31 20:18:28 +01:00
1 parent 79c33b25c5
commit 5cee00aaee
6 files changed
+209 -3

No files matched your search

+28
View File
@@ -128,6 +128,34 @@ class ProbeKind final : public SourceKind {
f.on_change = [cb](std::function<void()> fn) { *cb = std::move(fn); };
return f;
}
std::vector<SourcePanel> 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<DataSource*>(user);
s->post([s] { static_cast<ProbeSource*>(s)->note_applied(); });
}),
src, nullptr, static_cast<GConnectFlags>(0));
SourcePanel p;
p.id = "poke";
p.title = "Probe";
p.widget = box;
return {p};
}
};
} // namespace
+17 -3
View File
@@ -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<guint>(std::atoi(env)), density_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_SOURCE_KINDS")) {
g_timeout_add(static_cast<guint>(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().
+21
View File
@@ -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<SourcePanelPage> 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);
+93
View File
@@ -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 <sys/stat.h>
@@ -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<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const ProbeSource* src = a->acq != nullptr ? dynamic_cast<const ProbeSource*>(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<App*>(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<ProbeSource*>(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<App*>(user);
if (a->window == nullptr || a->rows->row_count() <= 0) return G_SOURCE_REMOVE;
+49
View File
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/registry.hpp"
#include "ui_app.hpp"
#include <adwaita.h>
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