Files
udpscope/src/ui_probe.cpp
T
Martino FerrariandClaude Opus 4.6 be71a1d4bb perf: a frozen frame is not re-rendered sixty times a second
The tick queued a GL render unconditionally. In `Held` and `Paused` the rows
are bound to an immutable snapshot on a pinned axis, so a frame in which none
of the picture's own terms moved is the same frame again. The gate keys on a
coarse signature of what a frozen view can be re-framed by, opens for
`kSettleRenders` frames on a change because the figure lays out per render, and
has a floor that renders anyway every few ticks. The layout editor is excluded:
its buttons are placed from the last frame's rects, so a skipped frame there is
a stale widget rather than a stale picture. `skips=` in the probe dump is the
reading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-13 14:50:55 +02:00

3757 lines
178 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The front end's self-check: the probes that drive the window from the environment variables in
// `docs/claude/verification.md`, and the dump that prints what they found. They are the app's only
// 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 "ui_app.hpp"
#include <sys/stat.h>
#include <cstdio>
#include <cstring>
namespace udpscope {
namespace ui_impl {
int on_render_probe(GtkGLArea*, GdkGLContext*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->dbg_paint_start_us != 0) {
const double ms = double(g_get_monotonic_time() - app->dbg_paint_start_us) / 1000.0;
app->dbg_draw_ms_sum += ms;
if (ms > app->dbg_draw_ms_max) {
app->dbg_draw_ms_max = ms;
app->dbg_draw_max_frame = app->dbg_paint_n;
}
}
if (app->area != nullptr) {
if (const plot::Renderer* r = app->area->renderer()) {
const plot::FrameStats& s = r->stats();
app->dbg_seg_sum += s.staged_segments;
app->dbg_seg_max = std::max(app->dbg_seg_max, s.staged_segments);
app->dbg_visible_max = std::max(app->dbg_visible_max, s.visible_samples);
app->dbg_seg_last = s.staged_segments;
if (s.raw_traces > 0) ++app->dbg_raw_frames;
if (s.lod_traces > 0) ++app->dbg_lod_frames;
if (s.column_traces > 0) ++app->dbg_column_frames;
if (s.graded_traces > 0) ++app->dbg_graded_frames;
if (s.raw_traces > 0 || s.lod_traces > 0) check_frame_ink(*app);
if (s.raw_traces == 0 && s.lod_traces == 0) {
++app->dbg_notrace_frames;
app->dbg_notrace_last = app->dbg_paint_n;
if (app->dbg_ever_bound) {
++app->dbg_notrace_after_bound;
// What the frame was asked to draw, beside what there was to draw it from: an axis
// that left the data and a trace that had nothing bound are the same picture and
// different bugs, so both are named here rather than inferred from the count.
if (app->dbg_notrace_after_bound <= 40) {
const RowSet::Row& r = app->rows->row(0);
const plot::Range ax = r.plot != nullptr ? r.plot->x_range(r.axis_x) : plot::Range{};
std::fprintf(stderr, "[notrace] paint %d mode=%s axis=[%.6f..%.6f] segments=%u",
app->dbg_paint_n, display_mode_name(app->dbg_display), ax.min, ax.max,
s.staged_segments);
for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) {
if (r.channel[static_cast<std::size_t>(sl)] < 0) continue;
const plot::Range b = r.slots[static_cast<std::size_t>(sl)].x_bounds();
std::fprintf(stderr, " slot%d n=%zu x=[%.6f..%.6f]", sl,
r.slots[static_cast<std::size_t>(sl)].size(), b.min, b.max);
}
std::fprintf(stderr, "\n");
}
}
}
}
}
return FALSE; // same reason the adapter does: never stop the emission
}
gboolean exit_probe_step3(gpointer user) {
App* a = static_cast<App*>(user);
a->dbg_mode_live = a->status_detail;
GtkWidget* window = a->window;
a->window = nullptr; // stop the tick before the widgets go
a->dbg_side_w = gtk_widget_get_width(a->side_box);
a->dbg_side_h = gtk_widget_get_height(a->side_box);
a->dbg_signal_w = gtk_widget_get_width(a->signal_revealer);
a->dbg_signal_h = gtk_widget_get_height(a->signal_revealer);
a->dbg_trigger_w = gtk_widget_get_width(a->trigger_revealer);
a->dbg_trigger_h = gtk_widget_get_height(a->trigger_revealer);
// The height of one signal row, which is the thing "the channel toggle is too tall"
// was about. The revealer's own height cannot stand in for it: it also covers the
// heading, the hint and the box margins.
if (GtkWidget* first = gtk_widget_get_first_child(a->signal_list)) {
a->dbg_signal_row_h = gtk_widget_get_height(first);
}
if (a->area != nullptr) a->dbg_frames_painted = static_cast<int>(a->area->frames_painted());
a->dbg_reset_visible = a->cursors_reset != nullptr && gtk_widget_get_visible(a->cursors_reset) ? 1 : 0;
a->dbg_signal_revealed =
gtk_revealer_get_child_revealed(GTK_REVEALER(a->signal_revealer)) ? 1 : 0;
a->dbg_trigger_revealed =
gtk_revealer_get_child_revealed(GTK_REVEALER(a->trigger_revealer)) ? 1 : 0;
a->dbg_side_visible = gtk_widget_get_visible(a->side_box) ? 1 : 0;
a->dbg_side_scroll_after_hide = gtk_widget_get_visible(a->side_scroll) ? 1 : 0;
if (a->trigger_signal_drop != nullptr) {
GListModel* model = gtk_drop_down_get_model(GTK_DROP_DOWN(a->trigger_signal_drop));
a->dbg_trigger_items = model != nullptr ? static_cast<int>(g_list_model_get_n_items(model)) : 0;
a->dbg_trigger_selected = static_cast<int>(
gtk_drop_down_get_selected(GTK_DROP_DOWN(a->trigger_signal_drop)));
}
// `dbg_window_text` is kept by `sync_window_display()`, the only place that knows the span the
// window control is showing: there is no label to read it back from any more.
a->dbg_toggles_visible =
(gtk_widget_get_visible(a->signal_toggle) && gtk_widget_get_visible(a->trigger_toggle)) ? 1
: 0;
if (window != nullptr) gtk_window_destroy(GTK_WINDOW(window));
return G_SOURCE_REMOVE;
}
gboolean exit_probe_step2(gpointer user) {
App* a = static_cast<App*>(user);
a->dbg_mode_triggered = a->status_detail;
if (a->rows->row(0).plot) {
a->dbg_marker_x = a->rows->row(0).plot->cursor(a->rows->row(0).trigger_marker).x;
a->dbg_marker_max = a->rows->row(0).plot->x_range(0).max;
a->dbg_trigger_time = a->model->trigger_time().load();
a->dbg_trigger_time_valid = a->model->trigger_time_valid().load() ? 1 : 0;
}
if (a->trigger_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), FALSE);
}
// The tick keeps running for these 400 ms, so by step 3 the status line reads
// the state the app is actually in after leaving trigger mode.
g_timeout_add(400, exit_probe_step3, a);
return G_SOURCE_REMOVE;
}
gboolean trig_trace_probe(gpointer user) {
App* a = static_cast<App*>(user);
// Before the first CONFIG the store does not exist yet: live() would hand back a
// null BufferStore.
if (a->window == nullptr || a->model->channel_count() == 0) return G_SOURCE_CONTINUE;
std::lock_guard<std::mutex> lock(a->model->mutex());
ScopeModel& m = *a->model;
// What the first bound slot shows, read off the slot itself: the binding is the
// thing under test, so it must not be inferred from the trigger state.
const char* display = "nothing";
const SlotSeries* shown = nullptr;
for (int i = 0; i < a->rows->row_count(); ++i) {
RowSet::Row& r = a->rows->row(i);
bool found = false;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (r.channel[si] < 0) continue;
// A live binding in trigger mode is only legitimate as a capture in progress, so
// the two are named apart: `live` with `trigger=on` is the defect, `live(capturing)`
// is the feature.
if (r.slots[si].bound_event() != nullptr) display = "event(frozen)";
else if (r.slots[si].bound() != nullptr) display = a->capturing ? "live(capturing)" : "live";
shown = &r.slots[si];
found = true;
break;
}
if (found) break;
}
// Where that slot's samples are *drawn*, reconstructed the way the renderer does it:
// `Series::gather()` emits x relative to an origin **the caller names**, and the
// consumer adds that same origin back before mapping to the screen. So
// `origin + gathered` must equal `x_at()`, and `drawn=` must equal `data=` to the
// f32's precision. The origin named here is `x0()`, deliberately -- the renderer
// passes the visible range's minimum, but this line reads the series' *whole* span,
// which is the widest relative value a gather can be asked to stage and so the worst
// case for the f32.
//
// This is the one defect in this area the picture alone shows and no other probe
// line does: a series that emits absolute x instead makes the consumer add the origin
// twice, so the trace lands a whole origin away from the axis it was measured
// against -- off screen entirely once the view is narrower than that shift, while
// the crosshair (which reads `x_at()`) goes on reporting correct values.
double drawn_lo = 0.0, drawn_hi = 0.0, data_lo = 0.0, data_hi = 0.0;
if (shown != nullptr && shown->size() > 0) {
const u64 n = shown->size();
std::vector<f32> xy;
shown->gather(0u, 0u, 1u, shown->x0(), xy);
const std::size_t head = xy.size();
shown->gather(0u, n - 1, n, shown->x0(), xy);
if (head >= 2 && xy.size() >= head + 2) {
drawn_lo = shown->x0() + double(xy[0]);
drawn_hi = shown->x0() + double(xy[head]);
data_lo = shown->x_at(0);
data_hi = shown->x_at(n - 1);
}
}
double snap_lo = 0.0;
double snap_hi = 0.0;
std::size_t snap_n = 0;
if (auto snap = m.event()) {
snap_lo = -snap->pre_s;
snap_hi = snap->post_s;
snap_n = snap->stamps.size();
}
plot::Range axis{0.0, 0.0};
if (a->rows->row_count() > 0 && a->rows->row(0).plot != nullptr) {
const RowSet::Row& r0 = a->rows->row(0);
axis = r0.plot->x_range(r0.axis_x);
}
// The renderer's own index window, recomputed exactly as `Renderer::draw_traces`
// does it: it maps the visible x range onto sample indices with
// `(x - x0()) / dx()` -- a *uniform grid* formula -- and gathers only
// `[first, last)`. `ChannelBuffer`'s x is a streamed timestamp, so that formula is
// only ever as good as `dx()` describing the real local spacing. `want=` is the
// same window found by binary search over the real time ring, which is what
// `x_at()`/`neighbours()` -- and therefore the crosshair -- use.
//
// **`got=` must contain `want=`.** When `dx()` comes out larger than the real
// spacing every index is overestimated, `first` lands past the first visible
// sample, and the samples between the two are in the buffer, readable by the
// crosshair, and never drawn: the view's older part goes blank while its newest
// part looks perfectly fine. `dx_ratio` is the cause in one number -- it is 1.0
// when the grid assumption holds, and it inflates whenever a stamp inside the
// window is wrong (a sample whose time slot the ring already dropped reads 0).
long long got_lo = -1, got_hi = -1, want_lo = -1, want_hi = -1;
double dx_nominal = 0.0, dx_real = 0.0, bound_lo = 0.0, bound_hi = 0.0;
if (shown != nullptr && shown->size() > 0) {
const u64 n = shown->size();
const f64 sx0 = shown->x0();
dx_nominal = shown->dx();
dx_real = n >= 2 ? shown->x_at(n - 1) - shown->x_at(n - 2) : 0.0;
bound_lo = shown->x_bounds().min;
bound_hi = shown->x_bounds().max;
u64 i0 = 0, i1 = 0;
(void)sx0;
if (dx_nominal > 0.0) {
// What the renderer draws, through the series' own x: it used to walk the
// `(x - x0()) / dx()` grid, and on a ring-backed series that landed the window inside
// the visible range and dropped the two segments that draw into the left and right
// margins. `neighbours()`, one sample of slack either side, is the whole of it now --
// the same call the crosshair reads through. The *guard* against the grid walk coming
// back is the library's own pixel test
// (`a_sparse_window_between_samples_still_inks_both_margins`), because a probe that
// recomputes the renderer's formula cannot see one that no longer matches it; what this
// line still says is the rate: `dx_ratio` is how far the nominal grid sits from the real
// stamps, which is what made the walk wrong in the first place.
// A window edge outside the series keeps the renderer's own default on that side: 0 on
// the left (draw from the oldest sample) and n on the right (draw to the newest), which
// is why a view reaching past the data leaves the margin beyond it genuinely empty.
got_lo = shown->neighbours(axis.min, i0, i1) ? (i0 > 0 ? (long long)i0 - 1 : 0) : 0;
got_hi = shown->neighbours(axis.max, i0, i1)
? std::min<long long>((long long)i1 + 1, (long long)n)
: (long long)n;
}
want_lo = shown->neighbours(axis.min, i0, i1) ? (long long)i0
: (axis.min < shown->x_at(0) ? 0 : (long long)n);
want_hi = shown->neighbours(axis.max, i0, i1) ? (long long)i1 + 1
: (axis.max < shown->x_at(0) ? 0 : (long long)n);
}
std::fprintf(stderr,
"[trig-probe/idx] n=%llu bounds=[%.4f..%.4f] dx=%.6g dx_real=%.6g "
"dx_ratio=%.3f got=[%lld..%lld) want=[%lld..%lld)\n",
static_cast<unsigned long long>(shown != nullptr ? shown->size() : 0), bound_lo,
bound_hi, dx_nominal, dx_real,
dx_real > 0.0 ? dx_nominal / dx_real : 0.0, got_lo, got_hi, want_lo, want_hi);
std::fprintf(stderr,
"[trig-probe] trigger=%s pending=%d display=%s epoch=%.4f latches=%llu armed=%d "
"live_pub=%llu snap=[%.4f..%.4f] n=%zu axis=[%.4f..%.4f] "
"data=[%.4f..%.4f] drawn=[%.4f..%.4f]\n",
m.trigger_enabled() ? "on" : "off", int(m.trigger_pending()), display,
m.display_epoch(),
static_cast<unsigned long long>(m.trigger_latches().load()),
int(m.trigger_armed().load()),
static_cast<unsigned long long>(m.live().values(0).produced()), snap_lo, snap_hi,
snap_n, double(axis.min), double(axis.max), data_lo, data_hi, drawn_lo, drawn_hi);
std::fflush(stderr);
return G_SOURCE_CONTINUE;
}
gboolean eventzoom_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (a->trigger_mode_drop != nullptr)
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_mode_drop), 1);
if (a->trigger_toggle != nullptr)
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
g_timeout_add(50, eventzoom_wait, a);
return G_SOURCE_REMOVE;
}
gboolean math_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
std::string name;
std::string late_name;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
if (a->model->channels().empty()) return G_SOURCE_REMOVE;
name = a->model->channels().front().name;
// The *last* acquired channel, and the probe needs it rather than merely liking it.
// Filling the editor's form calls `gtk_drop_down_set_selected()` on the source
// dropdown, and GObject does not emit `notify` when a property is set to the value it
// already holds -- so a channel sourcing `channels().front()` picks index 0 on a
// freshly built dropdown that is already at 0, the signal never fires, and the whole
// `notify::selected` path goes unexercised. That is exactly how a handler of the wrong
// shape on that signal survived every run of this probe while crashing the app on the
// first editor a user opened.
late_name = a->model->channels().back().name;
}
std::vector<MathChannel> channels;
MathChannel gain;
gain.name = "math x2";
gain.enabled = true;
gain.nodes = {MathNode{MathOp::Source, name}, MathNode{MathOp::Gain}};
gain.nodes[1].in_a = 0;
gain.nodes[1].gain = 2.0;
gain.output = 1;
channels.push_back(gain);
MathChannel lp;
lp.name = "math lp";
lp.enabled = true;
lp.nodes = {MathNode{MathOp::Source, late_name}, MathNode{MathOp::LowPass}};
lp.nodes[1].in_a = 0;
lp.nodes[1].fc = 20.0;
lp.output = 1;
channels.push_back(lp);
// Through the app's list, which is the pane's source of truth: a probe that wrote the model
// directly left the two disagreeing, and the next pane edit then silently dropped its channels.
a->math = std::move(channels);
apply_math_channels(*a);
std::fprintf(stderr, "[math-probe] 2 math channels on '%s' and '%s'\n", name.c_str(),
late_name.c_str());
// And open the editor on the first one: as much a construction crash test as a state check,
// since a modal window is presented and its canvas, form and gestures are all built here.
// The pane's own `+`, which is the path the user takes: it must source an *acquired* channel.
// From `signals().front()` it sourced the stream's timestamp signal -- no samples, so the graph
// validated and then read NaN for ever, which is exactly the reported "[ch] -> [gain] is all nan".
add_math_channel(*a);
if (!a->math.empty()) {
const std::string src = a->math.back().nodes.empty() ? std::string("(none)")
: a->math.back().nodes[0].source;
std::fprintf(stderr, "[math-probe] pane add: '%s' sources '%s'\n", a->math.back().name.c_str(),
src.c_str());
}
open_math_dialog(*a, 0);
std::fprintf(stderr, "[math-probe] editor opened\n");
// Opened on the channel sourcing the *last* acquired signal, which is the one open that moves
// the source dropdown off index 0 and so emits `notify::selected` -- see `late_name` above for
// why nothing else here does. The reading is the source the handler wrote back into the node:
// it must be `late_name` and not the dropdown's first entry, because a handler that reads the
// wrong argument writes through a `GParamSpec*` and the Gtk-CRITICAL baseline is what catches
// it when the dereference happens not to fault.
{
if (a->math_dialog != nullptr) gtk_window_close(GTK_WINDOW(a->math_dialog));
open_math_dialog(*a, 1);
auto* ed = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
const char* read = "NO DIALOG";
if (ed != nullptr && !ed->nodes.empty()) read = ed->nodes[0].source.c_str();
std::fprintf(stderr, "[math-probe] source drop: node reads '%s' (want '%s')\n", read,
late_name.c_str());
// The user's own gesture: click the gain node, then the source node. The source node must
// still name `late_name` -- clicking a node must never rename it, and the form's own writes
// reloading the dropdown is exactly how it used to.
if (ed != nullptr && ed->nodes.size() >= 2) {
const auto click_node = [&](std::size_t i) {
const MathNode& n = ed->nodes[i];
math_canvas_pressed(nullptr, 1, (n.x + 10.0) * ed->view_scale + ed->view_x,
(n.y + 10.0) * ed->view_scale + ed->view_y, ed);
};
click_node(1);
click_node(0);
const char* after = ed->nodes.empty() ? "NO NODES" : ed->nodes[0].source.c_str();
std::fprintf(stderr, "[math-probe] click source: node reads '%s' (want '%s')\n", after,
late_name.c_str());
}
}
// The user's own flow, through the app's handlers: the pane's `+` makes a bare source graph, the
// editor adds a gain, the gain is set, Apply saves. The *output* must be the node that was just
// added -- it defaulted to node 0, the source, so every operation was computed and discarded.
{
const int index = static_cast<int>(a->math.size()) - 1;
// Closed first: `open_math_dialog()` on a dialog that is already up just presents it, so this
// block used to edit the *previous* channel and its numbers described the wrong graph.
if (a->math_dialog != nullptr) gtk_window_close(GTK_WINDOW(a->math_dialog));
open_math_dialog(*a, index);
auto* ed = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (ed != nullptr) {
on_math_node_added(nullptr, ed);
const int output_after_add = ed->output;
// Through the *widgets*, which is what the user touches -- and the read-back below is what
// `on_math_param_changed()` will take them from.
if (ed->gain != nullptr) gtk_spin_button_set_value(GTK_SPIN_BUTTON(ed->gain), 2.0);
if (ed->offset != nullptr) gtk_spin_button_set_value(GTK_SPIN_BUTTON(ed->offset), 0.0);
on_math_param_changed(nullptr, ed);
// The frame, structurally: the canvas being *in* the dialog and given an allocation. A `.ui`
// mistake this has already paid for once -- `math_body` nested under the window instead of the
// outer box -- leaves the canvas visible but parentless: it never maps, stays 0x0 and draws
// nothing, which a user reads as "the editor opens and the canvas is blank". The values below
// cannot see that, and the reading is delayed because an allocation only exists after a layout.
g_timeout_add(500, +[](gpointer u) -> gboolean {
App* app = static_cast<App*>(u);
if (app->math_dialog == nullptr) {
std::fprintf(stderr, "[math-probe] editor frame: (dialog closed before the reading)\n");
return G_SOURCE_REMOVE;
}
MathDialog* m = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(app->math_dialog), "udpscope-math-dialog"));
if (m == nullptr) return G_SOURCE_REMOVE;
const auto in_dialog = [app](GtkWidget* w) {
for (GtkWidget* p = w; p != nullptr; p = gtk_widget_get_parent(p)) {
if (p == app->math_dialog) return 1;
}
return 0;
};
std::fprintf(stderr,
"[math-probe] editor frame: canvas=%dx%d mapped=%d in_dialog=%d "
"name_in_dialog=%d\n",
m->canvas != nullptr ? gtk_widget_get_width(m->canvas) : -1,
m->canvas != nullptr ? gtk_widget_get_height(m->canvas) : -1,
m->canvas != nullptr ? gtk_widget_get_mapped(m->canvas) : -1,
in_dialog(m->canvas), in_dialog(m->name));
return G_SOURCE_REMOVE;
}, a);
const std::size_t built = ed->nodes.size();
const double gain_read = ed->nodes.empty() ? 0.0 : ed->nodes.back().gain;
const double offset_read = ed->nodes.empty() ? 0.0 : ed->nodes.back().offset;
// The identity row, which the file cannot hold values for: the entries were filled from the channel
// being edited, so reading them back is what shows the row is the same one the code writes.
const std::string name_read =
ed->name != nullptr ? gtk_editable_get_text(GTK_EDITABLE(ed->name)) : "-";
const std::string unit_read =
ed->unit != nullptr ? gtk_editable_get_text(GTK_EDITABLE(ed->unit)) : "-";
// The edit must not cost the buffer. The plan is a partition over the channels and an edit does not
// move it, so every channel keeps its history and only the edited graph -- and whatever reads it -- is
// recomputed. Before this, `set_math_channels()` rebuilt the store and every channel started again.
const std::uint64_t slots_before = a->model->stored_slots();
on_math_apply(nullptr, ed); // frees the dialog: nothing below may touch `ed`
const std::uint64_t slots_after = a->model->stored_slots();
std::fprintf(stderr, "[math-probe] graph edit: stored_slots %llu -> %llu (want the second at least the first, and non-zero)\n",
static_cast<unsigned long long>(slots_before),
static_cast<unsigned long long>(slots_after));
const std::size_t saved = a->math.empty()
? 0u
: a->math[static_cast<std::size_t>(index)].nodes.size();
const int output_saved =
a->math.empty() ? -1 : a->math[static_cast<std::size_t>(index)].output;
std::fprintf(stderr,
"[math-probe] dialog build: nodes=%zu, output after add=%d (want %zu); "
"reading back gain=%.2f offset=%.2f; name='%s' unit='%s'; "
"saved nodes=%zu output=%d\n",
built, output_after_add, built - 1, gain_read, offset_read, name_read.c_str(),
unit_read.c_str(), saved, output_saved);
}
}
// Closed and reopened: `gtk_window_destroy()` does not emit `close-request`, so a dialog closed
// that way left `app.math_dialog` pointing at a dead widget and every later open was a `present`
// on it -- "I cannot edit a math channel again". A close must clear the pointer, and the reopen
// must build a *new* dialog with its own data.
if (a->math_dialog != nullptr) gtk_window_close(GTK_WINDOW(a->math_dialog));
std::fprintf(stderr, "[math-probe] after close: dialog=%s\n",
a->math_dialog == nullptr ? "cleared" : "STILL SET (the pointer did not clear)");
open_math_dialog(*a, 0);
if (a->math_dialog != nullptr) {
auto* again = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
std::fprintf(stderr, "[math-probe] reopened: dialog=%s, nodes=%s\n",
again != nullptr ? "ok" : "NO DATA",
again != nullptr ? std::to_string(again->nodes.size()).c_str() : "-");
} else {
std::fprintf(stderr, "[math-probe] reopened: FAILED (no dialog)\n");
}
if (a->math_dialog != nullptr) {
auto* h = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (h != nullptr && !h->nodes.empty()) {
// The output square straddles the box's right edge: it is drawn centred on
// `n.x + kNodeW` with a 5 px half-width and hit with a 10 px radius. A hit test that
// recovers the node by a box test therefore misses every pixel past the edge, which
// is a *silent disarm* of the first click of a wiring gesture.
double ox = 0.0;
double oy = 0.0;
math_output_pos(h->nodes[0], ox, oy);
int w_in = 0;
const int n_in = math_port_at(h, ox - 2.0, oy, kPort, w_in);
int w_out = 0;
const int n_out = math_port_at(h, ox + 4.0, oy, kPort, w_out);
std::fprintf(stderr,
"[math-probe] hit: inside node=%d which=%d, past-edge node=%d which=%d "
"(want 0 %d 0 %d)\n",
n_in, w_in, n_out, w_out, kPortOut, kPortOut);
}
// Cancel must clear the pointer exactly as the close above does: it is the *button* that is
// clicked here, not the window, because the defect was in what the button was connected to.
if (a->math_dialog != nullptr) {
GtkWidget* cancel_button = GTK_WIDGET(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-cancel"));
if (cancel_button != nullptr) {
g_signal_emit_by_name(cancel_button, "clicked");
std::fprintf(stderr, "[math-probe] cancel: dialog=%s (want cleared)\n",
a->math_dialog == nullptr ? "cleared" : "STILL SET");
open_math_dialog(*a, 0);
} else {
std::fprintf(stderr, "[math-probe] cancel: NO BUTTON (the data key is not set)\n");
}
}
}
// The editor's two reported failures, driven directly. A drag reports the *total* offset from
// where the gesture began, so replaying a real sequence of them must land the box at start+last,
// not at the sum of the deltas; and a numeric field must hold a fraction.
if (a->math_dialog != nullptr) {
auto* d = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (d != nullptr && !d->nodes.empty()) {
const double x0 = d->nodes[0].x;
const double y0 = d->nodes[0].y;
math_canvas_drag_begin(nullptr, x0 + kNodeW * 0.5, y0 + kNodeH * 0.5, d);
std::fprintf(stderr,
"[math-probe] drag-begin at (%.0f,%.0f) over node %d, selected %d, nodes %zu\n",
x0 + kNodeW * 0.5, y0 + kNodeH * 0.5, d->drag_node, d->selected, d->nodes.size());
math_canvas_drag(nullptr, 20.0, 10.0, d);
math_canvas_drag(nullptr, 40.0, 20.0, d);
math_canvas_drag(nullptr, 60.0, 30.0, d);
std::fprintf(stderr, "[math-probe] drag: (%.0f,%.0f) -> (%.0f,%.0f), want (%.0f,%.0f)\n", x0,
y0, d->nodes[0].x, d->nodes[0].y, x0 + 60.0, y0 + 30.0);
}
if (d != nullptr && !d->nodes.empty()) {
// Only *acquired* channels may be a source: a disabled signal would read as no data for ever,
// and the stream's timestamp signal has no samples at all.
const std::vector<std::string> acquired = math_source_names(*a);
std::size_t listed = 0;
std::string first;
if (d->source_drop != nullptr) {
GListModel* model = gtk_drop_down_get_model(GTK_DROP_DOWN(d->source_drop));
listed = model != nullptr ? g_list_model_get_n_items(model) : 0;
if (listed > 0) {
GObject* item = G_OBJECT(g_list_model_get_item(model, 0));
first = gtk_string_object_get_string(GTK_STRING_OBJECT(item));
g_object_unref(item);
}
}
std::fprintf(stderr,
"[math-probe] sources: %zu acquired, dropdown lists %zu, first='%s'\n",
acquired.size(), listed, first.c_str());
// The gain+offset group belongs to one op and must be hidden for the others.
d->selected = 0;
const MathOp was = d->nodes[0].op;
d->nodes[0].op = MathOp::LowPass;
math_dialog_sync(d);
const int for_filter = gtk_widget_get_visible(d->rows[1]) ? 1 : 0;
d->nodes[0].op = MathOp::Gain;
math_dialog_sync(d);
const int for_gain = gtk_widget_get_visible(d->rows[1]) ? 1 : 0;
d->nodes[0].op = was;
math_dialog_sync(d);
std::fprintf(stderr, "[math-probe] rows: gain+offset visible for a filter=%d, for gain=%d "
"(want 0, 1)\n", for_filter, for_gain);
}
if (d != nullptr) {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->fc), 12.5);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->gain), 0.25);
std::fprintf(stderr, "[math-probe] values: fc=%.4f gain=%.4f, want 12.5000 0.2500\n",
gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->fc)),
gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->gain)));
}
}
if (a->math_dialog != nullptr) {
auto* v = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (v != nullptr && !v->nodes.empty()) {
// A wheel zoom must keep the graph point under the pointer under the pointer: that is the
// one thing a zoom can get wrong without looking broken, and it reads as the canvas
// sliding away from the hand.
const double px = 180.0;
const double py = 140.0;
double gx0 = 0.0;
double gy0 = 0.0;
math_to_graph(v, px, py, gx0, gy0);
math_zoom_at(v, px, py, -1.0);
math_zoom_at(v, px, py, -1.0);
double gx1 = 0.0;
double gy1 = 0.0;
math_to_graph(v, px, py, gx1, gy1);
std::fprintf(stderr,
"[math-probe] view: zoom scale=%.3f anchor kept dx=%.4f dy=%.4f "
"(want 0.0000 0.0000)\n",
v->view_scale, gx1 - gx0, gy1 - gy0);
// And the hit tests must go through the inverse. At the identity view a test that forgot
// it passes anyway, so the port is probed *after* a zoom and a pan -- which is the only
// arrangement that can fail.
v->view_x += 37.0;
v->view_y -= 19.0;
double ox = 0.0;
double oy = 0.0;
math_output_pos(v->nodes[0], ox, oy);
const double cx = ox * v->view_scale + v->view_x;
const double cy = oy * v->view_scale + v->view_y;
int which = 0;
const int node = math_port_at_canvas(v, cx + 3.0, cy, which);
std::fprintf(stderr,
"[math-probe] view: pan+zoom hit node=%d which=%d (want 0 %d)\n", node,
which, kPortOut);
math_view_fit(v);
int inside = 1;
int w_px = gtk_widget_get_width(v->canvas);
int h_px = gtk_widget_get_height(v->canvas);
if (w_px <= 0) w_px = 420;
if (h_px <= 0) h_px = 300;
for (const MathNode& n : v->nodes) {
const double x0 = n.x * v->view_scale + v->view_x;
const double y0 = n.y * v->view_scale + v->view_y;
const double x1 = (n.x + kNodeW) * v->view_scale + v->view_x;
const double y1 = (n.y + kNodeH) * v->view_scale + v->view_y;
if (x0 < -0.5 || y0 < -0.5 || x1 > w_px + 0.5 || y1 > h_px + 0.5) inside = 0;
}
std::fprintf(stderr, "[math-probe] view: fit scale=%.3f all_inside=%d (want 1)\n",
v->view_scale, inside);
math_view_reset(v);
std::fprintf(stderr,
"[math-probe] view: reset scale=%.3f offset=(%.1f,%.1f) "
"(want 1.000 (0.0,0.0))\n",
v->view_scale, v->view_x, v->view_y);
}
}
if (a->math_dialog != nullptr) {
auto* g = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
// The editor's two graphs are source -> gain, so node 1 has one input and node 0 an output.
if (g != nullptr && g->nodes.size() >= 2) {
// Set a non-identity view (scale=2, x=50, y=20) before the gesture probes so that a
// transform bug -- e.g. passing canvas coords straight to math_port_at() without
// math_to_graph() -- would produce wrong hit results. At the identity view canvas coords equal
// graph coords, making such a bug invisible. The trailing math_view_reset() below tidies up.
math_set_scale(g, 2.0);
g->view_x = 50.0;
g->view_y = 20.0;
const auto canvas_of = [&](double gx, double gy, double& cx, double& cy) {
cx = gx * g->view_scale + g->view_x;
cy = gy * g->view_scale + g->view_y;
};
double ox = 0.0;
double oy = 0.0;
double ix = 0.0;
double iy = 0.0;
math_output_pos(g->nodes[0], ox, oy);
math_input_pos(g->nodes[1], 0, ix, iy);
double ocx = 0.0;
double ocy = 0.0;
double icx = 0.0;
double icy = 0.0;
canvas_of(ox, oy, ocx, ocy);
canvas_of(ix, iy, icx, icy);
// Connect: press the output, release over the input.
g->nodes[1].in_a = -1;
math_canvas_drag_begin(nullptr, ocx, ocy, g);
const int mode_wire = static_cast<int>(g->drag_mode);
math_canvas_drag(nullptr, icx - ocx, icy - ocy, g);
math_canvas_drag_end(nullptr, icx - ocx, icy - ocy, g);
std::fprintf(stderr,
"[math-probe] wire: connect mode=%d in_a=%d (want %d 0)\n", mode_wire,
g->nodes[1].in_a, static_cast<int>(MathDrag::Wire));
// Detach: a drag that starts on a *wired* input empties it at once, so a release over
// nothing is the detach. Dropping it back is what the connect above already covers.
math_canvas_drag_begin(nullptr, icx, icy, g);
const int emptied = g->nodes[1].in_a;
math_canvas_drag(nullptr, 0.0, 90.0, g);
math_canvas_drag_end(nullptr, 0.0, 90.0, g);
std::fprintf(stderr,
"[math-probe] detach: emptied_at_begin=%d in_a=%d (want -1 -1)\n", emptied,
g->nodes[1].in_a);
// And Escape during the same gesture must put it back.
g->nodes[1].in_a = 0;
math_canvas_drag_begin(nullptr, icx, icy, g);
math_canvas_drag(nullptr, 0.0, 90.0, g);
math_canvas_key(nullptr, GDK_KEY_Escape, 0, static_cast<GdkModifierType>(0), g);
std::fprintf(stderr, "[math-probe] detach: escape restores in_a=%d (want 0)\n",
g->nodes[1].in_a);
// Detach and release in place: a wire detached from an input and released at the same canvas
// position must reconnect. With Finding 1 unfixed the release point is computed from the wrong
// origin (the source node's output port rather than the press point), so this reads in_a=-1.
g->nodes[1].in_a = 0;
math_canvas_drag_begin(nullptr, icx, icy, g);
math_canvas_drag(nullptr, 0.0, 0.0, g);
math_canvas_drag_end(nullptr, 0.0, 0.0, g);
std::fprintf(stderr, "[math-probe] detach: release in place reconnects in_a=%d (want 0)\n",
g->nodes[1].in_a);
// A drag that begins on the background pans and moves nothing.
const double was_x = g->view_x;
const double node_x = g->nodes[0].x;
math_canvas_drag_begin(nullptr, 5.0, 5.0, g);
const int mode_pan = static_cast<int>(g->drag_mode);
math_canvas_drag(nullptr, 30.0, 0.0, g);
math_canvas_drag_end(nullptr, 30.0, 0.0, g);
std::fprintf(stderr,
"[math-probe] view: pan mode=%d dview=%.1f dnode=%.1f (want %d 30.0 0.0)\n",
mode_pan, g->view_x - was_x, g->nodes[0].x - node_x,
static_cast<int>(MathDrag::Pan));
math_view_reset(g);
}
}
if (a->math_dialog != nullptr) {
auto* p = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (p != nullptr && p->nodes.size() >= 2) {
math_set_scale(p, 2.0);
p->view_x = 50.0;
p->view_y = 20.0;
p->nodes[1].in_a = 0;
// The two boxes are put several rows apart on purpose: that is the geometry a tidied graph
// has, and it is the only one in which the pick can fail. Where consecutive curve samples are
// close together, a hit test that measures to the sample *points* and one that measures to the
// *segments* between them give the same answer, so a probe taken on a short wire asserts
// nothing about either.
p->nodes[0].x = 24.0;
p->nodes[0].y = 20.0;
p->nodes[1].x = 260.0;
p->nodes[1].y = 300.0;
double ax = 0.0;
double ay = 0.0;
double bx = 0.0;
double by = 0.0;
math_output_pos(p->nodes[0], ax, ay);
math_input_pos(p->nodes[1], 0, bx, by);
// **Between two samples, not at one.** `math_wire_at()` samples the cubic at `s / 24`, so
// `t = 0.5` is exactly sample 12 and a point-distance test passes there by construction --
// the same mistake as a hit test taken at the identity view. `t = 0.52` falls between samples
// 12 and 13, and `off_sample` below is the measured distance from the pick to the nearest of
// the 25 sample points, in graph units, beside the tolerance the pick is allowed: it reads
// off_sample=8.4 against tol=3.0, so a point test misses and only a segment test can hit.
const auto curve_at = [&](double t, double& px, double& py) {
const double u = 1.0 - t;
px = u * u * u * ax + 3 * u * u * t * (ax + 40.0) + 3 * u * t * t * (bx - 40.0) +
t * t * t * bx;
py = u * u * u * ay + 3 * u * u * t * ay + 3 * u * t * t * by + t * t * t * by;
};
const double t = 0.52;
double mx = 0.0;
double my = 0.0;
curve_at(t, mx, my);
double off_sample = 1e300;
for (int s = 0; s <= 24; ++s) {
double sx = 0.0;
double sy = 0.0;
curve_at(static_cast<double>(s) / 24.0, sx, sy);
off_sample = std::min(off_sample, std::max(std::fabs(mx - sx), std::fabs(my - sy)));
}
const MathWireRef hit = math_wire_at_canvas(p, mx * p->view_scale + p->view_x,
my * p->view_scale + p->view_y);
p->selected_wire = hit;
math_canvas_key(nullptr, GDK_KEY_Delete, 0, static_cast<GdkModifierType>(0), p);
std::fprintf(stderr,
"[math-probe] wire: pick node=%d which=%d -> in_a=%d off_sample=%.1f tol=%.1f "
"(want 1 0 -1, off_sample > tol)\n",
hit.node, hit.which, p->nodes[1].in_a, off_sample,
kWirePick / p->view_scale);
p->nodes[1].in_a = 0;
p->selected_wire = MathWireRef{-1, 0};
}
}
if (a->math_dialog != nullptr) {
auto* c = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (c != nullptr && c->nodes.size() >= 2) {
// A wire drawn right-to-left is legal: the editor re-sorts so that the reader still sits
// after what it reads. Node 0 is the source and node 1 the gain, so wiring the gain into
// the source is impossible (a source has no inputs) -- a second gain reading the first,
// placed *before* it, is the case.
c->nodes.clear();
MathNode src;
src.op = MathOp::Source;
src.source = name;
// The reader is placed *before* what it reads, which is the case the evaluator cannot run
// and the editor has to repair. Index 1 will be wired to read index 2, and index 2 reads
// the source.
MathNode reader;
reader.op = MathOp::Gain;
MathNode feeder;
feeder.op = MathOp::Gain;
c->nodes = {src, reader, feeder};
c->nodes[2].in_a = 0;
// The output is the reader, so the remap is visible in `output` as well as in the wiring.
c->output = 1;
c->selected = 1;
std::string why;
const bool ok = math_connect(c, 2, 1, 0, &why);
// After the sort node 1 must still be the one that reads 0, and the reader of it must sit
// after it. The op is the same for both, so the *wiring* is the reading.
std::string order;
for (std::size_t i = 0; i < c->nodes.size(); ++i) {
order += (i != 0 ? "," : "");
order += std::to_string(i) + ":" + std::to_string(c->nodes[i].in_a);
}
std::string valid_why;
const bool valid = math_channel_valid_nodes(c->nodes, c->output, &valid_why);
std::fprintf(stderr,
"[math-probe] wire: backwards ok=%d order=[%s] output=%d valid=%d why=[%s] "
"(want 1 ... 1 [])\n",
ok ? 1 : 0, order.c_str(), c->output, valid ? 1 : 0, valid_why.c_str());
std::string self_why;
const bool self_ok = math_connect(c, 1, 1, 0, &self_why);
std::fprintf(stderr, "[math-probe] wire: self refused=%d why=[%s] (want 1)\n",
self_ok ? 0 : 1, self_why.c_str());
// The same refusal again, through the **handler**. The line above asserts only
// `math_connect()`'s out-parameter: the UI path used to screen the self case out before the
// call and then drop `why` on the floor, so a user drew the wire, nothing appeared and nothing
// was said -- which is the whole of "the editor is broken" -- while the direct call passed.
// This half asserts what the *validation line* reads after `drag-end`, which is the only place
// that defect is visible. A synthetic GTK gesture is not reachable headlessly, so the drag's
// state is set up by hand and the real handler called: `press_cx`/`press_cy` plus a zero offset
// is a release exactly on the input port of `wire_from` itself.
for (std::size_t i = 0; i < c->nodes.size(); ++i) {
// Spread out, so the release point belongs to one port and not to three stacked at (0,0).
c->nodes[i].x = 24.0 + 200.0 * static_cast<double>(i);
c->nodes[i].y = 20.0;
}
math_set_scale(c, 1.0);
c->view_x = 0.0;
c->view_y = 0.0;
int self_node = -1;
for (std::size_t i = 0; i < c->nodes.size() && self_node < 0; ++i) {
if (math_op_arity(c->nodes[i].op) >= 1) self_node = static_cast<int>(i);
}
if (self_node >= 0) {
double sx = 0.0;
double sy = 0.0;
math_input_pos(c->nodes[static_cast<std::size_t>(self_node)], 0, sx, sy);
c->drag_mode = MathDrag::Wire;
c->wire_from = self_node;
c->wire_detach = -1;
c->press_cx = sx;
c->press_cy = sy;
math_canvas_drag_end(nullptr, 0.0, 0.0, c);
const char* line = gtk_label_get_text(GTK_LABEL(c->problem));
std::fprintf(stderr,
"[math-probe] wire: self through drag-end node=%d problem=[%s] "
"(want the refusal text, not empty)\n",
self_node, line != nullptr ? line : "(null)");
}
// A cycle: the node that already feeds the other is asked to read it back.
const int reader_idx = c->nodes[1].in_a == 0 ? 2 : 1;
const int feeder_idx = reader_idx == 2 ? 1 : 2;
std::string cyc_why;
const bool cyc_ok = math_connect(c, reader_idx, feeder_idx, 0, &cyc_why);
std::fprintf(stderr, "[math-probe] wire: cycle refused=%d why=[%s] (want 1)\n",
cyc_ok ? 0 : 1, cyc_why.c_str());
}
}
if (a->math_dialog != nullptr) {
auto* v2 = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (v2 != nullptr && v2->nodes.size() >= 2) {
// Broken: an unwired input. The line must say so and Apply must be insensitive -- the old
// behaviour was a modal alert *after* the press, which is a refusal rather than guidance.
v2->nodes[1].in_a = -1;
math_dialog_sync(v2);
const std::string bad = math_dialog_problem(v2);
const int bad_sensitive = gtk_widget_get_sensitive(v2->apply) ? 1 : 0;
// Copied before the next sync call overwrites the label's internal string.
const std::string bad_text_copy =
gtk_label_get_text(GTK_LABEL(v2->problem)) != nullptr
? std::string(gtk_label_get_text(GTK_LABEL(v2->problem)))
: std::string("(null)");
v2->nodes[1].in_a = 0;
math_dialog_sync(v2);
const std::string good = math_dialog_problem(v2);
const int good_sensitive = gtk_widget_get_sensitive(v2->apply) ? 1 : 0;
std::fprintf(stderr,
"[math-probe] validate: broken problem=[%s] label=[%s] apply=%d; "
"fixed problem=[%s] apply=%d (want non-empty 0; empty 1)\n",
bad.c_str(), bad_text_copy.c_str(), bad_sensitive,
good.c_str(), good_sensitive);
// The selection out of range is the case `math_dialog_sync()` returns early on, so the
// line must be written above that return or it goes stale exactly when it matters.
const int was_sel = v2->selected;
v2->selected = 999;
v2->nodes[1].in_a = -1;
math_dialog_sync(v2);
const char* stale = gtk_label_get_text(GTK_LABEL(v2->problem));
std::fprintf(stderr, "[math-probe] validate: no-selection label=[%s] (want non-empty)\n",
stale != nullptr ? stale : "(null)");
v2->selected = was_sel;
v2->nodes[1].in_a = 0;
math_dialog_sync(v2);
// And the source dropdown must list the *acquired* channels, which is what Apply checks
// against: listing every signal let a user pick one that could never be made valid.
const std::vector<std::string> acquired = math_source_names(*a);
std::size_t signals_n = 0;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
signals_n = a->model->signals().size();
}
v2->selected = 0;
v2->nodes[0].op = MathOp::Source;
math_dialog_sync(v2);
GListModel* lm = gtk_drop_down_get_model(GTK_DROP_DOWN(v2->source_drop));
const guint listed = lm != nullptr ? g_list_model_get_n_items(lm) : 0u;
std::fprintf(stderr,
"[math-probe] validate: source list=%u acquired=%zu signals=%zu "
"(want list==acquired)\n",
listed, acquired.size(), signals_n);
v2->selected = was_sel;
}
}
if (a->math_dialog != nullptr) {
auto* o = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (o != nullptr) {
const std::size_t before = o->nodes.size();
math_add_node(o, MathOp::BandPass);
const int op_added = o->nodes.empty() ? -1 : math_op_index(o->nodes.back().op);
// The op picker must make the node the user asked for, and the `rows` the form shows must
// follow it: a band pass is the one op that shows the Q row, so it is the case worth the
// check.
const int q_row = gtk_widget_get_visible(o->rows[3]) ? 1 : 0;
std::fprintf(stderr,
"[math-probe] op pick: nodes %zu->%zu op_index=%d q_row=%d "
"(want %zu %d 1)\n",
before, o->nodes.size(), op_added, q_row, before + 1,
math_op_index(MathOp::BandPass));
// And the identity the whole picker rests on: a round trip through both accessors.
int round = 1;
for (int i = 0; i < math_op_count(); ++i) {
if (math_op_index(math_op_at(i)) != i) round = 0;
}
std::fprintf(stderr, "[math-probe] op pick: index round trip=%d (want 1)\n", round);
// And placement: the first *free* grid cell, not cell `nodes.size()`. The boxes are put back
// on the grid, the middle one deleted and one added -- under the old placement the new box
// landed on the cell the deleted node's *successor* had moved out of the count for, i.e. on an
// occupied one, and `math_node_at()` searches topmost-first, so the covered box becomes
// unclickable. `cells=` is where the boxes ended up; `overlaps` is the assertion.
if (o->nodes.size() >= 3) {
for (std::size_t i = 0; i < o->nodes.size(); ++i) {
o->nodes[i].x = 24.0 + 160.0 * static_cast<double>(i % 2u);
o->nodes[i].y = 20.0 + 58.0 * static_cast<double>(i / 2u);
}
o->selected = 1;
on_math_node_deleted(nullptr, o);
math_add_node(o, MathOp::Gain);
int overlaps = 0;
std::string cells;
for (std::size_t i = 0; i < o->nodes.size(); ++i) {
const MathNode& p2 = o->nodes[i];
cells += (i != 0 ? " " : "");
cells += "(" + std::to_string(static_cast<int>(p2.x)) + "," +
std::to_string(static_cast<int>(p2.y)) + ")";
for (std::size_t j = i + 1; j < o->nodes.size(); ++j) {
const MathNode& q2 = o->nodes[j];
if (p2.x < q2.x + kNodeW && q2.x < p2.x + kNodeW && p2.y < q2.y + kNodeH &&
q2.y < p2.y + kNodeH) {
++overlaps;
}
}
}
std::fprintf(stderr,
"[math-probe] add node: after a delete cells=[%s] overlaps=%d (want 0)\n",
cells.c_str(), overlaps);
}
}
}
if (a->math_dialog != nullptr) {
auto* ty = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (ty != nullptr && ty->nodes.size() >= 2) {
// Pile every node on one spot, so a layout that did nothing would be visible.
for (MathNode& n : ty->nodes) {
n.x = 10.0;
n.y = 10.0;
}
math_tidy(ty);
// No two boxes may overlap -- `math_node_at()` searches topmost-first, so an overlap means
// a click or a drag lands on the wrong node, which reads as the dialog ignoring the pointer.
int overlaps = 0;
std::string xs;
for (std::size_t i = 0; i < ty->nodes.size(); ++i) {
xs += (i != 0 ? "," : "") + std::to_string(static_cast<int>(ty->nodes[i].x));
for (std::size_t j = i + 1; j < ty->nodes.size(); ++j) {
const MathNode& p = ty->nodes[i];
const MathNode& q = ty->nodes[j];
if (p.x < q.x + kNodeW && q.x < p.x + kNodeW && p.y < q.y + kNodeH &&
q.y < p.y + kNodeH) {
++overlaps;
}
}
}
std::fprintf(stderr, "[math-probe] tidy: xs=[%s] overlaps=%d (want 0)\n", xs.c_str(),
overlaps);
}
}
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean measure_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
std::string name;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
// A *channel*, not `signals().front()`: the first signal of a stream is its timestamp, which
// is not acquired and has no samples to measure. That mistake made the probe add four
// measurements that all read `--` -- and, because a measurement with no channel bails before
// collecting anything, it also made the first baseline look free.
if (a->model->channels().empty()) return G_SOURCE_REMOVE;
name = a->model->channels().front().name;
}
for (const MeasureType t : {MeasureType::Average, MeasureType::Top, MeasureType::PeakToPeak,
MeasureType::Amplitude}) {
MeasureOptions options;
// The marks are what a wrong window shows up in, so the probe asks for them.
options.show_on_plot = measure_can_show_on_plot(t);
a->measures.push_back({name, t, options});
}
rebuild_measures_pane(*a);
update_measures(*a, /*force=*/true, /*accumulate=*/true);
std::fprintf(stderr, "[measure-probe] %zu measurements on '%s'\n", a->measures.size(),
name.c_str());
std::fflush(stderr);
g_timeout_add(250, measure_trace, a);
return G_SOURCE_REMOVE;
}
gboolean stats_probe_report(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
std::fprintf(stderr, "[stats-probe] phase=%d on=%d passes=%" G_GUINT64_FORMAT,
g_stats_phase, int(a->measure_stats_on),
static_cast<std::uint64_t>(a->measure_passes));
for (std::size_t i = 0; i < a->measure_stats.size(); ++i) {
const MeasureStatsAccum& s = a->measure_stats[i];
std::fprintf(stderr, " | n=%" G_GUINT64_FORMAT " mean=%.6g sd=%.6g lo=%.6g hi=%.6g", s.n,
s.mean, s.sd(), s.lo, s.hi);
if (g_stats_phase == 1 && i < g_stats_n_before.size()) {
std::fprintf(stderr, " dn=%" G_GUINT64_FORMAT, s.n - g_stats_n_before[i]);
}
}
std::fprintf(stderr, "\n");
std::fflush(stderr);
if (g_stats_phase == 0) {
// Hold a pause and come back: `dn` on the next line is the assertion.
g_stats_n_before.clear();
for (const MeasureStatsAccum& s : a->measure_stats) g_stats_n_before.push_back(s.n);
g_stats_phase = 1;
if (a->pause_button != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->pause_button), TRUE);
}
g_timeout_add(2000, stats_probe_report, a);
return G_SOURCE_REMOVE;
}
if (g_stats_phase == 1) {
// Resume -- a resume must not reset, so phase 2's `n` starts from phase 1's -- then reset
// explicitly, which must.
if (a->pause_button != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->pause_button), FALSE);
}
g_stats_phase = 2;
reset_measure_stats(*a);
update_measures(*a, /*force=*/true, /*accumulate=*/true);
g_timeout_add(50, stats_probe_report, a);
}
return G_SOURCE_REMOVE;
}
gboolean stats_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (a->measures_stats_check != nullptr) {
gtk_check_button_set_active(GTK_CHECK_BUTTON(a->measures_stats_check), TRUE);
}
g_stats_phase = 0;
// Long enough for several throttled recomputes (one per 100 ms while the window moves).
g_timeout_add(2000, stats_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;
RowSet::Row& r0 = a->rows->row(0);
if (r0.plot == nullptr) return G_SOURCE_REMOVE;
const plot::Range before = r0.plot->x_range(r0.axis_x);
const double span = before.span();
if (!(span > 0.0)) return G_SOURCE_REMOVE;
// Where the data is not: ten windows to the left, the same order of magnitude a drag moves.
const plot::Range panned{before.min - 10.0 * span, before.max - 10.0 * span};
r0.plot->set_x_range(r0.axis_x, panned);
std::fprintf(stderr, "[pan-probe] asked=[%.6f..%.6f] was=[%.6f..%.6f]\n", panned.min,
panned.max, before.min, before.max);
std::fflush(stderr);
a->dbg_pan_asked_lo = panned.min;
a->dbg_pan_asked_hi = panned.max;
a->dbg_gesture_verdict = 1;
return G_SOURCE_REMOVE;
}
gboolean arm_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
// The toggle itself, not a label search: the header bar's buttons carry icons now,
// so there is no text to find them by.
if (a->trigger_toggle != nullptr)
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
std::fprintf(stderr, "[arm-probe] trigger armed\n");
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean trigrenum_probe_step3(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (a->model->trigger_latches().load(std::memory_order_relaxed) == g_trigrenum_latches)
return G_SOURCE_CONTINUE;
const double latched = a->model->trigger_time().load(std::memory_order_relaxed);
std::fprintf(stderr, "[trigrenum-probe] latched t0=%.4f opened on %.4f t0_held=%d\n", latched,
g_trigrenum_edge, int(std::fabs(latched - g_trigrenum_edge) < 1.0e-6));
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean trigrenum_probe_step2(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (!a->model->trigger_pending()) return G_SOURCE_CONTINUE;
g_trigrenum_edge = a->model->capture_edge();
g_trigrenum_latches = a->model->trigger_latches().load(std::memory_order_relaxed);
int ch = -1;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
ch = a->model->trigger().channel;
}
std::fprintf(stderr, "[trigrenum-probe] capture open: t0=%.4f ch=%d -- enabling signal 1 now\n",
g_trigrenum_edge, ch);
std::fflush(stderr);
a->model->set_signal_enabled(1, true);
{
std::lock_guard<std::mutex> lock(a->model->mutex());
ch = a->model->trigger().channel;
}
std::fprintf(stderr, "[trigrenum-probe] after enable: trigger ch=%d channels=%zu\n", ch,
a->model->channel_count());
std::fflush(stderr);
g_timeout_add(25, trigrenum_probe_step3, a);
return G_SOURCE_REMOVE;
}
gboolean trigrenum_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->window_entry == nullptr) return G_SOURCE_REMOVE;
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->window_unit_drop), 2);
gtk_editable_set_text(GTK_EDITABLE(a->window_entry), "1");
on_window_entry_activate(GTK_ENTRY(a->window_entry), a);
// Signal 2 is the mock's square; signal 1 is its sine. Only the square to begin with.
a->model->set_signal_enabled(1, false);
a->model->set_signal_enabled(2, true);
a->model->set_signal_enabled(3, false);
if (a->model->channel_count() > 0) {
const ChannelInfo& c0 = a->model->channels()[0];
a->rows->assign(0, 0, c0.name, c0.name,
signal_color_index(a->model->signals(), c0.signal));
a->relayout_pending = true;
}
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_pre_spin), 10.0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level_spin), 0.0);
publish_trigger_fields(a);
if (a->trigger_toggle != nullptr)
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
{
std::lock_guard<std::mutex> lock(a->model->mutex());
const TriggerConfig& tc = a->model->trigger();
std::fprintf(stderr,
"[trigrenum-probe] window=%.4f event_span=%.4f pre_pct=%.1f post=%.4f "
"holdoff=%.4f level=%.4g edge=%d mode=%d enabled=%d armed=%d ch=%d "
"channels=%zu\n",
a->rows->time_window(), a->model->event_span(), tc.pretrigger_pct,
tc.post_span_s(a->model->event_span()), tc.holdoff_s, tc.condition.level,
int(tc.condition.edge),
int(tc.mode), int(tc.enabled), int(tc.armed), tc.channel,
a->model->channel_count());
}
std::fflush(stderr);
g_timeout_add(25, trigrenum_probe_step2, a);
return G_SOURCE_REMOVE;
}
gboolean fields_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
GtkDropDown* mode = GTK_DROP_DOWN(a->trigger_mode_drop);
GtkDropDown* edge = GTK_DROP_DOWN(a->trigger_edge_drop);
gtk_drop_down_set_selected(mode, gtk_drop_down_get_selected(mode) == 0 ? 1 : 0);
gtk_drop_down_set_selected(edge, (gtk_drop_down_get_selected(edge) + 1) % 3);
std::lock_guard<std::mutex> lock(a->model->mutex());
std::fprintf(stderr, "[fields-probe] mode=%u edge=%u published mode=%d edge=%d capturing=%d\n",
gtk_drop_down_get_selected(mode), gtk_drop_down_get_selected(edge),
int(a->model->trigger().mode), int(a->model->trigger().condition.edge),
int(a->model->trigger_pending()));
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
static const char* condition_probe_channel(int shape) {
return (shape == 2 || shape == 3) ? "sine" : "square";
}
gboolean condition_probe_report(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const double t0 = a->model->trigger_time();
// A finite `t0` only says the edge was *accepted*: the mode is `Capturing` until the post
// window fills. Every field below is read from `display_snapshot()`, whose stamps span x = 0
// only once the event is `Held`, so reporting on the latch alone races the capture and
// prints `nan` for a leg that is working. Both waits share the one poll budget.
if (!std::isfinite(t0) || t0 <= 0.0 || a->model->display_mode() != DisplayMode::Held) {
if (++g_condition.waited < 200) return G_SOURCE_CONTINUE;
const bool latched = std::isfinite(t0) && t0 > 0.0;
std::fprintf(stderr, "[condition-probe] %s after %d polls\n",
latched ? "latched but never held" : "no latch", g_condition.waited);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
const double kNan = std::numeric_limits<double>::quiet_NaN();
const double level = a->model->trigger().condition.level;
const double level2 = a->model->trigger().condition.level2;
const std::size_t trig_ch = static_cast<std::size_t>(a->model->trigger().channel);
// Shape 2 reports two sample values rather than a measured width.
double at0 = kNan;
double earlier = kNan;
double measured = kNan;
if (std::shared_ptr<const EventSnapshot> snap = a->model->display_snapshot()) {
if (snap != nullptr && trig_ch < snap->values.size()) {
const std::vector<f64>& st = snap->stamps;
const std::vector<f32>& vals = snap->values[trig_ch];
if (vals.size() == st.size() && !st.empty()) {
// Index of the last stamp <= 0 (the latching event).
const auto it = std::upper_bound(st.begin(), st.end(), 0.0);
const std::size_t end = static_cast<std::size_t>(it - st.begin());
if (g_condition.shape == 2) {
// Window leg: x=0 is the moment the signal left the band (falling / out-of-band edge).
// `at0` is the sample at x=0: must be outside [lo, hi], i.e. |at0| >= 0.5.
// `earlier` is the sample immediately before x=0: must be inside, i.e. |earlier| < 0.5.
// Using the adjacent sample makes the assertion exact and independent of snapshot size.
if (end >= 1) at0 = static_cast<double>(vals[end - 1]);
if (end >= 2) earlier = static_cast<double>(vals[end - 2]);
} else {
// Pulse legs: scan backwards for the most recent upward crossing
// (prev < level, value >= level) and negate the stamp to get a duration.
// Dropout leg: accept a crossing in either direction because the last sine crossing
// before the quiet period may be downward, and an upward-only scan would miss it.
for (std::size_t i = end; i >= 2; --i) {
const double prev = static_cast<double>(vals[i - 2]);
const double val = static_cast<double>(vals[i - 1]);
const bool crossed = (g_condition.shape == 3)
? ((prev < level) != (val < level)) // either direction
: (prev < level && val >= level); // upward only
if (crossed) {
measured = -st[i - 1];
break;
}
}
}
}
}
}
std::fprintf(stderr,
"[condition-probe] type=%d cmp=%d width=%.6f t0=%.6f measured=%.6f "
"at0=%.6f earlier=%.6f level2=%.6f mode=%d\n",
int(a->model->trigger().condition.type), int(a->model->trigger().condition.cmp),
a->model->trigger().condition.width_s, t0, measured, at0, earlier, level2,
int(a->model->display_mode()));
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean condition_probe_arm(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
// Legs 0 and 2 need only a short snapshot (the event is one sample or one glitch-width back).
// Legs 1 and 3 need a 1.0 s window with 50% pre so the opening edge of a 0.2 s pulse and
// the last sine crossing before a 0.3 s timeout are both captured.
const bool wide = (g_condition.shape == 1 || g_condition.shape == 3);
const double win_s = wide ? 1.0 : 0.2;
const double pre_pct = wide ? 50.0 : 10.0;
apply_time_window(*a, win_s);
const std::vector<ChannelInfo>& channels = a->model->channels();
int sel = -1;
const bool dropout = g_condition.shape == 3;
const char* want_name = condition_probe_channel(g_condition.shape);
for (std::size_t i = 0; i < channels.size(); ++i) {
if (channels[i].name == want_name) sel = static_cast<int>(i);
}
std::fprintf(stderr, "[condition-probe] %s=%d of %zu channels\n", want_name, sel,
channels.size());
if (sel >= 0) {
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_signal_drop),
static_cast<guint>(sel));
}
// Shape 2 is `window`, which the dropdown numbers 2; the two pulse-width legs share type 1
// and differ only in the comparison.
const guint type = g_condition.shape == 2 ? 2u : (dropout ? 3u : 1u);
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_type_drop), type);
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_cmp_drop),
g_condition.shape == 1 ? 1u : 0u); // `>` for leg 1, `<` otherwise
// Window leg uses `falling` (index 1), meaning out-of-band, which makes the latch deterministic:
// x=0 is always a band exit, so at0 is outside and the previous sample is inside.
// Dropout has no direction (index 2, `both`). Pulse-width legs use `rising` (index 0).
const guint edge_idx = (g_condition.shape == 2) ? 1u : (dropout ? 2u : 0u);
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_edge_drop), edge_idx);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_width_spin), 0.010);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_timeout_spin), g_condition.timeout);
// The band for the window leg: straddling zero and well inside the sine's +/-1, so the sine
// crosses into it and out of it twice a period. Harmless for the other legs, which ignore
// `level2`.
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level2_spin), 0.5);
// Zero for every leg but the window, whose band is `[-0.5, +0.5]`.
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level_spin),
g_condition.shape == 2 ? -0.5 : 0.0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_pre_spin), pre_pct);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_holdoff_spin), 0.0);
publish_trigger_fields(a);
if (a->trigger_toggle != nullptr)
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
g_condition.waited = 0;
g_timeout_add(25, condition_probe_report, a);
return G_SOURCE_REMOVE;
}
gboolean condition_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (const char* s = g_getenv("UDPSCOPE_PROBE_CONDITION_SHAPE")) {
g_condition.shape = std::atoi(s);
}
// `square` is not acquired until it is enabled, and the trigger can only name a channel
// that is: enabling is what starts acquisition.
const char* want = condition_probe_channel(g_condition.shape);
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == want) a->model->set_signal_enabled(i, true);
}
// One beat for the channel list to be rebuilt and the ring to refill behind the glitch.
g_timeout_add(1500, condition_probe_arm, a);
return G_SOURCE_REMOVE;
}
gboolean search_probe_marks(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const plot::Range view = a->rows->view_range();
std::size_t in_view = 0;
const double s0 = a->model->stream_at_x0();
for (const double h : a->search.hits()) {
const double x = h - s0;
if (x >= view.min && x <= view.max) ++in_view;
}
std::fprintf(stderr,
"[search-probe] marks placed=%d of %zu in view (%zu found) locked=%d mode=%d "
"axis=[%.4f..%.4f]\n",
a->rows->search_marks_placed(), in_view, a->search.hits().size(),
a->rows->readouts_locked() ? 1 : 0, int(a->model->display_mode()), view.min,
view.max);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean search_probe_report(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const BufferSearch& s = a->search;
if (s.state() == BufferSearch::State::Running) return G_SOURCE_CONTINUE;
double nearest = 0.0;
double err = 0.0;
bool have = false;
for (const double h : s.hits()) {
const double d = std::fabs(h - g_search_t0);
if (!have || d < std::fabs(err)) {
have = true;
nearest = h;
err = h - g_search_t0;
}
}
// One stored sample period is the tolerance: the trigger saw the stored (filtered) stream
// and so does the scan, so they must agree to the sample, not merely to the neighbourhood.
const double tol = a->model->partition().stored_rate() > 0.0
? 1.0 / a->model->partition().stored_rate()
: 1.0e-3;
std::fprintf(stderr,
"[search-probe] state=%d hits=%zu scanned=%.4f total=%.4f chunks=%" G_GUINT64_FORMAT
" worst=%.3fms total=%.3fms armed t0=%.6f vs scanned first=%.6f err=%.6g agree=%d\n",
int(s.state()), s.hits().size(), s.scanned_s(), s.total_s(), s.chunks(),
s.worst_chunk_ms(), s.total_ms(), g_search_t0, have ? nearest : 0.0, err,
int(have && std::fabs(err) <= tol));
if (!s.hits().empty()) {
a->search.select(0);
search_jump(*a, a->search.hits()[0]);
update_search_panel(*a);
const plot::Range view = a->rows->view_range();
std::fprintf(stderr,
"[search-probe] jumped to hit 0 t=%.6f mode=%d axis=[%.4f..%.4f] pos=%d\n",
a->search.hits()[0], int(a->model->display_mode()), view.min, view.max,
a->search.cursor() + 1);
g_timeout_add(250, search_probe_marks, a);
}
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean search_probe_run(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
g_search_t0 = a->model->trigger_time();
EventCondition c;
int channel = -1;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
c = a->model->trigger().condition;
channel = a->model->trigger().channel;
}
a->search.start(c, channel);
std::fprintf(stderr, "[search-probe] started on channel %d, armed t0=%.6f\n", channel,
g_search_t0);
std::fflush(stderr);
g_timeout_add(100, search_probe_report, a);
return G_SOURCE_REMOVE;
}
gboolean search_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
// Reuse the condition probe's arming entirely: the point is that one condition gives two
// evaluators the same answer, so the condition must be set in exactly one place.
condition_probe(a);
g_timeout_add(6000, search_probe_run, a);
return G_SOURCE_REMOVE;
}
gboolean window_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->window_entry == nullptr) return G_SOURCE_REMOVE;
GtkEditable* entry = GTK_EDITABLE(a->window_entry);
GtkDropDown* unit = GTK_DROP_DOWN(a->window_unit_drop);
auto report = [a](const char* what) {
double span = 0.0;
u64 stride = 0;
double stored = 0.0;
double buffered = 0.0;
double aa = 0.0;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
span = a->model->event_span();
const Partition& part = a->model->partition();
stride = part.time_stride;
stored = part.stored_rate();
buffered = part.span_s;
aa = part.antialias_hz();
}
std::fprintf(stderr, "[window-probe] %-14s label='%s' entry='%s' unit=%u window=%.6fs "
"event_span=%.6fs cap=%.3fs decim=%llu stored=%.4gS/s aa=%.4gHz "
"buffered=%.4gs\n",
what, a->dbg_window_text.c_str(),
gtk_editable_get_text(GTK_EDITABLE(a->window_entry)),
gtk_drop_down_get_selected(GTK_DROP_DOWN(a->window_unit_drop)),
a->rows->time_window(), span, a->window_limit_s,
static_cast<unsigned long long>(stride), stored, aa, buffered);
std::fflush(stderr);
};
report("initial");
// 500 ms typed: the entry's own path, and a span no ladder of round numbers had.
gtk_drop_down_set_selected(unit, 1);
gtk_editable_set_text(entry, "500");
on_window_entry_activate(GTK_ENTRY(a->window_entry), a);
report("type 500ms");
// The unit re-interprets the number rather than converting it, so this is 500 s -- past the
// cap, and therefore the clamp's own test.
gtk_drop_down_set_selected(unit, 2);
report("unit ms->s");
gtk_editable_set_text(entry, "2");
on_window_entry_activate(GTK_ENTRY(a->window_entry), a);
report("type 2s");
GtkWidget* up = gtk_button_new_with_label("x10");
g_object_set_data(G_OBJECT(up), "udpscope-window-up", up);
on_window_step(GTK_BUTTON(up), a);
report("x10");
GtkWidget* down = gtk_button_new_with_label("/10");
on_window_step(GTK_BUTTON(down), a);
report("/10");
// Both were created only to carry the direction flag; they were never parented, so they
// still hold the floating reference GTK4 created them with.
g_object_ref_sink(up);
g_object_unref(up);
g_object_ref_sink(down);
g_object_unref(down);
return G_SOURCE_REMOVE;
}
void layout_probe_report(App& a, const char* what) {
const int rows = a.rows->row_count();
const double scale = (a.area != nullptr && a.area->device_scale() > 0.0f)
? a.area->device_scale() : 1.0;
std::string geom;
const plot::FigureLayout& fl = a.rows->figure().layout();
for (std::size_t i = 0; i < fl.rows.size(); ++i) {
char buf[48];
std::snprintf(buf, sizeof(buf), "%s%.0f..%.0f", i == 0 ? "" : ",",
double(fl.rows[i].y) / scale,
(double(fl.rows[i].y) + double(fl.rows[i].h)) / scale);
geom += buf;
}
std::string close_tops;
std::string add_tops;
for (int i = 0; i < rows; ++i) {
char buf[24];
if (GtkWidget* b = layout_button(a, false, i)) {
std::snprintf(buf, sizeof(buf), "%s%d", i == 0 ? "" : ",", gtk_widget_get_margin_top(b));
close_tops += buf;
}
}
for (int i = 0; i <= rows; ++i) {
char buf[24];
if (GtkWidget* b = layout_button(a, true, i)) {
std::snprintf(buf, sizeof(buf), "%s%d", i == 0 ? "" : ",", gtk_widget_get_margin_top(b));
add_tops += buf;
}
}
std::string traces;
for (int r = 0; r < rows; ++r) {
traces += (r == 0 ? "" : "|");
traces += std::to_string(r) + ":";
const RowSet::Row& row = a.rows->row(r);
bool any = false;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::string& n = row.signal_name[static_cast<std::size_t>(s)];
if (n.empty()) continue;
if (any) traces += ",";
traces += n;
any = true;
}
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.
std::string done_box = "-";
if (a.layout_done_button != nullptr && a.area_overlay != nullptr) {
graphene_rect_t r{};
if (gtk_widget_compute_bounds(a.layout_done_button, a.area_overlay, &r)) {
char buf[64];
std::snprintf(buf, sizeof(buf), "%.0f,%.0f,%.0fx%.0f", static_cast<double>(r.origin.x),
static_cast<double>(r.origin.y), static_cast<double>(r.size.width),
static_cast<double>(r.size.height));
done_box = buf;
}
}
std::fprintf(stderr, "[layout-probe] %-12s rows=%d buttons=%zu done=%d done_box=[%s] "
"rows_y=[%s] close=[%s] add=[%s] resize=[%s] traces=[%s] side_w=%d\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(),
// The sidebar, which is the pane a divider position can silently take away: `side_w`
// must be its own request (320) rather than 0, and `divider` the position the window's
// width less the sidebar's column left it at.
a.side_box != nullptr ? gtk_widget_get_width(a.side_box) : -1);
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;
}
/// `UDPSCOPE_PROBE_SIDEBAR_MS`: the sidebar's width and whether the toolbar button agrees with it, in
/// both of the split view's states and against a hide that did not come from the button.
///
/// The small-window case is the one a divider position got wrong: with the sidebar's width derived from
/// the window, a window narrower than that number gave it nothing at all. The split view takes its size
/// from its own properties instead, and when there is no room beside the plot it *overlays* it
/// (`collapsed`) rather than shrinking to nothing. Both states are read here, so "the sidebar is never
/// zero" is a number rather than a claim.
gboolean sidebar_probe(gpointer user) {
App& a = *static_cast<App*>(user);
if (a.side_view == nullptr) return G_SOURCE_REMOVE;
// `shown` is the panel's own state and `button` the toolbar's; they must agree, or the button is
// telling the user something the window does not do.
const auto report = [](App& app, const char* what) {
const int side = app.side_box != nullptr ? gtk_widget_get_width(app.side_box) : -1;
const int win = app.window != nullptr ? gtk_widget_get_width(app.window) : -1;
std::fprintf(stderr,
"[sidebar-probe] %-10s window=%d sidebar=%d collapsed=%d shown=%d button=%d(sens=%d)\n",
what, win, side,
adw_overlay_split_view_get_collapsed(ADW_OVERLAY_SPLIT_VIEW(app.side_view)) ? 1 : 0,
adw_overlay_split_view_get_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(app.side_view)) ? 1 : 0,
app.sidebar_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.sidebar_toggle)) ? 1 : 0,
app.sidebar_toggle != nullptr && gtk_widget_get_sensitive(app.sidebar_toggle) ? 1 : 0);
};
report(a, "beside");
// The state a window with no room produces: the sidebar goes over the plot, and its width is still its
// own. If this ever reads 0, the sidebar is being given a share of a width that is not there.
adw_overlay_split_view_set_collapsed(ADW_OVERLAY_SPLIT_VIEW(a.side_view), TRUE);
// A hide that did not come from the button -- an overlay swipe, or the split view's own collapsing --
// must not win: the panel is where the button says it is. `shown` and `button` still reading 1 here is
// the point, and reading 0 is the regression (a button that no longer reflects the panel, or a panel
// that slips out from under it).
adw_overlay_split_view_set_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(a.side_view), FALSE);
g_timeout_add(400, +[](gpointer u) -> gboolean {
App& app = *static_cast<App*>(u);
const int side = app.side_box != nullptr ? gtk_widget_get_width(app.side_box) : -1;
std::fprintf(stderr,
"[sidebar-probe] %-10s window=%d sidebar=%d collapsed=%d shown=%d button=%d(sens=%d)\n",
"regained", app.window != nullptr ? gtk_widget_get_width(app.window) : -1, side,
adw_overlay_split_view_get_collapsed(ADW_OVERLAY_SPLIT_VIEW(app.side_view)) ? 1 : 0,
adw_overlay_split_view_get_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(app.side_view)) ? 1 : 0,
app.sidebar_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.sidebar_toggle)) ? 1 : 0,
app.sidebar_toggle != nullptr && gtk_widget_get_sensitive(app.sidebar_toggle) ? 1 : 0);
adw_overlay_split_view_set_collapsed(ADW_OVERLAY_SPLIT_VIEW(app.side_view), FALSE);
// Back the way a person would: pressing the button is what records the wish, and the panel follows it.
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.sidebar_toggle), TRUE);
return G_SOURCE_REMOVE;
}, &a);
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 row shows the path in
// force rather than a plausible-looking string. `rows=1` is the dialog's rows having resolved from
// the `.ui` -- the one mistake neither the build nor `GtkBuilder` sees is a missing id.
const char* shown =
a.prefs.dir_row != nullptr ? gtk_editable_get_text(GTK_EDITABLE(a.prefs.dir_row)) : "-";
// 4/4: every row's title carries a mnemonic (`_Directory`, `Keep _events`, ...), so a row that does not
// ask for `use-underline` draws the underscore instead of underlining the letter.
std::fprintf(stderr, "[prefs-probe] mnemonics=%d/4\n",
(row_underlines(G_OBJECT(a.prefs.dir_row)) + row_underlines(G_OBJECT(a.prefs.keep_row)) +
row_underlines(G_OBJECT(a.prefs.relative_row)) +
row_underlines(G_OBJECT(a.prefs.theme_row))));
std::fprintf(stderr, "[prefs-probe] opened=%d rows=%d dir=%s store=%s\n",
a.prefs.dialog != nullptr ? 1 : 0, a.prefs.dir_row != nullptr ? 1 : 0,
a.prefs.dir_row != 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_change(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.
// Setting the row *is* applying it: there is no Apply button to press.
gtk_editable_set_text(GTK_EDITABLE(app.prefs.dir_row), default_events_dir().c_str());
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);
// Cancel through the chooser's own cancellable: a user's Cancel button lands in the same
// callback with the same null answer, and it is the *cancel* path a picker invites -- a
// callback after the thing it belongs to is gone.
if (app.prefs.browse_cancel != nullptr) g_cancellable_cancel(app.prefs.browse_cancel);
std::fprintf(stderr, "[prefs-probe] browse: cancelled=%d icon_ok=%d field='%s'\n",
app.prefs.browse_cancel != nullptr ? 1 : 0,
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_row != nullptr
? gtk_editable_get_text(GTK_EDITABLE(app.prefs.dir_row))
: "-");
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);
}
gboolean layout_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
switch (a->dbg_layout_step) {
case 0:
// Through the menu's toggle, so the probe exercises the handler the user reaches.
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->layout_toggle), true);
layout_probe_report(*a, "editor on");
layout_probe_next(*a, 1, 200);
break;
case 1:
layout_probe_report(*a, "placed");
layout_click(layout_button(*a, true, 0));
layout_probe_next(*a, 2, 200);
break;
case 2:
// What row 0 held must now be on row 1: an insert moves rows, it does not renumber.
layout_probe_report(*a, "add above 0");
layout_click(layout_button(*a, false, 0));
layout_probe_next(*a, 3, 200);
break;
case 3:
layout_probe_report(*a, "remove 0");
layout_click(layout_button(*a, true, a->rows->row_count()));
layout_probe_next(*a, 4, 200);
break;
case 4:
layout_probe_report(*a, "add at end");
// Leave through the editor's own Done button, which is the way out a user has in
// front of them; the toolbar toggle is the same state and stays exercised by the
// `on` step above.
layout_probe_report(*a, "before done");
layout_click(a->layout_done_button);
layout_probe_next(*a, 5, 200);
break;
default:
// Nothing left on the overlay: the editor's buttons exist only while it is up.
layout_probe_report(*a, "editor off");
break;
}
return G_SOURCE_REMOVE;
}
gboolean enable_all_probe(gpointer user) {
App* a = static_cast<App*>(user);
for (std::size_t i = 0; i < a->model->signals().size(); ++i) {
a->model->set_signal_enabled(i, true);
}
int row = 0;
int slot = 0;
for (std::size_t c = 0; c < a->model->channel_count(); ++c) {
if (row >= a->rows->row_count()) break;
const ChannelInfo& ch = a->model->channels()[c];
a->rows->assign(row, slot, ch.name, ch.name,
signal_color_index(a->model->signals(), ch.signal));
if (++slot >= RowSet::kSlotsPerRow) {
slot = 0;
++row;
}
}
a->relayout_pending = true;
std::fprintf(stderr, "[enable-all-probe] %zu channel(s) enabled and assigned to plots\n",
a->model->channel_count());
std::fflush(stderr);
// Then switch one off through its own switch in the signal list: a disabled signal must leave the plots,
// not sit there drawing nothing in a slot a live one could take. Late enough that a math probe armed behind
// this one has had its graphs installed -- the dependants are the point of the second line.
g_timeout_add(2000, +[](gpointer u) -> gboolean {
App* app = static_cast<App*>(u);
if (app->model->channels().empty() || app->signal_list == nullptr) return G_SOURCE_REMOVE;
const std::size_t signal = app->model->channels().front().signal;
const std::string name = app->model->channels().front().name;
GtkWidget* tick = nullptr;
for (GtkWidget* row_widget = gtk_widget_get_first_child(app->signal_list);
row_widget != nullptr && tick == nullptr;
row_widget = gtk_widget_get_next_sibling(row_widget)) {
// The tick sits a box inside the row, which is the shape `signal_tick_visible()` walks too.
for (GtkWidget* box = gtk_widget_get_first_child(row_widget); box != nullptr && tick == nullptr;
box = gtk_widget_get_next_sibling(box)) {
for (GtkWidget* w = gtk_widget_get_first_child(box); w != nullptr;
w = gtk_widget_get_next_sibling(w)) {
if (!GTK_IS_CHECK_BUTTON(w)) continue;
if (GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(w), "udpscope-signal")) != signal) continue;
tick = w;
break;
}
}
}
if (tick == nullptr) {
std::fprintf(stderr, "[enable-all-probe] no acquire tick found for signal %zu\n", signal);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gtk_check_button_set_active(GTK_CHECK_BUTTON(tick), FALSE);
int left = 0;
for (int r = 0; r < app->rows->row_count(); ++r) {
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
if (app->rows->row(r).signal_name[static_cast<std::size_t>(s)] == name) ++left;
}
}
// Which *math* channels the disabled signal took with it: a derived channel cannot be computed from a
// signal nobody acquires, so the ones that read it follow it off -- and their traces go too.
std::string off_list;
for (const MathChannel& m : app->math) {
if (m.enabled) continue;
if (!off_list.empty()) off_list += ",";
off_list += m.name;
}
// Both halves: the channels that read it went off, and the ones that do not are still on.
std::string on_list;
for (const MathChannel& m : app->math) {
if (!m.enabled) continue;
if (!on_list.empty()) on_list += ",";
on_list += m.name;
}
std::fprintf(stderr,
"[enable-all-probe] switched '%s' off: traces_left=%d (want 0) math_off=[%s] "
"math_on=[%s] notice='%s'\n",
name.c_str(), left, off_list.c_str(), on_list.c_str(), app->notice.c_str());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}, a);
return G_SOURCE_REMOVE;
}
gboolean disarm_probe_step2(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
// The Arm/Disarm button is in the trigger panel, not the title bar: search the
// whole window, and say so when it is not there rather than claiming a click.
GtkWidget* button = find_labelled_button(a->window, "Disarm");
if (button == nullptr) button = find_labelled_button(a->window, "Arm");
std::fprintf(stderr, "[disarm-probe] button %s\n", button ? "found, clicking" : "NOT FOUND");
if (button != nullptr) g_signal_emit_by_name(button, "clicked");
a->dbg_latches_at_disarm = a->model->trigger_latches().load();
std::fprintf(stderr, "[disarm-probe] %llu events so far, armed now=%d\n",
(unsigned long long)a->dbg_latches_at_disarm, int(a->model->trigger().armed));
a->dbg_capturing_at_disarm = a->model->trigger_pending();
a->dbg_seq_at_disarm = a->model->event_seq();
g_timeout_add(600, disarm_probe_step3, a);
return G_SOURCE_REMOVE;
}
gboolean disarm_probe_step3(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const DisplayMode d = a->model->display_mode();
auto snap = a->model->event();
RowSet::Row& r = a->rows->row(0);
const plot::Range axis = r.plot != nullptr ? r.plot->x_range(r.axis_x) : plot::Range{0.0, 0.0};
std::fprintf(stderr,
"[disarm-probe] was_capturing=%d display=%s seq=%llu->%llu events=%llu->%llu "
"snap=[%.4f..%.4f] n=%llu axis=[%.4f..%.4f]\n",
int(a->dbg_capturing_at_disarm),
d == DisplayMode::Held ? "event(frozen)"
: d == DisplayMode::Live ? "live"
: d == DisplayMode::Capturing ? "live(capturing)"
: "nothing",
(unsigned long long)a->dbg_seq_at_disarm,
(unsigned long long)a->model->event_seq(),
(unsigned long long)a->dbg_latches_at_disarm,
(unsigned long long)a->model->trigger_latches().load(),
snap ? -snap->pre_s : 0.0, snap ? snap->post_s : 0.0,
(unsigned long long)(snap ? snap->stamps.size() : 0), double(axis.min),
double(axis.max));
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean disarm_probe_wait(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
++a->dbg_disarm_polls;
if (a->model->trigger_pending()) ++a->dbg_disarm_in_capture;
// 300 ms into the capture: post-trigger data worth keeping, well short of the latch.
// The 10 s cap is for a stream that never triggers -- the probe still has to report.
if (a->dbg_disarm_in_capture >= 6 || a->dbg_disarm_polls > 200) {
if (a->dbg_disarm_in_capture == 0) {
std::fprintf(stderr, "[disarm-probe] no capture opened in 10 s; disarming anyway\n");
std::fflush(stderr);
}
disarm_probe_step2(a);
return G_SOURCE_REMOVE;
}
return G_SOURCE_CONTINUE;
}
gboolean disarm_probe_step1(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (a->trigger_toggle != nullptr)
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
g_timeout_add(50, disarm_probe_wait, a);
return G_SOURCE_REMOVE;
}
void pause_probe_report(App& a, const char* what) {
double bounds_lo = 0.0;
double bounds_hi = 0.0;
std::size_t event_size = 0;
std::size_t ring_produced = 0;
{
std::lock_guard<std::mutex> lock(a.model->mutex());
if (!a.model->channels().empty()) ring_produced = a.model->live().values(0).produced();
}
if (const EventSeries* ev = a.rows->row(0).slots[0].bound_event()) {
event_size = ev->size();
const plot::Range b = ev->x_bounds();
bounds_lo = b.min;
bounds_hi = b.max;
}
// The axis beside the data, because those two disagreeing is the whole failure: the frozen view
// is the *snapshot*, and a pin taken from the requested window instead of from the snapshot left
// the difference as an empty margin. `frozen_x` must equal `axis` while paused.
plot::Range axis{0.0, 0.0};
if (a.rows->row_count() > 0 && a.rows->row(0).plot != nullptr) {
const RowSet::Row& r0 = a.rows->row(0);
axis = r0.plot->x_range(r0.axis_x);
}
// The bar's own string, because "how long have I been paused" is a *status* field and the
// elapsed figure is the one part of it that no other line can show: the exit dump's
// `status bar:` is read live and the two `mode ...` lines are the live and triggered states.
// `skips` is the render gate (see the end of `tick()`), cumulative over the session: it must be
// 0 on the `live` report and have grown by the `paused` one, which is the gate being a number
// rather than a claim. A live view that skips frames is the gate's condition inverted; a paused
// view that skips none is the gate not engaging at all.
std::fprintf(stderr,
"[pause-probe] %-9s paused=%d ring_produced=%zu event_size=%zu "
"frozen_x=[%.6f..%.6f] axis=[%.6f..%.6f] skips=%d glance=[%s]\n",
what, a.model->paused() ? 1 : 0, ring_produced, event_size, bounds_lo, bounds_hi,
double(axis.min), double(axis.max), a.dbg_render_skips,
a.dbg_glance_last.c_str());
std::fflush(stderr);
}
gboolean pause_probe(gpointer user) {
App* a = static_cast<App*>(user);
// Through the button, so the *view* snapshot and the axis pin run: pausing is a display
// state, not only an acquisition one.
pause_probe_report(*a, "live");
if (a->pause_button != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->pause_button), TRUE);
} else {
a->acq->set_paused(true);
a->model->set_paused(true);
}
a->dbg_axes_x_min = 1 << 20;
a->dbg_axes_x_max = -(1 << 20);
a->dbg_axes_w_min = 1 << 20;
a->dbg_axes_w_max = -(1 << 20);
a->dbg_axes_w_prev = -1;
a->dbg_axes_w_changes = 0;
a->dbg_axes_w_change_frame = -1;
a->dbg_axes_w_change_side = -1;
a->dbg_vp_w_min = 1 << 20;
a->dbg_vp_w_max = -(1 << 20);
// Reported again a second later (the probe is re-armed below) and once after the resume: the
// ring count must have grown while the frozen window did not move.
static bool reported_paused = false;
if (!reported_paused) {
reported_paused = true;
g_timeout_add(1200, +[](gpointer user) {
App* app = static_cast<App*>(user);
pause_probe_report(*app, "paused");
return G_SOURCE_REMOVE;
}, a);
g_timeout_add(1600, +[](gpointer user) {
App* app = static_cast<App*>(user);
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->pause_button), FALSE);
return G_SOURCE_REMOVE;
}, a);
g_timeout_add(2200, +[](gpointer user) {
App* app = static_cast<App*>(user);
pause_probe_report(*app, "resumed");
return G_SOURCE_REMOVE;
}, a);
}
a->dbg_xtick_min = 1 << 20;
a->dbg_xtick_max = -(1 << 20);
a->dbg_gutter_min = 1 << 20;
a->dbg_gutter_max = -(1 << 20);
a->dbg_axes_frames = 0;
a->dbg_x_desync = 0;
return G_SOURCE_REMOVE;
}
gboolean export_probe(gpointer user) {
App* a = static_cast<App*>(user);
const char* env = g_getenv("UDPSCOPE_EXPORT_PATH");
const std::string prefix = env != nullptr ? env : "/tmp/udpscope-export";
const RowSet::Row& r0 = a->rows->row(0);
const plot::Range axis = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{0.0, 0.0};
const ExportTable table = collect_export(*a->model, *a->rows);
std::fprintf(stderr,
"[export-probe] mode=%s axis=[%.6f..%.6f] rows=%zu cols=%zu time=[%.6f..%.6f] "
"abs=[%.6f..%.6f]\n",
a->model->trigger_enabled() && a->model->event() != nullptr ? "event" : "live",
axis.min, axis.max, table.rows(), table.columns.size(),
table.rows() != 0 ? table.time.front() : 0.0,
table.rows() != 0 ? table.time.back() : 0.0,
table.rows() != 0 ? table.abs_seconds.front() : 0.0,
table.rows() != 0 ? table.abs_seconds.back() : 0.0);
for (const ExportTable::Column& col : table.columns) {
std::fprintf(stderr, "[export-probe] column '%s' n=%zu\n", col.name.c_str(), col.values.size());
}
for (const char* ext : {".npy", ".csv"}) {
const std::string path = prefix + ext;
std::string error;
const bool ok = std::strcmp(ext, ".csv") == 0 ? write_csv(path, table, error)
: write_npy(path, table, error);
long bytes = -1;
if (ok) {
if (std::FILE* f = std::fopen(path.c_str(), "rb"); f != nullptr) {
std::fseek(f, 0, SEEK_END);
bytes = std::ftell(f);
std::fclose(f);
}
}
std::fprintf(stderr, "[export-probe] %s %s bytes=%ld%s%s\n", path.c_str(),
ok ? "written" : "FAILED", bytes, ok ? "" : " error=", ok ? "" : error.c_str());
}
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean exit_probe_step1(gpointer user) {
App* a = static_cast<App*>(user);
// Record the panel states before the toggles are exercised.
a->dbg_trigger_revealed_default =
gtk_revealer_get_child_revealed(GTK_REVEALER(a->trigger_revealer)) ? 1 : 0;
if (a->trigger_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
}
g_timeout_add(1700, exit_probe_step2, a);
return G_SOURCE_REMOVE;
}
void cursors_probe_report(App& a, const char* what) {
static const char* const kModes[] = {"vertical", "horizontal", "both", "mix"};
const int mode = static_cast<int>(a.rows->cursor_mode());
// The table as a user reads it: the time row first, then one row per line, each cell's
// own text -- which is the assertion (the app's formatting, not the probe's).
std::string t_text;
std::string lines;
int n = 0;
// The row's own name too, not just its three cells: a `mix` line names the trace it reads,
// and a line that should not exist at all is only recognisable by *which* trace it claims.
const auto cells = [](const App::CursorLine& line) {
std::string who = line.label != nullptr ? gtk_label_get_text(GTK_LABEL(line.label)) : "";
return who + "=" + gtk_label_get_text(GTK_LABEL(line.a)) + " " +
gtk_label_get_text(GTK_LABEL(line.b)) + " " +
gtk_label_get_text(GTK_LABEL(line.d));
};
for (const App::CursorLine& line : a.cursors_lines) {
if (line.a == nullptr) continue;
if (line.slot == -2) {
t_text = cells(line);
continue;
}
if (n != 0) lines += " | ";
lines += cells(line);
++n;
}
// The `mix` crossing markers: how many are on screen and where the first is. They are
// what the mode promises on the plot itself, so they are what the probe asserts.
double mx = 0.0;
double my = 0.0;
const bool have_mark = a.rows->crossing_at(0, 0, false, mx, my);
std::fprintf(stderr,
"[cursors-probe] %-14s mode=%-10s on=%d pane=%d t0=%.6f t1=%.6f dt=%.6f "
"T=[%s] lines=%d marks=%d [%s]\n",
what, kModes[mode], a.rows->cursors_visible() ? 1 : 0,
a.cursors_revealer != nullptr &&
gtk_revealer_get_reveal_child(GTK_REVEALER(a.cursors_revealer))
? 1
: 0,
a.rows->cursor_a_x(), a.rows->cursor_b_x(),
a.rows->cursor_b_x() - a.rows->cursor_a_x(), t_text.c_str(), n,
a.rows->crossings_visible(), lines.c_str());
if (have_mark) {
std::fprintf(stderr, "[cursors-probe] %-14s first mark at x=%.6f y=%.6g\n", what, mx, my);
}
std::fflush(stderr);
}
void fit_probe_report(App& a, const char* what) {
const RowSet::Row& r0 = a.rows->row(0);
const plot::Range x = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{};
const plot::Range y = r0.plot != nullptr ? r0.plot->y_range(r0.axis_y) : plot::Range{};
// `window` and `event_span` are the assertion that a fit did *not* restate the time scale:
// both are written only by `apply_time_window()`, so they must hold the typed value on every
// line, with `span` the reading that moves -- down on the zoom, back up on the fit.
std::fprintf(stderr,
"[fit-probe] %-10s pinned=%d window=%.6f event_span=%.6f x=[%.6f..%.6f] span=%.6f "
"y=[%.6f..%.6f]\n",
what, a.rows->window_pinned() ? 1 : 0, a.rows->time_window(), a.model->event_span(),
x.min, x.max, x.span(), y.min, y.max);
std::fflush(stderr);
}
gboolean timelabels_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const char* what = "at 2 s";
switch (a->dbg_timelabels_step) {
case 0:
apply_time_window(*a, 2.0);
break;
case 1:
what = "at 500 ms";
apply_time_window(*a, 0.5);
break;
case 2:
what = "at 50 ms";
apply_time_window(*a, 0.05);
break;
default:
// Ends *in* a sub-second window: the dump runs after the last paint, so it is the
// race-free place to read whether the zero tick really sits on the left edge.
what = "final 20 ms";
apply_time_window(*a, 0.02);
break;
}
++a->dbg_timelabels_step;
if (a->dbg_timelabels_step <= 4) {
g_timeout_add(400, timelabels_probe_step, a);
}
// Report the *previous* step's state: the labels are laid out on the frame after the window
// changes, so reading them in the same call would report the old form.
const RowSet::Row& r0 = a->rows->row(0);
const RowSet::Row& last = a->rows->row(a->rows->row_count() - 1);
std::string ticks;
std::string note;
// Whether *every* major label on the axis is a different string. That, and not a span
// threshold, is what the relative form exists to buy: two adjacent ticks reading `12.3s` are
// not labels. It is therefore the one assertion that holds whatever the stream's uptime is --
// which decides where the switch actually falls, since the label resolution is a function of
// the axis' magnitude.
bool distinct = true;
if (last.plot != nullptr) {
int n = 0;
std::vector<std::string> seen;
for (const plot::Tick& t : last.plot->x_axis(last.axis_x).ticks) {
if (!t.major) continue;
if (std::find(seen.begin(), seen.end(), t.label) != seen.end()) distinct = false;
seen.emplace_back(t.label);
if (n++ < 4) {
if (n != 1) ticks += ",";
ticks += t.label;
}
}
note = last.plot->x_note();
}
double first_value = 0.0;
plot::Range shown{};
if (last.plot != nullptr) {
for (const plot::Tick& t : last.plot->x_axis(last.axis_x).ticks) {
if (t.major) {
first_value = t.value;
break;
}
}
shown = last.plot->x_range(last.axis_x);
}
std::fprintf(stderr,
"[timelabels-probe] %-12s relative=%d distinct=%d window=%.6g origin=%.6f "
"note=[%s] ticks=[%s] first_value=%.6f first_rel=%.6f range=[%.6f..%.6f]\n",
what, a->rows->time_labels_relative() ? 1 : 0, distinct ? 1 : 0,
a->rows->time_window(),
a->rows->time_labels_origin(), note.c_str(), ticks.c_str(), first_value,
first_value - (last.plot != nullptr ? last.plot->x_axis_origin(last.axis_x) : 0.0),
shown.min, shown.max);
std::fflush(stderr);
(void)r0;
return G_SOURCE_REMOVE;
}
void fit_probe_zoom_in(App& a) {
RowSet::Row& r0 = a.rows->row(0);
if (r0.plot == nullptr) return;
const plot::Range x = r0.plot->x_range(r0.axis_x);
const double mid = x.min + 0.5 * x.span();
const double half = 0.125 * x.span();
r0.plot->set_x_range(r0.axis_x, {mid - half, mid + half});
}
gboolean fit_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
switch (a->dbg_fit_step) {
case 0:
// Move the y scale off the data's range first, so "fit x leaves y alone" is a statement
// with something to be wrong about -- and set the window through the control's own path,
// since that is the only thing allowed to, and its value is what both fits must preserve.
a->rows->set_y_range(0, -100.0, 100.0);
// Not while pinned: `apply_time_window()` unpins, and the pinned branch of the fit is the
// other half of what this probe is for. Timed inside a pause (`UDPSCOPE_PROBE_PAUSE_MS`)
// it takes the window the pause froze and reports `pinned=1`.
if (!a->rows->window_pinned()) apply_time_window(*a, 2.0);
fit_probe_report(*a, "start");
g_timeout_add(400, fit_probe_step, a);
a->dbg_fit_step = 1;
break;
case 1:
fit_probe_zoom_in(*a);
g_timeout_add(400, fit_probe_step, a);
a->dbg_fit_step = 2;
break;
case 2:
// A quarter of the window: what the fits have to undo, and the reading that proves the
// `span=` on the fit lines is the fit's work rather than a window nothing ever left.
fit_probe_report(*a, "zoomed");
// The handlers themselves, not `RowSet::fit_x()` alone: the window the fit must *not*
// move is the app's, and the notice and the control are the handler's half.
on_fit_x_clicked(nullptr, a);
g_timeout_add(400, fit_probe_step, a);
a->dbg_fit_step = 3;
break;
case 3:
fit_probe_report(*a, "fit x");
fit_probe_zoom_in(*a);
on_fit_clicked(nullptr, a);
g_timeout_add(400, fit_probe_step, a);
a->dbg_fit_step = 4;
break;
default:
fit_probe_report(*a, "fit all");
break;
}
return G_SOURCE_REMOVE;
}
void cursors_probe_next(App& a, int step, guint delay_ms) {
a.dbg_cursors_step = step;
g_timeout_add(delay_ms, cursors_probe_step, &a);
}
gboolean cursors_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
switch (a->dbg_cursors_step) {
case 0:
// Through the toggle the toolbar offers, which is what also reveals the pane.
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->cursors_toggle), true);
cursors_probe_report(*a, "on");
cursors_probe_next(*a, 1, 300);
break;
case 1:
a->rows->set_cursor_mode(RowSet::CursorMode::Horizontal);
cursors_probe_next(*a, 2, 300);
break;
case 2:
cursors_probe_report(*a, "horizontal");
a->rows->set_cursor_mode(RowSet::CursorMode::Both);
cursors_probe_next(*a, 3, 300);
break;
case 3:
cursors_probe_report(*a, "both");
a->rows->set_cursor_mode(RowSet::CursorMode::Mix);
cursors_probe_next(*a, 4, 300);
break;
case 4:
cursors_probe_report(*a, "mix");
cursors_probe_next(*a, 5, 300);
break;
case 5: {
// A plot-level move with *no gesture behind it*: the sync reads a row only while its
// controller holds a cursor, so this must be ignored. That is the invariant the
// "drag it and it springs back" defect broke -- the tick adopted a row's own echo, and a
// row that had not been re-written yet adopted the stale shared value straight back.
RowSet::Row& r0 = a->rows->row(0);
a->dbg_gestureless_before = a->rows->cursor_a_x();
if (r0.plot != nullptr) {
const plot::Range xr = r0.plot->x_range(r0.axis_x);
r0.plot->set_cursor_x(r0.cursor_a, xr.min + 0.20 * xr.span(), plot::CursorPlacement::Free);
}
cursors_probe_next(*a, 6, 300);
break;
}
case 6: {
const double after = a->rows->cursor_a_x();
std::fprintf(stderr,
"[cursors-probe] %-14s plot move with no gesture: stored %.6f -> %.6f "
"ignored=%d\n",
"gestureless", a->dbg_gestureless_before, after,
std::fabs(after - a->dbg_gestureless_before) < 1e-12 ? 1 : 0);
std::fflush(stderr);
cursors_probe_report(*a, "after drag");
// (The adoption half -- a gesture that *does* move a cursor -- is the library's own
// controller test plus the app's rule that the tick passes the row whose controller holds
// a cursor; a programmatic press here cannot reach the adapter's event pump, so this probe
// asserts the invariant that is reachable from here.)
cursors_probe_next(*a, 7, 300);
break;
}
case 7: {
cursors_probe_report(*a, "both");
// A ruler is dragged through its own plot, and the pane's Y cells have to follow it.
// Nothing shares a ruler, so this is the read-back path alone.
RowSet::Row& r0 = a->rows->row(0);
if (r0.plot != nullptr) {
const plot::Range yr = r0.plot->y_range(r0.axis_y);
r0.plot->set_cursor_y(r0.ruler, yr.min + 0.80 * yr.span(), plot::CursorPlacement::Free);
}
cursors_probe_next(*a, 8, 300);
break;
}
default:
cursors_probe_report(*a, "after ruler drag");
break;
}
return G_SOURCE_REMOVE;
}
void measures_probe_next(App& a, int step, guint delay_ms) {
a.dbg_measure_step = step;
g_timeout_add(delay_ms, measures_probe_step, &a);
}
gboolean measures_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
switch (a->dbg_measure_step) {
case 0: {
std::string signal;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
if (!a->model->channels().empty()) signal = a->model->channels().front().name;
}
if (signal.empty()) {
std::fprintf(stderr, "[measures-probe] no acquired signal to measure\n");
return G_SOURCE_REMOVE;
}
for (int i = 0; i < measure_type_count(); ++i) {
Measure m;
m.signal = signal;
m.type = measure_type_at(i);
m.options.show_on_plot = measure_can_show_on_plot(m.type);
a->measures.push_back(std::move(m));
}
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->measures_toggle), true);
rebuild_measures_pane(*a);
std::fprintf(stderr, "[measures-probe] added %zu measurements on '%s'\n", a->measures.size(),
signal.c_str());
measures_probe_next(*a, 1, 400);
break;
}
case 1:
open_measure_dialog(*a, 0); // the edit path: this and the delete are what aborted
measures_probe_next(*a, 2, 400);
break;
case 2:
// Reported a frame after opening, so the dropdown has an allocation: its height is
// what says the combo is a combo and not a 160 px box.
measure_dialog_report(*a, "edit opened");
measures_probe_next(*a, 3, 200);
break;
case 3:
measure_dialog_click(*a, "Save");
measures_probe_next(*a, 4, 400);
break;
case 4:
measure_dialog_report(*a, "after save");
open_measure_dialog(*a, 0);
measures_probe_next(*a, 5, 200);
break;
case 5:
measure_dialog_click(*a, "Delete");
measures_probe_next(*a, 6, 400);
break;
case 6:
// A gesture on a measurement's own cursor, placed through the plot exactly as the
// controller would: the next frame has to put it back, which is what "read-only" means
// here (the library has no flag the hit test would respect).
// The marks as *placed* throughout: the drag writes a plot cursor and the check below reads
// one back, so both sides have to be in the plot's own coordinates -- and a time mark is
// stored on the stream's clock (see `place_measure_marks()`).
if (!a->measure_marks_display.empty()) {
RowSet::Row& r0 = a->rows->row(0);
if (r0.plot != nullptr) {
const RowSet::MeasureMark& mk = a->measure_marks_display.front();
if (mk.level) {
r0.plot->set_cursor_y(r0.measure_h[0], mk.value + 3.0, plot::CursorPlacement::Free);
} else {
r0.plot->set_cursor_x(r0.measure_v[0], mk.value + 3.0, plot::CursorPlacement::Free);
}
}
}
measures_probe_next(*a, 7, 300);
break;
default: {
double restored = -1.0;
double want = 0.0;
// Both read *now*: a time mark rolls leftwards with its sample on a live axis, so the value
// placed on the most recent frame is the only one the plot's cursor can be expected to hold
// -- comparing against the value the drag was launched from would read 300 ms of scroll as a
// failure to restore.
if (!a->measure_marks_display.empty()) {
const RowSet::Row& r0 = a->rows->row(0);
const RowSet::MeasureMark& mk = a->measure_marks_display.front();
want = mk.value;
if (r0.plot != nullptr) {
restored = mk.level ? r0.plot->cursor(r0.measure_h[0]).y
: r0.plot->cursor(r0.measure_v[0]).x;
}
}
std::fprintf(stderr,
"[measures-probe] %-14s marks=%zu first=%.6f plot_now=%.6f restored=%d\n",
"after drag", a->measure_marks_display.size(), want, restored,
std::fabs(restored - want) < 1.0e-9 ? 1 : 0);
std::fflush(stderr);
measure_dialog_report(*a, "final");
break;
}
}
return G_SOURCE_REMOVE;
}
gboolean zoomkeys_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->rows->row_count() == 0 || a->rows->row(0).plot == nullptr) return G_SOURCE_REMOVE;
plot::Plot& p = *a->rows->row(0).plot;
const plot::u32 ax = a->rows->row(0).axis_x;
const plot::u32 ay = a->rows->row(0).axis_y;
plot::input::Controller* c =
a->area != nullptr ? a->area->row_controller(static_cast<plot::u32>(0)) : nullptr;
if (c == nullptr) {
std::fprintf(stderr, "[zoomkeys] no controller for row 0\n");
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
const plot::Range x0 = p.x_range(ax);
const plot::Range y0 = p.y_range(ay);
c->on_key({plot::input::kKeyZoomIn,
static_cast<plot::u32>(plot::input::Mod::Ctrl), true});
const plot::Range x1 = p.x_range(ax);
const plot::Range y1 = p.y_range(ay);
const int y_held = std::fabs(y1.span() - y0.span()) < 1e-9 ? 1 : 0;
c->on_key({plot::input::kKeyZoomIn,
static_cast<plot::u32>(plot::input::Mod::Alt), true});
const plot::Range x2 = p.x_range(ax);
const plot::Range y2 = p.y_range(ay);
const int x_held = std::fabs(x2.span() - x1.span()) < 1e-9 ? 1 : 0;
std::fprintf(stderr,
"[zoomkeys] ctrl+plus x=[%.4f..%.4f]->[%.4f..%.4f] y_held=%d | "
"alt+plus y=[%.4f..%.4f]->[%.4f..%.4f] x_held=%d\n",
x0.min, x0.max, x1.min, x1.max, y_held, y1.min, y1.max, y2.min, y2.max,
x_held);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
void menus_probe_next(App& a, int step, guint delay_ms) {
a.dbg_menus_step = step;
g_timeout_add(delay_ms, menus_probe_step, &a);
}
/// `UDPSCOPE_PROBE_WINDOWPOP_MS`: the time-window popover, closed by a press elsewhere in the window.
///
/// Autohide is turned off for the probe, exactly as the menu probe does it: a popover dismisses itself
/// when GTK holds its *grab*, and a bare `xvfb-run` has no window manager to hand one out -- so with
/// autohide on the bubble would not stay up to be tested. Autohide off is the state a user meets when
/// the grab goes missing, which is the report this guards: the press has to close it anyway. The second
/// half presses the button itself, which must *not* close it (its own click toggles).
gboolean windowpop_probe_step(gpointer user) {
static int step = 0;
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->window_button == nullptr) return G_SOURCE_REMOVE;
const auto active = [a] {
return gtk_menu_button_get_active(GTK_MENU_BUTTON(a->window_button)) ? 1 : 0;
};
if (step == 0) {
GtkPopover* pop = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->window_button));
if (pop != nullptr) gtk_popover_set_autohide(pop, FALSE);
gtk_menu_button_popup(GTK_MENU_BUTTON(a->window_button));
step = 1;
g_timeout_add(300, windowpop_probe_step, a);
return G_SOURCE_REMOVE;
}
// How many click gestures the window carries, so that "the handler works" and "the handler is
// connected" are read as two facts rather than one.
int clicks = 0;
{
GListModel* controllers = gtk_widget_observe_controllers(a->window);
const guint n = controllers != nullptr ? g_list_model_get_n_items(controllers) : 0;
for (guint i = 0; i < n; ++i) {
GObject* item = G_OBJECT(g_list_model_get_item(controllers, i));
if (item != nullptr) {
if (GTK_IS_GESTURE_CLICK(item)) ++clicks;
g_object_unref(item);
}
}
if (controllers != nullptr) g_object_unref(controllers);
}
std::fprintf(stderr, "[windowpop] opened=%d visible=%d click_gestures=%d\n", active(),
gtk_widget_is_visible(GTK_WIDGET(gtk_menu_button_get_popover(
GTK_MENU_BUTTON(a->window_button))))
? 1
: 0,
clicks);
on_window_popover_press(nullptr, 0, 20.0, 200.0, a);
std::fprintf(stderr, "[windowpop] after a press elsewhere: active=%d (want 0)\n", active());
gtk_menu_button_popup(GTK_MENU_BUTTON(a->window_button));
graphene_rect_t r = GRAPHENE_RECT_INIT(0, 0, 0, 0);
if (gtk_widget_compute_bounds(a->window_button, a->window, &r)) {
on_window_popover_press(nullptr, 0, r.origin.x + r.size.width / 2.0,
r.origin.y + r.size.height / 2.0, a);
}
std::fprintf(stderr, "[windowpop] after a press on the button: active=%d (want 1)\n", active());
gtk_menu_button_popdown(GTK_MENU_BUTTON(a->window_button));
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean menus_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (a->dbg_menus_step == 0) {
// Hiding the panel is the case that was reported: the info button then sits at the
// window's right edge, and a bubble placed below it has nowhere to go.
run_app_action(*a, AppAction::SidePanel);
// Autohide off *for the probe only*: a popover dismisses itself when it loses the grab, and
// under a bare `xvfb-run` there is no window manager to hand it one, so the rows would be
// measured before they were ever mapped. The app's own popovers keep autohide on.
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->menu_button));
if (popover != nullptr) gtk_popover_set_autohide(popover, FALSE);
gtk_menu_button_popup(GTK_MENU_BUTTON(a->menu_button));
menus_probe_next(*a, 1, 300);
return G_SOURCE_REMOVE;
}
if (a->dbg_menus_step == 1) {
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->menu_button));
GtkWidget* box = popover != nullptr ? gtk_popover_get_child(popover) : nullptr;
static const char* const kExpected[] = {"Fit to data", "Layout editor", "Signal list",
"Export visible range...", "Configurations...",
"Keyboard shortcuts", "Help", "About udpscope"};
const int expected = static_cast<int>(sizeof(kExpected) / sizeof(kExpected[0]));
int rows = 0;
int bad = 0;
int first_label_x = 0;
for (GtkWidget* row = box != nullptr ? gtk_widget_get_first_child(box) : nullptr;
row != nullptr; row = gtk_widget_get_next_sibling(row)) {
if (!GTK_IS_BUTTON(row)) continue;
GtkWidget* label = nullptr;
GtkWidget* accel = nullptr;
menu_row_labels(gtk_widget_get_first_child(row), &label, &accel);
const int row_w = gtk_widget_get_width(row);
const int row_h = gtk_widget_get_height(row);
const graphene_point_t origin = GRAPHENE_POINT_INIT(0.0f, 0.0f);
graphene_point_t at = GRAPHENE_POINT_INIT(0.0f, 0.0f);
const int label_x = label != nullptr && gtk_widget_compute_point(label, row, &origin, &at)
? static_cast<int>(at.x) : -1;
const int accel_right =
accel != nullptr && gtk_widget_compute_point(accel, row, &origin, &at)
? static_cast<int>(at.x) + gtk_widget_get_width(accel) : -1;
const char* text = label != nullptr ? gtk_label_get_text(GTK_LABEL(label)) : "";
const char* key = accel != nullptr ? gtk_label_get_text(GTK_LABEL(accel)) : "";
const bool named = rows < expected && std::strcmp(text, kExpected[rows]) == 0;
// Every label starts at the same x -- that is what "left-aligned" means for a menu whose
// rows differ in whether they carry a check.
if (rows == 0) first_label_x = label_x;
const bool left = label_x >= 0 && label_x <= 40 && label_x == first_label_x;
const bool right = accel == nullptr || (accel_right > 0 && row_w - accel_right <= 12);
const bool compact = row_h > 0 && row_h <= kMenuRowMaxPx;
const bool ok = named && left && right && compact;
if (!ok) ++bad;
std::fprintf(stderr,
"[menu] %d '%s' accel='%s' h=%d label_x=%d accel_right=%d/%d order=%d "
"ok=%d\n", rows, text, key, row_h, label_x, accel_right, row_w,
named ? 1 : 0, ok ? 1 : 0);
++rows;
}
std::fprintf(stderr,
"[menu] rows=%d expected=%d order=%d rows_bad=%d popover_visible=%d "
"mapped=%d button_mapped=%d\n", rows, expected, rows == expected ? 1 : 0, bad,
popover != nullptr ? gtk_widget_get_visible(GTK_WIDGET(popover)) : -1,
popover != nullptr ? gtk_widget_get_mapped(GTK_WIDGET(popover)) : -1,
a->menu_button != nullptr ? gtk_widget_get_mapped(a->menu_button) : -1);
if (popover != nullptr) gtk_popover_set_autohide(popover, TRUE);
gtk_menu_button_popdown(GTK_MENU_BUTTON(a->menu_button));
if (a->status_info_button != nullptr) {
GtkPopover* info = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->status_info_button));
if (info != nullptr) gtk_popover_set_autohide(info, FALSE);
gtk_menu_button_popup(GTK_MENU_BUTTON(a->status_info_button));
}
menus_probe_next(*a, 2, 300);
return G_SOURCE_REMOVE;
}
// The info bubble: its own size against the room above the button it points at. GTK slides a
// popover sideways to fit, so only the vertical placement has to hold -- and it is the one
// that was wrong: below the button is off the window's bottom edge.
GtkWidget* bubble = a->status_popover;
const int bubble_w = bubble != nullptr ? gtk_widget_get_width(bubble) : -1;
const int bubble_h = bubble != nullptr ? gtk_widget_get_height(bubble) : -1;
graphene_rect_t bounds = GRAPHENE_RECT_INIT(0, 0, 0, 0);
const bool have_button = a->status_info_button != nullptr &&
gtk_widget_compute_bounds(a->status_info_button, a->window, &bounds);
const int button_top = have_button ? static_cast<int>(bounds.origin.y) : -1;
const int button_right =
have_button ? static_cast<int>(bounds.origin.x + bounds.size.width) : -1;
const int pos = bubble != nullptr
? static_cast<int>(gtk_popover_get_position(GTK_POPOVER(bubble))) : -1;
const int fits = (pos == GTK_POS_TOP && bubble_h > 0 && bubble_w > 0 &&
button_top >= bubble_h + 14) ? 1 : 0;
std::fprintf(stderr, "[menu] status pos=%s bubble=%dx%d button_top=%d button_right=%d "
"fits=%d\n", pos == GTK_POS_TOP ? "top" : "other", bubble_w, bubble_h,
button_top, button_right, fits);
if (a->status_info_button != nullptr) {
GtkPopover* info = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->status_info_button));
if (info != nullptr) gtk_popover_set_autohide(info, TRUE);
gtk_menu_button_popdown(GTK_MENU_BUTTON(a->status_info_button));
}
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean color_probe(gpointer user) {
App* a = static_cast<App*>(user);
// Any slot with a *bound* trace: an unbound one has no colour on screen to check.
int probe_row = -1;
int probe_slot = -1;
std::string name;
for (int r = 0; r < a->rows->row_count() && probe_row < 0; ++r) {
const RowSet::Row& row = a->rows->row(r);
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (row.signal_name[si].empty() || row.channel[si] < 0) continue;
probe_row = r;
probe_slot = s;
name = row.signal_name[si];
break;
}
}
if (probe_row < 0) {
std::fprintf(stderr, "[color] no bound trace to recolour\n");
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
// The chip as the signal list actually writes it, and the trace as the renderer will read it.
const auto chip_of = [&]() {
std::lock_guard<std::mutex> lock(a->model->mutex());
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == name) return signal_chip_markup(*a, signals, i);
}
return std::string();
};
const auto trace_of = [&]() {
const plot::Plot* p = a->rows->row(probe_row).plot;
if (p == nullptr) return std::string("#??????");
return color_hex(p->trace(static_cast<plot::u32>(probe_slot)).color);
};
// A chip carries its colour inside `<span foreground="...">`, so "the chip says X" is the
// substring test and not a recomputation of what it ought to say.
const auto chip_says = [](const std::string& markup, const std::string& hex) {
return markup.find("\"" + hex + "\"") != std::string::npos ? 1 : 0;
};
const std::string before = trace_of();
std::fprintf(stderr, "[color] '%s' at row %d slot %d: trace=%s chip_agrees=%d (want 1)\n",
name.c_str(), probe_row, probe_slot, before.c_str(),
chip_says(chip_of(), before));
constexpr plot::u32 kPick = 0x123456u;
a->rows->set_signal_color(name, kPick);
// The same path the frame tick takes: a trace's colour is fixed at `add_trace()`, so this is a
// new figure, and reading the old `Row::plot` after it would read the figure that was replaced.
relayout_areas(*a);
const std::string after = trace_of();
std::fprintf(stderr, "[color] override: trace=%s chip_agrees=%d (want #123456 1)\n",
after.c_str(), chip_says(chip_of(), after));
// The session file, which is where a colour has to survive to be worth choosing. Through the real
// writer and reader on a temp path, not the live session's -- a probe must not leave a colour in
// the file a later run would inherit.
const std::string tmp = std::string(g_get_tmp_dir()) + "/udpscope-color-probe.toml";
LayoutState saved = collect_layout(*a);
int round_trip = -1;
std::size_t entries = 0;
if (save_layout(tmp, saved)) {
LayoutState back;
if (load_layout(tmp, back)) {
entries = back.colors.size();
for (const auto& c : back.colors) {
if (c.name == name) round_trip = static_cast<int>(c.rgb);
}
}
}
std::remove(tmp.c_str());
std::fprintf(stderr, "[color] session: entries=%zu rgb=#%06X (want 1 #123456)\n", entries,
round_trip < 0 ? 0u : static_cast<unsigned>(round_trip));
a->rows->clear_signal_color(name);
relayout_areas(*a);
const std::string restored = trace_of();
std::fprintf(stderr, "[color] default: trace=%s chip_agrees=%d (want %s 1)\n", restored.c_str(),
chip_says(chip_of(), restored), before.c_str());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean reseq_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
ScopeModel& m = *a->model;
std::uint64_t nan_slots = 0;
std::uint64_t scanned = 0;
std::size_t open_holes = 0;
int trigger_in_hole = 0;
{
// The hole table and the live store are the producer's, so both are read under the one mutex
// -- the same lock the frame's bind takes (`src/ui.cpp:6263`).
std::lock_guard<std::mutex> lock(m.mutex());
// The first non-math channel's retained window. One channel and not all of them: every
// non-math channel is reserved and repaired by the same loop over the same ticks, so a second
// channel would restate the first's answer at the cost of another pass over the ring.
for (std::size_t i = 0; i < m.channel_count(); ++i) {
if (m.channels()[i].math) continue;
const plot::data::RingBuffer<f32>& v = m.live().values(i);
// `operator[]` is window-relative: index 0 is the slot at `base()`.
scanned = v.produced() - v.base();
for (u64 k = 0; k < scanned; ++k) {
if (!std::isfinite(v[k])) ++nan_slots;
}
break;
}
open_holes = m.holes().size();
// A reserved span's samples are never fed to the trigger engine, so a latch stamped inside one
// would mean they had been. `Hole::x_begin`/`x_end` and `trigger_time()` are both in stream
// seconds, so they compare directly with no epoch in between.
if (m.trigger_time_valid().load()) {
const f64 t = m.trigger_time().load();
for (const Hole& h : m.holes()) {
if (t >= h.x_begin && t <= h.x_end) {
trigger_in_hole = 1;
break;
}
}
}
}
// The shading as the plot actually holds it, read off row 0 after the frame published it. Every
// row carries the same spans, so one row is the whole answer. Split by state because the two
// mean different things: `bands` is what the window is reporting and `bands_repaired` how much
// of that report is historical, so `bands == bands_repaired` with `nan_slots=0` is a healthy
// reading and not the contradiction it would be if the band had one state.
std::size_t bands = 0;
std::size_t bands_repaired = 0;
if (a->rows->row_count() > 0 && a->rows->row(0).plot != nullptr) {
for (const plot::InvalidSpan& span : a->rows->row(0).plot->invalid_spans()) {
++bands;
if (span.repaired) ++bands_repaired;
}
}
std::fprintf(stderr,
"[reseq-probe] reseq=%llu late=%llu stale=%llu overflows=%llu holes=%llu "
"hole_slots=%llu "
"backfilled=%llu open_holes=%zu bands=%zu bands_repaired=%zu nan_slots=%llu "
"scanned=%llu trigger_in_hole=%d\n",
static_cast<unsigned long long>(m.stats().resequenced.load()),
static_cast<unsigned long long>(m.stats().late.load()),
static_cast<unsigned long long>(m.stats().stale.load()),
static_cast<unsigned long long>(m.stats().overflows.load()),
static_cast<unsigned long long>(m.stats().holes.load()),
static_cast<unsigned long long>(m.stats().hole_slots.load()),
static_cast<unsigned long long>(m.stats().backfilled.load()), open_holes, bands,
bands_repaired, static_cast<unsigned long long>(nan_slots),
static_cast<unsigned long long>(scanned), trigger_in_hole);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
void capture_probe_next(App& a, int step, guint delay_ms) {
a.dbg_capture_step = step;
g_timeout_add(delay_ms, capture_probe_step, &a);
}
gboolean capture_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (a->dbg_capture_step == 0) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->capture_button), TRUE);
capture_probe_next(*a, 1, 2500);
return G_SOURCE_REMOVE;
}
const PacketCapture& cap = a->acq->capture();
CaptureReadStats st;
const bool read_ok = read_capture(cap.path(), &st);
// `stored` is the datagrams-since-open counter, which is what must equal the exit
// dump's count and, in the wrap case, must exceed the slot count; `resident` is what
// the file itself holds, which is the slot count once the ring has wrapped.
std::fprintf(stderr,
"[capture-probe] enabled=%d read_ok=%d path=%s slots=%u stored=%llu "
"resident=%llu cursor=%u torn=%llu truncated=%llu header_ok=%d first_seq=%llu "
"last_seq=%llu payload=%llu file=%llu open_ms=%.1f t0=%.3f\n",
cap.enabled() ? 1 : 0, read_ok ? 1 : 0, cap.path().c_str(), st.slot_count,
static_cast<unsigned long long>(cap.stored()),
static_cast<unsigned long long>(st.stored), st.cursor,
static_cast<unsigned long long>(st.torn),
static_cast<unsigned long long>(st.truncated), st.header_ok ? 1 : 0,
static_cast<unsigned long long>(st.first_seq),
static_cast<unsigned long long>(st.last_seq),
static_cast<unsigned long long>(st.payload),
static_cast<unsigned long long>(st.file_bytes), cap.open_ms(), st.t0_realtime);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean configs_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->source_port == 0) return G_SOURCE_REMOVE;
const auto joined = [&]() {
std::string out;
for (const std::string& n : configuration_names(a->source_host, a->source_port)) {
out += (out.empty() ? "" : ",") + n;
}
return out.empty() ? std::string("-") : out;
};
std::fprintf(stderr, "[configs] before=%s active=%s\n", joined().c_str(),
active_configuration(a->source_host, a->source_port).c_str());
const bool saved = save_configuration_as(*a, "probe", "written by the configs probe");
std::fprintf(stderr, "[configs] save-as=%d names=%s active=%s\n", saved ? 1 : 0,
joined().c_str(), active_configuration(a->source_host, a->source_port).c_str());
// The round trip: what a reopened configuration must carry, note and startup settings included.
LayoutState st;
const std::string path = configuration_path(a->source_host, a->source_port, "probe");
const bool read = load_layout(path, st);
int signals = 0;
for (const LayoutState::Row& r : st.rows) {
for (const std::string& sig : r.signals) {
if (!sig.empty()) ++signals;
}
}
std::fprintf(stderr,
"[configs] round trip read=%d comment='%s' buffer_mb=%.6g relative=%d "
"capture=%u plots=%zu signals=%d measures=%zu math=%zu\n",
read ? 1 : 0, st.comment.c_str(), st.buffer_mb, st.relative_time ? 1 : 0,
st.capture_packets, st.rows.size(), signals, st.measures.size(), st.math.size());
forget_configuration(a->source_host, a->source_port, "probe");
std::fprintf(stderr, "[configs] after forget names=%s\n", joined().c_str());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
void rowmenu_probe_next(App& a, int step, guint delay_ms) {
a.dbg_rowmenu_step = step;
g_timeout_add(delay_ms, rowmenu_probe_step, &a);
}
gboolean rowmenu_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->rows->row_count() == 0) return G_SOURCE_REMOVE;
if (a->dbg_rowmenu_step == 0) {
// A trace first, or the remove view has nothing to list and the reading would not notice it
// missing: the point of the probe is the *populated* menu.
if (!a->model->channels().empty()) {
const ChannelInfo& ch = a->model->channels().front();
a->rows->assign(0, 0, ch.name, ch.name,
signal_color_index(a->model->signals(), ch.signal));
a->relayout_pending = true;
}
a->row_menu_popover = open_row_menu(*a, 0, 40.0, 20.0, a->area_widget, /*autohide=*/false);
rowmenu_probe_next(*a, 1, 300);
return G_SOURCE_REMOVE;
}
GtkWidget* popover = a->row_menu_popover;
GtkWidget* box = popover != nullptr ? gtk_popover_get_child(GTK_POPOVER(popover)) : nullptr;
if (a->dbg_rowmenu_step == 1) {
row_menu_measure(box, "main", &g_rowmenu_probe.main_rows, &g_rowmenu_probe.main_bad);
g_rowmenu_probe.remove_row_in_main = row_menu_find(box, "Remove signal") != nullptr ? 1 : 0;
if (GtkWidget* show = row_menu_find(box, "Remove signal")) {
g_signal_emit_by_name(show, "clicked");
}
rowmenu_probe_next(*a, 2, 250);
return G_SOURCE_REMOVE;
}
if (a->dbg_rowmenu_step == 2) {
row_menu_measure(box, "remove", &g_rowmenu_probe.remove_rows, &g_rowmenu_probe.remove_bad);
g_rowmenu_probe.remove_row_gone = row_menu_find(box, "Remove signal") == nullptr ? 1 : 0;
if (GtkWidget* back = row_menu_find(box, "Back")) g_signal_emit_by_name(back, "clicked");
rowmenu_probe_next(*a, 3, 250);
return G_SOURCE_REMOVE;
}
if (a->dbg_rowmenu_step == 3) {
row_menu_measure(box, "after-back", &g_rowmenu_probe.back_rows, &g_rowmenu_probe.back_bad);
if (GtkWidget* again = row_menu_find(box, "Remove signal")) {
g_signal_emit_by_name(again, "clicked");
}
rowmenu_probe_next(*a, 4, 250);
return G_SOURCE_REMOVE;
}
row_menu_measure(box, "second-open", &g_rowmenu_probe.second_rows,
&g_rowmenu_probe.second_bad);
g_rowmenu_probe.can_open_again = row_menu_find(box, "Back") != nullptr ? 1 : 0;
std::fprintf(stderr,
"[rowmenu] summary main_rows=%d main_bad=%d remove_rows=%d remove_bad=%d "
"after_back_rows=%d after_back_bad=%d second_rows=%d second_bad=%d "
"remove_row_in_main=%d remove_row_gone=%d can_open_again=%d\n",
g_rowmenu_probe.main_rows, g_rowmenu_probe.main_bad, g_rowmenu_probe.remove_rows,
g_rowmenu_probe.remove_bad, g_rowmenu_probe.back_rows, g_rowmenu_probe.back_bad,
g_rowmenu_probe.second_rows, g_rowmenu_probe.second_bad,
g_rowmenu_probe.remove_row_in_main, g_rowmenu_probe.remove_row_gone,
g_rowmenu_probe.can_open_again);
// And the removal itself: an entry of the remove view must free that trace of the row. The
// popover is left to the entry's own popdown (the app's path -- a click from *inside* the
// popover), which destroys it through the `closed` handler, so the handle is dropped first.
{
const std::string trace = a->rows->row(0).name[0];
GtkWidget* entry = row_menu_find(box, trace.c_str());
a->row_menu_popover = nullptr;
if (entry != nullptr) g_signal_emit_by_name(entry, "clicked");
const bool freed = a->rows->row(0).channel[0] < 0;
std::fprintf(stderr, "[rowmenu] remove-clicked '%s' trace_freed=%d\n", trace.c_str(),
freed ? 1 : 0);
}
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
void events_probe_next(App& a, int step, guint delay_ms) {
a.dbg_events_step = step;
g_timeout_add(delay_ms, events_probe_step, &a);
}
gboolean events_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (a->dbg_events_step == 0) {
// Before anything: streaming, no trigger, recording **off** (the default) -- so no button, no
// pane, and nothing written to disk that the user did not ask to have written.
report_events_availability(*a, "live, recording off");
// The three clicks a user makes: recording on in the trigger panel, trigger mode on, then the
// 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);
}
report_events_availability(*a, "after trigger on");
if (a->events_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->events_toggle), TRUE);
}
// Read straight after the click, before anything else can call the availability rule: this is the
// reading that says the *button* opens the pane, and it is the one a later replay used to mask.
report_events_availability(*a, "after history button");
// ... and again a few frames later, still with no replay in the way: this is the *click alone*
// path a user takes, which a later replay's own availability call used to mask.
events_probe_next(*a, 5, 500);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 5) {
GtkWidget* box = a->events_revealer != nullptr
? gtk_revealer_get_child(GTK_REVEALER(a->events_revealer))
: nullptr;
std::fprintf(stderr, "[events] click alone: pane=%d pane_w=%d pane_box=%d\n",
a->events_revealer != nullptr && gtk_widget_get_visible(a->events_revealer) ? 1 : 0,
a->events_divider != nullptr ? gtk_paned_get_position(GTK_PANED(a->events_divider))
: -1,
box != nullptr ? gtk_widget_get_width(box) : -1);
events_probe_next(*a, 1, 2500);
return G_SOURCE_REMOVE;
}
if (a->event_store == nullptr) {
std::fprintf(stderr, "[events] no store (no source)\n");
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 1) {
const std::vector<EventRecord> records = a->event_store->list();
std::fprintf(stderr, "[events] dir=%s n=%zu", a->event_store->dir().c_str(), records.size());
if (!records.empty()) {
const EventRecord& r = records.front();
long thumb_bytes = -1;
if (!r.thumbnail.empty()) {
struct stat sb {};
thumb_bytes = ::stat(r.thumbnail.c_str(), &sb) == 0 ? long(sb.st_size) : -2;
}
std::fprintf(stderr,
" newest: stem=%s dur=%.6f pre=%.6f post=%.6f edge=%.6f signals=%zu samples=%zu "
"rate=%.6g seq=%llu thumb_bytes=%ld recorded=%d pane=%d",
r.stem.c_str(), r.duration_s, r.pre_s, r.post_s, r.edge, r.signals, r.samples,
r.rate_hz, static_cast<unsigned long long>(r.seq), thumb_bytes,
a->event_recording ? 1 : 0,
gtk_revealer_get_reveal_child(GTK_REVEALER(a->events_revealer)) ? 1 : 0);
}
std::fprintf(stderr, "\n");
std::fflush(stderr);
if (!records.empty()) {
// Through the *row*, not `replay_event()` directly: that is the gesture -- it loads the
// thumbnail beside the description and then replays -- so the probe drives what a click does.
// A window that is *not* the event's span, and set *before* the click, so the reading after it
// says whether opening the event framed it: with the same window there would be nothing to tell
// "framed" from "untouched".
apply_time_window(*a, 0.5);
GtkListBoxRow* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(a->events_list), 0);
if (row != nullptr) g_signal_emit_by_name(a->events_list, "row-activated", row);
}
// One layout pass before the picture is measured: a widget created a moment ago has no
// allocation yet, so reading its size here would read 0x0 for a pane that is drawing fine.
events_probe_next(*a, 15, 350);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 15) {
// The *allocation*, not only the paint-able: a `GtkPicture` holds a paint-able the moment a file
// is set, so a non-null one says nothing about whether anything is on screen.
const bool picture = a->events_thumb != nullptr &&
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 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,
a->events_divider != nullptr ? gtk_paned_get_position(GTK_PANED(a->events_divider))
: -1,
// The pane's *rendered* width: the position is what we asked for, this is what the
// pane actually got -- a divider at 240 with a zero-width pane is still no pane.
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(),
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);
std::fflush(stderr);
events_probe_next(*a, 2, 1200);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 2) {
// After a replay the model holds the *event's* samples and every channel of it, from the loader
// `--open` uses -- which is the whole assertion: the cache is readable by the instrument.
const std::size_t channels = a->model->channels().size();
const std::size_t samples =
channels > 0 ? a->model->live().values(0).produced() : 0;
std::fprintf(stderr, "[events] replayed: channels=%zu samples=%zu replaying=%d waveform=%d\n",
channels, samples, a->replaying ? 1 : 0, a->waveform_name.empty() ? 0 : 1);
std::fflush(stderr);
go_live_again(*a);
events_probe_next(*a, 3, 900);
return G_SOURCE_REMOVE;
}
std::fprintf(stderr, "[events] live again: replaying=%d waveform=%d\n", a->replaying ? 1 : 0,
a->waveform_name.empty() ? 0 : 1);
// The pane's *other* precondition, through the real switch, in both trigger states: disabling
// recording has to take the button and the pane with it, and arming the trigger afterwards must not
// bring back a pane over an empty cache.
gtk_switch_set_active(GTK_SWITCH(a->events_record_switch), FALSE);
report_events_availability(*a, "recording off, trigger off");
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
report_events_availability(*a, "recording off, trigger on");
// The group layouts need a *second* event, and a scope back on the stream to collect one: recording
// on and the trigger armed again, then step 4 waits for the latch rather than assuming it.
gtk_switch_set_active(GTK_SWITCH(a->events_record_switch), TRUE);
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
events_group_probe_next(*a, 4, 2500);
return G_SOURCE_REMOVE;
}
gboolean events_group_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const std::size_t cached = a->event_store != nullptr ? a->event_store->list().size() : 0;
if (a->dbg_events_step == 4) {
// A bounded wait for the second latch: a source that never latches must report the wait, not hang.
if (cached < 2 && a->dbg_events_wait < 12) {
++a->dbg_events_wait;
events_group_probe_next(*a, 4, 700);
return G_SOURCE_REMOVE;
}
a->dbg_events_wait = 0;
// Open the pane first: its list is only rebuilt while the pane is *up*, so a selection made against
// a collapsed pane selects rows that are not there.
if (a->events_toggle != nullptr &&
!gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->events_toggle))) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->events_toggle), TRUE);
}
// The selection *is* the input, so select the two newest rows the way a user does and press the
// button: `on_group_clicked()` reads the list, not a parameter.
GtkListBox* list = GTK_LIST_BOX(a->events_list);
for (int i = 0; i < 2; ++i) {
if (GtkListBoxRow* row = gtk_list_box_get_row_at_index(list, i)) {
gtk_list_box_select_row(list, row);
}
}
std::fprintf(stderr,
"[events] compare buttons: cached=%zu selected=%zu compare=%d accumulate=%d\n",
cached, selected_events(*a).size(),
a->events_compare != nullptr && gtk_widget_get_sensitive(a->events_compare) ? 1 : 0,
a->events_accumulate != nullptr && gtk_widget_get_sensitive(a->events_accumulate) ? 1
: 0);
if (a->events_compare != nullptr) g_signal_emit_by_name(a->events_compare, "clicked");
// Read one step later: a rebuilt row's traces are bound by the next *frame*, so a reading taken in
// the same handler as the click reads the frame before the bind.
events_group_probe_next(*a, 45, 700);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 45) {
std::lock_guard<std::mutex> lock(a->model->mutex());
std::fprintf(stderr,
"[events] compare: kind=%d rows=%d channels=%zu samples0=%llu traces0=%d traces1=%d "
"window=%.3f recording=%d pane=%d\n",
static_cast<int>(a->group_kind), a->rows->row_count(),
a->model->channels().size(),
static_cast<unsigned long long>(a->model->live().values(0).produced()),
group_visible_traces(*a, 0), group_visible_traces(*a, 1), a->rows->time_window(),
a->event_recording ? 1 : 0,
a->events_revealer != nullptr && gtk_widget_get_visible(a->events_revealer) ? 1 : 0);
events_group_probe_next(*a, 46, 400);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 46) {
// The second plot: on screen, the same number of rows as the first, and both on one x window -- then
// a nudge to the *first* plot's row 0, read back from the second a couple of frames later. That last
// reading is the feature: a zoom in one row reaches the same row of the other plot.
const int rows_a = a->rows != nullptr ? a->rows->row_count() : -1;
const int rows_b = a->rows_b != nullptr ? a->rows_b->row_count() : -1;
const bool area_b = a->area_b_widget != nullptr && gtk_widget_get_visible(a->area_b_widget);
std::fprintf(stderr, "[events] compare plots: area_b=%d rows_a=%d rows_b=%d", area_b ? 1 : 0,
rows_a, rows_b);
if (area_b && rows_a > 0 && rows_b == rows_a && a->rows->row(0).plot != nullptr &&
a->rows_b->row(0).plot != nullptr) {
int entries = 0;
for (GtkWidget* w = gtk_widget_get_first_child(a->signal_list); w != nullptr;
w = gtk_widget_get_next_sibling(w)) {
++entries;
}
const plot::Range ax = a->rows->row(0).plot->x_range(a->rows->row(0).axis_x);
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});
}
std::fprintf(stderr, "\n");
std::fflush(stderr);
events_group_probe_next(*a, 47, 400);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 47) {
// The mirror, a couple of frames after the nudge above: the y range set on the *first* plot has to
// be the second's too. Read before the accumulation, which rebuilds the model and the rows.
double y_b = 0.0;
double y_a = 0.0;
if (a->rows_b != nullptr && a->rows_b->row(0).plot != nullptr) {
y_b = a->rows_b->row(0).plot->y_range(a->rows_b->row(0).axis_y).min;
}
if (a->rows != nullptr && a->rows->row(0).plot != nullptr) {
y_a = a->rows->row(0).plot->y_range(a->rows->row(0).axis_y).min;
}
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);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 55) {
{
std::lock_guard<std::mutex> lock(a->model->mutex());
std::fprintf(stderr,
"[events] accumulate: kind=%d rows=%d channels=%zu samples0=%llu selected=%zu "
"traces0=%d window=%.3f\n",
static_cast<int>(a->group_kind), a->rows->row_count(),
a->model->channels().size(),
static_cast<unsigned long long>(a->model->live().values(0).produced()),
selected_events(*a).size(), group_visible_traces(*a, 0), a->rows->time_window());
}
events_group_probe_next(*a, 59, 400);
return G_SOURCE_REMOVE;
}
return G_SOURCE_REMOVE;
}
void events_group_probe_next(App& a, int step, guint delay_ms) {
a.dbg_events_step = step;
g_timeout_add(delay_ms, events_group_probe_step, &a);
}
gboolean drop_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->model->signals().empty()) return G_SOURCE_REMOVE;
std::fprintf(stderr, "[drop-probe] signals=%zu channels=%zu rows=%d slots0=[%s|%s|%s|%s]\n",
a->model->signals().size(), a->model->channels().size(), a->rows->row_count(),
a->rows->row(0).name[0].c_str(), a->rows->row(0).name[1].c_str(),
a->rows->row(0).name[2].c_str(), a->rows->row(0).name[3].c_str());
for (int signal = 0; signal < 2 && static_cast<std::size_t>(signal) < a->model->signals().size();
++signal) {
GValue value = G_VALUE_INIT;
g_value_init(&value, G_TYPE_STRING);
g_value_set_string(&value, std::to_string(signal).c_str());
const gboolean ok = on_drop_channel(nullptr, &value, 40.0, 20.0, a);
g_value_unset(&value);
std::fprintf(stderr, "[drop-probe] dropped signal %d -> %d slots=[%s|%s|%s|%s]\n", signal,
ok ? 1 : 0, a->rows->row(0).name[0].c_str(), a->rows->row(0).name[1].c_str(),
a->rows->row(0).name[2].c_str(), a->rows->row(0).name[3].c_str());
}
// Fill the row first, so that the drop below is the *refusal*: a drop can otherwise land in a slot
// whose signal is named but not acquired, which is a success rather than the case under test.
{
const std::vector<ChannelInfo>& channels = a->model->channels();
for (int slot = 0; slot < RowSet::kSlotsPerRow && !channels.empty(); ++slot) {
if (a->rows->slot_bound(0, slot)) continue;
const ChannelInfo& ch = channels[static_cast<std::size_t>(slot) % channels.size()];
a->rows->assign(0, slot, ch.name, ch.name, 0);
}
}
// The refusal's *feedback*, a tick later: the status line composes the notice into itself on the
// next tick, so this is where both the text and the class that makes it visible can be read.
g_timeout_add(500, +[](gpointer u) -> gboolean {
App* app = static_cast<App*>(u);
// And the drop that the full row has to refuse, fired here so its feedback is what the reading below
// sees rather than the last success's.
GValue refused_value = G_VALUE_INIT;
g_value_init(&refused_value, G_TYPE_STRING);
g_value_set_string(&refused_value, "1");
const gboolean refused = on_drop_channel(nullptr, &refused_value, 40.0, 20.0, app);
g_value_unset(&refused_value);
std::fprintf(stderr, "[drop-probe] full row refused the drop: %d (want 0)\n", refused ? 1 : 0);
// The message and its class, which are set the moment the drop is refused. The *label* is read in
// the later line instead: the status line composes the notice into itself on its own cadence, so
// here it may still hold the previous one.
std::fprintf(stderr, "[drop-probe] refused feedback: notice='%s' warning=%d\n",
app->notice.c_str(),
gtk_widget_has_css_class(app->status_label, "warning") ? 1 : 0);
std::fflush(stderr);
// Again a second and a half later: if the label is still the previous notice then the status line
// never recomposes it, which would be the whole of the "no clear feedback" report.
g_timeout_add(1500, +[](gpointer u) -> gboolean {
App* app = static_cast<App*>(u);
std::fprintf(stderr,
"[drop-probe] the status line, composed: warning=%d status='%s'\n",
gtk_widget_has_css_class(app->status_label, "warning") ? 1 : 0,
gtk_label_get_text(GTK_LABEL(app->status_label)));
std::fflush(stderr);
return G_SOURCE_REMOVE;
}, app);
return G_SOURCE_REMOVE;
}, a);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean defaults_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->model->signals().empty()) return G_SOURCE_REMOVE;
open_calibration(*a, GTK_WINDOW(a->window), 0);
std::vector<double> cal;
{
// The dialog is a plain window with a title; find it by that title, because
// `open_calibration()` keeps no handle -- the probe is not going to add one just for itself.
GListModel* windows = gtk_window_get_toplevels();
const guint n = g_list_model_get_n_items(windows);
for (guint i = 0; i < n; ++i) {
GtkWindow* w = GTK_WINDOW(g_list_model_get_item(windows, i));
const char* title = gtk_window_get_title(w);
if (title != nullptr && std::string(title) == "Calibrate") {
collect_spins(gtk_window_get_child(w), &cal);
gtk_window_close(w);
}
g_object_unref(w);
}
}
std::vector<double> trig;
collect_spins(a->trigger_panel, &trig);
const std::pair<double, double> first = a->model->calibration(0);
std::fprintf(stderr, "[defaults] calibrate:");
for (double v : cal) std::fprintf(stderr, " %.6g", v);
std::fprintf(stderr, " model=%.6g %.6g | trigger:", first.first, first.second);
for (double v : trig) std::fprintf(stderr, " %.6g", v);
std::fprintf(stderr, "\n");
// A signal that *is* calibrated: the dialog must open at the stored values, which is the whole
// point of reading them. Set through the model's own setter -- the dialog's Apply calls it -- and
// read back through the dialog, so what is asserted is that the two agree.
a->model->set_calibration(0, 2.5, -1.25);
cal.clear();
open_calibration(*a, GTK_WINDOW(a->window), 0);
{
GListModel* windows = gtk_window_get_toplevels();
const guint n = g_list_model_get_n_items(windows);
for (guint i = 0; i < n; ++i) {
GtkWindow* w = GTK_WINDOW(g_list_model_get_item(windows, i));
const char* title = gtk_window_get_title(w);
if (title != nullptr && std::string(title) == "Calibrate") {
collect_spins(gtk_window_get_child(w), &cal);
gtk_window_close(w);
}
g_object_unref(w);
}
}
const std::pair<double, double> stored = a->model->calibration(0);
std::fprintf(stderr, "[defaults] calibrated:");
for (double v : cal) std::fprintf(stderr, " %.6g", v);
std::fprintf(stderr, " model=%.6g %.6g\n", stored.first, stored.second);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean keys_probe(gpointer user) {
App* a = static_cast<App*>(user);
std::string line = "[keys]";
const auto before_after = [&](const char* name, GtkWidget* button, AppAction action) {
const int before = button != nullptr
? (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(button)) ? 1 : 0)
: -1;
run_app_action(*a, action);
const int after = button != nullptr
? (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(button)) ? 1 : 0)
: -1;
char buf[96];
std::snprintf(buf, sizeof(buf), " %s: %d->%d |", name, before, after);
line += buf;
};
// Read the lock-protected field, not the atomic mirror: on_arm_clicked() writes
// trigger().armed directly, while trigger_armed() (the atomic) is updated by the
// acquisition thread on the next packet -- reading it immediately would race.
const bool armed_before = a->model->trigger().armed;
run_app_action(*a, AppAction::TriggerArm);
char armed[64];
std::snprintf(armed, sizeof(armed), " a: armed %d->%d |", armed_before ? 1 : 0,
a->model->trigger().armed ? 1 : 0);
line += armed;
const bool paused_before = a->acq->paused();
run_app_action(*a, AppAction::PauseToggle);
char paused[64];
std::snprintf(paused, sizeof(paused), " space: paused %d->%d |", paused_before ? 1 : 0,
a->acq->paused() ? 1 : 0);
line += paused;
run_app_action(*a, AppAction::PauseToggle); // leave the run live for the other probes
before_after("t: trigger panel", a->trigger_toggle, AppAction::TriggerPanel);
before_after("c: cursors", a->cursors_toggle, AppAction::CursorsPane);
before_after("m: measures", a->measures_toggle, AppAction::MeasuresPane);
before_after("g: math", a->math_toggle, AppAction::MathPane);
before_after("l: layout", a->layout_toggle, AppAction::LayoutEditor);
before_after("s: side panel", a->sidebar_toggle, AppAction::SidePanel);
// An accel must be refused while text is being typed. Focus the window entry, send 'm'
// through the real handler, and require the measurements toggle not to move.
int refused = -1;
if (a->window_entry != nullptr) {
gtk_menu_button_popup(GTK_MENU_BUTTON(a->window_button));
gtk_widget_grab_focus(a->window_entry);
const int before = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->measures_toggle))
? 1 : 0;
const gboolean handled = on_app_key_pressed(nullptr, GDK_KEY_m, 0,
static_cast<GdkModifierType>(0), a);
const int after = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->measures_toggle))
? 1 : 0;
refused = (handled == FALSE && before == after) ? 1 : 0;
gtk_menu_button_popdown(GTK_MENU_BUTTON(a->window_button));
}
char tail[64];
std::snprintf(tail, sizeof(tail), " editable focused: 'm' refused=%d\n", refused);
line += tail;
std::fprintf(stderr, "%s", line.c_str());
std::fflush(stderr);
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();
const std::string session =
layout_path(a->model->config().server, a->model->config().port);
show_help(GTK_WINDOW(a->window));
show_about(GTK_WINDOW(a->window), session);
show_shortcuts(GTK_WINDOW(a->window));
// Read while it is open: the icon is the point of the dialog carrying one, and
// `close_help_windows()` is right below.
const std::string about_icon = about_icon_name();
close_help_windows();
int accel_count = 0;
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 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, 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);
return G_SOURCE_REMOVE;
}
} // namespace ui_impl
} // namespace udpscope