Files
udpscope/src/ui.cpp
T

3258 lines
153 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The GTK4 front end: the toolbar, the trigger and signal panels, and the one plot
// area the `RowSet`'s figure is drawn in.
//
// Every widget here is a thin driver over the model: nothing in this file owns
// sample data. The one thing it does own is the `plot::gtk4::PlotArea`, which is in
// figure mode -- the rows are the figure's rows, so a row whose trace set changed
// (`RowSet::row_dirty()`) costs a new figure and not a new widget: the area keeps its
// GL context and is handed the rebuilt figure.
#include "ui.hpp"
#include <gtk4/gtk_plot_area.hpp>
#include "acquisition.hpp"
#include "config.hpp"
#include "export.hpp"
#include "model.hpp"
#include "plot_host.hpp"
#include <gtk/gtk.h>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
namespace udpscope {
namespace {
constexpr int kTickMs = 16;
struct App {
ScopeModel* model = nullptr;
Acquisition* acq = nullptr;
RowSet* rows = nullptr;
GtkWidget* window = nullptr;
GtkWidget* plot_box = nullptr;
/// The one GL area, drawing the `RowSet`'s figure: every row is a row of that
/// figure, so there is one widget, one context and one `::render` whatever the row
/// count is.
GtkWidget* area_widget = nullptr;
plot::gtk4::PlotArea* area = nullptr;
/// The GL area wrapped in a `GtkOverlay`, which is what the plot box actually holds.
/// The layout editor's buttons are overlay children: each is a small widget placed by
/// its margins over the row it acts on, so it intercepts pointer events only where it
/// is -- a single transparent child covering the area would swallow every pan, drop and
/// hover the editor is not interested in.
GtkWidget* area_overlay = nullptr;
/// On while the layout editor is up. Nothing else about the plot changes: the editor is
/// a set of overlay buttons, not a mode the figure knows about.
bool layout_editing = false;
GtkWidget* layout_toggle = nullptr;
/// The editor's buttons, in the order they were added, so they can be taken off the
/// overlay again. Rebuilt when the row count changes, repositioned when the rows move.
std::vector<GtkWidget*> layout_buttons;
/// The row count and figure geometry the buttons were placed for. Their positions are
/// restated only when one of these moves: a `gtk_widget_set_margin_top()` per frame is a
/// relayout per frame, and overlay children are measured out of the main child's
/// allocation, so the churn would reach the plot.
int layout_buttons_rows = 0;
/// Which step of the layout probe runs next (`UDPSCOPE_PROBE_LAYOUT_MS`). The steps are
/// a frame apart, because the buttons are placed from the rects the last frame was laid
/// out with, so the sequence cannot be one function.
int dbg_layout_step = 0;
/// What `PlotArea::create()` is handed. The area is in figure mode from the start and
/// never draws this, but it keeps a reference to the plot it was created with (its
/// single-plot controller holds one) -- and every plot of the figure is destroyed by a
/// rebuild, so handing it a row would leave that reference dangling.
plot::Plot placeholder_plot;
GtkWidget* status_label = nullptr;
/// The activity badge: one glyph whose *colour* is the whole signal, so that blinking
/// it cannot change its width and drive a relayout (see the status label's comment).
GtkWidget* status_badge = nullptr;
/// What the badge was last set to (`BadgePhase`), so the markup is rewritten on a
/// change of phase rather than on every one of the 60 frames a second.
int badge_phase = -1;
/// The display-window control: a header-bar button whose label is the current window
/// and whose popover holds the number, its unit and the two decade steppers. The label
/// is width-locked (see `kWindowLabelChars`) because the header bar sits above the plot
/// and a label that grew with the text would relayout everything under it.
GtkWidget* window_button = nullptr;
GtkWidget* window_label = nullptr;
GtkWidget* window_entry = nullptr;
GtkWidget* window_unit_drop = nullptr;
/// The unit `sync_window_display()` last wrote into the dropdown. `GtkDropDown`'s
/// `notify::selected` arrives *after* the call that caused it returns, so the
/// `updating_panels` flag is already clear by then and cannot tell our own write from the
/// user's pick; the entry the sync wrote is rounded for reading, so re-parsing it made the
/// rounding the window (a 500 s request came back as 499.98 s). A selection that matches
/// what we synced is ours -- GTK only notifies on a change, so the user cannot produce it.
int window_sync_unit = -1;
GtkWidget* trigger_toggle = nullptr;
GtkWidget* trigger_revealer = nullptr;
GtkWidget* signal_toggle = nullptr;
GtkWidget* signal_revealer = nullptr;
GtkWidget* signal_panel = nullptr;
GtkWidget* side_box = nullptr;
GtkWidget* arm_button = nullptr;
GtkWidget* trigger_signal_drop = nullptr;
GtkWidget* sidebar_toggle = nullptr;
GtkWidget* side_scroll = nullptr;
GtkWidget* trigger_edge_drop = nullptr;
GtkWidget* trigger_mode_drop = nullptr;
GtkWidget* trigger_level_spin = nullptr;
GtkWidget* trigger_pre_spin = nullptr;
GtkWidget* trigger_holdoff_spin = nullptr;
GtkWidget* signal_list = nullptr;
GtkWidget* pause_button = nullptr;
GtkWidget* menu_button = nullptr;
GtkWidget* cursors_toggle = nullptr;
GtkWidget* cursors_reset = nullptr;
unsigned signal_generation = ~0u;
bool updating_panels = false; ///< guards signal handlers during a rebuild
bool relayout_pending = false;
bool signals_panel_initialised = false;
/// Set once the user edits the trigger level, so the panel stops defaulting it
/// into the middle of the declared range.
bool trigger_level_touched = false;
/// A short-lived message shown in the status line (a drop's outcome), so an
/// action that changes nothing still says why.
std::string notice;
gint64 notice_until_us = 0;
/// Trigger-mode state for the status line: how many latches were on screen last
/// frame, and whether an event is held (which is what "frozen" means).
std::uint64_t seen_latches = 0;
/// Which snapshot was framed last. Separate from `seen_latches` because a Disarm during
/// a capture publishes a snapshot without an event having fired.
std::uint64_t seen_event_seq = 0;
bool event_held = false;
/// Whether a capture is open on screen: the edge has been accepted and the post window
/// is filling in view, on the coming event's own clock. It is what keeps
/// `clamp_to_data()` off the pinned axis and tells the status line apart from "armed".
bool capturing = false;
/// The edge of the capture `capturing` refers to, so a second edge accepted before the
/// first capture closed re-frames instead of being mistaken for the same one.
double capture_edge = 0.0;
/// Monotonic time of the last event, and the edge that started the clock. The clock
/// starts when the *edge* is accepted, not when the capture closes -- that is the
/// instant the user saw -- and the edge is kept so the latch that ends a live-updated
/// capture does not restart it.
/// Wall time, not stream time: "how long since the last event" is a question about the
/// user's wait, and a stream whose clock runs at any other speed would answer it wrong.
/// 0 until the first event of the session.
gint64 last_latch_us = 0;
double timed_edge = 0.0;
double window_limit_s = -1.0; ///< the cap the window control was last clamped against
/// The rate each channel actually *stored* over the last second, and the declared rate
/// beside it. The two differ for two reasons now and both are worth seeing: the declared
/// rate is a claim the stream may not keep, and a window too long to hold at the wire
/// rate is resampled on ingest (`Partition::time_stride`), so this is also the readout
/// of how much resolution the current window costs. Re-measured once a second (the same
/// tick as the channel dump), never per frame.
std::vector<double> stored_rate;
std::vector<double> declared_rate;
/// Self-check: how much the axes' left edge moves between frames.
int dbg_axes_x_min = 1 << 20;
int dbg_axes_x_max = -(1 << 20);
int dbg_axes_frames = 0;
/// Frames on which some row's x window did not match row 0's. The rows share the
/// link group's one window, so this is an assertion: it must read 0.
int dbg_x_desync = 0;
gint64 last_channels_meas = 0;
/// Frames drawn with every bound trace slot empty although its channel has
/// samples: the view was swapped and not yet published.
int dbg_blank_frames = 0;
/// Frames the one area staged: a figure is one `::render` for every row.
int dbg_frames_painted = 0;
/// Where a frame's time goes, split at the two boundaries that can be observed from
/// here: `tick` is this function (bind, acquire, clamp, status), `draw` is everything
/// the plot renderer did (measured from the frame clock's `before-paint` to the return
/// of `PlotArea`'s own `::render` handler, which is why the adapter returns FALSE from
/// it), and `paint` is the whole GTK paint including the toolkit compositing the GL
/// area's framebuffer. A complaint about lag names one of the three; a summary that
/// does not separate them cannot say which.
int dbg_tick_n = 0;
double dbg_tick_ms_sum = 0.0;
double dbg_tick_ms_max = 0.0;
int dbg_paint_n = 0;
double dbg_paint_ms_sum = 0.0;
double dbg_paint_ms_max = 0.0;
double dbg_draw_ms_sum = 0.0;
double dbg_draw_ms_max = 0.0;
/// Which paint the worst `draw` was. A mean far below the max says the cost is
/// concentrated in a few frames but not *which*, and the two answers need opposite
/// responses: an early frame is the warm-up (the glyph atlas bake and the first
/// vertex-buffer growth land on it), a late one is a frame whose own geometry was
/// expensive and is the thing to chase.
int dbg_draw_max_frame = -1;
gint64 dbg_paint_start_us = 0;
GdkFrameClock* dbg_clock = nullptr;
/// The renderer's own account of what each frame cost it: whether the traces went
/// through the LOD pyramid or were drawn raw, and how much geometry that staged. The
/// *path* is the first question to ask of a slow frame -- `raw` frames are O(visible
/// samples) by construction.
std::uint64_t dbg_seg_sum = 0;
std::uint32_t dbg_seg_max = 0;
std::uint64_t dbg_visible_max = 0;
int dbg_raw_frames = 0;
int dbg_lod_frames = 0;
/// Frames the badge spent in each `BadgePhase`. Both `lit` and `dim` have to be non-zero
/// over a run that acquired anything -- that is what "it blinks" means, and a badge
/// stuck lit looks identical to a working one in a screenshot.
int dbg_badge_frames[3] = {0, 0, 0};
int dbg_reset_visible = -1;
std::vector<std::uint64_t> last_stored;
std::uint64_t dbg_latches_at_disarm = 0;
std::uint64_t dbg_seq_at_disarm = 0;
bool dbg_capturing_at_disarm = false;
int dbg_disarm_polls = 0;
int dbg_disarm_in_capture = 0;
int dbg_swaps = 0;
int dbg_axes_w_min = 1 << 20;
int dbg_axes_w_max = -(1 << 20);
int dbg_axes_w_prev = -1;
int dbg_axes_w_changes = 0;
int dbg_axes_w_change_frame = -1;
int dbg_axes_w_change_side = -1;
float dbg_scale_min = 1e9f;
float dbg_scale_max = -1e9f;
int dbg_vp_w_min = 1 << 20;
int dbg_vp_w_max = -(1 << 20);
int dbg_xtick_min = 1 << 20;
int dbg_xtick_max = -(1 << 20);
int dbg_gutter_min = 1 << 20;
int dbg_gutter_max = -(1 << 20);
int dbg_side_w = 0;
int dbg_side_h = 0;
int dbg_signal_w = 0;
int dbg_signal_h = 0;
int dbg_signal_row_h = 0;
int dbg_trigger_w = 0;
int dbg_trigger_h = 0;
int dbg_signal_revealed = 0;
int dbg_trigger_revealed = 0;
int dbg_side_visible = 0;
int dbg_trigger_items = 0;
int dbg_trigger_selected = -1;
int dbg_side_scroll_after_hide = 1;
std::string dbg_mode_triggered;
std::string dbg_window_text;
double dbg_marker_x = 0.0;
double dbg_marker_max = 0.0;
double dbg_trigger_time = 0.0;
int dbg_trigger_time_valid = -1;
std::string dbg_mode_live;
int dbg_toggles_visible = 0;
int dbg_trigger_revealed_default = 0;
};
// --- small helpers ----------------------------------------------------------
/// Installs the one style rule the app needs: a signal row that is as short as its
/// contents.
///
/// Adwaita gives a button a 34 px minimum height and a list row its own padding, and
/// those -- not the widgets in it -- are what set a signal row's height, so no amount
/// of trimming margins in C makes the list shorter. A handful of signals then filled
/// the whole side panel. Scoped to `.udpscope-compact` so it cannot reach any other
/// button in the window.
void install_css() {
static const char kCss[] =
"row.udpscope-compact { min-height: 0; padding: 1px 0; }\n"
"row.udpscope-compact button { min-height: 20px; min-width: 20px; padding: 0 3px; }\n"
"row.udpscope-compact checkbutton { min-height: 20px; }\n";
GtkCssProvider* p = gtk_css_provider_new();
gtk_css_provider_load_from_string(p, kCss);
gtk_style_context_add_provider_for_display(gdk_display_get_default(),
GTK_STYLE_PROVIDER(p),
GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
g_object_unref(p);
}
GtkWidget* labeled_box(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_widget_set_hexpand(child, true);
gtk_box_append(GTK_BOX(box), child);
return box;
}
GtkWidget* spin(double min, double max, double step, int digits) {
GtkWidget* s = gtk_spin_button_new_with_range(min, max, step);
gtk_spin_button_set_digits(GTK_SPIN_BUTTON(s), digits);
return s;
}
/// A modal, transient window with a vertical content box; the caller appends its
/// controls and then a button row. GTK 4.10 deprecated `GtkDialog` and
/// `GtkMessageDialog`, so the dialogs here are built from a plain window.
GtkWidget* dialog_window(GtkWidget* parent, const char* title) {
GtkWidget* w = gtk_window_new();
gtk_window_set_title(GTK_WINDOW(w), title);
if (parent != nullptr) gtk_window_set_transient_for(GTK_WINDOW(w), GTK_WINDOW(parent));
gtk_window_set_modal(GTK_WINDOW(w), TRUE);
gtk_window_set_default_size(GTK_WINDOW(w), 340, -1);
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10);
gtk_widget_set_margin_top(box, 12);
gtk_widget_set_margin_bottom(box, 12);
gtk_widget_set_margin_start(box, 12);
gtk_widget_set_margin_end(box, 12);
gtk_window_set_child(GTK_WINDOW(w), box);
return w;
}
/// Appends `button` to a right-aligned row at the end of the dialog's box.
void dialog_add_button(GtkWidget* window, GtkWidget* button) {
GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window));
GtkWidget* last = gtk_widget_get_last_child(box);
GtkWidget* row = nullptr;
if (last != nullptr && g_object_get_data(G_OBJECT(last), "udpscope-dialog-buttons") != nullptr) {
row = last;
} else {
row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_halign(row, GTK_ALIGN_END);
g_object_set_data(G_OBJECT(row), "udpscope-dialog-buttons", GINT_TO_POINTER(1));
gtk_box_append(GTK_BOX(box), row);
}
gtk_box_append(GTK_BOX(row), button);
}
void on_popover_closed(GtkPopover* popover, gpointer) {
// Unparent only. `gtk_widget_set_parent()` took the reference that the popover
// was created with (GTK4 widgets are created with a floating reference, sunk by
// the first parent), so unparenting drops it to zero and destroys the popover --
// a following g_object_unref() would be a use-after-free.
gtk_widget_unparent(GTK_WIDGET(popover));
}
/// 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.
struct WindowUnit {
const char* name;
double seconds;
};
constexpr WindowUnit kWindowUnits[] = {
{"\u00b5s", 1.0e-6}, {"ms", 1.0e-3}, {"s", 1.0}, {"min", 60.0}};
constexpr int kWindowUnitCount = 4;
/// 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.
constexpr double kWindowMinS = 1.0e-6;
/// 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.
constexpr int kWindowLabelChars = 9;
/// 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;
}
/// What the activity badge is saying. `Lit`/`Dim` are the two halves of the blink, so
/// the badge is only *blinking* while the pipeline is actually running; a steady badge is
/// a stopped one, which is the state worth noticing on a glance.
enum class BadgePhase { Lit = 0, Dim = 1, Idle = 2 };
/// The badge is one glyph in one of three colours. It must stay the *same glyph*: the
/// status row sits above the plot, and a badge that changed width as it blinked would
/// move the label beside it and re-lay out the plot twice a second.
const char* badge_markup(BadgePhase phase) {
switch (phase) {
case BadgePhase::Lit:
return "<span foreground=\"#45E045\">\u25CF</span>";
case BadgePhase::Dim:
return "<span foreground=\"#1C5A1C\">\u25CF</span>";
case BadgePhase::Idle:
break;
}
return "<span foreground=\"#5A5A5A\">\u25CF</span>";
}
/// `12 s`, `3m 07s`, `2h 14m` -- one unit coarser as soon as the finer one would need
/// three digits, so the field never grows past six characters and the status line's shape
/// does not depend on how long the wait has been.
std::string format_elapsed(double seconds) {
if (!(seconds >= 0.0)) return "--";
char buf[32];
if (seconds < 60.0) {
std::snprintf(buf, sizeof(buf), "%.0f s", std::floor(seconds));
} else if (seconds < 3600.0) {
const int m = static_cast<int>(seconds) / 60;
std::snprintf(buf, sizeof(buf), "%dm %02ds", m, static_cast<int>(seconds) % 60);
} else {
const int h = static_cast<int>(seconds) / 3600;
std::snprintf(buf, sizeof(buf), "%dh %02dm", h, (static_cast<int>(seconds) / 60) % 60);
}
return buf;
}
/// A sample rate in the unit it reads shortest in. Rates here span six decades (a 1 Hz
/// housekeeping signal next to a 1 MS/s acquisition), so a fixed unit makes one of the
/// two unreadable.
std::string format_rate(double per_s) {
char buf[32];
if (!(per_s > 0.0)) {
return "0 S/s";
} else if (per_s >= 1.0e6) {
std::snprintf(buf, sizeof(buf), "%.3g MS/s", per_s / 1.0e6);
} else if (per_s >= 1.0e3) {
std::snprintf(buf, sizeof(buf), "%.3g kS/s", per_s / 1.0e3);
} else {
std::snprintf(buf, sizeof(buf), "%.3g S/s", per_s);
}
return buf;
}
/// A short-lived line in front of the status text: the outcome of an action that the
/// picture does not show by itself (a drop that changed nothing, an export that wrote a
/// file). `tick()` clears it when it expires.
void set_notice(App& app, std::string what, int seconds = 4) {
app.notice = std::move(what);
app.notice_until_us = g_get_monotonic_time() + seconds * G_TIME_SPAN_SECOND;
}
// --- plot widgets -----------------------------------------------------------
void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer user);
gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, gpointer user);
/// 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".
constexpr int kRowKeyBias = 1;
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});
}
}
/// 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.
int on_render_probe(GtkGLArea*, GdkGLContext*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->dbg_paint_start_us != 0) {
const double ms = double(g_get_monotonic_time() - app->dbg_paint_start_us) / 1000.0;
app->dbg_draw_ms_sum += ms;
if (ms > app->dbg_draw_ms_max) {
app->dbg_draw_ms_max = ms;
app->dbg_draw_max_frame = app->dbg_paint_n;
}
}
if (app->area != nullptr) {
if (const plot::Renderer* r = app->area->renderer()) {
const plot::FrameStats& s = r->stats();
app->dbg_seg_sum += s.staged_segments;
app->dbg_seg_max = std::max(app->dbg_seg_max, s.staged_segments);
app->dbg_visible_max = std::max(app->dbg_visible_max, s.visible_samples);
if (s.raw_traces > 0) ++app->dbg_raw_frames;
if (s.lod_traces > 0) ++app->dbg_lod_frames;
}
}
return FALSE; // same reason the adapter does: never stop the emission
}
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.
void rebuild_signal_panels(App& app);
// --- 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.
constexpr int kLayoutButtonPx = 24;
constexpr int kLayoutButtonPad = 4;
/// `g_object_set_data` key telling a remove button from an insert one. The row/boundary
/// index is the usual `set_row_key()`.
constexpr const char* kLayoutKindKey = "udpscope-layout-insert";
void rebuild_layout_editor(App& app);
/// 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);
}
}
app.layout_buttons.clear();
app.layout_buttons_rows = 0;
}
void on_layout_row_remove(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const int row = row_key(GTK_WIDGET(button));
if (!app->rows->remove_row(row)) {
app->notice = "a figure needs at least one row";
app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
return;
}
// `remove_row()` has already built the new figure (a `Figure`'s row count is fixed at
// construction), so the area has to be re-pointed here rather than through
// `relayout_areas()`, which only acts on a *dirty* row.
if (app->area != nullptr) app->area->set_figure(&app->rows->figure());
rebuild_layout_editor(*app);
// The signal list's chips name the trace a signal is drawn as, and a removed row took
// its traces with it.
rebuild_signal_panels(*app);
}
void on_layout_row_insert(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const int at = row_key(GTK_WIDGET(button));
if (!app->rows->insert_row(at)) {
app->notice = "at most " + std::to_string(RowSet::kMaxRows) + " rows";
app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
return;
}
if (app->area != nullptr) app->area->set_figure(&app->rows->figure());
rebuild_layout_editor(*app);
rebuild_signal_panels(*app);
}
/// 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;
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));
}
}
}
/// Builds one `×` per row and one `+` per boundary, or takes them all off again when the
/// editor is switched off.
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 = gtk_button_new_from_icon_name("window-close-symbolic");
gtk_widget_set_tooltip_text(b, "remove this row");
gtk_widget_add_css_class(b, "circular");
gtk_widget_add_css_class(b, "osd");
gtk_widget_set_size_request(b, kLayoutButtonPx, kLayoutButtonPx);
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);
gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), b);
app.layout_buttons.push_back(b);
}
for (int b_i = 0; b_i <= rows; ++b_i) {
GtkWidget* b = gtk_button_new_from_icon_name("list-add-symbolic");
gtk_widget_set_tooltip_text(b, b_i == 0 ? "add a row above"
: b_i == rows ? "add a row below"
: "add a row here");
gtk_widget_add_css_class(b, "circular");
gtk_widget_add_css_class(b, "osd");
gtk_widget_set_size_request(b, kLayoutButtonPx, kLayoutButtonPx);
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);
gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), b);
app.layout_buttons.push_back(b);
}
sync_layout_editor(app);
}
void on_layout_editor_toggled(GtkToggleButton* button, gpointer user) {
App* app = static_cast<App*>(user);
app->layout_editing = gtk_toggle_button_get_active(button);
rebuild_layout_editor(*app);
}
void on_signal_switch(GObject* obj, GParamSpec*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->updating_panels) return;
const std::size_t index = static_cast<std::size_t>(
GPOINTER_TO_SIZE(g_object_get_data(obj, "udpscope-signal")));
app->model->set_signal_enabled(index, gtk_check_button_get_active(GTK_CHECK_BUTTON(obj)));
}
void on_arm_clicked(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user);
(void)button;
bool armed = false;
int signal = -1;
{
// The producer reads (and, on a single-shot latch, writes) these fields under
// the same lock; an unguarded read-modify-write here can lose its update.
std::lock_guard<std::mutex> lock(app->model->mutex());
TriggerConfig& tc = app->model->trigger();
tc.armed = !tc.armed;
armed = tc.armed;
signal = tc.channel;
}
if (armed) {
app->event_held = false;
app->capturing = false;
app->model->trigger_time_valid().store(false, std::memory_order_relaxed);
}
// Arming is a statement that this signal should be watched, so its acquisition
// is switched on with it -- otherwise the trigger could never see a sample.
// Outside the lock: set_signal_enabled() takes it.
if (armed && signal >= 0) app->model->set_signal_enabled(static_cast<std::size_t>(signal), true);
gtk_button_set_label(GTK_BUTTON(app->arm_button), armed ? "Disarm" : "Arm");
}
void publish_trigger_fields(App* app) {
if (app->updating_panels) return;
// Read the widgets first (they may be touched again by a re-entrant handler if a
// setter ran while the lock was held), then publish the values under the lock the
// producer reads them with.
const double level = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_level_spin));
const double pre = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_pre_spin));
const double holdoff = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_holdoff_spin));
const guint edge = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_edge_drop));
const guint mode = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_mode_drop));
{
std::lock_guard<std::mutex> lock(app->model->mutex());
TriggerConfig& tc = app->model->trigger();
tc.level = level;
tc.pretrigger_pct = pre;
tc.holdoff_s = holdoff;
tc.edge = edge == 0 ? TriggerEdge::Rising
: edge == 1 ? TriggerEdge::Falling
: TriggerEdge::Both;
tc.mode = mode == 0 ? TriggerMode::Normal : TriggerMode::Single;
}
}
void on_trigger_field_changed(GtkWidget*, gpointer user) {
publish_trigger_fields(static_cast<App*>(user));
}
/// The same publish, for the two dropdowns. It needs its own entry point because
/// `notify::<prop>` passes a `GParamSpec*` ahead of the user data while
/// `GtkSpinButton::value-changed` does not: a two-parameter callback on a `notify::`
/// signal reads the pspec as its `App*` and crashes on the first field it touches.
void on_trigger_drop_changed(GObject*, GParamSpec*, gpointer user) {
publish_trigger_fields(static_cast<App*>(user));
}
void on_trigger_signal_changed(GObject* obj, GParamSpec*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->updating_panels) return;
(void)obj;
const guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_signal_drop));
if (sel == GTK_INVALID_LIST_POSITION) return;
{
std::lock_guard<std::mutex> lock(app->model->mutex());
if (sel >= app->model->channels().size()) return;
app->model->trigger().channel = static_cast<int>(sel);
}
app->trigger_level_touched = false; // a new signal gets a fresh default level
}
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]);
}
}
struct CalibContext {
App* app;
std::size_t signal;
GtkWidget* gain;
GtkWidget* offset;
GtkWidget* window;
};
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)));
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);
GtkWidget* gain = spin(-1.0e12, 1.0e12, 0.001, 6);
GtkWidget* offset = spin(-1.0e12, 1.0e12, 0.001, 6);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(gain), 1.0);
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");
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<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));
}
struct DragContext {
App* app;
std::size_t signal;
};
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);
}
/// The palette entry a signal owns: its position among the *plottable* signals, the
/// time base skipped. It is a property of the signal and not of the row/slot a drop
/// lands in, so the same signal is drawn in the same colour on every plot it appears on
/// -- which is what lets one chip in this list name every one of those traces.
///
/// Positional, like the channel list, so a new CONFIG re-colours; that is fine because a
/// CONFIG drops the assignments and the history with them. A name hash would survive a
/// CONFIG but collide inside one, and two traces of the same colour on one row is the
/// failure this exists to prevent.
int signal_color_index(const std::vector<SignalInfo>& signals, std::size_t signal) {
int index = 0;
for (std::size_t i = 0; i < signals.size() && i < signal; ++i) {
if (!ScopeModel::is_time_signal(signals, i)) ++index;
}
return index;
}
std::string chip_markup(plot::Color c) {
char buf[64];
std::snprintf(buf, sizeof(buf), "<span foreground=\"#%02X%02X%02X\">\u25CF</span>",
unsigned(c.r * 255.0f + 0.5f), unsigned(c.g * 255.0f + 0.5f),
unsigned(c.b * 255.0f + 0.5f));
return buf;
}
/// The chip for a row of the signal list, as Pango markup: the signal's own colour when
/// it is on a plot, grey when it is not (whether that is because it is not acquired or
/// because it is acquired and not placed). Always a filled disc, never a blank: a blank
/// cell is narrower than a disc, so the names beside it used to shift sideways as
/// signals were placed, and "no trace" read as "no chip here" rather than as a state.
std::string signal_chip_markup(App& app, const std::vector<SignalInfo>& signals,
std::size_t signal) {
const std::string& name = signals[signal].name;
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) {
if (row.signal_name[static_cast<std::size_t>(s)] != name) continue;
return chip_markup(RowSet::palette_color(signal_color_index(signals, signal)));
}
}
return chip_markup(RowSet::unplotted_color());
}
/// One line per signal: colour chip, checkbox, name, unit, and a menu for the things
/// you touch once (information, calibration). A `GtkSwitch` and two always-visible
/// buttons made every row about twice this tall, which is what turned a four-signal
/// stream into a panel you have to scroll.
void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
app.updating_panels = true;
GtkWidget* list = app.signal_list;
GtkWidget* child = gtk_widget_get_first_child(list);
while (child != nullptr) {
GtkWidget* next = gtk_widget_get_next_sibling(child);
gtk_list_box_remove(GTK_LIST_BOX(list), child);
child = next;
}
for (std::size_t i = 0; i < signals.size(); ++i) {
if (ScopeModel::is_time_signal(signals, i)) {
continue; // the time base is not a plottable signal
}
const SignalInfo& sig = signals[i];
GtkWidget* row = gtk_list_box_row_new();
gtk_widget_add_css_class(row, "udpscope-compact");
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_margin_start(box, 6);
gtk_widget_set_margin_end(box, 2);
GtkWidget* chip = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(chip), signal_chip_markup(app, signals, i).c_str());
gtk_widget_set_tooltip_text(chip, app.model->signal_enabled(i)
? "the colour this signal is plotted in"
: "not acquired");
gtk_box_append(GTK_BOX(box), chip);
GtkWidget* enable = gtk_check_button_new();
gtk_check_button_set_active(GTK_CHECK_BUTTON(enable), app.model->signal_enabled(i));
gtk_widget_set_tooltip_text(enable, "acquire this signal");
g_object_set_data(G_OBJECT(enable), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(enable, "notify::active", G_CALLBACK(on_signal_switch), &app);
gtk_box_append(GTK_BOX(box), enable);
GtkWidget* name = gtk_label_new(sig.name.c_str());
gtk_widget_set_hexpand(name, true);
gtk_widget_set_halign(name, GTK_ALIGN_START);
gtk_label_set_ellipsize(GTK_LABEL(name), PANGO_ELLIPSIZE_END);
gtk_box_append(GTK_BOX(box), name);
if (!sig.unit.empty()) {
GtkWidget* unit = gtk_label_new(sig.unit.c_str());
gtk_widget_add_css_class(unit, "dim-label");
gtk_box_append(GTK_BOX(box), unit);
}
// The overflow: information and calibration are set once per session, so they
// cost a click here instead of a button on every row forever.
GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
gtk_widget_set_margin_top(menu_box, 4);
gtk_widget_set_margin_bottom(menu_box, 4);
gtk_widget_set_margin_start(menu_box, 4);
gtk_widget_set_margin_end(menu_box, 4);
GtkWidget* info = gtk_button_new_with_label("Information");
g_object_set_data(G_OBJECT(info), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(info, "clicked", G_CALLBACK(on_info_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), info);
GtkWidget* cal = gtk_button_new_with_label("Calibrate...");
gtk_widget_set_tooltip_text(cal, "gain and offset, applied at ingest");
g_object_set_data(G_OBJECT(cal), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(cal, "clicked",
G_CALLBACK(+[](GtkButton* b, gpointer user) {
App* a = static_cast<App*>(user);
const std::size_t idx = static_cast<std::size_t>(
GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(b), "udpscope-signal")));
open_calibration(*a, GTK_WINDOW(a->window), idx);
}),
&app);
gtk_box_append(GTK_BOX(menu_box), cal);
GtkWidget* popover = gtk_popover_new();
gtk_popover_set_child(GTK_POPOVER(popover), menu_box);
GtkWidget* more = gtk_menu_button_new();
gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(more), "view-more-symbolic");
gtk_menu_button_set_popover(GTK_MENU_BUTTON(more), popover);
gtk_widget_add_css_class(more, "flat");
gtk_widget_set_tooltip_text(more, "information and calibration");
gtk_box_append(GTK_BOX(box), more);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box);
DragContext* ctx = new DragContext{&app, i};
GtkDragSource* source = gtk_drag_source_new();
gtk_drag_source_set_actions(source, GDK_ACTION_COPY);
g_signal_connect_data(source, "prepare", G_CALLBACK(on_drag_prepare), ctx,
[](gpointer data, GClosure*) { delete static_cast<DragContext*>(data); },
G_CONNECT_DEFAULT);
gtk_widget_add_controller(row, GTK_EVENT_CONTROLLER(source));
gtk_list_box_append(GTK_LIST_BOX(list), row);
}
app.updating_panels = false;
}
/// 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();
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.level = mid;
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_level_spin), mid);
}
}
app.updating_panels = false;
}
// --- toolbar handlers -------------------------------------------------------
/// The widest window that is worth offering. `--max-window` only: the buffer no longer
/// sets the ceiling, because a window too long to store at the wire rate is decimated on
/// ingest to fit rather than refused (see `plan_partition()`). It used to be capped to
/// `buffered_span()` as well, which is what made a 600 s view of a 1 MS/s stream
/// unreachable.
double window_limit(const App& app) { return app.model->config().max_window_s; }
/// 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 (app.window_label == nullptr || !(seconds > 0.0)) return;
app.updating_panels = true;
gtk_label_set_text(GTK_LABEL(app.window_label), format_window(seconds).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;
}
/// The one place the display window 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.
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.rows->set_time_window(clamped);
app.model->set_event_span(clamped);
sync_window_display(app, clamped);
}
/// 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);
}
/// Re-clamps the window against the current cap. Called once at start-up and again when a
/// CONFIG changes how much history one buffer holds -- never on a mere enable or calibration
/// change, so those cannot silently re-anchor the view.
void refresh_window_limits(App& app) {
if (app.window_label == nullptr) return;
const double limit = window_limit(app);
if (std::fabs(limit - app.window_limit_s) < 1.0e-9) return;
app.window_limit_s = limit;
apply_time_window(app, app.rows->time_window());
}
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);
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");
}
void on_trigger_toggled(GtkToggleButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const bool on = gtk_toggle_button_get_active(button);
int signal = -1;
app->model->set_trigger_enabled(on); // takes the lock and mirrors the atomic
{
std::lock_guard<std::mutex> lock(app->model->mutex());
TriggerConfig& tc = app->model->trigger();
tc.armed = on; // trigger mode means "wait for an event", so it starts armed
signal = tc.channel;
}
app->event_held = false;
app->capturing = false;
if (!on) {
app->model->trigger_time_valid().store(false, std::memory_order_relaxed);
app->model->set_display_epoch(0.0); // back to seconds since the stream started
app->rows->set_time_window(app->rows->time_window()); // and follow the live edge
}
if (on && signal >= 0) {
app->model->set_signal_enabled(static_cast<std::size_t>(signal), true);
}
gtk_revealer_set_reveal_child(GTK_REVEALER(app->trigger_revealer), on);
gtk_button_set_label(GTK_BUTTON(app->arm_button), on ? "Disarm" : "Arm");
}
/// The way out of trigger mode, from inside the panel that froze the view: it is
/// the toolbar's trigger switch, so there is exactly one piece of state.
void on_go_live(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->trigger_toggle), FALSE);
}
void on_cursors_toggled(GtkToggleButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const bool on = gtk_toggle_button_get_active(button);
app->rows->set_cursors_visible(on);
// The reset button exists for the cursors: with them hidden there is nothing to
// reset, and a button that does nothing reads as broken.
gtk_widget_set_visible(app->cursors_reset, on);
}
void on_signals_toggled(GtkToggleButton* button, gpointer user) {
App* app = static_cast<App*>(user);
gtk_revealer_set_reveal_child(GTK_REVEALER(app->signal_revealer),
gtk_toggle_button_get_active(button));
}
/// Fit: auto-scale every row's y axis and re-frame the x window on the data the
/// ring holds (the plot's own `f` semantics), then tell the toolbar what span that
/// is.
void on_fit_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
app->rows->fit_all();
sync_window_display(*app, app->rows->time_window());
app->notice = "fitted to the buffered data (" + format_window(app->rows->time_window()) + ")";
app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
}
void on_cursors_reset(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
// A reset the user cannot see reads as a dead button, so it also switches the
// cursors on.
if (!gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app->cursors_toggle))) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->cursors_toggle), true);
}
app->rows->reset_cursors();
}
// --- 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.
struct ExportContext {
App* app = nullptr;
ExportTable table;
};
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);
}
// --- 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);
}
struct FixedRangeContext {
App* app;
int row;
GtkWidget* lo;
GtkWidget* hi;
GtkWidget* window;
};
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);
GtkWidget* hi = spin(-1.0e12, 1.0e12, 0.1, 6);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(lo), cur.min);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(hi), 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.
struct RemoveTarget {
App* app;
int row;
int slot;
};
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;
const std::string name = target->app->rows->row(target->row).name[static_cast<std::size_t>(
target->slot)];
// Freeing the slot is all it takes: the row is rebuilt with a trace whose series
// is nothing, so it draws nothing and disappears from the legend.
target->app->rows->assign(target->row, target->slot, std::string(), std::string(), 0);
target->app->relayout_pending = true;
rebuild_signal_panels(*target->app); // the signal's chip goes grey with it
target->app->notice = "removed '" + name + "' from plot " + std::to_string(target->row + 1);
target->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);
}
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);
GtkWidget* popover = gtk_popover_new();
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
gtk_widget_set_margin_top(box, 4);
gtk_widget_set_margin_bottom(box, 4);
gtk_widget_set_margin_start(box, 4);
gtk_widget_set_margin_end(box, 4);
char title[64];
std::snprintf(title, sizeof(title), "Plot %d", row + 1);
GtkWidget* heading = gtk_label_new(title);
gtk_widget_set_halign(heading, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(box), heading);
GtkWidget* auto_btn = gtk_button_new_with_label("Y axis: auto scale");
set_row_key(auto_btn, row);
g_object_set_data(G_OBJECT(auto_btn), "udpscope-popover", popover);
g_signal_connect(auto_btn, "clicked", G_CALLBACK(on_y_auto_clicked), app);
gtk_box_append(GTK_BOX(box), auto_btn);
GtkWidget* fixed_btn = gtk_button_new_with_label("Y axis: fixed range...");
set_row_key(fixed_btn, row);
g_object_set_data(G_OBJECT(fixed_btn), "udpscope-popover", popover);
g_signal_connect(fixed_btn, "clicked", G_CALLBACK(on_y_fixed_clicked), app);
gtk_box_append(GTK_BOX(box), fixed_btn);
// One entry per occupied trace slot of this row.
const RowSet::Row& r = app->rows->row(row);
bool any = false;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
if (r.channel[static_cast<std::size_t>(s)] < 0) continue;
const std::string label =
"Remove '" + r.name[static_cast<std::size_t>(s)] + "' from plot " + std::to_string(row + 1);
GtkWidget* btn = gtk_button_new_with_label(label.c_str());
auto* target = new RemoveTarget{app, row, s};
g_object_set_data_full(G_OBJECT(btn), "udpscope-remove", target,
[](gpointer p) { delete static_cast<RemoveTarget*>(p); });
g_object_set_data(G_OBJECT(btn), "udpscope-popover", popover);
g_signal_connect(btn, "clicked", G_CALLBACK(on_remove_clicked), nullptr);
gtk_box_append(GTK_BOX(box), btn);
any = true;
}
if (!any) {
GtkWidget* empty = gtk_label_new("no signals on this plot");
gtk_widget_add_css_class(empty, "dim-label");
gtk_widget_set_halign(empty, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(box), empty);
}
gtk_popover_set_child(GTK_POPOVER(popover), box);
GdkRectangle rect{static_cast<int>(x), static_cast<int>(y), 1, 1};
gtk_popover_set_pointing_to(GTK_POPOVER(popover), &rect);
gtk_widget_set_parent(popover, widget);
// 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);
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);
}
gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y,
gpointer user) {
App* app = static_cast<App*>(user);
if (!G_VALUE_HOLDS_STRING(value)) return FALSE;
// Where the drop landed decides the row: one area covers them all.
const int row = row_at(*app, x, y);
const char* text = g_value_get_string(value);
const std::size_t signal = static_cast<std::size_t>(std::atoll(text ? text : "0"));
if (signal >= app->model->signals().size()) return FALSE;
const auto notice = [&](const std::string& what) {
app->notice = what;
app->notice_until_us = g_get_monotonic_time() + 4 * G_TIME_SPAN_SECOND;
std::fprintf(stderr, "[udpscope] drop: %s\n", what.c_str());
std::fflush(stderr);
};
// The dropped signal's channels are the ones the model built for it; take the
// first, or the first element if the signal is a vector.
const std::vector<ChannelInfo>& channels = app->model->channels();
int channel = -1;
std::string name;
for (std::size_t i = 0; i < channels.size(); ++i) {
if (channels[i].signal == signal) {
channel = static_cast<int>(i);
name = channels[i].name;
break;
}
}
if (channel < 0) {
// The signal is not enabled: enable it, then take the channel that appears.
app->model->set_signal_enabled(signal, true);
const std::vector<ChannelInfo>& again = app->model->channels();
for (std::size_t i = 0; i < again.size(); ++i) {
if (again[i].signal == signal) {
channel = static_cast<int>(i);
name = again[i].name;
break;
}
}
}
if (channel < 0) {
notice("cannot acquire '" + app->model->signals()[signal].name +
"' (multi-element signals are not acquired)");
return FALSE;
}
int slot = -1;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
if (!app->rows->slot_bound(row, s)) {
slot = s;
break;
}
}
if (slot < 0) {
notice("plot " + std::to_string(row + 1) + " already holds four signals");
return FALSE;
}
// The slot remembers the *signal* (and element), never the channel index: the
// channel list is renumbered whenever a signal is enabled or disabled.
const std::vector<SignalInfo>& signals = app->model->signals();
const SignalInfo& sig = signals[signal];
app->rows->assign(row, slot, sig.name, name, signal_color_index(signals, signal));
app->relayout_pending = true;
rebuild_signal_panels(*app); // the row's colour chip now has a trace to name
cancel_drag_on(*app, row);
notice("plotting '" + name + "' on plot " + std::to_string(row + 1));
return TRUE;
}
/// 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_scroll == nullptr) return;
const bool have_signals = !app.model->signals().empty();
const bool want_column =
have_signals && gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.sidebar_toggle));
gtk_widget_set_visible(app.side_scroll, want_column);
gtk_widget_set_visible(app.signal_toggle, have_signals);
gtk_widget_set_visible(app.trigger_toggle, have_signals);
}
void on_sidebar_toggled(GtkToggleButton* button, gpointer user) {
App* app = static_cast<App*>(user);
(void)button;
update_side_visibility(*app);
}
// --- rebuild of the derived panels -----------------------------------------
void rebuild_signal_panels(App& app) {
// Snapshot the model's lists under the lock: the acquisition thread replaces
// them on every CONFIG, and the panel build holds these references across many
// GTK calls (a use-after-free of the name strings otherwise).
std::vector<SignalInfo> signals;
bool have_signals = false;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
signals = app.model->signals();
have_signals = !signals.empty();
}
rebuild_signal_list(app, signals);
rebuild_trigger_signals(app);
refresh_window_limits(app);
// Nothing to configure before a CONFIG arrives: hide the whole column and its
// toolbar buttons rather than showing two empty panels. The first CONFIG
// reveals the signal panel (the trigger panel stays behind its own button).
update_side_visibility(app);
if (have_signals && !app.signals_panel_initialised) {
app.signals_panel_initialised = true;
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.signal_toggle), TRUE);
}
if (!have_signals) {
app.signals_panel_initialised = false;
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.signal_toggle), FALSE);
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.trigger_toggle), FALSE);
}
app.signal_generation = app.model->generation();
}
// --- the frame tick ---------------------------------------------------------
gboolean tick(gpointer user) {
App* app = static_cast<App*>(user);
if (app->window == nullptr) return G_SOURCE_REMOVE;
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);
}
}
if (app->relayout_pending) {
app->relayout_pending = false;
relayout_areas(*app);
}
if (model.generation() != app->signal_generation) rebuild_signal_panels(*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();
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());
// Mid-capture the live ring is drawn on the coming event's clock, so its edge sits at
// x = 0 exactly as a snapshot's does and the pinned [-pre, +post] axis means the same
// thing in both. Every other mode reads stream seconds. The epoch is a consumer-side
// offset on the live channels, so it has to be in place before the bind that hands
// them to the traces -- and it is written here, under the lock, because a rebuild can
// replace the store.
const double want_epoch = display == DisplayMode::Capturing ? -cap_edge : 0.0;
if (model.display_epoch() != want_epoch) model.set_display_epoch(want_epoch);
app->rows->bind(model, display);
app->rows->acquire(model);
}
// 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) app->rows->clamp_to_data();
// 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;
// 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.
if (!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;
}
// 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_y_gutter();
// 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);
if (app->area_widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(app->area_widget));
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;
}
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();
/// Filled only on a measuring tick, and applied to the status label's tooltip after the
/// model lock is dropped: the status line can only carry the aggregate rate, so the
/// per-channel breakdown goes where there is room for it.
std::string rate_tip;
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;
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;
rate_tip += (rate_tip.empty() ? "" : "\n") + info.name + ": " + format_rate(per_s) +
" stored (declared " + format_rate(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().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 (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.
const bool paused = app->acq->paused();
const bool active = st.connected.load() && !paused && (!trigger_on || armed);
const bool lit = ((g_get_monotonic_time() / (G_TIME_SPAN_SECOND / 2)) & 1) == 0;
const BadgePhase phase =
!active ? BadgePhase::Idle : (lit ? BadgePhase::Lit : BadgePhase::Dim);
const int reason = active ? 0 : !st.connected.load() ? 1 : paused ? 2 : 3;
static const char* const kWhy[] = {"acquiring", "waiting for the stream", "paused",
"trigger disarmed"};
// 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 = "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 = "rate " + (hi - lo <= 0.01 * hi ? format_rate(hi)
: format_rate(lo) + ".." + format_rate(hi));
}
// In trigger mode, how long ago the event on screen was captured. It is what tells a
// frozen picture apart from a stale one: the view looks identical whether the last
// event was a second or an hour ago.
std::string since;
if (trigger_on) {
since = app->last_latch_us != 0
? " | last event " +
format_elapsed(double(g_get_monotonic_time() - app->last_latch_us) / 1.0e6) +
" ago"
: " | no event yet";
}
char status[1024];
std::snprintf(status, sizeof(status),
"[%s] %s %s | %s | packets %llu frames %llu gaps %llu | %zu signals, %zu "
"channels | plot 1: %d trace(s), %zu samples | buffer %.1f MB, %.2f s span, "
"time x%llu%s",
mode, st.connected.load() ? "connected" : "waiting",
st.have_config.load() ? "configured" : "-", rate.c_str(),
static_cast<unsigned long long>(st.packets.load()),
static_cast<unsigned long long>(st.frames.load()),
static_cast<unsigned long long>(st.gaps.load()), signal_count, channel_count, bound,
samples, double(model.config().buffer_mb), span_s,
static_cast<unsigned long long>(time_stride), since.c_str());
if (!app->notice.empty() && g_get_monotonic_time() < app->notice_until_us) {
const std::string with_notice = app->notice + " | " + status;
gtk_label_set_text(GTK_LABEL(app->status_label), with_notice.c_str());
} else {
app->notice.clear();
gtk_label_set_text(GTK_LABEL(app->status_label), status);
}
if (!rate_tip.empty()) gtk_widget_set_tooltip_text(app->status_label, rate_tip.c_str());
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 ----------------------------------------------------
GtkWidget* build_trigger_panel(App& app) {
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
gtk_widget_set_margin_top(box, 8);
gtk_widget_set_margin_bottom(box, 8);
gtk_widget_set_margin_start(box, 8);
gtk_widget_set_margin_end(box, 8);
GtkWidget* heading = gtk_label_new("Trigger");
gtk_widget_set_halign(heading, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(box), heading);
app.trigger_signal_drop = gtk_drop_down_new(G_LIST_MODEL(gtk_string_list_new(nullptr)), nullptr);
g_signal_connect(app.trigger_signal_drop, "notify::selected",
G_CALLBACK(on_trigger_signal_changed), &app);
gtk_box_append(GTK_BOX(box), labeled_box("signal", app.trigger_signal_drop));
const char* edges[] = {"rising", "falling", "both", nullptr};
app.trigger_edge_drop = gtk_drop_down_new_from_strings(edges);
gtk_box_append(GTK_BOX(box), labeled_box("edge", app.trigger_edge_drop));
app.trigger_level_spin = spin(-1.0e12, 1.0e12, 0.01, 4);
g_signal_connect(app.trigger_level_spin, "value-changed", G_CALLBACK(+[](GtkWidget*, gpointer u) {
static_cast<App*>(u)->trigger_level_touched = true;
}),
&app);
gtk_box_append(GTK_BOX(box), labeled_box("level", app.trigger_level_spin));
app.trigger_pre_spin = spin(0.0, 99.0, 1.0, 0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_pre_spin), 50.0);
gtk_box_append(GTK_BOX(box), labeled_box("pre-trigger %", app.trigger_pre_spin));
app.trigger_holdoff_spin = spin(0.0, 10.0, 0.001, 4);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_holdoff_spin),
app.model->config().trigger_holdoff_s);
gtk_box_append(GTK_BOX(box), labeled_box("hold-off s", app.trigger_holdoff_spin));
const char* modes[] = {"normal", "single", nullptr};
app.trigger_mode_drop = gtk_drop_down_new_from_strings(modes);
gtk_box_append(GTK_BOX(box), labeled_box("mode", app.trigger_mode_drop));
app.arm_button = gtk_button_new_with_label("Arm");
g_signal_connect(app.arm_button, "clicked", G_CALLBACK(on_arm_clicked), &app);
gtk_box_append(GTK_BOX(box), app.arm_button);
GtkWidget* live = gtk_button_new_with_label("Go live (trigger off)");
gtk_widget_set_tooltip_text(live, "disable trigger mode and stream continuously");
g_signal_connect(live, "clicked", G_CALLBACK(on_go_live), &app);
gtk_box_append(GTK_BOX(box), live);
for (GtkWidget* w : {app.trigger_level_spin, app.trigger_pre_spin, app.trigger_holdoff_spin}) {
g_signal_connect(w, "value-changed", G_CALLBACK(on_trigger_field_changed), &app);
}
g_signal_connect(app.trigger_edge_drop, "notify::selected",
G_CALLBACK(on_trigger_drop_changed), &app);
g_signal_connect(app.trigger_mode_drop, "notify::selected",
G_CALLBACK(on_trigger_drop_changed), &app);
return box;
}
GtkWidget* build_signal_panel(App& app) {
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
gtk_widget_set_margin_top(box, 8);
gtk_widget_set_margin_bottom(box, 8);
gtk_widget_set_margin_start(box, 8);
gtk_widget_set_margin_end(box, 8);
GtkWidget* heading = gtk_label_new("Signals");
gtk_widget_set_halign(heading, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(box), heading);
GtkWidget* hint = gtk_label_new("drag a signal onto a plot");
gtk_widget_set_halign(hint, GTK_ALIGN_START);
gtk_widget_add_css_class(hint, "dim-label");
gtk_box_append(GTK_BOX(box), hint);
app.signal_list = gtk_list_box_new();
gtk_list_box_set_selection_mode(GTK_LIST_BOX(app.signal_list), GTK_SELECTION_NONE);
// Width only. A height floor here made the list occupy 120 px whatever it held,
// which hid the compact rows' height behind it and wasted the space on an empty
// stream; the scroller above it already caps how tall it can grow.
gtk_widget_set_size_request(app.signal_list, 260, -1);
gtk_box_append(GTK_BOX(box), app.signal_list);
return box;
}
/// 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;
}
/// 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.
///
/// [window ▾] [plots ▾] ............... [pause] [trigger] [panel] [menu]
GtkWidget* build_toolbar(App& app) {
GtkWidget* bar = gtk_header_bar_new();
gtk_header_bar_pack_start(GTK_HEADER_BAR(bar),
labeled_box("window", build_window_control(app)));
refresh_window_limits(app);
// --- the overflow menu ----------------------------------------------------
// Plain buttons in a popover rather than a GMenu: these act on the `RowSet`
// directly and two of them are toggles whose state the rest of the UI reads, so
// a GAction model would only add a second copy of that state.
GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
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 = gtk_button_new_with_label("Fit to data");
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 = gtk_toggle_button_new_with_label("Layout editor");
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);
app.cursors_toggle = gtk_toggle_button_new_with_label("Cursors");
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.cursors_toggle), false);
g_signal_connect(app.cursors_toggle, "toggled", G_CALLBACK(on_cursors_toggled), &app);
gtk_box_append(GTK_BOX(menu_box), app.cursors_toggle);
app.cursors_reset = gtk_button_new_with_label("Reset cursors");
gtk_widget_set_tooltip_text(app.cursors_reset,
"re-place the cursors (only shown while they exist)");
g_signal_connect(app.cursors_reset, "clicked", G_CALLBACK(on_cursors_reset), &app);
gtk_box_append(GTK_BOX(menu_box), app.cursors_reset);
gtk_box_append(GTK_BOX(menu_box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
GtkWidget* export_btn = gtk_button_new_with_label("Export visible range...");
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);
gtk_box_append(GTK_BOX(menu_box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
app.signal_toggle = gtk_toggle_button_new_with_label("Signal list");
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.signal_toggle), true);
g_signal_connect(app.signal_toggle, "toggled", G_CALLBACK(on_signals_toggled), &app);
gtk_box_append(GTK_BOX(menu_box), app.signal_toggle);
GtkWidget* popover = gtk_popover_new();
gtk_popover_set_child(GTK_POPOVER(popover), menu_box);
app.menu_button = gtk_menu_button_new();
gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(app.menu_button), "open-menu-symbolic");
gtk_menu_button_set_popover(GTK_MENU_BUTTON(app.menu_button), popover);
gtk_widget_set_tooltip_text(app.menu_button, "view options");
gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.menu_button);
// --- what stays on the bar (packed end: first call is rightmost) ----------
app.sidebar_toggle = gtk_toggle_button_new();
gtk_button_set_icon_name(GTK_BUTTON(app.sidebar_toggle), "sidebar-show-symbolic");
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.sidebar_toggle), true);
gtk_widget_set_tooltip_text(app.sidebar_toggle, "show the side panel");
g_signal_connect(app.sidebar_toggle, "toggled", G_CALLBACK(on_sidebar_toggled), &app);
gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.sidebar_toggle);
app.trigger_toggle = gtk_toggle_button_new();
gtk_button_set_icon_name(GTK_BUTTON(app.trigger_toggle), "media-record-symbolic");
gtk_widget_set_tooltip_text(app.trigger_toggle,
"trigger mode: freeze the view and show one captured event "
"at a time");
g_signal_connect(app.trigger_toggle, "toggled", G_CALLBACK(on_trigger_toggled), &app);
gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.trigger_toggle);
app.pause_button = gtk_toggle_button_new();
gtk_button_set_icon_name(GTK_BUTTON(app.pause_button), "media-playback-pause-symbolic");
gtk_widget_set_tooltip_text(app.pause_button, "pause acquisition");
g_signal_connect(app.pause_button, "toggled", G_CALLBACK(on_pause_toggled), &app);
gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.pause_button);
return bar;
}
void on_activate(GtkApplication* application, gpointer user) {
App* app = static_cast<App*>(user);
install_css();
app->window = gtk_application_window_new(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) {
static_cast<App*>(user)->window = nullptr;
}),
app);
gtk_window_set_title(GTK_WINDOW(app->window), "udpscope");
gtk_window_set_default_size(GTK_WINDOW(app->window), 1280, 800);
gtk_window_set_titlebar(GTK_WINDOW(app->window), build_toolbar(*app));
GtkWidget* paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL);
app->plot_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
gtk_box_set_homogeneous(GTK_BOX(app->plot_box), true);
gtk_widget_set_hexpand(app->plot_box, true);
gtk_widget_set_vexpand(app->plot_box, true);
GtkWidget* left = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_box_append(GTK_BOX(left), app->plot_box);
gtk_widget_set_vexpand(app->plot_box, true);
// 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.
GtkWidget* status_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
app->status_badge = gtk_label_new(nullptr);
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");
gtk_box_append(GTK_BOX(status_row), app->status_badge);
app->status_label = gtk_label_new("starting...");
// 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.
gtk_label_set_ellipsize(GTK_LABEL(app->status_label), PANGO_ELLIPSIZE_END);
gtk_widget_set_hexpand(app->status_label, true);
gtk_widget_set_halign(app->status_label, GTK_ALIGN_FILL);
gtk_box_append(GTK_BOX(status_row), app->status_label);
gtk_widget_set_margin_top(status_row, 2);
gtk_widget_set_margin_bottom(status_row, 2);
gtk_widget_set_margin_start(status_row, 6);
gtk_box_append(GTK_BOX(left), status_row);
// The right-hand side: two independently collapsible panels, each revealed by
// its toolbar toggle. No outer scrolled window -- the signal list scrolls
// inside its own panel -- so neither panel can be squeezed to nothing by the
// other's size request.
GtkWidget* side = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
app->side_box = side;
// The two panels share a vertical box, so they collapse *vertically*: a
// horizontal slide would animate the width and leave the panel's full height
// reserved -- an invisible gap pushing the other panel down the column.
app->trigger_revealer = gtk_revealer_new();
gtk_revealer_set_transition_type(GTK_REVEALER(app->trigger_revealer),
GTK_REVEALER_TRANSITION_TYPE_SLIDE_DOWN);
gtk_revealer_set_child(GTK_REVEALER(app->trigger_revealer), build_trigger_panel(*app));
gtk_box_append(GTK_BOX(side), app->trigger_revealer);
gtk_box_append(GTK_BOX(side), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
app->signal_panel = build_signal_panel(*app);
app->signal_revealer = gtk_revealer_new();
gtk_revealer_set_transition_type(GTK_REVEALER(app->signal_revealer),
GTK_REVEALER_TRANSITION_TYPE_SLIDE_DOWN);
gtk_revealer_set_child(GTK_REVEALER(app->signal_revealer), app->signal_panel);
gtk_revealer_set_reveal_child(GTK_REVEALER(app->signal_revealer), TRUE);
gtk_box_append(GTK_BOX(side), app->signal_revealer);
gtk_widget_set_size_request(side, 300, -1);
// The column scrolls as a whole, so a long signal list or a revealed trigger
// panel can never push either panel out of the window.
app->side_scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(app->side_scroll), GTK_POLICY_NEVER,
GTK_POLICY_AUTOMATIC);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(app->side_scroll), side);
gtk_widget_set_size_request(app->side_scroll, 320, -1);
gtk_paned_set_start_child(GTK_PANED(paned), left);
gtk_paned_set_end_child(GTK_PANED(paned), app->side_scroll);
gtk_paned_set_resize_end_child(GTK_PANED(paned), FALSE);
gtk_paned_set_shrink_end_child(GTK_PANED(paned), FALSE);
gtk_window_set_child(GTK_WINDOW(app->window), paned);
// After the children exist, or the position is clamped against an empty pane.
gtk_paned_set_position(GTK_PANED(paned), 940);
gtk_box_append(GTK_BOX(app->plot_box), make_area(*app));
rebuild_signal_panels(*app);
g_timeout_add(kTickMs, tick, app);
gtk_window_present(GTK_WINDOW(app->window));
}
/// 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.
gboolean exit_probe_step3(gpointer user) {
App* a = static_cast<App*>(user);
a->dbg_mode_live = gtk_label_get_text(GTK_LABEL(a->status_label));
GtkWidget* window = a->window;
a->window = nullptr; // stop the tick before the widgets go
a->dbg_side_w = gtk_widget_get_width(a->side_box);
a->dbg_side_h = gtk_widget_get_height(a->side_box);
a->dbg_signal_w = gtk_widget_get_width(a->signal_revealer);
a->dbg_signal_h = gtk_widget_get_height(a->signal_revealer);
a->dbg_trigger_w = gtk_widget_get_width(a->trigger_revealer);
a->dbg_trigger_h = gtk_widget_get_height(a->trigger_revealer);
// The height of one signal row, which is the thing "the channel toggle is too tall"
// was about. The revealer's own height cannot stand in for it: it also covers the
// heading, the hint and the box margins.
if (GtkWidget* first = gtk_widget_get_first_child(a->signal_list)) {
a->dbg_signal_row_h = gtk_widget_get_height(first);
}
if (a->area != nullptr) a->dbg_frames_painted = static_cast<int>(a->area->frames_painted());
a->dbg_reset_visible = a->cursors_reset != nullptr && gtk_widget_get_visible(a->cursors_reset) ? 1 : 0;
a->dbg_signal_revealed =
gtk_revealer_get_child_revealed(GTK_REVEALER(a->signal_revealer)) ? 1 : 0;
a->dbg_trigger_revealed =
gtk_revealer_get_child_revealed(GTK_REVEALER(a->trigger_revealer)) ? 1 : 0;
a->dbg_side_visible = gtk_widget_get_visible(a->side_box) ? 1 : 0;
a->dbg_side_scroll_after_hide = gtk_widget_get_visible(a->side_scroll) ? 1 : 0;
if (a->trigger_signal_drop != nullptr) {
GListModel* model = gtk_drop_down_get_model(GTK_DROP_DOWN(a->trigger_signal_drop));
a->dbg_trigger_items = model != nullptr ? static_cast<int>(g_list_model_get_n_items(model)) : 0;
a->dbg_trigger_selected = static_cast<int>(
gtk_drop_down_get_selected(GTK_DROP_DOWN(a->trigger_signal_drop)));
}
if (a->window_label != nullptr) {
// The button's label, not the entry: the label is what the user reads, and the entry can
// legitimately hold a half-typed number.
a->dbg_window_text = gtk_label_get_text(GTK_LABEL(a->window_label));
}
a->dbg_toggles_visible =
(gtk_widget_get_visible(a->signal_toggle) && gtk_widget_get_visible(a->trigger_toggle)) ? 1
: 0;
if (window != nullptr) gtk_window_destroy(GTK_WINDOW(window));
return G_SOURCE_REMOVE;
}
/// 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.
gboolean exit_probe_step2(gpointer user) {
App* a = static_cast<App*>(user);
a->dbg_mode_triggered = gtk_label_get_text(GTK_LABEL(a->status_label));
if (a->rows->row(0).plot) {
a->dbg_marker_x = a->rows->row(0).plot->cursor(a->rows->row(0).trigger_marker).x;
a->dbg_marker_max = a->rows->row(0).plot->x_range(0).max;
a->dbg_trigger_time = a->model->trigger_time().load();
a->dbg_trigger_time_valid = a->model->trigger_time_valid().load() ? 1 : 0;
}
if (a->trigger_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), FALSE);
}
// The tick keeps running for these 400 ms, so by step 3 the status line reads
// the state the app is actually in after leaving trigger mode.
g_timeout_add(400, exit_probe_step3, a);
return G_SOURCE_REMOVE;
}
/// 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.
gboolean trig_trace_probe(gpointer user) {
App* a = static_cast<App*>(user);
// Before the first CONFIG the store does not exist yet: live() would hand back a
// null BufferStore.
if (a->window == nullptr || a->model->channel_count() == 0) return G_SOURCE_CONTINUE;
std::lock_guard<std::mutex> lock(a->model->mutex());
ScopeModel& m = *a->model;
// What the first bound slot shows, read off the slot itself: the binding is the
// thing under test, so it must not be inferred from the trigger state.
const char* display = "nothing";
const SlotSeries* shown = nullptr;
for (int i = 0; i < a->rows->row_count(); ++i) {
RowSet::Row& r = a->rows->row(i);
bool found = false;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (r.channel[si] < 0) continue;
// A live binding in trigger mode is only legitimate as a capture in progress, so
// the two are named apart: `live` with `trigger=on` is the defect, `live(capturing)`
// is the feature.
if (r.slots[si].bound_event() != nullptr) display = "event(frozen)";
else if (r.slots[si].bound() != nullptr) display = a->capturing ? "live(capturing)" : "live";
shown = &r.slots[si];
found = true;
break;
}
if (found) break;
}
// Where that slot's samples are *drawn*, reconstructed exactly as the renderer
// does it: `Series::gather()` emits x relative to `x0()`, and the renderer adds
// `x0()` back before mapping to the screen. So `x0() + gathered` must equal
// `x_at()`, and `drawn=` must equal `data=` to the f32's precision.
//
// This is the one defect in this area the picture alone shows and no other probe
// line does: a series that emits absolute x instead makes the renderer add `x0()`
// twice, so the trace lands a whole `x0()` away from the axis it was measured
// against -- off screen entirely once the view is narrower than that shift, while
// the crosshair (which reads `x_at()`) goes on reporting correct values.
double drawn_lo = 0.0, drawn_hi = 0.0, data_lo = 0.0, data_hi = 0.0;
if (shown != nullptr && shown->size() > 0) {
const u64 n = shown->size();
std::vector<f32> xy;
shown->gather(0u, 0u, 1u, xy);
const std::size_t head = xy.size();
shown->gather(0u, n - 1, n, xy);
if (head >= 2 && xy.size() >= head + 2) {
drawn_lo = shown->x0() + double(xy[0]);
drawn_hi = shown->x0() + double(xy[head]);
data_lo = shown->x_at(0);
data_hi = shown->x_at(n - 1);
}
}
double snap_lo = 0.0;
double snap_hi = 0.0;
std::size_t snap_n = 0;
if (auto snap = m.event()) {
snap_lo = -snap->pre_s;
snap_hi = snap->post_s;
snap_n = snap->stamps.size();
}
plot::Range axis{0.0, 0.0};
if (a->rows->row_count() > 0 && a->rows->row(0).plot != nullptr) {
const RowSet::Row& r0 = a->rows->row(0);
axis = r0.plot->x_range(r0.axis_x);
}
// The renderer's own index window, recomputed exactly as `Renderer::draw_traces`
// does it: it maps the visible x range onto sample indices with
// `(x - x0()) / dx()` -- a *uniform grid* formula -- and gathers only
// `[first, last)`. `ChannelBuffer`'s x is a streamed timestamp, so that formula is
// only ever as good as `dx()` describing the real local spacing. `want=` is the
// same window found by binary search over the real time ring, which is what
// `x_at()`/`neighbours()` -- and therefore the crosshair -- use.
//
// **`got=` must contain `want=`.** When `dx()` comes out larger than the real
// spacing every index is overestimated, `first` lands past the first visible
// sample, and the samples between the two are in the buffer, readable by the
// crosshair, and never drawn: the view's older part goes blank while its newest
// part looks perfectly fine. `dx_ratio` is the cause in one number -- it is 1.0
// when the grid assumption holds, and it inflates whenever a stamp inside the
// window is wrong (a sample whose time slot the ring already dropped reads 0).
long long got_lo = -1, got_hi = -1, want_lo = -1, want_hi = -1;
double dx_nominal = 0.0, dx_real = 0.0, bound_lo = 0.0, bound_hi = 0.0;
if (shown != nullptr && shown->size() > 0) {
const u64 n = shown->size();
const f64 sx0 = shown->x0();
dx_nominal = shown->dx();
dx_real = n >= 2 ? shown->x_at(n - 1) - shown->x_at(n - 2) : 0.0;
bound_lo = shown->x_bounds().min;
bound_hi = shown->x_bounds().max;
if (dx_nominal > 0.0) {
const auto clamp_index = [n](double v) -> long long {
if (!(v > 0.0)) return 0;
return v >= double(n) ? (long long)n : (long long)v;
};
got_lo = clamp_index(std::floor((axis.min - sx0) / dx_nominal) - 1.0);
got_hi = clamp_index(std::ceil((axis.max - sx0) / dx_nominal) + 1.0);
}
u64 i0 = 0, i1 = 0;
want_lo = shown->neighbours(axis.min, i0, i1) ? (long long)i0
: (axis.min < shown->x_at(0) ? 0 : (long long)n);
want_hi = shown->neighbours(axis.max, i0, i1) ? (long long)i1 + 1
: (axis.max < shown->x_at(0) ? 0 : (long long)n);
}
std::fprintf(stderr,
"[trig-probe/idx] n=%llu bounds=[%.4f..%.4f] dx=%.6g dx_real=%.6g "
"dx_ratio=%.3f got=[%lld..%lld) want=[%lld..%lld)\n",
static_cast<unsigned long long>(shown != nullptr ? shown->size() : 0), bound_lo,
bound_hi, dx_nominal, dx_real,
dx_real > 0.0 ? dx_nominal / dx_real : 0.0, got_lo, got_hi, want_lo, want_hi);
std::fprintf(stderr,
"[trig-probe] trigger=%s pending=%d display=%s epoch=%.4f latches=%llu armed=%d "
"live_pub=%llu snap=[%.4f..%.4f] n=%zu axis=[%.4f..%.4f] "
"data=[%.4f..%.4f] drawn=[%.4f..%.4f]\n",
m.trigger_enabled() ? "on" : "off", int(m.trigger_pending()), display,
m.display_epoch(),
static_cast<unsigned long long>(m.trigger_latches().load()),
int(m.trigger_armed().load()),
static_cast<unsigned long long>(m.live().values(0).produced()), snap_lo, snap_hi,
snap_n, double(axis.min), double(axis.max), data_lo, data_hi, drawn_lo, drawn_hi);
std::fflush(stderr);
return G_SOURCE_CONTINUE;
}
/// Headless self-check: arms the trigger the way the toolbar's trigger switch does.
gboolean arm_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
// The toggle itself, not a label search: the header bar's buttons carry icons now,
// so there is no text to find them by.
if (a->trigger_toggle != nullptr)
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
std::fprintf(stderr, "[arm-probe] trigger armed\n");
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
/// 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.
gboolean fields_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
GtkDropDown* mode = GTK_DROP_DOWN(a->trigger_mode_drop);
GtkDropDown* edge = GTK_DROP_DOWN(a->trigger_edge_drop);
gtk_drop_down_set_selected(mode, gtk_drop_down_get_selected(mode) == 0 ? 1 : 0);
gtk_drop_down_set_selected(edge, (gtk_drop_down_get_selected(edge) + 1) % 3);
std::lock_guard<std::mutex> lock(a->model->mutex());
std::fprintf(stderr, "[fields-probe] mode=%u edge=%u published mode=%d edge=%d capturing=%d\n",
gtk_drop_down_get_selected(mode), gtk_drop_down_get_selected(edge),
int(a->model->trigger().mode), int(a->model->trigger().edge),
int(a->model->trigger_pending()));
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
/// 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.
gboolean window_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->window_entry == nullptr) return G_SOURCE_REMOVE;
GtkEditable* entry = GTK_EDITABLE(a->window_entry);
GtkDropDown* unit = GTK_DROP_DOWN(a->window_unit_drop);
auto report = [a](const char* what) {
double span = 0.0;
u64 stride = 0;
double stored = 0.0;
double buffered = 0.0;
double aa = 0.0;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
span = a->model->event_span();
const Partition& part = a->model->partition();
stride = part.time_stride;
stored = part.stored_rate();
buffered = part.span_s;
aa = part.antialias_hz();
}
std::fprintf(stderr, "[window-probe] %-14s label='%s' entry='%s' unit=%u window=%.6fs "
"event_span=%.6fs cap=%.3fs decim=%llu stored=%.4gS/s aa=%.4gHz "
"buffered=%.4gs\n",
what, gtk_label_get_text(GTK_LABEL(a->window_label)),
gtk_editable_get_text(GTK_EDITABLE(a->window_entry)),
gtk_drop_down_get_selected(GTK_DROP_DOWN(a->window_unit_drop)),
a->rows->time_window(), span, a->window_limit_s,
static_cast<unsigned long long>(stride), stored, aa, buffered);
std::fflush(stderr);
};
report("initial");
// 500 ms typed: the entry's own path, and a span no ladder of round numbers had.
gtk_drop_down_set_selected(unit, 1);
gtk_editable_set_text(entry, "500");
on_window_entry_activate(GTK_ENTRY(a->window_entry), a);
report("type 500ms");
// The unit re-interprets the number rather than converting it, so this is 500 s -- past the
// cap, and therefore the clamp's own test.
gtk_drop_down_set_selected(unit, 2);
report("unit ms->s");
gtk_editable_set_text(entry, "2");
on_window_entry_activate(GTK_ENTRY(a->window_entry), a);
report("type 2s");
GtkWidget* up = gtk_button_new_with_label("x10");
g_object_set_data(G_OBJECT(up), "udpscope-window-up", up);
on_window_step(GTK_BUTTON(up), a);
report("x10");
GtkWidget* down = gtk_button_new_with_label("/10");
on_window_step(GTK_BUTTON(down), a);
report("/10");
// Both were created only to carry the direction flag; they were never parented, so they
// still hold the floating reference GTK4 created them with.
g_object_ref_sink(up);
g_object_unref(up);
g_object_ref_sink(down);
g_object_unref(down);
return G_SOURCE_REMOVE;
}
/// The editor button of one kind at one index, or null. The probe clicks the real
/// widgets rather than calling the handlers, so what it drives is what a user reaches.
GtkWidget* layout_button(App& a, bool insert, int index) {
for (GtkWidget* b : a.layout_buttons) {
const bool is_insert = g_object_get_data(G_OBJECT(b), kLayoutKindKey) != nullptr;
if (is_insert == insert && row_key(b) == index) return b;
}
return nullptr;
}
/// 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.
void layout_probe_report(App& a, const char* what) {
const int rows = a.rows->row_count();
const double scale = (a.area != nullptr && a.area->device_scale() > 0.0f)
? a.area->device_scale() : 1.0;
std::string geom;
const plot::FigureLayout& fl = a.rows->figure().layout();
for (std::size_t i = 0; i < fl.rows.size(); ++i) {
char buf[48];
std::snprintf(buf, sizeof(buf), "%s%.0f..%.0f", i == 0 ? "" : ",",
double(fl.rows[i].y) / scale,
(double(fl.rows[i].y) + double(fl.rows[i].h)) / scale);
geom += buf;
}
std::string close_tops;
std::string add_tops;
for (int i = 0; i < rows; ++i) {
char buf[24];
if (GtkWidget* b = layout_button(a, false, i)) {
std::snprintf(buf, sizeof(buf), "%s%d", i == 0 ? "" : ",", gtk_widget_get_margin_top(b));
close_tops += buf;
}
}
for (int i = 0; i <= rows; ++i) {
char buf[24];
if (GtkWidget* b = layout_button(a, true, i)) {
std::snprintf(buf, sizeof(buf), "%s%d", i == 0 ? "" : ",", gtk_widget_get_margin_top(b));
add_tops += buf;
}
}
std::string traces;
for (int r = 0; r < rows; ++r) {
traces += (r == 0 ? "" : "|");
traces += std::to_string(r) + ":";
const RowSet::Row& row = a.rows->row(r);
bool any = false;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::string& n = row.signal_name[static_cast<std::size_t>(s)];
if (n.empty()) continue;
if (any) traces += ",";
traces += n;
any = true;
}
if (!any) traces += "-";
}
std::fprintf(stderr, "[layout-probe] %-12s rows=%d buttons=%zu rows_y=[%s] close=[%s] "
"add=[%s] traces=[%s]\n",
what, rows, a.layout_buttons.size(), geom.c_str(), close_tops.c_str(),
add_tops.c_str(), traces.c_str());
std::fflush(stderr);
}
/// 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.
gboolean layout_probe_step(gpointer user);
/// Clicks one editor button. `::clicked` directly and not `gtk_widget_activate()`: a
/// `GtkButton` activated that way holds the press for `ACTIVATE_TIMEOUT` (250 ms) and only
/// then emits `::clicked`, so every step of the probe read the *previous* step's result --
/// and the button whose timeout was pending is destroyed by the rebuild the step before it
/// caused, which dropped the click entirely.
void layout_click(GtkWidget* b) {
if (b != nullptr) g_signal_emit_by_name(b, "clicked");
}
void layout_probe_next(App& a, int step, guint delay_ms) {
a.dbg_layout_step = step;
g_timeout_add(delay_ms, layout_probe_step, &a);
}
gboolean layout_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
switch (a->dbg_layout_step) {
case 0:
// Through the menu's toggle, so the probe exercises the handler the user reaches.
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->layout_toggle), true);
layout_probe_report(*a, "editor on");
layout_probe_next(*a, 1, 200);
break;
case 1:
layout_probe_report(*a, "placed");
layout_click(layout_button(*a, true, 0));
layout_probe_next(*a, 2, 200);
break;
case 2:
// What row 0 held must now be on row 1: an insert moves rows, it does not renumber.
layout_probe_report(*a, "add above 0");
layout_click(layout_button(*a, false, 0));
layout_probe_next(*a, 3, 200);
break;
case 3:
layout_probe_report(*a, "remove 0");
layout_click(layout_button(*a, true, a->rows->row_count()));
layout_probe_next(*a, 4, 200);
break;
case 4:
layout_probe_report(*a, "add at end");
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->layout_toggle), false);
layout_probe_next(*a, 5, 200);
break;
default:
// Nothing left on the overlay: the editor's buttons exist only while it is up.
layout_probe_report(*a, "editor off");
break;
}
return G_SOURCE_REMOVE;
}
/// 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.
gboolean enable_all_probe(gpointer user) {
App* a = static_cast<App*>(user);
for (std::size_t i = 0; i < a->model->signals().size(); ++i) {
a->model->set_signal_enabled(i, true);
}
int row = 0;
int slot = 0;
for (std::size_t c = 0; c < a->model->channel_count(); ++c) {
if (row >= a->rows->row_count()) break;
const ChannelInfo& ch = a->model->channels()[c];
a->rows->assign(row, slot, ch.name, ch.name,
signal_color_index(a->model->signals(), ch.signal));
if (++slot >= RowSet::kSlotsPerRow) {
slot = 0;
++row;
}
}
a->relayout_pending = true;
std::fprintf(stderr, "[enable-all-probe] %zu channel(s) enabled and assigned to plots\n",
a->model->channel_count());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean disarm_probe_step3(gpointer user);
gboolean disarm_probe_step2(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
// The Arm/Disarm button is in the trigger panel, not the title bar: search the
// whole window, and say so when it is not there rather than claiming a click.
GtkWidget* button = find_labelled_button(a->window, "Disarm");
if (button == nullptr) button = find_labelled_button(a->window, "Arm");
std::fprintf(stderr, "[disarm-probe] button %s\n", button ? "found, clicking" : "NOT FOUND");
if (button != nullptr) g_signal_emit_by_name(button, "clicked");
a->dbg_latches_at_disarm = a->model->trigger_latches().load();
std::fprintf(stderr, "[disarm-probe] %llu events so far, armed now=%d\n",
(unsigned long long)a->dbg_latches_at_disarm, int(a->model->trigger().armed));
a->dbg_capturing_at_disarm = a->model->trigger_pending();
a->dbg_seq_at_disarm = a->model->event_seq();
g_timeout_add(600, disarm_probe_step3, a);
return G_SOURCE_REMOVE;
}
/// 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.
gboolean disarm_probe_step3(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const DisplayMode d = a->model->display_mode();
auto snap = a->model->event();
RowSet::Row& r = a->rows->row(0);
const plot::Range axis = r.plot != nullptr ? r.plot->x_range(r.axis_x) : plot::Range{0.0, 0.0};
std::fprintf(stderr,
"[disarm-probe] was_capturing=%d display=%s seq=%llu->%llu events=%llu->%llu "
"snap=[%.4f..%.4f] n=%llu axis=[%.4f..%.4f]\n",
int(a->dbg_capturing_at_disarm),
d == DisplayMode::Held ? "event(frozen)"
: d == DisplayMode::Live ? "live"
: d == DisplayMode::Capturing ? "live(capturing)"
: "nothing",
(unsigned long long)a->dbg_seq_at_disarm,
(unsigned long long)a->model->event_seq(),
(unsigned long long)a->dbg_latches_at_disarm,
(unsigned long long)a->model->trigger_latches().load(),
snap ? -snap->pre_s : 0.0, snap ? snap->post_s : 0.0,
(unsigned long long)(snap ? snap->stamps.size() : 0), double(axis.min),
double(axis.max));
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
/// 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.
gboolean disarm_probe_wait(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
++a->dbg_disarm_polls;
if (a->model->trigger_pending()) ++a->dbg_disarm_in_capture;
// 300 ms into the capture: post-trigger data worth keeping, well short of the latch.
// The 10 s cap is for a stream that never triggers -- the probe still has to report.
if (a->dbg_disarm_in_capture >= 6 || a->dbg_disarm_polls > 200) {
if (a->dbg_disarm_in_capture == 0) {
std::fprintf(stderr, "[disarm-probe] no capture opened in 10 s; disarming anyway\n");
std::fflush(stderr);
}
disarm_probe_step2(a);
return G_SOURCE_REMOVE;
}
return G_SOURCE_CONTINUE;
}
gboolean disarm_probe_step1(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (a->trigger_toggle != nullptr)
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
g_timeout_add(50, disarm_probe_wait, a);
return G_SOURCE_REMOVE;
}
/// 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.
gboolean pause_probe(gpointer user) {
App* a = static_cast<App*>(user);
a->acq->set_paused(true);
a->dbg_axes_x_min = 1 << 20;
a->dbg_axes_x_max = -(1 << 20);
a->dbg_axes_w_min = 1 << 20;
a->dbg_axes_w_max = -(1 << 20);
a->dbg_axes_w_prev = -1;
a->dbg_axes_w_changes = 0;
a->dbg_axes_w_change_frame = -1;
a->dbg_axes_w_change_side = -1;
a->dbg_vp_w_min = 1 << 20;
a->dbg_vp_w_max = -(1 << 20);
a->dbg_xtick_min = 1 << 20;
a->dbg_xtick_max = -(1 << 20);
a->dbg_gutter_min = 1 << 20;
a->dbg_gutter_max = -(1 << 20);
a->dbg_axes_frames = 0;
a->dbg_x_desync = 0;
return G_SOURCE_REMOVE;
}
/// 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.
gboolean export_probe(gpointer user) {
App* a = static_cast<App*>(user);
const char* env = g_getenv("UDPSCOPE_EXPORT_PATH");
const std::string prefix = env != nullptr ? env : "/tmp/udpscope-export";
const RowSet::Row& r0 = a->rows->row(0);
const plot::Range axis = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{0.0, 0.0};
const ExportTable table = collect_export(*a->model, *a->rows);
std::fprintf(stderr,
"[export-probe] mode=%s axis=[%.6f..%.6f] rows=%zu cols=%zu time=[%.6f..%.6f] "
"stream=[%.6f..%.6f]\n",
a->model->trigger_enabled() && a->model->event() != nullptr ? "event" : "live",
axis.min, axis.max, table.rows(), table.columns.size(),
table.rows() != 0 ? table.time.front() : 0.0,
table.rows() != 0 ? table.time.back() : 0.0,
table.rows() != 0 ? table.stream_time.front() : 0.0,
table.rows() != 0 ? table.stream_time.back() : 0.0);
for (const ExportTable::Column& col : table.columns) {
std::fprintf(stderr, "[export-probe] column '%s' n=%zu\n", col.name.c_str(), col.values.size());
}
for (const char* ext : {".npy", ".csv"}) {
const std::string path = prefix + ext;
std::string error;
const bool ok = std::strcmp(ext, ".csv") == 0 ? write_csv(path, table, error)
: write_npy(path, table, error);
long bytes = -1;
if (ok) {
if (std::FILE* f = std::fopen(path.c_str(), "rb"); f != nullptr) {
std::fseek(f, 0, SEEK_END);
bytes = std::ftell(f);
std::fclose(f);
}
}
std::fprintf(stderr, "[export-probe] %s %s bytes=%ld%s%s\n", path.c_str(),
ok ? "written" : "FAILED", bytes, ok ? "" : " error=", ok ? "" : error.c_str());
}
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean exit_probe_step1(gpointer user) {
App* a = static_cast<App*>(user);
// Record the panel states before the toggles are exercised.
a->dbg_trigger_revealed_default =
gtk_revealer_get_child_revealed(GTK_REVEALER(a->trigger_revealer)) ? 1 : 0;
if (a->trigger_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
}
g_timeout_add(1700, exit_probe_step2, a);
return G_SOURCE_REMOVE;
}
} // namespace
int run_ui(int argc, char** argv, ScopeModel& model, Acquisition& acq, RowSet& rows) {
(void)argc;
App app;
app.model = &model;
app.acq = &acq;
app.rows = &rows;
GtkApplication* application =
gtk_application_new("org.udpscope.app", G_APPLICATION_DEFAULT_FLAGS);
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_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_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_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);
}
// 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) {
const Partition& part = model.partition();
std::printf("[udpscope] frames=%llu packets=%llu gaps=%llu channels=%zu span=%.3fs "
"stride=%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));
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 frames, segments %llu/%u "
"(mean/max), visible max %llu samples\n",
app.dbg_raw_frames, app.dbg_lod_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 visible: %d\n", app.dbg_reset_visible);
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());
std::printf("[udpscope] panels: side=%dx%d (visible=%d) signals=%dx%d row_h=%d "
"(revealed=%d) trigger=%dx%d (revealed=%d) toggles=%d cursors=%d window=%.3fs "
"window_cap=%.1fs\n",
app.dbg_side_w, app.dbg_side_h, app.dbg_side_visible, app.dbg_signal_w,
app.dbg_signal_h, app.dbg_signal_row_h, app.dbg_signal_revealed,
app.dbg_trigger_w, app.dbg_trigger_h,
app.dbg_trigger_revealed, app.dbg_toggles_visible,
rows.row(0).cursors_visible ? 1 : 0, rows.time_window(),
app.window_limit_s);
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] badge frames: lit=%d dim=%d idle=%d\n", app.dbg_badge_frames[0],
app.dbg_badge_frames[1], app.dbg_badge_frames[2]);
std::printf("[udpscope] trigger panel revealed before the probe toggled it: %d; "
"signal list items=%d selected=%d; column visible after sidebar "
"toggle off=%d\n",
app.dbg_trigger_revealed_default, app.dbg_trigger_items,
app.dbg_trigger_selected, app.dbg_side_scroll_after_hide);
{
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);
}
}
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 udpscope