improved measuraments ,cursors, zooming and user interactions

This commit is contained in:
Martino Ferrari committed 2026-10-03 16:05:45 +02:00
1 parent 0eceaf537b
commit 02620ca946
12 files changed
+1488 -171

No files matched your search

+653 -68
View File
@@ -82,6 +82,11 @@ struct App {
int dbg_cursors_step = 0;
/// Which step of the fit probe runs next (`UDPSCOPE_PROBE_FIT_MS`).
int dbg_fit_step = 0;
/// Which step of the x-label-form probe runs next (`UDPSCOPE_PROBE_TIMELABELS_MS`).
int dbg_timelabels_step = 0;
/// The cursors probe's own bookkeeping: the stored time before a gestureless move, so the
/// report can assert the move was ignored.
double dbg_gestureless_before = 0.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
@@ -171,7 +176,29 @@ struct App {
bool measure_was_held = false;
/// The visible samples, per channel, for the *pass*: several measurements on one signal
/// must not copy the ring several times.
std::unordered_map<int, std::pair<std::vector<double>, std::vector<double>>> measure_samples;
/// The visible window of one channel, with the extremes several measurements on it share:
/// each of them needs `lo`/`hi` (a threshold is a share of the range, and two of the types
/// *are* the range), and the scan is over every visible sample.
struct MeasureWindow {
std::vector<double> time;
std::vector<double> value;
MeasureStats stats;
};
std::unordered_map<int, MeasureWindow> measure_samples;
/// When the pane was last recomputed, for the refresh cadence above.
gint64 measure_stamp_us = 0;
bool measure_stamp_valid = false;
/// What the pane cost: the recomputes it ran and the wall time they took. A readout that is
/// recomputed per frame over a live window is exactly the kind of thing that is invisible in a
/// frame mean, so it is measured where it happens and reported in the dump.
gint64 measure_cost_us = 0;
int measure_passes = 0;
/// The last pass' own duration, which the cadence adapts to.
gint64 measure_last_pass_us = 0;
/// The last *valid* result per measurement, which the marks fall back to: a level is a property
/// of the signal and survives the window it was measured in, so the rulers do not blink off when
/// a pass lands on a window with no samples (a rebuild, a capture boundary, a mode change).
std::vector<MeasureResult> measure_last_valid;
/// The marks the last computation produced: re-placed every frame, which is what makes the
/// measurement cursors read-only.
std::vector<RowSet::MeasureMark> measure_marks;
@@ -259,6 +286,12 @@ struct App {
/// 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;
/// The *renderer's* blank: a frame that staged no trace geometry while every bound trace held
/// samples. `dbg_blank_frames` cannot see this one -- it asks the rings, and a ring that holds
/// data while the renderer draws nothing is exactly the "the data disappears for a frame" glitch.
int dbg_render_blank_frames = 0;
/// The last frame's staged segment count, so the tick can judge the frame the renderer drew.
unsigned long long dbg_seg_last = 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
@@ -309,6 +342,20 @@ struct App {
int dbg_axes_w_max = -(1 << 20);
int dbg_axes_w_prev = -1;
int dbg_axes_w_changes = 0;
/// What the pan probe asked for, so the frame after it can be compared against it: a capture
/// during the probe is not a value either side can recompute, and a capturing lambda cannot be
/// a `GSourceFunc`.
double dbg_pan_asked_lo = 0.0;
double dbg_pan_asked_hi = 0.0;
bool dbg_pan_asked = false;
/// Which gesture the probe is on: 1 = the pan was asked and its verdict is due, 2 = the
/// zoom-out was asked and its verdict is due.
int dbg_gesture_stage = 0;
/// A verdict the *tick* owes, one frame after a gesture was asked: 1 pan, 2 zoom-out, 3
/// zoom-in. Reported from the frame loop rather than a `g_timeout`, because a 300 ms timer
/// slips past the state it was meant to observe as soon as the frames are heavy -- which is
/// exactly the load this probe runs under.
int dbg_gesture_verdict = 0;
int dbg_axes_w_change_frame = -1;
int dbg_axes_w_change_side = -1;
float dbg_scale_min = 1e9f;
@@ -773,6 +820,7 @@ int on_render_probe(GtkGLArea*, GdkGLContext*, gpointer user) {
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);
app->dbg_seg_last = s.staged_segments;
if (s.raw_traces > 0) ++app->dbg_raw_frames;
if (s.lod_traces > 0) ++app->dbg_lod_frames;
}
@@ -1492,6 +1540,30 @@ void on_pause_toggled(GtkToggleButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const bool paused = gtk_toggle_button_get_active(button);
app->acq->set_paused(paused);
app->model->set_paused(paused);
if (paused) {
// Freeze the *view*: snapshot exactly the window on screen, and pin the axes there. The
// acquisition thread keeps writing the live ring behind it, so resuming shows the data that
// arrived while paused instead of a hole.
if (RowSet::Row& r0 = app->rows->row(0); r0.plot != nullptr) {
const plot::Range view = r0.plot->x_range(r0.axis_x);
const double epoch = app->model->display_epoch();
app->model->publish_pause_view(view.min - epoch, view.max - epoch);
// Pin on what the snapshot *got*: the cut is clamped to the ring, so it can be narrower than
// the view, and pinning the request left the shortfall as an empty left margin.
plot::Range pin = view;
if (std::shared_ptr<const EventSnapshot> snap = app->model->pause_view()) {
if (!snap->stamps.empty()) {
pin = plot::Range{static_cast<double>(snap->stamps.front()),
static_cast<double>(snap->stamps.back())};
}
}
app->rows->pin_pause_window(pin.min, pin.max);
}
} else {
app->model->clear_pause_view();
app->rows->unpin_pause_window();
}
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"
@@ -1553,19 +1625,23 @@ bool visible_samples(App& app, std::size_t channel, std::vector<double>& time,
if (r0.plot == nullptr) return false;
const plot::Range view = r0.plot->x_range(r0.axis_x);
if (app.model->display_mode() == DisplayMode::Held) {
std::shared_ptr<const EventSnapshot> snap = app.model->event();
if (std::shared_ptr<const EventSnapshot> snap = app.model->display_snapshot()) {
if (snap == nullptr || channel >= snap->values.size()) return false;
const std::vector<f32>& st = snap->stamps;
const std::vector<f32>& vals = snap->values[channel];
if (vals.size() != st.size()) return false;
const auto lo = std::lower_bound(st.begin(), st.end(), static_cast<f32>(view.min));
const auto hi = std::upper_bound(st.begin(), st.end(), static_cast<f32>(view.max));
for (auto it = lo; it != hi; ++it) {
time.push_back(static_cast<double>(*it));
value.push_back(static_cast<double>(vals[static_cast<std::size_t>(it - st.begin())]));
const std::size_t count = static_cast<std::size_t>(hi - lo);
if (count == 0) return false;
const std::size_t off = static_cast<std::size_t>(lo - st.begin());
time.resize(count);
value.resize(count);
for (std::size_t i = 0; i < count; ++i) {
time[i] = static_cast<double>(st[off + i]);
value[i] = static_cast<double>(vals[off + i]);
}
return !time.empty();
return true;
}
std::lock_guard<std::mutex> lock(app.model->mutex());
@@ -1577,17 +1653,26 @@ bool visible_samples(App& app, std::size_t channel, std::vector<double>& time,
app.model->live_window_locked(view.min - epoch, view.max - epoch, first, last);
const u64 n = last - first;
if (n == 0) return false;
std::vector<f32> stamps(static_cast<std::size_t>(n));
std::vector<f32> vals(static_cast<std::size_t>(n));
store.time().copy_from(first, n, stamps.data());
store.values(channel).copy_from(first, n, vals.data());
time.reserve(stamps.size());
value.reserve(vals.size());
for (std::size_t i = 0; i < stamps.size(); ++i) {
time.push_back(epoch + static_cast<double>(stamps[i]));
value.push_back(static_cast<double>(vals[i]));
time.resize(static_cast<std::size_t>(n));
value.resize(static_cast<std::size_t>(n));
// In bounded chunks rather than through two whole-window temporaries: the rings hold f32 and
// the engine reads f64, so the widening is unavoidable, but the *copies* are not -- a full
// window is millions of samples, and the temporaries cost a full write and read of it on top
// of the result. `kChunk` keeps the scratch in cache.
constexpr u64 kChunk = 8192;
std::vector<f32> scratch(kChunk);
for (u64 at = 0; at < n; at += kChunk) {
const u64 take = std::min<u64>(kChunk, n - at);
store.time().copy_from(first + at, take, scratch.data());
for (u64 i = 0; i < take; ++i) {
time[static_cast<std::size_t>(at + i)] = epoch + static_cast<double>(scratch[i]);
}
store.values(channel).copy_from(first + at, take, scratch.data());
for (u64 i = 0; i < take; ++i) {
value[static_cast<std::size_t>(at + i)] = static_cast<double>(scratch[i]);
}
}
return !time.empty();
return true;
}
/// The channel a measurement's signal resolved to, or -1 while the stream does not carry it.
@@ -1626,6 +1711,16 @@ std::string measure_unit(App& app, const std::string& signal) {
return "";
}
/// How often the measurements pane is recomputed while the window is still moving: a readout,
/// where the honest currency is the window's own settling, not the frame rate. Ten a second is
/// past the point a number can be read, and it makes the cost independent of the framerate.
constexpr gint64 kMeasureRefreshUs = 100 * 1000;
/// A pass that costs more than this is a *hitch*, not a refresh: the pane waits four times as
/// long next time. Bounding the interval alone does not bound what a frame pays -- a window of
/// thirteen million samples costs 13 ms, which is most of a frame -- so the cadence follows the
/// *work*, and what stays bounded is the duty cycle at any window size.
constexpr gint64 kMeasureHeavyUs = 5 * 1000;
void rebuild_measures_pane(App& app);
void update_measures(App& app, bool force);
void open_measure_dialog(App& app, int edit_index);
@@ -1997,6 +2092,13 @@ void measure_dialog_sync(MeasureDialog* d) {
gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show),
"show on plot (ruler at the value, marker where it is)");
break;
case MeasureType::PeakToPeak:
case MeasureType::Amplitude:
gtk_label_set_text(GTK_LABEL(d->threshold_label), "");
gtk_label_set_text(GTK_LABEL(d->side_label), "");
gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show),
"show on plot (rulers at both extremes)");
break;
case MeasureType::Top:
case MeasureType::Bottom:
gtk_label_set_text(GTK_LABEL(d->threshold_label), "split % of the range");
@@ -2212,7 +2314,32 @@ void update_measures(App& app, bool force) {
std::fabs(view.min - app.measure_window.min) +
std::fabs(view.max - app.measure_window.max) >
0.02 * std::max(1.0e-12, view.span());
if (!force && app.measure_window_valid && !moved && held == app.measure_was_held) {
// A measurement is a statistic of the visible window, and *a live view moves that window every
// frame* -- so "recompute when the window moves" is "recompute every frame", at O(visible
// samples) per measurement. That is what stalled the frame tick (0.4 ms -> 13.7 ms, a third of
// the run's frames lost) once the window held a few million samples. The pane is a *readout*,
// so it is refreshed on the terms the status line's own fields already use: at most every
// `kMeasureRefreshUs`, plus immediately whenever the window stops moving or the mode changes,
// which is when the number has to be exact. Between those a tenth of a second of lag is
// invisible on a statistic that is itself a window average, and the marks below are re-placed
// from the last result every frame either way.
const gint64 now_us = g_get_monotonic_time();
const gint64 interval =
app.measure_last_pass_us > kMeasureHeavyUs ? 4 * kMeasureRefreshUs : kMeasureRefreshUs;
const bool due = !app.measure_stamp_valid || now_us - app.measure_stamp_us >= interval;
// Two reasons to skip, and they are different questions. `measure_window` is the window the
// *last recompute described*, so no change at all means the pane already says the right thing:
// that is the settled case, and it must recompute once when it settles and then never again --
// getting this backwards is what had a *pinned* window recomputing every single frame (457
// passes in one run) instead of once. The other is a window still scrolling: at most one pass
// per `kMeasureRefreshUs`, because the number is a window average and a tenth of a second of
// lag on it is not readable.
const bool changed = !app.measure_window_valid || held != app.measure_was_held || moved;
if (!force && !changed) {
app.rows->set_measure_marks(app.measure_marks);
return;
}
if (!force && moved && held == app.measure_was_held && !due) {
// Nothing to recompute -- but the marks are still re-placed below, which is what makes
// them read-only: a gesture moves one on the plot, and this is what puts it back.
app.rows->set_measure_marks(app.measure_marks);
@@ -2221,45 +2348,62 @@ void update_measures(App& app, bool force) {
app.measure_window = view;
app.measure_window_valid = true;
app.measure_was_held = held;
app.measure_stamp_us = now_us;
app.measure_stamp_valid = true;
const gint64 pass_start_us = g_get_monotonic_time();
app.measure_samples.clear();
app.measure_results.assign(app.measures.size(), MeasureResult{});
app.measure_last_valid.resize(app.measures.size());
app.measure_text.assign(app.measures.size(), std::string("--"));
app.measure_marks.clear();
std::vector<RowSet::MeasureMark> marks;
struct PassTimer {
App& app;
gint64 start;
~PassTimer() {
const gint64 took = g_get_monotonic_time() - start;
app.measure_cost_us += took;
app.measure_last_pass_us = took;
++app.measure_passes;
}
} pass_timer{app, pass_start_us};
for (std::size_t i = 0; i < app.measures.size(); ++i) {
const Measure& m = app.measures[i];
GtkWidget* label = app.measure_labels[i];
const int channel = measure_channel(app, m.signal);
std::vector<double> t;
std::vector<double> v;
if (channel < 0) {
// Its signal is momentarily unresolvable -- a rebuild renumbers the channels and empties
// the list -- so there is nothing to measure *this* pass. The marks still fall back (below);
// `continue`ing here was the other half of the rulers blinking off.
app.measure_text[i] = "--";
if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--");
continue;
}
{
// One copy of the visible window per channel per pass, shared by every measurement on
// that signal (the common case is several on one trace).
auto it = app.measure_samples.find(channel);
if (it == app.measure_samples.end()) {
std::pair<std::vector<double>, std::vector<double>> fresh;
if (visible_samples(app, static_cast<std::size_t>(channel), fresh.first, fresh.second)) {
it = app.measure_samples.emplace(channel, std::move(fresh)).first;
}
} else if (it->second.first.empty()) {
it = app.measure_samples.end();
// One *window* per channel per pass, shared by every measurement on that signal (the common
// case is several on one trace). It is held by reference from here on: this loop used to copy
// both vectors out of the cache for every measurement, which at four measurements over a
// 4.7M-sample window is 300 MB of memcpy per frame -- memory-bandwidth bound, and the tick's
// mean went from 0.4 ms to 13.7 ms with the pane holding them.
auto it = app.measure_samples.find(channel);
if (it == app.measure_samples.end()) {
App::MeasureWindow fresh;
if (visible_samples(app, static_cast<std::size_t>(channel), fresh.time, fresh.value)) {
fresh.stats = measure_stats(fresh.time, fresh.value);
it = app.measure_samples.emplace(channel, std::move(fresh)).first;
}
if (it == app.measure_samples.end()) {
app.measure_text[i] = "--";
if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--");
continue;
}
t = it->second.first;
v = it->second.second;
} else if (it->second.time.empty()) {
it = app.measure_samples.end();
}
const MeasureResult r = compute_measure(t, v, m.type, m.options);
if (it == app.measure_samples.end()) {
app.measure_text[i] = "--";
if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--");
continue;
}
const App::MeasureWindow& w = it->second;
const MeasureResult r = compute_measure(w.time, w.value, m.type, m.options, &w.stats);
app.measure_results[i] = r;
if (r.valid) app.measure_last_valid[i] = r;
if (label != nullptr) {
std::string text = "--";
if (r.valid) {
@@ -2279,12 +2423,30 @@ void update_measures(App& app, bool force) {
app.measure_text[i] = text;
gtk_label_set_text(GTK_LABEL(label), text.c_str());
}
if (m.options.show_on_plot && r.valid) {
const int row = measure_row(app, m.signal);
const plot::u32 color = measure_color(i);
for (const MeasureMarkSpec& spec : r.marks) {
marks.push_back({row, spec.level, spec.value, color});
}
}
// The marks last, in their own pass over the measurements, so that no `continue` above can skip
// them. That is exactly how the rulers came to blink off -- first at a mode change, then at a
// rebuild where the signal is momentarily unresolvable -- while the plot put them back a few
// frames later, which reads as the lines flickering.
//
// A pass with no answer falls back to the last valid one, and the split is by *kind*: a
// **level** is a property of the signal (a mean, a top, an amplitude) and outlives the window it
// was measured in, so its ruler stays; a **time** mark is a position *inside* the window that
// result describes, so it may not outlive it and is dropped.
for (std::size_t i = 0; i < app.measures.size() && i < app.measure_results.size(); ++i) {
const Measure& m = app.measures[i];
if (!m.options.show_on_plot) continue;
const MeasureResult& r = app.measure_results[i];
const MeasureResult* mk = &r;
if (!r.valid && i < app.measure_last_valid.size() && app.measure_last_valid[i].valid) {
mk = &app.measure_last_valid[i];
}
const int row = measure_row(app, m.signal);
const plot::u32 color = measure_color(i);
for (const MeasureMarkSpec& spec : mk->marks) {
if (!spec.level && !r.valid) continue;
marks.push_back({row, spec.level, spec.value, color});
}
}
app.measure_marks = std::move(marks);
@@ -2818,7 +2980,21 @@ gboolean tick(gpointer user) {
if (model.generation() != app->signal_generation) rebuild_signal_panels(*app);
// A gesture moves the *plot's* cursor; adopting it here is what makes the two
// vertical markers one time across every row, and what the pane then reads.
app->rows->sync_cursors();
app->rows->update_time_labels();
// Which row's controller holds a cursor: the only row whose plots are worth reading back
// (`cursor(id).x` is the free pointer position while a drag runs and the snapped value
// otherwise, so reading outside a gesture reads our own echo).
int cursor_drag_row = -1;
if (app->area != nullptr) {
for (int i = 0; i < app->rows->row_count(); ++i) {
plot::input::Controller* c = app->area->row_controller(static_cast<plot::u32>(i));
if (c != nullptr && c->grabbed_cursor() != plot::kInvalid) {
cursor_drag_row = i;
break;
}
}
}
app->rows->sync_cursors(cursor_drag_row);
update_cursors_pane(*app);
update_measures(*app, false);
@@ -2863,6 +3039,69 @@ gboolean tick(gpointer user) {
app->rows->acquire(model);
}
// The gesture probe's verdicts, one frame after the ask and after this frame's clamp: a
// `g_timeout` was the first version and it drifted past the paused/resumed boundary, so the
// "zoom in" verdict read the *live* window and called a working fix broken.
if (app->dbg_gesture_verdict != 0 && app->rows->row_count() > 0) {
const int was_verdict = app->dbg_gesture_verdict;
{
const RowSet::Row& r0 = app->rows->row(0);
const plot::Range after = app->rows->view_range();
if (app->dbg_gesture_verdict == 1) {
const bool moved = std::abs(after.min - app->dbg_pan_asked_lo) < 1e-9 &&
std::abs(after.max - app->dbg_pan_asked_hi) < 1e-9;
std::fprintf(stderr, "[pan-probe] after=[%.6f..%.6f] held=%d asked=[%.6f..%.6f]\n",
after.min, after.max, moved ? 0 : 1, app->dbg_pan_asked_lo,
app->dbg_pan_asked_hi);
// The next gesture: a zoom-out ten times the current span, anchored where a wheel zoom
// about the right edge would put it.
const double span = after.span();
if (span > 0.0) {
const plot::Range zoomed{after.max - 10.0 * span, after.max};
app->dbg_pan_asked_lo = zoomed.min;
app->dbg_pan_asked_hi = zoomed.max;
const_cast<RowSet::Row&>(r0).plot->set_x_range(r0.axis_x, zoomed);
// Judged on the next frame's tick: the member's range is only pushed by the layout.
app->dbg_gesture_verdict = 2;
}
} else if (app->dbg_gesture_verdict == 2) {
const double window = app->rows->time_window();
std::fprintf(stderr,
"[pan-probe] zoom asked=[%.6f..%.6f] after=[%.6f..%.6f] span=%.6f "
"window=%.6f limited=%d\n",
app->dbg_pan_asked_lo, app->dbg_pan_asked_hi, after.min, after.max,
after.span(), window, after.span() <= window * (1.0 + 1e-9) ? 1 : 0);
// The last gesture: a window a quarter of what is on screen, about its centre. A pinned
// view must allow it -- "not outside the event" is not "not readable".
const double span = after.span();
if (span > 0.0 && r0.plot != nullptr) {
const double centre = 0.5 * (after.min + after.max);
const plot::Range zoomed{centre - 0.125 * span, centre + 0.125 * span};
app->dbg_pan_asked_lo = zoomed.min;
app->dbg_pan_asked_hi = zoomed.max;
const_cast<RowSet::Row&>(r0).plot->set_x_range(r0.axis_x, zoomed);
app->dbg_gesture_verdict = 3;
}
} else if (app->dbg_gesture_verdict == 3) {
// The zoom-in ask was a quarter of the span: allowed means the view actually narrowed.
const double asked = app->dbg_pan_asked_hi - app->dbg_pan_asked_lo;
const plot::Range member = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x)
: plot::Range{0.0, 0.0};
std::fprintf(stderr,
"[pan-probe] zoom-in asked=[%.6f..%.6f] group=[%.6f..%.6f] member=[%.6f..%.6f]"
" span=%.6f allowed=%d\n",
app->dbg_pan_asked_lo, app->dbg_pan_asked_hi, after.min, after.max,
member.min, member.max, after.span(),
after.span() <= 2.0 * asked ? 1 : 0);
}
std::fflush(stderr);
// Cleared only if this frame did not hand the probe the *next* gesture: a report that asks
// for the next one must leave it armed for the following frame.
app->dbg_gesture_verdict = app->dbg_gesture_verdict == was_verdict ? 0 : app->dbg_gesture_verdict;
app->dbg_gesture_stage = 0;
}
}
// Nothing to do about the rows' x windows: they are one window, the x link group's.
// A gesture changes the row it landed on, `Figure::layout()` adopts that change into
// the group and pushes it to the others, so the "which row acted" search this replaced
@@ -2873,7 +3112,24 @@ gboolean tick(gpointer user) {
// 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();
if (app->event_held && !app->capturing) {
// A held event is navigable *within its data*. `pin_event_window()` framed the axis from the
// snapshot's recorded `pre_s`/`post_s` -- the *request* -- and a capture shorter than it (a
// ring that had not reached back a full pre-trigger window, or a trimmed one) left the
// shortfall as an empty margin, exactly as the pause did. The pin is the snapshot's own stamps
// here; the framing's split is what the marker at x = 0 already says.
double lo = 0.0;
double hi = 0.0;
if (app->rows->event_data_window(lo, hi)) app->rows->set_data_pin(lo, hi);
app->rows->hold_pinned_window();
} else if (display == DisplayMode::Paused || app->capturing) {
// Pinned: the event (or the frozen pause), not a navigation surface. `clamp_to_data()` is
// the live-mode half of the same rule -- a live view must stay on the data -- and the two
// are complements, which is why this is one branch rather than two conditions.
app->rows->hold_pinned_window();
} else {
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.
@@ -3001,6 +3257,8 @@ gboolean tick(gpointer user) {
}
}
if (bound > 0 && empty == bound) ++app->dbg_blank_frames;
// And the renderer's blank: the rings hold samples, yet the frame staged nothing.
if (bound > 0 && empty == 0 && app->dbg_seg_last == 0) ++app->dbg_render_blank_frames;
}
// The gutter the rows need, measured from the frame just laid out, imposed as a
@@ -3399,7 +3657,7 @@ void note_starvation(App& app) {
if (!starved) {
app.starve_reported = false;
return;
// (the zoom-in is judged on the next frame, after the layout pushes it)
}
app.notice =
"connected, but no data is arriving: another reader may hold the source (a unicast "
@@ -3901,15 +4159,27 @@ gboolean trig_trace_probe(gpointer user) {
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;
(void)sx0;
if (dx_nominal > 0.0) {
// What the renderer draws, through the series' own x: it used to walk the
// `(x - x0()) / dx()` grid, and on a ring-backed series that landed the window inside
// the visible range and dropped the two segments that draw into the left and right
// margins. `neighbours()`, one sample of slack either side, is the whole of it now --
// the same call the crosshair reads through. The *guard* against the grid walk coming
// back is the library's own pixel test
// (`a_sparse_window_between_samples_still_inks_both_margins`), because a probe that
// recomputes the renderer's formula cannot see one that no longer matches it; what this
// line still says is the rate: `dx_ratio` is how far the nominal grid sits from the real
// stamps, which is what made the walk wrong in the first place.
// A window edge outside the series keeps the renderer's own default on that side: 0 on
// the left (draw from the oldest sample) and n on the right (draw to the newest), which
// is why a view reaching past the data leaves the margin beyond it genuinely empty.
got_lo = shown->neighbours(axis.min, i0, i1) ? (i0 > 0 ? (long long)i0 - 1 : 0) : 0;
got_hi = shown->neighbours(axis.max, i0, i1)
? std::min<long long>((long long)i1 + 1, (long long)n)
: (long long)n;
}
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
@@ -3935,6 +4205,93 @@ gboolean trig_trace_probe(gpointer user) {
return G_SOURCE_CONTINUE;
}
/// Headless self-check: adds one measurement of each statistical type, which is what a user does
/// from the pane's `+`.
///
/// It exists because the frame cost with measurements on is a thing to *measure*, not to reason
/// about: a measurement is a statistic of the visible window, and a live view moves that window
/// every frame. The values land in the exit dump (`measurements:`), next to `frame cost:`.
gboolean measure_trace(gpointer user);
gboolean measure_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
std::string name;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
// A *channel*, not `signals().front()`: the first signal of a stream is its timestamp, which
// is not acquired and has no samples to measure. That mistake made the probe add four
// measurements that all read `--` -- and, because a measurement with no channel bails before
// collecting anything, it also made the first baseline look free.
if (a->model->channels().empty()) return G_SOURCE_REMOVE;
name = a->model->channels().front().name;
}
for (const MeasureType t : {MeasureType::Average, MeasureType::Top, MeasureType::PeakToPeak,
MeasureType::Amplitude}) {
MeasureOptions options;
// The marks are what a wrong window shows up in, so the probe asks for them.
options.show_on_plot = measure_can_show_on_plot(t);
a->measures.push_back({name, t, options});
}
rebuild_measures_pane(*a);
update_measures(*a, true);
std::fprintf(stderr, "[measure-probe] %zu measurements on '%s'\n", a->measures.size(),
name.c_str());
std::fflush(stderr);
g_timeout_add(250, measure_trace, a);
return G_SOURCE_REMOVE;
}
/// Repeating trace of the pane's numbers beside the *current* window and the marks actually
/// placed: the only way a stale result is visible as a number rather than as a stutter.
gboolean measure_trace(gpointer user) {
App* a = static_cast<App*>(user);
if (a->rows->row_count() <= 0) return G_SOURCE_REMOVE;
RowSet::Row& r0 = a->rows->row(0);
if (r0.plot == nullptr) return G_SOURCE_REMOVE;
const plot::Range view = r0.plot->x_range(r0.axis_x);
std::string line = "[measure-trace] view=[" + std::to_string(view.min) + ".." +
std::to_string(view.max) + "]";
for (std::size_t i = 0; i < a->measures.size() && i < a->measure_results.size(); ++i) {
const MeasureResult& r = a->measure_results[i];
line += " " + std::string(measure_type_name(a->measures[i].type)) + "=" +
(r.valid ? std::to_string(r.primary) : std::string("--"));
}
line += " marks=" + std::to_string(a->measure_marks.size());
for (const RowSet::MeasureMark& m : a->measure_marks) {
line += " " + std::string(m.level ? "level" : "time") + ":" + std::to_string(m.value);
}
std::fprintf(stderr, "%s\n", line.c_str());
std::fflush(stderr);
return G_SOURCE_CONTINUE;
}
/// Headless self-check: a gesture must not move a pinned window.
///
/// It writes the far-away range the way a gesture does -- on the *member* axis, which
/// `Figure::layout()` adopts into the group -- and then reports the axis the frame after it
/// produced: `held=1` when the pin survived, and the panned range when it did not.
gboolean pan_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->rows->row_count() <= 0) return G_SOURCE_REMOVE;
RowSet::Row& r0 = a->rows->row(0);
if (r0.plot == nullptr) return G_SOURCE_REMOVE;
const plot::Range before = r0.plot->x_range(r0.axis_x);
const double span = before.span();
if (!(span > 0.0)) return G_SOURCE_REMOVE;
// Where the data is not: ten windows to the left, the same order of magnitude a drag moves.
const plot::Range panned{before.min - 10.0 * span, before.max - 10.0 * span};
r0.plot->set_x_range(r0.axis_x, panned);
std::fprintf(stderr, "[pan-probe] asked=[%.6f..%.6f] was=[%.6f..%.6f]\n", panned.min,
panned.max, before.min, before.max);
std::fflush(stderr);
a->dbg_pan_asked_lo = panned.min;
a->dbg_pan_asked_hi = panned.max;
a->dbg_gesture_verdict = 1;
return G_SOURCE_REMOVE;
}
/// 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);
@@ -4289,11 +4646,53 @@ gboolean disarm_probe_step1(gpointer user) {
return G_SOURCE_REMOVE;
}
/// What the pause work is *for*: the acquisition keeps filling the ring while the picture is
/// frozen on the view the pause snapshotted, and resuming shows the data that arrived meanwhile
/// instead of a hole. Reported from the probe below.
void pause_probe_report(App& a, const char* what) {
double bounds_lo = 0.0;
double bounds_hi = 0.0;
std::size_t event_size = 0;
std::size_t ring_produced = 0;
{
std::lock_guard<std::mutex> lock(a.model->mutex());
if (!a.model->channels().empty()) ring_produced = a.model->live().values(0).produced();
}
if (const EventSeries* ev = a.rows->row(0).slots[0].bound_event()) {
event_size = ev->size();
const plot::Range b = ev->x_bounds();
bounds_lo = b.min;
bounds_hi = b.max;
}
// The axis beside the data, because those two disagreeing is the whole failure: the frozen view
// is the *snapshot*, and a pin taken from the requested window instead of from the snapshot left
// the difference as an empty margin. `frozen_x` must equal `axis` while paused.
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);
}
std::fprintf(stderr,
"[pause-probe] %-9s paused=%d ring_produced=%zu event_size=%zu "
"frozen_x=[%.6f..%.6f] axis=[%.6f..%.6f]\n",
what, a.model->paused() ? 1 : 0, ring_produced, event_size, bounds_lo, bounds_hi,
double(axis.min), double(axis.max));
std::fflush(stderr);
}
/// 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);
// Through the button, so the *view* snapshot and the axis pin run: pausing is a display
// state, not only an acquisition one.
pause_probe_report(*a, "live");
if (a->pause_button != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->pause_button), TRUE);
} else {
a->acq->set_paused(true);
a->model->set_paused(true);
}
a->dbg_axes_x_min = 1 << 20;
a->dbg_axes_x_max = -(1 << 20);
a->dbg_axes_w_min = 1 << 20;
@@ -4304,6 +4703,27 @@ gboolean pause_probe(gpointer user) {
a->dbg_axes_w_change_side = -1;
a->dbg_vp_w_min = 1 << 20;
a->dbg_vp_w_max = -(1 << 20);
// Reported again a second later (the probe is re-armed below) and once after the resume: the
// ring count must have grown while the frozen window did not move.
static bool reported_paused = false;
if (!reported_paused) {
reported_paused = true;
g_timeout_add(1200, +[](gpointer user) {
App* app = static_cast<App*>(user);
pause_probe_report(*app, "paused");
return G_SOURCE_REMOVE;
}, a);
g_timeout_add(1600, +[](gpointer user) {
App* app = static_cast<App*>(user);
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->pause_button), FALSE);
return G_SOURCE_REMOVE;
}, a);
g_timeout_add(2200, +[](gpointer user) {
App* app = static_cast<App*>(user);
pause_probe_report(*app, "resumed");
return G_SOURCE_REMOVE;
}, a);
}
a->dbg_xtick_min = 1 << 20;
a->dbg_xtick_max = -(1 << 20);
a->dbg_gutter_min = 1 << 20;
@@ -4435,6 +4855,79 @@ void fit_probe_report(App& a, const char* what) {
std::fflush(stderr);
}
/// The x label form across a zoom, driven through the *app's* window path (`apply_time_window`:
/// under the figure's x link group the group owns the window, so a row-level `set_time_window`
/// is ignored). Absolute at 2 s and at 500 ms, relative below a second with the caption naming
/// the anchor and the ticks written against it.
gboolean timelabels_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const char* what = "at 2 s";
switch (a->dbg_timelabels_step) {
case 0:
apply_time_window(*a, 2.0);
break;
case 1:
what = "at 500 ms";
apply_time_window(*a, 0.5);
break;
case 2:
what = "at 50 ms";
apply_time_window(*a, 0.05);
break;
default:
// Ends *in* a sub-second window: the dump runs after the last paint, so it is the
// race-free place to read whether the zero tick really sits on the left edge.
what = "final 20 ms";
apply_time_window(*a, 0.02);
break;
}
++a->dbg_timelabels_step;
if (a->dbg_timelabels_step <= 4) {
g_timeout_add(400, timelabels_probe_step, a);
}
// Report the *previous* step's state: the labels are laid out on the frame after the window
// changes, so reading them in the same call would report the old form.
const RowSet::Row& r0 = a->rows->row(0);
const RowSet::Row& last = a->rows->row(a->rows->row_count() - 1);
std::string ticks;
std::string note;
if (last.plot != nullptr) {
int n = 0;
for (const plot::Tick& t : last.plot->x_axis(last.axis_x).ticks) {
if (!t.major) continue;
if (n++ != 0) ticks += ",";
ticks += t.label;
if (n >= 4) break;
}
if (last.time_origin_note < last.plot->annotation_count()) {
const plot::Annotation& an = last.plot->annotation(last.time_origin_note);
if (an.visible) note = an.text;
}
}
double first_value = 0.0;
plot::Range shown{};
if (last.plot != nullptr) {
for (const plot::Tick& t : last.plot->x_axis(last.axis_x).ticks) {
if (t.major) {
first_value = t.value;
break;
}
}
shown = last.plot->x_range(last.axis_x);
}
std::fprintf(stderr,
"[timelabels-probe] %-12s relative=%d window=%.6g origin=%.6f note=[%s] "
"ticks=[%s] first_value=%.6f first_rel=%.6f range=[%.6f..%.6f]\n",
what, a->rows->time_labels_relative() ? 1 : 0, a->rows->time_window(),
a->rows->time_labels_origin(), note.c_str(), ticks.c_str(), first_value,
first_value - (last.plot != nullptr ? last.plot->x_axis_origin(last.axis_x) : 0.0),
shown.min, shown.max);
std::fflush(stderr);
(void)r0;
return G_SOURCE_REMOVE;
}
gboolean fit_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
@@ -4502,9 +4995,12 @@ gboolean cursors_probe_step(gpointer user) {
cursors_probe_next(*a, 5, 300);
break;
case 5: {
// A drag, as the adapter delivers one: the *plot's* cursor moves and the frame
// tick is what notices. If the sync works, every row's marker follows.
// A plot-level move with *no gesture behind it*: the sync reads a row only while its
// controller holds a cursor, so this must be ignored. That is the invariant the
// "drag it and it springs back" defect broke -- the tick adopted a row's own echo, and a
// row that had not been re-written yet adopted the stale shared value straight back.
RowSet::Row& r0 = a->rows->row(0);
a->dbg_gestureless_before = a->rows->cursor_a_x();
if (r0.plot != nullptr) {
const plot::Range xr = r0.plot->x_range(r0.axis_x);
r0.plot->set_cursor_x(r0.cursor_a, xr.min + 0.20 * xr.span(), plot::CursorPlacement::Free);
@@ -4512,17 +5008,26 @@ gboolean cursors_probe_step(gpointer user) {
cursors_probe_next(*a, 6, 300);
break;
}
case 6:
case 6: {
const double after = a->rows->cursor_a_x();
std::fprintf(stderr,
"[cursors-probe] %-14s plot move with no gesture: stored %.6f -> %.6f "
"ignored=%d\n",
"gestureless", a->dbg_gestureless_before, after,
std::fabs(after - a->dbg_gestureless_before) < 1e-12 ? 1 : 0);
std::fflush(stderr);
cursors_probe_report(*a, "after drag");
// The other half of issue 1: a *ruler* is dragged through its own plot, and the
// pane's Y cells have to follow it. Nothing shares a ruler, so this is the read-back
// path alone -- the one that used to leave the numbers showing where the app last
// put them.
a->rows->set_cursor_mode(RowSet::CursorMode::Both);
// (The adoption half -- a gesture that *does* move a cursor -- is the library's own
// controller test plus the app's rule that the tick passes the row whose controller holds
// a cursor; a programmatic press here cannot reach the adapter's event pump, so this probe
// asserts the invariant that is reachable from here.)
cursors_probe_next(*a, 7, 300);
break;
}
case 7: {
cursors_probe_report(*a, "both");
// A ruler is dragged through its own plot, and the pane's Y cells have to follow it.
// Nothing shares a ruler, so this is the read-back path alone.
RowSet::Row& r0 = a->rows->row(0);
if (r0.plot != nullptr) {
const plot::Range yr = r0.plot->y_range(r0.axis_y);
@@ -4545,12 +5050,12 @@ gboolean cursors_probe_step(gpointer user) {
void measure_dialog_click(App& a, const char* label) {
if (a.measure_dialog == nullptr) {
std::fprintf(stderr, "[measures-probe] no dialog to click '%s' in\n", label);
return;
// (the zoom-in is judged on the next frame, after the layout pushes it)
}
GtkWidget* b = find_labelled_button(a.measure_dialog, label);
if (b == nullptr) {
std::fprintf(stderr, "[measures-probe] no '%s' button in the dialog\n", label);
return;
// (the zoom-in is judged on the next frame, after the layout pushes it)
}
g_signal_emit_by_name(b, "clicked");
}
@@ -4722,7 +5227,11 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
app.source_port = cfg.port;
GtkApplication* application =
gtk_application_new("org.udpscope.app", G_APPLICATION_DEFAULT_FLAGS);
// `NON_UNIQUE`: a scope is the kind of program a user runs twice side by side -- one
// instance per source, or two viewers of the same one -- and without it GApplication's
// D-Bus single-instance rule turns every later launch into an activation on the first
// (the new process exits at once, so only the first instance ever lives).
gtk_application_new("org.udpscope.app", G_APPLICATION_NON_UNIQUE);
g_signal_connect(application, "activate", G_CALLBACK(on_activate), &app);
// Headless self-check: exercise the toolbar's toggles, then exit and print what
@@ -4748,6 +5257,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_WINDOW_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), window_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_TIMELABELS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), timelabels_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURES_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), measures_probe_step, &app);
}
@@ -4763,6 +5275,12 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
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_MEASURE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), measure_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_PAN_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), pan_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_PAUSE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), pause_probe, &app);
}
@@ -4833,6 +5351,8 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
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] render blank frames: %d of %d painted\n",
app.dbg_render_blank_frames, app.dbg_frames_painted);
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 "
@@ -4855,6 +5375,30 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
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());
// Unconditional, unlike the pane's own lines below: this is about the instruments the app
// *created*, which exist whether or not any measurement was added.
std::printf("[udpscope] readout instruments locked: %d (trigger marker + measurement marks)\n",
app.rows->readouts_locked() ? 1 : 0);
// The measurements pane, with the numbers the engine computed: a probe adds measurements and
// this is where their values are read, so a wrong threshold or a window that moved under a
// stale result is visible in the run rather than only on screen.
if (!app.measures.empty()) {
std::printf("[udpscope] measurements: %zu", app.measures.size());
for (std::size_t i = 0; i < app.measures.size() && i < app.measure_results.size(); ++i) {
const MeasureResult& r = app.measure_results[i];
std::printf(" %s=%s", measure_type_name(app.measures[i].type),
r.valid ? std::to_string(r.primary).c_str() : "--");
if (r.has_secondary) std::printf("(%s)", std::to_string(r.secondary).c_str());
}
std::printf("\n");
std::printf("[udpscope] clamp fires: %d (a clamp that fires every frame is a re-frame loop)\n",
app.rows->clamp_fires());
if (app.measure_passes > 0) {
std::printf("[udpscope] measure cost: %d recomputes, %.2f ms total, %.2f ms each\n",
app.measure_passes, app.measure_cost_us / 1000.0,
app.measure_cost_us / 1000.0 / app.measure_passes);
}
}
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; "
@@ -4946,6 +5490,47 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
}
std::printf("\n");
}
{
// The x label form: relative to the window's own start (with the caption that names
// that start) while the span is sub-second, absolute otherwise. `x_origin_set` is read
// back out of the plot, so this is what the axis is actually formatting with.
const RowSet::Row& rlt = rows.row(rows.row_count() - 1);
std::string note;
if (rlt.plot != nullptr && rlt.time_origin_note < rlt.plot->annotation_count()) {
const plot::Annotation& a = rlt.plot->annotation(rlt.time_origin_note);
if (a.visible) note = a.text;
}
// The labels themselves, from the axis the reader looks at: the assertion is what the
// ticks *say*, not only which mode is on.
std::string ticks;
if (rlt.plot != nullptr) {
int shown = 0;
for (const plot::Tick& t : rlt.plot->x_axis(rlt.axis_x).ticks) {
if (!t.major) continue;
if (shown++ != 0) ticks += ",";
ticks += t.label;
if (shown >= 4) break;
}
}
// The labels against the anchor they were built with: the dump runs after the last
// paint, so unlike the probe's timer there is no race here -- the first major must sit
// on the origin (`first_rel` the fraction of a step the window has scrolled since).
double first_value = 0.0;
double origin_set = 0.0;
if (rlt.plot != nullptr) {
origin_set = rlt.plot->x_axis_origin(rlt.axis_x);
for (const plot::Tick& t : rlt.plot->x_axis(rlt.axis_x).ticks) {
if (t.major) {
first_value = t.value;
break;
}
}
}
std::printf("[udpscope] time labels: relative=%d origin=%.6f note=[%s] x_origin_set=%.6f "
"ticks=[%s] first_rel=%.9f\n",
rows.time_labels_relative() ? 1 : 0, rows.time_labels_origin(), note.c_str(),
origin_set, ticks.c_str(), first_value - origin_set);
}
{
std::lock_guard<std::mutex> lock(model.mutex());
// `results_for` is the window the numbers describe, `win` the one on screen now: the