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

-1
View File
@@ -332,7 +332,6 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
}
void Acquisition::on_data(const udps_frame_t* frame) {
if (paused_.load(std::memory_order_relaxed)) return;
std::lock_guard<std::mutex> lock(model_.mutex());
if (!running_.load(std::memory_order_relaxed)) return;
+8 -8
View File
@@ -80,14 +80,14 @@ f32 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
// capacities are equal, so there is no slack at all): once the ring saturates, the
// few oldest samples of the window routinely point at slots the producer has
// overwritten since. Returning 0 for those put a stamp of 0 into `x_bounds()`, and
// from there into `dx()` and `x0()` -- which is the renderer's entire index
// mapping, `(x - x0()) / dx()`. So *one* unreadable slot at the front inflated
// `dx()` by the ratio of the whole elapsed stream time to the retained window and
// pushed every index up with it: `first` landed well past the first visible
// sample, and the samples in between were in the buffer, readable by the crosshair
// (`x_at()`/`neighbours()` go to the ring per sample and never saw the sentinel),
// and never drawn. That is the "live trace is clipped, only the most recent part
// is visible, but the crosshair still reads values there" defect.
// from there into `dx()`/`x0()` and every consumer of the window's extent. So
// *one* unreadable slot at the front inflated `dx()` by the ratio of the whole
// elapsed stream time to the retained window: the axis in `x_bounds()` read the
// stream's start rather than the window's, every measurement cut computed from it
// came out too wide, and the readout disagreed with what the crosshair said
// (`x_at()`/`neighbours()` go to the ring per sample and never saw the sentinel).
// That is the "live trace is clipped, only the most recent part is visible, but
// the crosshair still reads values there" defect.
//
// Clamping degrades the way the library's own `StreamSeries` documents for this
// contract: a snapshot bounds the sample *count*, not the slots, so the oldest few
+11 -9
View File
@@ -25,8 +25,8 @@ using plot::u64;
/// does not hold here, because x is a *streamed timestamp*, not an index: a lost
/// datagram leaves a gap and the samples either side of it are not one grid step
/// apart. `dx()` and `x0()` therefore report a nominal grid (what the LOD level
/// and the snap use), while `gather()` and `x_bounds()` report the real
/// timestamps. `reduce()`, `sum()` and `sumsq()` are the inherited ones and read
/// and the snap use), while `gather()`, `x_bounds()`, `x_at()` and `neighbours()`
/// report the real timestamps. `reduce()`, `sum()` and `sumsq()` are the inherited ones and read
/// only y, so they are exact either way.
///
/// The time ring is shared by every channel of one buffer (spec: the +1 slice);
@@ -56,13 +56,15 @@ class ChannelBuffer final : public plot::data::Series {
// --- plot::data::Series ---------------------------------------------------
u64 size() const override { return pyramid_.size(); }
f64 x0() const override;
/// The *mean* spacing of the retained window. The renderer converts a visible x
/// range into an index range with `(x - x0()) / dx()`, so this must describe the
/// samples that are actually retained: reporting the nominal `stride * dt` made
/// that index range exceed the series by the decimation factor, the gather
/// clamped to nothing, and the trace drew empty -- which is exactly what a fast
/// channel looked like next to a slow one, because only the slow one is
/// recognisable from the few samples a wrong range still happens to cover.
/// The *mean* spacing of the retained window, and a nominal quantity: the renderer
/// picks the visible index window through `neighbours()` over the real stamps, not
/// through `(x - x0()) / dx()`, precisely because this is only a local average (a
/// lost datagram moves the real spacing around it). It still has to describe the
/// samples that are actually retained: the LOD level is chosen from the window's
/// sample count divided by its pixel width, so reporting the nominal `stride * dt`
/// over a decimated window picks a level the pyramid cannot serve and the trace
/// draws empty -- which is exactly what a fast channel looked like next to a slow
/// one.
f64 dx() const override;
/// The authoritative x of a sample: the streamed timestamp, not a grid. `dx()`
/// is only the mean spacing (the LOD level and a coarse snap use it); a lost
+4 -2
View File
@@ -103,8 +103,10 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
// otherwise -- including mid-capture, where the rows follow the live ring too. Reading
// the ring while an event is on screen would export data the user cannot see, and
// reading the stale snapshot mid-capture would export the *previous* event.
std::shared_ptr<const EventSnapshot> snap = model.event();
if (model.display_mode() == DisplayMode::Held && snap != nullptr) {
// What is on screen: a pause's frozen view, or the trigger's held event. Both are immutable
// snapshots, so the export is the picture the user is looking at either way.
std::shared_ptr<const EventSnapshot> snap = model.display_snapshot();
if (snap != nullptr) {
// The snapshot is immutable and already index-aligned, so the visible window is two
// binary searches over its stamps and the same index range for every channel.
const std::vector<f32>& st = snap->stamps;
+34
View File
@@ -372,6 +372,40 @@ void ScopeModel::reset_live_history_locked() {
}
}
void ScopeModel::publish_pause_view(double lo_s, double hi_s) {
auto snap = std::make_shared<EventSnapshot>();
std::lock_guard<std::mutex> lock(mutex_);
BufferStore& src = live();
u64 first = 0;
u64 last = 0;
live_window_locked(lo_s, hi_s, first, last);
const u64 n = last - first;
snap->stamps.resize(static_cast<std::size_t>(n));
if (n > 0) {
// The stamps go in *as the ring holds them*: the stream's own seconds, which is the
// coordinate the axis was already reading. Nothing is rebased, so a pause does not move the
// dates under the reader the way a trigger event's edge-relative axis deliberately does.
src.time().copy_from(first, n, snap->stamps.data());
}
snap->values.resize(channels_.size());
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
for (std::size_t c = 0; c < channels_.size(); ++c) {
const u64 stride = std::max<u64>(1, part_.stride[c]);
auto& vals = snap->values[c];
const u64 take = std::min(n / stride, src.values(c).size());
vals.resize(static_cast<std::size_t>(take));
if (take > 0) src.values(c).copy_from(first + n - take, take, vals.data());
f32 lo = vals.empty() ? 0.0f : vals.front();
f32 hi = lo;
for (f32 v : vals) {
lo = std::min(lo, v);
hi = std::max(hi, v);
}
snap->y[c] = plot::Range{lo, hi};
}
pause_view_.store(std::move(snap), std::memory_order_release);
}
void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
auto snap = std::make_shared<EventSnapshot>();
BufferStore& src = live();
+36
View File
@@ -157,6 +157,10 @@ enum class DisplayMode {
Capturing, ///< an edge was accepted: the live ring, edge-relative clock
Held, ///< the frozen snapshot of the last event
Waiting, ///< armed, nothing captured yet: blank
/// The user paused: the acquisition thread keeps writing the live ring, and the plot draws
/// the view that was frozen when the pause began -- a snapshot, exactly as `Held` is, so
/// resuming costs no data.
Paused,
};
/// The application's data model: the stream description, the two sample buffers,
@@ -238,6 +242,33 @@ class ScopeModel {
///
/// Caller holds `mutex()` (the acquisition thread already does when it latches).
void live_window_locked(double lo_s, double hi_s, u64& first, u64& last);
/// The frozen view taken when the user paused, or null while running. A pause is a
/// snapshot of the *visible window* in exactly the sense a trigger event is -- an immutable
/// series the plot binds to -- and the difference is the anchoring: an event's stamps are
/// edge-relative, a pause's are the stream's own, so the axis does not move when the pause
/// begins and the reader keeps the times they were already reading.
std::shared_ptr<const EventSnapshot> pause_view() const noexcept {
return pause_view_.load(std::memory_order_acquire);
}
/// Snapshots the live ring over `[lo_s, hi_s]` (stream seconds, the display epoch already
/// removed) as the pause view. The acquisition thread keeps writing, so this is a copy: what
/// is frozen is the picture, not the data.
void publish_pause_view(double lo_s, double hi_s);
void clear_pause_view() noexcept { pause_view_.store(nullptr, std::memory_order_release); }
/// Whether the user paused acquisition. Lives here rather than on `Acquisition` because the
/// bind, the measurement cut and the export all have to agree about what is on screen.
bool paused() const noexcept { return paused_.load(std::memory_order_relaxed); }
void set_paused(bool on) noexcept { paused_.store(on, std::memory_order_relaxed); }
/// The snapshot the plot should draw from, if any: the pause's frozen view while acquisition
/// is paused, else the trigger's held event. One accessor, so the bind, the measurements and
/// the export cannot disagree about which picture is on screen.
std::shared_ptr<const EventSnapshot> display_snapshot() const noexcept {
if (paused()) return pause_view();
return display_mode() == DisplayMode::Held ? event() : nullptr;
}
/// The event on screen, or null before the first latch.
std::shared_ptr<const EventSnapshot> event() const noexcept {
return event_.load(std::memory_order_acquire);
@@ -293,6 +324,9 @@ class ScopeModel {
/// answer around: calling it twice can straddle a latch, and then the bind and the
/// axis disagree about which clock they are on.
DisplayMode display_mode() const noexcept {
// A pause is the most recent instruction, so it wins over the trigger's own state: what the
// user last asked for is a still picture.
if (paused()) return DisplayMode::Paused;
if (!trigger_enabled()) return DisplayMode::Live;
if (trigger_pending()) return DisplayMode::Capturing;
return event() != nullptr ? DisplayMode::Held : DisplayMode::Waiting;
@@ -372,6 +406,8 @@ class ScopeModel {
std::atomic<bool> trigger_pending_{false};
std::atomic<double> capture_edge_{0.0};
std::atomic<std::shared_ptr<const EventSnapshot>> event_; ///< the event on screen
std::atomic<std::shared_ptr<const EventSnapshot>> pause_view_; ///< the view frozen at pause
std::atomic<bool> paused_{false};
/// Per-signal-name calibration (see `set_calibration`).
std::unordered_map<std::string, std::pair<double, double>> calibration_;
std::atomic<std::uint64_t> event_seq_{0};
+249 -49
View File
@@ -30,9 +30,17 @@ constexpr plot::u32 kRulerBColor = 0x8FA8C8u;
/// The measurements' own colour, distinct from both cursor rulers so a readout mark cannot be
/// mistaken for something the user placed.
constexpr plot::u32 kMeasureColor = 0x9BE8A0u;
/// The window-start caption's colour: quiet chrome, brighter than a grid line.
constexpr plot::u32 kTimeNoteColor = 0xC8C8C8u;
constexpr plot::u32 kTriggerColor = 0xFF5B5Bu;
constexpr double kParked = -1.0e12; ///< hides an instrument without removing it
/// The measurement marks' stroke: thinner than the chrome that frames the plot (a trace is 1.5,
/// the axis lines and the trigger marker `Style::line_width_px`), and broken, so a readout over
/// the data is distinguishable by *form* from the instruments around it.
constexpr float kMeasureStrokeWidth = 0.75f;
constexpr float kMeasureDashOn = 4.0f;
constexpr float kMeasureDashOff = 4.0f;
/// The gap between two rows, in device pixels. The rows used to be separate widgets in
/// a box, so there was a widget gap; a figure tiles its viewport, and without a gap two
@@ -138,11 +146,29 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
// place that can say no.
r.plot->set_cursor_selectable(r.measure_h[mi], false);
r.plot->set_cursor_selectable(r.measure_v[mi], false);
// Thin and dashed: a measurement's mark is an *annotation over the data* -- a measured
// level, a peak, a pair of extremes -- and the solid 1.5 px chrome stroke it used to take
// made it read as another instrument framing the plot. The trigger marker below keeps that
// stroke on purpose: it marks an event the scope acted on, which is a different claim.
r.plot->set_cursor_stroke(r.measure_h[mi], kMeasureStrokeWidth,
plot::Vec2{kMeasureDashOn, kMeasureDashOff});
r.plot->set_cursor_stroke(r.measure_v[mi], kMeasureStrokeWidth,
plot::Vec2{kMeasureDashOn, kMeasureDashOff});
}
// The window-start caption for the relative x labels: an annotation, hidden until the
// labels are relative. Inside the axes on purpose -- its text changes with the window, and
// an axis title of a changing width would relayout the plot every frame.
r.time_origin_note = r.plot->add_annotation(
plot::Annotation{0.0, 0.0, "", plot::Color::hex(kTimeNoteColor), r.axis_x, r.axis_y, false});
r.trigger_marker = r.plot->add_cursor(kParked, plot::Color::hex(kTriggerColor));
// A marker, not an instrument: it must sit exactly on the edge's time, so its
// snap is Free and the controller never rounds it onto a sample.
r.plot->set_cursor_snap(r.trigger_marker, plot::SnapMode::Free);
// And it is a *readout* like the measurement marks above, for the same reason: it says where the
// scope put the edge, which is not something the pointer may drag. Parking it outside the axis
// does not stop a drag -- the controller holds the marker for the gesture's life and follows the
// pointer between frames -- so excluding it from the hit test is the only place that can say no.
r.plot->set_cursor_selectable(r.trigger_marker, false);
// No `set_x_follow(Live)`: the link group owns the window *and* the follow state, and
// `Figure::link_x()` refuses a member whose x axis still follows on its own.
}
@@ -258,6 +284,15 @@ void RowSet::bind(ScopeModel& model, DisplayMode mode) {
// the *clock*, which the display epoch carries, not the binding.
const std::size_t ci = static_cast<std::size_t>(channel);
switch (mode) {
case DisplayMode::Paused:
// The view frozen at the pause. The ring behind it keeps filling, which is the point
// of a pause that does not stop acquisition.
if (auto frozen = model.pause_view()) {
r.slots[si].bind_event(std::move(frozen), ci);
} else {
r.slots[si].bind(nullptr);
}
break;
case DisplayMode::Held:
if (auto snap = model.event()) {
r.slots[si].bind_event(std::move(snap), ci);
@@ -325,7 +360,7 @@ void RowSet::set_y_range(int row_index, double lo, double hi) {
r.plot->set_y_range(r.axis_y, {a, b});
}
void RowSet::place_cursors(Row& r) {
void RowSet::place_cursors(Row& r, plot::CursorPlacement placement) {
if (!r.plot) return;
const bool on = cursors_on_;
// Which instruments the mode uses; the rest are parked (a `Plot` cannot drop a
@@ -333,10 +368,13 @@ void RowSet::place_cursors(Row& r) {
const bool vertical = on && cursor_mode_ != CursorMode::Horizontal;
const bool horizontal = on && (cursor_mode_ == CursorMode::Horizontal ||
cursor_mode_ == CursorMode::Both);
r.plot->set_cursor_x(r.cursor_a, vertical ? cursor_a_x_ : kParked);
r.plot->set_cursor_x(r.cursor_b, vertical ? cursor_b_x_ : kParked);
r.plot->set_cursor_y(r.ruler, horizontal ? r.ruler_y : kParked);
r.plot->set_cursor_y(r.ruler_b, horizontal ? r.ruler_b_y : kParked);
const double a = vertical ? cursor_a_x_ : kParked;
const double b = vertical ? cursor_b_x_ : kParked;
r.plot->set_cursor_x(r.cursor_a, a, placement);
r.plot->set_cursor_x(r.cursor_b, b, placement);
r.plot->set_cursor_y(r.ruler, horizontal ? r.ruler_y : kParked, placement);
r.plot->set_cursor_y(r.ruler_b, horizontal ? r.ruler_b_y : kParked, placement);
}
void RowSet::set_cursor_mode(CursorMode mode) {
@@ -452,6 +490,161 @@ void RowSet::set_measure_marks(const std::vector<MeasureMark>& marks) {
}
}
bool RowSet::update_time_labels() {
Row& first = row(0);
if (!first.plot || !figure_) return false;
const plot::Range view = first.plot->x_range(first.axis_x);
const double span = view.span();
if (!(span > 0.0) || !std::isfinite(span)) return false;
// Sub-second windows read relative: `TickFormat::Time` writes three significant digits, so
// `12.345671s` and `12.345672s` are the same string and a 1 ms step around a multi-second
// time cannot be labelled at all. The band is the switch's hysteresis -- entering below a
// second, leaving only above 1.05 -- so a span sitting on the boundary cannot flicker
// between two forms while a zoom is nudged.
const bool want_relative = relative_x_ ? span < 1.05 : span < 1.0;
const bool changed = want_relative != relative_x_;
relative_x_ = want_relative;
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
if (!r.plot) continue;
// The axis takes the origin *from its own range* at refresh while relative: the host cannot
// predict where the follow will place the window (that is derived from the newest sample at
// layout time), and a value written here is a frame of scroll stale -- more than a
// millisecond-scale window shows. `x_origin_` is read back below for the caption.
r.plot->set_x_axis_origin_from_range(r.axis_x, relative_x_);
if (!relative_x_) r.plot->set_x_axis_origin(r.axis_x, 0.0);
// The caption belongs with the labels, which a figure draws once, on the bottom row (the
// one whose x axis is the figure's). Every row gets it parked.
const bool bottom = i == rows_ - 1;
if (r.time_origin_note >= r.plot->annotation_count()) continue;
plot::Annotation note = r.plot->annotation(r.time_origin_note);
note.visible = relative_x_ && bottom;
if (note.visible) {
// Inside the axes, just above the axis line, at their left edge: the reader adds this
// to a tick, so it sits beside the ticks.
const plot::Range yr = r.plot->y_range(r.axis_y);
note.x = view.min;
note.y = yr.min + 0.04 * (yr.span() > 0.0 ? yr.span() : 1.0);
char text[64];
// The anchor the *drawn* labels were built with: the axis' own origin, which the last
// layout set from its range. Reading it back (rather than keeping our own copy) is what
// makes the reader's addition exact -- label plus caption is that tick's absolute time.
const double shown = r.plot->x_axis_origin(r.axis_x);
x_origin_ = shown;
std::snprintf(text, sizeof(text), "t0 = %.6g s", shown);
note.text = text;
}
r.plot->set_annotation(r.time_origin_note, note);
}
return changed;
}
void RowSet::hold_pinned_window() {
if (!figure_ || !pinned_) return;
// `Off` as well as the range: a rebuild or a mode change may have left the group following,
// and a following group would re-derive the window from a live edge on the next layout --
// ignoring the pin altogether.
figure_->set_x_link_follow(x_group_, plot::XFollow::Off);
const plot::Range pin{-pinned_pre_, pinned_post_};
// The *request*: what the user's gesture left on the row's own axis. A member's `set_x_range`
// -- what the controller writes -- is not adopted into the group while the group is not
// following, so reading the group here only ever saw the previous clamp's answer and the wheel
// did nothing at all. The group is preferred when the member has *not* moved since our own last
// statement, which is what keeps a fresh pin (`pin_event_window()`, `pin_pause_window()`, both
// of which write the group in this same tick) from being reverted to the member's stale window.
plot::Range now = figure_->x_link_range(x_group_);
if (rows_ > 0 && row(0).plot != nullptr) {
const plot::Range member = row(0).plot->x_range(row(0).axis_x);
const bool member_is_ours = std::abs(member.min - pinned_stated_.min) < 1e-12 &&
std::abs(member.max - pinned_stated_.max) < 1e-12;
if (!member_is_ours) now = member;
}
const double pin_span = pin.span();
// **A zoom *in* is allowed.** The rule is that the view may not leave the time window, and a
// window narrower than the event's is still inside it -- it shows less of the same event. This
// used to re-state the pin every frame, which made the wheel do nothing at all in single
// trigger mode, so an acquired event could not be examined. What is clamped is the *span* (at
// the pin's) and the *position* (inside it): together, "never outside".
if (!(pin_span > 0.0) || !std::isfinite(pin_span)) return;
const double span = std::clamp(now.span(), std::min(1.0e-6, pin_span), pin_span);
const double lo = std::clamp(now.min, pin.min, pin.max - span);
pinned_stated_ = plot::Range{lo, lo + span};
figure_->set_x_link_range(x_group_, pinned_stated_);
}
void RowSet::pin_pause_window(double lo, double hi) {
// The pin is the **snapshot's own span**, not the window that was requested when the pause began.
// The cut is clamped to what the ring holds, so a window that reaches back past the oldest
// retained sample produces a *shorter* snapshot -- and pinning the axis to the request left the
// difference as an empty margin on the left, visible before any zoom and made worse by one
// (zooming in moved the view *into* it). The event pin already works this way ("the event axis is
// pinned from the snapshot's own stamps, not from the request"); this is the pause agreeing.
const plot::Range pin{lo, hi};
if (!(pin.span() > 0.0) || !std::isfinite(pin.min) || !std::isfinite(pin.max)) return;
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
if (!r.plot) continue;
r.plot->set_x_follow(r.axis_x, plot::XFollow::Off);
r.plot->set_x_range(r.axis_x, pin);
}
set_data_pin(pin.min, pin.max);
if (figure_) {
figure_->set_x_link_follow(x_group_, plot::XFollow::Off);
figure_->set_x_link_range(x_group_, pin);
}
}
bool RowSet::readouts_locked() const noexcept {
for (int i = 0; i < kMaxRows; ++i) {
const Row& r = row(i);
if (!r.plot) continue;
if (r.plot->cursor(r.trigger_marker).selectable) return false;
for (const plot::u32 id : r.measure_h) {
if (r.plot->cursor(id).selectable) return false;
}
for (const plot::u32 id : r.measure_v) {
if (r.plot->cursor(id).selectable) return false;
}
}
return true;
}
bool RowSet::event_data_window(double& lo, double& hi) const {
bool have = false;
for (int i = 0; i < rows_; ++i) {
const Row& r = row(i);
if (!r.plot) continue;
for (int s = 0; s < kSlotsPerRow; ++s) {
const EventSeries* ev = r.slots[static_cast<std::size_t>(s)].bound_event();
if (ev == nullptr || ev->size() < 2) continue;
const plot::Range b = ev->x_bounds();
if (!std::isfinite(b.min) || !std::isfinite(b.max) || !(b.max > b.min)) continue;
lo = have ? std::min(lo, b.min) : b.min;
hi = have ? std::max(hi, b.max) : b.max;
have = true;
}
}
return have;
}
void RowSet::set_data_pin(double lo, double hi) {
if (!(hi > lo) || !std::isfinite(lo) || !std::isfinite(hi)) return;
pinned_ = true;
pinned_pre_ = -lo;
pinned_post_ = hi;
}
void RowSet::unpin_pause_window() {
// The same path the window control and a go-live take: re-arm the follow and frame the newest
// span, so a resume lands on the data that arrived while paused rather than on the frozen
// frame's left edge.
set_time_window(window_s_);
}
void RowSet::fit_x() {
Row& first = row(0);
if (!first.plot || !figure_) return;
@@ -469,46 +662,43 @@ void RowSet::fit_x() {
}
}
void RowSet::sync_cursors() {
if (!cursors_visible()) return;
void RowSet::sync_cursors(int drag_row) {
if (!cursors_visible() || drag_row < 0 || drag_row >= rows_) return;
// Only a mode that *shows* the vertical pair reads them back. With them parked (the
// `horizontal` mode) whatever the plot reports is our own park coming back -- and a
// snapped cursor parked far outside the axis reads back as something finite, which
// would be adopted as a gesture and destroy the shared times.
// Anything within this much of what we last pushed is our own placement coming back
// (the plot stores f64, so an exact compare would be brittle across a rebuild).
const auto moved = [](double a, double b) {
return std::fabs(a - b) > 1.0e-9 * std::max(1.0, std::fabs(b));
};
Row& r = row(drag_row);
if (!r.plot) return;
// A gesture writes the *plot*; while it runs, this is its only visible result. A ruler is its
// own row's value -- nothing to share, but it still has to be read back or the pane would
// keep showing wherever the app last put it.
const bool rulers = cursor_mode_ == CursorMode::Horizontal || cursor_mode_ == CursorMode::Both;
if (rulers) {
const double y = r.plot->cursor(r.ruler).y;
const double yb = r.plot->cursor(r.ruler_b).y;
if (y > kParked * 0.5) r.ruler_y = y;
if (yb > kParked * 0.5) r.ruler_b_y = yb;
}
bool push = false;
for (int i = 0; i < rows_; ++i) {
Row& r = row(i);
if (!r.plot) continue;
// A gesture writes the *plot*; this is the only place its result is visible. A
// ruler is its own row's value -- nothing to share, but it still has to be read
// back or the pane would keep showing wherever the app last put it.
const bool rulers = cursor_mode_ == CursorMode::Horizontal || cursor_mode_ == CursorMode::Both;
if (rulers) {
const double y = r.plot->cursor(r.ruler).y;
const double yb = r.plot->cursor(r.ruler_b).y;
if (y > kParked * 0.5) r.ruler_y = y;
if (yb > kParked * 0.5) r.ruler_b_y = yb;
}
if (cursor_mode_ == CursorMode::Horizontal) continue; // the pair is parked: not a value
if (cursor_mode_ != CursorMode::Horizontal) {
const double a = r.plot->cursor(r.cursor_a).x;
const double b = r.plot->cursor(r.cursor_b).x;
if (a < kParked * 0.5 || b < kParked * 0.5) continue; // parked: not a value
if (moved(a, cursor_a_x_)) {
cursor_a_x_ = a;
push = true;
}
if (moved(b, cursor_b_x_)) {
cursor_b_x_ = b;
push = true;
if (a > kParked * 0.5 && b > kParked * 0.5) {
if (a != cursor_a_x_) {
cursor_a_x_ = a;
push = true;
}
if (b != cursor_b_x_) {
cursor_b_x_ = b;
push = true;
}
}
}
if (push) {
for (int i = 0; i < rows_; ++i) place_cursors(row(i));
// `Free`: the value came out of the plot a moment ago and is already the free position the
// controller is tracking; snapping it here would fight the hand that is still moving it.
for (int i = 0; i < rows_; ++i) place_cursors(row(i), plot::CursorPlacement::Free);
}
place_crossings();
}
@@ -536,27 +726,37 @@ void RowSet::clamp_to_data() {
have = true;
}
if (!have) return;
const double retained = data.max - data.min;
const double span = figure_->x_link_range(x_group_).span();
if (!(span > 0.0) || !std::isfinite(span)) return;
// The *requested* window is intent, and a buffer that has not filled it yet must not
// be allowed to redefine it. This runs every frame and cannot tell "the user zoomed
// out past the data" from "the ring has only just started filling", and the first
// frames after a signal is enabled hold a couple of hundred samples: clamping to
// those shrank a requested 2 s window to ~0.02 s, and nothing here ever grows a span
// back, so the view stayed pinned to the newest 20 ms of a 6.5 s buffer for the rest
// of the run. That is the loudest form of the "live trace is clipped, only the most
// recent part is visible, data is there because the crosshair reads it" defect -- the
// data was never clipped at all, the window was. Clamping against `max(retained,
// window_s_)` leaves a partly filled window empty on its left, which is what a scope
// that just started should look like.
// The bound is the **requested window itself** -- what the window control states, capped by
// `--max-window` -- and not `retained`, how much the ring happens to hold.
//
// The difference is the whole point of this function, and both halves of it have bitten:
// clamping to `retained` alone broke the *early* case, because the first frames after a signal
// is enabled hold a couple of hundred samples, a requested 2 s window was clamped to the
// ~0.02 s they covered, and nothing here ever grows a span back -- the view stayed pinned to
// the newest 20 ms of a 6.5 s buffer for the rest of the run. Loosening the bound to
// `max(retained, window_s_)` fixed that and broke the *late* case instead: while the ring is
// still filling the two are equal, but once it holds more than the window the bound becomes the
// whole ring, so zooming out walked the view past the window into data the window was never set
// to show -- "more points than the time window in the buffer, and it still zooms out of it".
// `window_s_` is the right bound in both states: it never shrinks a window the caller asked
// for, and it is the app's own statement of what the view *is*. A partly filled buffer is left
// with an empty left-hand side, which is what a scope that just started should look like.
//
// The span only. A pan is the group's `offset` behind its live edge and the group
// clamps it against what its members retain on every layout, so there is nothing
// here to do about `view.min < data.min` -- and doing it anyway would state a maximum
// away from the live edge, i.e. ask the group to scroll back.
const double limit = std::max(retained, window_s_);
if (span > limit) figure_->set_x_link_range(x_group_, {data.max - limit, data.max});
if (!(window_s_ > 0.0)) return;
// A *relative* tolerance, not an exact comparison: the range handed to the group is exactly
// `window_s_` wide, but the group's span comes back through f64 and can land an ULP above it --
// and then a clamp that re-frames on `span > window_s_` re-frames *every frame*, which is a
// flickering view rather than a clamped one. The tolerance is far below a pixel of data.
if (span > window_s_ * (1.0 + 1.0e-9)) {
++clamp_fires_;
figure_->set_x_link_range(x_group_, {data.max - window_s_, data.max});
}
}
void RowSet::pin_event_window(double pre_s, double post_s) {
+101 -8
View File
@@ -106,6 +106,11 @@ class RowSet {
/// it keeps its position whatever the cursor toggle says.
plot::u32 trigger_marker = 0;
/// The caption naming the window's start while the x labels are relative: an annotation
/// inside the axes, so its text can change every frame without moving the layout (an axis
/// *title* would, and it would be a different width each time).
plot::u32 time_origin_note = 0;
/// The measurements' readout marks: a pool created with the plot (a `Plot` cannot add or
/// drop a cursor after the fact) and parked unless a measurement is using the slot.
std::array<plot::u32, kMeasureMarks> measure_h{};
@@ -208,6 +213,19 @@ class RowSet {
/// end up with its instruments parked while every other row showed them.
bool cursors_visible() const noexcept { return cursors_on_; }
/// Whether the x labels are currently written relative to the window's start, and what that
/// start was. The mode is decided by the span (see `update_time_labels()`), which is why it
/// is state rather than a per-frame expression: the switch has hysteresis, so a span
/// hovering on the boundary cannot make the labels flicker between two forms.
bool time_labels_relative() const noexcept { return relative_x_; }
/// The value the x labels are relative to (meaningless when `time_labels_relative()` is
/// false). The host prints it beside the axis so the reader can add it to a tick.
double time_labels_origin() const noexcept { return x_origin_; }
/// Chooses the x label form for the window the rows are showing and applies it: relative
/// with the window's own start as the origin when the span is sub-second, absolute
/// otherwise. Called once per frame from the tick, and returns true when the form changed.
bool update_time_labels();
/// One measurement's readout mark on a row: a *level* (drawn as a horizontal ruler) or a
/// *time* (a vertical marker). Which one a measurement needs is the measurement's own
/// business; the row is the one that plots its signal.
@@ -227,18 +245,75 @@ class RowSet {
/// measurement is drawn", as opposed to merely computed.
int measure_marks_placed() const noexcept { return measure_marks_placed_; }
/// Whether every instrument this app *computed* is out of the pointer's reach: the trigger
/// marker and the measurement marks. They are readouts -- where the scope put the edge, what a
/// measurement measured -- and a user must not be able to drag them; the report in the dump is
/// how that is checked, since a drag is not something a probe can perform.
bool readouts_locked() const noexcept;
/// How many times `clamp_to_data()` re-framed the view. A clamp that fires every frame is a
/// *loop*, not a clamp -- and `span > window_s_` can stay true after clamping to exactly
/// `window_s_`, because the group's span is the range's span through f64 and lands a ULP over.
/// Reported in the dump so a flickering view has a number rather than a description.
int clamp_fires() const noexcept { return clamp_fires_; }
/// The window the rows are actually on: the *group's* range, which is what the gestures and the
/// clamps write. A member's own range only catches up at the next layout, so a probe that reads
/// a member to judge a clamp sees the previous frame's window.
plot::Range view_range() const noexcept {
return figure_ ? figure_->x_link_range(x_group_) : plot::Range{0.0, 0.0};
}
/// Re-states the pinned window, so a gesture cannot move it.
///
/// In trigger mode the group follows nothing: `clamp_to_data()` is live-only by design, and the
/// library's own pan bound lives in the *following* path (a `Live` group's offset is clamped
/// against what its members retain), so with the follow off nothing pulled a drag back -- a pan
/// or a zoom-out walked the frozen capture off its axis and left an empty plot over data the
/// scope never had. The view is kept inside the event instead: the span is capped at the event's
/// and the position clamped into it, so a **zoom in is allowed** (less of the same event is
/// still the event) while leaving it is not. Doing it by re-stating the pin -- this function's
/// first version -- made the wheel a no-op, i.e. an acquired event could not be examined at all.
/// Does nothing when no window is pinned.
void hold_pinned_window();
/// Pins the axes where they are for the pause's frozen view: the snapshot is not a streaming
/// series, so live follow has nothing to derive a window from, and the frame the pause froze
/// has to stay the frame on screen. `unpin_pause_window()` restores following and re-frames on
/// the newest data, which is what a resume should show.
/// `lo`/`hi` are the *snapshot's* own stamps -- the data the frozen view actually holds, which
/// may be narrower than the window that was requested when the pause began.
void pin_pause_window(double lo, double hi);
/// The window the bound **event data** spans: the union of the event series the rows hold. What
/// a `Held` view may be navigated *within* -- the snapshot's stamps, never the recorded
/// `pre_s`/`post_s` split, which is the *request* and can exceed what was captured. False when
/// no row holds an event.
bool event_data_window(double& lo, double& hi) const;
/// Sets the pin without touching what is on screen: the bound a gesture is clamped into. The
/// `Held` tick re-states it every frame (the snapshot does not move, so it is idempotent) while
/// a user's zoom -- which is a narrower window *inside* the pin -- survives.
void set_data_pin(double lo, double hi);
void unpin_pause_window();
/// Frames the x window on the data row 0 holds, leaving every y axis alone. The x
/// counterpart of `fit_all()`, for the one thing the y axes cannot say: how wide the
/// window should be.
void fit_x();
/// Adopts the instruments a gesture moved. The vertical pair is one time for the whole
/// figure, so a drag on any row's marker moves every row's; a ruler belongs to its own
/// row and is only read back. Either way the plot is where a drag writes, so this is the
/// one place the app can see it -- and the pane reads what is stored here, which is why a
/// ruler that was not read back showed a stale value while its marker on the plot moved.
/// Called once a frame from the tick.
void sync_cursors();
/// Adopts the instruments a gesture is moving, on `drag_row` (the row whose controller holds
/// a cursor), and nothing otherwise.
///
/// Reading the plots is only meaningful *while a drag runs*: `cursor(id).x` reports the free
/// pointer position then, and the snapped one otherwise -- which is what this very code wrote
/// a frame earlier (or the snap of it). Adopting outside a drag therefore adopts our own echo
/// back and a row's stale copy of the shared time, which is what made a dragged cursor spring
/// to the position it had before the gesture. The vertical pair is one time for the whole
/// figure, so a drag on any row moves every row's; a ruler belongs to its own row alone.
/// Called once a frame from the tick, with `drag_row` < 0 when no cursor is held.
void sync_cursors(int drag_row);
/// Puts both vertical cursors at a quarter and three quarters of the x window, and
/// the two rulers at a third and two thirds of each row's y range. Also switches the
@@ -309,7 +384,13 @@ class RowSet {
static void clear_row(Row& r);
void install(Row& r, int row_index, plot::Plot& p);
void place_cursors(Row& r);
/// Writes a row's instruments from the stored values. `placement` is passed through to the
/// plot: a *programmatic* placement (reset, a mode change) wants the default `Snapped`, so
/// the marker lands on a sample; echoing a value a *drag* just produced wants `Free`, because
/// snapping it again is what made a dragged cursor spring back -- the library snaps a cursor
/// at the end of the gesture (`Controller::end_drag()`), and a value read out of the plot
/// mid-drag has already been through that act.
void place_cursors(Row& r, plot::CursorPlacement placement = plot::CursorPlacement::Snapped);
/// The newest x any row's live traces hold, which is what the group follows.
bool live_edge(double& newest) const;
@@ -325,11 +406,23 @@ class RowSet {
double pinned_pre_ = 0.0;
double pinned_post_ = 0.0;
bool pinned_ = false;
int clamp_fires_ = 0;
/// The window the pinned clamp last stated. A row's own axis that differs from this is a
/// gesture to be honoured; one that matches it is just the clamp's own answer arriving back.
plot::Range pinned_stated_{0.0, 0.0};
double y_gutter_px_ = 0.0;
bool y_gutter_reserved_ = false;
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{};
CursorMode cursor_mode_ = CursorMode::Vertical;
bool cursors_on_ = false;
bool relative_x_ = false;
double x_origin_ = 0.0;
/// What was last *written* to each row's own vertical cursors. A row is only evidence of a
/// gesture when its value differs from its own last write: the pair's value is shared, so a
/// row that has not been re-written yet still holds the previous shared value, and adopting
/// *that* would undo the row the user actually dragged -- which is what made a drag spring
/// back, one row's stale echo at a time.
int measure_marks_placed_ = 0;
/// The one stream time each vertical cursor is at, whatever row's copy was last moved.
double cursor_a_x_ = 0.0;
+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