Files
udpscope/src/ui.cpp
T
Martino Ferrari a29f38eaa9 session: remember extra windows, and reopen them from the menu
A scope or detached window was never written to the session, and a detached figure vanished from
the layout at the next start -- the figure had been removed from the main rows, and nothing kept
the window that held it.

Each window is now a `[[window]]` block in the session -- `name`, `detached`, and its own
`[[window.plot]]` rows with the same keys the main `[[plot]]` rows carry (signals, row weight,
cell width). `collect_layout()` writes the open windows' present arrangement plus the ones already
closed; loading puts **all** of them in `App::closed_windows` and never opens one: a window is a
view the user closed, and a launch that tiled windows onto the desktop would take that decision
for them (a tiling WM would tile them straight away). The overflow menu gains **Reopen closed
windows (N)**, which opens every remembered window through `open_scope_window_from_state()` -- the
one window constructor, then its rows, weights and signals dealt into its row set.

Closing a window records it, by name (a close/reopen cycle replaces its entry rather than
multiplying them), and schedules the save; **attaching** records nothing, because that window's
figure is on the main window again. A detached figure therefore survives a quit through its
window's entry.

Probe: `[scope-probe] loaded closed=N scopes=0` (remembered, none opened) and `reopen
scopes=..->.. remembered=.. left=0` (close, remember, the menu item's own click reopens).
Docs: README, the Mallard layout page, the probe readings and the session invariant.
2026-11-06 12:06:21 +01:00

8408 lines
444 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/registry.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 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);
char* escaped_title = g_markup_escape_text(app.source_title.c_str(), -1);
gtk_label_set_markup(GTK_LABEL(title), (std::string("<b>") + escaped_title + "</b>").c_str());
g_free(escaped_title);
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.
//
// The handler **returns FALSE**, and that return is the whole of "the dialog can be closed":
// `close-request`'s default handler is what destroys the window, and a handler that returns
// `TRUE` suppresses it. This one was a bare `void` lambda -- the value GTK read as its verdict
// was whatever the compiler happened to leave in the return register, and a build of exactly this
// shape (gtk4, -O2, one `void` lambda on `close-request`) leaves it non-zero -- so both the Close
// button and the window manager's button emitted
// `close-request`, were told "handled", and **the dialog stayed up**. It is the same shape the
// math dialog's and the chooser's handlers already carry, and it cannot be seen in a callback's
// signature: `G_CALLBACK` is a cast, so a `void(GtkWindow*, gpointer)` where GTK expects
// `gboolean(GtkWindow*, gpointer)` compiles without a warning.
g_signal_connect_swapped(window, "close-request", G_CALLBACK(+[](gpointer user) -> gboolean {
static_cast<App*>(user)->config_dialog = nullptr;
return FALSE; // let the default handler destroy the window
}),
&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);
// The corner toggles are revealed by proximity, so the pointer's position is kept here,
// on the **overlay** and not on the GL area: a revealed toggle sits *under* the pointer,
// and the event still travels the ancestor -- a leave on the GL widget would fire the
// moment the button appeared and hide it again (the flicker the overlay avoids). `leave`
// here means the pointer left the plot altogether, which is when the toggles go away.
GtkEventController* motion = gtk_event_controller_motion_new();
g_signal_connect(motion, "motion", G_CALLBACK(+[](GtkEventControllerMotion*, double x,
double y, gpointer user) {
App& a = *static_cast<App*>(user);
a.area_pointer_x = x;
a.area_pointer_y = y;
a.area_pointer_valid = true;
}),
&app);
g_signal_connect(motion, "leave", G_CALLBACK(+[](GtkEventControllerMotion*, gpointer user) {
static_cast<App*>(user)->area_pointer_valid = false;
}),
&app);
gtk_widget_add_controller(app.area_overlay, GTK_EVENT_CONTROLLER(motion));
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());
}
/// A second top-level window: the same stream, another figure, its own `RowSet`. The main
/// window's tick is its bind/acquire/repaint pump (see `tick()`), so the two never disagree
/// about the data or the display mode. `figure_row` names the main window's figure to copy --
/// its cells, their width shares and every slot's signal in its colour -- because "open this
/// one big" is the reason a user reaches for a second window; -1 opens an empty scope.
void open_scope_window(App& app, int figure_row, bool detached) {
if (figure_row >= app.rows->figure_row_count()) figure_row = -1;
auto scope = std::make_unique<App::ScopeWindow>();
scope->rows = std::make_unique<RowSet>();
if (figure_row >= 0) {
const int cells = app.rows->cells_in_row(figure_row);
scope->rows->set_cells_per_row(std::vector<int>(1, cells));
if (cells > 1) scope->rows->set_cell_weights(0, app.rows->cell_weights(figure_row));
const int first = app.rows->first_plot_of_row(figure_row);
for (int k = 0; k < cells; ++k) {
const RowSet::Row& src = app.rows->row(first + k);
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (src.signal_name[si].empty()) continue;
scope->rows->assign(k, s, src.signal_name[si], src.name[si], src.color_index[si]);
}
}
}
scope->rows->set_time_window(app.rows->time_window());
// The preference travels: the scope draws the same chrome the main window does, unless
// the setting is off.
scope->rows->set_crosshair_axis_labels(app.settings.crosshair_axis_labels);
scope->area = plot::gtk4::PlotArea::create(scope->placeholder);
scope->area->set_figure(&scope->rows->figure());
scope->widget = scope->area->widget();
gtk_widget_set_hexpand(scope->widget, true);
gtk_widget_set_vexpand(scope->widget, true);
gtk_widget_set_size_request(scope->widget, 320, 160);
// The window's short name, what the signal page's *Plot on* groups carry: "plot N" for a copy
// (of the main window's figure N), "scope N" -- numbered app-wide -- for an empty one.
scope->name = figure_row >= 0 ? "plot " + std::to_string(figure_row + 1)
: "scope " + std::to_string(++app.scope_seq);
// A drop target like the main area's: a signal badge dragged onto this window's plot lands in
// that window's own row set, on the cell the pointer is over.
scope->drop = gtk_drop_target_new(G_TYPE_STRING, GDK_ACTION_COPY);
g_object_set_data(G_OBJECT(scope->drop), "udpscope-scope", scope.get());
g_signal_connect(scope->drop, "drop", G_CALLBACK(on_scope_drop_channel), &app);
gtk_widget_add_controller(scope->widget, GTK_EVENT_CONTROLLER(scope->drop));
// The window's own frame pump: a tick callback on its area asks GTK for one frame per tick,
// which is what keeps *this* toplevel's frame clock running. The main window's tick binds and
// queues the render, but a second toplevel whose clock is never advanced keeps its queue full
// and presents nothing -- a blank plot with axes nowhere to be seen.
gtk_widget_add_tick_callback(scope->widget,
+[](GtkWidget* widget, GdkFrameClock*, gpointer) -> gboolean {
gtk_gl_area_queue_render(GTK_GL_AREA(widget));
return G_SOURCE_CONTINUE;
},
nullptr, nullptr);
// The window's own right-click menu: its y scales, its traces, its removals -- the same
// actions the main window's plot menu carries, aimed at **this window's** row set.
GtkGesture* right = gtk_gesture_click_new();
gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(right), 3);
g_signal_connect(right, "pressed", G_CALLBACK(on_scope_right_click), &app);
gtk_widget_add_controller(scope->widget, GTK_EVENT_CONTROLLER(right));
scope->detached = detached;
scope->window = GTK_WINDOW(gtk_window_new());
std::string title = figure_row >= 0
? "udpscope \u2014 plot " + std::to_string(figure_row + 1)
: std::string("udpscope \u2014 scope");
if (detached) title += " (detached)";
gtk_window_set_title(scope->window, title.c_str());
gtk_window_set_default_size(scope->window, 900, 500);
// The overlay holds every corner control this window has: the attach button (detached windows
// only), the layout toggle, and the layout editor's own buttons. It is **always** created, not
// only for detached windows, because a scope is a view with its own arrangement and every one of
// them can be rearranged. The motion controller lives on the overlay, so a revealed button sits
// *under* the pointer and the event still travels the ancestor -- a leave on the GL widget would
// fire the moment the button appeared and hide it again.
scope->overlay = gtk_overlay_new();
gtk_overlay_set_child(GTK_OVERLAY(scope->overlay), scope->widget);
gtk_window_set_child(scope->window, scope->overlay);
GtkEventController* motion = gtk_event_controller_motion_new();
g_signal_connect(motion, "motion", G_CALLBACK(+[](GtkEventControllerMotion*, double x,
double y, gpointer user) {
App::ScopeWindow* scope = static_cast<App::ScopeWindow*>(user);
scope->pointer_x = x;
scope->pointer_y = y;
scope->pointer_valid = true;
}),
scope.get());
g_signal_connect(motion, "leave", G_CALLBACK(+[](GtkEventControllerMotion*, gpointer user) {
static_cast<App::ScopeWindow*>(user)->pointer_valid = false;
}),
scope.get());
gtk_widget_add_controller(scope->overlay, GTK_EVENT_CONTROLLER(motion));
if (detached) {
// Only a detached window has something to put back.
GtkWidget* attach = gtk_button_new_from_icon_name("view-restore-symbolic");
gtk_widget_set_tooltip_text(attach, "attach this figure back to the main window");
gtk_widget_add_css_class(attach, "circular");
gtk_widget_add_css_class(attach, "osd");
gtk_widget_set_size_request(attach, kLayoutButtonPx, kLayoutButtonPx);
gtk_widget_set_visible(attach, FALSE);
g_object_set_data(G_OBJECT(attach), "udpscope-scope", scope.get());
g_signal_connect(attach, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
App& a = *static_cast<App*>(user);
auto* scope =
static_cast<App::ScopeWindow*>(g_object_get_data(G_OBJECT(b),
"udpscope-scope"));
if (scope == nullptr) return;
outlive_the_rebuild(GTK_WIDGET(b));
attach_scope_window(a, *scope);
}),
&app);
gtk_overlay_add_overlay(GTK_OVERLAY(scope->overlay), attach);
scope->attach_buttons.push_back(attach);
}
// The layout toggle: the editor's door, the same one the main window's overflow menu carries,
// revealed by the same proximity rule.
scope->layout_toggle = gtk_button_new_from_icon_name("view-grid-symbolic");
gtk_widget_set_tooltip_text(scope->layout_toggle,
"layout editor: add and remove figure rows in this window");
gtk_widget_add_css_class(scope->layout_toggle, "circular");
gtk_widget_add_css_class(scope->layout_toggle, "osd");
gtk_widget_set_size_request(scope->layout_toggle, kLayoutButtonPx, kLayoutButtonPx);
gtk_widget_set_visible(scope->layout_toggle, FALSE);
g_object_set_data(G_OBJECT(scope->layout_toggle), "udpscope-scope", scope.get());
g_signal_connect(scope->layout_toggle, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
App& a = *static_cast<App*>(user);
auto* scope =
static_cast<App::ScopeWindow*>(g_object_get_data(G_OBJECT(b),
"udpscope-scope"));
if (scope == nullptr) return;
set_scope_layout_editing(a, *scope, !scope->layout_editing);
}),
&app);
gtk_overlay_add_overlay(GTK_OVERLAY(scope->overlay), scope->layout_toggle);
// The window's keyboard: the same accelerators the main window's keys carry. `L` acts on *this*
// window's layout; every other action reaches the application (see `on_scope_key()`).
GtkEventController* keys = gtk_event_controller_key_new();
g_signal_connect(keys, "key-pressed", G_CALLBACK(on_scope_key), &app);
gtk_widget_add_controller(GTK_WIDGET(scope->window), GTK_EVENT_CONTROLLER(keys));
if (GtkApplication* application = gtk_window_get_application(GTK_WINDOW(app.window))) {
gtk_window_set_application(scope->window, application);
}
// The vector owns the struct, so the window has to remove itself. The erase is deferred to
// an idle: the destroy emission runs before the GL child is finalized, and the area
// wrapper (`PlotArea::create()`'s, owned by the widget) holds references into this struct's
// placeholder plot and row set -- freeing them under the finalization would be a
// use-after-free. One main-loop turn later the widget is gone and the erase is safe.
g_signal_connect(scope->window, "destroy", G_CALLBACK(+[](GtkWindow* window, gpointer user) {
App* a = static_cast<App*>(user);
for (auto it = a->scopes.begin(); it != a->scopes.end(); ++it) {
if ((*it)->window != window) continue;
App::ScopeWindow* victim = it->release();
// A closed window is remembered -- its arrangement is the session's, and
// *Reopen closed windows* brings it back. Attaching is the exception: the
// figure is on the main window again, so a reopen entry would be a
// duplicate of what is already on screen.
if (victim->remember_on_close) remember_closed_window(*a, *victim);
a->scopes.erase(it);
g_idle_add_full(G_PRIORITY_DEFAULT_IDLE,
+[](gpointer p) -> gboolean {
delete static_cast<App::ScopeWindow*>(p);
return G_SOURCE_REMOVE;
},
victim, nullptr);
break;
}
// The signal page's *Plot on* groups carry a button per window: with one gone,
// the pages are rebuilt before anything can read a dangling scope pointer
// (the struct itself is deleted on the idle above).
signal_pages_sync(*a);
}),
&app);
gtk_window_present(scope->window);
app.scopes.push_back(std::move(scope));
}
/// The plot menu's "open this plot in a new window": the figure row the clicked plot belongs
/// to, copied into a scope window; the popover dismisses like every other row's action.
void on_open_scope_row(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const int row = row_key(GTK_WIDGET(button));
const int fr = app->rows->figure_row_of(row);
open_scope_window(*app, fr);
GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(button), "udpscope-popover"));
if (popover != nullptr) gtk_popover_popdown(popover);
}
// --- 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 plots = app.rows->row_count();
const int frows = app.rows->figure_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()) != frows) return;
if (static_cast<int>(fl.cells.size()) != plots) 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 bool insert = g_object_get_data(G_OBJECT(b), kLayoutKindKey) != nullptr;
const bool resize = g_object_get_data(G_OBJECT(b), kLayoutResizeKey) != nullptr;
const bool cell_insert = g_object_get_data(G_OBJECT(b), kLayoutCellKey) != nullptr;
const bool col_resize = g_object_get_data(G_OBJECT(b), kLayoutColKey) != nullptr;
if (col_resize) {
// Column handle: placed at the internal boundary between two cells.
const int plot = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kLayoutColKey));
if (plot <= 0 || plot >= plots) continue;
const plot::Rect& cl = fl.cells[static_cast<std::size_t>(plot - 1)];
const plot::Rect& cr = fl.cells[static_cast<std::size_t>(plot)];
const double cx = (static_cast<double>(cl.right()) + static_cast<double>(cr.x)) / (2.0 * scale);
const double cy = static_cast<double>(cr.y) / scale + static_cast<double>(cr.h) / (2.0 * scale);
gtk_widget_set_margin_start(
b, static_cast<int>(std::clamp(cx - half, 0.0,
std::max(0.0, static_cast<double>(gtk_widget_get_width(app.area_widget)) - kLayoutButtonPx)) + 0.5));
gtk_widget_set_margin_top(b, static_cast<int>(std::clamp(cy - half, 0.0, height - kLayoutButtonPx) + 0.5));
} else if (cell_insert) {
// Cell `+`: placed along the figure row's cell boundaries.
const int tag = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kLayoutCellKey)) - 1;
const int fr = tag / 8;
const int pos = tag % 8;
if (fr < 0 || fr >= frows) continue;
const int first = app.rows->first_plot_of_row(fr);
const int n = app.rows->cells_in_row(fr);
const plot::Rect& row_r = fl.rows[static_cast<std::size_t>(fr)];
const double row_cy = static_cast<double>(row_r.y) / scale +
static_cast<double>(row_r.h) / (2.0 * scale);
double cx;
if (pos == 0) {
cx = static_cast<double>(fl.cells[static_cast<std::size_t>(first)].x) / scale + kLayoutButtonPad;
} else if (pos >= n) {
cx = static_cast<double>(fl.cells[static_cast<std::size_t>(first + n - 1)].right()) / scale -
kLayoutButtonPx - kLayoutButtonPad;
} else {
const double r_edge = static_cast<double>(fl.cells[static_cast<std::size_t>(first + pos - 1)].right()) / scale;
const double l_edge = static_cast<double>(fl.cells[static_cast<std::size_t>(first + pos)].x) / scale;
cx = (r_edge + l_edge) * 0.5 - half;
}
gtk_widget_set_margin_start(
b, static_cast<int>(std::clamp(cx, 0.0,
std::max(0.0, static_cast<double>(gtk_widget_get_width(app.area_widget)) - kLayoutButtonPx)) + 0.5));
// An in-between `+` sits above the divider's resize handle (centred on the same point), or the
// handle, added later, would cover it and "split here" there could not be clicked.
const double top = pos > 0 && pos < n ? row_cy - half - kLayoutButtonPx - 2.0 : row_cy - half;
gtk_widget_set_margin_top(b, static_cast<int>(std::clamp(top, 0.0, height - kLayoutButtonPx) + 0.5));
} else if (resize) {
// Row resize handle: placed on the boundary between figure rows.
const int idx = row_key(b);
const double edge = idx < frows ? 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) {
// Row boundary `+`: placed on the boundary between figure rows.
const int idx = row_key(b);
const double edge = idx < frows ? 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 {
// Per-plot `×`: placed from `fl.cells[idx]`.
const int idx = row_key(b);
if (idx < 0 || idx >= plots) continue;
const plot::Rect& c = fl.cells[static_cast<std::size_t>(idx)];
const double top = std::clamp(static_cast<double>(c.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.
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>(c.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->figure_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 figure rows `at - 1` and `at`
const int frows = app.rows->figure_row_count();
if (at <= 0 || at >= frows) return; // the figure's own edges are not boundaries
if (app.layout_drag_start.size() != static_cast<std::size_t>(frows)) return;
const plot::FigureLayout& fl = app.rows->figure().layout();
if (static_cast<int>(fl.rows.size()) != frows) 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);
}
// --- column (cell) resize drag -----------------------------------------------
// The two cells' widths here are *plot* widths -- `layout().axes.w`, the framed plotting area, which is
// what the plotter places cells by (cell rects add the first cell's y gutter, the last cell's right gutter
// and the column gap on top): the 96 px floor and the plotter's 64 px floor both apply to them, and the
// weights share them out. `offset_px` is the pointer's travel in *device* px (a gesture's logical offset
// times the area's scale).
void layout_col_apply(App& app, int plot, double offset_px) {
RowSet& rs = *app.rows;
const int fr = rs.figure_row_of(plot);
const int k = rs.cell_of(plot);
if (fr < 0 || k < 1) return;
// The weights the drag began with: a drag is relative to its start, so reading the live weights here
// would compound with every update.
if (app.layout_col_drag_start.size() != static_cast<std::size_t>(rs.cells_in_row(fr))) return;
const double scale =
app.area != nullptr && app.area->device_scale() > 0.0f ? app.area->device_scale() : 1.0;
const double pair = app.layout_col_drag_left0 + app.layout_col_drag_right0;
const double floor = std::min(kMinCellPx, pair * 0.5);
const double want_left = std::clamp(app.layout_col_drag_left0 + offset_px / scale,
floor, pair - floor);
// The plotter lays a cell out as `floor + extra * weight / total`, so a width is *not* proportional to
// its weight: the pair's `pair` px are `2 * floor` plus `(pair - 2 * floor)` shared by the two weights.
// Solve for the share that puts the left cell at `want_left`; the pair's weight sum stays what it was,
// which keeps the pair's total width (and every other cell) where it was.
const double spare = pair - 2.0 * kPlotterMinCellPx;
const double frac = spare > 0.0
? std::clamp((want_left - kPlotterMinCellPx) / spare, 0.001, 0.999)
: 0.5;
std::vector<float> w = app.layout_col_drag_start;
const float sum = w[static_cast<std::size_t>(k - 1)] + w[static_cast<std::size_t>(k)];
w[static_cast<std::size_t>(k - 1)] = static_cast<float>(sum * frac);
w[static_cast<std::size_t>(k)] = sum - w[static_cast<std::size_t>(k - 1)];
rs.set_cell_weights(fr, w);
if (app.area_widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(app.area_widget));
}
bool layout_col_drag_begin(App& app, int plot) {
app.layout_col_drag_plot = plot;
if (app.rows == nullptr || plot < 1 || plot >= app.rows->row_count()) return false;
const int fr = app.rows->figure_row_of(plot);
const int k = app.rows->cell_of(plot);
if (fr < 0 || k < 1) return false;
const double scale =
app.area != nullptr && app.area->device_scale() > 0.0f ? app.area->device_scale() : 1.0;
// The weights share out *plot* widths, so the pair is measured as plots: each cell's `axes.w`, the
// plotting area the plotter places a cell by. Not the cell rect: that adds the y gutter of the first
// cell, the right gutter of the last and half a column gap, none of which the weights apply to.
(void)app.rows->figure().layout(); // non-const: re-lays the figure, and every plot, on the current weights
const auto plot_width = [&](int cell) {
const RowSet::Row& r = app.rows->row(cell);
return r.plot != nullptr ? std::max(0.0, static_cast<double>(r.plot->layout().axes.w)) : 0.0;
};
app.layout_col_drag_left0 = plot_width(plot - 1) / scale;
app.layout_col_drag_right0 = plot_width(plot) / scale;
app.layout_col_drag_start = app.rows->cell_weights(fr);
app.layout_col_drag_start.resize(static_cast<std::size_t>(app.rows->cells_in_row(fr)), 1.0f);
return true;
}
/// Where the drag handle sits in the overlay's coordinates (logical px), or `fallback` when it cannot be
/// asked. The handle follows the divider it drags (`sync_layout_editor()` re-places it every frame), so it
/// is *moving* while the gesture runs.
static double layout_col_handle_x(App& app, GtkWidget* handle, double fallback) {
if (handle == nullptr || app.area_overlay == nullptr) return fallback;
graphene_point_t out;
const graphene_point_t origin = GRAPHENE_POINT_INIT(0.0f, 0.0f);
if (!gtk_widget_compute_point(handle, app.area_overlay, &origin, &out)) return fallback;
return static_cast<double>(out.x);
}
double layout_col_total_offset(double offset, double handle_now, double handle_begin) {
return offset + (handle_now - handle_begin);
}
void on_layout_col_begin(GtkGestureDrag* gesture, double, double, gpointer user) {
App& app = *static_cast<App*>(user);
layout_col_drag_begin(
app, GPOINTER_TO_INT(g_object_get_data(G_OBJECT(gesture), "udpscope-layout-col-drag")));
app.layout_col_drag_handle_x0 = layout_col_handle_x(
app, gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(gesture)), 0.0);
}
void on_layout_col_update(GtkGestureDrag* gesture, double offset_x, double, gpointer user) {
App& app = *static_cast<App*>(user);
// The gesture reports its offset in the *handle's* coordinates, and the handle follows the divider, so
// the offset shrinks by the handle's own displacement; adding that back gives the pointer's travel.
const double now = layout_col_handle_x(
app, gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(gesture)), app.layout_col_drag_handle_x0);
const double scale =
app.area != nullptr && app.area->device_scale() > 0.0f ? app.area->device_scale() : 1.0;
layout_col_apply(app, app.layout_col_drag_plot,
layout_col_total_offset(offset_x, now, app.layout_col_drag_handle_x0) * scale);
}
void on_layout_col_end(GtkGestureDrag*, double, double, gpointer user) {
App& app = *static_cast<App*>(user);
app.layout_col_drag_plot = -1;
app.layout_col_drag_left0 = 0.0;
app.layout_col_drag_right0 = 0.0;
app.layout_col_drag_handle_x0 = 0.0;
app.layout_col_drag_start.clear();
schedule_layout_save(app);
}
// --- cell insert handler -----------------------------------------------------
void on_layout_cell_insert(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const int tag = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(button), kLayoutCellKey)) - 1;
const int fr = tag / 8;
const int pos = tag % 8;
if (!app->rows->insert_cell(fr, pos)) {
app->notice = "at most " + std::to_string(RowSet::kMaxCellsPerRow) +
" cells per row and " + std::to_string(RowSet::kMaxRows) + " plots total";
app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
return;
}
if (app->area != nullptr) app->area->set_figure(&app->rows->figure());
rebuild_layout_editor(*app);
rebuild_signal_panels(*app);
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.
/// The look every overlay button shares -- icon, the two CSS classes that make a small round
/// on-screen-display control, and the size -- in one place, because the editor's buttons and
/// the full-screen toggles must not drift apart.
GtkWidget* overlay_button(App& app, const char* icon, const char* tooltip,
std::vector<GtkWidget*>& list) {
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);
list.push_back(b);
return b;
}
/// The editor's own set: on the overlay only while the editor is up, cleared with it.
GtkWidget* layout_overlay_button(App& app, const char* icon, const char* tooltip) {
return overlay_button(app, icon, tooltip, app.layout_buttons);
}
/// Forward declarations: the toggles' handlers are defined after the builder that wires them.
void on_maximize_clicked(GtkButton* b, gpointer user);
void on_detach_clicked(GtkButton* b, gpointer user);
void on_fullscreen_row(GtkButton* b, gpointer user);
void rebuild_maximize_buttons(App& app);
/// The one action behind both the row's toggle and the plot menu's entry: the figure row goes
/// full screen, or -- while one is -- the saved arrangement comes back. The figure is handed
/// over at once (the same path a cell insert takes), the toggles are rebuilt because their
/// number and icon follow the state, and the signal panels are rebuilt because a maximize
/// renumbers which row shows what.
void toggle_fullscreen(App& app, int fr) {
app.rows->toggle_maximize_figure_row(fr);
if (app.area != nullptr) app.area->set_figure(&app.rows->figure());
rebuild_maximize_buttons(app);
rebuild_signal_panels(app);
if (app.layout_editing) rebuild_layout_editor(app);
}
/// One full-screen toggle per figure row: the row to enlarge is the row you are looking at,
/// so the control sits on the row itself rather than behind the layout editor. While a row
/// is maximized the single remaining button is the way back. A layout of one figure has
/// nothing to maximize *to*, so it carries no toggle -- except while that one figure is
/// maximized, where the button is the way out.
void rebuild_maximize_buttons(App& app) {
if (app.area_overlay == nullptr) return;
for (GtkWidget* b : app.maximize_buttons) {
gtk_overlay_remove_overlay(GTK_OVERLAY(app.area_overlay), b);
}
app.maximize_buttons.clear();
for (GtkWidget* b : app.detach_buttons) {
gtk_overlay_remove_overlay(GTK_OVERLAY(app.area_overlay), b);
}
app.detach_buttons.clear();
app.maximize_buttons_rows = app.rows->figure_row_count();
const bool maximized = app.rows->figures_maximized();
if (!maximized && app.maximize_buttons_rows <= 1) return;
for (int fr = 0; fr < app.maximize_buttons_rows; ++fr) {
GtkWidget* b = overlay_button(app,
maximized ? "view-restore-symbolic" : "view-fullscreen-symbolic",
maximized ? "back to the whole layout"
: "show this figure full screen",
app.maximize_buttons);
g_object_set_data(G_OBJECT(b), kMaximizeKey, GINT_TO_POINTER(fr + 1));
g_signal_connect(b, "clicked", G_CALLBACK(on_maximize_clicked), &app);
// The detach button sits beside it, one rule behind it: a layout of one figure detaches
// nothing (the main window would be left with nothing to draw), and a maximized one has
// hidden its own rows anyway.
if (!maximized && app.maximize_buttons_rows >= 2) {
GtkWidget* d = overlay_button(app, "send-to-symbolic",
"detach this figure to a window of its own",
app.detach_buttons);
gtk_widget_set_name(d, "udpscope-detach-button");
g_object_set_data(G_OBJECT(d), kMaximizeKey, GINT_TO_POINTER(fr + 1));
g_signal_connect(d, "clicked", G_CALLBACK(on_detach_clicked), &app);
}
}
}
/// Restates where the toggles sit, and rebuilds them when the figure row count changed (a
/// resize, a flush, a maximize). Placed from the same `FigureLayout` rects the editor's
/// buttons use -- the axes' right edge, so the button sits over the right gutter and never
/// over a trace -- and from the rows' top edges, so each one names the row it would enlarge.
void sync_maximize_buttons(App& app) {
if (app.area_overlay == nullptr || app.area_widget == nullptr || app.area == nullptr) return;
const int frows = app.rows->figure_row_count();
if (frows != app.maximize_buttons_rows) rebuild_maximize_buttons(app);
if (app.maximize_buttons.empty()) return;
const plot::FigureLayout& fl = app.rows->figure().layout();
const double scale = app.area->device_scale() > 0.0f
? static_cast<double>(app.area->device_scale())
: 1.0;
const double width = static_cast<double>(gtk_widget_get_width(app.area_widget));
const double height = static_cast<double>(gtk_widget_get_height(app.area_widget));
for (GtkWidget* b : app.maximize_buttons) {
const int fr = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kMaximizeKey)) - 1;
if (fr < 0 || fr >= frows || fr >= static_cast<int>(fl.rows.size())) {
gtk_widget_set_visible(b, FALSE);
continue;
}
const int first = app.rows->first_plot_of_row(fr);
const int n = app.rows->cells_in_row(fr);
const plot::Plot* last = n > 0 ? app.rows->row(first + n - 1).plot : nullptr;
const double right =
last != nullptr
? static_cast<double>(last->layout().axes.right()) / scale
: static_cast<double>(fl.rows[static_cast<std::size_t>(fr)].right()) / scale;
const double top =
static_cast<double>(fl.rows[static_cast<std::size_t>(fr)].y) / scale + kLayoutButtonPad;
gtk_widget_set_halign(b, GTK_ALIGN_START);
gtk_widget_set_valign(b, GTK_ALIGN_START);
const double by = static_cast<int>(std::clamp(top, 0.0, std::max(0.0, height - kLayoutButtonPx)) + 0.5);
gtk_widget_set_margin_top(b, static_cast<int>(by));
const double bx = static_cast<int>(std::clamp(right - kLayoutButtonPx - kLayoutButtonPad, 0.0,
std::max(0.0, width - kLayoutButtonPx)) + 0.5);
gtk_widget_set_margin_start(b, static_cast<int>(bx));
// Out of hiding only while the pointer is near this row's corner: the button's own rect
// inflated by `kMaximizeRevealPx`. The rect is the placed one (`bx`/`by`), so the zone and
// the button cannot disagree -- a zone measured from the axes' edge while the button was
// clamped to the widget would leave a corner that never reveals.
const bool revealed =
app.area_pointer_valid && app.area_pointer_x >= bx - kMaximizeRevealPx &&
app.area_pointer_x <= bx + kLayoutButtonPx + kMaximizeRevealPx &&
app.area_pointer_y >= by - kMaximizeRevealPx &&
app.area_pointer_y <= by + kLayoutButtonPx + kMaximizeRevealPx;
gtk_widget_set_visible(b, revealed);
}
// The detach buttons, one per figure row to the **left** of its full-screen toggle: the same
// corner, the same reveal, the row's own rect -- a pointer arriving for one of the pair shows
// both, and leaving hides both.
for (GtkWidget* b : app.detach_buttons) {
const int fr = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kMaximizeKey)) - 1;
if (fr < 0 || fr >= frows || fr >= static_cast<int>(fl.rows.size())) {
gtk_widget_set_visible(b, FALSE);
continue;
}
const int first = app.rows->first_plot_of_row(fr);
const int n = app.rows->cells_in_row(fr);
const plot::Plot* last = n > 0 ? app.rows->row(first + n - 1).plot : nullptr;
const double right =
last != nullptr
? static_cast<double>(last->layout().axes.right()) / scale
: static_cast<double>(fl.rows[static_cast<std::size_t>(fr)].right()) / scale;
const double top =
static_cast<double>(fl.rows[static_cast<std::size_t>(fr)].y) / scale + kLayoutButtonPad;
gtk_widget_set_halign(b, GTK_ALIGN_START);
gtk_widget_set_valign(b, GTK_ALIGN_START);
const double dx = std::clamp(right - 2 * kLayoutButtonPx - 2 * kLayoutButtonPad, 0.0,
std::max(0.0, width - kLayoutButtonPx));
gtk_widget_set_margin_start(b, static_cast<int>(dx));
const double dy = std::clamp(top, 0.0, std::max(0.0, height - kLayoutButtonPx));
gtk_widget_set_margin_top(b, static_cast<int>(dy));
const bool revealed =
app.area_pointer_valid && app.area_pointer_x >= dx - kMaximizeRevealPx &&
app.area_pointer_x <= dx + kLayoutButtonPx + kMaximizeRevealPx &&
app.area_pointer_y >= dy - kMaximizeRevealPx &&
app.area_pointer_y <= dy + kLayoutButtonPx + kMaximizeRevealPx;
gtk_widget_set_visible(b, revealed);
}
}
/// The row's toggle: the `kMaximizeKey` data names the figure row it sits on.
void on_maximize_clicked(GtkButton* b, gpointer user) {
App& app = *static_cast<App*>(user);
toggle_fullscreen(app, GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kMaximizeKey)) - 1);
}
/// The plot menu's entry: the figure row the clicked plot belongs to, through the same action,
/// with the popover dismissed like every other row's.
void on_fullscreen_row(GtkButton* b, gpointer user) {
App& app = *static_cast<App*>(user);
toggle_fullscreen(app, app.rows->figure_row_of(row_key(GTK_WIDGET(b))));
GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(b), "udpscope-popover"));
if (popover != nullptr) gtk_popover_popdown(popover);
}
/// The detach button, the full-screen toggle's neighbour: the figure row it names moves to a
/// window of its own.
void on_detach_clicked(GtkButton* b, gpointer user) {
App& app = *static_cast<App*>(user);
detach_figure(app, GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kMaximizeKey)) - 1);
}
/// The attach control's reveal and placement, one call per frame from the tick: the button sits at
/// the window's top-right corner and comes out of hiding while the pointer is near it -- the
/// placed rect inflated by `kMaximizeRevealPx`, the zone and the button measured from the same
/// numbers so they cannot disagree.
void sync_scope_attach(App& app, App::ScopeWindow& scope) {
(void)app;
if (scope.overlay == nullptr || scope.widget == nullptr) return;
const double scale = scope.area != nullptr && scope.area->device_scale() > 0.0f
? static_cast<double>(scope.area->device_scale())
: 1.0;
(void)scale;
const double width = static_cast<double>(gtk_widget_get_width(scope.widget));
const double height = static_cast<double>(gtk_widget_get_height(scope.widget));
for (GtkWidget* b : scope.attach_buttons) {
const double bx = std::clamp(static_cast<double>(width - kLayoutButtonPx - kLayoutButtonPad),
0.0, std::max(0.0, width - kLayoutButtonPx));
const double by = std::clamp(static_cast<double>(kLayoutButtonPad), 0.0,
std::max(0.0, height - kLayoutButtonPx));
gtk_widget_set_halign(b, GTK_ALIGN_START);
gtk_widget_set_valign(b, GTK_ALIGN_START);
gtk_widget_set_margin_start(b, static_cast<int>(bx));
gtk_widget_set_margin_top(b, static_cast<int>(by));
const bool revealed =
scope.pointer_valid && scope.pointer_x >= bx - kMaximizeRevealPx &&
scope.pointer_x <= bx + kLayoutButtonPx + kMaximizeRevealPx &&
scope.pointer_y >= by - kMaximizeRevealPx &&
scope.pointer_y <= by + kLayoutButtonPx + kMaximizeRevealPx;
gtk_widget_set_visible(b, revealed);
}
if (scope.layout_toggle != nullptr) {
// The layout toggle sits at the corner, to the left of the attach button when there is one.
const double bx = std::clamp(static_cast<double>(width - kLayoutButtonPx - kLayoutButtonPad -
(scope.attach_buttons.empty()
? 0
: kLayoutButtonPx + kLayoutButtonPad)),
0.0, std::max(0.0, width - kLayoutButtonPx));
const double by = std::clamp(static_cast<double>(kLayoutButtonPad), 0.0,
std::max(0.0, height - kLayoutButtonPx));
gtk_widget_set_halign(scope.layout_toggle, GTK_ALIGN_START);
gtk_widget_set_valign(scope.layout_toggle, GTK_ALIGN_START);
gtk_widget_set_margin_start(scope.layout_toggle, static_cast<int>(bx));
gtk_widget_set_margin_top(scope.layout_toggle, static_cast<int>(by));
const bool revealed =
!scope.layout_editing && scope.pointer_valid &&
scope.pointer_x >= bx - kMaximizeRevealPx &&
scope.pointer_x <= bx + kLayoutButtonPx + kMaximizeRevealPx &&
scope.pointer_y >= by - kMaximizeRevealPx &&
scope.pointer_y <= by + kLayoutButtonPx + kMaximizeRevealPx;
gtk_widget_set_visible(scope.layout_toggle, revealed);
}
// The attach button steps aside while the editor is up: Done is the way out of that mode.
for (GtkWidget* b : scope.attach_buttons) {
if (scope.layout_editing) gtk_widget_set_visible(b, FALSE);
}
}
// --- the scope windows' own layout editor -----------------------------------------------------
//
// A scope window is a view with its own arrangement, and its arrangement is the user's: it holds
// as many figure rows as it is given and edits them with the same controls the main window's
// editor carries (`+` on a boundary, `×` on a row, Done to leave). The buttons are the window's
// own -- the main editor's target is the main window -- so the two editors cannot move each
// other's rows.
namespace {
/// Data keys of a scope editor's buttons: the figure row a `×` removes, the boundary a `+` splits.
constexpr const char* kScopeLayoutRowKey = "udpscope-scope-layout-row";
constexpr const char* kScopeLayoutBoundaryKey = "udpscope-scope-layout-boundary";
App::ScopeWindow* scope_layout_scope(GtkWidget* w) {
return static_cast<App::ScopeWindow*>(g_object_get_data(G_OBJECT(w), "udpscope-scope"));
}
} // namespace
void rebuild_scope_layout_editor(App& app, App::ScopeWindow& scope);
void set_scope_layout_editing(App& app, App::ScopeWindow& scope, bool on);
void clear_scope_layout_editor(App::ScopeWindow& scope) {
if (scope.overlay != nullptr) {
for (GtkWidget* b : scope.layout_buttons) {
gtk_overlay_remove_overlay(GTK_OVERLAY(scope.overlay), b);
}
if (scope.layout_done != nullptr) {
gtk_overlay_remove_overlay(GTK_OVERLAY(scope.overlay), scope.layout_done);
}
}
scope.layout_buttons.clear();
scope.layout_done = nullptr;
scope.layout_buttons_rows = -1;
}
/// One of the scope editor's buttons: the main editor's look (`circular`, `osd`, the button size)
/// on this window's overlay.
GtkWidget* scope_layout_button(App::ScopeWindow& scope, const char* icon, const char* tip) {
GtkWidget* b = gtk_button_new_from_icon_name(icon);
gtk_widget_set_tooltip_text(b, tip);
gtk_widget_add_css_class(b, "circular");
gtk_widget_add_css_class(b, "osd");
gtk_widget_set_size_request(b, kLayoutButtonPx, kLayoutButtonPx);
g_object_set_data(G_OBJECT(b), "udpscope-scope", &scope);
gtk_overlay_add_overlay(GTK_OVERLAY(scope.overlay), b);
scope.layout_buttons.push_back(b);
return b;
}
void on_scope_layout_row_remove(GtkButton* b, gpointer user) {
App& app = *static_cast<App*>(user);
App::ScopeWindow* scope = scope_layout_scope(GTK_WIDGET(b));
if (scope == nullptr || scope->rows == nullptr) return;
const int fr = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kScopeLayoutRowKey)) - 1;
outlive_the_rebuild(GTK_WIDGET(b));
if (!scope->rows->remove_row(fr)) {
set_notice(app, "a window keeps at least one figure", 4);
return;
}
if (scope->area != nullptr) scope->area->set_figure(&scope->rows->figure());
rebuild_scope_layout_editor(app, *scope);
if (scope->widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(scope->widget));
}
void on_scope_layout_row_insert(GtkButton* b, gpointer user) {
App& app = *static_cast<App*>(user);
App::ScopeWindow* scope = scope_layout_scope(GTK_WIDGET(b));
if (scope == nullptr || scope->rows == nullptr) return;
const int at = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kScopeLayoutBoundaryKey));
outlive_the_rebuild(GTK_WIDGET(b));
if (!scope->rows->insert_row(at)) {
set_notice(app, "this window holds " + std::to_string(RowSet::kMaxRows) + " figures already", 4);
return;
}
if (scope->area != nullptr) scope->area->set_figure(&scope->rows->figure());
rebuild_scope_layout_editor(app, *scope);
if (scope->widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(scope->widget));
}
void on_scope_layout_done(GtkButton* b, gpointer user) {
App& app = *static_cast<App*>(user);
App::ScopeWindow* scope = scope_layout_scope(GTK_WIDGET(b));
if (scope == nullptr) return;
set_scope_layout_editing(app, *scope, false);
}
void rebuild_scope_layout_editor(App& app, App::ScopeWindow& scope) {
clear_scope_layout_editor(scope);
if (!scope.layout_editing || scope.rows == nullptr || scope.overlay == nullptr) return;
const int frows = scope.rows->figure_row_count();
scope.layout_buttons_rows = frows;
for (int fr = 0; fr < frows; ++fr) {
GtkWidget* rm = scope_layout_button(scope, "window-close-symbolic", "remove this figure");
g_object_set_data(G_OBJECT(rm), kScopeLayoutRowKey, GINT_TO_POINTER(fr + 1));
g_signal_connect(rm, "clicked", G_CALLBACK(on_scope_layout_row_remove), &app);
}
for (int at = 0; at <= frows; ++at) {
GtkWidget* add = scope_layout_button(scope, "list-add-symbolic", "add a figure here");
g_object_set_data(G_OBJECT(add), kScopeLayoutBoundaryKey, GINT_TO_POINTER(at));
g_signal_connect(add, "clicked", G_CALLBACK(on_scope_layout_row_insert), &app);
}
scope.layout_done = gtk_button_new_with_label("Done");
gtk_widget_set_tooltip_text(scope.layout_done, "leave the layout editor");
gtk_widget_add_css_class(scope.layout_done, "osd");
gtk_widget_set_size_request(scope.layout_done, -1, kLayoutButtonPx);
g_object_set_data(G_OBJECT(scope.layout_done), "udpscope-scope", &scope);
g_signal_connect(scope.layout_done, "clicked", G_CALLBACK(on_scope_layout_done), &app);
gtk_overlay_add_overlay(GTK_OVERLAY(scope.overlay), scope.layout_done);
}
/// Restates where the editor's buttons sit, from the rects the last frame was laid out with: the
/// `×` inside its row's left edge, the `+` on its boundary, Done in the corner.
void sync_scope_layout_editor(App& app, App::ScopeWindow& scope) {
if (!scope.layout_editing || scope.rows == nullptr || scope.area == nullptr ||
scope.widget == nullptr) {
return;
}
const int frows = scope.rows->figure_row_count();
if (frows != scope.layout_buttons_rows) rebuild_scope_layout_editor(app, scope);
const plot::FigureLayout& fl = scope.rows->figure().layout();
if (static_cast<int>(fl.rows.size()) != frows) return;
const double scale =
scope.area->device_scale() > 0.0f ? static_cast<double>(scope.area->device_scale()) : 1.0;
const double height = static_cast<double>(gtk_widget_get_height(scope.widget));
const double width = static_cast<double>(gtk_widget_get_width(scope.widget));
if (!(height > 0.0) || !(width > 0.0)) return;
const double half = kLayoutButtonPx * 0.5;
for (GtkWidget* b : scope.layout_buttons) {
const int fr = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kScopeLayoutRowKey)) - 1;
const int at = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kScopeLayoutBoundaryKey));
gtk_widget_set_halign(b, GTK_ALIGN_START);
gtk_widget_set_valign(b, GTK_ALIGN_START);
if (fr >= 0) {
if (fr >= frows) continue;
const plot::Rect& row = fl.rows[static_cast<std::size_t>(fr)];
gtk_widget_set_margin_top(
b, static_cast<int>(std::clamp(static_cast<double>(row.y) / scale + kLayoutButtonPad, 0.0,
height - kLayoutButtonPx) + 0.5));
// Inside the axes, not over the y labels: the row's first cell's plot knows where they end.
const plot::Plot* p = scope.rows->row(scope.rows->first_plot_of_row(fr)).plot;
const double left =
(p != nullptr ? static_cast<double>(p->layout().axes.x) / scale
: static_cast<double>(row.x) / scale) + kLayoutButtonPad;
gtk_widget_set_margin_start(b, static_cast<int>(std::clamp(left, 0.0, width - kLayoutButtonPx) + 0.5));
} else if (at >= 0) {
const double edge = at < frows
? static_cast<double>(fl.rows[static_cast<std::size_t>(at)].y) / scale
: (static_cast<double>(fl.rows.back().y) +
static_cast<double>(fl.rows.back().h)) / scale;
gtk_widget_set_margin_top(
b, static_cast<int>(std::clamp(edge - half, 0.0, height - kLayoutButtonPx) + 0.5));
gtk_widget_set_margin_start(b, static_cast<int>(kLayoutButtonPad));
}
}
if (scope.layout_done != nullptr) {
gtk_widget_set_halign(scope.layout_done, GTK_ALIGN_END);
gtk_widget_set_valign(scope.layout_done, GTK_ALIGN_START);
gtk_widget_set_margin_end(scope.layout_done, kLayoutButtonPad);
gtk_widget_set_margin_top(scope.layout_done, kLayoutButtonPad);
}
}
void set_scope_layout_editing(App& app, App::ScopeWindow& scope, bool on) {
if (scope.layout_editing == on) return;
scope.layout_editing = on;
if (on) {
rebuild_scope_layout_editor(app, scope);
} else {
clear_scope_layout_editor(scope);
}
sync_scope_attach(app, scope);
if (scope.widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(scope.widget));
}
void sync_reopen_scopes_item(App& app) {
if (app.reopen_scopes_item == nullptr) return;
const std::size_t n = app.closed_windows.size();
const std::string label =
n == 0 ? std::string("Reopen closed windows")
: "Reopen closed windows (" + std::to_string(n) + ")";
gtk_button_set_label(GTK_BUTTON(app.reopen_scopes_item), label.c_str());
gtk_widget_set_sensitive(app.reopen_scopes_item, n > 0 ? TRUE : FALSE);
}
App::ScopeWindow* open_scope_window_from_state(App& app, const LayoutState::Window& state) {
// The window itself comes from the one constructor every window uses -- an empty scope window,
// the same one the overflow menu makes -- and then its arrangement is dealt into its row set.
open_scope_window(app, -1, state.detached);
if (app.scopes.empty()) return nullptr;
App::ScopeWindow* scope = app.scopes.back().get();
if (scope->rows == nullptr || state.rows.empty()) {
scope->name = state.name;
if (scope->window != nullptr) gtk_window_set_title(scope->window, state.name.c_str());
return scope;
}
// The cells per figure row, from the `row` keys the same way `apply_layout()` reads the main
// layout: a file that names the figure row of every plot carries its splits, an older one is
// one cell per row.
std::vector<int> cells;
{
int prev = -1;
for (const LayoutState::Row& r : state.rows) {
const int fr = r.row < 0 ? prev + 1 : r.row;
if (fr != prev) cells.push_back(1);
else if (++cells.back() > RowSet::kMaxCellsPerRow) cells.back() = RowSet::kMaxCellsPerRow;
prev = fr;
}
if (cells.size() > static_cast<std::size_t>(RowSet::kMaxRows)) cells.resize(RowSet::kMaxRows);
}
scope->rows->set_cells_per_row(cells);
std::vector<float> row_weights;
row_weights.reserve(cells.size());
std::size_t at = 0;
for (const int n : cells) {
row_weights.push_back(static_cast<float>(state.rows[at].weight));
at += static_cast<std::size_t>(n);
}
scope->rows->set_row_weights(std::move(row_weights));
at = 0;
for (std::size_t fr = 0; fr < cells.size(); ++fr) {
const std::size_t n = static_cast<std::size_t>(cells[fr]);
if (n >= 2) {
std::vector<float> widths;
for (std::size_t c = 0; c < n; ++c) widths.push_back(static_cast<float>(state.rows[at + c].width));
scope->rows->set_cell_weights(static_cast<int>(fr), std::move(widths));
}
at += n;
}
// The slots, by name (the channel index is re-resolved every frame by `bind()`). The copy is
// taken under the model's lock: the producer replaces the list on every CONFIG.
std::vector<SignalInfo> signals;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
signals = app.model->signals();
}
const auto color_of = [&](const std::string& name) {
for (std::size_t si = 0; si < signals.size(); ++si) {
if (signals[si].name == name) return signal_color_index(signals, si);
}
return app.rows->signal_colors().count(name) != 0
? static_cast<int>(app.rows->signal_colors().at(name))
: 0;
};
for (int i = 0; i < scope->rows->row_count() &&
static_cast<std::size_t>(i) < state.rows.size();
++i) {
const LayoutState::Row& row = state.rows[static_cast<std::size_t>(i)];
for (int sl = 0; sl < RowSet::kSlotsPerRow && static_cast<std::size_t>(sl) < row.signals.size(); ++sl) {
const std::string& name = row.signals[static_cast<std::size_t>(sl)];
if (name.empty()) continue;
scope->rows->assign(i, sl, name, name, color_of(name));
}
}
if (scope->area != nullptr) scope->area->set_figure(&scope->rows->figure());
scope->name = state.name;
if (scope->window != nullptr) {
gtk_window_set_title(scope->window, (state.name + (state.detached ? " (detached)" : "")).c_str());
}
return scope;
}
gboolean on_scope_key(GtkEventControllerKey* controller, guint keyval, guint, GdkModifierType state,
gpointer user) {
App& app = *static_cast<App*>(user);
const Accel* accel = find_accel(keyval, static_cast<unsigned>(state));
if (accel == nullptr) return FALSE;
if (accel->action == AppAction::LayoutEditor) {
// The window the key arrived in, not the main one: `L` is the layout editor of the view you
// are looking at.
GtkWidget* widget = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(controller));
GtkWidget* window = widget != nullptr ? gtk_widget_get_ancestor(widget, GTK_TYPE_WINDOW) : nullptr;
for (const std::unique_ptr<App::ScopeWindow>& scope : app.scopes) {
if (GTK_WIDGET(scope->window) != window) continue;
set_scope_layout_editing(app, *scope, !scope->layout_editing);
return TRUE;
}
return FALSE;
}
// Everything else is the application's, exactly as the main window's keys take it.
run_app_action(app, accel->action);
return TRUE;
}
/// **Detach**: the figure row moves to a window of its own. Copied first (the new window opens
/// carrying exactly what was on screen), then removed from the main window's rows -- the copy and
/// the removal are one action in one call, so nothing renders in between. A detached window is the
/// one kind with an attach control; the arrangement the user had follows it there.
void detach_figure(App& app, int fr) {
if (app.rows->figures_maximized() || app.rows->figure_row_count() <= 1) return;
if (fr < 0 || fr >= app.rows->figure_row_count()) return;
const std::string label = app.rows->plot_label(app.rows->first_plot_of_row(fr));
open_scope_window(app, fr, /*detached=*/true);
if (!app.rows->remove_row(fr)) return;
if (app.area != nullptr) app.area->set_figure(&app.rows->figure());
rebuild_maximize_buttons(app);
rebuild_signal_panels(app);
if (app.layout_editing) rebuild_layout_editor(app);
schedule_layout_save(app);
set_notice(app, "plot " + label + " detached to its own window", 4);
}
/// **Attach**: a detached window's figure row is appended to the main window's -- the window's
/// cells, their weights and every slot's signal in its colour -- and the window closes. Closing
/// runs its own teardown (the pages' *Plot on* groups are refreshed before the window's struct is
/// freed), so nothing here may touch `scope` once the destroy has been asked for.
void attach_scope_window(App& app, App::ScopeWindow& scope) {
if (!scope.detached || scope.rows == nullptr || scope.rows->row_count() <= 0) return;
const int at = app.rows->figure_row_count();
if (!app.rows->insert_row(at)) {
set_notice(app, "no room for another figure row", 4);
return;
}
const int cells = scope.rows->cells_in_row(0);
for (int k = 1; k < cells; ++k) {
if (!app.rows->insert_cell(at, k)) break;
}
if (cells > 1) app.rows->set_cell_weights(at, scope.rows->cell_weights(0));
const int first = app.rows->first_plot_of_row(at);
for (int k = 0; k < cells; ++k) {
const RowSet::Row& src = scope.rows->row(k);
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (src.signal_name[si].empty()) continue;
app.rows->assign(first + k, s, src.signal_name[si], src.name[si], src.color_index[si]);
}
}
GtkWindow* window = scope.window;
scope.remember_on_close = false; // its figure is going back to the main window
if (window != nullptr) gtk_window_destroy(window); // erases the scope, refreshes the pages
app.relayout_pending = true;
if (app.area != nullptr) app.area->set_figure(&app.rows->figure());
rebuild_maximize_buttons(app);
rebuild_signal_panels(app);
if (app.layout_editing) rebuild_layout_editor(app);
schedule_layout_save(app);
set_notice(app, "the detached figure is back on the main window", 4);
}
/// The plot menu's way into the layout editor: the same toggle the overflow menu's `Layout editor`
/// entry flips, so the menu cannot drift from the button's own state -- and the place a user who is
/// looking at the plot will find splitting.
void on_row_menu_layout_editor(GtkButton* b, gpointer user) {
App& app = *static_cast<App*>(user);
if (app.layout_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.layout_toggle), !app.layout_editing);
}
GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(b), "udpscope-popover"));
if (popover != nullptr) gtk_popover_popdown(popover);
}
void rebuild_layout_editor(App& app) {
clear_layout_editor(app);
if (!app.layout_editing || app.area_overlay == nullptr) return;
const int plots = app.rows->row_count();
const int frows = app.rows->figure_row_count();
app.layout_buttons_rows = plots;
// One `×` per plot, keyed by plot index. In a one-cell row "remove this row"; in a
// multi-cell row "remove this plot" and it calls `remove_cell`.
for (int i = 0; i < plots; ++i) {
const int fr = app.rows->figure_row_of(i);
const bool multi = fr >= 0 && app.rows->cells_in_row(fr) > 1;
GtkWidget* b = layout_overlay_button(app, "window-close-symbolic",
multi ? "remove this plot" : "remove this row");
gtk_widget_set_halign(b, GTK_ALIGN_START);
gtk_widget_set_valign(b, GTK_ALIGN_START);
gtk_widget_set_sensitive(b, plots > 1);
set_row_key(b, i);
g_signal_connect(b, "clicked", G_CALLBACK(on_layout_row_remove), &app);
}
// One `+` per figure row boundary (row insert).
for (int b_i = 0; b_i <= frows; ++b_i) {
GtkWidget* b = layout_overlay_button(app, "list-add-symbolic",
b_i == 0 ? "add a row above"
: b_i == frows ? "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, plots < 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);
}
// One row resize handle per figure row boundary.
for (int b_i = 0; b_i <= frows; ++b_i) {
GtkWidget* b = layout_overlay_button(app, "udpscope-resize-rows-symbolic",
b_i == 0 ? "drag to resize the first row"
: b_i == frows ? "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);
gtk_widget_set_visible(b, b_i > 0 && b_i < frows);
gtk_widget_set_halign(b, GTK_ALIGN_START);
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));
}
// Cell `+` buttons: n+1 per figure row with n cells.
for (int fr = 0; fr < frows; ++fr) {
const int n = app.rows->cells_in_row(fr);
const bool can_split = n < RowSet::kMaxCellsPerRow && plots < RowSet::kMaxRows;
for (int pos = 0; pos <= n; ++pos) {
GtkWidget* b = layout_overlay_button(app, "list-add-symbolic", "split here");
gtk_widget_set_halign(b, GTK_ALIGN_START);
gtk_widget_set_valign(b, GTK_ALIGN_START);
gtk_widget_set_sensitive(b, can_split);
// Encode fr and pos as a single int (do NOT call set_row_key).
g_object_set_data(G_OBJECT(b), kLayoutCellKey, GINT_TO_POINTER(fr * 8 + pos + 1));
g_signal_connect(b, "clicked", G_CALLBACK(on_layout_cell_insert), &app);
}
}
// Column resize handles at each internal cell boundary.
for (int fr = 0; fr < frows; ++fr) {
const int n = app.rows->cells_in_row(fr);
const int first = app.rows->first_plot_of_row(fr);
for (int k = 1; k < n; ++k) {
const int plot = first + k;
GtkWidget* b = layout_overlay_button(app, "udpscope-resize-cols-symbolic", "drag to resize the plots");
gtk_widget_set_halign(b, GTK_ALIGN_START);
gtk_widget_set_valign(b, GTK_ALIGN_START);
g_object_set_data(G_OBJECT(b), kLayoutColKey, GINT_TO_POINTER(plot));
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-col-drag", GINT_TO_POINTER(plot));
g_signal_connect(drag, "drag-begin", G_CALLBACK(on_layout_col_begin), &app);
g_signal_connect(drag, "drag-update", G_CALLBACK(on_layout_col_update), &app);
g_signal_connect(drag, "drag-end", G_CALLBACK(on_layout_col_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);
// A single-field dialog has only its own spin; the other half is the model's record, unchanged.
std::pair<double, double> cal = ctx->app->model->calibration(ctx->signal);
if (ctx->gain != nullptr) cal.first = gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->gain));
if (ctx->offset != nullptr) cal.second = gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->offset));
ctx->app->model->set_calibration(ctx->signal, cal.first, cal.second);
schedule_layout_save(*ctx->app);
// The signal page quotes the calibration; refill it before the dialog goes.
signal_pages_sync(*ctx->app);
gtk_window_destroy(GTK_WINDOW(ctx->window));
}
void open_calibration(App& app, GtkWindow* parent, std::size_t signal, CalibField field) {
const SignalInfo& sig = app.model->signals()[signal];
const char* heading = field == CalibField::Gain ? "Gain" : field == CalibField::Offset ? "Offset" : "Calibrate";
GtkWidget* window = dialog_window(GTK_WIDGET(parent), heading);
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 = nullptr;
GtkWidget* offset = nullptr;
if (field != CalibField::Offset) {
gain = spin(-1.0e12, 1.0e12, 0.001, 6, cal.first);
gtk_box_append(GTK_BOX(box), labeled_box("gain", gain));
}
if (field != CalibField::Gain) {
offset = spin(-1.0e12, 1.0e12, 0.001, 6, cal.second);
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));
}
/// The unit editor: one entry, seeded with the unit now shown, the stream's own unit as its placeholder.
/// An empty entry restores the declared unit (see `ScopeModel::set_signal_unit`).
void open_unit_editor(App& app, GtkWindow* parent, std::size_t signal) {
std::string name;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
if (signal >= app.model->signals().size()) return;
name = app.model->signals()[signal].name;
}
const std::string declared = app.model->declared_unit(signal);
GtkWidget* window = dialog_window(GTK_WIDGET(parent), "Unit");
GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window));
GtkWidget* title = gtk_label_new(name.c_str());
gtk_widget_set_halign(title, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(box), title);
GtkWidget* entry = gtk_entry_new();
{
std::lock_guard<std::mutex> lock(app.model->mutex());
if (signal < app.model->signals().size()) {
gtk_editable_set_text(GTK_EDITABLE(entry), app.model->signals()[signal].unit.c_str());
}
}
gtk_entry_set_placeholder_text(GTK_ENTRY(entry), declared.empty() ? "(none declared)" : declared.c_str());
gtk_entry_set_activates_default(GTK_ENTRY(entry), true);
gtk_box_append(GTK_BOX(box), labeled_box("unit", entry));
GtkWidget* hint = gtk_label_new("Empty restores the unit the stream declares.");
gtk_widget_add_css_class(hint, "dim-label");
gtk_widget_set_halign(hint, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(box), hint);
struct UnitContext {
App* app;
std::string name;
GtkWidget* entry;
GtkWidget* window;
};
auto* ctx = new UnitContext{&app, name, entry, window};
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
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<UnitContext*>(p); });
g_signal_connect(apply, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
UnitContext* c = static_cast<UnitContext*>(user);
// Resolved by name at Apply: a CONFIG may have renumbered the signals while the dialog was open.
std::size_t index = std::string::npos;
{
std::lock_guard<std::mutex> lock(c->app->model->mutex());
const std::vector<SignalInfo>& s = c->app->model->signals();
for (std::size_t i = 0; i < s.size(); ++i) {
if (s[i].name == c->name) index = i;
}
}
if (index != std::string::npos) {
std::string text = gtk_editable_get_text(GTK_EDITABLE(c->entry));
const auto b = text.find_first_not_of(" \t");
const auto e = text.find_last_not_of(" \t");
text = b == std::string::npos ? std::string() : text.substr(b, e - b + 1);
c->app->model->set_signal_unit(index, text);
schedule_layout_save(*c->app);
signal_pages_sync(*c->app);
}
gtk_window_destroy(GTK_WINDOW(c->window));
}),
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);
// 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.
//
// The window and its offset are read **before** the pause is set, and that order is the whole
// of what makes a pause taken while an event is *held* work. `stream_at_x0()` follows the
// *display*: the moment `paused` is true with no pause view published yet, it answers
// `-display_epoch()`, which on a held event is 0 -- so the cut searched the ring at
// edge-relative x, found no samples, and froze an *empty* picture (`ev=0` on the probe's own
// line, every measurement `--`). What it has to hand the snapshot is the offset the axis is
// reading at the instant of the click, which is exactly what it says below.
plot::Range view{0.0, 0.0};
double s0 = 0.0;
bool have_view = false;
if (RowSet::Row& r0 = app->rows->row(0); r0.plot != nullptr) {
view = r0.plot->x_range(r0.axis_x);
have_view = true;
}
if (have_view) {
// 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.
s0 = app->model->stream_at_x0();
}
app->acq->set_paused(paused);
app->model->set_paused(paused);
if (paused) {
if (have_view) {
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.
/// The chip of a measurement's signal, in the colour every other view names that signal with (the row and
/// the dock's line both). The signal list is the producer's to rebuild, so it is read under the mutex.
std::string measure_chip_markup(App& app, const std::string& signal) {
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 == signal) {
color_index = signal_color_index(signals, i);
break;
}
}
return chip_markup(color_index >= 0 ? app.rows->signal_color(signal, color_index)
: RowSet::unplotted_color());
}
/// 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("");
gtk_label_set_markup(GTK_LABEL(sig_chip), measure_chip_markup(app, m.signal).c_str());
gtk_widget_set_tooltip_text(sig_chip, m.signal.c_str());
gtk_box_append(GTK_BOX(box), sig_chip);
// The row's who: the channel's number and name, then the type. The number is the active
// channel's (see `measure_channel_number`) -- the same one the signal's badge carries -- so
// a row, a badge and the signal page all answer "which channel is this" with one word.
// Markup because the name is the stream's text; escaped for the same reason. A signal that
// is not acquired keeps the old type-only row (there is no number to show and the row is
// already grey).
const int number = measure_channel_number(app, m.signal);
std::string kind_text;
{
char* esc = g_markup_escape_text(m.signal.c_str(), -1);
kind_text = number > 0 ? "<b>" + std::to_string(number) + ". " + esc + "</b> · " +
measure_type_name(m.type)
: std::string(measure_type_name(m.type));
g_free(esc);
}
GtkWidget* kind = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(kind), kind_text.c_str());
gtk_widget_set_halign(kind, GTK_ALIGN_START);
gtk_widget_set_hexpand(kind, true);
gtk_label_set_ellipsize(GTK_LABEL(kind), PANGO_ELLIPSIZE_END);
const std::string tip = m.signal + "\n" + measure_row_summary(m);
gtk_widget_set_tooltip_text(kind, tip.c_str());
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 cells of a split row share that axis, so the channels of
/// every cell of `row`'s figure row count.
///
/// 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;
const int fr = app.rows->figure_row_of(row);
const int begin = fr >= 0 ? app.rows->first_plot_of_row(fr) : row;
const int end = fr >= 0 ? begin + app.rows->cells_in_row(fr) : row + 1;
for (int p = begin; p < end; ++p) {
const RowSet::Row& r = app.rows->row(p);
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
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];
const int fr = app.rows->figure_row_of(r);
const bool split = app.rows->cells_in_row(fr) > 1;
// A ruler pair belongs to the figure row (its cells share one y axis), so a split row gets one
// line, on its first cell, named by the row's number -- the one `plot_label()` leads with. An
// unsplit row keeps the wording it always had.
if (split && app.rows->cell_of(r) != 0) continue;
if (split) {
std::snprintf(who, sizeof(who), "row %d", fr + 1);
} else {
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;
}
}
}
}
dock_rebuild_cursors(app);
}
/// `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.
//
// Each entry is shown with its **channel number** (`measure_channel_number`), the same one
// the pane's rows and the signal badges carry -- the dropdown and the rows name a channel
// alike, which is what makes "1. sine" in one findable in the other. `d->signals` keeps the
// raw names: the number is decoration, and Save resolves through the raw name. An entry
// that is not an active channel (a persistence layer, `sine (A)`, whose base signal's
// channel is someone else's) shows unnumbered rather than borrowing one.
std::vector<std::string> sig_display;
sig_display.reserve(sig_names.size());
std::vector<const char*> sig_cstrs;
sig_cstrs.reserve(sig_names.size() + 1);
for (const std::string& n : sig_names) {
const int number = measure_channel_number(app, n);
sig_display.push_back(number > 0 ? std::to_string(number) + ". " + n : n);
}
for (const std::string& n : sig_display) 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);
// `Between`'s second share: the dialog offers it only for the one type that reads two (see
// `measure_uses_band()`), and the label is retitled per type with the field above it. The 75 is
// `MeasureOptions::threshold_pct2`'s default -- a default the *reader* sees, so it is seeded here
// as well as in the struct.
GtkWidget* band = spin(0.0, 100.0, 1.0, 1, 75.0);
gtk_widget_set_tooltip_text(band,
"the band's other edge, the same share of the visible range");
GtkWidget* band_label = nullptr;
GtkWidget* band_row = labelled_row("to %", band, &band_label);
gtk_box_append(GTK_BOX(box), band_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);
// The per-measurement gate: one check per measurement, one band shared by all of them. The
// band is shown while any measurement is gated (`rebuild_measures_pane()` derives it), and
// it is the thing this check switches on the plots -- the measurement itself only names
// which side of the statistics question reads inside it. Shown for every type: the gate is
// about *which samples* answer, and every type reads samples.
GtkWidget* gate = gtk_check_button_new_with_label("Gate enabled");
gtk_widget_set_tooltip_text(gate,
"restrict this measurement to the shared gate band on the plots "
"(violet). The band is shown while any measurement is gated, and "
"stays where it is put: live it names an age (\"300 ms ago to "
"100 ms ago\"), on a held event a fixed slice of the frame");
gtk_box_append(GTK_BOX(box), gate);
auto* d = new MeasureDialog{&app, edit_index, signal_drop, type_drop,
threshold, band, above, show,
gate, window, threshold_row, threshold_label,
band_row, band_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(band), editing->options.threshold_pct2);
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);
gtk_check_button_set_active(GTK_CHECK_BUTTON(gate), editing->options.gate);
} else {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(threshold), 50.0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(band), MeasureOptions{}.threshold_pct2);
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 contextual-help "?" for this editor. This is the one surface whose heading only exists while a
// dialog is open -- the file is rebuilt on every opening -- so it cannot be a row the window's own
// build visits: the row is still the table's, and this states its host and asks for the hint.
app.math_head = head;
context_help_add(app, "math", head);
// 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) {
// The host goes with the window, and the builder that owns it is dropped on the
// next opening: clear the pointer here rather than leaving it aimed at widgets
// that are about to be finalized.
md->app->math_head = 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);
// The heading row -- the .ui's anonymous first child, carrying the title and the `+`. It is where
// the contextual help's "?" joins (see `ui_context_help.cpp`).
app.measures_head = gtk_widget_get_first_child(box);
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. `.npy` is what an
// unknown extension gets, which is the dialog's default filter too.
std::string error;
const bool ok = write_export(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);
// MDF 3.30, which is what ScryLab (and every MDF reader) opens: offered by both of the
// extensions the format is stored under.
GtkFileFilter* mdf = gtk_file_filter_new();
gtk_file_filter_set_name(mdf, "MDF 3.30 (*.mf3, *.mdf)");
gtk_file_filter_add_pattern(mdf, "*.mf3");
gtk_file_filter_add_pattern(mdf, "*.mdf");
g_list_store_append(filters, mdf);
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(mdf);
g_object_unref(filters);
gtk_file_dialog_save(dialog, GTK_WINDOW(app->window), nullptr, on_export_response,
ctx.release());
g_object_unref(dialog);
}
/// Dismisses the overflow menu. Every entry that opens something *else* -- a dialog, a window, the
/// tour -- does this first, or the menu is still up behind what it opened.
void close_overflow_menu(App& app) {
if (app.menu_button == nullptr) return;
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app.menu_button));
if (popover != nullptr) gtk_popover_popdown(popover);
}
void on_about_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
close_overflow_menu(*app);
show_about(GTK_WINDOW(app->window),
layout_path(app->model->config().session_host(), app->model->config().session_port()));
}
void on_help_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
close_overflow_menu(*app);
show_help(GTK_WINDOW(app->window));
}
void on_shortcuts_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
close_overflow_menu(*app);
show_shortcuts(GTK_WINDOW(app->window));
}
/// The Help submenu's two rows beyond the dialogs. The view swap is the plot menu's own shape: the
/// second view *replaces* the first, with a Back row at its head, because a submenu torn out of the
/// menu it belongs to is a second thing to dismiss and a `GtkMenuButton` that owned one toggled
/// itself out of sync the moment anything else closed its popover.
///
/// What it does *not* do is rebuild the rows. The main view holds `app.layout_toggle`, which the tick
/// reads (`sync_layout_editor()`) and which the `L` accel moves, so a rebuild would leave that pointer
/// aimed at a destroyed widget; the two views are pages of a `GtkStack` instead, and a view is swapped
/// by naming the page.
void on_menu_show_help(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->menu_stack == nullptr) return;
gtk_stack_set_visible_child_name(GTK_STACK(app->menu_stack), "help");
// The popover's content changed size, which is what `present()` re-allocates and re-positions: a
// popover that is already up does not re-measure itself.
if (app->menu_button != nullptr) {
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
if (popover != nullptr) gtk_popover_present(popover);
}
}
void on_menu_back(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->menu_stack == nullptr) return;
gtk_stack_set_visible_child_name(GTK_STACK(app->menu_stack), "main");
if (app->menu_button != nullptr) {
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
if (popover != nullptr) gtk_popover_present(popover);
}
}
/// "Guided tour": the menu hands over to the tour module, and the tour is startable at any time --
/// from here, from `<Control>t`, and by itself on a first run.
void on_tour_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
close_overflow_menu(*app);
start_tour(*app);
}
// --- 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);
RowSet& rows = ctx->rows != nullptr ? *ctx->rows : *ctx->app->rows;
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, nullptr, 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(RowSet& rows, int row, int slot) {
if (row < 0 || row >= rows.row_count()) return;
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.rows, 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(*target->app->rows, target->row, s);
++removed;
}
what = "removed " + std::to_string(removed) + (removed == 1 ? " trace" : " traces") +
" from plot " + app.rows->plot_label(target->row);
} else {
const std::string name = r.name[static_cast<std::size_t>(target->slot)];
remove_trace(*target->app->rows, target->row, target->slot);
what = "removed '" + name + "' from plot " + app.rows->plot_label(target->row);
}
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 %s</b>", app.rows->plot_label(row).c_str());
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);
// One click from a row to that row alone filling the plot: the button on the figure's
// corner does the same, and the menu is where a user who cannot see it looks. A layout of
// one figure has nothing to maximize *to*, so the entry appears only when it would act.
if (app.rows->figure_row_count() > 1 || app.rows->figures_maximized()) {
add_row(app.rows->figures_maximized() ? "Restore the layout" : "Full screen this figure",
nullptr, G_CALLBACK(on_fullscreen_row), &app);
}
// One click from looking at a figure to having it in a window of its own -- the second
// window copies this plot's figure, so the picture on screen is the picture it opens with.
add_row("Open this plot in a new window", nullptr, G_CALLBACK(on_open_scope_row), &app);
// Arranging is a plot gesture too: the editor's own buttons live on the plot, but the way in
// belongs where the arrangement is being judged. It flips the app's one toggle, so its label
// and the overflow menu's state cannot disagree.
add_row(app.layout_editing ? "Done editing the layout" : "Edit the layout...", "L",
G_CALLBACK(on_row_menu_layout_editor), &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);
}
/// Scope-menu helpers: the rows carry their window, their plot and their slot, the popover to
/// dismiss, and the handlers are free functions -- a menu row's handler is a plain callback, not a
/// lambda, whose commas would split the `G_CALLBACK` macro into arguments.
App::ScopeWindow* scope_menu_scope(GtkButton* b) {
return static_cast<App::ScopeWindow*>(g_object_get_data(G_OBJECT(b), "udpscope-scope"));
}
int scope_menu_row(GtkButton* b) {
return GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), "udpscope-row"));
}
void scope_menu_dismiss(GtkButton* b) {
GtkPopover* p = GTK_POPOVER(g_object_get_data(G_OBJECT(b), "udpscope-popover"));
if (p != nullptr) gtk_popover_popdown(p);
}
void on_scope_y_auto_clicked(GtkButton* b, gpointer) {
App::ScopeWindow* scope = scope_menu_scope(b);
if (scope == nullptr || scope->rows == nullptr) return;
scope->rows->set_y_auto(scope_menu_row(b), true);
scope_menu_dismiss(b);
}
/// The main menu's fixed-range dialog, aimed at **this window's** rows: the dialog's Apply
/// writes the row set the context names, and the context carries the scope's.
void on_scope_y_fixed_clicked(GtkButton* b, gpointer user) {
App* a = static_cast<App*>(user);
App::ScopeWindow* scope = scope_menu_scope(b);
const int row = scope_menu_row(b);
if (scope == nullptr || scope->rows == nullptr || scope->rows->row(row).plot == nullptr) return;
GtkWidget* window = dialog_window(a->window, "Fixed Y range");
GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window));
const plot::Range cur = scope->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{a, scope->rows.get(), 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));
scope_menu_dismiss(b);
}
/// The y fit of a scope window: the y axes alone.
void on_scope_fit_y_clicked(GtkButton* b, gpointer) {
App::ScopeWindow* scope = scope_menu_scope(b);
if (scope == nullptr || scope->rows == nullptr) return;
scope->rows->set_y_auto(scope_menu_row(b), true);
scope_menu_dismiss(b);
}
/// The x fit of a scope window is the main window's: there is **one** time span for every view,
/// and the scope mirrors it -- so the fit resets the shared x range and the next frame mirrors
/// the result back, in this window and in every other.
void on_scope_fit_x_clicked(GtkButton* b, 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;
scope_menu_dismiss(b);
}
/// Both fits: this window's y, the shared x.
void on_scope_fit_all_clicked(GtkButton* b, gpointer user) {
App& app = *static_cast<App*>(user);
App::ScopeWindow* scope = scope_menu_scope(b);
if (scope == nullptr || scope->rows == nullptr) return;
scope->rows->set_y_auto(scope_menu_row(b), true);
app.rows->fit_x();
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;
scope_menu_dismiss(b);
}
void on_scope_remove_clicked(GtkButton* b, gpointer) {
App::ScopeWindow* scope = scope_menu_scope(b);
if (scope == nullptr || scope->rows == nullptr) return;
remove_trace(*scope->rows, scope_menu_row(b),
GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), "udpscope-slot")));
scope_menu_dismiss(b);
}
void on_scope_attach_menu_clicked(GtkButton* b, gpointer user) {
App& a = *static_cast<App*>(user);
App::ScopeWindow* scope = scope_menu_scope(b);
if (scope == nullptr) return;
outlive_the_rebuild(GTK_WIDGET(b));
attach_scope_window(a, *scope);
}
/// A scope window's own right-click menu. A scope window is a *view with its own arrangement*:
/// its y scales, its traces and its removals are its own, so the menu acts on **that window's**
/// row set -- the same rows the drop target and the signal page's *Plot on* groups write. What it
/// deliberately does not carry is an x fit: the window follows the main window's time span, so an
/// x fit here would be undone by the next frame.
void on_scope_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));
App::ScopeWindow* scope = nullptr;
for (const std::unique_ptr<App::ScopeWindow>& s : app->scopes) {
if (s->widget == widget) scope = s.get();
}
if (scope == nullptr || scope->rows == nullptr || scope->rows->row_count() <= 0) return;
const int row = std::clamp(static_cast<int>(scope->area->row_at(x, y)), 0,
scope->rows->row_count() - 1);
gtk_gesture_set_state(GTK_GESTURE(gesture), GTK_EVENT_SEQUENCE_CLAIMED);
if (plot::input::Controller* c = scope->area->row_controller(static_cast<plot::u32>(row))) {
c->on_key(plot::input::KeyEvent{plot::input::kKeyEscape, 0, true});
}
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);
const auto row_btn = [&](const char* label) {
GtkWidget* b = gtk_button_new_with_label(label);
gtk_widget_add_css_class(b, "flat");
gtk_widget_set_halign(b, GTK_ALIGN_FILL);
g_object_set_data(G_OBJECT(b), "udpscope-popover", popover);
g_object_set_data(G_OBJECT(b), "udpscope-scope", scope);
g_object_set_data(G_OBJECT(b), "udpscope-row", GINT_TO_POINTER(row));
gtk_box_append(GTK_BOX(box), b);
return b;
};
char markup[64];
std::snprintf(markup, sizeof(markup), "<b>Plot %s</b>", scope->rows->plot_label(row).c_str());
GtkWidget* heading = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(heading), markup);
gtk_widget_set_halign(heading, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(box), heading);
GtkWidget* y_auto = row_btn("Y axis: auto scale");
g_signal_connect(y_auto, "clicked", G_CALLBACK(on_scope_y_auto_clicked), user);
GtkWidget* y_fixed = row_btn("Y axis: fixed range...");
g_signal_connect(y_fixed, "clicked", G_CALLBACK(on_scope_y_fixed_clicked), user);
GtkWidget* fit_x = row_btn("Fit X axis");
g_signal_connect(fit_x, "clicked", G_CALLBACK(on_scope_fit_x_clicked), user);
GtkWidget* fit = row_btn("Fit all");
g_signal_connect(fit, "clicked", G_CALLBACK(on_scope_fit_all_clicked), user);
// One removal per trace on the plot, flat rather than as a second view: a scope window holds
// at most its own figure's traces, and the menu is small.
bool traces = false;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
const std::string name = scope->rows->row(row).signal_name[si];
if (name.empty()) continue;
traces = true;
GtkWidget* rm = row_btn(("Remove '" + name + "'").c_str());
g_object_set_data(G_OBJECT(rm), "udpscope-slot", GINT_TO_POINTER(s));
g_signal_connect(rm, "clicked", G_CALLBACK(on_scope_remove_clicked), user);
}
if (!traces) {
GtkWidget* none = gtk_label_new("no traces on this plot");
gtk_widget_add_css_class(none, "dim-label");
gtk_widget_set_halign(none, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(box), none);
}
if (scope->detached) {
GtkWidget* back = row_btn("Attach back to the main window");
g_signal_connect(back, "clicked", G_CALLBACK(on_scope_attach_menu_clicked), user);
}
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, widget);
gtk_popover_popup(GTK_POPOVER(popover));
g_signal_connect(popover, "closed", G_CALLBACK(on_popover_closed), nullptr);
}
/// The availability of everything that does nothing until the stream has signals: the dock and the two
/// slide-over pages that were once in it (Cursors, Measurements). "Disable, do not hide": a page may be
/// opened by its key before the first CONFIG, and shows its controls insensitive until then. The signal
/// list is read under the model's mutex, as the producer replaces it on a CONFIG.
bool update_signal_availability(App& app) {
bool have_signals = false;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
have_signals = !app.model->signals().empty();
}
if (app.side_box != nullptr) gtk_widget_set_sensitive(app.side_box, have_signals);
if (app.cursors_panel != nullptr) gtk_widget_set_sensitive(app.cursors_panel, have_signals);
if (app.measures_panel != nullptr) gtk_widget_set_sensitive(app.measures_panel, have_signals);
return have_signals;
}
/// The dock is on when the user's sidebar button is in -- from the start, with or without signals; the
/// two panel buttons appear as soon as a CONFIG describes any signal at all.
void update_side_visibility(App& app) {
if (app.side_box == nullptr) return;
const bool have_signals = update_signal_availability(app);
// `sidebar_wanted` is the user's last word, which is why it is kept apart from the button's own state:
// a swipe that hides the sidebar fires the button's handler (the binding is bidirectional), so a gesture
// is a choice too. "Disable, do not hide": the dock is on screen from the start and does nothing until
// the stream has signals, so the signals decide its *sensitivity* and never whether it is there.
// (`update_signal_availability()` above set it, and the two slide-over pages' with it.)
gtk_widget_set_sensitive(app.sidebar_toggle, TRUE);
const bool want_column = 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;
// The wish is persisted (`SessionState::dock_shown`), so a change of it is a change of the session --
// but not one made while a layout is being applied, which would save what it just read.
if (!app->updating_panels) schedule_layout_save(*app);
}
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.
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, dock=%d, reset=%d in the cursors pane",
in_bar(app.cursors_toggle), app.dock_page != 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.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 layout editor's two resize handles are not toolbar buttons (they exist only while the editor is
// up), so they are asked of the theme by name: the same silent failure, the same line.
for (const auto& [label, icon] : {std::pair<const char*, const char*>{"resize_rows", "udpscope-resize-rows-symbolic"},
std::pair<const char*, const char*>{"resize_cols", "udpscope-resize-cols-symbolic"}}) {
app.dbg_toolbar_icons += std::string(app.dbg_toolbar_icons.empty() ? "" : " ") + label + "=" + 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 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 ---------------------------------------------------------
/// The phosphor density's one step for a frame. See the declaration for the rule; what is here is
/// the two things easy to get wrong.
///
/// **The deposit is one frame behind the bind**, and that is deliberate. `Plot::accumulate_density()`
/// deposits the traces the plot *currently* holds through the transforms of its *last* layout --
/// which is the only layout there is, since the frame's own is computed inside the paint that
/// follows this tick. Running the deposit in the same tick that frames an acquisition would
/// therefore deposit the new snapshot through the *previous* window's transform: right on the
/// common Held-to-Held transition (both are edge-relative over the same span) and wrong on the
/// first latch, whose axis was the live ring's. Waiting one frame costs a frame of latency on an
/// image whose whole subject is the last few seconds.
///
/// **The fade's clock is the frame's own wall `dt`.** The plot states the time constant in seconds
/// and the app hands it the seconds this frame covered: at a steady stream that is the same clock
/// the display window slides on, and it is what makes the fade "while you watch" rather than "per
/// acquisition". A stall (a rebuild, a modal dialog, a paused process) is capped at a quarter of a
/// second, so a step the user did not watch cannot take the whole image out in one go.
void apply_density(App& app, DisplayMode display, bool live_display) {
ScopeModel& model = *app.model;
TriggerConfig tc;
{
std::lock_guard<std::mutex> lock(model.mutex());
tc = model.trigger();
}
// On while an acquisition is on screen; a *file* is not a stream of acquisitions at all, and a
// live or blank display has nothing for the image to be an image *of*.
const bool on =
!live_display && (display == DisplayMode::Held || display == DisplayMode::Capturing) &&
tc.density;
const gint64 now_us = g_get_monotonic_time();
double dt = app.density_last_us != 0 ? double(now_us - app.density_last_us) / 1.0e6 : 0.0;
app.density_last_us = now_us;
dt = std::clamp(dt, 0.0, 0.25);
// Switching it on under an acquisition already on screen deposits that one: the alternative is a
// switch that appears to do nothing until the next latch, which in `single` mode is for ever.
const bool fresh = on && !app.density_was_on;
const bool deposit = app.density_pending || fresh;
app.density_was_on = on;
int rows_on = 0;
int traces_on = 0;
unsigned long long cells = 0;
double ink = 0.0;
double peak = 0.0;
for (int i = 0; i < app.rows->row_count(); ++i) {
plot::Plot* p = app.rows->row(i).plot;
if (p == nullptr) continue;
plot::DensityConfig cfg;
cfg.enabled = on;
cfg.decay = tc.density_decay == PersistDecay::Time ? plot::DensityDecay::TimeConstant
: plot::DensityDecay::PerAcquisition;
cfg.time_constant_s = static_cast<float>(tc.density_tau_s);
cfg.per_acquisition = static_cast<float>(tc.density_factor);
// The brightness ceiling is left at the library's default of **one sweep** -- the trace's own
// brightness -- so the acquisition just deposited reads as the trace it is a picture of and a
// signal crossing the same place every acquisition settles at the trace's colour rather than
// brighter. The fade's steady state is deliberately *not* the ceiling: it put a gain offset
// between the newest sweep and the image behind it (the newest at a fraction of the settled
// brightness), which is what a user reported as a gain offset between the latest acquisition
// and the persistent image, and the "dimmer by design" headroom that made the ramp visible is
// the wrong call at this brightness scale -- reversed here.
// The plot drops what it held when this turns the display off, so the step below only ever
// speaks about an image that is on screen.
p->set_density(cfg);
if (!on) continue;
++rows_on;
// Time first, then the deposit: under `PerAcquisition` the decay is the accumulate path's own,
// so this is a no-op there; under `TimeConstant` it is this frame's fade and the deposit lands
// on top of it at full ink. Either way the *newest* acquisition is inside the image, so it
// fades with the rest -- the renderer draws no solid trace over the density.
p->decay_density(dt);
if (deposit) p->accumulate_density();
// One grid per trace, each in its own signal's colour, so the row's figures are the **sum over
// its traces** and `cells` scales with the signal count as well as the row count. `peak` is the
// brightest of them, which is the reading that says whether any one trace is saturating.
for (plot::u32 t = 0; t < p->trace_count(); ++t) {
const plot::DensityGrid& g = p->density_grid(t);
if (g.cell_count() == 0u) continue; // an unbound slot holds no grid
++traces_on;
cells += static_cast<unsigned long long>(g.cell_count());
ink += g.ink();
peak = std::max(peak, static_cast<double>(g.peak()));
}
}
app.density_pending = false;
if (rows_on == 0) return; // nothing was on this frame: the dump's figures stay the last real ones
app.dbg_density_rows = rows_on;
app.dbg_density_traces = traces_on;
app.dbg_density_cells = cells;
app.dbg_density_grid_bytes = cells * sizeof(plot::f32);
app.dbg_density_ink = ink;
app.dbg_density_peak = peak;
++app.dbg_density_frames;
if (app.area != nullptr) {
if (const plot::Renderer* r = app.area->renderer()) {
// The **most** any one frame uploaded, not the last one's: the exit probe changes the display
// before the dump, so the last painted frame is not a frame of this display -- and "the grid
// is one byte a cell" is the claim, which a maximum states and a last value does not.
const long long uploaded = static_cast<long long>(r->stats().density_upload_bytes);
app.dbg_density_upload_bytes = std::max(app.dbg_density_upload_bytes, uploaded);
}
}
}
gboolean tick(gpointer user) {
App* app = static_cast<App*>(user);
if (app->window == nullptr) return G_SOURCE_REMOVE;
note_panel_controls(*app);
sync_source_panels(*app);
sync_badge_bar(*app);
// The tour, re-measured every frame: the target's allocation is 0 on the frame it is first shown
// (see the allocation bullets in `docs/claude/invariants-trigger-and-pinning.md`) and the widget
// moves under the spotlight on a window resize, so a one-shot measurement is wrong both ways.
// Cheap when the tour is not running: one flag.
update_tour(*app);
ScopeModel& model = *app->model;
const gint64 tick_start_us = g_get_monotonic_time();
// The renderer GTK actually built, printed once and only under the probe: `--renderer` exports
// `GSK_RENDERER` before any GTK call, and this is the other half of that claim -- the class GTK
// chose, not the name it was asked for. The renderer is null until the window is realized, so a
// tick that finds none simply tries again.
{
static const bool renderer_probe = g_getenv("UDPSCOPE_PROBE_RENDERER") != nullptr;
static bool renderer_printed = false;
if (renderer_probe && !renderer_printed) {
if (GskRenderer* r = gtk_native_get_renderer(GTK_NATIVE(app->window))) {
std::fprintf(stderr, "[renderer] realized=%s\n", G_OBJECT_TYPE_NAME(r));
std::fflush(stderr);
renderer_printed = true;
}
}
}
// 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;
// The widths stay sampled on this one-second cadence whatever the environment, because the
// exit dump's `header:` line reads them; only the *line* is behind a variable. A reading
// printed on every run is a log flood and not a reading -- the same rule the `[notrace]`
// per-paint line follows.
static const bool header_print = g_getenv("UDPSCOPE_PROBE_HEADER") != nullptr;
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);
if (header_print) {
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);
}
}
}
// The help layer's own readings and the bounds the help's annotated figures crop, on the same
// once-a-second cadence and for the same reason as the line above: the dump runs after the window
// is destroyed and `verification.md` forbids a `gtk_widget_*` call there.
{
static gint64 next_help_log = 0;
const gint64 now = g_get_monotonic_time();
if (now >= next_help_log) {
next_help_log = now + G_TIME_SPAN_SECOND;
capture_help_state(*app);
}
}
// Captured every frame so the dump has the last live value: see `dbg_measures_pane_w`.
// Only while the `measure` page is on screen: a closed page has no allocation, and the dump should
// keep the last real size rather than 0.
if (app->measures_list != nullptr && gtk_widget_get_width(app->measures_list) > 0) {
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);
// The measures pane too, for the row's channel number: the number is the channel's
// position in the new list, and a rebuild is what re-derives it. The stats were reset
// above for exactly this reason (the old readings do not belong to the new list), so the
// rebuild's own reset is the same act, not a second one.
rebuild_measures_pane(*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;
// The same question for the measurement gate's region: a drag on it is the only time
// `region(id)`'s bounds are the hand's and not our own last write.
int gate_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) continue;
if (cursor_drag_row < 0 && c->grabbed_cursor() != plot::kInvalid) cursor_drag_row = i;
if (gate_drag_row < 0 && c->grabbed_region() != plot::kInvalid) gate_drag_row = i;
}
}
app->rows->sync_cursors(cursor_drag_row);
app->rows->sync_gate(gate_drag_row);
// The drag's end is a new pair of gate bounds, and the bounds are session state: save there,
// the way a dragged row sizing is saved when its own gesture ends. During the drag every
// frame would only re-arm the same 400 ms debounce, but the *end* is the one moment the
// state is neither mid-gesture nor already saved.
if (app->gate_drag_last >= 0 && gate_drag_row < 0) schedule_layout_save(*app);
app->gate_drag_last = gate_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;
// The persistence plan this frame: what the depth asked for and what the rows had room to draw.
// Declared outside the lock block because the notice below is a widget write, and the tick's
// widget work happens outside the model's lock.
const bool live_display = display == DisplayMode::Live || !app->waveform_name.empty();
// The phosphor density's step for this frame, *before* the bind below: what it deposits is the
// acquisition the last frame bound and laid out, which is what the plot's transforms still
// describe (see `apply_density()`).
apply_density(*app, display, live_display);
int persist_depth = 1;
int persist_want = 0;
{
// 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());
// Persistence first, so the bind below is what *resolves* the layers: the last N acquisitions
// are planned into the rows' free slots, and a depth of 1 -- or the trigger off, or a *file* on
// screen, whose data is not a stream of acquisitions at all -- frees every layer slot, which is
// the display without the feature. The plan is a function of the rows and the depth and not of
// the data, so a latch costs no rebuild and the names and ages on screen stay put.
persist_depth = live_display ? 1 : model.persist_depth_locked();
const int placed = app->rows->sync_layers(persist_depth, &persist_want);
app->persist_want = persist_want;
app->persist_placed = placed;
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);
}
// A scope window is a second view of the same data: its own figure binds and acquires
// under the same lock, and the window/pin mirroring after it keeps the two windows on
// one span.
for (const std::unique_ptr<App::ScopeWindow>& scope : app->scopes) {
scope->rows->bind(model, display);
scope->rows->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);
}
// The scope windows, outside the lock: mirroring a window, restating a pin and asking for a
// render are RowSet and GTK work, not model reads. The span follows the main window every
// frame -- a scope is "the same picture, another window" -- and its own media (a held event
// binds the same snapshot under the same epoch) comes from the bind above.
if (!app->scopes.empty()) {
const plot::Range pin = app->rows->pinned_range();
const bool pinned = app->rows->window_pinned();
const double window_s = app->rows->time_window();
for (const std::unique_ptr<App::ScopeWindow>& scope : app->scopes) {
if (pinned) {
// The pin as a *range*: `pin_event_window()` takes spans (pre, post), and handing it
// `pin.min` -- a signed time -- inverted the window, so a scope in Held or Paused mode
// drew no trace and no x labels at all.
scope->rows->pin_display_range(pin.min, pin.max);
} else if (std::fabs(scope->rows->time_window() - window_s) > 1.0e-12) {
scope->rows->set_time_window(window_s);
}
// An arrangement change (a drop, a *Plot on* button) rebuilt the row set: point the area at
// the new figure here, in the tick -- the change arrives from inside this very widget (a drop
// signal), and a synchronous rebuild would reset the drag controller under it. The traces
// themselves are `SlotSeries` rebinds, so a slot that gains or loses a source draws either
// way; what *does* need this is the figure's own state: a row's legend, its cell structure,
// and the trace objects `install()` creates only for the slots that hold a signal at rebuild
// time -- a window opened empty and filled by a drop has none until this runs.
if (scope->rows->rebuild_if_dirty() && scope->area != nullptr) {
scope->area->set_figure(&scope->rows->figure());
}
if (scope->detached || scope->layout_toggle != nullptr) sync_scope_attach(*app, *scope);
// The window's own editor: buttons restated from the rects this frame was laid out with.
// Its own arrangement is its own -- nothing here saves the main window's session.
if (scope->layout_editing) sync_scope_layout_editor(*app, *scope);
// **A window that cannot paint says so.** A blank plot is indistinguishable from a
// context that failed or an area that never got a frame, and the report ("nothing is
// drawn, no axes") is exactly what this turns into a sentence: the window's own title
// carries the reason, and stderr carries it with the window's name.
if (scope->area != nullptr && scope->paint_checks >= 0 && scope->paint_checks < 120) {
++scope->paint_checks;
if (scope->paint_checks == 120) {
const plot::Status st = scope->area->init_status();
const plot::u64 painted = scope->area->frames_painted();
if (!st || painted == 0) {
std::string why =
st ? std::string("the plot painted no frame in two seconds")
: std::string("GL context failed: ") + st.message;
// Name the GSK renderer: the Vulkan one is known to interact badly with an embedded
// `GtkGLArea` in a second toplevel, and the workaround is one environment variable.
if (GskRenderer* r = gtk_native_get_renderer(GTK_NATIVE(scope->window))) {
const char* type = G_OBJECT_TYPE_NAME(r);
why += std::string(" [GSK renderer: ") + type + "]";
if (g_strstr_len(type, -1, "Vulkan") != nullptr) {
why += "; restart with GSK_RENDERER=gl to check";
}
}
std::fprintf(stderr, "[scope] window '%s' is not drawing: %s\n",
scope->name.c_str(), why.c_str());
std::fflush(stderr);
set_notice(*app, "window '" + scope->name + "' is not drawing: " + why, 10);
if (scope->window != nullptr) {
const char* current = gtk_window_get_title(scope->window);
gtk_window_set_title(scope->window,
((current != nullptr ? std::string(current) + " — " : "") + why)
.c_str());
}
}
}
}
if (scope->widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(scope->widget));
}
}
// A depth the room or the retention budget could not show in full says so, once per *plan* rather
// than once per frame: a user who asked for eight layers and sees two has no other way to tell
// "the display is deliberately shallower" from "the feature is broken". Both bounds are named,
// because they are different numbers and only one of them is the user's own: a *slot* shortfall is
// `placed < want` (the rows had no free trace slot), and a *budget* shortfall is fewer retained
// acquisitions than the depth asks for, in which case the slots exist and draw nothing at all.
const int want_ages = persist_depth > 1 ? persist_depth - 1 : 0;
const int kept_ages = static_cast<int>(std::min<std::size_t>(
model.persist_count(), static_cast<std::size_t>(std::max(0, want_ages))));
if (persist_depth > 1 &&
(app->persist_placed < persist_want || kept_ages < want_ages) &&
(app->persist_noted_depth != persist_depth || app->persist_noted_want != persist_want ||
app->persist_noted_placed != app->persist_placed)) {
app->persist_noted_depth = persist_depth;
app->persist_noted_want = persist_want;
app->persist_noted_placed = app->persist_placed;
set_notice(*app,
"persistence: " + std::to_string(app->persist_placed) + " of " +
std::to_string(persist_want) + " layers fit, " + std::to_string(kept_ages) +
" of " + std::to_string(want_ages) + " older acquisitions retained",
5);
}
// Outside the lock: it reads and writes the two plots' own windows, not the model.
sync_compare_axes(*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);
sync_arm_buttons(*app);
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;
// ... and the frame after this one deposits it into every row's density grid, through the
// transforms the pin below is about to state (`apply_density()`).
app->density_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 full-screen toggles are on the overlay always, so they follow the figure every frame
// (and rebuild themselves when the figure row count changed).
sync_maximize_buttons(*app);
// The one thing that has to be decided *per frame* about the dock: 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 280 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. The wish alone decides
// whether the dock is there; having signals decides only whether it does anything ("disable, do not
// hide"): a dock that is on screen from the start does not jump in when the first CONFIG arrives.
const bool want = app->sidebar_wanted;
update_signal_availability(*app);
gtk_widget_set_sensitive(app->sidebar_toggle, TRUE);
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;
}
}
// The readouts strip is on screen when the dock is not beside the plot: `!show-sidebar || collapsed`.
// Decided here, right after the wish was re-asserted and *before* the collapsing below (which makes
// the split view write `show-sidebar` false itself until the next frame re-asserts it): read after
// it, `show-sidebar` would be that transient false. The strip reads `collapsed` too, and here that is
// the previous frame's value (`set_collapsed()` follows): on a collapse or a widening the strip
// changes one tick after the split view does, but never to a wrong state -- `show-sidebar` is always
// the re-asserted wish and `collapsed` a settled value -- so it does not flash. Moving the call after
// `set_collapsed()` would bring back the transient `show-sidebar` for nothing.
sync_readouts_strip(*app);
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 dock block's and the events page'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]);
}
// The trigger's own circle, one row up in the same alphabet as the badge: solid green while
// the trigger has fired (an event held, or a capture filling), flashing green while armed
// and waiting for one, red while armed-able but disarmed, and *hidden* while the trigger is
// off -- a light for a mode the user is not in is noise, and the mode line next to it is
// not showing trigger words either. The flash is the badge's own half-second square, and
// the markup is rewritten on a change of the state key only, for the same relayout reason
// the badge's is.
const int circle = !trigger_on ? -1
: (app->event_held || app->capturing)
? 0
: armed ? (lit ? 1 : 2) : 3;
app->dbg_trigger_circle = circle;
if (app->trigger_status != nullptr && circle != app->trigger_circle_state) {
app->trigger_circle_state = circle;
gtk_widget_set_visible(app->trigger_status, circle >= 0);
if (circle >= 0) {
gtk_label_set_markup(GTK_LABEL(app->trigger_status), trigger_circle_markup(circle));
gtk_widget_set_tooltip_text(
app->trigger_status,
circle == 0 ? "trigger: triggered (an event is on screen)"
: circle == 3 ? "trigger: disarmed (Arm to wait for an event)"
: "trigger: armed, waiting for an event");
}
}
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;
{
// The planned buffer when there is one; before the first CONFIG there is no plan, and the
// request (rounded as the ring will round it) is the honest figure, with no bytes to quote.
const Partition& part = model.partition();
snap.buffer_requested = model.config().buffer_samples;
snap.buffer_samples = part.time_capacity > 0 ? part.time_capacity : buffer_ticks(snap.buffer_requested);
snap.buffer_bytes = part.bytes();
snap.memory_limit_bytes = model.memory_limit_bytes();
snap.memory_limited = part.memory_limited;
}
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 && app->source_has_datagrams ? 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);
}
/// The trigger pane's mode: its body against its summary line and its [edit] button against what it would
/// do. Idempotent, so a caller only flips the 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");
// The lock decides whether the compact line's arm control is the one on screen: with the body shown the
// body's own button is there, and two arm buttons at once would be two controls for one state.
sync_arm_buttons(app);
sync_trigger_summary(app);
}
/// The trigger pane's [edit] button.
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;
apply_pane_lock(*app);
}
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::ShowMeasure: slideover_toggle(app, "measure"); break;
case AppAction::ShowSignals: {
// The `+` badge is insensitive without signals; so is its shortcut.
bool have_signals = false;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
have_signals = !app.model->signals().empty();
}
if (have_signals) slideover_toggle(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::GuidedTour:
start_tour(app);
break;
case AppAction::ContextualHelp:
// Through the menu's own row rather than around it: the check *is* the setting, and the row's
// handler is the one place that writes it.
toggle(app.contextual_toggle);
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;
// Escape ends the tour, and it does so ahead of the editable check: a tour that cannot be dismissed
// with the key every one of them teaches is a tour with a trap in it.
if (keyval == GDK_KEY_Escape && tour_active(*app)) {
tour_dismiss(*app);
return TRUE;
}
// ... and then the slide-over: Escape closes it, ahead of the editable check for the same reason -- the
// key that dismisses a panel must work from inside one of its own spin buttons.
if (keyval == GDK_KEY_Escape && slideover_escape(*app)) return TRUE;
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));
// Where the contextual help's "?" for the span control joins: the slot, not the bar, because a
// control added to a header bar lands right of everything the file packed.
app.window_control_slot = slot;
}
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 Help, then the
// two about-the-program entries. 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());
// The Help submenu's door. A row and not a `GtkMenuButton`: the submenu is the *next view of this
// menu*, and the chevron is what says so -- the same shape, and the same reason, as the plot menu's
// Remove-signal row.
GtkWidget* help_row = menu_button("Help", nullptr);
gtk_widget_set_tooltip_text(help_row,
"the shortcut list, the documentation, a guided tour and the "
"contextual help");
g_signal_connect(help_row, "clicked", G_CALLBACK(on_menu_show_help), &app);
if (GtkWidget* inner = gtk_button_get_child(GTK_BUTTON(help_row))) {
gtk_box_append(GTK_BOX(inner), gtk_image_new_from_icon_name("pan-end-symbolic"));
}
app.menu_help_row = help_row;
gtk_box_append(GTK_BOX(menu_box), help_row);
GtkWidget* scope_item = menu_button("New scope window", nullptr);
gtk_widget_set_tooltip_text(scope_item,
"a second window onto the same stream, with an empty figure; "
"a plot's own menu opens that figure in one");
g_signal_connect(scope_item, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
open_scope_window(*static_cast<App*>(user), -1);
}),
&app);
gtk_box_append(GTK_BOX(menu_box), scope_item);
// The windows a session remembers but a launch does not open: the entry carries their count and
// opens them on request.
app.reopen_scopes_item = menu_button("Reopen closed windows", nullptr);
gtk_widget_set_tooltip_text(app.reopen_scopes_item,
"open the extra windows the session remembers (they are saved, "
"never opened at start)");
g_signal_connect(app.reopen_scopes_item, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
App& a = *static_cast<App*>(user);
std::vector<LayoutState::Window> windows = std::move(a.closed_windows);
a.closed_windows.clear();
for (const LayoutState::Window& w : windows) open_scope_window_from_state(a, w);
sync_reopen_scopes_item(a);
schedule_layout_save(a);
}),
&app);
gtk_box_append(GTK_BOX(menu_box), app.reopen_scopes_item);
sync_reopen_scopes_item(app);
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 Help view, the menu's second page --------------------------------
// Exactly the four entries the menu promises: the two dialogs that already existed, the tour, and
// the switch for the "?" layer. The two dialogs are the *same* entry points the keys reach --
// `show_shortcuts()` and `show_help()` -- so the menu and the accelerator cannot drift apart.
GtkWidget* help_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_set_margin_top(help_box, 6);
gtk_widget_set_margin_bottom(help_box, 6);
gtk_widget_set_margin_start(help_box, 6);
gtk_widget_set_margin_end(help_box, 6);
GtkWidget* back_row = menu_icon_button("go-previous-symbolic", "Back");
g_signal_connect(back_row, "clicked", G_CALLBACK(on_menu_back), &app);
app.menu_back_row = back_row;
gtk_box_append(GTK_BOX(help_box), back_row);
gtk_box_append(GTK_BOX(help_box), menu_separator());
app.menu_shortcuts_row = menu_button("Keyboard shortcuts", "Ctrl+/");
gtk_widget_set_tooltip_text(app.menu_shortcuts_row,
"every key and mouse gesture, including the plot's own");
g_signal_connect(app.menu_shortcuts_row, "clicked", G_CALLBACK(on_shortcuts_clicked), &app);
gtk_box_append(GTK_BOX(help_box), app.menu_shortcuts_row);
app.menu_docs_row = menu_button("Documentation", "F1");
gtk_widget_set_tooltip_text(app.menu_docs_row,
"the udpscope manual, opened in the desktop help viewer");
g_signal_connect(app.menu_docs_row, "clicked", G_CALLBACK(on_help_clicked), &app);
gtk_box_append(GTK_BOX(help_box), app.menu_docs_row);
app.menu_tour_row = menu_button("Guided tour", "Ctrl+T");
gtk_widget_set_tooltip_text(app.menu_tour_row,
"walk over the plot, the badges, the trigger, the dock, the signals and "
"the status line, one step at a time");
g_signal_connect(app.menu_tour_row, "clicked", G_CALLBACK(on_tour_clicked), &app);
gtk_box_append(GTK_BOX(help_box), app.menu_tour_row);
// The check is the setting: `menu_toggle()` states it before connecting, so the row that starts
// checked does not fire the handler while the surfaces it acts on are still being built.
app.contextual_toggle = menu_toggle("Enable contextual help", nullptr,
app.settings.contextual_help);
gtk_widget_set_tooltip_text(app.contextual_toggle,
"a small \u201c?\u201d beside each surface, saying what that surface "
"is for");
g_signal_connect(app.contextual_toggle, "toggled", G_CALLBACK(on_contextual_help_toggled), &app);
gtk_box_append(GTK_BOX(help_box), app.contextual_toggle);
// The two views, as the pages of a stack: the popover's child is the stack and the visible child is
// the view. Swapping the *page* rather than rebuilding the box is what keeps `app.layout_toggle` --
// which points into the main view and which the tick reads -- a live widget.
app.menu_main_view = menu_box;
app.menu_help_view = help_box;
app.menu_stack = gtk_stack_new();
gtk_stack_set_transition_type(GTK_STACK(app.menu_stack), GTK_STACK_TRANSITION_TYPE_NONE);
gtk_stack_add_named(GTK_STACK(app.menu_stack), menu_box, "main");
gtk_stack_add_named(GTK_STACK(app.menu_stack), help_box, "help");
// --- 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), app.menu_stack);
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.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.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);
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 dock's scroller. Everything that depends on the
// stream -- the events page, the two plot areas, the dock'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;
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->plot_overlay = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_overlay"));
app->status_badge = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_badge"));
app->trigger_status = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "trigger_status"));
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"));
// The status line's row, which is where the status line's own "?" joins and one of the surfaces the
// help's annotated figures label.
app->status_row = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_row"));
GtkWidget* side = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_column"));
app->dock_scroller = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "dock_scroller"));
app->dock_page = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "dock_page"));
app->badge_bar = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "badge_bar"));
app->badge_signals = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "badge_signals"));
app->badge_system = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "badge_system"));
app->readouts_strip = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "readouts_strip"));
app->strip_box = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "readouts_strip_box"));
// 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 ||
app->plot_box == nullptr || app->plot_overlay == nullptr || app->plot_split == nullptr ||
app->status_badge == nullptr ||
app->status_label == nullptr || app->status_info_button == nullptr ||
app->status_row == nullptr || app->badge_bar == nullptr || app->badge_signals == nullptr ||
app->badge_system == nullptr || app->readouts_strip == nullptr || app->strip_box == nullptr ||
side == nullptr || app->dock_scroller == nullptr || app->dock_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);
// The destroy handler below needs the application, and a captureless callback cannot close
// over it: kept on the window, which outlives the handler.
g_object_set_data(G_OBJECT(app->window), "udpscope-application", application);
// The main window's lifecycle hook, and it is `close-request`: **GTK4 has no
// `GtkWidget::destroy` signal** (the GTK3 connect this used to be never fired), and the only
// way a window is asked to end is the close request -- the WM's button, `gtk_window_close()`
// from a menu action, and the exit probe's own close. `gtk_window_destroy()` does not emit
// it, which is why everything that ends this window programmatically goes through
// `gtk_window_close()` instead.
//
// What it does, in order: the **scope windows** go with it (they are views of this window's
// acquisition; left behind they keep the GApplication's loop alive around a window that is
// gone -- the exit run's hang), the debounced layout save is flushed (a pending save would
// never fire now), the tick is stopped (`a->window = nullptr`; the widgets are about to be
// finalized), and the application is asked to quit -- the main window is its anchor, so the
// run must end whatever else was open. `FALSE` lets the default handler destroy the window.
g_signal_connect(app->window, "close-request", G_CALLBACK(+[](GtkWindow* window, gpointer user) {
App* a = static_cast<App*>(user);
// Collected first: each scope's own destroy handler erases it from
// `scopes` as it fires.
std::vector<GtkWindow*> scope_windows;
scope_windows.reserve(a->scopes.size());
for (const std::unique_ptr<App::ScopeWindow>& scope : a->scopes) {
if (scope->window != nullptr) scope_windows.push_back(scope->window);
}
for (GtkWindow* w : scope_windows) gtk_window_close(w);
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;
if (auto* application = GTK_APPLICATION(
g_object_get_data(G_OBJECT(window), "udpscope-application"))) {
g_application_quit(G_APPLICATION(application));
}
return FALSE;
}),
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 guided tour's overlay goes *above* the window's content, between the window and the split
// view: its children -- the four dim rectangles, the spotlight's outline and the card -- have to
// be able to cover the whole content, and a widget placed anywhere inside the split view could
// only ever cover its own half of it. The split view keeps its reference from the builder, so
// handing the window a new child and then adopting the old one costs no reference bookkeeping.
{
GtkWidget* overlay = gtk_overlay_new();
gtk_window_set_child(GTK_WINDOW(app->window), overlay);
gtk_overlay_set_child(GTK_OVERLAY(overlay), side_view);
app->tour.overlay = overlay;
build_tour(*app);
}
// The slide-over panel: an overlay child of the plot, above the GL area.
build_slideover(*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 280. 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 trigger circle starts hidden: the trigger starts off, and a light for a mode the user
// is not in is noise (see the tick's write).
if (app->trigger_status != nullptr) {
gtk_label_set_markup(GTK_LABEL(app->trigger_status), trigger_circle_markup(3));
gtk_widget_set_visible(app->trigger_status, FALSE);
}
// 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 the dock: one scroller holding only the compact read-only readouts (the
// cursors table and the measurements). The full Cursors and Measurements panels are slide-over pages
// (`cursors`, `measure`), live whether or not they are open: the pages hold the same widgets and the
// tick writes them either way. The signal list is a page too (reached by the `+` badge).
// `side` and `dock_page`/`dock_scroller` are resolved above with the rest of the skeleton and checked
// by the null guard; assign the member here.
app->side_box = side;
// Built in the order the panels have always been (cursors, measurements, trigger, signals); each is a
// page of the slide-over, reached by its badge or its key (the Cursors page by the dock's Edit button;
// the header's Cursors toggle only switches the cursors on and mirrors the switch inside the page).
app->cursors_panel = build_cursors_panel(*app);
app->measures_panel = build_measures_panel(*app);
app->trigger_panel = build_trigger_panel(*app);
// Top-aligned: the page's scroller gives the child the page's height, and a form that stretched to it
// would spread its rows (or draw its frame) over empty space under the last one.
gtk_widget_set_valign(app->trigger_panel, GTK_ALIGN_START);
slideover_add_page(*app, "trigger", "Trigger", app->trigger_panel);
// The signal list is the `signals` page, reached by the `+` badge or `g`; the panel is built here, after
// `build_slideover`, and registered once.
app->signal_panel = build_signal_panel(*app);
slideover_add_page(*app, "signals", "Signals", app->signal_panel);
// `c` / the header toggle drive the cursors themselves; this page is where they are configured and read
// in full. `m` toggles the measurements page.
gtk_widget_set_valign(app->cursors_panel, GTK_ALIGN_START);
gtk_widget_set_valign(app->measures_panel, GTK_ALIGN_START);
slideover_add_page(*app, "cursors", "Cursors", app->cursors_panel);
slideover_add_page(*app, "measure", "Measurements", app->measures_panel);
// The dock: the compact readouts, and under them the compact events list.
gtk_box_append(GTK_BOX(app->dock_page), build_readouts_dock(*app));
gtk_box_append(GTK_BOX(app->dock_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
gtk_box_append(GTK_BOX(app->dock_page), build_events_dock(*app));
// 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);
dock_set_cursors_sensitive(*app, false); // the dock's grid and the strip's chips, together
// The arm controls are the one part of the trigger pane that needs trigger mode running: both of them,
// and the compact line's visibility, are stated from the toggle by `sync_arm_buttons()`.
sync_arm_buttons(*app);
gtk_widget_set_sensitive(app->cursors_reset, FALSE);
// The source kind's own panels (a generator, digital I/O), pages of the slide-over, added before the
// first tick.
build_source_panels(*app);
// The badge bar reads `trigger_toggle` (made by `build_toolbar()` above) and the source's pages, which
// `build_source_panels()` has just registered: the Source badge opens the first of them.
build_badge_bar(*app);
// The full events list is the `events` page of the slide-over (the dock's Manage button opens it), so
// the plot is never squeezed by it. Once the page and the dock block both exist: nothing is cached yet,
// so both start insensitive -- stated by the one function that decides it rather than by whichever
// widget happened to be built with which default.
build_events_pane(*app);
slideover_add_page(*app, "events", "Events", app->events_box);
sync_events_availability(*app);
// 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: 280 px from the `.ui` (min = max),
// 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);
// The contextual-help layer, once every surface it names exists. It reads the setting, so a user
// who turned it off meets a window with no "?" on it and a menu whose row is unchecked.
context_help_sync(*app);
// A first run is the run whose settings file does not say the tour has been offered -- see
// `tour_autostart()`. Delayed rather than fired on the first tick: the window has to be up and
// laid out for the tour to have anything to point at, and a tour that opened on the frame the
// window appeared would be a spotlight on a widget with no allocation yet. The source chooser's
// own "Guided tour" entry arrives as `Config::tour_pending` (the request has to outlive the dialog
// that made it -- the tour is drawn on *this* window) and gets the same delay for the same reason,
// through its own callback so the two are told apart in the readings.
if (app->model->config().tour_pending) {
g_timeout_add(1500, tour_start_requested, app);
} else if (!app->settings.tour_seen) {
g_timeout_add(1500, tour_autostart, 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 buffer: %s samples per signal",
cfg.server.c_str(), unsigned(cfg.port),
format_samples(buffer_ticks(cfg.buffer_samples)).c_str());
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;
}
/// The events block's availability as one reading: the rule, the dock block, the page, and the slide-over's
/// open page. Every step that changes recording, replay or the cache prints all of them.
void report_events_availability(App& a, const char* what) {
// `available` is the rule `sync_events_availability()` states; the dock block and the page must both
// follow it, and `page` is the slide-over's open page.
const bool available = a.event_recording || a.replaying || a.events_cached;
std::fprintf(stderr, "[events] %s: available=%d dock_sensitive=%d page_sensitive=%d page=%s\n", what,
available ? 1 : 0,
a.dock_events_box != nullptr && gtk_widget_get_sensitive(a.dock_events_box) ? 1 : 0,
a.events_box != nullptr && gtk_widget_get_sensitive(a.events_box) ? 1 : 0,
slideover_page(a).empty() ? "none" : slideover_page(a).c_str());
}
/// Walks the whole event-cache loop the way a user does: trigger mode on (the real toggle), 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.
/// Checks that the manual is present in the tree where the build says it is (`topics` and
/// `index`, both zero if it is missing), opens the About and shortcut dialogs, reads them and
/// closes them. The manual itself opens in a separate viewer process, so there is no help
/// window here to build; `closed ok` with no GTK critical is the crash check for the two
/// dialogs built and destroyed in one run.
/// GTK writes its own log messages -- `Trying to measure GtkBox \u2026 for width of N, but it needs at
/// least M` is one -- from inside a call of ours, so they reach stderr and never a widget: no probe
/// can read one, and the class is otherwise invisible to the self-check. The exit dump therefore
/// carries a count of them, which is the one reading that can assert the class away (`gtk warnings:
/// 0`). GTK is built with structured logging, so its messages go through the log *writer* and not
/// through the handler API (`g_log_set_handler()` never sees them); the writer is chained to rather
/// than replaced, so every message still reaches stderr exactly as before and the documented
/// baselines are read the same way they always were.
int g_gtk_warning_count = 0;
GLogWriterOutput count_gtk_log(GLogLevelFlags level, const GLogField* fields, gsize n_fields,
gpointer user_data) {
for (gsize i = 0; i < n_fields; ++i) {
if (g_strcmp0(fields[i].key, "GLIB_DOMAIN") != 0) continue;
if (g_strcmp0(static_cast<const char*>(fields[i].value), "Gtk") == 0 &&
(level & (G_LOG_LEVEL_WARNING | G_LOG_LEVEL_CRITICAL | G_LOG_LEVEL_ERROR)) != 0) {
++g_gtk_warning_count;
}
break;
}
return g_log_writer_default(level, fields, n_fields, user_data);
}
int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisition& acq,
RowSet& rows) {
(void)argc;
// Count GTK's own log messages for the run (see `count_gtk_log`). Installed before anything can
// log -- and it must be: a log writer function may only be installed once, and GTK does not install
// one, so this is the whole of the run's logging rather than a replacement.
g_log_set_writer_func(count_gtk_log, nullptr, nullptr);
// 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;
// The crosshair's axis badges, before the window exists: the setting is per plot, and the
// flag on the set is what every rebuild restates (see `RowSet::install()`).
rows.set_crosshair_axis_labels(startup_settings.crosshair_axis_labels);
// 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.session_host() : std::string();
app.source_port = cfg.open_waveform.empty() ? cfg.session_port() : 0;
app.source_multicast = cfg.open_waveform.empty() ? cfg.multicast : std::string();
app.source_interface = cfg.interface_addr;
app.source_kind = cfg.source_kind;
app.source_opts = cfg.source_opts;
if (cfg.source_kind != "udps" && find_kind(cfg.source_kind) != nullptr) {
app.source_title = find_kind(cfg.source_kind)->display_name() + " " +
find_kind(cfg.source_kind)->identity(cfg.source_config());
} else {
app.source_title = app.source_host + ":" + std::to_string(app.source_port);
}
if (SourceKind* k = find_kind(cfg.source_kind)) app.source_has_datagrams = k->has_datagrams();
// The event cache is keyed on the source **and its active configuration**, like the packet
// capture and the session files: an event's trigger, channel set and units belong to the
// configuration it was latched under, so two configurations of one source must not list each
// other's events. The active configuration is readable here -- the configuration files are not
// stream data -- so the store starts on the right one instead of being re-keyed after the first
// CONFIG arrives. 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) {
if (app.config_name.empty()) {
app.config_name = active_configuration(app.source_host, app.source_port);
}
app.event_store = std::make_unique<EventStore>(
app.source_host + "-" + std::to_string(app.source_port), app.settings.event_cache_dir,
app.config_name);
}
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_PROBE_FORCE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), force_probe, &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)), dock_section_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_SLIDEOVER_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), slideover_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_BADGES_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), badges_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_SIGNAL_BADGES_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), signal_badges_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_SIGNAL_PAGES_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), signal_page_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_BANDAVG_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), bandavg_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_PROBE_SPLIT_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), split_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_MATHWRAP_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), mathwrap_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_GATE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), gate_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_CROSSHAIR_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), crosshair_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_MAXIMIZE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), maximize_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_SCOPE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), scope_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_LEVELS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), levels_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASUREPIN_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), measurepin_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_HELPUI_MS")) {
helpui_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_TOUR_SURFACE_MS")) {
tour_surface_probe_next(app, static_cast<guint>(std::atoi(env)));
}
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_DOCK_SESSION_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), dock_session_probe, &app);
}
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);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_PERSIST_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), persist_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_DENSITY_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), density_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_SOURCE_KINDS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), source_kinds_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), e1608_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_GEN")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), e1608_gen_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_RECONNECT")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), e1608_probe_reconnect, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_SCAN")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), e1608_scan_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_FLATUDP")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), flatudp_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_FLATUDP_GAP")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), flatudp_probe_gap, &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()));
}
// The sample budget as this run planned it, and the planner on fixed inputs. The first line is
// the live buffer: `used` is the request rounded down to a power of two and clipped by the memory
// limit, `bytes` the rings' real size. The second feeds `plan_partition()` three channels and a
// 100 M request three ways -- room to spare, a 1 GiB limit that must clip, a limit too small for
// even 128 slots -- so a change to the rounding, the halving or the byte formula reads here even
// when the run's own plan happens not to be clipped.
std::printf("[udpscope] buffer: requested=%llu used=%llu bytes=%llu limit=%llu limited=%d\n",
static_cast<unsigned long long>(model.config().buffer_samples),
static_cast<unsigned long long>(part.time_capacity),
static_cast<unsigned long long>(part.bytes()),
static_cast<unsigned long long>(model.memory_limit_bytes()),
part.memory_limited ? 1 : 0);
{
const std::vector<double> dts(3, 1.0e-4);
const auto pow2 = [](unsigned long long v) { return v != 0 && (v & (v - 1)) == 0; };
const Partition open = plan_partition(dts, 100000000ull, 16ull << 30, 0.0);
constexpr unsigned long long kGiB = 1ull << 30;
const Partition clip = plan_partition(dts, 100000000ull, kGiB, 0.0);
const Partition clip_rel = plan_partition(dts, 100000000ull, kGiB, 0.0, true);
bool thrown = false;
try {
(void)plan_partition(dts, 100000000ull, 1000, 0.0);
} catch (const std::exception&) {
thrown = true;
}
std::printf("[udpscope] planner: unclipped got=%llu pow2=%d limited=%d | clipped(1GiB) "
"got=%llu bytes=%llu fits=%d pow2=%d limited=%d | clipped_rel got=%llu "
"bytes=%llu fits=%d | too_small_throws=%d\n",
static_cast<unsigned long long>(open.time_capacity),
pow2(open.time_capacity) ? 1 : 0, open.memory_limited ? 1 : 0,
static_cast<unsigned long long>(clip.time_capacity),
static_cast<unsigned long long>(clip.bytes()), clip.bytes() <= kGiB ? 1 : 0,
pow2(clip.time_capacity) ? 1 : 0, clip.memory_limited ? 1 : 0,
static_cast<unsigned long long>(clip_rel.time_capacity),
static_cast<unsigned long long>(clip_rel.bytes()),
clip_rel.bytes() <= kGiB ? 1 : 0, thrown ? 1 : 0);
}
// `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 three labels (heading,
// hint, and the "every signal is acquired" one, hidden or not) 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
// and the hint in a box of its own, 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 addable=%zu addable_ok=%d empty_label=%d\n",
labels, lists, app.dbg_signal_rows, app.dbg_signal_plain, app.dbg_signal_math_rows,
app.dbg_signal_rows ==
static_cast<int>(app.dbg_signal_plain + 1 + app.dbg_signal_math_rows)
? 1
: 0,
app.dbg_signal_drag_sources, app.dbg_signal_addable,
app.dbg_signal_plain + app.dbg_signal_math_rows == app.dbg_signal_addable ? 1 : 0,
app.dbg_signal_empty_label);
}
{
// The events page's frame, read from the window: the widgets the `.ui` gives it, and the list
// being inside the box the file builds. A control that lost its id reads 0 here rather than
// showing as a page missing a button, and `nested=0` is a list that is no longer in the page 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 nested=%d\n",
app.events_compare != nullptr ? 1 : 0, app.events_accumulate != nullptr ? 1 : 0,
app.events_list != nullptr ? 1 : 0, inside(app.events_list, app.events_box));
}
std::printf("[udpscope] panels: side=%dx%d (visible=%d) signals=%dx%d row_h=%d "
"(page_current=%d) trigger=%dx%d (sensitive=%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,
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: slide-over closed for the drag=%d\n",
app.dbg_drag_closed_slideover);
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 body_visible=%d summary='%s'\n",
app.dbg_trigger_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] dock: shown=%d sensitive=%d w=%d scroller_pages=%d\n", app.dbg_dock_shown,
app.dbg_dock_sensitive, app.dbg_dock_w, app.dbg_dock_scrollers);
// The help affordance layer: what the contextual-help toggle left on the surfaces, and where the
// guided tour stood when the last capture ran. Both are captured on the tick (`capture_help_state`),
// so neither asks a destroyed widget anything.
std::printf("[udpscope] help layer: %s\n", app.dbg_context_help.c_str());
// GTK's own log at warning or above, counted for the whole run (see `count_gtk_log`). A probe
// cannot read one of these -- they are written by the toolkit, not stored in a widget -- so this
// count is the reading that catches the class, and the run must print `0`.
std::printf("[udpscope] gtk warnings: %d\n", g_gtk_warning_count);
// The bounds the help's annotated figures draw their crop boxes from: window coordinates, or
// -1,-1,0,0 for a widget that is not on screen (`is_visible()`, not `get_visible()` -- a widget
// under a hidden parent keeps its own flag true). Measured on the tick, after a frame.
std::printf("[udpscope] help figures: %s\n", app.dbg_help_figures.c_str());
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] 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];
// Numbered like the pane's row: which active channel this measurement is about, and
// unnumbered when its signal is not acquired -- a probe that switched a signal off
// before the dump (enable_all does exactly that) must not read as a numbering fault.
// The number is read from the model here (the dump cannot ask the row's label), so a
// mismatch with what the pane showed while the signal was up is a finding about
// `measure_channel_number`.
const int number = measure_channel_number(app, app.measures[i].signal);
const std::string prefix = number > 0 ? std::to_string(number) + "." : "";
std::printf(" %s%s %s=%s", prefix.c_str(),
app.measures[i].signal.c_str(), 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);
}
}
// The measurement gate's own line: whether it is on and where its bounds sit. The state is
// the RowSet's (no GTK here -- the dump runs after the window is gone), and it is what a
// session round trip is read against: a restore that left the gate off, or put the band
// somewhere else than the saved session had it, reads here.
std::printf("[udpscope] gate: on=%d bounds=[%.6f..%.6f]\n",
app.rows->gate_visible() ? 1 : 0, app.rows->gate_x0(), app.rows->gate_x1());
// The trigger circle's state as of the last tick: off / green / flash / dim / red. The
// dump runs after the window is gone, so this is the tick's cached answer, not a read.
{
static const char* const kCircle[] = {"green", "flash", "dim", "red"};
const int c = app.dbg_trigger_circle;
std::printf("[udpscope] trigger circle: %s\n",
c < 0 ? "off" : (c < 4 ? kCircle[c] : "?"));
}
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; "
"compact arm with the trigger off: visible=%d; with the trigger on and the pane "
"locked: visible=%d sensitive=%d width=%d label=%s\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, app.dbg_summary_arm_off,
app.dbg_summary_arm_visible, app.dbg_summary_arm_sensitive, app.dbg_summary_arm_width,
app.dbg_summary_arm_label.c_str());
{
// 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 a block's widgets are read with has
// to start at that block's own child and not at the panel -- walking to the panel reads 0 on a panel
// that is entirely correct, which is what this line did first.
//
// Two expanders now: the search block and the Advanced one. The root is taken from **the expander
// itself** (`App::search_exp`/`App::trigger_advanced`) rather than from "the first expander in the
// panel", which is what this loop used to do -- and which stopped naming the search block the moment
// a second one was added before it.
GtkWidget* search_root = app.trigger_body;
if (app.search_exp != nullptr) {
if (GtkWidget* inner = gtk_expander_get_child(GTK_EXPANDER(app.search_exp))) {
search_root = inner;
}
}
GtkWidget* advanced_root = app.trigger_body;
if (app.trigger_advanced != nullptr) {
if (GtkWidget* inner = gtk_expander_get_child(GTK_EXPANDER(app.trigger_advanced))) {
advanced_root = inner;
}
}
const int search_present =
(app.search_run_button != nullptr) + (app.search_cancel_button != nullptr) +
(app.search_prev_button != nullptr) + (app.search_next_button != nullptr);
// The Advanced block's own two readings, for the search block's two reasons: the ids resolving and
// the controls being *in* the block. Its controls are the five the density added, and they must be
// inside `advanced_root` and not merely non-null -- an id that drifted leaves a control built and
// nowhere, which is the failure this pair exists to catch.
const int advanced_present =
(app.trigger_persist_spin != nullptr) + (app.trigger_density_switch != nullptr) +
(app.trigger_density_decay_drop != nullptr) + (app.trigger_density_tau_spin != nullptr) +
(app.trigger_density_factor_spin != nullptr);
const int advanced_inside =
inside(app.trigger_persist_spin, advanced_root) +
inside(app.trigger_density_switch, advanced_root) +
inside(app.trigger_density_decay_drop, advanced_root) +
inside(app.trigger_density_tau_spin, advanced_root) +
inside(app.trigger_density_factor_spin, advanced_root);
std::printf(
"[udpscope] trigger panel: boxes=%d controls_inside=%d/15 search_widgets=%d/4 "
"search_inside=%d/4 advanced_widgets=%d/5 advanced_inside=%d/5\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),
advanced_present, advanced_inside);
}
{
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);
// The band's other edge, only for the type that reads two shares: the reading of a `between`
// is a function of the pair, so a line that printed one of them could not say what it is.
if (measure_uses_band(m.type)) std::printf(" thr2=%.1f%%", m.options.threshold_pct2);
// 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 window it
// *should* describe -- 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`).
//
// Which window that should be depends on the view, so `drift` is measured against the
// **target**: `win` live, and the **pin** (`pin=`, the same stream seconds) while the view
// is pinned. On a pinned view the pane is about the frame, and a zoom inside it is allowed
// to leave the screen showing a slice of that frame -- reading that as drift would report
// the intended behaviour as the defect it was already reported as once.
//
// `win` and `pin` come from the last **tick** (`App::measure_view_window`,
// `App::measure_pin_window`) and are not read back here: this runs from the destroy
// handler, where the exit probe has already switched the trigger on, and `stream_at_x0()`
// then answers for a mode no frame was drawn in.
const plot::Range win = app.measure_view_window;
const plot::Range target = app.measure_pin_valid ? app.measure_pin_window : win;
const double span = target.span();
int in_window = 0;
int marks = 0;
// Against `win`, the window **on screen** -- not against `measure_window`/`target`, which
// contain every mark by construction: read that way the count was a tautology, and it
// printed `2/2` while the pane's numbers described a window 11 s and a forty-fold zoom
// away from the one displayed. A *time* mark may legitimately sit outside `win` on a
// pinned view that has been zoomed into the pin -- the mark is a position in the window the
// result describes, and that window is the frame, not the slice -- so the strict `K/K`
// reading belongs to a live view and to a pinned view left at its pin.
for (const RowSet::MeasureMark& mk : app.measure_marks) {
if (mk.level) continue;
++marks;
if (mk.value >= win.min && mk.value <= win.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;
}
}
const std::string pin_text =
app.measure_pin_valid ? std::to_string(app.measure_pin_window.min) + ".." +
std::to_string(app.measure_pin_window.max)
: std::string("off");
std::printf("[udpscope] measures window: win=[%.6f..%.6f] pin=[%s] results_for=[%.6f..%.6f] "
"drift=%.4f%% marks_in_window=%d/%d marks_selectable=%d\n",
win.min, win.max, pin_text.c_str(), app.measure_window.min,
app.measure_window.max,
span > 0.0 ? 100.0 * (std::fabs(target.min - app.measure_window.min) +
std::fabs(target.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("]");
}
// The split, appended so every field before it keeps its place: cells per figure row, and the
// cell weights per figure row (`|` between rows, empty for an unsplit one). RowSet only.
std::printf(" cells=[%s] colweights=[%s]", layout_cells_text(rows).c_str(),
layout_colweights_text(rows).c_str());
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));
}
}
{
// Persistence: what is retained, what the rows had room to draw, and the decay schedule in
// use (oldest first, so the half-per-acquisition fall-off reads straight off the line). The
// layer slots are named in the `layout:` line above, as `sine (A)`, and are deliberately
// absent from the per-slot channel lines below it: a layer is not a channel.
std::lock_guard<std::mutex> lock(model.mutex());
const int depth = model.persist_depth_locked();
std::printf("[udpscope] persistence: depth=%d kept=%zu bytes=%zu budget=%zu dropped=%llu "
"layers=%d want=%d placed=%d schedule=[",
depth, model.persist_count(), model.persist_bytes(),
model.persist_budget_bytes(),
static_cast<unsigned long long>(model.persist_dropped()), rows.layer_slots(),
app.persist_want, app.persist_placed);
// The decay schedule the depth asks for, oldest first: half per acquisition, so this line
// reads the rule and the probe's `alphas=` reads what the rows were actually built with.
for (int age = std::min(depth - 1, kMaxPersistDepth - 1); age >= 1; --age) {
std::printf("%s%.4f", age != depth - 1 ? "," : "",
static_cast<double>(RowSet::persist_alpha(age)));
}
std::printf("] alphas=[");
const std::vector<std::pair<int, float>> layers = rows.row_layers(0);
for (std::size_t i = 0; i < layers.size(); ++i) {
std::printf("%s%.4f", i != 0 ? "," : "", static_cast<double>(layers[i].second));
}
std::printf("]\n");
}
{
// The phosphor density, as of the last tick it was **on** (the exit probe switches the
// trigger off before this runs, so the plots hold nothing by now -- see `App::dbg_density_*`).
// `frames` is how many ticks drew an image; `rows`/`traces` how many plots and how many of
// their traces held a grid (one per trace, in the trace's own colour); `cells`/`grid_bytes`
// its resolution and memory over them, `ink`/`peak` the numbers the plot's own readers
// report, and `upload_bytes` the most any one frame uploaded -- one byte a cell, on the frames
// whose grids moved; a frame whose grids did not move uploads nothing at all.
std::printf("[udpscope] density: frames=%d rows=%d traces=%d cells=%llu grid_bytes=%llu "
"ink=%.1f peak=%.1f upload_bytes=%lld\n",
app.dbg_density_frames, app.dbg_density_rows, app.dbg_density_traces,
app.dbg_density_cells, app.dbg_density_grid_bytes, app.dbg_density_ink,
app.dbg_density_peak, app.dbg_density_upload_bytes);
}
}
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