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

+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) {