source: capability panels as side-stack pages, with a probe for the path
This commit is contained in:
1 parent
79c33b25c5
commit
5cee00aaee
6 files changed
+209
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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().
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user