diff --git a/src/plot_host.cpp b/src/plot_host.cpp index 37c2142..a604234 100644 --- a/src/plot_host.cpp +++ b/src/plot_host.cpp @@ -31,6 +31,14 @@ constexpr plot::u32 kRulerBColor = 0x8FA8C8u; /// mistaken for something the user placed. constexpr plot::u32 kMeasureColor = 0x9BE8A0u; 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 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, 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(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)); // 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. @@ -544,6 +561,32 @@ void RowSet::set_measure_marks(const std::vector& marks) { } } +void RowSet::set_search_marks(const std::vector& xs, int current) { + const int n = static_cast(xs.size()) < kSearchMarks ? static_cast(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(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(m)], kParked); + } + } +} + bool RowSet::update_time_labels() { Row& first = row(0); if (!first.plot || !figure_) return false; @@ -697,6 +740,9 @@ bool RowSet::readouts_locked() const noexcept { for (const plot::u32 id : r.measure_v) { 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; } diff --git a/src/plot_host.hpp b/src/plot_host.hpp index 03cd247..b69f1ab 100644 --- a/src/plot_host.hpp +++ b/src/plot_host.hpp @@ -53,6 +53,10 @@ class RowSet { /// measurements to be shown at once, and a fixed number because a `Plot` fixes its cursor /// set at construction. 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(); @@ -112,6 +116,10 @@ class RowSet { std::array measure_h{}; std::array 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 search_v{}; + /// 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 /// 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. 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& 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 /// 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 @@ -479,6 +498,7 @@ class RowSet { /// back, one row's stale echo at a time. 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. double cursor_a_x_ = 0.0; double cursor_b_x_ = 0.0; diff --git a/src/ui.cpp b/src/ui.cpp index f9a0043..27d3ae3 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -32,6 +32,7 @@ #include #include +#include #include #include #include @@ -165,6 +166,9 @@ struct App { GtkWidget* search_cancel_button = nullptr; GtkWidget* search_status_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_next_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); } +/// 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 xs; + int current = -1; + std::size_t in_view = 0; + const std::vector& 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> 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(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(RowSet::kSearchMarks); + xs.reserve(cap); + if (sel >= 0 && sel < static_cast(hits.size())) { + current = 0; + xs.push_back(hits[static_cast(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, /// 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); update_cursors_pane(*app); 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 // 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(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_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(), 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", 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(),