Two things the lock got wrong, and they are the same mistake seen twice: trigger-off was treated as "the
pane is disabled".
**The configuration was unreachable exactly when it was wanted.** `on_trigger_toggled()` set the trigger
*body* insensitive with the mode — a rule carried over from the five-revealer sidebar, where a closed pane
had nothing to edit. A trigger is configured *before* it is switched on, so switching it off to change it,
or to read what it is set to, is the ordinary way to work. The body is now never disabled; only the
*cursors'* readout still is, which is the pane whose content has nothing to show with the instruments off.
**The locked pane described a trigger that was not running.** With trigger mode off the summary is one
line — `mode: stream (trigger off)` — because the four lines it used to show (signal, condition, level,
mode) describe a trigger that is not there. With the trigger on it is those four plus whatever is not at
its default (`noise band`, `HF reject`, `pre-trigger`, `hold-off`).
The reading changes with it: `trigger panel sensitive …=1` is now a **regression guard** rather than a
state — the trigger's body is always live, so a `0` there means the settings have been locked out again.
Verified in both states, from sessions whose only difference is `trigger_enabled`:
panes: trigger=locked signals=locked body_visible=0 summary='mode: stream (trigger off)'
panes: trigger=locked signals=locked body_visible=0 summary='signal: square | condition: window, rising | level: 0.3 .. 0.6 | mode: single | pre-trigger: 20 %'
trigger panel sensitive before the probe toggled it: 1
The locked trigger pane is 72 px with the trigger off and 192 px with it on. Docs: the invariants' rule
for which body is disabled and why the trigger's is not, the `summary`'s two forms with what each means,
the README's deviation note, and the second `panes:` line renamed to say what it is — the line as the
probe found it, because only a run whose *session* has the trigger off shows the stream form.
6264 lines
322 KiB
C++
6264 lines
322 KiB
C++
// SPDX-License-Identifier: MIT
|
||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||
|
||
// The GTK4 front end: the toolbar, the trigger and signal panels, and the one plot
|
||
// area the `RowSet`'s figure is drawn in.
|
||
//
|
||
// Every widget here is a thin driver over the model: nothing in this file owns
|
||
// sample data. The one thing it does own is the `plot::gtk4::PlotArea`, which is in
|
||
// figure mode -- the rows are the figure's rows, so a row whose trace set changed
|
||
// (`RowSet::row_dirty()`) costs a new figure and not a new widget: the area keeps its
|
||
// GL context and is handed the rebuilt figure.
|
||
|
||
#include "ui.hpp"
|
||
|
||
#include "ui_app.hpp"
|
||
|
||
#include <sys/stat.h>
|
||
|
||
#include <gtk4/gtk_plot_area.hpp>
|
||
|
||
#include "acquisition.hpp"
|
||
#include "build_info.hpp"
|
||
#include "capture.hpp"
|
||
#include "config.hpp"
|
||
#include "event_store.hpp"
|
||
#include "waveform.hpp"
|
||
#include "export.hpp"
|
||
#include "help_window.hpp"
|
||
#include "layout_store.hpp"
|
||
#include "shortcuts.hpp"
|
||
#include "status_text.hpp"
|
||
#include "measure.hpp"
|
||
#include "model.hpp"
|
||
#include "plot_host.hpp"
|
||
#include "search.hpp"
|
||
#include "source_dialog.hpp"
|
||
|
||
#include <adwaita.h>
|
||
#include <gtk/gtk.h>
|
||
|
||
#include <algorithm>
|
||
#include <cmath>
|
||
#include <utility>
|
||
#include <cstdio>
|
||
#include <cstdlib>
|
||
#include <cstring>
|
||
#include <ctime>
|
||
#include <memory>
|
||
#include <mutex>
|
||
#include <string>
|
||
#include <unordered_map>
|
||
#include <vector>
|
||
|
||
namespace udpscope {
|
||
namespace ui_impl {
|
||
|
||
|
||
/// Which group layout is on screen, if any.
|
||
|
||
|
||
// --- small helpers ----------------------------------------------------------
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
/// Same, without expanding the child. Header-bar children expand through the
|
||
/// title's centre box (a start-packed expanding widget is measured into it), so
|
||
/// the window control there stretched all the way to the title; panels want the
|
||
/// fill, the bar does not.
|
||
// --- persisted layout -------------------------------------------------------
|
||
|
||
/// Defined with the events pane; the trigger toggle reveals it.
|
||
|
||
|
||
// Defined below; the configurations dialog opens a session with them, and it sits above all three.
|
||
// `set_notice`'s default is on its definition, so the third argument is passed explicitly here.
|
||
|
||
/// The configurations of the current source, as a modal window: the list, and a form that saves
|
||
/// the session under a new name with a note. Opening one applies its layout to the running scope
|
||
/// and remembers it as active; the settings that can only take effect at startup (the buffer, the
|
||
/// clock, the capture ring) are reported rather than pretended.
|
||
|
||
|
||
|
||
|
||
|
||
|
||
void open_configurations(App& app) {
|
||
if (app.source_port == 0) return;
|
||
if (app.config_dialog != nullptr) {
|
||
gtk_window_present(GTK_WINDOW(app.config_dialog));
|
||
return;
|
||
}
|
||
GtkWidget* window = dialog_window(GTK_WIDGET(app.window), "Configurations");
|
||
GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window));
|
||
|
||
GtkWidget* title = gtk_label_new(nullptr);
|
||
gtk_label_set_markup(GTK_LABEL(title),
|
||
("<b>" + app.source_host + ":" + std::to_string(app.source_port) +
|
||
"</b>")
|
||
.c_str());
|
||
gtk_widget_set_halign(title, GTK_ALIGN_START);
|
||
gtk_box_append(GTK_BOX(box), title);
|
||
|
||
auto* d = new ConfigDialog;
|
||
d->app = &app;
|
||
d->window = window;
|
||
d->list = gtk_list_box_new();
|
||
gtk_list_box_set_selection_mode(GTK_LIST_BOX(d->list), GTK_SELECTION_NONE);
|
||
g_signal_connect(d->list, "row-activated", G_CALLBACK(on_config_row_activated), d);
|
||
GtkWidget* scroll = gtk_scrolled_window_new();
|
||
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_NEVER,
|
||
GTK_POLICY_AUTOMATIC);
|
||
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), d->list);
|
||
gtk_widget_set_size_request(scroll, 460, 220);
|
||
gtk_box_append(GTK_BOX(box), scroll);
|
||
|
||
d->name = gtk_entry_new();
|
||
gtk_entry_set_placeholder_text(GTK_ENTRY(d->name), "new configuration name");
|
||
gtk_box_append(GTK_BOX(box), labeled_box("name", d->name));
|
||
d->comment = gtk_entry_new();
|
||
gtk_entry_set_placeholder_text(GTK_ENTRY(d->comment), "what it is for");
|
||
gtk_box_append(GTK_BOX(box), labeled_box("note", d->comment));
|
||
|
||
GtkWidget* save = gtk_button_new_with_label("Save this session");
|
||
gtk_widget_set_tooltip_text(save,
|
||
"write the current arrangement under this name, and make it the "
|
||
"source's active configuration");
|
||
g_signal_connect(save, "clicked", G_CALLBACK(on_config_save_as), d);
|
||
GtkWidget* close = gtk_button_new_with_label("Close");
|
||
g_signal_connect_swapped(close, "clicked", G_CALLBACK(gtk_window_close), window);
|
||
dialog_add_button(window, close);
|
||
dialog_add_button(window, save);
|
||
|
||
g_object_set_data_full(G_OBJECT(window), "udpscope-config-dialog", d,
|
||
[](gpointer p) { delete static_cast<ConfigDialog*>(p); });
|
||
// Cleared on close: the pointer is what makes a second open a present rather than a second
|
||
// window, and a destroyed window must not leave it dangling.
|
||
g_signal_connect_swapped(window, "close-request", G_CALLBACK(+[](gpointer user) {
|
||
static_cast<App*>(user)->config_dialog = nullptr;
|
||
}),
|
||
&app);
|
||
app.config_dialog = window;
|
||
config_dialog_refresh(*d);
|
||
gtk_window_present(GTK_WINDOW(window));
|
||
}
|
||
|
||
|
||
|
||
|
||
// Defined below; `apply_layout()` uses them, and it is above both.
|
||
|
||
|
||
|
||
GtkWidget* labeled_box_fixed(const char* label, GtkWidget* child) {
|
||
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||
GtkWidget* l = gtk_label_new(label);
|
||
gtk_widget_set_halign(l, GTK_ALIGN_START);
|
||
gtk_box_append(GTK_BOX(box), l);
|
||
gtk_box_append(GTK_BOX(box), child);
|
||
return box;
|
||
}
|
||
|
||
/// A spin button that *starts* where the caller says. `gtk_spin_button_new_with_range()` leaves a
|
||
/// fresh button at the range's **minimum**, which is how an offset defaulted to -1e12 and a trigger
|
||
/// level to -1e12: a value nobody chose, read back by the dialog's own reader and applied. The
|
||
/// starting value is a parameter rather than a default so no call site can forget it.
|
||
GtkWidget* spin(double min, double max, double step, int digits, double value) {
|
||
GtkWidget* s = gtk_spin_button_new_with_range(min, max, step);
|
||
gtk_spin_button_set_digits(GTK_SPIN_BUTTON(s), digits);
|
||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(s), value);
|
||
return s;
|
||
}
|
||
|
||
|
||
|
||
|
||
/// The units the window control offers, coarsest last. A window is typed as a number in
|
||
/// one of these rather than picked off a ladder, so any span the buffer can hold is
|
||
/// reachable -- the ladder could only ever offer the spans someone had thought of.
|
||
/// Seconds is the coarsest: a scope's window is a span of the stream, and "minutes" is a unit for a
|
||
/// recording, not for a view. A window longer than a minute is still typeable -- the entry takes the
|
||
/// number and the cap is the buffer's own (`max_window_s`), not this list's last entry.
|
||
constexpr WindowUnit kWindowUnits[] = {
|
||
{"\u00b5s", 1.0e-6}, {"ms", 1.0e-3}, {"s", 1.0}};
|
||
|
||
/// The narrowest window the control will accept. Below a microsecond the axis labels stop
|
||
/// being distinguishable and no datasource this talks to streams that fast.
|
||
|
||
/// The window button's label is pinned to this many characters and ellipsized, exactly like
|
||
/// the status label: it sits in the header bar directly above the plot, so a label that grew
|
||
/// with its text ("1 s" -> "123.456 ms") would widen the bar and relayout everything under
|
||
/// it on a mere change of window.
|
||
|
||
/// Which unit reads most naturally for `s`: the coarsest whose value is still at least 1,
|
||
/// so 0.05 s is "50 ms" and not "0.05 s" or "50000 us".
|
||
int window_unit_for(double s) {
|
||
int unit = 0;
|
||
for (int i = kWindowUnitCount - 1; i >= 0; --i) {
|
||
if (s >= kWindowUnits[i].seconds * (1.0 - 1.0e-9)) {
|
||
unit = i;
|
||
break;
|
||
}
|
||
}
|
||
return unit;
|
||
}
|
||
|
||
/// The number part alone, in `unit`. Trailing zeros are trimmed so a decade stepper walking
|
||
/// 1 -> 10 -> 100 does not print "1.000"; three decimals is enough to survive a fit, whose
|
||
/// span is whatever the ring happened to hold.
|
||
std::string format_window_value(double s, int unit) {
|
||
char buf[32];
|
||
std::snprintf(buf, sizeof(buf), "%.3f", s / kWindowUnits[unit].seconds);
|
||
std::string out = buf;
|
||
const std::size_t dot = out.find('.');
|
||
if (dot != std::string::npos) {
|
||
std::size_t last = out.size();
|
||
while (last > dot + 1 && out[last - 1] == '0') --last;
|
||
if (last == dot + 1) last = dot;
|
||
out.resize(last);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
std::string format_window(double s) {
|
||
const int unit = window_unit_for(s);
|
||
return format_window_value(s, unit) + " " + kWindowUnits[unit].name;
|
||
}
|
||
|
||
|
||
|
||
/// A measurement: at most two decimals, scaled by an SI prefix so the number reads in
|
||
/// `1..999` (0.01052 V is `10.52 mV`), and the unit appended when the stream declares one.
|
||
///
|
||
/// Two decimals is the cursors' resolution claim: the markers land on a sample and the
|
||
/// sample is an `f32` of a value the CONFIG may scale arbitrarily, so more digits would be
|
||
/// noise dressed as precision. Trailing zeros go, so `1.50 V` reads `1.5 V` -- "at most
|
||
/// two decimals", not exactly two.
|
||
///
|
||
/// The prefix is applied **whether or not a unit is declared**, and that is the point: two
|
||
/// decimals of an unprefixed small value is `0.00`, i.e. the reading is simply gone. A signal
|
||
/// whose CONFIG declares no unit is ordinary (and a *math* channel never has one), so the pane
|
||
/// showed `0.00` for every value under 5 mV of an entire class of channels. A bare prefix is
|
||
/// written tight against the number -- `1.34m`, not `1.34 m` -- because the space is there to
|
||
/// separate the number from its unit, and with no unit to separate, a spaced `m` reads as metres.
|
||
std::string format_measure(double v, const std::string& unit) {
|
||
if (!std::isfinite(v)) return "--";
|
||
static const struct {
|
||
double scale;
|
||
const char* prefix;
|
||
} kPrefixes[] = {
|
||
{1.0e12, "T"}, {1.0e9, "G"}, {1.0e6, "M"}, {1.0e3, "k"},
|
||
{1.0, ""}, {1.0e-3, "m"}, {1.0e-6, "\u00b5"}, {1.0e-9, "n"}, {1.0e-12, "p"},
|
||
};
|
||
const double mag = std::fabs(v);
|
||
const char* prefix = "";
|
||
double scale = 1.0;
|
||
for (const auto& p : kPrefixes) {
|
||
if (mag >= p.scale) {
|
||
prefix = p.prefix;
|
||
scale = p.scale;
|
||
break;
|
||
}
|
||
}
|
||
char buf[64];
|
||
if (mag > 0.0 && mag < 1.0e-12) {
|
||
// Below the ladder, where scaling cannot help and `%.2f` would print `0.00` again. Three
|
||
// significant digits in whatever form fits, so the reading survives as a reading.
|
||
std::snprintf(buf, sizeof(buf), "%.3g", v);
|
||
std::string out = buf;
|
||
if (!unit.empty()) out += " " + unit;
|
||
return out;
|
||
}
|
||
std::snprintf(buf, sizeof(buf), "%.2f", v / scale);
|
||
std::string num = buf;
|
||
if (num.find('.') != std::string::npos) { // at most two decimals, not exactly two
|
||
while (num.size() > 1 && num.back() == '0') num.pop_back();
|
||
if (num.back() == '.') num.pop_back();
|
||
}
|
||
if (num == "-0") num = "0";
|
||
std::string out = num;
|
||
if (!unit.empty()) out += " "; // the space separates the number from its unit, nothing else
|
||
out += prefix;
|
||
out += unit;
|
||
return out;
|
||
}
|
||
|
||
|
||
|
||
// --- plot widgets -----------------------------------------------------------
|
||
|
||
|
||
|
||
/// The row index a widget belongs to -- a popover entry's, not a plot's: the menu is
|
||
/// built for one row and its buttons have to remember which. Stored biased by one,
|
||
/// because `g_object_set_data(obj, key, NULL)` *removes* the key and row 0 would then
|
||
/// read back as "no data".
|
||
|
||
void set_row_key(GtkWidget* widget, int row) {
|
||
g_object_set_data(G_OBJECT(widget), "udpscope-row", GINT_TO_POINTER(row + kRowKeyBias));
|
||
}
|
||
|
||
int row_key(GtkWidget* widget) {
|
||
return GPOINTER_TO_INT(g_object_get_data(G_OBJECT(widget), "udpscope-row")) - kRowKeyBias;
|
||
}
|
||
|
||
/// The row a pointer event at logical `(x, y)` in the area is over. One widget covers
|
||
/// every row now, so the row comes from the figure's layout rather than from a key on
|
||
/// the widget the event arrived on.
|
||
int row_at(const App& app, double x, double y) {
|
||
if (app.area == nullptr) return 0;
|
||
const int row = static_cast<int>(app.area->row_at(x, y));
|
||
return std::clamp(row, 0, app.rows->row_count() - 1);
|
||
}
|
||
|
||
/// Cancels the pan drag the adapter's controller opened on the press a GTK gesture is
|
||
/// about to swallow -- the right-click menu claims the sequence, and a drop's release
|
||
/// goes to the DnD machinery, so in both cases the controller never sees the release
|
||
/// that would end the drag and the plot would then follow the pointer with no button
|
||
/// held. Escape is the controller's own cancel (it also restores the ranges it
|
||
/// captured at the press), and it has to go to *that row's* controller.
|
||
void cancel_drag_on(App& app, int row) {
|
||
if (app.area == nullptr) return;
|
||
if (plot::input::Controller* c = app.area->row_controller(static_cast<plot::u32>(row))) {
|
||
c->on_key(plot::input::KeyEvent{plot::input::kKeyEscape, 0, true});
|
||
}
|
||
}
|
||
|
||
/// Every widget under `root` whose *minimum* width exceeds `threshold`, as a type name and a
|
||
/// depth. A minimum width is what GTK must honour, so the chain printed here is the chain that
|
||
/// forced the toplevel wider than the screen -- and the deepest line in it is the widget whose
|
||
/// own content did it, which no summary of the plot's allocation can name.
|
||
void report_wide_widgets(GtkWidget* root, int threshold, int depth) {
|
||
if (root == nullptr || !GTK_IS_WIDGET(root)) return;
|
||
int min = 0;
|
||
int nat = 0;
|
||
gtk_widget_measure(root, GTK_ORIENTATION_HORIZONTAL, -1, &min, &nat, nullptr, nullptr);
|
||
if (min <= threshold) return;
|
||
std::fprintf(stderr, "[width-probe] %*s%s min=%d nat=%d\n", depth * 2, "",
|
||
G_OBJECT_TYPE_NAME(root), min, nat);
|
||
for (GtkWidget* c = gtk_widget_get_first_child(root); c != nullptr;
|
||
c = gtk_widget_get_next_sibling(c)) {
|
||
report_wide_widgets(c, threshold, depth + 1);
|
||
}
|
||
}
|
||
|
||
const char* display_mode_name(DisplayMode m) {
|
||
switch (m) {
|
||
case DisplayMode::Live: return "live";
|
||
case DisplayMode::Capturing: return "capturing";
|
||
case DisplayMode::Held: return "held";
|
||
case DisplayMode::Waiting: return "waiting";
|
||
case DisplayMode::Paused: return "paused";
|
||
}
|
||
return "?";
|
||
}
|
||
|
||
/// What a painted frame's first bound trace actually had to *draw*, recomputed through the
|
||
/// same three calls `Renderer::draw_traces` makes: the index window from `neighbours()`, the
|
||
/// LOD level from the 64-samples-per-pixel gate, then `gather()`. It then **counts** the
|
||
/// renderer's increasing-suffix filter rather than applying it, and measures the kept
|
||
/// polyline's extent against the axis in pixels.
|
||
///
|
||
/// This exists because every other counter here measures *geometry*, and the reported defect
|
||
/// is missing *ink*: `staged_segments` includes the grid and the instruments, so a frame that
|
||
/// drew all the chrome and not one sample still reports tens of thousands, and `lod_traces`
|
||
/// counts a trace whose gathered points the filter then collapses onto a single x as drawn.
|
||
/// Three distinguishable outcomes, each a different bug: `collapse` (under two distinct
|
||
/// positions left -- an invisible trace on a frame the renderer thinks it drew), `staircase`
|
||
/// (most points dropped, i.e. the stamps came back in runs of equal f32) and `short` (the ink
|
||
/// reaches a fraction of the axis -- geometry that is there but off to one side).
|
||
void check_frame_ink(App& app) {
|
||
if (app.rows == nullptr || app.rows->row_count() == 0) return;
|
||
const RowSet::Row& r = app.rows->row(0);
|
||
if (r.plot == nullptr) return;
|
||
const plot::data::Series* s = nullptr;
|
||
for (int i = 0; i < RowSet::kSlotsPerRow; ++i) {
|
||
const std::size_t si = static_cast<std::size_t>(i);
|
||
if (r.channel[si] < 0) continue;
|
||
s = &r.slots[si];
|
||
break;
|
||
}
|
||
if (s == nullptr || s->size() == 0) return;
|
||
const plot::Range axis = r.plot->x_range(r.axis_x);
|
||
const double axes_w = double(r.plot->layout().axes.w);
|
||
if (!(axes_w > 0.0) || !(axis.span() > 0.0)) return;
|
||
|
||
const plot::u64 n = s->size();
|
||
plot::u64 i0 = 0;
|
||
plot::u64 i1 = 0;
|
||
plot::u64 first = 0;
|
||
plot::u64 last = n;
|
||
if (s->neighbours(axis.min, i0, i1)) first = i0 > 0 ? i0 - 1 : 0;
|
||
if (s->neighbours(axis.max, i0, i1)) last = std::min(n, i1 + 1);
|
||
if (last <= first) return;
|
||
const double spp = double(last - first) / axes_w;
|
||
const plot::u32 level = spp >= 64.0 ? s->pyramid().level_for(n, spp) : 0u;
|
||
std::vector<plot::f32>& xy = app.measure_ink_scratch;
|
||
xy.clear();
|
||
// The same origin the renderer names (`x_range.min`), so the reconstruction below is its
|
||
// reconstruction: see `plot::data::Series::gather()` for why the caller owns it.
|
||
s->gather(level, first, last, axis.min, xy);
|
||
const std::size_t pts = xy.size() / 2;
|
||
|
||
std::size_t kept = 0;
|
||
double lo = 0.0;
|
||
double hi = 0.0;
|
||
// Where the filter's drops fall, and how far back each one steps. The origin is a constant
|
||
// for the walk's duration *by construction* now -- the renderer reconstructs
|
||
// `gather[i] + x_range.min`, a local of its own, where it used to re-read `series.x0()` on
|
||
// every point and a ring-backed `x0()` advances with the producer. So a drop here is a
|
||
// genuinely out-of-order gather, and `back` says by how much.
|
||
std::size_t drop_first = 0;
|
||
std::size_t drop_last = 0;
|
||
double worst_back = 0.0;
|
||
bool any_drop = false;
|
||
{
|
||
double next = std::numeric_limits<double>::infinity();
|
||
for (std::size_t k = pts; k > 0; --k) {
|
||
const double x = double(xy[2 * (k - 1)]) + axis.min;
|
||
if (!(x < next)) {
|
||
worst_back = std::max(worst_back, x - next);
|
||
if (!any_drop) drop_last = k - 1;
|
||
any_drop = true;
|
||
drop_first = k - 1;
|
||
continue;
|
||
}
|
||
next = x;
|
||
if (kept == 0) hi = x;
|
||
lo = x;
|
||
++kept;
|
||
}
|
||
}
|
||
++app.dbg_ink_checked;
|
||
// Drawn against quoted, in pixels. At level 0 a gathered pair *is* sample `first + k`, so
|
||
// this is the one place the two numbers the user compares by eye -- the polyline's position
|
||
// and the crosshair's readout -- can be differenced. The origin is what decides it: the f32
|
||
// resolves 2^-24 of whatever span it is relative to, so a view narrower than that fraction
|
||
// of the *retained window* drew a sample visibly away from the x quoted for it, which is
|
||
// what "when zooming there is a visible offset between the real data point and the crosshair
|
||
// selected one" was.
|
||
if (level == 0 && pts > 0) {
|
||
const double px_per_x = axes_w / axis.span();
|
||
double worst = 0.0;
|
||
for (std::size_t k = 0; k < pts; ++k) {
|
||
const double drawn = axis.min + double(xy[2 * k]);
|
||
worst = std::max(worst, std::abs(drawn - s->x_at(first + k)) * px_per_x);
|
||
}
|
||
app.dbg_ink_worst_offset_px = std::max(app.dbg_ink_worst_offset_px, worst);
|
||
++app.dbg_ink_raw_checked;
|
||
}
|
||
const double cover = kept >= 2 ? (hi - lo) / axis.span() : 0.0;
|
||
app.dbg_ink_worst_cover = std::min(app.dbg_ink_worst_cover, cover);
|
||
const bool collapse = kept < 2;
|
||
const bool staircase = pts >= 8 && kept * 4 < pts;
|
||
// A ring that has not yet reached back to the axis' left edge is *meant* to leave its left
|
||
// side empty (see the `clamp_to_data()` invariant), so a short ink there is the buffer
|
||
// filling and not a defect: it is only reported once the data spans the window.
|
||
const bool short_ink = !collapse && cover < 0.25 && s->x_bounds().min <= axis.min;
|
||
if (collapse) ++app.dbg_ink_collapse;
|
||
if (staircase) ++app.dbg_ink_staircase;
|
||
if (short_ink) ++app.dbg_ink_short;
|
||
if ((collapse || staircase || short_ink) && app.dbg_ink_reports < 40) {
|
||
++app.dbg_ink_reports;
|
||
// The series' own answers beside the reconstruction, because the two disagreeing is itself
|
||
// a reading: `rel` is what `gather()` emitted (relative to `axis.min`, the origin it was
|
||
// given) while `x0`/`x_at`/`bounds` are the window the series says it holds, so a trace
|
||
// whose ink sits a whole ring-span off its axis separates into "the stamps moved under us"
|
||
// and "the window is the wrong one".
|
||
const plot::Range xb = s->x_bounds();
|
||
std::fprintf(stderr,
|
||
"[ink] paint %d %s%s%s mode=%s axis=[%.6f..%.6f] n=%llu idx=[%llu..%llu) "
|
||
"spp=%.1f level=%u pts=%zu kept=%zu ink=[%.6f..%.6f] cover=%.1f%% "
|
||
"x0=%.6f x_at=[%.6f..%.6f] bounds=[%.6f..%.6f] rel=[%.6f..%.6f] dx=%.3g "
|
||
"drops=[%zu..%zu] back=%.3g\n",
|
||
app.dbg_paint_n, collapse ? "collapse " : "", staircase ? "staircase " : "",
|
||
short_ink ? "short " : "", display_mode_name(app.dbg_display), axis.min, axis.max,
|
||
(unsigned long long)n, (unsigned long long)first, (unsigned long long)last, spp,
|
||
level, pts, kept, lo, hi, cover * 100.0, s->x0(), s->x_at(0), s->x_at(n - 1),
|
||
xb.min, xb.max, pts > 0 ? double(xy[0]) : 0.0,
|
||
pts > 0 ? double(xy[2 * (pts - 1)]) : 0.0, s->dx(), drop_first, drop_last,
|
||
worst_back);
|
||
std::fflush(stderr);
|
||
}
|
||
}
|
||
|
||
/// Frame-cost probe, end of the plot's own draw. Connected *after* the adapter's
|
||
/// `::render`, which is the whole reason `PlotArea::on_render` returns FALSE: the signal
|
||
/// accumulates with `g_signal_accumulator_true_handled`, so a TRUE there would stop the
|
||
/// emission and this would never run. Reading the stats here rather than in `tick()` is
|
||
/// what makes them this frame's: `begin_frame()` resets them.
|
||
|
||
void on_before_paint(GdkFrameClock*, gpointer user) {
|
||
static_cast<App*>(user)->dbg_paint_start_us = g_get_monotonic_time();
|
||
}
|
||
|
||
void on_after_paint(GdkFrameClock*, gpointer user) {
|
||
App* app = static_cast<App*>(user);
|
||
if (app->dbg_paint_start_us == 0) return;
|
||
const double ms = double(g_get_monotonic_time() - app->dbg_paint_start_us) / 1000.0;
|
||
app->dbg_paint_ms_sum += ms;
|
||
app->dbg_paint_ms_max = std::max(app->dbg_paint_ms_max, ms);
|
||
++app->dbg_paint_n;
|
||
}
|
||
|
||
|
||
GtkWidget* make_area(App& app) {
|
||
plot::gtk4::PlotArea* area = plot::gtk4::PlotArea::create(app.placeholder_plot);
|
||
app.area = area;
|
||
area->set_figure(&app.rows->figure());
|
||
GtkWidget* widget = area->widget();
|
||
app.area_widget = widget;
|
||
|
||
gtk_widget_set_hexpand(widget, true);
|
||
gtk_widget_set_vexpand(widget, true);
|
||
gtk_widget_set_size_request(widget, 320, 120);
|
||
g_signal_connect(widget, "render", G_CALLBACK(on_render_probe), &app);
|
||
|
||
GtkDropTarget* target = gtk_drop_target_new(G_TYPE_STRING, GDK_ACTION_COPY);
|
||
g_signal_connect(target, "drop", G_CALLBACK(on_drop_channel), &app);
|
||
gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(target));
|
||
|
||
GtkGesture* right = gtk_gesture_click_new();
|
||
gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(right), 3);
|
||
g_signal_connect(right, "pressed", G_CALLBACK(on_right_click), &app);
|
||
gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(right));
|
||
|
||
// The overlay exists for the layout editor's buttons and is otherwise empty, so it
|
||
// costs one pass-through widget: with no overlay children, picking and allocation
|
||
// reach the GL area exactly as before.
|
||
app.area_overlay = gtk_overlay_new();
|
||
gtk_overlay_set_child(GTK_OVERLAY(app.area_overlay), widget);
|
||
gtk_widget_set_hexpand(app.area_overlay, true);
|
||
gtk_widget_set_vexpand(app.area_overlay, true);
|
||
return app.area_overlay;
|
||
}
|
||
|
||
/// Brings the plot area in line with the rows: a changed trace set (or row count)
|
||
/// rebuilds the figure, and the area is pointed at the new one. Deferred to the frame
|
||
/// tick -- a drop arrives from inside a gesture attached to this widget, and the
|
||
/// rebuild resets that gesture's controller.
|
||
void relayout_areas(App& app) {
|
||
if (!app.rows->rebuild_if_dirty()) return;
|
||
if (app.area != nullptr) app.area->set_figure(&app.rows->figure());
|
||
}
|
||
|
||
// --- panel rebuilds ---------------------------------------------------------
|
||
|
||
/// Defined below, forward-declared because placing or freeing a trace slot has to
|
||
/// rebuild the signal list: each row's colour chip names the trace that signal is
|
||
/// drawn as, so it goes stale the moment the placement changes.
|
||
|
||
// --- the layout editor ------------------------------------------------------
|
||
//
|
||
// Rows are added and removed in place rather than picked off a list of layouts: with a
|
||
// count you can only say "I want three", and which of the three your traces end up on is
|
||
// whatever the renumbering decides. The editor's buttons sit *on* the rows, so the row
|
||
// the action names is the row you are pointing at.
|
||
//
|
||
// Geometry comes from `FigureLayout::rows` -- the same rects the frame was drawn from --
|
||
// divided by `PlotArea::device_scale()`, because a `Rect` is framebuffer pixels and a GTK
|
||
// margin is logical ones.
|
||
//
|
||
// One `+` per *boundary*, not two per row: a row's bottom edge and the next row's top
|
||
// edge are the same line (the rows tile the figure), so "add below row 1" and "add above
|
||
// row 2" are the same insertion and two buttons there would be one on top of the other.
|
||
// There are `rows + 1` of them -- above the first row and below the last included -- so
|
||
// every position the two-per-row reading offers is still reachable.
|
||
|
||
/// `g_object_set_data` key telling a remove button from an insert one. The row/boundary
|
||
/// index is the usual `set_row_key()`.
|
||
|
||
|
||
/// Takes the editor's buttons off the overlay. `gtk_overlay_remove_overlay()` drops the
|
||
/// overlay's reference, which is the only one, so the widgets go with it.
|
||
///
|
||
/// This runs from a *button's own* `::clicked` handler (the click rebuilds the set), which
|
||
/// is safe because the emission holds its own reference on the instance: the button is
|
||
/// finalized after the handler returns, not under it.
|
||
void clear_layout_editor(App& app) {
|
||
if (app.area_overlay != nullptr) {
|
||
for (GtkWidget* b : app.layout_buttons) {
|
||
gtk_overlay_remove_overlay(GTK_OVERLAY(app.area_overlay), b);
|
||
}
|
||
if (app.layout_done_button != nullptr) {
|
||
gtk_overlay_remove_overlay(GTK_OVERLAY(app.area_overlay), app.layout_done_button);
|
||
}
|
||
}
|
||
app.layout_buttons.clear();
|
||
app.layout_done_button = nullptr;
|
||
app.layout_buttons_rows = 0;
|
||
}
|
||
|
||
|
||
|
||
/// Restates where the buttons sit, from the rects the last frame was laid out with.
|
||
/// Called every frame while the editor is up: `gtk_widget_set_margin_*()` returns early
|
||
/// on an unchanged value, so a settled layout costs the comparisons and nothing else.
|
||
void sync_layout_editor(App& app) {
|
||
if (app.layout_buttons.empty() || app.area == nullptr) return;
|
||
const plot::FigureLayout& fl = app.rows->figure().layout();
|
||
const int rows = app.rows->row_count();
|
||
// Before the first frame the figure has no rects yet; placing against them would put
|
||
// every button in the top-left corner and then move it, which is a visible jump.
|
||
if (static_cast<int>(fl.rows.size()) != rows) return;
|
||
const double scale = app.area->device_scale() > 0.0f ? app.area->device_scale() : 1.0;
|
||
const double height = static_cast<double>(gtk_widget_get_height(app.area_widget));
|
||
if (!(height > 0.0)) return;
|
||
const double half = kLayoutButtonPx * 0.5;
|
||
for (GtkWidget* b : app.layout_buttons) {
|
||
const int idx = row_key(b);
|
||
const bool insert = g_object_get_data(G_OBJECT(b), kLayoutKindKey) != nullptr;
|
||
const bool resize = g_object_get_data(G_OBJECT(b), kLayoutResizeKey) != nullptr;
|
||
if (resize) {
|
||
// *On* the boundary, beside the `+` -- the same edge, one handle's width to its right. Below it sat
|
||
// inside the lower row, which read as a handle for that row: a boundary handle that looks like it
|
||
// belongs to the plot under it is a handle whose drag changes the wrong plot, whatever it does.
|
||
const double edge = idx < rows ? static_cast<double>(fl.rows[static_cast<std::size_t>(idx)].y) / scale
|
||
: (static_cast<double>(fl.rows.back().y) +
|
||
static_cast<double>(fl.rows.back().h)) / scale;
|
||
const double top = std::clamp(edge - half, 0.0, height - kLayoutButtonPx);
|
||
gtk_widget_set_margin_top(b, static_cast<int>(top + 0.5));
|
||
const double width = static_cast<double>(gtk_widget_get_width(app.area_widget));
|
||
const double left = width * 0.5 + kLayoutButtonPx * 0.5 + 6.0;
|
||
gtk_widget_set_margin_start(
|
||
b, static_cast<int>(std::clamp(left, 0.0, std::max(0.0, width - kLayoutButtonPx)) + 0.5));
|
||
} else if (insert) {
|
||
// Centred on the boundary: the top edge of row `idx`, or the bottom edge of the
|
||
// last row for the one past the end.
|
||
const double edge = idx < rows ? static_cast<double>(fl.rows[static_cast<std::size_t>(idx)].y) / scale
|
||
: (static_cast<double>(fl.rows.back().y) +
|
||
static_cast<double>(fl.rows.back().h)) / scale;
|
||
const double top = std::clamp(edge - half, 0.0, height - kLayoutButtonPx);
|
||
gtk_widget_set_margin_top(b, static_cast<int>(top + 0.5));
|
||
} else {
|
||
const plot::Rect& r = fl.rows[static_cast<std::size_t>(idx)];
|
||
const double top = std::clamp(static_cast<double>(r.y) / scale + kLayoutButtonPad,
|
||
0.0, height - kLayoutButtonPx);
|
||
gtk_widget_set_margin_top(b, static_cast<int>(top + 0.5));
|
||
// Inside the axes, not over the y labels: the gutter is the one piece of row chrome
|
||
// whose width is reserved rather than measured per frame, so this does not move.
|
||
const plot::Plot* p = app.rows->row(idx).plot;
|
||
const double left = (p != nullptr ? static_cast<double>(p->layout().axes.x) / scale
|
||
: static_cast<double>(r.x) / scale) +
|
||
kLayoutButtonPad;
|
||
gtk_widget_set_margin_start(b, static_cast<int>(left + 0.5));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A row may not be dragged below this, in logical pixels: a row of no height is a row a user cannot
|
||
/// grab again, and the plotter keeps a floor of its own -- this is the one the *drag* respects.
|
||
constexpr double kMinRowPx = 48.0;
|
||
|
||
void on_layout_drag_begin(GtkGestureDrag* gesture, double, double, gpointer user) {
|
||
App& app = *static_cast<App*>(user);
|
||
app.layout_drag_row =
|
||
static_cast<int>(GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(gesture), "udpscope-layout-drag")));
|
||
// A drag is *relative*: the weights it started from are kept, because reading them back while dragging
|
||
// would compound -- each update would build on the last frame's instead of on the grab.
|
||
app.layout_drag_start = app.rows->row_weights();
|
||
app.layout_drag_start.resize(static_cast<std::size_t>(app.rows->row_count()), 1.0f);
|
||
}
|
||
|
||
void on_layout_drag_update(GtkGestureDrag*, double, double offset_y, gpointer user) {
|
||
App& app = *static_cast<App*>(user);
|
||
const int at = app.layout_drag_row; // the boundary between rows `at - 1` and `at`
|
||
const int rows = app.rows->row_count();
|
||
if (at <= 0 || at >= rows) return; // the figure's own edges are not boundaries
|
||
if (app.layout_drag_start.size() != static_cast<std::size_t>(rows)) return;
|
||
const plot::FigureLayout& fl = app.rows->figure().layout();
|
||
if (static_cast<int>(fl.rows.size()) != rows) return;
|
||
const double scale =
|
||
app.area != nullptr && app.area->device_scale() > 0.0f ? app.area->device_scale() : 1.0;
|
||
const double up = static_cast<double>(fl.rows[static_cast<std::size_t>(at - 1)].h) / scale;
|
||
const double lo = static_cast<double>(fl.rows[static_cast<std::size_t>(at)].h) / scale;
|
||
const double pair = up + lo;
|
||
if (!(pair > kMinRowPx * 2.0)) return;
|
||
// Where the boundary is asked to go, floored on both sides: the *pixels* say where, and the pair's
|
||
// weights are the shares that put it there.
|
||
const double want_up = std::clamp(up + offset_y / scale, kMinRowPx, pair - kMinRowPx);
|
||
std::vector<float> weights = app.layout_drag_start;
|
||
const float sum = weights[static_cast<std::size_t>(at - 1)] + weights[static_cast<std::size_t>(at)];
|
||
weights[static_cast<std::size_t>(at - 1)] = sum * static_cast<float>(want_up / pair);
|
||
weights[static_cast<std::size_t>(at)] = sum - weights[static_cast<std::size_t>(at - 1)];
|
||
app.rows->set_row_weights(std::move(weights));
|
||
}
|
||
|
||
void on_layout_drag_end(GtkGestureDrag*, double, double, gpointer user) {
|
||
App& app = *static_cast<App*>(user);
|
||
app.layout_drag_row = -1;
|
||
app.layout_drag_start.clear();
|
||
// A dragged sizing is an arrangement, so it is saved like one.
|
||
schedule_layout_save(app);
|
||
}
|
||
|
||
/// Builds one `×` per row and one `+` per boundary, or takes them all off again when the
|
||
/// editor is switched off.
|
||
/// One of the layout editor's overlay buttons: the look they all share is *here*, once. It was written out
|
||
/// three times -- the icon, the two CSS classes that make a small round on-screen-display control, and the
|
||
/// size -- and the fourth copy would have been a button added next to them.
|
||
///
|
||
/// These buttons cannot be a `.ui` file, and the reason is worth stating because the plan expected them to
|
||
/// be: their *number* is the row count, one per row and one more than that for the boundaries, and a
|
||
/// `GtkBuilder` instantiates a file's objects once. This function reappears whenever a `.ui` is asked to hold
|
||
/// something whose count is data.
|
||
///
|
||
/// What each button does with the look is the caller's: they differ in alignment, sensitivity, the data they
|
||
/// carry and the gesture they take.
|
||
GtkWidget* layout_overlay_button(App& app, const char* icon, const char* tooltip) {
|
||
GtkWidget* b = gtk_button_new_from_icon_name(icon);
|
||
gtk_widget_set_tooltip_text(b, tooltip);
|
||
gtk_widget_add_css_class(b, "circular");
|
||
gtk_widget_add_css_class(b, "osd");
|
||
gtk_widget_set_size_request(b, kLayoutButtonPx, kLayoutButtonPx);
|
||
gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), b);
|
||
app.layout_buttons.push_back(b);
|
||
return b;
|
||
}
|
||
|
||
void rebuild_layout_editor(App& app) {
|
||
clear_layout_editor(app);
|
||
if (!app.layout_editing || app.area_overlay == nullptr) return;
|
||
const int rows = app.rows->row_count();
|
||
app.layout_buttons_rows = rows;
|
||
|
||
for (int i = 0; i < rows; ++i) {
|
||
GtkWidget* b = layout_overlay_button(app, "window-close-symbolic", "remove this row");
|
||
gtk_widget_set_halign(b, GTK_ALIGN_START);
|
||
gtk_widget_set_valign(b, GTK_ALIGN_START);
|
||
gtk_widget_set_sensitive(b, rows > 1);
|
||
set_row_key(b, i);
|
||
g_signal_connect(b, "clicked", G_CALLBACK(on_layout_row_remove), &app);
|
||
}
|
||
for (int b_i = 0; b_i <= rows; ++b_i) {
|
||
GtkWidget* b = layout_overlay_button(app, "list-add-symbolic",
|
||
b_i == 0 ? "add a row above"
|
||
: b_i == rows ? "add a row below"
|
||
: "add a row here");
|
||
gtk_widget_set_halign(b, GTK_ALIGN_CENTER);
|
||
gtk_widget_set_valign(b, GTK_ALIGN_START);
|
||
gtk_widget_set_sensitive(b, rows < RowSet::kMaxRows);
|
||
set_row_key(b, b_i);
|
||
g_object_set_data(G_OBJECT(b), kLayoutKindKey, b);
|
||
g_signal_connect(b, "clicked", G_CALLBACK(on_layout_row_insert), &app);
|
||
}
|
||
// ... and one arrow per boundary whose *only* job is resizing the two rows it sits between. A widget
|
||
// of its own rather than a second gesture on the `+`, so each handle does one thing -- and a handle
|
||
// that cannot be clicked cannot be clicked by accident. The figure's own edges are not boundaries
|
||
// (there is no pair of rows to share between them), so the first and last are inert.
|
||
for (int b_i = 0; b_i <= rows; ++b_i) {
|
||
GtkWidget* b = layout_overlay_button(app, "udpscope-resize-rows-symbolic",
|
||
b_i == 0 ? "drag to resize the first row"
|
||
: b_i == rows ? "drag to resize the last row"
|
||
: "drag to resize the two rows here");
|
||
gtk_widget_set_halign(b, GTK_ALIGN_CENTER);
|
||
gtk_widget_set_valign(b, GTK_ALIGN_START);
|
||
// Hidden, not disabled, where it has no pair to share between: the figure's own edges. A greyed
|
||
// handle is a question ("why can I not drag this?"), and the answer is that there is nothing there.
|
||
gtk_widget_set_visible(b, b_i > 0 && b_i < rows);
|
||
gtk_widget_set_halign(b, GTK_ALIGN_START);
|
||
// The *row key* is what `sync_layout_editor()` places by -- without it every arrow was placed at
|
||
// index 0, the figure's top edge, whatever boundary it belonged to.
|
||
set_row_key(b, b_i);
|
||
g_object_set_data(G_OBJECT(b), kLayoutResizeKey, GSIZE_TO_POINTER(b_i));
|
||
GtkGesture* drag = gtk_gesture_drag_new();
|
||
gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(drag), 1);
|
||
gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(drag), GTK_PHASE_CAPTURE);
|
||
g_object_set_data(G_OBJECT(drag), "udpscope-layout-drag", GSIZE_TO_POINTER(b_i));
|
||
g_signal_connect(drag, "drag-begin", G_CALLBACK(on_layout_drag_begin), &app);
|
||
g_signal_connect(drag, "drag-update", G_CALLBACK(on_layout_drag_update), &app);
|
||
g_signal_connect(drag, "drag-end", G_CALLBACK(on_layout_drag_end), &app);
|
||
gtk_widget_add_controller(b, GTK_EVENT_CONTROLLER(drag));
|
||
}
|
||
|
||
// The editor's way out, in the area's top-right corner: the toolbar's toggle is in the
|
||
// overflow menu, which is a long way from the plot you are editing. Aligned by margins
|
||
// like every other overlay child, so it never touches the figure's layout.
|
||
GtkWidget* done = gtk_button_new_with_label("Done");
|
||
gtk_widget_set_tooltip_text(done, "leave the layout editor");
|
||
gtk_widget_add_css_class(done, "osd");
|
||
gtk_widget_set_size_request(done, -1, kLayoutButtonPx);
|
||
gtk_widget_set_halign(done, GTK_ALIGN_END);
|
||
gtk_widget_set_valign(done, GTK_ALIGN_START);
|
||
gtk_widget_set_margin_top(done, kLayoutButtonPad);
|
||
gtk_widget_set_margin_end(done, kLayoutButtonPad);
|
||
g_signal_connect(done, "clicked", G_CALLBACK(on_layout_editor_done), &app);
|
||
gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), done);
|
||
app.layout_done_button = done;
|
||
|
||
sync_layout_editor(app);
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
/// The width-comparison dropdown's order. `<` first, because that is the glitch trigger
|
||
/// and the reason this condition exists.
|
||
WidthCmp width_cmp_at(guint i) {
|
||
switch (i) {
|
||
case 0:
|
||
return WidthCmp::Less;
|
||
case 2:
|
||
return WidthCmp::Within;
|
||
case 3:
|
||
return WidthCmp::Outside;
|
||
default:
|
||
return WidthCmp::Greater;
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
void show_info_dialog(GtkWindow* parent, const SignalInfo& sig) {
|
||
char body[1024];
|
||
std::snprintf(body, sizeof(body),
|
||
"unit: %s\ntype: %d\nquantisation: %d\nrows x cols: %u x %u\nrange: %g .. %g\n"
|
||
"sample rate: %g Hz\ntime mode: %d\ntime signal: %u",
|
||
sig.unit.empty() ? "(none)" : sig.unit.c_str(), int(sig.type_code),
|
||
int(sig.quant_type), sig.rows, sig.cols, sig.range_min, sig.range_max,
|
||
sig.sampling_rate, int(sig.time_mode), sig.time_signal_idx);
|
||
GtkWidget* window = dialog_window(GTK_WIDGET(parent), sig.name.c_str());
|
||
GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window));
|
||
GtkWidget* label = gtk_label_new(body);
|
||
gtk_widget_set_halign(label, GTK_ALIGN_START);
|
||
gtk_label_set_wrap(GTK_LABEL(label), true);
|
||
gtk_label_set_selectable(GTK_LABEL(label), true);
|
||
gtk_box_append(GTK_BOX(box), label);
|
||
GtkWidget* close = gtk_button_new_with_label("Close");
|
||
g_signal_connect_swapped(close, "clicked", G_CALLBACK(gtk_window_destroy), window);
|
||
dialog_add_button(window, close);
|
||
gtk_window_present(GTK_WINDOW(window));
|
||
}
|
||
|
||
void on_info_clicked(GtkButton* button, gpointer user) {
|
||
App* app = static_cast<App*>(user);
|
||
const std::size_t index = static_cast<std::size_t>(
|
||
GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(button), "udpscope-signal")));
|
||
if (index < app->model->signals().size()) {
|
||
show_info_dialog(GTK_WINDOW(app->window), app->model->signals()[index]);
|
||
}
|
||
}
|
||
|
||
|
||
void on_calibrate_apply(GtkButton*, gpointer user) {
|
||
CalibContext* ctx = static_cast<CalibContext*>(user);
|
||
ctx->app->model->set_calibration(ctx->signal, gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->gain)),
|
||
gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->offset)));
|
||
schedule_layout_save(*ctx->app);
|
||
gtk_window_destroy(GTK_WINDOW(ctx->window));
|
||
}
|
||
|
||
void open_calibration(App& app, GtkWindow* parent, std::size_t signal) {
|
||
const SignalInfo& sig = app.model->signals()[signal];
|
||
GtkWidget* window = dialog_window(GTK_WIDGET(parent), "Calibrate");
|
||
GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window));
|
||
GtkWidget* title = gtk_label_new(sig.name.c_str());
|
||
gtk_widget_set_halign(title, GTK_ALIGN_START);
|
||
gtk_box_append(GTK_BOX(box), title);
|
||
// The signal's *own* calibration, not a stock identity: a dialog that opened at 1/0 while the
|
||
// trace was drawn through a gain of 2 invited the user to "confirm" it back to 1 by pressing
|
||
// Apply. A signal nobody calibrated reads 1 and 0, which is the identity it is drawn through --
|
||
// and the values are the model's record, so a signal that is not acquired still shows the
|
||
// calibration that will apply to it when it is.
|
||
const std::pair<double, double> cal = app.model->calibration(signal);
|
||
GtkWidget* gain = spin(-1.0e12, 1.0e12, 0.001, 6, cal.first);
|
||
GtkWidget* offset = spin(-1.0e12, 1.0e12, 0.001, 6, cal.second);
|
||
gtk_box_append(GTK_BOX(box), labeled_box("gain", gain));
|
||
gtk_box_append(GTK_BOX(box), labeled_box("offset", offset));
|
||
|
||
auto* ctx = new CalibContext{&app, signal, gain, offset, window};
|
||
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
|
||
// `gtk_window_close()`, not `gtk_window_destroy()`: the destroy path does **not** emit
|
||
// `close-request`, so the handler that clears `app.math_dialog` never ran and every later open
|
||
// was a `present` on a dead widget -- a critical, and nothing on screen. Closing emits the signal,
|
||
// the handler clears the pointer, and the default handler then destroys the window.
|
||
g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_close), window);
|
||
GtkWidget* apply = gtk_button_new_with_label("Apply");
|
||
g_object_set_data_full(G_OBJECT(apply), "udpscope-ctx", ctx,
|
||
[](gpointer p) { delete static_cast<CalibContext*>(p); });
|
||
g_signal_connect(apply, "clicked", G_CALLBACK(on_calibrate_apply), ctx);
|
||
dialog_add_button(window, cancel);
|
||
dialog_add_button(window, apply);
|
||
gtk_window_present(GTK_WINDOW(window));
|
||
}
|
||
|
||
|
||
GdkContentProvider* on_drag_prepare(GtkDragSource*, double, double, gpointer user) {
|
||
DragContext* ctx = static_cast<DragContext*>(user);
|
||
// The payload is the signal index; the channel is resolved on drop.
|
||
char buf[64];
|
||
std::snprintf(buf, sizeof(buf), "%zu", ctx->signal);
|
||
return gdk_content_provider_new_typed(G_TYPE_STRING, buf);
|
||
}
|
||
|
||
|
||
/// A trace colour as `#RRGGBB`: what a chip is drawn with and what the session file stores. One
|
||
/// formatter for both, so a chip and the colour it claims to name cannot be written differently --
|
||
/// and so the probe can read a chip's own markup rather than recomputing what it should say.
|
||
std::string color_hex(plot::Color c) {
|
||
char buf[16];
|
||
std::snprintf(buf, sizeof(buf), "#%02X%02X%02X", unsigned(c.r * 255.0f + 0.5f),
|
||
unsigned(c.g * 255.0f + 0.5f), unsigned(c.b * 255.0f + 0.5f));
|
||
return buf;
|
||
}
|
||
|
||
std::string chip_markup(plot::Color c) {
|
||
return "<span foreground=\"" + color_hex(c) + "\">\u25CF</span>";
|
||
}
|
||
|
||
|
||
|
||
/// What an open colour chooser is choosing for. The signal's **name**, not its index: the chooser
|
||
/// is asynchronous and a CONFIG can renumber the signal list while it is open, which is the same
|
||
/// reason every persisted identity in this app is a name.
|
||
|
||
|
||
/// Opens the colour chooser for one signal's trace.
|
||
///
|
||
/// `GtkColorDialog`, not the `GtkColorChooserDialog` deprecated in GTK 4.10: libadwaita 1.8 already
|
||
/// puts this build's floor at GTK 4.18, so the modern API is simply available. Alpha is off --
|
||
/// `plot::Color` carries one but a trace is stroked opaque, so a transparency slider would offer a
|
||
/// setting that does nothing.
|
||
void open_signal_color(App& app, std::size_t signal) {
|
||
std::string name;
|
||
int palette_index = 0;
|
||
{
|
||
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||
const std::vector<SignalInfo>& signals = app.model->signals();
|
||
if (signal >= signals.size()) return;
|
||
name = signals[signal].name;
|
||
palette_index = signal_color_index(signals, signal);
|
||
}
|
||
if (name.empty()) return;
|
||
const plot::Color current = app.rows->signal_color(name, palette_index);
|
||
const GdkRGBA start{current.r, current.g, current.b, 1.0f};
|
||
|
||
GtkColorDialog* dialog = gtk_color_dialog_new();
|
||
gtk_color_dialog_set_title(dialog, ("Colour for " + name).c_str());
|
||
gtk_color_dialog_set_with_alpha(dialog, FALSE);
|
||
gtk_color_dialog_choose_rgba(dialog, GTK_WINDOW(app.window), &start, nullptr,
|
||
on_signal_color_chosen, new ColorPick{&app, name});
|
||
// The async call holds its own reference for the operation's life, which is the documented
|
||
// idiom for every `Gtk*Dialog` in GTK 4.10's async family.
|
||
g_object_unref(dialog);
|
||
}
|
||
|
||
|
||
/// Fills the trigger's signal dropdown with the channels that are actually being
|
||
/// acquired -- a trigger can only fire on a channel that has samples. Selecting a
|
||
/// channel defaults the level into the middle of its signal's declared range (a
|
||
/// level of 0 on a 0..5 V transducer never fires, and "the trigger does nothing" is
|
||
/// exactly what that looks like), until the user edits the level by hand.
|
||
void rebuild_trigger_signals(App& app) {
|
||
const std::vector<ChannelInfo>& channels = app.model->channels();
|
||
app.updating_panels = true;
|
||
std::vector<std::string> storage;
|
||
storage.reserve(channels.size());
|
||
for (const ChannelInfo& c : channels) {
|
||
storage.push_back(c.unit.empty() ? c.name : c.name + " [" + c.unit + "]");
|
||
}
|
||
std::vector<const char*> names;
|
||
names.reserve(storage.size() + 1);
|
||
for (const std::string& s : storage) names.push_back(s.c_str());
|
||
if (names.empty()) {
|
||
storage.emplace_back("(no acquired channels)");
|
||
names.push_back(storage.back().c_str());
|
||
}
|
||
names.push_back(nullptr);
|
||
GtkStringList* list = gtk_string_list_new(names.data());
|
||
gtk_drop_down_set_model(GTK_DROP_DOWN(app.trigger_signal_drop), G_LIST_MODEL(list));
|
||
g_object_unref(list);
|
||
|
||
TriggerConfig& tc = app.model->trigger();
|
||
// A session stores the trigger's channel by *name*: resolved here, where the acquired channels
|
||
// exist. The pending name is cleared either way, so a name this stream does not have falls through
|
||
// to the default below rather than being retried on every rebuild.
|
||
if (!app.pending_trigger_channel.empty()) {
|
||
for (std::size_t c = 0; c < channels.size(); ++c) {
|
||
if (channels[c].name == app.pending_trigger_channel) {
|
||
tc.channel = static_cast<int>(c);
|
||
break;
|
||
}
|
||
}
|
||
app.pending_trigger_channel.clear();
|
||
}
|
||
if (tc.channel < 0 && !channels.empty()) tc.channel = 0;
|
||
const int sel = tc.channel;
|
||
gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_signal_drop),
|
||
sel < 0 || sel >= static_cast<int>(storage.size())
|
||
? 0u
|
||
: static_cast<guint>(sel));
|
||
|
||
if (sel >= 0 && sel < static_cast<int>(channels.size()) && !app.trigger_level_touched) {
|
||
const SignalInfo& sig =
|
||
app.model->signals()[static_cast<std::size_t>(channels[static_cast<std::size_t>(sel)].signal)];
|
||
if (std::isfinite(sig.range_min) && std::isfinite(sig.range_max) &&
|
||
sig.range_max > sig.range_min) {
|
||
const double mid = 0.5 * (sig.range_min + sig.range_max);
|
||
tc.condition.level = mid;
|
||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_level_spin), mid);
|
||
}
|
||
}
|
||
app.updating_panels = false;
|
||
}
|
||
|
||
// --- toolbar handlers -------------------------------------------------------
|
||
|
||
/// Writes `seconds` into the control -- the button's label, the entry and the unit -- and
|
||
/// nothing else. Read-only with respect to the plot, so every path that changes the span on
|
||
/// screen for its own reasons (a fit, an event pin) can say so without looping back through
|
||
/// `apply_time_window()`.
|
||
void sync_window_display(App& app, double seconds) {
|
||
if (!(seconds > 0.0)) return;
|
||
app.updating_panels = true;
|
||
app.dbg_window_text = format_window(seconds);
|
||
if (app.window_label != nullptr) {
|
||
gtk_label_set_text(GTK_LABEL(app.window_label), app.dbg_window_text.c_str());
|
||
}
|
||
const int unit = window_unit_for(seconds);
|
||
app.window_sync_unit = unit;
|
||
gtk_drop_down_set_selected(GTK_DROP_DOWN(app.window_unit_drop), static_cast<guint>(unit));
|
||
gtk_editable_set_text(GTK_EDITABLE(app.window_entry),
|
||
format_window_value(seconds, unit).c_str());
|
||
app.updating_panels = false;
|
||
}
|
||
|
||
/// Clears every measurement's statistics. Called explicitly by the pane's Reset, and
|
||
/// implicitly wherever the series of results stops being one series: a measurement edit
|
||
/// (`rebuild_measures_pane()`), a change of display window, and a CONFIG.
|
||
void reset_measure_stats(App& app) {
|
||
for (MeasureStatsAccum& s : app.measure_stats) s.reset();
|
||
}
|
||
|
||
/// The one place the time scale changes. Clamps to what the buffer can hold, moves the plot and
|
||
/// the trigger's event span together -- its pre-trigger share is a share of *this* window -- and
|
||
/// then restates the result in the control, so a clamped request reads back as the window that
|
||
/// was actually taken rather than the one that was typed.
|
||
///
|
||
/// **Only the window control comes here.** The time scale is more than the axis: it is also the
|
||
/// trigger's event span, the buffer partition (so a change of decimation drops the history) and
|
||
/// the session, so a *plot interaction* may not state one. A zoom and a pan never touch it by
|
||
/// construction -- they move the row's axis, and `clamp_to_data()` caps the span at `window_s_` --
|
||
/// and the two fits do not either: `RowSet::fit_x()` undoes a zoom by restoring the window that
|
||
/// is already set. Both fits used to frame the data instead and move `window_s_` alone, which
|
||
/// left the event span on the *old* window until the next decade step or unit change applied the
|
||
/// fitted value for real: one action, two changes of time scale.
|
||
void apply_time_window(App& app, double seconds) {
|
||
if (!(seconds > 0.0)) return;
|
||
const double clamped = std::min(std::max(seconds, kWindowMinS), window_limit(app));
|
||
// The buffer has to cover the window, so this may re-plan the partition and drop the
|
||
// history -- a change of decimation factor cannot keep samples stored at the old rate.
|
||
// It is announced rather than silent, because losing the trace is otherwise unexplained.
|
||
// The frame tick rebinds every slot from the model, so the new rings are picked up
|
||
// without being told -- the same path an enable or a CONFIG already takes.
|
||
if (app.model->set_target_span(clamped)) {
|
||
app.notice = "window " + format_window(clamped) + ": resampled to " +
|
||
format_rate(app.model->partition().stored_rate()) + ", history cleared";
|
||
app.notice_until_us = g_get_monotonic_time() + 4 * G_TIME_SPAN_SECOND;
|
||
}
|
||
app.model->set_event_span(clamped);
|
||
// The partition before the axis, so the axis is framed over the rings that will exist.
|
||
app.rows->set_time_window(clamped);
|
||
sync_window_display(app, clamped);
|
||
// A measurement over a different window is a different figure -- a mean over 20 ms and a
|
||
// mean over 2 s are not observations of one quantity -- so the history behind it goes.
|
||
// The search shares the same premise: the ring was rebuilt, so its at_/end_ describe nothing.
|
||
reset_measure_stats(app);
|
||
app.search.clear();
|
||
schedule_layout_save(app);
|
||
}
|
||
|
||
/// Enter in the entry. A number that will not parse restores the current window instead of
|
||
/// reporting an error: the entry is also the readout, so leaving junk in it would leave the
|
||
/// control disagreeing with the plot.
|
||
void on_window_entry_activate(GtkEntry* entry, gpointer user) {
|
||
App* app = static_cast<App*>(user);
|
||
if (app->updating_panels) return;
|
||
const char* text = gtk_editable_get_text(GTK_EDITABLE(entry));
|
||
char* end = nullptr;
|
||
const double value = std::strtod(text, &end);
|
||
if (end == text || !(value > 0.0)) {
|
||
sync_window_display(*app, app->rows->time_window());
|
||
return;
|
||
}
|
||
const guint unit = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->window_unit_drop));
|
||
apply_time_window(*app, value * kWindowUnits[unit < kWindowUnitCount ? unit : 2].seconds);
|
||
}
|
||
|
||
/// Changing the unit *re-interprets* the number already typed rather than converting it: "2"
|
||
/// switched from ms to s means 2 s. Converting instead would make the unit selector a
|
||
/// no-op, since the window it names would not change.
|
||
void on_window_unit_changed(GObject* obj, GParamSpec*, gpointer user) {
|
||
App* app = static_cast<App*>(user);
|
||
if (app->updating_panels) return;
|
||
// Our own sync, arriving late (see `window_sync_unit`).
|
||
if (gtk_drop_down_get_selected(GTK_DROP_DOWN(obj)) ==
|
||
static_cast<guint>(app->window_sync_unit)) {
|
||
return;
|
||
}
|
||
on_window_entry_activate(GTK_ENTRY(app->window_entry), user);
|
||
}
|
||
|
||
/// The decade steppers. A factor of ten per click, from the window actually in force rather
|
||
/// than from the entry, so a half-typed number cannot be stepped.
|
||
void on_window_step(GtkButton* button, gpointer user) {
|
||
App* app = static_cast<App*>(user);
|
||
const double factor =
|
||
g_object_get_data(G_OBJECT(button), "udpscope-window-up") != nullptr ? 10.0 : 0.1;
|
||
apply_time_window(*app, app->rows->time_window() * factor);
|
||
}
|
||
|
||
|
||
void on_pause_toggled(GtkToggleButton* button, gpointer user) {
|
||
App* app = static_cast<App*>(user);
|
||
const bool paused = gtk_toggle_button_get_active(button);
|
||
app->acq->set_paused(paused);
|
||
app->model->set_paused(paused);
|
||
if (paused) {
|
||
// Freeze the *view*: snapshot exactly the window on screen, and pin the axes there. The
|
||
// acquisition thread keeps writing the live ring behind it, so resuming shows the data that
|
||
// arrived while paused instead of a hole.
|
||
if (RowSet::Row& r0 = app->rows->row(0); r0.plot != nullptr) {
|
||
const plot::Range view = r0.plot->x_range(r0.axis_x);
|
||
// The axis is in display x and the ring is on the stream's clock, so the window has to be
|
||
// put back on that clock to be cut -- `stream_at_x0()` is the one accessor that is right in
|
||
// every mode, including a pause taken while an event is *held*, where the axis is
|
||
// edge-relative and the old `- display_epoch()` form searched the ring with event
|
||
// coordinates. The same offset is handed to the snapshot as its `x_offset`, so its stamps
|
||
// come back rebased onto the x the axis is already showing and the frozen picture does not
|
||
// move at the instant of the pause.
|
||
const double s0 = app->model->stream_at_x0();
|
||
app->model->publish_pause_view(view.min + s0, view.max + s0, s0);
|
||
app->pause_started_us = g_get_monotonic_time();
|
||
// Pin on what the snapshot *got*: the cut is clamped to the ring, so it can be narrower than
|
||
// the view, and pinning the request left the shortfall as an empty left margin.
|
||
plot::Range pin = view;
|
||
if (std::shared_ptr<const EventSnapshot> snap = app->model->pause_view()) {
|
||
if (!snap->stamps.empty()) {
|
||
pin = plot::Range{static_cast<double>(snap->stamps.front()),
|
||
static_cast<double>(snap->stamps.back())};
|
||
}
|
||
}
|
||
app->rows->pin_pause_window(pin.min, pin.max);
|
||
}
|
||
} else {
|
||
app->model->clear_pause_view();
|
||
app->rows->unpin_pause_window();
|
||
app->pause_started_us = 0;
|
||
}
|
||
gtk_button_set_icon_name(GTK_BUTTON(button), paused ? "media-playback-start-symbolic"
|
||
: "media-playback-pause-symbolic");
|
||
gtk_widget_set_tooltip_text(GTK_WIDGET(button), paused ? "resume acquisition"
|
||
: "pause acquisition");
|
||
}
|
||
|
||
/// Freezes the view on a hit, centred, with the hit itself at x = 0.
|
||
///
|
||
/// This is the existing pause, not a fifth display mode: the toggle has to end up on, or the
|
||
/// next frame's `clamp_to_data()` would follow the live edge and walk the frozen window off
|
||
/// the hit entirely.
|
||
void search_jump(App& app, double t) {
|
||
app.search_jump_empty = false;
|
||
const double w = app.rows->time_window();
|
||
if (app.pause_button != nullptr &&
|
||
!gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.pause_button))) {
|
||
// Its handler publishes the *current* window; the publish below replaces it.
|
||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.pause_button), TRUE);
|
||
}
|
||
app.model->publish_pause_view(t - w / 2.0, t + w / 2.0, /*x_offset=*/t);
|
||
app.pause_started_us = g_get_monotonic_time();
|
||
// Pinned from the snapshot's *own* stamps, never from the window asked for: a cut is
|
||
// clamped to what the ring holds, so a hit near the oldest sample yields a shorter
|
||
// snapshot, and pinning the request would leave an empty margin that a zoom moves into.
|
||
if (std::shared_ptr<const EventSnapshot> snap = app.model->pause_view()) {
|
||
if (!snap->stamps.empty()) {
|
||
app.rows->pin_pause_window(static_cast<double>(snap->stamps.front()),
|
||
static_cast<double>(snap->stamps.back()));
|
||
} else {
|
||
app.search_jump_empty = true;
|
||
}
|
||
} else {
|
||
app.search_jump_empty = true;
|
||
}
|
||
}
|
||
|
||
void update_search_panel(App& app) {
|
||
if (app.search_status_label == nullptr) return;
|
||
const BufferSearch& s = app.search;
|
||
const std::size_t n = s.hits().size();
|
||
char text[192];
|
||
switch (s.state()) {
|
||
case BufferSearch::State::Idle:
|
||
std::snprintf(text, sizeof(text), "not searched");
|
||
break;
|
||
case BufferSearch::State::Running:
|
||
std::snprintf(text, sizeof(text), "scanning %s of %s, %zu found",
|
||
format_window(s.scanned_s()).c_str(), format_window(s.total_s()).c_str(), n);
|
||
break;
|
||
case BufferSearch::State::Done:
|
||
std::snprintf(text, sizeof(text), "%zu found in %s", n, format_window(s.total_s()).c_str());
|
||
break;
|
||
case BufferSearch::State::Truncated:
|
||
std::snprintf(text, sizeof(text), "%zu found; the stream overtook the oldest samples", n);
|
||
break;
|
||
case BufferSearch::State::Capped:
|
||
std::snprintf(text, sizeof(text), "%zu found (the limit) in the newest %s", n,
|
||
format_window(s.scanned_s()).c_str());
|
||
break;
|
||
case BufferSearch::State::Cancelled:
|
||
std::snprintf(text, sizeof(text), "stopped after %zu", n);
|
||
break;
|
||
}
|
||
gtk_label_set_text(GTK_LABEL(app.search_status_label), text);
|
||
|
||
char pos[96];
|
||
if (s.cursor() < 0 || n == 0) {
|
||
std::snprintf(pos, sizeof(pos), "--");
|
||
} else if (app.search_jump_empty) {
|
||
std::snprintf(pos, sizeof(pos), "%d of %zu (no longer in the buffer)", s.cursor() + 1, n);
|
||
} else {
|
||
std::snprintf(pos, sizeof(pos), "%d of %zu", s.cursor() + 1, n);
|
||
}
|
||
gtk_label_set_text(GTK_LABEL(app.search_pos_label), pos);
|
||
|
||
const bool running = s.state() == BufferSearch::State::Running;
|
||
gtk_widget_set_sensitive(app.search_run_button, !running);
|
||
gtk_widget_set_sensitive(app.search_cancel_button, running);
|
||
gtk_widget_set_sensitive(app.search_prev_button, s.cursor() > 0);
|
||
gtk_widget_set_sensitive(app.search_next_button,
|
||
n > 0 && s.cursor() + 1 < static_cast<int>(n));
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
/// The visible samples of one channel as parallel time/value arrays: the same cut the export
|
||
/// makes -- the held event's snapshot while one is on screen, else the live ring over the
|
||
/// shared window -- at the stored rate, so a measurement describes exactly the data the plot
|
||
/// is showing. Call from the GTK thread; the live branch takes the model mutex.
|
||
bool visible_samples(App& app, std::size_t channel, std::vector<double>& time,
|
||
std::vector<double>& value) {
|
||
time.clear();
|
||
value.clear();
|
||
const RowSet::Row& r0 = app.rows->row(0);
|
||
if (r0.plot == nullptr) return false;
|
||
const plot::Range view = r0.plot->x_range(r0.axis_x);
|
||
|
||
if (std::shared_ptr<const EventSnapshot> snap = app.model->display_snapshot()) {
|
||
if (snap == nullptr || channel >= snap->values.size()) return false;
|
||
const std::vector<f64>& st = snap->stamps;
|
||
const std::vector<f32>& vals = snap->values[channel];
|
||
if (vals.size() != st.size()) return false;
|
||
const auto lo = std::lower_bound(st.begin(), st.end(), view.min);
|
||
const auto hi = std::upper_bound(st.begin(), st.end(), view.max);
|
||
const std::size_t count = static_cast<std::size_t>(hi - lo);
|
||
if (count == 0) return false;
|
||
const std::size_t off = static_cast<std::size_t>(lo - st.begin());
|
||
time.resize(count);
|
||
value.resize(count);
|
||
for (std::size_t i = 0; i < count; ++i) {
|
||
time[i] = st[off + i];
|
||
value[i] = static_cast<double>(vals[off + i]);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||
if (channel >= app.model->channel_count()) return false;
|
||
BufferStore& store = app.model->live();
|
||
const f64 epoch = app.model->display_epoch();
|
||
u64 first = 0;
|
||
u64 last = 0;
|
||
app.model->live_window_locked(view.min - epoch, view.max - epoch, first, last);
|
||
const u64 n = last - first;
|
||
if (n == 0) return false;
|
||
time.resize(static_cast<std::size_t>(n));
|
||
value.resize(static_cast<std::size_t>(n));
|
||
// In bounded chunks rather than through two whole-window temporaries: the value ring holds
|
||
// f32 and the engine reads f64, so that widening is unavoidable, but the *copies* are not --
|
||
// a full window is millions of samples, and the temporaries cost a full write and read of it
|
||
// on top of the result. `kChunk` keeps both scratches in cache. Two of them, because the
|
||
// clock ring is f64 and the values f32 (see `BufferStore::time()`).
|
||
constexpr u64 kChunk = 8192;
|
||
std::vector<f64> t_scratch(kChunk);
|
||
std::vector<f32> v_scratch(kChunk);
|
||
for (u64 at = 0; at < n; at += kChunk) {
|
||
const u64 take = std::min<u64>(kChunk, n - at);
|
||
store.time().copy_from(first + at, take, t_scratch.data());
|
||
for (u64 i = 0; i < take; ++i) {
|
||
time[static_cast<std::size_t>(at + i)] = epoch + t_scratch[i];
|
||
}
|
||
store.values(channel).copy_from(first + at, take, v_scratch.data());
|
||
for (u64 i = 0; i < take; ++i) {
|
||
value[static_cast<std::size_t>(at + i)] = static_cast<double>(v_scratch[i]);
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/// One channel's visible window, readable in chunks and never copied out whole -- what the
|
||
/// measurements walk instead of `visible_samples()`.
|
||
///
|
||
/// The old path widened the window into two `std::vector<double>` before anything was computed,
|
||
/// which at a 1.7 M-sample window was 27 MB written and then read again per pass: 60 % of the
|
||
/// pane's cost, 12 ns a sample, 20 ms a recompute. Here the *values* are handed out as `f32`
|
||
/// straight from the snapshot (a pointer, no copy) or in a cache-sized scratch from the ring, and
|
||
/// a **stamp** is fetched one at a time -- the walks need a time only at the window's extremes and
|
||
/// at the level crossings, which number in the handful.
|
||
///
|
||
/// For a live window it holds `ScopeModel::mutex()` for its whole life, as the materialization did
|
||
/// but for a fraction of the time. Keep one of these alive no longer than the walks.
|
||
|
||
|
||
|
||
|
||
/// How often the measurements pane is recomputed while the window is still moving: a readout,
|
||
/// where the honest currency is the window's own settling, not the frame rate. Ten a second is
|
||
/// past the point a number can be read, and it makes the cost independent of the framerate.
|
||
/// A pass that costs more than this is a *hitch*, not a refresh: the pane waits four times as
|
||
/// long next time. Bounding the interval alone does not bound what a frame pays -- a window of
|
||
/// thirteen million samples costs 13 ms, which is most of a frame -- so the cadence follows the
|
||
/// *work*, and what stays bounded is the duty cycle at any window size.
|
||
|
||
|
||
/// One pane row: the measurement's badge, its type, its value and an `edit` button. The value
|
||
/// label and the statistics line below it are the two widgets `update_measures()` rewrites;
|
||
/// everything else is rebuilt with the list. The row is two visual lines so that the
|
||
/// statistics figures do not push the reading itself off a narrow pane.
|
||
GtkWidget* measures_row(App& app, std::size_t index, const Measure& m) {
|
||
// The row is two lines: the reading, and the statistics under it. A vertical wrapper
|
||
// rather than more columns, because the statistics line is four figures wide and would
|
||
// push the reading itself off the pane.
|
||
GtkWidget* outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||
GtkWidget* chip = gtk_label_new("");
|
||
gtk_label_set_markup(GTK_LABEL(chip),
|
||
chip_markup(plot::Color::hex(measure_color(index))).c_str());
|
||
gtk_widget_set_tooltip_text(chip, "this measurement's colour, and the colour of its marks");
|
||
gtk_box_append(GTK_BOX(box), chip);
|
||
// The signal's own chip beside it, in the palette colour every other view names that signal
|
||
// with, so the row says *what* is being measured as well as by which measurement.
|
||
GtkWidget* sig_chip = gtk_label_new("");
|
||
{
|
||
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||
const std::vector<SignalInfo>& signals = app.model->signals();
|
||
int color_index = -1;
|
||
for (std::size_t i = 0; i < signals.size(); ++i) {
|
||
if (signals[i].name == m.signal) {
|
||
color_index = signal_color_index(signals, i);
|
||
break;
|
||
}
|
||
}
|
||
gtk_label_set_markup(GTK_LABEL(sig_chip),
|
||
chip_markup(color_index >= 0
|
||
? app.rows->signal_color(m.signal, color_index)
|
||
: RowSet::unplotted_color())
|
||
.c_str());
|
||
}
|
||
gtk_widget_set_tooltip_text(sig_chip, m.signal.c_str());
|
||
gtk_box_append(GTK_BOX(box), sig_chip);
|
||
GtkWidget* kind = gtk_label_new(measure_type_name(m.type));
|
||
gtk_widget_set_halign(kind, GTK_ALIGN_START);
|
||
gtk_widget_set_hexpand(kind, true);
|
||
gtk_label_set_ellipsize(GTK_LABEL(kind), PANGO_ELLIPSIZE_END);
|
||
char tip[160];
|
||
std::snprintf(tip, sizeof(tip), "%s\n%s", m.signal.c_str(),
|
||
measure_uses_threshold(m.type)
|
||
? (m.options.above ? "the above-threshold side" : "the below-threshold side")
|
||
: "");
|
||
gtk_widget_set_tooltip_text(kind, tip);
|
||
gtk_box_append(GTK_BOX(box), kind);
|
||
GtkWidget* value = gtk_label_new("--");
|
||
gtk_widget_set_halign(value, GTK_ALIGN_END);
|
||
gtk_label_set_selectable(GTK_LABEL(value), true);
|
||
gtk_box_append(GTK_BOX(box), value);
|
||
GtkWidget* edit = gtk_button_new_with_label("edit");
|
||
gtk_widget_add_css_class(edit, "flat");
|
||
g_object_set_data(G_OBJECT(edit), "udpscope-measure-index",
|
||
GINT_TO_POINTER(static_cast<int>(index) + 1));
|
||
g_signal_connect(edit, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
|
||
App* a = static_cast<App*>(user);
|
||
const int idx = GPOINTER_TO_INT(
|
||
g_object_get_data(G_OBJECT(b), "udpscope-measure-index")) -
|
||
1;
|
||
open_measure_dialog(*a, idx);
|
||
}),
|
||
&app);
|
||
gtk_box_append(GTK_BOX(box), edit);
|
||
gtk_box_append(GTK_BOX(outer), box);
|
||
|
||
GtkWidget* stats = gtk_label_new("");
|
||
gtk_widget_set_halign(stats, GTK_ALIGN_END);
|
||
gtk_widget_add_css_class(stats, "dim-label");
|
||
gtk_label_set_selectable(GTK_LABEL(stats), true);
|
||
// Ellipsize so the label's minimum width is bounded: without this, a long reading would
|
||
// widen the pane just as the `kind` label would without its own ellipsize.
|
||
gtk_label_set_ellipsize(GTK_LABEL(stats), PANGO_ELLIPSIZE_END);
|
||
gtk_widget_set_tooltip_text(stats,
|
||
"how this reading has varied since the last reset: the number "
|
||
"of passes, the mean, the standard deviation, and the extremes");
|
||
gtk_widget_set_visible(stats, app.measure_stats_on);
|
||
gtk_box_append(GTK_BOX(outer), stats);
|
||
|
||
app.measure_labels.push_back(value);
|
||
app.measure_stats_labels.push_back(stats);
|
||
return outer;
|
||
}
|
||
|
||
|
||
/// The cursor modes, in the order the dropdown lists them; the index is `CursorMode`.
|
||
|
||
|
||
void on_cursor_mode_changed(GObject* obj, GParamSpec*, gpointer user) {
|
||
App* app = static_cast<App*>(user);
|
||
if (app->updating_panels) return;
|
||
const guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(obj));
|
||
if (sel > 3) return;
|
||
app->rows->set_cursor_mode(static_cast<RowSet::CursorMode>(sel));
|
||
rebuild_cursors_pane(*app);
|
||
}
|
||
|
||
/// Appends one row to the pane's table: a `who` cell and the three value cells, in one
|
||
/// `GtkGrid` so the four columns size to their widest cell and the numbers read as a table
|
||
/// (`| | A | B | delta |`) rather than as a sentence per line.
|
||
App::CursorLine cursors_line(App& app, const char* who, plot::Color chip, bool has_chip) {
|
||
GtkGrid* grid = GTK_GRID(app.cursors_body);
|
||
const int at = static_cast<int>(app.cursors_lines.size()) + 1; // row 0 is the header
|
||
App::CursorLine line;
|
||
line.label = gtk_label_new(who);
|
||
gtk_widget_set_halign(line.label, GTK_ALIGN_START);
|
||
gtk_label_set_ellipsize(GTK_LABEL(line.label), PANGO_ELLIPSIZE_END);
|
||
if (has_chip) {
|
||
// The chip **and** the name: a chip alone says a trace has a colour and leaves the user
|
||
// to match it against the plot, which with several traces is a guess. Escaped because
|
||
// this is markup and a signal name is the stream's text, not ours.
|
||
char* esc = g_markup_escape_text(who, -1);
|
||
gtk_label_set_markup(GTK_LABEL(line.label), (chip_markup(chip) + " " + esc).c_str());
|
||
g_free(esc);
|
||
gtk_widget_set_tooltip_text(line.label, who);
|
||
}
|
||
gtk_grid_attach(grid, line.label, 0, at, 1, 1);
|
||
GtkWidget* cells[3] = {nullptr, nullptr, nullptr};
|
||
for (int c = 0; c < 3; ++c) {
|
||
GtkWidget* v = gtk_label_new("");
|
||
gtk_widget_set_halign(v, GTK_ALIGN_END);
|
||
gtk_label_set_selectable(GTK_LABEL(v), true);
|
||
gtk_widget_set_margin_start(v, 10);
|
||
gtk_grid_attach(grid, v, c + 1, at, 1, 1);
|
||
cells[c] = v;
|
||
}
|
||
line.a = cells[0];
|
||
line.b = cells[1];
|
||
line.d = cells[2];
|
||
return line;
|
||
}
|
||
|
||
/// The table's header: the columns have no name but the two cursors and their difference.
|
||
GtkWidget* cursors_header(App& app) {
|
||
GtkGrid* grid = GTK_GRID(app.cursors_body);
|
||
GtkWidget* cells[4] = {gtk_label_new(""), gtk_label_new("A"), gtk_label_new("B"),
|
||
gtk_label_new("delta")};
|
||
for (int c = 0; c < 4; ++c) {
|
||
gtk_widget_set_halign(cells[c], c == 0 ? GTK_ALIGN_START : GTK_ALIGN_END);
|
||
gtk_widget_add_css_class(cells[c], "dim-label");
|
||
if (c > 0) gtk_widget_set_margin_start(cells[c], 10);
|
||
gtk_grid_attach(grid, cells[c], c, 0, 1, 1);
|
||
}
|
||
return cells[1];
|
||
}
|
||
|
||
/// The unit a row's y rulers are in: the one its bound channels agree on, or none when they
|
||
/// disagree -- a ruler sits on the row's y axis, and two signals of different units on one
|
||
/// axis make "the" unit a guess.
|
||
///
|
||
/// The channel list is rebuilt by the producer, so it is read under the model mutex, like
|
||
/// every other read of it from this thread.
|
||
std::string row_unit(App& app, int row) {
|
||
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||
std::string unit;
|
||
bool first = true;
|
||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||
const std::size_t si = static_cast<std::size_t>(s);
|
||
const RowSet::Row& r = app.rows->row(row);
|
||
if (r.signal_name[si].empty()) continue;
|
||
const int ch = r.channel[si];
|
||
if (ch < 0 || static_cast<std::size_t>(ch) >= app.model->channels().size()) continue;
|
||
const std::string& u = app.model->channels()[static_cast<std::size_t>(ch)].unit;
|
||
if (first) {
|
||
unit = u;
|
||
first = false;
|
||
} else if (unit != u) {
|
||
return "";
|
||
}
|
||
}
|
||
return unit;
|
||
}
|
||
|
||
/// The unit of one bound trace, or "" for a slot that plots nothing.
|
||
std::string slot_unit(App& app, int row, int slot) {
|
||
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||
const RowSet::Row& r = app.rows->row(row);
|
||
const int ch = r.channel[static_cast<std::size_t>(slot)];
|
||
if (ch < 0 || static_cast<std::size_t>(ch) >= app.model->channels().size()) return "";
|
||
return app.model->channels()[static_cast<std::size_t>(ch)].unit;
|
||
}
|
||
|
||
/// One bit per row/slot for the slots that are *bound* -- a name **and** a channel the
|
||
/// model actually built. A name alone is not a trace: the stream's time signal is never
|
||
/// acquired, so a slot naming it has a name for ever and data never, and `mix` would draw
|
||
/// it a line that can only read `--`.
|
||
std::uint32_t bound_slot_mask(App& app) {
|
||
std::uint32_t mask = 0;
|
||
for (int r = 0; r < app.rows->row_count(); ++r) {
|
||
const RowSet::Row& row = app.rows->row(r);
|
||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||
const std::size_t si = static_cast<std::size_t>(s);
|
||
if (row.signal_name[si].empty() || row.channel[si] < 0) continue;
|
||
mask |= 1u << (r * RowSet::kSlotsPerRow + s);
|
||
}
|
||
}
|
||
return mask;
|
||
}
|
||
|
||
/// The pane's body follows what the mode reads and what is plotted: a line per row for
|
||
/// the rulers, a line per bound trace for `mix`. Called when the mode changes, when the
|
||
/// rows change and when the stream is re-described -- the texts are then rewritten every
|
||
/// frame by `update_cursors_pane()`.
|
||
void rebuild_cursors_pane(App& app) {
|
||
if (app.cursors_body == nullptr) return;
|
||
GtkWidget* child = gtk_widget_get_first_child(app.cursors_body);
|
||
while (child != nullptr) {
|
||
GtkWidget* next = gtk_widget_get_next_sibling(child);
|
||
gtk_grid_remove(GTK_GRID(app.cursors_body), child);
|
||
child = next;
|
||
}
|
||
app.cursors_lines.clear();
|
||
cursors_header(app);
|
||
app.cursors_pane_mode = static_cast<int>(app.rows->cursor_mode());
|
||
app.cursors_pane_rows = app.rows->row_count();
|
||
app.cursors_pane_generation = app.model->generation();
|
||
app.cursors_pane_bound = bound_slot_mask(app);
|
||
|
||
const RowSet::CursorMode mode = app.rows->cursor_mode();
|
||
const bool rulers = mode == RowSet::CursorMode::Horizontal || mode == RowSet::CursorMode::Both;
|
||
const bool crossings = mode == RowSet::CursorMode::Mix;
|
||
|
||
// The times are a row of the same table: `| t | T0 | T1 | dT |`. `slot = -2` marks it,
|
||
// `-1` a ruler row, `>= 0` a trace. Only the modes that *have* the time cursors get it:
|
||
// in `horizontal` the pair is parked, and a row of times nothing on the plot can move
|
||
// would be a readout of nothing.
|
||
if (mode != RowSet::CursorMode::Horizontal) {
|
||
app.cursors_lines.push_back(cursors_line(app, "t", plot::Color::hex(0), false));
|
||
app.cursors_lines.back().row = -1;
|
||
app.cursors_lines.back().slot = -2;
|
||
}
|
||
|
||
if (rulers || crossings) {
|
||
for (int r = 0; r < app.rows->row_count(); ++r) {
|
||
if (!rulers) break;
|
||
char who[64];
|
||
std::snprintf(who, sizeof(who), "row %d", r);
|
||
app.cursors_lines.push_back(cursors_line(app, who, plot::Color::hex(0), false));
|
||
app.cursors_lines.back().row = r;
|
||
app.cursors_lines.back().slot = -1;
|
||
}
|
||
if (crossings) {
|
||
for (int r = 0; r < app.rows->row_count(); ++r) {
|
||
const RowSet::Row& row = app.rows->row(r);
|
||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||
const std::size_t si = static_cast<std::size_t>(s);
|
||
// A *bound* trace, not merely a named slot: a slot can name a signal the model
|
||
// will never build a channel for (the stream's time signal is never acquired),
|
||
// and a line for one of those is a row of `--` beside a colour chip -- which is
|
||
// what "the pane shows a lot of empty signals that do not exist" was.
|
||
if (row.signal_name[si].empty() || row.channel[si] < 0) continue;
|
||
app.cursors_lines.push_back(cursors_line(
|
||
app, row.name[si].c_str(),
|
||
app.rows->signal_color(row.signal_name[si], row.color_index[si]), true));
|
||
app.cursors_lines.back().row = r;
|
||
app.cursors_lines.back().slot = s;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
/// `labeled_box()` with the label handed back, for a row whose text depends on what is being
|
||
/// edited (the dialog's option rows).
|
||
GtkWidget* labelled_row(const char* label, GtkWidget* child, GtkWidget** label_out) {
|
||
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||
GtkWidget* l = gtk_label_new(label);
|
||
gtk_widget_set_halign(l, GTK_ALIGN_START);
|
||
gtk_box_append(GTK_BOX(box), l);
|
||
gtk_box_append(GTK_BOX(box), child);
|
||
*label_out = l;
|
||
return box;
|
||
}
|
||
|
||
/// The add/edit dialog. `edit_index < 0` adds; otherwise it edits that measurement in place
|
||
/// (and offers Delete, which is the only way one is removed -- there is no undo here, so the
|
||
/// destructive button sits with the dialog that shows what it would destroy).
|
||
|
||
|
||
|
||
|
||
|
||
/// The dialog. Two dropdowns and the type's own options, laid out like the calibration one:
|
||
/// a small window with a `labeled_box` per field and the buttons in the header bar.
|
||
void open_measure_dialog(App& app, int edit_index) {
|
||
const Measure* editing =
|
||
edit_index >= 0 && static_cast<std::size_t>(edit_index) < app.measures.size()
|
||
? &app.measures[static_cast<std::size_t>(edit_index)]
|
||
: nullptr;
|
||
|
||
GtkWidget* window = dialog_window(GTK_WIDGET(app.window),
|
||
editing != nullptr ? "Edit measurement" : "Add measurement");
|
||
GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window));
|
||
|
||
// The list is the signals the *plots* are showing, in signal-list order: a measurement
|
||
// reads the window a plot is displaying, and its marks are drawn on the row that plots the
|
||
// signal, so the candidates are the traces on screen. That is also what keeps a channel
|
||
// nobody plots out of the list -- including a stream's timestamp signal, which is
|
||
// acquired but is not something to measure. The editing measurement's own signal is added
|
||
// back when it is not plotted (otherwise Save would silently re-point it at whatever the
|
||
// dropdown happened to show).
|
||
std::vector<std::string> sig_names;
|
||
int selected_signal = 0;
|
||
for (int r = 0; r < app.rows->row_count(); ++r) {
|
||
const RowSet::Row& row = app.rows->row(r);
|
||
for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) {
|
||
const std::size_t si = static_cast<std::size_t>(sl);
|
||
const std::string& n = row.name[si];
|
||
if (n.empty()) continue;
|
||
if (std::find(sig_names.begin(), sig_names.end(), n) != sig_names.end()) continue;
|
||
sig_names.push_back(n);
|
||
}
|
||
}
|
||
if (editing != nullptr) {
|
||
if (std::find(sig_names.begin(), sig_names.end(), editing->signal) == sig_names.end()) {
|
||
sig_names.push_back(editing->signal);
|
||
}
|
||
}
|
||
// The `const char*` array is built from the *finished* strings: taking `c_str()` as the
|
||
// vector grew left every pointer pointing into memory the next push reallocated.
|
||
std::vector<const char*> sig_cstrs;
|
||
sig_cstrs.reserve(sig_names.size() + 1);
|
||
for (const std::string& n : sig_names) sig_cstrs.push_back(n.c_str());
|
||
sig_cstrs.push_back(nullptr);
|
||
if (editing != nullptr) {
|
||
const auto it = std::find(sig_names.begin(), sig_names.end(), editing->signal);
|
||
if (it != sig_names.end()) {
|
||
selected_signal = static_cast<int>(it - sig_names.begin());
|
||
}
|
||
}
|
||
GtkWidget* signal_drop = gtk_drop_down_new_from_strings(sig_cstrs.data());
|
||
gtk_drop_down_set_selected(GTK_DROP_DOWN(signal_drop), static_cast<guint>(selected_signal));
|
||
// No scrolled window around it: a `GtkDropDown` scrolls its own popover list, and wrapping
|
||
// one asked the *combo box itself* to grow to 160 px.
|
||
gtk_box_append(GTK_BOX(box), labeled_box("signal", signal_drop));
|
||
|
||
std::vector<const char*> type_names;
|
||
for (int i = 0; i < measure_type_count(); ++i) type_names.push_back(measure_type_name(measure_type_at(i)));
|
||
type_names.push_back(nullptr);
|
||
GtkWidget* type_drop = gtk_drop_down_new_from_strings(type_names.data());
|
||
gtk_box_append(GTK_BOX(box), labeled_box("measure", type_drop));
|
||
|
||
GtkWidget* threshold = spin(0.0, 100.0, 1.0, 1, 50.0); // the split's own default, re-seeded below
|
||
gtk_widget_set_tooltip_text(threshold,
|
||
"where the signal is split, as a share of the visible range");
|
||
GtkWidget* threshold_label = nullptr;
|
||
GtkWidget* threshold_row = labelled_row("threshold %", threshold, &threshold_label);
|
||
gtk_box_append(GTK_BOX(box), threshold_row);
|
||
|
||
GtkWidget* above = gtk_switch_new();
|
||
gtk_widget_set_halign(above, GTK_ALIGN_START);
|
||
gtk_widget_set_tooltip_text(above, "which half of the waveform is the pulse");
|
||
GtkWidget* side_label = nullptr;
|
||
GtkWidget* side_row = labelled_row("pulse above level", above, &side_label);
|
||
gtk_box_append(GTK_BOX(box), side_row);
|
||
|
||
// A percentage of the window's own peak-to-peak, which is the unit the engine takes.
|
||
GtkWidget* noise = spin(0.0, 50.0, 1.0, 1, 10.0);
|
||
// `labelled_row()` writes the label out unconditionally, and these two never retitle, so the
|
||
// out-parameters are named and then explicitly unused rather than passed as nullptr.
|
||
GtkWidget* noise_label = nullptr;
|
||
GtkWidget* noise_row = labelled_row("noise band %", noise, &noise_label);
|
||
gtk_box_append(GTK_BOX(box), noise_row);
|
||
(void)noise_label;
|
||
|
||
// 0 is off. The engine clamps to just under the stream's Nyquist, which the dialog cannot know.
|
||
GtkWidget* hf = spin(0.0, 1.0e7, 1.0, 3, 0.0);
|
||
GtkWidget* hf_label = nullptr;
|
||
GtkWidget* hf_row = labelled_row("HF reject Hz", hf, &hf_label);
|
||
gtk_box_append(GTK_BOX(box), hf_row);
|
||
(void)hf_label;
|
||
|
||
GtkWidget* show = gtk_check_button_new_with_label("show on plot");
|
||
gtk_widget_set_tooltip_text(show,
|
||
"draw the result on the plot: a ruler at the level, a marker "
|
||
"at the time it happened, the pulse's edges as markers");
|
||
gtk_box_append(GTK_BOX(box), show);
|
||
|
||
auto* d = new MeasureDialog{&app, edit_index, signal_drop, type_drop,
|
||
threshold, above, show, window,
|
||
threshold_row, threshold_label, side_row, side_label,
|
||
noise, noise_row, hf, hf_row,
|
||
sig_names};
|
||
app.measure_dialog = window;
|
||
g_signal_connect(window, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer user) {
|
||
static_cast<App*>(user)->measure_dialog = nullptr;
|
||
}),
|
||
&app);
|
||
// One owner, on the window: the buttons only *refer* to the context. Letting two of them
|
||
// own it (Apply and Delete) freed it twice, which is the abort an edit or a delete used to
|
||
// produce -- an add has one button, which is why only those two paths crashed.
|
||
g_object_set_data_full(G_OBJECT(window), "udpscope-measure-ctx", d,
|
||
[](gpointer p) { delete static_cast<MeasureDialog*>(p); });
|
||
if (editing != nullptr) {
|
||
// The index by looking `editing->type` up in the same function that fills the dropdown:
|
||
// a hand-written chain of ternaries is a second copy of the order, and the copy is what
|
||
// decides which type the user gets.
|
||
for (int i = 0; i < measure_type_count(); ++i) {
|
||
if (measure_type_at(i) == editing->type) {
|
||
gtk_drop_down_set_selected(GTK_DROP_DOWN(type_drop), static_cast<guint>(i));
|
||
break;
|
||
}
|
||
}
|
||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(threshold), editing->options.threshold_pct);
|
||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(noise), editing->options.noise_pct);
|
||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(hf), editing->options.hf_fc);
|
||
gtk_switch_set_active(GTK_SWITCH(above), editing->options.above);
|
||
gtk_check_button_set_active(GTK_CHECK_BUTTON(show), editing->options.show_on_plot);
|
||
} else {
|
||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(threshold), 50.0);
|
||
gtk_switch_set_active(GTK_SWITCH(above), true);
|
||
}
|
||
g_signal_connect(type_drop, "notify::selected", G_CALLBACK(on_measure_type_changed), d);
|
||
measure_dialog_sync(d);
|
||
|
||
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
|
||
g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window);
|
||
dialog_add_button(window, cancel);
|
||
if (editing != nullptr) {
|
||
GtkWidget* del = gtk_button_new_with_label("Delete");
|
||
gtk_widget_add_css_class(del, "destructive-action");
|
||
g_signal_connect(del, "clicked", G_CALLBACK(on_measure_dialog_delete), d);
|
||
dialog_add_button(window, del);
|
||
}
|
||
GtkWidget* apply = gtk_button_new_with_label(editing != nullptr ? "Save" : "Add");
|
||
gtk_widget_add_css_class(apply, "suggested-action");
|
||
g_signal_connect(apply, "clicked", G_CALLBACK(on_measure_dialog_apply), d);
|
||
dialog_add_button(window, apply);
|
||
gtk_window_present(GTK_WINDOW(window));
|
||
}
|
||
|
||
|
||
/// Puts the search hits nearest the view on the plots, converting each from the stream seconds
|
||
/// `BufferSearch` records to the x the axis is reading this frame -- the same conversion
|
||
/// `place_measure_marks()` makes, and for the same reason. Called every frame: that is what
|
||
/// makes a mark read-only and what keeps it on its own sample as a live view rolls.
|
||
///
|
||
/// Only the hits **inside the visible window** are candidates, nearest the view's centre first, so
|
||
/// that a zoomed-out view with more hits than marks shows the middle of the cluster rather than an
|
||
/// arbitrary end of it. The selected hit is placed first regardless, so stepping to a hit never
|
||
/// leaves it unmarked, and it is the one drawn in the current colour.
|
||
void place_search_marks(App& app) {
|
||
std::vector<double> xs;
|
||
int current = -1;
|
||
std::size_t in_view = 0;
|
||
bool sel_off_view = false;
|
||
const std::vector<double>& hits = app.search.hits();
|
||
if (!hits.empty()) {
|
||
const double s0 = app.model->stream_at_x0();
|
||
const plot::Range view = app.rows->view_range();
|
||
const double centre = 0.5 * (view.min + view.max);
|
||
const int sel = app.search.cursor();
|
||
|
||
// Candidates: the display x of every hit in view, paired with its distance from the centre.
|
||
std::vector<std::pair<double, double>> cand;
|
||
for (std::size_t i = 0; i < hits.size(); ++i) {
|
||
const double x = hits[i] - s0;
|
||
if (x < view.min || x > view.max) continue;
|
||
++in_view;
|
||
if (sel >= 0 && i == static_cast<std::size_t>(sel)) continue; // placed first, below
|
||
cand.push_back({std::fabs(x - centre), x});
|
||
}
|
||
std::sort(cand.begin(), cand.end());
|
||
|
||
const std::size_t cap = static_cast<std::size_t>(RowSet::kSearchMarks);
|
||
// xs.reserve(cap) is only a hint; the actual cap is enforced by xs.size() < cap in the fill loop.
|
||
xs.reserve(cap);
|
||
if (sel >= 0 && sel < static_cast<int>(hits.size())) {
|
||
current = 0;
|
||
const double x = hits[static_cast<std::size_t>(sel)] - s0;
|
||
// The selected hit is placed whether or not it is on screen, so the label must not fold it
|
||
// into the in-view count: it is the one mark `in_view` above deliberately did not see.
|
||
sel_off_view = x < view.min || x > view.max;
|
||
xs.push_back(x);
|
||
}
|
||
for (std::size_t i = 0; i < cand.size() && xs.size() < cap; ++i) xs.push_back(cand[i].second);
|
||
}
|
||
if (app.search_marks_label != nullptr) {
|
||
// The marks that are *in view*: `xs` also carries the selected hit when it is off screen.
|
||
const std::size_t shown = xs.size() - (sel_off_view ? 1u : 0u);
|
||
char text[128];
|
||
if (hits.empty()) {
|
||
std::snprintf(text, sizeof(text), " ");
|
||
} else if (in_view > shown) {
|
||
// A truncated display says so: "16 of 38" with no qualifier reads as "there are 38 and you
|
||
// are seeing 16 of them *everywhere*", which is not the claim.
|
||
std::snprintf(text, sizeof(text), "%zu of %zu marks shown \xE2\x80\x94 zoom in to see the rest",
|
||
shown, in_view);
|
||
} else if (sel_off_view) {
|
||
std::snprintf(text, sizeof(text),
|
||
"%zu of %zu in view \xE2\x80\x94 the selected hit is outside the window", shown,
|
||
hits.size());
|
||
} else {
|
||
std::snprintf(text, sizeof(text), "%zu of %zu in view", shown, hits.size());
|
||
}
|
||
gtk_label_set_text(GTK_LABEL(app.search_marks_label), text);
|
||
}
|
||
app.rows->set_search_marks(xs, current);
|
||
}
|
||
|
||
|
||
/// The measurements panel: a header with the `[+]`, and one row per measurement.
|
||
// ----------------------------------------------------------------- math channels
|
||
//
|
||
// A math channel is a *channel*: the model synthesises a signal for it, so the panel's switch, the
|
||
// plot binding, the measurements and the export all treat it like a streamed one, and the pane only
|
||
// has to say which graphs exist. The editor is the compact node graph: nodes as boxes on a canvas,
|
||
// click a box to select it and edit its parameters in the form beside the canvas, click an output
|
||
// port and then an input port to wire them, drag a box to move it.
|
||
|
||
|
||
|
||
/// The panel's `+`: a new channel with one source node, so the editor opens on something valid and
|
||
/// the user has a box to connect.
|
||
void add_math_channel(App& app) {
|
||
// From the *acquired* channels, never `signals().front()`: the first signal of a stream is its
|
||
// timestamp, which is not acquired, so a node sourced from it reads NaN for ever -- a graph that
|
||
// validates and then shows nothing. (The dialog's own default was fixed for this; the pane's `+`
|
||
// was not.)
|
||
std::string name;
|
||
{
|
||
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||
if (!app.model->channels().empty()) name = app.model->channels().front().name;
|
||
}
|
||
MathChannel m;
|
||
// A name of its own: calling it after its first source would put a second entry with that name in
|
||
// the signal panel, where the user cannot tell the derived one from the streamed one.
|
||
m.name = "math" + std::to_string(app.math.size() + 1);
|
||
m.enabled = true;
|
||
MathNode src;
|
||
src.op = MathOp::Source;
|
||
src.source = name;
|
||
src.x = 30.0;
|
||
src.y = 40.0;
|
||
m.nodes.push_back(src);
|
||
m.output = 0;
|
||
app.math.push_back(std::move(m));
|
||
apply_math_channels(app);
|
||
}
|
||
|
||
// The node editor: a compact graph. Each node is a box; inputs are the squares on its left edge,
|
||
// the output the square on its right. Click a box to select it (its parameters appear in the form
|
||
// beside the canvas), drag from an output square to an input square to wire them, drag a wired
|
||
// input off to detach it, drag a box to move it. The graph is a *list*, walked in order, so a node
|
||
// may only read ones above it -- but that order is the *evaluator's* contract, not a restriction on
|
||
// the gesture: a wire may be drawn in either direction and `math_connect()` re-sorts the list
|
||
// (Kahn's algorithm) so the contract still holds. Only a wire that cannot be ordered at all, i.e.
|
||
// one that closes a cycle, is refused, and it is refused at connect time with a message.
|
||
|
||
/// A wire, named by the node that *consumes* it and which of its inputs. The consumer is the
|
||
/// identity because that is where a wire is stored: `in_a`/`in_b` on the reader. A producer may
|
||
/// feed several inputs and so cannot name one.
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
/// A port's identity: `kPortOut` for a node's output, 0 or 1 for one of its inputs.
|
||
|
||
|
||
/// The smallest and largest the canvas may be scaled to. Below a quarter the op names are
|
||
/// unreadable; above four a box fills the canvas and there is nothing left to navigate.
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
/// The pick radius for a wire, in screen pixels. Smaller than a port's, so a wire that passes
|
||
/// behind a port never wins the press -- the port is tested first anyway, but a user aiming at a
|
||
/// port should not be able to miss it onto a wire either.
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
void open_math_dialog(App& app, int edit) {
|
||
if (app.math_dialog != nullptr) {
|
||
gtk_window_present(GTK_WINDOW(app.math_dialog));
|
||
return;
|
||
}
|
||
MathDialog* d = new MathDialog();
|
||
d->app = &app;
|
||
d->edit = edit;
|
||
{
|
||
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||
d->nodes = edit >= 0 && edit < static_cast<int>(app.math.size())
|
||
? app.math[static_cast<std::size_t>(edit)].nodes
|
||
: std::vector<MathNode>{};
|
||
}
|
||
if (d->nodes.empty()) {
|
||
MathNode src;
|
||
src.op = MathOp::Source;
|
||
const std::vector<std::string> names = math_source_names(app);
|
||
if (!names.empty()) src.source = names.front();
|
||
d->nodes.push_back(src);
|
||
}
|
||
// A node with no position yet -- a graph built by a probe, or one whose box was never moved --
|
||
// would otherwise sit in the canvas's top-left corner, stacked under whatever is drawn after it.
|
||
// Laid out in a column, which is also the order the graph is walked in.
|
||
// Two columns, spaced by more than a box is wide. The first version staggered them by 24 px for a
|
||
// 132 px box, so every node sat on top of the one before it and the click always found the last --
|
||
// which reads as the dialog ignoring the pointer.
|
||
for (std::size_t i = 0; i < d->nodes.size(); ++i) {
|
||
if (d->nodes[i].x != 0.0 || d->nodes[i].y != 0.0) continue;
|
||
d->nodes[i].x = 24.0 + 160.0 * static_cast<double>(i % 2u);
|
||
d->nodes[i].y = 20.0 + 58.0 * static_cast<double>(i / 2u);
|
||
}
|
||
d->output = edit >= 0 && edit < static_cast<int>(app.math.size())
|
||
? app.math[static_cast<std::size_t>(edit)].output
|
||
: 0;
|
||
// **The last node, not the first.** A new graph is built source -> operation, and the *output*
|
||
// defaulted to node 0 -- the source -- so every operation was computed and then thrown away: a
|
||
// gain of 2 and a low pass both produced a channel equal to its input. The output is drawn as the
|
||
// orange dot and settable explicitly, so a graph whose output is genuinely an earlier node keeps
|
||
// it; this is only the default for one that has never been given a choice.
|
||
if (d->output < 0 || d->output >= static_cast<int>(d->nodes.size())) {
|
||
d->output = static_cast<int>(d->nodes.size()) - 1;
|
||
}
|
||
d->selected = d->output;
|
||
|
||
// The window and its identity row are a `.ui` file (`src/ui/math-editor.ui`). The canvas, the palette and
|
||
// the parameter form below them stay in code: the canvas is drawn rather than declared, and the form
|
||
// follows which node is selected.
|
||
g_clear_object(&app.math_dialog_ui);
|
||
app.math_dialog_ui = gtk_builder_new_from_resource("/org/udpscope/ui/math-editor.ui");
|
||
GtkBuilder* ui = app.math_dialog_ui;
|
||
d->window = GTK_WIDGET(gtk_builder_get_object(ui, "math_window"));
|
||
GtkWidget* outer = GTK_WIDGET(gtk_builder_get_object(ui, "math_outer"));
|
||
GtkWidget* head = GTK_WIDGET(gtk_builder_get_object(ui, "math_head"));
|
||
d->name = GTK_WIDGET(gtk_builder_get_object(ui, "math_name"));
|
||
d->unit = GTK_WIDGET(gtk_builder_get_object(ui, "math_unit"));
|
||
if (d->window == nullptr || outer == nullptr || head == nullptr || d->name == nullptr ||
|
||
d->unit == nullptr) {
|
||
// A missing id is a mistake in the `.ui`. There is no editor to open, so this says so and gives up
|
||
// rather than building the rest of a window around a hole; `[math-probe] editor` reads the difference.
|
||
std::fprintf(stderr, "[math] editor: math-editor.ui is missing an id this code reads\n");
|
||
delete d;
|
||
return;
|
||
}
|
||
GtkWidget* window = d->window;
|
||
gtk_window_set_title(GTK_WINDOW(window), edit >= 0 ? "Edit math channel" : "New math channel");
|
||
// The dialog's own state, freed with the window: a plain local here would leak the graph and every drag
|
||
// it is halfway through on each opening.
|
||
g_object_set_data_full(G_OBJECT(window), "udpscope-math-dialog", d,
|
||
+[](gpointer p) { delete static_cast<MathDialog*>(p); });
|
||
g_signal_connect(window, "close-request", G_CALLBACK(+[](GtkWindow* w, gpointer) {
|
||
GtkWidget* dd = GTK_WIDGET(w);
|
||
MathDialog* md = static_cast<MathDialog*>(
|
||
g_object_get_data(G_OBJECT(dd), "udpscope-math-dialog"));
|
||
if (md != nullptr && md->app != nullptr) md->app->math_dialog = nullptr;
|
||
return false;
|
||
}),
|
||
nullptr);
|
||
|
||
// The two values the file cannot hold: what this channel is called and the unit its readouts carry. The
|
||
// name is also how another graph refers to this channel as a source, which is why it takes the slack.
|
||
if (edit >= 0 && edit < static_cast<int>(app.math.size())) {
|
||
gtk_editable_set_text(GTK_EDITABLE(d->name), app.math[static_cast<std::size_t>(edit)].name.c_str());
|
||
gtk_editable_set_text(GTK_EDITABLE(d->unit), app.math[static_cast<std::size_t>(edit)].unit.c_str());
|
||
}
|
||
|
||
// The body and the canvas are the file's as well; what is code is the *drawing* -- the function below and
|
||
// the gestures under it, which are what make the area a graph editor rather than a picture.
|
||
GtkWidget* body = GTK_WIDGET(gtk_builder_get_object(ui, "math_body"));
|
||
d->canvas = GTK_WIDGET(gtk_builder_get_object(ui, "math_canvas"));
|
||
if (body == nullptr || d->canvas == nullptr) {
|
||
std::fprintf(stderr, "[math] editor: math-editor.ui is missing its body or canvas\n");
|
||
delete d;
|
||
return;
|
||
}
|
||
gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(d->canvas), math_canvas_draw, d, nullptr);
|
||
GtkGesture* click = gtk_gesture_click_new();
|
||
g_signal_connect(click, "pressed", G_CALLBACK(+[](GtkGestureClick* g, int, double x, double y,
|
||
gpointer user) {
|
||
math_canvas_pressed(g, 0, x, y, user);
|
||
}),
|
||
d);
|
||
gtk_widget_add_controller(d->canvas, GTK_EVENT_CONTROLLER(click));
|
||
GtkGesture* drag = gtk_gesture_drag_new();
|
||
g_signal_connect(drag, "drag-begin", G_CALLBACK(+[](GtkGestureDrag* g, double x, double y,
|
||
gpointer user) {
|
||
math_canvas_drag_begin(g, x, y, user);
|
||
}),
|
||
d);
|
||
g_signal_connect(drag, "drag-update", G_CALLBACK(+[](GtkGestureDrag* g, double dx, double dy,
|
||
gpointer user) {
|
||
math_canvas_drag(g, dx, dy, user);
|
||
}),
|
||
d);
|
||
g_signal_connect(drag, "drag-end", G_CALLBACK(+[](GtkGestureDrag* g, double dx, double dy,
|
||
gpointer user) {
|
||
math_canvas_drag_end(g, dx, dy, user);
|
||
}),
|
||
d);
|
||
gtk_widget_add_controller(d->canvas, GTK_EVENT_CONTROLLER(drag));
|
||
|
||
// The wheel zooms. A scroll controller is the only way to get one on a drawing area, and
|
||
// `VERTICAL` is what a wheel reports.
|
||
GtkEventController* zoom = gtk_event_controller_scroll_new(GTK_EVENT_CONTROLLER_SCROLL_VERTICAL);
|
||
g_signal_connect(zoom, "scroll", G_CALLBACK(math_canvas_scrolled), d);
|
||
gtk_widget_add_controller(d->canvas, zoom);
|
||
|
||
// A `GtkDrawingArea` is not focusable by default, so a key controller on it never fires. It
|
||
// takes focus on a press rather than only on Tab, because every key this editor binds is about
|
||
// something the pointer just did.
|
||
gtk_widget_set_focusable(d->canvas, TRUE);
|
||
GtkEventController* keys = gtk_event_controller_key_new();
|
||
g_signal_connect(keys, "key-pressed", G_CALLBACK(math_canvas_key), d);
|
||
gtk_widget_add_controller(d->canvas, keys);
|
||
|
||
// The form: every row exists from the start and is shown or hidden per op, so no rebuild can
|
||
// invalidate a callback's handle.
|
||
GtkWidget* form = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
|
||
gtk_widget_set_size_request(form, 250, -1);
|
||
|
||
GtkWidget* op_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||
GtkWidget* op_text = gtk_label_new("operation");
|
||
gtk_widget_set_size_request(op_text, 74, -1);
|
||
gtk_widget_set_halign(op_text, GTK_ALIGN_START);
|
||
gtk_box_append(GTK_BOX(op_row), op_text);
|
||
std::vector<const char*> op_names;
|
||
for (int i = 0; i < math_op_count(); ++i) op_names.push_back(math_op_name(math_op_at(i)));
|
||
op_names.push_back(nullptr);
|
||
d->op_drop = gtk_drop_down_new_from_strings(op_names.data());
|
||
gtk_widget_set_hexpand(d->op_drop, true);
|
||
g_signal_connect(d->op_drop, "notify::selected", G_CALLBACK(on_math_op_changed), d);
|
||
gtk_box_append(GTK_BOX(op_row), d->op_drop);
|
||
gtk_box_append(GTK_BOX(form), op_row);
|
||
|
||
d->source_drop = gtk_drop_down_new_from_strings(nullptr);
|
||
g_signal_connect(d->source_drop, "notify::selected", G_CALLBACK(on_math_source_changed), d);
|
||
gtk_box_append(GTK_BOX(form), math_row("channel", d->source_drop, &d->rows[0]));
|
||
|
||
d->gain = gtk_spin_button_new_with_range(-1e6, 1e6, 0.1);
|
||
d->offset = gtk_spin_button_new_with_range(-1e6, 1e6, 0.1);
|
||
// Digits, not just a range: a `GtkSpinButton` holds zero decimals by default, so a 12.5 Hz cutoff
|
||
// was stored as 12 and a gain of 0.25 as 0 -- the values looked wrong because they *were*.
|
||
gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->gain), 4);
|
||
gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->offset), 4);
|
||
// An explicit starting value, because `gtk_spin_button_new_with_range()` leaves the value at the
|
||
// range's *minimum* when zero is not one of the bounds it can pick: the offset began at -1e6, and
|
||
// `on_math_param_changed()` reads the widgets -- so a gain of 2 saved as `x*2 - 2000000`. Every
|
||
// field is seeded from its own identity value; `math_dialog_sync()` then sets the node's.
|
||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->gain), 1.0);
|
||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->offset), 0.0);
|
||
g_signal_connect(d->gain, "value-changed", G_CALLBACK(on_math_param_changed), d);
|
||
g_signal_connect(d->offset, "value-changed", G_CALLBACK(on_math_param_changed), d);
|
||
{
|
||
// One box for both: `gain + offset` is a single op and the two fields are only meaningful
|
||
// together, so they show and hide as one. The offset used to be a row of its own that nothing
|
||
// ever hid, which is what made it look like a property of every node instead of of this one op.
|
||
GtkWidget* gain_rows = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
|
||
gtk_box_append(GTK_BOX(gain_rows), math_row("gain", d->gain, nullptr));
|
||
gtk_box_append(GTK_BOX(gain_rows), math_row("offset", d->offset, nullptr));
|
||
d->rows[1] = gain_rows;
|
||
gtk_box_append(GTK_BOX(form), gain_rows);
|
||
}
|
||
|
||
d->fc = gtk_spin_button_new_with_range(0.001, 1e7, 0.5);
|
||
d->q = gtk_spin_button_new_with_range(0.01, 100.0, 0.05);
|
||
gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->fc), 3);
|
||
gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->q), 3);
|
||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->fc), 100.0);
|
||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->q), 0.707);
|
||
g_signal_connect(d->fc, "value-changed", G_CALLBACK(on_math_param_changed), d);
|
||
g_signal_connect(d->q, "value-changed", G_CALLBACK(on_math_param_changed), d);
|
||
{
|
||
GtkWidget* filter_rows = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
|
||
gtk_box_append(GTK_BOX(filter_rows), math_row("corner Hz", d->fc, nullptr));
|
||
gtk_box_append(GTK_BOX(filter_rows), math_row("Q", d->q, &d->rows[3]));
|
||
gtk_box_append(GTK_BOX(form), filter_rows);
|
||
d->rows[2] = filter_rows;
|
||
// The Q row is only meaningful for the band pass, which is the only op that reads it.
|
||
gtk_widget_set_visible(d->rows[3], false);
|
||
}
|
||
|
||
d->lo = gtk_spin_button_new_with_range(-1e6, 1e6, 0.1);
|
||
d->hi = gtk_spin_button_new_with_range(-1e6, 1e6, 0.1);
|
||
gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->lo), 4);
|
||
gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->hi), 4);
|
||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->lo), -1.0);
|
||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->hi), 1.0);
|
||
g_signal_connect(d->lo, "value-changed", G_CALLBACK(on_math_param_changed), d);
|
||
g_signal_connect(d->hi, "value-changed", G_CALLBACK(on_math_param_changed), d);
|
||
{
|
||
GtkWidget* clamp_rows = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
|
||
gtk_box_append(GTK_BOX(clamp_rows), math_row("min", d->lo, nullptr));
|
||
gtk_box_append(GTK_BOX(clamp_rows), math_row("max", d->hi, nullptr));
|
||
d->rows[4] = clamp_rows;
|
||
gtk_box_append(GTK_BOX(form), clamp_rows);
|
||
}
|
||
|
||
// Odd, 3..9. The engine rounds up to odd anyway, but a step of 2 means the widget never offers an even
|
||
// one.
|
||
d->med = gtk_spin_button_new_with_range(3.0, 9.0, 2.0);
|
||
gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->med), 0);
|
||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->med), 5.0);
|
||
g_signal_connect(d->med, "value-changed", G_CALLBACK(on_math_param_changed), d);
|
||
gtk_box_append(GTK_BOX(form), math_row("median samples", d->med, &d->rows[6]));
|
||
|
||
d->inputs = gtk_label_new("");
|
||
gtk_label_set_wrap(GTK_LABEL(d->inputs), true);
|
||
gtk_widget_add_css_class(d->inputs, "dim-label");
|
||
gtk_widget_set_halign(d->inputs, GTK_ALIGN_START);
|
||
gtk_box_append(GTK_BOX(form), math_row("wired", d->inputs, &d->rows[5]));
|
||
|
||
// Why the graph cannot be saved, in the form column where the user is already reading. Hidden
|
||
// when there is nothing to say, and **ellipsized rather than wrapped**, with the full text on the
|
||
// tooltip (`math_dialog_sync()` sets both together): layout must not depend on content. This label
|
||
// sits in a dialog whose canvas is `hexpand`, so a wrapping message re-heights the form -- and a
|
||
// long channel name in it would widen the dialog -- on a mere change of graph. The same rule the
|
||
// status bar and the y gutter are built to.
|
||
d->problem = gtk_label_new("");
|
||
gtk_label_set_ellipsize(GTK_LABEL(d->problem), PANGO_ELLIPSIZE_END);
|
||
gtk_label_set_xalign(GTK_LABEL(d->problem), 0.0f);
|
||
gtk_widget_add_css_class(d->problem, "error");
|
||
gtk_widget_set_visible(d->problem, FALSE);
|
||
gtk_box_append(GTK_BOX(form), d->problem);
|
||
|
||
// A menu of the thirteen ops rather than one button that always makes a gain: building anything
|
||
// else was add-then-change-the-dropdown, which is two steps for the common case.
|
||
GtkWidget* add = gtk_menu_button_new();
|
||
gtk_menu_button_set_label(GTK_MENU_BUTTON(add), "add node");
|
||
GtkWidget* add_menu = gtk_popover_new();
|
||
GtkWidget* add_list = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||
for (int i = 0; i < math_op_count(); ++i) {
|
||
const MathOp op = math_op_at(i);
|
||
GtkWidget* item = gtk_button_new_with_label(math_op_name(op));
|
||
gtk_widget_add_css_class(item, "flat");
|
||
gtk_widget_set_halign(gtk_button_get_child(GTK_BUTTON(item)), GTK_ALIGN_START);
|
||
// The op travels in the widget's own data, as an `int` through `math_op_index()`'s space: a
|
||
// pointer-sized enum smuggled through `gpointer` is the kind of cast that survives a
|
||
// reordering of the enum and then means something else.
|
||
g_object_set_data(G_OBJECT(item), "udpscope-math-op", GINT_TO_POINTER(i));
|
||
g_object_set_data(G_OBJECT(item), "udpscope-math-popover", add_menu);
|
||
g_signal_connect(item, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
|
||
MathDialog* dd = static_cast<MathDialog*>(user);
|
||
const int index =
|
||
GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), "udpscope-math-op"));
|
||
auto* pop = GTK_POPOVER(
|
||
g_object_get_data(G_OBJECT(b), "udpscope-math-popover"));
|
||
if (pop != nullptr) gtk_popover_popdown(pop);
|
||
math_add_node(dd, math_op_at(index));
|
||
}), d);
|
||
gtk_box_append(GTK_BOX(add_list), item);
|
||
}
|
||
gtk_popover_set_child(GTK_POPOVER(add_menu), add_list);
|
||
gtk_menu_button_set_popover(GTK_MENU_BUTTON(add), add_menu);
|
||
GtkWidget* del = gtk_button_new_with_label("delete node");
|
||
GtkWidget* out = gtk_button_new_with_label("set as output");
|
||
g_signal_connect(del, "clicked", G_CALLBACK(on_math_node_deleted), d);
|
||
g_signal_connect(out, "clicked", G_CALLBACK(on_math_output_set), d);
|
||
GtkWidget* fit = gtk_button_new_with_label("fit");
|
||
gtk_widget_set_tooltip_text(fit, "Frame the whole graph (f). Ctrl+0 resets the zoom.");
|
||
g_signal_connect(fit, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||
math_view_fit(static_cast<MathDialog*>(user));
|
||
}), d);
|
||
GtkWidget* tidy = gtk_button_new_with_label("tidy");
|
||
gtk_widget_set_tooltip_text(tidy, "Lay the graph out left to right and frame it.");
|
||
g_signal_connect(tidy, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||
math_tidy(static_cast<MathDialog*>(user));
|
||
}), d);
|
||
GtkWidget* buttons = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||
gtk_box_append(GTK_BOX(buttons), add);
|
||
gtk_box_append(GTK_BOX(buttons), del);
|
||
gtk_box_append(GTK_BOX(buttons), out);
|
||
gtk_box_append(GTK_BOX(buttons), fit);
|
||
gtk_box_append(GTK_BOX(buttons), tidy);
|
||
gtk_box_append(GTK_BOX(form), buttons);
|
||
gtk_box_append(GTK_BOX(body), form); // the form is the body's second child; the canvas is the file's
|
||
|
||
// The action row is the file's; what is code is what the buttons *do*, and what the second one says:
|
||
// "Save" edits an existing channel and "Add" makes a new one, which the file cannot know.
|
||
GtkWidget* actions = GTK_WIDGET(gtk_builder_get_object(ui, "math_actions"));
|
||
GtkWidget* cancel = GTK_WIDGET(gtk_builder_get_object(ui, "math_cancel"));
|
||
GtkWidget* apply = GTK_WIDGET(gtk_builder_get_object(ui, "math_apply"));
|
||
if (actions == nullptr || cancel == nullptr || apply == nullptr) {
|
||
std::fprintf(stderr, "[math] editor: math-editor.ui is missing its action row\n");
|
||
delete d;
|
||
return;
|
||
}
|
||
gtk_button_set_label(GTK_BUTTON(apply), edit >= 0 ? "Save" : "Add");
|
||
// Named so the headless probe can click the *button* rather than call its handler: the defect
|
||
// this guards was in the connection, which a direct call cannot see.
|
||
g_object_set_data(G_OBJECT(window), "udpscope-math-cancel", cancel);
|
||
// `gtk_window_close()`, never `gtk_window_destroy()`: only a close emits `close-request`, which
|
||
// is what clears `app.math_dialog`. A destroy left that pointer at a dead widget, and every
|
||
// later open then took the "already open" branch and presented it -- "I cannot edit a math
|
||
// channel again". The same trap the other three windows in this file document.
|
||
g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_close), window);
|
||
g_signal_connect(apply, "clicked", G_CALLBACK(on_math_apply), d);
|
||
|
||
app.math_dialog = window;
|
||
d->apply = apply;
|
||
math_dialog_sync(d);
|
||
gtk_window_present(GTK_WINDOW(window));
|
||
}
|
||
|
||
|
||
GtkWidget* build_measures_panel(App& app) {
|
||
// The panel's frame is a UI file; the readings are not, because each row is one measurement and they are
|
||
// rebuilt whenever the set or the mode changes. `make test-ui` checks the file's types against the GIR;
|
||
// the ids below are the only thing that keeps the code and the file agreeing on the names.
|
||
GtkBuilder* ui = ui_load(app, "/org/udpscope/ui/measures-panel.ui");
|
||
GtkWidget* box = GTK_WIDGET(gtk_builder_get_object(ui, "measures_panel"));
|
||
GtkWidget* add = GTK_WIDGET(gtk_builder_get_object(ui, "measures_add"));
|
||
app.measures_stats_check = GTK_WIDGET(gtk_builder_get_object(ui, "measures_stats_check"));
|
||
app.measures_stats_reset = GTK_WIDGET(gtk_builder_get_object(ui, "measures_stats_reset"));
|
||
app.measures_list = GTK_WIDGET(gtk_builder_get_object(ui, "measures_list"));
|
||
if (box == nullptr || add == nullptr || app.measures_stats_check == nullptr ||
|
||
app.measures_stats_reset == nullptr || app.measures_list == nullptr) {
|
||
// A missing id is a mistake in the `.ui`. The pane comes up empty rather than taking the window with
|
||
// it, and `[udpscope] measures pane:` reads differently instead of the application failing to start.
|
||
return box != nullptr ? box : gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||
}
|
||
|
||
g_signal_connect(add, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||
open_measure_dialog(*static_cast<App*>(user), -1);
|
||
}),
|
||
&app);
|
||
|
||
// One toggle for the whole pane, not one per measurement: statistics are either what the
|
||
// user is looking at or they are not, and a per-row switch is a column of chrome paid for
|
||
// on every row to answer a question asked once.
|
||
g_signal_connect(app.measures_stats_check, "toggled",
|
||
G_CALLBACK(+[](GtkCheckButton* b, gpointer user) {
|
||
App* a = static_cast<App*>(user);
|
||
if (a->updating_panels) return;
|
||
a->measure_stats_on = gtk_check_button_get_active(b);
|
||
// Deliberately *not* a rebuild: that would reset the statistics the
|
||
// user just asked to look at. The rows are already there and hidden.
|
||
for (GtkWidget* w : a->measure_stats_labels) {
|
||
if (w != nullptr) gtk_widget_set_visible(w, a->measure_stats_on);
|
||
}
|
||
if (a->measures_stats_reset != nullptr)
|
||
gtk_widget_set_sensitive(a->measures_stats_reset, a->measure_stats_on);
|
||
// Refresh the now-visible labels without counting the window again: the
|
||
// toggle does not reset or disturb the history (README, Statistics).
|
||
update_measures(*a, /*force=*/true, /*accumulate=*/false);
|
||
schedule_layout_save(*a);
|
||
}),
|
||
&app);
|
||
|
||
g_signal_connect(app.measures_stats_reset, "clicked",
|
||
G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||
App* a = static_cast<App*>(user);
|
||
reset_measure_stats(*a);
|
||
update_measures(*a, /*force=*/true, /*accumulate=*/true);
|
||
}),
|
||
&app);
|
||
return box;
|
||
}
|
||
|
||
/// The panel's frame: the title row (the mode) and the readouts. The time line is not
|
||
/// part of the body, because it is there in every mode.
|
||
|
||
|
||
|
||
|
||
/// Fit: auto-scale every row's y axis to the data it holds, and put x back on the whole time
|
||
/// window.
|
||
///
|
||
/// No `apply_time_window()` here, deliberately: a fit is a plot interaction and may not restate
|
||
/// the time scale (see that function). The y axes are fitted to the data; x is only un-zoomed.
|
||
void on_fit_clicked(GtkButton*, gpointer user) {
|
||
App* app = static_cast<App*>(user);
|
||
app->rows->fit_all();
|
||
app->notice = "y fitted to the data, x back to the " +
|
||
format_window(app->rows->time_window()) + " window";
|
||
app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
|
||
}
|
||
|
||
/// Reset the x zoom only: the y axes keep the scales the user set. The counterpart of the other
|
||
/// fit, for the common case where a zoom should be undone but the levels were placed by hand.
|
||
void on_fit_x_clicked(GtkButton*, gpointer user) {
|
||
App* app = static_cast<App*>(user);
|
||
app->rows->fit_x();
|
||
app->notice = "x back to the " + format_window(app->rows->time_window()) + " window";
|
||
app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
|
||
}
|
||
|
||
|
||
// --- export -----------------------------------------------------------------
|
||
|
||
/// The table travels to the async callback. It is collected when the button is clicked,
|
||
/// not when the dialog is answered: a live view keeps scrolling while the file chooser
|
||
/// is up, so collecting late would write a window the user never saw.
|
||
|
||
void on_export_response(GObject* source, GAsyncResult* result, gpointer user) {
|
||
std::unique_ptr<ExportContext> ctx(static_cast<ExportContext*>(user));
|
||
GError* err = nullptr;
|
||
GFile* file = gtk_file_dialog_save_finish(GTK_FILE_DIALOG(source), result, &err);
|
||
if (file == nullptr) {
|
||
// A cancelled dialog is not a failure and says nothing; anything else does.
|
||
if (err != nullptr && !g_error_matches(err, GTK_DIALOG_ERROR, GTK_DIALOG_ERROR_DISMISSED)) {
|
||
set_notice(*ctx->app, std::string("export failed: ") + err->message);
|
||
}
|
||
if (err != nullptr) g_error_free(err);
|
||
return;
|
||
}
|
||
char* raw = g_file_get_path(file);
|
||
const std::string path = raw != nullptr ? raw : std::string();
|
||
g_free(raw);
|
||
g_object_unref(file);
|
||
if (path.empty()) {
|
||
set_notice(*ctx->app, "export failed: that location is not a local file");
|
||
return;
|
||
}
|
||
|
||
// The extension picks the writer, so the filter the user chose is honoured by way of
|
||
// the name it put in the entry rather than by a second piece of state.
|
||
const bool csv = path.size() >= 4 && path.compare(path.size() - 4, 4, ".csv") == 0;
|
||
std::string error;
|
||
const bool ok = csv ? write_csv(path, ctx->table, error) : write_npy(path, ctx->table, error);
|
||
if (!ok) {
|
||
set_notice(*ctx->app, "export failed: " + error);
|
||
return;
|
||
}
|
||
set_notice(*ctx->app, "exported " + std::to_string(ctx->table.rows()) + " samples x " +
|
||
std::to_string(ctx->table.columns.size()) + " signals to " + path);
|
||
}
|
||
|
||
void on_export_clicked(GtkButton*, gpointer user) {
|
||
App* app = static_cast<App*>(user);
|
||
auto ctx = std::make_unique<ExportContext>();
|
||
ctx->app = app;
|
||
ctx->table = collect_export(*app->model, *app->rows);
|
||
if (ctx->table.empty()) {
|
||
set_notice(*app, "nothing to export: no signal is plotted in the visible range");
|
||
return;
|
||
}
|
||
|
||
GtkFileDialog* dialog = gtk_file_dialog_new();
|
||
gtk_file_dialog_set_title(dialog, "Export visible range");
|
||
char stamp[32];
|
||
const std::time_t now = std::time(nullptr);
|
||
std::tm tm{};
|
||
localtime_r(&now, &tm);
|
||
std::strftime(stamp, sizeof(stamp), "%Y%m%d-%H%M%S", &tm);
|
||
gtk_file_dialog_set_initial_name(dialog, (std::string("udpscope-") + stamp + ".npy").c_str());
|
||
|
||
GListStore* filters = g_list_store_new(GTK_TYPE_FILE_FILTER);
|
||
GtkFileFilter* npy = gtk_file_filter_new();
|
||
gtk_file_filter_set_name(npy, "NumPy array (*.npy)");
|
||
gtk_file_filter_add_pattern(npy, "*.npy");
|
||
g_list_store_append(filters, npy);
|
||
GtkFileFilter* csv = gtk_file_filter_new();
|
||
gtk_file_filter_set_name(csv, "CSV (*.csv)");
|
||
gtk_file_filter_add_pattern(csv, "*.csv");
|
||
g_list_store_append(filters, csv);
|
||
gtk_file_dialog_set_filters(dialog, G_LIST_MODEL(filters));
|
||
gtk_file_dialog_set_default_filter(dialog, npy);
|
||
g_object_unref(npy);
|
||
g_object_unref(csv);
|
||
g_object_unref(filters);
|
||
|
||
gtk_file_dialog_save(dialog, GTK_WINDOW(app->window), nullptr, on_export_response,
|
||
ctx.release());
|
||
g_object_unref(dialog);
|
||
}
|
||
|
||
void on_about_clicked(GtkButton*, gpointer user) {
|
||
App* app = static_cast<App*>(user);
|
||
if (app->menu_button != nullptr) {
|
||
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
|
||
if (popover != nullptr) gtk_popover_popdown(popover);
|
||
}
|
||
show_about(GTK_WINDOW(app->window),
|
||
layout_path(app->model->config().server, app->model->config().port));
|
||
}
|
||
|
||
void on_help_clicked(GtkButton*, gpointer user) {
|
||
App* app = static_cast<App*>(user);
|
||
if (app->menu_button != nullptr) {
|
||
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
|
||
if (popover != nullptr) gtk_popover_popdown(popover);
|
||
}
|
||
show_help(GTK_WINDOW(app->window));
|
||
}
|
||
|
||
void on_shortcuts_clicked(GtkButton*, gpointer user) {
|
||
App* app = static_cast<App*>(user);
|
||
if (app->menu_button != nullptr) {
|
||
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
|
||
if (popover != nullptr) gtk_popover_popdown(popover);
|
||
}
|
||
show_shortcuts(GTK_WINDOW(app->window));
|
||
}
|
||
|
||
// --- right-click menu -------------------------------------------------------
|
||
|
||
void on_y_auto_clicked(GtkButton* button, gpointer user) {
|
||
App* app = static_cast<App*>(user);
|
||
const int row = row_key(GTK_WIDGET(button));
|
||
app->rows->set_y_auto(row, true);
|
||
GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(button), "udpscope-popover"));
|
||
if (popover != nullptr) gtk_popover_popdown(popover);
|
||
}
|
||
|
||
|
||
void on_fixed_range_apply(GtkButton*, gpointer user) {
|
||
FixedRangeContext* ctx = static_cast<FixedRangeContext*>(user);
|
||
ctx->app->rows->set_y_range(ctx->row, gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->lo)),
|
||
gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->hi)));
|
||
gtk_window_destroy(GTK_WINDOW(ctx->window));
|
||
}
|
||
|
||
void on_y_fixed_clicked(GtkButton* button, gpointer user) {
|
||
App* app = static_cast<App*>(user);
|
||
const int row = row_key(GTK_WIDGET(button));
|
||
GtkWidget* window = dialog_window(app->window, "Fixed Y range");
|
||
GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window));
|
||
const plot::Range cur = app->rows->row(row).plot->y_range(0);
|
||
GtkWidget* lo = spin(-1.0e12, 1.0e12, 0.1, 6, cur.min);
|
||
GtkWidget* hi = spin(-1.0e12, 1.0e12, 0.1, 6, cur.max);
|
||
gtk_box_append(GTK_BOX(box), labeled_box("min", lo));
|
||
gtk_box_append(GTK_BOX(box), labeled_box("max", hi));
|
||
|
||
auto* ctx = new FixedRangeContext{app, row, lo, hi, window};
|
||
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
|
||
g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window);
|
||
GtkWidget* apply = gtk_button_new_with_label("Apply");
|
||
g_object_set_data_full(G_OBJECT(apply), "udpscope-ctx", ctx,
|
||
[](gpointer p) { delete static_cast<FixedRangeContext*>(p); });
|
||
g_signal_connect(apply, "clicked", G_CALLBACK(on_fixed_range_apply), ctx);
|
||
dialog_add_button(window, cancel);
|
||
dialog_add_button(window, apply);
|
||
gtk_window_present(GTK_WINDOW(window));
|
||
|
||
GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(button), "udpscope-popover"));
|
||
if (popover != nullptr) gtk_popover_popdown(popover);
|
||
}
|
||
|
||
/// One "remove from this plot" entry's payload: which row, and which trace slot -- or `kAllSlots`
|
||
/// for the row's every trace, which is what the submenu's last entry means.
|
||
|
||
/// Frees one trace slot of a row. All it takes is the free: the row is rebuilt with a series that is
|
||
/// nothing, so it draws nothing and leaves the legend.
|
||
void remove_trace(App& app, int row, int slot) {
|
||
if (row < 0 || row >= app.rows->row_count()) return;
|
||
app.rows->assign(row, slot, std::string(), std::string(), 0);
|
||
}
|
||
|
||
/// Disables every math channel that (transitively) reads `name`, and answers with their names.
|
||
///
|
||
/// A derived channel cannot be computed from a signal nobody acquires: `Source` resolves to a *channel*, and a
|
||
/// disabled signal has none -- so a channel left enabled would silently compute nothing. The rule is that the
|
||
/// channels which depend on a signal follow it off, which is also what the pane's own switch would do by hand.
|
||
/// The dependency is transitive: one math channel may name another, so a channel reading a disabled one goes
|
||
/// too, and the loop runs to a fixpoint for that.
|
||
std::vector<std::string> disable_dependent_math_channels(App& app, const std::string& name) {
|
||
std::vector<std::string> off;
|
||
if (name.empty()) return off;
|
||
const auto math_index_of = [&app](const std::string& n) -> int {
|
||
for (std::size_t k = 0; k < app.math.size(); ++k) {
|
||
if (app.math[k].name == n) return static_cast<int>(k);
|
||
}
|
||
return -1;
|
||
};
|
||
for (bool moved = true; moved;) {
|
||
moved = false;
|
||
for (MathChannel& channel : app.math) {
|
||
if (!channel.enabled) continue;
|
||
for (const MathNode& node : channel.nodes) {
|
||
if (node.op != MathOp::Source) continue;
|
||
const int k = math_index_of(node.source);
|
||
const bool reads_it = node.source == name;
|
||
const bool reads_a_disabled_one = k >= 0 && !app.math[static_cast<std::size_t>(k)].enabled;
|
||
if (!reads_it && !reads_a_disabled_one) continue;
|
||
channel.enabled = false;
|
||
off.push_back(channel.name);
|
||
moved = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
return off;
|
||
}
|
||
|
||
/// Takes a signal off every plot, and says how many traces went.
|
||
///
|
||
/// Switching a signal off means "do not acquire this", so a trace of it left on a plot draws nothing: a slot
|
||
/// that reads `--`, takes the place a live signal could have had, and blocks the drop that would fill it. The
|
||
/// slot is what is cleared, and the caller does the follow-ups once (a relayout, the panels, the layout save)
|
||
/// rather than per slot.
|
||
int remove_signal_from_plots(App& app, const std::string& name) {
|
||
if (name.empty()) return 0;
|
||
int removed = 0;
|
||
for (int row = 0; row < app.rows->row_count(); ++row) {
|
||
for (int slot = 0; slot < RowSet::kSlotsPerRow; ++slot) {
|
||
// The slot remembers the *signal* (and its element), never the channel index -- the channel list is
|
||
// renumbered on every enable change, which is what this is reacting to.
|
||
if (app.rows->row(row).signal_name[static_cast<std::size_t>(slot)] != name) continue;
|
||
remove_trace(app, row, slot);
|
||
++removed;
|
||
}
|
||
}
|
||
return removed;
|
||
}
|
||
|
||
void on_remove_clicked(GtkButton* button, gpointer user) {
|
||
(void)user;
|
||
auto* target = static_cast<RemoveTarget*>(g_object_get_data(G_OBJECT(button), "udpscope-remove"));
|
||
if (target == nullptr) return;
|
||
App& app = *target->app;
|
||
const RowSet::Row& r = app.rows->row(target->row);
|
||
|
||
std::string what;
|
||
if (target->slot == RemoveTarget::kAllSlots) {
|
||
int removed = 0;
|
||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||
if (r.channel[static_cast<std::size_t>(s)] < 0) continue;
|
||
remove_trace(app, target->row, s);
|
||
++removed;
|
||
}
|
||
what = "removed " + std::to_string(removed) + (removed == 1 ? " trace" : " traces") +
|
||
" from plot " + std::to_string(target->row + 1);
|
||
} else {
|
||
const std::string name = r.name[static_cast<std::size_t>(target->slot)];
|
||
remove_trace(app, target->row, target->slot);
|
||
what = "removed '" + name + "' from plot " + std::to_string(target->row + 1);
|
||
}
|
||
|
||
app.relayout_pending = true;
|
||
rebuild_signal_panels(app); // the signal's chip goes grey with it
|
||
app.notice = what;
|
||
app.notice_until_us = g_get_monotonic_time() + 4 * G_TIME_SPAN_SECOND;
|
||
GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(button), "udpscope-popover"));
|
||
if (popover != nullptr) gtk_popover_popdown(popover);
|
||
}
|
||
|
||
/// Which of the plot menu's two views is showing. The second **replaces** the first, with a Back
|
||
/// row at the top, rather than floating beside it: a submenu torn out of the menu it belongs to is
|
||
/// a second thing to dismiss, and the `GtkMenuButton` that owned one toggled itself out of sync the
|
||
/// moment its popover was closed by anything else -- the row stopped opening at all on the second
|
||
/// click.
|
||
/// The plot menu's own state, one per open menu: which view it shows, and the box it builds its rows
|
||
/// into. Owned by the popover (see `open_row_menu`), so a dismissed menu takes it with it.
|
||
|
||
|
||
/// A `menu_button` with a leading icon: the Back row's arrow, which every GNOME menu puts before
|
||
/// its label rather than after.
|
||
GtkWidget* menu_icon_button(const char* icon, const char* label) {
|
||
GtkWidget* button = menu_button(label, nullptr);
|
||
if (GtkWidget* box = gtk_button_get_child(GTK_BUTTON(button))) {
|
||
gtk_box_prepend(GTK_BOX(box), gtk_image_new_from_icon_name(icon));
|
||
}
|
||
return button;
|
||
}
|
||
|
||
void on_row_menu_show_remove(GtkButton*, gpointer user) {
|
||
RowMenuState& st = *static_cast<RowMenuState*>(user);
|
||
st.view = RowMenuView::Remove;
|
||
row_menu_fill(st);
|
||
// The popover's content changed size, which is what `present()` is for: it re-allocates and
|
||
// re-positions a popover that is already up.
|
||
gtk_popover_present(GTK_POPOVER(st.popover));
|
||
}
|
||
|
||
void on_row_menu_back(GtkButton*, gpointer user) {
|
||
RowMenuState& st = *static_cast<RowMenuState*>(user);
|
||
st.view = RowMenuView::Main;
|
||
row_menu_fill(st);
|
||
gtk_popover_present(GTK_POPOVER(st.popover));
|
||
}
|
||
|
||
/// Builds the rows for `st.view`. Rebuilt rather than swapped between two boxes: the rows are a
|
||
/// handful of widgets and this happens on a click, and a box held aside for later would have to be
|
||
/// kept alive through the popover's own teardown.
|
||
void row_menu_fill(RowMenuState& st) {
|
||
while (GtkWidget* child = gtk_widget_get_first_child(st.box)) {
|
||
gtk_box_remove(GTK_BOX(st.box), child);
|
||
}
|
||
App& app = *st.app;
|
||
const int row = st.row;
|
||
|
||
// Each row knows which row of the figure it acts on and which popover dismisses when it is done.
|
||
const auto add_row = [&](const char* label, const char* accel, GCallback handler,
|
||
gpointer user) {
|
||
GtkWidget* b = menu_button(label, accel);
|
||
set_row_key(b, row);
|
||
g_object_set_data(G_OBJECT(b), "udpscope-popover", st.popover);
|
||
if (handler != nullptr) g_signal_connect(b, "clicked", handler, user);
|
||
gtk_box_append(GTK_BOX(st.box), b);
|
||
return b;
|
||
};
|
||
// The heading is a row like the rest but not a button: it names what the view acts on.
|
||
const auto add_heading = [&](const char* text) {
|
||
GtkWidget* heading = gtk_label_new(nullptr);
|
||
gtk_label_set_markup(GTK_LABEL(heading), text);
|
||
gtk_label_set_xalign(GTK_LABEL(heading), 0.0f);
|
||
gtk_widget_set_margin_start(heading, 8);
|
||
gtk_widget_set_margin_bottom(heading, 4);
|
||
gtk_box_append(GTK_BOX(st.box), heading);
|
||
};
|
||
|
||
if (st.view == RowMenuView::Main) {
|
||
char markup[64];
|
||
std::snprintf(markup, sizeof(markup), "<b>Plot %d</b>", row + 1);
|
||
add_heading(markup);
|
||
add_row("Y axis: auto scale", nullptr, G_CALLBACK(on_y_auto_clicked), &app);
|
||
add_row("Y axis: fixed range...", nullptr, G_CALLBACK(on_y_fixed_clicked), &app);
|
||
// Fitting is a per-plot gesture in the menu as it is on the screen: the two do the same thing
|
||
// whatever row they were opened on (the x window is shared and the y fit walks them all), which
|
||
// is what "fit" means here. `F` is the plot's own key for the second of them.
|
||
add_row("Fit X axis", nullptr, G_CALLBACK(on_fit_x_clicked), &app);
|
||
add_row("Fit all", "F", G_CALLBACK(on_fit_clicked), &app);
|
||
|
||
gtk_box_append(GTK_BOX(st.box), menu_separator());
|
||
// A row, not a `GtkMenuButton`: the removals are the *next view of this menu*, and the chevron
|
||
// is what says so.
|
||
GtkWidget* remove_row = add_row("Remove signal", nullptr, G_CALLBACK(on_row_menu_show_remove),
|
||
&st);
|
||
if (GtkWidget* inner = gtk_button_get_child(GTK_BUTTON(remove_row))) {
|
||
gtk_box_append(GTK_BOX(inner), gtk_image_new_from_icon_name("pan-end-symbolic"));
|
||
}
|
||
return;
|
||
}
|
||
|
||
// The way out, with the arrow every menu puts before a Back row's label.
|
||
GtkWidget* back = menu_icon_button("go-previous-symbolic", "Back");
|
||
set_row_key(back, row);
|
||
g_object_set_data(G_OBJECT(back), "udpscope-popover", st.popover);
|
||
g_signal_connect(back, "clicked", G_CALLBACK(on_row_menu_back), &st);
|
||
gtk_box_append(GTK_BOX(st.box), back);
|
||
gtk_box_append(GTK_BOX(st.box), menu_separator());
|
||
|
||
const RowSet::Row& r = app.rows->row(row);
|
||
int traces = 0;
|
||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||
if (r.channel[static_cast<std::size_t>(s)] < 0) continue;
|
||
++traces;
|
||
GtkWidget* b = add_row(r.name[static_cast<std::size_t>(s)].c_str(), nullptr,
|
||
G_CALLBACK(on_remove_clicked), nullptr);
|
||
auto* target = new RemoveTarget{&app, row, s};
|
||
g_object_set_data_full(G_OBJECT(b), "udpscope-remove", target,
|
||
[](gpointer p) { delete static_cast<RemoveTarget*>(p); });
|
||
}
|
||
if (traces > 1) {
|
||
GtkWidget* all = add_row("All traces on this plot", nullptr, G_CALLBACK(on_remove_clicked),
|
||
nullptr);
|
||
auto* target = new RemoveTarget{&app, row, RemoveTarget::kAllSlots};
|
||
g_object_set_data_full(G_OBJECT(all), "udpscope-remove", target,
|
||
[](gpointer p) { delete static_cast<RemoveTarget*>(p); });
|
||
}
|
||
if (traces == 0) {
|
||
GtkWidget* empty = gtk_label_new("nothing to remove");
|
||
gtk_widget_add_css_class(empty, "dim-label");
|
||
gtk_label_set_xalign(GTK_LABEL(empty), 0.0f);
|
||
gtk_widget_set_margin_start(empty, 8);
|
||
gtk_box_append(GTK_BOX(st.box), empty);
|
||
}
|
||
}
|
||
|
||
/// The one entry point for the plot's context menu, so the probe can build it without a gesture:
|
||
/// `on_right_click()` is the gesture's wrapper around this, and nothing else. Returns the popover,
|
||
/// which is what the probe measures.
|
||
GtkWidget* open_row_menu(App& app, int row, double x, double y, GtkWidget* parent, bool autohide) {
|
||
GtkWidget* popover = gtk_popover_new();
|
||
gtk_widget_add_css_class(popover, "udpscope-menu");
|
||
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||
gtk_widget_set_margin_top(box, 6);
|
||
gtk_widget_set_margin_bottom(box, 6);
|
||
gtk_widget_set_margin_start(box, 6);
|
||
gtk_widget_set_margin_end(box, 6);
|
||
|
||
auto* st = new RowMenuState;
|
||
st->app = &app;
|
||
st->row = row;
|
||
st->popover = popover;
|
||
st->box = box;
|
||
// Owned by the popover: a dismissed menu frees its state with it, and the `closed` handler that
|
||
// unparents the popover is what does the dismissing.
|
||
g_object_set_data_full(G_OBJECT(popover), "udpscope-row-menu", st,
|
||
[](gpointer p) { delete static_cast<RowMenuState*>(p); });
|
||
row_menu_fill(*st);
|
||
|
||
// Autohide off *for the probe*: a popover dismisses itself when it loses the grab, and under a
|
||
// bare `xvfb-run` there is no window manager to hand it one -- so the rows would be measured
|
||
// before they were ever mapped. The app's own menu keeps autohide on.
|
||
gtk_popover_set_autohide(GTK_POPOVER(popover), autohide ? TRUE : FALSE);
|
||
gtk_popover_set_child(GTK_POPOVER(popover), box);
|
||
GdkRectangle rect{static_cast<int>(x), static_cast<int>(y), 1, 1};
|
||
gtk_popover_set_pointing_to(GTK_POPOVER(popover), &rect);
|
||
gtk_widget_set_parent(popover, parent);
|
||
gtk_popover_popup(GTK_POPOVER(popover));
|
||
// NOT g_signal_connect_swapped: that hands the callback its user_data as the first argument, so
|
||
// `on_popover_closed` would receive nullptr instead of the popover and both the unparent and the
|
||
// unref would fail with a Gtk-CRITICAL.
|
||
g_signal_connect(popover, "closed", G_CALLBACK(on_popover_closed), nullptr);
|
||
return popover;
|
||
}
|
||
|
||
void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer user) {
|
||
App* app = static_cast<App*>(user);
|
||
GtkWidget* widget = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(gesture));
|
||
const int row = row_at(*app, x, y);
|
||
// Claim the sequence: without this the button *release* arrives while the popover is up, counts
|
||
// as a click outside it, and dismisses the menu before it can be read -- the menu flashes and
|
||
// vanishes.
|
||
gtk_gesture_set_state(GTK_GESTURE(gesture), GTK_EVENT_SEQUENCE_CLAIMED);
|
||
cancel_drag_on(*app, row);
|
||
open_row_menu(*app, row, x, y, widget);
|
||
}
|
||
|
||
void on_side_tab_changed(GObject* o, GParamSpec*, gpointer user) {
|
||
App* app = static_cast<App*>(user);
|
||
const char* name = adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(o));
|
||
app->side_tab = name != nullptr ? name : "signals";
|
||
// Not while the layout is being applied: the restore would schedule a save of what it just read.
|
||
if (!app->updating_panels) schedule_layout_save(*app);
|
||
}
|
||
|
||
void select_side_tab(App& app, const char* name) {
|
||
if (app.side_stack == nullptr || name == nullptr) return;
|
||
// An unknown name leaves the stack where it is -- a session file from a later version must not
|
||
// be able to blank the sidebar.
|
||
if (adw_view_stack_get_child_by_name(ADW_VIEW_STACK(app.side_stack), name) == nullptr) return;
|
||
adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(app.side_stack), name);
|
||
app.side_tab = name;
|
||
}
|
||
|
||
/// The side column is on when it has something to show *and* the user's sidebar
|
||
/// button is in; the two panel buttons appear as soon as a CONFIG describes any
|
||
/// signal at all.
|
||
void update_side_visibility(App& app) {
|
||
if (app.side_stack == nullptr) return;
|
||
const bool have_signals = !app.model->signals().empty();
|
||
// `have_signals` decides whether the sidebar *can* be on; `sidebar_wanted` is the user's last word,
|
||
// which is why it is kept apart from the button's own state -- that state follows the panel now, and
|
||
// the panel is off while there is nothing to show. A swipe that hides the sidebar fires the button's
|
||
// handler (the binding is bidirectional), so a gesture is a choice too.
|
||
gtk_widget_set_sensitive(app.sidebar_toggle, have_signals);
|
||
const bool want_column = have_signals && app.sidebar_wanted;
|
||
// The split view owns the sidebar's visibility: it animates it, and when the window is too narrow it
|
||
// shows it as an overlay rather than letting the column be squeezed to nothing.
|
||
if (app.side_view != nullptr) {
|
||
app.sidebar_syncing = true;
|
||
adw_overlay_split_view_set_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(app.side_view), want_column);
|
||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.sidebar_toggle), want_column);
|
||
app.sidebar_syncing = false;
|
||
}
|
||
gtk_widget_set_visible(app.trigger_toggle, have_signals);
|
||
}
|
||
|
||
void on_sidebar_toggled(GtkToggleButton* button, gpointer user) {
|
||
App* app = static_cast<App*>(user);
|
||
// Only a state we did not write is a wish: `update_side_visibility` sets this button (and the panel)
|
||
// from `sidebar_wanted`, so an unguarded change here is the user's.
|
||
if (!app->sidebar_syncing) app->sidebar_wanted = gtk_toggle_button_get_active(button) != FALSE;
|
||
update_side_visibility(*app);
|
||
}
|
||
|
||
|
||
// --- rebuild of the derived panels -----------------------------------------
|
||
|
||
|
||
/// The width and height the root `<svg>` element of `data` declares, or 0x0 when it has none.
|
||
/// GMarkup rather than a string scan because the attributes may be in any order and on any
|
||
/// line; it is *not* a validity check -- GMarkup is a deliberately lenient subset parser and
|
||
/// accepts a double hyphen inside a comment, which is exactly the file librsvg refuses. That
|
||
/// check is `xml-stripblanks` in the GResource manifest, at build time.
|
||
void svg_declared_size(const char* data, gsize size, int* w, int* h) {
|
||
struct Found { int w = 0; int h = 0; };
|
||
Found found;
|
||
GMarkupParser parser{};
|
||
parser.start_element = [](GMarkupParseContext*, const char* element, const char** names,
|
||
const char** values, gpointer user, GError**) {
|
||
if (std::strcmp(element, "svg") != 0) return;
|
||
Found* f = static_cast<Found*>(user);
|
||
for (int i = 0; names[i] != nullptr; ++i) {
|
||
if (std::strcmp(names[i], "width") == 0) f->w = std::atoi(values[i]);
|
||
if (std::strcmp(names[i], "height") == 0) f->h = std::atoi(values[i]);
|
||
}
|
||
};
|
||
GMarkupParseContext* ctx = g_markup_parse_context_new(&parser, GMarkupParseFlags(0), &found,
|
||
nullptr);
|
||
if (g_markup_parse_context_parse(ctx, data, static_cast<gssize>(size), nullptr)) {
|
||
g_markup_parse_context_end_parse(ctx, nullptr);
|
||
}
|
||
g_markup_parse_context_free(ctx);
|
||
*w = found.w;
|
||
*h = found.h;
|
||
}
|
||
|
||
/// One entry of the `app icons:` line: the icon is *named* (the theme resolved it, so the
|
||
/// resource path was registered) and *present* (the bytes the name resolved to are really in
|
||
/// the binary), at the size its own root element declares.
|
||
///
|
||
/// It deliberately does not claim the file renders. `gtk_icon_theme_lookup_icon()` never
|
||
/// returns NULL, its paintable's intrinsic size is the size that was asked for, and a valid
|
||
/// icon, a malformed one and a name that does not exist at all all snapshot to the same
|
||
/// texture node -- GTK substitutes `image-missing` and there is nothing left to compare. The
|
||
/// malformed case is a build error instead; see the GResource manifest.
|
||
std::string icon_report(GtkIconTheme* theme, const char* name, const char* resource) {
|
||
const bool named = gtk_icon_theme_has_icon(theme, name) != FALSE;
|
||
GBytes* bytes = g_resources_lookup_data(resource, G_RESOURCE_LOOKUP_FLAGS_NONE, nullptr);
|
||
const bool present = bytes != nullptr;
|
||
int w = 0;
|
||
int h = 0;
|
||
if (present) {
|
||
gsize size = 0;
|
||
const char* data = static_cast<const char*>(g_bytes_get_data(bytes, &size));
|
||
svg_declared_size(data, size, &w, &h);
|
||
g_bytes_unref(bytes);
|
||
}
|
||
std::string out = std::string(name) + "(";
|
||
out += (named && present) ? "ok " : "MISSING ";
|
||
out += std::to_string(w) + "x" + std::to_string(h) + ")";
|
||
return out;
|
||
}
|
||
|
||
/// Where the panel controls live, as their parents say: the three pane toggles are toolbar buttons
|
||
/// and Reset is inside the cursors pane. Recorded on the first tick -- the widgets exist and the
|
||
/// window is alive, neither of which is true when the dump runs -- so the dump can print a fact
|
||
/// instead of asking GTK about a destroyed widget.
|
||
/// The words the pills do not draw. The pages' `title` is empty on purpose (see the `.ui`), so the
|
||
/// human name of a tab lives here and is put on the pill — its tooltip and its accessible name — by
|
||
/// `name_side_tabs()`. That keeps an icon-only pill a *named* control for a screen reader.
|
||
static const char* side_tab_title(const char* name) {
|
||
if (name != nullptr && std::strcmp(name, "measure") == 0) return "Measure";
|
||
if (name != nullptr && std::strcmp(name, "signals") == 0) return "Signals";
|
||
return "";
|
||
}
|
||
|
||
/// Names the switcher's pills and takes their empty labels out of the layout: an empty `GtkLabel`
|
||
/// still asks for its own width, which leaves the icon off-centre in a pill meant to be one glyph.
|
||
/// Called from the first tick, because the pills are `AdwViewSwitcher`'s own and exist only once the
|
||
/// widget has been mapped.
|
||
void name_side_tabs(App& app) {
|
||
if (app.side_tabs_named || app.side_switcher == nullptr) return;
|
||
GListModel* pages = app.side_stack != nullptr
|
||
? G_LIST_MODEL(adw_view_stack_get_pages(ADW_VIEW_STACK(app.side_stack)))
|
||
: nullptr;
|
||
// The switcher's children *are* its pills, in page order, and `AdwViewSwitcherButton` is not a
|
||
// public type (there is no `ADW_IS_VIEW_SWITCHER_BUTTON`), so the two lists are walked together by
|
||
// index rather than checked by type.
|
||
GtkWidget* pill = gtk_widget_get_first_child(app.side_switcher);
|
||
for (guint i = 0; pages != nullptr && pill != nullptr && i < g_list_model_get_n_items(pages);
|
||
pill = gtk_widget_get_next_sibling(pill), ++i) {
|
||
AdwViewStackPage* page = ADW_VIEW_STACK_PAGE(g_list_model_get_item(pages, i));
|
||
const char* title = side_tab_title(adw_view_stack_page_get_name(page));
|
||
g_object_unref(page);
|
||
if (title[0] == '\0') continue;
|
||
gtk_widget_set_tooltip_text(pill, title);
|
||
gtk_accessible_update_property(GTK_ACCESSIBLE(pill), GTK_ACCESSIBLE_PROPERTY_LABEL, title, -1);
|
||
const std::function<void(GtkWidget*)> hide_labels = [&](GtkWidget* w) {
|
||
if (GTK_IS_LABEL(w)) gtk_widget_set_visible(w, FALSE);
|
||
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
|
||
c = gtk_widget_get_next_sibling(c)) {
|
||
hide_labels(c);
|
||
}
|
||
};
|
||
hide_labels(pill);
|
||
// The tabs' glyphs are 32x16 documents, and the pill has the width for them: drawn at 32 they are
|
||
// 1:1, so both motifs sit side by side at the size they were authored at and the 2px bars stay
|
||
// crisp. A 16px box would scale the pair down to a smear, and a fractional scale would blur it.
|
||
const std::function<void(GtkWidget*)> size_images = [&](GtkWidget* w) {
|
||
if (GTK_IS_IMAGE(w)) gtk_image_set_pixel_size(GTK_IMAGE(w), 32);
|
||
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
|
||
c = gtk_widget_get_next_sibling(c)) {
|
||
size_images(c);
|
||
}
|
||
};
|
||
size_images(pill);
|
||
app.side_tabs_named = true;
|
||
}
|
||
}
|
||
|
||
void note_panel_controls(App& app) {
|
||
if (!app.dbg_panel_controls.empty()) return;
|
||
const auto in_bar = [](GtkWidget* w) {
|
||
for (GtkWidget* p = w != nullptr ? gtk_widget_get_parent(w) : nullptr; p != nullptr;
|
||
p = gtk_widget_get_parent(p)) {
|
||
if (GTK_IS_HEADER_BAR(p)) return 1;
|
||
}
|
||
return 0;
|
||
};
|
||
const auto in_cursors_pane = [&app](GtkWidget* w) {
|
||
for (GtkWidget* p = w != nullptr ? gtk_widget_get_parent(w) : nullptr; p != nullptr;
|
||
p = gtk_widget_get_parent(p)) {
|
||
if (p == app.cursors_panel) return 1;
|
||
}
|
||
return 0;
|
||
};
|
||
// `HeaderBar::pack_end()` parents into an internal box, so the bar is a level up, not the parent.
|
||
char text[256];
|
||
std::snprintf(text, sizeof(text),
|
||
"cursors=%d in the toolbar, switcher=%d, reset=%d in the cursors pane",
|
||
in_bar(app.cursors_toggle), app.side_switcher != nullptr ? 1 : 0,
|
||
in_cursors_pane(app.cursors_reset));
|
||
// ... and the order the bar actually shows, which is what "the menu should be last" is about. A
|
||
// widget tree walked in child order *is* the layout order, so this is the reading order of the
|
||
// controls the user sees -- the only way to check an arrangement without a screenshot.
|
||
const std::pair<GtkWidget*, const char*> named[] = {
|
||
{app.trigger_toggle, "trigger"}, {app.events_toggle, "events"},
|
||
{app.pause_button, "pause"},
|
||
{app.cursors_toggle, "cursors"}, {app.sidebar_toggle, "sidebar"},
|
||
{app.menu_button, "menu"},
|
||
};
|
||
// The widget tree's child order, which *is* the visual order left to right: a box lays its children
|
||
// out in the order it holds them. Since `pack_end()` puts the *first* packed furthest right, this
|
||
// reads as the reverse of the pack order -- which is the whole reason the line exists, because the
|
||
// pack order alone is not what the user sees.
|
||
std::string order;
|
||
const std::function<void(GtkWidget*)> walk = [&](GtkWidget* w) {
|
||
for (const auto& [widget, name] : named) {
|
||
if (w == widget) order += std::string(order.empty() ? "" : " ") + name;
|
||
}
|
||
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
|
||
c = gtk_widget_get_next_sibling(c)) {
|
||
walk(c);
|
||
}
|
||
};
|
||
if (app.toolbar != nullptr) walk(app.toolbar);
|
||
app.dbg_panel_controls = std::string(text) + "; toolbar, left to right: " + order;
|
||
|
||
// ... and the icon each of them carries, asked of the theme rather than of our own
|
||
// string. Four of these are drawn for this app and live in a GResource inside the
|
||
// binary, which the theme only looks in because `run_ui()` adds the resource path
|
||
// before building anything -- and a path that was never added fails *silently*: the
|
||
// button keeps working, the layout is unchanged, and GTK draws a missing-image
|
||
// placeholder no probe can see. The two app icons below come out of the same
|
||
// registration, which is why a MISSING on either line is one defect.
|
||
GtkIconTheme* theme = gtk_icon_theme_get_for_display(gdk_display_get_default());
|
||
for (const auto& [widget, name] : named) {
|
||
const char* icon = nullptr;
|
||
if (GTK_IS_BUTTON(widget)) {
|
||
icon = gtk_button_get_icon_name(GTK_BUTTON(widget));
|
||
} else if (GTK_IS_MENU_BUTTON(widget)) {
|
||
icon = gtk_menu_button_get_icon_name(GTK_MENU_BUTTON(widget));
|
||
}
|
||
if (icon == nullptr) continue;
|
||
app.dbg_toolbar_icons += std::string(app.dbg_toolbar_icons.empty() ? "" : " ") + name + "=" +
|
||
icon + (gtk_icon_theme_has_icon(theme, icon) ? "(ok)" : "(MISSING)");
|
||
}
|
||
// The application icon, the same two ways and for the same reason: a resource path that was
|
||
// never registered is not an error, it is a missing-image placeholder. This one is read on
|
||
// the first tick like the line above, because the dump runs after the window is destroyed.
|
||
app.dbg_app_icons =
|
||
icon_report(theme, "org.udpscope.app",
|
||
"/org/udpscope/icons/scalable/apps/org.udpscope.app.svg") +
|
||
" " +
|
||
icon_report(theme, "org.udpscope.app-symbolic",
|
||
"/org/udpscope/icons/scalable/apps/org.udpscope.app-symbolic.svg");
|
||
|
||
// The two sidebar pages' own icons, named and resolved the same way, and what the switcher is
|
||
// drawing for them. The pills are `AdwViewSwitcher`'s own buttons, built when the stack is mapped,
|
||
// so this reads on the first tick: `pills` is the guard (0 pills would make a label count of 0
|
||
// meaningless) and `labels` must be 0, because the switcher's policy is `narrow` -- the pages are
|
||
// icons, not words, in a 300px column.
|
||
// Walked through `get_pages()`, not the widget tree: an `AdwViewStackPage` is a plain `GObject`
|
||
// (only its `child` is a widget), so `gtk_widget_get_first_child()` on the stack yields the pages'
|
||
// scrolled windows and this line read empty the first time it was written.
|
||
GListModel* pages = app.side_stack != nullptr
|
||
? G_LIST_MODEL(adw_view_stack_get_pages(ADW_VIEW_STACK(app.side_stack)))
|
||
: nullptr;
|
||
for (guint i = 0; pages != nullptr && i < g_list_model_get_n_items(pages); ++i) {
|
||
AdwViewStackPage* page = ADW_VIEW_STACK_PAGE(g_list_model_get_item(pages, i));
|
||
const char* page_name = adw_view_stack_page_get_name(page);
|
||
const char* icon = adw_view_stack_page_get_icon_name(page);
|
||
app.dbg_page_icons += std::string(app.dbg_page_icons.empty() ? "" : " ") +
|
||
(page_name != nullptr ? page_name : "?") + "=" +
|
||
(icon != nullptr ? icon : "-") +
|
||
(icon != nullptr && gtk_icon_theme_has_icon(theme, icon) ? "(ok)"
|
||
: "(MISSING)");
|
||
g_object_unref(page);
|
||
}
|
||
// `mapped`, not `visible`: visibility is per widget, so a label whose *parent* is hidden still
|
||
// reads visible, and in the narrow policy that is exactly the case. `mapped` requires every
|
||
// ancestor, which is what "on screen" means -- and it is why the pill count is read the same way:
|
||
// if nothing is mapped the pill count reads 0 and the label count means nothing, rather than
|
||
// passing vacuously.
|
||
int pills = 0;
|
||
int drawn_labels = 0;
|
||
const std::function<void(GtkWidget*)> count_drawn = [&](GtkWidget* w) {
|
||
if (GTK_IS_TOGGLE_BUTTON(w) && gtk_widget_get_mapped(w)) ++pills;
|
||
// A *mapped* label with text: libadwaita's narrow pill keeps an empty label in the layout, so
|
||
// "mapped" alone would count the empty ones and read 2 whether the pills were icons or words.
|
||
if (GTK_IS_LABEL(w) && gtk_widget_get_mapped(w) && gtk_label_get_text(GTK_LABEL(w))[0] != '\0') {
|
||
++drawn_labels;
|
||
}
|
||
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
|
||
c = gtk_widget_get_next_sibling(c)) {
|
||
count_drawn(c);
|
||
}
|
||
};
|
||
if (app.side_switcher != nullptr) count_drawn(app.side_switcher);
|
||
app.dbg_switcher_pills = pills;
|
||
app.dbg_switcher_labels = drawn_labels;
|
||
int named_pills = 0;
|
||
for (GtkWidget* w = app.side_switcher != nullptr ? gtk_widget_get_first_child(app.side_switcher)
|
||
: nullptr;
|
||
w != nullptr; w = gtk_widget_get_next_sibling(w)) {
|
||
const char* tip = gtk_widget_get_tooltip_text(w);
|
||
if (tip != nullptr && tip[0] != '\0') ++named_pills;
|
||
}
|
||
app.dbg_switcher_names = named_pills;
|
||
if (app.side_switcher != nullptr) {
|
||
g_object_get(G_OBJECT(app.side_switcher), "policy", &app.dbg_switcher_policy, nullptr);
|
||
}
|
||
}
|
||
|
||
/// The event-zoom sweep's two halves: a gesture zoom, then the same walk through the window
|
||
/// control. The second is the tail of the countdown.
|
||
|
||
/// Headless self-check: what `Renderer::draw_traces` would gather for row 0's first bound
|
||
/// trace at the axis the frame settled on, recomputed through the same calls it makes.
|
||
///
|
||
/// The report this exists for is "zooming into a held event clips the trace further and
|
||
/// further from the left, while the crosshair still reads the right values there". The
|
||
/// crosshair reads `x_at()`, so a symptom that only the *ink* has localises to the index
|
||
/// window and the gather -- and a blank margin that grows as a fraction of the screen while
|
||
/// the view narrows is the signature of a roughly constant shift in *data* space. So the
|
||
/// line prints both sides of the comparison in data units and in pixels:
|
||
/// `axis=` is what the frame drew, `ink=` is the first and last gathered x reconstructed the
|
||
/// renderer's way (`x0() + gather`), and `gap_px=` is how many pixels of the left margin no
|
||
/// geometry reaches. `gap_px` must stay at a pixel or two at every zoom.
|
||
void report_event_zoom(App& a, const char* what) {
|
||
if (a.rows->row_count() == 0 || a.rows->row(0).plot == nullptr) return;
|
||
const RowSet::Row& r0 = a.rows->row(0);
|
||
const plot::data::Series* s = nullptr;
|
||
for (int i = 0; i < RowSet::kSlotsPerRow; ++i) {
|
||
const std::size_t si = static_cast<std::size_t>(i);
|
||
if (r0.channel[si] < 0) continue;
|
||
s = &r0.slots[si];
|
||
break;
|
||
}
|
||
const plot::Range axis = r0.plot->x_range(r0.axis_x);
|
||
const plot::Rect axes = r0.plot->layout().axes;
|
||
const double axes_w = double(axes.w);
|
||
if (s == nullptr || s->size() == 0 || !(axes_w > 0.0)) {
|
||
std::fprintf(stderr, "[zoom-probe] %-8s axis=[%.6f..%.6f] no trace\n", what, double(axis.min),
|
||
double(axis.max));
|
||
std::fflush(stderr);
|
||
return;
|
||
}
|
||
const plot::u64 n = s->size();
|
||
plot::u64 i0 = 0, i1 = 0;
|
||
plot::u64 first = 0;
|
||
plot::u64 last = n;
|
||
if (s->neighbours(axis.min, i0, i1)) first = i0 > 0 ? i0 - 1 : 0;
|
||
if (s->neighbours(axis.max, i0, i1)) last = std::min(n, i1 + 1);
|
||
if (last <= first) {
|
||
std::fprintf(stderr, "[zoom-probe] %-8s axis=[%.6f..%.6f] empty window [%llu..%llu)\n", what,
|
||
double(axis.min), double(axis.max), (unsigned long long)first,
|
||
(unsigned long long)last);
|
||
std::fflush(stderr);
|
||
return;
|
||
}
|
||
// `dense_threshold_spp` is the library's own default (64): above it the renderer takes a
|
||
// pyramid level, below it raw samples, and which side of the gate a step lands on is part
|
||
// of what this line reports.
|
||
const double spp = double(last - first) / axes_w;
|
||
const plot::u32 level = spp >= 64.0 ? s->pyramid().level_for(n, spp) : 0u;
|
||
std::vector<plot::f32> xy;
|
||
// `axis.min` is the origin the renderer names, so every reconstruction below is its own --
|
||
// and at the deep end of this sweep that choice is the whole point: relative to the retained
|
||
// window instead, the f32's ULP would be a large fraction of these spans.
|
||
s->gather(level, first, last, axis.min, xy);
|
||
double ink_lo = 0.0, ink_hi = 0.0;
|
||
if (xy.size() >= 2) {
|
||
ink_lo = axis.min + double(xy[0]);
|
||
ink_hi = axis.min + double(xy[xy.size() - 2]);
|
||
}
|
||
// The renderer's own increasing-suffix filter, counted rather than applied: it drops every
|
||
// point that is not strictly older than the one kept after it, which for a *run* of equal x
|
||
// keeps the last of the run. The stamps here are f32, so once the sample spacing falls below
|
||
// the f32 step at the *gather origin's* magnitude the gathered x has such runs -- and `kept`
|
||
// is how many distinct positions the polyline is left with. A `kept` far below `pts` is a
|
||
// staircase, and a `kept` of a handful is a trace with nothing to draw. This is the other
|
||
// reading the origin change buys: the step is now 2^-24 of the span on screen, so a sweep
|
||
// that used to grind `kept` down as it zoomed keeps every point.
|
||
std::size_t kept = 0;
|
||
double worst_back = 0.0; // the largest backwards step, in data units: non-monotonic gather
|
||
{
|
||
double next = std::numeric_limits<double>::infinity();
|
||
for (std::size_t k = xy.size() / 2; k > 0; --k) {
|
||
const double x = double(xy[2 * (k - 1)]) + axis.min;
|
||
if (!(x < next)) {
|
||
worst_back = std::max(worst_back, x - next);
|
||
continue;
|
||
}
|
||
next = x;
|
||
++kept;
|
||
}
|
||
}
|
||
const double span = axis.span() > 0.0 ? axis.span() : 1.0;
|
||
std::fprintf(stderr,
|
||
"[zoom-probe] %-8s axis=[%.6f..%.6f] span=%.6g n=%llu idx=[%llu..%llu) spp=%.1f "
|
||
"level=%u pts=%zu kept=%zu back=%.3g ink=[%.6f..%.6f] x_at_first=%.6f "
|
||
"gap_px=%.1f end_px=%.1f rect=[%g,%g,%gx%g]\n",
|
||
what, double(axis.min), double(axis.max), axis.span(), (unsigned long long)n,
|
||
(unsigned long long)first, (unsigned long long)last, spp, level, xy.size() / 2, kept,
|
||
worst_back, ink_lo, ink_hi, s->x_at(first),
|
||
(ink_lo - axis.min) / span * axes_w, (axis.max - ink_hi) / span * axes_w,
|
||
double(axes.x), double(axes.y), double(axes.w), double(axes.h));
|
||
std::fflush(stderr);
|
||
}
|
||
|
||
|
||
/// The reserved file's size for a ring of `slots`: the 64-byte header plus one slot per
|
||
/// datagram. A constant for a given N, which is why the popover and the tooltip can state
|
||
/// it without touching the file.
|
||
std::uint64_t capture_file_bytes(std::uint32_t slots) {
|
||
return 64 + std::uint64_t(slots) * kSlotBytes;
|
||
}
|
||
|
||
/// `$HOME` -> `~`, purely so the popover row and the tooltip stay readable.
|
||
std::string abbrev_home(const std::string& path) {
|
||
const std::string home = g_get_home_dir();
|
||
if (!home.empty() && path.rfind(home, 0) == 0) return "~" + path.substr(home.size());
|
||
return path;
|
||
}
|
||
|
||
/// The toggle's tooltip: what it does, where the ring lands, what it costs.
|
||
void update_capture_tooltip(App& app) {
|
||
if (app.capture_button == nullptr) return;
|
||
const std::uint32_t slots = app.acq != nullptr && app.acq->capture().slot_count() > 0
|
||
? app.acq->capture().slot_count()
|
||
: 1000;
|
||
char tip[512];
|
||
std::snprintf(tip, sizeof(tip),
|
||
"Store the last %u raw datagrams on disk for forensic analysis\n"
|
||
"%s (%.1f MiB reserved) -- off unless toggled, and never in the way of a packet",
|
||
slots, abbrev_home(app.acq->capture().path()).c_str(),
|
||
double(capture_file_bytes(slots)) / 1048576.0);
|
||
gtk_widget_set_tooltip_text(app.capture_button, tip);
|
||
}
|
||
|
||
/// The capture row's three faces. The path is abbreviated with `~` for `$HOME` -- the
|
||
/// row's job is to be recognisable, not to be copied -- and the file's size is stated
|
||
/// in slots and MiB, because the reserve is the one cost of switching the feature on.
|
||
void update_capture_row(App& app) {
|
||
if (app.popover_label[12] == nullptr) return;
|
||
const PacketCapture& cap = app.acq->capture();
|
||
char err[128];
|
||
cap.error(err, sizeof(err));
|
||
std::string text = "off";
|
||
if (err[0] != '\0') {
|
||
text = std::string("failed: ") + err;
|
||
} else if (cap.enabled()) {
|
||
char row[512];
|
||
std::snprintf(row, sizeof(row), "on · %llu/%u slots · %.1f MiB · %s",
|
||
static_cast<unsigned long long>(cap.stored()), cap.slot_count(),
|
||
double(capture_file_bytes(cap.slot_count())) / 1048576.0,
|
||
abbrev_home(cap.path()).c_str());
|
||
text = row;
|
||
} else if (app.capture_want_ui) {
|
||
text = "opening...";
|
||
}
|
||
gtk_label_set_text(GTK_LABEL(app.popover_label[12]), text.c_str());
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
/// Loads an event's preview into the pane's picture.
|
||
///
|
||
/// Decoded *here*, synchronously, rather than through `gtk_picture_set_filename()`: that path goes
|
||
/// through the image loader's sandboxed, asynchronous decoder, which on this machine fails silently
|
||
/// and leaves an allocated but empty picture -- a picture the probe sees a paint-able on and a user
|
||
/// sees nothing in. `gdk_texture_new_from_filename()` decodes the same file on this thread.
|
||
/// One event file's columns and stamps, in memory. Both group layouts need two or more of them at
|
||
/// once, and the ordinary loader cannot serve that: it *rebuilds* the model's buffers, so a second
|
||
/// load takes the first with it.
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
/// Decoded event previews, keyed by file. The map belongs to the list widget, so it is released with
|
||
/// it, and every texture in it is a reference this file owns.
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
// --- the frame tick ---------------------------------------------------------
|
||
|
||
gboolean tick(gpointer user) {
|
||
App* app = static_cast<App*>(user);
|
||
if (app->window == nullptr) return G_SOURCE_REMOVE;
|
||
// Before the reading, and not after it: the pills are `AdwViewSwitcher`'s own widgets and exist
|
||
// only once the switcher is mapped, so this is the first frame that can name them -- and their
|
||
// page titles are deliberately empty, so a frame that read before this ran would read pills with
|
||
// no text and no name either.
|
||
name_side_tabs(*app);
|
||
note_panel_controls(*app);
|
||
ScopeModel& model = *app->model;
|
||
const gint64 tick_start_us = g_get_monotonic_time();
|
||
|
||
// The paint probe's clock, claimed on the first tick that finds one: a widget has no
|
||
// frame clock until it is realized, and the area is realized on its first draw.
|
||
if (app->dbg_clock == nullptr && app->area_widget != nullptr) {
|
||
if (GdkFrameClock* fc = gtk_widget_get_frame_clock(app->area_widget)) {
|
||
app->dbg_clock = fc;
|
||
g_signal_connect(fc, "before-paint", G_CALLBACK(on_before_paint), app);
|
||
g_signal_connect(fc, "after-paint", G_CALLBACK(on_after_paint), app);
|
||
}
|
||
}
|
||
|
||
// The trace look, stated on the first tick that finds a renderer -- there is none until
|
||
// the area is realized, and this is the same "claim it when it exists" shape as the frame
|
||
// clock above. `intensity_grading` is off in the library by default (it is a change of
|
||
// look, not a correction, and other hosts must not be restyled by an upgrade); an
|
||
// oscilloscope is exactly the host it is for, so udpscope turns it on. A one-shot rather
|
||
// than a per-frame write because `Style` is plain data and nothing else touches it.
|
||
if (!app->style_stated && app->area != nullptr) {
|
||
if (plot::Renderer* r = app->area->renderer()) {
|
||
// `UDPSCOPE_NO_PHOSPHOR` puts it back to the library's default, which is the only way
|
||
// to read `frame cost:` and `frame path:` against the ungraded path -- a frame mean
|
||
// cannot attribute its own cost, so the A/B is the measurement.
|
||
r->style().intensity_grading = g_getenv("UDPSCOPE_NO_PHOSPHOR") == nullptr;
|
||
app->style_stated = true;
|
||
}
|
||
}
|
||
|
||
if (app->relayout_pending) {
|
||
app->relayout_pending = false;
|
||
relayout_areas(*app);
|
||
}
|
||
// Header geometry, once a second: the window control must be a fixed, small
|
||
// widget beside the title whatever the bar's width is (it used to expand through
|
||
// the title's centre box, i.e. window_button_w tracked the window width).
|
||
if (app->window_button != nullptr) {
|
||
static gint64 next_header_log = 0;
|
||
const gint64 now = g_get_monotonic_time();
|
||
if (now >= next_header_log) {
|
||
next_header_log = now + G_TIME_SPAN_SECOND;
|
||
app->dbg_win_btn_w = gtk_widget_get_width(app->window_button);
|
||
app->dbg_bar_w = gtk_widget_get_width(app->window);
|
||
std::fprintf(stderr, "[udpscope] header: window_button_w=%d bar_w=%d\n",
|
||
app->dbg_win_btn_w, app->dbg_bar_w);
|
||
std::fflush(stderr);
|
||
}
|
||
}
|
||
// Captured every frame so the dump has the last live value: see `dbg_measures_pane_w`.
|
||
if (app->measures_list != nullptr) {
|
||
app->dbg_measures_pane_w = gtk_widget_get_width(app->measures_list);
|
||
app->dbg_measures_pane_h = gtk_widget_get_height(app->measures_list);
|
||
}
|
||
// A few milliseconds of scanning per frame: enough that a full buffer finishes in a
|
||
// handful of seconds, little enough that neither the producer (which waits on the mutex
|
||
// for one chunk) nor the frame budget notices.
|
||
if (app->search.state() == BufferSearch::State::Running) {
|
||
const gint64 budget_end_us = g_get_monotonic_time() + 5000;
|
||
while (app->search.pump(*app->model) && g_get_monotonic_time() < budget_end_us) {
|
||
}
|
||
update_search_panel(*app);
|
||
}
|
||
if (model.generation() != app->signal_generation) {
|
||
// A CONFIG re-describes the stream and drops the buffer, so nothing measured before it
|
||
// belongs in the same series as what comes after. The search shares the same premise:
|
||
// the ring it was scanning no longer exists.
|
||
reset_measure_stats(*app);
|
||
app->search.clear();
|
||
rebuild_signal_panels(*app);
|
||
}
|
||
// A gesture moves the *plot's* cursor; adopting it here is what makes the two
|
||
// vertical markers one time across every row, and what the pane then reads.
|
||
app->rows->update_time_labels();
|
||
// Which row's controller holds a cursor: the only row whose plots are worth reading back
|
||
// (`cursor(id).x` is the free pointer position while a drag runs and the snapped value
|
||
// otherwise, so reading outside a gesture reads our own echo).
|
||
int cursor_drag_row = -1;
|
||
if (app->area != nullptr) {
|
||
for (int i = 0; i < app->rows->row_count(); ++i) {
|
||
plot::input::Controller* c = app->area->row_controller(static_cast<plot::u32>(i));
|
||
if (c != nullptr && c->grabbed_cursor() != plot::kInvalid) {
|
||
cursor_drag_row = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
app->rows->sync_cursors(cursor_drag_row);
|
||
update_cursors_pane(*app);
|
||
update_measures(*app, /*force=*/false, /*accumulate=*/true);
|
||
// Every frame, whether or not a scan is running: the marks are in stream seconds and the axis
|
||
// is newest-relative, so a live window that has rolled since the last frame needs them moved.
|
||
place_search_marks(*app);
|
||
|
||
// The first CONFIG names the signals, which is what the persisted layout is
|
||
// written against: apply it once, here, and never again (a later CONFIG only
|
||
// renames or renumbers; the user's layout survives those untouched).
|
||
if (!app->layout_loaded && !model.signals().empty()) {
|
||
app->layout_loaded = true;
|
||
LayoutState st;
|
||
app->config_name = active_configuration(app->source_host, app->source_port);
|
||
const std::string path =
|
||
configuration_path(app->source_host, app->source_port, app->config_name);
|
||
if (load_layout(path, st)) {
|
||
apply_layout(*app, st);
|
||
app->config_comment = st.comment;
|
||
std::fprintf(stderr, "[udpscope] layout loaded from %s\n", path.c_str());
|
||
std::fflush(stderr);
|
||
}
|
||
}
|
||
|
||
// ... and *after* it, which is the point: the block above ends in `apply_time_window()` with the
|
||
// window the session file holds, so a restore done before it would be overwritten by the saved
|
||
// value. The generation is the other half of the wait -- the stream's first CONFIG, which is what
|
||
// gives the stored trigger channel a list to be found in.
|
||
if (app->live_restore_pending && !app->replaying && !model.channels().empty() &&
|
||
model.generation() != app->live_restore_generation) {
|
||
app->live_restore_pending = false;
|
||
restore_live_state(*app);
|
||
}
|
||
|
||
// What the display shows, decided once for the whole frame: the producer can open or
|
||
// close a capture between any two reads of the model, and the bind, the epoch, the pin
|
||
// and the status line all have to describe the same picture.
|
||
const DisplayMode display = model.display_mode();
|
||
app->dbg_display = display; // the paint probe names the state rather than re-reading it
|
||
const double cap_edge = model.capture_edge();
|
||
const bool new_capture =
|
||
display == DisplayMode::Capturing && (!app->capturing || cap_edge != app->capture_edge);
|
||
app->capturing = display == DisplayMode::Capturing;
|
||
if (new_capture) app->capture_edge = cap_edge;
|
||
|
||
{
|
||
// Serialise against the acquisition thread's CONFIG handling and against the
|
||
// UI's own structural changes: the bind takes shared handles into the live
|
||
// store, and acquire() reads it.
|
||
std::lock_guard<std::mutex> lock(model.mutex());
|
||
app->rows->bind(model, display);
|
||
app->rows->acquire(model);
|
||
if (app->comparing && app->rows_b != nullptr) {
|
||
app->rows_b->bind(model, display);
|
||
app->rows_b->acquire(model);
|
||
}
|
||
// What x = 0 means, written *after* the bind and the acquire on purpose: the live answer
|
||
// is minus the newest sample the traces can now report, and asking before they were bound
|
||
// would put the newest data a frame's production to the right of the axis -- a third of
|
||
// the screen at 1 MS/s over a 50 ms window. The epoch is only read on the consumer side
|
||
// (it offsets the live channels' x), so nothing between here and the next frame's bind
|
||
// depends on it having been set earlier. It is written under the lock because a rebuild
|
||
// can replace the store it is pushed into.
|
||
//
|
||
// Three cases, one rule -- `display_x = stream_t + epoch`:
|
||
// * streaming, the newest stored sample sits at x = 0, so the axis is a fixed
|
||
// [-window, 0] and the data flows leftwards through it. A scope's time base is "how
|
||
// long ago", and the stream's own clock is an uptime nobody reads off an axis.
|
||
// * mid-capture, the coming event's edge sits at x = 0 exactly as the published
|
||
// snapshot's will, so the pinned [-pre, +post] means the same thing in both and the
|
||
// picture does not jump at the latch.
|
||
// * with a *snapshot* on screen (a held event, a paused view) the live channels are
|
||
// bound to nothing and the snapshot carries its own `x_offset`, so the epoch is 0 and
|
||
// `ScopeModel::stream_at_x0()` is what crosses between the clocks.
|
||
double want_epoch = 0.0;
|
||
if (!app->waveform_name.empty()) {
|
||
// A file *is* an event: an export's stamps are already trigger-relative, so the trigger instant
|
||
// is x = 0 and the epoch must not move it. The live rule below ("the newest sample sits at 0")
|
||
// would put the trigger at the right edge and push the whole pre-trigger half off the screen --
|
||
// which is what "I only see 0 to 0.2 of a -0.8 to 0.2 recording" was.
|
||
want_epoch = 0.0;
|
||
} else if (display == DisplayMode::Capturing) {
|
||
want_epoch = -cap_edge;
|
||
} else if (model.display_snapshot() == nullptr) {
|
||
// `live_edge()` reports in the epoch currently in force, so subtracting it is minus the
|
||
// raw stamp: the fixed point is reached in one frame rather than converged on.
|
||
double newest = 0.0;
|
||
want_epoch = app->rows->live_edge(newest) ? model.display_epoch() - newest
|
||
: model.display_epoch();
|
||
}
|
||
if (model.display_epoch() != want_epoch) model.set_display_epoch(want_epoch);
|
||
// The band last, after the epoch it is expressed in: it reads `model.holes()`, which the
|
||
// acquisition thread mutates, so it belongs under this lock and not after it.
|
||
app->rows->set_invalid_spans(model, display);
|
||
}
|
||
// Outside the lock: it reads and writes the two plots' own windows, not the model.
|
||
sync_compare_axes(*app);
|
||
sync_events_divider(*app);
|
||
// A file on screen is an event, and gets what a held capture gets: the axis pinned to the event's own
|
||
// `[-pre, +post]` and the trigger instant marked at x = 0. That *is* the constraint -- the time base is
|
||
// the event's, so no gesture can take the view outside it -- and the window control reads the event's
|
||
// length. The extent comes from the ring itself, so nothing has to remember it: with the epoch at 0
|
||
// the stamps are the display x.
|
||
if (!app->waveform_name.empty() && !app->capturing && app->rows != nullptr) {
|
||
double lo = 0.0;
|
||
double hi = 0.0;
|
||
{
|
||
const auto& t = app->model->live().time();
|
||
const std::uint64_t n = t.size();
|
||
if (n > 0) {
|
||
lo = t[0];
|
||
hi = t[n - 1];
|
||
}
|
||
}
|
||
if (hi > lo) {
|
||
// State the pin **once**, then only its numbers. `pin_event_window()` writes the group's range as
|
||
// well as the pin, and doing that every frame undid the user's box zoom before the clamp below
|
||
// could adopt it -- the same defect the wheel had in single trigger mode, where re-stating the pin
|
||
// made it a no-op. The held-event branch above has this shape for this reason: `set_data_pin()`
|
||
// for the numbers, `pin_event_window()` when the view first becomes an event. A re-open into the
|
||
// same row set with a different extent *is* a new event and re-states it.
|
||
const auto pin_view = [lo, hi, app](RowSet& rows) {
|
||
// `set_data_pin()` takes the **range**, `pinned_window_is()`/`pin_event_window()` the
|
||
// pre/post split: passing the split to the former wrote the pin as `[lo, -lo]`-ish, which
|
||
// on a recording whose stamps start *before* zero is a window that no longer holds the
|
||
// data. The next frame's `pinned_window_is()` then read false, re-stated the pin, and the
|
||
// view alternated between the file's span and the corrupted one every frame -- a flicker
|
||
// with no interaction surviving it. A file whose first stamp was positive hid this: the
|
||
// corrupted pin was wider than the data and the clamp never fired.
|
||
if (rows.pinned_window_is(-lo, hi)) {
|
||
rows.set_data_pin(lo, hi);
|
||
} else {
|
||
rows.pin_event_window(-lo, hi);
|
||
++app->dbg_file_repins;
|
||
}
|
||
rows.set_trigger_marker(0.0, true);
|
||
};
|
||
pin_view(*app->rows);
|
||
if (app->rows_b != nullptr) pin_view(*app->rows_b);
|
||
sync_window_display(*app, hi - lo);
|
||
}
|
||
}
|
||
// The comparison's plot needs the same re-point the primary gets from `relayout_areas()`: a `RowSet`
|
||
// rebuilds its *figure* when a row's trace set changes, and the area has to be handed the new one.
|
||
// Only when it *did* change, though -- `set_figure()` creates **a controller per row**, so handing it
|
||
// the same figure every frame threw away the second plot's interaction state sixty times a second:
|
||
// its box-zoom and its crosshair never took, while the primary's (re-pointed only on a dirty row) did.
|
||
if (app->comparing && app->area_b != nullptr && app->rows_b != nullptr) {
|
||
plot::Figure* want = &app->rows_b->figure();
|
||
if (app->area_b_figure != want) {
|
||
app->area_b->set_figure(want);
|
||
app->area_b_figure = want;
|
||
}
|
||
}
|
||
|
||
// The gesture probe's verdicts, one frame after the ask and after this frame's clamp: a
|
||
// `g_timeout` was the first version and it drifted past the paused/resumed boundary, so the
|
||
// "zoom in" verdict read the *live* window and called a working fix broken.
|
||
if (app->dbg_gesture_verdict != 0 && app->rows->row_count() > 0) {
|
||
const int was_verdict = app->dbg_gesture_verdict;
|
||
{
|
||
const RowSet::Row& r0 = app->rows->row(0);
|
||
const plot::Range after = app->rows->view_range();
|
||
if (app->dbg_gesture_verdict == 1) {
|
||
const bool moved = std::abs(after.min - app->dbg_pan_asked_lo) < 1e-9 &&
|
||
std::abs(after.max - app->dbg_pan_asked_hi) < 1e-9;
|
||
std::fprintf(stderr, "[pan-probe] after=[%.6f..%.6f] held=%d asked=[%.6f..%.6f]\n",
|
||
after.min, after.max, moved ? 0 : 1, app->dbg_pan_asked_lo,
|
||
app->dbg_pan_asked_hi);
|
||
// The next gesture: a zoom-out ten times the current span, anchored where a wheel zoom
|
||
// about the right edge would put it.
|
||
const double span = after.span();
|
||
if (span > 0.0) {
|
||
const plot::Range zoomed{after.max - 10.0 * span, after.max};
|
||
app->dbg_pan_asked_lo = zoomed.min;
|
||
app->dbg_pan_asked_hi = zoomed.max;
|
||
const_cast<RowSet::Row&>(r0).plot->set_x_range(r0.axis_x, zoomed);
|
||
// Judged on the next frame's tick: the member's range is only pushed by the layout.
|
||
app->dbg_gesture_verdict = 2;
|
||
}
|
||
} else if (app->dbg_gesture_verdict == 2) {
|
||
const double window = app->rows->time_window();
|
||
std::fprintf(stderr,
|
||
"[pan-probe] zoom asked=[%.6f..%.6f] after=[%.6f..%.6f] span=%.6f "
|
||
"window=%.6f limited=%d\n",
|
||
app->dbg_pan_asked_lo, app->dbg_pan_asked_hi, after.min, after.max,
|
||
after.span(), window, after.span() <= window * (1.0 + 1e-9) ? 1 : 0);
|
||
// The last gesture: a window a quarter of what is on screen, about its centre. A pinned
|
||
// view must allow it -- "not outside the event" is not "not readable".
|
||
const double span = after.span();
|
||
if (span > 0.0 && r0.plot != nullptr) {
|
||
const double centre = 0.5 * (after.min + after.max);
|
||
const plot::Range zoomed{centre - 0.125 * span, centre + 0.125 * span};
|
||
app->dbg_pan_asked_lo = zoomed.min;
|
||
app->dbg_pan_asked_hi = zoomed.max;
|
||
const_cast<RowSet::Row&>(r0).plot->set_x_range(r0.axis_x, zoomed);
|
||
app->dbg_gesture_verdict = 3;
|
||
}
|
||
} else if (app->dbg_gesture_verdict == 3) {
|
||
// The zoom-in ask was a quarter of the span: allowed means the view actually narrowed.
|
||
const double asked = app->dbg_pan_asked_hi - app->dbg_pan_asked_lo;
|
||
const plot::Range member = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x)
|
||
: plot::Range{0.0, 0.0};
|
||
std::fprintf(stderr,
|
||
"[pan-probe] zoom-in asked=[%.6f..%.6f] group=[%.6f..%.6f] member=[%.6f..%.6f]"
|
||
" span=%.6f allowed=%d\n",
|
||
app->dbg_pan_asked_lo, app->dbg_pan_asked_hi, after.min, after.max,
|
||
member.min, member.max, after.span(),
|
||
after.span() <= 2.0 * asked ? 1 : 0);
|
||
}
|
||
std::fflush(stderr);
|
||
// Cleared only if this frame did not hand the probe the *next* gesture: a report that asks
|
||
// for the next one must leave it armed for the following frame.
|
||
app->dbg_gesture_verdict = app->dbg_gesture_verdict == was_verdict ? 0 : app->dbg_gesture_verdict;
|
||
app->dbg_gesture_stage = 0;
|
||
}
|
||
}
|
||
|
||
// Nothing to do about the rows' x windows: they are one window, the x link group's.
|
||
// A gesture changes the row it landed on, `Figure::layout()` adopts that change into
|
||
// the group and pushes it to the others, so the "which row acted" search this replaced
|
||
// (and the per-row pan/zoom propagation behind it) has no subject any more.
|
||
|
||
// While an event is on screen -- held, or still being captured -- the window is pinned
|
||
// to [-pre, +post]; clamp_to_data would fight that pin whenever the bound series still
|
||
// reports the acquisition buffer's raw coordinates, dragging the view along the ring.
|
||
// Clamping is a live-view concern only, and a capture is live data on a pinned axis,
|
||
// which is exactly the case it must not touch.
|
||
if (app->event_held && !app->capturing) {
|
||
// A held event is navigable *within its data*. `pin_event_window()` framed the axis from the
|
||
// snapshot's recorded `pre_s`/`post_s` -- the *request* -- and a capture shorter than it (a
|
||
// ring that had not reached back a full pre-trigger window, or a trimmed one) left the
|
||
// shortfall as an empty margin, exactly as the pause did. The pin is the snapshot's own stamps
|
||
// here; the framing's split is what the marker at x = 0 already says.
|
||
double lo = 0.0;
|
||
double hi = 0.0;
|
||
if (app->rows->event_data_window(lo, hi)) app->rows->set_data_pin(lo, hi);
|
||
app->rows->hold_pinned_window();
|
||
} else if (display == DisplayMode::Paused || app->capturing || app->rows->window_pinned()) {
|
||
// Pinned: the event, the frozen pause *or an opened file*, not a navigation surface.
|
||
// `clamp_to_data()` is the live-mode half of the same rule -- a live view must stay on the data
|
||
// -- and the two are complements, which is why this is one branch rather than two conditions.
|
||
//
|
||
// A file belongs here and was the case that was missing. It pins its axis every frame (see the
|
||
// `waveform_name` block above), but a pinned axis does *not* mean this branch: only
|
||
// `hold_pinned_window()` adopts a gesture from the row's own axis, because a member's
|
||
// `set_x_range` is deliberately not adopted into a group that is not following. Falling through
|
||
// to `clamp_to_data()` therefore left a box zoom in a file view drawing its frame and changing
|
||
// nothing -- the gesture's range went onto the member, the group kept the pin, and the picture
|
||
// that was drawn came from the group.
|
||
app->rows->hold_pinned_window();
|
||
} else {
|
||
app->rows->clamp_to_data();
|
||
}
|
||
|
||
// The event-zoom probe's step, taken here because the pin and the clamp above are what
|
||
// decide the axis the renderer is about to read: a `g_timeout` would report the ask.
|
||
if (app->dbg_zoom_steps > 0 && app->rows->row_count() > 0 &&
|
||
app->rows->row(0).plot != nullptr) {
|
||
char what[24];
|
||
// Two sweeps, because there are two ways to zoom the time base and they take different
|
||
// paths: the second half drives the *window control* (`apply_time_window`, which unpins
|
||
// and re-arms the live follow before the pin is restated), the first half a gesture.
|
||
const bool via_window = app->dbg_zoom_steps <= kZoomWindowSteps;
|
||
std::snprintf(what, sizeof what, "%s/x%d", via_window ? "win" : "ges",
|
||
1 << app->dbg_zoom_step_no);
|
||
report_event_zoom(*app, what);
|
||
--app->dbg_zoom_steps;
|
||
++app->dbg_zoom_step_no;
|
||
const RowSet::Row& r0 = app->rows->row(0);
|
||
if (app->dbg_zoom_steps == kZoomWindowSteps) {
|
||
// Hand the window sweep a full event again, so it starts where the gesture sweep did.
|
||
app->dbg_zoom_step_no = 0;
|
||
double lo = 0.0, hi = 0.0;
|
||
if (app->rows->event_data_window(lo, hi)) apply_time_window(*app, hi - lo);
|
||
} else if (via_window) {
|
||
apply_time_window(*app, std::max(1.0e-4, app->rows->time_window() * 0.5));
|
||
} else {
|
||
// Halve the window about its **centre**, so the left edge walks *into* the data. An
|
||
// anchor that keeps the left edge where it is keeps it on the series' own first sample,
|
||
// where no index window can be short and the margin cannot be empty -- which is how the
|
||
// first version of this probe reported `gap_px=0.0` at every zoom on a view that was
|
||
// visibly clipped.
|
||
const plot::Range now = r0.plot->x_range(r0.axis_x);
|
||
const double c = 0.5 * (now.min + now.max);
|
||
r0.plot->set_x_range(r0.axis_x, plot::Range{c - 0.25 * now.span(), c + 0.25 * now.span()});
|
||
}
|
||
}
|
||
|
||
// Curator: the trigger's arm state can change under us (single mode disarms
|
||
// itself), so the button and the status line read the published atomics.
|
||
const bool trigger_on = model.trigger_enabled();
|
||
if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app->trigger_toggle)) != trigger_on) {
|
||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->trigger_toggle), trigger_on);
|
||
}
|
||
const bool armed = model.trigger_armed().load(std::memory_order_relaxed);
|
||
gtk_button_set_label(GTK_BUTTON(app->arm_button), armed ? "Disarm" : "Arm");
|
||
if (new_capture) {
|
||
// The edge has been accepted, which means the pre window was already full, so the
|
||
// whole event window can be framed now and the post side filled in view.
|
||
//
|
||
// The split is the *requested* one -- the same `post_span_s()` the producer will cut
|
||
// with -- because there is no snapshot to read it off yet, and the marker has to sit
|
||
// where that cut will put it rather than where a different rounding would.
|
||
double span = 0.0;
|
||
double post = 0.0;
|
||
{
|
||
std::lock_guard<std::mutex> lock(model.mutex());
|
||
span = std::max(1.0e-3, model.event_span());
|
||
post = model.trigger().post_span_s(span);
|
||
}
|
||
app->event_held = false;
|
||
app->rows->pin_event_window(span - post, post);
|
||
app->rows->set_trigger_marker(0.0, true);
|
||
sync_window_display(*app, span);
|
||
// The user saw the event at its edge, so that is when the counter starts.
|
||
app->timed_edge = cap_edge;
|
||
app->last_latch_us = g_get_monotonic_time();
|
||
}
|
||
|
||
const std::uint64_t latches = model.trigger_latches().load(std::memory_order_relaxed);
|
||
if (latches != app->seen_latches) {
|
||
app->seen_latches = latches;
|
||
const double edge = model.trigger_time().load(std::memory_order_relaxed);
|
||
// A capture the UI watched fill already started this clock at its edge; only one that
|
||
// opened and closed inside a single frame -- never reported pending, so never
|
||
// displayed as a capture -- starts it here.
|
||
if (edge != app->timed_edge) {
|
||
app->timed_edge = edge;
|
||
app->last_latch_us = g_get_monotonic_time();
|
||
}
|
||
}
|
||
|
||
// Framing the held event is keyed on *which snapshot was published*, not on the latch
|
||
// count: a Disarm during a capture publishes what the capture got to without an event
|
||
// having fired, and that partial has to be framed and held exactly like one that ran
|
||
// to term. The latch count stays the answer to "how many events fired", which is what
|
||
// the probes assert on.
|
||
const std::uint64_t seq = model.event_seq();
|
||
// The frame that *frames* the snapshot is the one that consumes its sequence number,
|
||
// not merely the first frame to see it. Two things can bind something else while a new
|
||
// snapshot is already published:
|
||
//
|
||
// - A publication and the *next* edge land in the same frame: the display follows the
|
||
// newer of the two and the capture above has framed its own window.
|
||
// - `display` is read before the lock the producer publishes under, and
|
||
// `trigger_pending` is only lowered at the end of the producer's packet loop -- long
|
||
// after `publish_event()`. So a tick can read `Capturing`, block on the mutex while
|
||
// the snapshot is published and the flag lowered, and get here bound to a capture
|
||
// that no longer exists.
|
||
//
|
||
// Consuming the number in either case dropped the pin permanently: the axis kept the
|
||
// capture's *requested* [-pre, +post] while the snapshot was shorter, and `event_held`
|
||
// stayed false, so `clamp_to_data()` was free to drag the frozen view off it. Leaving
|
||
// it unconsumed costs nothing -- the pin is simply owed until a frame is bound to the
|
||
// snapshot, and by then it is still the newest one.
|
||
if (seq != app->seen_event_seq && display == DisplayMode::Held) {
|
||
app->seen_event_seq = seq;
|
||
app->event_held = true;
|
||
// Keep it, at the end of this tick: the record's thumbnail is a picture of the plot, and the plot
|
||
// is bound to the event later in this same frame.
|
||
app->record_pending = true;
|
||
// The window is pinned to exactly what the snapshot captured, read off the
|
||
// snapshot itself. Not off `event_span`/`pretrigger_pct`: those say what was
|
||
// *asked for*, in stream seconds, while how much the capture actually covers
|
||
// depends on the cadence the trigger channel was running at. Pinning to the
|
||
// request collapsed the waveform into a sliver at x = 0 and left the rest of
|
||
// the axis -- the whole post side included -- blank.
|
||
//
|
||
// The snapshot is edge-relative (the edge at x = 0) and the epoch is back to 0, so
|
||
// the bind at the top of this frame already put the slots and the axis on the same
|
||
// coordinates the pin is about to state.
|
||
double pre = 0.0;
|
||
double post = 0.0;
|
||
if (auto snap = model.event()) {
|
||
pre = snap->pre_s;
|
||
post = snap->post_s;
|
||
}
|
||
if (!(pre + post > 0.0)) {
|
||
// Nothing was captured (no samples in the ring yet): fall back to the
|
||
// requested window so the axis is at least readable.
|
||
const double span = std::max(1.0e-3, model.event_span());
|
||
post = model.trigger().post_span_s(span);
|
||
pre = span - post;
|
||
}
|
||
app->rows->pin_event_window(pre, post);
|
||
app->rows->set_trigger_marker(0.0, true); // the edge sits at x = 0
|
||
sync_window_display(*app, pre + post);
|
||
}
|
||
// The trigger instant is marked on every row while its event is the thing on screen --
|
||
// being captured or held. x is seconds from the event in both: the display epoch is
|
||
// minus the edge while the live ring is on a pinned axis, and the snapshot's own stamps
|
||
// are edge-relative, so the edge sits at exactly x = 0 wherever the user has zoomed.
|
||
// ... and it is the *only* thing that hides it. A file's own event is on screen for as long as the
|
||
// file is, so the file branch above must survive this one: it used to run first and be undone here,
|
||
// which is why an opened event had no trigger line.
|
||
if (app->waveform_name.empty() && !app->capturing &&
|
||
!(trigger_on && model.trigger_time_valid().load(std::memory_order_relaxed))) {
|
||
app->rows->set_trigger_marker(0.0, false);
|
||
}
|
||
|
||
{
|
||
// A bound slot with no published samples while its channel's ring has plenty
|
||
// means the consumer side has not caught up: this frame draws an empty plot, and
|
||
// the y auto-fit collapses with it.
|
||
int bound = 0;
|
||
int empty = 0;
|
||
for (int i = 0; i < app->rows->row_count(); ++i) {
|
||
RowSet::Row& r = app->rows->row(i);
|
||
for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) {
|
||
const std::size_t si = static_cast<std::size_t>(sl);
|
||
const int ch = r.channel[si];
|
||
if (ch < 0) continue;
|
||
const ChannelBuffer* b = r.slots[si].bound();
|
||
if (b == nullptr || static_cast<std::size_t>(ch) >= model.channel_count()) continue;
|
||
++bound;
|
||
if (b->size() == 0 && model.live().values(static_cast<std::size_t>(ch)).produced() > 0) {
|
||
++empty;
|
||
}
|
||
}
|
||
}
|
||
if (bound > 0 && empty == bound) ++app->dbg_blank_frames;
|
||
if (bound > 0 && empty < bound) app->dbg_ever_bound = true;
|
||
// A rebuild drops every ring's history, so the frames after one draw an empty plot until the
|
||
// producer has refilled the window -- which is what "the plot flickers as if there were no
|
||
// data" looks like. `dbg_blank_frames` cannot see it: it asks whether the ring holds samples
|
||
// the view has not got, and a rebuilt ring holds none either. The generation is the only
|
||
// thing that says a rebuild happened, so each change is named here with what asked for it.
|
||
const unsigned gen = model.generation();
|
||
if (app->dbg_generation_seen && gen != app->dbg_generation_prev) {
|
||
++app->dbg_generation_changes;
|
||
if (app->dbg_generation_changes <= 40) {
|
||
std::fprintf(stderr, "[gen] frame %d generation %u -> %u (bound=%d empty=%d)\n",
|
||
app->dbg_axes_frames, app->dbg_generation_prev, gen, bound, empty);
|
||
}
|
||
}
|
||
app->dbg_generation_prev = gen;
|
||
app->dbg_generation_seen = true;
|
||
// And the renderer's blank: the rings hold samples, yet the frame staged nothing.
|
||
if (bound > 0 && empty == 0 && app->dbg_seg_last == 0) ++app->dbg_render_blank_frames;
|
||
}
|
||
|
||
// The gutter the rows need, measured from the frame just laid out, imposed as a
|
||
// floor that only grows: without it the axes' left edge follows the y tick
|
||
// labels and the x axis twitches by a few pixels as the scale changes.
|
||
double gutter = 0.0;
|
||
for (int i = 0; i < app->rows->row_count(); ++i) {
|
||
const RowSet::Row& r = app->rows->row(i);
|
||
if (r.plot) gutter = std::max(gutter, double(r.plot->layout().y_axis_width));
|
||
}
|
||
app->rows->note_y_gutter(gutter);
|
||
app->rows->apply_gutters();
|
||
|
||
// The editor's buttons are placed from the rects this frame was laid out with, so they
|
||
// follow a resize and the gutter settling without being told.
|
||
if (app->layout_editing) sync_layout_editor(*app);
|
||
|
||
// The one thing that has to be decided *per frame* about the sidebar: whether there is room for it
|
||
// beside the plot. Collapsed, the split view puts it *over* the plot rather than giving it a share of
|
||
// a width that is not there -- so a small window shows fewer pixels of plot, never a panel squeezed
|
||
// to nothing. `kSidebarRoom` is the sidebar's 320 plus the width below which the plot stops being
|
||
// worth looking at.
|
||
if (app->side_view != nullptr && app->window != nullptr) {
|
||
// The split view writes `show-sidebar` itself (at startup and when it collapses), so the wish is
|
||
// re-asserted here -- but only when they disagree, so a settled frame touches nothing. This is also
|
||
// why a hide *gesture* does not stick: the panel is where the button says it is.
|
||
const bool want = !app->model->signals().empty() && app->sidebar_wanted;
|
||
if (adw_overlay_split_view_get_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(app->side_view)) != want) {
|
||
adw_overlay_split_view_set_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(app->side_view), want);
|
||
if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app->sidebar_toggle)) != want) {
|
||
app->sidebar_syncing = true;
|
||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->sidebar_toggle), want);
|
||
app->sidebar_syncing = false;
|
||
}
|
||
}
|
||
const int window_w = gtk_widget_get_width(app->window);
|
||
if (window_w > 0) {
|
||
adw_overlay_split_view_set_collapsed(ADW_OVERLAY_SPLIT_VIEW(app->side_view),
|
||
window_w < kSidebarRoom);
|
||
}
|
||
}
|
||
// The render, queued once a frame -- but not unconditionally. In `Held` and `Paused` the rows
|
||
// are bound to an immutable snapshot on a pinned axis and the producer is feeding the live ring,
|
||
// not the series on screen, so a frame in which none of the picture's own terms moved is the
|
||
// same frame again. Every other mode renders: `Live` and `Capturing` roll new samples under the
|
||
// axis, and `Waiting` moves the live epoch, so in all three the picture differs every frame.
|
||
//
|
||
// The gate is two parts, and the second is what makes the first safe to keep coarse:
|
||
// * a signature of what a frozen view *can* be re-framed by -- a new snapshot, a pan or zoom
|
||
// (the group's range, which is where the gestures and the pin both write), a row added or
|
||
// removed -- which renders the moment it changes;
|
||
// * `kStaticRenderEvery`, a floor that renders anyway every few ticks, so anything not in the
|
||
// signature is late rather than lost. A gate that can only be opened by a list of state is
|
||
// a gate that goes wrong the first time the list is one item short.
|
||
// Interaction needs neither: `PlotArea` queues its own render from the pointer, scroll and drag
|
||
// handlers, so a crosshair and a cursor drag are immediate whatever this decides.
|
||
//
|
||
// The layout editor is excluded outright: its buttons are placed from the rects the last frame
|
||
// was laid out with (`sync_layout_editor()` above), so it is the one state where a skipped frame
|
||
// is a stale *widget* and not just a stale picture.
|
||
if (app->area_widget != nullptr) {
|
||
const bool frozen = (display == DisplayMode::Held || display == DisplayMode::Paused) &&
|
||
!app->layout_editing;
|
||
const plot::Range view = app->rows->view_range();
|
||
const std::uint64_t seq = model.event_seq();
|
||
const int rows = app->rows->row_count();
|
||
const bool same = frozen && seq == app->render_event_seq && view.min == app->render_x_min &&
|
||
view.max == app->render_x_max && rows == app->render_rows;
|
||
// A change opens the gate for `kSettleRenders` frames, not one: the figure's left gutter is a
|
||
// monotone floor that converges over two layout passes (see the `layout over N frames`
|
||
// reading), and a layout pass happens per *render* -- so a single frame after a re-frame would
|
||
// leave the gutter one pass short until the floor below fired, 64 ms later.
|
||
if (!same) app->render_open = kSettleRenders;
|
||
if (app->render_open > 0 || app->render_skipped + 1 >= kStaticRenderEvery) {
|
||
if (app->render_open > 0) --app->render_open;
|
||
app->render_skipped = 0;
|
||
gtk_gl_area_queue_render(GTK_GL_AREA(app->area_widget));
|
||
} else {
|
||
++app->render_skipped;
|
||
++app->dbg_render_skips;
|
||
}
|
||
app->render_event_seq = seq;
|
||
app->render_x_min = view.min;
|
||
app->render_x_max = view.max;
|
||
app->render_rows = rows;
|
||
}
|
||
// What the side column *demands*, not what it got. A GTK box's minimum width is its
|
||
// children's, a `GtkLabel`'s minimum is its text, and a minimum larger than the screen is
|
||
// honoured by growing the toplevel -- which hands the plot an allocation wider than the window
|
||
// and pushes most of the trace off the right-hand edge. `axes.w` sees the consequence a frame
|
||
// later and cannot name the cause, so the demand is read here, from the panes whose contents
|
||
// the frame tick rewrites.
|
||
{
|
||
const auto min_w = [](GtkWidget* w) {
|
||
if (w == nullptr || !GTK_IS_WIDGET(w)) return 0;
|
||
int min = 0;
|
||
int nat = 0;
|
||
gtk_widget_measure(w, GTK_ORIENTATION_HORIZONTAL, -1, &min, &nat, nullptr, nullptr);
|
||
return min;
|
||
};
|
||
const int side = min_w(app->side_box);
|
||
const int meas = min_w(app->measures_list);
|
||
const int curs = min_w(app->cursors_body);
|
||
const int worst = std::max(side, std::max(meas, curs));
|
||
if (worst > app->dbg_min_w_worst) {
|
||
const int was = app->dbg_min_w_worst;
|
||
app->dbg_min_w_worst = worst;
|
||
if (was > 0 && worst - was > 32 && app->dbg_min_w_reports < 40) {
|
||
++app->dbg_min_w_reports;
|
||
std::fprintf(stderr,
|
||
"[width-probe] frame %d min width grew %d -> %d (side=%d measures=%d "
|
||
"cursors=%d)\n",
|
||
app->dbg_axes_frames, was, worst, side, meas, curs);
|
||
}
|
||
}
|
||
}
|
||
if (plot::Plot* p0 = app->rows->row(0).plot;
|
||
p0 != nullptr && p0->viewport().w > 100.0f) { // steady state only
|
||
const int ax = static_cast<int>(p0->layout().axes.x);
|
||
const int gw = static_cast<int>(p0->layout().y_axis_width);
|
||
app->dbg_axes_x_min = std::min(app->dbg_axes_x_min, ax);
|
||
app->dbg_axes_x_max = std::max(app->dbg_axes_x_max, ax);
|
||
app->dbg_gutter_min = std::min(app->dbg_gutter_min, gw);
|
||
app->dbg_gutter_max = std::max(app->dbg_gutter_max, gw);
|
||
const int aw = static_cast<int>(p0->layout().axes.w);
|
||
app->dbg_axes_w_min = std::min(app->dbg_axes_w_min, aw);
|
||
app->dbg_axes_w_max = std::max(app->dbg_axes_w_max, aw);
|
||
// A min..max range alone cannot tell the twitch bug (a width that changes every
|
||
// frame) from one resize that happened once and stayed, which look identical in a
|
||
// summary and need opposite responses. Count the changes and keep the last one's
|
||
// frame, and whether the side panel was up for it -- hiding that column is the one
|
||
// legitimate reason the plot's width moves by itself.
|
||
if (app->dbg_axes_frames > 0 && aw != app->dbg_axes_w_prev) {
|
||
++app->dbg_axes_w_changes;
|
||
app->dbg_axes_w_change_frame = app->dbg_axes_frames;
|
||
app->dbg_axes_w_change_side = gtk_widget_get_visible(app->side_box) ? 1 : 0;
|
||
// Each change, not just the last: a summary says the plot's width moved and cannot say
|
||
// *what* moved it, and a width that moves by itself is the "the plot flickers" report's
|
||
// first candidate. Named here beside the widths that can cause it -- the side column and
|
||
// the two panes whose contents the frame tick rewrites -- so the culprit is read rather
|
||
// than guessed. Capped, because the twitch bug would otherwise fill the log.
|
||
if (app->dbg_axes_w_changes <= 40) {
|
||
int side_w = 0;
|
||
int meas_w = 0;
|
||
int curs_w = 0;
|
||
if (app->side_box != nullptr) side_w = gtk_widget_get_width(app->side_box);
|
||
if (app->measures_list != nullptr) meas_w = gtk_widget_get_width(app->measures_list);
|
||
if (app->cursors_body != nullptr) curs_w = gtk_widget_get_width(app->cursors_body);
|
||
std::fprintf(stderr,
|
||
"[width-probe] frame %d axes.w %d -> %d viewport.w %d side=%dx(vis=%d) "
|
||
"measures_w=%d cursors_w=%d\n",
|
||
app->dbg_axes_frames, app->dbg_axes_w_prev, aw,
|
||
static_cast<int>(p0->viewport().w), side_w,
|
||
app->dbg_axes_w_change_side, meas_w, curs_w);
|
||
// A step of this size is not the gutter settling, so name what demanded it.
|
||
if (std::abs(aw - app->dbg_axes_w_prev) > 100) {
|
||
report_wide_widgets(app->window, 900, 1);
|
||
}
|
||
}
|
||
}
|
||
app->dbg_axes_w_prev = aw;
|
||
app->dbg_vp_w_min = std::min(app->dbg_vp_w_min, static_cast<int>(p0->viewport().w));
|
||
app->dbg_vp_w_max = std::max(app->dbg_vp_w_max, static_cast<int>(p0->viewport().w));
|
||
const int nt = static_cast<int>(p0->x_axis(0).ticks.size());
|
||
app->dbg_xtick_min = std::min(app->dbg_xtick_min, nt);
|
||
app->dbg_xtick_max = std::max(app->dbg_xtick_max, nt);
|
||
if (app->area != nullptr) {
|
||
const float sc = app->area->device_scale();
|
||
app->dbg_scale_min = std::min(app->dbg_scale_min, sc);
|
||
app->dbg_scale_max = std::max(app->dbg_scale_max, sc);
|
||
}
|
||
// The rows are members of one x link group, so they hold one window; a row that
|
||
// disagrees by more than a pixel of data means the group did not reach it.
|
||
const plot::Range x0 = p0->x_range(app->rows->row(0).axis_x);
|
||
const double tol = std::max(1.0e-12, x0.span() / std::max(1.0, double(aw)));
|
||
for (int i = 1; i < app->rows->row_count(); ++i) {
|
||
const RowSet::Row& r = app->rows->row(i);
|
||
if (r.plot == nullptr) continue;
|
||
const plot::Range xi = r.plot->x_range(r.axis_x);
|
||
if (std::fabs(xi.min - x0.min) > tol || std::fabs(xi.max - x0.max) > tol) {
|
||
++app->dbg_x_desync;
|
||
break;
|
||
}
|
||
}
|
||
++app->dbg_axes_frames;
|
||
}
|
||
|
||
// Once a second: how many samples each active channel has *stored*, and the rate that
|
||
// implies. Channels are never decimated against each other (`stride=1`), so with
|
||
// `decim=1` a stored rate far below the declared one is the datasource's own wire rate;
|
||
// with `decim=N` it is the wire rate over N, the resampling the window asked for. This
|
||
// line is the receipt for both.
|
||
static gint64 next_channels = 0;
|
||
const gint64 now_c = g_get_monotonic_time();
|
||
// Per-channel rate computed only on the measuring tick; the detail tooltip on the label
|
||
// now carries the full status string instead of a per-channel breakdown.
|
||
if (app->last_stored.size() != model.channel_count()) {
|
||
// Seed new entries with the current count: a fresh entry must not be counted
|
||
// as a delta from zero.
|
||
app->last_stored.assign(model.channel_count(), 0);
|
||
for (std::size_t c = 0; c < model.channel_count() && c < app->last_stored.size(); ++c) {
|
||
app->last_stored[c] = model.live().values(c).produced();
|
||
}
|
||
}
|
||
if (now_c >= next_channels) {
|
||
const double dt_s = std::min(10.0, double(now_c - app->last_channels_meas) / 1.0e6);
|
||
next_channels = now_c + G_TIME_SPAN_SECOND;
|
||
app->last_channels_meas = now_c;
|
||
// The cache's own question, on the same cadence as the channel dump: a directory read per second,
|
||
// and the button's availability re-decided only when the answer changed.
|
||
if (app->event_store != nullptr) {
|
||
const bool cached = !app->event_store->list().empty();
|
||
if (cached != app->events_cached) {
|
||
app->events_cached = cached;
|
||
sync_events_availability(*app);
|
||
}
|
||
}
|
||
// Once a second, alongside the rate measurement: is the stream arriving at all? Nothing is
|
||
// owed in file mode -- there is no socket and no source -- so a file is never starved.
|
||
if (app->waveform_name.empty()) note_starvation(*app);
|
||
std::lock_guard<std::mutex> lock(model.mutex());
|
||
app->stored_rate.assign(model.channel_count(), 0.0);
|
||
app->declared_rate.assign(model.channel_count(), 0.0);
|
||
for (std::size_t c = 0; c < model.channel_count(); ++c) {
|
||
const ChannelInfo& info = model.channels()[c];
|
||
const u64 stored = model.live().values(c).produced();
|
||
// `produced()` restarts at 0 whenever the buffer is rebuilt (a CONFIG, an
|
||
// enable/disable, a calibration change), so the delta can run backwards. As
|
||
// unsigned that wrapped to ~2^64 and the line reported a rate near 1.8e13 MS/s
|
||
// -- a number with no relation to the stream, which is actively misleading when
|
||
// this line is the evidence for a timing complaint. A rebuild has no measurable
|
||
// rate, so report none.
|
||
const u64 prev = c < app->last_stored.size()
|
||
? app->last_stored[static_cast<std::size_t>(c)]
|
||
: stored;
|
||
const double per_s =
|
||
stored >= prev && dt_s > 0.0 ? double(stored - prev) / dt_s : 0.0;
|
||
if (c < app->last_stored.size()) app->last_stored[static_cast<std::size_t>(c)] = stored;
|
||
const double declared = info.dt > 0.0 ? 1.0 / info.dt : 0.0;
|
||
app->stored_rate[c] = per_s;
|
||
app->declared_rate[c] = declared;
|
||
const ChannelBuffer& buf = model.live().channel(c);
|
||
const plot::Range y = buf.size() > 0 ? buf.y_bounds() : plot::Range{0.0, 0.0};
|
||
std::fprintf(stderr,
|
||
"[udpscope] ch %zu '%s' stored=%llu (%.4g MS/s, declared %.4g S/s) "
|
||
"elements=%llu stride=%llu decim=%llu published=%llu y=[%.6g, %.6g]\n",
|
||
c, info.name.c_str(), static_cast<unsigned long long>(stored), per_s / 1e6,
|
||
declared, static_cast<unsigned long long>(info.elements),
|
||
static_cast<unsigned long long>(model.partition().stride[c]),
|
||
static_cast<unsigned long long>(model.partition().time_stride),
|
||
static_cast<unsigned long long>(buf.size()), double(y.min), double(y.max));
|
||
}
|
||
std::fflush(stderr);
|
||
}
|
||
|
||
// Status line. The model's lists and partition are rebuilt on the producer
|
||
// thread, so every read of them takes the model mutex -- narrowly: GTK calls and
|
||
// model setters below must stay outside it (a setter takes the same lock, and a
|
||
// button toggle emits a handler that calls one).
|
||
std::size_t signal_count = 0;
|
||
std::size_t channel_count = 0;
|
||
double span_s = 0.0;
|
||
std::uint64_t time_stride = 1;
|
||
double trigger_level = 0.0;
|
||
double trigger_value = 0.0;
|
||
bool have_trigger_value = false;
|
||
std::string trigger_name;
|
||
{
|
||
std::lock_guard<std::mutex> lock(model.mutex());
|
||
signal_count = model.signals().size();
|
||
channel_count = model.channel_count();
|
||
span_s = model.partition().span_s;
|
||
time_stride = model.partition().time_stride;
|
||
const int chan = model.trigger().channel;
|
||
trigger_level = model.trigger().condition.level;
|
||
if (chan >= 0 && chan < static_cast<int>(model.channel_count())) {
|
||
trigger_name = model.channels()[static_cast<std::size_t>(chan)].name;
|
||
}
|
||
for (std::size_t c = 0; c < model.channel_count(); ++c) {
|
||
if (static_cast<int>(c) != chan) continue;
|
||
const ChannelBuffer& buf = model.live().channel(c);
|
||
if (buf.size() > 0) {
|
||
f32 v = 0.0f;
|
||
buf.raw(buf.size() - 1, buf.size(), &v);
|
||
trigger_value = double(v);
|
||
have_trigger_value = true;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
StreamStats& st = model.stats();
|
||
const char* mode = "live";
|
||
if (!st.have_config.load()) {
|
||
// Ahead of every other branch for the reason the glance word is: with no CONFIG there is no
|
||
// signal list, so "live" (or "armed", which cannot happen without one) would name a pipeline
|
||
// that has not been described yet.
|
||
mode = "trying to connect: no configuration, and so no signal list, from the stream yet";
|
||
} else if (trigger_on) {
|
||
if (app->capturing) {
|
||
mode = "trigger: CAPTURING (post-trigger window filling)";
|
||
} else if (app->event_held) {
|
||
mode = "trigger: EVENT HELD (Arm to re-arm, or 'Go live' for streaming)";
|
||
} else if (armed && have_trigger_value) {
|
||
static char waiting[192];
|
||
std::snprintf(waiting, sizeof(waiting),
|
||
"trigger: armed on '%s' (now %.4g, level %.4g), waiting for an event",
|
||
trigger_name.c_str(), trigger_value, trigger_level);
|
||
mode = waiting;
|
||
} else if (armed) {
|
||
mode = "trigger: armed, waiting for an event";
|
||
} else {
|
||
mode = "trigger: disarmed (view frozen)";
|
||
}
|
||
} else if (app->acq->paused()) {
|
||
mode = "paused";
|
||
}
|
||
|
||
// The badge: lit and blinking while the pipeline is actually running end to end.
|
||
// Paused and disarmed both stop it, and so does a stream that has not arrived -- a
|
||
// green light over a dead socket is the one reading that must not happen. The blink is
|
||
// a 1 Hz square wave off the monotonic clock rather than its own timer, so it cannot
|
||
// drift away from the frame it is drawn on.
|
||
// A source that answered CONNECT but has sent no CONFIG is *connecting*, not acquiring:
|
||
// there is no signal list, so nothing can be enabled and no DATA frame can arrive. It gets
|
||
// the steady yellow rather than the blinking green, because the green says "the pipeline is
|
||
// running end to end" and here it has not started.
|
||
const bool paused = app->acq->paused();
|
||
const bool connecting = st.connected.load() && !st.have_config.load();
|
||
const bool active =
|
||
st.connected.load() && !connecting && !paused && (!trigger_on || armed);
|
||
const bool lit = ((g_get_monotonic_time() / (G_TIME_SPAN_SECOND / 2)) & 1) == 0;
|
||
const BadgePhase phase = connecting ? BadgePhase::Connecting
|
||
: !active ? BadgePhase::Idle
|
||
: (lit ? BadgePhase::Lit : BadgePhase::Dim);
|
||
const int reason = active ? 0
|
||
: !st.connected.load() ? 1
|
||
: connecting ? 4
|
||
: paused ? 2
|
||
: 3;
|
||
static const char* const kWhy[] = {"acquiring", "waiting for the stream", "paused",
|
||
"trigger disarmed",
|
||
"trying to connect: no configuration from the stream yet"};
|
||
// Keyed on the reason as well as the phase: three of the reasons share the Idle phase,
|
||
// so a pause that becomes a disarm has to reach the tooltip.
|
||
++app->dbg_badge_frames[static_cast<int>(phase)];
|
||
const int badge_key = static_cast<int>(phase) * 8 + reason;
|
||
if (app->status_badge != nullptr && badge_key != app->badge_phase) {
|
||
app->badge_phase = badge_key;
|
||
gtk_label_set_markup(GTK_LABEL(app->status_badge), badge_markup(phase));
|
||
gtk_widget_set_tooltip_text(app->status_badge, kWhy[reason]);
|
||
}
|
||
|
||
int bound = 0;
|
||
std::size_t samples = 0;
|
||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||
const ChannelBuffer* b = app->rows->row(0).slots[static_cast<std::size_t>(s)].bound();
|
||
if (b != nullptr && b->size() > 0) {
|
||
++bound;
|
||
samples += static_cast<std::size_t>(b->size());
|
||
}
|
||
}
|
||
// The rate the buffer is actually storing at. One figure when every channel agrees and
|
||
// a range when they do not: a per-channel list would make the field's width depend on
|
||
// the stream, and the breakdown is on the label's tooltip instead.
|
||
std::string rate;
|
||
if (!app->stored_rate.empty()) {
|
||
double lo = app->stored_rate.front();
|
||
double hi = lo;
|
||
for (const double r : app->stored_rate) {
|
||
lo = std::min(lo, r);
|
||
hi = std::max(hi, r);
|
||
}
|
||
// Within a percent is the same rate measured over a one-second window, not two rates.
|
||
rate = hi - lo <= 0.01 * hi ? format_rate(hi) : format_rate(lo) + ".." + format_rate(hi);
|
||
}
|
||
|
||
// Derive the one-word state for the glance line from the same conditions the mode prose
|
||
// branches on, so the two cannot disagree.
|
||
//
|
||
// `connecting` is first and covers both halves of the handshake -- no socket yet, and a
|
||
// socket with no CONFIG behind it -- because in neither is there a signal list, and every
|
||
// other word here claims one. The badge is what distinguishes them (grey against yellow):
|
||
// the word says what the app is doing, the colour says how far it got. It is no wider than
|
||
// `event held`, which is what lets it join a set the plot's layout is measured against.
|
||
const char* state_word = "live";
|
||
if (!st.have_config.load()) {
|
||
state_word = "connecting";
|
||
} else if (display == DisplayMode::Capturing) {
|
||
state_word = "capturing";
|
||
} else if (display == DisplayMode::Held) {
|
||
state_word = "event held";
|
||
} else if (trigger_on && !armed) {
|
||
state_word = "disarmed";
|
||
} else if (display == DisplayMode::Waiting) {
|
||
state_word = "armed";
|
||
} else if (paused) {
|
||
state_word = "paused";
|
||
}
|
||
|
||
udpscope::StatusSnapshot snap;
|
||
snap.state_detail = mode;
|
||
snap.state_word = state_word;
|
||
snap.connected = st.connected.load();
|
||
snap.have_config = st.have_config.load();
|
||
snap.trigger_on = trigger_on;
|
||
snap.rate_text = rate;
|
||
snap.stored_rate_hz = app->stored_rate.empty() ? 0.0 : app->stored_rate.front();
|
||
snap.declared_rate_hz = app->declared_rate.empty() ? 0.0 : app->declared_rate.front();
|
||
snap.window_text = format_window(app->rows->time_window());
|
||
snap.packets = st.packets.load();
|
||
snap.frames = st.frames.load();
|
||
snap.gaps = st.gaps.load();
|
||
snap.resequenced = st.resequenced.load();
|
||
snap.late = st.late.load();
|
||
snap.stale = st.stale.load();
|
||
snap.holes = st.holes.load();
|
||
snap.hole_slots = st.hole_slots.load();
|
||
snap.backfilled = st.backfilled.load();
|
||
snap.signals = signal_count;
|
||
snap.channels = channel_count;
|
||
snap.plot1_traces = bound;
|
||
snap.plot1_samples = samples;
|
||
snap.buffer_mb = double(model.config().buffer_mb);
|
||
snap.span_s = span_s;
|
||
snap.time_stride = time_stride;
|
||
snap.have_event = app->last_latch_us != 0;
|
||
snap.since_event_s = snap.have_event
|
||
? double(g_get_monotonic_time() - app->last_latch_us) / 1.0e6
|
||
: 0.0;
|
||
snap.reconnects = static_cast<int>(st.reconnects.load(std::memory_order_relaxed));
|
||
// Paused *and* with a recorded start: the button's handler is what stamps it, so a pause
|
||
// arriving any other way (there is none today) reports the state without a duration rather
|
||
// than counting from the epoch.
|
||
snap.paused = paused && app->pause_started_us != 0;
|
||
snap.paused_for_s =
|
||
snap.paused ? double(g_get_monotonic_time() - app->pause_started_us) / 1.0e6 : 0.0;
|
||
|
||
const std::string glance = udpscope::format_glance(snap);
|
||
app->dbg_glance_last = glance;
|
||
app->status_detail = udpscope::format_detail(snap);
|
||
if (app->status_detail.size() > app->status_detail_max) {
|
||
app->status_detail_max = app->status_detail.size();
|
||
}
|
||
if (glance.size() > app->glance_max_chars) app->glance_max_chars = glance.size();
|
||
|
||
// A notice is not the glance. It is prefixed onto the same line, which is exactly where a reader
|
||
// skimming the routine text will not see it -- "there is no clear feedback" was that, with a refusal
|
||
// ("plot 2 holds four signals already") looking like part of the status. The stock `.warning` class is
|
||
// what makes a message look like a message, and it goes when the notice does.
|
||
if (!app->notice.empty() && g_get_monotonic_time() < app->notice_until_us) {
|
||
gtk_widget_add_css_class(app->status_label, "warning");
|
||
const std::string with_notice = app->notice + " | " + glance;
|
||
gtk_label_set_text(GTK_LABEL(app->status_label), with_notice.c_str());
|
||
} else {
|
||
gtk_widget_remove_css_class(app->status_label, "warning");
|
||
app->notice.clear();
|
||
gtk_label_set_text(GTK_LABEL(app->status_label), glance.c_str());
|
||
}
|
||
gtk_widget_set_tooltip_text(app->status_label, app->status_detail.c_str());
|
||
if (app->status_popover != nullptr &&
|
||
gtk_widget_get_visible(app->status_popover) == TRUE) {
|
||
update_status_popover(*app, snap);
|
||
update_capture_row(*app);
|
||
}
|
||
if (app->record_pending) {
|
||
app->record_pending = false;
|
||
record_event(*app);
|
||
}
|
||
if (app->capture_button != nullptr) {
|
||
// Insensitive until connected: the capture's path is keyed on the source.
|
||
gtk_widget_set_sensitive(app->capture_button, snap.connected ? TRUE : FALSE);
|
||
if (app->capture_want_ui && !app->acq->capture_want()) {
|
||
// The producer refused the open (a full disk, a read-only directory) and cleared
|
||
// the flag; the button follows the flag rather than lying about the state. Going
|
||
// inactive runs the button's own handler, which only ever sets the flag.
|
||
app->capture_want_ui = false;
|
||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->capture_button), FALSE);
|
||
}
|
||
}
|
||
const double tick_ms = double(g_get_monotonic_time() - tick_start_us) / 1000.0;
|
||
app->dbg_tick_ms_sum += tick_ms;
|
||
app->dbg_tick_ms_max = std::max(app->dbg_tick_ms_max, tick_ms);
|
||
++app->dbg_tick_n;
|
||
return G_SOURCE_CONTINUE;
|
||
}
|
||
|
||
// --- window construction ----------------------------------------------------
|
||
|
||
|
||
|
||
|
||
|
||
/// The display-window control: a button reading the current window, and a popover holding
|
||
/// the number, its unit and a decade stepper either side.
|
||
///
|
||
/// [ 2 s ▾ ] -> [ /10 ] [ 2____ ] [ s ▾ ] [ x10 ]
|
||
///
|
||
/// A ladder dropdown could only offer the spans someone had thought of, and the useful spans
|
||
/// run from microseconds (a fast datasource's few hundred samples) to minutes; a typed number
|
||
/// plus a unit covers that without an entry per decade, and the steppers cover the common
|
||
/// case of "an order of magnitude wider" without typing at all.
|
||
GtkWidget* build_window_control(App& app) {
|
||
app.window_label = gtk_label_new("");
|
||
gtk_label_set_width_chars(GTK_LABEL(app.window_label), kWindowLabelChars);
|
||
gtk_label_set_max_width_chars(GTK_LABEL(app.window_label), kWindowLabelChars);
|
||
gtk_label_set_ellipsize(GTK_LABEL(app.window_label), PANGO_ELLIPSIZE_END);
|
||
|
||
GtkWidget* face = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
|
||
gtk_box_append(GTK_BOX(face), app.window_label);
|
||
gtk_box_append(GTK_BOX(face), gtk_image_new_from_icon_name("pan-down-symbolic"));
|
||
|
||
app.window_button = gtk_menu_button_new();
|
||
gtk_menu_button_set_child(GTK_MENU_BUTTON(app.window_button), face);
|
||
gtk_widget_set_tooltip_text(app.window_button,
|
||
"the time span on screen, and the trigger's event window");
|
||
|
||
GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
|
||
gtk_widget_set_margin_top(row, 6);
|
||
gtk_widget_set_margin_bottom(row, 6);
|
||
gtk_widget_set_margin_start(row, 6);
|
||
gtk_widget_set_margin_end(row, 6);
|
||
|
||
GtkWidget* slower = gtk_button_new_with_label("\u00f710");
|
||
gtk_widget_set_tooltip_text(slower, "a decade narrower");
|
||
g_signal_connect(slower, "clicked", G_CALLBACK(on_window_step), &app);
|
||
gtk_box_append(GTK_BOX(row), slower);
|
||
|
||
app.window_entry = gtk_entry_new();
|
||
gtk_entry_set_input_purpose(GTK_ENTRY(app.window_entry), GTK_INPUT_PURPOSE_NUMBER);
|
||
gtk_editable_set_width_chars(GTK_EDITABLE(app.window_entry), 8);
|
||
// Enter only. `changed` would re-frame the plot on every keystroke, so typing "100" would
|
||
// pass through 1 and 10 -- and each of those re-partitions nothing but does move the axis.
|
||
g_signal_connect(app.window_entry, "activate", G_CALLBACK(on_window_entry_activate), &app);
|
||
gtk_box_append(GTK_BOX(row), app.window_entry);
|
||
|
||
const char* units[kWindowUnitCount + 1] = {};
|
||
for (int i = 0; i < kWindowUnitCount; ++i) units[i] = kWindowUnits[i].name;
|
||
app.window_unit_drop = gtk_drop_down_new_from_strings(units);
|
||
g_signal_connect(app.window_unit_drop, "notify::selected", G_CALLBACK(on_window_unit_changed),
|
||
&app);
|
||
gtk_box_append(GTK_BOX(row), app.window_unit_drop);
|
||
|
||
GtkWidget* faster = gtk_button_new_with_label("\u00d710");
|
||
gtk_widget_set_tooltip_text(faster, "a decade wider");
|
||
// Which direction a stepper steps is data on the button rather than a second callback: both
|
||
// do the same thing to the same number.
|
||
g_object_set_data(G_OBJECT(faster), "udpscope-window-up", faster);
|
||
g_signal_connect(faster, "clicked", G_CALLBACK(on_window_step), &app);
|
||
gtk_box_append(GTK_BOX(row), faster);
|
||
|
||
GtkWidget* popover = gtk_popover_new();
|
||
gtk_popover_set_child(GTK_POPOVER(popover), row);
|
||
gtk_menu_button_set_popover(GTK_MENU_BUTTON(app.window_button), popover);
|
||
return app.window_button;
|
||
}
|
||
|
||
/// Any press in the window closes the time-window popover.
|
||
///
|
||
/// `GtkPopover`'s autohide does this by itself for as long as GTK holds the popover's grab -- and the grab
|
||
/// is the thing that can go missing (a window manager moving it, a nested popover taking it for the unit
|
||
/// menu, and every headless run, where there is no manager to hand one out at all), after which a click
|
||
/// anywhere else in the window leaves the bubble up. That is the "sometimes it is awkward to close".
|
||
///
|
||
/// A press that reaches the window's own surface was *not* inside the popover: a popover is its own
|
||
/// surface, so a click within it never arrives here. That is what makes this the rule stated outright
|
||
/// rather than a grab away from working -- and it needs no hit test beyond leaving the button alone,
|
||
/// whose own click is what toggles the bubble shut.
|
||
void on_window_popover_press(GtkGestureClick*, int, double x, double y, gpointer user) {
|
||
App* app = static_cast<App*>(user);
|
||
if (app->window_button == nullptr ||
|
||
!gtk_menu_button_get_active(GTK_MENU_BUTTON(app->window_button))) {
|
||
return;
|
||
}
|
||
// The button itself is left to its own click, which toggles the bubble shut -- closing it here would
|
||
// only have the toggle open it again. Anything else on the window's surface closes it.
|
||
for (GtkWidget* w = gtk_widget_pick(app->window, x, y, GTK_PICK_DEFAULT);
|
||
w != nullptr && w != app->window; w = gtk_widget_get_parent(w)) {
|
||
if (w == app->window_button) return;
|
||
}
|
||
gtk_menu_button_popdown(GTK_MENU_BUTTON(app->window_button));
|
||
}
|
||
|
||
/// The one place a key turns into an action. Toggles go through
|
||
/// gtk_toggle_button_set_active(), which emits ::toggled and so runs exactly the handler a
|
||
/// click would; the non-toggles call their click handlers with a null button, which they
|
||
/// already ignore. Split out of the key handler so the probe can fire a row directly.
|
||
/// The pane's switches and the header bar's buttons are one control with two places to click, so a
|
||
/// change in either re-reads the other. `panel_switch_sync` is the guard: setting a `GtkSwitch` emits
|
||
/// its own `notify::active`, and without it the program's write would come back as a click.
|
||
void sync_panel_switches(App& app) {
|
||
if (app.panel_switch_sync) return;
|
||
app.panel_switch_sync = true;
|
||
const auto mirror = [](GtkWidget* panel_switch, GtkWidget* toolbar_toggle) {
|
||
if (panel_switch == nullptr || toolbar_toggle == nullptr) return;
|
||
gtk_switch_set_active(GTK_SWITCH(panel_switch),
|
||
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(toolbar_toggle)) == TRUE);
|
||
};
|
||
mirror(app.trigger_switch, app.trigger_toggle);
|
||
mirror(app.cursors_switch, app.cursors_toggle);
|
||
app.panel_switch_sync = false;
|
||
}
|
||
|
||
/// A pane's own switch drives the *same action* as the header bar's button, so either place reaches
|
||
/// the feature down one path, and the sync above is what keeps the two showing the same thing.
|
||
void on_panel_switch_toggled(GObject* obj, GParamSpec*, gpointer user) {
|
||
App* app = static_cast<App*>(user);
|
||
if (app->panel_switch_sync) return;
|
||
const bool on = gtk_switch_get_active(GTK_SWITCH(obj)) != FALSE;
|
||
const bool is_trigger = app->trigger_switch != nullptr && obj == G_OBJECT(app->trigger_switch);
|
||
GtkWidget* toggle = is_trigger ? app->trigger_toggle : app->cursors_toggle;
|
||
if (toggle == nullptr) return;
|
||
// The action toggles the toolbar button, so it is run only when the two actually disagree -- which
|
||
// is what makes this a *set* rather than a second toggle of something already in that state.
|
||
if ((gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(toggle)) != FALSE) != on) {
|
||
run_app_action(*app, is_trigger ? AppAction::TriggerPanel : AppAction::CursorsPane);
|
||
}
|
||
sync_panel_switches(*app);
|
||
}
|
||
|
||
/// Both panes' modes, applied together: the trigger's body against its summary line, the two [edit]
|
||
/// buttons against what they would do, and the signal list rebuilt because the lock decides what a row
|
||
/// carries. Idempotent, so a caller only flips a flag and calls this.
|
||
void apply_pane_lock(App& app) {
|
||
if (app.trigger_body != nullptr) gtk_widget_set_visible(app.trigger_body, !app.trigger_locked);
|
||
if (app.trigger_summary != nullptr) {
|
||
gtk_widget_set_visible(app.trigger_summary, app.trigger_locked);
|
||
}
|
||
const auto edit_button = [](GtkWidget* button, bool locked, const char* what) {
|
||
if (button == nullptr) return;
|
||
// Both names must be ones Adwaita *has*: `emblem-ok-symbolic` exists only in breeze, so the "done"
|
||
// button was blank -- and a blank icon button is invisible in every other reading, which is why
|
||
// `pane edits:` checks these two by name and verdict.
|
||
gtk_button_set_icon_name(GTK_BUTTON(button),
|
||
locked ? "document-edit-symbolic" : "object-select-symbolic");
|
||
const std::string tip = std::string(locked ? "configure " : "keep this configuration of ") + what;
|
||
gtk_widget_set_tooltip_text(button, tip.c_str());
|
||
};
|
||
edit_button(app.trigger_edit, app.trigger_locked, "the trigger");
|
||
edit_button(app.signals_edit, app.signals_locked, "the signals");
|
||
sync_trigger_summary(app);
|
||
// The list, because the lock is what decides whether a row carries a tick, a menu and the `+`.
|
||
if (app.signal_list != nullptr) rebuild_signal_panels(app);
|
||
}
|
||
|
||
/// Either pane's [edit] button: one handler, because the two differ only in which flag they flip.
|
||
void on_pane_edit_clicked(GtkButton* button, gpointer user) {
|
||
App* app = static_cast<App*>(user);
|
||
if (GTK_WIDGET(button) == app->trigger_edit) {
|
||
app->trigger_locked = !app->trigger_locked;
|
||
} else if (GTK_WIDGET(button) == app->signals_edit) {
|
||
app->signals_locked = !app->signals_locked;
|
||
}
|
||
apply_pane_lock(*app);
|
||
}
|
||
|
||
void run_app_action(App& app, AppAction action) {
|
||
const auto toggle = [](GtkWidget* button) {
|
||
if (button == nullptr) return;
|
||
const gboolean now = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(button));
|
||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(button), now == TRUE ? FALSE : TRUE);
|
||
};
|
||
switch (action) {
|
||
case AppAction::PauseToggle:
|
||
toggle(app.pause_button);
|
||
break;
|
||
case AppAction::TriggerArm:
|
||
on_arm_clicked(nullptr, &app);
|
||
break;
|
||
case AppAction::TriggerPanel:
|
||
toggle(app.trigger_toggle);
|
||
break;
|
||
case AppAction::CursorsPane:
|
||
toggle(app.cursors_toggle);
|
||
break;
|
||
case AppAction::SideTabMeasure: select_side_tab(app, "measure"); break;
|
||
case AppAction::SideTabSignals: select_side_tab(app, "signals"); break;
|
||
case AppAction::LayoutEditor:
|
||
toggle(app.layout_toggle);
|
||
break;
|
||
case AppAction::SidePanel:
|
||
toggle(app.sidebar_toggle);
|
||
break;
|
||
case AppAction::Export:
|
||
on_export_clicked(nullptr, &app);
|
||
break;
|
||
case AppAction::Help:
|
||
show_help(GTK_WINDOW(app.window));
|
||
break;
|
||
case AppAction::Shortcuts:
|
||
show_shortcuts(GTK_WINDOW(app.window));
|
||
break;
|
||
case AppAction::Quit:
|
||
if (app.window != nullptr) gtk_window_close(GTK_WINDOW(app.window));
|
||
break;
|
||
}
|
||
}
|
||
|
||
/// Looks a key up in the accel table and runs it. Refuses outright while a GtkEditable has
|
||
/// focus: every bare-letter accel is also a character somebody may be typing.
|
||
gboolean on_app_key_pressed(GtkEventControllerKey*, guint keyval, guint, GdkModifierType state,
|
||
gpointer user) {
|
||
App* app = static_cast<App*>(user);
|
||
if (app->window == nullptr) return FALSE;
|
||
GtkWidget* focus = gtk_window_get_focus(GTK_WINDOW(app->window));
|
||
if (focus != nullptr && GTK_IS_EDITABLE(focus)) return FALSE;
|
||
|
||
const Accel* accel = find_accel(keyval, static_cast<unsigned>(state));
|
||
if (accel == nullptr) return FALSE;
|
||
run_app_action(*app, accel->action);
|
||
return TRUE;
|
||
}
|
||
|
||
/// Re-reads the file a *file view* is showing; true when something was reloaded.
|
||
///
|
||
/// Any structural change -- a math channel added, a signal enabled, a plan change -- rebuilds the buffer
|
||
/// and drops its history. That is right for a stream, whose producer refills it from the next packet. A
|
||
/// file has no producer, so the same rebuild leaves *every* channel reading `0 samples` and the view is
|
||
/// simply empty: "adding a math channel breaks the plot" was this, and it is the same emptiness whichever
|
||
/// structural change caused it. The file's own path is the only thing that can refill it, so it is loaded
|
||
/// again -- which is also what computes the derived channels over it, since the load's own pushes run the
|
||
/// math once the set is in place.
|
||
bool reload_file_view(App& app) {
|
||
if (!app.replaying && app.model->config().open_waveform.empty()) return false;
|
||
// Nothing to restore: a change that really happened leaves the store empty in a file view -- the
|
||
// rebuild replaces it and there is no producer to refill it -- while one that found nothing to do
|
||
// leaves it full. Reading the file again for the second kind is the waste this avoids.
|
||
if (app.model->stored_slots() > 0) return false;
|
||
const std::string path =
|
||
!app.waveform_name.empty() ? app.waveform_name : app.model->config().open_waveform;
|
||
if (path.empty()) return false;
|
||
WaveformLoadReport report;
|
||
std::string error;
|
||
if (!load_waveform_into_model(*app.model, path, &report, &error)) {
|
||
set_notice(app, "cannot reload " + path + " after the change: " + error, 6);
|
||
return false;
|
||
}
|
||
// Said out loud because it is the one place a file is read twice: a structural change really did
|
||
// drop the buffer, and the file is the only thing that can put it back.
|
||
std::fprintf(stderr, "[udpscope] reloaded %s after a structural change: %llu samples back\n",
|
||
path.c_str(), static_cast<unsigned long long>(report.samples));
|
||
return true;
|
||
}
|
||
|
||
/// The header bar carries only the controls you reach for *while watching a signal*;
|
||
/// everything you set up once lives in the overflow menu. Nine text buttons in a
|
||
/// header bar is a wall of words to read past every time you want the one you need,
|
||
/// so the four that stay are icons with tooltips.
|
||
///
|
||
/// [ 2 s ▾ ] [plots ▾] ............... [pause] [trigger] [panel] [menu]
|
||
GtkWidget* build_toolbar(App& app) {
|
||
// The bar's frame is a `.ui` file (`src/ui/header-bar.ui`): the bar, the slot the window control goes
|
||
// in, the trigger/pause/events group with its separator, and the five controls at the far end. Its
|
||
// *order* is the file's too, which is what three comments here used to explain.
|
||
GtkBuilder* ui = ui_load(app, "/org/udpscope/ui/header-bar.ui");
|
||
GtkWidget* bar = GTK_WIDGET(gtk_builder_get_object(ui, "header_bar"));
|
||
if (bar == nullptr) return gtk_header_bar_new();
|
||
app.toolbar = bar;
|
||
|
||
// The control already *is* the window's name: a `window` label in front of it said nothing the
|
||
// `2 s` beside it did not, and the bar is the busiest row in the window. It goes into the file's slot
|
||
// rather than straight onto the bar: `gtk_header_bar_pack_start()` appends *after* whatever the file
|
||
// has already packed, so a control added here would land right of the whole group.
|
||
if (GtkWidget* slot = GTK_WIDGET(gtk_builder_get_object(ui, "window_control_slot"))) {
|
||
gtk_box_append(GTK_BOX(slot), build_window_control(app));
|
||
}
|
||
refresh_window_limits(app);
|
||
|
||
// The end group reads, left to right: cursors, measurements, math, menu -- with the
|
||
// sidebar toggle to their left. `pack_end()` lays children out right to left (the first
|
||
// packed is the *rightmost*), so they are packed in the reverse of that order, which is
|
||
// what puts the menu last on screen. The trigger and the pause are *start*-packed above,
|
||
// beside the window control: their event window is read against the span on screen, so
|
||
// they belong with the control that sets it.
|
||
|
||
|
||
// --- the overflow menu ----------------------------------------------------
|
||
// Rows rather than plain buttons: the label at the left, the shortcut at the right and a
|
||
// check on the rows that toggle something, which is the shape every GNOME menu has and the
|
||
// reason this popover no longer reads as a stack of dialogs (see menu_row(), and the
|
||
// `.udpscope-menu` rules in install_css()). It stays hand-built instead of a `GMenu` model
|
||
// because these act on the `RowSet` directly and two of them are toggles whose state the
|
||
// rest of the UI reads, so an action model would only add a second copy of that state.
|
||
//
|
||
// The order is a GNOME menu's: what you do to the *view*, then the data, then the help
|
||
// trio. Nothing here needs a separator of its own -- the groups are the separators.
|
||
GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||
gtk_widget_set_margin_top(menu_box, 6);
|
||
gtk_widget_set_margin_bottom(menu_box, 6);
|
||
gtk_widget_set_margin_start(menu_box, 6);
|
||
gtk_widget_set_margin_end(menu_box, 6);
|
||
|
||
GtkWidget* fit = menu_button("Fit to data", "F");
|
||
gtk_widget_set_tooltip_text(fit, "auto-scale every axis and frame the buffered data");
|
||
g_signal_connect(fit, "clicked", G_CALLBACK(on_fit_clicked), &app);
|
||
gtk_box_append(GTK_BOX(menu_box), fit);
|
||
|
||
app.layout_toggle = menu_toggle("Layout editor", "L", false);
|
||
gtk_widget_set_tooltip_text(app.layout_toggle,
|
||
"add and remove plot rows in place: \u00d7 on a row removes "
|
||
"it, + between rows inserts one there");
|
||
g_signal_connect(app.layout_toggle, "toggled", G_CALLBACK(on_layout_editor_toggled), &app);
|
||
gtk_box_append(GTK_BOX(menu_box), app.layout_toggle);
|
||
|
||
gtk_box_append(GTK_BOX(menu_box), menu_separator());
|
||
|
||
GtkWidget* export_btn = menu_button("Export visible range...", "Ctrl+E");
|
||
gtk_widget_set_tooltip_text(export_btn,
|
||
"write every plotted signal over the visible x range, at the "
|
||
"stored sample rate, to a .npy or .csv file");
|
||
g_signal_connect(export_btn, "clicked", G_CALLBACK(on_export_clicked), &app);
|
||
gtk_box_append(GTK_BOX(menu_box), export_btn);
|
||
|
||
GtkWidget* configs = menu_button("Configurations...", nullptr);
|
||
gtk_widget_set_tooltip_text(configs,
|
||
"named sessions of this source: open one, save this arrangement "
|
||
"under a new name with a note, or delete one");
|
||
g_signal_connect(configs, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||
open_configurations(*static_cast<App*>(user));
|
||
}),
|
||
&app);
|
||
gtk_box_append(GTK_BOX(menu_box), configs);
|
||
|
||
gtk_box_append(GTK_BOX(menu_box), menu_separator());
|
||
|
||
GtkWidget* shortcuts_item = menu_button("Keyboard shortcuts", "Ctrl+/");
|
||
gtk_widget_set_tooltip_text(shortcuts_item,
|
||
"every key and mouse gesture, including the plot's own");
|
||
g_signal_connect(shortcuts_item, "clicked", G_CALLBACK(on_shortcuts_clicked), &app);
|
||
gtk_box_append(GTK_BOX(menu_box), shortcuts_item);
|
||
|
||
GtkWidget* help_item = menu_button("Help", "F1");
|
||
gtk_widget_set_tooltip_text(help_item, "the paged guide to every feature");
|
||
g_signal_connect(help_item, "clicked", G_CALLBACK(on_help_clicked), &app);
|
||
gtk_box_append(GTK_BOX(menu_box), help_item);
|
||
|
||
GtkWidget* prefs_item = menu_button("Preferences\u2026", nullptr);
|
||
g_signal_connect(prefs_item, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||
open_preferences(*static_cast<App*>(user));
|
||
}),
|
||
&app);
|
||
gtk_box_append(GTK_BOX(menu_box), prefs_item);
|
||
gtk_box_append(GTK_BOX(menu_box), menu_separator());
|
||
GtkWidget* about_item = menu_button("About udpscope", nullptr);
|
||
gtk_widget_set_tooltip_text(about_item, "version, build timestamp and settings paths");
|
||
g_signal_connect(about_item, "clicked", G_CALLBACK(on_about_clicked), &app);
|
||
gtk_box_append(GTK_BOX(menu_box), about_item);
|
||
|
||
// --- the pane toggles, which live on the bar rather than in the menu ------
|
||
// The three pane toggles, and the rest of the bar, are the file's now; their handlers are wired where
|
||
// the bar's ids are resolved, below. The popover above still shares this function's styling helpers
|
||
// (`menu_button()`, `menu_toggle()`), which is why it is built here.
|
||
|
||
GtkWidget* popover = gtk_popover_new();
|
||
gtk_widget_add_css_class(popover, "udpscope-menu");
|
||
gtk_popover_set_child(GTK_POPOVER(popover), menu_box);
|
||
app.menu_button = GTK_WIDGET(gtk_builder_get_object(ui, "menu_button"));
|
||
app.sidebar_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "sidebar_toggle"));
|
||
app.trigger_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_toggle"));
|
||
app.events_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "events_toggle"));
|
||
app.pause_button = GTK_WIDGET(gtk_builder_get_object(ui, "pause_button"));
|
||
app.cursors_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "cursors_toggle"));
|
||
if (app.menu_button == nullptr || app.sidebar_toggle == nullptr || app.trigger_toggle == nullptr ||
|
||
app.events_toggle == nullptr || app.pause_button == nullptr ||
|
||
app.cursors_toggle == nullptr) {
|
||
// A missing id is a mistake in the `.ui`: the bar comes up with the controls it got rather than taking
|
||
// the window with it, and `[udpscope] header:` reads whatever geometry is left.
|
||
return bar;
|
||
}
|
||
gtk_menu_button_set_popover(GTK_MENU_BUTTON(app.menu_button), popover);
|
||
|
||
// What each control *does*, which a file cannot hold.
|
||
g_signal_connect(app.sidebar_toggle, "toggled", G_CALLBACK(on_sidebar_toggled), &app);
|
||
g_signal_connect(app.trigger_toggle, "toggled", G_CALLBACK(on_trigger_toggled), &app);
|
||
// The event history is a *trigger* thing, and one click from the mode that produces it.
|
||
g_signal_connect(app.events_toggle, "toggled", G_CALLBACK(+[](GtkToggleButton* b, gpointer user) {
|
||
App* a = static_cast<App*>(user);
|
||
const bool open = gtk_toggle_button_get_active(b);
|
||
gtk_revealer_set_reveal_child(GTK_REVEALER(a->events_revealer), open);
|
||
// Through the one function that decides *both*: revealing the child alone leaves an
|
||
// invisible pane with no width, which is a button that appears to do nothing.
|
||
sync_events_availability(*a);
|
||
if (open) refresh_events_pane(*a);
|
||
}),
|
||
&app);
|
||
g_signal_connect(app.pause_button, "toggled", G_CALLBACK(on_pause_toggled), &app);
|
||
g_signal_connect(app.cursors_toggle, "toggled", G_CALLBACK(on_cursors_toggled), &app);
|
||
return bar;
|
||
}
|
||
|
||
void on_activate(GtkApplication* application, gpointer user) {
|
||
App* app = static_cast<App*>(user);
|
||
|
||
install_css();
|
||
// The window's skeleton is a `.ui` file: the window, its two panes, the plot column with the status
|
||
// line under it, and the sidebar's tab switcher and two-page stack. Everything that depends on the
|
||
// stream -- the events pane, the two plot areas, the sidebar's panels -- is built below and put
|
||
// into the slots by id.
|
||
g_clear_object(&app->window_ui);
|
||
app->window_ui = gtk_builder_new_from_resource("/org/udpscope/ui/main-window.ui");
|
||
if (app->window_ui == nullptr) return;
|
||
app->window = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "main_window"));
|
||
GtkWidget* side_view = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_view"));
|
||
app->side_view = side_view;
|
||
GtkWidget* events_divider = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "events_divider"));
|
||
app->plot_box = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_box"));
|
||
app->plot_split = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_split"));
|
||
app->status_badge = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_badge"));
|
||
app->status_label = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_label"));
|
||
app->status_info_button = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_info"));
|
||
GtkWidget* side = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_column"));
|
||
GtkWidget* side_stack = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_stack"));
|
||
GtkWidget* side_switcher = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_switcher"));
|
||
GtkWidget* measure_page = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "measure_page"));
|
||
GtkWidget* signals_page = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "signals_page"));
|
||
// A missing id is the one mistake neither the build nor `GtkBuilder` sees, and every use below would
|
||
// be a null pointer.
|
||
if (app->window == nullptr || side_view == nullptr || events_divider == nullptr ||
|
||
app->plot_box == nullptr || app->plot_split == nullptr || app->status_badge == nullptr ||
|
||
app->status_label == nullptr || app->status_info_button == nullptr ||
|
||
side == nullptr || side_stack == nullptr || side_switcher == nullptr ||
|
||
measure_page == nullptr || signals_page == nullptr) {
|
||
return;
|
||
}
|
||
// The window's actions live on the application, so it has to know which one it belongs to.
|
||
gtk_window_set_application(GTK_WINDOW(app->window), application);
|
||
// A window closed by hand (a WM close button) must stop the frame tick: it holds
|
||
// pointers to widgets that are finalized with the window.
|
||
g_signal_connect(app->window, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer user) {
|
||
App* a = static_cast<App*>(user);
|
||
// A pending debounced save will never fire now: flush it, so
|
||
// quitting right after a change keeps the change.
|
||
if (a->layout_save_source != 0) {
|
||
g_source_remove(a->layout_save_source);
|
||
a->layout_save_source = 0;
|
||
save_layout_now(*a);
|
||
}
|
||
a->window = nullptr;
|
||
}),
|
||
app);
|
||
// The header bar is the window's own titlebar, and it is *not* in the `.ui`: every button on it is
|
||
// wired to an action the application owns, which is code by nature. Its title and the icon the shell
|
||
// shows are the window's, and those are in the file.
|
||
gtk_window_set_titlebar(GTK_WINDOW(app->window), build_toolbar(*app));
|
||
|
||
// The size the window opens at, stated in code as well as in the `.ui`: `default-width` there is the
|
||
// size it *asks* for, and a window whose content has a larger minimum opens at that minimum instead --
|
||
// 756 px here, the plot column's own minimum plus the sidebar's 320. The old code set it explicitly;
|
||
// this is where it went when the window moved into the file, and it is why the window came up small.
|
||
gtk_window_set_default_size(GTK_WINDOW(app->window), 1280, 800);
|
||
|
||
// Nothing listens to the split view's own `show-sidebar`: it is an *output*. `AdwOverlaySplitView`
|
||
// writes it itself -- at startup, and when it collapses -- and reading that back as user input is what
|
||
// made connecting a source leave the sidebar shut. The button is the only input; `update_side_visibility`
|
||
// re-asserts the wish on top of the widget (see the tick).
|
||
|
||
// The badge and the text share one row so the badge sits at the line's head. It is a
|
||
// sibling rather than part of the label's own markup because the label's text is
|
||
// rewritten every frame and its markup is ellipsized -- a blinking span inside it would
|
||
// be reparsed sixty times a second and could be the character that gets elided.
|
||
// The badge's markup is the one value the `.ui` cannot carry (it is a phase, not a string), and its
|
||
// tooltip says which phase the badge is *not* reporting.
|
||
gtk_label_set_markup(GTK_LABEL(app->status_badge), badge_markup(BadgePhase::Idle));
|
||
gtk_widget_set_tooltip_text(app->status_badge, "waiting for the stream");
|
||
|
||
// The line is rewritten every frame and its text changes width as the counters
|
||
// grow. A label's *minimum* width is its text, so an unellipsized one pushes the
|
||
// paned divider around as the numbers change -- which resizes the plot, which
|
||
// re-lays out the axes and their ticks. Ellipsizing gives it a minimum of "…" and
|
||
// makes it fill whatever width it is given instead.
|
||
|
||
// Guard the construction so the counter means something: if status_popover is already
|
||
// non-null (e.g. run_ui called twice), count the extra build and avoid a leak.
|
||
if (app->status_popover != nullptr) {
|
||
++app->popover_rebuilds;
|
||
} else {
|
||
app->status_popover = gtk_popover_new();
|
||
GtkWidget* detail_grid = gtk_grid_new();
|
||
gtk_grid_set_row_spacing(GTK_GRID(detail_grid), 2);
|
||
gtk_grid_set_column_spacing(GTK_GRID(detail_grid), 16);
|
||
gtk_widget_set_margin_start(detail_grid, 10);
|
||
gtk_widget_set_margin_end(detail_grid, 10);
|
||
gtk_widget_set_margin_top(detail_grid, 8);
|
||
gtk_widget_set_margin_bottom(detail_grid, 8);
|
||
|
||
// Row headings at indices 0..4, value lines at 4..12, in the order format_detail's fields
|
||
// appear, then the capture's. Built once: update_status_popover() only ever calls
|
||
// gtk_label_set_text().
|
||
static const char* const kHeadings[] = {"Stream", "Buffer", "Plot 1", "Reconnects", "Capture"};
|
||
const int kHeadingRow[] = {0, 3, 5, 6, 7};
|
||
for (int i = 0; i < 5; ++i) {
|
||
GtkWidget* heading = gtk_label_new(nullptr);
|
||
char markup[64];
|
||
std::snprintf(markup, sizeof(markup), "<b>%s</b>", kHeadings[i]);
|
||
gtk_label_set_markup(GTK_LABEL(heading), markup);
|
||
gtk_label_set_xalign(GTK_LABEL(heading), 0.0f);
|
||
gtk_label_set_yalign(GTK_LABEL(heading), 0.0f);
|
||
gtk_grid_attach(GTK_GRID(detail_grid), heading, 0, kHeadingRow[i], 1, 1);
|
||
app->popover_label[i] = heading;
|
||
}
|
||
for (int i = 0; i < 8; ++i) {
|
||
GtkWidget* value = gtk_label_new("");
|
||
gtk_label_set_xalign(GTK_LABEL(value), 0.0f);
|
||
gtk_grid_attach(GTK_GRID(detail_grid), value, 1, i, 1, 1);
|
||
app->popover_label[4 + i] = value;
|
||
}
|
||
|
||
// The capture toggle, below the grid: `toggled` sets the producer's atomic and
|
||
// nothing else. Insensitive until connected, because the capture's path is keyed
|
||
// on the source and there is no source before the first connect.
|
||
app->capture_button = gtk_toggle_button_new();
|
||
GtkWidget* cap_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||
gtk_box_append(GTK_BOX(cap_box), gtk_image_new_from_icon_name("media-record-symbolic"));
|
||
gtk_box_append(GTK_BOX(cap_box), gtk_label_new("Capture packets"));
|
||
gtk_button_set_child(GTK_BUTTON(app->capture_button), cap_box);
|
||
gtk_widget_add_css_class(app->capture_button, "flat");
|
||
gtk_widget_set_margin_start(app->capture_button, 10);
|
||
gtk_widget_set_margin_end(app->capture_button, 10);
|
||
gtk_widget_set_margin_bottom(app->capture_button, 8);
|
||
gtk_widget_set_sensitive(app->capture_button, FALSE);
|
||
g_signal_connect(app->capture_button, "toggled",
|
||
G_CALLBACK(+[](GtkToggleButton* b, gpointer user) {
|
||
App* a = static_cast<App*>(user);
|
||
// The whole GTK side of the feature: the flag, and nothing else.
|
||
// The path is written once inside, before the flag is published.
|
||
a->capture_want_ui = gtk_toggle_button_get_active(b);
|
||
a->acq->set_capture_want(gtk_toggle_button_get_active(b));
|
||
}),
|
||
app);
|
||
update_capture_tooltip(*app);
|
||
|
||
GtkWidget* popover_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
|
||
gtk_box_append(GTK_BOX(popover_box), detail_grid);
|
||
gtk_box_append(GTK_BOX(popover_box), app->capture_button);
|
||
gtk_popover_set_child(GTK_POPOVER(app->status_popover), popover_box);
|
||
|
||
// Above the button, always. The status row is the last thing in the window, so "below"
|
||
// is off the window's bottom edge and, on a window that reaches the bottom of the screen,
|
||
// off the screen: the bubble's top edge and its arrow stayed at the button while the body
|
||
// was clipped away -- what reads as an arrow disconnected from its body. GTK still slides
|
||
// it sideways to fit, which is why only the vertical placement has to be stated.
|
||
gtk_menu_button_set_popover(GTK_MENU_BUTTON(app->status_info_button),
|
||
app->status_popover);
|
||
// After `set_popover()`: a `GtkMenuButton` states a position of its own (below, which is
|
||
// where a menu button in a header bar wants it), and it is the *last* word that counts.
|
||
gtk_popover_set_position(GTK_POPOVER(app->status_popover), GTK_POS_TOP);
|
||
gtk_popover_set_has_arrow(GTK_POPOVER(app->status_popover), TRUE);
|
||
}
|
||
|
||
// The right-hand side is two tabs, not five stacked panels: one tab is on screen at a time, so
|
||
// no section competes with four others for height. Each page scrolls on its own -- the two are
|
||
// different heights, and one shared scroller would leave a switch scrolled where the new page
|
||
// never asked to be.
|
||
// `side` and `side_stack`/`side_switcher`/`measure_page`/`signals_page` are resolved above with
|
||
// the rest of the skeleton and checked by the null guard; assign the members here.
|
||
app->side_box = side;
|
||
app->side_stack = side_stack;
|
||
app->side_switcher = side_switcher;
|
||
|
||
// Measure: what the cursors bracket, and the figures derived from it.
|
||
app->cursors_panel = build_cursors_panel(*app);
|
||
gtk_box_append(GTK_BOX(measure_page), app->cursors_panel);
|
||
gtk_box_append(GTK_BOX(measure_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
|
||
gtk_box_append(GTK_BOX(measure_page), build_measures_panel(*app));
|
||
|
||
// Signals: the trigger picks a channel from the list beside it.
|
||
app->trigger_panel = build_trigger_panel(*app);
|
||
gtk_box_append(GTK_BOX(signals_page), app->trigger_panel);
|
||
gtk_box_append(GTK_BOX(signals_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
|
||
app->signal_panel = build_signal_panel(*app);
|
||
gtk_box_append(GTK_BOX(signals_page), app->signal_panel);
|
||
|
||
// The cursors start off, so their readout starts insensitive -- stated here by the one place that
|
||
// knows the default, and the *body* and not the panel, because the panel's heading carries the
|
||
// switch that turns it back on. The trigger's body is deliberately *not* disabled: a trigger is
|
||
// configured before it is switched on, so its settings have to stay editable while it is off.
|
||
gtk_widget_set_sensitive(app->cursors_body, FALSE);
|
||
gtk_widget_set_sensitive(app->cursors_reset, FALSE);
|
||
|
||
if (app->side_stack != nullptr) {
|
||
adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(app->side_stack), "signals");
|
||
g_signal_connect(app->side_stack, "notify::visible-child-name",
|
||
G_CALLBACK(on_side_tab_changed), app);
|
||
}
|
||
|
||
// The left side is the events pane *and* the plot, in one row: the pane is a column of its own
|
||
// (the item's "collapsable pane on the left"), revealed by trigger mode.
|
||
// A *paned*: the pane's width is the user's to choose, and the plot takes what is left.
|
||
// The events pane is the one subtree of the inner divider that is built: whether it exists at all
|
||
// depends on trigger mode.
|
||
gtk_paned_set_start_child(GTK_PANED(events_divider), build_events_pane(*app));
|
||
// Once, now that both the button and the pane exist: trigger mode is off, so the pane is closed and
|
||
// its button is not on the bar -- stated by the one function that decides it rather than by whichever
|
||
// widget happened to be built with which default.
|
||
sync_events_availability(*app);
|
||
app->events_divider = events_divider;
|
||
|
||
// Two plots, in a paned so a comparison's divider can move: the primary takes everything while the
|
||
// second is hidden, and the width is the user's once it is not.
|
||
// Nothing to state about the sidebar's size. It is the split view's: 320 px from the `.ui`, floored
|
||
// at 240, and never a number derived from the window -- which is what the removed
|
||
// `gtk_paned_set_position(paned, 940)` was, and why a window narrower than 940 px gave the sidebar
|
||
// no width at all.
|
||
|
||
// The split is in the `.ui`; what goes in it is not, because an area is a GL surface with a figure
|
||
// handed to it.
|
||
gtk_paned_set_start_child(GTK_PANED(app->plot_split), make_area(*app));
|
||
gtk_paned_set_end_child(GTK_PANED(app->plot_split), make_compare_area(*app));
|
||
|
||
// A waveform opened from a file is already in the model by the time the UI is built -- there is no
|
||
// drop and no CONFIG to wait for -- so its channels are bound to the rows here: four traces to a
|
||
// row, more rows when the file has more columns. This is the drop handler's job for a live source,
|
||
// done once. The samples stay: they are the file's, and the plot draws from the same rings a
|
||
// streamed run fills.
|
||
const std::string waveform = app->model->config().open_waveform;
|
||
if (!waveform.empty()) {
|
||
app->waveform_name = waveform;
|
||
const std::vector<ChannelInfo>& loaded = app->model->channels();
|
||
if (!loaded.empty()) {
|
||
// **One column per row**, not four to a row. Four to a row fills the default single row
|
||
// completely, and then every drop is refused -- "plot 1 already holds four signals" -- which
|
||
// is a loaded file the user cannot plot anything else on, and cannot rearrange. One per row
|
||
// leaves three free slots on each, shows a four-signal file as the four stacked traces a
|
||
// bench scope would, and keeps the remaining columns draggable from the panel.
|
||
// One column per row, and the row count was already sized for exactly that before the UI
|
||
// existed (`main.cpp`, the way `--plots` does it): a `set_row_count()` *here* leaves the
|
||
// panels built for the old count, and the app hangs on the first relayout rather than
|
||
// drawing. Four to a row would fit one row, fill it, and refuse every later drop.
|
||
for (std::size_t c = 0; c < loaded.size(); ++c) {
|
||
const int row = static_cast<int>(c);
|
||
if (row >= app->rows->row_count()) break;
|
||
app->rows->assign(row, 0, loaded[c].name, loaded[c].name,
|
||
signal_color_index(app->model->signals(), loaded[c].signal));
|
||
}
|
||
// The figure is rebuilt with them on the first tick -- the drop handler's own contract.
|
||
app->relayout_pending = true;
|
||
app->rows->fit_all();
|
||
std::string base = waveform;
|
||
const std::size_t slash = base.rfind('/');
|
||
if (slash != std::string::npos) base = base.substr(slash + 1);
|
||
set_notice(*app, "opened " + base + ": " + std::to_string(loaded.size()) + " signals, " +
|
||
std::to_string(app->rows->row_count()) + " rows");
|
||
}
|
||
}
|
||
rebuild_signal_panels(*app);
|
||
g_timeout_add(kTickMs, tick, app);
|
||
|
||
// Bubble phase on purpose: a focused GtkEditable or GtkButton wins its own keys, so typing
|
||
// 'm' into the window entry does not toggle the measurements pane and Space does not
|
||
// double-fire on a focused button.
|
||
GtkEventController* keys = gtk_event_controller_key_new();
|
||
gtk_event_controller_set_propagation_phase(keys, GTK_PHASE_BUBBLE);
|
||
g_signal_connect(keys, "key-pressed", G_CALLBACK(on_app_key_pressed), app);
|
||
gtk_widget_add_controller(app->window, keys);
|
||
|
||
// Capture phase, unlike the keys above: a press belongs to whatever widget is under it (the plot's
|
||
// gestures among them), and the point here is to see every one of them, not to win none of them.
|
||
GtkGesture* press = gtk_gesture_click_new();
|
||
gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(press), GTK_PHASE_CAPTURE);
|
||
g_signal_connect(press, "pressed", G_CALLBACK(on_window_popover_press), app);
|
||
gtk_widget_add_controller(app->window, GTK_EVENT_CONTROLLER(press));
|
||
|
||
gtk_window_present(GTK_WINDOW(app->window));
|
||
|
||
// The loading window covered the gap from the chooser to here -- the buffer, the file read and
|
||
// the whole of the construction above, none of which returns to a main loop. It goes down only
|
||
// now, *after* the present: destroying it before would leave the screen empty again for the
|
||
// frame or two the real window takes to map. A no-op when there was no chooser.
|
||
loading_hide();
|
||
|
||
// What the window is waiting for, said *before* the first tick. The stream's first CONFIG plans
|
||
// the buffer on the producer thread, which holds the model's mutex for the whole of it -- at a few
|
||
// gigabytes that is around a second per gigabyte, and the frame tick cannot read the model while
|
||
// it runs, so the window does not change. Set here it is on screen already: the present paints a
|
||
// frame before any timeout fires, which is the difference between waiting and "the dialog
|
||
// disappeared and nothing happened".
|
||
if (app->waveform_name.empty()) {
|
||
const Config& cfg = app->model->config();
|
||
char buf[192];
|
||
std::snprintf(buf, sizeof(buf), "connecting to %s:%u -- planning the %.0f MB buffer",
|
||
cfg.server.c_str(), unsigned(cfg.port), cfg.buffer_mb);
|
||
set_notice(*app, buf, 6);
|
||
}
|
||
}
|
||
|
||
/// Headless self-check, step 2: read the panel geometry while the widgets still
|
||
/// exist, then close the window. Runs after the reveal animations have settled.
|
||
|
||
/// Step 2: record the status line while trigger mode is on, then leave trigger
|
||
/// mode the way the "Go live" button does, and stop the tick.
|
||
|
||
/// Headless self-check, step 1: toggle the trigger the way the toolbar button
|
||
/// does, then let the reveal animation settle before measuring.
|
||
/// Headless self-check: arm the trigger, then disarm it the way the button does,
|
||
/// so the check can prove no event latched after the disarm.
|
||
/// Finds a toolbar/panel button by its label, so the self-check drives the *user's*
|
||
/// path (a toggle's handler, a button's click) rather than poking the model -- which
|
||
/// the frame tick then legitimately corrects.
|
||
GtkWidget* find_labelled_button(GtkWidget* w, const char* label) {
|
||
if (w == nullptr) return nullptr;
|
||
if (GTK_IS_BUTTON(w)) {
|
||
const char* l = gtk_button_get_label(GTK_BUTTON(w));
|
||
if (l != nullptr && std::strcmp(l, label) == 0) return w;
|
||
}
|
||
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
|
||
c = gtk_widget_get_next_sibling(c)) {
|
||
if (GtkWidget* hit = find_labelled_button(c, label)) return hit;
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
/// Headless self-check: sample the trigger state machine and what the plot is
|
||
/// actually bound to, so the arm -> capture -> latch -> wait cycle is observable
|
||
/// headlessly. Two things in this line are assertions in disguise:
|
||
///
|
||
/// * with `trigger=on`, `display=` must never read `live` -- in trigger mode the
|
||
/// picture changes only on a latch;
|
||
/// * `snap=[a..b]` and `axis=[a..b]` must agree, and `b` must be positive by
|
||
/// roughly the post-trigger share of the span. A post side that is empty, or a
|
||
/// snapshot that does not fill the axis, is the defect this probe exists for.
|
||
|
||
|
||
/// Headless self-check: waits for a *held* event, then lets the tick run the zoom sweep (see
|
||
/// `report_event_zoom`). Polling for `Held` rather than firing on a fixed delay is the same
|
||
/// lesson the disarm probe learned: a latch happens when the stream says so, and a sweep that
|
||
/// starts before one has nothing to zoom into.
|
||
gboolean eventzoom_wait(gpointer user) {
|
||
App* a = static_cast<App*>(user);
|
||
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||
if (a->model->display_mode() != DisplayMode::Held) return G_SOURCE_CONTINUE;
|
||
// Eight halvings: a 2 s event down to about 8 ms, which crosses the pyramid's own
|
||
// raw/LOD gate on the way and so exercises both gather paths. Then the same again
|
||
// through the window control.
|
||
a->dbg_zoom_steps = kZoomGestureSteps + kZoomWindowSteps;
|
||
a->dbg_zoom_step_no = 0;
|
||
return G_SOURCE_REMOVE;
|
||
}
|
||
|
||
/// Single trigger mode, armed: one event, held for as long as the sweep needs it.
|
||
|
||
/// Headless self-check: defines math channels the way the math pane will, so the *pipeline* can be
|
||
/// checked without driving the editor -- the same graph reaches the producer either way. Two graphs
|
||
/// on the first acquired channel: a gain of 2 and a low pass, which the dump then reports beside the
|
||
/// source's own extremes, so "twice the amplitude" and "attenuated" are read off real samples.
|
||
|
||
/// Headless self-check: adds one measurement of each statistical type, which is what a user does
|
||
/// from the pane's `+`.
|
||
///
|
||
/// It exists because the frame cost with measurements on is a thing to *measure*, not to reason
|
||
/// about: a measurement is a statistic of the visible window, and a live view moves that window
|
||
/// every frame. The values land in the exit dump (`measurements:`), next to `frame cost:`.
|
||
|
||
|
||
|
||
/// Headless self-check for the statistics line: turns the pane-wide toggle on over the
|
||
/// measurements `measure_probe` added, lets several recomputes land, and reports each
|
||
/// accumulator. `n` must grow with the passes and never with the samples, and a Reset must
|
||
/// put every `n` back to zero.
|
||
///
|
||
/// Three phases, because "one observation per recompute" needs two different witnesses. Phase 0
|
||
/// is the running count. Phase 1 is the same count after a **pause held for two seconds**: a
|
||
/// pinned window recomputes once when it settles and then never again, so `n` must advance by
|
||
/// *at most one* across the pause -- that is what separates accumulating per recompute from
|
||
/// accumulating per frame, which would add a hundred and twenty observations of the identical
|
||
/// window and collapse `sd`. Phase 2 is after a Reset.
|
||
|
||
|
||
|
||
/// Headless self-check: a gesture must not move a pinned window.
|
||
///
|
||
/// It writes the far-away range the way a gesture does -- on the *member* axis, which
|
||
/// `Figure::layout()` adopts into the group -- and then reports the axis the frame after it
|
||
/// produced: `held=1` when the pin survived, and the panned range when it did not.
|
||
|
||
/// Headless self-check: arms the trigger the way the toolbar's trigger switch does.
|
||
|
||
/// The capture the renumber probe opened: the edge it opened on, and the latch count
|
||
/// before it, so step 3 can tell a new latch from the one already on screen.
|
||
|
||
/// Headless self-check, step 3: waits for the capture to latch and reports whether the
|
||
/// edge it latched on is still the one it opened on.
|
||
///
|
||
/// `t0_held=1` is the reading. With the engine's invalidation keyed on the trigger
|
||
/// channel's *index* rather than its name, step 2's renumber abandoned the capture and
|
||
/// set no hold-off, so the next edge inside the post window became t0 and this read 0.
|
||
|
||
/// Headless self-check, step 2: the moment a capture is open, enables a signal that sorts
|
||
/// *before* the trigger's own -- which is what renumbers the trigger channel under the
|
||
/// engine. Polls rather than times, so it lands inside the post window whatever the rate.
|
||
|
||
/// Headless self-check, step 1: the reported geometry, driven through the real widgets --
|
||
/// 1 s window, 10 % pre share, hold-off from `--holdoff` -- on the *square* alone, so that
|
||
/// enabling the sine later moves the trigger channel's index.
|
||
|
||
/// Headless self-check: changes the trigger panel's two dropdowns, which is what a user
|
||
/// does to switch Normal/Single or the edge -- mid-capture, if the probe is timed into
|
||
/// one. Reaching the dropdowns directly is the point: their `notify::selected` handler
|
||
/// has a different signature from the spin buttons' `value-changed`, and connecting the
|
||
/// wrong one crashed on the first field read with no way to tell from the state dump.
|
||
/// A line here with no SIGSEGV after it is the assertion.
|
||
|
||
/// Headless self-check for the qualified conditions. Selected by
|
||
/// `UDPSCOPE_PROBE_CONDITION_SHAPE` (0 = pulse `<`, 1 = pulse `>`, 2 = window, 3 = dropout).
|
||
///
|
||
/// The assertion is snapshot-relative rather than an absolute t0. The app's stream clock
|
||
/// epoch is the first packet it received, not the mock's t = 0; the two clocks differ by
|
||
/// however many packets the CONNECT handshake cost (typically a whole number of 100-sample /
|
||
/// 10 ms packets), so comparing trigger_time() against a mock-clock target is meaningless.
|
||
///
|
||
/// Shape 0 (pulse `<`): x = 0 is the glitch's closing edge. Scans backwards for the most
|
||
/// recent upward crossing; `measured = -stamp` is the glitch width (≈ 0.002 s). Window 0.2 s,
|
||
/// 10% pre (0.02 s back) — the glitch is 0.002 s wide so 0.02 s comfortably contains it.
|
||
///
|
||
/// Shape 1 (pulse `>`): same scan; `measured` is the ordinary square half-period (≈ 0.200 s).
|
||
/// Needs a 1.0 s window with 50% pre (0.5 s) because the opening edge of the captured pulse
|
||
/// is 0.2 s before x = 0, beyond the 0.02 s pre a 0.2 s window would provide.
|
||
///
|
||
/// Shape 2 (window): edge is set to `falling` (out-of-band), so x = 0 is the moment the
|
||
/// signal left the band. Uses the sine channel because the square has no sample inside
|
||
/// [-0.5, +0.5] and can never produce a band exit. Reports `at0` (the sample at x = 0, which
|
||
/// must be outside the band) and `earlier` (the sample immediately before it, which must be
|
||
/// inside). Window 0.2 s, 10% pre — the crossing is one sample before x = 0.
|
||
///
|
||
/// Shape 3 (dropout): x = 0 is the moment the timeout expired. Scans backwards for the most
|
||
/// recent crossing in either direction, because the last sine crossing before the quiet period
|
||
/// may be downward, and an upward-only scan would miss it. `timeout` must exceed the sine's
|
||
/// 0.1 s half-period; 0.3 s is used so that an ordinary live sine cannot satisfy the
|
||
/// condition. Needs a 1.0 s window with 50% pre because the crossing is `timeout` ≈ 0.3 s
|
||
/// before x = 0.
|
||
///
|
||
/// All legs print one `[condition-probe]` line with `nan` for any unmeasurable field.
|
||
|
||
// Returns the channel name required by the given shape: shapes 2 and 3 use the sine (the
|
||
// square has no sample inside [-0.5, +0.5] and cannot satisfy a window condition, and the
|
||
// dropout relies on the sine going quiet); shapes 0 and 1 use the square.
|
||
|
||
|
||
|
||
|
||
/// Headless self-check for the buffer search, and for the claim that makes it worth having:
|
||
/// a scanned hit is an instant the *live trigger* would have fired on.
|
||
///
|
||
/// It arms the same pulse-width condition `condition_probe` arms, waits for a real latch,
|
||
/// then searches the buffer for that condition and compares the latched t0 with the nearest
|
||
/// scanned hit. `agree=1` is the assertion; `agree=0` means the two evaluators have drifted
|
||
/// apart, which is the whole failure mode separating `EventDetector` out was meant to
|
||
/// prevent. It then steps to a hit and reports the pinned axis against it.
|
||
|
||
/// Reports the marks a *frame* after the jump, because that is the only moment they can be read
|
||
/// honestly: `search_jump()` publishes a pause view, but the axis it is pinned against and the
|
||
/// `view_range()` that `place_search_marks()` filters by are both settled by the frame tick, not
|
||
/// by the publish. Reading `search_marks_placed()` in the same callback as the jump would quote
|
||
/// the window the scope was on *before* it moved.
|
||
///
|
||
/// This cannot be read from the exit dump instead. The exit probe stops the tick from `Live`,
|
||
/// where the newest window holds no hit at all, so a dump reading is `placed=0` whatever the
|
||
/// marks did -- true of a correct implementation and of a broken one alike. The dump's own line
|
||
/// asserts the thing that does survive going live: that the new cursor pool is unselectable.
|
||
|
||
|
||
|
||
|
||
/// Headless self-check: drives the window control the four ways a user can -- type a number,
|
||
/// change the unit under it, and step a decade either way -- and prints what each produced.
|
||
/// Each line reports the typed request, the window the plot took and the trigger's event
|
||
/// span: all three must agree except where the cap clamps, and the event span must track the
|
||
/// display window or a capture would be cut to a different length than the axis frames.
|
||
|
||
|
||
/// One line of the layout probe: the row count, how many buttons are on the overlay, the
|
||
/// rows' own rects in logical pixels, where each button was placed, and what every row is
|
||
/// plotting. The last of those is the point of the whole probe -- an insert or a remove
|
||
/// has to move the rows' *contents* with them, not renumber which row a signal is on.
|
||
|
||
/// Headless self-check: drives the layout editor the way a user does -- switch it on,
|
||
/// insert above the first row, remove a row, append one at the bottom, switch it off --
|
||
/// with a frame between each step, because the buttons are placed from the rects the last
|
||
/// frame was laid out with.
|
||
|
||
|
||
|
||
|
||
/// Headless self-check: acquire every signal the CONFIG described, the way the
|
||
/// signal panel's switches would, and put each resulting channel on a plot, the way
|
||
/// a drag from the panel onto a row would. Without the second half nothing is bound
|
||
/// and every downstream number -- trace sizes, painted frames, what the trigger
|
||
/// displays -- reads zero whatever the pipeline is doing.
|
||
|
||
|
||
|
||
/// Headless self-check, the half that reads the *outcome* of the Disarm. A disarm that
|
||
/// landed mid-capture (`was_capturing=1`) must leave an event on screen with the
|
||
/// capture's own span: `display` reading `nothing` there is the "the event completely
|
||
/// disappeared" defect, and `seq` must have advanced by one while `events` has not.
|
||
|
||
/// Waits for a capture to actually be open before clicking Disarm. The interesting case
|
||
/// is the Disarm that lands *mid-capture*, and a fixed delay catches it only by luck --
|
||
/// the capture opens as soon as the ring holds a pre window and closes one post window
|
||
/// later. Polling for `trigger_pending()` makes the probe deterministic.
|
||
|
||
|
||
/// What the pause work is *for*: the acquisition keeps filling the ring while the picture is
|
||
/// frozen on the view the pause snapshotted, and resuming shows the data that arrived meanwhile
|
||
/// instead of a hole. Reported from the probe below.
|
||
|
||
/// Headless self-check: pause the acquisition and reset the movement sample, so
|
||
/// what is measured is a still picture -- the state a wiggle is most visible in.
|
||
|
||
/// Headless self-check for the export: collect the same table the menu item would and
|
||
/// write it both ways, skipping only the file chooser. Two numbers are the assertion --
|
||
/// `time=` must equal `axis=` (the export is the *visible* range, not the whole ring)
|
||
/// and `rows=` must be that span times the stream's real rate, so a table cut from the
|
||
/// LOD pyramid or from a stale window shows up as a count far short of it.
|
||
|
||
|
||
|
||
/// Reports the cursors: the instruments' values, whether the pane is up, and the pane's
|
||
/// own text (which is what a user reads, so it is what the probe asserts).
|
||
|
||
|
||
/// The two fits the right-click menu offers, driven through the handlers the menu wires
|
||
/// (a null button: neither handler looks at it). Both must put x back on the whole time window
|
||
/// and **leave the window itself alone**; `fit x` must leave the y ranges alone too, and
|
||
/// `fit all` must move them to the data's.
|
||
|
||
/// The x label form across a zoom, driven through the *app's* window path (`apply_time_window`:
|
||
/// under the figure's x link group the group owns the window, so a row-level `set_time_window`
|
||
/// is ignored). Absolute at 2 s and at 500 ms, relative below a second with the caption naming
|
||
/// the anchor and the ticks written against it.
|
||
|
||
/// Zooms row 0 in to a quarter of what it shows, the way a gesture does -- on the *member* axis,
|
||
/// which `Figure::layout()` adopts into the group. A fit is the undo of this, so the probe has to
|
||
/// perform it or it has nothing to undo.
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
/// Drives the plot's keyboard zoom with Ctrl and with Alt and reports which axis moved.
|
||
/// The ranges are read **synchronously**, in this callback, which is deliberate and is the
|
||
/// opposite of what the pan and zoom probes do. The question here is only "does the
|
||
/// controller's modifier restriction reach the app", so the read has to see the
|
||
/// controller's own write and nothing else: `hold_pinned_window()` clamps the *x* span
|
||
/// into the pinned window, so a reading taken a frame later would report that clamp's
|
||
/// answer rather than the restriction's for the ctrl case. Nor can a same-callback read
|
||
/// drift past a state boundary, which is the hazard the read-in-the-tick rule exists for.
|
||
/// Whether a zoom the controller performed is then *adopted* by the figure is a different
|
||
/// question, and `[pan-probe]`'s `zoom-in asked=/group=/member=` line is what asserts it.
|
||
/// Pausing first is therefore not required, only tidy — it is how Task 11 runs it:
|
||
/// UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200
|
||
|
||
/// How tall a menu row may be. Adwaita's own minimum for a button is 34 px, and a popover of
|
||
/// seven such rows is what made the overflow read as a stack of dialogs; the `.udpscope-menu`
|
||
/// rules trim it to a menu row and this is what says they are still applied.
|
||
|
||
|
||
|
||
/// The label and the shortcut of one row, in the order menu_row() appends them. The optional
|
||
/// check image comes first and is not a label, so this counts labels rather than positions.
|
||
void menu_row_labels(GtkWidget* box, GtkWidget** label, GtkWidget** accel) {
|
||
*label = nullptr;
|
||
*accel = nullptr;
|
||
for (GtkWidget* child = box != nullptr ? gtk_widget_get_first_child(box) : nullptr;
|
||
child != nullptr; child = gtk_widget_get_next_sibling(child)) {
|
||
if (!GTK_IS_LABEL(child)) continue;
|
||
if (*label == nullptr) {
|
||
*label = child;
|
||
} else {
|
||
*accel = child;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The two popovers, measured, with the side panel hidden -- the state the report is about.
|
||
/// What it asserts, row by row, is what a screenshot can only show an eye: the label at the
|
||
/// left of its row, the shortcut at the right of it, a row under kMenuRowMaxPx, the rows in
|
||
/// the intended order, and an info bubble placed above its button with room for its height.
|
||
|
||
/// Checks that a chosen trace colour reaches the **trace** and not merely the resolver.
|
||
///
|
||
/// It reads the colour back out of `plot::Plot::trace()` -- what the renderer will stroke -- and out
|
||
/// of the signal list's chip markup, because the chip's only job is to name a trace and the two
|
||
/// resolving a colour by separate routes is the one failure a chip cannot survive. The override is
|
||
/// `#123456`, deliberately in no palette slot: an override that happened to equal the entry it
|
||
/// replaced would pass against an implementation that ignored it entirely.
|
||
|
||
/// `UDPSCOPE_PROBE_RESEQ_MS`: one reading of the resequencing path, mid-run.
|
||
///
|
||
/// The counters are in the exit dump too; what is only here is `nan_slots`, `trigger_in_hole` and
|
||
/// `bands`. The first is the state of the *data* rather than of a counter: a reservation writes
|
||
/// placeholders and a backfill repairs them, and the two counters agreeing proves only that the
|
||
/// same number of slots was written and read, not that the right ones were. The second asserts the
|
||
/// trigger never latched inside a reserved span. The third asserts the band reached the plot, and
|
||
/// splits it by state: a hole whose late packets have all arrived keeps its band in the fainter
|
||
/// tint, so `bands_repaired` is how a reading tells a window that is *missing* data from one that
|
||
/// merely *was*.
|
||
|
||
|
||
|
||
/// Toggles the capture **through the same handler the button uses**, lets the stream
|
||
/// run, then reads the file back off disk and prints what it holds. `stored` must equal
|
||
/// the datagrams the exit dump counts from the toggle's instant, `torn` must be 0, and
|
||
/// `first_seq` must be 1; with `--capture-packets=N` and a long enough run, `stored`
|
||
/// exceeds the slot count and `last_seq - first_seq == N - 1` -- the wrap, which shows
|
||
/// here and nowhere else.
|
||
|
||
|
||
|
||
|
||
/// The probe's readings across its steps: each view is measured a frame after the one before it was
|
||
/// swapped out, because a popover re-lays out its new child on the next layout pass -- measured in
|
||
/// the same call, the rows are unallocated (`h=0`), which reads like a style defect and is not one.
|
||
|
||
/// A row of the menu, found by the label it shows: the menu is rebuilt on every view change, so no
|
||
/// widget handle survives one.
|
||
GtkWidget* row_menu_find(GtkWidget* container, const char* want) {
|
||
for (GtkWidget* row = container != nullptr ? gtk_widget_get_first_child(container) : nullptr;
|
||
row != nullptr; row = gtk_widget_get_next_sibling(row)) {
|
||
if (!GTK_IS_BUTTON(row)) continue;
|
||
GtkWidget* label = nullptr;
|
||
GtkWidget* accel = nullptr;
|
||
menu_row_labels(gtk_widget_get_first_child(row), &label, &accel);
|
||
if (label != nullptr && std::string(gtk_label_get_text(GTK_LABEL(label))) == want) return row;
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
/// One view's rows, measured. `bad` counts anything outside the compact style: a row taller than
|
||
/// `kMenuRowMaxPx`, a label not left of its shortcut, or a label past the indent a leading icon
|
||
/// costs (the Back row's arrow -- every menu indents a Back row).
|
||
void row_menu_measure(GtkWidget* box, const char* tag, int* rows_out, int* bad_out) {
|
||
int rows = 0;
|
||
int bad = 0;
|
||
std::string labels;
|
||
for (GtkWidget* row = box != nullptr ? gtk_widget_get_first_child(box) : nullptr; row != nullptr;
|
||
row = gtk_widget_get_next_sibling(row)) {
|
||
if (!GTK_IS_BUTTON(row)) continue;
|
||
GtkWidget* label = nullptr;
|
||
GtkWidget* accel = nullptr;
|
||
menu_row_labels(gtk_widget_get_first_child(row), &label, &accel);
|
||
const int row_w = gtk_widget_get_width(row);
|
||
const int h = gtk_widget_get_height(row);
|
||
const graphene_point_t origin = GRAPHENE_POINT_INIT(0.0f, 0.0f);
|
||
graphene_point_t at = GRAPHENE_POINT_INIT(0.0f, 0.0f);
|
||
const int label_x = label != nullptr && gtk_widget_compute_point(label, row, &origin, &at)
|
||
? static_cast<int>(at.x) : -1;
|
||
const int accel_right = accel != nullptr && gtk_widget_compute_point(accel, row, &origin, &at)
|
||
? static_cast<int>(at.x) + gtk_widget_get_width(accel) : -1;
|
||
const bool left = label_x >= 0 && label_x <= 60;
|
||
const bool right = accel == nullptr || (accel_right > 0 && row_w - accel_right <= 12);
|
||
const bool compact = h > 0 && h <= kMenuRowMaxPx;
|
||
const bool ok = left && right && compact;
|
||
if (!ok) ++bad;
|
||
const char* text = label != nullptr ? gtk_label_get_text(GTK_LABEL(label)) : "?";
|
||
labels += (labels.empty() ? "" : ",") + std::string(text);
|
||
std::fprintf(stderr, "[rowmenu] %s %d '%s' h=%d label_x=%d accel_right=%d/%d ok=%d\n", tag,
|
||
rows, text, h, label_x, accel_right, row_w, ok ? 1 : 0);
|
||
++rows;
|
||
}
|
||
std::fprintf(stderr, "[rowmenu] %s rows=%d bad=%d labels=[%s]\n", tag, rows, bad, labels.c_str());
|
||
*rows_out = rows;
|
||
*bad_out = bad;
|
||
}
|
||
|
||
/// Opens the plot's context menu on row 0 and walks its two views: the main rows, the remove view
|
||
/// it swaps in, Back to the main view, and into the remove view a *second* time. The last one is the
|
||
/// case that used to break -- a `GtkMenuButton` collapsed on the second click and left its row dead,
|
||
/// which is why the submenu is a second view of this menu rather than a popover of its own.
|
||
|
||
|
||
|
||
/// How many traces a row is *actually drawing*: the figure's answer, not the slots'.
|
||
int group_visible_traces(App& app, int row) {
|
||
if (app.rows == nullptr || row < 0 || row >= app.rows->row_count()) return -1;
|
||
const RowSet::Row& r = app.rows->row(row);
|
||
if (r.plot == nullptr) return -1;
|
||
int visible = 0;
|
||
for (plot::u32 t = 0; t < r.plot->trace_count(); ++t) {
|
||
if (r.plot->trace(t).visible()) ++visible;
|
||
}
|
||
return visible;
|
||
}
|
||
|
||
void report_events_availability(App& a, const char* what) {
|
||
std::fprintf(stderr, "[events] %s: button_visible=%d pane=%d pane_w=%d tick=%d recording=%d\n",
|
||
what,
|
||
a.events_toggle != nullptr && gtk_widget_get_visible(a.events_toggle) ? 1 : 0,
|
||
a.events_revealer != nullptr &&
|
||
gtk_revealer_get_reveal_child(GTK_REVEALER(a.events_revealer)) ? 1 : 0,
|
||
a.events_divider != nullptr ? gtk_paned_get_position(GTK_PANED(a.events_divider)) : -1,
|
||
signal_tick_visible(a), a.event_recording ? 1 : 0);
|
||
}
|
||
|
||
|
||
/// Walks the whole event-cache loop the way a user does: trigger mode on (the real toggle, which is
|
||
/// what reveals the pane), a latch recorded automatically, the cache listed, the newest event
|
||
/// replayed through the loader `--open` uses, and *Live* taken back. What it reports is what each
|
||
/// step must be able to do -- a cache nothing can replay is a directory of files, and a replay with
|
||
/// no way back is a one-way door.
|
||
|
||
/// The two group layouts, driven through the buttons a user presses. What they must read is what the
|
||
/// figure *draws*, and what the second plot *is*.
|
||
|
||
|
||
/// Drives the drop handler the way a drag from the signal panel does, and reports what the row holds
|
||
/// afterwards. This is the path a user's "plot this signal" takes, so it is the one that says
|
||
/// whether a *loaded file* can be plotted at all: a file-mode run whose rows are sized four signals
|
||
/// to one row fills its only row, refuses every drop with "plot 1 already holds four signals", and
|
||
/// reads exactly like a scope that cannot plot anything.
|
||
|
||
/// Every spin button under `w`, in the order they were built. A `GtkSpinButton` starts at its
|
||
/// range's **minimum**, so an unseeded one is a number nobody chose -- which is exactly how a
|
||
/// calibration offset opened at -1e12 and how a trigger level did too.
|
||
void collect_spins(GtkWidget* w, std::vector<double>* out) {
|
||
if (w == nullptr) return;
|
||
if (GTK_IS_SPIN_BUTTON(w)) out->push_back(gtk_spin_button_get_value(GTK_SPIN_BUTTON(w)));
|
||
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
|
||
c = gtk_widget_get_next_sibling(c)) {
|
||
collect_spins(c, out);
|
||
}
|
||
}
|
||
|
||
/// Opens the two dialogs a user reads *before* touching anything -- Calibrate and the trigger
|
||
/// panel -- and reports every value in them. Each must be the field's own default; a `-1e12` here
|
||
/// is an unseeded spin button the dialog would then apply.
|
||
|
||
/// Fires a representative accel from each group and reports the state it changed, then
|
||
/// checks the GtkEditable refusal. It goes through run_app_action() for the actions and
|
||
/// through the real handler for the refusal, because the refusal lives in the handler.
|
||
|
||
/// Loads and validates every help page, opens all three windows, closes them, and reports.
|
||
/// `parsed` must equal `pages` and no page may be empty; `closed ok` with no GTK critical
|
||
/// is the crash check for three windows built and destroyed in one run.
|
||
|
||
|
||
int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisition& acq,
|
||
RowSet& rows) {
|
||
(void)argc;
|
||
// Installs libadwaita's stylesheet, which the shortcut overview's `AdwShortcutsDialog`
|
||
// needs to be drawn at all. Idempotent, and `choose_source()` may already have run it.
|
||
adw_init();
|
||
// The preferences are read before anything is built: the theme decides what the window looks like,
|
||
// and the rest is in force from the first frame. A *local* until `App` exists below, which is why
|
||
// this one read happens here and the assignment after it.
|
||
const Settings startup_settings = load_settings();
|
||
apply_theme(startup_settings.theme);
|
||
// The app's own icons, before anything asks for one. They are a GResource compiled into the
|
||
// binary (see CMakeLists), and this is what makes the theme look there: an unregistered path
|
||
// is not an error, it is a missing-image placeholder.
|
||
//
|
||
// It is here rather than in `build_toolbar()` because there are now two consumers -- the
|
||
// window's own icon and the toolbar's buttons -- and this is the one place that is ahead of
|
||
// both. `adw_init()` calls `gtk_init()`, which opens the default display, so the line above
|
||
// is what makes `gdk_display_get_default()` valid here.
|
||
gtk_icon_theme_add_resource_path(gtk_icon_theme_get_for_display(gdk_display_get_default()),
|
||
"/org/udpscope/icons");
|
||
App app;
|
||
app.settings = startup_settings;
|
||
app.model = &model;
|
||
app.acq = &acq;
|
||
app.rows = &rows;
|
||
// Which data source this session talks to: the persisted-layout file's name.
|
||
// A waveform has **no source**: the address the command line defaults to is not one, and treating
|
||
// it as one made a file-mode run load -- and then *save* -- the session of `127.0.0.1:8000`, which
|
||
// both overrode the arrangement the file was opened with and could clobber a real session. With no
|
||
// port, `save_layout_now()` refuses and the first-CONFIG layout load never runs, which is exactly
|
||
// a source-less run.
|
||
app.source_host = cfg.open_waveform.empty() ? cfg.server : std::string();
|
||
app.source_port = cfg.open_waveform.empty() ? cfg.port : 0;
|
||
app.source_multicast = cfg.open_waveform.empty() ? cfg.multicast : std::string();
|
||
app.source_interface = cfg.interface_addr;
|
||
// The event cache is keyed on the source, like the packet capture. A waveform has no source, so a
|
||
// file-mode run has no cache either -- there are no latches in a file.
|
||
if (!app.source_host.empty() && app.source_port != 0) {
|
||
app.event_store = std::make_unique<EventStore>(app.source_host + "-" +
|
||
std::to_string(app.source_port), app.settings.event_cache_dir);
|
||
}
|
||
|
||
refresh_events_pane(app);
|
||
|
||
GtkApplication* application =
|
||
// `NON_UNIQUE`: a scope is the kind of program a user runs twice side by side -- one
|
||
// instance per source, or two viewers of the same one -- and without it GApplication's
|
||
// D-Bus single-instance rule turns every later launch into an activation on the first
|
||
// (the new process exits at once, so only the first instance ever lives).
|
||
gtk_application_new("org.udpscope.app", G_APPLICATION_NON_UNIQUE);
|
||
g_signal_connect(application, "activate", G_CALLBACK(on_activate), &app);
|
||
|
||
// Headless self-check: exercise the toolbar's toggles, then exit and print what
|
||
// the pipeline produced, so a CI run can assert the stream actually arrived and
|
||
// both panels were laid out.
|
||
double exit_after_ms = 0.0;
|
||
if (const char* env = g_getenv("UDPSCOPE_EXIT_AFTER_MS")) exit_after_ms = std::atof(env);
|
||
if (exit_after_ms > 0.0) {
|
||
g_timeout_add(static_cast<guint>(exit_after_ms), exit_probe_step1, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_ENABLE_ALL_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), enable_all_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_DISARM_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), disarm_probe_step1, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_ARM_TRIGGER_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), arm_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_TRIGRENUM_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), trigrenum_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_CONDITION_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), condition_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_STATS_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), stats_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_SIDE_TAB_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), side_tab_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_SEARCH_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), search_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_FIELDS_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), fields_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_WINDOW_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), window_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_TIMELABELS_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), timelabels_probe_step, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURES_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), measures_probe_step, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_FIT_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), fit_probe_step, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_CURSORS_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), cursors_probe_step, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_LAYOUT_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), layout_probe_step, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_TRIG_TRACE_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), trig_trace_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_MATH_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), math_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURE_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), measure_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_EVENTZOOM_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), eventzoom_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_PAN_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), pan_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_PAUSE_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), pause_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_EXPORT_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), export_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_ZOOMKEYS_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), zoomkeys_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_HELP_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), help_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_CONFIGS_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), configs_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_EVENTS_MS")) {
|
||
events_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_DROP_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), drop_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_NOISE_MS")) {
|
||
const int ms = std::atoi(env);
|
||
// Times the *setup* stage; `noise_probe` runs 5000 ms after it, so 3000 puts the legs at the same
|
||
// 8000 ms they were measured at.
|
||
if (ms > 0) g_timeout_add(static_cast<guint>(ms), noise_probe_setup, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_ROWMENU_MS")) {
|
||
rowmenu_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_DEFAULTS_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), defaults_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_BOXZOOM_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), boxzoom_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_SIDEBAR_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), sidebar_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_PREFS_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), prefs_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_CAPTURE_MS")) {
|
||
capture_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_MENUS_MS")) {
|
||
menus_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_KEYS_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), keys_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_WINDOWPOP_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), windowpop_probe_step, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_COLOR_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), color_probe, &app);
|
||
}
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_RESEQ_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), reseq_probe, &app);
|
||
}
|
||
|
||
// Our own options were consumed by parse_args(); GTK would reject them, so only
|
||
// the program name is handed to g_application_run().
|
||
const int result =
|
||
g_application_run(G_APPLICATION(application), 1, const_cast<char**>(argv));
|
||
g_object_unref(application);
|
||
|
||
if (exit_after_ms > 0.0) {
|
||
// The derived channels' own reading: how much of the tail is *finite*, and what it holds.
|
||
// A filter that integrated a reserved hole's NaN used to come out empty here for ever
|
||
// (`finite=0` at the first drop, with the streamed channels holding values) -- so this is
|
||
// the assertion that a gap is a gap and not the end of the trace.
|
||
{
|
||
std::lock_guard<std::mutex> lock(model.mutex());
|
||
for (std::size_t i = 0; i < model.channels().size(); ++i) {
|
||
if (!model.channels()[i].math) continue;
|
||
auto& ring = model.live().values(i);
|
||
const std::uint64_t produced = ring.produced();
|
||
const std::size_t take = static_cast<std::size_t>(std::min<std::uint64_t>(produced, 4000));
|
||
if (take == 0) continue;
|
||
double sum = 0, sum2 = 0, lo = 0, hi = 0;
|
||
std::size_t finite = 0;
|
||
for (std::size_t k = 0; k < take; ++k) {
|
||
const double v = ring[static_cast<u64>(produced - take + k)];
|
||
if (!std::isfinite(v)) continue;
|
||
if (finite == 0) lo = hi = v;
|
||
++finite;
|
||
sum += v; sum2 += v * v; lo = std::min(lo, v); hi = std::max(hi, v);
|
||
}
|
||
const double mean = finite > 0 ? sum / finite : 0.0;
|
||
const double sd = finite > 0 ? std::sqrt(std::max(0.0, sum2 / finite - mean * mean)) : 0.0;
|
||
std::fprintf(stderr,
|
||
"[udpscope] math '%s': finite=%zu/%zu mean=%.4f sd=%.4f min=%.4f max=%.4f\n",
|
||
model.channels()[i].name.c_str(), finite, take, mean, sd, lo, hi);
|
||
}
|
||
}
|
||
const Partition& part = model.partition();
|
||
std::printf("[udpscope] frames=%llu packets=%llu gaps=%llu channels=%zu span=%.3fs "
|
||
"stride=%llu"
|
||
" reseq=%llu late=%llu stale=%llu overflows=%llu holes=%llu hole_slots=%llu"
|
||
" backfilled=%llu\n",
|
||
static_cast<unsigned long long>(model.stats().frames.load()),
|
||
static_cast<unsigned long long>(model.stats().packets.load()),
|
||
static_cast<unsigned long long>(model.stats().gaps.load()),
|
||
model.channel_count(), part.span_s,
|
||
static_cast<unsigned long long>(part.time_stride),
|
||
static_cast<unsigned long long>(model.stats().resequenced.load()),
|
||
static_cast<unsigned long long>(model.stats().late.load()),
|
||
static_cast<unsigned long long>(model.stats().stale.load()),
|
||
static_cast<unsigned long long>(model.stats().overflows.load()),
|
||
static_cast<unsigned long long>(model.stats().holes.load()),
|
||
static_cast<unsigned long long>(model.stats().hole_slots.load()),
|
||
static_cast<unsigned long long>(model.stats().backfilled.load()));
|
||
for (std::size_t i = 0; i < model.channel_count(); ++i) {
|
||
std::printf("[udpscope] channel %zu '%s' stored=%llu published=%llu\n", i,
|
||
model.channels()[i].name.c_str(),
|
||
static_cast<unsigned long long>(model.live().values(i).produced()),
|
||
static_cast<unsigned long long>(model.live().channel(i).size()));
|
||
}
|
||
// `frames_painted` is the area's, read from the snapshot taken before the window was
|
||
// destroyed (the PlotArea wrapper dies with its widget): one figure, one render per
|
||
// frame, so it is the same count for every row.
|
||
std::printf("[udpscope] rows: %d of at most %d, layout editor %s\n", rows.row_count(),
|
||
RowSet::kMaxRows, app.layout_editing ? "up" : "down");
|
||
for (int i = 0; i < rows.row_count() && i < RowSet::kMaxRows; ++i) {
|
||
if (rows.row(i).plot == nullptr) continue;
|
||
std::printf("[udpscope] plot %d frames_painted=%d traces=%u\n", i,
|
||
app.dbg_frames_painted, rows.row(i).plot->trace_count());
|
||
}
|
||
// Where a frame's time went, split at the only two boundaries observable from the
|
||
// host: the frame function, the plot's draw, and the toolkit's whole paint. `draw`
|
||
// close to `paint` is the library's cost; `paint` far above `draw` is the toolkit
|
||
// compositing a framebuffer (which llvmpipe dominates). `path` says whether the
|
||
// traces went through the LOD pyramid at all -- a raw frame is O(visible samples).
|
||
std::printf("[udpscope] frame cost: tick %.2f/%.2f ms over %d, draw %.2f/%.2f ms "
|
||
"(max at frame %d), paint %.2f/%.2f ms over %d (mean/max)\n",
|
||
app.dbg_tick_n > 0 ? app.dbg_tick_ms_sum / app.dbg_tick_n : 0.0,
|
||
app.dbg_tick_ms_max, app.dbg_tick_n,
|
||
app.dbg_paint_n > 0 ? app.dbg_draw_ms_sum / app.dbg_paint_n : 0.0,
|
||
app.dbg_draw_ms_max, app.dbg_draw_max_frame,
|
||
app.dbg_paint_n > 0 ? app.dbg_paint_ms_sum / app.dbg_paint_n : 0.0,
|
||
app.dbg_paint_ms_max, app.dbg_paint_n);
|
||
std::printf("[udpscope] frame path: raw=%d lod=%d columns=%d graded=%d frames, segments "
|
||
"%llu/%u (mean/max), visible max %llu samples\n",
|
||
app.dbg_raw_frames, app.dbg_lod_frames, app.dbg_column_frames,
|
||
app.dbg_graded_frames,
|
||
static_cast<unsigned long long>(app.dbg_paint_n > 0
|
||
? app.dbg_seg_sum / app.dbg_paint_n
|
||
: 0),
|
||
app.dbg_seg_max, static_cast<unsigned long long>(app.dbg_visible_max));
|
||
// Layout stability: every one of these must be a single value. A range means
|
||
// something is resizing or re-measuring the plot, which is what a wiggling axis
|
||
// looks like from the outside.
|
||
std::printf("[udpscope] layout over %d frames: axes.x %d..%d, axes.w %d..%d, "
|
||
"viewport.w %d..%d, y-gutter %d..%d, x-ticks %d..%d, x-desync %d\n",
|
||
app.dbg_axes_frames, app.dbg_axes_x_min, app.dbg_axes_x_max, app.dbg_axes_w_min,
|
||
app.dbg_axes_w_max, app.dbg_vp_w_min, app.dbg_vp_w_max, app.dbg_gutter_min,
|
||
app.dbg_gutter_max, app.dbg_xtick_min, app.dbg_xtick_max, app.dbg_x_desync);
|
||
std::printf("[udpscope] axes.w changes: %d (last at frame %d, side panel up=%d)\n",
|
||
app.dbg_axes_w_changes, app.dbg_axes_w_change_frame,
|
||
app.dbg_axes_w_change_side);
|
||
std::printf("[udpscope] reset button sensitive: %d\n", app.dbg_reset_sensitive);
|
||
std::printf("[udpscope] render blank frames: %d of %d painted\n",
|
||
app.dbg_render_blank_frames, app.dbg_frames_painted);
|
||
std::printf("[udpscope] traceless paints: %d (%d after the first bind, last at paint %d)\n",
|
||
app.dbg_notrace_frames, app.dbg_notrace_after_bound, app.dbg_notrace_last);
|
||
std::printf("[udpscope] worst side/pane min width: %d px\n", app.dbg_min_w_worst);
|
||
std::printf("[udpscope] trace ink: %d paints checked, %d collapsed, %d staircase, %d short, "
|
||
"worst cover %.1f%%, worst drawn-vs-quoted %.4f px over %d raw paints\n",
|
||
app.dbg_ink_checked, app.dbg_ink_collapse, app.dbg_ink_staircase,
|
||
app.dbg_ink_short, app.dbg_ink_worst_cover * 100.0, app.dbg_ink_worst_offset_px,
|
||
app.dbg_ink_raw_checked);
|
||
std::printf("[udpscope] blank view frames: %d of %d\n", app.dbg_blank_frames,
|
||
app.dbg_axes_frames);
|
||
std::printf("[udpscope] trigger events: %llu total, %llu at the disarm (no event after it "
|
||
"means Disarm reached the engine)\n",
|
||
static_cast<unsigned long long>(model.trigger_latches().load()),
|
||
static_cast<unsigned long long>(app.dbg_latches_at_disarm));
|
||
std::printf("[udpscope] window control: '%s'\n", app.dbg_window_text.c_str());
|
||
{
|
||
// The signals panel's frame, read from the window rather than from the `.ui`: the two labels and the
|
||
// list the file gives it. A `.ui` that lost one of them reads here -- `labels=1` or `list=0` -- rather
|
||
// than showing as a panel quietly missing its hint.
|
||
GtkWidget* panel = app.signal_list != nullptr ? gtk_widget_get_parent(app.signal_list) : nullptr;
|
||
// Counted over the panel's *subtree*, not its direct children: the heading lives in a heading row
|
||
// beside the pane's [edit] button, and a count of children would read 1 whatever the file lost.
|
||
int labels = 0;
|
||
int lists = 0;
|
||
const std::function<void(GtkWidget*)> count = [&](GtkWidget* w) {
|
||
// The list stops the walk: its rows are the code's, and this reading is about the file's frame.
|
||
if (GTK_IS_LIST_BOX(w)) {
|
||
++lists;
|
||
return;
|
||
}
|
||
if (GTK_IS_LABEL(w)) ++labels;
|
||
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
|
||
c = gtk_widget_get_next_sibling(c)) {
|
||
count(c);
|
||
}
|
||
};
|
||
if (panel != nullptr) count(panel);
|
||
// The list's shape, beside the frame: `rows` is what the list holds, `signals` the streamed
|
||
// ones, and the `MATH` divider plus the math channels account for the rest -- so
|
||
// `rows == signals + 1 + math` is the invariant, and `divider=0` is a list that lost its `+`.
|
||
std::printf("[udpscope] signals panel: labels=%d list=%d rows=%d signals=%zu math=%zu "
|
||
"divider=%d\n",
|
||
labels, lists, app.dbg_signal_rows, app.dbg_signal_plain, app.math.size(),
|
||
app.dbg_signal_rows ==
|
||
static_cast<int>(app.dbg_signal_plain + 1 + app.math.size())
|
||
? 1
|
||
: 0);
|
||
}
|
||
{
|
||
// The events pane's frame, read from the window: the four widgets the `.ui` gives it, and the list
|
||
// being inside the revealer the file builds. A control that lost its id reads 0 here rather than
|
||
// showing as a pane missing a button, and `nested=0` is a list that is no longer in the pane at all.
|
||
const auto inside = [](GtkWidget* w, GtkWidget* root) {
|
||
if (w == nullptr) return 0;
|
||
for (GtkWidget* p = w; p != nullptr; p = gtk_widget_get_parent(p)) {
|
||
if (p == root) return 1;
|
||
}
|
||
return 0;
|
||
};
|
||
std::printf("[udpscope] events pane: compare=%d accumulate=%d list=%d revealer=%d nested=%d\n",
|
||
app.events_compare != nullptr ? 1 : 0, app.events_accumulate != nullptr ? 1 : 0,
|
||
app.events_list != nullptr ? 1 : 0, app.events_revealer != nullptr ? 1 : 0,
|
||
inside(app.events_list, app.events_revealer));
|
||
}
|
||
std::printf("[udpscope] panels: side=%dx%d (visible=%d) signals=%dx%d row_h=%d "
|
||
"(page_current=%d) trigger=%dx%d (sensitive=%d) switcher=%d "
|
||
"cursors=%d window=%.3fs window_cap=%.1fs\n",
|
||
app.dbg_side_w, app.dbg_side_h, app.dbg_side_column_after_hide, app.dbg_signal_w,
|
||
app.dbg_signal_h, app.dbg_signal_row_h, app.dbg_signal_page_current,
|
||
app.dbg_trigger_w, app.dbg_trigger_h,
|
||
app.dbg_trigger_sensitive, app.dbg_switcher_visible,
|
||
rows.cursors_visible() ? 1 : 0, rows.time_window(),
|
||
app.window_limit_s);
|
||
{
|
||
GtkIconTheme* theme = gtk_icon_theme_get_for_display(gdk_display_get_default());
|
||
std::printf("[udpscope] pane edits: locked=%s(%s) config=%s(%s)\n",
|
||
app.dbg_pane_edit_icon.c_str(),
|
||
gtk_icon_theme_has_icon(theme, app.dbg_pane_edit_icon.c_str()) ? "ok" : "MISSING",
|
||
app.dbg_pane_view_icon.c_str(),
|
||
gtk_icon_theme_has_icon(theme, app.dbg_pane_view_icon.c_str()) ? "ok" : "MISSING");
|
||
}
|
||
std::printf("[udpscope] panes: trigger=%s signals=%s body_visible=%d summary='%s'\n",
|
||
app.dbg_trigger_locked ? "locked" : "config",
|
||
app.dbg_signals_locked ? "locked" : "config", app.dbg_trigger_body_visible,
|
||
app.dbg_trigger_summary.c_str());
|
||
std::printf("[udpscope] panes: the same line as the probe found it: '%s'\n",
|
||
app.dbg_trigger_summary_early.c_str());
|
||
std::printf("[udpscope] panel switches: trigger=%d/%d cursors=%d/%d drove_toolbar=%d "
|
||
"(pane switch / header bar button; `drove_toolbar` is the pane's own switch having "
|
||
"moved the button)\n",
|
||
app.dbg_panel_switch_trigger, app.dbg_toolbar_switch_trigger,
|
||
app.dbg_panel_switch_cursors, app.dbg_toolbar_switch_cursors,
|
||
app.dbg_panel_drove_toolbar);
|
||
std::printf("[udpscope] switcher: policy=%s pills=%d labels=%d names=%d icon=%dx%d "
|
||
"size=%dx%d\n",
|
||
app.dbg_switcher_policy == 0 ? "narrow" : (app.dbg_switcher_policy == 1 ? "wide" : "?"),
|
||
app.dbg_switcher_pills, app.dbg_switcher_labels, app.dbg_switcher_names,
|
||
app.dbg_switcher_icon_w, app.dbg_switcher_icon_h, app.dbg_switcher_w,
|
||
app.dbg_switcher_h);
|
||
std::printf("[udpscope] mode with trigger on: %s\n", app.dbg_mode_triggered.c_str());
|
||
std::printf("[udpscope] trigger marker x=%.3f, view x_max=%.3f; model trigger_time=%.3f "
|
||
"valid=%d\n",
|
||
app.dbg_marker_x, app.dbg_marker_max, app.dbg_trigger_time,
|
||
app.dbg_trigger_time_valid);
|
||
std::printf("[udpscope] mode after go-live: %s\n", app.dbg_mode_live.c_str());
|
||
std::printf("[udpscope] status bar: glance=[● %s] max_glance_chars=%zu detail_fields=11 "
|
||
"popover_rebuilds=%d\n",
|
||
app.dbg_glance_last.c_str(), app.glance_max_chars, app.popover_rebuilds);
|
||
// The derived channels beside the channel they came from. A math channel defined by the pane
|
||
// reaches the producer by exactly the path the probe uses, so this is the pipeline's own
|
||
// assertion: `x2` must read twice its source's extremes, a low pass must read less.
|
||
{
|
||
std::vector<std::pair<std::string, int>> wanted;
|
||
{
|
||
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||
// The first *streamed* channel too: it is the one a saturated ring clamps at the front, and
|
||
// the order check below is the only place that shows it without a screenshot.
|
||
if (!app.model->channels().empty()) {
|
||
wanted.emplace_back(app.model->channels().front().name, 0);
|
||
}
|
||
for (const MathChannel& m : app.model->math_channels()) {
|
||
int idx = -1;
|
||
for (std::size_t c = 0; c < app.model->channels().size(); ++c) {
|
||
if (app.model->channels()[c].name == m.name) idx = static_cast<int>(c);
|
||
}
|
||
wanted.emplace_back(m.name, idx);
|
||
}
|
||
}
|
||
for (const auto& [name, idx] : wanted) {
|
||
if (idx < 0) {
|
||
std::printf("[udpscope] math '%s': no channel (disabled?)\n", name.c_str());
|
||
continue;
|
||
}
|
||
std::vector<double> t;
|
||
std::vector<double> v;
|
||
const bool ok = visible_samples(app, static_cast<std::size_t>(idx), t, v);
|
||
double lo = 0.0;
|
||
double hi = 0.0;
|
||
for (std::size_t i = 0; ok && i < v.size(); ++i) {
|
||
lo = i == 0 ? v[i] : std::min(lo, v[i]);
|
||
hi = i == 0 ? v[i] : std::max(hi, v[i]);
|
||
}
|
||
// The stamps of the window the plot would draw: they must be strictly increasing. A
|
||
// duplicate or a backwards step is what a wrap artifact looks like from the data side --
|
||
// the renderer's index window comes from a binary search over these, and a segment drawn
|
||
// from the end of the ring to its beginning is a window that spans the wrap.
|
||
// A break can only be at the *front*: the window's oldest slots may be clamped to the oldest
|
||
// readable stamp (the published window is pinned while the rings slide under it), so a
|
||
// saturated ring legitimately shows a few non-increasing stamps at the start. A break
|
||
// anywhere else is a real one, so the *first* break's position is reported.
|
||
std::size_t order_breaks = 0;
|
||
long long first_break = -1;
|
||
for (std::size_t i = 1; ok && i < t.size(); ++i) {
|
||
if (!(t[i] > t[i - 1])) {
|
||
++order_breaks;
|
||
if (first_break < 0) first_break = static_cast<long long>(i);
|
||
}
|
||
}
|
||
// `produced` beside the visible count: a derived channel is filled once per stored time
|
||
// slot, so its ring must hold exactly as many samples as the time ring the plot reads it
|
||
// against. The first version pushed twice per slot and this ratio would have said so.
|
||
u64 produced = 0;
|
||
u64 slot_count = 0;
|
||
{
|
||
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||
produced = app.model->live().values(static_cast<std::size_t>(idx)).produced();
|
||
slot_count = app.model->live().time().produced();
|
||
}
|
||
std::printf("[udpscope] math '%s': channel %d, %llu samples vs %llu time slots, "
|
||
"%zu visible (order breaks %zu, first at %lld), y=[%.4f, %.4f]\n",
|
||
name.c_str(), idx, static_cast<unsigned long long>(produced),
|
||
static_cast<unsigned long long>(slot_count), ok ? v.size() : 0u, order_breaks,
|
||
first_break, ok ? lo : 0.0, ok ? hi : 0.0);
|
||
}
|
||
// What the derived channels cost the *producer*. This is not a frame cost: the math runs
|
||
// between two DATA packets on the acquisition thread, so it never appears in `frame cost:`
|
||
// and shows up only as dropped packets once it exceeds the inter-packet budget. `ns/slot`
|
||
// is the figure to compare across runs; it is *sampled* (see `ScopeModel::math_slots()`),
|
||
// because timing every slot costs about as much as a slot and would measure itself.
|
||
if (app.model->math_timed() > 0) {
|
||
const double per_slot = static_cast<double>(app.model->math_cost_ns()) /
|
||
static_cast<double>(app.model->math_timed());
|
||
std::printf("[udpscope] math cost: %llu slots, %.1f ns/slot (%llu timed), "
|
||
"%.2f ms total est\n",
|
||
static_cast<unsigned long long>(app.model->math_slots()), per_slot,
|
||
static_cast<unsigned long long>(app.model->math_timed()),
|
||
per_slot * static_cast<double>(app.model->math_slots()) / 1.0e6);
|
||
}
|
||
}
|
||
// What freezing a view costs, counted apart for the two publishes because they stall opposite
|
||
// threads (see `ScopeModel::publish_event_count()`). This is the figure a second live ring would
|
||
// buy: freezing a whole `BufferStore` copies nothing and inherits the pyramid the producer
|
||
// already maintains, so both `worst` figures would be 0 -- at a second budget's worth of memory
|
||
// and a second per-sample write on the producer. Read `event worst` against `frame cost:`'s
|
||
// `tick` max (the same stall, seen from the UI) and `pause worst` against `gaps`.
|
||
{
|
||
const auto ev = app.model->publish_event_count();
|
||
const auto pa = app.model->publish_pause_count();
|
||
std::printf("[udpscope] publish cost: %llu events, %.2f/%.2f ms (mean/worst, blocks the UI);"
|
||
" %llu pauses, %.2f/%.2f ms (mean/worst, blocks the producer)\n",
|
||
static_cast<unsigned long long>(ev),
|
||
ev > 0 ? static_cast<double>(app.model->publish_event_us_total()) /
|
||
static_cast<double>(ev) / 1000.0
|
||
: 0.0,
|
||
static_cast<double>(app.model->publish_event_us_worst()) / 1000.0,
|
||
static_cast<unsigned long long>(pa),
|
||
pa > 0 ? static_cast<double>(app.model->publish_pause_us_total()) /
|
||
static_cast<double>(pa) / 1000.0
|
||
: 0.0,
|
||
static_cast<double>(app.model->publish_pause_us_worst()) / 1000.0);
|
||
}
|
||
// Unconditional, unlike the pane's own lines below: this is about the instruments the app
|
||
// *created*, which exist whether or not any measurement was added.
|
||
std::printf("[udpscope] readout instruments locked: %d (trigger marker + measurement marks)\n",
|
||
app.rows->readouts_locked() ? 1 : 0);
|
||
// Where the panel controls live. Computed once, when the widgets exist -- the dump runs *after*
|
||
// the window is destroyed, where a widget pointer is dangling and asking GTK about it is a
|
||
// critical (which is how the first version of this line earned four of them).
|
||
std::printf("[udpscope] panel controls: %s\n", app.dbg_panel_controls.c_str());
|
||
std::printf("[udpscope] toolbar icons: %s\n", app.dbg_toolbar_icons.c_str());
|
||
std::printf("[udpscope] page icons: %s\n", app.dbg_page_icons.c_str());
|
||
std::printf("[udpscope] app icons: %s\n", app.dbg_app_icons.c_str());
|
||
// The measurements pane, with the numbers the engine computed: a probe adds measurements and
|
||
// this is where their values are read, so a wrong threshold or a window that moved under a
|
||
// stale result is visible in the run rather than only on screen.
|
||
if (!app.measures.empty()) {
|
||
std::printf("[udpscope] measurements: %zu", app.measures.size());
|
||
for (std::size_t i = 0; i < app.measures.size() && i < app.measure_results.size(); ++i) {
|
||
const MeasureResult& r = app.measure_results[i];
|
||
std::printf(" %s=%s", measure_type_name(app.measures[i].type),
|
||
r.valid ? std::to_string(r.primary).c_str() : "--");
|
||
if (r.has_secondary) std::printf("(%s)", std::to_string(r.secondary).c_str());
|
||
}
|
||
std::printf("\n");
|
||
std::printf("[udpscope] clamp fires: %d (a clamp that fires every frame is a re-frame loop)\n",
|
||
app.rows->clamp_fires());
|
||
if (app.measure_passes > 0) {
|
||
std::printf("[udpscope] measure cost: %d recomputes, %.2f ms total, %.2f ms each, "
|
||
"data %.2f ms (%.0f%%), covered=%llu buckets=%llu raw=%llu (%.1f%%), "
|
||
"%.1f ns/covered sample\n",
|
||
app.measure_passes, app.measure_cost_us / 1000.0,
|
||
app.measure_cost_us / 1000.0 / app.measure_passes,
|
||
app.measure_fetch_us / 1000.0,
|
||
app.measure_cost_us > 0
|
||
? 100.0 * static_cast<double>(app.measure_fetch_us) /
|
||
static_cast<double>(app.measure_cost_us)
|
||
: 0.0,
|
||
static_cast<unsigned long long>(app.measure_covered),
|
||
static_cast<unsigned long long>(app.measure_bucketed),
|
||
static_cast<unsigned long long>(app.measure_raw),
|
||
app.measure_covered > 0 ? 100.0 * static_cast<double>(app.measure_raw) /
|
||
static_cast<double>(app.measure_covered)
|
||
: 0.0,
|
||
app.measure_covered > 0
|
||
? 1000.0 * static_cast<double>(app.measure_cost_us) /
|
||
static_cast<double>(app.measure_covered)
|
||
: 0.0);
|
||
}
|
||
}
|
||
std::printf("[udpscope] badge frames: lit=%d dim=%d idle=%d connecting=%d\n",
|
||
app.dbg_badge_frames[0], app.dbg_badge_frames[1], app.dbg_badge_frames[2],
|
||
app.dbg_badge_frames[3]);
|
||
std::printf("[udpscope] trigger panel sensitive before the probe toggled it: %d; "
|
||
"signal list items=%d selected=%d; column visible after sidebar toggle off=%d; "
|
||
"pane switches live while their panes are off=%d\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);
|
||
{
|
||
// 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;
|
||
};
|
||
int boxes = 0;
|
||
for (GtkWidget* c = gtk_widget_get_first_child(app.trigger_panel); c != nullptr;
|
||
c = gtk_widget_get_next_sibling(c)) {
|
||
if (GTK_IS_BOX(c)) ++boxes;
|
||
}
|
||
// `search_widgets` separated from `search_inside` on purpose: the first says the ids resolved, the
|
||
// second says they are in the panel. A `.ui` that lost one and a `.ui` that lost its *place* are two
|
||
// different mistakes, and only one of them shows as a missing button.
|
||
// The expander's child is **not** in the widget-parent chain: `gtk_widget_get_parent()` on it stays
|
||
// NULL even after the window is presented, while `gtk_expander_get_child()` returns it (measured with
|
||
// a throwaway program, all three ways of setting it). So the walk the search widgets are read with has
|
||
// to start at the expander's child and not at the panel -- walking to the panel reads 0/4 on a panel
|
||
// that is entirely correct, which is what this line did first.
|
||
GtkWidget* search_root = app.trigger_panel;
|
||
for (GtkWidget* c = gtk_widget_get_first_child(app.trigger_panel); c != nullptr;
|
||
c = gtk_widget_get_next_sibling(c)) {
|
||
if (GTK_IS_EXPANDER(c)) {
|
||
if (GtkWidget* inner = gtk_expander_get_child(GTK_EXPANDER(c))) search_root = inner;
|
||
break;
|
||
}
|
||
}
|
||
const int search_present =
|
||
(app.search_run_button != nullptr) + (app.search_cancel_button != nullptr) +
|
||
(app.search_prev_button != nullptr) + (app.search_next_button != nullptr);
|
||
std::printf(
|
||
"[udpscope] trigger panel: boxes=%d controls_inside=%d/15 search_widgets=%d/4 "
|
||
"search_inside=%d/4\n",
|
||
boxes,
|
||
inside(app.trigger_signal_drop, app.trigger_panel) +
|
||
inside(app.trigger_type_drop, app.trigger_panel) +
|
||
inside(app.trigger_edge_drop, app.trigger_panel) +
|
||
inside(app.trigger_cmp_drop, app.trigger_panel) +
|
||
inside(app.trigger_mode_drop, app.trigger_panel) +
|
||
inside(app.trigger_width_spin, app.trigger_panel) +
|
||
inside(app.trigger_width2_spin, app.trigger_panel) +
|
||
inside(app.trigger_level2_spin, app.trigger_panel) +
|
||
inside(app.trigger_timeout_spin, app.trigger_panel) +
|
||
inside(app.trigger_level_spin, app.trigger_panel) +
|
||
inside(app.trigger_pre_spin, app.trigger_panel) +
|
||
inside(app.trigger_holdoff_spin, app.trigger_panel) +
|
||
inside(app.trigger_band_spin, app.trigger_panel) +
|
||
inside(app.trigger_hf_spin, app.trigger_panel) +
|
||
inside(app.events_record_switch, app.trigger_panel),
|
||
search_present,
|
||
inside(app.search_run_button, search_root) + inside(app.search_cancel_button, search_root) +
|
||
inside(app.search_prev_button, search_root) + inside(app.search_next_button, search_root));
|
||
}
|
||
{
|
||
plot::Plot& p = *rows.row(0).plot;
|
||
const plot::PlotLayout& lay = p.layout();
|
||
int visible = 0;
|
||
for (plot::u32 t = 0; t < p.trace_count(); ++t) {
|
||
if (p.trace(t).visible()) ++visible;
|
||
}
|
||
std::printf("[udpscope] row 0 plot: x=[%.3f,%.3f] y=[%.3f,%.3f] axes=%.0fx%.0f "
|
||
"visible_traces=%d legend_rows=%zu legend_panel=%.0fx%.0f offset=%.3f "
|
||
"trigger_marker_x=%.3f\n",
|
||
p.x_range(0).min, p.x_range(0).max, p.y_range(0).min, p.y_range(0).max,
|
||
double(lay.axes.w), double(lay.axes.h), visible, lay.legend.rows.size(),
|
||
double(lay.legend.panel.w), double(lay.legend.panel.h), p.x_offset(0),
|
||
p.cursor(rows.row(0).trigger_marker).x);
|
||
for (plot::u32 t = 0; t < p.trace_count(); ++t) {
|
||
const plot::Trace& tr = p.trace(t);
|
||
if (tr.series == nullptr) continue;
|
||
std::printf("[udpscope] trace %u '%s' size=%llu x0=%.3f dx=%.6f bounds=[%.3f,%.3f]\n",
|
||
t, tr.name.c_str(), static_cast<unsigned long long>(tr.series->size()),
|
||
tr.series->x0(), tr.series->dx(), tr.series->x_bounds().min,
|
||
tr.series->x_bounds().max);
|
||
}
|
||
}
|
||
{
|
||
static const char* const kModes[] = {"vertical", "horizontal", "both", "mix"};
|
||
const int mode = static_cast<int>(rows.cursor_mode());
|
||
// What the *plot* holds, which is what the renderer draws from: a marker the app
|
||
// placed but the plot parked (or hid) would show up here and not on screen.
|
||
for (int i = 0; i < rows.row_count(); ++i) {
|
||
const RowSet::Row& r = rows.row(i);
|
||
if (r.plot == nullptr) continue;
|
||
const plot::Plot& p = *r.plot;
|
||
std::printf("[udpscope] cursors in plot %d: vis=%d a=(x=%.6f vis=%d) "
|
||
"b=(x=%.6f vis=%d) ruler=(y=%.6f vis=%d) ruler_b=(y=%.6f vis=%d) "
|
||
"stored_rulers=%.6f,%.6f\n",
|
||
i, rows.cursors_visible() ? 1 : 0, p.cursor(r.cursor_a).x,
|
||
p.cursor(r.cursor_a).visible, p.cursor(r.cursor_b).x,
|
||
p.cursor(r.cursor_b).visible, p.cursor(r.ruler).y,
|
||
p.cursor(r.ruler).visible, p.cursor(r.ruler_b).y,
|
||
p.cursor(r.ruler_b).visible, r.ruler_y, r.ruler_b_y);
|
||
// Where the plot would draw them: the row's own axes rect and the device
|
||
// coordinate the ruler's y maps to (`cursor_screen_coordinate`).
|
||
const plot::PlotLayout& lay = p.layout();
|
||
std::printf("[udpscope] cursors in plot %d: axes=(%.0f,%.0f %.0fx%.0f) "
|
||
"ruler_screen=%.1f ruler_b_screen=%.1f y_range=[%.4g..%.4g]\n",
|
||
i, double(lay.axes.x), double(lay.axes.y), double(lay.axes.w),
|
||
double(lay.axes.h), double(p.cursor_screen_coordinate(r.ruler)),
|
||
double(p.cursor_screen_coordinate(r.ruler_b)), p.y_range(r.axis_y).min,
|
||
p.y_range(r.axis_y).max);
|
||
}
|
||
std::printf("[udpscope] cursors: on=%d mode=%s t0=%.6f t1=%.6f dt=%.6f",
|
||
rows.cursors_visible() ? 1 : 0, kModes[mode], rows.cursor_a_x(),
|
||
rows.cursor_b_x(), rows.cursor_b_x() - rows.cursor_a_x());
|
||
for (int r = 0; r < rows.row_count(); ++r) {
|
||
std::printf(" y%d=[%.6g,%.6g]", r, rows.ruler_y(r), rows.ruler_b_y(r));
|
||
}
|
||
std::printf("\n");
|
||
}
|
||
for (std::size_t i = 0; i < app.measures.size(); ++i) {
|
||
const Measure& m = app.measures[i];
|
||
const MeasureResult& r = i < app.measure_results.size() ? app.measure_results[i]
|
||
: MeasureResult{};
|
||
std::printf("[udpscope] measure %zu: '%s' %s thr=%.1f%% above=%d show=%d -> %s "
|
||
"%.9g",
|
||
i, m.signal.c_str(), measure_type_name(m.type), m.options.threshold_pct,
|
||
m.options.above ? 1 : 0, m.options.show_on_plot ? 1 : 0,
|
||
r.valid ? "ok" : "invalid", r.valid ? r.primary : 0.0);
|
||
// What the row *reads*: the formatted string with its unit (so `frequency` showing Hz
|
||
// is asserted here, not inferred), and the row's chip count (two: the measurement's
|
||
// colour and the signal's).
|
||
if (i < app.measure_text.size()) std::printf(" text=[%s]", app.measure_text[i].c_str());
|
||
if (r.valid && r.has_secondary) std::printf(" duty=%.6g", r.secondary);
|
||
if (r.valid && !r.marks.empty()) {
|
||
// One entry per mark with its kind: the mix is the point (`min` is a ruler *and* a
|
||
// marker), so a shape that could only say "level" or "time" would hide the case this
|
||
// asserts.
|
||
std::printf(" marks=");
|
||
for (std::size_t k = 0; k < r.marks.size(); ++k) {
|
||
if (k != 0) std::printf(",");
|
||
std::printf("%s:%.9g", r.marks[k].level ? "level" : "time", r.marks[k].value);
|
||
}
|
||
}
|
||
std::printf("\n");
|
||
}
|
||
{
|
||
// The x label form: relative to the window's own start (with the caption that names
|
||
// that start) when the labels could not carry the tick step absolutely, absolute
|
||
// otherwise. `x_origin_set` is read back out of the plot, so this is what the axis is
|
||
// actually formatting with, and `note` is the plot's own caption string.
|
||
const RowSet::Row& rlt = rows.row(rows.row_count() - 1);
|
||
std::string note;
|
||
if (rlt.plot != nullptr) note = rlt.plot->x_note();
|
||
// The labels themselves, from the axis the reader looks at: the assertion is what the
|
||
// ticks *say*, not only which mode is on.
|
||
std::string ticks;
|
||
bool distinct = true;
|
||
if (rlt.plot != nullptr) {
|
||
int shown = 0;
|
||
std::vector<std::string> seen;
|
||
for (const plot::Tick& t : rlt.plot->x_axis(rlt.axis_x).ticks) {
|
||
if (!t.major) continue;
|
||
if (std::find(seen.begin(), seen.end(), t.label) != seen.end()) distinct = false;
|
||
seen.emplace_back(t.label);
|
||
if (shown++ < 4) {
|
||
if (shown != 1) ticks += ",";
|
||
ticks += t.label;
|
||
}
|
||
}
|
||
}
|
||
// The labels against the anchor they were built with: the dump runs after the last
|
||
// paint, so unlike the probe's timer there is no race here -- the first major must sit
|
||
// on the origin (`first_rel` the fraction of a step the window has scrolled since).
|
||
double first_value = 0.0;
|
||
double origin_set = 0.0;
|
||
if (rlt.plot != nullptr) {
|
||
origin_set = rlt.plot->x_axis_origin(rlt.axis_x);
|
||
for (const plot::Tick& t : rlt.plot->x_axis(rlt.axis_x).ticks) {
|
||
if (t.major) {
|
||
first_value = t.value;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
std::printf("[udpscope] time labels: relative=%d distinct=%d origin=%.6f note=[%s] "
|
||
"x_origin_set=%.6f ticks=[%s] first_rel=%.9f\n",
|
||
rows.time_labels_relative() ? 1 : 0, distinct ? 1 : 0, rows.time_labels_origin(),
|
||
note.c_str(), origin_set, ticks.c_str(), first_value - origin_set);
|
||
}
|
||
{
|
||
std::lock_guard<std::mutex> lock(model.mutex());
|
||
// `results_for` is the window the numbers describe, `win` the one on screen now: the
|
||
// drift between them is the assertion that a measurement is a statistic of the *current*
|
||
// window -- a timer-decoupled recomputation is what used to leave a marker outside it.
|
||
// Both on the **stream's** clock, which is the space `measure_window` and the time marks
|
||
// are stored in: a live axis is a fixed `[-window, 0]`, so comparing display x would read
|
||
// no drift whatever the data did (see `App::measure_window`).
|
||
const double s0 = model.stream_at_x0();
|
||
const RowSet::Row& r0 = rows.row(0);
|
||
const plot::Range view = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{};
|
||
const plot::Range win{view.min + s0, view.max + s0};
|
||
const double span = app.measure_window.span();
|
||
int in_window = 0;
|
||
int marks = 0;
|
||
for (const RowSet::MeasureMark& mk : app.measure_marks) {
|
||
if (mk.level) continue;
|
||
++marks;
|
||
if (mk.value >= app.measure_window.min && mk.value <= app.measure_window.max) ++in_window;
|
||
}
|
||
// Whether the marks the app placed are *unselectable* -- the property the pointer's hit
|
||
// test respects. Read back out of the plot, so this is the app's own setting and not
|
||
// the intent behind it.
|
||
int selectable = 0;
|
||
for (int i = 0; i < rows.row_count(); ++i) {
|
||
const RowSet::Row& r = rows.row(i);
|
||
if (r.plot == nullptr) continue;
|
||
for (int m = 0; m < RowSet::kMeasureMarks; ++m) {
|
||
const std::size_t mi = static_cast<std::size_t>(m);
|
||
if (r.plot->cursor(r.measure_h[mi]).selectable) ++selectable;
|
||
if (r.plot->cursor(r.measure_v[mi]).selectable) ++selectable;
|
||
}
|
||
}
|
||
std::printf("[udpscope] measures window: win=[%.6f..%.6f] results_for=[%.6f..%.6f] "
|
||
"drift=%.4f%% marks_in_window=%d/%d marks_selectable=%d\n",
|
||
win.min, win.max, app.measure_window.min, app.measure_window.max,
|
||
span > 0.0 ? 100.0 * (std::fabs(win.min - app.measure_window.min) +
|
||
std::fabs(win.max - app.measure_window.max)) /
|
||
span
|
||
: 0.0,
|
||
in_window, marks, selectable);
|
||
}
|
||
{
|
||
std::lock_guard<std::mutex> lock(model.mutex());
|
||
std::printf("[udpscope] measures pane: n=%zu marks_shown=%d pane_rows=%zu "
|
||
"pane_w=%d pane_h=%d switch_on=%d reset_sensitive=%d\n",
|
||
app.measures.size(),
|
||
rows.measure_marks_placed(), app.measure_labels.size(),
|
||
app.dbg_measures_pane_w, app.dbg_measures_pane_h,
|
||
// The two widgets the panel's *frame* is read by: the code drives `measure_stats_on`,
|
||
// so the switch and the Reset button are where a `.ui` that lost them -- or lost the
|
||
// wiring to them -- shows up. `switch_on` disagreeing with `[stats-probe] on=` is that.
|
||
app.measures_stats_check != nullptr &&
|
||
gtk_check_button_get_active(GTK_CHECK_BUTTON(app.measures_stats_check)) ? 1 : 0,
|
||
app.measures_stats_reset != nullptr &&
|
||
gtk_widget_get_sensitive(app.measures_stats_reset) ? 1 : 0);
|
||
}
|
||
// `locked` is re-reported here rather than only in the readouts line: this task added a
|
||
// whole new cursor pool, and a pool created selectable is exactly the regression that a
|
||
// readout-vs-instrument check is for.
|
||
std::printf("[udpscope] search marks: placed=%d hits=%zu locked=%d\n",
|
||
rows.search_marks_placed(), app.search.hits().size(),
|
||
rows.readouts_locked() ? 1 : 0);
|
||
std::printf("[udpscope] header: window_button_w=%d (bar_w=%d)\n",
|
||
app.dbg_win_btn_w, app.dbg_bar_w);
|
||
std::printf("[udpscope] layout: rows=%d window_s=%.6g", rows.row_count(),
|
||
rows.time_window());
|
||
for (int r = 0; r < rows.row_count() && r < RowSet::kMaxRows; ++r) {
|
||
std::printf(" row%d=[", r);
|
||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||
if (s != 0) std::printf(",");
|
||
std::printf("'%s'", rows.row(r).signal_name[static_cast<std::size_t>(s)].c_str());
|
||
}
|
||
std::printf("]");
|
||
}
|
||
std::printf("\n");
|
||
for (int r = 0; r < rows.row_count() && r < RowSet::kMaxRows; ++r) {
|
||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||
const int ch = rows.channel_of(r, s);
|
||
if (ch < 0) continue;
|
||
ChannelBuffer* buf = rows.row(r).slots[static_cast<std::size_t>(s)].bound();
|
||
std::printf("[udpscope] row %d slot %d -> channel %d '%s' samples=%llu\n", r, s, ch,
|
||
rows.row(r).name[static_cast<std::size_t>(s)].c_str(),
|
||
static_cast<unsigned long long>(buf != nullptr ? buf->size() : 0));
|
||
}
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
} // namespace ui_impl
|
||
|
||
/// The public entry point `ui.hpp` declares. The front end's own `run_ui` lives with the rest
|
||
/// of the window construction in `ui_impl`, which owns `App`; this is the one function the rest
|
||
/// of the program sees.
|
||
int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisition& acq,
|
||
RowSet& rows) {
|
||
return ui_impl::run_ui(argc, argv, cfg, model, acq, rows);
|
||
}
|
||
|
||
} // namespace udpscope
|