3258 lines
153 KiB
C++
3258 lines
153 KiB
C++
// SPDX-License-Identifier: MIT
|
||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||
|
||
// The GTK4 front end: the toolbar, the trigger and signal panels, and the one plot
|
||
// area the `RowSet`'s figure is drawn in.
|
||
//
|
||
// Every widget here is a thin driver over the model: nothing in this file owns
|
||
// sample data. The one thing it does own is the `plot::gtk4::PlotArea`, which is in
|
||
// figure mode -- the rows are the figure's rows, so a row whose trace set changed
|
||
// (`RowSet::row_dirty()`) costs a new figure and not a new widget: the area keeps its
|
||
// GL context and is handed the rebuilt figure.
|
||
|
||
#include "ui.hpp"
|
||
|
||
#include <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
|