feat: a search's hits are marked on every row, and the panel says how many of them it is showing

This commit is contained in:
Martino Ferrari committed 2026-10-08 10:11:50 +02:00
1 parent 8ee4effb70
commit db81ab2700
3 files changed
+140

No files matched your search

+46
View File
@@ -31,6 +31,14 @@ constexpr plot::u32 kRulerBColor = 0x8FA8C8u;
/// mistaken for something the user placed. /// mistaken for something the user placed.
constexpr plot::u32 kMeasureColor = 0x9BE8A0u; constexpr plot::u32 kMeasureColor = 0x9BE8A0u;
constexpr plot::u32 kTriggerColor = 0xFF5B5Bu; constexpr plot::u32 kTriggerColor = 0xFF5B5Bu;
/// A found hit's colour: the trigger marker's claim ("the scope fired here") in a cooler hue,
/// because a hit is the same condition found in *history* and the two are on screen together --
/// the live marker's red must stay the one that means "this is the event the capture is about".
constexpr plot::u32 kSearchColor = 0x7FB2FFu;
/// The *selected* hit's colour: the one the arrows are standing on, and the one a pause was taken
/// around. Sixteen identical lines with no way to tell which of them the readout is quoting is a
/// picture that cannot be read, so the current hit is brighter than its neighbours.
constexpr plot::u32 kSearchCurrentColor = 0xE8D37Fu;
/// The smallest difference a `TickFormat::Time` label can *write* at the magnitude `t` -- what /// The smallest difference a `TickFormat::Time` label can *write* at the magnitude `t` -- what
/// the last digit of a label at that magnitude is worth. `format_time` writes three significant /// the last digit of a label at that magnitude is worth. `format_time` writes three significant
@@ -202,6 +210,15 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
r.plot->set_cursor_stroke(r.measure_v[mi], kMeasureStrokeWidth, r.plot->set_cursor_stroke(r.measure_v[mi], kMeasureStrokeWidth,
plot::Vec2{kMeasureDashOn, kMeasureDashOff}); plot::Vec2{kMeasureDashOn, kMeasureDashOff});
} }
// The search hits' pool, parked, created for the same reason the measurement pool above is:
// `add_cursor()` only appends, so every mark a scan could ever want exists from the start.
for (int m = 0; m < kSearchMarks; ++m) {
const std::size_t mi = static_cast<std::size_t>(m);
r.search_v[mi] = r.plot->add_cursor(kParked, plot::Color::hex(kSearchColor));
r.plot->set_cursor_selectable(r.search_v[mi], false);
r.plot->set_cursor_stroke(r.search_v[mi], kMeasureStrokeWidth,
plot::Vec2{kMeasureDashOn, kMeasureDashOff});
}
r.trigger_marker = r.plot->add_cursor(kParked, plot::Color::hex(kTriggerColor)); 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 // 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. // snap is Free and the controller never rounds it onto a sample.
@@ -544,6 +561,32 @@ void RowSet::set_measure_marks(const std::vector<MeasureMark>& marks) {
} }
} }
void RowSet::set_search_marks(const std::vector<double>& xs, int current) {
const int n = static_cast<int>(xs.size()) < kSearchMarks ? static_cast<int>(xs.size())
: kSearchMarks;
search_marks_placed_ = n;
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
if (!r.plot) continue;
// Every row, not the hit's own row: a hit is an *instant on the shared x window*, so the
// mark lines up down the column the way the vertical cursors do. There is no "the hit's
// row" to pick -- the condition was found on the trigger's channel, which may not be
// plotted at all.
for (int m = 0; m < n; ++m) {
const std::size_t mi = static_cast<std::size_t>(m);
r.plot->set_cursor_x(r.search_v[mi], xs[mi], plot::CursorPlacement::Free);
// The colour is re-set every frame rather than only when the selection moves: the pool is
// re-ordered each frame as the view rolls, so slot `m` is not the same hit it was.
r.plot->set_cursor_color(r.search_v[mi],
plot::Color::hex(m == current ? kSearchCurrentColor
: kSearchColor));
}
for (int m = n; m < kSearchMarks; ++m) {
r.plot->set_cursor_x(r.search_v[static_cast<std::size_t>(m)], kParked);
}
}
}
bool RowSet::update_time_labels() { bool RowSet::update_time_labels() {
Row& first = row(0); Row& first = row(0);
if (!first.plot || !figure_) return false; if (!first.plot || !figure_) return false;
@@ -697,6 +740,9 @@ bool RowSet::readouts_locked() const noexcept {
for (const plot::u32 id : r.measure_v) { for (const plot::u32 id : r.measure_v) {
if (r.plot->cursor(id).selectable) return false; if (r.plot->cursor(id).selectable) return false;
} }
for (const plot::u32 id : r.search_v) {
if (r.plot->cursor(id).selectable) return false;
}
} }
return true; return true;
} }
+20
View File
@@ -53,6 +53,10 @@ class RowSet {
/// measurements to be shown at once, and a fixed number because a `Plot` fixes its cursor /// measurements to be shown at once, and a fixed number because a `Plot` fixes its cursor
/// set at construction. /// set at construction.
static constexpr int kMeasureMarks = 6; static constexpr int kMeasureMarks = 6;
/// Search hits drawn at once, per row. A scan can return thousands; a plot cannot grow its
/// cursor set, and sixteen lines over one window is already the most a reader can count. The
/// panel states how many of the hits it is showing.
static constexpr int kSearchMarks = 16;
RowSet(); RowSet();
@@ -112,6 +116,10 @@ class RowSet {
std::array<plot::u32, kMeasureMarks> measure_h{}; std::array<plot::u32, kMeasureMarks> measure_h{};
std::array<plot::u32, kMeasureMarks> measure_v{}; std::array<plot::u32, kMeasureMarks> measure_v{};
/// The search hits' marks: the same arrangement as the measurement pool above, vertical only
/// (a hit is an instant, never a level).
std::array<plot::u32, kSearchMarks> search_v{};
/// One marker per trace per cursor: where each vertical cursor crosses that trace's /// One marker per trace per cursor: where each vertical cursor crosses that trace's
/// series, drawn only in `mix` mode. Two per slot because the pair crosses a trace /// series, drawn only in `mix` mode. Two per slot because the pair crosses a trace
/// at two places, and an annotation cannot be shared between them. /// at two places, and an annotation cannot be shared between them.
@@ -255,6 +263,17 @@ class RowSet {
/// measurement is drawn", as opposed to merely computed. /// measurement is drawn", as opposed to merely computed.
int measure_marks_placed() const noexcept { return measure_marks_placed_; } int measure_marks_placed() const noexcept { return measure_marks_placed_; }
/// Places the search hits' marks and parks the rest of the pool. `xs` are **display** x values
/// (the caller has already taken `stream_at_x0()` off a stream time), the same convention
/// `set_measure_marks()` takes a time mark in, and the marks are unselectable for the same
/// reason: a hit is where the scope found the condition, not something the pointer may move.
///
/// `current` indexes the one the navigation is standing on, which is drawn in its own colour, or
/// is negative for none. The caller orders `xs`; this places them in the order it is given.
void set_search_marks(const std::vector<double>& xs, int current);
/// How many search marks the last call placed, which is `min(xs.size(), kSearchMarks)`.
int search_marks_placed() const noexcept { return search_marks_placed_; }
/// Whether every instrument this app *computed* is out of the pointer's reach: the trigger /// 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 /// 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 /// measurement measured -- and a user must not be able to drag them; the report in the dump is
@@ -479,6 +498,7 @@ class RowSet {
/// back, one row's stale echo at a time. /// back, one row's stale echo at a time.
int measure_marks_placed_ = 0; int measure_marks_placed_ = 0;
int search_marks_placed_ = 0;
/// The one stream time each vertical cursor is at, whatever row's copy was last moved. /// The one stream time each vertical cursor is at, whatever row's copy was last moved.
double cursor_a_x_ = 0.0; double cursor_a_x_ = 0.0;
double cursor_b_x_ = 0.0; double cursor_b_x_ = 0.0;
+74
View File
@@ -32,6 +32,7 @@
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <utility>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <cstring> #include <cstring>
@@ -165,6 +166,9 @@ struct App {
GtkWidget* search_cancel_button = nullptr; GtkWidget* search_cancel_button = nullptr;
GtkWidget* search_status_label = nullptr; GtkWidget* search_status_label = nullptr;
GtkWidget* search_pos_label = nullptr; GtkWidget* search_pos_label = nullptr;
/// "16 of 38 marks shown" -- the pool is smaller than a scan's result, and a display that is
/// showing a sixth of what it found has to say so.
GtkWidget* search_marks_label = nullptr;
GtkWidget* search_prev_button = nullptr; GtkWidget* search_prev_button = nullptr;
GtkWidget* search_next_button = nullptr; GtkWidget* search_next_button = nullptr;
GtkWidget* menu_button = nullptr; GtkWidget* menu_button = nullptr;
@@ -3341,6 +3345,62 @@ void place_measure_marks(App& app) {
app.rows->set_measure_marks(app.measure_marks_display); app.rows->set_measure_marks(app.measure_marks_display);
} }
/// Puts the search hits nearest the view on the plots, converting each from the stream seconds
/// `BufferSearch` records to the x the axis is reading this frame -- the same conversion
/// `place_measure_marks()` makes, and for the same reason. Called every frame: that is what
/// makes a mark read-only and what keeps it on its own sample as a live view rolls.
///
/// Only the hits **inside the visible window** are candidates, nearest the view's centre first, so
/// that a zoomed-out view with more hits than marks shows the middle of the cluster rather than an
/// arbitrary end of it. The selected hit is placed first regardless, so stepping to a hit never
/// leaves it unmarked, and it is the one drawn in the current colour.
void place_search_marks(App& app) {
std::vector<double> xs;
int current = -1;
std::size_t in_view = 0;
const std::vector<double>& hits = app.search.hits();
if (!hits.empty()) {
const double s0 = app.model->stream_at_x0();
const plot::Range view = app.rows->view_range();
const double centre = 0.5 * (view.min + view.max);
const int sel = app.search.cursor();
// Candidates: the display x of every hit in view, paired with its distance from the centre.
std::vector<std::pair<double, double>> cand;
for (std::size_t i = 0; i < hits.size(); ++i) {
const double x = hits[i] - s0;
if (x < view.min || x > view.max) continue;
++in_view;
if (sel >= 0 && i == static_cast<std::size_t>(sel)) continue; // placed first, below
cand.push_back({std::fabs(x - centre), x});
}
std::sort(cand.begin(), cand.end());
const std::size_t cap = static_cast<std::size_t>(RowSet::kSearchMarks);
xs.reserve(cap);
if (sel >= 0 && sel < static_cast<int>(hits.size())) {
current = 0;
xs.push_back(hits[static_cast<std::size_t>(sel)] - s0);
}
for (std::size_t i = 0; i < cand.size() && xs.size() < cap; ++i) xs.push_back(cand[i].second);
}
if (app.search_marks_label != nullptr) {
char text[128];
if (hits.empty()) {
std::snprintf(text, sizeof(text), " ");
} else if (in_view > xs.size()) {
// A truncated display says so: "16 of 38" with no qualifier reads as "there are 38 and you
// are seeing 16 of them *everywhere*", which is not the claim.
std::snprintf(text, sizeof(text), "%zu of %zu marks shown \xE2\x80\x94 zoom in to see the rest",
xs.size(), in_view);
} else {
std::snprintf(text, sizeof(text), "%zu of %zu in view", xs.size(), hits.size());
}
gtk_label_set_text(GTK_LABEL(app.search_marks_label), text);
}
app.rows->set_search_marks(xs, current);
}
/// Recomputes every measurement over the visible window and writes the results into the pane, /// Recomputes every measurement over the visible window and writes the results into the pane,
/// collecting the marks the ones asking to be shown need. /// collecting the marks the ones asking to be shown need.
/// ///
@@ -6128,6 +6188,9 @@ gboolean tick(gpointer user) {
app->rows->sync_cursors(cursor_drag_row); app->rows->sync_cursors(cursor_drag_row);
update_cursors_pane(*app); update_cursors_pane(*app);
update_measures(*app, false); update_measures(*app, false);
// Every frame, whether or not a scan is running: the marks are in stream seconds and the axis
// is newest-relative, so a live window that has rolled since the last frame needs them moved.
place_search_marks(*app);
// The first CONFIG names the signals, which is what the persisted layout is // The first CONFIG names the signals, which is what the persisted layout is
// written against: apply it once, here, and never again (a later CONFIG only // written against: apply it once, here, and never again (a later CONFIG only
@@ -7026,6 +7089,11 @@ GtkWidget* build_trigger_panel(App& app) {
gtk_box_append(GTK_BOX(snav), app.search_next_button); gtk_box_append(GTK_BOX(snav), app.search_next_button);
gtk_box_append(GTK_BOX(sbox), snav); gtk_box_append(GTK_BOX(sbox), snav);
app.search_marks_label = gtk_label_new(" ");
gtk_widget_set_halign(app.search_marks_label, GTK_ALIGN_START);
gtk_widget_add_css_class(app.search_marks_label, "dim-label");
gtk_box_append(GTK_BOX(sbox), app.search_marks_label);
gtk_expander_set_child(GTK_EXPANDER(search_exp), sbox); gtk_expander_set_child(GTK_EXPANDER(search_exp), sbox);
gtk_box_append(GTK_BOX(box), search_exp); gtk_box_append(GTK_BOX(box), search_exp);
@@ -10967,6 +11035,12 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
rows.measure_marks_placed(), app.measure_labels.size(), rows.measure_marks_placed(), app.measure_labels.size(),
app.dbg_measures_pane_w, app.dbg_measures_pane_h); app.dbg_measures_pane_w, app.dbg_measures_pane_h);
} }
// `locked` is re-reported here rather than only in the readouts line: this task added a
// whole new cursor pool, and a pool created selectable is exactly the regression that a
// readout-vs-instrument check is for.
std::printf("[udpscope] search marks: placed=%d hits=%zu locked=%d\n",
rows.search_marks_placed(), app.search.hits().size(),
rows.readouts_locked() ? 1 : 0);
std::printf("[udpscope] header: window_button_w=%d window_label_w=%d (bar_w=%d)\n", std::printf("[udpscope] header: window_button_w=%d window_label_w=%d (bar_w=%d)\n",
app.dbg_win_btn_w, app.dbg_win_label_w, app.dbg_bar_w); app.dbg_win_btn_w, app.dbg_win_label_w, app.dbg_bar_w);
std::printf("[udpscope] layout: rows=%d window_s=%.6g", rows.row_count(), std::printf("[udpscope] layout: rows=%d window_s=%.6g", rows.row_count(),