// 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 #include #include namespace udpscope { namespace ui_impl { int on_render_probe(GtkGLArea*, GdkGLContext*, gpointer user) { App* app = static_cast(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(sl)] < 0) continue; const plot::Range b = r.slots[static_cast(sl)].x_bounds(); std::fprintf(stderr, " slot%d n=%zu x=[%.6f..%.6f]", sl, r.slots[static_cast(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(user); a->dbg_mode_live = a->status_detail; GtkWidget* window = a->window; a->window = nullptr; // stop the tick before the widgets go a->dbg_side_w = gtk_widget_get_width(a->side_box); a->dbg_side_h = gtk_widget_get_height(a->side_box); a->dbg_signal_w = gtk_widget_get_width(a->signal_revealer); a->dbg_signal_h = gtk_widget_get_height(a->signal_revealer); a->dbg_trigger_w = gtk_widget_get_width(a->trigger_revealer); a->dbg_trigger_h = gtk_widget_get_height(a->trigger_revealer); // The height of one signal row, which is the thing "the channel toggle is too tall" // was about. The revealer's own height cannot stand in for it: it also covers the // heading, the hint and the box margins. if (GtkWidget* first = gtk_widget_get_first_child(a->signal_list)) { a->dbg_signal_row_h = gtk_widget_get_height(first); } if (a->area != nullptr) a->dbg_frames_painted = static_cast(a->area->frames_painted()); a->dbg_reset_visible = a->cursors_reset != nullptr && gtk_widget_get_visible(a->cursors_reset) ? 1 : 0; a->dbg_signal_revealed = gtk_revealer_get_child_revealed(GTK_REVEALER(a->signal_revealer)) ? 1 : 0; a->dbg_trigger_revealed = gtk_revealer_get_child_revealed(GTK_REVEALER(a->trigger_revealer)) ? 1 : 0; a->dbg_side_visible = gtk_widget_get_visible(a->side_box) ? 1 : 0; a->dbg_side_scroll_after_hide = gtk_widget_get_visible(a->side_scroll) ? 1 : 0; if (a->trigger_signal_drop != nullptr) { GListModel* model = gtk_drop_down_get_model(GTK_DROP_DOWN(a->trigger_signal_drop)); a->dbg_trigger_items = model != nullptr ? static_cast(g_list_model_get_n_items(model)) : 0; a->dbg_trigger_selected = static_cast( gtk_drop_down_get_selected(GTK_DROP_DOWN(a->trigger_signal_drop))); } // `dbg_window_text` is kept by `sync_window_display()`, the only place that knows the span the // window control is showing: there is no label to read it back from any more. a->dbg_toggles_visible = (gtk_widget_get_visible(a->signal_toggle) && gtk_widget_get_visible(a->trigger_toggle)) ? 1 : 0; if (window != nullptr) gtk_window_destroy(GTK_WINDOW(window)); return G_SOURCE_REMOVE; } gboolean exit_probe_step2(gpointer user) { App* a = static_cast(user); a->dbg_mode_triggered = a->status_detail; if (a->rows->row(0).plot) { a->dbg_marker_x = a->rows->row(0).plot->cursor(a->rows->row(0).trigger_marker).x; a->dbg_marker_max = a->rows->row(0).plot->x_range(0).max; a->dbg_trigger_time = a->model->trigger_time().load(); a->dbg_trigger_time_valid = a->model->trigger_time_valid().load() ? 1 : 0; } if (a->trigger_toggle != nullptr) { gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), FALSE); } // The tick keeps running for these 400 ms, so by step 3 the status line reads // the state the app is actually in after leaving trigger mode. g_timeout_add(400, exit_probe_step3, a); return G_SOURCE_REMOVE; } gboolean trig_trace_probe(gpointer user) { App* a = static_cast(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 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(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 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)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(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(m.trigger_latches().load()), int(m.trigger_armed().load()), static_cast(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(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(user); if (a->window == nullptr) return G_SOURCE_REMOVE; std::string name; std::string late_name; { std::lock_guard 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 channels; MathChannel gain; gain.name = "math x2"; gain.enabled = true; gain.nodes = {MathNode{MathOp::Source, name}, MathNode{MathOp::Gain}}; gain.nodes[1].in_a = 0; gain.nodes[1].gain = 2.0; gain.output = 1; channels.push_back(gain); MathChannel lp; lp.name = "math lp"; lp.enabled = true; lp.nodes = {MathNode{MathOp::Source, late_name}, MathNode{MathOp::LowPass}}; lp.nodes[1].in_a = 0; lp.nodes[1].fc = 20.0; lp.output = 1; channels.push_back(lp); // Through the app's list, which is the pane's source of truth: a probe that wrote the model // directly left the two disagreeing, and the next pane edit then silently dropped its channels. a->math = std::move(channels); apply_math_channels(*a); std::fprintf(stderr, "[math-probe] 2 math channels on '%s' and '%s'\n", name.c_str(), late_name.c_str()); // And open the editor on the first one: as much a construction crash test as a state check, // since a modal window is presented and its canvas, form and gestures are all built here. // The pane's own `+`, which is the path the user takes: it must source an *acquired* channel. // From `signals().front()` it sourced the stream's timestamp signal -- no samples, so the graph // validated and then read NaN for ever, which is exactly the reported "[ch] -> [gain] is all nan". add_math_channel(*a); if (!a->math.empty()) { const std::string src = a->math.back().nodes.empty() ? std::string("(none)") : a->math.back().nodes[0].source; std::fprintf(stderr, "[math-probe] pane add: '%s' sources '%s'\n", a->math.back().name.c_str(), src.c_str()); } open_math_dialog(*a, 0); std::fprintf(stderr, "[math-probe] editor opened\n"); // Opened on the channel sourcing the *last* acquired signal, which is the one open that moves // the source dropdown off index 0 and so emits `notify::selected` -- see `late_name` above for // why nothing else here does. The reading is the source the handler wrote back into the node: // it must be `late_name` and not the dropdown's first entry, because a handler that reads the // wrong argument writes through a `GParamSpec*` and the Gtk-CRITICAL baseline is what catches // it when the dereference happens not to fault. { if (a->math_dialog != nullptr) gtk_window_close(GTK_WINDOW(a->math_dialog)); open_math_dialog(*a, 1); auto* ed = static_cast( 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(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( 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(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( 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(slots_before), static_cast(slots_after)); const std::size_t saved = a->math.empty() ? 0u : a->math[static_cast(index)].nodes.size(); const int output_saved = a->math.empty() ? -1 : a->math[static_cast(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( 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( 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( 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 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( 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( 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(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(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(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(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(MathDrag::Pan)); math_view_reset(g); } } if (a->math_dialog != nullptr) { auto* p = static_cast( 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(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(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( 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(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(i); } if (self_node >= 0) { double sx = 0.0; double sy = 0.0; math_input_pos(c->nodes[static_cast(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( 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 acquired = math_source_names(*a); std::size_t signals_n = 0; { std::lock_guard 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( 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(i % 2u); o->nodes[i].y = 20.0 + 58.0 * static_cast(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(p2.x)) + "," + std::to_string(static_cast(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( 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(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(user); if (a->window == nullptr) return G_SOURCE_REMOVE; std::string name; { std::lock_guard 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(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(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(user); if (a->window == nullptr) return G_SOURCE_REMOVE; if (a->measures_stats_check != nullptr) { gtk_check_button_set_active(GTK_CHECK_BUTTON(a->measures_stats_check), TRUE); } g_stats_phase = 0; // Long enough for several throttled recomputes (one per 100 ms while the window moves). g_timeout_add(2000, stats_probe_report, a); return G_SOURCE_REMOVE; } gboolean pan_probe(gpointer user) { App* a = static_cast(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(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(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(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 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 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(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 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(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 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(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::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(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 snap = a->model->display_snapshot()) { if (snap != nullptr && trig_ch < snap->values.size()) { const std::vector& st = snap->stamps; const std::vector& 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(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(vals[end - 1]); if (end >= 2) earlier = static_cast(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(vals[i - 2]); const double val = static_cast(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(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& 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(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(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(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& 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(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(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(user); if (a->window == nullptr) return G_SOURCE_REMOVE; g_search_t0 = a->model->trigger_time(); EventCondition c; int channel = -1; { std::lock_guard lock(a->model->mutex()); c = a->model->trigger().condition; channel = a->model->trigger().channel; } a->search.start(c, channel); std::fprintf(stderr, "[search-probe] started on channel %d, armed t0=%.6f\n", channel, g_search_t0); std::fflush(stderr); g_timeout_add(100, search_probe_report, a); return G_SOURCE_REMOVE; } gboolean search_probe(gpointer user) { App* a = static_cast(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(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 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(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(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(r.origin.x), static_cast(r.origin.y), static_cast(r.size.width), static_cast(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(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(frame.w), static_cast(frame.h), (frame.w == 0.0f && frame.h == 0.0f) ? 1 : 0, static_cast(now.min), static_cast(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(user)->app; plot::input::Controller* c = app.area->row_controller(0); if (c != nullptr) { const plot::u32 ctrl = static_cast(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(p); }); return G_SOURCE_REMOVE; } gboolean boxzoom_probe_open(gpointer user) { BoxZoomProbe& probe = *static_cast(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(frame.x), static_cast(frame.y), static_cast(frame.w), static_cast(frame.h), static_cast(axes.h), band ? 1 : 0, static_cast(probe.before.min), static_cast(probe.before.max)); return boxzoom_probe_release(user); } gboolean boxzoom_probe(gpointer user) { App& a = *static_cast(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::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(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(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(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(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(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(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(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(user); for (std::size_t i = 0; i < a->model->signals().size(); ++i) { a->model->set_signal_enabled(i, true); } int row = 0; int slot = 0; for (std::size_t c = 0; c < a->model->channel_count(); ++c) { if (row >= a->rows->row_count()) break; const ChannelInfo& ch = a->model->channels()[c]; a->rows->assign(row, slot, ch.name, ch.name, signal_color_index(a->model->signals(), ch.signal)); if (++slot >= RowSet::kSlotsPerRow) { slot = 0; ++row; } } a->relayout_pending = true; std::fprintf(stderr, "[enable-all-probe] %zu channel(s) enabled and assigned to plots\n", a->model->channel_count()); std::fflush(stderr); // Then switch one off through its own switch in the signal list: a disabled signal must leave the plots, // not sit there drawing nothing in a slot a live one could take. Late enough that a math probe armed behind // this one has had its graphs installed -- the dependants are the point of the second line. g_timeout_add(2000, +[](gpointer u) -> gboolean { App* app = static_cast(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(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(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(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(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(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 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(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(user); pause_probe_report(*app, "paused"); return G_SOURCE_REMOVE; }, a); g_timeout_add(1600, +[](gpointer user) { App* app = static_cast(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(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(user); const char* env = g_getenv("UDPSCOPE_EXPORT_PATH"); const std::string prefix = env != nullptr ? env : "/tmp/udpscope-export"; const RowSet::Row& r0 = a->rows->row(0); const plot::Range axis = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{0.0, 0.0}; const ExportTable table = collect_export(*a->model, *a->rows); std::fprintf(stderr, "[export-probe] mode=%s axis=[%.6f..%.6f] rows=%zu cols=%zu time=[%.6f..%.6f] " "abs=[%.6f..%.6f]\n", a->model->trigger_enabled() && a->model->event() != nullptr ? "event" : "live", axis.min, axis.max, table.rows(), table.columns.size(), table.rows() != 0 ? table.time.front() : 0.0, table.rows() != 0 ? table.time.back() : 0.0, table.rows() != 0 ? table.abs_seconds.front() : 0.0, table.rows() != 0 ? table.abs_seconds.back() : 0.0); for (const ExportTable::Column& col : table.columns) { std::fprintf(stderr, "[export-probe] column '%s' n=%zu\n", col.name.c_str(), col.values.size()); } for (const char* ext : {".npy", ".csv"}) { const std::string path = prefix + ext; std::string error; const bool ok = std::strcmp(ext, ".csv") == 0 ? write_csv(path, table, error) : write_npy(path, table, error); long bytes = -1; if (ok) { if (std::FILE* f = std::fopen(path.c_str(), "rb"); f != nullptr) { std::fseek(f, 0, SEEK_END); bytes = std::ftell(f); std::fclose(f); } } std::fprintf(stderr, "[export-probe] %s %s bytes=%ld%s%s\n", path.c_str(), ok ? "written" : "FAILED", bytes, ok ? "" : " error=", ok ? "" : error.c_str()); } std::fflush(stderr); return G_SOURCE_REMOVE; } gboolean exit_probe_step1(gpointer user) { App* a = static_cast(user); // Record the panel states before the toggles are exercised. a->dbg_trigger_revealed_default = gtk_revealer_get_child_revealed(GTK_REVEALER(a->trigger_revealer)) ? 1 : 0; if (a->trigger_toggle != nullptr) { gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE); } g_timeout_add(1700, exit_probe_step2, a); return G_SOURCE_REMOVE; } void cursors_probe_report(App& a, const char* what) { static const char* const kModes[] = {"vertical", "horizontal", "both", "mix"}; const int mode = static_cast(a.rows->cursor_mode()); // The table as a user reads it: the time row first, then one row per line, each cell's // own text -- which is the assertion (the app's formatting, not the probe's). std::string t_text; std::string lines; int n = 0; // The row's own name too, not just its three cells: a `mix` line names the trace it reads, // and a line that should not exist at all is only recognisable by *which* trace it claims. const auto cells = [](const App::CursorLine& line) { std::string who = line.label != nullptr ? gtk_label_get_text(GTK_LABEL(line.label)) : ""; return who + "=" + gtk_label_get_text(GTK_LABEL(line.a)) + " " + gtk_label_get_text(GTK_LABEL(line.b)) + " " + gtk_label_get_text(GTK_LABEL(line.d)); }; for (const App::CursorLine& line : a.cursors_lines) { if (line.a == nullptr) continue; if (line.slot == -2) { t_text = cells(line); continue; } if (n != 0) lines += " | "; lines += cells(line); ++n; } // The `mix` crossing markers: how many are on screen and where the first is. They are // what the mode promises on the plot itself, so they are what the probe asserts. double mx = 0.0; double my = 0.0; const bool have_mark = a.rows->crossing_at(0, 0, false, mx, my); std::fprintf(stderr, "[cursors-probe] %-14s mode=%-10s on=%d pane=%d t0=%.6f t1=%.6f dt=%.6f " "T=[%s] lines=%d marks=%d [%s]\n", what, kModes[mode], a.rows->cursors_visible() ? 1 : 0, a.cursors_revealer != nullptr && gtk_revealer_get_reveal_child(GTK_REVEALER(a.cursors_revealer)) ? 1 : 0, a.rows->cursor_a_x(), a.rows->cursor_b_x(), a.rows->cursor_b_x() - a.rows->cursor_a_x(), t_text.c_str(), n, a.rows->crossings_visible(), lines.c_str()); if (have_mark) { std::fprintf(stderr, "[cursors-probe] %-14s first mark at x=%.6f y=%.6g\n", what, mx, my); } std::fflush(stderr); } void fit_probe_report(App& a, const char* what) { const RowSet::Row& r0 = a.rows->row(0); const plot::Range x = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{}; const plot::Range y = r0.plot != nullptr ? r0.plot->y_range(r0.axis_y) : plot::Range{}; // `window` and `event_span` are the assertion that a fit did *not* restate the time scale: // both are written only by `apply_time_window()`, so they must hold the typed value on every // line, with `span` the reading that moves -- down on the zoom, back up on the fit. std::fprintf(stderr, "[fit-probe] %-10s pinned=%d window=%.6f event_span=%.6f x=[%.6f..%.6f] span=%.6f " "y=[%.6f..%.6f]\n", what, a.rows->window_pinned() ? 1 : 0, a.rows->time_window(), a.model->event_span(), x.min, x.max, x.span(), y.min, y.max); std::fflush(stderr); } gboolean timelabels_probe_step(gpointer user) { App* a = static_cast(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 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(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(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(user); if (a->window == nullptr) return G_SOURCE_REMOVE; switch (a->dbg_measure_step) { case 0: { std::string signal; { std::lock_guard lock(a->model->mutex()); if (!a->model->channels().empty()) signal = a->model->channels().front().name; } if (signal.empty()) { std::fprintf(stderr, "[measures-probe] no acquired signal to measure\n"); return G_SOURCE_REMOVE; } for (int i = 0; i < measure_type_count(); ++i) { Measure m; m.signal = signal; m.type = measure_type_at(i); m.options.show_on_plot = measure_can_show_on_plot(m.type); a->measures.push_back(std::move(m)); } gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->measures_toggle), true); rebuild_measures_pane(*a); std::fprintf(stderr, "[measures-probe] added %zu measurements on '%s'\n", a->measures.size(), signal.c_str()); measures_probe_next(*a, 1, 400); break; } case 1: open_measure_dialog(*a, 0); // the edit path: this and the delete are what aborted measures_probe_next(*a, 2, 400); break; case 2: // Reported a frame after opening, so the dropdown has an allocation: its height is // what says the combo is a combo and not a 160 px box. measure_dialog_report(*a, "edit opened"); measures_probe_next(*a, 3, 200); break; case 3: measure_dialog_click(*a, "Save"); measures_probe_next(*a, 4, 400); break; case 4: measure_dialog_report(*a, "after save"); open_measure_dialog(*a, 0); measures_probe_next(*a, 5, 200); break; case 5: measure_dialog_click(*a, "Delete"); measures_probe_next(*a, 6, 400); break; case 6: // A gesture on a measurement's own cursor, placed through the plot exactly as the // controller would: the next frame has to put it back, which is what "read-only" means // here (the library has no flag the hit test would respect). // The marks as *placed* throughout: the drag writes a plot cursor and the check below reads // one back, so both sides have to be in the plot's own coordinates -- and a time mark is // stored on the stream's clock (see `place_measure_marks()`). if (!a->measure_marks_display.empty()) { RowSet::Row& r0 = a->rows->row(0); if (r0.plot != nullptr) { const RowSet::MeasureMark& mk = a->measure_marks_display.front(); if (mk.level) { r0.plot->set_cursor_y(r0.measure_h[0], mk.value + 3.0, plot::CursorPlacement::Free); } else { r0.plot->set_cursor_x(r0.measure_v[0], mk.value + 3.0, plot::CursorPlacement::Free); } } } measures_probe_next(*a, 7, 300); break; default: { double restored = -1.0; double want = 0.0; // Both read *now*: a time mark rolls leftwards with its sample on a live axis, so the value // placed on the most recent frame is the only one the plot's cursor can be expected to hold // -- comparing against the value the drag was launched from would read 300 ms of scroll as a // failure to restore. if (!a->measure_marks_display.empty()) { const RowSet::Row& r0 = a->rows->row(0); const RowSet::MeasureMark& mk = a->measure_marks_display.front(); want = mk.value; if (r0.plot != nullptr) { restored = mk.level ? r0.plot->cursor(r0.measure_h[0]).y : r0.plot->cursor(r0.measure_v[0]).x; } } std::fprintf(stderr, "[measures-probe] %-14s marks=%zu first=%.6f plot_now=%.6f restored=%d\n", "after drag", a->measure_marks_display.size(), want, restored, std::fabs(restored - want) < 1.0e-9 ? 1 : 0); std::fflush(stderr); measure_dialog_report(*a, "final"); break; } } return G_SOURCE_REMOVE; } gboolean zoomkeys_probe(gpointer user) { App* a = static_cast(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(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::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::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(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(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(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(at.x) : -1; const int accel_right = accel != nullptr && gtk_widget_compute_point(accel, row, &origin, &at) ? static_cast(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(bounds.origin.y) : -1; const int button_right = have_button ? static_cast(bounds.origin.x + bounds.size.width) : -1; const int pos = bubble != nullptr ? static_cast(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(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(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 lock(a->model->mutex()); const std::vector& 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(probe_slot)).color); }; // A chip carries its colour inside ``, 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(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(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(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 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& 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(m.stats().resequenced.load()), static_cast(m.stats().late.load()), static_cast(m.stats().stale.load()), static_cast(m.stats().overflows.load()), static_cast(m.stats().holes.load()), static_cast(m.stats().hole_slots.load()), static_cast(m.stats().backfilled.load()), open_holes, bands, bands_repaired, static_cast(nan_slots), static_cast(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(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(cap.stored()), static_cast(st.stored), st.cursor, static_cast(st.torn), static_cast(st.truncated), st.header_ok ? 1 : 0, static_cast(st.first_seq), static_cast(st.last_seq), static_cast(st.payload), static_cast(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(user); if (a->window == nullptr || a->source_port == 0) return G_SOURCE_REMOVE; const auto joined = [&]() { std::string out; for (const std::string& n : configuration_names(a->source_host, a->source_port)) { out += (out.empty() ? "" : ",") + n; } return out.empty() ? std::string("-") : out; }; std::fprintf(stderr, "[configs] before=%s active=%s\n", joined().c_str(), active_configuration(a->source_host, a->source_port).c_str()); const bool saved = save_configuration_as(*a, "probe", "written by the configs probe"); std::fprintf(stderr, "[configs] save-as=%d names=%s active=%s\n", saved ? 1 : 0, joined().c_str(), active_configuration(a->source_host, a->source_port).c_str()); // The round trip: what a reopened configuration must carry, note and startup settings included. LayoutState st; const std::string path = configuration_path(a->source_host, a->source_port, "probe"); const bool read = load_layout(path, st); int signals = 0; for (const LayoutState::Row& r : st.rows) { for (const std::string& sig : r.signals) { if (!sig.empty()) ++signals; } } std::fprintf(stderr, "[configs] round trip read=%d comment='%s' buffer_mb=%.6g relative=%d " "capture=%u plots=%zu signals=%d measures=%zu math=%zu\n", read ? 1 : 0, st.comment.c_str(), st.buffer_mb, st.relative_time ? 1 : 0, st.capture_packets, st.rows.size(), signals, st.measures.size(), st.math.size()); forget_configuration(a->source_host, a->source_port, "probe"); std::fprintf(stderr, "[configs] after forget names=%s\n", joined().c_str()); std::fflush(stderr); return G_SOURCE_REMOVE; } void rowmenu_probe_next(App& a, int step, guint delay_ms) { a.dbg_rowmenu_step = step; g_timeout_add(delay_ms, rowmenu_probe_step, &a); } gboolean rowmenu_probe_step(gpointer user) { App* a = static_cast(user); if (a->window == nullptr || a->rows->row_count() == 0) return G_SOURCE_REMOVE; if (a->dbg_rowmenu_step == 0) { // A trace first, or the remove view has nothing to list and the reading would not notice it // missing: the point of the probe is the *populated* menu. if (!a->model->channels().empty()) { const ChannelInfo& ch = a->model->channels().front(); a->rows->assign(0, 0, ch.name, ch.name, signal_color_index(a->model->signals(), ch.signal)); a->relayout_pending = true; } a->row_menu_popover = open_row_menu(*a, 0, 40.0, 20.0, a->area_widget, /*autohide=*/false); rowmenu_probe_next(*a, 1, 300); return G_SOURCE_REMOVE; } GtkWidget* popover = a->row_menu_popover; GtkWidget* box = popover != nullptr ? gtk_popover_get_child(GTK_POPOVER(popover)) : nullptr; if (a->dbg_rowmenu_step == 1) { row_menu_measure(box, "main", &g_rowmenu_probe.main_rows, &g_rowmenu_probe.main_bad); g_rowmenu_probe.remove_row_in_main = row_menu_find(box, "Remove signal") != nullptr ? 1 : 0; if (GtkWidget* show = row_menu_find(box, "Remove signal")) { g_signal_emit_by_name(show, "clicked"); } rowmenu_probe_next(*a, 2, 250); return G_SOURCE_REMOVE; } if (a->dbg_rowmenu_step == 2) { row_menu_measure(box, "remove", &g_rowmenu_probe.remove_rows, &g_rowmenu_probe.remove_bad); g_rowmenu_probe.remove_row_gone = row_menu_find(box, "Remove signal") == nullptr ? 1 : 0; if (GtkWidget* back = row_menu_find(box, "Back")) g_signal_emit_by_name(back, "clicked"); rowmenu_probe_next(*a, 3, 250); return G_SOURCE_REMOVE; } if (a->dbg_rowmenu_step == 3) { row_menu_measure(box, "after-back", &g_rowmenu_probe.back_rows, &g_rowmenu_probe.back_bad); if (GtkWidget* again = row_menu_find(box, "Remove signal")) { g_signal_emit_by_name(again, "clicked"); } rowmenu_probe_next(*a, 4, 250); return G_SOURCE_REMOVE; } row_menu_measure(box, "second-open", &g_rowmenu_probe.second_rows, &g_rowmenu_probe.second_bad); g_rowmenu_probe.can_open_again = row_menu_find(box, "Back") != nullptr ? 1 : 0; std::fprintf(stderr, "[rowmenu] summary main_rows=%d main_bad=%d remove_rows=%d remove_bad=%d " "after_back_rows=%d after_back_bad=%d second_rows=%d second_bad=%d " "remove_row_in_main=%d remove_row_gone=%d can_open_again=%d\n", g_rowmenu_probe.main_rows, g_rowmenu_probe.main_bad, g_rowmenu_probe.remove_rows, g_rowmenu_probe.remove_bad, g_rowmenu_probe.back_rows, g_rowmenu_probe.back_bad, g_rowmenu_probe.second_rows, g_rowmenu_probe.second_bad, g_rowmenu_probe.remove_row_in_main, g_rowmenu_probe.remove_row_gone, g_rowmenu_probe.can_open_again); // And the removal itself: an entry of the remove view must free that trace of the row. The // popover is left to the entry's own popdown (the app's path -- a click from *inside* the // popover), which destroys it through the `closed` handler, so the handle is dropped first. { const std::string trace = a->rows->row(0).name[0]; GtkWidget* entry = row_menu_find(box, trace.c_str()); a->row_menu_popover = nullptr; if (entry != nullptr) g_signal_emit_by_name(entry, "clicked"); const bool freed = a->rows->row(0).channel[0] < 0; std::fprintf(stderr, "[rowmenu] remove-clicked '%s' trace_freed=%d\n", trace.c_str(), freed ? 1 : 0); } std::fflush(stderr); return G_SOURCE_REMOVE; } void events_probe_next(App& a, int step, guint delay_ms) { a.dbg_events_step = step; g_timeout_add(delay_ms, events_probe_step, &a); } gboolean events_probe_step(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; if (a->dbg_events_step == 0) { // Before anything: streaming, no trigger, recording **off** (the default) -- so no button, no // pane, and nothing written to disk that the user did not ask to have written. report_events_availability(*a, "live, recording off"); // The three clicks a user makes: recording on in the trigger panel, trigger mode on, then the // history button (which is only there once the first two have happened -- that is the item's rule, // and this is where it is asserted). gtk_switch_set_active(GTK_SWITCH(a->events_record_switch), TRUE); // An 80% pre-trigger share, so the event that latches is *not* symmetric: the window a click // opens must be the event's own span, and with a symmetric event the live window would look // identical and say nothing. if (a->trigger_pre_spin != nullptr) { gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_pre_spin), 80.0); } report_events_availability(*a, "recording on, no trigger"); if (a->trigger_toggle != nullptr) { gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE); } report_events_availability(*a, "after trigger on"); if (a->events_toggle != nullptr) { gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->events_toggle), TRUE); } // Read straight after the click, before anything else can call the availability rule: this is the // reading that says the *button* opens the pane, and it is the one a later replay used to mask. report_events_availability(*a, "after history button"); // ... and again a few frames later, still with no replay in the way: this is the *click alone* // path a user takes, which a later replay's own availability call used to mask. events_probe_next(*a, 5, 500); return G_SOURCE_REMOVE; } if (a->dbg_events_step == 5) { GtkWidget* box = a->events_revealer != nullptr ? gtk_revealer_get_child(GTK_REVEALER(a->events_revealer)) : nullptr; std::fprintf(stderr, "[events] click alone: pane=%d pane_w=%d pane_box=%d\n", a->events_revealer != nullptr && gtk_widget_get_visible(a->events_revealer) ? 1 : 0, a->events_divider != nullptr ? gtk_paned_get_position(GTK_PANED(a->events_divider)) : -1, box != nullptr ? gtk_widget_get_width(box) : -1); events_probe_next(*a, 1, 2500); return G_SOURCE_REMOVE; } if (a->event_store == nullptr) { std::fprintf(stderr, "[events] no store (no source)\n"); std::fflush(stderr); return G_SOURCE_REMOVE; } if (a->dbg_events_step == 1) { const std::vector 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(r.seq), thumb_bytes, a->event_recording ? 1 : 0, gtk_revealer_get_reveal_child(GTK_REVEALER(a->events_revealer)) ? 1 : 0); } std::fprintf(stderr, "\n"); std::fflush(stderr); if (!records.empty()) { // Through the *row*, not `replay_event()` directly: that is the gesture -- it loads the // thumbnail beside the description and then replays -- so the probe drives what a click does. // A window that is *not* the event's span, and set *before* the click, so the reading after it // says whether opening the event framed it: with the same window there would be nothing to tell // "framed" from "untouched". apply_time_window(*a, 0.5); GtkListBoxRow* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(a->events_list), 0); if (row != nullptr) g_signal_emit_by_name(a->events_list, "row-activated", row); } // One layout pass before the picture is measured: a widget created a moment ago has no // allocation yet, so reading its size here would read 0x0 for a pane that is drawing fine. events_probe_next(*a, 15, 350); return G_SOURCE_REMOVE; } if (a->dbg_events_step == 15) { // The *allocation*, not only the paint-able: a `GtkPicture` holds a paint-able the moment a file // is set, so a non-null one says nothing about whether anything is on screen. const bool picture = a->events_thumb != nullptr && gtk_picture_get_paintable(GTK_PICTURE(a->events_thumb)) != nullptr; std::fprintf(stderr, "[events] activated row0: replaying=%d pane=%d pane_w=%d pane_box=%d tick=%d " "window=%.3f axis=[%.3f,%.3f] marker=%.3f " "thumbnail=%d thumb_size=%dx%d thumb_visible=%d\n", a->replaying ? 1 : 0, a->events_revealer != nullptr && gtk_widget_get_visible(a->events_revealer) ? 1 : 0, a->events_divider != nullptr ? gtk_paned_get_position(GTK_PANED(a->events_divider)) : -1, // The pane's *rendered* width: the position is what we asked for, this is what the // pane actually got -- a divider at 240 with a zero-width pane is still no pane. a->events_revealer != nullptr && gtk_revealer_get_child(GTK_REVEALER(a->events_revealer)) != nullptr ? gtk_widget_get_width(gtk_revealer_get_child(GTK_REVEALER(a->events_revealer))) : -1, signal_tick_visible(*a), a->rows->time_window(), a->rows->row(0).plot != nullptr ? a->rows->row(0).plot->x_range(a->rows->row(0).axis_x).min : 0.0, a->rows->row(0).plot != nullptr ? a->rows->row(0).plot->x_range(a->rows->row(0).axis_x).max : 0.0, // The trigger line's own cursor: 0 is the trigger instant and a large negative is the // park. A file that opens without its trigger line reads the parked value here. a->rows->row(0).plot != nullptr ? a->rows->row(0).plot->cursor(a->rows->row(0).trigger_marker).x : 0.0, picture ? 1 : 0, a->events_thumb != nullptr ? gtk_widget_get_width(a->events_thumb) : -1, a->events_thumb != nullptr ? gtk_widget_get_height(a->events_thumb) : -1, a->events_thumb != nullptr && gtk_widget_get_mapped(a->events_thumb) ? 1 : 0); std::fflush(stderr); events_probe_next(*a, 2, 1200); return G_SOURCE_REMOVE; } if (a->dbg_events_step == 2) { // After a replay the model holds the *event's* samples and every channel of it, from the loader // `--open` uses -- which is the whole assertion: the cache is readable by the instrument. const std::size_t channels = a->model->channels().size(); const std::size_t samples = channels > 0 ? a->model->live().values(0).produced() : 0; std::fprintf(stderr, "[events] replayed: channels=%zu samples=%zu replaying=%d waveform=%d\n", channels, samples, a->replaying ? 1 : 0, a->waveform_name.empty() ? 0 : 1); std::fflush(stderr); go_live_again(*a); events_probe_next(*a, 3, 900); return G_SOURCE_REMOVE; } std::fprintf(stderr, "[events] live again: replaying=%d waveform=%d\n", a->replaying ? 1 : 0, a->waveform_name.empty() ? 0 : 1); // The pane's *other* precondition, through the real switch, in both trigger states: disabling // recording has to take the button and the pane with it, and arming the trigger afterwards must not // bring back a pane over an empty cache. gtk_switch_set_active(GTK_SWITCH(a->events_record_switch), FALSE); report_events_availability(*a, "recording off, trigger off"); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE); report_events_availability(*a, "recording off, trigger on"); // The group layouts need a *second* event, and a scope back on the stream to collect one: recording // on and the trigger armed again, then step 4 waits for the latch rather than assuming it. gtk_switch_set_active(GTK_SWITCH(a->events_record_switch), TRUE); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE); events_group_probe_next(*a, 4, 2500); return G_SOURCE_REMOVE; } gboolean events_group_probe_step(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; const std::size_t cached = a->event_store != nullptr ? a->event_store->list().size() : 0; if (a->dbg_events_step == 4) { // A bounded wait for the second latch: a source that never latches must report the wait, not hang. if (cached < 2 && a->dbg_events_wait < 12) { ++a->dbg_events_wait; events_group_probe_next(*a, 4, 700); return G_SOURCE_REMOVE; } a->dbg_events_wait = 0; // Open the pane first: its list is only rebuilt while the pane is *up*, so a selection made against // a collapsed pane selects rows that are not there. if (a->events_toggle != nullptr && !gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->events_toggle))) { gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->events_toggle), TRUE); } // The selection *is* the input, so select the two newest rows the way a user does and press the // button: `on_group_clicked()` reads the list, not a parameter. GtkListBox* list = GTK_LIST_BOX(a->events_list); for (int i = 0; i < 2; ++i) { if (GtkListBoxRow* row = gtk_list_box_get_row_at_index(list, i)) { gtk_list_box_select_row(list, row); } } std::fprintf(stderr, "[events] compare buttons: cached=%zu selected=%zu compare=%d accumulate=%d\n", cached, selected_events(*a).size(), a->events_compare != nullptr && gtk_widget_get_sensitive(a->events_compare) ? 1 : 0, a->events_accumulate != nullptr && gtk_widget_get_sensitive(a->events_accumulate) ? 1 : 0); if (a->events_compare != nullptr) g_signal_emit_by_name(a->events_compare, "clicked"); // Read one step later: a rebuilt row's traces are bound by the next *frame*, so a reading taken in // the same handler as the click reads the frame before the bind. events_group_probe_next(*a, 45, 700); return G_SOURCE_REMOVE; } if (a->dbg_events_step == 45) { std::lock_guard 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(a->group_kind), a->rows->row_count(), a->model->channels().size(), static_cast(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 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 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(samples_before), static_cast(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(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(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(a->group_kind), a->waveform_name.empty() ? 0 : 1); std::fflush(stderr); return G_SOURCE_REMOVE; } if (a->dbg_events_step == 5) { if (a->events_accumulate != nullptr) g_signal_emit_by_name(a->events_accumulate, "clicked"); events_group_probe_next(*a, 55, 700); return G_SOURCE_REMOVE; } if (a->dbg_events_step == 55) { { std::lock_guard 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(a->group_kind), a->rows->row_count(), a->model->channels().size(), static_cast(a->model->live().values(0).produced()), selected_events(*a).size(), group_visible_traces(*a, 0), a->rows->time_window()); } events_group_probe_next(*a, 59, 400); return G_SOURCE_REMOVE; } return G_SOURCE_REMOVE; } void events_group_probe_next(App& a, int step, guint delay_ms) { a.dbg_events_step = step; g_timeout_add(delay_ms, events_group_probe_step, &a); } gboolean drop_probe(gpointer user) { App* a = static_cast(user); if (a->window == nullptr || a->model->signals().empty()) return G_SOURCE_REMOVE; std::fprintf(stderr, "[drop-probe] signals=%zu channels=%zu rows=%d slots0=[%s|%s|%s|%s]\n", a->model->signals().size(), a->model->channels().size(), a->rows->row_count(), a->rows->row(0).name[0].c_str(), a->rows->row(0).name[1].c_str(), a->rows->row(0).name[2].c_str(), a->rows->row(0).name[3].c_str()); for (int signal = 0; signal < 2 && static_cast(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& 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(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(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(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(user); if (a->window == nullptr || a->model->signals().empty()) return G_SOURCE_REMOVE; open_calibration(*a, GTK_WINDOW(a->window), 0); std::vector 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 trig; collect_spins(a->trigger_panel, &trig); const std::pair 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 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(user); std::string line = "[keys]"; const auto before_after = [&](const char* name, GtkWidget* button, AppAction action) { const int before = button != nullptr ? (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(button)) ? 1 : 0) : -1; run_app_action(*a, action); const int after = button != nullptr ? (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(button)) ? 1 : 0) : -1; char buf[96]; std::snprintf(buf, sizeof(buf), " %s: %d->%d |", name, before, after); line += buf; }; // Read the lock-protected field, not the atomic mirror: on_arm_clicked() writes // trigger().armed directly, while trigger_armed() (the atomic) is updated by the // acquisition thread on the next packet -- reading it immediately would race. const bool armed_before = a->model->trigger().armed; run_app_action(*a, AppAction::TriggerArm); char armed[64]; std::snprintf(armed, sizeof(armed), " a: armed %d->%d |", armed_before ? 1 : 0, a->model->trigger().armed ? 1 : 0); line += armed; const bool paused_before = a->acq->paused(); run_app_action(*a, AppAction::PauseToggle); char paused[64]; std::snprintf(paused, sizeof(paused), " space: paused %d->%d |", paused_before ? 1 : 0, a->acq->paused() ? 1 : 0); line += paused; run_app_action(*a, AppAction::PauseToggle); // leave the run live for the other probes before_after("t: trigger panel", a->trigger_toggle, AppAction::TriggerPanel); before_after("c: cursors", a->cursors_toggle, AppAction::CursorsPane); before_after("m: measures", a->measures_toggle, AppAction::MeasuresPane); before_after("g: math", a->math_toggle, AppAction::MathPane); before_after("l: layout", a->layout_toggle, AppAction::LayoutEditor); before_after("s: side panel", a->sidebar_toggle, AppAction::SidePanel); // An accel must be refused while text is being typed. Focus the window entry, send 'm' // through the real handler, and require the measurements toggle not to move. int refused = -1; if (a->window_entry != nullptr) { gtk_menu_button_popup(GTK_MENU_BUTTON(a->window_button)); gtk_widget_grab_focus(a->window_entry); const int before = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->measures_toggle)) ? 1 : 0; const gboolean handled = on_app_key_pressed(nullptr, GDK_KEY_m, 0, static_cast(0), a); const int after = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->measures_toggle)) ? 1 : 0; refused = (handled == FALSE && before == after) ? 1 : 0; gtk_menu_button_popdown(GTK_MENU_BUTTON(a->window_button)); } char tail[64]; std::snprintf(tail, sizeof(tail), " editable focused: 'm' refused=%d\n", refused); line += tail; std::fprintf(stderr, "%s", line.c_str()); std::fflush(stderr); return G_SOURCE_REMOVE; } /// What the shortcut overview's UI file *declares*, read out of the resource rather than off the /// built widgets: `GtkShortcutsWindow` reaches each row from more than one place in its own tree, so a /// walk counts every row twice (52 for 26), and what a reader counts on the screen is the declaration. /// /// The two numbers that matter are `with_accel`, the rows drawn as keycaps, and `with_icon`, the ones /// drawn as an icon. `bare` is a row with *neither*, which is what a cell reading "No Shortcut" is -- /// the reason this overview is a GTK window built from a UI file rather than libadwaita's dialog, whose /// items have no icon property at all. It must be zero. struct UiShortcuts { int rows = -1; int with_accel = 0; int with_icon = 0; int bare = 0; // Per-row state, and the depth that tells a row's own `` 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(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(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(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(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(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(user); const HelpReport report = help_self_check(); const std::string session = layout_path(a->model->config().server, a->model->config().port); show_help(GTK_WINDOW(a->window)); show_about(GTK_WINDOW(a->window), session); show_shortcuts(GTK_WINDOW(a->window)); // Read while it is open: the icon is the point of the dialog carrying one, and // `close_help_windows()` is right below. const std::string about_icon = about_icon_name(); close_help_windows(); int accel_count = 0; int gesture_count = 0; (void)app_accels(&accel_count); (void)plot_gestures(&gesture_count); // The tables are what the application handles, the UI file is what it shows. They are compared here // because they are two lists of the same thing: a row in one and not the other is a gesture that // works and is not documented, or is documented and does nothing. const UiShortcuts ui = ui_shortcut_rows("/org/udpscope/ui/shortcuts.ui"); std::fprintf(stderr, "[help] pages=%d parsed=%d shortest='%s' %zu B; shortcuts: app=%d plot=%d tables=%d " "ui=%d ui_accel=%d ui_icon=%d ui_bare=%d; " "about version=%s commit=%s built=%s settings=%s icon=%s; closed ok\n", report.pages, report.parsed, report.shortest_title.c_str(), report.shortest_bytes, accel_count, gesture_count, accel_count + gesture_count, ui.rows, ui.with_accel, ui.with_icon, ui.bare, build_info::kVersion, build_info::kCommit, build_info::kTimestamp, config_dir().c_str(), about_icon.c_str()); std::fflush(stderr); return G_SOURCE_REMOVE; } } // namespace ui_impl } // namespace udpscope