Files
udpscope/src/ui.cpp
T
Martino Ferrari 4e29b958a2 fix: going live restores immediately instead of after a few trigger toggles
The symptom was "view a recorded event, click `<live>`, then toggle the trigger a few times before it
starts streaming again". The mechanism was the restore's own gate: `restore_live_state()` waits for the
*stream's* channel list, because the trigger's stored channel is a **name** and a file view that renamed its
channels (`square (A)`) must be gone before the name means the right signal — and that wait was gated on
`generation()` having moved since *Go live* was pressed.

That gate could never be satisfied in the common case. A **single** event keeps the stream's own channel
names, and the restarted client's CONFIG is identical — which the model treats as *no change on purpose*
(`same_stream_signals()`: "the honest recovery is nothing") — so nothing bumped the generation and the
restore sat there. Toggling the trigger *is* a structural change and moved it, which is exactly the
workaround the report describes.

The gate is now the question it was actually asking: is the snapshot's channel name resolvable against the
live channels (or did the snapshot name none)? For a single event that is true as soon as the client has a
channel list at all, so going live is immediate; for a group the names are renamed, so it waits for the
stream's own CONFIG exactly as before — which is what the gate was for. `live_restore_generation` is still
captured and printed (`gen=a/b`) as information, not as the gate.

Verified against the documented reading, both ways into a file view, from a session with a window and a
trigger so the paired fields have something to disagree with:

    [events] live restored (after replay):  window=0.500000 (want 0.500000) trigger=1 (want 1) channel='square' (want 'square') level=0 (want 0) enabled=1 armed=1 pinned=0 held=0 mode=2 pending=0 gen=6/3
    [events] live restored (after a group): window=0.500000 (want 0.500000) trigger=1 (want 1) channel='square' (want 'square') level=0 (want 0) enabled=1 armed=1 pinned=0 held=0 mode=2 pending=0 gen=16/12

Every want satisfied, `pending=0` in both, `make test-ui` 14 files, zero `Gtk-CRITICAL`. Docs: the
invariants' bullet rewritten around what the restore waits for and why the generation was the wrong
question, and `probe-readings.md` gaining the thing that cost me two runs to find — those four paired
fields are the probe's capture of the live view, so a bare first run reads the application's defaults and
is not a defect; reproduce the documented line from a session that has a window and a trigger.
2026-10-14 15:10:49 +02:00

6304 lines
325 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The GTK4 front end: the toolbar, the trigger and signal panels, and the one plot
// area the `RowSet`'s figure is drawn in.
//
// Every widget here is a thin driver over the model: nothing in this file owns
// sample data. The one thing it does own is the `plot::gtk4::PlotArea`, which is in
// figure mode -- the rows are the figure's rows, so a row whose trace set changed
// (`RowSet::row_dirty()`) costs a new figure and not a new widget: the area keeps its
// GL context and is handed the rebuilt figure.
#include "ui.hpp"
#include "ui_app.hpp"
#include <sys/stat.h>
#include <gtk4/gtk_plot_area.hpp>
#include "acquisition.hpp"
#include "build_info.hpp"
#include "capture.hpp"
#include "config.hpp"
#include "event_store.hpp"
#include "waveform.hpp"
#include "export.hpp"
#include "help_window.hpp"
#include "layout_store.hpp"
#include "shortcuts.hpp"
#include "status_text.hpp"
#include "measure.hpp"
#include "model.hpp"
#include "plot_host.hpp"
#include "search.hpp"
#include "source_dialog.hpp"
#include <adwaita.h>
#include <gtk/gtk.h>
#include <algorithm>
#include <cmath>
#include <utility>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <memory>
#include <mutex>
#include <string>
#include <unordered_map>
#include <vector>
namespace udpscope {
namespace ui_impl {
/// Which group layout is on screen, if any.
// --- small helpers ----------------------------------------------------------
/// Same, without expanding the child. Header-bar children expand through the
/// title's centre box (a start-packed expanding widget is measured into it), so
/// the window control there stretched all the way to the title; panels want the
/// fill, the bar does not.
// --- persisted layout -------------------------------------------------------
/// Defined with the events pane; the trigger toggle reveals it.
// Defined below; the configurations dialog opens a session with them, and it sits above all three.
// `set_notice`'s default is on its definition, so the third argument is passed explicitly here.
/// The configurations of the current source, as a modal window: the list, and a form that saves
/// the session under a new name with a note. Opening one applies its layout to the running scope
/// and remembers it as active; the settings that can only take effect at startup (the buffer, the
/// clock, the capture ring) are reported rather than pretended.
void open_configurations(App& app) {
if (app.source_port == 0) return;
if (app.config_dialog != nullptr) {
gtk_window_present(GTK_WINDOW(app.config_dialog));
return;
}
GtkWidget* window = dialog_window(GTK_WIDGET(app.window), "Configurations");
GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window));
GtkWidget* title = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(title),
("<b>" + app.source_host + ":" + std::to_string(app.source_port) +
"</b>")
.c_str());
gtk_widget_set_halign(title, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(box), title);
auto* d = new ConfigDialog;
d->app = &app;
d->window = window;
d->list = gtk_list_box_new();
gtk_list_box_set_selection_mode(GTK_LIST_BOX(d->list), GTK_SELECTION_NONE);
g_signal_connect(d->list, "row-activated", G_CALLBACK(on_config_row_activated), d);
GtkWidget* scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_NEVER,
GTK_POLICY_AUTOMATIC);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), d->list);
gtk_widget_set_size_request(scroll, 460, 220);
gtk_box_append(GTK_BOX(box), scroll);
d->name = gtk_entry_new();
gtk_entry_set_placeholder_text(GTK_ENTRY(d->name), "new configuration name");
gtk_box_append(GTK_BOX(box), labeled_box("name", d->name));
d->comment = gtk_entry_new();
gtk_entry_set_placeholder_text(GTK_ENTRY(d->comment), "what it is for");
gtk_box_append(GTK_BOX(box), labeled_box("note", d->comment));
GtkWidget* save = gtk_button_new_with_label("Save this session");
gtk_widget_set_tooltip_text(save,
"write the current arrangement under this name, and make it the "
"source's active configuration");
g_signal_connect(save, "clicked", G_CALLBACK(on_config_save_as), d);
GtkWidget* close = gtk_button_new_with_label("Close");
g_signal_connect_swapped(close, "clicked", G_CALLBACK(gtk_window_close), window);
dialog_add_button(window, close);
dialog_add_button(window, save);
g_object_set_data_full(G_OBJECT(window), "udpscope-config-dialog", d,
[](gpointer p) { delete static_cast<ConfigDialog*>(p); });
// Cleared on close: the pointer is what makes a second open a present rather than a second
// window, and a destroyed window must not leave it dangling.
g_signal_connect_swapped(window, "close-request", G_CALLBACK(+[](gpointer user) {
static_cast<App*>(user)->config_dialog = nullptr;
}),
&app);
app.config_dialog = window;
config_dialog_refresh(*d);
gtk_window_present(GTK_WINDOW(window));
}
// Defined below; `apply_layout()` uses them, and it is above both.
GtkWidget* labeled_box_fixed(const char* label, GtkWidget* child) {
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget* l = gtk_label_new(label);
gtk_widget_set_halign(l, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(box), l);
gtk_box_append(GTK_BOX(box), child);
return box;
}
/// A spin button that *starts* where the caller says. `gtk_spin_button_new_with_range()` leaves a
/// fresh button at the range's **minimum**, which is how an offset defaulted to -1e12 and a trigger
/// level to -1e12: a value nobody chose, read back by the dialog's own reader and applied. The
/// starting value is a parameter rather than a default so no call site can forget it.
GtkWidget* spin(double min, double max, double step, int digits, double value) {
GtkWidget* s = gtk_spin_button_new_with_range(min, max, step);
gtk_spin_button_set_digits(GTK_SPIN_BUTTON(s), digits);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(s), value);
return s;
}
/// The units the window control offers, coarsest last. A window is typed as a number in
/// one of these rather than picked off a ladder, so any span the buffer can hold is
/// reachable -- the ladder could only ever offer the spans someone had thought of.
/// Seconds is the coarsest: a scope's window is a span of the stream, and "minutes" is a unit for a
/// recording, not for a view. A window longer than a minute is still typeable -- the entry takes the
/// number and the cap is the buffer's own (`max_window_s`), not this list's last entry.
constexpr WindowUnit kWindowUnits[] = {
{"\u00b5s", 1.0e-6}, {"ms", 1.0e-3}, {"s", 1.0}};
/// The narrowest window the control will accept. Below a microsecond the axis labels stop
/// being distinguishable and no datasource this talks to streams that fast.
/// The window button's label is pinned to this many characters and ellipsized, exactly like
/// the status label: it sits in the header bar directly above the plot, so a label that grew
/// with its text ("1 s" -> "123.456 ms") would widen the bar and relayout everything under
/// it on a mere change of window.
/// Which unit reads most naturally for `s`: the coarsest whose value is still at least 1,
/// so 0.05 s is "50 ms" and not "0.05 s" or "50000 us".
int window_unit_for(double s) {
int unit = 0;
for (int i = kWindowUnitCount - 1; i >= 0; --i) {
if (s >= kWindowUnits[i].seconds * (1.0 - 1.0e-9)) {
unit = i;
break;
}
}
return unit;
}
/// The number part alone, in `unit`. Trailing zeros are trimmed so a decade stepper walking
/// 1 -> 10 -> 100 does not print "1.000"; three decimals is enough to survive a fit, whose
/// span is whatever the ring happened to hold.
std::string format_window_value(double s, int unit) {
char buf[32];
std::snprintf(buf, sizeof(buf), "%.3f", s / kWindowUnits[unit].seconds);
std::string out = buf;
const std::size_t dot = out.find('.');
if (dot != std::string::npos) {
std::size_t last = out.size();
while (last > dot + 1 && out[last - 1] == '0') --last;
if (last == dot + 1) last = dot;
out.resize(last);
}
return out;
}
std::string format_window(double s) {
const int unit = window_unit_for(s);
return format_window_value(s, unit) + " " + kWindowUnits[unit].name;
}
/// A measurement: at most two decimals, scaled by an SI prefix so the number reads in
/// `1..999` (0.01052 V is `10.52 mV`), and the unit appended when the stream declares one.
///
/// Two decimals is the cursors' resolution claim: the markers land on a sample and the
/// sample is an `f32` of a value the CONFIG may scale arbitrarily, so more digits would be
/// noise dressed as precision. Trailing zeros go, so `1.50 V` reads `1.5 V` -- "at most
/// two decimals", not exactly two.
///
/// The prefix is applied **whether or not a unit is declared**, and that is the point: two
/// decimals of an unprefixed small value is `0.00`, i.e. the reading is simply gone. A signal
/// whose CONFIG declares no unit is ordinary (and a *math* channel never has one), so the pane
/// showed `0.00` for every value under 5 mV of an entire class of channels. A bare prefix is
/// written tight against the number -- `1.34m`, not `1.34 m` -- because the space is there to
/// separate the number from its unit, and with no unit to separate, a spaced `m` reads as metres.
std::string format_measure(double v, const std::string& unit) {
if (!std::isfinite(v)) return "--";
static const struct {
double scale;
const char* prefix;
} kPrefixes[] = {
{1.0e12, "T"}, {1.0e9, "G"}, {1.0e6, "M"}, {1.0e3, "k"},
{1.0, ""}, {1.0e-3, "m"}, {1.0e-6, "\u00b5"}, {1.0e-9, "n"}, {1.0e-12, "p"},
};
const double mag = std::fabs(v);
const char* prefix = "";
double scale = 1.0;
for (const auto& p : kPrefixes) {
if (mag >= p.scale) {
prefix = p.prefix;
scale = p.scale;
break;
}
}
char buf[64];
if (mag > 0.0 && mag < 1.0e-12) {
// Below the ladder, where scaling cannot help and `%.2f` would print `0.00` again. Three
// significant digits in whatever form fits, so the reading survives as a reading.
std::snprintf(buf, sizeof(buf), "%.3g", v);
std::string out = buf;
if (!unit.empty()) out += " " + unit;
return out;
}
std::snprintf(buf, sizeof(buf), "%.2f", v / scale);
std::string num = buf;
if (num.find('.') != std::string::npos) { // at most two decimals, not exactly two
while (num.size() > 1 && num.back() == '0') num.pop_back();
if (num.back() == '.') num.pop_back();
}
if (num == "-0") num = "0";
std::string out = num;
if (!unit.empty()) out += " "; // the space separates the number from its unit, nothing else
out += prefix;
out += unit;
return out;
}
// --- plot widgets -----------------------------------------------------------
/// The row index a widget belongs to -- a popover entry's, not a plot's: the menu is
/// built for one row and its buttons have to remember which. Stored biased by one,
/// because `g_object_set_data(obj, key, NULL)` *removes* the key and row 0 would then
/// read back as "no data".
void set_row_key(GtkWidget* widget, int row) {
g_object_set_data(G_OBJECT(widget), "udpscope-row", GINT_TO_POINTER(row + kRowKeyBias));
}
int row_key(GtkWidget* widget) {
return GPOINTER_TO_INT(g_object_get_data(G_OBJECT(widget), "udpscope-row")) - kRowKeyBias;
}
/// The row a pointer event at logical `(x, y)` in the area is over. One widget covers
/// every row now, so the row comes from the figure's layout rather than from a key on
/// the widget the event arrived on.
int row_at(const App& app, double x, double y) {
if (app.area == nullptr) return 0;
const int row = static_cast<int>(app.area->row_at(x, y));
return std::clamp(row, 0, app.rows->row_count() - 1);
}
/// Cancels the pan drag the adapter's controller opened on the press a GTK gesture is
/// about to swallow -- the right-click menu claims the sequence, and a drop's release
/// goes to the DnD machinery, so in both cases the controller never sees the release
/// that would end the drag and the plot would then follow the pointer with no button
/// held. Escape is the controller's own cancel (it also restores the ranges it
/// captured at the press), and it has to go to *that row's* controller.
void cancel_drag_on(App& app, int row) {
if (app.area == nullptr) return;
if (plot::input::Controller* c = app.area->row_controller(static_cast<plot::u32>(row))) {
c->on_key(plot::input::KeyEvent{plot::input::kKeyEscape, 0, true});
}
}
/// Every widget under `root` whose *minimum* width exceeds `threshold`, as a type name and a
/// depth. A minimum width is what GTK must honour, so the chain printed here is the chain that
/// forced the toplevel wider than the screen -- and the deepest line in it is the widget whose
/// own content did it, which no summary of the plot's allocation can name.
void report_wide_widgets(GtkWidget* root, int threshold, int depth) {
if (root == nullptr || !GTK_IS_WIDGET(root)) return;
int min = 0;
int nat = 0;
gtk_widget_measure(root, GTK_ORIENTATION_HORIZONTAL, -1, &min, &nat, nullptr, nullptr);
if (min <= threshold) return;
std::fprintf(stderr, "[width-probe] %*s%s min=%d nat=%d\n", depth * 2, "",
G_OBJECT_TYPE_NAME(root), min, nat);
for (GtkWidget* c = gtk_widget_get_first_child(root); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
report_wide_widgets(c, threshold, depth + 1);
}
}
const char* display_mode_name(DisplayMode m) {
switch (m) {
case DisplayMode::Live: return "live";
case DisplayMode::Capturing: return "capturing";
case DisplayMode::Held: return "held";
case DisplayMode::Waiting: return "waiting";
case DisplayMode::Paused: return "paused";
}
return "?";
}
/// What a painted frame's first bound trace actually had to *draw*, recomputed through the
/// same three calls `Renderer::draw_traces` makes: the index window from `neighbours()`, the
/// LOD level from the 64-samples-per-pixel gate, then `gather()`. It then **counts** the
/// renderer's increasing-suffix filter rather than applying it, and measures the kept
/// polyline's extent against the axis in pixels.
///
/// This exists because every other counter here measures *geometry*, and the reported defect
/// is missing *ink*: `staged_segments` includes the grid and the instruments, so a frame that
/// drew all the chrome and not one sample still reports tens of thousands, and `lod_traces`
/// counts a trace whose gathered points the filter then collapses onto a single x as drawn.
/// Three distinguishable outcomes, each a different bug: `collapse` (under two distinct
/// positions left -- an invisible trace on a frame the renderer thinks it drew), `staircase`
/// (most points dropped, i.e. the stamps came back in runs of equal f32) and `short` (the ink
/// reaches a fraction of the axis -- geometry that is there but off to one side).
void check_frame_ink(App& app) {
if (app.rows == nullptr || app.rows->row_count() == 0) return;
const RowSet::Row& r = app.rows->row(0);
if (r.plot == nullptr) return;
const plot::data::Series* s = nullptr;
for (int i = 0; i < RowSet::kSlotsPerRow; ++i) {
const std::size_t si = static_cast<std::size_t>(i);
if (r.channel[si] < 0) continue;
s = &r.slots[si];
break;
}
if (s == nullptr || s->size() == 0) return;
const plot::Range axis = r.plot->x_range(r.axis_x);
const double axes_w = double(r.plot->layout().axes.w);
if (!(axes_w > 0.0) || !(axis.span() > 0.0)) return;
const plot::u64 n = s->size();
plot::u64 i0 = 0;
plot::u64 i1 = 0;
plot::u64 first = 0;
plot::u64 last = n;
if (s->neighbours(axis.min, i0, i1)) first = i0 > 0 ? i0 - 1 : 0;
if (s->neighbours(axis.max, i0, i1)) last = std::min(n, i1 + 1);
if (last <= first) return;
const double spp = double(last - first) / axes_w;
const plot::u32 level = spp >= 64.0 ? s->pyramid().level_for(n, spp) : 0u;
std::vector<plot::f32>& xy = app.measure_ink_scratch;
xy.clear();
// The same origin the renderer names (`x_range.min`), so the reconstruction below is its
// reconstruction: see `plot::data::Series::gather()` for why the caller owns it.
s->gather(level, first, last, axis.min, xy);
const std::size_t pts = xy.size() / 2;
std::size_t kept = 0;
double lo = 0.0;
double hi = 0.0;
// Where the filter's drops fall, and how far back each one steps. The origin is a constant
// for the walk's duration *by construction* now -- the renderer reconstructs
// `gather[i] + x_range.min`, a local of its own, where it used to re-read `series.x0()` on
// every point and a ring-backed `x0()` advances with the producer. So a drop here is a
// genuinely out-of-order gather, and `back` says by how much.
std::size_t drop_first = 0;
std::size_t drop_last = 0;
double worst_back = 0.0;
bool any_drop = false;
{
double next = std::numeric_limits<double>::infinity();
for (std::size_t k = pts; k > 0; --k) {
const double x = double(xy[2 * (k - 1)]) + axis.min;
if (!(x < next)) {
worst_back = std::max(worst_back, x - next);
if (!any_drop) drop_last = k - 1;
any_drop = true;
drop_first = k - 1;
continue;
}
next = x;
if (kept == 0) hi = x;
lo = x;
++kept;
}
}
++app.dbg_ink_checked;
// Drawn against quoted, in pixels. At level 0 a gathered pair *is* sample `first + k`, so
// this is the one place the two numbers the user compares by eye -- the polyline's position
// and the crosshair's readout -- can be differenced. The origin is what decides it: the f32
// resolves 2^-24 of whatever span it is relative to, so a view narrower than that fraction
// of the *retained window* drew a sample visibly away from the x quoted for it, which is
// what "when zooming there is a visible offset between the real data point and the crosshair
// selected one" was.
if (level == 0 && pts > 0) {
const double px_per_x = axes_w / axis.span();
double worst = 0.0;
for (std::size_t k = 0; k < pts; ++k) {
const double drawn = axis.min + double(xy[2 * k]);
worst = std::max(worst, std::abs(drawn - s->x_at(first + k)) * px_per_x);
}
app.dbg_ink_worst_offset_px = std::max(app.dbg_ink_worst_offset_px, worst);
++app.dbg_ink_raw_checked;
}
const double cover = kept >= 2 ? (hi - lo) / axis.span() : 0.0;
app.dbg_ink_worst_cover = std::min(app.dbg_ink_worst_cover, cover);
const bool collapse = kept < 2;
const bool staircase = pts >= 8 && kept * 4 < pts;
// A ring that has not yet reached back to the axis' left edge is *meant* to leave its left
// side empty (see the `clamp_to_data()` invariant), so a short ink there is the buffer
// filling and not a defect: it is only reported once the data spans the window.
const bool short_ink = !collapse && cover < 0.25 && s->x_bounds().min <= axis.min;
if (collapse) ++app.dbg_ink_collapse;
if (staircase) ++app.dbg_ink_staircase;
if (short_ink) ++app.dbg_ink_short;
if ((collapse || staircase || short_ink) && app.dbg_ink_reports < 40) {
++app.dbg_ink_reports;
// The series' own answers beside the reconstruction, because the two disagreeing is itself
// a reading: `rel` is what `gather()` emitted (relative to `axis.min`, the origin it was
// given) while `x0`/`x_at`/`bounds` are the window the series says it holds, so a trace
// whose ink sits a whole ring-span off its axis separates into "the stamps moved under us"
// and "the window is the wrong one".
const plot::Range xb = s->x_bounds();
std::fprintf(stderr,
"[ink] paint %d %s%s%s mode=%s axis=[%.6f..%.6f] n=%llu idx=[%llu..%llu) "
"spp=%.1f level=%u pts=%zu kept=%zu ink=[%.6f..%.6f] cover=%.1f%% "
"x0=%.6f x_at=[%.6f..%.6f] bounds=[%.6f..%.6f] rel=[%.6f..%.6f] dx=%.3g "
"drops=[%zu..%zu] back=%.3g\n",
app.dbg_paint_n, collapse ? "collapse " : "", staircase ? "staircase " : "",
short_ink ? "short " : "", display_mode_name(app.dbg_display), axis.min, axis.max,
(unsigned long long)n, (unsigned long long)first, (unsigned long long)last, spp,
level, pts, kept, lo, hi, cover * 100.0, s->x0(), s->x_at(0), s->x_at(n - 1),
xb.min, xb.max, pts > 0 ? double(xy[0]) : 0.0,
pts > 0 ? double(xy[2 * (pts - 1)]) : 0.0, s->dx(), drop_first, drop_last,
worst_back);
std::fflush(stderr);
}
}
/// Frame-cost probe, end of the plot's own draw. Connected *after* the adapter's
/// `::render`, which is the whole reason `PlotArea::on_render` returns FALSE: the signal
/// accumulates with `g_signal_accumulator_true_handled`, so a TRUE there would stop the
/// emission and this would never run. Reading the stats here rather than in `tick()` is
/// what makes them this frame's: `begin_frame()` resets them.
void on_before_paint(GdkFrameClock*, gpointer user) {
static_cast<App*>(user)->dbg_paint_start_us = g_get_monotonic_time();
}
void on_after_paint(GdkFrameClock*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->dbg_paint_start_us == 0) return;
const double ms = double(g_get_monotonic_time() - app->dbg_paint_start_us) / 1000.0;
app->dbg_paint_ms_sum += ms;
app->dbg_paint_ms_max = std::max(app->dbg_paint_ms_max, ms);
++app->dbg_paint_n;
}
GtkWidget* make_area(App& app) {
plot::gtk4::PlotArea* area = plot::gtk4::PlotArea::create(app.placeholder_plot);
app.area = area;
area->set_figure(&app.rows->figure());
GtkWidget* widget = area->widget();
app.area_widget = widget;
gtk_widget_set_hexpand(widget, true);
gtk_widget_set_vexpand(widget, true);
gtk_widget_set_size_request(widget, 320, 120);
g_signal_connect(widget, "render", G_CALLBACK(on_render_probe), &app);
GtkDropTarget* target = gtk_drop_target_new(G_TYPE_STRING, GDK_ACTION_COPY);
g_signal_connect(target, "drop", G_CALLBACK(on_drop_channel), &app);
gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(target));
GtkGesture* right = gtk_gesture_click_new();
gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(right), 3);
g_signal_connect(right, "pressed", G_CALLBACK(on_right_click), &app);
gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(right));
// The overlay exists for the layout editor's buttons and is otherwise empty, so it
// costs one pass-through widget: with no overlay children, picking and allocation
// reach the GL area exactly as before.
app.area_overlay = gtk_overlay_new();
gtk_overlay_set_child(GTK_OVERLAY(app.area_overlay), widget);
gtk_widget_set_hexpand(app.area_overlay, true);
gtk_widget_set_vexpand(app.area_overlay, true);
return app.area_overlay;
}
/// Brings the plot area in line with the rows: a changed trace set (or row count)
/// rebuilds the figure, and the area is pointed at the new one. Deferred to the frame
/// tick -- a drop arrives from inside a gesture attached to this widget, and the
/// rebuild resets that gesture's controller.
void relayout_areas(App& app) {
if (!app.rows->rebuild_if_dirty()) return;
if (app.area != nullptr) app.area->set_figure(&app.rows->figure());
}
// --- panel rebuilds ---------------------------------------------------------
/// Defined below, forward-declared because placing or freeing a trace slot has to
/// rebuild the signal list: each row's colour chip names the trace that signal is
/// drawn as, so it goes stale the moment the placement changes.
// --- the layout editor ------------------------------------------------------
//
// Rows are added and removed in place rather than picked off a list of layouts: with a
// count you can only say "I want three", and which of the three your traces end up on is
// whatever the renumbering decides. The editor's buttons sit *on* the rows, so the row
// the action names is the row you are pointing at.
//
// Geometry comes from `FigureLayout::rows` -- the same rects the frame was drawn from --
// divided by `PlotArea::device_scale()`, because a `Rect` is framebuffer pixels and a GTK
// margin is logical ones.
//
// One `+` per *boundary*, not two per row: a row's bottom edge and the next row's top
// edge are the same line (the rows tile the figure), so "add below row 1" and "add above
// row 2" are the same insertion and two buttons there would be one on top of the other.
// There are `rows + 1` of them -- above the first row and below the last included -- so
// every position the two-per-row reading offers is still reachable.
/// `g_object_set_data` key telling a remove button from an insert one. The row/boundary
/// index is the usual `set_row_key()`.
/// Takes the editor's buttons off the overlay. `gtk_overlay_remove_overlay()` drops the
/// overlay's reference, which is the only one, so the widgets go with it.
///
/// This runs from a *button's own* `::clicked` handler (the click rebuilds the set), which
/// is safe because the emission holds its own reference on the instance: the button is
/// finalized after the handler returns, not under it.
void clear_layout_editor(App& app) {
if (app.area_overlay != nullptr) {
for (GtkWidget* b : app.layout_buttons) {
gtk_overlay_remove_overlay(GTK_OVERLAY(app.area_overlay), b);
}
if (app.layout_done_button != nullptr) {
gtk_overlay_remove_overlay(GTK_OVERLAY(app.area_overlay), app.layout_done_button);
}
}
app.layout_buttons.clear();
app.layout_done_button = nullptr;
app.layout_buttons_rows = 0;
}
/// Restates where the buttons sit, from the rects the last frame was laid out with.
/// Called every frame while the editor is up: `gtk_widget_set_margin_*()` returns early
/// on an unchanged value, so a settled layout costs the comparisons and nothing else.
void sync_layout_editor(App& app) {
if (app.layout_buttons.empty() || app.area == nullptr) return;
const plot::FigureLayout& fl = app.rows->figure().layout();
const int rows = app.rows->row_count();
// Before the first frame the figure has no rects yet; placing against them would put
// every button in the top-left corner and then move it, which is a visible jump.
if (static_cast<int>(fl.rows.size()) != rows) return;
const double scale = app.area->device_scale() > 0.0f ? app.area->device_scale() : 1.0;
const double height = static_cast<double>(gtk_widget_get_height(app.area_widget));
if (!(height > 0.0)) return;
const double half = kLayoutButtonPx * 0.5;
for (GtkWidget* b : app.layout_buttons) {
const int idx = row_key(b);
const bool insert = g_object_get_data(G_OBJECT(b), kLayoutKindKey) != nullptr;
const bool resize = g_object_get_data(G_OBJECT(b), kLayoutResizeKey) != nullptr;
if (resize) {
// *On* the boundary, beside the `+` -- the same edge, one handle's width to its right. Below it sat
// inside the lower row, which read as a handle for that row: a boundary handle that looks like it
// belongs to the plot under it is a handle whose drag changes the wrong plot, whatever it does.
const double edge = idx < rows ? static_cast<double>(fl.rows[static_cast<std::size_t>(idx)].y) / scale
: (static_cast<double>(fl.rows.back().y) +
static_cast<double>(fl.rows.back().h)) / scale;
const double top = std::clamp(edge - half, 0.0, height - kLayoutButtonPx);
gtk_widget_set_margin_top(b, static_cast<int>(top + 0.5));
const double width = static_cast<double>(gtk_widget_get_width(app.area_widget));
const double left = width * 0.5 + kLayoutButtonPx * 0.5 + 6.0;
gtk_widget_set_margin_start(
b, static_cast<int>(std::clamp(left, 0.0, std::max(0.0, width - kLayoutButtonPx)) + 0.5));
} else if (insert) {
// Centred on the boundary: the top edge of row `idx`, or the bottom edge of the
// last row for the one past the end.
const double edge = idx < rows ? static_cast<double>(fl.rows[static_cast<std::size_t>(idx)].y) / scale
: (static_cast<double>(fl.rows.back().y) +
static_cast<double>(fl.rows.back().h)) / scale;
const double top = std::clamp(edge - half, 0.0, height - kLayoutButtonPx);
gtk_widget_set_margin_top(b, static_cast<int>(top + 0.5));
} else {
const plot::Rect& r = fl.rows[static_cast<std::size_t>(idx)];
const double top = std::clamp(static_cast<double>(r.y) / scale + kLayoutButtonPad,
0.0, height - kLayoutButtonPx);
gtk_widget_set_margin_top(b, static_cast<int>(top + 0.5));
// Inside the axes, not over the y labels: the gutter is the one piece of row chrome
// whose width is reserved rather than measured per frame, so this does not move.
const plot::Plot* p = app.rows->row(idx).plot;
const double left = (p != nullptr ? static_cast<double>(p->layout().axes.x) / scale
: static_cast<double>(r.x) / scale) +
kLayoutButtonPad;
gtk_widget_set_margin_start(b, static_cast<int>(left + 0.5));
}
}
}
/// A row may not be dragged below this, in logical pixels: a row of no height is a row a user cannot
/// grab again, and the plotter keeps a floor of its own -- this is the one the *drag* respects.
constexpr double kMinRowPx = 48.0;
void on_layout_drag_begin(GtkGestureDrag* gesture, double, double, gpointer user) {
App& app = *static_cast<App*>(user);
app.layout_drag_row =
static_cast<int>(GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(gesture), "udpscope-layout-drag")));
// A drag is *relative*: the weights it started from are kept, because reading them back while dragging
// would compound -- each update would build on the last frame's instead of on the grab.
app.layout_drag_start = app.rows->row_weights();
app.layout_drag_start.resize(static_cast<std::size_t>(app.rows->row_count()), 1.0f);
}
void on_layout_drag_update(GtkGestureDrag*, double, double offset_y, gpointer user) {
App& app = *static_cast<App*>(user);
const int at = app.layout_drag_row; // the boundary between rows `at - 1` and `at`
const int rows = app.rows->row_count();
if (at <= 0 || at >= rows) return; // the figure's own edges are not boundaries
if (app.layout_drag_start.size() != static_cast<std::size_t>(rows)) return;
const plot::FigureLayout& fl = app.rows->figure().layout();
if (static_cast<int>(fl.rows.size()) != rows) return;
const double scale =
app.area != nullptr && app.area->device_scale() > 0.0f ? app.area->device_scale() : 1.0;
const double up = static_cast<double>(fl.rows[static_cast<std::size_t>(at - 1)].h) / scale;
const double lo = static_cast<double>(fl.rows[static_cast<std::size_t>(at)].h) / scale;
const double pair = up + lo;
if (!(pair > kMinRowPx * 2.0)) return;
// Where the boundary is asked to go, floored on both sides: the *pixels* say where, and the pair's
// weights are the shares that put it there.
const double want_up = std::clamp(up + offset_y / scale, kMinRowPx, pair - kMinRowPx);
std::vector<float> weights = app.layout_drag_start;
const float sum = weights[static_cast<std::size_t>(at - 1)] + weights[static_cast<std::size_t>(at)];
weights[static_cast<std::size_t>(at - 1)] = sum * static_cast<float>(want_up / pair);
weights[static_cast<std::size_t>(at)] = sum - weights[static_cast<std::size_t>(at - 1)];
app.rows->set_row_weights(std::move(weights));
}
void on_layout_drag_end(GtkGestureDrag*, double, double, gpointer user) {
App& app = *static_cast<App*>(user);
app.layout_drag_row = -1;
app.layout_drag_start.clear();
// A dragged sizing is an arrangement, so it is saved like one.
schedule_layout_save(app);
}
/// Builds one `×` per row and one `+` per boundary, or takes them all off again when the
/// editor is switched off.
/// One of the layout editor's overlay buttons: the look they all share is *here*, once. It was written out
/// three times -- the icon, the two CSS classes that make a small round on-screen-display control, and the
/// size -- and the fourth copy would have been a button added next to them.
///
/// These buttons cannot be a `.ui` file, and the reason is worth stating because the plan expected them to
/// be: their *number* is the row count, one per row and one more than that for the boundaries, and a
/// `GtkBuilder` instantiates a file's objects once. This function reappears whenever a `.ui` is asked to hold
/// something whose count is data.
///
/// What each button does with the look is the caller's: they differ in alignment, sensitivity, the data they
/// carry and the gesture they take.
GtkWidget* layout_overlay_button(App& app, const char* icon, const char* tooltip) {
GtkWidget* b = gtk_button_new_from_icon_name(icon);
gtk_widget_set_tooltip_text(b, tooltip);
gtk_widget_add_css_class(b, "circular");
gtk_widget_add_css_class(b, "osd");
gtk_widget_set_size_request(b, kLayoutButtonPx, kLayoutButtonPx);
gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), b);
app.layout_buttons.push_back(b);
return b;
}
void rebuild_layout_editor(App& app) {
clear_layout_editor(app);
if (!app.layout_editing || app.area_overlay == nullptr) return;
const int rows = app.rows->row_count();
app.layout_buttons_rows = rows;
for (int i = 0; i < rows; ++i) {
GtkWidget* b = layout_overlay_button(app, "window-close-symbolic", "remove this row");
gtk_widget_set_halign(b, GTK_ALIGN_START);
gtk_widget_set_valign(b, GTK_ALIGN_START);
gtk_widget_set_sensitive(b, rows > 1);
set_row_key(b, i);
g_signal_connect(b, "clicked", G_CALLBACK(on_layout_row_remove), &app);
}
for (int b_i = 0; b_i <= rows; ++b_i) {
GtkWidget* b = layout_overlay_button(app, "list-add-symbolic",
b_i == 0 ? "add a row above"
: b_i == rows ? "add a row below"
: "add a row here");
gtk_widget_set_halign(b, GTK_ALIGN_CENTER);
gtk_widget_set_valign(b, GTK_ALIGN_START);
gtk_widget_set_sensitive(b, rows < RowSet::kMaxRows);
set_row_key(b, b_i);
g_object_set_data(G_OBJECT(b), kLayoutKindKey, b);
g_signal_connect(b, "clicked", G_CALLBACK(on_layout_row_insert), &app);
}
// ... and one arrow per boundary whose *only* job is resizing the two rows it sits between. A widget
// of its own rather than a second gesture on the `+`, so each handle does one thing -- and a handle
// that cannot be clicked cannot be clicked by accident. The figure's own edges are not boundaries
// (there is no pair of rows to share between them), so the first and last are inert.
for (int b_i = 0; b_i <= rows; ++b_i) {
GtkWidget* b = layout_overlay_button(app, "udpscope-resize-rows-symbolic",
b_i == 0 ? "drag to resize the first row"
: b_i == rows ? "drag to resize the last row"
: "drag to resize the two rows here");
gtk_widget_set_halign(b, GTK_ALIGN_CENTER);
gtk_widget_set_valign(b, GTK_ALIGN_START);
// Hidden, not disabled, where it has no pair to share between: the figure's own edges. A greyed
// handle is a question ("why can I not drag this?"), and the answer is that there is nothing there.
gtk_widget_set_visible(b, b_i > 0 && b_i < rows);
gtk_widget_set_halign(b, GTK_ALIGN_START);
// The *row key* is what `sync_layout_editor()` places by -- without it every arrow was placed at
// index 0, the figure's top edge, whatever boundary it belonged to.
set_row_key(b, b_i);
g_object_set_data(G_OBJECT(b), kLayoutResizeKey, GSIZE_TO_POINTER(b_i));
GtkGesture* drag = gtk_gesture_drag_new();
gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(drag), 1);
gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(drag), GTK_PHASE_CAPTURE);
g_object_set_data(G_OBJECT(drag), "udpscope-layout-drag", GSIZE_TO_POINTER(b_i));
g_signal_connect(drag, "drag-begin", G_CALLBACK(on_layout_drag_begin), &app);
g_signal_connect(drag, "drag-update", G_CALLBACK(on_layout_drag_update), &app);
g_signal_connect(drag, "drag-end", G_CALLBACK(on_layout_drag_end), &app);
gtk_widget_add_controller(b, GTK_EVENT_CONTROLLER(drag));
}
// The editor's way out, in the area's top-right corner: the toolbar's toggle is in the
// overflow menu, which is a long way from the plot you are editing. Aligned by margins
// like every other overlay child, so it never touches the figure's layout.
GtkWidget* done = gtk_button_new_with_label("Done");
gtk_widget_set_tooltip_text(done, "leave the layout editor");
gtk_widget_add_css_class(done, "osd");
gtk_widget_set_size_request(done, -1, kLayoutButtonPx);
gtk_widget_set_halign(done, GTK_ALIGN_END);
gtk_widget_set_valign(done, GTK_ALIGN_START);
gtk_widget_set_margin_top(done, kLayoutButtonPad);
gtk_widget_set_margin_end(done, kLayoutButtonPad);
g_signal_connect(done, "clicked", G_CALLBACK(on_layout_editor_done), &app);
gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), done);
app.layout_done_button = done;
sync_layout_editor(app);
}
/// The width-comparison dropdown's order. `<` first, because that is the glitch trigger
/// and the reason this condition exists.
WidthCmp width_cmp_at(guint i) {
switch (i) {
case 0:
return WidthCmp::Less;
case 2:
return WidthCmp::Within;
case 3:
return WidthCmp::Outside;
default:
return WidthCmp::Greater;
}
}
void show_info_dialog(GtkWindow* parent, const SignalInfo& sig) {
char body[1024];
std::snprintf(body, sizeof(body),
"unit: %s\ntype: %d\nquantisation: %d\nrows x cols: %u x %u\nrange: %g .. %g\n"
"sample rate: %g Hz\ntime mode: %d\ntime signal: %u",
sig.unit.empty() ? "(none)" : sig.unit.c_str(), int(sig.type_code),
int(sig.quant_type), sig.rows, sig.cols, sig.range_min, sig.range_max,
sig.sampling_rate, int(sig.time_mode), sig.time_signal_idx);
GtkWidget* window = dialog_window(GTK_WIDGET(parent), sig.name.c_str());
GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window));
GtkWidget* label = gtk_label_new(body);
gtk_widget_set_halign(label, GTK_ALIGN_START);
gtk_label_set_wrap(GTK_LABEL(label), true);
gtk_label_set_selectable(GTK_LABEL(label), true);
gtk_box_append(GTK_BOX(box), label);
GtkWidget* close = gtk_button_new_with_label("Close");
g_signal_connect_swapped(close, "clicked", G_CALLBACK(gtk_window_destroy), window);
dialog_add_button(window, close);
gtk_window_present(GTK_WINDOW(window));
}
void on_info_clicked(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const std::size_t index = static_cast<std::size_t>(
GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(button), "udpscope-signal")));
if (index < app->model->signals().size()) {
show_info_dialog(GTK_WINDOW(app->window), app->model->signals()[index]);
}
}
void on_calibrate_apply(GtkButton*, gpointer user) {
CalibContext* ctx = static_cast<CalibContext*>(user);
ctx->app->model->set_calibration(ctx->signal, gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->gain)),
gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->offset)));
schedule_layout_save(*ctx->app);
gtk_window_destroy(GTK_WINDOW(ctx->window));
}
void open_calibration(App& app, GtkWindow* parent, std::size_t signal) {
const SignalInfo& sig = app.model->signals()[signal];
GtkWidget* window = dialog_window(GTK_WIDGET(parent), "Calibrate");
GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window));
GtkWidget* title = gtk_label_new(sig.name.c_str());
gtk_widget_set_halign(title, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(box), title);
// The signal's *own* calibration, not a stock identity: a dialog that opened at 1/0 while the
// trace was drawn through a gain of 2 invited the user to "confirm" it back to 1 by pressing
// Apply. A signal nobody calibrated reads 1 and 0, which is the identity it is drawn through --
// and the values are the model's record, so a signal that is not acquired still shows the
// calibration that will apply to it when it is.
const std::pair<double, double> cal = app.model->calibration(signal);
GtkWidget* gain = spin(-1.0e12, 1.0e12, 0.001, 6, cal.first);
GtkWidget* offset = spin(-1.0e12, 1.0e12, 0.001, 6, cal.second);
gtk_box_append(GTK_BOX(box), labeled_box("gain", gain));
gtk_box_append(GTK_BOX(box), labeled_box("offset", offset));
auto* ctx = new CalibContext{&app, signal, gain, offset, window};
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
// `gtk_window_close()`, not `gtk_window_destroy()`: the destroy path does **not** emit
// `close-request`, so the handler that clears `app.math_dialog` never ran and every later open
// was a `present` on a dead widget -- a critical, and nothing on screen. Closing emits the signal,
// the handler clears the pointer, and the default handler then destroys the window.
g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_close), window);
GtkWidget* apply = gtk_button_new_with_label("Apply");
g_object_set_data_full(G_OBJECT(apply), "udpscope-ctx", ctx,
[](gpointer p) { delete static_cast<CalibContext*>(p); });
g_signal_connect(apply, "clicked", G_CALLBACK(on_calibrate_apply), ctx);
dialog_add_button(window, cancel);
dialog_add_button(window, apply);
gtk_window_present(GTK_WINDOW(window));
}
GdkContentProvider* on_drag_prepare(GtkDragSource*, double, double, gpointer user) {
DragContext* ctx = static_cast<DragContext*>(user);
// The payload is the signal index; the channel is resolved on drop.
char buf[64];
std::snprintf(buf, sizeof(buf), "%zu", ctx->signal);
return gdk_content_provider_new_typed(G_TYPE_STRING, buf);
}
/// A trace colour as `#RRGGBB`: what a chip is drawn with and what the session file stores. One
/// formatter for both, so a chip and the colour it claims to name cannot be written differently --
/// and so the probe can read a chip's own markup rather than recomputing what it should say.
std::string color_hex(plot::Color c) {
char buf[16];
std::snprintf(buf, sizeof(buf), "#%02X%02X%02X", unsigned(c.r * 255.0f + 0.5f),
unsigned(c.g * 255.0f + 0.5f), unsigned(c.b * 255.0f + 0.5f));
return buf;
}
std::string chip_markup(plot::Color c) {
return "<span foreground=\"" + color_hex(c) + "\">\u25CF</span>";
}
/// What an open colour chooser is choosing for. The signal's **name**, not its index: the chooser
/// is asynchronous and a CONFIG can renumber the signal list while it is open, which is the same
/// reason every persisted identity in this app is a name.
/// Opens the colour chooser for one signal's trace.
///
/// `GtkColorDialog`, not the `GtkColorChooserDialog` deprecated in GTK 4.10: libadwaita 1.8 already
/// puts this build's floor at GTK 4.18, so the modern API is simply available. Alpha is off --
/// `plot::Color` carries one but a trace is stroked opaque, so a transparency slider would offer a
/// setting that does nothing.
void open_signal_color(App& app, std::size_t signal) {
std::string name;
int palette_index = 0;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
const std::vector<SignalInfo>& signals = app.model->signals();
if (signal >= signals.size()) return;
name = signals[signal].name;
palette_index = signal_color_index(signals, signal);
}
if (name.empty()) return;
const plot::Color current = app.rows->signal_color(name, palette_index);
const GdkRGBA start{current.r, current.g, current.b, 1.0f};
GtkColorDialog* dialog = gtk_color_dialog_new();
gtk_color_dialog_set_title(dialog, ("Colour for " + name).c_str());
gtk_color_dialog_set_with_alpha(dialog, FALSE);
gtk_color_dialog_choose_rgba(dialog, GTK_WINDOW(app.window), &start, nullptr,
on_signal_color_chosen, new ColorPick{&app, name});
// The async call holds its own reference for the operation's life, which is the documented
// idiom for every `Gtk*Dialog` in GTK 4.10's async family.
g_object_unref(dialog);
}
/// Fills the trigger's signal dropdown with the channels that are actually being
/// acquired -- a trigger can only fire on a channel that has samples. Selecting a
/// channel defaults the level into the middle of its signal's declared range (a
/// level of 0 on a 0..5 V transducer never fires, and "the trigger does nothing" is
/// exactly what that looks like), until the user edits the level by hand.
void rebuild_trigger_signals(App& app) {
const std::vector<ChannelInfo>& channels = app.model->channels();
app.updating_panels = true;
std::vector<std::string> storage;
storage.reserve(channels.size());
for (const ChannelInfo& c : channels) {
storage.push_back(c.unit.empty() ? c.name : c.name + " [" + c.unit + "]");
}
std::vector<const char*> names;
names.reserve(storage.size() + 1);
for (const std::string& s : storage) names.push_back(s.c_str());
if (names.empty()) {
storage.emplace_back("(no acquired channels)");
names.push_back(storage.back().c_str());
}
names.push_back(nullptr);
GtkStringList* list = gtk_string_list_new(names.data());
gtk_drop_down_set_model(GTK_DROP_DOWN(app.trigger_signal_drop), G_LIST_MODEL(list));
g_object_unref(list);
TriggerConfig& tc = app.model->trigger();
// A session stores the trigger's channel by *name*: resolved here, where the acquired channels
// exist. The pending name is cleared either way, so a name this stream does not have falls through
// to the default below rather than being retried on every rebuild.
if (!app.pending_trigger_channel.empty()) {
for (std::size_t c = 0; c < channels.size(); ++c) {
if (channels[c].name == app.pending_trigger_channel) {
tc.channel = static_cast<int>(c);
break;
}
}
app.pending_trigger_channel.clear();
}
if (tc.channel < 0 && !channels.empty()) tc.channel = 0;
const int sel = tc.channel;
gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_signal_drop),
sel < 0 || sel >= static_cast<int>(storage.size())
? 0u
: static_cast<guint>(sel));
if (sel >= 0 && sel < static_cast<int>(channels.size()) && !app.trigger_level_touched) {
const SignalInfo& sig =
app.model->signals()[static_cast<std::size_t>(channels[static_cast<std::size_t>(sel)].signal)];
if (std::isfinite(sig.range_min) && std::isfinite(sig.range_max) &&
sig.range_max > sig.range_min) {
const double mid = 0.5 * (sig.range_min + sig.range_max);
tc.condition.level = mid;
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_level_spin), mid);
}
}
app.updating_panels = false;
}
// --- toolbar handlers -------------------------------------------------------
/// Writes `seconds` into the control -- the button's label, the entry and the unit -- and
/// nothing else. Read-only with respect to the plot, so every path that changes the span on
/// screen for its own reasons (a fit, an event pin) can say so without looping back through
/// `apply_time_window()`.
void sync_window_display(App& app, double seconds) {
if (!(seconds > 0.0)) return;
app.updating_panels = true;
app.dbg_window_text = format_window(seconds);
if (app.window_label != nullptr) {
gtk_label_set_text(GTK_LABEL(app.window_label), app.dbg_window_text.c_str());
}
const int unit = window_unit_for(seconds);
app.window_sync_unit = unit;
gtk_drop_down_set_selected(GTK_DROP_DOWN(app.window_unit_drop), static_cast<guint>(unit));
gtk_editable_set_text(GTK_EDITABLE(app.window_entry),
format_window_value(seconds, unit).c_str());
app.updating_panels = false;
}
/// Clears every measurement's statistics. Called explicitly by the pane's Reset, and
/// implicitly wherever the series of results stops being one series: a measurement edit
/// (`rebuild_measures_pane()`), a change of display window, and a CONFIG.
void reset_measure_stats(App& app) {
for (MeasureStatsAccum& s : app.measure_stats) s.reset();
}
/// The one place the time scale changes. Clamps to what the buffer can hold, moves the plot and
/// the trigger's event span together -- its pre-trigger share is a share of *this* window -- and
/// then restates the result in the control, so a clamped request reads back as the window that
/// was actually taken rather than the one that was typed.
///
/// **Only the window control comes here.** The time scale is more than the axis: it is also the
/// trigger's event span, the buffer partition (so a change of decimation drops the history) and
/// the session, so a *plot interaction* may not state one. A zoom and a pan never touch it by
/// construction -- they move the row's axis, and `clamp_to_data()` caps the span at `window_s_` --
/// and the two fits do not either: `RowSet::fit_x()` undoes a zoom by restoring the window that
/// is already set. Both fits used to frame the data instead and move `window_s_` alone, which
/// left the event span on the *old* window until the next decade step or unit change applied the
/// fitted value for real: one action, two changes of time scale.
void apply_time_window(App& app, double seconds) {
if (!(seconds > 0.0)) return;
const double clamped = std::min(std::max(seconds, kWindowMinS), window_limit(app));
// The buffer has to cover the window, so this may re-plan the partition and drop the
// history -- a change of decimation factor cannot keep samples stored at the old rate.
// It is announced rather than silent, because losing the trace is otherwise unexplained.
// The frame tick rebinds every slot from the model, so the new rings are picked up
// without being told -- the same path an enable or a CONFIG already takes.
if (app.model->set_target_span(clamped)) {
app.notice = "window " + format_window(clamped) + ": resampled to " +
format_rate(app.model->partition().stored_rate()) + ", history cleared";
app.notice_until_us = g_get_monotonic_time() + 4 * G_TIME_SPAN_SECOND;
}
app.model->set_event_span(clamped);
// The partition before the axis, so the axis is framed over the rings that will exist.
app.rows->set_time_window(clamped);
sync_window_display(app, clamped);
// A measurement over a different window is a different figure -- a mean over 20 ms and a
// mean over 2 s are not observations of one quantity -- so the history behind it goes.
// The search shares the same premise: the ring was rebuilt, so its at_/end_ describe nothing.
reset_measure_stats(app);
app.search.clear();
schedule_layout_save(app);
}
/// Enter in the entry. A number that will not parse restores the current window instead of
/// reporting an error: the entry is also the readout, so leaving junk in it would leave the
/// control disagreeing with the plot.
void on_window_entry_activate(GtkEntry* entry, gpointer user) {
App* app = static_cast<App*>(user);
if (app->updating_panels) return;
const char* text = gtk_editable_get_text(GTK_EDITABLE(entry));
char* end = nullptr;
const double value = std::strtod(text, &end);
if (end == text || !(value > 0.0)) {
sync_window_display(*app, app->rows->time_window());
return;
}
const guint unit = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->window_unit_drop));
apply_time_window(*app, value * kWindowUnits[unit < kWindowUnitCount ? unit : 2].seconds);
}
/// Changing the unit *re-interprets* the number already typed rather than converting it: "2"
/// switched from ms to s means 2 s. Converting instead would make the unit selector a
/// no-op, since the window it names would not change.
void on_window_unit_changed(GObject* obj, GParamSpec*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->updating_panels) return;
// Our own sync, arriving late (see `window_sync_unit`).
if (gtk_drop_down_get_selected(GTK_DROP_DOWN(obj)) ==
static_cast<guint>(app->window_sync_unit)) {
return;
}
on_window_entry_activate(GTK_ENTRY(app->window_entry), user);
}
/// The decade steppers. A factor of ten per click, from the window actually in force rather
/// than from the entry, so a half-typed number cannot be stepped.
void on_window_step(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const double factor =
g_object_get_data(G_OBJECT(button), "udpscope-window-up") != nullptr ? 10.0 : 0.1;
apply_time_window(*app, app->rows->time_window() * factor);
}
void on_pause_toggled(GtkToggleButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const bool paused = gtk_toggle_button_get_active(button);
app->acq->set_paused(paused);
app->model->set_paused(paused);
if (paused) {
// Freeze the *view*: snapshot exactly the window on screen, and pin the axes there. The
// acquisition thread keeps writing the live ring behind it, so resuming shows the data that
// arrived while paused instead of a hole.
if (RowSet::Row& r0 = app->rows->row(0); r0.plot != nullptr) {
const plot::Range view = r0.plot->x_range(r0.axis_x);
// The axis is in display x and the ring is on the stream's clock, so the window has to be
// put back on that clock to be cut -- `stream_at_x0()` is the one accessor that is right in
// every mode, including a pause taken while an event is *held*, where the axis is
// edge-relative and the old `- display_epoch()` form searched the ring with event
// coordinates. The same offset is handed to the snapshot as its `x_offset`, so its stamps
// come back rebased onto the x the axis is already showing and the frozen picture does not
// move at the instant of the pause.
const double s0 = app->model->stream_at_x0();
app->model->publish_pause_view(view.min + s0, view.max + s0, s0);
app->pause_started_us = g_get_monotonic_time();
// Pin on what the snapshot *got*: the cut is clamped to the ring, so it can be narrower than
// the view, and pinning the request left the shortfall as an empty left margin.
plot::Range pin = view;
if (std::shared_ptr<const EventSnapshot> snap = app->model->pause_view()) {
if (!snap->stamps.empty()) {
pin = plot::Range{static_cast<double>(snap->stamps.front()),
static_cast<double>(snap->stamps.back())};
}
}
app->rows->pin_pause_window(pin.min, pin.max);
}
} else {
app->model->clear_pause_view();
app->rows->unpin_pause_window();
app->pause_started_us = 0;
}
gtk_button_set_icon_name(GTK_BUTTON(button), paused ? "media-playback-start-symbolic"
: "media-playback-pause-symbolic");
gtk_widget_set_tooltip_text(GTK_WIDGET(button), paused ? "resume acquisition"
: "pause acquisition");
}
/// Freezes the view on a hit, centred, with the hit itself at x = 0.
///
/// This is the existing pause, not a fifth display mode: the toggle has to end up on, or the
/// next frame's `clamp_to_data()` would follow the live edge and walk the frozen window off
/// the hit entirely.
void search_jump(App& app, double t) {
app.search_jump_empty = false;
const double w = app.rows->time_window();
if (app.pause_button != nullptr &&
!gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.pause_button))) {
// Its handler publishes the *current* window; the publish below replaces it.
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.pause_button), TRUE);
}
app.model->publish_pause_view(t - w / 2.0, t + w / 2.0, /*x_offset=*/t);
app.pause_started_us = g_get_monotonic_time();
// Pinned from the snapshot's *own* stamps, never from the window asked for: a cut is
// clamped to what the ring holds, so a hit near the oldest sample yields a shorter
// snapshot, and pinning the request would leave an empty margin that a zoom moves into.
if (std::shared_ptr<const EventSnapshot> snap = app.model->pause_view()) {
if (!snap->stamps.empty()) {
app.rows->pin_pause_window(static_cast<double>(snap->stamps.front()),
static_cast<double>(snap->stamps.back()));
} else {
app.search_jump_empty = true;
}
} else {
app.search_jump_empty = true;
}
}
void update_search_panel(App& app) {
if (app.search_status_label == nullptr) return;
const BufferSearch& s = app.search;
const std::size_t n = s.hits().size();
char text[192];
switch (s.state()) {
case BufferSearch::State::Idle:
std::snprintf(text, sizeof(text), "not searched");
break;
case BufferSearch::State::Running:
std::snprintf(text, sizeof(text), "scanning %s of %s, %zu found",
format_window(s.scanned_s()).c_str(), format_window(s.total_s()).c_str(), n);
break;
case BufferSearch::State::Done:
std::snprintf(text, sizeof(text), "%zu found in %s", n, format_window(s.total_s()).c_str());
break;
case BufferSearch::State::Truncated:
std::snprintf(text, sizeof(text), "%zu found; the stream overtook the oldest samples", n);
break;
case BufferSearch::State::Capped:
std::snprintf(text, sizeof(text), "%zu found (the limit) in the newest %s", n,
format_window(s.scanned_s()).c_str());
break;
case BufferSearch::State::Cancelled:
std::snprintf(text, sizeof(text), "stopped after %zu", n);
break;
}
gtk_label_set_text(GTK_LABEL(app.search_status_label), text);
char pos[96];
if (s.cursor() < 0 || n == 0) {
std::snprintf(pos, sizeof(pos), "--");
} else if (app.search_jump_empty) {
std::snprintf(pos, sizeof(pos), "%d of %zu (no longer in the buffer)", s.cursor() + 1, n);
} else {
std::snprintf(pos, sizeof(pos), "%d of %zu", s.cursor() + 1, n);
}
gtk_label_set_text(GTK_LABEL(app.search_pos_label), pos);
const bool running = s.state() == BufferSearch::State::Running;
gtk_widget_set_sensitive(app.search_run_button, !running);
gtk_widget_set_sensitive(app.search_cancel_button, running);
gtk_widget_set_sensitive(app.search_prev_button, s.cursor() > 0);
gtk_widget_set_sensitive(app.search_next_button,
n > 0 && s.cursor() + 1 < static_cast<int>(n));
}
/// The visible samples of one channel as parallel time/value arrays: the same cut the export
/// makes -- the held event's snapshot while one is on screen, else the live ring over the
/// shared window -- at the stored rate, so a measurement describes exactly the data the plot
/// is showing. Call from the GTK thread; the live branch takes the model mutex.
bool visible_samples(App& app, std::size_t channel, std::vector<double>& time,
std::vector<double>& value) {
time.clear();
value.clear();
const RowSet::Row& r0 = app.rows->row(0);
if (r0.plot == nullptr) return false;
const plot::Range view = r0.plot->x_range(r0.axis_x);
if (std::shared_ptr<const EventSnapshot> snap = app.model->display_snapshot()) {
if (snap == nullptr || channel >= snap->values.size()) return false;
const std::vector<f64>& st = snap->stamps;
const std::vector<f32>& vals = snap->values[channel];
if (vals.size() != st.size()) return false;
const auto lo = std::lower_bound(st.begin(), st.end(), view.min);
const auto hi = std::upper_bound(st.begin(), st.end(), view.max);
const std::size_t count = static_cast<std::size_t>(hi - lo);
if (count == 0) return false;
const std::size_t off = static_cast<std::size_t>(lo - st.begin());
time.resize(count);
value.resize(count);
for (std::size_t i = 0; i < count; ++i) {
time[i] = st[off + i];
value[i] = static_cast<double>(vals[off + i]);
}
return true;
}
std::lock_guard<std::mutex> lock(app.model->mutex());
if (channel >= app.model->channel_count()) return false;
BufferStore& store = app.model->live();
const f64 epoch = app.model->display_epoch();
u64 first = 0;
u64 last = 0;
app.model->live_window_locked(view.min - epoch, view.max - epoch, first, last);
const u64 n = last - first;
if (n == 0) return false;
time.resize(static_cast<std::size_t>(n));
value.resize(static_cast<std::size_t>(n));
// In bounded chunks rather than through two whole-window temporaries: the value ring holds
// f32 and the engine reads f64, so that widening is unavoidable, but the *copies* are not --
// a full window is millions of samples, and the temporaries cost a full write and read of it
// on top of the result. `kChunk` keeps both scratches in cache. Two of them, because the
// clock ring is f64 and the values f32 (see `BufferStore::time()`).
constexpr u64 kChunk = 8192;
std::vector<f64> t_scratch(kChunk);
std::vector<f32> v_scratch(kChunk);
for (u64 at = 0; at < n; at += kChunk) {
const u64 take = std::min<u64>(kChunk, n - at);
store.time().copy_from(first + at, take, t_scratch.data());
for (u64 i = 0; i < take; ++i) {
time[static_cast<std::size_t>(at + i)] = epoch + t_scratch[i];
}
store.values(channel).copy_from(first + at, take, v_scratch.data());
for (u64 i = 0; i < take; ++i) {
value[static_cast<std::size_t>(at + i)] = static_cast<double>(v_scratch[i]);
}
}
return true;
}
/// One channel's visible window, readable in chunks and never copied out whole -- what the
/// measurements walk instead of `visible_samples()`.
///
/// The old path widened the window into two `std::vector<double>` before anything was computed,
/// which at a 1.7 M-sample window was 27 MB written and then read again per pass: 60 % of the
/// pane's cost, 12 ns a sample, 20 ms a recompute. Here the *values* are handed out as `f32`
/// straight from the snapshot (a pointer, no copy) or in a cache-sized scratch from the ring, and
/// a **stamp** is fetched one at a time -- the walks need a time only at the window's extremes and
/// at the level crossings, which number in the handful.
///
/// For a live window it holds `ScopeModel::mutex()` for its whole life, as the materialization did
/// but for a fraction of the time. Keep one of these alive no longer than the walks.
/// How often the measurements pane is recomputed while the window is still moving: a readout,
/// where the honest currency is the window's own settling, not the frame rate. Ten a second is
/// past the point a number can be read, and it makes the cost independent of the framerate.
/// A pass that costs more than this is a *hitch*, not a refresh: the pane waits four times as
/// long next time. Bounding the interval alone does not bound what a frame pays -- a window of
/// thirteen million samples costs 13 ms, which is most of a frame -- so the cadence follows the
/// *work*, and what stays bounded is the duty cycle at any window size.
/// One pane row: the measurement's badge, its type, its value and an `edit` button. The value
/// label and the statistics line below it are the two widgets `update_measures()` rewrites;
/// everything else is rebuilt with the list. The row is two visual lines so that the
/// statistics figures do not push the reading itself off a narrow pane.
GtkWidget* measures_row(App& app, std::size_t index, const Measure& m) {
// The row is two lines: the reading, and the statistics under it. A vertical wrapper
// rather than more columns, because the statistics line is four figures wide and would
// push the reading itself off the pane.
GtkWidget* outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget* chip = gtk_label_new("");
gtk_label_set_markup(GTK_LABEL(chip),
chip_markup(plot::Color::hex(measure_color(index))).c_str());
gtk_widget_set_tooltip_text(chip, "this measurement's colour, and the colour of its marks");
gtk_box_append(GTK_BOX(box), chip);
// The signal's own chip beside it, in the palette colour every other view names that signal
// with, so the row says *what* is being measured as well as by which measurement.
GtkWidget* sig_chip = gtk_label_new("");
{
std::lock_guard<std::mutex> lock(app.model->mutex());
const std::vector<SignalInfo>& signals = app.model->signals();
int color_index = -1;
for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == m.signal) {
color_index = signal_color_index(signals, i);
break;
}
}
gtk_label_set_markup(GTK_LABEL(sig_chip),
chip_markup(color_index >= 0
? app.rows->signal_color(m.signal, color_index)
: RowSet::unplotted_color())
.c_str());
}
gtk_widget_set_tooltip_text(sig_chip, m.signal.c_str());
gtk_box_append(GTK_BOX(box), sig_chip);
GtkWidget* kind = gtk_label_new(measure_type_name(m.type));
gtk_widget_set_halign(kind, GTK_ALIGN_START);
gtk_widget_set_hexpand(kind, true);
gtk_label_set_ellipsize(GTK_LABEL(kind), PANGO_ELLIPSIZE_END);
char tip[160];
std::snprintf(tip, sizeof(tip), "%s\n%s", m.signal.c_str(),
measure_uses_threshold(m.type)
? (m.options.above ? "the above-threshold side" : "the below-threshold side")
: "");
gtk_widget_set_tooltip_text(kind, tip);
gtk_box_append(GTK_BOX(box), kind);
GtkWidget* value = gtk_label_new("--");
gtk_widget_set_halign(value, GTK_ALIGN_END);
gtk_label_set_selectable(GTK_LABEL(value), true);
gtk_box_append(GTK_BOX(box), value);
GtkWidget* edit = gtk_button_new_with_label("edit");
gtk_widget_add_css_class(edit, "flat");
g_object_set_data(G_OBJECT(edit), "udpscope-measure-index",
GINT_TO_POINTER(static_cast<int>(index) + 1));
g_signal_connect(edit, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
App* a = static_cast<App*>(user);
const int idx = GPOINTER_TO_INT(
g_object_get_data(G_OBJECT(b), "udpscope-measure-index")) -
1;
open_measure_dialog(*a, idx);
}),
&app);
gtk_box_append(GTK_BOX(box), edit);
gtk_box_append(GTK_BOX(outer), box);
GtkWidget* stats = gtk_label_new("");
gtk_widget_set_halign(stats, GTK_ALIGN_END);
gtk_widget_add_css_class(stats, "dim-label");
gtk_label_set_selectable(GTK_LABEL(stats), true);
// Ellipsize so the label's minimum width is bounded: without this, a long reading would
// widen the pane just as the `kind` label would without its own ellipsize.
gtk_label_set_ellipsize(GTK_LABEL(stats), PANGO_ELLIPSIZE_END);
gtk_widget_set_tooltip_text(stats,
"how this reading has varied since the last reset: the number "
"of passes, the mean, the standard deviation, and the extremes");
gtk_widget_set_visible(stats, app.measure_stats_on);
gtk_box_append(GTK_BOX(outer), stats);
app.measure_labels.push_back(value);
app.measure_stats_labels.push_back(stats);
return outer;
}
/// The cursor modes, in the order the dropdown lists them; the index is `CursorMode`.
void on_cursor_mode_changed(GObject* obj, GParamSpec*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->updating_panels) return;
const guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(obj));
if (sel > 3) return;
app->rows->set_cursor_mode(static_cast<RowSet::CursorMode>(sel));
rebuild_cursors_pane(*app);
}
/// Appends one row to the pane's table: a `who` cell and the three value cells, in one
/// `GtkGrid` so the four columns size to their widest cell and the numbers read as a table
/// (`| | A | B | delta |`) rather than as a sentence per line.
App::CursorLine cursors_line(App& app, const char* who, plot::Color chip, bool has_chip) {
GtkGrid* grid = GTK_GRID(app.cursors_body);
const int at = static_cast<int>(app.cursors_lines.size()) + 1; // row 0 is the header
App::CursorLine line;
line.label = gtk_label_new(who);
gtk_widget_set_halign(line.label, GTK_ALIGN_START);
gtk_label_set_ellipsize(GTK_LABEL(line.label), PANGO_ELLIPSIZE_END);
if (has_chip) {
// The chip **and** the name: a chip alone says a trace has a colour and leaves the user
// to match it against the plot, which with several traces is a guess. Escaped because
// this is markup and a signal name is the stream's text, not ours.
char* esc = g_markup_escape_text(who, -1);
gtk_label_set_markup(GTK_LABEL(line.label), (chip_markup(chip) + " " + esc).c_str());
g_free(esc);
gtk_widget_set_tooltip_text(line.label, who);
}
gtk_grid_attach(grid, line.label, 0, at, 1, 1);
GtkWidget* cells[3] = {nullptr, nullptr, nullptr};
for (int c = 0; c < 3; ++c) {
GtkWidget* v = gtk_label_new("");
gtk_widget_set_halign(v, GTK_ALIGN_END);
gtk_label_set_selectable(GTK_LABEL(v), true);
gtk_widget_set_margin_start(v, 10);
gtk_grid_attach(grid, v, c + 1, at, 1, 1);
cells[c] = v;
}
line.a = cells[0];
line.b = cells[1];
line.d = cells[2];
return line;
}
/// The table's header: the columns have no name but the two cursors and their difference.
GtkWidget* cursors_header(App& app) {
GtkGrid* grid = GTK_GRID(app.cursors_body);
GtkWidget* cells[4] = {gtk_label_new(""), gtk_label_new("A"), gtk_label_new("B"),
gtk_label_new("delta")};
for (int c = 0; c < 4; ++c) {
gtk_widget_set_halign(cells[c], c == 0 ? GTK_ALIGN_START : GTK_ALIGN_END);
gtk_widget_add_css_class(cells[c], "dim-label");
if (c > 0) gtk_widget_set_margin_start(cells[c], 10);
gtk_grid_attach(grid, cells[c], c, 0, 1, 1);
}
return cells[1];
}
/// The unit a row's y rulers are in: the one its bound channels agree on, or none when they
/// disagree -- a ruler sits on the row's y axis, and two signals of different units on one
/// axis make "the" unit a guess.
///
/// The channel list is rebuilt by the producer, so it is read under the model mutex, like
/// every other read of it from this thread.
std::string row_unit(App& app, int row) {
std::lock_guard<std::mutex> lock(app.model->mutex());
std::string unit;
bool first = true;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
const RowSet::Row& r = app.rows->row(row);
if (r.signal_name[si].empty()) continue;
const int ch = r.channel[si];
if (ch < 0 || static_cast<std::size_t>(ch) >= app.model->channels().size()) continue;
const std::string& u = app.model->channels()[static_cast<std::size_t>(ch)].unit;
if (first) {
unit = u;
first = false;
} else if (unit != u) {
return "";
}
}
return unit;
}
/// The unit of one bound trace, or "" for a slot that plots nothing.
std::string slot_unit(App& app, int row, int slot) {
std::lock_guard<std::mutex> lock(app.model->mutex());
const RowSet::Row& r = app.rows->row(row);
const int ch = r.channel[static_cast<std::size_t>(slot)];
if (ch < 0 || static_cast<std::size_t>(ch) >= app.model->channels().size()) return "";
return app.model->channels()[static_cast<std::size_t>(ch)].unit;
}
/// One bit per row/slot for the slots that are *bound* -- a name **and** a channel the
/// model actually built. A name alone is not a trace: the stream's time signal is never
/// acquired, so a slot naming it has a name for ever and data never, and `mix` would draw
/// it a line that can only read `--`.
std::uint32_t bound_slot_mask(App& app) {
std::uint32_t mask = 0;
for (int r = 0; r < app.rows->row_count(); ++r) {
const RowSet::Row& row = app.rows->row(r);
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (row.signal_name[si].empty() || row.channel[si] < 0) continue;
mask |= 1u << (r * RowSet::kSlotsPerRow + s);
}
}
return mask;
}
/// The pane's body follows what the mode reads and what is plotted: a line per row for
/// the rulers, a line per bound trace for `mix`. Called when the mode changes, when the
/// rows change and when the stream is re-described -- the texts are then rewritten every
/// frame by `update_cursors_pane()`.
void rebuild_cursors_pane(App& app) {
if (app.cursors_body == nullptr) return;
GtkWidget* child = gtk_widget_get_first_child(app.cursors_body);
while (child != nullptr) {
GtkWidget* next = gtk_widget_get_next_sibling(child);
gtk_grid_remove(GTK_GRID(app.cursors_body), child);
child = next;
}
app.cursors_lines.clear();
cursors_header(app);
app.cursors_pane_mode = static_cast<int>(app.rows->cursor_mode());
app.cursors_pane_rows = app.rows->row_count();
app.cursors_pane_generation = app.model->generation();
app.cursors_pane_bound = bound_slot_mask(app);
const RowSet::CursorMode mode = app.rows->cursor_mode();
const bool rulers = mode == RowSet::CursorMode::Horizontal || mode == RowSet::CursorMode::Both;
const bool crossings = mode == RowSet::CursorMode::Mix;
// The times are a row of the same table: `| t | T0 | T1 | dT |`. `slot = -2` marks it,
// `-1` a ruler row, `>= 0` a trace. Only the modes that *have* the time cursors get it:
// in `horizontal` the pair is parked, and a row of times nothing on the plot can move
// would be a readout of nothing.
if (mode != RowSet::CursorMode::Horizontal) {
app.cursors_lines.push_back(cursors_line(app, "t", plot::Color::hex(0), false));
app.cursors_lines.back().row = -1;
app.cursors_lines.back().slot = -2;
}
if (rulers || crossings) {
for (int r = 0; r < app.rows->row_count(); ++r) {
if (!rulers) break;
char who[64];
std::snprintf(who, sizeof(who), "row %d", r);
app.cursors_lines.push_back(cursors_line(app, who, plot::Color::hex(0), false));
app.cursors_lines.back().row = r;
app.cursors_lines.back().slot = -1;
}
if (crossings) {
for (int r = 0; r < app.rows->row_count(); ++r) {
const RowSet::Row& row = app.rows->row(r);
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
// A *bound* trace, not merely a named slot: a slot can name a signal the model
// will never build a channel for (the stream's time signal is never acquired),
// and a line for one of those is a row of `--` beside a colour chip -- which is
// what "the pane shows a lot of empty signals that do not exist" was.
if (row.signal_name[si].empty() || row.channel[si] < 0) continue;
app.cursors_lines.push_back(cursors_line(
app, row.name[si].c_str(),
app.rows->signal_color(row.signal_name[si], row.color_index[si]), true));
app.cursors_lines.back().row = r;
app.cursors_lines.back().slot = s;
}
}
}
}
}
/// `labeled_box()` with the label handed back, for a row whose text depends on what is being
/// edited (the dialog's option rows).
GtkWidget* labelled_row(const char* label, GtkWidget* child, GtkWidget** label_out) {
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget* l = gtk_label_new(label);
gtk_widget_set_halign(l, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(box), l);
gtk_box_append(GTK_BOX(box), child);
*label_out = l;
return box;
}
/// The add/edit dialog. `edit_index < 0` adds; otherwise it edits that measurement in place
/// (and offers Delete, which is the only way one is removed -- there is no undo here, so the
/// destructive button sits with the dialog that shows what it would destroy).
/// The dialog. Two dropdowns and the type's own options, laid out like the calibration one:
/// a small window with a `labeled_box` per field and the buttons in the header bar.
void open_measure_dialog(App& app, int edit_index) {
const Measure* editing =
edit_index >= 0 && static_cast<std::size_t>(edit_index) < app.measures.size()
? &app.measures[static_cast<std::size_t>(edit_index)]
: nullptr;
GtkWidget* window = dialog_window(GTK_WIDGET(app.window),
editing != nullptr ? "Edit measurement" : "Add measurement");
GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window));
// The list is the signals the *plots* are showing, in signal-list order: a measurement
// reads the window a plot is displaying, and its marks are drawn on the row that plots the
// signal, so the candidates are the traces on screen. That is also what keeps a channel
// nobody plots out of the list -- including a stream's timestamp signal, which is
// acquired but is not something to measure. The editing measurement's own signal is added
// back when it is not plotted (otherwise Save would silently re-point it at whatever the
// dropdown happened to show).
std::vector<std::string> sig_names;
int selected_signal = 0;
for (int r = 0; r < app.rows->row_count(); ++r) {
const RowSet::Row& row = app.rows->row(r);
for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) {
const std::size_t si = static_cast<std::size_t>(sl);
const std::string& n = row.name[si];
if (n.empty()) continue;
if (std::find(sig_names.begin(), sig_names.end(), n) != sig_names.end()) continue;
sig_names.push_back(n);
}
}
if (editing != nullptr) {
if (std::find(sig_names.begin(), sig_names.end(), editing->signal) == sig_names.end()) {
sig_names.push_back(editing->signal);
}
}
// The `const char*` array is built from the *finished* strings: taking `c_str()` as the
// vector grew left every pointer pointing into memory the next push reallocated.
std::vector<const char*> sig_cstrs;
sig_cstrs.reserve(sig_names.size() + 1);
for (const std::string& n : sig_names) sig_cstrs.push_back(n.c_str());
sig_cstrs.push_back(nullptr);
if (editing != nullptr) {
const auto it = std::find(sig_names.begin(), sig_names.end(), editing->signal);
if (it != sig_names.end()) {
selected_signal = static_cast<int>(it - sig_names.begin());
}
}
GtkWidget* signal_drop = gtk_drop_down_new_from_strings(sig_cstrs.data());
gtk_drop_down_set_selected(GTK_DROP_DOWN(signal_drop), static_cast<guint>(selected_signal));
// No scrolled window around it: a `GtkDropDown` scrolls its own popover list, and wrapping
// one asked the *combo box itself* to grow to 160 px.
gtk_box_append(GTK_BOX(box), labeled_box("signal", signal_drop));
std::vector<const char*> type_names;
for (int i = 0; i < measure_type_count(); ++i) type_names.push_back(measure_type_name(measure_type_at(i)));
type_names.push_back(nullptr);
GtkWidget* type_drop = gtk_drop_down_new_from_strings(type_names.data());
gtk_box_append(GTK_BOX(box), labeled_box("measure", type_drop));
GtkWidget* threshold = spin(0.0, 100.0, 1.0, 1, 50.0); // the split's own default, re-seeded below
gtk_widget_set_tooltip_text(threshold,
"where the signal is split, as a share of the visible range");
GtkWidget* threshold_label = nullptr;
GtkWidget* threshold_row = labelled_row("threshold %", threshold, &threshold_label);
gtk_box_append(GTK_BOX(box), threshold_row);
GtkWidget* above = gtk_switch_new();
gtk_widget_set_halign(above, GTK_ALIGN_START);
gtk_widget_set_tooltip_text(above, "which half of the waveform is the pulse");
GtkWidget* side_label = nullptr;
GtkWidget* side_row = labelled_row("pulse above level", above, &side_label);
gtk_box_append(GTK_BOX(box), side_row);
// A percentage of the window's own peak-to-peak, which is the unit the engine takes.
GtkWidget* noise = spin(0.0, 50.0, 1.0, 1, 10.0);
// `labelled_row()` writes the label out unconditionally, and these two never retitle, so the
// out-parameters are named and then explicitly unused rather than passed as nullptr.
GtkWidget* noise_label = nullptr;
GtkWidget* noise_row = labelled_row("noise band %", noise, &noise_label);
gtk_box_append(GTK_BOX(box), noise_row);
(void)noise_label;
// 0 is off. The engine clamps to just under the stream's Nyquist, which the dialog cannot know.
GtkWidget* hf = spin(0.0, 1.0e7, 1.0, 3, 0.0);
GtkWidget* hf_label = nullptr;
GtkWidget* hf_row = labelled_row("HF reject Hz", hf, &hf_label);
gtk_box_append(GTK_BOX(box), hf_row);
(void)hf_label;
GtkWidget* show = gtk_check_button_new_with_label("show on plot");
gtk_widget_set_tooltip_text(show,
"draw the result on the plot: a ruler at the level, a marker "
"at the time it happened, the pulse's edges as markers");
gtk_box_append(GTK_BOX(box), show);
auto* d = new MeasureDialog{&app, edit_index, signal_drop, type_drop,
threshold, above, show, window,
threshold_row, threshold_label, side_row, side_label,
noise, noise_row, hf, hf_row,
sig_names};
app.measure_dialog = window;
g_signal_connect(window, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer user) {
static_cast<App*>(user)->measure_dialog = nullptr;
}),
&app);
// One owner, on the window: the buttons only *refer* to the context. Letting two of them
// own it (Apply and Delete) freed it twice, which is the abort an edit or a delete used to
// produce -- an add has one button, which is why only those two paths crashed.
g_object_set_data_full(G_OBJECT(window), "udpscope-measure-ctx", d,
[](gpointer p) { delete static_cast<MeasureDialog*>(p); });
if (editing != nullptr) {
// The index by looking `editing->type` up in the same function that fills the dropdown:
// a hand-written chain of ternaries is a second copy of the order, and the copy is what
// decides which type the user gets.
for (int i = 0; i < measure_type_count(); ++i) {
if (measure_type_at(i) == editing->type) {
gtk_drop_down_set_selected(GTK_DROP_DOWN(type_drop), static_cast<guint>(i));
break;
}
}
gtk_spin_button_set_value(GTK_SPIN_BUTTON(threshold), editing->options.threshold_pct);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(noise), editing->options.noise_pct);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(hf), editing->options.hf_fc);
gtk_switch_set_active(GTK_SWITCH(above), editing->options.above);
gtk_check_button_set_active(GTK_CHECK_BUTTON(show), editing->options.show_on_plot);
} else {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(threshold), 50.0);
gtk_switch_set_active(GTK_SWITCH(above), true);
}
g_signal_connect(type_drop, "notify::selected", G_CALLBACK(on_measure_type_changed), d);
measure_dialog_sync(d);
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window);
dialog_add_button(window, cancel);
if (editing != nullptr) {
GtkWidget* del = gtk_button_new_with_label("Delete");
gtk_widget_add_css_class(del, "destructive-action");
g_signal_connect(del, "clicked", G_CALLBACK(on_measure_dialog_delete), d);
dialog_add_button(window, del);
}
GtkWidget* apply = gtk_button_new_with_label(editing != nullptr ? "Save" : "Add");
gtk_widget_add_css_class(apply, "suggested-action");
g_signal_connect(apply, "clicked", G_CALLBACK(on_measure_dialog_apply), d);
dialog_add_button(window, apply);
gtk_window_present(GTK_WINDOW(window));
}
/// Puts the search hits nearest the view on the plots, converting each from the stream seconds
/// `BufferSearch` records to the x the axis is reading this frame -- the same conversion
/// `place_measure_marks()` makes, and for the same reason. Called every frame: that is what
/// makes a mark read-only and what keeps it on its own sample as a live view rolls.
///
/// Only the hits **inside the visible window** are candidates, nearest the view's centre first, so
/// that a zoomed-out view with more hits than marks shows the middle of the cluster rather than an
/// arbitrary end of it. The selected hit is placed first regardless, so stepping to a hit never
/// leaves it unmarked, and it is the one drawn in the current colour.
void place_search_marks(App& app) {
std::vector<double> xs;
int current = -1;
std::size_t in_view = 0;
bool sel_off_view = false;
const std::vector<double>& hits = app.search.hits();
if (!hits.empty()) {
const double s0 = app.model->stream_at_x0();
const plot::Range view = app.rows->view_range();
const double centre = 0.5 * (view.min + view.max);
const int sel = app.search.cursor();
// Candidates: the display x of every hit in view, paired with its distance from the centre.
std::vector<std::pair<double, double>> cand;
for (std::size_t i = 0; i < hits.size(); ++i) {
const double x = hits[i] - s0;
if (x < view.min || x > view.max) continue;
++in_view;
if (sel >= 0 && i == static_cast<std::size_t>(sel)) continue; // placed first, below
cand.push_back({std::fabs(x - centre), x});
}
std::sort(cand.begin(), cand.end());
const std::size_t cap = static_cast<std::size_t>(RowSet::kSearchMarks);
// xs.reserve(cap) is only a hint; the actual cap is enforced by xs.size() < cap in the fill loop.
xs.reserve(cap);
if (sel >= 0 && sel < static_cast<int>(hits.size())) {
current = 0;
const double x = hits[static_cast<std::size_t>(sel)] - s0;
// The selected hit is placed whether or not it is on screen, so the label must not fold it
// into the in-view count: it is the one mark `in_view` above deliberately did not see.
sel_off_view = x < view.min || x > view.max;
xs.push_back(x);
}
for (std::size_t i = 0; i < cand.size() && xs.size() < cap; ++i) xs.push_back(cand[i].second);
}
if (app.search_marks_label != nullptr) {
// The marks that are *in view*: `xs` also carries the selected hit when it is off screen.
const std::size_t shown = xs.size() - (sel_off_view ? 1u : 0u);
char text[128];
if (hits.empty()) {
std::snprintf(text, sizeof(text), " ");
} else if (in_view > shown) {
// A truncated display says so: "16 of 38" with no qualifier reads as "there are 38 and you
// are seeing 16 of them *everywhere*", which is not the claim.
std::snprintf(text, sizeof(text), "%zu of %zu marks shown \xE2\x80\x94 zoom in to see the rest",
shown, in_view);
} else if (sel_off_view) {
std::snprintf(text, sizeof(text),
"%zu of %zu in view \xE2\x80\x94 the selected hit is outside the window", shown,
hits.size());
} else {
std::snprintf(text, sizeof(text), "%zu of %zu in view", shown, hits.size());
}
gtk_label_set_text(GTK_LABEL(app.search_marks_label), text);
}
app.rows->set_search_marks(xs, current);
}
/// The measurements panel: a header with the `[+]`, and one row per measurement.
// ----------------------------------------------------------------- math channels
//
// A math channel is a *channel*: the model synthesises a signal for it, so the panel's switch, the
// plot binding, the measurements and the export all treat it like a streamed one, and the pane only
// has to say which graphs exist. The editor is the compact node graph: nodes as boxes on a canvas,
// click a box to select it and edit its parameters in the form beside the canvas, click an output
// port and then an input port to wire them, drag a box to move it.
/// The panel's `+`: a new channel with one source node, so the editor opens on something valid and
/// the user has a box to connect.
void add_math_channel(App& app) {
// From the *acquired* channels, never `signals().front()`: the first signal of a stream is its
// timestamp, which is not acquired, so a node sourced from it reads NaN for ever -- a graph that
// validates and then shows nothing. (The dialog's own default was fixed for this; the pane's `+`
// was not.)
std::string name;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
if (!app.model->channels().empty()) name = app.model->channels().front().name;
}
MathChannel m;
// A name of its own: calling it after its first source would put a second entry with that name in
// the signal panel, where the user cannot tell the derived one from the streamed one.
m.name = "math" + std::to_string(app.math.size() + 1);
m.enabled = true;
MathNode src;
src.op = MathOp::Source;
src.source = name;
src.x = 30.0;
src.y = 40.0;
m.nodes.push_back(src);
m.output = 0;
app.math.push_back(std::move(m));
apply_math_channels(app);
}
// The node editor: a compact graph. Each node is a box; inputs are the squares on its left edge,
// the output the square on its right. Click a box to select it (its parameters appear in the form
// beside the canvas), drag from an output square to an input square to wire them, drag a wired
// input off to detach it, drag a box to move it. The graph is a *list*, walked in order, so a node
// may only read ones above it -- but that order is the *evaluator's* contract, not a restriction on
// the gesture: a wire may be drawn in either direction and `math_connect()` re-sorts the list
// (Kahn's algorithm) so the contract still holds. Only a wire that cannot be ordered at all, i.e.
// one that closes a cycle, is refused, and it is refused at connect time with a message.
/// A wire, named by the node that *consumes* it and which of its inputs. The consumer is the
/// identity because that is where a wire is stored: `in_a`/`in_b` on the reader. A producer may
/// feed several inputs and so cannot name one.
/// A port's identity: `kPortOut` for a node's output, 0 or 1 for one of its inputs.
/// The smallest and largest the canvas may be scaled to. Below a quarter the op names are
/// unreadable; above four a box fills the canvas and there is nothing left to navigate.
/// The pick radius for a wire, in screen pixels. Smaller than a port's, so a wire that passes
/// behind a port never wins the press -- the port is tested first anyway, but a user aiming at a
/// port should not be able to miss it onto a wire either.
void open_math_dialog(App& app, int edit) {
if (app.math_dialog != nullptr) {
gtk_window_present(GTK_WINDOW(app.math_dialog));
return;
}
MathDialog* d = new MathDialog();
d->app = &app;
d->edit = edit;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
d->nodes = edit >= 0 && edit < static_cast<int>(app.math.size())
? app.math[static_cast<std::size_t>(edit)].nodes
: std::vector<MathNode>{};
}
if (d->nodes.empty()) {
MathNode src;
src.op = MathOp::Source;
const std::vector<std::string> names = math_source_names(app);
if (!names.empty()) src.source = names.front();
d->nodes.push_back(src);
}
// A node with no position yet -- a graph built by a probe, or one whose box was never moved --
// would otherwise sit in the canvas's top-left corner, stacked under whatever is drawn after it.
// Laid out in a column, which is also the order the graph is walked in.
// Two columns, spaced by more than a box is wide. The first version staggered them by 24 px for a
// 132 px box, so every node sat on top of the one before it and the click always found the last --
// which reads as the dialog ignoring the pointer.
for (std::size_t i = 0; i < d->nodes.size(); ++i) {
if (d->nodes[i].x != 0.0 || d->nodes[i].y != 0.0) continue;
d->nodes[i].x = 24.0 + 160.0 * static_cast<double>(i % 2u);
d->nodes[i].y = 20.0 + 58.0 * static_cast<double>(i / 2u);
}
d->output = edit >= 0 && edit < static_cast<int>(app.math.size())
? app.math[static_cast<std::size_t>(edit)].output
: 0;
// **The last node, not the first.** A new graph is built source -> operation, and the *output*
// defaulted to node 0 -- the source -- so every operation was computed and then thrown away: a
// gain of 2 and a low pass both produced a channel equal to its input. The output is drawn as the
// orange dot and settable explicitly, so a graph whose output is genuinely an earlier node keeps
// it; this is only the default for one that has never been given a choice.
if (d->output < 0 || d->output >= static_cast<int>(d->nodes.size())) {
d->output = static_cast<int>(d->nodes.size()) - 1;
}
d->selected = d->output;
// The window and its identity row are a `.ui` file (`src/ui/math-editor.ui`). The canvas, the palette and
// the parameter form below them stay in code: the canvas is drawn rather than declared, and the form
// follows which node is selected.
g_clear_object(&app.math_dialog_ui);
app.math_dialog_ui = gtk_builder_new_from_resource("/org/udpscope/ui/math-editor.ui");
GtkBuilder* ui = app.math_dialog_ui;
d->window = GTK_WIDGET(gtk_builder_get_object(ui, "math_window"));
GtkWidget* outer = GTK_WIDGET(gtk_builder_get_object(ui, "math_outer"));
GtkWidget* head = GTK_WIDGET(gtk_builder_get_object(ui, "math_head"));
d->name = GTK_WIDGET(gtk_builder_get_object(ui, "math_name"));
d->unit = GTK_WIDGET(gtk_builder_get_object(ui, "math_unit"));
if (d->window == nullptr || outer == nullptr || head == nullptr || d->name == nullptr ||
d->unit == nullptr) {
// A missing id is a mistake in the `.ui`. There is no editor to open, so this says so and gives up
// rather than building the rest of a window around a hole; `[math-probe] editor` reads the difference.
std::fprintf(stderr, "[math] editor: math-editor.ui is missing an id this code reads\n");
delete d;
return;
}
GtkWidget* window = d->window;
gtk_window_set_title(GTK_WINDOW(window), edit >= 0 ? "Edit math channel" : "New math channel");
// The dialog's own state, freed with the window: a plain local here would leak the graph and every drag
// it is halfway through on each opening.
g_object_set_data_full(G_OBJECT(window), "udpscope-math-dialog", d,
+[](gpointer p) { delete static_cast<MathDialog*>(p); });
g_signal_connect(window, "close-request", G_CALLBACK(+[](GtkWindow* w, gpointer) {
GtkWidget* dd = GTK_WIDGET(w);
MathDialog* md = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(dd), "udpscope-math-dialog"));
if (md != nullptr && md->app != nullptr) md->app->math_dialog = nullptr;
return false;
}),
nullptr);
// The two values the file cannot hold: what this channel is called and the unit its readouts carry. The
// name is also how another graph refers to this channel as a source, which is why it takes the slack.
if (edit >= 0 && edit < static_cast<int>(app.math.size())) {
gtk_editable_set_text(GTK_EDITABLE(d->name), app.math[static_cast<std::size_t>(edit)].name.c_str());
gtk_editable_set_text(GTK_EDITABLE(d->unit), app.math[static_cast<std::size_t>(edit)].unit.c_str());
}
// The body and the canvas are the file's as well; what is code is the *drawing* -- the function below and
// the gestures under it, which are what make the area a graph editor rather than a picture.
GtkWidget* body = GTK_WIDGET(gtk_builder_get_object(ui, "math_body"));
d->canvas = GTK_WIDGET(gtk_builder_get_object(ui, "math_canvas"));
if (body == nullptr || d->canvas == nullptr) {
std::fprintf(stderr, "[math] editor: math-editor.ui is missing its body or canvas\n");
delete d;
return;
}
gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(d->canvas), math_canvas_draw, d, nullptr);
GtkGesture* click = gtk_gesture_click_new();
g_signal_connect(click, "pressed", G_CALLBACK(+[](GtkGestureClick* g, int, double x, double y,
gpointer user) {
math_canvas_pressed(g, 0, x, y, user);
}),
d);
gtk_widget_add_controller(d->canvas, GTK_EVENT_CONTROLLER(click));
GtkGesture* drag = gtk_gesture_drag_new();
g_signal_connect(drag, "drag-begin", G_CALLBACK(+[](GtkGestureDrag* g, double x, double y,
gpointer user) {
math_canvas_drag_begin(g, x, y, user);
}),
d);
g_signal_connect(drag, "drag-update", G_CALLBACK(+[](GtkGestureDrag* g, double dx, double dy,
gpointer user) {
math_canvas_drag(g, dx, dy, user);
}),
d);
g_signal_connect(drag, "drag-end", G_CALLBACK(+[](GtkGestureDrag* g, double dx, double dy,
gpointer user) {
math_canvas_drag_end(g, dx, dy, user);
}),
d);
gtk_widget_add_controller(d->canvas, GTK_EVENT_CONTROLLER(drag));
// The wheel zooms. A scroll controller is the only way to get one on a drawing area, and
// `VERTICAL` is what a wheel reports.
GtkEventController* zoom = gtk_event_controller_scroll_new(GTK_EVENT_CONTROLLER_SCROLL_VERTICAL);
g_signal_connect(zoom, "scroll", G_CALLBACK(math_canvas_scrolled), d);
gtk_widget_add_controller(d->canvas, zoom);
// A `GtkDrawingArea` is not focusable by default, so a key controller on it never fires. It
// takes focus on a press rather than only on Tab, because every key this editor binds is about
// something the pointer just did.
gtk_widget_set_focusable(d->canvas, TRUE);
GtkEventController* keys = gtk_event_controller_key_new();
g_signal_connect(keys, "key-pressed", G_CALLBACK(math_canvas_key), d);
gtk_widget_add_controller(d->canvas, keys);
// The form: every row exists from the start and is shown or hidden per op, so no rebuild can
// invalidate a callback's handle.
GtkWidget* form = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
gtk_widget_set_size_request(form, 250, -1);
GtkWidget* op_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget* op_text = gtk_label_new("operation");
gtk_widget_set_size_request(op_text, 74, -1);
gtk_widget_set_halign(op_text, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(op_row), op_text);
std::vector<const char*> op_names;
for (int i = 0; i < math_op_count(); ++i) op_names.push_back(math_op_name(math_op_at(i)));
op_names.push_back(nullptr);
d->op_drop = gtk_drop_down_new_from_strings(op_names.data());
gtk_widget_set_hexpand(d->op_drop, true);
g_signal_connect(d->op_drop, "notify::selected", G_CALLBACK(on_math_op_changed), d);
gtk_box_append(GTK_BOX(op_row), d->op_drop);
gtk_box_append(GTK_BOX(form), op_row);
d->source_drop = gtk_drop_down_new_from_strings(nullptr);
g_signal_connect(d->source_drop, "notify::selected", G_CALLBACK(on_math_source_changed), d);
gtk_box_append(GTK_BOX(form), math_row("channel", d->source_drop, &d->rows[0]));
d->gain = gtk_spin_button_new_with_range(-1e6, 1e6, 0.1);
d->offset = gtk_spin_button_new_with_range(-1e6, 1e6, 0.1);
// Digits, not just a range: a `GtkSpinButton` holds zero decimals by default, so a 12.5 Hz cutoff
// was stored as 12 and a gain of 0.25 as 0 -- the values looked wrong because they *were*.
gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->gain), 4);
gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->offset), 4);
// An explicit starting value, because `gtk_spin_button_new_with_range()` leaves the value at the
// range's *minimum* when zero is not one of the bounds it can pick: the offset began at -1e6, and
// `on_math_param_changed()` reads the widgets -- so a gain of 2 saved as `x*2 - 2000000`. Every
// field is seeded from its own identity value; `math_dialog_sync()` then sets the node's.
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->gain), 1.0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->offset), 0.0);
g_signal_connect(d->gain, "value-changed", G_CALLBACK(on_math_param_changed), d);
g_signal_connect(d->offset, "value-changed", G_CALLBACK(on_math_param_changed), d);
{
// One box for both: `gain + offset` is a single op and the two fields are only meaningful
// together, so they show and hide as one. The offset used to be a row of its own that nothing
// ever hid, which is what made it look like a property of every node instead of of this one op.
GtkWidget* gain_rows = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
gtk_box_append(GTK_BOX(gain_rows), math_row("gain", d->gain, nullptr));
gtk_box_append(GTK_BOX(gain_rows), math_row("offset", d->offset, nullptr));
d->rows[1] = gain_rows;
gtk_box_append(GTK_BOX(form), gain_rows);
}
d->fc = gtk_spin_button_new_with_range(0.001, 1e7, 0.5);
d->q = gtk_spin_button_new_with_range(0.01, 100.0, 0.05);
gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->fc), 3);
gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->q), 3);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->fc), 100.0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->q), 0.707);
g_signal_connect(d->fc, "value-changed", G_CALLBACK(on_math_param_changed), d);
g_signal_connect(d->q, "value-changed", G_CALLBACK(on_math_param_changed), d);
{
GtkWidget* filter_rows = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
gtk_box_append(GTK_BOX(filter_rows), math_row("corner Hz", d->fc, nullptr));
gtk_box_append(GTK_BOX(filter_rows), math_row("Q", d->q, &d->rows[3]));
gtk_box_append(GTK_BOX(form), filter_rows);
d->rows[2] = filter_rows;
// The Q row is only meaningful for the band pass, which is the only op that reads it.
gtk_widget_set_visible(d->rows[3], false);
}
d->lo = gtk_spin_button_new_with_range(-1e6, 1e6, 0.1);
d->hi = gtk_spin_button_new_with_range(-1e6, 1e6, 0.1);
gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->lo), 4);
gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->hi), 4);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->lo), -1.0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->hi), 1.0);
g_signal_connect(d->lo, "value-changed", G_CALLBACK(on_math_param_changed), d);
g_signal_connect(d->hi, "value-changed", G_CALLBACK(on_math_param_changed), d);
{
GtkWidget* clamp_rows = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
gtk_box_append(GTK_BOX(clamp_rows), math_row("min", d->lo, nullptr));
gtk_box_append(GTK_BOX(clamp_rows), math_row("max", d->hi, nullptr));
d->rows[4] = clamp_rows;
gtk_box_append(GTK_BOX(form), clamp_rows);
}
// Odd, 3..9. The engine rounds up to odd anyway, but a step of 2 means the widget never offers an even
// one.
d->med = gtk_spin_button_new_with_range(3.0, 9.0, 2.0);
gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->med), 0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->med), 5.0);
g_signal_connect(d->med, "value-changed", G_CALLBACK(on_math_param_changed), d);
gtk_box_append(GTK_BOX(form), math_row("median samples", d->med, &d->rows[6]));
d->inputs = gtk_label_new("");
gtk_label_set_wrap(GTK_LABEL(d->inputs), true);
gtk_widget_add_css_class(d->inputs, "dim-label");
gtk_widget_set_halign(d->inputs, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(form), math_row("wired", d->inputs, &d->rows[5]));
// Why the graph cannot be saved, in the form column where the user is already reading. Hidden
// when there is nothing to say, and **ellipsized rather than wrapped**, with the full text on the
// tooltip (`math_dialog_sync()` sets both together): layout must not depend on content. This label
// sits in a dialog whose canvas is `hexpand`, so a wrapping message re-heights the form -- and a
// long channel name in it would widen the dialog -- on a mere change of graph. The same rule the
// status bar and the y gutter are built to.
d->problem = gtk_label_new("");
gtk_label_set_ellipsize(GTK_LABEL(d->problem), PANGO_ELLIPSIZE_END);
gtk_label_set_xalign(GTK_LABEL(d->problem), 0.0f);
gtk_widget_add_css_class(d->problem, "error");
gtk_widget_set_visible(d->problem, FALSE);
gtk_box_append(GTK_BOX(form), d->problem);
// A menu of the thirteen ops rather than one button that always makes a gain: building anything
// else was add-then-change-the-dropdown, which is two steps for the common case.
GtkWidget* add = gtk_menu_button_new();
gtk_menu_button_set_label(GTK_MENU_BUTTON(add), "add node");
GtkWidget* add_menu = gtk_popover_new();
GtkWidget* add_list = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
for (int i = 0; i < math_op_count(); ++i) {
const MathOp op = math_op_at(i);
GtkWidget* item = gtk_button_new_with_label(math_op_name(op));
gtk_widget_add_css_class(item, "flat");
gtk_widget_set_halign(gtk_button_get_child(GTK_BUTTON(item)), GTK_ALIGN_START);
// The op travels in the widget's own data, as an `int` through `math_op_index()`'s space: a
// pointer-sized enum smuggled through `gpointer` is the kind of cast that survives a
// reordering of the enum and then means something else.
g_object_set_data(G_OBJECT(item), "udpscope-math-op", GINT_TO_POINTER(i));
g_object_set_data(G_OBJECT(item), "udpscope-math-popover", add_menu);
g_signal_connect(item, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
MathDialog* dd = static_cast<MathDialog*>(user);
const int index =
GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), "udpscope-math-op"));
auto* pop = GTK_POPOVER(
g_object_get_data(G_OBJECT(b), "udpscope-math-popover"));
if (pop != nullptr) gtk_popover_popdown(pop);
math_add_node(dd, math_op_at(index));
}), d);
gtk_box_append(GTK_BOX(add_list), item);
}
gtk_popover_set_child(GTK_POPOVER(add_menu), add_list);
gtk_menu_button_set_popover(GTK_MENU_BUTTON(add), add_menu);
GtkWidget* del = gtk_button_new_with_label("delete node");
GtkWidget* out = gtk_button_new_with_label("set as output");
g_signal_connect(del, "clicked", G_CALLBACK(on_math_node_deleted), d);
g_signal_connect(out, "clicked", G_CALLBACK(on_math_output_set), d);
GtkWidget* fit = gtk_button_new_with_label("fit");
gtk_widget_set_tooltip_text(fit, "Frame the whole graph (f). Ctrl+0 resets the zoom.");
g_signal_connect(fit, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
math_view_fit(static_cast<MathDialog*>(user));
}), d);
GtkWidget* tidy = gtk_button_new_with_label("tidy");
gtk_widget_set_tooltip_text(tidy, "Lay the graph out left to right and frame it.");
g_signal_connect(tidy, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
math_tidy(static_cast<MathDialog*>(user));
}), d);
GtkWidget* buttons = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_box_append(GTK_BOX(buttons), add);
gtk_box_append(GTK_BOX(buttons), del);
gtk_box_append(GTK_BOX(buttons), out);
gtk_box_append(GTK_BOX(buttons), fit);
gtk_box_append(GTK_BOX(buttons), tidy);
gtk_box_append(GTK_BOX(form), buttons);
gtk_box_append(GTK_BOX(body), form); // the form is the body's second child; the canvas is the file's
// The action row is the file's; what is code is what the buttons *do*, and what the second one says:
// "Save" edits an existing channel and "Add" makes a new one, which the file cannot know.
GtkWidget* actions = GTK_WIDGET(gtk_builder_get_object(ui, "math_actions"));
GtkWidget* cancel = GTK_WIDGET(gtk_builder_get_object(ui, "math_cancel"));
GtkWidget* apply = GTK_WIDGET(gtk_builder_get_object(ui, "math_apply"));
if (actions == nullptr || cancel == nullptr || apply == nullptr) {
std::fprintf(stderr, "[math] editor: math-editor.ui is missing its action row\n");
delete d;
return;
}
gtk_button_set_label(GTK_BUTTON(apply), edit >= 0 ? "Save" : "Add");
// Named so the headless probe can click the *button* rather than call its handler: the defect
// this guards was in the connection, which a direct call cannot see.
g_object_set_data(G_OBJECT(window), "udpscope-math-cancel", cancel);
// `gtk_window_close()`, never `gtk_window_destroy()`: only a close emits `close-request`, which
// is what clears `app.math_dialog`. A destroy left that pointer at a dead widget, and every
// later open then took the "already open" branch and presented it -- "I cannot edit a math
// channel again". The same trap the other three windows in this file document.
g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_close), window);
g_signal_connect(apply, "clicked", G_CALLBACK(on_math_apply), d);
app.math_dialog = window;
d->apply = apply;
math_dialog_sync(d);
gtk_window_present(GTK_WINDOW(window));
}
GtkWidget* build_measures_panel(App& app) {
// The panel's frame is a UI file; the readings are not, because each row is one measurement and they are
// rebuilt whenever the set or the mode changes. `make test-ui` checks the file's types against the GIR;
// the ids below are the only thing that keeps the code and the file agreeing on the names.
GtkBuilder* ui = ui_load(app, "/org/udpscope/ui/measures-panel.ui");
GtkWidget* box = GTK_WIDGET(gtk_builder_get_object(ui, "measures_panel"));
GtkWidget* add = GTK_WIDGET(gtk_builder_get_object(ui, "measures_add"));
app.measures_stats_check = GTK_WIDGET(gtk_builder_get_object(ui, "measures_stats_check"));
app.measures_stats_reset = GTK_WIDGET(gtk_builder_get_object(ui, "measures_stats_reset"));
app.measures_list = GTK_WIDGET(gtk_builder_get_object(ui, "measures_list"));
if (box == nullptr || add == nullptr || app.measures_stats_check == nullptr ||
app.measures_stats_reset == nullptr || app.measures_list == nullptr) {
// A missing id is a mistake in the `.ui`. The pane comes up empty rather than taking the window with
// it, and `[udpscope] measures pane:` reads differently instead of the application failing to start.
return box != nullptr ? box : gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
}
g_signal_connect(add, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
open_measure_dialog(*static_cast<App*>(user), -1);
}),
&app);
// One toggle for the whole pane, not one per measurement: statistics are either what the
// user is looking at or they are not, and a per-row switch is a column of chrome paid for
// on every row to answer a question asked once.
g_signal_connect(app.measures_stats_check, "toggled",
G_CALLBACK(+[](GtkCheckButton* b, gpointer user) {
App* a = static_cast<App*>(user);
if (a->updating_panels) return;
a->measure_stats_on = gtk_check_button_get_active(b);
// Deliberately *not* a rebuild: that would reset the statistics the
// user just asked to look at. The rows are already there and hidden.
for (GtkWidget* w : a->measure_stats_labels) {
if (w != nullptr) gtk_widget_set_visible(w, a->measure_stats_on);
}
if (a->measures_stats_reset != nullptr)
gtk_widget_set_sensitive(a->measures_stats_reset, a->measure_stats_on);
// Refresh the now-visible labels without counting the window again: the
// toggle does not reset or disturb the history (README, Statistics).
update_measures(*a, /*force=*/true, /*accumulate=*/false);
schedule_layout_save(*a);
}),
&app);
g_signal_connect(app.measures_stats_reset, "clicked",
G_CALLBACK(+[](GtkButton*, gpointer user) {
App* a = static_cast<App*>(user);
reset_measure_stats(*a);
update_measures(*a, /*force=*/true, /*accumulate=*/true);
}),
&app);
return box;
}
/// The panel's frame: the title row (the mode) and the readouts. The time line is not
/// part of the body, because it is there in every mode.
/// Fit: auto-scale every row's y axis to the data it holds, and put x back on the whole time
/// window.
///
/// No `apply_time_window()` here, deliberately: a fit is a plot interaction and may not restate
/// the time scale (see that function). The y axes are fitted to the data; x is only un-zoomed.
void on_fit_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
app->rows->fit_all();
app->notice = "y fitted to the data, x back to the " +
format_window(app->rows->time_window()) + " window";
app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
}
/// Reset the x zoom only: the y axes keep the scales the user set. The counterpart of the other
/// fit, for the common case where a zoom should be undone but the levels were placed by hand.
void on_fit_x_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
app->rows->fit_x();
app->notice = "x back to the " + format_window(app->rows->time_window()) + " window";
app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
}
// --- export -----------------------------------------------------------------
/// The table travels to the async callback. It is collected when the button is clicked,
/// not when the dialog is answered: a live view keeps scrolling while the file chooser
/// is up, so collecting late would write a window the user never saw.
void on_export_response(GObject* source, GAsyncResult* result, gpointer user) {
std::unique_ptr<ExportContext> ctx(static_cast<ExportContext*>(user));
GError* err = nullptr;
GFile* file = gtk_file_dialog_save_finish(GTK_FILE_DIALOG(source), result, &err);
if (file == nullptr) {
// A cancelled dialog is not a failure and says nothing; anything else does.
if (err != nullptr && !g_error_matches(err, GTK_DIALOG_ERROR, GTK_DIALOG_ERROR_DISMISSED)) {
set_notice(*ctx->app, std::string("export failed: ") + err->message);
}
if (err != nullptr) g_error_free(err);
return;
}
char* raw = g_file_get_path(file);
const std::string path = raw != nullptr ? raw : std::string();
g_free(raw);
g_object_unref(file);
if (path.empty()) {
set_notice(*ctx->app, "export failed: that location is not a local file");
return;
}
// The extension picks the writer, so the filter the user chose is honoured by way of
// the name it put in the entry rather than by a second piece of state.
const bool csv = path.size() >= 4 && path.compare(path.size() - 4, 4, ".csv") == 0;
std::string error;
const bool ok = csv ? write_csv(path, ctx->table, error) : write_npy(path, ctx->table, error);
if (!ok) {
set_notice(*ctx->app, "export failed: " + error);
return;
}
set_notice(*ctx->app, "exported " + std::to_string(ctx->table.rows()) + " samples x " +
std::to_string(ctx->table.columns.size()) + " signals to " + path);
}
void on_export_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
auto ctx = std::make_unique<ExportContext>();
ctx->app = app;
ctx->table = collect_export(*app->model, *app->rows);
if (ctx->table.empty()) {
set_notice(*app, "nothing to export: no signal is plotted in the visible range");
return;
}
GtkFileDialog* dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Export visible range");
char stamp[32];
const std::time_t now = std::time(nullptr);
std::tm tm{};
localtime_r(&now, &tm);
std::strftime(stamp, sizeof(stamp), "%Y%m%d-%H%M%S", &tm);
gtk_file_dialog_set_initial_name(dialog, (std::string("udpscope-") + stamp + ".npy").c_str());
GListStore* filters = g_list_store_new(GTK_TYPE_FILE_FILTER);
GtkFileFilter* npy = gtk_file_filter_new();
gtk_file_filter_set_name(npy, "NumPy array (*.npy)");
gtk_file_filter_add_pattern(npy, "*.npy");
g_list_store_append(filters, npy);
GtkFileFilter* csv = gtk_file_filter_new();
gtk_file_filter_set_name(csv, "CSV (*.csv)");
gtk_file_filter_add_pattern(csv, "*.csv");
g_list_store_append(filters, csv);
gtk_file_dialog_set_filters(dialog, G_LIST_MODEL(filters));
gtk_file_dialog_set_default_filter(dialog, npy);
g_object_unref(npy);
g_object_unref(csv);
g_object_unref(filters);
gtk_file_dialog_save(dialog, GTK_WINDOW(app->window), nullptr, on_export_response,
ctx.release());
g_object_unref(dialog);
}
void on_about_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->menu_button != nullptr) {
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
if (popover != nullptr) gtk_popover_popdown(popover);
}
show_about(GTK_WINDOW(app->window),
layout_path(app->model->config().server, app->model->config().port));
}
void on_help_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->menu_button != nullptr) {
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
if (popover != nullptr) gtk_popover_popdown(popover);
}
show_help(GTK_WINDOW(app->window));
}
void on_shortcuts_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->menu_button != nullptr) {
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
if (popover != nullptr) gtk_popover_popdown(popover);
}
show_shortcuts(GTK_WINDOW(app->window));
}
// --- right-click menu -------------------------------------------------------
void on_y_auto_clicked(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const int row = row_key(GTK_WIDGET(button));
app->rows->set_y_auto(row, true);
GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(button), "udpscope-popover"));
if (popover != nullptr) gtk_popover_popdown(popover);
}
void on_fixed_range_apply(GtkButton*, gpointer user) {
FixedRangeContext* ctx = static_cast<FixedRangeContext*>(user);
ctx->app->rows->set_y_range(ctx->row, gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->lo)),
gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->hi)));
gtk_window_destroy(GTK_WINDOW(ctx->window));
}
void on_y_fixed_clicked(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const int row = row_key(GTK_WIDGET(button));
GtkWidget* window = dialog_window(app->window, "Fixed Y range");
GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window));
const plot::Range cur = app->rows->row(row).plot->y_range(0);
GtkWidget* lo = spin(-1.0e12, 1.0e12, 0.1, 6, cur.min);
GtkWidget* hi = spin(-1.0e12, 1.0e12, 0.1, 6, cur.max);
gtk_box_append(GTK_BOX(box), labeled_box("min", lo));
gtk_box_append(GTK_BOX(box), labeled_box("max", hi));
auto* ctx = new FixedRangeContext{app, row, lo, hi, window};
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window);
GtkWidget* apply = gtk_button_new_with_label("Apply");
g_object_set_data_full(G_OBJECT(apply), "udpscope-ctx", ctx,
[](gpointer p) { delete static_cast<FixedRangeContext*>(p); });
g_signal_connect(apply, "clicked", G_CALLBACK(on_fixed_range_apply), ctx);
dialog_add_button(window, cancel);
dialog_add_button(window, apply);
gtk_window_present(GTK_WINDOW(window));
GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(button), "udpscope-popover"));
if (popover != nullptr) gtk_popover_popdown(popover);
}
/// One "remove from this plot" entry's payload: which row, and which trace slot -- or `kAllSlots`
/// for the row's every trace, which is what the submenu's last entry means.
/// Frees one trace slot of a row. All it takes is the free: the row is rebuilt with a series that is
/// nothing, so it draws nothing and leaves the legend.
void remove_trace(App& app, int row, int slot) {
if (row < 0 || row >= app.rows->row_count()) return;
app.rows->assign(row, slot, std::string(), std::string(), 0);
}
/// Disables every math channel that (transitively) reads `name`, and answers with their names.
///
/// A derived channel cannot be computed from a signal nobody acquires: `Source` resolves to a *channel*, and a
/// disabled signal has none -- so a channel left enabled would silently compute nothing. The rule is that the
/// channels which depend on a signal follow it off, which is also what the pane's own switch would do by hand.
/// The dependency is transitive: one math channel may name another, so a channel reading a disabled one goes
/// too, and the loop runs to a fixpoint for that.
std::vector<std::string> disable_dependent_math_channels(App& app, const std::string& name) {
std::vector<std::string> off;
if (name.empty()) return off;
const auto math_index_of = [&app](const std::string& n) -> int {
for (std::size_t k = 0; k < app.math.size(); ++k) {
if (app.math[k].name == n) return static_cast<int>(k);
}
return -1;
};
for (bool moved = true; moved;) {
moved = false;
for (MathChannel& channel : app.math) {
if (!channel.enabled) continue;
for (const MathNode& node : channel.nodes) {
if (node.op != MathOp::Source) continue;
const int k = math_index_of(node.source);
const bool reads_it = node.source == name;
const bool reads_a_disabled_one = k >= 0 && !app.math[static_cast<std::size_t>(k)].enabled;
if (!reads_it && !reads_a_disabled_one) continue;
channel.enabled = false;
off.push_back(channel.name);
moved = true;
break;
}
}
}
return off;
}
/// Takes a signal off every plot, and says how many traces went.
///
/// Switching a signal off means "do not acquire this", so a trace of it left on a plot draws nothing: a slot
/// that reads `--`, takes the place a live signal could have had, and blocks the drop that would fill it. The
/// slot is what is cleared, and the caller does the follow-ups once (a relayout, the panels, the layout save)
/// rather than per slot.
int remove_signal_from_plots(App& app, const std::string& name) {
if (name.empty()) return 0;
int removed = 0;
for (int row = 0; row < app.rows->row_count(); ++row) {
for (int slot = 0; slot < RowSet::kSlotsPerRow; ++slot) {
// The slot remembers the *signal* (and its element), never the channel index -- the channel list is
// renumbered on every enable change, which is what this is reacting to.
if (app.rows->row(row).signal_name[static_cast<std::size_t>(slot)] != name) continue;
remove_trace(app, row, slot);
++removed;
}
}
return removed;
}
void on_remove_clicked(GtkButton* button, gpointer user) {
(void)user;
auto* target = static_cast<RemoveTarget*>(g_object_get_data(G_OBJECT(button), "udpscope-remove"));
if (target == nullptr) return;
App& app = *target->app;
const RowSet::Row& r = app.rows->row(target->row);
std::string what;
if (target->slot == RemoveTarget::kAllSlots) {
int removed = 0;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
if (r.channel[static_cast<std::size_t>(s)] < 0) continue;
remove_trace(app, target->row, s);
++removed;
}
what = "removed " + std::to_string(removed) + (removed == 1 ? " trace" : " traces") +
" from plot " + std::to_string(target->row + 1);
} else {
const std::string name = r.name[static_cast<std::size_t>(target->slot)];
remove_trace(app, target->row, target->slot);
what = "removed '" + name + "' from plot " + std::to_string(target->row + 1);
}
app.relayout_pending = true;
rebuild_signal_panels(app); // the signal's chip goes grey with it
app.notice = what;
app.notice_until_us = g_get_monotonic_time() + 4 * G_TIME_SPAN_SECOND;
GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(button), "udpscope-popover"));
if (popover != nullptr) gtk_popover_popdown(popover);
}
/// Which of the plot menu's two views is showing. The second **replaces** the first, with a Back
/// row at the top, rather than floating beside it: a submenu torn out of the menu it belongs to is
/// a second thing to dismiss, and the `GtkMenuButton` that owned one toggled itself out of sync the
/// moment its popover was closed by anything else -- the row stopped opening at all on the second
/// click.
/// The plot menu's own state, one per open menu: which view it shows, and the box it builds its rows
/// into. Owned by the popover (see `open_row_menu`), so a dismissed menu takes it with it.
/// A `menu_button` with a leading icon: the Back row's arrow, which every GNOME menu puts before
/// its label rather than after.
GtkWidget* menu_icon_button(const char* icon, const char* label) {
GtkWidget* button = menu_button(label, nullptr);
if (GtkWidget* box = gtk_button_get_child(GTK_BUTTON(button))) {
gtk_box_prepend(GTK_BOX(box), gtk_image_new_from_icon_name(icon));
}
return button;
}
void on_row_menu_show_remove(GtkButton*, gpointer user) {
RowMenuState& st = *static_cast<RowMenuState*>(user);
st.view = RowMenuView::Remove;
row_menu_fill(st);
// The popover's content changed size, which is what `present()` is for: it re-allocates and
// re-positions a popover that is already up.
gtk_popover_present(GTK_POPOVER(st.popover));
}
void on_row_menu_back(GtkButton*, gpointer user) {
RowMenuState& st = *static_cast<RowMenuState*>(user);
st.view = RowMenuView::Main;
row_menu_fill(st);
gtk_popover_present(GTK_POPOVER(st.popover));
}
/// Builds the rows for `st.view`. Rebuilt rather than swapped between two boxes: the rows are a
/// handful of widgets and this happens on a click, and a box held aside for later would have to be
/// kept alive through the popover's own teardown.
void row_menu_fill(RowMenuState& st) {
while (GtkWidget* child = gtk_widget_get_first_child(st.box)) {
gtk_box_remove(GTK_BOX(st.box), child);
}
App& app = *st.app;
const int row = st.row;
// Each row knows which row of the figure it acts on and which popover dismisses when it is done.
const auto add_row = [&](const char* label, const char* accel, GCallback handler,
gpointer user) {
GtkWidget* b = menu_button(label, accel);
set_row_key(b, row);
g_object_set_data(G_OBJECT(b), "udpscope-popover", st.popover);
if (handler != nullptr) g_signal_connect(b, "clicked", handler, user);
gtk_box_append(GTK_BOX(st.box), b);
return b;
};
// The heading is a row like the rest but not a button: it names what the view acts on.
const auto add_heading = [&](const char* text) {
GtkWidget* heading = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(heading), text);
gtk_label_set_xalign(GTK_LABEL(heading), 0.0f);
gtk_widget_set_margin_start(heading, 8);
gtk_widget_set_margin_bottom(heading, 4);
gtk_box_append(GTK_BOX(st.box), heading);
};
if (st.view == RowMenuView::Main) {
char markup[64];
std::snprintf(markup, sizeof(markup), "<b>Plot %d</b>", row + 1);
add_heading(markup);
add_row("Y axis: auto scale", nullptr, G_CALLBACK(on_y_auto_clicked), &app);
add_row("Y axis: fixed range...", nullptr, G_CALLBACK(on_y_fixed_clicked), &app);
// Fitting is a per-plot gesture in the menu as it is on the screen: the two do the same thing
// whatever row they were opened on (the x window is shared and the y fit walks them all), which
// is what "fit" means here. `F` is the plot's own key for the second of them.
add_row("Fit X axis", nullptr, G_CALLBACK(on_fit_x_clicked), &app);
add_row("Fit all", "F", G_CALLBACK(on_fit_clicked), &app);
gtk_box_append(GTK_BOX(st.box), menu_separator());
// A row, not a `GtkMenuButton`: the removals are the *next view of this menu*, and the chevron
// is what says so.
GtkWidget* remove_row = add_row("Remove signal", nullptr, G_CALLBACK(on_row_menu_show_remove),
&st);
if (GtkWidget* inner = gtk_button_get_child(GTK_BUTTON(remove_row))) {
gtk_box_append(GTK_BOX(inner), gtk_image_new_from_icon_name("pan-end-symbolic"));
}
return;
}
// The way out, with the arrow every menu puts before a Back row's label.
GtkWidget* back = menu_icon_button("go-previous-symbolic", "Back");
set_row_key(back, row);
g_object_set_data(G_OBJECT(back), "udpscope-popover", st.popover);
g_signal_connect(back, "clicked", G_CALLBACK(on_row_menu_back), &st);
gtk_box_append(GTK_BOX(st.box), back);
gtk_box_append(GTK_BOX(st.box), menu_separator());
const RowSet::Row& r = app.rows->row(row);
int traces = 0;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
if (r.channel[static_cast<std::size_t>(s)] < 0) continue;
++traces;
GtkWidget* b = add_row(r.name[static_cast<std::size_t>(s)].c_str(), nullptr,
G_CALLBACK(on_remove_clicked), nullptr);
auto* target = new RemoveTarget{&app, row, s};
g_object_set_data_full(G_OBJECT(b), "udpscope-remove", target,
[](gpointer p) { delete static_cast<RemoveTarget*>(p); });
}
if (traces > 1) {
GtkWidget* all = add_row("All traces on this plot", nullptr, G_CALLBACK(on_remove_clicked),
nullptr);
auto* target = new RemoveTarget{&app, row, RemoveTarget::kAllSlots};
g_object_set_data_full(G_OBJECT(all), "udpscope-remove", target,
[](gpointer p) { delete static_cast<RemoveTarget*>(p); });
}
if (traces == 0) {
GtkWidget* empty = gtk_label_new("nothing to remove");
gtk_widget_add_css_class(empty, "dim-label");
gtk_label_set_xalign(GTK_LABEL(empty), 0.0f);
gtk_widget_set_margin_start(empty, 8);
gtk_box_append(GTK_BOX(st.box), empty);
}
}
/// The one entry point for the plot's context menu, so the probe can build it without a gesture:
/// `on_right_click()` is the gesture's wrapper around this, and nothing else. Returns the popover,
/// which is what the probe measures.
GtkWidget* open_row_menu(App& app, int row, double x, double y, GtkWidget* parent, bool autohide) {
GtkWidget* popover = gtk_popover_new();
gtk_widget_add_css_class(popover, "udpscope-menu");
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_set_margin_top(box, 6);
gtk_widget_set_margin_bottom(box, 6);
gtk_widget_set_margin_start(box, 6);
gtk_widget_set_margin_end(box, 6);
auto* st = new RowMenuState;
st->app = &app;
st->row = row;
st->popover = popover;
st->box = box;
// Owned by the popover: a dismissed menu frees its state with it, and the `closed` handler that
// unparents the popover is what does the dismissing.
g_object_set_data_full(G_OBJECT(popover), "udpscope-row-menu", st,
[](gpointer p) { delete static_cast<RowMenuState*>(p); });
row_menu_fill(*st);
// Autohide off *for the probe*: 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 menu keeps autohide on.
gtk_popover_set_autohide(GTK_POPOVER(popover), autohide ? TRUE : FALSE);
gtk_popover_set_child(GTK_POPOVER(popover), box);
GdkRectangle rect{static_cast<int>(x), static_cast<int>(y), 1, 1};
gtk_popover_set_pointing_to(GTK_POPOVER(popover), &rect);
gtk_widget_set_parent(popover, parent);
gtk_popover_popup(GTK_POPOVER(popover));
// NOT g_signal_connect_swapped: that hands the callback its user_data as the first argument, so
// `on_popover_closed` would receive nullptr instead of the popover and both the unparent and the
// unref would fail with a Gtk-CRITICAL.
g_signal_connect(popover, "closed", G_CALLBACK(on_popover_closed), nullptr);
return popover;
}
void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer user) {
App* app = static_cast<App*>(user);
GtkWidget* widget = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(gesture));
const int row = row_at(*app, x, y);
// Claim the sequence: without this the button *release* arrives while the popover is up, counts
// as a click outside it, and dismisses the menu before it can be read -- the menu flashes and
// vanishes.
gtk_gesture_set_state(GTK_GESTURE(gesture), GTK_EVENT_SEQUENCE_CLAIMED);
cancel_drag_on(*app, row);
open_row_menu(*app, row, x, y, widget);
}
void on_side_tab_changed(GObject* o, GParamSpec*, gpointer user) {
App* app = static_cast<App*>(user);
const char* name = adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(o));
app->side_tab = name != nullptr ? name : "signals";
// Not while the layout is being applied: the restore would schedule a save of what it just read.
if (!app->updating_panels) schedule_layout_save(*app);
}
void select_side_tab(App& app, const char* name) {
if (app.side_stack == nullptr || name == nullptr) return;
// An unknown name leaves the stack where it is -- a session file from a later version must not
// be able to blank the sidebar.
if (adw_view_stack_get_child_by_name(ADW_VIEW_STACK(app.side_stack), name) == nullptr) return;
adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(app.side_stack), name);
app.side_tab = name;
}
/// The side column is on when it has something to show *and* the user's sidebar
/// button is in; the two panel buttons appear as soon as a CONFIG describes any
/// signal at all.
void update_side_visibility(App& app) {
if (app.side_stack == nullptr) return;
const bool have_signals = !app.model->signals().empty();
// `have_signals` decides whether the sidebar *can* be on; `sidebar_wanted` is the user's last word,
// which is why it is kept apart from the button's own state -- that state follows the panel now, and
// the panel is off while there is nothing to show. A swipe that hides the sidebar fires the button's
// handler (the binding is bidirectional), so a gesture is a choice too.
gtk_widget_set_sensitive(app.sidebar_toggle, have_signals);
const bool want_column = have_signals && app.sidebar_wanted;
// The split view owns the sidebar's visibility: it animates it, and when the window is too narrow it
// shows it as an overlay rather than letting the column be squeezed to nothing.
if (app.side_view != nullptr) {
app.sidebar_syncing = true;
adw_overlay_split_view_set_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(app.side_view), want_column);
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.sidebar_toggle), want_column);
app.sidebar_syncing = false;
}
gtk_widget_set_visible(app.trigger_toggle, have_signals);
}
void on_sidebar_toggled(GtkToggleButton* button, gpointer user) {
App* app = static_cast<App*>(user);
// Only a state we did not write is a wish: `update_side_visibility` sets this button (and the panel)
// from `sidebar_wanted`, so an unguarded change here is the user's.
if (!app->sidebar_syncing) app->sidebar_wanted = gtk_toggle_button_get_active(button) != FALSE;
update_side_visibility(*app);
}
// --- rebuild of the derived panels -----------------------------------------
/// The width and height the root `<svg>` element of `data` declares, or 0x0 when it has none.
/// GMarkup rather than a string scan because the attributes may be in any order and on any
/// line; it is *not* a validity check -- GMarkup is a deliberately lenient subset parser and
/// accepts a double hyphen inside a comment, which is exactly the file librsvg refuses. That
/// check is `xml-stripblanks` in the GResource manifest, at build time.
void svg_declared_size(const char* data, gsize size, int* w, int* h) {
struct Found { int w = 0; int h = 0; };
Found found;
GMarkupParser parser{};
parser.start_element = [](GMarkupParseContext*, const char* element, const char** names,
const char** values, gpointer user, GError**) {
if (std::strcmp(element, "svg") != 0) return;
Found* f = static_cast<Found*>(user);
for (int i = 0; names[i] != nullptr; ++i) {
if (std::strcmp(names[i], "width") == 0) f->w = std::atoi(values[i]);
if (std::strcmp(names[i], "height") == 0) f->h = std::atoi(values[i]);
}
};
GMarkupParseContext* ctx = g_markup_parse_context_new(&parser, GMarkupParseFlags(0), &found,
nullptr);
if (g_markup_parse_context_parse(ctx, data, static_cast<gssize>(size), nullptr)) {
g_markup_parse_context_end_parse(ctx, nullptr);
}
g_markup_parse_context_free(ctx);
*w = found.w;
*h = found.h;
}
/// One entry of the `app icons:` line: the icon is *named* (the theme resolved it, so the
/// resource path was registered) and *present* (the bytes the name resolved to are really in
/// the binary), at the size its own root element declares.
///
/// It deliberately does not claim the file renders. `gtk_icon_theme_lookup_icon()` never
/// returns NULL, its paintable's intrinsic size is the size that was asked for, and a valid
/// icon, a malformed one and a name that does not exist at all all snapshot to the same
/// texture node -- GTK substitutes `image-missing` and there is nothing left to compare. The
/// malformed case is a build error instead; see the GResource manifest.
std::string icon_report(GtkIconTheme* theme, const char* name, const char* resource) {
const bool named = gtk_icon_theme_has_icon(theme, name) != FALSE;
GBytes* bytes = g_resources_lookup_data(resource, G_RESOURCE_LOOKUP_FLAGS_NONE, nullptr);
const bool present = bytes != nullptr;
int w = 0;
int h = 0;
if (present) {
gsize size = 0;
const char* data = static_cast<const char*>(g_bytes_get_data(bytes, &size));
svg_declared_size(data, size, &w, &h);
g_bytes_unref(bytes);
}
std::string out = std::string(name) + "(";
out += (named && present) ? "ok " : "MISSING ";
out += std::to_string(w) + "x" + std::to_string(h) + ")";
return out;
}
/// Where the panel controls live, as their parents say: the three pane toggles are toolbar buttons
/// and Reset is inside the cursors pane. Recorded on the first tick -- the widgets exist and the
/// window is alive, neither of which is true when the dump runs -- so the dump can print a fact
/// instead of asking GTK about a destroyed widget.
/// The words the pills do not draw. The pages' `title` is empty on purpose (see the `.ui`), so the
/// human name of a tab lives here and is put on the pill — its tooltip and its accessible name — by
/// `name_side_tabs()`. That keeps an icon-only pill a *named* control for a screen reader.
static const char* side_tab_title(const char* name) {
if (name != nullptr && std::strcmp(name, "measure") == 0) return "Measure";
if (name != nullptr && std::strcmp(name, "signals") == 0) return "Signals";
return "";
}
/// Names the switcher's pills and takes their empty labels out of the layout: an empty `GtkLabel`
/// still asks for its own width, which leaves the icon off-centre in a pill meant to be one glyph.
/// Called from the first tick, because the pills are `AdwViewSwitcher`'s own and exist only once the
/// widget has been mapped.
void name_side_tabs(App& app) {
if (app.side_tabs_named || app.side_switcher == nullptr) return;
GListModel* pages = app.side_stack != nullptr
? G_LIST_MODEL(adw_view_stack_get_pages(ADW_VIEW_STACK(app.side_stack)))
: nullptr;
// The switcher's children *are* its pills, in page order, and `AdwViewSwitcherButton` is not a
// public type (there is no `ADW_IS_VIEW_SWITCHER_BUTTON`), so the two lists are walked together by
// index rather than checked by type.
GtkWidget* pill = gtk_widget_get_first_child(app.side_switcher);
for (guint i = 0; pages != nullptr && pill != nullptr && i < g_list_model_get_n_items(pages);
pill = gtk_widget_get_next_sibling(pill), ++i) {
AdwViewStackPage* page = ADW_VIEW_STACK_PAGE(g_list_model_get_item(pages, i));
const char* title = side_tab_title(adw_view_stack_page_get_name(page));
g_object_unref(page);
if (title[0] == '\0') continue;
gtk_widget_set_tooltip_text(pill, title);
gtk_accessible_update_property(GTK_ACCESSIBLE(pill), GTK_ACCESSIBLE_PROPERTY_LABEL, title, -1);
const std::function<void(GtkWidget*)> hide_labels = [&](GtkWidget* w) {
if (GTK_IS_LABEL(w)) gtk_widget_set_visible(w, FALSE);
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
hide_labels(c);
}
};
hide_labels(pill);
// The tabs' glyphs are 32x16 documents, and the pill has the width for them: drawn at 32 they are
// 1:1, so both motifs sit side by side at the size they were authored at and the 2px bars stay
// crisp. A 16px box would scale the pair down to a smear, and a fractional scale would blur it.
const std::function<void(GtkWidget*)> size_images = [&](GtkWidget* w) {
if (GTK_IS_IMAGE(w)) gtk_image_set_pixel_size(GTK_IMAGE(w), 32);
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
size_images(c);
}
};
size_images(pill);
app.side_tabs_named = true;
}
}
void note_panel_controls(App& app) {
if (!app.dbg_panel_controls.empty()) return;
const auto in_bar = [](GtkWidget* w) {
for (GtkWidget* p = w != nullptr ? gtk_widget_get_parent(w) : nullptr; p != nullptr;
p = gtk_widget_get_parent(p)) {
if (GTK_IS_HEADER_BAR(p)) return 1;
}
return 0;
};
const auto in_cursors_pane = [&app](GtkWidget* w) {
for (GtkWidget* p = w != nullptr ? gtk_widget_get_parent(w) : nullptr; p != nullptr;
p = gtk_widget_get_parent(p)) {
if (p == app.cursors_panel) return 1;
}
return 0;
};
// `HeaderBar::pack_end()` parents into an internal box, so the bar is a level up, not the parent.
char text[256];
std::snprintf(text, sizeof(text),
"cursors=%d in the toolbar, switcher=%d, reset=%d in the cursors pane",
in_bar(app.cursors_toggle), app.side_switcher != nullptr ? 1 : 0,
in_cursors_pane(app.cursors_reset));
// ... and the order the bar actually shows, which is what "the menu should be last" is about. A
// widget tree walked in child order *is* the layout order, so this is the reading order of the
// controls the user sees -- the only way to check an arrangement without a screenshot.
const std::pair<GtkWidget*, const char*> named[] = {
{app.trigger_toggle, "trigger"}, {app.events_toggle, "events"},
{app.pause_button, "pause"},
{app.cursors_toggle, "cursors"}, {app.sidebar_toggle, "sidebar"},
{app.menu_button, "menu"},
};
// The widget tree's child order, which *is* the visual order left to right: a box lays its children
// out in the order it holds them. Since `pack_end()` puts the *first* packed furthest right, this
// reads as the reverse of the pack order -- which is the whole reason the line exists, because the
// pack order alone is not what the user sees.
std::string order;
const std::function<void(GtkWidget*)> walk = [&](GtkWidget* w) {
for (const auto& [widget, name] : named) {
if (w == widget) order += std::string(order.empty() ? "" : " ") + name;
}
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
walk(c);
}
};
if (app.toolbar != nullptr) walk(app.toolbar);
app.dbg_panel_controls = std::string(text) + "; toolbar, left to right: " + order;
// ... and the icon each of them carries, asked of the theme rather than of our own
// string. Four of these are drawn for this app and live in a GResource inside the
// binary, which the theme only looks in because `run_ui()` adds the resource path
// before building anything -- and a path that was never added fails *silently*: the
// button keeps working, the layout is unchanged, and GTK draws a missing-image
// placeholder no probe can see. The two app icons below come out of the same
// registration, which is why a MISSING on either line is one defect.
GtkIconTheme* theme = gtk_icon_theme_get_for_display(gdk_display_get_default());
for (const auto& [widget, name] : named) {
const char* icon = nullptr;
if (GTK_IS_BUTTON(widget)) {
icon = gtk_button_get_icon_name(GTK_BUTTON(widget));
} else if (GTK_IS_MENU_BUTTON(widget)) {
icon = gtk_menu_button_get_icon_name(GTK_MENU_BUTTON(widget));
}
if (icon == nullptr) continue;
app.dbg_toolbar_icons += std::string(app.dbg_toolbar_icons.empty() ? "" : " ") + name + "=" +
icon + (gtk_icon_theme_has_icon(theme, icon) ? "(ok)" : "(MISSING)");
}
// The application icon, the same two ways and for the same reason: a resource path that was
// never registered is not an error, it is a missing-image placeholder. This one is read on
// the first tick like the line above, because the dump runs after the window is destroyed.
app.dbg_app_icons =
icon_report(theme, "org.udpscope.app",
"/org/udpscope/icons/scalable/apps/org.udpscope.app.svg") +
" " +
icon_report(theme, "org.udpscope.app-symbolic",
"/org/udpscope/icons/scalable/apps/org.udpscope.app-symbolic.svg");
// The two sidebar pages' own icons, named and resolved the same way, and what the switcher is
// drawing for them. The pills are `AdwViewSwitcher`'s own buttons, built when the stack is mapped,
// so this reads on the first tick: `pills` is the guard (0 pills would make a label count of 0
// meaningless) and `labels` must be 0, because the switcher's policy is `narrow` -- the pages are
// icons, not words, in a 300px column.
// Walked through `get_pages()`, not the widget tree: an `AdwViewStackPage` is a plain `GObject`
// (only its `child` is a widget), so `gtk_widget_get_first_child()` on the stack yields the pages'
// scrolled windows and this line read empty the first time it was written.
GListModel* pages = app.side_stack != nullptr
? G_LIST_MODEL(adw_view_stack_get_pages(ADW_VIEW_STACK(app.side_stack)))
: nullptr;
for (guint i = 0; pages != nullptr && i < g_list_model_get_n_items(pages); ++i) {
AdwViewStackPage* page = ADW_VIEW_STACK_PAGE(g_list_model_get_item(pages, i));
const char* page_name = adw_view_stack_page_get_name(page);
const char* icon = adw_view_stack_page_get_icon_name(page);
app.dbg_page_icons += std::string(app.dbg_page_icons.empty() ? "" : " ") +
(page_name != nullptr ? page_name : "?") + "=" +
(icon != nullptr ? icon : "-") +
(icon != nullptr && gtk_icon_theme_has_icon(theme, icon) ? "(ok)"
: "(MISSING)");
g_object_unref(page);
}
// `mapped`, not `visible`: visibility is per widget, so a label whose *parent* is hidden still
// reads visible, and in the narrow policy that is exactly the case. `mapped` requires every
// ancestor, which is what "on screen" means -- and it is why the pill count is read the same way:
// if nothing is mapped the pill count reads 0 and the label count means nothing, rather than
// passing vacuously.
int pills = 0;
int drawn_labels = 0;
const std::function<void(GtkWidget*)> count_drawn = [&](GtkWidget* w) {
if (GTK_IS_TOGGLE_BUTTON(w) && gtk_widget_get_mapped(w)) ++pills;
// A *mapped* label with text: libadwaita's narrow pill keeps an empty label in the layout, so
// "mapped" alone would count the empty ones and read 2 whether the pills were icons or words.
if (GTK_IS_LABEL(w) && gtk_widget_get_mapped(w) && gtk_label_get_text(GTK_LABEL(w))[0] != '\0') {
++drawn_labels;
}
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
count_drawn(c);
}
};
if (app.side_switcher != nullptr) count_drawn(app.side_switcher);
app.dbg_switcher_pills = pills;
app.dbg_switcher_labels = drawn_labels;
int named_pills = 0;
for (GtkWidget* w = app.side_switcher != nullptr ? gtk_widget_get_first_child(app.side_switcher)
: nullptr;
w != nullptr; w = gtk_widget_get_next_sibling(w)) {
const char* tip = gtk_widget_get_tooltip_text(w);
if (tip != nullptr && tip[0] != '\0') ++named_pills;
}
app.dbg_switcher_names = named_pills;
if (app.side_switcher != nullptr) {
g_object_get(G_OBJECT(app.side_switcher), "policy", &app.dbg_switcher_policy, nullptr);
}
}
/// The event-zoom sweep's two halves: a gesture zoom, then the same walk through the window
/// control. The second is the tail of the countdown.
/// Headless self-check: what `Renderer::draw_traces` would gather for row 0's first bound
/// trace at the axis the frame settled on, recomputed through the same calls it makes.
///
/// The report this exists for is "zooming into a held event clips the trace further and
/// further from the left, while the crosshair still reads the right values there". The
/// crosshair reads `x_at()`, so a symptom that only the *ink* has localises to the index
/// window and the gather -- and a blank margin that grows as a fraction of the screen while
/// the view narrows is the signature of a roughly constant shift in *data* space. So the
/// line prints both sides of the comparison in data units and in pixels:
/// `axis=` is what the frame drew, `ink=` is the first and last gathered x reconstructed the
/// renderer's way (`x0() + gather`), and `gap_px=` is how many pixels of the left margin no
/// geometry reaches. `gap_px` must stay at a pixel or two at every zoom.
void report_event_zoom(App& a, const char* what) {
if (a.rows->row_count() == 0 || a.rows->row(0).plot == nullptr) return;
const RowSet::Row& r0 = a.rows->row(0);
const plot::data::Series* s = nullptr;
for (int i = 0; i < RowSet::kSlotsPerRow; ++i) {
const std::size_t si = static_cast<std::size_t>(i);
if (r0.channel[si] < 0) continue;
s = &r0.slots[si];
break;
}
const plot::Range axis = r0.plot->x_range(r0.axis_x);
const plot::Rect axes = r0.plot->layout().axes;
const double axes_w = double(axes.w);
if (s == nullptr || s->size() == 0 || !(axes_w > 0.0)) {
std::fprintf(stderr, "[zoom-probe] %-8s axis=[%.6f..%.6f] no trace\n", what, double(axis.min),
double(axis.max));
std::fflush(stderr);
return;
}
const plot::u64 n = s->size();
plot::u64 i0 = 0, i1 = 0;
plot::u64 first = 0;
plot::u64 last = n;
if (s->neighbours(axis.min, i0, i1)) first = i0 > 0 ? i0 - 1 : 0;
if (s->neighbours(axis.max, i0, i1)) last = std::min(n, i1 + 1);
if (last <= first) {
std::fprintf(stderr, "[zoom-probe] %-8s axis=[%.6f..%.6f] empty window [%llu..%llu)\n", what,
double(axis.min), double(axis.max), (unsigned long long)first,
(unsigned long long)last);
std::fflush(stderr);
return;
}
// `dense_threshold_spp` is the library's own default (64): above it the renderer takes a
// pyramid level, below it raw samples, and which side of the gate a step lands on is part
// of what this line reports.
const double spp = double(last - first) / axes_w;
const plot::u32 level = spp >= 64.0 ? s->pyramid().level_for(n, spp) : 0u;
std::vector<plot::f32> xy;
// `axis.min` is the origin the renderer names, so every reconstruction below is its own --
// and at the deep end of this sweep that choice is the whole point: relative to the retained
// window instead, the f32's ULP would be a large fraction of these spans.
s->gather(level, first, last, axis.min, xy);
double ink_lo = 0.0, ink_hi = 0.0;
if (xy.size() >= 2) {
ink_lo = axis.min + double(xy[0]);
ink_hi = axis.min + double(xy[xy.size() - 2]);
}
// The renderer's own increasing-suffix filter, counted rather than applied: it drops every
// point that is not strictly older than the one kept after it, which for a *run* of equal x
// keeps the last of the run. The stamps here are f32, so once the sample spacing falls below
// the f32 step at the *gather origin's* magnitude the gathered x has such runs -- and `kept`
// is how many distinct positions the polyline is left with. A `kept` far below `pts` is a
// staircase, and a `kept` of a handful is a trace with nothing to draw. This is the other
// reading the origin change buys: the step is now 2^-24 of the span on screen, so a sweep
// that used to grind `kept` down as it zoomed keeps every point.
std::size_t kept = 0;
double worst_back = 0.0; // the largest backwards step, in data units: non-monotonic gather
{
double next = std::numeric_limits<double>::infinity();
for (std::size_t k = xy.size() / 2; k > 0; --k) {
const double x = double(xy[2 * (k - 1)]) + axis.min;
if (!(x < next)) {
worst_back = std::max(worst_back, x - next);
continue;
}
next = x;
++kept;
}
}
const double span = axis.span() > 0.0 ? axis.span() : 1.0;
std::fprintf(stderr,
"[zoom-probe] %-8s axis=[%.6f..%.6f] span=%.6g n=%llu idx=[%llu..%llu) spp=%.1f "
"level=%u pts=%zu kept=%zu back=%.3g ink=[%.6f..%.6f] x_at_first=%.6f "
"gap_px=%.1f end_px=%.1f rect=[%g,%g,%gx%g]\n",
what, double(axis.min), double(axis.max), axis.span(), (unsigned long long)n,
(unsigned long long)first, (unsigned long long)last, spp, level, xy.size() / 2, kept,
worst_back, ink_lo, ink_hi, s->x_at(first),
(ink_lo - axis.min) / span * axes_w, (axis.max - ink_hi) / span * axes_w,
double(axes.x), double(axes.y), double(axes.w), double(axes.h));
std::fflush(stderr);
}
/// The reserved file's size for a ring of `slots`: the 64-byte header plus one slot per
/// datagram. A constant for a given N, which is why the popover and the tooltip can state
/// it without touching the file.
std::uint64_t capture_file_bytes(std::uint32_t slots) {
return 64 + std::uint64_t(slots) * kSlotBytes;
}
/// `$HOME` -> `~`, purely so the popover row and the tooltip stay readable.
std::string abbrev_home(const std::string& path) {
const std::string home = g_get_home_dir();
if (!home.empty() && path.rfind(home, 0) == 0) return "~" + path.substr(home.size());
return path;
}
/// The toggle's tooltip: what it does, where the ring lands, what it costs.
void update_capture_tooltip(App& app) {
if (app.capture_button == nullptr) return;
const std::uint32_t slots = app.acq != nullptr && app.acq->capture().slot_count() > 0
? app.acq->capture().slot_count()
: 1000;
char tip[512];
std::snprintf(tip, sizeof(tip),
"Store the last %u raw datagrams on disk for forensic analysis\n"
"%s (%.1f MiB reserved) -- off unless toggled, and never in the way of a packet",
slots, abbrev_home(app.acq->capture().path()).c_str(),
double(capture_file_bytes(slots)) / 1048576.0);
gtk_widget_set_tooltip_text(app.capture_button, tip);
}
/// The capture row's three faces. The path is abbreviated with `~` for `$HOME` -- the
/// row's job is to be recognisable, not to be copied -- and the file's size is stated
/// in slots and MiB, because the reserve is the one cost of switching the feature on.
void update_capture_row(App& app) {
if (app.popover_label[12] == nullptr) return;
const PacketCapture& cap = app.acq->capture();
char err[128];
cap.error(err, sizeof(err));
std::string text = "off";
if (err[0] != '\0') {
text = std::string("failed: ") + err;
} else if (cap.enabled()) {
char row[512];
std::snprintf(row, sizeof(row), "on · %llu/%u slots · %.1f MiB · %s",
static_cast<unsigned long long>(cap.stored()), cap.slot_count(),
double(capture_file_bytes(cap.slot_count())) / 1048576.0,
abbrev_home(cap.path()).c_str());
text = row;
} else if (app.capture_want_ui) {
text = "opening...";
}
gtk_label_set_text(GTK_LABEL(app.popover_label[12]), text.c_str());
}
/// Loads an event's preview into the pane's picture.
///
/// Decoded *here*, synchronously, rather than through `gtk_picture_set_filename()`: that path goes
/// through the image loader's sandboxed, asynchronous decoder, which on this machine fails silently
/// and leaves an allocated but empty picture -- a picture the probe sees a paint-able on and a user
/// sees nothing in. `gdk_texture_new_from_filename()` decodes the same file on this thread.
/// One event file's columns and stamps, in memory. Both group layouts need two or more of them at
/// once, and the ordinary loader cannot serve that: it *rebuilds* the model's buffers, so a second
/// load takes the first with it.
/// Decoded event previews, keyed by file. The map belongs to the list widget, so it is released with
/// it, and every texture in it is a reference this file owns.
// --- the frame tick ---------------------------------------------------------
gboolean tick(gpointer user) {
App* app = static_cast<App*>(user);
if (app->window == nullptr) return G_SOURCE_REMOVE;
// Before the reading, and not after it: the pills are `AdwViewSwitcher`'s own widgets and exist
// only once the switcher is mapped, so this is the first frame that can name them -- and their
// page titles are deliberately empty, so a frame that read before this ran would read pills with
// no text and no name either.
name_side_tabs(*app);
note_panel_controls(*app);
ScopeModel& model = *app->model;
const gint64 tick_start_us = g_get_monotonic_time();
// The paint probe's clock, claimed on the first tick that finds one: a widget has no
// frame clock until it is realized, and the area is realized on its first draw.
if (app->dbg_clock == nullptr && app->area_widget != nullptr) {
if (GdkFrameClock* fc = gtk_widget_get_frame_clock(app->area_widget)) {
app->dbg_clock = fc;
g_signal_connect(fc, "before-paint", G_CALLBACK(on_before_paint), app);
g_signal_connect(fc, "after-paint", G_CALLBACK(on_after_paint), app);
}
}
// The trace look, stated on the first tick that finds a renderer -- there is none until
// the area is realized, and this is the same "claim it when it exists" shape as the frame
// clock above. `intensity_grading` is off in the library by default (it is a change of
// look, not a correction, and other hosts must not be restyled by an upgrade); an
// oscilloscope is exactly the host it is for, so udpscope turns it on. A one-shot rather
// than a per-frame write because `Style` is plain data and nothing else touches it.
if (!app->style_stated && app->area != nullptr) {
if (plot::Renderer* r = app->area->renderer()) {
// `UDPSCOPE_NO_PHOSPHOR` puts it back to the library's default, which is the only way
// to read `frame cost:` and `frame path:` against the ungraded path -- a frame mean
// cannot attribute its own cost, so the A/B is the measurement.
r->style().intensity_grading = g_getenv("UDPSCOPE_NO_PHOSPHOR") == nullptr;
app->style_stated = true;
}
}
if (app->relayout_pending) {
app->relayout_pending = false;
relayout_areas(*app);
}
// Header geometry, once a second: the window control must be a fixed, small
// widget beside the title whatever the bar's width is (it used to expand through
// the title's centre box, i.e. window_button_w tracked the window width).
if (app->window_button != nullptr) {
static gint64 next_header_log = 0;
const gint64 now = g_get_monotonic_time();
if (now >= next_header_log) {
next_header_log = now + G_TIME_SPAN_SECOND;
app->dbg_win_btn_w = gtk_widget_get_width(app->window_button);
app->dbg_bar_w = gtk_widget_get_width(app->window);
std::fprintf(stderr, "[udpscope] header: window_button_w=%d bar_w=%d\n",
app->dbg_win_btn_w, app->dbg_bar_w);
std::fflush(stderr);
}
}
// Captured every frame so the dump has the last live value: see `dbg_measures_pane_w`.
if (app->measures_list != nullptr) {
app->dbg_measures_pane_w = gtk_widget_get_width(app->measures_list);
app->dbg_measures_pane_h = gtk_widget_get_height(app->measures_list);
}
// A few milliseconds of scanning per frame: enough that a full buffer finishes in a
// handful of seconds, little enough that neither the producer (which waits on the mutex
// for one chunk) nor the frame budget notices.
if (app->search.state() == BufferSearch::State::Running) {
const gint64 budget_end_us = g_get_monotonic_time() + 5000;
while (app->search.pump(*app->model) && g_get_monotonic_time() < budget_end_us) {
}
update_search_panel(*app);
}
if (model.generation() != app->signal_generation) {
// A CONFIG re-describes the stream and drops the buffer, so nothing measured before it
// belongs in the same series as what comes after. The search shares the same premise:
// the ring it was scanning no longer exists.
reset_measure_stats(*app);
app->search.clear();
rebuild_signal_panels(*app);
}
// A gesture moves the *plot's* cursor; adopting it here is what makes the two
// vertical markers one time across every row, and what the pane then reads.
app->rows->update_time_labels();
// Which row's controller holds a cursor: the only row whose plots are worth reading back
// (`cursor(id).x` is the free pointer position while a drag runs and the snapped value
// otherwise, so reading outside a gesture reads our own echo).
int cursor_drag_row = -1;
if (app->area != nullptr) {
for (int i = 0; i < app->rows->row_count(); ++i) {
plot::input::Controller* c = app->area->row_controller(static_cast<plot::u32>(i));
if (c != nullptr && c->grabbed_cursor() != plot::kInvalid) {
cursor_drag_row = i;
break;
}
}
}
app->rows->sync_cursors(cursor_drag_row);
update_cursors_pane(*app);
update_measures(*app, /*force=*/false, /*accumulate=*/true);
// Every frame, whether or not a scan is running: the marks are in stream seconds and the axis
// is newest-relative, so a live window that has rolled since the last frame needs them moved.
place_search_marks(*app);
// The first CONFIG names the signals, which is what the persisted layout is
// written against: apply it once, here, and never again (a later CONFIG only
// renames or renumbers; the user's layout survives those untouched).
if (!app->layout_loaded && !model.signals().empty()) {
app->layout_loaded = true;
LayoutState st;
app->config_name = active_configuration(app->source_host, app->source_port);
const std::string path =
configuration_path(app->source_host, app->source_port, app->config_name);
if (load_layout(path, st)) {
apply_layout(*app, st);
app->config_comment = st.comment;
std::fprintf(stderr, "[udpscope] layout loaded from %s\n", path.c_str());
std::fflush(stderr);
}
}
// ... and *after* it, which is the point: the block above ends in `apply_time_window()` with the
// window the session file holds, so a restore done before it would be overwritten by the saved
// value. The generation is the other half of the wait -- the stream's first CONFIG, which is what
// gives the stored trigger channel a list to be found in.
// The wait is for the stream's own channel list, and the question it is really asking is whether the
// trigger's channel *name* can be resolved again: a file view that renamed its channels (`square (A)`)
// has to be gone before the name means the signal it meant. Asking that directly, rather than watching
// `generation()`, is what makes going live immediate in the common case -- a single event keeps the
// stream's own channel names, so its channel list is already the right one, and the generation of a
// restarted client whose CONFIG is identical is *deliberately* unchanged (`same_stream_signals()`), so
// the restore used to wait for something that was never going to happen. What it took to shake loose
// was the trigger toggle, which is a structural change and moved the generation -- the "toggle it a few
// times" this fixes.
if (app->live_restore_pending && !app->replaying && !model.channels().empty()) {
bool resolvable = app->live_state.trigger_channel.empty();
for (const ChannelInfo& c : model.channels()) {
if (c.name == app->live_state.trigger_channel) resolvable = true;
}
if (resolvable) {
app->live_restore_pending = false;
restore_live_state(*app);
}
}
// What the display shows, decided once for the whole frame: the producer can open or
// close a capture between any two reads of the model, and the bind, the epoch, the pin
// and the status line all have to describe the same picture.
const DisplayMode display = model.display_mode();
app->dbg_display = display; // the paint probe names the state rather than re-reading it
const double cap_edge = model.capture_edge();
const bool new_capture =
display == DisplayMode::Capturing && (!app->capturing || cap_edge != app->capture_edge);
app->capturing = display == DisplayMode::Capturing;
if (new_capture) app->capture_edge = cap_edge;
{
// Serialise against the acquisition thread's CONFIG handling and against the
// UI's own structural changes: the bind takes shared handles into the live
// store, and acquire() reads it.
std::lock_guard<std::mutex> lock(model.mutex());
app->rows->bind(model, display);
app->rows->acquire(model);
if (app->comparing && app->rows_b != nullptr) {
app->rows_b->bind(model, display);
app->rows_b->acquire(model);
}
// What x = 0 means, written *after* the bind and the acquire on purpose: the live answer
// is minus the newest sample the traces can now report, and asking before they were bound
// would put the newest data a frame's production to the right of the axis -- a third of
// the screen at 1 MS/s over a 50 ms window. The epoch is only read on the consumer side
// (it offsets the live channels' x), so nothing between here and the next frame's bind
// depends on it having been set earlier. It is written under the lock because a rebuild
// can replace the store it is pushed into.
//
// Three cases, one rule -- `display_x = stream_t + epoch`:
// * streaming, the newest stored sample sits at x = 0, so the axis is a fixed
// [-window, 0] and the data flows leftwards through it. A scope's time base is "how
// long ago", and the stream's own clock is an uptime nobody reads off an axis.
// * mid-capture, the coming event's edge sits at x = 0 exactly as the published
// snapshot's will, so the pinned [-pre, +post] means the same thing in both and the
// picture does not jump at the latch.
// * with a *snapshot* on screen (a held event, a paused view) the live channels are
// bound to nothing and the snapshot carries its own `x_offset`, so the epoch is 0 and
// `ScopeModel::stream_at_x0()` is what crosses between the clocks.
double want_epoch = 0.0;
if (!app->waveform_name.empty()) {
// A file *is* an event: an export's stamps are already trigger-relative, so the trigger instant
// is x = 0 and the epoch must not move it. The live rule below ("the newest sample sits at 0")
// would put the trigger at the right edge and push the whole pre-trigger half off the screen --
// which is what "I only see 0 to 0.2 of a -0.8 to 0.2 recording" was.
want_epoch = 0.0;
} else if (display == DisplayMode::Capturing) {
want_epoch = -cap_edge;
} else if (model.display_snapshot() == nullptr) {
// `live_edge()` reports in the epoch currently in force, so subtracting it is minus the
// raw stamp: the fixed point is reached in one frame rather than converged on.
double newest = 0.0;
want_epoch = app->rows->live_edge(newest) ? model.display_epoch() - newest
: model.display_epoch();
}
if (model.display_epoch() != want_epoch) model.set_display_epoch(want_epoch);
// The band last, after the epoch it is expressed in: it reads `model.holes()`, which the
// acquisition thread mutates, so it belongs under this lock and not after it.
app->rows->set_invalid_spans(model, display);
}
// Outside the lock: it reads and writes the two plots' own windows, not the model.
sync_compare_axes(*app);
sync_events_divider(*app);
// A file on screen is an event, and gets what a held capture gets: the axis pinned to the event's own
// `[-pre, +post]` and the trigger instant marked at x = 0. That *is* the constraint -- the time base is
// the event's, so no gesture can take the view outside it -- and the window control reads the event's
// length. The extent comes from the ring itself, so nothing has to remember it: with the epoch at 0
// the stamps are the display x.
if (!app->waveform_name.empty() && !app->capturing && app->rows != nullptr) {
double lo = 0.0;
double hi = 0.0;
{
const auto& t = app->model->live().time();
const std::uint64_t n = t.size();
if (n > 0) {
lo = t[0];
hi = t[n - 1];
}
}
if (hi > lo) {
// State the pin **once**, then only its numbers. `pin_event_window()` writes the group's range as
// well as the pin, and doing that every frame undid the user's box zoom before the clamp below
// could adopt it -- the same defect the wheel had in single trigger mode, where re-stating the pin
// made it a no-op. The held-event branch above has this shape for this reason: `set_data_pin()`
// for the numbers, `pin_event_window()` when the view first becomes an event. A re-open into the
// same row set with a different extent *is* a new event and re-states it.
const auto pin_view = [lo, hi, app](RowSet& rows) {
// `set_data_pin()` takes the **range**, `pinned_window_is()`/`pin_event_window()` the
// pre/post split: passing the split to the former wrote the pin as `[lo, -lo]`-ish, which
// on a recording whose stamps start *before* zero is a window that no longer holds the
// data. The next frame's `pinned_window_is()` then read false, re-stated the pin, and the
// view alternated between the file's span and the corrupted one every frame -- a flicker
// with no interaction surviving it. A file whose first stamp was positive hid this: the
// corrupted pin was wider than the data and the clamp never fired.
if (rows.pinned_window_is(-lo, hi)) {
rows.set_data_pin(lo, hi);
} else {
rows.pin_event_window(-lo, hi);
++app->dbg_file_repins;
}
rows.set_trigger_marker(0.0, true);
};
pin_view(*app->rows);
if (app->rows_b != nullptr) pin_view(*app->rows_b);
sync_window_display(*app, hi - lo);
}
}
// The comparison's plot needs the same re-point the primary gets from `relayout_areas()`: a `RowSet`
// rebuilds its *figure* when a row's trace set changes, and the area has to be handed the new one.
// Only when it *did* change, though -- `set_figure()` creates **a controller per row**, so handing it
// the same figure every frame threw away the second plot's interaction state sixty times a second:
// its box-zoom and its crosshair never took, while the primary's (re-pointed only on a dirty row) did.
if (app->comparing && app->area_b != nullptr && app->rows_b != nullptr) {
plot::Figure* want = &app->rows_b->figure();
if (app->area_b_figure != want) {
app->area_b->set_figure(want);
app->area_b_figure = want;
}
}
// The gesture probe's verdicts, one frame after the ask and after this frame's clamp: a
// `g_timeout` was the first version and it drifted past the paused/resumed boundary, so the
// "zoom in" verdict read the *live* window and called a working fix broken.
if (app->dbg_gesture_verdict != 0 && app->rows->row_count() > 0) {
const int was_verdict = app->dbg_gesture_verdict;
{
const RowSet::Row& r0 = app->rows->row(0);
const plot::Range after = app->rows->view_range();
if (app->dbg_gesture_verdict == 1) {
const bool moved = std::abs(after.min - app->dbg_pan_asked_lo) < 1e-9 &&
std::abs(after.max - app->dbg_pan_asked_hi) < 1e-9;
std::fprintf(stderr, "[pan-probe] after=[%.6f..%.6f] held=%d asked=[%.6f..%.6f]\n",
after.min, after.max, moved ? 0 : 1, app->dbg_pan_asked_lo,
app->dbg_pan_asked_hi);
// The next gesture: a zoom-out ten times the current span, anchored where a wheel zoom
// about the right edge would put it.
const double span = after.span();
if (span > 0.0) {
const plot::Range zoomed{after.max - 10.0 * span, after.max};
app->dbg_pan_asked_lo = zoomed.min;
app->dbg_pan_asked_hi = zoomed.max;
const_cast<RowSet::Row&>(r0).plot->set_x_range(r0.axis_x, zoomed);
// Judged on the next frame's tick: the member's range is only pushed by the layout.
app->dbg_gesture_verdict = 2;
}
} else if (app->dbg_gesture_verdict == 2) {
const double window = app->rows->time_window();
std::fprintf(stderr,
"[pan-probe] zoom asked=[%.6f..%.6f] after=[%.6f..%.6f] span=%.6f "
"window=%.6f limited=%d\n",
app->dbg_pan_asked_lo, app->dbg_pan_asked_hi, after.min, after.max,
after.span(), window, after.span() <= window * (1.0 + 1e-9) ? 1 : 0);
// The last gesture: a window a quarter of what is on screen, about its centre. A pinned
// view must allow it -- "not outside the event" is not "not readable".
const double span = after.span();
if (span > 0.0 && r0.plot != nullptr) {
const double centre = 0.5 * (after.min + after.max);
const plot::Range zoomed{centre - 0.125 * span, centre + 0.125 * span};
app->dbg_pan_asked_lo = zoomed.min;
app->dbg_pan_asked_hi = zoomed.max;
const_cast<RowSet::Row&>(r0).plot->set_x_range(r0.axis_x, zoomed);
app->dbg_gesture_verdict = 3;
}
} else if (app->dbg_gesture_verdict == 3) {
// The zoom-in ask was a quarter of the span: allowed means the view actually narrowed.
const double asked = app->dbg_pan_asked_hi - app->dbg_pan_asked_lo;
const plot::Range member = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x)
: plot::Range{0.0, 0.0};
std::fprintf(stderr,
"[pan-probe] zoom-in asked=[%.6f..%.6f] group=[%.6f..%.6f] member=[%.6f..%.6f]"
" span=%.6f allowed=%d\n",
app->dbg_pan_asked_lo, app->dbg_pan_asked_hi, after.min, after.max,
member.min, member.max, after.span(),
after.span() <= 2.0 * asked ? 1 : 0);
}
std::fflush(stderr);
// Cleared only if this frame did not hand the probe the *next* gesture: a report that asks
// for the next one must leave it armed for the following frame.
app->dbg_gesture_verdict = app->dbg_gesture_verdict == was_verdict ? 0 : app->dbg_gesture_verdict;
app->dbg_gesture_stage = 0;
}
}
// Nothing to do about the rows' x windows: they are one window, the x link group's.
// A gesture changes the row it landed on, `Figure::layout()` adopts that change into
// the group and pushes it to the others, so the "which row acted" search this replaced
// (and the per-row pan/zoom propagation behind it) has no subject any more.
// While an event is on screen -- held, or still being captured -- the window is pinned
// to [-pre, +post]; clamp_to_data would fight that pin whenever the bound series still
// reports the acquisition buffer's raw coordinates, dragging the view along the ring.
// Clamping is a live-view concern only, and a capture is live data on a pinned axis,
// which is exactly the case it must not touch.
if (app->event_held && !app->capturing) {
// A held event is navigable *within its data*. `pin_event_window()` framed the axis from the
// snapshot's recorded `pre_s`/`post_s` -- the *request* -- and a capture shorter than it (a
// ring that had not reached back a full pre-trigger window, or a trimmed one) left the
// shortfall as an empty margin, exactly as the pause did. The pin is the snapshot's own stamps
// here; the framing's split is what the marker at x = 0 already says.
double lo = 0.0;
double hi = 0.0;
if (app->rows->event_data_window(lo, hi)) app->rows->set_data_pin(lo, hi);
app->rows->hold_pinned_window();
} else if (display == DisplayMode::Paused || app->capturing || app->rows->window_pinned()) {
// Pinned: the event, the frozen pause *or an opened file*, not a navigation surface.
// `clamp_to_data()` is the live-mode half of the same rule -- a live view must stay on the data
// -- and the two are complements, which is why this is one branch rather than two conditions.
//
// A file belongs here and was the case that was missing. It pins its axis every frame (see the
// `waveform_name` block above), but a pinned axis does *not* mean this branch: only
// `hold_pinned_window()` adopts a gesture from the row's own axis, because a member's
// `set_x_range` is deliberately not adopted into a group that is not following. Falling through
// to `clamp_to_data()` therefore left a box zoom in a file view drawing its frame and changing
// nothing -- the gesture's range went onto the member, the group kept the pin, and the picture
// that was drawn came from the group.
app->rows->hold_pinned_window();
} else {
app->rows->clamp_to_data();
}
// The event-zoom probe's step, taken here because the pin and the clamp above are what
// decide the axis the renderer is about to read: a `g_timeout` would report the ask.
if (app->dbg_zoom_steps > 0 && app->rows->row_count() > 0 &&
app->rows->row(0).plot != nullptr) {
char what[24];
// Two sweeps, because there are two ways to zoom the time base and they take different
// paths: the second half drives the *window control* (`apply_time_window`, which unpins
// and re-arms the live follow before the pin is restated), the first half a gesture.
const bool via_window = app->dbg_zoom_steps <= kZoomWindowSteps;
std::snprintf(what, sizeof what, "%s/x%d", via_window ? "win" : "ges",
1 << app->dbg_zoom_step_no);
report_event_zoom(*app, what);
--app->dbg_zoom_steps;
++app->dbg_zoom_step_no;
const RowSet::Row& r0 = app->rows->row(0);
if (app->dbg_zoom_steps == kZoomWindowSteps) {
// Hand the window sweep a full event again, so it starts where the gesture sweep did.
app->dbg_zoom_step_no = 0;
double lo = 0.0, hi = 0.0;
if (app->rows->event_data_window(lo, hi)) apply_time_window(*app, hi - lo);
} else if (via_window) {
apply_time_window(*app, std::max(1.0e-4, app->rows->time_window() * 0.5));
} else {
// Halve the window about its **centre**, so the left edge walks *into* the data. An
// anchor that keeps the left edge where it is keeps it on the series' own first sample,
// where no index window can be short and the margin cannot be empty -- which is how the
// first version of this probe reported `gap_px=0.0` at every zoom on a view that was
// visibly clipped.
const plot::Range now = r0.plot->x_range(r0.axis_x);
const double c = 0.5 * (now.min + now.max);
r0.plot->set_x_range(r0.axis_x, plot::Range{c - 0.25 * now.span(), c + 0.25 * now.span()});
}
}
// Curator: the trigger's arm state can change under us (single mode disarms
// itself), so the button and the status line read the published atomics.
const bool trigger_on = model.trigger_enabled();
if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app->trigger_toggle)) != trigger_on) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->trigger_toggle), trigger_on);
}
const bool armed = model.trigger_armed().load(std::memory_order_relaxed);
gtk_button_set_label(GTK_BUTTON(app->arm_button), armed ? "Disarm" : "Arm");
if (new_capture) {
// The edge has been accepted, which means the pre window was already full, so the
// whole event window can be framed now and the post side filled in view.
//
// The split is the *requested* one -- the same `post_span_s()` the producer will cut
// with -- because there is no snapshot to read it off yet, and the marker has to sit
// where that cut will put it rather than where a different rounding would.
double span = 0.0;
double post = 0.0;
{
std::lock_guard<std::mutex> lock(model.mutex());
span = std::max(1.0e-3, model.event_span());
post = model.trigger().post_span_s(span);
}
app->event_held = false;
app->rows->pin_event_window(span - post, post);
app->rows->set_trigger_marker(0.0, true);
sync_window_display(*app, span);
// The user saw the event at its edge, so that is when the counter starts.
app->timed_edge = cap_edge;
app->last_latch_us = g_get_monotonic_time();
}
const std::uint64_t latches = model.trigger_latches().load(std::memory_order_relaxed);
if (latches != app->seen_latches) {
app->seen_latches = latches;
const double edge = model.trigger_time().load(std::memory_order_relaxed);
// A capture the UI watched fill already started this clock at its edge; only one that
// opened and closed inside a single frame -- never reported pending, so never
// displayed as a capture -- starts it here.
if (edge != app->timed_edge) {
app->timed_edge = edge;
app->last_latch_us = g_get_monotonic_time();
}
}
// Framing the held event is keyed on *which snapshot was published*, not on the latch
// count: a Disarm during a capture publishes what the capture got to without an event
// having fired, and that partial has to be framed and held exactly like one that ran
// to term. The latch count stays the answer to "how many events fired", which is what
// the probes assert on.
const std::uint64_t seq = model.event_seq();
// The frame that *frames* the snapshot is the one that consumes its sequence number,
// not merely the first frame to see it. Two things can bind something else while a new
// snapshot is already published:
//
// - A publication and the *next* edge land in the same frame: the display follows the
// newer of the two and the capture above has framed its own window.
// - `display` is read before the lock the producer publishes under, and
// `trigger_pending` is only lowered at the end of the producer's packet loop -- long
// after `publish_event()`. So a tick can read `Capturing`, block on the mutex while
// the snapshot is published and the flag lowered, and get here bound to a capture
// that no longer exists.
//
// Consuming the number in either case dropped the pin permanently: the axis kept the
// capture's *requested* [-pre, +post] while the snapshot was shorter, and `event_held`
// stayed false, so `clamp_to_data()` was free to drag the frozen view off it. Leaving
// it unconsumed costs nothing -- the pin is simply owed until a frame is bound to the
// snapshot, and by then it is still the newest one.
if (seq != app->seen_event_seq && display == DisplayMode::Held) {
app->seen_event_seq = seq;
app->event_held = true;
// Keep it, at the end of this tick: the record's thumbnail is a picture of the plot, and the plot
// is bound to the event later in this same frame.
app->record_pending = true;
// The window is pinned to exactly what the snapshot captured, read off the
// snapshot itself. Not off `event_span`/`pretrigger_pct`: those say what was
// *asked for*, in stream seconds, while how much the capture actually covers
// depends on the cadence the trigger channel was running at. Pinning to the
// request collapsed the waveform into a sliver at x = 0 and left the rest of
// the axis -- the whole post side included -- blank.
//
// The snapshot is edge-relative (the edge at x = 0) and the epoch is back to 0, so
// the bind at the top of this frame already put the slots and the axis on the same
// coordinates the pin is about to state.
double pre = 0.0;
double post = 0.0;
if (auto snap = model.event()) {
pre = snap->pre_s;
post = snap->post_s;
}
if (!(pre + post > 0.0)) {
// Nothing was captured (no samples in the ring yet): fall back to the
// requested window so the axis is at least readable.
const double span = std::max(1.0e-3, model.event_span());
post = model.trigger().post_span_s(span);
pre = span - post;
}
app->rows->pin_event_window(pre, post);
app->rows->set_trigger_marker(0.0, true); // the edge sits at x = 0
sync_window_display(*app, pre + post);
}
// The trigger instant is marked on every row while its event is the thing on screen --
// being captured or held. x is seconds from the event in both: the display epoch is
// minus the edge while the live ring is on a pinned axis, and the snapshot's own stamps
// are edge-relative, so the edge sits at exactly x = 0 wherever the user has zoomed.
// ... and it is the *only* thing that hides it. A file's own event is on screen for as long as the
// file is, so the file branch above must survive this one: it used to run first and be undone here,
// which is why an opened event had no trigger line.
if (app->waveform_name.empty() && !app->capturing &&
!(trigger_on && model.trigger_time_valid().load(std::memory_order_relaxed))) {
app->rows->set_trigger_marker(0.0, false);
}
{
// A bound slot with no published samples while its channel's ring has plenty
// means the consumer side has not caught up: this frame draws an empty plot, and
// the y auto-fit collapses with it.
int bound = 0;
int empty = 0;
for (int i = 0; i < app->rows->row_count(); ++i) {
RowSet::Row& r = app->rows->row(i);
for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) {
const std::size_t si = static_cast<std::size_t>(sl);
const int ch = r.channel[si];
if (ch < 0) continue;
const ChannelBuffer* b = r.slots[si].bound();
if (b == nullptr || static_cast<std::size_t>(ch) >= model.channel_count()) continue;
++bound;
if (b->size() == 0 && model.live().values(static_cast<std::size_t>(ch)).produced() > 0) {
++empty;
}
}
}
if (bound > 0 && empty == bound) ++app->dbg_blank_frames;
if (bound > 0 && empty < bound) app->dbg_ever_bound = true;
// A rebuild drops every ring's history, so the frames after one draw an empty plot until the
// producer has refilled the window -- which is what "the plot flickers as if there were no
// data" looks like. `dbg_blank_frames` cannot see it: it asks whether the ring holds samples
// the view has not got, and a rebuilt ring holds none either. The generation is the only
// thing that says a rebuild happened, so each change is named here with what asked for it.
const unsigned gen = model.generation();
if (app->dbg_generation_seen && gen != app->dbg_generation_prev) {
++app->dbg_generation_changes;
if (app->dbg_generation_changes <= 40) {
std::fprintf(stderr, "[gen] frame %d generation %u -> %u (bound=%d empty=%d)\n",
app->dbg_axes_frames, app->dbg_generation_prev, gen, bound, empty);
}
}
app->dbg_generation_prev = gen;
app->dbg_generation_seen = true;
// And the renderer's blank: the rings hold samples, yet the frame staged nothing.
if (bound > 0 && empty == 0 && app->dbg_seg_last == 0) ++app->dbg_render_blank_frames;
}
// The gutter the rows need, measured from the frame just laid out, imposed as a
// floor that only grows: without it the axes' left edge follows the y tick
// labels and the x axis twitches by a few pixels as the scale changes.
double gutter = 0.0;
for (int i = 0; i < app->rows->row_count(); ++i) {
const RowSet::Row& r = app->rows->row(i);
if (r.plot) gutter = std::max(gutter, double(r.plot->layout().y_axis_width));
}
app->rows->note_y_gutter(gutter);
app->rows->apply_gutters();
// The editor's buttons are placed from the rects this frame was laid out with, so they
// follow a resize and the gutter settling without being told.
if (app->layout_editing) sync_layout_editor(*app);
// The one thing that has to be decided *per frame* about the sidebar: whether there is room for it
// beside the plot. Collapsed, the split view puts it *over* the plot rather than giving it a share of
// a width that is not there -- so a small window shows fewer pixels of plot, never a panel squeezed
// to nothing. `kSidebarRoom` is the sidebar's 320 plus the width below which the plot stops being
// worth looking at.
if (app->side_view != nullptr && app->window != nullptr) {
// The split view writes `show-sidebar` itself (at startup and when it collapses), so the wish is
// re-asserted here -- but only when they disagree, so a settled frame touches nothing. This is also
// why a hide *gesture* does not stick: the panel is where the button says it is.
const bool want = !app->model->signals().empty() && app->sidebar_wanted;
if (adw_overlay_split_view_get_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(app->side_view)) != want) {
adw_overlay_split_view_set_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(app->side_view), want);
if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app->sidebar_toggle)) != want) {
app->sidebar_syncing = true;
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->sidebar_toggle), want);
app->sidebar_syncing = false;
}
}
const int window_w = gtk_widget_get_width(app->window);
if (window_w > 0) {
adw_overlay_split_view_set_collapsed(ADW_OVERLAY_SPLIT_VIEW(app->side_view),
window_w < kSidebarRoom);
}
}
// The render, queued once a frame -- but not unconditionally. In `Held` and `Paused` the rows
// are bound to an immutable snapshot on a pinned axis and the producer is feeding the live ring,
// not the series on screen, so a frame in which none of the picture's own terms moved is the
// same frame again. Every other mode renders: `Live` and `Capturing` roll new samples under the
// axis, and `Waiting` moves the live epoch, so in all three the picture differs every frame.
//
// The gate is two parts, and the second is what makes the first safe to keep coarse:
// * a signature of what a frozen view *can* be re-framed by -- a new snapshot, a pan or zoom
// (the group's range, which is where the gestures and the pin both write), a row added or
// removed -- which renders the moment it changes;
// * `kStaticRenderEvery`, a floor that renders anyway every few ticks, so anything not in the
// signature is late rather than lost. A gate that can only be opened by a list of state is
// a gate that goes wrong the first time the list is one item short.
// Interaction needs neither: `PlotArea` queues its own render from the pointer, scroll and drag
// handlers, so a crosshair and a cursor drag are immediate whatever this decides.
//
// The layout editor is excluded outright: its buttons are placed from the rects the last frame
// was laid out with (`sync_layout_editor()` above), so it is the one state where a skipped frame
// is a stale *widget* and not just a stale picture.
if (app->area_widget != nullptr) {
const bool frozen = (display == DisplayMode::Held || display == DisplayMode::Paused) &&
!app->layout_editing;
const plot::Range view = app->rows->view_range();
const std::uint64_t seq = model.event_seq();
const int rows = app->rows->row_count();
const bool same = frozen && seq == app->render_event_seq && view.min == app->render_x_min &&
view.max == app->render_x_max && rows == app->render_rows;
// A change opens the gate for `kSettleRenders` frames, not one: the figure's left gutter is a
// monotone floor that converges over two layout passes (see the `layout over N frames`
// reading), and a layout pass happens per *render* -- so a single frame after a re-frame would
// leave the gutter one pass short until the floor below fired, 64 ms later.
if (!same) app->render_open = kSettleRenders;
if (app->render_open > 0 || app->render_skipped + 1 >= kStaticRenderEvery) {
if (app->render_open > 0) --app->render_open;
app->render_skipped = 0;
gtk_gl_area_queue_render(GTK_GL_AREA(app->area_widget));
} else {
++app->render_skipped;
++app->dbg_render_skips;
}
app->render_event_seq = seq;
app->render_x_min = view.min;
app->render_x_max = view.max;
app->render_rows = rows;
}
// What the side column *demands*, not what it got. A GTK box's minimum width is its
// children's, a `GtkLabel`'s minimum is its text, and a minimum larger than the screen is
// honoured by growing the toplevel -- which hands the plot an allocation wider than the window
// and pushes most of the trace off the right-hand edge. `axes.w` sees the consequence a frame
// later and cannot name the cause, so the demand is read here, from the panes whose contents
// the frame tick rewrites.
{
const auto min_w = [](GtkWidget* w) {
if (w == nullptr || !GTK_IS_WIDGET(w)) return 0;
int min = 0;
int nat = 0;
gtk_widget_measure(w, GTK_ORIENTATION_HORIZONTAL, -1, &min, &nat, nullptr, nullptr);
return min;
};
const int side = min_w(app->side_box);
const int meas = min_w(app->measures_list);
const int curs = min_w(app->cursors_body);
const int worst = std::max(side, std::max(meas, curs));
if (worst > app->dbg_min_w_worst) {
const int was = app->dbg_min_w_worst;
app->dbg_min_w_worst = worst;
if (was > 0 && worst - was > 32 && app->dbg_min_w_reports < 40) {
++app->dbg_min_w_reports;
std::fprintf(stderr,
"[width-probe] frame %d min width grew %d -> %d (side=%d measures=%d "
"cursors=%d)\n",
app->dbg_axes_frames, was, worst, side, meas, curs);
}
}
}
if (plot::Plot* p0 = app->rows->row(0).plot;
p0 != nullptr && p0->viewport().w > 100.0f) { // steady state only
const int ax = static_cast<int>(p0->layout().axes.x);
const int gw = static_cast<int>(p0->layout().y_axis_width);
app->dbg_axes_x_min = std::min(app->dbg_axes_x_min, ax);
app->dbg_axes_x_max = std::max(app->dbg_axes_x_max, ax);
app->dbg_gutter_min = std::min(app->dbg_gutter_min, gw);
app->dbg_gutter_max = std::max(app->dbg_gutter_max, gw);
const int aw = static_cast<int>(p0->layout().axes.w);
app->dbg_axes_w_min = std::min(app->dbg_axes_w_min, aw);
app->dbg_axes_w_max = std::max(app->dbg_axes_w_max, aw);
// A min..max range alone cannot tell the twitch bug (a width that changes every
// frame) from one resize that happened once and stayed, which look identical in a
// summary and need opposite responses. Count the changes and keep the last one's
// frame, and whether the side panel was up for it -- hiding that column is the one
// legitimate reason the plot's width moves by itself.
if (app->dbg_axes_frames > 0 && aw != app->dbg_axes_w_prev) {
++app->dbg_axes_w_changes;
app->dbg_axes_w_change_frame = app->dbg_axes_frames;
app->dbg_axes_w_change_side = gtk_widget_get_visible(app->side_box) ? 1 : 0;
// Each change, not just the last: a summary says the plot's width moved and cannot say
// *what* moved it, and a width that moves by itself is the "the plot flickers" report's
// first candidate. Named here beside the widths that can cause it -- the side column and
// the two panes whose contents the frame tick rewrites -- so the culprit is read rather
// than guessed. Capped, because the twitch bug would otherwise fill the log.
if (app->dbg_axes_w_changes <= 40) {
int side_w = 0;
int meas_w = 0;
int curs_w = 0;
if (app->side_box != nullptr) side_w = gtk_widget_get_width(app->side_box);
if (app->measures_list != nullptr) meas_w = gtk_widget_get_width(app->measures_list);
if (app->cursors_body != nullptr) curs_w = gtk_widget_get_width(app->cursors_body);
std::fprintf(stderr,
"[width-probe] frame %d axes.w %d -> %d viewport.w %d side=%dx(vis=%d) "
"measures_w=%d cursors_w=%d\n",
app->dbg_axes_frames, app->dbg_axes_w_prev, aw,
static_cast<int>(p0->viewport().w), side_w,
app->dbg_axes_w_change_side, meas_w, curs_w);
// A step of this size is not the gutter settling, so name what demanded it.
if (std::abs(aw - app->dbg_axes_w_prev) > 100) {
report_wide_widgets(app->window, 900, 1);
}
}
}
app->dbg_axes_w_prev = aw;
app->dbg_vp_w_min = std::min(app->dbg_vp_w_min, static_cast<int>(p0->viewport().w));
app->dbg_vp_w_max = std::max(app->dbg_vp_w_max, static_cast<int>(p0->viewport().w));
const int nt = static_cast<int>(p0->x_axis(0).ticks.size());
app->dbg_xtick_min = std::min(app->dbg_xtick_min, nt);
app->dbg_xtick_max = std::max(app->dbg_xtick_max, nt);
if (app->area != nullptr) {
const float sc = app->area->device_scale();
app->dbg_scale_min = std::min(app->dbg_scale_min, sc);
app->dbg_scale_max = std::max(app->dbg_scale_max, sc);
}
// The rows are members of one x link group, so they hold one window; a row that
// disagrees by more than a pixel of data means the group did not reach it.
const plot::Range x0 = p0->x_range(app->rows->row(0).axis_x);
const double tol = std::max(1.0e-12, x0.span() / std::max(1.0, double(aw)));
for (int i = 1; i < app->rows->row_count(); ++i) {
const RowSet::Row& r = app->rows->row(i);
if (r.plot == nullptr) continue;
const plot::Range xi = r.plot->x_range(r.axis_x);
if (std::fabs(xi.min - x0.min) > tol || std::fabs(xi.max - x0.max) > tol) {
++app->dbg_x_desync;
break;
}
}
++app->dbg_axes_frames;
}
// Once a second: how many samples each active channel has *stored*, and the rate that
// implies. Channels are never decimated against each other (`stride=1`), so with
// `decim=1` a stored rate far below the declared one is the datasource's own wire rate;
// with `decim=N` it is the wire rate over N, the resampling the window asked for. This
// line is the receipt for both.
static gint64 next_channels = 0;
const gint64 now_c = g_get_monotonic_time();
// Per-channel rate computed only on the measuring tick; the detail tooltip on the label
// now carries the full status string instead of a per-channel breakdown.
if (app->last_stored.size() != model.channel_count()) {
// Seed new entries with the current count: a fresh entry must not be counted
// as a delta from zero.
app->last_stored.assign(model.channel_count(), 0);
for (std::size_t c = 0; c < model.channel_count() && c < app->last_stored.size(); ++c) {
app->last_stored[c] = model.live().values(c).produced();
}
}
if (now_c >= next_channels) {
const double dt_s = std::min(10.0, double(now_c - app->last_channels_meas) / 1.0e6);
next_channels = now_c + G_TIME_SPAN_SECOND;
app->last_channels_meas = now_c;
// The cache's own question, on the same cadence as the channel dump: a directory read per second,
// and the button's availability re-decided only when the answer changed.
if (app->event_store != nullptr) {
const bool cached = !app->event_store->list().empty();
if (cached != app->events_cached) {
app->events_cached = cached;
sync_events_availability(*app);
}
}
// Once a second, alongside the rate measurement: is the stream arriving at all? Nothing is
// owed in file mode -- there is no socket and no source -- so a file is never starved.
if (app->waveform_name.empty()) note_starvation(*app);
std::lock_guard<std::mutex> lock(model.mutex());
app->stored_rate.assign(model.channel_count(), 0.0);
app->declared_rate.assign(model.channel_count(), 0.0);
for (std::size_t c = 0; c < model.channel_count(); ++c) {
const ChannelInfo& info = model.channels()[c];
const u64 stored = model.live().values(c).produced();
// `produced()` restarts at 0 whenever the buffer is rebuilt (a CONFIG, an
// enable/disable, a calibration change), so the delta can run backwards. As
// unsigned that wrapped to ~2^64 and the line reported a rate near 1.8e13 MS/s
// -- a number with no relation to the stream, which is actively misleading when
// this line is the evidence for a timing complaint. A rebuild has no measurable
// rate, so report none.
const u64 prev = c < app->last_stored.size()
? app->last_stored[static_cast<std::size_t>(c)]
: stored;
const double per_s =
stored >= prev && dt_s > 0.0 ? double(stored - prev) / dt_s : 0.0;
if (c < app->last_stored.size()) app->last_stored[static_cast<std::size_t>(c)] = stored;
const double declared = info.dt > 0.0 ? 1.0 / info.dt : 0.0;
app->stored_rate[c] = per_s;
app->declared_rate[c] = declared;
const ChannelBuffer& buf = model.live().channel(c);
const plot::Range y = buf.size() > 0 ? buf.y_bounds() : plot::Range{0.0, 0.0};
std::fprintf(stderr,
"[udpscope] ch %zu '%s' stored=%llu (%.4g MS/s, declared %.4g S/s) "
"elements=%llu stride=%llu decim=%llu published=%llu y=[%.6g, %.6g]\n",
c, info.name.c_str(), static_cast<unsigned long long>(stored), per_s / 1e6,
declared, static_cast<unsigned long long>(info.elements),
static_cast<unsigned long long>(model.partition().stride[c]),
static_cast<unsigned long long>(model.partition().time_stride),
static_cast<unsigned long long>(buf.size()), double(y.min), double(y.max));
}
std::fflush(stderr);
}
// Status line. The model's lists and partition are rebuilt on the producer
// thread, so every read of them takes the model mutex -- narrowly: GTK calls and
// model setters below must stay outside it (a setter takes the same lock, and a
// button toggle emits a handler that calls one).
std::size_t signal_count = 0;
std::size_t channel_count = 0;
double span_s = 0.0;
std::uint64_t time_stride = 1;
double trigger_level = 0.0;
double trigger_value = 0.0;
bool have_trigger_value = false;
std::string trigger_name;
{
std::lock_guard<std::mutex> lock(model.mutex());
signal_count = model.signals().size();
channel_count = model.channel_count();
span_s = model.partition().span_s;
time_stride = model.partition().time_stride;
const int chan = model.trigger().channel;
trigger_level = model.trigger().condition.level;
if (chan >= 0 && chan < static_cast<int>(model.channel_count())) {
trigger_name = model.channels()[static_cast<std::size_t>(chan)].name;
}
for (std::size_t c = 0; c < model.channel_count(); ++c) {
if (static_cast<int>(c) != chan) continue;
const ChannelBuffer& buf = model.live().channel(c);
if (buf.size() > 0) {
f32 v = 0.0f;
buf.raw(buf.size() - 1, buf.size(), &v);
trigger_value = double(v);
have_trigger_value = true;
}
break;
}
}
StreamStats& st = model.stats();
const char* mode = "live";
if (!st.have_config.load()) {
// Ahead of every other branch for the reason the glance word is: with no CONFIG there is no
// signal list, so "live" (or "armed", which cannot happen without one) would name a pipeline
// that has not been described yet.
mode = "trying to connect: no configuration, and so no signal list, from the stream yet";
} else if (trigger_on) {
if (app->capturing) {
mode = "trigger: CAPTURING (post-trigger window filling)";
} else if (app->event_held) {
mode = "trigger: EVENT HELD (Arm to re-arm, or switch the trigger off to stream)";
} else if (armed && have_trigger_value) {
static char waiting[192];
std::snprintf(waiting, sizeof(waiting),
"trigger: armed on '%s' (now %.4g, level %.4g), waiting for an event",
trigger_name.c_str(), trigger_value, trigger_level);
mode = waiting;
} else if (armed) {
mode = "trigger: armed, waiting for an event";
} else {
mode = "trigger: disarmed (view frozen)";
}
} else if (app->acq->paused()) {
mode = "paused";
}
// The badge: lit and blinking while the pipeline is actually running end to end.
// Paused and disarmed both stop it, and so does a stream that has not arrived -- a
// green light over a dead socket is the one reading that must not happen. The blink is
// a 1 Hz square wave off the monotonic clock rather than its own timer, so it cannot
// drift away from the frame it is drawn on.
// A source that answered CONNECT but has sent no CONFIG is *connecting*, not acquiring:
// there is no signal list, so nothing can be enabled and no DATA frame can arrive. It gets
// the steady yellow rather than the blinking green, because the green says "the pipeline is
// running end to end" and here it has not started.
const bool paused = app->acq->paused();
const bool connecting = st.connected.load() && !st.have_config.load();
const bool active =
st.connected.load() && !connecting && !paused && (!trigger_on || armed);
const bool lit = ((g_get_monotonic_time() / (G_TIME_SPAN_SECOND / 2)) & 1) == 0;
const BadgePhase phase = connecting ? BadgePhase::Connecting
: !active ? BadgePhase::Idle
: (lit ? BadgePhase::Lit : BadgePhase::Dim);
const int reason = active ? 0
: !st.connected.load() ? 1
: connecting ? 4
: paused ? 2
: 3;
static const char* const kWhy[] = {"acquiring", "waiting for the stream", "paused",
"trigger disarmed",
"trying to connect: no configuration from the stream yet"};
// Keyed on the reason as well as the phase: three of the reasons share the Idle phase,
// so a pause that becomes a disarm has to reach the tooltip.
++app->dbg_badge_frames[static_cast<int>(phase)];
const int badge_key = static_cast<int>(phase) * 8 + reason;
if (app->status_badge != nullptr && badge_key != app->badge_phase) {
app->badge_phase = badge_key;
gtk_label_set_markup(GTK_LABEL(app->status_badge), badge_markup(phase));
gtk_widget_set_tooltip_text(app->status_badge, kWhy[reason]);
}
int bound = 0;
std::size_t samples = 0;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const ChannelBuffer* b = app->rows->row(0).slots[static_cast<std::size_t>(s)].bound();
if (b != nullptr && b->size() > 0) {
++bound;
samples += static_cast<std::size_t>(b->size());
}
}
// The rate the buffer is actually storing at. One figure when every channel agrees and
// a range when they do not: a per-channel list would make the field's width depend on
// the stream, and the breakdown is on the label's tooltip instead.
std::string rate;
if (!app->stored_rate.empty()) {
double lo = app->stored_rate.front();
double hi = lo;
for (const double r : app->stored_rate) {
lo = std::min(lo, r);
hi = std::max(hi, r);
}
// Within a percent is the same rate measured over a one-second window, not two rates.
rate = hi - lo <= 0.01 * hi ? format_rate(hi) : format_rate(lo) + ".." + format_rate(hi);
}
// Derive the one-word state for the glance line from the same conditions the mode prose
// branches on, so the two cannot disagree.
//
// `connecting` is first and covers both halves of the handshake -- no socket yet, and a
// socket with no CONFIG behind it -- because in neither is there a signal list, and every
// other word here claims one. The badge is what distinguishes them (grey against yellow):
// the word says what the app is doing, the colour says how far it got. It is no wider than
// `event held`, which is what lets it join a set the plot's layout is measured against.
const char* state_word = "live";
if (!st.have_config.load()) {
state_word = "connecting";
} else if (display == DisplayMode::Capturing) {
state_word = "capturing";
} else if (display == DisplayMode::Held) {
state_word = "event held";
} else if (trigger_on && !armed) {
state_word = "disarmed";
} else if (display == DisplayMode::Waiting) {
state_word = "armed";
} else if (paused) {
state_word = "paused";
}
udpscope::StatusSnapshot snap;
snap.state_detail = mode;
snap.state_word = state_word;
snap.connected = st.connected.load();
snap.have_config = st.have_config.load();
snap.trigger_on = trigger_on;
snap.rate_text = rate;
snap.stored_rate_hz = app->stored_rate.empty() ? 0.0 : app->stored_rate.front();
snap.declared_rate_hz = app->declared_rate.empty() ? 0.0 : app->declared_rate.front();
snap.window_text = format_window(app->rows->time_window());
snap.packets = st.packets.load();
snap.frames = st.frames.load();
snap.gaps = st.gaps.load();
snap.resequenced = st.resequenced.load();
snap.late = st.late.load();
snap.stale = st.stale.load();
snap.holes = st.holes.load();
snap.hole_slots = st.hole_slots.load();
snap.backfilled = st.backfilled.load();
snap.signals = signal_count;
snap.channels = channel_count;
snap.plot1_traces = bound;
snap.plot1_samples = samples;
snap.buffer_mb = double(model.config().buffer_mb);
snap.span_s = span_s;
snap.time_stride = time_stride;
snap.have_event = app->last_latch_us != 0;
snap.since_event_s = snap.have_event
? double(g_get_monotonic_time() - app->last_latch_us) / 1.0e6
: 0.0;
snap.reconnects = static_cast<int>(st.reconnects.load(std::memory_order_relaxed));
// Paused *and* with a recorded start: the button's handler is what stamps it, so a pause
// arriving any other way (there is none today) reports the state without a duration rather
// than counting from the epoch.
snap.paused = paused && app->pause_started_us != 0;
snap.paused_for_s =
snap.paused ? double(g_get_monotonic_time() - app->pause_started_us) / 1.0e6 : 0.0;
const std::string glance = udpscope::format_glance(snap);
app->dbg_glance_last = glance;
app->status_detail = udpscope::format_detail(snap);
if (app->status_detail.size() > app->status_detail_max) {
app->status_detail_max = app->status_detail.size();
}
if (glance.size() > app->glance_max_chars) app->glance_max_chars = glance.size();
// A notice is not the glance. It is prefixed onto the same line, which is exactly where a reader
// skimming the routine text will not see it -- "there is no clear feedback" was that, with a refusal
// ("plot 2 holds four signals already") looking like part of the status. The stock `.warning` class is
// what makes a message look like a message, and it goes when the notice does.
if (!app->notice.empty() && g_get_monotonic_time() < app->notice_until_us) {
gtk_widget_add_css_class(app->status_label, "warning");
const std::string with_notice = app->notice + " | " + glance;
gtk_label_set_text(GTK_LABEL(app->status_label), with_notice.c_str());
} else {
gtk_widget_remove_css_class(app->status_label, "warning");
app->notice.clear();
gtk_label_set_text(GTK_LABEL(app->status_label), glance.c_str());
}
gtk_widget_set_tooltip_text(app->status_label, app->status_detail.c_str());
if (app->status_popover != nullptr &&
gtk_widget_get_visible(app->status_popover) == TRUE) {
update_status_popover(*app, snap);
update_capture_row(*app);
}
if (app->record_pending) {
app->record_pending = false;
record_event(*app);
}
if (app->capture_button != nullptr) {
// Insensitive until connected: the capture's path is keyed on the source.
gtk_widget_set_sensitive(app->capture_button, snap.connected ? TRUE : FALSE);
if (app->capture_want_ui && !app->acq->capture_want()) {
// The producer refused the open (a full disk, a read-only directory) and cleared
// the flag; the button follows the flag rather than lying about the state. Going
// inactive runs the button's own handler, which only ever sets the flag.
app->capture_want_ui = false;
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->capture_button), FALSE);
}
}
const double tick_ms = double(g_get_monotonic_time() - tick_start_us) / 1000.0;
app->dbg_tick_ms_sum += tick_ms;
app->dbg_tick_ms_max = std::max(app->dbg_tick_ms_max, tick_ms);
++app->dbg_tick_n;
return G_SOURCE_CONTINUE;
}
// --- window construction ----------------------------------------------------
/// The display-window control: a button reading the current window, and a popover holding
/// the number, its unit and a decade stepper either side.
///
/// [ 2 s ▾ ] -> [ /10 ] [ 2____ ] [ s ▾ ] [ x10 ]
///
/// A ladder dropdown could only offer the spans someone had thought of, and the useful spans
/// run from microseconds (a fast datasource's few hundred samples) to minutes; a typed number
/// plus a unit covers that without an entry per decade, and the steppers cover the common
/// case of "an order of magnitude wider" without typing at all.
GtkWidget* build_window_control(App& app) {
app.window_label = gtk_label_new("");
gtk_label_set_width_chars(GTK_LABEL(app.window_label), kWindowLabelChars);
gtk_label_set_max_width_chars(GTK_LABEL(app.window_label), kWindowLabelChars);
gtk_label_set_ellipsize(GTK_LABEL(app.window_label), PANGO_ELLIPSIZE_END);
GtkWidget* face = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_box_append(GTK_BOX(face), app.window_label);
gtk_box_append(GTK_BOX(face), gtk_image_new_from_icon_name("pan-down-symbolic"));
app.window_button = gtk_menu_button_new();
gtk_menu_button_set_child(GTK_MENU_BUTTON(app.window_button), face);
gtk_widget_set_tooltip_text(app.window_button,
"the time span on screen, and the trigger's event window");
GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_margin_top(row, 6);
gtk_widget_set_margin_bottom(row, 6);
gtk_widget_set_margin_start(row, 6);
gtk_widget_set_margin_end(row, 6);
GtkWidget* slower = gtk_button_new_with_label("\u00f710");
gtk_widget_set_tooltip_text(slower, "a decade narrower");
g_signal_connect(slower, "clicked", G_CALLBACK(on_window_step), &app);
gtk_box_append(GTK_BOX(row), slower);
app.window_entry = gtk_entry_new();
gtk_entry_set_input_purpose(GTK_ENTRY(app.window_entry), GTK_INPUT_PURPOSE_NUMBER);
gtk_editable_set_width_chars(GTK_EDITABLE(app.window_entry), 8);
// Enter only. `changed` would re-frame the plot on every keystroke, so typing "100" would
// pass through 1 and 10 -- and each of those re-partitions nothing but does move the axis.
g_signal_connect(app.window_entry, "activate", G_CALLBACK(on_window_entry_activate), &app);
gtk_box_append(GTK_BOX(row), app.window_entry);
const char* units[kWindowUnitCount + 1] = {};
for (int i = 0; i < kWindowUnitCount; ++i) units[i] = kWindowUnits[i].name;
app.window_unit_drop = gtk_drop_down_new_from_strings(units);
g_signal_connect(app.window_unit_drop, "notify::selected", G_CALLBACK(on_window_unit_changed),
&app);
gtk_box_append(GTK_BOX(row), app.window_unit_drop);
GtkWidget* faster = gtk_button_new_with_label("\u00d710");
gtk_widget_set_tooltip_text(faster, "a decade wider");
// Which direction a stepper steps is data on the button rather than a second callback: both
// do the same thing to the same number.
g_object_set_data(G_OBJECT(faster), "udpscope-window-up", faster);
g_signal_connect(faster, "clicked", G_CALLBACK(on_window_step), &app);
gtk_box_append(GTK_BOX(row), faster);
GtkWidget* popover = gtk_popover_new();
gtk_popover_set_child(GTK_POPOVER(popover), row);
gtk_menu_button_set_popover(GTK_MENU_BUTTON(app.window_button), popover);
return app.window_button;
}
/// Any press in the window closes the time-window popover.
///
/// `GtkPopover`'s autohide does this by itself for as long as GTK holds the popover's grab -- and the grab
/// is the thing that can go missing (a window manager moving it, a nested popover taking it for the unit
/// menu, and every headless run, where there is no manager to hand one out at all), after which a click
/// anywhere else in the window leaves the bubble up. That is the "sometimes it is awkward to close".
///
/// A press that reaches the window's own surface was *not* inside the popover: a popover is its own
/// surface, so a click within it never arrives here. That is what makes this the rule stated outright
/// rather than a grab away from working -- and it needs no hit test beyond leaving the button alone,
/// whose own click is what toggles the bubble shut.
void on_window_popover_press(GtkGestureClick*, int, double x, double y, gpointer user) {
App* app = static_cast<App*>(user);
if (app->window_button == nullptr ||
!gtk_menu_button_get_active(GTK_MENU_BUTTON(app->window_button))) {
return;
}
// The button itself is left to its own click, which toggles the bubble shut -- closing it here would
// only have the toggle open it again. Anything else on the window's surface closes it.
for (GtkWidget* w = gtk_widget_pick(app->window, x, y, GTK_PICK_DEFAULT);
w != nullptr && w != app->window; w = gtk_widget_get_parent(w)) {
if (w == app->window_button) return;
}
gtk_menu_button_popdown(GTK_MENU_BUTTON(app->window_button));
}
/// The one place a key turns into an action. Toggles go through
/// gtk_toggle_button_set_active(), which emits ::toggled and so runs exactly the handler a
/// click would; the non-toggles call their click handlers with a null button, which they
/// already ignore. Split out of the key handler so the probe can fire a row directly.
/// The pane's switches and the header bar's buttons are one control with two places to click, so a
/// change in either re-reads the other. `panel_switch_sync` is the guard: setting a `GtkSwitch` emits
/// its own `notify::active`, and without it the program's write would come back as a click.
void sync_panel_switches(App& app) {
if (app.panel_switch_sync) return;
app.panel_switch_sync = true;
const auto mirror = [](GtkWidget* panel_switch, GtkWidget* toolbar_toggle) {
if (panel_switch == nullptr || toolbar_toggle == nullptr) return;
gtk_switch_set_active(GTK_SWITCH(panel_switch),
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(toolbar_toggle)) == TRUE);
};
mirror(app.trigger_switch, app.trigger_toggle);
mirror(app.cursors_switch, app.cursors_toggle);
app.panel_switch_sync = false;
}
/// A pane's own switch drives the *same action* as the header bar's button, so either place reaches
/// the feature down one path, and the sync above is what keeps the two showing the same thing.
void on_panel_switch_toggled(GObject* obj, GParamSpec*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->panel_switch_sync) return;
const bool on = gtk_switch_get_active(GTK_SWITCH(obj)) != FALSE;
const bool is_trigger = app->trigger_switch != nullptr && obj == G_OBJECT(app->trigger_switch);
GtkWidget* toggle = is_trigger ? app->trigger_toggle : app->cursors_toggle;
if (toggle == nullptr) return;
// The action toggles the toolbar button, so it is run only when the two actually disagree -- which
// is what makes this a *set* rather than a second toggle of something already in that state.
if ((gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(toggle)) != FALSE) != on) {
run_app_action(*app, is_trigger ? AppAction::TriggerPanel : AppAction::CursorsPane);
}
sync_panel_switches(*app);
}
/// Both panes' modes, applied together: the trigger's body against its summary line, the two [edit]
/// buttons against what they would do, and the signal list rebuilt because the lock decides what a row
/// carries. Idempotent, so a caller only flips a flag and calls this.
void apply_pane_lock(App& app) {
if (app.trigger_body != nullptr) gtk_widget_set_visible(app.trigger_body, !app.trigger_locked);
if (app.trigger_summary != nullptr) {
gtk_widget_set_visible(app.trigger_summary, app.trigger_locked);
}
const auto edit_button = [](GtkWidget* button, bool locked, const char* what) {
if (button == nullptr) return;
// Both names must be ones Adwaita *has*: `emblem-ok-symbolic` exists only in breeze, so the "done"
// button was blank -- and a blank icon button is invisible in every other reading, which is why
// `pane edits:` checks these two by name and verdict.
gtk_button_set_icon_name(GTK_BUTTON(button),
locked ? "document-edit-symbolic" : "object-select-symbolic");
const std::string tip = std::string(locked ? "configure " : "keep this configuration of ") + what;
gtk_widget_set_tooltip_text(button, tip.c_str());
};
edit_button(app.trigger_edit, app.trigger_locked, "the trigger");
edit_button(app.signals_edit, app.signals_locked, "the signals");
sync_trigger_summary(app);
// The list, because the lock is what decides whether a row carries a tick, a menu and the `+`.
if (app.signal_list != nullptr) rebuild_signal_panels(app);
}
/// Either pane's [edit] button: one handler, because the two differ only in which flag they flip.
void on_pane_edit_clicked(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user);
if (GTK_WIDGET(button) == app->trigger_edit) {
app->trigger_locked = !app->trigger_locked;
} else if (GTK_WIDGET(button) == app->signals_edit) {
app->signals_locked = !app->signals_locked;
}
apply_pane_lock(*app);
}
/// Hides the sidebar for the length of a drag, when it is *overlaid*, and restores it after.
///
/// With the split view collapsed the sidebar covers the plot -- that is what collapsed means -- so a drag
/// that starts in the signal list has nowhere to land: the pointer is over the sidebar and never over a
/// drop target, and the drag is impossible rather than merely awkward. Side by side nothing is done: the
/// plot is already visible, and hiding the list the drag started in would be startling.
void row_drag_sidebar(App& app, bool dragging) {
if (app.side_view == nullptr) return;
AdwOverlaySplitView* split = ADW_OVERLAY_SPLIT_VIEW(app.side_view);
if (dragging) {
if (!adw_overlay_split_view_get_collapsed(split)) return;
app.sidebar_hidden_for_drag = adw_overlay_split_view_get_show_sidebar(split) != FALSE;
if (app.sidebar_hidden_for_drag) adw_overlay_split_view_set_show_sidebar(split, FALSE);
} else if (app.sidebar_hidden_for_drag) {
app.sidebar_hidden_for_drag = false;
adw_overlay_split_view_set_show_sidebar(split, TRUE);
}
}
void run_app_action(App& app, AppAction action) {
const auto toggle = [](GtkWidget* button) {
if (button == nullptr) return;
const gboolean now = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(button));
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(button), now == TRUE ? FALSE : TRUE);
};
switch (action) {
case AppAction::PauseToggle:
toggle(app.pause_button);
break;
case AppAction::TriggerArm:
on_arm_clicked(nullptr, &app);
break;
case AppAction::TriggerPanel:
toggle(app.trigger_toggle);
break;
case AppAction::CursorsPane:
toggle(app.cursors_toggle);
break;
case AppAction::SideTabMeasure: select_side_tab(app, "measure"); break;
case AppAction::SideTabSignals: select_side_tab(app, "signals"); break;
case AppAction::LayoutEditor:
toggle(app.layout_toggle);
break;
case AppAction::SidePanel:
toggle(app.sidebar_toggle);
break;
case AppAction::Export:
on_export_clicked(nullptr, &app);
break;
case AppAction::Help:
show_help(GTK_WINDOW(app.window));
break;
case AppAction::Shortcuts:
show_shortcuts(GTK_WINDOW(app.window));
break;
case AppAction::Quit:
if (app.window != nullptr) gtk_window_close(GTK_WINDOW(app.window));
break;
}
}
/// Looks a key up in the accel table and runs it. Refuses outright while a GtkEditable has
/// focus: every bare-letter accel is also a character somebody may be typing.
gboolean on_app_key_pressed(GtkEventControllerKey*, guint keyval, guint, GdkModifierType state,
gpointer user) {
App* app = static_cast<App*>(user);
if (app->window == nullptr) return FALSE;
GtkWidget* focus = gtk_window_get_focus(GTK_WINDOW(app->window));
if (focus != nullptr && GTK_IS_EDITABLE(focus)) return FALSE;
const Accel* accel = find_accel(keyval, static_cast<unsigned>(state));
if (accel == nullptr) return FALSE;
run_app_action(*app, accel->action);
return TRUE;
}
/// Re-reads the file a *file view* is showing; true when something was reloaded.
///
/// Any structural change -- a math channel added, a signal enabled, a plan change -- rebuilds the buffer
/// and drops its history. That is right for a stream, whose producer refills it from the next packet. A
/// file has no producer, so the same rebuild leaves *every* channel reading `0 samples` and the view is
/// simply empty: "adding a math channel breaks the plot" was this, and it is the same emptiness whichever
/// structural change caused it. The file's own path is the only thing that can refill it, so it is loaded
/// again -- which is also what computes the derived channels over it, since the load's own pushes run the
/// math once the set is in place.
bool reload_file_view(App& app) {
if (!app.replaying && app.model->config().open_waveform.empty()) return false;
// Nothing to restore: a change that really happened leaves the store empty in a file view -- the
// rebuild replaces it and there is no producer to refill it -- while one that found nothing to do
// leaves it full. Reading the file again for the second kind is the waste this avoids.
if (app.model->stored_slots() > 0) return false;
const std::string path =
!app.waveform_name.empty() ? app.waveform_name : app.model->config().open_waveform;
if (path.empty()) return false;
WaveformLoadReport report;
std::string error;
if (!load_waveform_into_model(*app.model, path, &report, &error)) {
set_notice(app, "cannot reload " + path + " after the change: " + error, 6);
return false;
}
// Said out loud because it is the one place a file is read twice: a structural change really did
// drop the buffer, and the file is the only thing that can put it back.
std::fprintf(stderr, "[udpscope] reloaded %s after a structural change: %llu samples back\n",
path.c_str(), static_cast<unsigned long long>(report.samples));
return true;
}
/// The header bar carries only the controls you reach for *while watching a signal*;
/// everything you set up once lives in the overflow menu. Nine text buttons in a
/// header bar is a wall of words to read past every time you want the one you need,
/// so the four that stay are icons with tooltips.
///
/// [ 2 s ▾ ] [plots ▾] ............... [pause] [trigger] [panel] [menu]
GtkWidget* build_toolbar(App& app) {
// The bar's frame is a `.ui` file (`src/ui/header-bar.ui`): the bar, the slot the window control goes
// in, the trigger/pause/events group with its separator, and the five controls at the far end. Its
// *order* is the file's too, which is what three comments here used to explain.
GtkBuilder* ui = ui_load(app, "/org/udpscope/ui/header-bar.ui");
GtkWidget* bar = GTK_WIDGET(gtk_builder_get_object(ui, "header_bar"));
if (bar == nullptr) return gtk_header_bar_new();
app.toolbar = bar;
// The control already *is* the window's name: a `window` label in front of it said nothing the
// `2 s` beside it did not, and the bar is the busiest row in the window. It goes into the file's slot
// rather than straight onto the bar: `gtk_header_bar_pack_start()` appends *after* whatever the file
// has already packed, so a control added here would land right of the whole group.
if (GtkWidget* slot = GTK_WIDGET(gtk_builder_get_object(ui, "window_control_slot"))) {
gtk_box_append(GTK_BOX(slot), build_window_control(app));
}
refresh_window_limits(app);
// The end group reads, left to right: cursors, measurements, math, menu -- with the
// sidebar toggle to their left. `pack_end()` lays children out right to left (the first
// packed is the *rightmost*), so they are packed in the reverse of that order, which is
// what puts the menu last on screen. The trigger and the pause are *start*-packed above,
// beside the window control: their event window is read against the span on screen, so
// they belong with the control that sets it.
// --- the overflow menu ----------------------------------------------------
// Rows rather than plain buttons: the label at the left, the shortcut at the right and a
// check on the rows that toggle something, which is the shape every GNOME menu has and the
// reason this popover no longer reads as a stack of dialogs (see menu_row(), and the
// `.udpscope-menu` rules in install_css()). It stays hand-built instead of a `GMenu` model
// because these act on the `RowSet` directly and two of them are toggles whose state the
// rest of the UI reads, so an action model would only add a second copy of that state.
//
// The order is a GNOME menu's: what you do to the *view*, then the data, then the help
// trio. Nothing here needs a separator of its own -- the groups are the separators.
GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_set_margin_top(menu_box, 6);
gtk_widget_set_margin_bottom(menu_box, 6);
gtk_widget_set_margin_start(menu_box, 6);
gtk_widget_set_margin_end(menu_box, 6);
GtkWidget* fit = menu_button("Fit to data", "F");
gtk_widget_set_tooltip_text(fit, "auto-scale every axis and frame the buffered data");
g_signal_connect(fit, "clicked", G_CALLBACK(on_fit_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), fit);
app.layout_toggle = menu_toggle("Layout editor", "L", false);
gtk_widget_set_tooltip_text(app.layout_toggle,
"add and remove plot rows in place: \u00d7 on a row removes "
"it, + between rows inserts one there");
g_signal_connect(app.layout_toggle, "toggled", G_CALLBACK(on_layout_editor_toggled), &app);
gtk_box_append(GTK_BOX(menu_box), app.layout_toggle);
gtk_box_append(GTK_BOX(menu_box), menu_separator());
GtkWidget* export_btn = menu_button("Export visible range...", "Ctrl+E");
gtk_widget_set_tooltip_text(export_btn,
"write every plotted signal over the visible x range, at the "
"stored sample rate, to a .npy or .csv file");
g_signal_connect(export_btn, "clicked", G_CALLBACK(on_export_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), export_btn);
GtkWidget* configs = menu_button("Configurations...", nullptr);
gtk_widget_set_tooltip_text(configs,
"named sessions of this source: open one, save this arrangement "
"under a new name with a note, or delete one");
g_signal_connect(configs, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
open_configurations(*static_cast<App*>(user));
}),
&app);
gtk_box_append(GTK_BOX(menu_box), configs);
gtk_box_append(GTK_BOX(menu_box), menu_separator());
GtkWidget* shortcuts_item = menu_button("Keyboard shortcuts", "Ctrl+/");
gtk_widget_set_tooltip_text(shortcuts_item,
"every key and mouse gesture, including the plot's own");
g_signal_connect(shortcuts_item, "clicked", G_CALLBACK(on_shortcuts_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), shortcuts_item);
GtkWidget* help_item = menu_button("Help", "F1");
gtk_widget_set_tooltip_text(help_item, "the paged guide to every feature");
g_signal_connect(help_item, "clicked", G_CALLBACK(on_help_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), help_item);
GtkWidget* prefs_item = menu_button("Preferences\u2026", nullptr);
g_signal_connect(prefs_item, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
open_preferences(*static_cast<App*>(user));
}),
&app);
gtk_box_append(GTK_BOX(menu_box), prefs_item);
gtk_box_append(GTK_BOX(menu_box), menu_separator());
GtkWidget* about_item = menu_button("About udpscope", nullptr);
gtk_widget_set_tooltip_text(about_item, "version, build timestamp and settings paths");
g_signal_connect(about_item, "clicked", G_CALLBACK(on_about_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), about_item);
// --- the pane toggles, which live on the bar rather than in the menu ------
// The three pane toggles, and the rest of the bar, are the file's now; their handlers are wired where
// the bar's ids are resolved, below. The popover above still shares this function's styling helpers
// (`menu_button()`, `menu_toggle()`), which is why it is built here.
GtkWidget* popover = gtk_popover_new();
gtk_widget_add_css_class(popover, "udpscope-menu");
gtk_popover_set_child(GTK_POPOVER(popover), menu_box);
app.menu_button = GTK_WIDGET(gtk_builder_get_object(ui, "menu_button"));
app.sidebar_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "sidebar_toggle"));
app.trigger_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_toggle"));
app.events_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "events_toggle"));
app.pause_button = GTK_WIDGET(gtk_builder_get_object(ui, "pause_button"));
app.cursors_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "cursors_toggle"));
if (app.menu_button == nullptr || app.sidebar_toggle == nullptr || app.trigger_toggle == nullptr ||
app.events_toggle == nullptr || app.pause_button == nullptr ||
app.cursors_toggle == nullptr) {
// A missing id is a mistake in the `.ui`: the bar comes up with the controls it got rather than taking
// the window with it, and `[udpscope] header:` reads whatever geometry is left.
return bar;
}
gtk_menu_button_set_popover(GTK_MENU_BUTTON(app.menu_button), popover);
// What each control *does*, which a file cannot hold.
g_signal_connect(app.sidebar_toggle, "toggled", G_CALLBACK(on_sidebar_toggled), &app);
g_signal_connect(app.trigger_toggle, "toggled", G_CALLBACK(on_trigger_toggled), &app);
// The event history is a *trigger* thing, and one click from the mode that produces it.
g_signal_connect(app.events_toggle, "toggled", G_CALLBACK(+[](GtkToggleButton* b, gpointer user) {
App* a = static_cast<App*>(user);
const bool open = gtk_toggle_button_get_active(b);
gtk_revealer_set_reveal_child(GTK_REVEALER(a->events_revealer), open);
// Through the one function that decides *both*: revealing the child alone leaves an
// invisible pane with no width, which is a button that appears to do nothing.
sync_events_availability(*a);
if (open) refresh_events_pane(*a);
}),
&app);
g_signal_connect(app.pause_button, "toggled", G_CALLBACK(on_pause_toggled), &app);
g_signal_connect(app.cursors_toggle, "toggled", G_CALLBACK(on_cursors_toggled), &app);
return bar;
}
void on_activate(GtkApplication* application, gpointer user) {
App* app = static_cast<App*>(user);
install_css();
// The window's skeleton is a `.ui` file: the window, its two panes, the plot column with the status
// line under it, and the sidebar's tab switcher and two-page stack. Everything that depends on the
// stream -- the events pane, the two plot areas, the sidebar's panels -- is built below and put
// into the slots by id.
g_clear_object(&app->window_ui);
app->window_ui = gtk_builder_new_from_resource("/org/udpscope/ui/main-window.ui");
if (app->window_ui == nullptr) return;
app->window = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "main_window"));
GtkWidget* side_view = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_view"));
app->side_view = side_view;
GtkWidget* events_divider = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "events_divider"));
app->plot_box = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_box"));
app->plot_split = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_split"));
app->status_badge = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_badge"));
app->status_label = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_label"));
app->status_info_button = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_info"));
GtkWidget* side = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_column"));
GtkWidget* side_stack = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_stack"));
GtkWidget* side_switcher = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_switcher"));
GtkWidget* measure_page = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "measure_page"));
GtkWidget* signals_page = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "signals_page"));
// A missing id is the one mistake neither the build nor `GtkBuilder` sees, and every use below would
// be a null pointer.
if (app->window == nullptr || side_view == nullptr || events_divider == nullptr ||
app->plot_box == nullptr || app->plot_split == nullptr || app->status_badge == nullptr ||
app->status_label == nullptr || app->status_info_button == nullptr ||
side == nullptr || side_stack == nullptr || side_switcher == nullptr ||
measure_page == nullptr || signals_page == nullptr) {
return;
}
// The window's actions live on the application, so it has to know which one it belongs to.
gtk_window_set_application(GTK_WINDOW(app->window), application);
// A window closed by hand (a WM close button) must stop the frame tick: it holds
// pointers to widgets that are finalized with the window.
g_signal_connect(app->window, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer user) {
App* a = static_cast<App*>(user);
// A pending debounced save will never fire now: flush it, so
// quitting right after a change keeps the change.
if (a->layout_save_source != 0) {
g_source_remove(a->layout_save_source);
a->layout_save_source = 0;
save_layout_now(*a);
}
a->window = nullptr;
}),
app);
// The header bar is the window's own titlebar, and it is *not* in the `.ui`: every button on it is
// wired to an action the application owns, which is code by nature. Its title and the icon the shell
// shows are the window's, and those are in the file.
gtk_window_set_titlebar(GTK_WINDOW(app->window), build_toolbar(*app));
// The size the window opens at, stated in code as well as in the `.ui`: `default-width` there is the
// size it *asks* for, and a window whose content has a larger minimum opens at that minimum instead --
// 756 px here, the plot column's own minimum plus the sidebar's 320. The old code set it explicitly;
// this is where it went when the window moved into the file, and it is why the window came up small.
gtk_window_set_default_size(GTK_WINDOW(app->window), 1280, 800);
// Nothing listens to the split view's own `show-sidebar`: it is an *output*. `AdwOverlaySplitView`
// writes it itself -- at startup, and when it collapses -- and reading that back as user input is what
// made connecting a source leave the sidebar shut. The button is the only input; `update_side_visibility`
// re-asserts the wish on top of the widget (see the tick).
// The badge and the text share one row so the badge sits at the line's head. It is a
// sibling rather than part of the label's own markup because the label's text is
// rewritten every frame and its markup is ellipsized -- a blinking span inside it would
// be reparsed sixty times a second and could be the character that gets elided.
// The badge's markup is the one value the `.ui` cannot carry (it is a phase, not a string), and its
// tooltip says which phase the badge is *not* reporting.
gtk_label_set_markup(GTK_LABEL(app->status_badge), badge_markup(BadgePhase::Idle));
gtk_widget_set_tooltip_text(app->status_badge, "waiting for the stream");
// The line is rewritten every frame and its text changes width as the counters
// grow. A label's *minimum* width is its text, so an unellipsized one pushes the
// paned divider around as the numbers change -- which resizes the plot, which
// re-lays out the axes and their ticks. Ellipsizing gives it a minimum of "…" and
// makes it fill whatever width it is given instead.
// Guard the construction so the counter means something: if status_popover is already
// non-null (e.g. run_ui called twice), count the extra build and avoid a leak.
if (app->status_popover != nullptr) {
++app->popover_rebuilds;
} else {
app->status_popover = gtk_popover_new();
GtkWidget* detail_grid = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(detail_grid), 2);
gtk_grid_set_column_spacing(GTK_GRID(detail_grid), 16);
gtk_widget_set_margin_start(detail_grid, 10);
gtk_widget_set_margin_end(detail_grid, 10);
gtk_widget_set_margin_top(detail_grid, 8);
gtk_widget_set_margin_bottom(detail_grid, 8);
// Row headings at indices 0..4, value lines at 4..12, in the order format_detail's fields
// appear, then the capture's. Built once: update_status_popover() only ever calls
// gtk_label_set_text().
static const char* const kHeadings[] = {"Stream", "Buffer", "Plot 1", "Reconnects", "Capture"};
const int kHeadingRow[] = {0, 3, 5, 6, 7};
for (int i = 0; i < 5; ++i) {
GtkWidget* heading = gtk_label_new(nullptr);
char markup[64];
std::snprintf(markup, sizeof(markup), "<b>%s</b>", kHeadings[i]);
gtk_label_set_markup(GTK_LABEL(heading), markup);
gtk_label_set_xalign(GTK_LABEL(heading), 0.0f);
gtk_label_set_yalign(GTK_LABEL(heading), 0.0f);
gtk_grid_attach(GTK_GRID(detail_grid), heading, 0, kHeadingRow[i], 1, 1);
app->popover_label[i] = heading;
}
for (int i = 0; i < 8; ++i) {
GtkWidget* value = gtk_label_new("");
gtk_label_set_xalign(GTK_LABEL(value), 0.0f);
gtk_grid_attach(GTK_GRID(detail_grid), value, 1, i, 1, 1);
app->popover_label[4 + i] = value;
}
// The capture toggle, below the grid: `toggled` sets the producer's atomic and
// nothing else. Insensitive until connected, because the capture's path is keyed
// on the source and there is no source before the first connect.
app->capture_button = gtk_toggle_button_new();
GtkWidget* cap_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_box_append(GTK_BOX(cap_box), gtk_image_new_from_icon_name("media-record-symbolic"));
gtk_box_append(GTK_BOX(cap_box), gtk_label_new("Capture packets"));
gtk_button_set_child(GTK_BUTTON(app->capture_button), cap_box);
gtk_widget_add_css_class(app->capture_button, "flat");
gtk_widget_set_margin_start(app->capture_button, 10);
gtk_widget_set_margin_end(app->capture_button, 10);
gtk_widget_set_margin_bottom(app->capture_button, 8);
gtk_widget_set_sensitive(app->capture_button, FALSE);
g_signal_connect(app->capture_button, "toggled",
G_CALLBACK(+[](GtkToggleButton* b, gpointer user) {
App* a = static_cast<App*>(user);
// The whole GTK side of the feature: the flag, and nothing else.
// The path is written once inside, before the flag is published.
a->capture_want_ui = gtk_toggle_button_get_active(b);
a->acq->set_capture_want(gtk_toggle_button_get_active(b));
}),
app);
update_capture_tooltip(*app);
GtkWidget* popover_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
gtk_box_append(GTK_BOX(popover_box), detail_grid);
gtk_box_append(GTK_BOX(popover_box), app->capture_button);
gtk_popover_set_child(GTK_POPOVER(app->status_popover), popover_box);
// Above the button, always. The status row is the last thing in the window, so "below"
// is off the window's bottom edge and, on a window that reaches the bottom of the screen,
// off the screen: the bubble's top edge and its arrow stayed at the button while the body
// was clipped away -- what reads as an arrow disconnected from its body. GTK still slides
// it sideways to fit, which is why only the vertical placement has to be stated.
gtk_menu_button_set_popover(GTK_MENU_BUTTON(app->status_info_button),
app->status_popover);
// After `set_popover()`: a `GtkMenuButton` states a position of its own (below, which is
// where a menu button in a header bar wants it), and it is the *last* word that counts.
gtk_popover_set_position(GTK_POPOVER(app->status_popover), GTK_POS_TOP);
gtk_popover_set_has_arrow(GTK_POPOVER(app->status_popover), TRUE);
}
// The right-hand side is two tabs, not five stacked panels: one tab is on screen at a time, so
// no section competes with four others for height. Each page scrolls on its own -- the two are
// different heights, and one shared scroller would leave a switch scrolled where the new page
// never asked to be.
// `side` and `side_stack`/`side_switcher`/`measure_page`/`signals_page` are resolved above with
// the rest of the skeleton and checked by the null guard; assign the members here.
app->side_box = side;
app->side_stack = side_stack;
app->side_switcher = side_switcher;
// Measure: what the cursors bracket, and the figures derived from it.
app->cursors_panel = build_cursors_panel(*app);
gtk_box_append(GTK_BOX(measure_page), app->cursors_panel);
gtk_box_append(GTK_BOX(measure_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
gtk_box_append(GTK_BOX(measure_page), build_measures_panel(*app));
// Signals: the trigger picks a channel from the list beside it.
app->trigger_panel = build_trigger_panel(*app);
gtk_box_append(GTK_BOX(signals_page), app->trigger_panel);
gtk_box_append(GTK_BOX(signals_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
app->signal_panel = build_signal_panel(*app);
gtk_box_append(GTK_BOX(signals_page), app->signal_panel);
// The cursors start off, so their readout starts insensitive -- stated here by the one place that
// knows the default, and the *body* and not the panel, because the panel's heading carries the
// switch that turns it back on. The trigger's body is deliberately *not* disabled: a trigger is
// configured before it is switched on, so its settings have to stay editable while it is off.
gtk_widget_set_sensitive(app->cursors_body, FALSE);
// The trigger's Arm button is the one control in its pane that needs trigger mode running.
if (app->arm_button != nullptr) gtk_widget_set_sensitive(app->arm_button, FALSE);
gtk_widget_set_sensitive(app->cursors_reset, FALSE);
if (app->side_stack != nullptr) {
adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(app->side_stack), "signals");
g_signal_connect(app->side_stack, "notify::visible-child-name",
G_CALLBACK(on_side_tab_changed), app);
}
// The left side is the events pane *and* the plot, in one row: the pane is a column of its own
// (the item's "collapsable pane on the left"), revealed by trigger mode.
// A *paned*: the pane's width is the user's to choose, and the plot takes what is left.
// The events pane is the one subtree of the inner divider that is built: whether it exists at all
// depends on trigger mode.
gtk_paned_set_start_child(GTK_PANED(events_divider), build_events_pane(*app));
// Once, now that both the button and the pane exist: trigger mode is off, so the pane is closed and
// its button is not on the bar -- stated by the one function that decides it rather than by whichever
// widget happened to be built with which default.
sync_events_availability(*app);
app->events_divider = events_divider;
// Two plots, in a paned so a comparison's divider can move: the primary takes everything while the
// second is hidden, and the width is the user's once it is not.
// Nothing to state about the sidebar's size. It is the split view's: 320 px from the `.ui`, floored
// at 240, and never a number derived from the window -- which is what the removed
// `gtk_paned_set_position(paned, 940)` was, and why a window narrower than 940 px gave the sidebar
// no width at all.
// The split is in the `.ui`; what goes in it is not, because an area is a GL surface with a figure
// handed to it.
gtk_paned_set_start_child(GTK_PANED(app->plot_split), make_area(*app));
gtk_paned_set_end_child(GTK_PANED(app->plot_split), make_compare_area(*app));
// A waveform opened from a file is already in the model by the time the UI is built -- there is no
// drop and no CONFIG to wait for -- so its channels are bound to the rows here: four traces to a
// row, more rows when the file has more columns. This is the drop handler's job for a live source,
// done once. The samples stay: they are the file's, and the plot draws from the same rings a
// streamed run fills.
const std::string waveform = app->model->config().open_waveform;
if (!waveform.empty()) {
app->waveform_name = waveform;
const std::vector<ChannelInfo>& loaded = app->model->channels();
if (!loaded.empty()) {
// **One column per row**, not four to a row. Four to a row fills the default single row
// completely, and then every drop is refused -- "plot 1 already holds four signals" -- which
// is a loaded file the user cannot plot anything else on, and cannot rearrange. One per row
// leaves three free slots on each, shows a four-signal file as the four stacked traces a
// bench scope would, and keeps the remaining columns draggable from the panel.
// One column per row, and the row count was already sized for exactly that before the UI
// existed (`main.cpp`, the way `--plots` does it): a `set_row_count()` *here* leaves the
// panels built for the old count, and the app hangs on the first relayout rather than
// drawing. Four to a row would fit one row, fill it, and refuse every later drop.
for (std::size_t c = 0; c < loaded.size(); ++c) {
const int row = static_cast<int>(c);
if (row >= app->rows->row_count()) break;
app->rows->assign(row, 0, loaded[c].name, loaded[c].name,
signal_color_index(app->model->signals(), loaded[c].signal));
}
// The figure is rebuilt with them on the first tick -- the drop handler's own contract.
app->relayout_pending = true;
app->rows->fit_all();
std::string base = waveform;
const std::size_t slash = base.rfind('/');
if (slash != std::string::npos) base = base.substr(slash + 1);
set_notice(*app, "opened " + base + ": " + std::to_string(loaded.size()) + " signals, " +
std::to_string(app->rows->row_count()) + " rows");
}
}
rebuild_signal_panels(*app);
g_timeout_add(kTickMs, tick, app);
// Bubble phase on purpose: a focused GtkEditable or GtkButton wins its own keys, so typing
// 'm' into the window entry does not toggle the measurements pane and Space does not
// double-fire on a focused button.
GtkEventController* keys = gtk_event_controller_key_new();
gtk_event_controller_set_propagation_phase(keys, GTK_PHASE_BUBBLE);
g_signal_connect(keys, "key-pressed", G_CALLBACK(on_app_key_pressed), app);
gtk_widget_add_controller(app->window, keys);
// Capture phase, unlike the keys above: a press belongs to whatever widget is under it (the plot's
// gestures among them), and the point here is to see every one of them, not to win none of them.
GtkGesture* press = gtk_gesture_click_new();
gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(press), GTK_PHASE_CAPTURE);
g_signal_connect(press, "pressed", G_CALLBACK(on_window_popover_press), app);
gtk_widget_add_controller(app->window, GTK_EVENT_CONTROLLER(press));
gtk_window_present(GTK_WINDOW(app->window));
// The loading window covered the gap from the chooser to here -- the buffer, the file read and
// the whole of the construction above, none of which returns to a main loop. It goes down only
// now, *after* the present: destroying it before would leave the screen empty again for the
// frame or two the real window takes to map. A no-op when there was no chooser.
loading_hide();
// What the window is waiting for, said *before* the first tick. The stream's first CONFIG plans
// the buffer on the producer thread, which holds the model's mutex for the whole of it -- at a few
// gigabytes that is around a second per gigabyte, and the frame tick cannot read the model while
// it runs, so the window does not change. Set here it is on screen already: the present paints a
// frame before any timeout fires, which is the difference between waiting and "the dialog
// disappeared and nothing happened".
if (app->waveform_name.empty()) {
const Config& cfg = app->model->config();
char buf[192];
std::snprintf(buf, sizeof(buf), "connecting to %s:%u -- planning the %.0f MB buffer",
cfg.server.c_str(), unsigned(cfg.port), cfg.buffer_mb);
set_notice(*app, buf, 6);
}
}
/// Headless self-check, step 2: read the panel geometry while the widgets still
/// exist, then close the window. Runs after the reveal animations have settled.
/// Step 2: record the status line while trigger mode is on, then leave trigger
/// mode the way the "Go live" button does, and stop the tick.
/// Headless self-check, step 1: toggle the trigger the way the toolbar button
/// does, then let the reveal animation settle before measuring.
/// Headless self-check: arm the trigger, then disarm it the way the button does,
/// so the check can prove no event latched after the disarm.
/// Finds a toolbar/panel button by its label, so the self-check drives the *user's*
/// path (a toggle's handler, a button's click) rather than poking the model -- which
/// the frame tick then legitimately corrects.
GtkWidget* find_labelled_button(GtkWidget* w, const char* label) {
if (w == nullptr) return nullptr;
if (GTK_IS_BUTTON(w)) {
const char* l = gtk_button_get_label(GTK_BUTTON(w));
if (l != nullptr && std::strcmp(l, label) == 0) return w;
}
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
if (GtkWidget* hit = find_labelled_button(c, label)) return hit;
}
return nullptr;
}
/// Headless self-check: sample the trigger state machine and what the plot is
/// actually bound to, so the arm -> capture -> latch -> wait cycle is observable
/// headlessly. Two things in this line are assertions in disguise:
///
/// * with `trigger=on`, `display=` must never read `live` -- in trigger mode the
/// picture changes only on a latch;
/// * `snap=[a..b]` and `axis=[a..b]` must agree, and `b` must be positive by
/// roughly the post-trigger share of the span. A post side that is empty, or a
/// snapshot that does not fill the axis, is the defect this probe exists for.
/// Headless self-check: waits for a *held* event, then lets the tick run the zoom sweep (see
/// `report_event_zoom`). Polling for `Held` rather than firing on a fixed delay is the same
/// lesson the disarm probe learned: a latch happens when the stream says so, and a sweep that
/// starts before one has nothing to zoom into.
gboolean eventzoom_wait(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (a->model->display_mode() != DisplayMode::Held) return G_SOURCE_CONTINUE;
// Eight halvings: a 2 s event down to about 8 ms, which crosses the pyramid's own
// raw/LOD gate on the way and so exercises both gather paths. Then the same again
// through the window control.
a->dbg_zoom_steps = kZoomGestureSteps + kZoomWindowSteps;
a->dbg_zoom_step_no = 0;
return G_SOURCE_REMOVE;
}
/// Single trigger mode, armed: one event, held for as long as the sweep needs it.
/// Headless self-check: defines math channels the way the math pane will, so the *pipeline* can be
/// checked without driving the editor -- the same graph reaches the producer either way. Two graphs
/// on the first acquired channel: a gain of 2 and a low pass, which the dump then reports beside the
/// source's own extremes, so "twice the amplitude" and "attenuated" are read off real samples.
/// Headless self-check: adds one measurement of each statistical type, which is what a user does
/// from the pane's `+`.
///
/// It exists because the frame cost with measurements on is a thing to *measure*, not to reason
/// about: a measurement is a statistic of the visible window, and a live view moves that window
/// every frame. The values land in the exit dump (`measurements:`), next to `frame cost:`.
/// Headless self-check for the statistics line: turns the pane-wide toggle on over the
/// measurements `measure_probe` added, lets several recomputes land, and reports each
/// accumulator. `n` must grow with the passes and never with the samples, and a Reset must
/// put every `n` back to zero.
///
/// Three phases, because "one observation per recompute" needs two different witnesses. Phase 0
/// is the running count. Phase 1 is the same count after a **pause held for two seconds**: a
/// pinned window recomputes once when it settles and then never again, so `n` must advance by
/// *at most one* across the pause -- that is what separates accumulating per recompute from
/// accumulating per frame, which would add a hundred and twenty observations of the identical
/// window and collapse `sd`. Phase 2 is after a Reset.
/// Headless self-check: a gesture must not move a pinned window.
///
/// It writes the far-away range the way a gesture does -- on the *member* axis, which
/// `Figure::layout()` adopts into the group -- and then reports the axis the frame after it
/// produced: `held=1` when the pin survived, and the panned range when it did not.
/// Headless self-check: arms the trigger the way the toolbar's trigger switch does.
/// The capture the renumber probe opened: the edge it opened on, and the latch count
/// before it, so step 3 can tell a new latch from the one already on screen.
/// Headless self-check, step 3: waits for the capture to latch and reports whether the
/// edge it latched on is still the one it opened on.
///
/// `t0_held=1` is the reading. With the engine's invalidation keyed on the trigger
/// channel's *index* rather than its name, step 2's renumber abandoned the capture and
/// set no hold-off, so the next edge inside the post window became t0 and this read 0.
/// Headless self-check, step 2: the moment a capture is open, enables a signal that sorts
/// *before* the trigger's own -- which is what renumbers the trigger channel under the
/// engine. Polls rather than times, so it lands inside the post window whatever the rate.
/// Headless self-check, step 1: the reported geometry, driven through the real widgets --
/// 1 s window, 10 % pre share, hold-off from `--holdoff` -- on the *square* alone, so that
/// enabling the sine later moves the trigger channel's index.
/// Headless self-check: changes the trigger panel's two dropdowns, which is what a user
/// does to switch Normal/Single or the edge -- mid-capture, if the probe is timed into
/// one. Reaching the dropdowns directly is the point: their `notify::selected` handler
/// has a different signature from the spin buttons' `value-changed`, and connecting the
/// wrong one crashed on the first field read with no way to tell from the state dump.
/// A line here with no SIGSEGV after it is the assertion.
/// Headless self-check for the qualified conditions. Selected by
/// `UDPSCOPE_PROBE_CONDITION_SHAPE` (0 = pulse `<`, 1 = pulse `>`, 2 = window, 3 = dropout).
///
/// The assertion is snapshot-relative rather than an absolute t0. The app's stream clock
/// epoch is the first packet it received, not the mock's t = 0; the two clocks differ by
/// however many packets the CONNECT handshake cost (typically a whole number of 100-sample /
/// 10 ms packets), so comparing trigger_time() against a mock-clock target is meaningless.
///
/// Shape 0 (pulse `<`): x = 0 is the glitch's closing edge. Scans backwards for the most
/// recent upward crossing; `measured = -stamp` is the glitch width (≈ 0.002 s). Window 0.2 s,
/// 10% pre (0.02 s back) — the glitch is 0.002 s wide so 0.02 s comfortably contains it.
///
/// Shape 1 (pulse `>`): same scan; `measured` is the ordinary square half-period (≈ 0.200 s).
/// Needs a 1.0 s window with 50% pre (0.5 s) because the opening edge of the captured pulse
/// is 0.2 s before x = 0, beyond the 0.02 s pre a 0.2 s window would provide.
///
/// Shape 2 (window): edge is set to `falling` (out-of-band), so x = 0 is the moment the
/// signal left the band. Uses the sine channel because the square has no sample inside
/// [-0.5, +0.5] and can never produce a band exit. Reports `at0` (the sample at x = 0, which
/// must be outside the band) and `earlier` (the sample immediately before it, which must be
/// inside). Window 0.2 s, 10% pre — the crossing is one sample before x = 0.
///
/// Shape 3 (dropout): x = 0 is the moment the timeout expired. Scans backwards for the most
/// recent crossing in either direction, because the last sine crossing before the quiet period
/// may be downward, and an upward-only scan would miss it. `timeout` must exceed the sine's
/// 0.1 s half-period; 0.3 s is used so that an ordinary live sine cannot satisfy the
/// condition. Needs a 1.0 s window with 50% pre because the crossing is `timeout` ≈ 0.3 s
/// before x = 0.
///
/// All legs print one `[condition-probe]` line with `nan` for any unmeasurable field.
// Returns the channel name required by the given shape: shapes 2 and 3 use the sine (the
// square has no sample inside [-0.5, +0.5] and cannot satisfy a window condition, and the
// dropout relies on the sine going quiet); shapes 0 and 1 use the square.
/// Headless self-check for the buffer search, and for the claim that makes it worth having:
/// a scanned hit is an instant the *live trigger* would have fired on.
///
/// It arms the same pulse-width condition `condition_probe` arms, waits for a real latch,
/// then searches the buffer for that condition and compares the latched t0 with the nearest
/// scanned hit. `agree=1` is the assertion; `agree=0` means the two evaluators have drifted
/// apart, which is the whole failure mode separating `EventDetector` out was meant to
/// prevent. It then steps to a hit and reports the pinned axis against it.
/// Reports the marks a *frame* after the jump, because that is the only moment they can be read
/// honestly: `search_jump()` publishes a pause view, but the axis it is pinned against and the
/// `view_range()` that `place_search_marks()` filters by are both settled by the frame tick, not
/// by the publish. Reading `search_marks_placed()` in the same callback as the jump would quote
/// the window the scope was on *before* it moved.
///
/// This cannot be read from the exit dump instead. The exit probe stops the tick from `Live`,
/// where the newest window holds no hit at all, so a dump reading is `placed=0` whatever the
/// marks did -- true of a correct implementation and of a broken one alike. The dump's own line
/// asserts the thing that does survive going live: that the new cursor pool is unselectable.
/// Headless self-check: drives the window control the four ways a user can -- type a number,
/// change the unit under it, and step a decade either way -- and prints what each produced.
/// Each line reports the typed request, the window the plot took and the trigger's event
/// span: all three must agree except where the cap clamps, and the event span must track the
/// display window or a capture would be cut to a different length than the axis frames.
/// One line of the layout probe: the row count, how many buttons are on the overlay, the
/// rows' own rects in logical pixels, where each button was placed, and what every row is
/// plotting. The last of those is the point of the whole probe -- an insert or a remove
/// has to move the rows' *contents* with them, not renumber which row a signal is on.
/// Headless self-check: drives the layout editor the way a user does -- switch it on,
/// insert above the first row, remove a row, append one at the bottom, switch it off --
/// with a frame between each step, because the buttons are placed from the rects the last
/// frame was laid out with.
/// Headless self-check: acquire every signal the CONFIG described, the way the
/// signal panel's switches would, and put each resulting channel on a plot, the way
/// a drag from the panel onto a row would. Without the second half nothing is bound
/// and every downstream number -- trace sizes, painted frames, what the trigger
/// displays -- reads zero whatever the pipeline is doing.
/// Headless self-check, the half that reads the *outcome* of the Disarm. A disarm that
/// landed mid-capture (`was_capturing=1`) must leave an event on screen with the
/// capture's own span: `display` reading `nothing` there is the "the event completely
/// disappeared" defect, and `seq` must have advanced by one while `events` has not.
/// Waits for a capture to actually be open before clicking Disarm. The interesting case
/// is the Disarm that lands *mid-capture*, and a fixed delay catches it only by luck --
/// the capture opens as soon as the ring holds a pre window and closes one post window
/// later. Polling for `trigger_pending()` makes the probe deterministic.
/// What the pause work is *for*: the acquisition keeps filling the ring while the picture is
/// frozen on the view the pause snapshotted, and resuming shows the data that arrived meanwhile
/// instead of a hole. Reported from the probe below.
/// Headless self-check: pause the acquisition and reset the movement sample, so
/// what is measured is a still picture -- the state a wiggle is most visible in.
/// Headless self-check for the export: collect the same table the menu item would and
/// write it both ways, skipping only the file chooser. Two numbers are the assertion --
/// `time=` must equal `axis=` (the export is the *visible* range, not the whole ring)
/// and `rows=` must be that span times the stream's real rate, so a table cut from the
/// LOD pyramid or from a stale window shows up as a count far short of it.
/// Reports the cursors: the instruments' values, whether the pane is up, and the pane's
/// own text (which is what a user reads, so it is what the probe asserts).
/// The two fits the right-click menu offers, driven through the handlers the menu wires
/// (a null button: neither handler looks at it). Both must put x back on the whole time window
/// and **leave the window itself alone**; `fit x` must leave the y ranges alone too, and
/// `fit all` must move them to the data's.
/// The x label form across a zoom, driven through the *app's* window path (`apply_time_window`:
/// under the figure's x link group the group owns the window, so a row-level `set_time_window`
/// is ignored). Absolute at 2 s and at 500 ms, relative below a second with the caption naming
/// the anchor and the ticks written against it.
/// Zooms row 0 in to a quarter of what it shows, the way a gesture does -- on the *member* axis,
/// which `Figure::layout()` adopts into the group. A fit is the undo of this, so the probe has to
/// perform it or it has nothing to undo.
/// Drives the plot's keyboard zoom with Ctrl and with Alt and reports which axis moved.
/// The ranges are read **synchronously**, in this callback, which is deliberate and is the
/// opposite of what the pan and zoom probes do. The question here is only "does the
/// controller's modifier restriction reach the app", so the read has to see the
/// controller's own write and nothing else: `hold_pinned_window()` clamps the *x* span
/// into the pinned window, so a reading taken a frame later would report that clamp's
/// answer rather than the restriction's for the ctrl case. Nor can a same-callback read
/// drift past a state boundary, which is the hazard the read-in-the-tick rule exists for.
/// Whether a zoom the controller performed is then *adopted* by the figure is a different
/// question, and `[pan-probe]`'s `zoom-in asked=/group=/member=` line is what asserts it.
/// Pausing first is therefore not required, only tidy — it is how Task 11 runs it:
/// UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200
/// How tall a menu row may be. Adwaita's own minimum for a button is 34 px, and a popover of
/// seven such rows is what made the overflow read as a stack of dialogs; the `.udpscope-menu`
/// rules trim it to a menu row and this is what says they are still applied.
/// The label and the shortcut of one row, in the order menu_row() appends them. The optional
/// check image comes first and is not a label, so this counts labels rather than positions.
void menu_row_labels(GtkWidget* box, GtkWidget** label, GtkWidget** accel) {
*label = nullptr;
*accel = nullptr;
for (GtkWidget* child = box != nullptr ? gtk_widget_get_first_child(box) : nullptr;
child != nullptr; child = gtk_widget_get_next_sibling(child)) {
if (!GTK_IS_LABEL(child)) continue;
if (*label == nullptr) {
*label = child;
} else {
*accel = child;
}
}
}
/// The two popovers, measured, with the side panel hidden -- the state the report is about.
/// What it asserts, row by row, is what a screenshot can only show an eye: the label at the
/// left of its row, the shortcut at the right of it, a row under kMenuRowMaxPx, the rows in
/// the intended order, and an info bubble placed above its button with room for its height.
/// Checks that a chosen trace colour reaches the **trace** and not merely the resolver.
///
/// It reads the colour back out of `plot::Plot::trace()` -- what the renderer will stroke -- and out
/// of the signal list's chip markup, because the chip's only job is to name a trace and the two
/// resolving a colour by separate routes is the one failure a chip cannot survive. The override is
/// `#123456`, deliberately in no palette slot: an override that happened to equal the entry it
/// replaced would pass against an implementation that ignored it entirely.
/// `UDPSCOPE_PROBE_RESEQ_MS`: one reading of the resequencing path, mid-run.
///
/// The counters are in the exit dump too; what is only here is `nan_slots`, `trigger_in_hole` and
/// `bands`. The first is the state of the *data* rather than of a counter: a reservation writes
/// placeholders and a backfill repairs them, and the two counters agreeing proves only that the
/// same number of slots was written and read, not that the right ones were. The second asserts the
/// trigger never latched inside a reserved span. The third asserts the band reached the plot, and
/// splits it by state: a hole whose late packets have all arrived keeps its band in the fainter
/// tint, so `bands_repaired` is how a reading tells a window that is *missing* data from one that
/// merely *was*.
/// Toggles the capture **through the same handler the button uses**, lets the stream
/// run, then reads the file back off disk and prints what it holds. `stored` must equal
/// the datagrams the exit dump counts from the toggle's instant, `torn` must be 0, and
/// `first_seq` must be 1; with `--capture-packets=N` and a long enough run, `stored`
/// exceeds the slot count and `last_seq - first_seq == N - 1` -- the wrap, which shows
/// here and nowhere else.
/// The probe's readings across its steps: each view is measured a frame after the one before it was
/// swapped out, because a popover re-lays out its new child on the next layout pass -- measured in
/// the same call, the rows are unallocated (`h=0`), which reads like a style defect and is not one.
/// A row of the menu, found by the label it shows: the menu is rebuilt on every view change, so no
/// widget handle survives one.
GtkWidget* row_menu_find(GtkWidget* container, const char* want) {
for (GtkWidget* row = container != nullptr ? gtk_widget_get_first_child(container) : 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);
if (label != nullptr && std::string(gtk_label_get_text(GTK_LABEL(label))) == want) return row;
}
return nullptr;
}
/// One view's rows, measured. `bad` counts anything outside the compact style: a row taller than
/// `kMenuRowMaxPx`, a label not left of its shortcut, or a label past the indent a leading icon
/// costs (the Back row's arrow -- every menu indents a Back row).
void row_menu_measure(GtkWidget* box, const char* tag, int* rows_out, int* bad_out) {
int rows = 0;
int bad = 0;
std::string labels;
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 h = gtk_widget_get_height(row);
const graphene_point_t origin = GRAPHENE_POINT_INIT(0.0f, 0.0f);
graphene_point_t at = GRAPHENE_POINT_INIT(0.0f, 0.0f);
const int label_x = label != nullptr && gtk_widget_compute_point(label, row, &origin, &at)
? static_cast<int>(at.x) : -1;
const int accel_right = accel != nullptr && gtk_widget_compute_point(accel, row, &origin, &at)
? static_cast<int>(at.x) + gtk_widget_get_width(accel) : -1;
const bool left = label_x >= 0 && label_x <= 60;
const bool right = accel == nullptr || (accel_right > 0 && row_w - accel_right <= 12);
const bool compact = h > 0 && h <= kMenuRowMaxPx;
const bool ok = left && right && compact;
if (!ok) ++bad;
const char* text = label != nullptr ? gtk_label_get_text(GTK_LABEL(label)) : "?";
labels += (labels.empty() ? "" : ",") + std::string(text);
std::fprintf(stderr, "[rowmenu] %s %d '%s' h=%d label_x=%d accel_right=%d/%d ok=%d\n", tag,
rows, text, h, label_x, accel_right, row_w, ok ? 1 : 0);
++rows;
}
std::fprintf(stderr, "[rowmenu] %s rows=%d bad=%d labels=[%s]\n", tag, rows, bad, labels.c_str());
*rows_out = rows;
*bad_out = bad;
}
/// Opens the plot's context menu on row 0 and walks its two views: the main rows, the remove view
/// it swaps in, Back to the main view, and into the remove view a *second* time. The last one is the
/// case that used to break -- a `GtkMenuButton` collapsed on the second click and left its row dead,
/// which is why the submenu is a second view of this menu rather than a popover of its own.
/// How many traces a row is *actually drawing*: the figure's answer, not the slots'.
int group_visible_traces(App& app, int row) {
if (app.rows == nullptr || row < 0 || row >= app.rows->row_count()) return -1;
const RowSet::Row& r = app.rows->row(row);
if (r.plot == nullptr) return -1;
int visible = 0;
for (plot::u32 t = 0; t < r.plot->trace_count(); ++t) {
if (r.plot->trace(t).visible()) ++visible;
}
return visible;
}
void report_events_availability(App& a, const char* what) {
std::fprintf(stderr, "[events] %s: button_visible=%d pane=%d pane_w=%d tick=%d recording=%d\n",
what,
a.events_toggle != nullptr && gtk_widget_get_visible(a.events_toggle) ? 1 : 0,
a.events_revealer != nullptr &&
gtk_revealer_get_reveal_child(GTK_REVEALER(a.events_revealer)) ? 1 : 0,
a.events_divider != nullptr ? gtk_paned_get_position(GTK_PANED(a.events_divider)) : -1,
signal_tick_visible(a), a.event_recording ? 1 : 0);
}
/// Walks the whole event-cache loop the way a user does: trigger mode on (the real toggle, which is
/// what reveals the pane), a latch recorded automatically, the cache listed, the newest event
/// replayed through the loader `--open` uses, and *Live* taken back. What it reports is what each
/// step must be able to do -- a cache nothing can replay is a directory of files, and a replay with
/// no way back is a one-way door.
/// The two group layouts, driven through the buttons a user presses. What they must read is what the
/// figure *draws*, and what the second plot *is*.
/// Drives the drop handler the way a drag from the signal panel does, and reports what the row holds
/// afterwards. This is the path a user's "plot this signal" takes, so it is the one that says
/// whether a *loaded file* can be plotted at all: a file-mode run whose rows are sized four signals
/// to one row fills its only row, refuses every drop with "plot 1 already holds four signals", and
/// reads exactly like a scope that cannot plot anything.
/// Every spin button under `w`, in the order they were built. A `GtkSpinButton` starts at its
/// range's **minimum**, so an unseeded one is a number nobody chose -- which is exactly how a
/// calibration offset opened at -1e12 and how a trigger level did too.
void collect_spins(GtkWidget* w, std::vector<double>* out) {
if (w == nullptr) return;
if (GTK_IS_SPIN_BUTTON(w)) out->push_back(gtk_spin_button_get_value(GTK_SPIN_BUTTON(w)));
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
collect_spins(c, out);
}
}
/// Opens the two dialogs a user reads *before* touching anything -- Calibrate and the trigger
/// panel -- and reports every value in them. Each must be the field's own default; a `-1e12` here
/// is an unseeded spin button the dialog would then apply.
/// Fires a representative accel from each group and reports the state it changed, then
/// checks the GtkEditable refusal. It goes through run_app_action() for the actions and
/// through the real handler for the refusal, because the refusal lives in the handler.
/// Loads and validates every help page, opens all three windows, closes them, and reports.
/// `parsed` must equal `pages` and no page may be empty; `closed ok` with no GTK critical
/// is the crash check for three windows built and destroyed in one run.
int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisition& acq,
RowSet& rows) {
(void)argc;
// Installs libadwaita's stylesheet, which the shortcut overview's `AdwShortcutsDialog`
// needs to be drawn at all. Idempotent, and `choose_source()` may already have run it.
adw_init();
// The preferences are read before anything is built: the theme decides what the window looks like,
// and the rest is in force from the first frame. A *local* until `App` exists below, which is why
// this one read happens here and the assignment after it.
const Settings startup_settings = load_settings();
apply_theme(startup_settings.theme);
// The app's own icons, before anything asks for one. They are a GResource compiled into the
// binary (see CMakeLists), and this is what makes the theme look there: an unregistered path
// is not an error, it is a missing-image placeholder.
//
// It is here rather than in `build_toolbar()` because there are now two consumers -- the
// window's own icon and the toolbar's buttons -- and this is the one place that is ahead of
// both. `adw_init()` calls `gtk_init()`, which opens the default display, so the line above
// is what makes `gdk_display_get_default()` valid here.
gtk_icon_theme_add_resource_path(gtk_icon_theme_get_for_display(gdk_display_get_default()),
"/org/udpscope/icons");
App app;
app.settings = startup_settings;
app.model = &model;
app.acq = &acq;
app.rows = &rows;
// Which data source this session talks to: the persisted-layout file's name.
// A waveform has **no source**: the address the command line defaults to is not one, and treating
// it as one made a file-mode run load -- and then *save* -- the session of `127.0.0.1:8000`, which
// both overrode the arrangement the file was opened with and could clobber a real session. With no
// port, `save_layout_now()` refuses and the first-CONFIG layout load never runs, which is exactly
// a source-less run.
app.source_host = cfg.open_waveform.empty() ? cfg.server : std::string();
app.source_port = cfg.open_waveform.empty() ? cfg.port : 0;
app.source_multicast = cfg.open_waveform.empty() ? cfg.multicast : std::string();
app.source_interface = cfg.interface_addr;
// The event cache is keyed on the source, like the packet capture. A waveform has no source, so a
// file-mode run has no cache either -- there are no latches in a file.
if (!app.source_host.empty() && app.source_port != 0) {
app.event_store = std::make_unique<EventStore>(app.source_host + "-" +
std::to_string(app.source_port), app.settings.event_cache_dir);
}
refresh_events_pane(app);
GtkApplication* application =
// `NON_UNIQUE`: a scope is the kind of program a user runs twice side by side -- one
// instance per source, or two viewers of the same one -- and without it GApplication's
// D-Bus single-instance rule turns every later launch into an activation on the first
// (the new process exits at once, so only the first instance ever lives).
gtk_application_new("org.udpscope.app", G_APPLICATION_NON_UNIQUE);
g_signal_connect(application, "activate", G_CALLBACK(on_activate), &app);
// Headless self-check: exercise the toolbar's toggles, then exit and print what
// the pipeline produced, so a CI run can assert the stream actually arrived and
// both panels were laid out.
double exit_after_ms = 0.0;
if (const char* env = g_getenv("UDPSCOPE_EXIT_AFTER_MS")) exit_after_ms = std::atof(env);
if (exit_after_ms > 0.0) {
g_timeout_add(static_cast<guint>(exit_after_ms), exit_probe_step1, &app);
}
if (const char* env = g_getenv("UDPSCOPE_ENABLE_ALL_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), enable_all_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_DISARM_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), disarm_probe_step1, &app);
}
if (const char* env = g_getenv("UDPSCOPE_ARM_TRIGGER_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), arm_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_TRIGRENUM_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), trigrenum_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_CONDITION_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), condition_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_STATS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), stats_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_SIDE_TAB_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), side_tab_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_SEARCH_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), search_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_FIELDS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), fields_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_WINDOW_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), window_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_TIMELABELS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), timelabels_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURES_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), measures_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_FIT_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), fit_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_CURSORS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), cursors_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_LAYOUT_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), layout_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_TRIG_TRACE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), trig_trace_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_MATH_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), math_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), measure_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_EVENTZOOM_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), eventzoom_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_PAN_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), pan_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_PAUSE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), pause_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_EXPORT_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), export_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_ZOOMKEYS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), zoomkeys_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_HELP_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), help_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_CONFIGS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), configs_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_EVENTS_MS")) {
events_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_DROP_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), drop_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_NOISE_MS")) {
const int ms = std::atoi(env);
// Times the *setup* stage; `noise_probe` runs 5000 ms after it, so 3000 puts the legs at the same
// 8000 ms they were measured at.
if (ms > 0) g_timeout_add(static_cast<guint>(ms), noise_probe_setup, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_ROWMENU_MS")) {
rowmenu_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_DEFAULTS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), defaults_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_BOXZOOM_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), boxzoom_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_SIDEBAR_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), sidebar_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_PREFS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), prefs_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_CAPTURE_MS")) {
capture_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_MENUS_MS")) {
menus_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_KEYS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), keys_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_WINDOWPOP_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), windowpop_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_COLOR_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), color_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_RESEQ_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), reseq_probe, &app);
}
// Our own options were consumed by parse_args(); GTK would reject them, so only
// the program name is handed to g_application_run().
const int result =
g_application_run(G_APPLICATION(application), 1, const_cast<char**>(argv));
g_object_unref(application);
if (exit_after_ms > 0.0) {
// The derived channels' own reading: how much of the tail is *finite*, and what it holds.
// A filter that integrated a reserved hole's NaN used to come out empty here for ever
// (`finite=0` at the first drop, with the streamed channels holding values) -- so this is
// the assertion that a gap is a gap and not the end of the trace.
{
std::lock_guard<std::mutex> lock(model.mutex());
for (std::size_t i = 0; i < model.channels().size(); ++i) {
if (!model.channels()[i].math) continue;
auto& ring = model.live().values(i);
const std::uint64_t produced = ring.produced();
const std::size_t take = static_cast<std::size_t>(std::min<std::uint64_t>(produced, 4000));
if (take == 0) continue;
double sum = 0, sum2 = 0, lo = 0, hi = 0;
std::size_t finite = 0;
for (std::size_t k = 0; k < take; ++k) {
const double v = ring[static_cast<u64>(produced - take + k)];
if (!std::isfinite(v)) continue;
if (finite == 0) lo = hi = v;
++finite;
sum += v; sum2 += v * v; lo = std::min(lo, v); hi = std::max(hi, v);
}
const double mean = finite > 0 ? sum / finite : 0.0;
const double sd = finite > 0 ? std::sqrt(std::max(0.0, sum2 / finite - mean * mean)) : 0.0;
std::fprintf(stderr,
"[udpscope] math '%s': finite=%zu/%zu mean=%.4f sd=%.4f min=%.4f max=%.4f\n",
model.channels()[i].name.c_str(), finite, take, mean, sd, lo, hi);
}
}
const Partition& part = model.partition();
std::printf("[udpscope] frames=%llu packets=%llu gaps=%llu channels=%zu span=%.3fs "
"stride=%llu"
" reseq=%llu late=%llu stale=%llu overflows=%llu holes=%llu hole_slots=%llu"
" backfilled=%llu\n",
static_cast<unsigned long long>(model.stats().frames.load()),
static_cast<unsigned long long>(model.stats().packets.load()),
static_cast<unsigned long long>(model.stats().gaps.load()),
model.channel_count(), part.span_s,
static_cast<unsigned long long>(part.time_stride),
static_cast<unsigned long long>(model.stats().resequenced.load()),
static_cast<unsigned long long>(model.stats().late.load()),
static_cast<unsigned long long>(model.stats().stale.load()),
static_cast<unsigned long long>(model.stats().overflows.load()),
static_cast<unsigned long long>(model.stats().holes.load()),
static_cast<unsigned long long>(model.stats().hole_slots.load()),
static_cast<unsigned long long>(model.stats().backfilled.load()));
for (std::size_t i = 0; i < model.channel_count(); ++i) {
std::printf("[udpscope] channel %zu '%s' stored=%llu published=%llu\n", i,
model.channels()[i].name.c_str(),
static_cast<unsigned long long>(model.live().values(i).produced()),
static_cast<unsigned long long>(model.live().channel(i).size()));
}
// `frames_painted` is the area's, read from the snapshot taken before the window was
// destroyed (the PlotArea wrapper dies with its widget): one figure, one render per
// frame, so it is the same count for every row.
std::printf("[udpscope] rows: %d of at most %d, layout editor %s\n", rows.row_count(),
RowSet::kMaxRows, app.layout_editing ? "up" : "down");
for (int i = 0; i < rows.row_count() && i < RowSet::kMaxRows; ++i) {
if (rows.row(i).plot == nullptr) continue;
std::printf("[udpscope] plot %d frames_painted=%d traces=%u\n", i,
app.dbg_frames_painted, rows.row(i).plot->trace_count());
}
// Where a frame's time went, split at the only two boundaries observable from the
// host: the frame function, the plot's draw, and the toolkit's whole paint. `draw`
// close to `paint` is the library's cost; `paint` far above `draw` is the toolkit
// compositing a framebuffer (which llvmpipe dominates). `path` says whether the
// traces went through the LOD pyramid at all -- a raw frame is O(visible samples).
std::printf("[udpscope] frame cost: tick %.2f/%.2f ms over %d, draw %.2f/%.2f ms "
"(max at frame %d), paint %.2f/%.2f ms over %d (mean/max)\n",
app.dbg_tick_n > 0 ? app.dbg_tick_ms_sum / app.dbg_tick_n : 0.0,
app.dbg_tick_ms_max, app.dbg_tick_n,
app.dbg_paint_n > 0 ? app.dbg_draw_ms_sum / app.dbg_paint_n : 0.0,
app.dbg_draw_ms_max, app.dbg_draw_max_frame,
app.dbg_paint_n > 0 ? app.dbg_paint_ms_sum / app.dbg_paint_n : 0.0,
app.dbg_paint_ms_max, app.dbg_paint_n);
std::printf("[udpscope] frame path: raw=%d lod=%d columns=%d graded=%d frames, segments "
"%llu/%u (mean/max), visible max %llu samples\n",
app.dbg_raw_frames, app.dbg_lod_frames, app.dbg_column_frames,
app.dbg_graded_frames,
static_cast<unsigned long long>(app.dbg_paint_n > 0
? app.dbg_seg_sum / app.dbg_paint_n
: 0),
app.dbg_seg_max, static_cast<unsigned long long>(app.dbg_visible_max));
// Layout stability: every one of these must be a single value. A range means
// something is resizing or re-measuring the plot, which is what a wiggling axis
// looks like from the outside.
std::printf("[udpscope] layout over %d frames: axes.x %d..%d, axes.w %d..%d, "
"viewport.w %d..%d, y-gutter %d..%d, x-ticks %d..%d, x-desync %d\n",
app.dbg_axes_frames, app.dbg_axes_x_min, app.dbg_axes_x_max, app.dbg_axes_w_min,
app.dbg_axes_w_max, app.dbg_vp_w_min, app.dbg_vp_w_max, app.dbg_gutter_min,
app.dbg_gutter_max, app.dbg_xtick_min, app.dbg_xtick_max, app.dbg_x_desync);
std::printf("[udpscope] axes.w changes: %d (last at frame %d, side panel up=%d)\n",
app.dbg_axes_w_changes, app.dbg_axes_w_change_frame,
app.dbg_axes_w_change_side);
std::printf("[udpscope] reset button sensitive: %d\n", app.dbg_reset_sensitive);
std::printf("[udpscope] render blank frames: %d of %d painted\n",
app.dbg_render_blank_frames, app.dbg_frames_painted);
std::printf("[udpscope] traceless paints: %d (%d after the first bind, last at paint %d)\n",
app.dbg_notrace_frames, app.dbg_notrace_after_bound, app.dbg_notrace_last);
std::printf("[udpscope] worst side/pane min width: %d px\n", app.dbg_min_w_worst);
std::printf("[udpscope] trace ink: %d paints checked, %d collapsed, %d staircase, %d short, "
"worst cover %.1f%%, worst drawn-vs-quoted %.4f px over %d raw paints\n",
app.dbg_ink_checked, app.dbg_ink_collapse, app.dbg_ink_staircase,
app.dbg_ink_short, app.dbg_ink_worst_cover * 100.0, app.dbg_ink_worst_offset_px,
app.dbg_ink_raw_checked);
std::printf("[udpscope] blank view frames: %d of %d\n", app.dbg_blank_frames,
app.dbg_axes_frames);
std::printf("[udpscope] trigger events: %llu total, %llu at the disarm (no event after it "
"means Disarm reached the engine)\n",
static_cast<unsigned long long>(model.trigger_latches().load()),
static_cast<unsigned long long>(app.dbg_latches_at_disarm));
std::printf("[udpscope] window control: '%s'\n", app.dbg_window_text.c_str());
{
// The signals panel's frame, read from the window rather than from the `.ui`: the two labels and the
// list the file gives it. A `.ui` that lost one of them reads here -- `labels=1` or `list=0` -- rather
// than showing as a panel quietly missing its hint.
GtkWidget* panel = app.signal_list != nullptr ? gtk_widget_get_parent(app.signal_list) : nullptr;
// Counted over the panel's *subtree*, not its direct children: the heading lives in a heading row
// beside the pane's [edit] button, and a count of children would read 1 whatever the file lost.
int labels = 0;
int lists = 0;
const std::function<void(GtkWidget*)> count = [&](GtkWidget* w) {
// The list stops the walk: its rows are the code's, and this reading is about the file's frame.
if (GTK_IS_LIST_BOX(w)) {
++lists;
return;
}
if (GTK_IS_LABEL(w)) ++labels;
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
count(c);
}
};
if (panel != nullptr) count(panel);
// The list's shape, beside the frame: `rows` is what the list holds, `signals` the streamed
// ones, and the `MATH` divider plus the math channels account for the rest -- so
// `rows == signals + 1 + math` is the invariant, and `divider=0` is a list that lost its `+`.
std::printf("[udpscope] signals panel: labels=%d list=%d rows=%d signals=%zu math=%zu "
"divider=%d drag_sources=%d\n",
labels, lists, app.dbg_signal_rows, app.dbg_signal_plain, app.math.size(),
app.dbg_signal_rows ==
static_cast<int>(app.dbg_signal_plain + 1 + app.math.size())
? 1
: 0,
app.dbg_signal_drag_sources);
}
{
// The events pane's frame, read from the window: the four widgets the `.ui` gives it, and the list
// being inside the revealer the file builds. A control that lost its id reads 0 here rather than
// showing as a pane missing a button, and `nested=0` is a list that is no longer in the pane at all.
const auto inside = [](GtkWidget* w, GtkWidget* root) {
if (w == nullptr) return 0;
for (GtkWidget* p = w; p != nullptr; p = gtk_widget_get_parent(p)) {
if (p == root) return 1;
}
return 0;
};
std::printf("[udpscope] events pane: compare=%d accumulate=%d list=%d revealer=%d nested=%d\n",
app.events_compare != nullptr ? 1 : 0, app.events_accumulate != nullptr ? 1 : 0,
app.events_list != nullptr ? 1 : 0, app.events_revealer != nullptr ? 1 : 0,
inside(app.events_list, app.events_revealer));
}
std::printf("[udpscope] panels: side=%dx%d (visible=%d) signals=%dx%d row_h=%d "
"(page_current=%d) trigger=%dx%d (sensitive=%d) switcher=%d "
"cursors=%d window=%.3fs window_cap=%.1fs\n",
app.dbg_side_w, app.dbg_side_h, app.dbg_side_column_after_hide, app.dbg_signal_w,
app.dbg_signal_h, app.dbg_signal_row_h, app.dbg_signal_page_current,
app.dbg_trigger_w, app.dbg_trigger_h,
app.dbg_trigger_sensitive, app.dbg_switcher_visible,
rows.cursors_visible() ? 1 : 0, rows.time_window(),
app.window_limit_s);
{
GtkIconTheme* theme = gtk_icon_theme_get_for_display(gdk_display_get_default());
std::printf("[udpscope] pane drag: sidebar hidden for the drag=%d restored after it=%d\n",
app.dbg_drag_hid_sidebar, app.dbg_drag_restored_sidebar);
std::printf("[udpscope] pane edits: locked=%s(%s) config=%s(%s)\n",
app.dbg_pane_edit_icon.c_str(),
gtk_icon_theme_has_icon(theme, app.dbg_pane_edit_icon.c_str()) ? "ok" : "MISSING",
app.dbg_pane_view_icon.c_str(),
gtk_icon_theme_has_icon(theme, app.dbg_pane_view_icon.c_str()) ? "ok" : "MISSING");
}
std::printf("[udpscope] panes: trigger=%s signals=%s body_visible=%d summary='%s'\n",
app.dbg_trigger_locked ? "locked" : "config",
app.dbg_signals_locked ? "locked" : "config", app.dbg_trigger_body_visible,
app.dbg_trigger_summary.c_str());
std::printf("[udpscope] panes: the same line as the probe found it: '%s'\n",
app.dbg_trigger_summary_early.c_str());
std::printf("[udpscope] panel switches: trigger=%d/%d cursors=%d/%d drove_toolbar=%d "
"(pane switch / header bar button; `drove_toolbar` is the pane's own switch having "
"moved the button)\n",
app.dbg_panel_switch_trigger, app.dbg_toolbar_switch_trigger,
app.dbg_panel_switch_cursors, app.dbg_toolbar_switch_cursors,
app.dbg_panel_drove_toolbar);
std::printf("[udpscope] switcher: policy=%s pills=%d labels=%d names=%d icon=%dx%d "
"size=%dx%d\n",
app.dbg_switcher_policy == 0 ? "narrow" : (app.dbg_switcher_policy == 1 ? "wide" : "?"),
app.dbg_switcher_pills, app.dbg_switcher_labels, app.dbg_switcher_names,
app.dbg_switcher_icon_w, app.dbg_switcher_icon_h, app.dbg_switcher_w,
app.dbg_switcher_h);
std::printf("[udpscope] mode with trigger on: %s\n", app.dbg_mode_triggered.c_str());
std::printf("[udpscope] trigger marker x=%.3f, view x_max=%.3f; model trigger_time=%.3f "
"valid=%d\n",
app.dbg_marker_x, app.dbg_marker_max, app.dbg_trigger_time,
app.dbg_trigger_time_valid);
std::printf("[udpscope] mode after go-live: %s\n", app.dbg_mode_live.c_str());
std::printf("[udpscope] status bar: glance=[● %s] max_glance_chars=%zu detail_fields=11 "
"popover_rebuilds=%d\n",
app.dbg_glance_last.c_str(), app.glance_max_chars, app.popover_rebuilds);
// The derived channels beside the channel they came from. A math channel defined by the pane
// reaches the producer by exactly the path the probe uses, so this is the pipeline's own
// assertion: `x2` must read twice its source's extremes, a low pass must read less.
{
std::vector<std::pair<std::string, int>> wanted;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
// The first *streamed* channel too: it is the one a saturated ring clamps at the front, and
// the order check below is the only place that shows it without a screenshot.
if (!app.model->channels().empty()) {
wanted.emplace_back(app.model->channels().front().name, 0);
}
for (const MathChannel& m : app.model->math_channels()) {
int idx = -1;
for (std::size_t c = 0; c < app.model->channels().size(); ++c) {
if (app.model->channels()[c].name == m.name) idx = static_cast<int>(c);
}
wanted.emplace_back(m.name, idx);
}
}
for (const auto& [name, idx] : wanted) {
if (idx < 0) {
std::printf("[udpscope] math '%s': no channel (disabled?)\n", name.c_str());
continue;
}
std::vector<double> t;
std::vector<double> v;
const bool ok = visible_samples(app, static_cast<std::size_t>(idx), t, v);
double lo = 0.0;
double hi = 0.0;
for (std::size_t i = 0; ok && i < v.size(); ++i) {
lo = i == 0 ? v[i] : std::min(lo, v[i]);
hi = i == 0 ? v[i] : std::max(hi, v[i]);
}
// The stamps of the window the plot would draw: they must be strictly increasing. A
// duplicate or a backwards step is what a wrap artifact looks like from the data side --
// the renderer's index window comes from a binary search over these, and a segment drawn
// from the end of the ring to its beginning is a window that spans the wrap.
// A break can only be at the *front*: the window's oldest slots may be clamped to the oldest
// readable stamp (the published window is pinned while the rings slide under it), so a
// saturated ring legitimately shows a few non-increasing stamps at the start. A break
// anywhere else is a real one, so the *first* break's position is reported.
std::size_t order_breaks = 0;
long long first_break = -1;
for (std::size_t i = 1; ok && i < t.size(); ++i) {
if (!(t[i] > t[i - 1])) {
++order_breaks;
if (first_break < 0) first_break = static_cast<long long>(i);
}
}
// `produced` beside the visible count: a derived channel is filled once per stored time
// slot, so its ring must hold exactly as many samples as the time ring the plot reads it
// against. The first version pushed twice per slot and this ratio would have said so.
u64 produced = 0;
u64 slot_count = 0;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
produced = app.model->live().values(static_cast<std::size_t>(idx)).produced();
slot_count = app.model->live().time().produced();
}
std::printf("[udpscope] math '%s': channel %d, %llu samples vs %llu time slots, "
"%zu visible (order breaks %zu, first at %lld), y=[%.4f, %.4f]\n",
name.c_str(), idx, static_cast<unsigned long long>(produced),
static_cast<unsigned long long>(slot_count), ok ? v.size() : 0u, order_breaks,
first_break, ok ? lo : 0.0, ok ? hi : 0.0);
}
// What the derived channels cost the *producer*. This is not a frame cost: the math runs
// between two DATA packets on the acquisition thread, so it never appears in `frame cost:`
// and shows up only as dropped packets once it exceeds the inter-packet budget. `ns/slot`
// is the figure to compare across runs; it is *sampled* (see `ScopeModel::math_slots()`),
// because timing every slot costs about as much as a slot and would measure itself.
if (app.model->math_timed() > 0) {
const double per_slot = static_cast<double>(app.model->math_cost_ns()) /
static_cast<double>(app.model->math_timed());
std::printf("[udpscope] math cost: %llu slots, %.1f ns/slot (%llu timed), "
"%.2f ms total est\n",
static_cast<unsigned long long>(app.model->math_slots()), per_slot,
static_cast<unsigned long long>(app.model->math_timed()),
per_slot * static_cast<double>(app.model->math_slots()) / 1.0e6);
}
}
// What freezing a view costs, counted apart for the two publishes because they stall opposite
// threads (see `ScopeModel::publish_event_count()`). This is the figure a second live ring would
// buy: freezing a whole `BufferStore` copies nothing and inherits the pyramid the producer
// already maintains, so both `worst` figures would be 0 -- at a second budget's worth of memory
// and a second per-sample write on the producer. Read `event worst` against `frame cost:`'s
// `tick` max (the same stall, seen from the UI) and `pause worst` against `gaps`.
{
const auto ev = app.model->publish_event_count();
const auto pa = app.model->publish_pause_count();
std::printf("[udpscope] publish cost: %llu events, %.2f/%.2f ms (mean/worst, blocks the UI);"
" %llu pauses, %.2f/%.2f ms (mean/worst, blocks the producer)\n",
static_cast<unsigned long long>(ev),
ev > 0 ? static_cast<double>(app.model->publish_event_us_total()) /
static_cast<double>(ev) / 1000.0
: 0.0,
static_cast<double>(app.model->publish_event_us_worst()) / 1000.0,
static_cast<unsigned long long>(pa),
pa > 0 ? static_cast<double>(app.model->publish_pause_us_total()) /
static_cast<double>(pa) / 1000.0
: 0.0,
static_cast<double>(app.model->publish_pause_us_worst()) / 1000.0);
}
// Unconditional, unlike the pane's own lines below: this is about the instruments the app
// *created*, which exist whether or not any measurement was added.
std::printf("[udpscope] readout instruments locked: %d (trigger marker + measurement marks)\n",
app.rows->readouts_locked() ? 1 : 0);
// Where the panel controls live. Computed once, when the widgets exist -- the dump runs *after*
// the window is destroyed, where a widget pointer is dangling and asking GTK about it is a
// critical (which is how the first version of this line earned four of them).
std::printf("[udpscope] panel controls: %s\n", app.dbg_panel_controls.c_str());
std::printf("[udpscope] toolbar icons: %s\n", app.dbg_toolbar_icons.c_str());
std::printf("[udpscope] page icons: %s\n", app.dbg_page_icons.c_str());
std::printf("[udpscope] app icons: %s\n", app.dbg_app_icons.c_str());
// The measurements pane, with the numbers the engine computed: a probe adds measurements and
// this is where their values are read, so a wrong threshold or a window that moved under a
// stale result is visible in the run rather than only on screen.
if (!app.measures.empty()) {
std::printf("[udpscope] measurements: %zu", app.measures.size());
for (std::size_t i = 0; i < app.measures.size() && i < app.measure_results.size(); ++i) {
const MeasureResult& r = app.measure_results[i];
std::printf(" %s=%s", measure_type_name(app.measures[i].type),
r.valid ? std::to_string(r.primary).c_str() : "--");
if (r.has_secondary) std::printf("(%s)", std::to_string(r.secondary).c_str());
}
std::printf("\n");
std::printf("[udpscope] clamp fires: %d (a clamp that fires every frame is a re-frame loop)\n",
app.rows->clamp_fires());
if (app.measure_passes > 0) {
std::printf("[udpscope] measure cost: %d recomputes, %.2f ms total, %.2f ms each, "
"data %.2f ms (%.0f%%), covered=%llu buckets=%llu raw=%llu (%.1f%%), "
"%.1f ns/covered sample\n",
app.measure_passes, app.measure_cost_us / 1000.0,
app.measure_cost_us / 1000.0 / app.measure_passes,
app.measure_fetch_us / 1000.0,
app.measure_cost_us > 0
? 100.0 * static_cast<double>(app.measure_fetch_us) /
static_cast<double>(app.measure_cost_us)
: 0.0,
static_cast<unsigned long long>(app.measure_covered),
static_cast<unsigned long long>(app.measure_bucketed),
static_cast<unsigned long long>(app.measure_raw),
app.measure_covered > 0 ? 100.0 * static_cast<double>(app.measure_raw) /
static_cast<double>(app.measure_covered)
: 0.0,
app.measure_covered > 0
? 1000.0 * static_cast<double>(app.measure_cost_us) /
static_cast<double>(app.measure_covered)
: 0.0);
}
}
std::printf("[udpscope] badge frames: lit=%d dim=%d idle=%d connecting=%d\n",
app.dbg_badge_frames[0], app.dbg_badge_frames[1], app.dbg_badge_frames[2],
app.dbg_badge_frames[3]);
std::printf("[udpscope] trigger panel sensitive before the probe toggled it: %d; "
"signal list items=%d selected=%d; column visible after sidebar toggle off=%d; "
"pane switches live while their panes are off=%d; arm sensitive with the trigger off=%d\n",
app.dbg_trigger_sensitive_default, app.dbg_trigger_items,
app.dbg_trigger_selected, app.dbg_side_column_after_hide,
app.dbg_panel_switch_live, app.dbg_arm_sensitive);
{
// The panel's *frame* read back: the row boxes the `.ui` holds, and that every control the code built
// ended up inside the panel rather than in a row that is not there. A control is created in code and
// placed by an id, so an id that drifted leaves it built and nowhere -- which looks like a panel
// missing a field, and is what `controls_inside` counts. `[defaults]` prints the same panel's spins
// in tree order, so the two together read what the file gave and what the code put in it.
const auto inside = [](GtkWidget* w, GtkWidget* root) {
if (w == nullptr) return 0;
for (GtkWidget* p = w; p != nullptr; p = gtk_widget_get_parent(p)) {
if (p == root) return 1;
}
return 0;
};
// The *file's* panel (`trigger_body`), not the wrapper this app puts it in: the heading row and the
// locked summary are the code's arrangement, and what these counts read is what the `.ui` holds.
int boxes = 0;
for (GtkWidget* c = gtk_widget_get_first_child(app.trigger_body); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
if (GTK_IS_BOX(c)) ++boxes;
}
// `search_widgets` separated from `search_inside` on purpose: the first says the ids resolved, the
// second says they are in the panel. A `.ui` that lost one and a `.ui` that lost its *place* are two
// different mistakes, and only one of them shows as a missing button.
// The expander's child is **not** in the widget-parent chain: `gtk_widget_get_parent()` on it stays
// NULL even after the window is presented, while `gtk_expander_get_child()` returns it (measured with
// a throwaway program, all three ways of setting it). So the walk the search widgets are read with has
// to start at the expander's child and not at the panel -- walking to the panel reads 0/4 on a panel
// that is entirely correct, which is what this line did first.
GtkWidget* search_root = app.trigger_body;
for (GtkWidget* c = gtk_widget_get_first_child(app.trigger_body); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
if (GTK_IS_EXPANDER(c)) {
if (GtkWidget* inner = gtk_expander_get_child(GTK_EXPANDER(c))) search_root = inner;
break;
}
}
const int search_present =
(app.search_run_button != nullptr) + (app.search_cancel_button != nullptr) +
(app.search_prev_button != nullptr) + (app.search_next_button != nullptr);
std::printf(
"[udpscope] trigger panel: boxes=%d controls_inside=%d/15 search_widgets=%d/4 "
"search_inside=%d/4\n",
boxes,
inside(app.trigger_signal_drop, app.trigger_body) +
inside(app.trigger_type_drop, app.trigger_body) +
inside(app.trigger_edge_drop, app.trigger_body) +
inside(app.trigger_cmp_drop, app.trigger_body) +
inside(app.trigger_mode_drop, app.trigger_body) +
inside(app.trigger_width_spin, app.trigger_body) +
inside(app.trigger_width2_spin, app.trigger_body) +
inside(app.trigger_level2_spin, app.trigger_body) +
inside(app.trigger_timeout_spin, app.trigger_body) +
inside(app.trigger_level_spin, app.trigger_body) +
inside(app.trigger_pre_spin, app.trigger_body) +
inside(app.trigger_holdoff_spin, app.trigger_body) +
inside(app.trigger_band_spin, app.trigger_body) +
inside(app.trigger_hf_spin, app.trigger_body) +
inside(app.events_record_switch, app.trigger_body),
search_present,
inside(app.search_run_button, search_root) + inside(app.search_cancel_button, search_root) +
inside(app.search_prev_button, search_root) + inside(app.search_next_button, search_root));
}
{
plot::Plot& p = *rows.row(0).plot;
const plot::PlotLayout& lay = p.layout();
int visible = 0;
for (plot::u32 t = 0; t < p.trace_count(); ++t) {
if (p.trace(t).visible()) ++visible;
}
std::printf("[udpscope] row 0 plot: x=[%.3f,%.3f] y=[%.3f,%.3f] axes=%.0fx%.0f "
"visible_traces=%d legend_rows=%zu legend_panel=%.0fx%.0f offset=%.3f "
"trigger_marker_x=%.3f\n",
p.x_range(0).min, p.x_range(0).max, p.y_range(0).min, p.y_range(0).max,
double(lay.axes.w), double(lay.axes.h), visible, lay.legend.rows.size(),
double(lay.legend.panel.w), double(lay.legend.panel.h), p.x_offset(0),
p.cursor(rows.row(0).trigger_marker).x);
for (plot::u32 t = 0; t < p.trace_count(); ++t) {
const plot::Trace& tr = p.trace(t);
if (tr.series == nullptr) continue;
std::printf("[udpscope] trace %u '%s' size=%llu x0=%.3f dx=%.6f bounds=[%.3f,%.3f]\n",
t, tr.name.c_str(), static_cast<unsigned long long>(tr.series->size()),
tr.series->x0(), tr.series->dx(), tr.series->x_bounds().min,
tr.series->x_bounds().max);
}
}
{
static const char* const kModes[] = {"vertical", "horizontal", "both", "mix"};
const int mode = static_cast<int>(rows.cursor_mode());
// What the *plot* holds, which is what the renderer draws from: a marker the app
// placed but the plot parked (or hid) would show up here and not on screen.
for (int i = 0; i < rows.row_count(); ++i) {
const RowSet::Row& r = rows.row(i);
if (r.plot == nullptr) continue;
const plot::Plot& p = *r.plot;
std::printf("[udpscope] cursors in plot %d: vis=%d a=(x=%.6f vis=%d) "
"b=(x=%.6f vis=%d) ruler=(y=%.6f vis=%d) ruler_b=(y=%.6f vis=%d) "
"stored_rulers=%.6f,%.6f\n",
i, rows.cursors_visible() ? 1 : 0, p.cursor(r.cursor_a).x,
p.cursor(r.cursor_a).visible, p.cursor(r.cursor_b).x,
p.cursor(r.cursor_b).visible, p.cursor(r.ruler).y,
p.cursor(r.ruler).visible, p.cursor(r.ruler_b).y,
p.cursor(r.ruler_b).visible, r.ruler_y, r.ruler_b_y);
// Where the plot would draw them: the row's own axes rect and the device
// coordinate the ruler's y maps to (`cursor_screen_coordinate`).
const plot::PlotLayout& lay = p.layout();
std::printf("[udpscope] cursors in plot %d: axes=(%.0f,%.0f %.0fx%.0f) "
"ruler_screen=%.1f ruler_b_screen=%.1f y_range=[%.4g..%.4g]\n",
i, double(lay.axes.x), double(lay.axes.y), double(lay.axes.w),
double(lay.axes.h), double(p.cursor_screen_coordinate(r.ruler)),
double(p.cursor_screen_coordinate(r.ruler_b)), p.y_range(r.axis_y).min,
p.y_range(r.axis_y).max);
}
std::printf("[udpscope] cursors: on=%d mode=%s t0=%.6f t1=%.6f dt=%.6f",
rows.cursors_visible() ? 1 : 0, kModes[mode], rows.cursor_a_x(),
rows.cursor_b_x(), rows.cursor_b_x() - rows.cursor_a_x());
for (int r = 0; r < rows.row_count(); ++r) {
std::printf(" y%d=[%.6g,%.6g]", r, rows.ruler_y(r), rows.ruler_b_y(r));
}
std::printf("\n");
}
for (std::size_t i = 0; i < app.measures.size(); ++i) {
const Measure& m = app.measures[i];
const MeasureResult& r = i < app.measure_results.size() ? app.measure_results[i]
: MeasureResult{};
std::printf("[udpscope] measure %zu: '%s' %s thr=%.1f%% above=%d show=%d -> %s "
"%.9g",
i, m.signal.c_str(), measure_type_name(m.type), m.options.threshold_pct,
m.options.above ? 1 : 0, m.options.show_on_plot ? 1 : 0,
r.valid ? "ok" : "invalid", r.valid ? r.primary : 0.0);
// What the row *reads*: the formatted string with its unit (so `frequency` showing Hz
// is asserted here, not inferred), and the row's chip count (two: the measurement's
// colour and the signal's).
if (i < app.measure_text.size()) std::printf(" text=[%s]", app.measure_text[i].c_str());
if (r.valid && r.has_secondary) std::printf(" duty=%.6g", r.secondary);
if (r.valid && !r.marks.empty()) {
// One entry per mark with its kind: the mix is the point (`min` is a ruler *and* a
// marker), so a shape that could only say "level" or "time" would hide the case this
// asserts.
std::printf(" marks=");
for (std::size_t k = 0; k < r.marks.size(); ++k) {
if (k != 0) std::printf(",");
std::printf("%s:%.9g", r.marks[k].level ? "level" : "time", r.marks[k].value);
}
}
std::printf("\n");
}
{
// The x label form: relative to the window's own start (with the caption that names
// that start) when the labels could not carry the tick step absolutely, absolute
// otherwise. `x_origin_set` is read back out of the plot, so this is what the axis is
// actually formatting with, and `note` is the plot's own caption string.
const RowSet::Row& rlt = rows.row(rows.row_count() - 1);
std::string note;
if (rlt.plot != nullptr) note = rlt.plot->x_note();
// The labels themselves, from the axis the reader looks at: the assertion is what the
// ticks *say*, not only which mode is on.
std::string ticks;
bool distinct = true;
if (rlt.plot != nullptr) {
int shown = 0;
std::vector<std::string> seen;
for (const plot::Tick& t : rlt.plot->x_axis(rlt.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 (shown++ < 4) {
if (shown != 1) ticks += ",";
ticks += t.label;
}
}
}
// The labels against the anchor they were built with: the dump runs after the last
// paint, so unlike the probe's timer there is no race here -- the first major must sit
// on the origin (`first_rel` the fraction of a step the window has scrolled since).
double first_value = 0.0;
double origin_set = 0.0;
if (rlt.plot != nullptr) {
origin_set = rlt.plot->x_axis_origin(rlt.axis_x);
for (const plot::Tick& t : rlt.plot->x_axis(rlt.axis_x).ticks) {
if (t.major) {
first_value = t.value;
break;
}
}
}
std::printf("[udpscope] time labels: relative=%d distinct=%d origin=%.6f note=[%s] "
"x_origin_set=%.6f ticks=[%s] first_rel=%.9f\n",
rows.time_labels_relative() ? 1 : 0, distinct ? 1 : 0, rows.time_labels_origin(),
note.c_str(), origin_set, ticks.c_str(), first_value - origin_set);
}
{
std::lock_guard<std::mutex> lock(model.mutex());
// `results_for` is the window the numbers describe, `win` the one on screen now: the
// drift between them is the assertion that a measurement is a statistic of the *current*
// window -- a timer-decoupled recomputation is what used to leave a marker outside it.
// Both on the **stream's** clock, which is the space `measure_window` and the time marks
// are stored in: a live axis is a fixed `[-window, 0]`, so comparing display x would read
// no drift whatever the data did (see `App::measure_window`).
const double s0 = model.stream_at_x0();
const RowSet::Row& r0 = rows.row(0);
const plot::Range view = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{};
const plot::Range win{view.min + s0, view.max + s0};
const double span = app.measure_window.span();
int in_window = 0;
int marks = 0;
for (const RowSet::MeasureMark& mk : app.measure_marks) {
if (mk.level) continue;
++marks;
if (mk.value >= app.measure_window.min && mk.value <= app.measure_window.max) ++in_window;
}
// Whether the marks the app placed are *unselectable* -- the property the pointer's hit
// test respects. Read back out of the plot, so this is the app's own setting and not
// the intent behind it.
int selectable = 0;
for (int i = 0; i < rows.row_count(); ++i) {
const RowSet::Row& r = rows.row(i);
if (r.plot == nullptr) continue;
for (int m = 0; m < RowSet::kMeasureMarks; ++m) {
const std::size_t mi = static_cast<std::size_t>(m);
if (r.plot->cursor(r.measure_h[mi]).selectable) ++selectable;
if (r.plot->cursor(r.measure_v[mi]).selectable) ++selectable;
}
}
std::printf("[udpscope] measures window: win=[%.6f..%.6f] results_for=[%.6f..%.6f] "
"drift=%.4f%% marks_in_window=%d/%d marks_selectable=%d\n",
win.min, win.max, app.measure_window.min, app.measure_window.max,
span > 0.0 ? 100.0 * (std::fabs(win.min - app.measure_window.min) +
std::fabs(win.max - app.measure_window.max)) /
span
: 0.0,
in_window, marks, selectable);
}
{
std::lock_guard<std::mutex> lock(model.mutex());
std::printf("[udpscope] measures pane: n=%zu marks_shown=%d pane_rows=%zu "
"pane_w=%d pane_h=%d switch_on=%d reset_sensitive=%d\n",
app.measures.size(),
rows.measure_marks_placed(), app.measure_labels.size(),
app.dbg_measures_pane_w, app.dbg_measures_pane_h,
// The two widgets the panel's *frame* is read by: the code drives `measure_stats_on`,
// so the switch and the Reset button are where a `.ui` that lost them -- or lost the
// wiring to them -- shows up. `switch_on` disagreeing with `[stats-probe] on=` is that.
app.measures_stats_check != nullptr &&
gtk_check_button_get_active(GTK_CHECK_BUTTON(app.measures_stats_check)) ? 1 : 0,
app.measures_stats_reset != nullptr &&
gtk_widget_get_sensitive(app.measures_stats_reset) ? 1 : 0);
}
// `locked` is re-reported here rather than only in the readouts line: this task added a
// whole new cursor pool, and a pool created selectable is exactly the regression that a
// readout-vs-instrument check is for.
std::printf("[udpscope] search marks: placed=%d hits=%zu locked=%d\n",
rows.search_marks_placed(), app.search.hits().size(),
rows.readouts_locked() ? 1 : 0);
std::printf("[udpscope] header: window_button_w=%d (bar_w=%d)\n",
app.dbg_win_btn_w, app.dbg_bar_w);
std::printf("[udpscope] layout: rows=%d window_s=%.6g", rows.row_count(),
rows.time_window());
for (int r = 0; r < rows.row_count() && r < RowSet::kMaxRows; ++r) {
std::printf(" row%d=[", r);
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
if (s != 0) std::printf(",");
std::printf("'%s'", rows.row(r).signal_name[static_cast<std::size_t>(s)].c_str());
}
std::printf("]");
}
std::printf("\n");
for (int r = 0; r < rows.row_count() && r < RowSet::kMaxRows; ++r) {
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const int ch = rows.channel_of(r, s);
if (ch < 0) continue;
ChannelBuffer* buf = rows.row(r).slots[static_cast<std::size_t>(s)].bound();
std::printf("[udpscope] row %d slot %d -> channel %d '%s' samples=%llu\n", r, s, ch,
rows.row(r).name[static_cast<std::size_t>(s)].c_str(),
static_cast<unsigned long long>(buf != nullptr ? buf->size() : 0));
}
}
}
return result;
}
} // namespace ui_impl
/// The public entry point `ui.hpp` declares. The front end's own `run_ui` lives with the rest
/// of the window construction in `ui_impl`, which owns `App`; this is the one function the rest
/// of the program sees.
int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisition& acq,
RowSet& rows) {
return ui_impl::run_ui(argc, argv, cfg, model, acq, rows);
}
} // namespace udpscope