udpscope: an oscilloscope view for a UDPStreamer source
GTK4 and libadwaita. It receives one UDP stream through the vendored udpsclient, buffers it in a large ring, and draws it with the vendored plotter: a trigger with a pre/post event window, a pause that freezes the window, cursors, measurements, math channels in a node editor, an event cache, named configurations, preferences, and an export of the visible window to .npy/.csv. Waveform files can be opened and analysed in place. The interface is two threads -- a producer that owns the socket and the GTK main thread that owns every widget and the GL context -- sharing one model under one mutex, with samples crossing through lock-free rings. deps/plotter is a submodule pinned to the commit this tree builds against; deps/udpsclient is vendored in place. docs/claude/ documents the rules that cost a defect to learn, and SPECS.md is the requirement document.
This commit is contained in:
1 parent
8f54841c2c
commit
0e256764d5
41 files changed
+2081
-154
No files matched your search
+393
-11
@@ -1505,6 +1505,22 @@ void layout_probe_report(App& a, const char* what) {
|
||||
}
|
||||
if (!any) traces += "-";
|
||||
}
|
||||
// Where the resize arrows are and whether they are on screen: a boundary handle placed at the wrong
|
||||
// index is invisible in a button count, and the one thing this control must get right is *which*
|
||||
// border it sits on.
|
||||
std::string resize_tops;
|
||||
for (GtkWidget* w : a.layout_buttons) {
|
||||
if (g_object_get_data(G_OBJECT(w), "udpscope-layout-resize") == nullptr) continue;
|
||||
if (gtk_widget_get_visible(w)) {
|
||||
const int top = gtk_widget_get_margin_top(w);
|
||||
resize_tops += (resize_tops.empty() ? "" : ",") + std::to_string(row_key(w)) + ":" +
|
||||
std::to_string(top);
|
||||
} else {
|
||||
resize_tops += std::string(resize_tops.empty() ? "" : ",") + "(" + std::to_string(row_key(w)) +
|
||||
":-)";
|
||||
}
|
||||
}
|
||||
if (resize_tops.empty()) resize_tops = "-";
|
||||
// Where the Done button ended up, in the overlay's own coordinates: the assertion is
|
||||
// that it hugs the area's top-right corner, so a layout change that pushed it off the
|
||||
// corner shows here as numbers rather than as a screenshot someone has to look at.
|
||||
@@ -1520,13 +1536,175 @@ void layout_probe_report(App& a, const char* what) {
|
||||
}
|
||||
}
|
||||
std::fprintf(stderr, "[layout-probe] %-12s rows=%d buttons=%zu done=%d done_box=[%s] "
|
||||
"rows_y=[%s] close=[%s] add=[%s] traces=[%s]\n",
|
||||
"rows_y=[%s] close=[%s] add=[%s] resize=[%s] traces=[%s]\n",
|
||||
what, rows, a.layout_buttons.size(), a.layout_done_button != nullptr ? 1 : 0,
|
||||
done_box.c_str(), geom.c_str(), close_tops.c_str(), add_tops.c_str(),
|
||||
resize_tops.c_str(),
|
||||
traces.c_str());
|
||||
std::fflush(stderr);
|
||||
}
|
||||
|
||||
|
||||
/// The preferences dialog, driven the way a user drives it: opened, filled in, *Apply* clicked, then
|
||||
/// the file read back from disk. Reading it back rather than checking `App::settings` is the point --
|
||||
/// a dialog that edits its copy and never writes is the failure this has to catch.
|
||||
|
||||
/// `UDPSCOPE_PROBE_BOXZOOM_MS`: what a Ctrl box zoom puts on the plot, and what it does to the axis.
|
||||
///
|
||||
/// The gesture is injected straight into the row's controller -- the way a headless probe has to, since
|
||||
/// the area's gestures arrive from GTK -- and both halves are read back a few ticks later. The *axis* is
|
||||
/// the half a rect cannot report: it is the *settled* one that matters, after the pin or the data clamp
|
||||
/// has had its say, and it is the half that silently does nothing in a view whose axis is pinned.
|
||||
///
|
||||
/// Three named steps rather than nested lambdas: `g_timeout_add` takes a plain function pointer, so a
|
||||
/// lambda can carry no state, and the reading needs what the axis was before the drag.
|
||||
struct BoxZoomProbe {
|
||||
App* app = nullptr;
|
||||
plot::Range before{0.0, 0.0};
|
||||
};
|
||||
|
||||
plot::Range boxzoom_axis(App& app) {
|
||||
const RowSet::Row& r = app.rows->row(0);
|
||||
return r.plot != nullptr ? r.plot->x_range(r.axis_x) : plot::Range{0.0, 0.0};
|
||||
}
|
||||
|
||||
gboolean boxzoom_probe_done(gpointer user) {
|
||||
BoxZoomProbe& probe = *static_cast<BoxZoomProbe*>(user);
|
||||
App& app = *probe.app;
|
||||
const plot::Rect frame = app.rows->figure().plot(0u).selection_rect();
|
||||
const plot::Range now = boxzoom_axis(app);
|
||||
std::fprintf(stderr,
|
||||
"[boxzoom-probe] closed: frame=%.0fx%.0f gone=%d axis_after=[%.6f..%.6f] zoomed=%d\n",
|
||||
static_cast<double>(frame.w), static_cast<double>(frame.h),
|
||||
(frame.w == 0.0f && frame.h == 0.0f) ? 1 : 0, static_cast<double>(now.min),
|
||||
static_cast<double>(now.max),
|
||||
(now.span() < probe.before.span() * 0.999) ? 1 : 0);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
gboolean boxzoom_probe_release(gpointer user) {
|
||||
App& app = *static_cast<BoxZoomProbe*>(user)->app;
|
||||
plot::input::Controller* c = app.area->row_controller(0);
|
||||
if (c != nullptr) {
|
||||
const plot::u32 ctrl = static_cast<plot::u32>(plot::input::Mod::Ctrl);
|
||||
c->on_pointer({300.0f, 160.0f, 0.0f, 0.0f, plot::input::Button::Left, ctrl, false, false, true});
|
||||
}
|
||||
g_timeout_add_full(G_PRIORITY_DEFAULT, 80, boxzoom_probe_done, user,
|
||||
+[](gpointer p) { delete static_cast<BoxZoomProbe*>(p); });
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
gboolean boxzoom_probe_open(gpointer user) {
|
||||
BoxZoomProbe& probe = *static_cast<BoxZoomProbe*>(user);
|
||||
App& app = *probe.app;
|
||||
plot::Plot& plot = app.rows->figure().plot(0u);
|
||||
const plot::Rect frame = plot.selection_rect();
|
||||
const plot::Rect axes = plot.layout().axes;
|
||||
// The frame is the band the *release* will apply: the width the pointer swept, and the height of the
|
||||
// axes rather than the ten pixels the pointer also moved -- Ctrl zooms x only.
|
||||
const bool band = std::fabs(frame.w - 200.0f) < 1.0f && std::fabs(frame.h - axes.h) < 1.0f;
|
||||
std::fprintf(stderr,
|
||||
"[boxzoom-probe] open: swept=200x10 frame=%.0f,%.0f %.0fx%.0f axes_h=%.0f band=%d "
|
||||
"axis_before=[%.6f..%.6f]\n",
|
||||
static_cast<double>(frame.x), static_cast<double>(frame.y),
|
||||
static_cast<double>(frame.w), static_cast<double>(frame.h),
|
||||
static_cast<double>(axes.h), band ? 1 : 0, static_cast<double>(probe.before.min),
|
||||
static_cast<double>(probe.before.max));
|
||||
return boxzoom_probe_release(user);
|
||||
}
|
||||
|
||||
gboolean boxzoom_probe(gpointer user) {
|
||||
App& a = *static_cast<App*>(user);
|
||||
if (a.area == nullptr || a.rows == nullptr) return G_SOURCE_REMOVE;
|
||||
plot::input::Controller* c = a.area->row_controller(0);
|
||||
if (c == nullptr) return G_SOURCE_REMOVE;
|
||||
auto* probe = new BoxZoomProbe{&a, boxzoom_axis(a)};
|
||||
const plot::u32 ctrl = static_cast<plot::u32>(plot::input::Mod::Ctrl);
|
||||
c->on_pointer({100.0f, 150.0f, 0.0f, 0.0f, plot::input::Button::Left, ctrl, true, true, false});
|
||||
c->on_pointer({300.0f, 160.0f, 200.0f, 10.0f, plot::input::Button::Left, ctrl, true, false, false});
|
||||
// No destroy here: the *last* step owns the struct, because the middle step schedules it. A destroy
|
||||
// on every step would free it when the first one returns `G_SOURCE_REMOVE` -- and the step it just
|
||||
// armed would then read freed memory, which is a segfault in a self-check rather than a failure.
|
||||
g_timeout_add(80, boxzoom_probe_open, probe);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
gboolean prefs_probe(gpointer user) {
|
||||
App& a = *static_cast<App*>(user);
|
||||
open_preferences(a);
|
||||
// `dir` is the directory the dialog is offering and `store` is the one the cache is really using:
|
||||
// `store` must be `dir` plus the source's folder name, which is what says the field shows the path in
|
||||
// force rather than a plausible-looking string.
|
||||
const char* shown =
|
||||
a.prefs.dir_entry != nullptr ? gtk_editable_get_text(GTK_EDITABLE(a.prefs.dir_entry)) : "-";
|
||||
std::fprintf(stderr, "[prefs-probe] opened=%d apply=%d dir=%s store=%s\n",
|
||||
a.prefs.window != nullptr ? 1 : 0, a.prefs.apply != nullptr ? 1 : 0,
|
||||
a.prefs.dir_entry != nullptr ? shown : "-",
|
||||
a.event_store != nullptr ? a.event_store->dir().c_str() : "-");
|
||||
g_timeout_add(400, +[](gpointer u) -> gboolean {
|
||||
App& app = *static_cast<App*>(u);
|
||||
prefs_probe_fill_and_apply(app.prefs);
|
||||
const Settings back = load_settings();
|
||||
std::fprintf(stderr, "[prefs-probe] dir=%s keep=%d rel=%d theme=%s live=%d\n",
|
||||
back.event_cache_dir.c_str(), back.event_cache_max, back.relative_time ? 1 : 0,
|
||||
back.theme.c_str(),
|
||||
app.settings.theme == "dark" ? 1 : 0);
|
||||
// And typing the default back must not *pin* it: the file keeps "unset", so the default still
|
||||
// follows whatever cache directory the next run has. An empty value here is the whole point --
|
||||
// a path written into the file is a user's choice, and this one was not a choice.
|
||||
gtk_editable_set_text(GTK_EDITABLE(app.prefs.dir_entry), default_events_dir().c_str());
|
||||
g_signal_emit_by_name(app.prefs.apply, "clicked");
|
||||
std::fprintf(stderr, "[prefs-probe] default restored: file='%s'\n",
|
||||
load_settings().event_cache_dir.c_str());
|
||||
// The folder chooser, opened from its button and then *cancelled from outside*: a callback that
|
||||
// lands after its dialog's window is gone is what an async picker invites, and cancelling must
|
||||
// change nothing at all. The preferences window is closed a beat later, so the cancel callback
|
||||
// arrives while the form is still intact -- and the null checks in it are what makes the other
|
||||
// order safe too.
|
||||
if (app.prefs.browse != nullptr) {
|
||||
g_signal_emit_by_name(app.prefs.browse, "clicked");
|
||||
g_timeout_add(600, +[](gpointer u) -> gboolean {
|
||||
App& app = *static_cast<App*>(u);
|
||||
GListModel* tops = gtk_window_get_toplevels();
|
||||
const guint n = g_list_model_get_n_items(tops);
|
||||
guint extra = 0;
|
||||
for (guint i = 0; i < n; ++i) {
|
||||
GtkWidget* w = GTK_WIDGET(g_list_model_get_item(tops, i));
|
||||
if (w != app.window && w != app.prefs.window) {
|
||||
++extra;
|
||||
gtk_window_close(GTK_WINDOW(w));
|
||||
}
|
||||
g_object_unref(w);
|
||||
}
|
||||
// The button is an icon and nothing else, so "the theme has this icon" is the difference
|
||||
// between a control and a blank square: a name the icon theme does not know draws a
|
||||
// missing-image placeholder and says nothing anywhere else.
|
||||
std::fprintf(stderr, "[prefs-probe] browse: toplevels=%u extra=%u icon_ok=%d field='%s'\n", n,
|
||||
extra,
|
||||
app.prefs.browse != nullptr &&
|
||||
gtk_icon_theme_has_icon(
|
||||
gtk_icon_theme_get_for_display(
|
||||
gtk_widget_get_display(app.prefs.browse)),
|
||||
"folder-open-symbolic")
|
||||
? 1
|
||||
: 0,
|
||||
app.prefs.dir_entry != nullptr
|
||||
? gtk_editable_get_text(GTK_EDITABLE(app.prefs.dir_entry))
|
||||
: "-");
|
||||
g_timeout_add(600, +[](gpointer u2) -> gboolean {
|
||||
prefs_close(static_cast<App*>(u2)->prefs);
|
||||
return G_SOURCE_REMOVE;
|
||||
}, &app);
|
||||
return G_SOURCE_REMOVE;
|
||||
}, &app);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
prefs_close(app.prefs);
|
||||
return G_SOURCE_REMOVE;
|
||||
}, &a);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
void layout_probe_next(App& a, int step, guint delay_ms) {
|
||||
a.dbg_layout_step = step;
|
||||
g_timeout_add(delay_ms, layout_probe_step, &a);
|
||||
@@ -2670,6 +2848,12 @@ gboolean events_probe_step(gpointer user) {
|
||||
// history button (which is only there once the first two have happened -- that is the item's rule,
|
||||
// and this is where it is asserted).
|
||||
gtk_switch_set_active(GTK_SWITCH(a->events_record_switch), TRUE);
|
||||
// An 80% pre-trigger share, so the event that latches is *not* symmetric: the window a click
|
||||
// opens must be the event's own span, and with a symmetric event the live window would look
|
||||
// identical and say nothing.
|
||||
if (a->trigger_pre_spin != nullptr) {
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_pre_spin), 80.0);
|
||||
}
|
||||
report_events_availability(*a, "recording on, no trigger");
|
||||
if (a->trigger_toggle != nullptr) {
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
|
||||
@@ -2749,7 +2933,7 @@ gboolean events_probe_step(gpointer user) {
|
||||
gtk_picture_get_paintable(GTK_PICTURE(a->events_thumb)) != nullptr;
|
||||
std::fprintf(stderr,
|
||||
"[events] activated row0: replaying=%d pane=%d pane_w=%d pane_box=%d tick=%d "
|
||||
"window=%.3f "
|
||||
"window=%.3f axis=[%.3f,%.3f] marker=%.3f "
|
||||
"thumbnail=%d thumb_size=%dx%d thumb_visible=%d\n",
|
||||
a->replaying ? 1 : 0,
|
||||
a->events_revealer != nullptr && gtk_widget_get_visible(a->events_revealer) ? 1 : 0,
|
||||
@@ -2760,7 +2944,19 @@ gboolean events_probe_step(gpointer user) {
|
||||
a->events_revealer != nullptr && gtk_revealer_get_child(GTK_REVEALER(a->events_revealer)) != nullptr
|
||||
? gtk_widget_get_width(gtk_revealer_get_child(GTK_REVEALER(a->events_revealer)))
|
||||
: -1,
|
||||
signal_tick_visible(*a), a->rows->time_window(), picture ? 1 : 0,
|
||||
signal_tick_visible(*a), a->rows->time_window(),
|
||||
a->rows->row(0).plot != nullptr
|
||||
? a->rows->row(0).plot->x_range(a->rows->row(0).axis_x).min
|
||||
: 0.0,
|
||||
a->rows->row(0).plot != nullptr
|
||||
? a->rows->row(0).plot->x_range(a->rows->row(0).axis_x).max
|
||||
: 0.0,
|
||||
// The trigger line's own cursor: 0 is the trigger instant and a large negative is the
|
||||
// park. A file that opens without its trigger line reads the parked value here.
|
||||
a->rows->row(0).plot != nullptr
|
||||
? a->rows->row(0).plot->cursor(a->rows->row(0).trigger_marker).x
|
||||
: 0.0,
|
||||
picture ? 1 : 0,
|
||||
a->events_thumb != nullptr ? gtk_widget_get_width(a->events_thumb) : -1,
|
||||
a->events_thumb != nullptr ? gtk_widget_get_height(a->events_thumb) : -1,
|
||||
a->events_thumb != nullptr && gtk_widget_get_mapped(a->events_thumb) ? 1 : 0);
|
||||
@@ -2875,6 +3071,17 @@ gboolean events_group_probe_step(gpointer user) {
|
||||
const plot::Range bx = a->rows_b->row(0).plot->x_range(a->rows_b->row(0).axis_x);
|
||||
std::fprintf(stderr, " list=%d x_a=[%.3f,%.3f] x_b=[%.3f,%.3f] same_x=%d", entries, ax.min,
|
||||
ax.max, bx.min, bx.max, (ax.min == bx.min && ax.max == bx.max) ? 1 : 0);
|
||||
// Which *channel* each plot's row 0 draws: the comparison is only a comparison if the second
|
||||
// plot's slots resolved to the other event's channels (and not to the first event's).
|
||||
std::fprintf(stderr, " channels_a=[");
|
||||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||||
std::fprintf(stderr, "%d%s", a->rows->channel_of(0, s), s + 1 < RowSet::kSlotsPerRow ? "," : "");
|
||||
}
|
||||
std::fprintf(stderr, "] channels_b=[");
|
||||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||||
std::fprintf(stderr, "%d%s", a->rows_b->channel_of(0, s), s + 1 < RowSet::kSlotsPerRow ? "," : "");
|
||||
}
|
||||
std::fprintf(stderr, "]");
|
||||
// A y range nobody would arrive at by accident, set on the first plot only.
|
||||
a->rows->row(0).plot->set_y_range(a->rows->row(0).axis_y, plot::Range{-3.5, 3.5});
|
||||
}
|
||||
@@ -2897,10 +3104,76 @@ gboolean events_group_probe_step(gpointer user) {
|
||||
}
|
||||
std::fprintf(stderr, "[events] compare sync: y_a=%.3f y_b=%.3f mirrored=%d\n", y_a, y_b,
|
||||
(y_a == y_b && y_b == -3.5) ? 1 : 0);
|
||||
events_group_probe_next(*a, 57, 400);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
if (a->dbg_events_step == 57) {
|
||||
// The drop, in a *file* view: the signal is not on any plot yet, so `on_drop_channel()` takes the
|
||||
// path a drag takes -- and what it must not do is rebuild the model, which in this mode has no
|
||||
// producer to refill it.
|
||||
const std::size_t before = a->model->channels().size();
|
||||
std::uint64_t samples_before = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(a->model->mutex());
|
||||
if (before > 0) samples_before = a->model->live().values(0).produced();
|
||||
}
|
||||
GValue value = G_VALUE_INIT;
|
||||
g_value_init(&value, G_TYPE_STRING);
|
||||
g_value_set_string(&value, "1"); // the second signal: on no row in the default layout
|
||||
const gboolean ok = on_drop_channel(nullptr, &value, 40.0, 20.0, a);
|
||||
g_value_unset(&value);
|
||||
std::uint64_t samples_after = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(a->model->mutex());
|
||||
if (before > 0) samples_after = a->model->live().values(0).produced();
|
||||
}
|
||||
std::fprintf(stderr,
|
||||
"[events] drop in a file view: ok=%d channels=%zu slot0=['%s'] slot1=['%s'] "
|
||||
"samples=%llu->%llu\n",
|
||||
ok ? 1 : 0, a->model->channels().size(),
|
||||
a->rows->row(0).signal_name[0].c_str(), a->rows->row(0).signal_name[1].c_str(),
|
||||
static_cast<unsigned long long>(samples_before),
|
||||
static_cast<unsigned long long>(samples_after));
|
||||
// One step later, after the figure has been rebuilt and the area re-pointed: what the row now
|
||||
// *draws* -- the slot's channel and the traces the plot reports as visible.
|
||||
events_group_probe_next(*a, 58, 600);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
if (a->dbg_events_step == 58) {
|
||||
std::fprintf(stderr, "[events] drop result: slot1_channel=%d visible_traces=%d\n",
|
||||
a->rows->channel_of(0, 1), group_visible_traces(*a, 0));
|
||||
events_group_probe_next(*a, 5, 400);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
if (a->dbg_events_step == 59) {
|
||||
// A comparison *after* an accumulation: the accumulation left layers in slots a comparison does not
|
||||
// use, and those used to stay -- extra legend entries, in colours belonging to nothing.
|
||||
if (a->events_compare != nullptr) g_signal_emit_by_name(a->events_compare, "clicked");
|
||||
events_group_probe_next(*a, 60, 700);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
if (a->dbg_events_step == 60) {
|
||||
const RowSet::Row& r0 = a->rows->row(0);
|
||||
std::fprintf(stderr, "[events] compare after accumulate: kind=%d traces0=%d legend0=%zu slots=[",
|
||||
static_cast<int>(a->group_kind), group_visible_traces(*a, 0),
|
||||
r0.plot != nullptr ? r0.plot->layout().legend.rows.size() : 0UL);
|
||||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||||
std::fprintf(stderr, "%s'%s'", s ? "," : "",
|
||||
a->rows->row(0).signal_name[static_cast<std::size_t>(s)].c_str());
|
||||
}
|
||||
std::fprintf(stderr, "]\n");
|
||||
std::fflush(stderr);
|
||||
go_live_again(*a);
|
||||
std::fprintf(stderr, "[events] group left: replaying=%d kind=%d waveform=%d\n", a->replaying ? 1 : 0,
|
||||
static_cast<int>(a->group_kind), a->waveform_name.empty() ? 0 : 1);
|
||||
std::fflush(stderr);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
if (a->dbg_events_step == 5) {
|
||||
if (a->events_accumulate != nullptr) g_signal_emit_by_name(a->events_accumulate, "clicked");
|
||||
events_group_probe_next(*a, 55, 700);
|
||||
@@ -2918,12 +3191,7 @@ gboolean events_group_probe_step(gpointer user) {
|
||||
static_cast<unsigned long long>(a->model->live().values(0).produced()),
|
||||
selected_events(*a).size(), group_visible_traces(*a, 0), a->rows->time_window());
|
||||
}
|
||||
// A group is a file view, so the way out of it is the way out of a replay.
|
||||
go_live_again(*a);
|
||||
std::fprintf(stderr, "[events] group left: replaying=%d kind=%d waveform=%d\n",
|
||||
a->replaying ? 1 : 0, static_cast<int>(a->group_kind),
|
||||
a->waveform_name.empty() ? 0 : 1);
|
||||
std::fflush(stderr);
|
||||
events_group_probe_next(*a, 59, 400);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
return G_SOURCE_REMOVE;
|
||||
@@ -3080,6 +3348,114 @@ gboolean keys_probe(gpointer user) {
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
|
||||
/// What the shortcut overview's UI file *declares*, read out of the resource rather than off the
|
||||
/// built widgets: `GtkShortcutsWindow` reaches each row from more than one place in its own tree, so a
|
||||
/// walk counts every row twice (52 for 26), and what a reader counts on the screen is the declaration.
|
||||
///
|
||||
/// The two numbers that matter are `with_accel`, the rows drawn as keycaps, and `with_icon`, the ones
|
||||
/// drawn as an icon. `bare` is a row with *neither*, which is what a cell reading "No Shortcut" is --
|
||||
/// the reason this overview is a GTK window built from a UI file rather than libadwaita's dialog, whose
|
||||
/// items have no icon property at all. It must be zero.
|
||||
struct UiShortcuts {
|
||||
int rows = -1;
|
||||
int with_accel = 0;
|
||||
int with_icon = 0;
|
||||
int bare = 0;
|
||||
// Per-row state, and the depth that tells a row's own `</object>` from the one closing the
|
||||
// `GThemedIcon` nested inside its icon property.
|
||||
int depth = 0;
|
||||
int row_depth = -1;
|
||||
bool row_accel = false;
|
||||
bool row_icon = false;
|
||||
bool in_property = false;
|
||||
std::string property;
|
||||
};
|
||||
|
||||
const char* attribute(const gchar** names, const gchar** values, const char* want) {
|
||||
for (int i = 0; names[i] != nullptr; ++i) {
|
||||
if (g_strcmp0(names[i], want) == 0) return values[i];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void ui_row_start(GMarkupParseContext*, const gchar* name, const gchar** names, const gchar** values,
|
||||
gpointer user, GError**) {
|
||||
UiShortcuts& out = *static_cast<UiShortcuts*>(user);
|
||||
if (g_strcmp0(name, "object") == 0) {
|
||||
++out.depth;
|
||||
if (g_strcmp0(attribute(names, values, "class"), "GtkShortcutsShortcut") == 0) {
|
||||
out.row_depth = out.depth;
|
||||
out.row_accel = false;
|
||||
out.row_icon = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (out.row_depth < 0 || g_strcmp0(name, "property") != 0) return;
|
||||
const char* prop = attribute(names, values, "name");
|
||||
out.property = prop != nullptr ? prop : "";
|
||||
// Only an accelerator is carried as text; an icon's value is a nested `GThemedIcon` object, so its
|
||||
// *declaration* is the whole of what there is to see and it is marked here rather than in the text
|
||||
// callback that would never fire for it.
|
||||
out.in_property = out.property == "accelerator";
|
||||
if (out.property == "icon") out.row_icon = true;
|
||||
}
|
||||
|
||||
/// The text callbacks carry the property's *value*; an empty `accelerator` is not a keycap, so only
|
||||
/// non-empty text counts.
|
||||
void ui_row_text(GMarkupParseContext*, const gchar* text, gsize len, gpointer user, GError**) {
|
||||
(void)text; // the length is what the callback is asked for; the bytes are not
|
||||
UiShortcuts& out = *static_cast<UiShortcuts*>(user);
|
||||
if (!out.in_property || len == 0) return;
|
||||
if (out.property == "accelerator") out.row_accel = true;
|
||||
}
|
||||
|
||||
void ui_row_end(GMarkupParseContext*, const gchar* name, gpointer user, GError**) {
|
||||
UiShortcuts& out = *static_cast<UiShortcuts*>(user);
|
||||
if (g_strcmp0(name, "property") == 0) {
|
||||
out.in_property = false;
|
||||
return;
|
||||
}
|
||||
if (g_strcmp0(name, "object") != 0) return;
|
||||
if (out.depth == out.row_depth) {
|
||||
out.row_depth = -1;
|
||||
++out.rows;
|
||||
if (out.row_accel) {
|
||||
++out.with_accel;
|
||||
} else if (out.row_icon) {
|
||||
++out.with_icon;
|
||||
} else {
|
||||
++out.bare;
|
||||
}
|
||||
}
|
||||
--out.depth;
|
||||
}
|
||||
|
||||
UiShortcuts ui_shortcut_rows(const char* path) {
|
||||
UiShortcuts out;
|
||||
GError* error = nullptr;
|
||||
GBytes* bytes = g_resources_lookup_data(path, G_RESOURCE_LOOKUP_FLAGS_NONE, &error);
|
||||
if (bytes == nullptr) {
|
||||
if (error != nullptr) g_error_free(error);
|
||||
return out;
|
||||
}
|
||||
gsize len = 0;
|
||||
const char* data = static_cast<const char*>(g_bytes_get_data(bytes, &len));
|
||||
GMarkupParser parser{&ui_row_start, &ui_row_end, &ui_row_text, nullptr, nullptr};
|
||||
if (GMarkupParseContext* ctx = g_markup_parse_context_new(&parser, G_MARKUP_DEFAULT_FLAGS, &out,
|
||||
nullptr);
|
||||
ctx != nullptr) {
|
||||
out.rows = 0;
|
||||
if (!g_markup_parse_context_parse(ctx, data, static_cast<gssize>(len), &error) && error != nullptr) {
|
||||
g_error_free(error);
|
||||
out.rows = 0; // a file this probe cannot parse is not a file with no rows
|
||||
}
|
||||
g_markup_parse_context_free(ctx);
|
||||
}
|
||||
g_bytes_unref(bytes);
|
||||
return out;
|
||||
}
|
||||
|
||||
gboolean help_probe(gpointer user) {
|
||||
App* a = static_cast<App*>(user);
|
||||
const HelpReport report = help_self_check();
|
||||
@@ -3098,11 +3474,17 @@ gboolean help_probe(gpointer user) {
|
||||
int gesture_count = 0;
|
||||
(void)app_accels(&accel_count);
|
||||
(void)plot_gestures(&gesture_count);
|
||||
// The tables are what the application handles, the UI file is what it shows. They are compared here
|
||||
// because they are two lists of the same thing: a row in one and not the other is a gesture that
|
||||
// works and is not documented, or is documented and does nothing.
|
||||
const UiShortcuts ui = ui_shortcut_rows("/org/udpscope/ui/shortcuts.ui");
|
||||
std::fprintf(stderr,
|
||||
"[help] pages=%d parsed=%d shortest='%s' %zu B; shortcuts: app=%d plot=%d; "
|
||||
"[help] pages=%d parsed=%d shortest='%s' %zu B; shortcuts: app=%d plot=%d tables=%d "
|
||||
"ui=%d ui_accel=%d ui_icon=%d ui_bare=%d; "
|
||||
"about version=%s commit=%s built=%s settings=%s icon=%s; closed ok\n",
|
||||
report.pages, report.parsed, report.shortest_title.c_str(),
|
||||
report.shortest_bytes, accel_count, gesture_count, build_info::kVersion,
|
||||
report.shortest_bytes, accel_count, gesture_count, accel_count + gesture_count,
|
||||
ui.rows, ui.with_accel, ui.with_icon, ui.bare, build_info::kVersion,
|
||||
build_info::kCommit, build_info::kTimestamp, config_dir().c_str(),
|
||||
about_icon.c_str());
|
||||
std::fflush(stderr);
|
||||
|
||||
Reference in new issue
Block a user