The help was 14 Pango-markup `.txt` bodies in a second GResource, drawn into a paged `GtkWindow` built in `help_window.cpp`. That made F1 a viewer of ours with no search index, no cross-references and no translation, and it kept the manual in the *binary* when every other desktop application's is a document on disk. It is now a real Mallard set: help/C/org.udpscope.app/ index.page + 14 topic pages + figures/*.png and `show_help()` hands `help:org.udpscope.app` to whatever the desktop registered for the `help:` scheme -- Yelp on GNOME -- through a `GtkUriLauncher`, reporting a launch failure with a `GtkAlertDialog` rather than a click that silently does nothing. The paged window's builder, the page table and `help_self_check()`'s markup validation are gone with the resource. The document identifier is the desktop file's own basename (its desktop-file id), derived in CMakeLists from `data/org.udpscope.app.desktop.in`, so the same string is the `.desktop` name, the GApplication id and the install directory at once. The set is installed by a new `install()` rule to `<datadir>/help/C/org.udpscope.app/` -- where the freedesktop Help System Specification's `help:` URI resolves `help:<id>` -- so the manual is read from a datadir by another process even though the binary has no install step. `UDPSCOPE_HELP_NAME` and `UDPSCOPE_HELP_DIR` are compiled in from that one place. The 14 pages were migrated fact for fact, but restructured the Mallard way: a `<desc>` per page (what a search result shows), `<steps>` for procedures, `<terms>` for option lists, `<gui>`/`<key>`/`<keyseq>` for interface words and keys, `<note>` for the asides, and a topic graph via `<link type="guide" xref="index#main"/>`. The figures are annotated screenshots produced by `tools/make-help-shots.sh`, which drives the app headlessly against `tools/mock_udpstreamer.py` and draws the boxes with ImageMagick; the side panel's box is measured from the app's own `side=NNNx` dump reading, the header and status bands come from a documented table, and a crop whose standard deviation is 0 fails the script. `cmake --build build --target test-help` checks the set the way `test-ui` checks the `.ui` files: `yelp-check`'s offline subcommands (links, media, orphans, ids) and a `yelp-build html` render. `yelp-check validate` is deliberately absent -- it fetches the Mallard RelaxNG schema over the network and fails offline for that reason, not for a fault in the pages. The `[help]` probe reading changes with the resource it was written for. `parsed == pages` over in-binary markup cannot exist now, so `help_self_check()` scans `UDPSCOPE_HELP_DIR` and the line reads `topics=14 index=1 figures=3`; `topics=0 index=0` is the manual missing from the tree, which is the finding the old count named. Verified: clean build with the documented three warnings (the help bundle's `-Woverlength-strings` is gone, so the baseline moves from four to three); test-ui 14/14; test-help clean (the four subcommands and 15 pages rendered); a temp-prefix install lands 15 `.page` files and 3 figures at `share/help/C/org.udpscope.app/`; the headless run reads `[help] topics=14 index=1 figures=3 dir=...` with 0 Gtk-CRITICAL. Yelp's own window could not be exercised on this box -- it starts under Xvfb, activates its portals and never maps, the same WebKit/portal limitation that stops a grab -- so its rendering and its search index are unverified here; the content pipeline was (`yelp-build` renders the whole set). Docs: the manual's build/install/open story in `build-and-run.md` and README; the warning baseline in `verification.md`/CLAUDE.md; the help bullets in `invariants-gtk-and-chrome.md`; the `[help]` line in `probe-readings.md`.
4484 lines
216 KiB
C++
4484 lines
216 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_panel);
|
|
a->dbg_signal_h = gtk_widget_get_height(a->signal_panel);
|
|
a->dbg_trigger_w = gtk_widget_get_width(a->trigger_panel);
|
|
a->dbg_trigger_h = gtk_widget_get_height(a->trigger_panel);
|
|
// The switcher's icon as it is *rendered*: a 32x16 glyph in a 32px box draws 1:1, which is what
|
|
// keeps its 2px bars crisp rather than blurred by a fractional scale. Read here and not on the
|
|
// first tick, where the window has not been allocated yet and every widget still measures 0x0.
|
|
if (a->side_switcher != nullptr) {
|
|
a->dbg_switcher_w = gtk_widget_get_width(a->side_switcher);
|
|
a->dbg_switcher_h = gtk_widget_get_height(a->side_switcher);
|
|
}
|
|
// How many rows of the signal list carry a drag source: dragging a signal onto a plot is how a plot is
|
|
// filled, so it has to survive both pane modes -- the locked list drops the tick, the menu and the `+`,
|
|
// and this is the reading that says it did not drop the drag with them. One row is the `MATH` divider,
|
|
// which is not draggable: the count must be `rows - 1`.
|
|
if (a->signal_list != nullptr) {
|
|
int sources = 0;
|
|
for (GtkWidget* r = gtk_widget_get_first_child(a->signal_list); r != nullptr;
|
|
r = gtk_widget_get_next_sibling(r)) {
|
|
GListModel* controllers = gtk_widget_observe_controllers(r);
|
|
const guint n = controllers != nullptr ? g_list_model_get_n_items(controllers) : 0;
|
|
for (guint i = 0; i < n; ++i) {
|
|
GObject* c = G_OBJECT(g_list_model_get_item(controllers, i));
|
|
if (GTK_IS_DRAG_SOURCE(c)) ++sources;
|
|
g_object_unref(c);
|
|
}
|
|
}
|
|
a->dbg_signal_drag_sources = sources;
|
|
}
|
|
// A drag out of an *overlaid* sidebar: the sidebar has to step aside or the drop has nowhere to land.
|
|
// The state is forced the way the sidebar probe forces it -- collapsed is what a window with no room
|
|
// beside the plot produces -- and the drag is begun through the *source*, so the handler is the one a
|
|
// real drag reaches.
|
|
if (a->side_view != nullptr && a->signal_list != nullptr) {
|
|
AdwOverlaySplitView* split = ADW_OVERLAY_SPLIT_VIEW(a->side_view);
|
|
GtkWidget* row = gtk_widget_get_first_child(a->signal_list);
|
|
adw_overlay_split_view_set_collapsed(split, TRUE);
|
|
adw_overlay_split_view_set_show_sidebar(split, TRUE);
|
|
if (row != nullptr) {
|
|
GListModel* controllers = gtk_widget_observe_controllers(row);
|
|
const guint n = controllers != nullptr ? g_list_model_get_n_items(controllers) : 0;
|
|
for (guint i = 0; i < n; ++i) {
|
|
GObject* c = G_OBJECT(g_list_model_get_item(controllers, i));
|
|
if (GTK_IS_DRAG_SOURCE(c)) {
|
|
g_signal_emit_by_name(c, "drag-begin", nullptr);
|
|
a->dbg_drag_hid_sidebar =
|
|
adw_overlay_split_view_get_show_sidebar(split) == FALSE ? 1 : 0;
|
|
row_drag_sidebar(*a, false);
|
|
a->dbg_drag_restored_sidebar =
|
|
adw_overlay_split_view_get_show_sidebar(split) != FALSE ? 1 : 0;
|
|
}
|
|
g_object_unref(c);
|
|
}
|
|
}
|
|
// Left as the window's own width decides, so the readings taken before this are the ones that stand.
|
|
adw_overlay_split_view_set_collapsed(split, FALSE);
|
|
adw_overlay_split_view_set_show_sidebar(split, TRUE);
|
|
}
|
|
// The panes' own switches, and *both* directions of the sync: the header bar's trigger button is
|
|
// flipped here and the pane's switch has to follow, then the cursors' switch is driven from the
|
|
// pane and the button is what has to follow. Both are read back after the fact, so the two pairs in
|
|
// the dump are the claim: they agree, having been moved from opposite ends.
|
|
if (a->trigger_toggle != nullptr) {
|
|
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
|
|
}
|
|
a->dbg_panel_switch_trigger =
|
|
a->trigger_switch != nullptr && gtk_switch_get_active(GTK_SWITCH(a->trigger_switch)) ? 1 : 0;
|
|
a->dbg_toolbar_switch_trigger =
|
|
a->trigger_toggle != nullptr &&
|
|
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->trigger_toggle))
|
|
? 1 : 0;
|
|
if (a->cursors_switch != nullptr) {
|
|
gtk_switch_set_active(GTK_SWITCH(a->cursors_switch), TRUE);
|
|
a->dbg_panel_switch_cursors = gtk_switch_get_active(GTK_SWITCH(a->cursors_switch)) ? 1 : 0;
|
|
a->dbg_toolbar_switch_cursors =
|
|
a->cursors_toggle != nullptr &&
|
|
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->cursors_toggle))
|
|
? 1 : 0;
|
|
a->dbg_panel_drove_toolbar = a->dbg_toolbar_switch_cursors;
|
|
}
|
|
for (GtkWidget* pill = a->side_switcher != nullptr ? gtk_widget_get_first_child(a->side_switcher)
|
|
: nullptr;
|
|
pill != nullptr; pill = gtk_widget_get_next_sibling(pill)) {
|
|
const std::function<void(GtkWidget*)> measure_icon = [&](GtkWidget* node) {
|
|
if (GTK_IS_IMAGE(node) && gtk_widget_get_mapped(node)) {
|
|
a->dbg_switcher_icon_w = std::max(a->dbg_switcher_icon_w, gtk_widget_get_width(node));
|
|
a->dbg_switcher_icon_h = std::max(a->dbg_switcher_icon_h, gtk_widget_get_height(node));
|
|
}
|
|
for (GtkWidget* c = gtk_widget_get_first_child(node); c != nullptr;
|
|
c = gtk_widget_get_next_sibling(c)) {
|
|
measure_icon(c);
|
|
}
|
|
};
|
|
measure_icon(pill);
|
|
}
|
|
// The height of one signal row, which is the thing "the channel toggle is too tall"
|
|
// was about. The panel'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_sensitive = a->cursors_reset != nullptr && gtk_widget_get_sensitive(a->cursors_reset) ? 1 : 0;
|
|
// A page, not a revealer: a stack's hidden page measures 0x0 the way an unrevealed revealer
|
|
// did, so the reading asks about *state* -- is this page the current one -- and never about size.
|
|
const char* page = a->side_stack != nullptr
|
|
? adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(a->side_stack))
|
|
: nullptr;
|
|
const int on_signals = page != nullptr && std::strcmp(page, "signals") == 0 ? 1 : 0;
|
|
a->dbg_signal_page_current =
|
|
on_signals && gtk_widget_get_first_child(a->signal_list) != nullptr ? 1 : 0;
|
|
a->dbg_trigger_sensitive = on_signals && gtk_widget_get_sensitive(a->trigger_body) ? 1 : 0;
|
|
// `gtk_widget_get_visible` on the sidebar's GtkBox is always 1: AdwOverlaySplitView hides its
|
|
// sidebar child via `gtk_widget_set_child_visible()`, not `gtk_widget_set_visible()`. The useful
|
|
// reading is whether the split view is currently showing the sidebar panel.
|
|
const int sidebar_shown = a->side_view != nullptr &&
|
|
adw_overlay_split_view_get_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(a->side_view)) ? 1 : 0;
|
|
a->dbg_side_column_after_hide = sidebar_shown;
|
|
a->dbg_switcher_visible =
|
|
a->side_switcher != nullptr && gtk_widget_get_visible(a->side_switcher) ? 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.
|
|
if (window != nullptr) gtk_window_destroy(GTK_WINDOW(window));
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
|
|
/// The frame after the pane was locked: read the compact arm control's *allocation*, then leave the
|
|
/// configuration mode and trigger mode as step 2 used to, and hand the chain back its 400 ms. The button
|
|
/// is created hidden and this is the first tick it is shown, so its width in the same tick would always
|
|
/// read 0 -- a number that says "not yet laid out" rather than "not on the surface".
|
|
gboolean compact_arm_step(gpointer user) {
|
|
App* a = static_cast<App*>(user);
|
|
GtkWidget* b = a->trigger_summary_arm;
|
|
a->dbg_summary_arm_width = b != nullptr ? gtk_widget_get_width(b) : -1;
|
|
std::fprintf(stderr,
|
|
"[udpscope] compact arm probe: locked=%d enabled=%d own=%d effective=%d "
|
|
"width=%d panel_width=%d label=%s\n",
|
|
a->trigger_locked ? 1 : 0,
|
|
a->trigger_toggle != nullptr &&
|
|
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->trigger_toggle)) != FALSE
|
|
? 1 : 0,
|
|
b != nullptr && gtk_widget_get_visible(b) ? 1 : 0,
|
|
b != nullptr && gtk_widget_is_visible(b) ? 1 : 0, a->dbg_summary_arm_width,
|
|
// The body's own Arm button, read only if it is itself on screen: with the pane locked it
|
|
// is not, and its stale allocation would read as if it were. -1 is "not the one on screen".
|
|
// `is_visible`, not `get_visible`: the flag is the widget's own and stays true while its
|
|
// *body* is hidden, which reported the body button's stale allocation as a live one.
|
|
a->arm_button != nullptr && gtk_widget_is_visible(a->arm_button)
|
|
? gtk_widget_get_width(a->arm_button)
|
|
: -1,
|
|
a->dbg_summary_arm_label.c_str());
|
|
std::fflush(stderr);
|
|
if (a->trigger_edit != nullptr) {
|
|
g_signal_emit_by_name(a->trigger_edit, "clicked"); // and back to the view only mode
|
|
}
|
|
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 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;
|
|
}
|
|
// The combination the compact control exists for: the trigger *on*, the pane *locked*, so the body's
|
|
// own Arm button has gone with the body. Locking is the [edit] button's click, because that is what a
|
|
// user does, and the two properties read are the two the user sees. The label is checked against the
|
|
// engine's armed state rather than against a word: a single-shot latch can have disarmed it by now.
|
|
if (a->trigger_edit != nullptr) {
|
|
g_signal_emit_by_name(a->trigger_edit, "clicked"); // into configuration mode
|
|
GtkWidget* b = a->trigger_summary_arm;
|
|
a->dbg_summary_arm_visible = b != nullptr && gtk_widget_get_visible(b) ? 1 : 0;
|
|
a->dbg_summary_arm_sensitive = b != nullptr && gtk_widget_get_sensitive(b) ? 1 : 0;
|
|
const char* text = b != nullptr && GTK_IS_BUTTON(b) ? gtk_button_get_label(GTK_BUTTON(b)) : nullptr;
|
|
a->dbg_summary_arm_label = text != nullptr ? text : "-";
|
|
}
|
|
// The pane stays locked for one more frame: the allocation the next step reads is only meaningful once
|
|
// a frame has run with the control shown. Leaving trigger mode is that step's job now.
|
|
g_timeout_add(400, compact_arm_step, 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;
|
|
// The channel the graphs are built from is enabled *here*, for the reason the noise and measurement
|
|
// probes give: `enable_all_probe` finishes by switching one signal off, and with `sine` gone the graphs
|
|
// land on `square`, which turns the documented "2 math channels on 'sine' and 'noise'" into a different
|
|
// line for reasons that have nothing to do with the math.
|
|
{
|
|
const std::vector<SignalInfo>& signals = a->model->signals();
|
|
for (std::size_t i = 0; i < signals.size(); ++i) {
|
|
if (signals[i].name == "sine") a->model->set_signal_enabled(i, true);
|
|
}
|
|
}
|
|
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);
|
|
|
|
// A third channel so the editor's new form row is built and synced on a baseline run. Assigned field
|
|
// by field: a partial brace-initialiser would add a fifth build warning.
|
|
MathChannel dn;
|
|
dn.name = "math denoise";
|
|
dn.enabled = true;
|
|
MathNode dn_src;
|
|
dn_src.op = MathOp::Source;
|
|
dn_src.source = name;
|
|
MathNode dn_op;
|
|
dn_op.op = MathOp::DeNoise;
|
|
dn_op.in_a = 0;
|
|
dn_op.med_n = 5;
|
|
dn_op.fc = 500.0;
|
|
dn.nodes = {dn_src, dn_op};
|
|
dn.output = 1;
|
|
channels.push_back(dn);
|
|
|
|
// 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] 3 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;
|
|
// The signal this probe measures is enabled *here*, for the same reason the noise probe enables its own:
|
|
// a probe must not depend on another probe's side effects. `enable_all_probe` ends by switching a signal
|
|
// off (it reads what a disabled signal does to the plots), and with `sine` gone the readings below were
|
|
// taken on `square` -- where `top` is 1.0, not the 2/pi the documented baseline is.
|
|
{
|
|
const std::vector<SignalInfo>& signals = a->model->signals();
|
|
for (std::size_t i = 0; i < signals.size(); ++i) {
|
|
if (signals[i].name == "sine") a->model->set_signal_enabled(i, true);
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
|
|
/// Phase two of the tab-switch probe: the hidden page's accumulator must have advanced. A statistics
|
|
/// readout is *history*, so min/max/avg would gain a gap for exactly as long as the trigger was on
|
|
/// screen -- which is the one defect the design's "what runs on a hidden page" rule is there to
|
|
/// prevent, and the reason no frame-rate code may be gated on which tab is current.
|
|
gboolean side_tab_probe_report(gpointer user) {
|
|
App* a = static_cast<App*>(user);
|
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
|
std::uint64_t advanced = 0;
|
|
std::uint64_t min_n = 0;
|
|
bool first = true;
|
|
for (std::size_t i = 0; i < a->measure_stats.size(); ++i) {
|
|
const std::uint64_t n = a->measure_stats[i].n;
|
|
if (i < g_side_tab_n_before.size() && n > g_side_tab_n_before[i]) ++advanced;
|
|
if (first || n < min_n) {
|
|
min_n = n;
|
|
first = false;
|
|
}
|
|
}
|
|
std::fprintf(stderr,
|
|
"[side-tab-probe] page=%s rows=%zu accums=%zu advanced=%" G_GUINT64_FORMAT
|
|
" min_n=%" G_GUINT64_FORMAT "\n",
|
|
a->side_tab.c_str(), a->measures.size(), a->measure_stats.size(), advanced, min_n);
|
|
std::fflush(stderr);
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
|
|
/// Phase one: four measurements on the first channel, statistics on, then switch *away* from the
|
|
/// Measure tab and come back in two seconds to read what ran while it was not on screen.
|
|
gboolean side_tab_probe(gpointer user) {
|
|
App* a = static_cast<App*>(user);
|
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
|
// One signal on, and on a plot, the way `enable_all_probe` does it and a restored session arrives.
|
|
// Both halves matter here: a signal starts disabled (enabling is what starts acquisition), and a
|
|
// channel with no trace leaves the live window *still* -- and a still window is exactly the state
|
|
// the tick is supposed to skip, so a probe that left it that way would be testing nothing. This
|
|
// probe is about the case where the window does move while the page is somewhere else.
|
|
std::size_t pick = a->model->signals().size();
|
|
{
|
|
const std::vector<SignalInfo>& signals = a->model->signals();
|
|
for (std::size_t i = 0; i < signals.size(); ++i) {
|
|
if (ScopeModel::is_time_signal(signals, i)) continue; // the time base is not acquired
|
|
pick = i;
|
|
break;
|
|
}
|
|
}
|
|
if (pick >= a->model->signals().size()) return G_SOURCE_REMOVE;
|
|
a->model->set_signals_enabled({pick}, true);
|
|
std::string name;
|
|
{
|
|
const std::vector<ChannelInfo>& channels = a->model->channels();
|
|
if (channels.empty()) return G_SOURCE_REMOVE;
|
|
name = channels.front().name;
|
|
a->rows->assign(0, 0, channels.front().name, channels.front().name,
|
|
signal_color_index(a->model->signals(), channels.front().signal));
|
|
}
|
|
a->relayout_pending = true;
|
|
if (a->measures.empty()) {
|
|
for (const MeasureType t : {MeasureType::Average, MeasureType::Top, MeasureType::PeakToPeak,
|
|
MeasureType::Amplitude}) {
|
|
MeasureOptions options;
|
|
options.show_on_plot = measure_can_show_on_plot(t);
|
|
a->measures.push_back({name, t, options});
|
|
}
|
|
rebuild_measures_pane(*a);
|
|
}
|
|
if (a->measures_stats_check != nullptr) {
|
|
gtk_check_button_set_active(GTK_CHECK_BUTTON(a->measures_stats_check), TRUE);
|
|
}
|
|
select_side_tab(*a, "measure");
|
|
update_measures(*a, /*force=*/true, /*accumulate=*/true);
|
|
g_side_tab_n_before.clear();
|
|
for (const MeasureStatsAccum& s : a->measure_stats) g_side_tab_n_before.push_back(s.n);
|
|
// Away from the page the measurements are on: its widgets are now unrealized and measure 0x0, and
|
|
// the tick keeps calling `update_measures()` regardless.
|
|
select_side_tab(*a, "signals");
|
|
std::fprintf(stderr, "[side-tab-probe] page=%s accums=%zu baseline recorded\n",
|
|
a->side_tab.c_str(), g_side_tab_n_before.size());
|
|
std::fflush(stderr);
|
|
// Long enough for several throttled recomputes (one per 100 ms while the window moves).
|
|
g_timeout_add(2000, side_tab_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;
|
|
double dt = 0.0;
|
|
{
|
|
std::lock_guard<std::mutex> lock(a->model->mutex());
|
|
c = a->model->trigger().condition;
|
|
channel = a->model->trigger().channel;
|
|
dt = a->model->partition().slot_dt();
|
|
}
|
|
a->search.start(c, channel, dt);
|
|
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);
|
|
// In one call, the way a restored session does it: enabling them one at a time re-plans
|
|
// the buffer per signal, so a probe that did that would not be measuring the path a real
|
|
// start takes (and `[gen]` would read a burst that the application no longer produces).
|
|
{
|
|
std::vector<std::size_t> all(a->model->signals().size());
|
|
for (std::size_t i = 0; i < all.size(); ++i) all[i] = i;
|
|
a->model->set_signals_enabled(all, 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;
|
|
// The startup notice comes with the reading, because it is the window's only answer to "why is
|
|
// nothing happening yet": it is set as the window is presented, before the first CONFIG can take
|
|
// the model's mutex, and it names the buffer being planned. An empty `notice` here is the window
|
|
// that used to come up silent while a large buffer was allocated.
|
|
std::fprintf(stderr, "[enable-all-probe] %zu channel(s) enabled and assigned to plots, notice='%s'\n",
|
|
a->model->channel_count(), a->notice.c_str());
|
|
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", ".mf3"}) {
|
|
const std::string path = prefix + ext;
|
|
std::string error;
|
|
const bool ok = write_export(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());
|
|
}
|
|
// What the `.mf3` was handed, in the shape the file stores it: the master and the relative
|
|
// axis are channels too, and `record` is the bytes of one interleaved record. So the size the
|
|
// line above printed is `272 + comment + rows*record + 28 + channels*274 + 30`, and the units
|
|
// are the ones ScryLab reads each signal's unit from -- the CONFIG's, per column.
|
|
std::string units;
|
|
for (const char* u : {"s", "s"}) units += std::string(units.empty() ? "" : " ") + "'" + u + "'";
|
|
for (const ExportTable::Column& col : table.columns) {
|
|
units += std::string(units.empty() ? "" : " ") + "'" + col.unit + "'";
|
|
}
|
|
std::fprintf(stderr,
|
|
"[export-probe] mdf master=time master_unit=s channels=%zu record=%zu units=[%s]\n",
|
|
table.columns.size() + 2, 16 + 4 * table.columns.size(), units.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_sensitive_default = gtk_widget_get_sensitive(a->trigger_body) ? 1 : 0;
|
|
// The two [edit] buttons, in each mode: the icon is the only thing that says which mode the pane is
|
|
// in, and an icon the theme does not have draws nothing at all.
|
|
const auto pane_icon = [](GtkWidget* b) {
|
|
const char* n = b != nullptr && GTK_IS_BUTTON(b) ? gtk_button_get_icon_name(GTK_BUTTON(b)) : nullptr;
|
|
return std::string(n != nullptr ? n : "-");
|
|
};
|
|
a->dbg_pane_edit_icon = pane_icon(a->trigger_edit);
|
|
if (a->trigger_edit != nullptr) {
|
|
g_signal_emit_by_name(a->trigger_edit, "clicked"); // into configuration mode
|
|
a->dbg_pane_view_icon = pane_icon(a->trigger_edit);
|
|
g_signal_emit_by_name(a->trigger_edit, "clicked"); // and back to the view
|
|
}
|
|
a->dbg_trigger_locked = a->trigger_locked ? 1 : 0;
|
|
a->dbg_signals_locked = a->signals_locked ? 1 : 0;
|
|
a->dbg_trigger_body_visible =
|
|
a->trigger_body != nullptr && gtk_widget_get_visible(a->trigger_body) ? 1 : 0;
|
|
// The summary's text comes from the sync that wrote it: the container is a grid of key/value rows,
|
|
// and asking it for a label's text was itself a critical.
|
|
a->dbg_trigger_summary_early = a->dbg_trigger_summary; // as found, before this probe touches it
|
|
a->dbg_arm_sensitive = a->arm_button != nullptr && gtk_widget_get_sensitive(a->arm_button) ? 1 : 0;
|
|
// The compact line's arm control must be *absent* here: the trigger is off, the line beside it reads
|
|
// "stream (trigger off)", and a trigger that is off has no engine to arm. Read before the toggle, which
|
|
// is where that absence is the assertion -- and it is the pair to the reading in step 2.
|
|
a->dbg_summary_arm_off =
|
|
a->trigger_summary_arm != nullptr && gtk_widget_get_visible(a->trigger_summary_arm) ? 1 : 0;
|
|
a->dbg_panel_switch_live =
|
|
a->trigger_switch != nullptr && gtk_widget_get_sensitive(a->trigger_switch) &&
|
|
a->cursors_switch != nullptr && gtk_widget_get_sensitive(a->cursors_switch)
|
|
? 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_sensitive=%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_body != nullptr && gtk_widget_get_sensitive(a.cursors_body) ? 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));
|
|
}
|
|
select_side_tab(*a, "measure");
|
|
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());
|
|
|
|
// A non-default band and corner before the save, so the round trip below proves the two keys are
|
|
// *written*: left at their defaults, a key that never made it to the file would read back the same.
|
|
const bool has_measure = !a->measures.empty();
|
|
if (has_measure) {
|
|
a->measures.front().options.noise_pct = 7.5;
|
|
a->measures.front().options.hf_fc = 250.0;
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
const double noise_back = st.measures.empty() ? -1.0 : st.measures.front().options.noise_pct;
|
|
const double hf_back = st.measures.empty() ? -1.0 : st.measures.front().options.hf_fc;
|
|
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 noise=%.6g hf=%.6g\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(),
|
|
noise_back, hf_back);
|
|
|
|
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;
|
|
}
|
|
|
|
/// The live view's own settings, taken just before a file view is opened over them, so that every
|
|
/// way back out can be read against what was there. The trigger is read under the lock and its
|
|
/// channel by *name*: a file view rebuilds the channel list, so an index means nothing on the way
|
|
/// back. The probe keeps its own copy rather than asking the restore path for one -- a reading taken
|
|
/// from the mechanism under test asserts nothing.
|
|
void capture_live_expectation(App& a) {
|
|
a.dbg_live_window = a.rows->time_window();
|
|
a.dbg_live_trigger_on =
|
|
a.trigger_toggle != nullptr &&
|
|
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a.trigger_toggle)) != FALSE;
|
|
std::lock_guard<std::mutex> lock(a.model->mutex());
|
|
const TriggerConfig& tc = a.model->trigger();
|
|
a.dbg_live_level = tc.condition.level;
|
|
a.dbg_live_trigger_channel = tc.channel >= 0 && tc.channel < int(a.model->channels().size())
|
|
? a.model->channels()[std::size_t(tc.channel)].name
|
|
: std::string();
|
|
}
|
|
|
|
/// What came back when a file view was left: every setting the live view had before it opened, read
|
|
/// against the expectation the probe captured itself. Four of the five readings are a *pair* on
|
|
/// purpose -- a file view overwrites the window, the trigger mode, the trigger's channel and its
|
|
/// level, so a line where the two halves of a pair disagree names the one that was not put back.
|
|
/// `pinned` is the fifth and has no pair: a file pins the axis to the event's span, and going live
|
|
/// must leave the axis following again, so the only passing reading is 0.
|
|
void report_live_restored(App& a, const char* what) {
|
|
std::string channel;
|
|
double level = 0.0;
|
|
bool enabled = false;
|
|
bool armed = false;
|
|
{
|
|
std::lock_guard<std::mutex> lock(a.model->mutex());
|
|
const TriggerConfig& tc = a.model->trigger();
|
|
enabled = tc.enabled;
|
|
armed = tc.armed;
|
|
level = tc.condition.level;
|
|
if (tc.channel >= 0 && tc.channel < int(a.model->channels().size())) {
|
|
channel = a.model->channels()[std::size_t(tc.channel)].name;
|
|
}
|
|
}
|
|
std::fprintf(stderr,
|
|
"[events] live restored (%s): window=%.6f (want %.6f) trigger=%d (want %d) "
|
|
"channel='%s' (want '%s') level=%.6g (want %.6g) enabled=%d armed=%d pinned=%d "
|
|
"held=%d mode=%d pending=%d gen=%u/%u\n",
|
|
what, a.rows->time_window(), a.dbg_live_window,
|
|
a.trigger_toggle != nullptr &&
|
|
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a.trigger_toggle))
|
|
? 1
|
|
: 0,
|
|
a.dbg_live_trigger_on ? 1 : 0, channel.c_str(), a.dbg_live_trigger_channel.c_str(),
|
|
level, a.dbg_live_level, enabled ? 1 : 0, armed ? 1 : 0,
|
|
a.rows->window_pinned() ? 1 : 0, a.event_held ? 1 : 0,
|
|
static_cast<int>(a.model->display_mode()), a.live_restore_pending ? 1 : 0,
|
|
a.model->generation(), a.live_restore_generation);
|
|
std::fflush(stderr);
|
|
}
|
|
|
|
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) {
|
|
// The preview's repair, measured before anything else reads the pane: the newest event's picture
|
|
// is deleted and the pane refreshed, so the row that comes back proves the pane draws it again
|
|
// from the samples rather than showing a blank. The stored geometry is read back too, because the
|
|
// defect this fixes was a readable *file* that was too short to read -- `strip_h * signals` says
|
|
// the picture grew with the signal count, a fixed 160 says it did not.
|
|
const std::vector<EventRecord> before = a->event_store->list();
|
|
if (!before.empty()) {
|
|
const std::string bmp = a->event_store->dir() + "/" + before.front().stem + ".bmp";
|
|
::remove(bmp.c_str());
|
|
// The decoded picture has to go with the file: the cache answers by path and would hand back
|
|
// the texture of a file that is no longer there, so the refresh would never reach the repair.
|
|
if (EventThumbCache* cache = event_thumb_cache(*a)) {
|
|
for (auto& entry : *cache) {
|
|
if (entry.second != nullptr) g_object_unref(entry.second);
|
|
}
|
|
cache->clear();
|
|
}
|
|
refresh_events_pane(*a);
|
|
int w = -1;
|
|
int h = -1;
|
|
if (std::FILE* f = std::fopen(bmp.c_str(), "rb"); f != nullptr) {
|
|
unsigned char head[26] = {};
|
|
if (std::fread(head, 1, sizeof(head), f) == sizeof(head)) {
|
|
w = int(head[18]) | int(head[19]) << 8 | int(head[20]) << 16;
|
|
h = int(head[22]) | int(head[23]) << 8 | int(head[24]) << 16;
|
|
}
|
|
std::fclose(f);
|
|
}
|
|
const bool drawn = a->events_thumb != nullptr &&
|
|
gtk_picture_get_paintable(GTK_PICTURE(a->events_thumb)) != nullptr;
|
|
std::fprintf(stderr, "[events] preview redrawn: signals=%zu stored=%dx%d row_drawn=%d\n",
|
|
before.front().signals, w, h, drawn ? 1 : 0);
|
|
std::fflush(stderr);
|
|
}
|
|
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);
|
|
capture_live_expectation(*a);
|
|
GtkListBoxRow* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(a->events_list), 0);
|
|
// Selected *and* remembered, because what the activation must not do is rebuild the list:
|
|
// a selection the user made for a comparison, and the row object itself, are what a
|
|
// rebuild silently discarded. Two rows, so the reading after the click can say the
|
|
// selection survived rather than merely that something is selected.
|
|
if (row != nullptr) gtk_list_box_select_row(GTK_LIST_BOX(a->events_list), row);
|
|
if (GtkListBoxRow* second = gtk_list_box_get_row_at_index(GTK_LIST_BOX(a->events_list), 1)) {
|
|
gtk_list_box_select_row(GTK_LIST_BOX(a->events_list), second);
|
|
}
|
|
a->dbg_events_row0 = row;
|
|
GList* chosen = gtk_list_box_get_selected_rows(GTK_LIST_BOX(a->events_list));
|
|
a->dbg_events_selected = int(g_list_length(chosen));
|
|
g_list_free(chosen);
|
|
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;
|
|
// The row *object*, not its index: a rebuilt list has a row 0 too, so only identity tells a
|
|
// list that was left alone from one that was torn down and refilled under the user's scroll.
|
|
const bool rows_kept =
|
|
a->dbg_events_row0 != nullptr &&
|
|
gtk_list_box_get_row_at_index(GTK_LIST_BOX(a->events_list), 0) == a->dbg_events_row0;
|
|
GList* still = gtk_list_box_get_selected_rows(GTK_LIST_BOX(a->events_list));
|
|
const int selected_now = int(g_list_length(still));
|
|
g_list_free(still);
|
|
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 rows_kept=%d selected=%d/%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,
|
|
rows_kept ? 1 : 0, selected_now, a->dbg_events_selected);
|
|
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);
|
|
report_live_restored(*a, "after replay");
|
|
// 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);
|
|
// The live settings this leg will be read against, taken *here* rather than inherited from the
|
|
// replay leg: a group is a second, independent way into a file view, and its way back has to put
|
|
// back what was on screen when the button was pressed.
|
|
capture_live_expectation(*a);
|
|
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);
|
|
// The restore is not this handler's: it waits for the restarted client's first CONFIG, so it is a
|
|
// *later tick* that performs it. Reading here would read the frame before the stream came back.
|
|
events_group_probe_next(*a, 61, 900);
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
|
|
if (a->dbg_events_step == 61) {
|
|
report_live_restored(*a, "after a group");
|
|
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);
|
|
}
|
|
|
|
namespace {
|
|
|
|
struct NoiseLeg {
|
|
std::size_t hits = 0;
|
|
double first = std::numeric_limits<double>::quiet_NaN();
|
|
int state = 0;
|
|
};
|
|
|
|
/// One rising-edge search of the whole retained buffer at level 0.
|
|
///
|
|
/// Synchronous on purpose. Every leg must see the *same* buffer, and yielding to the main loop between legs
|
|
/// would let the producer append between them, so the counts would no longer be comparable. The bound is a
|
|
/// safety net: a chunk is `BufferSearch::kChunk` samples, so 20000 pumps cover 160 M of them, far more than
|
|
/// the ring holds.
|
|
NoiseLeg noise_leg(App* a, int channel, double dt, double band, double hf) {
|
|
EventCondition c;
|
|
c.type = TriggerType::Edge;
|
|
c.edge = TriggerEdge::Rising;
|
|
c.level = 0.0;
|
|
c.noise_band = band;
|
|
c.hf_fc = hf;
|
|
a->search.start(c, channel, dt);
|
|
for (int i = 0; i < 20000 && a->search.pump(*a->model); ++i) {
|
|
}
|
|
NoiseLeg r;
|
|
r.hits = a->search.hits().size();
|
|
if (r.hits > 0) r.first = a->search.hits().front();
|
|
r.state = static_cast<int>(a->search.state());
|
|
return r;
|
|
}
|
|
|
|
/// The difference of two first-hit stamps, folded into +/- half a period, so a pair whose first hits landed
|
|
/// on different cycles still reads as a delay.
|
|
double noise_fold(double d, double period) {
|
|
const double half = 0.5 * period;
|
|
d = std::fmod(d, period);
|
|
if (d > half) d -= period;
|
|
if (d < -half) d += period;
|
|
return d;
|
|
}
|
|
|
|
int noise_channel(App* a, const char* name) {
|
|
const std::vector<ChannelInfo>& channels = a->model->channels();
|
|
for (std::size_t i = 0; i < channels.size(); ++i) {
|
|
if (channels[i].name == name) return static_cast<int>(i);
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// Stage one of three. `apply_math_channels()` is a structural rebuild that drops the buffer's
|
|
// history, and every reading in `noise_probe` is taken over retained history -- so the rebuild
|
|
// happens here, five seconds before the first leg runs, and the buffer refills in between. The
|
|
// de-noise channel also needs that time: a math channel starts producing when it is applied.
|
|
//
|
|
// The channel is installed **disabled**, and `noise_probe_math` enables it two and a half seconds
|
|
// before its own reading. An acquired channel is a share of the ring, so a fifth one shortens every
|
|
// channel's retained span -- and that moves every figure above it: the six search legs' counts scale
|
|
// with the retained buffer, and `pkpk bare` is a peak-to-peak *of that window*. Those readings are
|
|
// the older feature's proof, so the new channel stays out of the acquisition plan until they have
|
|
// all run, and only the de-noise comparison is taken with it in.
|
|
//
|
|
// Fields assigned rather than brace-initialised: a partial brace-initialiser on `MathNode` is
|
|
// what the two `-Wmissing-field-initializers` in this file already are, and the build baseline
|
|
// is exactly four warnings.
|
|
gboolean noise_probe_setup(gpointer user) {
|
|
App* a = static_cast<App*>(user);
|
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
|
MathChannel dn;
|
|
dn.name = "math denoise";
|
|
dn.enabled = false; // enabled by `noise_probe_math`, so it costs no ring while the legs run
|
|
MathNode src_node;
|
|
src_node.op = MathOp::Source;
|
|
src_node.source = "sine";
|
|
MathNode dn_node;
|
|
dn_node.op = MathOp::DeNoise;
|
|
dn_node.in_a = 0;
|
|
dn_node.med_n = 5;
|
|
// The median alone: a 2-sample impulse is shorter than half a 5-sample window, so it is rejected
|
|
// outright and the pole has nothing left to do. A corner here would attenuate the 5 Hz sine as well
|
|
// and make the peak-to-peak reading a function of two things.
|
|
dn_node.fc = 0.0;
|
|
dn.nodes = {src_node, dn_node};
|
|
dn.output = 1;
|
|
a->math.push_back(dn);
|
|
apply_math_channels(*a);
|
|
std::fprintf(stderr, "[noise-probe] setup channels=%zu\n", a->math.size());
|
|
std::fflush(stderr);
|
|
// 3300 + 4700 = 8000 ms, where Tasks 4 and 8 measured the legs.
|
|
g_timeout_add(5000, noise_probe, a);
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
|
|
gboolean noise_probe(gpointer user) {
|
|
static int phase = 0;
|
|
App* a = static_cast<App*>(user);
|
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
|
if (phase == 0) {
|
|
// The signals this probe measures are enabled *here*. Not because it is tidier, but because it must not
|
|
// depend on another probe's side effects: `enable_all_probe` ends by switching one signal off again (it
|
|
// reads what a disabled signal does to the plots), and a run that arms both would take `sine` away.
|
|
const std::vector<SignalInfo>& signals = a->model->signals();
|
|
for (std::size_t i = 0; i < signals.size(); ++i) {
|
|
if (signals[i].name == "sine" || signals[i].name == "square") {
|
|
a->model->set_signal_enabled(i, true);
|
|
}
|
|
}
|
|
phase = 1;
|
|
// The enable is a structural change: it replaces the store, so the ring has to fill again before there
|
|
// is anything to search.
|
|
g_timeout_add(2500, noise_probe, a);
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
const int sine = noise_channel(a, "sine");
|
|
const int square = noise_channel(a, "square");
|
|
double dt = 0.0;
|
|
{
|
|
std::lock_guard<std::mutex> lock(a->model->mutex());
|
|
dt = a->model->partition().slot_dt();
|
|
}
|
|
if (sine < 0 || square < 0 || !(dt > 0.0)) {
|
|
// Name the channels that *are* there: an index alone says a probe found nothing, not what it saw.
|
|
std::string have;
|
|
for (const ChannelInfo& c : a->model->channels()) have += c.name + " ";
|
|
std::fprintf(stderr, "[noise-probe] no stream: sine=%d square=%d dt=%g channels=[%s]\n", sine, square,
|
|
dt, have.c_str());
|
|
std::fflush(stderr);
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
|
|
// A: the bare comparator on a spiked sine. Every impulse landing where the sine is within the impulse's own
|
|
// amplitude of zero fires a crossing.
|
|
const NoiseLeg a_leg = noise_leg(a, sine, dt, 0.0, 0.0);
|
|
// B: a 0.8 band. A +/-0.5 impulse cannot carry the signal across a 1.6-wide band, so only the sine's own
|
|
// cycles are left.
|
|
const NoiseLeg b_leg = noise_leg(a, sine, dt, 0.8, 0.0);
|
|
// C: no band, a 50 Hz reject. A 2-sample 0.5 impulse moves a pole with a = 1 - exp(-2*pi*50*dt) by about
|
|
// 5e-4, which cannot reach the level.
|
|
const NoiseLeg c_leg = noise_leg(a, sine, dt, 0.0, 50.0);
|
|
// D: both. The reject attenuates a 5 Hz sine by only 1/sqrt(1 + (5/50)^2), so its peaks still clear the band
|
|
// and the count is unchanged.
|
|
const NoiseLeg d_leg = noise_leg(a, sine, dt, 0.8, 50.0);
|
|
// E/F: the reject's group delay, on `square`, which carries no impulses. A one-pole turns a -1 -> +1 step
|
|
// into -1 + 2*(1 - exp(-t/tau)), which crosses zero at tau*ln2 -- exact, and the same on every edge.
|
|
const NoiseLeg e_leg = noise_leg(a, square, dt, 0.0, 0.0);
|
|
const NoiseLeg f_leg = noise_leg(a, square, dt, 0.0, 50.0);
|
|
|
|
const double period = 0.2; // the mock's default --freq is 5 Hz.
|
|
const double delay = noise_fold(f_leg.first - e_leg.first, period);
|
|
const double want = std::log(2.0) / (2.0 * 3.14159265358979323846 * 50.0);
|
|
|
|
std::fprintf(stderr, "[noise-probe] trigger band: bare=%zu/%d band=%zu/%d ratio=%.2f\n", a_leg.hits,
|
|
a_leg.state, b_leg.hits, b_leg.state,
|
|
b_leg.hits > 0 ? static_cast<double>(a_leg.hits) / static_cast<double>(b_leg.hits) : 0.0);
|
|
std::fprintf(stderr, "[noise-probe] trigger hf: hf=%zu/%d band+hf=%zu/%d band=%zu\n", c_leg.hits,
|
|
c_leg.state, d_leg.hits, d_leg.state, b_leg.hits);
|
|
std::fprintf(stderr,
|
|
"[noise-probe] trigger delay: clean=%zu filtered=%zu delay=%.6f want=%.6f ratio=%.3f\n",
|
|
e_leg.hits, f_leg.hits, delay, want, want > 0.0 ? delay / want : 0.0);
|
|
std::fflush(stderr);
|
|
|
|
// Five measurements on the spiked sine, as *two* windows: three legs at `hf=0` and two at `hf=50`. The
|
|
// pane keys its walks by (channel, corner), so this is also what checks that a second corner does not
|
|
// reuse the first one's walk.
|
|
std::string 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; // `sine`; signal 0 is the timestamp
|
|
}
|
|
struct Leg {
|
|
const char* label;
|
|
MeasureType type;
|
|
double noise;
|
|
double hf;
|
|
};
|
|
// The impulses dominate the window's extremes, so `peak to peak` reads the *stimulus* (about 3)
|
|
// unfiltered and the *waveform* (about 2) filtered -- the one reading that says the filter reached pass
|
|
// one and not only pass two.
|
|
const Leg legs[] = {
|
|
{"pkpk bare", MeasureType::PeakToPeak, 10.0, 0.0},
|
|
{"pkpk hf", MeasureType::PeakToPeak, 10.0, 50.0},
|
|
{"period noband", MeasureType::Period, 0.0, 0.0},
|
|
// 25 % and not the 10 % default: the band is a share of the *window's* peak-to-peak, which with the
|
|
// impulses in it is about 3, so 10 % is a half-band of 0.3 -- less than the 0.5 an impulse moves the
|
|
// signal by, and the `MeasureOptions::noise_pct` comment says as much ("10 % holds to an interferer
|
|
// amplitude of 0.3"). The default is what `period noband` and `period hf` are about; this leg asks for
|
|
// a band that clears the stimulus, which is what makes the reading legible.
|
|
{"period band", MeasureType::Period, 25.0, 0.0},
|
|
{"period hf", MeasureType::Period, 0.0, 50.0},
|
|
};
|
|
const std::size_t base = a->measures.size();
|
|
for (const Leg& leg : legs) {
|
|
MeasureOptions options;
|
|
options.noise_pct = leg.noise;
|
|
options.hf_fc = leg.hf;
|
|
a->measures.push_back({name, leg.type, options});
|
|
}
|
|
rebuild_measures_pane(*a);
|
|
update_measures(*a, /*force=*/true, /*accumulate=*/true);
|
|
for (std::size_t i = 0; i < std::size(legs); ++i) {
|
|
const std::size_t at = base + i;
|
|
// A *value*, not a reference: the conditional's other arm is a temporary.
|
|
const MeasureResult r = at < a->measure_results.size() ? a->measure_results[at] : MeasureResult{};
|
|
std::fprintf(stderr, "[noise-probe] measure %-14s noise=%.1f hf=%.1f valid=%d value=%.6f\n",
|
|
legs[i].label, legs[i].noise, legs[i].hf, int(r.valid), r.primary);
|
|
}
|
|
std::fprintf(stderr, "[noise-probe] measure windows=%zu\n", a->measure_samples.size());
|
|
std::fflush(stderr);
|
|
a->search.clear();
|
|
update_search_panel(*a);
|
|
g_timeout_add(2500, noise_probe_math, a);
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
|
|
gboolean noise_probe_math(gpointer user) {
|
|
App* a = static_cast<App*>(user);
|
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
|
{
|
|
// Enabled here for the reason `noise_probe_setup` gives: until this moment the channel must not
|
|
// be a share of the ring. Enabled *by name*, through the same switch the panel drives, so the
|
|
// model and the panel agree.
|
|
const std::vector<SignalInfo>& signals = a->model->signals();
|
|
for (std::size_t i = 0; i < signals.size(); ++i) {
|
|
if (signals[i].name == "math denoise") a->model->set_signal_enabled(i, true);
|
|
}
|
|
}
|
|
// A fill before the reading: the channel's first sample is the first one after this enable, and a
|
|
// peak-to-peak needs a window with a sine trough *and* a crest in it. Two and a half seconds is
|
|
// twelve cycles of the 5 Hz source.
|
|
g_timeout_add(2500, noise_probe_denoise, a);
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
|
|
gboolean noise_probe_denoise(gpointer user) {
|
|
App* a = static_cast<App*>(user);
|
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
|
const std::size_t base = a->measures.size();
|
|
// Both unfiltered and at no band: the only difference between the two readings is the node.
|
|
MeasureOptions options;
|
|
a->measures.push_back({std::string("sine"), MeasureType::PeakToPeak, options});
|
|
a->measures.push_back({std::string("math denoise"), MeasureType::PeakToPeak, options});
|
|
rebuild_measures_pane(*a);
|
|
update_measures(*a, /*force=*/true, /*accumulate=*/true);
|
|
const MeasureResult raw =
|
|
base < a->measure_results.size() ? a->measure_results[base] : MeasureResult{};
|
|
const MeasureResult den =
|
|
base + 1 < a->measure_results.size() ? a->measure_results[base + 1] : MeasureResult{};
|
|
std::fprintf(stderr,
|
|
"[noise-probe] denoise raw=%.6f/%d denoised=%.6f/%d ratio=%.3f channels=%zu\n",
|
|
raw.primary, int(raw.valid), den.primary, int(den.valid),
|
|
raw.primary > 0.0 ? den.primary / raw.primary : 0.0, a->math.size());
|
|
std::fflush(stderr);
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
|
|
gboolean drop_probe(gpointer user) {
|
|
App* a = static_cast<App*>(user);
|
|
if (a->window == nullptr || a->model->signals().empty()) return G_SOURCE_REMOVE;
|
|
// `repins` beside the view: a file's extent is fixed, so the pin is stated once and only its
|
|
// numbers are refreshed afterwards. A count that tracks the frame number is the pin being written
|
|
// every frame, which flickers the view and swallows every gesture.
|
|
const plot::Range view = a->rows->view_range();
|
|
std::fprintf(stderr,
|
|
"[drop-probe] signals=%zu channels=%zu rows=%d slots0=[%s|%s|%s|%s] "
|
|
"view=[%.6f..%.6f] repins=%d\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(), view.min, view.max,
|
|
a->dbg_file_repins);
|
|
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);
|
|
{
|
|
const std::string before_tab = a->side_tab;
|
|
run_app_action(*a, AppAction::SideTabMeasure);
|
|
const std::string after_tab = a->side_tab;
|
|
char buf[96];
|
|
std::snprintf(buf, sizeof(buf), " m: tab %s->%s |", before_tab.c_str(), after_tab.c_str());
|
|
line += buf;
|
|
}
|
|
{
|
|
const std::string before_tab = a->side_tab;
|
|
run_app_action(*a, AppAction::SideTabSignals);
|
|
const std::string after_tab = a->side_tab;
|
|
char buf[96];
|
|
std::snprintf(buf, sizeof(buf), " g: tab %s->%s |", before_tab.c_str(), after_tab.c_str());
|
|
line += buf;
|
|
}
|
|
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 side tab not to change.
|
|
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 std::string before_tab = a->side_tab;
|
|
const gboolean handled = on_app_key_pressed(nullptr, GDK_KEY_m, 0,
|
|
static_cast<GdkModifierType>(0), a);
|
|
const std::string after_tab = a->side_tab;
|
|
refused = (handled == FALSE && before_tab == after_tab) ? 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);
|
|
|
|
// The manual is not opened here: it lives in another process (Yelp, through the `help:` URI)
|
|
// and neither it nor this box can draw a window for it, so there is nothing to open, read
|
|
// back or close. `help_self_check()` above is the assertion -- the set is on disk where the
|
|
// build says it is. The two AdwDialogs below are still built, read and closed for real.
|
|
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] topics=%d index=%d figures=%d dir=%s; 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.topics, report.index ? 1 : 0, report.figures, UDPSCOPE_HELP_DIR,
|
|
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
|