// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari // The GTK4 front end: the toolbar, the trigger and signal panels, and the one plot // area the `RowSet`'s figure is drawn in. // // Every widget here is a thin driver over the model: nothing in this file owns // sample data. The one thing it does own is the `plot::gtk4::PlotArea`, which is in // figure mode -- the rows are the figure's rows, so a row whose trace set changed // (`RowSet::row_dirty()`) costs a new figure and not a new widget: the area keeps its // GL context and is handed the rebuilt figure. #include "ui.hpp" #include "ui_app.hpp" #include #include #include "acquisition.hpp" #include "build_info.hpp" #include "capture.hpp" #include "config.hpp" #include "event_store.hpp" #include "waveform.hpp" #include "export.hpp" #include "help_window.hpp" #include "layout_store.hpp" #include "shortcuts.hpp" #include "status_text.hpp" #include "measure.hpp" #include "model.hpp" #include "plot_host.hpp" #include "search.hpp" #include "source_dialog.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace udpscope { namespace ui_impl { /// Which group layout is on screen, if any. // --- small helpers ---------------------------------------------------------- /// Same, without expanding the child. Header-bar children expand through the /// title's centre box (a start-packed expanding widget is measured into it), so /// the window control there stretched all the way to the title; panels want the /// fill, the bar does not. // --- persisted layout ------------------------------------------------------- /// Defined with the events pane; the trigger toggle reveals it. // Defined below; the configurations dialog opens a session with them, and it sits above all three. // `set_notice`'s default is on its definition, so the third argument is passed explicitly here. /// The configurations of the current source, as a modal window: the list, and a form that saves /// the session under a new name with a note. Opening one applies its layout to the running scope /// and remembers it as active; the settings that can only take effect at startup (the buffer, the /// clock, the capture ring) are reported rather than pretended. void open_configurations(App& app) { if (app.source_port == 0) return; if (app.config_dialog != nullptr) { gtk_window_present(GTK_WINDOW(app.config_dialog)); return; } GtkWidget* window = dialog_window(GTK_WIDGET(app.window), "Configurations"); GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window)); GtkWidget* title = gtk_label_new(nullptr); gtk_label_set_markup(GTK_LABEL(title), ("" + app.source_host + ":" + std::to_string(app.source_port) + "") .c_str()); gtk_widget_set_halign(title, GTK_ALIGN_START); gtk_box_append(GTK_BOX(box), title); auto* d = new ConfigDialog; d->app = &app; d->window = window; d->list = gtk_list_box_new(); gtk_list_box_set_selection_mode(GTK_LIST_BOX(d->list), GTK_SELECTION_NONE); g_signal_connect(d->list, "row-activated", G_CALLBACK(on_config_row_activated), d); GtkWidget* scroll = gtk_scrolled_window_new(); gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), d->list); gtk_widget_set_size_request(scroll, 460, 220); gtk_box_append(GTK_BOX(box), scroll); d->name = gtk_entry_new(); gtk_entry_set_placeholder_text(GTK_ENTRY(d->name), "new configuration name"); gtk_box_append(GTK_BOX(box), labeled_box("name", d->name)); d->comment = gtk_entry_new(); gtk_entry_set_placeholder_text(GTK_ENTRY(d->comment), "what it is for"); gtk_box_append(GTK_BOX(box), labeled_box("note", d->comment)); GtkWidget* save = gtk_button_new_with_label("Save this session"); gtk_widget_set_tooltip_text(save, "write the current arrangement under this name, and make it the " "source's active configuration"); g_signal_connect(save, "clicked", G_CALLBACK(on_config_save_as), d); GtkWidget* close = gtk_button_new_with_label("Close"); g_signal_connect_swapped(close, "clicked", G_CALLBACK(gtk_window_close), window); dialog_add_button(window, close); dialog_add_button(window, save); g_object_set_data_full(G_OBJECT(window), "udpscope-config-dialog", d, [](gpointer p) { delete static_cast(p); }); // Cleared on close: the pointer is what makes a second open a present rather than a second // window, and a destroyed window must not leave it dangling. g_signal_connect_swapped(window, "close-request", G_CALLBACK(+[](gpointer user) { static_cast(user)->config_dialog = nullptr; }), &app); app.config_dialog = window; config_dialog_refresh(*d); gtk_window_present(GTK_WINDOW(window)); } // Defined below; `apply_layout()` uses them, and it is above both. GtkWidget* labeled_box_fixed(const char* label, GtkWidget* child) { GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget* l = gtk_label_new(label); gtk_widget_set_halign(l, GTK_ALIGN_START); gtk_box_append(GTK_BOX(box), l); gtk_box_append(GTK_BOX(box), child); return box; } /// A spin button that *starts* where the caller says. `gtk_spin_button_new_with_range()` leaves a /// fresh button at the range's **minimum**, which is how an offset defaulted to -1e12 and a trigger /// level to -1e12: a value nobody chose, read back by the dialog's own reader and applied. The /// starting value is a parameter rather than a default so no call site can forget it. GtkWidget* spin(double min, double max, double step, int digits, double value) { GtkWidget* s = gtk_spin_button_new_with_range(min, max, step); gtk_spin_button_set_digits(GTK_SPIN_BUTTON(s), digits); gtk_spin_button_set_value(GTK_SPIN_BUTTON(s), value); return s; } /// The units the window control offers, coarsest last. A window is typed as a number in /// one of these rather than picked off a ladder, so any span the buffer can hold is /// reachable -- the ladder could only ever offer the spans someone had thought of. /// Seconds is the coarsest: a scope's window is a span of the stream, and "minutes" is a unit for a /// recording, not for a view. A window longer than a minute is still typeable -- the entry takes the /// number and the cap is the buffer's own (`max_window_s`), not this list's last entry. constexpr WindowUnit kWindowUnits[] = { {"\u00b5s", 1.0e-6}, {"ms", 1.0e-3}, {"s", 1.0}}; /// The narrowest window the control will accept. Below a microsecond the axis labels stop /// being distinguishable and no datasource this talks to streams that fast. /// The window button's label is pinned to this many characters and ellipsized, exactly like /// the status label: it sits in the header bar directly above the plot, so a label that grew /// with its text ("1 s" -> "123.456 ms") would widen the bar and relayout everything under /// it on a mere change of window. /// Which unit reads most naturally for `s`: the coarsest whose value is still at least 1, /// so 0.05 s is "50 ms" and not "0.05 s" or "50000 us". int window_unit_for(double s) { int unit = 0; for (int i = kWindowUnitCount - 1; i >= 0; --i) { if (s >= kWindowUnits[i].seconds * (1.0 - 1.0e-9)) { unit = i; break; } } return unit; } /// The number part alone, in `unit`. Trailing zeros are trimmed so a decade stepper walking /// 1 -> 10 -> 100 does not print "1.000"; three decimals is enough to survive a fit, whose /// span is whatever the ring happened to hold. std::string format_window_value(double s, int unit) { char buf[32]; std::snprintf(buf, sizeof(buf), "%.3f", s / kWindowUnits[unit].seconds); std::string out = buf; const std::size_t dot = out.find('.'); if (dot != std::string::npos) { std::size_t last = out.size(); while (last > dot + 1 && out[last - 1] == '0') --last; if (last == dot + 1) last = dot; out.resize(last); } return out; } std::string format_window(double s) { const int unit = window_unit_for(s); return format_window_value(s, unit) + " " + kWindowUnits[unit].name; } /// A measurement: at most two decimals, scaled by an SI prefix so the number reads in /// `1..999` (0.01052 V is `10.52 mV`), and the unit appended when the stream declares one. /// /// Two decimals is the cursors' resolution claim: the markers land on a sample and the /// sample is an `f32` of a value the CONFIG may scale arbitrarily, so more digits would be /// noise dressed as precision. Trailing zeros go, so `1.50 V` reads `1.5 V` -- "at most /// two decimals", not exactly two. /// /// The prefix is applied **whether or not a unit is declared**, and that is the point: two /// decimals of an unprefixed small value is `0.00`, i.e. the reading is simply gone. A signal /// whose CONFIG declares no unit is ordinary (and a *math* channel never has one), so the pane /// showed `0.00` for every value under 5 mV of an entire class of channels. A bare prefix is /// written tight against the number -- `1.34m`, not `1.34 m` -- because the space is there to /// separate the number from its unit, and with no unit to separate, a spaced `m` reads as metres. std::string format_measure(double v, const std::string& unit) { if (!std::isfinite(v)) return "--"; static const struct { double scale; const char* prefix; } kPrefixes[] = { {1.0e12, "T"}, {1.0e9, "G"}, {1.0e6, "M"}, {1.0e3, "k"}, {1.0, ""}, {1.0e-3, "m"}, {1.0e-6, "\u00b5"}, {1.0e-9, "n"}, {1.0e-12, "p"}, }; const double mag = std::fabs(v); const char* prefix = ""; double scale = 1.0; for (const auto& p : kPrefixes) { if (mag >= p.scale) { prefix = p.prefix; scale = p.scale; break; } } char buf[64]; if (mag > 0.0 && mag < 1.0e-12) { // Below the ladder, where scaling cannot help and `%.2f` would print `0.00` again. Three // significant digits in whatever form fits, so the reading survives as a reading. std::snprintf(buf, sizeof(buf), "%.3g", v); std::string out = buf; if (!unit.empty()) out += " " + unit; return out; } std::snprintf(buf, sizeof(buf), "%.2f", v / scale); std::string num = buf; if (num.find('.') != std::string::npos) { // at most two decimals, not exactly two while (num.size() > 1 && num.back() == '0') num.pop_back(); if (num.back() == '.') num.pop_back(); } if (num == "-0") num = "0"; std::string out = num; if (!unit.empty()) out += " "; // the space separates the number from its unit, nothing else out += prefix; out += unit; return out; } // --- plot widgets ----------------------------------------------------------- /// The row index a widget belongs to -- a popover entry's, not a plot's: the menu is /// built for one row and its buttons have to remember which. Stored biased by one, /// because `g_object_set_data(obj, key, NULL)` *removes* the key and row 0 would then /// read back as "no data". void set_row_key(GtkWidget* widget, int row) { g_object_set_data(G_OBJECT(widget), "udpscope-row", GINT_TO_POINTER(row + kRowKeyBias)); } int row_key(GtkWidget* widget) { return GPOINTER_TO_INT(g_object_get_data(G_OBJECT(widget), "udpscope-row")) - kRowKeyBias; } /// The row a pointer event at logical `(x, y)` in the area is over. One widget covers /// every row now, so the row comes from the figure's layout rather than from a key on /// the widget the event arrived on. int row_at(const App& app, double x, double y) { if (app.area == nullptr) return 0; const int row = static_cast(app.area->row_at(x, y)); return std::clamp(row, 0, app.rows->row_count() - 1); } /// Cancels the pan drag the adapter's controller opened on the press a GTK gesture is /// about to swallow -- the right-click menu claims the sequence, and a drop's release /// goes to the DnD machinery, so in both cases the controller never sees the release /// that would end the drag and the plot would then follow the pointer with no button /// held. Escape is the controller's own cancel (it also restores the ranges it /// captured at the press), and it has to go to *that row's* controller. void cancel_drag_on(App& app, int row) { if (app.area == nullptr) return; if (plot::input::Controller* c = app.area->row_controller(static_cast(row))) { c->on_key(plot::input::KeyEvent{plot::input::kKeyEscape, 0, true}); } } /// Every widget under `root` whose *minimum* width exceeds `threshold`, as a type name and a /// depth. A minimum width is what GTK must honour, so the chain printed here is the chain that /// forced the toplevel wider than the screen -- and the deepest line in it is the widget whose /// own content did it, which no summary of the plot's allocation can name. void report_wide_widgets(GtkWidget* root, int threshold, int depth) { if (root == nullptr || !GTK_IS_WIDGET(root)) return; int min = 0; int nat = 0; gtk_widget_measure(root, GTK_ORIENTATION_HORIZONTAL, -1, &min, &nat, nullptr, nullptr); if (min <= threshold) return; std::fprintf(stderr, "[width-probe] %*s%s min=%d nat=%d\n", depth * 2, "", G_OBJECT_TYPE_NAME(root), min, nat); for (GtkWidget* c = gtk_widget_get_first_child(root); c != nullptr; c = gtk_widget_get_next_sibling(c)) { report_wide_widgets(c, threshold, depth + 1); } } const char* display_mode_name(DisplayMode m) { switch (m) { case DisplayMode::Live: return "live"; case DisplayMode::Capturing: return "capturing"; case DisplayMode::Held: return "held"; case DisplayMode::Waiting: return "waiting"; case DisplayMode::Paused: return "paused"; } return "?"; } /// What a painted frame's first bound trace actually had to *draw*, recomputed through the /// same three calls `Renderer::draw_traces` makes: the index window from `neighbours()`, the /// LOD level from the 64-samples-per-pixel gate, then `gather()`. It then **counts** the /// renderer's increasing-suffix filter rather than applying it, and measures the kept /// polyline's extent against the axis in pixels. /// /// This exists because every other counter here measures *geometry*, and the reported defect /// is missing *ink*: `staged_segments` includes the grid and the instruments, so a frame that /// drew all the chrome and not one sample still reports tens of thousands, and `lod_traces` /// counts a trace whose gathered points the filter then collapses onto a single x as drawn. /// Three distinguishable outcomes, each a different bug: `collapse` (under two distinct /// positions left -- an invisible trace on a frame the renderer thinks it drew), `staircase` /// (most points dropped, i.e. the stamps came back in runs of equal f32) and `short` (the ink /// reaches a fraction of the axis -- geometry that is there but off to one side). void check_frame_ink(App& app) { if (app.rows == nullptr || app.rows->row_count() == 0) return; const RowSet::Row& r = app.rows->row(0); if (r.plot == nullptr) return; const plot::data::Series* s = nullptr; for (int i = 0; i < RowSet::kSlotsPerRow; ++i) { const std::size_t si = static_cast(i); if (r.channel[si] < 0) continue; s = &r.slots[si]; break; } if (s == nullptr || s->size() == 0) return; const plot::Range axis = r.plot->x_range(r.axis_x); const double axes_w = double(r.plot->layout().axes.w); if (!(axes_w > 0.0) || !(axis.span() > 0.0)) return; const plot::u64 n = s->size(); plot::u64 i0 = 0; plot::u64 i1 = 0; plot::u64 first = 0; plot::u64 last = n; if (s->neighbours(axis.min, i0, i1)) first = i0 > 0 ? i0 - 1 : 0; if (s->neighbours(axis.max, i0, i1)) last = std::min(n, i1 + 1); if (last <= first) return; const double spp = double(last - first) / axes_w; const plot::u32 level = spp >= 64.0 ? s->pyramid().level_for(n, spp) : 0u; std::vector& xy = app.measure_ink_scratch; xy.clear(); // The same origin the renderer names (`x_range.min`), so the reconstruction below is its // reconstruction: see `plot::data::Series::gather()` for why the caller owns it. s->gather(level, first, last, axis.min, xy); const std::size_t pts = xy.size() / 2; std::size_t kept = 0; double lo = 0.0; double hi = 0.0; // Where the filter's drops fall, and how far back each one steps. The origin is a constant // for the walk's duration *by construction* now -- the renderer reconstructs // `gather[i] + x_range.min`, a local of its own, where it used to re-read `series.x0()` on // every point and a ring-backed `x0()` advances with the producer. So a drop here is a // genuinely out-of-order gather, and `back` says by how much. std::size_t drop_first = 0; std::size_t drop_last = 0; double worst_back = 0.0; bool any_drop = false; { double next = std::numeric_limits::infinity(); for (std::size_t k = pts; k > 0; --k) { const double x = double(xy[2 * (k - 1)]) + axis.min; if (!(x < next)) { worst_back = std::max(worst_back, x - next); if (!any_drop) drop_last = k - 1; any_drop = true; drop_first = k - 1; continue; } next = x; if (kept == 0) hi = x; lo = x; ++kept; } } ++app.dbg_ink_checked; // Drawn against quoted, in pixels. At level 0 a gathered pair *is* sample `first + k`, so // this is the one place the two numbers the user compares by eye -- the polyline's position // and the crosshair's readout -- can be differenced. The origin is what decides it: the f32 // resolves 2^-24 of whatever span it is relative to, so a view narrower than that fraction // of the *retained window* drew a sample visibly away from the x quoted for it, which is // what "when zooming there is a visible offset between the real data point and the crosshair // selected one" was. if (level == 0 && pts > 0) { const double px_per_x = axes_w / axis.span(); double worst = 0.0; for (std::size_t k = 0; k < pts; ++k) { const double drawn = axis.min + double(xy[2 * k]); worst = std::max(worst, std::abs(drawn - s->x_at(first + k)) * px_per_x); } app.dbg_ink_worst_offset_px = std::max(app.dbg_ink_worst_offset_px, worst); ++app.dbg_ink_raw_checked; } const double cover = kept >= 2 ? (hi - lo) / axis.span() : 0.0; app.dbg_ink_worst_cover = std::min(app.dbg_ink_worst_cover, cover); const bool collapse = kept < 2; const bool staircase = pts >= 8 && kept * 4 < pts; // A ring that has not yet reached back to the axis' left edge is *meant* to leave its left // side empty (see the `clamp_to_data()` invariant), so a short ink there is the buffer // filling and not a defect: it is only reported once the data spans the window. const bool short_ink = !collapse && cover < 0.25 && s->x_bounds().min <= axis.min; if (collapse) ++app.dbg_ink_collapse; if (staircase) ++app.dbg_ink_staircase; if (short_ink) ++app.dbg_ink_short; if ((collapse || staircase || short_ink) && app.dbg_ink_reports < 40) { ++app.dbg_ink_reports; // The series' own answers beside the reconstruction, because the two disagreeing is itself // a reading: `rel` is what `gather()` emitted (relative to `axis.min`, the origin it was // given) while `x0`/`x_at`/`bounds` are the window the series says it holds, so a trace // whose ink sits a whole ring-span off its axis separates into "the stamps moved under us" // and "the window is the wrong one". const plot::Range xb = s->x_bounds(); std::fprintf(stderr, "[ink] paint %d %s%s%s mode=%s axis=[%.6f..%.6f] n=%llu idx=[%llu..%llu) " "spp=%.1f level=%u pts=%zu kept=%zu ink=[%.6f..%.6f] cover=%.1f%% " "x0=%.6f x_at=[%.6f..%.6f] bounds=[%.6f..%.6f] rel=[%.6f..%.6f] dx=%.3g " "drops=[%zu..%zu] back=%.3g\n", app.dbg_paint_n, collapse ? "collapse " : "", staircase ? "staircase " : "", short_ink ? "short " : "", display_mode_name(app.dbg_display), axis.min, axis.max, (unsigned long long)n, (unsigned long long)first, (unsigned long long)last, spp, level, pts, kept, lo, hi, cover * 100.0, s->x0(), s->x_at(0), s->x_at(n - 1), xb.min, xb.max, pts > 0 ? double(xy[0]) : 0.0, pts > 0 ? double(xy[2 * (pts - 1)]) : 0.0, s->dx(), drop_first, drop_last, worst_back); std::fflush(stderr); } } /// Frame-cost probe, end of the plot's own draw. Connected *after* the adapter's /// `::render`, which is the whole reason `PlotArea::on_render` returns FALSE: the signal /// accumulates with `g_signal_accumulator_true_handled`, so a TRUE there would stop the /// emission and this would never run. Reading the stats here rather than in `tick()` is /// what makes them this frame's: `begin_frame()` resets them. void on_before_paint(GdkFrameClock*, gpointer user) { static_cast(user)->dbg_paint_start_us = g_get_monotonic_time(); } void on_after_paint(GdkFrameClock*, gpointer user) { App* app = static_cast(user); if (app->dbg_paint_start_us == 0) return; const double ms = double(g_get_monotonic_time() - app->dbg_paint_start_us) / 1000.0; app->dbg_paint_ms_sum += ms; app->dbg_paint_ms_max = std::max(app->dbg_paint_ms_max, ms); ++app->dbg_paint_n; } GtkWidget* make_area(App& app) { plot::gtk4::PlotArea* area = plot::gtk4::PlotArea::create(app.placeholder_plot); app.area = area; area->set_figure(&app.rows->figure()); GtkWidget* widget = area->widget(); app.area_widget = widget; gtk_widget_set_hexpand(widget, true); gtk_widget_set_vexpand(widget, true); gtk_widget_set_size_request(widget, 320, 120); g_signal_connect(widget, "render", G_CALLBACK(on_render_probe), &app); GtkDropTarget* target = gtk_drop_target_new(G_TYPE_STRING, GDK_ACTION_COPY); g_signal_connect(target, "drop", G_CALLBACK(on_drop_channel), &app); gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(target)); GtkGesture* right = gtk_gesture_click_new(); gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(right), 3); g_signal_connect(right, "pressed", G_CALLBACK(on_right_click), &app); gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(right)); // The overlay exists for the layout editor's buttons and is otherwise empty, so it // costs one pass-through widget: with no overlay children, picking and allocation // reach the GL area exactly as before. app.area_overlay = gtk_overlay_new(); gtk_overlay_set_child(GTK_OVERLAY(app.area_overlay), widget); gtk_widget_set_hexpand(app.area_overlay, true); gtk_widget_set_vexpand(app.area_overlay, true); return app.area_overlay; } /// Brings the plot area in line with the rows: a changed trace set (or row count) /// rebuilds the figure, and the area is pointed at the new one. Deferred to the frame /// tick -- a drop arrives from inside a gesture attached to this widget, and the /// rebuild resets that gesture's controller. void relayout_areas(App& app) { if (!app.rows->rebuild_if_dirty()) return; if (app.area != nullptr) app.area->set_figure(&app.rows->figure()); } // --- panel rebuilds --------------------------------------------------------- /// Defined below, forward-declared because placing or freeing a trace slot has to /// rebuild the signal list: each row's colour chip names the trace that signal is /// drawn as, so it goes stale the moment the placement changes. // --- the layout editor ------------------------------------------------------ // // Rows are added and removed in place rather than picked off a list of layouts: with a // count you can only say "I want three", and which of the three your traces end up on is // whatever the renumbering decides. The editor's buttons sit *on* the rows, so the row // the action names is the row you are pointing at. // // Geometry comes from `FigureLayout::rows` -- the same rects the frame was drawn from -- // divided by `PlotArea::device_scale()`, because a `Rect` is framebuffer pixels and a GTK // margin is logical ones. // // One `+` per *boundary*, not two per row: a row's bottom edge and the next row's top // edge are the same line (the rows tile the figure), so "add below row 1" and "add above // row 2" are the same insertion and two buttons there would be one on top of the other. // There are `rows + 1` of them -- above the first row and below the last included -- so // every position the two-per-row reading offers is still reachable. /// `g_object_set_data` key telling a remove button from an insert one. The row/boundary /// index is the usual `set_row_key()`. /// Takes the editor's buttons off the overlay. `gtk_overlay_remove_overlay()` drops the /// overlay's reference, which is the only one, so the widgets go with it. /// /// This runs from a *button's own* `::clicked` handler (the click rebuilds the set), which /// is safe because the emission holds its own reference on the instance: the button is /// finalized after the handler returns, not under it. void clear_layout_editor(App& app) { if (app.area_overlay != nullptr) { for (GtkWidget* b : app.layout_buttons) { gtk_overlay_remove_overlay(GTK_OVERLAY(app.area_overlay), b); } if (app.layout_done_button != nullptr) { gtk_overlay_remove_overlay(GTK_OVERLAY(app.area_overlay), app.layout_done_button); } } app.layout_buttons.clear(); app.layout_done_button = nullptr; app.layout_buttons_rows = 0; } /// Restates where the buttons sit, from the rects the last frame was laid out with. /// Called every frame while the editor is up: `gtk_widget_set_margin_*()` returns early /// on an unchanged value, so a settled layout costs the comparisons and nothing else. void sync_layout_editor(App& app) { if (app.layout_buttons.empty() || app.area == nullptr) return; const plot::FigureLayout& fl = app.rows->figure().layout(); const int rows = app.rows->row_count(); // Before the first frame the figure has no rects yet; placing against them would put // every button in the top-left corner and then move it, which is a visible jump. if (static_cast(fl.rows.size()) != rows) return; const double scale = app.area->device_scale() > 0.0f ? app.area->device_scale() : 1.0; const double height = static_cast(gtk_widget_get_height(app.area_widget)); if (!(height > 0.0)) return; const double half = kLayoutButtonPx * 0.5; for (GtkWidget* b : app.layout_buttons) { const int idx = row_key(b); const bool insert = g_object_get_data(G_OBJECT(b), kLayoutKindKey) != nullptr; const bool resize = g_object_get_data(G_OBJECT(b), kLayoutResizeKey) != nullptr; if (resize) { // *On* the boundary, beside the `+` -- the same edge, one handle's width to its right. Below it sat // inside the lower row, which read as a handle for that row: a boundary handle that looks like it // belongs to the plot under it is a handle whose drag changes the wrong plot, whatever it does. const double edge = idx < rows ? static_cast(fl.rows[static_cast(idx)].y) / scale : (static_cast(fl.rows.back().y) + static_cast(fl.rows.back().h)) / scale; const double top = std::clamp(edge - half, 0.0, height - kLayoutButtonPx); gtk_widget_set_margin_top(b, static_cast(top + 0.5)); const double width = static_cast(gtk_widget_get_width(app.area_widget)); const double left = width * 0.5 + kLayoutButtonPx * 0.5 + 6.0; gtk_widget_set_margin_start( b, static_cast(std::clamp(left, 0.0, std::max(0.0, width - kLayoutButtonPx)) + 0.5)); } else if (insert) { // Centred on the boundary: the top edge of row `idx`, or the bottom edge of the // last row for the one past the end. const double edge = idx < rows ? static_cast(fl.rows[static_cast(idx)].y) / scale : (static_cast(fl.rows.back().y) + static_cast(fl.rows.back().h)) / scale; const double top = std::clamp(edge - half, 0.0, height - kLayoutButtonPx); gtk_widget_set_margin_top(b, static_cast(top + 0.5)); } else { const plot::Rect& r = fl.rows[static_cast(idx)]; const double top = std::clamp(static_cast(r.y) / scale + kLayoutButtonPad, 0.0, height - kLayoutButtonPx); gtk_widget_set_margin_top(b, static_cast(top + 0.5)); // Inside the axes, not over the y labels: the gutter is the one piece of row chrome // whose width is reserved rather than measured per frame, so this does not move. const plot::Plot* p = app.rows->row(idx).plot; const double left = (p != nullptr ? static_cast(p->layout().axes.x) / scale : static_cast(r.x) / scale) + kLayoutButtonPad; gtk_widget_set_margin_start(b, static_cast(left + 0.5)); } } } /// A row may not be dragged below this, in logical pixels: a row of no height is a row a user cannot /// grab again, and the plotter keeps a floor of its own -- this is the one the *drag* respects. constexpr double kMinRowPx = 48.0; void on_layout_drag_begin(GtkGestureDrag* gesture, double, double, gpointer user) { App& app = *static_cast(user); app.layout_drag_row = static_cast(GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(gesture), "udpscope-layout-drag"))); // A drag is *relative*: the weights it started from are kept, because reading them back while dragging // would compound -- each update would build on the last frame's instead of on the grab. app.layout_drag_start = app.rows->row_weights(); app.layout_drag_start.resize(static_cast(app.rows->row_count()), 1.0f); } void on_layout_drag_update(GtkGestureDrag*, double, double offset_y, gpointer user) { App& app = *static_cast(user); const int at = app.layout_drag_row; // the boundary between rows `at - 1` and `at` const int rows = app.rows->row_count(); if (at <= 0 || at >= rows) return; // the figure's own edges are not boundaries if (app.layout_drag_start.size() != static_cast(rows)) return; const plot::FigureLayout& fl = app.rows->figure().layout(); if (static_cast(fl.rows.size()) != rows) return; const double scale = app.area != nullptr && app.area->device_scale() > 0.0f ? app.area->device_scale() : 1.0; const double up = static_cast(fl.rows[static_cast(at - 1)].h) / scale; const double lo = static_cast(fl.rows[static_cast(at)].h) / scale; const double pair = up + lo; if (!(pair > kMinRowPx * 2.0)) return; // Where the boundary is asked to go, floored on both sides: the *pixels* say where, and the pair's // weights are the shares that put it there. const double want_up = std::clamp(up + offset_y / scale, kMinRowPx, pair - kMinRowPx); std::vector weights = app.layout_drag_start; const float sum = weights[static_cast(at - 1)] + weights[static_cast(at)]; weights[static_cast(at - 1)] = sum * static_cast(want_up / pair); weights[static_cast(at)] = sum - weights[static_cast(at - 1)]; app.rows->set_row_weights(std::move(weights)); } void on_layout_drag_end(GtkGestureDrag*, double, double, gpointer user) { App& app = *static_cast(user); app.layout_drag_row = -1; app.layout_drag_start.clear(); // A dragged sizing is an arrangement, so it is saved like one. schedule_layout_save(app); } /// Builds one `×` per row and one `+` per boundary, or takes them all off again when the /// editor is switched off. /// One of the layout editor's overlay buttons: the look they all share is *here*, once. It was written out /// three times -- the icon, the two CSS classes that make a small round on-screen-display control, and the /// size -- and the fourth copy would have been a button added next to them. /// /// These buttons cannot be a `.ui` file, and the reason is worth stating because the plan expected them to /// be: their *number* is the row count, one per row and one more than that for the boundaries, and a /// `GtkBuilder` instantiates a file's objects once. This function reappears whenever a `.ui` is asked to hold /// something whose count is data. /// /// What each button does with the look is the caller's: they differ in alignment, sensitivity, the data they /// carry and the gesture they take. GtkWidget* layout_overlay_button(App& app, const char* icon, const char* tooltip) { GtkWidget* b = gtk_button_new_from_icon_name(icon); gtk_widget_set_tooltip_text(b, tooltip); gtk_widget_add_css_class(b, "circular"); gtk_widget_add_css_class(b, "osd"); gtk_widget_set_size_request(b, kLayoutButtonPx, kLayoutButtonPx); gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), b); app.layout_buttons.push_back(b); return b; } void rebuild_layout_editor(App& app) { clear_layout_editor(app); if (!app.layout_editing || app.area_overlay == nullptr) return; const int rows = app.rows->row_count(); app.layout_buttons_rows = rows; for (int i = 0; i < rows; ++i) { GtkWidget* b = layout_overlay_button(app, "window-close-symbolic", "remove this row"); gtk_widget_set_halign(b, GTK_ALIGN_START); gtk_widget_set_valign(b, GTK_ALIGN_START); gtk_widget_set_sensitive(b, rows > 1); set_row_key(b, i); g_signal_connect(b, "clicked", G_CALLBACK(on_layout_row_remove), &app); } for (int b_i = 0; b_i <= rows; ++b_i) { GtkWidget* b = layout_overlay_button(app, "list-add-symbolic", b_i == 0 ? "add a row above" : b_i == rows ? "add a row below" : "add a row here"); gtk_widget_set_halign(b, GTK_ALIGN_CENTER); gtk_widget_set_valign(b, GTK_ALIGN_START); gtk_widget_set_sensitive(b, rows < RowSet::kMaxRows); set_row_key(b, b_i); g_object_set_data(G_OBJECT(b), kLayoutKindKey, b); g_signal_connect(b, "clicked", G_CALLBACK(on_layout_row_insert), &app); } // ... and one arrow per boundary whose *only* job is resizing the two rows it sits between. A widget // of its own rather than a second gesture on the `+`, so each handle does one thing -- and a handle // that cannot be clicked cannot be clicked by accident. The figure's own edges are not boundaries // (there is no pair of rows to share between them), so the first and last are inert. for (int b_i = 0; b_i <= rows; ++b_i) { GtkWidget* b = layout_overlay_button(app, "udpscope-resize-rows-symbolic", b_i == 0 ? "drag to resize the first row" : b_i == rows ? "drag to resize the last row" : "drag to resize the two rows here"); gtk_widget_set_halign(b, GTK_ALIGN_CENTER); gtk_widget_set_valign(b, GTK_ALIGN_START); // Hidden, not disabled, where it has no pair to share between: the figure's own edges. A greyed // handle is a question ("why can I not drag this?"), and the answer is that there is nothing there. gtk_widget_set_visible(b, b_i > 0 && b_i < rows); gtk_widget_set_halign(b, GTK_ALIGN_START); // The *row key* is what `sync_layout_editor()` places by -- without it every arrow was placed at // index 0, the figure's top edge, whatever boundary it belonged to. set_row_key(b, b_i); g_object_set_data(G_OBJECT(b), kLayoutResizeKey, GSIZE_TO_POINTER(b_i)); GtkGesture* drag = gtk_gesture_drag_new(); gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(drag), 1); gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(drag), GTK_PHASE_CAPTURE); g_object_set_data(G_OBJECT(drag), "udpscope-layout-drag", GSIZE_TO_POINTER(b_i)); g_signal_connect(drag, "drag-begin", G_CALLBACK(on_layout_drag_begin), &app); g_signal_connect(drag, "drag-update", G_CALLBACK(on_layout_drag_update), &app); g_signal_connect(drag, "drag-end", G_CALLBACK(on_layout_drag_end), &app); gtk_widget_add_controller(b, GTK_EVENT_CONTROLLER(drag)); } // The editor's way out, in the area's top-right corner: the toolbar's toggle is in the // overflow menu, which is a long way from the plot you are editing. Aligned by margins // like every other overlay child, so it never touches the figure's layout. GtkWidget* done = gtk_button_new_with_label("Done"); gtk_widget_set_tooltip_text(done, "leave the layout editor"); gtk_widget_add_css_class(done, "osd"); gtk_widget_set_size_request(done, -1, kLayoutButtonPx); gtk_widget_set_halign(done, GTK_ALIGN_END); gtk_widget_set_valign(done, GTK_ALIGN_START); gtk_widget_set_margin_top(done, kLayoutButtonPad); gtk_widget_set_margin_end(done, kLayoutButtonPad); g_signal_connect(done, "clicked", G_CALLBACK(on_layout_editor_done), &app); gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), done); app.layout_done_button = done; sync_layout_editor(app); } /// The width-comparison dropdown's order. `<` first, because that is the glitch trigger /// and the reason this condition exists. WidthCmp width_cmp_at(guint i) { switch (i) { case 0: return WidthCmp::Less; case 2: return WidthCmp::Within; case 3: return WidthCmp::Outside; default: return WidthCmp::Greater; } } void show_info_dialog(GtkWindow* parent, const SignalInfo& sig) { char body[1024]; std::snprintf(body, sizeof(body), "unit: %s\ntype: %d\nquantisation: %d\nrows x cols: %u x %u\nrange: %g .. %g\n" "sample rate: %g Hz\ntime mode: %d\ntime signal: %u", sig.unit.empty() ? "(none)" : sig.unit.c_str(), int(sig.type_code), int(sig.quant_type), sig.rows, sig.cols, sig.range_min, sig.range_max, sig.sampling_rate, int(sig.time_mode), sig.time_signal_idx); GtkWidget* window = dialog_window(GTK_WIDGET(parent), sig.name.c_str()); GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window)); GtkWidget* label = gtk_label_new(body); gtk_widget_set_halign(label, GTK_ALIGN_START); gtk_label_set_wrap(GTK_LABEL(label), true); gtk_label_set_selectable(GTK_LABEL(label), true); gtk_box_append(GTK_BOX(box), label); GtkWidget* close = gtk_button_new_with_label("Close"); g_signal_connect_swapped(close, "clicked", G_CALLBACK(gtk_window_destroy), window); dialog_add_button(window, close); gtk_window_present(GTK_WINDOW(window)); } void on_info_clicked(GtkButton* button, gpointer user) { App* app = static_cast(user); const std::size_t index = static_cast( GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(button), "udpscope-signal"))); if (index < app->model->signals().size()) { show_info_dialog(GTK_WINDOW(app->window), app->model->signals()[index]); } } void on_calibrate_apply(GtkButton*, gpointer user) { CalibContext* ctx = static_cast(user); ctx->app->model->set_calibration(ctx->signal, gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->gain)), gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->offset))); schedule_layout_save(*ctx->app); gtk_window_destroy(GTK_WINDOW(ctx->window)); } void open_calibration(App& app, GtkWindow* parent, std::size_t signal) { const SignalInfo& sig = app.model->signals()[signal]; GtkWidget* window = dialog_window(GTK_WIDGET(parent), "Calibrate"); GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window)); GtkWidget* title = gtk_label_new(sig.name.c_str()); gtk_widget_set_halign(title, GTK_ALIGN_START); gtk_box_append(GTK_BOX(box), title); // The signal's *own* calibration, not a stock identity: a dialog that opened at 1/0 while the // trace was drawn through a gain of 2 invited the user to "confirm" it back to 1 by pressing // Apply. A signal nobody calibrated reads 1 and 0, which is the identity it is drawn through -- // and the values are the model's record, so a signal that is not acquired still shows the // calibration that will apply to it when it is. const std::pair cal = app.model->calibration(signal); GtkWidget* gain = spin(-1.0e12, 1.0e12, 0.001, 6, cal.first); GtkWidget* offset = spin(-1.0e12, 1.0e12, 0.001, 6, cal.second); gtk_box_append(GTK_BOX(box), labeled_box("gain", gain)); gtk_box_append(GTK_BOX(box), labeled_box("offset", offset)); auto* ctx = new CalibContext{&app, signal, gain, offset, window}; GtkWidget* cancel = gtk_button_new_with_label("Cancel"); // `gtk_window_close()`, not `gtk_window_destroy()`: the destroy path does **not** emit // `close-request`, so the handler that clears `app.math_dialog` never ran and every later open // was a `present` on a dead widget -- a critical, and nothing on screen. Closing emits the signal, // the handler clears the pointer, and the default handler then destroys the window. g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_close), window); GtkWidget* apply = gtk_button_new_with_label("Apply"); g_object_set_data_full(G_OBJECT(apply), "udpscope-ctx", ctx, [](gpointer p) { delete static_cast(p); }); g_signal_connect(apply, "clicked", G_CALLBACK(on_calibrate_apply), ctx); dialog_add_button(window, cancel); dialog_add_button(window, apply); gtk_window_present(GTK_WINDOW(window)); } GdkContentProvider* on_drag_prepare(GtkDragSource*, double, double, gpointer user) { DragContext* ctx = static_cast(user); // The payload is the signal index; the channel is resolved on drop. char buf[64]; std::snprintf(buf, sizeof(buf), "%zu", ctx->signal); return gdk_content_provider_new_typed(G_TYPE_STRING, buf); } /// A trace colour as `#RRGGBB`: what a chip is drawn with and what the session file stores. One /// formatter for both, so a chip and the colour it claims to name cannot be written differently -- /// and so the probe can read a chip's own markup rather than recomputing what it should say. std::string color_hex(plot::Color c) { char buf[16]; std::snprintf(buf, sizeof(buf), "#%02X%02X%02X", unsigned(c.r * 255.0f + 0.5f), unsigned(c.g * 255.0f + 0.5f), unsigned(c.b * 255.0f + 0.5f)); return buf; } std::string chip_markup(plot::Color c) { return "\u25CF"; } /// What an open colour chooser is choosing for. The signal's **name**, not its index: the chooser /// is asynchronous and a CONFIG can renumber the signal list while it is open, which is the same /// reason every persisted identity in this app is a name. /// Opens the colour chooser for one signal's trace. /// /// `GtkColorDialog`, not the `GtkColorChooserDialog` deprecated in GTK 4.10: libadwaita 1.8 already /// puts this build's floor at GTK 4.18, so the modern API is simply available. Alpha is off -- /// `plot::Color` carries one but a trace is stroked opaque, so a transparency slider would offer a /// setting that does nothing. void open_signal_color(App& app, std::size_t signal) { std::string name; int palette_index = 0; { std::lock_guard lock(app.model->mutex()); const std::vector& signals = app.model->signals(); if (signal >= signals.size()) return; name = signals[signal].name; palette_index = signal_color_index(signals, signal); } if (name.empty()) return; const plot::Color current = app.rows->signal_color(name, palette_index); const GdkRGBA start{current.r, current.g, current.b, 1.0f}; GtkColorDialog* dialog = gtk_color_dialog_new(); gtk_color_dialog_set_title(dialog, ("Colour for " + name).c_str()); gtk_color_dialog_set_with_alpha(dialog, FALSE); gtk_color_dialog_choose_rgba(dialog, GTK_WINDOW(app.window), &start, nullptr, on_signal_color_chosen, new ColorPick{&app, name}); // The async call holds its own reference for the operation's life, which is the documented // idiom for every `Gtk*Dialog` in GTK 4.10's async family. g_object_unref(dialog); } /// Fills the trigger's signal dropdown with the channels that are actually being /// acquired -- a trigger can only fire on a channel that has samples. Selecting a /// channel defaults the level into the middle of its signal's declared range (a /// level of 0 on a 0..5 V transducer never fires, and "the trigger does nothing" is /// exactly what that looks like), until the user edits the level by hand. void rebuild_trigger_signals(App& app) { const std::vector& channels = app.model->channels(); app.updating_panels = true; std::vector storage; storage.reserve(channels.size()); for (const ChannelInfo& c : channels) { storage.push_back(c.unit.empty() ? c.name : c.name + " [" + c.unit + "]"); } std::vector names; names.reserve(storage.size() + 1); for (const std::string& s : storage) names.push_back(s.c_str()); if (names.empty()) { storage.emplace_back("(no acquired channels)"); names.push_back(storage.back().c_str()); } names.push_back(nullptr); GtkStringList* list = gtk_string_list_new(names.data()); gtk_drop_down_set_model(GTK_DROP_DOWN(app.trigger_signal_drop), G_LIST_MODEL(list)); g_object_unref(list); TriggerConfig& tc = app.model->trigger(); // A session stores the trigger's channel by *name*: resolved here, where the acquired channels // exist. The pending name is cleared either way, so a name this stream does not have falls through // to the default below rather than being retried on every rebuild. if (!app.pending_trigger_channel.empty()) { for (std::size_t c = 0; c < channels.size(); ++c) { if (channels[c].name == app.pending_trigger_channel) { tc.channel = static_cast(c); break; } } app.pending_trigger_channel.clear(); } if (tc.channel < 0 && !channels.empty()) tc.channel = 0; const int sel = tc.channel; gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_signal_drop), sel < 0 || sel >= static_cast(storage.size()) ? 0u : static_cast(sel)); if (sel >= 0 && sel < static_cast(channels.size()) && !app.trigger_level_touched) { const SignalInfo& sig = app.model->signals()[static_cast(channels[static_cast(sel)].signal)]; if (std::isfinite(sig.range_min) && std::isfinite(sig.range_max) && sig.range_max > sig.range_min) { const double mid = 0.5 * (sig.range_min + sig.range_max); tc.condition.level = mid; gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_level_spin), mid); } } app.updating_panels = false; } // --- toolbar handlers ------------------------------------------------------- /// Writes `seconds` into the control -- the button's label, the entry and the unit -- and /// nothing else. Read-only with respect to the plot, so every path that changes the span on /// screen for its own reasons (a fit, an event pin) can say so without looping back through /// `apply_time_window()`. void sync_window_display(App& app, double seconds) { if (!(seconds > 0.0)) return; app.updating_panels = true; app.dbg_window_text = format_window(seconds); if (app.window_label != nullptr) { gtk_label_set_text(GTK_LABEL(app.window_label), app.dbg_window_text.c_str()); } const int unit = window_unit_for(seconds); app.window_sync_unit = unit; gtk_drop_down_set_selected(GTK_DROP_DOWN(app.window_unit_drop), static_cast(unit)); gtk_editable_set_text(GTK_EDITABLE(app.window_entry), format_window_value(seconds, unit).c_str()); app.updating_panels = false; } /// Clears every measurement's statistics. Called explicitly by the pane's Reset, and /// implicitly wherever the series of results stops being one series: a measurement edit /// (`rebuild_measures_pane()`), a change of display window, and a CONFIG. void reset_measure_stats(App& app) { for (MeasureStatsAccum& s : app.measure_stats) s.reset(); } /// The one place the time scale changes. Clamps to what the buffer can hold, moves the plot and /// the trigger's event span together -- its pre-trigger share is a share of *this* window -- and /// then restates the result in the control, so a clamped request reads back as the window that /// was actually taken rather than the one that was typed. /// /// **Only the window control comes here.** The time scale is more than the axis: it is also the /// trigger's event span, the buffer partition (so a change of decimation drops the history) and /// the session, so a *plot interaction* may not state one. A zoom and a pan never touch it by /// construction -- they move the row's axis, and `clamp_to_data()` caps the span at `window_s_` -- /// and the two fits do not either: `RowSet::fit_x()` undoes a zoom by restoring the window that /// is already set. Both fits used to frame the data instead and move `window_s_` alone, which /// left the event span on the *old* window until the next decade step or unit change applied the /// fitted value for real: one action, two changes of time scale. void apply_time_window(App& app, double seconds) { if (!(seconds > 0.0)) return; const double clamped = std::min(std::max(seconds, kWindowMinS), window_limit(app)); // The buffer has to cover the window, so this may re-plan the partition and drop the // history -- a change of decimation factor cannot keep samples stored at the old rate. // It is announced rather than silent, because losing the trace is otherwise unexplained. // The frame tick rebinds every slot from the model, so the new rings are picked up // without being told -- the same path an enable or a CONFIG already takes. if (app.model->set_target_span(clamped)) { app.notice = "window " + format_window(clamped) + ": resampled to " + format_rate(app.model->partition().stored_rate()) + ", history cleared"; app.notice_until_us = g_get_monotonic_time() + 4 * G_TIME_SPAN_SECOND; } app.model->set_event_span(clamped); // The partition before the axis, so the axis is framed over the rings that will exist. app.rows->set_time_window(clamped); sync_window_display(app, clamped); // A measurement over a different window is a different figure -- a mean over 20 ms and a // mean over 2 s are not observations of one quantity -- so the history behind it goes. // The search shares the same premise: the ring was rebuilt, so its at_/end_ describe nothing. reset_measure_stats(app); app.search.clear(); schedule_layout_save(app); } /// Enter in the entry. A number that will not parse restores the current window instead of /// reporting an error: the entry is also the readout, so leaving junk in it would leave the /// control disagreeing with the plot. void on_window_entry_activate(GtkEntry* entry, gpointer user) { App* app = static_cast(user); if (app->updating_panels) return; const char* text = gtk_editable_get_text(GTK_EDITABLE(entry)); char* end = nullptr; const double value = std::strtod(text, &end); if (end == text || !(value > 0.0)) { sync_window_display(*app, app->rows->time_window()); return; } const guint unit = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->window_unit_drop)); apply_time_window(*app, value * kWindowUnits[unit < kWindowUnitCount ? unit : 2].seconds); } /// Changing the unit *re-interprets* the number already typed rather than converting it: "2" /// switched from ms to s means 2 s. Converting instead would make the unit selector a /// no-op, since the window it names would not change. void on_window_unit_changed(GObject* obj, GParamSpec*, gpointer user) { App* app = static_cast(user); if (app->updating_panels) return; // Our own sync, arriving late (see `window_sync_unit`). if (gtk_drop_down_get_selected(GTK_DROP_DOWN(obj)) == static_cast(app->window_sync_unit)) { return; } on_window_entry_activate(GTK_ENTRY(app->window_entry), user); } /// The decade steppers. A factor of ten per click, from the window actually in force rather /// than from the entry, so a half-typed number cannot be stepped. void on_window_step(GtkButton* button, gpointer user) { App* app = static_cast(user); const double factor = g_object_get_data(G_OBJECT(button), "udpscope-window-up") != nullptr ? 10.0 : 0.1; apply_time_window(*app, app->rows->time_window() * factor); } void on_pause_toggled(GtkToggleButton* button, gpointer user) { App* app = static_cast(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); // The axis is in display x and the ring is on the stream's clock, so the window has to be // put back on that clock to be cut -- `stream_at_x0()` is the one accessor that is right in // every mode, including a pause taken while an event is *held*, where the axis is // edge-relative and the old `- display_epoch()` form searched the ring with event // coordinates. The same offset is handed to the snapshot as its `x_offset`, so its stamps // come back rebased onto the x the axis is already showing and the frozen picture does not // move at the instant of the pause. const double s0 = app->model->stream_at_x0(); app->model->publish_pause_view(view.min + s0, view.max + s0, s0); app->pause_started_us = g_get_monotonic_time(); // 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 snap = app->model->pause_view()) { if (!snap->stamps.empty()) { pin = plot::Range{static_cast(snap->stamps.front()), static_cast(snap->stamps.back())}; } } app->rows->pin_pause_window(pin.min, pin.max); } } else { app->model->clear_pause_view(); app->rows->unpin_pause_window(); app->pause_started_us = 0; } 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" : "pause acquisition"); } /// Freezes the view on a hit, centred, with the hit itself at x = 0. /// /// This is the existing pause, not a fifth display mode: the toggle has to end up on, or the /// next frame's `clamp_to_data()` would follow the live edge and walk the frozen window off /// the hit entirely. void search_jump(App& app, double t) { app.search_jump_empty = false; const double w = app.rows->time_window(); if (app.pause_button != nullptr && !gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.pause_button))) { // Its handler publishes the *current* window; the publish below replaces it. gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.pause_button), TRUE); } app.model->publish_pause_view(t - w / 2.0, t + w / 2.0, /*x_offset=*/t); app.pause_started_us = g_get_monotonic_time(); // Pinned from the snapshot's *own* stamps, never from the window asked for: a cut is // clamped to what the ring holds, so a hit near the oldest sample yields a shorter // snapshot, and pinning the request would leave an empty margin that a zoom moves into. if (std::shared_ptr snap = app.model->pause_view()) { if (!snap->stamps.empty()) { app.rows->pin_pause_window(static_cast(snap->stamps.front()), static_cast(snap->stamps.back())); } else { app.search_jump_empty = true; } } else { app.search_jump_empty = true; } } void update_search_panel(App& app) { if (app.search_status_label == nullptr) return; const BufferSearch& s = app.search; const std::size_t n = s.hits().size(); char text[192]; switch (s.state()) { case BufferSearch::State::Idle: std::snprintf(text, sizeof(text), "not searched"); break; case BufferSearch::State::Running: std::snprintf(text, sizeof(text), "scanning %s of %s, %zu found", format_window(s.scanned_s()).c_str(), format_window(s.total_s()).c_str(), n); break; case BufferSearch::State::Done: std::snprintf(text, sizeof(text), "%zu found in %s", n, format_window(s.total_s()).c_str()); break; case BufferSearch::State::Truncated: std::snprintf(text, sizeof(text), "%zu found; the stream overtook the oldest samples", n); break; case BufferSearch::State::Capped: std::snprintf(text, sizeof(text), "%zu found (the limit) in the newest %s", n, format_window(s.scanned_s()).c_str()); break; case BufferSearch::State::Cancelled: std::snprintf(text, sizeof(text), "stopped after %zu", n); break; } gtk_label_set_text(GTK_LABEL(app.search_status_label), text); char pos[96]; if (s.cursor() < 0 || n == 0) { std::snprintf(pos, sizeof(pos), "--"); } else if (app.search_jump_empty) { std::snprintf(pos, sizeof(pos), "%d of %zu (no longer in the buffer)", s.cursor() + 1, n); } else { std::snprintf(pos, sizeof(pos), "%d of %zu", s.cursor() + 1, n); } gtk_label_set_text(GTK_LABEL(app.search_pos_label), pos); const bool running = s.state() == BufferSearch::State::Running; gtk_widget_set_sensitive(app.search_run_button, !running); gtk_widget_set_sensitive(app.search_cancel_button, running); gtk_widget_set_sensitive(app.search_prev_button, s.cursor() > 0); gtk_widget_set_sensitive(app.search_next_button, n > 0 && s.cursor() + 1 < static_cast(n)); } /// The visible samples of one channel as parallel time/value arrays: the same cut the export /// makes -- the held event's snapshot while one is on screen, else the live ring over the /// shared window -- at the stored rate, so a measurement describes exactly the data the plot /// is showing. Call from the GTK thread; the live branch takes the model mutex. bool visible_samples(App& app, std::size_t channel, std::vector& time, std::vector& value) { time.clear(); value.clear(); const RowSet::Row& r0 = app.rows->row(0); if (r0.plot == nullptr) return false; const plot::Range view = r0.plot->x_range(r0.axis_x); if (std::shared_ptr snap = app.model->display_snapshot()) { if (snap == nullptr || channel >= snap->values.size()) return false; const std::vector& st = snap->stamps; const std::vector& vals = snap->values[channel]; if (vals.size() != st.size()) return false; const auto lo = std::lower_bound(st.begin(), st.end(), view.min); const auto hi = std::upper_bound(st.begin(), st.end(), view.max); const std::size_t count = static_cast(hi - lo); if (count == 0) return false; const std::size_t off = static_cast(lo - st.begin()); time.resize(count); value.resize(count); for (std::size_t i = 0; i < count; ++i) { time[i] = st[off + i]; value[i] = static_cast(vals[off + i]); } return true; } std::lock_guard lock(app.model->mutex()); if (channel >= app.model->channel_count()) return false; BufferStore& store = app.model->live(); const f64 epoch = app.model->display_epoch(); u64 first = 0; u64 last = 0; app.model->live_window_locked(view.min - epoch, view.max - epoch, first, last); const u64 n = last - first; if (n == 0) return false; time.resize(static_cast(n)); value.resize(static_cast(n)); // In bounded chunks rather than through two whole-window temporaries: the value ring holds // f32 and the engine reads f64, so that 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 both scratches in cache. Two of them, because the // clock ring is f64 and the values f32 (see `BufferStore::time()`). constexpr u64 kChunk = 8192; std::vector t_scratch(kChunk); std::vector v_scratch(kChunk); for (u64 at = 0; at < n; at += kChunk) { const u64 take = std::min(kChunk, n - at); store.time().copy_from(first + at, take, t_scratch.data()); for (u64 i = 0; i < take; ++i) { time[static_cast(at + i)] = epoch + t_scratch[i]; } store.values(channel).copy_from(first + at, take, v_scratch.data()); for (u64 i = 0; i < take; ++i) { value[static_cast(at + i)] = static_cast(v_scratch[i]); } } return true; } /// One channel's visible window, readable in chunks and never copied out whole -- what the /// measurements walk instead of `visible_samples()`. /// /// The old path widened the window into two `std::vector` before anything was computed, /// which at a 1.7 M-sample window was 27 MB written and then read again per pass: 60 % of the /// pane's cost, 12 ns a sample, 20 ms a recompute. Here the *values* are handed out as `f32` /// straight from the snapshot (a pointer, no copy) or in a cache-sized scratch from the ring, and /// a **stamp** is fetched one at a time -- the walks need a time only at the window's extremes and /// at the level crossings, which number in the handful. /// /// For a live window it holds `ScopeModel::mutex()` for its whole life, as the materialization did /// but for a fraction of the time. Keep one of these alive no longer than the walks. /// 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. /// 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. /// One pane row: the measurement's badge, its type, its value and an `edit` button. The value /// label and the statistics line below it are the two widgets `update_measures()` rewrites; /// everything else is rebuilt with the list. The row is two visual lines so that the /// statistics figures do not push the reading itself off a narrow pane. GtkWidget* measures_row(App& app, std::size_t index, const Measure& m) { // The row is two lines: the reading, and the statistics under it. A vertical wrapper // rather than more columns, because the statistics line is four figures wide and would // push the reading itself off the pane. GtkWidget* outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget* chip = gtk_label_new(""); gtk_label_set_markup(GTK_LABEL(chip), chip_markup(plot::Color::hex(measure_color(index))).c_str()); gtk_widget_set_tooltip_text(chip, "this measurement's colour, and the colour of its marks"); gtk_box_append(GTK_BOX(box), chip); // The signal's own chip beside it, in the palette colour every other view names that signal // with, so the row says *what* is being measured as well as by which measurement. GtkWidget* sig_chip = gtk_label_new(""); { std::lock_guard lock(app.model->mutex()); const std::vector& signals = app.model->signals(); int color_index = -1; for (std::size_t i = 0; i < signals.size(); ++i) { if (signals[i].name == m.signal) { color_index = signal_color_index(signals, i); break; } } gtk_label_set_markup(GTK_LABEL(sig_chip), chip_markup(color_index >= 0 ? app.rows->signal_color(m.signal, color_index) : RowSet::unplotted_color()) .c_str()); } gtk_widget_set_tooltip_text(sig_chip, m.signal.c_str()); gtk_box_append(GTK_BOX(box), sig_chip); GtkWidget* kind = gtk_label_new(measure_type_name(m.type)); gtk_widget_set_halign(kind, GTK_ALIGN_START); gtk_widget_set_hexpand(kind, true); gtk_label_set_ellipsize(GTK_LABEL(kind), PANGO_ELLIPSIZE_END); char tip[160]; std::snprintf(tip, sizeof(tip), "%s\n%s", m.signal.c_str(), measure_uses_threshold(m.type) ? (m.options.above ? "the above-threshold side" : "the below-threshold side") : ""); gtk_widget_set_tooltip_text(kind, tip); gtk_box_append(GTK_BOX(box), kind); GtkWidget* value = gtk_label_new("--"); gtk_widget_set_halign(value, GTK_ALIGN_END); gtk_label_set_selectable(GTK_LABEL(value), true); gtk_box_append(GTK_BOX(box), value); GtkWidget* edit = gtk_button_new_with_label("edit"); gtk_widget_add_css_class(edit, "flat"); g_object_set_data(G_OBJECT(edit), "udpscope-measure-index", GINT_TO_POINTER(static_cast(index) + 1)); g_signal_connect(edit, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { App* a = static_cast(user); const int idx = GPOINTER_TO_INT( g_object_get_data(G_OBJECT(b), "udpscope-measure-index")) - 1; open_measure_dialog(*a, idx); }), &app); gtk_box_append(GTK_BOX(box), edit); gtk_box_append(GTK_BOX(outer), box); GtkWidget* stats = gtk_label_new(""); gtk_widget_set_halign(stats, GTK_ALIGN_END); gtk_widget_add_css_class(stats, "dim-label"); gtk_label_set_selectable(GTK_LABEL(stats), true); // Ellipsize so the label's minimum width is bounded: without this, a long reading would // widen the pane just as the `kind` label would without its own ellipsize. gtk_label_set_ellipsize(GTK_LABEL(stats), PANGO_ELLIPSIZE_END); gtk_widget_set_tooltip_text(stats, "how this reading has varied since the last reset: the number " "of passes, the mean, the standard deviation, and the extremes"); gtk_widget_set_visible(stats, app.measure_stats_on); gtk_box_append(GTK_BOX(outer), stats); app.measure_labels.push_back(value); app.measure_stats_labels.push_back(stats); return outer; } /// The cursor modes, in the order the dropdown lists them; the index is `CursorMode`. void on_cursor_mode_changed(GObject* obj, GParamSpec*, gpointer user) { App* app = static_cast(user); if (app->updating_panels) return; const guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(obj)); if (sel > 3) return; app->rows->set_cursor_mode(static_cast(sel)); rebuild_cursors_pane(*app); } /// Appends one row to the pane's table: a `who` cell and the three value cells, in one /// `GtkGrid` so the four columns size to their widest cell and the numbers read as a table /// (`| | A | B | delta |`) rather than as a sentence per line. App::CursorLine cursors_line(App& app, const char* who, plot::Color chip, bool has_chip) { GtkGrid* grid = GTK_GRID(app.cursors_body); const int at = static_cast(app.cursors_lines.size()) + 1; // row 0 is the header App::CursorLine line; line.label = gtk_label_new(who); gtk_widget_set_halign(line.label, GTK_ALIGN_START); gtk_label_set_ellipsize(GTK_LABEL(line.label), PANGO_ELLIPSIZE_END); if (has_chip) { // The chip **and** the name: a chip alone says a trace has a colour and leaves the user // to match it against the plot, which with several traces is a guess. Escaped because // this is markup and a signal name is the stream's text, not ours. char* esc = g_markup_escape_text(who, -1); gtk_label_set_markup(GTK_LABEL(line.label), (chip_markup(chip) + " " + esc).c_str()); g_free(esc); gtk_widget_set_tooltip_text(line.label, who); } gtk_grid_attach(grid, line.label, 0, at, 1, 1); GtkWidget* cells[3] = {nullptr, nullptr, nullptr}; for (int c = 0; c < 3; ++c) { GtkWidget* v = gtk_label_new(""); gtk_widget_set_halign(v, GTK_ALIGN_END); gtk_label_set_selectable(GTK_LABEL(v), true); gtk_widget_set_margin_start(v, 10); gtk_grid_attach(grid, v, c + 1, at, 1, 1); cells[c] = v; } line.a = cells[0]; line.b = cells[1]; line.d = cells[2]; return line; } /// The table's header: the columns have no name but the two cursors and their difference. GtkWidget* cursors_header(App& app) { GtkGrid* grid = GTK_GRID(app.cursors_body); GtkWidget* cells[4] = {gtk_label_new(""), gtk_label_new("A"), gtk_label_new("B"), gtk_label_new("delta")}; for (int c = 0; c < 4; ++c) { gtk_widget_set_halign(cells[c], c == 0 ? GTK_ALIGN_START : GTK_ALIGN_END); gtk_widget_add_css_class(cells[c], "dim-label"); if (c > 0) gtk_widget_set_margin_start(cells[c], 10); gtk_grid_attach(grid, cells[c], c, 0, 1, 1); } return cells[1]; } /// The unit a row's y rulers are in: the one its bound channels agree on, or none when they /// disagree -- a ruler sits on the row's y axis, and two signals of different units on one /// axis make "the" unit a guess. /// /// The channel list is rebuilt by the producer, so it is read under the model mutex, like /// every other read of it from this thread. std::string row_unit(App& app, int row) { std::lock_guard lock(app.model->mutex()); std::string unit; bool first = true; for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { const std::size_t si = static_cast(s); const RowSet::Row& r = app.rows->row(row); if (r.signal_name[si].empty()) continue; const int ch = r.channel[si]; if (ch < 0 || static_cast(ch) >= app.model->channels().size()) continue; const std::string& u = app.model->channels()[static_cast(ch)].unit; if (first) { unit = u; first = false; } else if (unit != u) { return ""; } } return unit; } /// The unit of one bound trace, or "" for a slot that plots nothing. std::string slot_unit(App& app, int row, int slot) { std::lock_guard lock(app.model->mutex()); const RowSet::Row& r = app.rows->row(row); const int ch = r.channel[static_cast(slot)]; if (ch < 0 || static_cast(ch) >= app.model->channels().size()) return ""; return app.model->channels()[static_cast(ch)].unit; } /// One bit per row/slot for the slots that are *bound* -- a name **and** a channel the /// model actually built. A name alone is not a trace: the stream's time signal is never /// acquired, so a slot naming it has a name for ever and data never, and `mix` would draw /// it a line that can only read `--`. std::uint32_t bound_slot_mask(App& app) { std::uint32_t mask = 0; for (int r = 0; r < app.rows->row_count(); ++r) { const RowSet::Row& row = app.rows->row(r); for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { const std::size_t si = static_cast(s); if (row.signal_name[si].empty() || row.channel[si] < 0) continue; mask |= 1u << (r * RowSet::kSlotsPerRow + s); } } return mask; } /// The pane's body follows what the mode reads and what is plotted: a line per row for /// the rulers, a line per bound trace for `mix`. Called when the mode changes, when the /// rows change and when the stream is re-described -- the texts are then rewritten every /// frame by `update_cursors_pane()`. void rebuild_cursors_pane(App& app) { if (app.cursors_body == nullptr) return; GtkWidget* child = gtk_widget_get_first_child(app.cursors_body); while (child != nullptr) { GtkWidget* next = gtk_widget_get_next_sibling(child); gtk_grid_remove(GTK_GRID(app.cursors_body), child); child = next; } app.cursors_lines.clear(); cursors_header(app); app.cursors_pane_mode = static_cast(app.rows->cursor_mode()); app.cursors_pane_rows = app.rows->row_count(); app.cursors_pane_generation = app.model->generation(); app.cursors_pane_bound = bound_slot_mask(app); const RowSet::CursorMode mode = app.rows->cursor_mode(); const bool rulers = mode == RowSet::CursorMode::Horizontal || mode == RowSet::CursorMode::Both; const bool crossings = mode == RowSet::CursorMode::Mix; // The times are a row of the same table: `| t | T0 | T1 | dT |`. `slot = -2` marks it, // `-1` a ruler row, `>= 0` a trace. Only the modes that *have* the time cursors get it: // in `horizontal` the pair is parked, and a row of times nothing on the plot can move // would be a readout of nothing. if (mode != RowSet::CursorMode::Horizontal) { app.cursors_lines.push_back(cursors_line(app, "t", plot::Color::hex(0), false)); app.cursors_lines.back().row = -1; app.cursors_lines.back().slot = -2; } if (rulers || crossings) { for (int r = 0; r < app.rows->row_count(); ++r) { if (!rulers) break; char who[64]; std::snprintf(who, sizeof(who), "row %d", r); app.cursors_lines.push_back(cursors_line(app, who, plot::Color::hex(0), false)); app.cursors_lines.back().row = r; app.cursors_lines.back().slot = -1; } if (crossings) { for (int r = 0; r < app.rows->row_count(); ++r) { const RowSet::Row& row = app.rows->row(r); for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { const std::size_t si = static_cast(s); // A *bound* trace, not merely a named slot: a slot can name a signal the model // will never build a channel for (the stream's time signal is never acquired), // and a line for one of those is a row of `--` beside a colour chip -- which is // what "the pane shows a lot of empty signals that do not exist" was. if (row.signal_name[si].empty() || row.channel[si] < 0) continue; app.cursors_lines.push_back(cursors_line( app, row.name[si].c_str(), app.rows->signal_color(row.signal_name[si], row.color_index[si]), true)); app.cursors_lines.back().row = r; app.cursors_lines.back().slot = s; } } } } } /// `labeled_box()` with the label handed back, for a row whose text depends on what is being /// edited (the dialog's option rows). GtkWidget* labelled_row(const char* label, GtkWidget* child, GtkWidget** label_out) { GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget* l = gtk_label_new(label); gtk_widget_set_halign(l, GTK_ALIGN_START); gtk_box_append(GTK_BOX(box), l); gtk_box_append(GTK_BOX(box), child); *label_out = l; return box; } /// The add/edit dialog. `edit_index < 0` adds; otherwise it edits that measurement in place /// (and offers Delete, which is the only way one is removed -- there is no undo here, so the /// destructive button sits with the dialog that shows what it would destroy). /// The dialog. Two dropdowns and the type's own options, laid out like the calibration one: /// a small window with a `labeled_box` per field and the buttons in the header bar. void open_measure_dialog(App& app, int edit_index) { const Measure* editing = edit_index >= 0 && static_cast(edit_index) < app.measures.size() ? &app.measures[static_cast(edit_index)] : nullptr; GtkWidget* window = dialog_window(GTK_WIDGET(app.window), editing != nullptr ? "Edit measurement" : "Add measurement"); GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window)); // The list is the signals the *plots* are showing, in signal-list order: a measurement // reads the window a plot is displaying, and its marks are drawn on the row that plots the // signal, so the candidates are the traces on screen. That is also what keeps a channel // nobody plots out of the list -- including a stream's timestamp signal, which is // acquired but is not something to measure. The editing measurement's own signal is added // back when it is not plotted (otherwise Save would silently re-point it at whatever the // dropdown happened to show). std::vector sig_names; int selected_signal = 0; for (int r = 0; r < app.rows->row_count(); ++r) { const RowSet::Row& row = app.rows->row(r); for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) { const std::size_t si = static_cast(sl); const std::string& n = row.name[si]; if (n.empty()) continue; if (std::find(sig_names.begin(), sig_names.end(), n) != sig_names.end()) continue; sig_names.push_back(n); } } if (editing != nullptr) { if (std::find(sig_names.begin(), sig_names.end(), editing->signal) == sig_names.end()) { sig_names.push_back(editing->signal); } } // The `const char*` array is built from the *finished* strings: taking `c_str()` as the // vector grew left every pointer pointing into memory the next push reallocated. std::vector sig_cstrs; sig_cstrs.reserve(sig_names.size() + 1); for (const std::string& n : sig_names) sig_cstrs.push_back(n.c_str()); sig_cstrs.push_back(nullptr); if (editing != nullptr) { const auto it = std::find(sig_names.begin(), sig_names.end(), editing->signal); if (it != sig_names.end()) { selected_signal = static_cast(it - sig_names.begin()); } } GtkWidget* signal_drop = gtk_drop_down_new_from_strings(sig_cstrs.data()); gtk_drop_down_set_selected(GTK_DROP_DOWN(signal_drop), static_cast(selected_signal)); // No scrolled window around it: a `GtkDropDown` scrolls its own popover list, and wrapping // one asked the *combo box itself* to grow to 160 px. gtk_box_append(GTK_BOX(box), labeled_box("signal", signal_drop)); std::vector type_names; for (int i = 0; i < measure_type_count(); ++i) type_names.push_back(measure_type_name(measure_type_at(i))); type_names.push_back(nullptr); GtkWidget* type_drop = gtk_drop_down_new_from_strings(type_names.data()); gtk_box_append(GTK_BOX(box), labeled_box("measure", type_drop)); GtkWidget* threshold = spin(0.0, 100.0, 1.0, 1, 50.0); // the split's own default, re-seeded below gtk_widget_set_tooltip_text(threshold, "where the signal is split, as a share of the visible range"); GtkWidget* threshold_label = nullptr; GtkWidget* threshold_row = labelled_row("threshold %", threshold, &threshold_label); gtk_box_append(GTK_BOX(box), threshold_row); GtkWidget* above = gtk_switch_new(); gtk_widget_set_halign(above, GTK_ALIGN_START); gtk_widget_set_tooltip_text(above, "which half of the waveform is the pulse"); GtkWidget* side_label = nullptr; GtkWidget* side_row = labelled_row("pulse above level", above, &side_label); gtk_box_append(GTK_BOX(box), side_row); // A percentage of the window's own peak-to-peak, which is the unit the engine takes. GtkWidget* noise = spin(0.0, 50.0, 1.0, 1, 10.0); // `labelled_row()` writes the label out unconditionally, and these two never retitle, so the // out-parameters are named and then explicitly unused rather than passed as nullptr. GtkWidget* noise_label = nullptr; GtkWidget* noise_row = labelled_row("noise band %", noise, &noise_label); gtk_box_append(GTK_BOX(box), noise_row); (void)noise_label; // 0 is off. The engine clamps to just under the stream's Nyquist, which the dialog cannot know. GtkWidget* hf = spin(0.0, 1.0e7, 1.0, 3, 0.0); GtkWidget* hf_label = nullptr; GtkWidget* hf_row = labelled_row("HF reject Hz", hf, &hf_label); gtk_box_append(GTK_BOX(box), hf_row); (void)hf_label; GtkWidget* show = gtk_check_button_new_with_label("show on plot"); gtk_widget_set_tooltip_text(show, "draw the result on the plot: a ruler at the level, a marker " "at the time it happened, the pulse's edges as markers"); gtk_box_append(GTK_BOX(box), show); auto* d = new MeasureDialog{&app, edit_index, signal_drop, type_drop, threshold, above, show, window, threshold_row, threshold_label, side_row, side_label, noise, noise_row, hf, hf_row, sig_names}; app.measure_dialog = window; g_signal_connect(window, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer user) { static_cast(user)->measure_dialog = nullptr; }), &app); // One owner, on the window: the buttons only *refer* to the context. Letting two of them // own it (Apply and Delete) freed it twice, which is the abort an edit or a delete used to // produce -- an add has one button, which is why only those two paths crashed. g_object_set_data_full(G_OBJECT(window), "udpscope-measure-ctx", d, [](gpointer p) { delete static_cast(p); }); if (editing != nullptr) { // The index by looking `editing->type` up in the same function that fills the dropdown: // a hand-written chain of ternaries is a second copy of the order, and the copy is what // decides which type the user gets. for (int i = 0; i < measure_type_count(); ++i) { if (measure_type_at(i) == editing->type) { gtk_drop_down_set_selected(GTK_DROP_DOWN(type_drop), static_cast(i)); break; } } gtk_spin_button_set_value(GTK_SPIN_BUTTON(threshold), editing->options.threshold_pct); gtk_spin_button_set_value(GTK_SPIN_BUTTON(noise), editing->options.noise_pct); gtk_spin_button_set_value(GTK_SPIN_BUTTON(hf), editing->options.hf_fc); gtk_switch_set_active(GTK_SWITCH(above), editing->options.above); gtk_check_button_set_active(GTK_CHECK_BUTTON(show), editing->options.show_on_plot); } else { gtk_spin_button_set_value(GTK_SPIN_BUTTON(threshold), 50.0); gtk_switch_set_active(GTK_SWITCH(above), true); } g_signal_connect(type_drop, "notify::selected", G_CALLBACK(on_measure_type_changed), d); measure_dialog_sync(d); GtkWidget* cancel = gtk_button_new_with_label("Cancel"); g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window); dialog_add_button(window, cancel); if (editing != nullptr) { GtkWidget* del = gtk_button_new_with_label("Delete"); gtk_widget_add_css_class(del, "destructive-action"); g_signal_connect(del, "clicked", G_CALLBACK(on_measure_dialog_delete), d); dialog_add_button(window, del); } GtkWidget* apply = gtk_button_new_with_label(editing != nullptr ? "Save" : "Add"); gtk_widget_add_css_class(apply, "suggested-action"); g_signal_connect(apply, "clicked", G_CALLBACK(on_measure_dialog_apply), d); dialog_add_button(window, apply); gtk_window_present(GTK_WINDOW(window)); } /// 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; bool sel_off_view = false; 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) is only a hint; the actual cap is enforced by xs.size() < cap in the fill loop. xs.reserve(cap); if (sel >= 0 && sel < static_cast(hits.size())) { current = 0; const double x = hits[static_cast(sel)] - s0; // The selected hit is placed whether or not it is on screen, so the label must not fold it // into the in-view count: it is the one mark `in_view` above deliberately did not see. sel_off_view = x < view.min || x > view.max; xs.push_back(x); } 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) { // The marks that are *in view*: `xs` also carries the selected hit when it is off screen. const std::size_t shown = xs.size() - (sel_off_view ? 1u : 0u); char text[128]; if (hits.empty()) { std::snprintf(text, sizeof(text), " "); } else if (in_view > shown) { // 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", shown, in_view); } else if (sel_off_view) { std::snprintf(text, sizeof(text), "%zu of %zu in view \xE2\x80\x94 the selected hit is outside the window", shown, hits.size()); } else { std::snprintf(text, sizeof(text), "%zu of %zu in view", shown, hits.size()); } gtk_label_set_text(GTK_LABEL(app.search_marks_label), text); } app.rows->set_search_marks(xs, current); } /// The measurements panel: a header with the `[+]`, and one row per measurement. // ----------------------------------------------------------------- math channels // // A math channel is a *channel*: the model synthesises a signal for it, so the panel's switch, the // plot binding, the measurements and the export all treat it like a streamed one, and the pane only // has to say which graphs exist. The editor is the compact node graph: nodes as boxes on a canvas, // click a box to select it and edit its parameters in the form beside the canvas, click an output // port and then an input port to wire them, drag a box to move it. /// The panel's `+`: a new channel with one source node, so the editor opens on something valid and /// the user has a box to connect. void add_math_channel(App& app) { // From the *acquired* channels, never `signals().front()`: the first signal of a stream is its // timestamp, which is not acquired, so a node sourced from it reads NaN for ever -- a graph that // validates and then shows nothing. (The dialog's own default was fixed for this; the pane's `+` // was not.) std::string name; { std::lock_guard lock(app.model->mutex()); if (!app.model->channels().empty()) name = app.model->channels().front().name; } MathChannel m; // A name of its own: calling it after its first source would put a second entry with that name in // the signal panel, where the user cannot tell the derived one from the streamed one. m.name = "math" + std::to_string(app.math.size() + 1); m.enabled = true; MathNode src; src.op = MathOp::Source; src.source = name; src.x = 30.0; src.y = 40.0; m.nodes.push_back(src); m.output = 0; app.math.push_back(std::move(m)); apply_math_channels(app); } // The node editor: a compact graph. Each node is a box; inputs are the squares on its left edge, // the output the square on its right. Click a box to select it (its parameters appear in the form // beside the canvas), drag from an output square to an input square to wire them, drag a wired // input off to detach it, drag a box to move it. The graph is a *list*, walked in order, so a node // may only read ones above it -- but that order is the *evaluator's* contract, not a restriction on // the gesture: a wire may be drawn in either direction and `math_connect()` re-sorts the list // (Kahn's algorithm) so the contract still holds. Only a wire that cannot be ordered at all, i.e. // one that closes a cycle, is refused, and it is refused at connect time with a message. /// A wire, named by the node that *consumes* it and which of its inputs. The consumer is the /// identity because that is where a wire is stored: `in_a`/`in_b` on the reader. A producer may /// feed several inputs and so cannot name one. /// A port's identity: `kPortOut` for a node's output, 0 or 1 for one of its inputs. /// The smallest and largest the canvas may be scaled to. Below a quarter the op names are /// unreadable; above four a box fills the canvas and there is nothing left to navigate. /// The pick radius for a wire, in screen pixels. Smaller than a port's, so a wire that passes /// behind a port never wins the press -- the port is tested first anyway, but a user aiming at a /// port should not be able to miss it onto a wire either. void open_math_dialog(App& app, int edit) { if (app.math_dialog != nullptr) { gtk_window_present(GTK_WINDOW(app.math_dialog)); return; } MathDialog* d = new MathDialog(); d->app = &app; d->edit = edit; { std::lock_guard lock(app.model->mutex()); d->nodes = edit >= 0 && edit < static_cast(app.math.size()) ? app.math[static_cast(edit)].nodes : std::vector{}; } if (d->nodes.empty()) { MathNode src; src.op = MathOp::Source; const std::vector names = math_source_names(app); if (!names.empty()) src.source = names.front(); d->nodes.push_back(src); } // A node with no position yet -- a graph built by a probe, or one whose box was never moved -- // would otherwise sit in the canvas's top-left corner, stacked under whatever is drawn after it. // Laid out in a column, which is also the order the graph is walked in. // Two columns, spaced by more than a box is wide. The first version staggered them by 24 px for a // 132 px box, so every node sat on top of the one before it and the click always found the last -- // which reads as the dialog ignoring the pointer. for (std::size_t i = 0; i < d->nodes.size(); ++i) { if (d->nodes[i].x != 0.0 || d->nodes[i].y != 0.0) continue; d->nodes[i].x = 24.0 + 160.0 * static_cast(i % 2u); d->nodes[i].y = 20.0 + 58.0 * static_cast(i / 2u); } d->output = edit >= 0 && edit < static_cast(app.math.size()) ? app.math[static_cast(edit)].output : 0; // **The last node, not the first.** A new graph is built source -> operation, and the *output* // defaulted to node 0 -- the source -- so every operation was computed and then thrown away: a // gain of 2 and a low pass both produced a channel equal to its input. The output is drawn as the // orange dot and settable explicitly, so a graph whose output is genuinely an earlier node keeps // it; this is only the default for one that has never been given a choice. if (d->output < 0 || d->output >= static_cast(d->nodes.size())) { d->output = static_cast(d->nodes.size()) - 1; } d->selected = d->output; // The window and its identity row are a `.ui` file (`src/ui/math-editor.ui`). The canvas, the palette and // the parameter form below them stay in code: the canvas is drawn rather than declared, and the form // follows which node is selected. g_clear_object(&app.math_dialog_ui); app.math_dialog_ui = gtk_builder_new_from_resource("/org/udpscope/ui/math-editor.ui"); GtkBuilder* ui = app.math_dialog_ui; d->window = GTK_WIDGET(gtk_builder_get_object(ui, "math_window")); GtkWidget* outer = GTK_WIDGET(gtk_builder_get_object(ui, "math_outer")); GtkWidget* head = GTK_WIDGET(gtk_builder_get_object(ui, "math_head")); d->name = GTK_WIDGET(gtk_builder_get_object(ui, "math_name")); d->unit = GTK_WIDGET(gtk_builder_get_object(ui, "math_unit")); if (d->window == nullptr || outer == nullptr || head == nullptr || d->name == nullptr || d->unit == nullptr) { // A missing id is a mistake in the `.ui`. There is no editor to open, so this says so and gives up // rather than building the rest of a window around a hole; `[math-probe] editor` reads the difference. std::fprintf(stderr, "[math] editor: math-editor.ui is missing an id this code reads\n"); delete d; return; } GtkWidget* window = d->window; gtk_window_set_title(GTK_WINDOW(window), edit >= 0 ? "Edit math channel" : "New math channel"); // The dialog's own state, freed with the window: a plain local here would leak the graph and every drag // it is halfway through on each opening. g_object_set_data_full(G_OBJECT(window), "udpscope-math-dialog", d, +[](gpointer p) { delete static_cast(p); }); g_signal_connect(window, "close-request", G_CALLBACK(+[](GtkWindow* w, gpointer) { GtkWidget* dd = GTK_WIDGET(w); MathDialog* md = static_cast( g_object_get_data(G_OBJECT(dd), "udpscope-math-dialog")); if (md != nullptr && md->app != nullptr) md->app->math_dialog = nullptr; return false; }), nullptr); // The two values the file cannot hold: what this channel is called and the unit its readouts carry. The // name is also how another graph refers to this channel as a source, which is why it takes the slack. if (edit >= 0 && edit < static_cast(app.math.size())) { gtk_editable_set_text(GTK_EDITABLE(d->name), app.math[static_cast(edit)].name.c_str()); gtk_editable_set_text(GTK_EDITABLE(d->unit), app.math[static_cast(edit)].unit.c_str()); } // The body and the canvas are the file's as well; what is code is the *drawing* -- the function below and // the gestures under it, which are what make the area a graph editor rather than a picture. GtkWidget* body = GTK_WIDGET(gtk_builder_get_object(ui, "math_body")); d->canvas = GTK_WIDGET(gtk_builder_get_object(ui, "math_canvas")); if (body == nullptr || d->canvas == nullptr) { std::fprintf(stderr, "[math] editor: math-editor.ui is missing its body or canvas\n"); delete d; return; } gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(d->canvas), math_canvas_draw, d, nullptr); GtkGesture* click = gtk_gesture_click_new(); g_signal_connect(click, "pressed", G_CALLBACK(+[](GtkGestureClick* g, int, double x, double y, gpointer user) { math_canvas_pressed(g, 0, x, y, user); }), d); gtk_widget_add_controller(d->canvas, GTK_EVENT_CONTROLLER(click)); GtkGesture* drag = gtk_gesture_drag_new(); g_signal_connect(drag, "drag-begin", G_CALLBACK(+[](GtkGestureDrag* g, double x, double y, gpointer user) { math_canvas_drag_begin(g, x, y, user); }), d); g_signal_connect(drag, "drag-update", G_CALLBACK(+[](GtkGestureDrag* g, double dx, double dy, gpointer user) { math_canvas_drag(g, dx, dy, user); }), d); g_signal_connect(drag, "drag-end", G_CALLBACK(+[](GtkGestureDrag* g, double dx, double dy, gpointer user) { math_canvas_drag_end(g, dx, dy, user); }), d); gtk_widget_add_controller(d->canvas, GTK_EVENT_CONTROLLER(drag)); // The wheel zooms. A scroll controller is the only way to get one on a drawing area, and // `VERTICAL` is what a wheel reports. GtkEventController* zoom = gtk_event_controller_scroll_new(GTK_EVENT_CONTROLLER_SCROLL_VERTICAL); g_signal_connect(zoom, "scroll", G_CALLBACK(math_canvas_scrolled), d); gtk_widget_add_controller(d->canvas, zoom); // A `GtkDrawingArea` is not focusable by default, so a key controller on it never fires. It // takes focus on a press rather than only on Tab, because every key this editor binds is about // something the pointer just did. gtk_widget_set_focusable(d->canvas, TRUE); GtkEventController* keys = gtk_event_controller_key_new(); g_signal_connect(keys, "key-pressed", G_CALLBACK(math_canvas_key), d); gtk_widget_add_controller(d->canvas, keys); // The form: every row exists from the start and is shown or hidden per op, so no rebuild can // invalidate a callback's handle. GtkWidget* form = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); gtk_widget_set_size_request(form, 250, -1); GtkWidget* op_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget* op_text = gtk_label_new("operation"); gtk_widget_set_size_request(op_text, 74, -1); gtk_widget_set_halign(op_text, GTK_ALIGN_START); gtk_box_append(GTK_BOX(op_row), op_text); std::vector op_names; for (int i = 0; i < math_op_count(); ++i) op_names.push_back(math_op_name(math_op_at(i))); op_names.push_back(nullptr); d->op_drop = gtk_drop_down_new_from_strings(op_names.data()); gtk_widget_set_hexpand(d->op_drop, true); g_signal_connect(d->op_drop, "notify::selected", G_CALLBACK(on_math_op_changed), d); gtk_box_append(GTK_BOX(op_row), d->op_drop); gtk_box_append(GTK_BOX(form), op_row); d->source_drop = gtk_drop_down_new_from_strings(nullptr); g_signal_connect(d->source_drop, "notify::selected", G_CALLBACK(on_math_source_changed), d); gtk_box_append(GTK_BOX(form), math_row("channel", d->source_drop, &d->rows[0])); d->gain = gtk_spin_button_new_with_range(-1e6, 1e6, 0.1); d->offset = gtk_spin_button_new_with_range(-1e6, 1e6, 0.1); // Digits, not just a range: a `GtkSpinButton` holds zero decimals by default, so a 12.5 Hz cutoff // was stored as 12 and a gain of 0.25 as 0 -- the values looked wrong because they *were*. gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->gain), 4); gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->offset), 4); // An explicit starting value, because `gtk_spin_button_new_with_range()` leaves the value at the // range's *minimum* when zero is not one of the bounds it can pick: the offset began at -1e6, and // `on_math_param_changed()` reads the widgets -- so a gain of 2 saved as `x*2 - 2000000`. Every // field is seeded from its own identity value; `math_dialog_sync()` then sets the node's. gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->gain), 1.0); gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->offset), 0.0); g_signal_connect(d->gain, "value-changed", G_CALLBACK(on_math_param_changed), d); g_signal_connect(d->offset, "value-changed", G_CALLBACK(on_math_param_changed), d); { // One box for both: `gain + offset` is a single op and the two fields are only meaningful // together, so they show and hide as one. The offset used to be a row of its own that nothing // ever hid, which is what made it look like a property of every node instead of of this one op. GtkWidget* gain_rows = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); gtk_box_append(GTK_BOX(gain_rows), math_row("gain", d->gain, nullptr)); gtk_box_append(GTK_BOX(gain_rows), math_row("offset", d->offset, nullptr)); d->rows[1] = gain_rows; gtk_box_append(GTK_BOX(form), gain_rows); } d->fc = gtk_spin_button_new_with_range(0.001, 1e7, 0.5); d->q = gtk_spin_button_new_with_range(0.01, 100.0, 0.05); gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->fc), 3); gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->q), 3); gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->fc), 100.0); gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->q), 0.707); g_signal_connect(d->fc, "value-changed", G_CALLBACK(on_math_param_changed), d); g_signal_connect(d->q, "value-changed", G_CALLBACK(on_math_param_changed), d); { GtkWidget* filter_rows = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); gtk_box_append(GTK_BOX(filter_rows), math_row("corner Hz", d->fc, nullptr)); gtk_box_append(GTK_BOX(filter_rows), math_row("Q", d->q, &d->rows[3])); gtk_box_append(GTK_BOX(form), filter_rows); d->rows[2] = filter_rows; // The Q row is only meaningful for the band pass, which is the only op that reads it. gtk_widget_set_visible(d->rows[3], false); } d->lo = gtk_spin_button_new_with_range(-1e6, 1e6, 0.1); d->hi = gtk_spin_button_new_with_range(-1e6, 1e6, 0.1); gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->lo), 4); gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->hi), 4); gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->lo), -1.0); gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->hi), 1.0); g_signal_connect(d->lo, "value-changed", G_CALLBACK(on_math_param_changed), d); g_signal_connect(d->hi, "value-changed", G_CALLBACK(on_math_param_changed), d); { GtkWidget* clamp_rows = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); gtk_box_append(GTK_BOX(clamp_rows), math_row("min", d->lo, nullptr)); gtk_box_append(GTK_BOX(clamp_rows), math_row("max", d->hi, nullptr)); d->rows[4] = clamp_rows; gtk_box_append(GTK_BOX(form), clamp_rows); } // Odd, 3..9. The engine rounds up to odd anyway, but a step of 2 means the widget never offers an even // one. d->med = gtk_spin_button_new_with_range(3.0, 9.0, 2.0); gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->med), 0); gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->med), 5.0); g_signal_connect(d->med, "value-changed", G_CALLBACK(on_math_param_changed), d); gtk_box_append(GTK_BOX(form), math_row("median samples", d->med, &d->rows[6])); d->inputs = gtk_label_new(""); gtk_label_set_wrap(GTK_LABEL(d->inputs), true); gtk_widget_add_css_class(d->inputs, "dim-label"); gtk_widget_set_halign(d->inputs, GTK_ALIGN_START); gtk_box_append(GTK_BOX(form), math_row("wired", d->inputs, &d->rows[5])); // Why the graph cannot be saved, in the form column where the user is already reading. Hidden // when there is nothing to say, and **ellipsized rather than wrapped**, with the full text on the // tooltip (`math_dialog_sync()` sets both together): layout must not depend on content. This label // sits in a dialog whose canvas is `hexpand`, so a wrapping message re-heights the form -- and a // long channel name in it would widen the dialog -- on a mere change of graph. The same rule the // status bar and the y gutter are built to. d->problem = gtk_label_new(""); gtk_label_set_ellipsize(GTK_LABEL(d->problem), PANGO_ELLIPSIZE_END); gtk_label_set_xalign(GTK_LABEL(d->problem), 0.0f); gtk_widget_add_css_class(d->problem, "error"); gtk_widget_set_visible(d->problem, FALSE); gtk_box_append(GTK_BOX(form), d->problem); // A menu of the thirteen ops rather than one button that always makes a gain: building anything // else was add-then-change-the-dropdown, which is two steps for the common case. GtkWidget* add = gtk_menu_button_new(); gtk_menu_button_set_label(GTK_MENU_BUTTON(add), "add node"); GtkWidget* add_menu = gtk_popover_new(); GtkWidget* add_list = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); for (int i = 0; i < math_op_count(); ++i) { const MathOp op = math_op_at(i); GtkWidget* item = gtk_button_new_with_label(math_op_name(op)); gtk_widget_add_css_class(item, "flat"); gtk_widget_set_halign(gtk_button_get_child(GTK_BUTTON(item)), GTK_ALIGN_START); // The op travels in the widget's own data, as an `int` through `math_op_index()`'s space: a // pointer-sized enum smuggled through `gpointer` is the kind of cast that survives a // reordering of the enum and then means something else. g_object_set_data(G_OBJECT(item), "udpscope-math-op", GINT_TO_POINTER(i)); g_object_set_data(G_OBJECT(item), "udpscope-math-popover", add_menu); g_signal_connect(item, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { MathDialog* dd = static_cast(user); const int index = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), "udpscope-math-op")); auto* pop = GTK_POPOVER( g_object_get_data(G_OBJECT(b), "udpscope-math-popover")); if (pop != nullptr) gtk_popover_popdown(pop); math_add_node(dd, math_op_at(index)); }), d); gtk_box_append(GTK_BOX(add_list), item); } gtk_popover_set_child(GTK_POPOVER(add_menu), add_list); gtk_menu_button_set_popover(GTK_MENU_BUTTON(add), add_menu); GtkWidget* del = gtk_button_new_with_label("delete node"); GtkWidget* out = gtk_button_new_with_label("set as output"); g_signal_connect(del, "clicked", G_CALLBACK(on_math_node_deleted), d); g_signal_connect(out, "clicked", G_CALLBACK(on_math_output_set), d); GtkWidget* fit = gtk_button_new_with_label("fit"); gtk_widget_set_tooltip_text(fit, "Frame the whole graph (f). Ctrl+0 resets the zoom."); g_signal_connect(fit, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { math_view_fit(static_cast(user)); }), d); GtkWidget* tidy = gtk_button_new_with_label("tidy"); gtk_widget_set_tooltip_text(tidy, "Lay the graph out left to right and frame it."); g_signal_connect(tidy, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { math_tidy(static_cast(user)); }), d); GtkWidget* buttons = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); gtk_box_append(GTK_BOX(buttons), add); gtk_box_append(GTK_BOX(buttons), del); gtk_box_append(GTK_BOX(buttons), out); gtk_box_append(GTK_BOX(buttons), fit); gtk_box_append(GTK_BOX(buttons), tidy); gtk_box_append(GTK_BOX(form), buttons); gtk_box_append(GTK_BOX(body), form); // the form is the body's second child; the canvas is the file's // The action row is the file's; what is code is what the buttons *do*, and what the second one says: // "Save" edits an existing channel and "Add" makes a new one, which the file cannot know. GtkWidget* actions = GTK_WIDGET(gtk_builder_get_object(ui, "math_actions")); GtkWidget* cancel = GTK_WIDGET(gtk_builder_get_object(ui, "math_cancel")); GtkWidget* apply = GTK_WIDGET(gtk_builder_get_object(ui, "math_apply")); if (actions == nullptr || cancel == nullptr || apply == nullptr) { std::fprintf(stderr, "[math] editor: math-editor.ui is missing its action row\n"); delete d; return; } gtk_button_set_label(GTK_BUTTON(apply), edit >= 0 ? "Save" : "Add"); // Named so the headless probe can click the *button* rather than call its handler: the defect // this guards was in the connection, which a direct call cannot see. g_object_set_data(G_OBJECT(window), "udpscope-math-cancel", cancel); // `gtk_window_close()`, never `gtk_window_destroy()`: only a close emits `close-request`, which // is what clears `app.math_dialog`. A destroy left that pointer at a dead widget, and every // later open then took the "already open" branch and presented it -- "I cannot edit a math // channel again". The same trap the other three windows in this file document. g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_close), window); g_signal_connect(apply, "clicked", G_CALLBACK(on_math_apply), d); app.math_dialog = window; d->apply = apply; math_dialog_sync(d); gtk_window_present(GTK_WINDOW(window)); } GtkWidget* build_measures_panel(App& app) { // The panel's frame is a UI file; the readings are not, because each row is one measurement and they are // rebuilt whenever the set or the mode changes. `make test-ui` checks the file's types against the GIR; // the ids below are the only thing that keeps the code and the file agreeing on the names. GtkBuilder* ui = ui_load(app, "/org/udpscope/ui/measures-panel.ui"); GtkWidget* box = GTK_WIDGET(gtk_builder_get_object(ui, "measures_panel")); GtkWidget* add = GTK_WIDGET(gtk_builder_get_object(ui, "measures_add")); app.measures_stats_check = GTK_WIDGET(gtk_builder_get_object(ui, "measures_stats_check")); app.measures_stats_reset = GTK_WIDGET(gtk_builder_get_object(ui, "measures_stats_reset")); app.measures_list = GTK_WIDGET(gtk_builder_get_object(ui, "measures_list")); if (box == nullptr || add == nullptr || app.measures_stats_check == nullptr || app.measures_stats_reset == nullptr || app.measures_list == nullptr) { // A missing id is a mistake in the `.ui`. The pane comes up empty rather than taking the window with // it, and `[udpscope] measures pane:` reads differently instead of the application failing to start. return box != nullptr ? box : gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); } g_signal_connect(add, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { open_measure_dialog(*static_cast(user), -1); }), &app); // One toggle for the whole pane, not one per measurement: statistics are either what the // user is looking at or they are not, and a per-row switch is a column of chrome paid for // on every row to answer a question asked once. g_signal_connect(app.measures_stats_check, "toggled", G_CALLBACK(+[](GtkCheckButton* b, gpointer user) { App* a = static_cast(user); if (a->updating_panels) return; a->measure_stats_on = gtk_check_button_get_active(b); // Deliberately *not* a rebuild: that would reset the statistics the // user just asked to look at. The rows are already there and hidden. for (GtkWidget* w : a->measure_stats_labels) { if (w != nullptr) gtk_widget_set_visible(w, a->measure_stats_on); } if (a->measures_stats_reset != nullptr) gtk_widget_set_sensitive(a->measures_stats_reset, a->measure_stats_on); // Refresh the now-visible labels without counting the window again: the // toggle does not reset or disturb the history (README, Statistics). update_measures(*a, /*force=*/true, /*accumulate=*/false); schedule_layout_save(*a); }), &app); g_signal_connect(app.measures_stats_reset, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { App* a = static_cast(user); reset_measure_stats(*a); update_measures(*a, /*force=*/true, /*accumulate=*/true); }), &app); return box; } /// The panel's frame: the title row (the mode) and the readouts. The time line is not /// part of the body, because it is there in every mode. /// Fit: auto-scale every row's y axis to the data it holds, and put x back on the whole time /// window. /// /// No `apply_time_window()` here, deliberately: a fit is a plot interaction and may not restate /// the time scale (see that function). The y axes are fitted to the data; x is only un-zoomed. void on_fit_clicked(GtkButton*, gpointer user) { App* app = static_cast(user); app->rows->fit_all(); app->notice = "y fitted to the data, x back to the " + format_window(app->rows->time_window()) + " window"; app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND; } /// Reset the x zoom only: the y axes keep the scales the user set. The counterpart of the other /// fit, for the common case where a zoom should be undone but the levels were placed by hand. void on_fit_x_clicked(GtkButton*, gpointer user) { App* app = static_cast(user); app->rows->fit_x(); app->notice = "x back to the " + format_window(app->rows->time_window()) + " window"; app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND; } // --- export ----------------------------------------------------------------- /// The table travels to the async callback. It is collected when the button is clicked, /// not when the dialog is answered: a live view keeps scrolling while the file chooser /// is up, so collecting late would write a window the user never saw. void on_export_response(GObject* source, GAsyncResult* result, gpointer user) { std::unique_ptr ctx(static_cast(user)); GError* err = nullptr; GFile* file = gtk_file_dialog_save_finish(GTK_FILE_DIALOG(source), result, &err); if (file == nullptr) { // A cancelled dialog is not a failure and says nothing; anything else does. if (err != nullptr && !g_error_matches(err, GTK_DIALOG_ERROR, GTK_DIALOG_ERROR_DISMISSED)) { set_notice(*ctx->app, std::string("export failed: ") + err->message); } if (err != nullptr) g_error_free(err); return; } char* raw = g_file_get_path(file); const std::string path = raw != nullptr ? raw : std::string(); g_free(raw); g_object_unref(file); if (path.empty()) { set_notice(*ctx->app, "export failed: that location is not a local file"); return; } // The extension picks the writer, so the filter the user chose is honoured by way of // the name it put in the entry rather than by a second piece of state. const bool csv = path.size() >= 4 && path.compare(path.size() - 4, 4, ".csv") == 0; std::string error; const bool ok = csv ? write_csv(path, ctx->table, error) : write_npy(path, ctx->table, error); if (!ok) { set_notice(*ctx->app, "export failed: " + error); return; } set_notice(*ctx->app, "exported " + std::to_string(ctx->table.rows()) + " samples x " + std::to_string(ctx->table.columns.size()) + " signals to " + path); } void on_export_clicked(GtkButton*, gpointer user) { App* app = static_cast(user); auto ctx = std::make_unique(); ctx->app = app; ctx->table = collect_export(*app->model, *app->rows); if (ctx->table.empty()) { set_notice(*app, "nothing to export: no signal is plotted in the visible range"); return; } GtkFileDialog* dialog = gtk_file_dialog_new(); gtk_file_dialog_set_title(dialog, "Export visible range"); char stamp[32]; const std::time_t now = std::time(nullptr); std::tm tm{}; localtime_r(&now, &tm); std::strftime(stamp, sizeof(stamp), "%Y%m%d-%H%M%S", &tm); gtk_file_dialog_set_initial_name(dialog, (std::string("udpscope-") + stamp + ".npy").c_str()); GListStore* filters = g_list_store_new(GTK_TYPE_FILE_FILTER); GtkFileFilter* npy = gtk_file_filter_new(); gtk_file_filter_set_name(npy, "NumPy array (*.npy)"); gtk_file_filter_add_pattern(npy, "*.npy"); g_list_store_append(filters, npy); GtkFileFilter* csv = gtk_file_filter_new(); gtk_file_filter_set_name(csv, "CSV (*.csv)"); gtk_file_filter_add_pattern(csv, "*.csv"); g_list_store_append(filters, csv); gtk_file_dialog_set_filters(dialog, G_LIST_MODEL(filters)); gtk_file_dialog_set_default_filter(dialog, npy); g_object_unref(npy); g_object_unref(csv); g_object_unref(filters); gtk_file_dialog_save(dialog, GTK_WINDOW(app->window), nullptr, on_export_response, ctx.release()); g_object_unref(dialog); } void on_about_clicked(GtkButton*, gpointer user) { App* app = static_cast(user); if (app->menu_button != nullptr) { GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button)); if (popover != nullptr) gtk_popover_popdown(popover); } show_about(GTK_WINDOW(app->window), layout_path(app->model->config().server, app->model->config().port)); } void on_help_clicked(GtkButton*, gpointer user) { App* app = static_cast(user); if (app->menu_button != nullptr) { GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button)); if (popover != nullptr) gtk_popover_popdown(popover); } show_help(GTK_WINDOW(app->window)); } void on_shortcuts_clicked(GtkButton*, gpointer user) { App* app = static_cast(user); if (app->menu_button != nullptr) { GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button)); if (popover != nullptr) gtk_popover_popdown(popover); } show_shortcuts(GTK_WINDOW(app->window)); } // --- right-click menu ------------------------------------------------------- void on_y_auto_clicked(GtkButton* button, gpointer user) { App* app = static_cast(user); const int row = row_key(GTK_WIDGET(button)); app->rows->set_y_auto(row, true); GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(button), "udpscope-popover")); if (popover != nullptr) gtk_popover_popdown(popover); } void on_fixed_range_apply(GtkButton*, gpointer user) { FixedRangeContext* ctx = static_cast(user); ctx->app->rows->set_y_range(ctx->row, gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->lo)), gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->hi))); gtk_window_destroy(GTK_WINDOW(ctx->window)); } void on_y_fixed_clicked(GtkButton* button, gpointer user) { App* app = static_cast(user); const int row = row_key(GTK_WIDGET(button)); GtkWidget* window = dialog_window(app->window, "Fixed Y range"); GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window)); const plot::Range cur = app->rows->row(row).plot->y_range(0); GtkWidget* lo = spin(-1.0e12, 1.0e12, 0.1, 6, cur.min); GtkWidget* hi = spin(-1.0e12, 1.0e12, 0.1, 6, cur.max); gtk_box_append(GTK_BOX(box), labeled_box("min", lo)); gtk_box_append(GTK_BOX(box), labeled_box("max", hi)); auto* ctx = new FixedRangeContext{app, row, lo, hi, window}; GtkWidget* cancel = gtk_button_new_with_label("Cancel"); g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window); GtkWidget* apply = gtk_button_new_with_label("Apply"); g_object_set_data_full(G_OBJECT(apply), "udpscope-ctx", ctx, [](gpointer p) { delete static_cast(p); }); g_signal_connect(apply, "clicked", G_CALLBACK(on_fixed_range_apply), ctx); dialog_add_button(window, cancel); dialog_add_button(window, apply); gtk_window_present(GTK_WINDOW(window)); GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(button), "udpscope-popover")); if (popover != nullptr) gtk_popover_popdown(popover); } /// One "remove from this plot" entry's payload: which row, and which trace slot -- or `kAllSlots` /// for the row's every trace, which is what the submenu's last entry means. /// Frees one trace slot of a row. All it takes is the free: the row is rebuilt with a series that is /// nothing, so it draws nothing and leaves the legend. void remove_trace(App& app, int row, int slot) { if (row < 0 || row >= app.rows->row_count()) return; app.rows->assign(row, slot, std::string(), std::string(), 0); } /// Disables every math channel that (transitively) reads `name`, and answers with their names. /// /// A derived channel cannot be computed from a signal nobody acquires: `Source` resolves to a *channel*, and a /// disabled signal has none -- so a channel left enabled would silently compute nothing. The rule is that the /// channels which depend on a signal follow it off, which is also what the pane's own switch would do by hand. /// The dependency is transitive: one math channel may name another, so a channel reading a disabled one goes /// too, and the loop runs to a fixpoint for that. std::vector disable_dependent_math_channels(App& app, const std::string& name) { std::vector off; if (name.empty()) return off; const auto math_index_of = [&app](const std::string& n) -> int { for (std::size_t k = 0; k < app.math.size(); ++k) { if (app.math[k].name == n) return static_cast(k); } return -1; }; for (bool moved = true; moved;) { moved = false; for (MathChannel& channel : app.math) { if (!channel.enabled) continue; for (const MathNode& node : channel.nodes) { if (node.op != MathOp::Source) continue; const int k = math_index_of(node.source); const bool reads_it = node.source == name; const bool reads_a_disabled_one = k >= 0 && !app.math[static_cast(k)].enabled; if (!reads_it && !reads_a_disabled_one) continue; channel.enabled = false; off.push_back(channel.name); moved = true; break; } } } return off; } /// Takes a signal off every plot, and says how many traces went. /// /// Switching a signal off means "do not acquire this", so a trace of it left on a plot draws nothing: a slot /// that reads `--`, takes the place a live signal could have had, and blocks the drop that would fill it. The /// slot is what is cleared, and the caller does the follow-ups once (a relayout, the panels, the layout save) /// rather than per slot. int remove_signal_from_plots(App& app, const std::string& name) { if (name.empty()) return 0; int removed = 0; for (int row = 0; row < app.rows->row_count(); ++row) { for (int slot = 0; slot < RowSet::kSlotsPerRow; ++slot) { // The slot remembers the *signal* (and its element), never the channel index -- the channel list is // renumbered on every enable change, which is what this is reacting to. if (app.rows->row(row).signal_name[static_cast(slot)] != name) continue; remove_trace(app, row, slot); ++removed; } } return removed; } void on_remove_clicked(GtkButton* button, gpointer user) { (void)user; auto* target = static_cast(g_object_get_data(G_OBJECT(button), "udpscope-remove")); if (target == nullptr) return; App& app = *target->app; const RowSet::Row& r = app.rows->row(target->row); std::string what; if (target->slot == RemoveTarget::kAllSlots) { int removed = 0; for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { if (r.channel[static_cast(s)] < 0) continue; remove_trace(app, target->row, s); ++removed; } what = "removed " + std::to_string(removed) + (removed == 1 ? " trace" : " traces") + " from plot " + std::to_string(target->row + 1); } else { const std::string name = r.name[static_cast(target->slot)]; remove_trace(app, target->row, target->slot); what = "removed '" + name + "' from plot " + std::to_string(target->row + 1); } app.relayout_pending = true; rebuild_signal_panels(app); // the signal's chip goes grey with it app.notice = what; app.notice_until_us = g_get_monotonic_time() + 4 * G_TIME_SPAN_SECOND; GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(button), "udpscope-popover")); if (popover != nullptr) gtk_popover_popdown(popover); } /// Which of the plot menu's two views is showing. The second **replaces** the first, with a Back /// row at the top, rather than floating beside it: a submenu torn out of the menu it belongs to is /// a second thing to dismiss, and the `GtkMenuButton` that owned one toggled itself out of sync the /// moment its popover was closed by anything else -- the row stopped opening at all on the second /// click. /// The plot menu's own state, one per open menu: which view it shows, and the box it builds its rows /// into. Owned by the popover (see `open_row_menu`), so a dismissed menu takes it with it. /// A `menu_button` with a leading icon: the Back row's arrow, which every GNOME menu puts before /// its label rather than after. GtkWidget* menu_icon_button(const char* icon, const char* label) { GtkWidget* button = menu_button(label, nullptr); if (GtkWidget* box = gtk_button_get_child(GTK_BUTTON(button))) { gtk_box_prepend(GTK_BOX(box), gtk_image_new_from_icon_name(icon)); } return button; } void on_row_menu_show_remove(GtkButton*, gpointer user) { RowMenuState& st = *static_cast(user); st.view = RowMenuView::Remove; row_menu_fill(st); // The popover's content changed size, which is what `present()` is for: it re-allocates and // re-positions a popover that is already up. gtk_popover_present(GTK_POPOVER(st.popover)); } void on_row_menu_back(GtkButton*, gpointer user) { RowMenuState& st = *static_cast(user); st.view = RowMenuView::Main; row_menu_fill(st); gtk_popover_present(GTK_POPOVER(st.popover)); } /// Builds the rows for `st.view`. Rebuilt rather than swapped between two boxes: the rows are a /// handful of widgets and this happens on a click, and a box held aside for later would have to be /// kept alive through the popover's own teardown. void row_menu_fill(RowMenuState& st) { while (GtkWidget* child = gtk_widget_get_first_child(st.box)) { gtk_box_remove(GTK_BOX(st.box), child); } App& app = *st.app; const int row = st.row; // Each row knows which row of the figure it acts on and which popover dismisses when it is done. const auto add_row = [&](const char* label, const char* accel, GCallback handler, gpointer user) { GtkWidget* b = menu_button(label, accel); set_row_key(b, row); g_object_set_data(G_OBJECT(b), "udpscope-popover", st.popover); if (handler != nullptr) g_signal_connect(b, "clicked", handler, user); gtk_box_append(GTK_BOX(st.box), b); return b; }; // The heading is a row like the rest but not a button: it names what the view acts on. const auto add_heading = [&](const char* text) { GtkWidget* heading = gtk_label_new(nullptr); gtk_label_set_markup(GTK_LABEL(heading), text); gtk_label_set_xalign(GTK_LABEL(heading), 0.0f); gtk_widget_set_margin_start(heading, 8); gtk_widget_set_margin_bottom(heading, 4); gtk_box_append(GTK_BOX(st.box), heading); }; if (st.view == RowMenuView::Main) { char markup[64]; std::snprintf(markup, sizeof(markup), "Plot %d", row + 1); add_heading(markup); add_row("Y axis: auto scale", nullptr, G_CALLBACK(on_y_auto_clicked), &app); add_row("Y axis: fixed range...", nullptr, G_CALLBACK(on_y_fixed_clicked), &app); // Fitting is a per-plot gesture in the menu as it is on the screen: the two do the same thing // whatever row they were opened on (the x window is shared and the y fit walks them all), which // is what "fit" means here. `F` is the plot's own key for the second of them. add_row("Fit X axis", nullptr, G_CALLBACK(on_fit_x_clicked), &app); add_row("Fit all", "F", G_CALLBACK(on_fit_clicked), &app); gtk_box_append(GTK_BOX(st.box), menu_separator()); // A row, not a `GtkMenuButton`: the removals are the *next view of this menu*, and the chevron // is what says so. GtkWidget* remove_row = add_row("Remove signal", nullptr, G_CALLBACK(on_row_menu_show_remove), &st); if (GtkWidget* inner = gtk_button_get_child(GTK_BUTTON(remove_row))) { gtk_box_append(GTK_BOX(inner), gtk_image_new_from_icon_name("pan-end-symbolic")); } return; } // The way out, with the arrow every menu puts before a Back row's label. GtkWidget* back = menu_icon_button("go-previous-symbolic", "Back"); set_row_key(back, row); g_object_set_data(G_OBJECT(back), "udpscope-popover", st.popover); g_signal_connect(back, "clicked", G_CALLBACK(on_row_menu_back), &st); gtk_box_append(GTK_BOX(st.box), back); gtk_box_append(GTK_BOX(st.box), menu_separator()); const RowSet::Row& r = app.rows->row(row); int traces = 0; for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { if (r.channel[static_cast(s)] < 0) continue; ++traces; GtkWidget* b = add_row(r.name[static_cast(s)].c_str(), nullptr, G_CALLBACK(on_remove_clicked), nullptr); auto* target = new RemoveTarget{&app, row, s}; g_object_set_data_full(G_OBJECT(b), "udpscope-remove", target, [](gpointer p) { delete static_cast(p); }); } if (traces > 1) { GtkWidget* all = add_row("All traces on this plot", nullptr, G_CALLBACK(on_remove_clicked), nullptr); auto* target = new RemoveTarget{&app, row, RemoveTarget::kAllSlots}; g_object_set_data_full(G_OBJECT(all), "udpscope-remove", target, [](gpointer p) { delete static_cast(p); }); } if (traces == 0) { GtkWidget* empty = gtk_label_new("nothing to remove"); gtk_widget_add_css_class(empty, "dim-label"); gtk_label_set_xalign(GTK_LABEL(empty), 0.0f); gtk_widget_set_margin_start(empty, 8); gtk_box_append(GTK_BOX(st.box), empty); } } /// The one entry point for the plot's context menu, so the probe can build it without a gesture: /// `on_right_click()` is the gesture's wrapper around this, and nothing else. Returns the popover, /// which is what the probe measures. GtkWidget* open_row_menu(App& app, int row, double x, double y, GtkWidget* parent, bool autohide) { GtkWidget* popover = gtk_popover_new(); gtk_widget_add_css_class(popover, "udpscope-menu"); GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); gtk_widget_set_margin_top(box, 6); gtk_widget_set_margin_bottom(box, 6); gtk_widget_set_margin_start(box, 6); gtk_widget_set_margin_end(box, 6); auto* st = new RowMenuState; st->app = &app; st->row = row; st->popover = popover; st->box = box; // Owned by the popover: a dismissed menu frees its state with it, and the `closed` handler that // unparents the popover is what does the dismissing. g_object_set_data_full(G_OBJECT(popover), "udpscope-row-menu", st, [](gpointer p) { delete static_cast(p); }); row_menu_fill(*st); // Autohide off *for the probe*: a popover dismisses itself when it loses the grab, and under a // bare `xvfb-run` there is no window manager to hand it one -- so the rows would be measured // before they were ever mapped. The app's own menu keeps autohide on. gtk_popover_set_autohide(GTK_POPOVER(popover), autohide ? TRUE : FALSE); gtk_popover_set_child(GTK_POPOVER(popover), box); GdkRectangle rect{static_cast(x), static_cast(y), 1, 1}; gtk_popover_set_pointing_to(GTK_POPOVER(popover), &rect); gtk_widget_set_parent(popover, parent); gtk_popover_popup(GTK_POPOVER(popover)); // NOT g_signal_connect_swapped: that hands the callback its user_data as the first argument, so // `on_popover_closed` would receive nullptr instead of the popover and both the unparent and the // unref would fail with a Gtk-CRITICAL. g_signal_connect(popover, "closed", G_CALLBACK(on_popover_closed), nullptr); return popover; } void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer user) { App* app = static_cast(user); GtkWidget* widget = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(gesture)); const int row = row_at(*app, x, y); // Claim the sequence: without this the button *release* arrives while the popover is up, counts // as a click outside it, and dismisses the menu before it can be read -- the menu flashes and // vanishes. gtk_gesture_set_state(GTK_GESTURE(gesture), GTK_EVENT_SEQUENCE_CLAIMED); cancel_drag_on(*app, row); open_row_menu(*app, row, x, y, widget); } void on_side_tab_changed(GObject* o, GParamSpec*, gpointer user) { App* app = static_cast(user); const char* name = adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(o)); app->side_tab = name != nullptr ? name : "signals"; // Not while the layout is being applied: the restore would schedule a save of what it just read. if (!app->updating_panels) schedule_layout_save(*app); } void select_side_tab(App& app, const char* name) { if (app.side_stack == nullptr || name == nullptr) return; // An unknown name leaves the stack where it is -- a session file from a later version must not // be able to blank the sidebar. if (adw_view_stack_get_child_by_name(ADW_VIEW_STACK(app.side_stack), name) == nullptr) return; adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(app.side_stack), name); app.side_tab = name; } /// The side column is on when it has something to show *and* the user's sidebar /// button is in; the two panel buttons appear as soon as a CONFIG describes any /// signal at all. void update_side_visibility(App& app) { if (app.side_stack == nullptr) return; const bool have_signals = !app.model->signals().empty(); // `have_signals` decides whether the sidebar *can* be on; `sidebar_wanted` is the user's last word, // which is why it is kept apart from the button's own state -- that state follows the panel now, and // the panel is off while there is nothing to show. A swipe that hides the sidebar fires the button's // handler (the binding is bidirectional), so a gesture is a choice too. gtk_widget_set_sensitive(app.sidebar_toggle, have_signals); const bool want_column = have_signals && app.sidebar_wanted; // The split view owns the sidebar's visibility: it animates it, and when the window is too narrow it // shows it as an overlay rather than letting the column be squeezed to nothing. if (app.side_view != nullptr) { app.sidebar_syncing = true; adw_overlay_split_view_set_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(app.side_view), want_column); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.sidebar_toggle), want_column); app.sidebar_syncing = false; } gtk_widget_set_visible(app.trigger_toggle, have_signals); } void on_sidebar_toggled(GtkToggleButton* button, gpointer user) { App* app = static_cast(user); // Only a state we did not write is a wish: `update_side_visibility` sets this button (and the panel) // from `sidebar_wanted`, so an unguarded change here is the user's. if (!app->sidebar_syncing) app->sidebar_wanted = gtk_toggle_button_get_active(button) != FALSE; update_side_visibility(*app); } // --- rebuild of the derived panels ----------------------------------------- /// The width and height the root `` element of `data` declares, or 0x0 when it has none. /// GMarkup rather than a string scan because the attributes may be in any order and on any /// line; it is *not* a validity check -- GMarkup is a deliberately lenient subset parser and /// accepts a double hyphen inside a comment, which is exactly the file librsvg refuses. That /// check is `xml-stripblanks` in the GResource manifest, at build time. void svg_declared_size(const char* data, gsize size, int* w, int* h) { struct Found { int w = 0; int h = 0; }; Found found; GMarkupParser parser{}; parser.start_element = [](GMarkupParseContext*, const char* element, const char** names, const char** values, gpointer user, GError**) { if (std::strcmp(element, "svg") != 0) return; Found* f = static_cast(user); for (int i = 0; names[i] != nullptr; ++i) { if (std::strcmp(names[i], "width") == 0) f->w = std::atoi(values[i]); if (std::strcmp(names[i], "height") == 0) f->h = std::atoi(values[i]); } }; GMarkupParseContext* ctx = g_markup_parse_context_new(&parser, GMarkupParseFlags(0), &found, nullptr); if (g_markup_parse_context_parse(ctx, data, static_cast(size), nullptr)) { g_markup_parse_context_end_parse(ctx, nullptr); } g_markup_parse_context_free(ctx); *w = found.w; *h = found.h; } /// One entry of the `app icons:` line: the icon is *named* (the theme resolved it, so the /// resource path was registered) and *present* (the bytes the name resolved to are really in /// the binary), at the size its own root element declares. /// /// It deliberately does not claim the file renders. `gtk_icon_theme_lookup_icon()` never /// returns NULL, its paintable's intrinsic size is the size that was asked for, and a valid /// icon, a malformed one and a name that does not exist at all all snapshot to the same /// texture node -- GTK substitutes `image-missing` and there is nothing left to compare. The /// malformed case is a build error instead; see the GResource manifest. std::string icon_report(GtkIconTheme* theme, const char* name, const char* resource) { const bool named = gtk_icon_theme_has_icon(theme, name) != FALSE; GBytes* bytes = g_resources_lookup_data(resource, G_RESOURCE_LOOKUP_FLAGS_NONE, nullptr); const bool present = bytes != nullptr; int w = 0; int h = 0; if (present) { gsize size = 0; const char* data = static_cast(g_bytes_get_data(bytes, &size)); svg_declared_size(data, size, &w, &h); g_bytes_unref(bytes); } std::string out = std::string(name) + "("; out += (named && present) ? "ok " : "MISSING "; out += std::to_string(w) + "x" + std::to_string(h) + ")"; return out; } /// Where the panel controls live, as their parents say: the three pane toggles are toolbar buttons /// and Reset is inside the cursors pane. Recorded on the first tick -- the widgets exist and the /// window is alive, neither of which is true when the dump runs -- so the dump can print a fact /// instead of asking GTK about a destroyed widget. /// The words the pills do not draw. The pages' `title` is empty on purpose (see the `.ui`), so the /// human name of a tab lives here and is put on the pill — its tooltip and its accessible name — by /// `name_side_tabs()`. That keeps an icon-only pill a *named* control for a screen reader. static const char* side_tab_title(const char* name) { if (name != nullptr && std::strcmp(name, "measure") == 0) return "Measure"; if (name != nullptr && std::strcmp(name, "signals") == 0) return "Signals"; return ""; } /// Names the switcher's pills and takes their empty labels out of the layout: an empty `GtkLabel` /// still asks for its own width, which leaves the icon off-centre in a pill meant to be one glyph. /// Called from the first tick, because the pills are `AdwViewSwitcher`'s own and exist only once the /// widget has been mapped. void name_side_tabs(App& app) { if (app.side_tabs_named || app.side_switcher == nullptr) return; GListModel* pages = app.side_stack != nullptr ? G_LIST_MODEL(adw_view_stack_get_pages(ADW_VIEW_STACK(app.side_stack))) : nullptr; // The switcher's children *are* its pills, in page order, and `AdwViewSwitcherButton` is not a // public type (there is no `ADW_IS_VIEW_SWITCHER_BUTTON`), so the two lists are walked together by // index rather than checked by type. GtkWidget* pill = gtk_widget_get_first_child(app.side_switcher); for (guint i = 0; pages != nullptr && pill != nullptr && i < g_list_model_get_n_items(pages); pill = gtk_widget_get_next_sibling(pill), ++i) { AdwViewStackPage* page = ADW_VIEW_STACK_PAGE(g_list_model_get_item(pages, i)); const char* title = side_tab_title(adw_view_stack_page_get_name(page)); g_object_unref(page); if (title[0] == '\0') continue; gtk_widget_set_tooltip_text(pill, title); gtk_accessible_update_property(GTK_ACCESSIBLE(pill), GTK_ACCESSIBLE_PROPERTY_LABEL, title, -1); const std::function hide_labels = [&](GtkWidget* w) { if (GTK_IS_LABEL(w)) gtk_widget_set_visible(w, FALSE); for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr; c = gtk_widget_get_next_sibling(c)) { hide_labels(c); } }; hide_labels(pill); // The tabs' glyphs are 32x16 documents, and the pill has the width for them: drawn at 32 they are // 1:1, so both motifs sit side by side at the size they were authored at and the 2px bars stay // crisp. A 16px box would scale the pair down to a smear, and a fractional scale would blur it. const std::function size_images = [&](GtkWidget* w) { if (GTK_IS_IMAGE(w)) gtk_image_set_pixel_size(GTK_IMAGE(w), 32); for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr; c = gtk_widget_get_next_sibling(c)) { size_images(c); } }; size_images(pill); app.side_tabs_named = true; } } void note_panel_controls(App& app) { if (!app.dbg_panel_controls.empty()) return; const auto in_bar = [](GtkWidget* w) { for (GtkWidget* p = w != nullptr ? gtk_widget_get_parent(w) : nullptr; p != nullptr; p = gtk_widget_get_parent(p)) { if (GTK_IS_HEADER_BAR(p)) return 1; } return 0; }; const auto in_cursors_pane = [&app](GtkWidget* w) { for (GtkWidget* p = w != nullptr ? gtk_widget_get_parent(w) : nullptr; p != nullptr; p = gtk_widget_get_parent(p)) { if (p == app.cursors_panel) return 1; } return 0; }; // `HeaderBar::pack_end()` parents into an internal box, so the bar is a level up, not the parent. char text[256]; std::snprintf(text, sizeof(text), "cursors=%d in the toolbar, switcher=%d, reset=%d in the cursors pane", in_bar(app.cursors_toggle), app.side_switcher != nullptr ? 1 : 0, in_cursors_pane(app.cursors_reset)); // ... and the order the bar actually shows, which is what "the menu should be last" is about. A // widget tree walked in child order *is* the layout order, so this is the reading order of the // controls the user sees -- the only way to check an arrangement without a screenshot. const std::pair named[] = { {app.trigger_toggle, "trigger"}, {app.events_toggle, "events"}, {app.pause_button, "pause"}, {app.cursors_toggle, "cursors"}, {app.sidebar_toggle, "sidebar"}, {app.menu_button, "menu"}, }; // The widget tree's child order, which *is* the visual order left to right: a box lays its children // out in the order it holds them. Since `pack_end()` puts the *first* packed furthest right, this // reads as the reverse of the pack order -- which is the whole reason the line exists, because the // pack order alone is not what the user sees. std::string order; const std::function walk = [&](GtkWidget* w) { for (const auto& [widget, name] : named) { if (w == widget) order += std::string(order.empty() ? "" : " ") + name; } for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr; c = gtk_widget_get_next_sibling(c)) { walk(c); } }; if (app.toolbar != nullptr) walk(app.toolbar); app.dbg_panel_controls = std::string(text) + "; toolbar, left to right: " + order; // ... and the icon each of them carries, asked of the theme rather than of our own // string. Four of these are drawn for this app and live in a GResource inside the // binary, which the theme only looks in because `run_ui()` adds the resource path // before building anything -- and a path that was never added fails *silently*: the // button keeps working, the layout is unchanged, and GTK draws a missing-image // placeholder no probe can see. The two app icons below come out of the same // registration, which is why a MISSING on either line is one defect. GtkIconTheme* theme = gtk_icon_theme_get_for_display(gdk_display_get_default()); for (const auto& [widget, name] : named) { const char* icon = nullptr; if (GTK_IS_BUTTON(widget)) { icon = gtk_button_get_icon_name(GTK_BUTTON(widget)); } else if (GTK_IS_MENU_BUTTON(widget)) { icon = gtk_menu_button_get_icon_name(GTK_MENU_BUTTON(widget)); } if (icon == nullptr) continue; app.dbg_toolbar_icons += std::string(app.dbg_toolbar_icons.empty() ? "" : " ") + name + "=" + icon + (gtk_icon_theme_has_icon(theme, icon) ? "(ok)" : "(MISSING)"); } // The application icon, the same two ways and for the same reason: a resource path that was // never registered is not an error, it is a missing-image placeholder. This one is read on // the first tick like the line above, because the dump runs after the window is destroyed. app.dbg_app_icons = icon_report(theme, "org.udpscope.app", "/org/udpscope/icons/scalable/apps/org.udpscope.app.svg") + " " + icon_report(theme, "org.udpscope.app-symbolic", "/org/udpscope/icons/scalable/apps/org.udpscope.app-symbolic.svg"); // The two sidebar pages' own icons, named and resolved the same way, and what the switcher is // drawing for them. The pills are `AdwViewSwitcher`'s own buttons, built when the stack is mapped, // so this reads on the first tick: `pills` is the guard (0 pills would make a label count of 0 // meaningless) and `labels` must be 0, because the switcher's policy is `narrow` -- the pages are // icons, not words, in a 300px column. // Walked through `get_pages()`, not the widget tree: an `AdwViewStackPage` is a plain `GObject` // (only its `child` is a widget), so `gtk_widget_get_first_child()` on the stack yields the pages' // scrolled windows and this line read empty the first time it was written. GListModel* pages = app.side_stack != nullptr ? G_LIST_MODEL(adw_view_stack_get_pages(ADW_VIEW_STACK(app.side_stack))) : nullptr; for (guint i = 0; pages != nullptr && i < g_list_model_get_n_items(pages); ++i) { AdwViewStackPage* page = ADW_VIEW_STACK_PAGE(g_list_model_get_item(pages, i)); const char* page_name = adw_view_stack_page_get_name(page); const char* icon = adw_view_stack_page_get_icon_name(page); app.dbg_page_icons += std::string(app.dbg_page_icons.empty() ? "" : " ") + (page_name != nullptr ? page_name : "?") + "=" + (icon != nullptr ? icon : "-") + (icon != nullptr && gtk_icon_theme_has_icon(theme, icon) ? "(ok)" : "(MISSING)"); g_object_unref(page); } // `mapped`, not `visible`: visibility is per widget, so a label whose *parent* is hidden still // reads visible, and in the narrow policy that is exactly the case. `mapped` requires every // ancestor, which is what "on screen" means -- and it is why the pill count is read the same way: // if nothing is mapped the pill count reads 0 and the label count means nothing, rather than // passing vacuously. int pills = 0; int drawn_labels = 0; const std::function count_drawn = [&](GtkWidget* w) { if (GTK_IS_TOGGLE_BUTTON(w) && gtk_widget_get_mapped(w)) ++pills; // A *mapped* label with text: libadwaita's narrow pill keeps an empty label in the layout, so // "mapped" alone would count the empty ones and read 2 whether the pills were icons or words. if (GTK_IS_LABEL(w) && gtk_widget_get_mapped(w) && gtk_label_get_text(GTK_LABEL(w))[0] != '\0') { ++drawn_labels; } for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr; c = gtk_widget_get_next_sibling(c)) { count_drawn(c); } }; if (app.side_switcher != nullptr) count_drawn(app.side_switcher); app.dbg_switcher_pills = pills; app.dbg_switcher_labels = drawn_labels; int named_pills = 0; for (GtkWidget* w = app.side_switcher != nullptr ? gtk_widget_get_first_child(app.side_switcher) : nullptr; w != nullptr; w = gtk_widget_get_next_sibling(w)) { const char* tip = gtk_widget_get_tooltip_text(w); if (tip != nullptr && tip[0] != '\0') ++named_pills; } app.dbg_switcher_names = named_pills; if (app.side_switcher != nullptr) { g_object_get(G_OBJECT(app.side_switcher), "policy", &app.dbg_switcher_policy, nullptr); } } /// The event-zoom sweep's two halves: a gesture zoom, then the same walk through the window /// control. The second is the tail of the countdown. /// Headless self-check: what `Renderer::draw_traces` would gather for row 0's first bound /// trace at the axis the frame settled on, recomputed through the same calls it makes. /// /// The report this exists for is "zooming into a held event clips the trace further and /// further from the left, while the crosshair still reads the right values there". The /// crosshair reads `x_at()`, so a symptom that only the *ink* has localises to the index /// window and the gather -- and a blank margin that grows as a fraction of the screen while /// the view narrows is the signature of a roughly constant shift in *data* space. So the /// line prints both sides of the comparison in data units and in pixels: /// `axis=` is what the frame drew, `ink=` is the first and last gathered x reconstructed the /// renderer's way (`x0() + gather`), and `gap_px=` is how many pixels of the left margin no /// geometry reaches. `gap_px` must stay at a pixel or two at every zoom. void report_event_zoom(App& a, const char* what) { if (a.rows->row_count() == 0 || a.rows->row(0).plot == nullptr) return; const RowSet::Row& r0 = a.rows->row(0); const plot::data::Series* s = nullptr; for (int i = 0; i < RowSet::kSlotsPerRow; ++i) { const std::size_t si = static_cast(i); if (r0.channel[si] < 0) continue; s = &r0.slots[si]; break; } const plot::Range axis = r0.plot->x_range(r0.axis_x); const plot::Rect axes = r0.plot->layout().axes; const double axes_w = double(axes.w); if (s == nullptr || s->size() == 0 || !(axes_w > 0.0)) { std::fprintf(stderr, "[zoom-probe] %-8s axis=[%.6f..%.6f] no trace\n", what, double(axis.min), double(axis.max)); std::fflush(stderr); return; } const plot::u64 n = s->size(); plot::u64 i0 = 0, i1 = 0; plot::u64 first = 0; plot::u64 last = n; if (s->neighbours(axis.min, i0, i1)) first = i0 > 0 ? i0 - 1 : 0; if (s->neighbours(axis.max, i0, i1)) last = std::min(n, i1 + 1); if (last <= first) { std::fprintf(stderr, "[zoom-probe] %-8s axis=[%.6f..%.6f] empty window [%llu..%llu)\n", what, double(axis.min), double(axis.max), (unsigned long long)first, (unsigned long long)last); std::fflush(stderr); return; } // `dense_threshold_spp` is the library's own default (64): above it the renderer takes a // pyramid level, below it raw samples, and which side of the gate a step lands on is part // of what this line reports. const double spp = double(last - first) / axes_w; const plot::u32 level = spp >= 64.0 ? s->pyramid().level_for(n, spp) : 0u; std::vector xy; // `axis.min` is the origin the renderer names, so every reconstruction below is its own -- // and at the deep end of this sweep that choice is the whole point: relative to the retained // window instead, the f32's ULP would be a large fraction of these spans. s->gather(level, first, last, axis.min, xy); double ink_lo = 0.0, ink_hi = 0.0; if (xy.size() >= 2) { ink_lo = axis.min + double(xy[0]); ink_hi = axis.min + double(xy[xy.size() - 2]); } // The renderer's own increasing-suffix filter, counted rather than applied: it drops every // point that is not strictly older than the one kept after it, which for a *run* of equal x // keeps the last of the run. The stamps here are f32, so once the sample spacing falls below // the f32 step at the *gather origin's* magnitude the gathered x has such runs -- and `kept` // is how many distinct positions the polyline is left with. A `kept` far below `pts` is a // staircase, and a `kept` of a handful is a trace with nothing to draw. This is the other // reading the origin change buys: the step is now 2^-24 of the span on screen, so a sweep // that used to grind `kept` down as it zoomed keeps every point. std::size_t kept = 0; double worst_back = 0.0; // the largest backwards step, in data units: non-monotonic gather { double next = std::numeric_limits::infinity(); for (std::size_t k = xy.size() / 2; k > 0; --k) { const double x = double(xy[2 * (k - 1)]) + axis.min; if (!(x < next)) { worst_back = std::max(worst_back, x - next); continue; } next = x; ++kept; } } const double span = axis.span() > 0.0 ? axis.span() : 1.0; std::fprintf(stderr, "[zoom-probe] %-8s axis=[%.6f..%.6f] span=%.6g n=%llu idx=[%llu..%llu) spp=%.1f " "level=%u pts=%zu kept=%zu back=%.3g ink=[%.6f..%.6f] x_at_first=%.6f " "gap_px=%.1f end_px=%.1f rect=[%g,%g,%gx%g]\n", what, double(axis.min), double(axis.max), axis.span(), (unsigned long long)n, (unsigned long long)first, (unsigned long long)last, spp, level, xy.size() / 2, kept, worst_back, ink_lo, ink_hi, s->x_at(first), (ink_lo - axis.min) / span * axes_w, (axis.max - ink_hi) / span * axes_w, double(axes.x), double(axes.y), double(axes.w), double(axes.h)); std::fflush(stderr); } /// The reserved file's size for a ring of `slots`: the 64-byte header plus one slot per /// datagram. A constant for a given N, which is why the popover and the tooltip can state /// it without touching the file. std::uint64_t capture_file_bytes(std::uint32_t slots) { return 64 + std::uint64_t(slots) * kSlotBytes; } /// `$HOME` -> `~`, purely so the popover row and the tooltip stay readable. std::string abbrev_home(const std::string& path) { const std::string home = g_get_home_dir(); if (!home.empty() && path.rfind(home, 0) == 0) return "~" + path.substr(home.size()); return path; } /// The toggle's tooltip: what it does, where the ring lands, what it costs. void update_capture_tooltip(App& app) { if (app.capture_button == nullptr) return; const std::uint32_t slots = app.acq != nullptr && app.acq->capture().slot_count() > 0 ? app.acq->capture().slot_count() : 1000; char tip[512]; std::snprintf(tip, sizeof(tip), "Store the last %u raw datagrams on disk for forensic analysis\n" "%s (%.1f MiB reserved) -- off unless toggled, and never in the way of a packet", slots, abbrev_home(app.acq->capture().path()).c_str(), double(capture_file_bytes(slots)) / 1048576.0); gtk_widget_set_tooltip_text(app.capture_button, tip); } /// The capture row's three faces. The path is abbreviated with `~` for `$HOME` -- the /// row's job is to be recognisable, not to be copied -- and the file's size is stated /// in slots and MiB, because the reserve is the one cost of switching the feature on. void update_capture_row(App& app) { if (app.popover_label[12] == nullptr) return; const PacketCapture& cap = app.acq->capture(); char err[128]; cap.error(err, sizeof(err)); std::string text = "off"; if (err[0] != '\0') { text = std::string("failed: ") + err; } else if (cap.enabled()) { char row[512]; std::snprintf(row, sizeof(row), "on · %llu/%u slots · %.1f MiB · %s", static_cast(cap.stored()), cap.slot_count(), double(capture_file_bytes(cap.slot_count())) / 1048576.0, abbrev_home(cap.path()).c_str()); text = row; } else if (app.capture_want_ui) { text = "opening..."; } gtk_label_set_text(GTK_LABEL(app.popover_label[12]), text.c_str()); } /// Loads an event's preview into the pane's picture. /// /// Decoded *here*, synchronously, rather than through `gtk_picture_set_filename()`: that path goes /// through the image loader's sandboxed, asynchronous decoder, which on this machine fails silently /// and leaves an allocated but empty picture -- a picture the probe sees a paint-able on and a user /// sees nothing in. `gdk_texture_new_from_filename()` decodes the same file on this thread. /// One event file's columns and stamps, in memory. Both group layouts need two or more of them at /// once, and the ordinary loader cannot serve that: it *rebuilds* the model's buffers, so a second /// load takes the first with it. /// Decoded event previews, keyed by file. The map belongs to the list widget, so it is released with /// it, and every texture in it is a reference this file owns. // --- the frame tick --------------------------------------------------------- gboolean tick(gpointer user) { App* app = static_cast(user); if (app->window == nullptr) return G_SOURCE_REMOVE; // Before the reading, and not after it: the pills are `AdwViewSwitcher`'s own widgets and exist // only once the switcher is mapped, so this is the first frame that can name them -- and their // page titles are deliberately empty, so a frame that read before this ran would read pills with // no text and no name either. name_side_tabs(*app); note_panel_controls(*app); ScopeModel& model = *app->model; const gint64 tick_start_us = g_get_monotonic_time(); // The paint probe's clock, claimed on the first tick that finds one: a widget has no // frame clock until it is realized, and the area is realized on its first draw. if (app->dbg_clock == nullptr && app->area_widget != nullptr) { if (GdkFrameClock* fc = gtk_widget_get_frame_clock(app->area_widget)) { app->dbg_clock = fc; g_signal_connect(fc, "before-paint", G_CALLBACK(on_before_paint), app); g_signal_connect(fc, "after-paint", G_CALLBACK(on_after_paint), app); } } // The trace look, stated on the first tick that finds a renderer -- there is none until // the area is realized, and this is the same "claim it when it exists" shape as the frame // clock above. `intensity_grading` is off in the library by default (it is a change of // look, not a correction, and other hosts must not be restyled by an upgrade); an // oscilloscope is exactly the host it is for, so udpscope turns it on. A one-shot rather // than a per-frame write because `Style` is plain data and nothing else touches it. if (!app->style_stated && app->area != nullptr) { if (plot::Renderer* r = app->area->renderer()) { // `UDPSCOPE_NO_PHOSPHOR` puts it back to the library's default, which is the only way // to read `frame cost:` and `frame path:` against the ungraded path -- a frame mean // cannot attribute its own cost, so the A/B is the measurement. r->style().intensity_grading = g_getenv("UDPSCOPE_NO_PHOSPHOR") == nullptr; app->style_stated = true; } } if (app->relayout_pending) { app->relayout_pending = false; relayout_areas(*app); } // Header geometry, once a second: the window control must be a fixed, small // widget beside the title whatever the bar's width is (it used to expand through // the title's centre box, i.e. window_button_w tracked the window width). if (app->window_button != nullptr) { static gint64 next_header_log = 0; const gint64 now = g_get_monotonic_time(); if (now >= next_header_log) { next_header_log = now + G_TIME_SPAN_SECOND; app->dbg_win_btn_w = gtk_widget_get_width(app->window_button); app->dbg_bar_w = gtk_widget_get_width(app->window); std::fprintf(stderr, "[udpscope] header: window_button_w=%d bar_w=%d\n", app->dbg_win_btn_w, app->dbg_bar_w); std::fflush(stderr); } } // Captured every frame so the dump has the last live value: see `dbg_measures_pane_w`. if (app->measures_list != nullptr) { app->dbg_measures_pane_w = gtk_widget_get_width(app->measures_list); app->dbg_measures_pane_h = gtk_widget_get_height(app->measures_list); } // A few milliseconds of scanning per frame: enough that a full buffer finishes in a // handful of seconds, little enough that neither the producer (which waits on the mutex // for one chunk) nor the frame budget notices. if (app->search.state() == BufferSearch::State::Running) { const gint64 budget_end_us = g_get_monotonic_time() + 5000; while (app->search.pump(*app->model) && g_get_monotonic_time() < budget_end_us) { } update_search_panel(*app); } if (model.generation() != app->signal_generation) { // A CONFIG re-describes the stream and drops the buffer, so nothing measured before it // belongs in the same series as what comes after. The search shares the same premise: // the ring it was scanning no longer exists. reset_measure_stats(*app); app->search.clear(); 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->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(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, /*force=*/false, /*accumulate=*/true); // 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 // renames or renumbers; the user's layout survives those untouched). if (!app->layout_loaded && !model.signals().empty()) { app->layout_loaded = true; LayoutState st; app->config_name = active_configuration(app->source_host, app->source_port); const std::string path = configuration_path(app->source_host, app->source_port, app->config_name); if (load_layout(path, st)) { apply_layout(*app, st); app->config_comment = st.comment; std::fprintf(stderr, "[udpscope] layout loaded from %s\n", path.c_str()); std::fflush(stderr); } } // ... and *after* it, which is the point: the block above ends in `apply_time_window()` with the // window the session file holds, so a restore done before it would be overwritten by the saved // value. The generation is the other half of the wait -- the stream's first CONFIG, which is what // gives the stored trigger channel a list to be found in. // The wait is for the stream's own channel list, and the question it is really asking is whether the // trigger's channel *name* can be resolved again: a file view that renamed its channels (`square (A)`) // has to be gone before the name means the signal it meant. Asking that directly, rather than watching // `generation()`, is what makes going live immediate in the common case -- a single event keeps the // stream's own channel names, so its channel list is already the right one, and the generation of a // restarted client whose CONFIG is identical is *deliberately* unchanged (`same_stream_signals()`), so // the restore used to wait for something that was never going to happen. What it took to shake loose // was the trigger toggle, which is a structural change and moved the generation -- the "toggle it a few // times" this fixes. if (app->live_restore_pending && !app->replaying && !model.channels().empty()) { bool resolvable = app->live_state.trigger_channel.empty(); for (const ChannelInfo& c : model.channels()) { if (c.name == app->live_state.trigger_channel) resolvable = true; } if (resolvable) { app->live_restore_pending = false; restore_live_state(*app); } } // What the display shows, decided once for the whole frame: the producer can open or // close a capture between any two reads of the model, and the bind, the epoch, the pin // and the status line all have to describe the same picture. const DisplayMode display = model.display_mode(); app->dbg_display = display; // the paint probe names the state rather than re-reading it const double cap_edge = model.capture_edge(); const bool new_capture = display == DisplayMode::Capturing && (!app->capturing || cap_edge != app->capture_edge); app->capturing = display == DisplayMode::Capturing; if (new_capture) app->capture_edge = cap_edge; { // Serialise against the acquisition thread's CONFIG handling and against the // UI's own structural changes: the bind takes shared handles into the live // store, and acquire() reads it. std::lock_guard lock(model.mutex()); app->rows->bind(model, display); app->rows->acquire(model); if (app->comparing && app->rows_b != nullptr) { app->rows_b->bind(model, display); app->rows_b->acquire(model); } // What x = 0 means, written *after* the bind and the acquire on purpose: the live answer // is minus the newest sample the traces can now report, and asking before they were bound // would put the newest data a frame's production to the right of the axis -- a third of // the screen at 1 MS/s over a 50 ms window. The epoch is only read on the consumer side // (it offsets the live channels' x), so nothing between here and the next frame's bind // depends on it having been set earlier. It is written under the lock because a rebuild // can replace the store it is pushed into. // // Three cases, one rule -- `display_x = stream_t + epoch`: // * streaming, the newest stored sample sits at x = 0, so the axis is a fixed // [-window, 0] and the data flows leftwards through it. A scope's time base is "how // long ago", and the stream's own clock is an uptime nobody reads off an axis. // * mid-capture, the coming event's edge sits at x = 0 exactly as the published // snapshot's will, so the pinned [-pre, +post] means the same thing in both and the // picture does not jump at the latch. // * with a *snapshot* on screen (a held event, a paused view) the live channels are // bound to nothing and the snapshot carries its own `x_offset`, so the epoch is 0 and // `ScopeModel::stream_at_x0()` is what crosses between the clocks. double want_epoch = 0.0; if (!app->waveform_name.empty()) { // A file *is* an event: an export's stamps are already trigger-relative, so the trigger instant // is x = 0 and the epoch must not move it. The live rule below ("the newest sample sits at 0") // would put the trigger at the right edge and push the whole pre-trigger half off the screen -- // which is what "I only see 0 to 0.2 of a -0.8 to 0.2 recording" was. want_epoch = 0.0; } else if (display == DisplayMode::Capturing) { want_epoch = -cap_edge; } else if (model.display_snapshot() == nullptr) { // `live_edge()` reports in the epoch currently in force, so subtracting it is minus the // raw stamp: the fixed point is reached in one frame rather than converged on. double newest = 0.0; want_epoch = app->rows->live_edge(newest) ? model.display_epoch() - newest : model.display_epoch(); } if (model.display_epoch() != want_epoch) model.set_display_epoch(want_epoch); // The band last, after the epoch it is expressed in: it reads `model.holes()`, which the // acquisition thread mutates, so it belongs under this lock and not after it. app->rows->set_invalid_spans(model, display); } // Outside the lock: it reads and writes the two plots' own windows, not the model. sync_compare_axes(*app); sync_events_divider(*app); // A file on screen is an event, and gets what a held capture gets: the axis pinned to the event's own // `[-pre, +post]` and the trigger instant marked at x = 0. That *is* the constraint -- the time base is // the event's, so no gesture can take the view outside it -- and the window control reads the event's // length. The extent comes from the ring itself, so nothing has to remember it: with the epoch at 0 // the stamps are the display x. if (!app->waveform_name.empty() && !app->capturing && app->rows != nullptr) { double lo = 0.0; double hi = 0.0; { const auto& t = app->model->live().time(); const std::uint64_t n = t.size(); if (n > 0) { lo = t[0]; hi = t[n - 1]; } } if (hi > lo) { // State the pin **once**, then only its numbers. `pin_event_window()` writes the group's range as // well as the pin, and doing that every frame undid the user's box zoom before the clamp below // could adopt it -- the same defect the wheel had in single trigger mode, where re-stating the pin // made it a no-op. The held-event branch above has this shape for this reason: `set_data_pin()` // for the numbers, `pin_event_window()` when the view first becomes an event. A re-open into the // same row set with a different extent *is* a new event and re-states it. const auto pin_view = [lo, hi, app](RowSet& rows) { // `set_data_pin()` takes the **range**, `pinned_window_is()`/`pin_event_window()` the // pre/post split: passing the split to the former wrote the pin as `[lo, -lo]`-ish, which // on a recording whose stamps start *before* zero is a window that no longer holds the // data. The next frame's `pinned_window_is()` then read false, re-stated the pin, and the // view alternated between the file's span and the corrupted one every frame -- a flicker // with no interaction surviving it. A file whose first stamp was positive hid this: the // corrupted pin was wider than the data and the clamp never fired. if (rows.pinned_window_is(-lo, hi)) { rows.set_data_pin(lo, hi); } else { rows.pin_event_window(-lo, hi); ++app->dbg_file_repins; } rows.set_trigger_marker(0.0, true); }; pin_view(*app->rows); if (app->rows_b != nullptr) pin_view(*app->rows_b); sync_window_display(*app, hi - lo); } } // The comparison's plot needs the same re-point the primary gets from `relayout_areas()`: a `RowSet` // rebuilds its *figure* when a row's trace set changes, and the area has to be handed the new one. // Only when it *did* change, though -- `set_figure()` creates **a controller per row**, so handing it // the same figure every frame threw away the second plot's interaction state sixty times a second: // its box-zoom and its crosshair never took, while the primary's (re-pointed only on a dirty row) did. if (app->comparing && app->area_b != nullptr && app->rows_b != nullptr) { plot::Figure* want = &app->rows_b->figure(); if (app->area_b_figure != want) { app->area_b->set_figure(want); app->area_b_figure = want; } } // 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(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(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 // (and the per-row pan/zoom propagation behind it) has no subject any more. // While an event is on screen -- held, or still being captured -- the window is pinned // to [-pre, +post]; clamp_to_data would fight that pin whenever the bound series still // 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) { // 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 || app->rows->window_pinned()) { // Pinned: the event, the frozen pause *or an opened file*, 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. // // A file belongs here and was the case that was missing. It pins its axis every frame (see the // `waveform_name` block above), but a pinned axis does *not* mean this branch: only // `hold_pinned_window()` adopts a gesture from the row's own axis, because a member's // `set_x_range` is deliberately not adopted into a group that is not following. Falling through // to `clamp_to_data()` therefore left a box zoom in a file view drawing its frame and changing // nothing -- the gesture's range went onto the member, the group kept the pin, and the picture // that was drawn came from the group. app->rows->hold_pinned_window(); } else { app->rows->clamp_to_data(); } // The event-zoom probe's step, taken here because the pin and the clamp above are what // decide the axis the renderer is about to read: a `g_timeout` would report the ask. if (app->dbg_zoom_steps > 0 && app->rows->row_count() > 0 && app->rows->row(0).plot != nullptr) { char what[24]; // Two sweeps, because there are two ways to zoom the time base and they take different // paths: the second half drives the *window control* (`apply_time_window`, which unpins // and re-arms the live follow before the pin is restated), the first half a gesture. const bool via_window = app->dbg_zoom_steps <= kZoomWindowSteps; std::snprintf(what, sizeof what, "%s/x%d", via_window ? "win" : "ges", 1 << app->dbg_zoom_step_no); report_event_zoom(*app, what); --app->dbg_zoom_steps; ++app->dbg_zoom_step_no; const RowSet::Row& r0 = app->rows->row(0); if (app->dbg_zoom_steps == kZoomWindowSteps) { // Hand the window sweep a full event again, so it starts where the gesture sweep did. app->dbg_zoom_step_no = 0; double lo = 0.0, hi = 0.0; if (app->rows->event_data_window(lo, hi)) apply_time_window(*app, hi - lo); } else if (via_window) { apply_time_window(*app, std::max(1.0e-4, app->rows->time_window() * 0.5)); } else { // Halve the window about its **centre**, so the left edge walks *into* the data. An // anchor that keeps the left edge where it is keeps it on the series' own first sample, // where no index window can be short and the margin cannot be empty -- which is how the // first version of this probe reported `gap_px=0.0` at every zoom on a view that was // visibly clipped. const plot::Range now = r0.plot->x_range(r0.axis_x); const double c = 0.5 * (now.min + now.max); r0.plot->set_x_range(r0.axis_x, plot::Range{c - 0.25 * now.span(), c + 0.25 * now.span()}); } } // 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. const bool trigger_on = model.trigger_enabled(); if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app->trigger_toggle)) != trigger_on) { gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->trigger_toggle), trigger_on); } const bool armed = model.trigger_armed().load(std::memory_order_relaxed); gtk_button_set_label(GTK_BUTTON(app->arm_button), armed ? "Disarm" : "Arm"); if (new_capture) { // The edge has been accepted, which means the pre window was already full, so the // whole event window can be framed now and the post side filled in view. // // The split is the *requested* one -- the same `post_span_s()` the producer will cut // with -- because there is no snapshot to read it off yet, and the marker has to sit // where that cut will put it rather than where a different rounding would. double span = 0.0; double post = 0.0; { std::lock_guard lock(model.mutex()); span = std::max(1.0e-3, model.event_span()); post = model.trigger().post_span_s(span); } app->event_held = false; app->rows->pin_event_window(span - post, post); app->rows->set_trigger_marker(0.0, true); sync_window_display(*app, span); // The user saw the event at its edge, so that is when the counter starts. app->timed_edge = cap_edge; app->last_latch_us = g_get_monotonic_time(); } const std::uint64_t latches = model.trigger_latches().load(std::memory_order_relaxed); if (latches != app->seen_latches) { app->seen_latches = latches; const double edge = model.trigger_time().load(std::memory_order_relaxed); // A capture the UI watched fill already started this clock at its edge; only one that // opened and closed inside a single frame -- never reported pending, so never // displayed as a capture -- starts it here. if (edge != app->timed_edge) { app->timed_edge = edge; app->last_latch_us = g_get_monotonic_time(); } } // Framing the held event is keyed on *which snapshot was published*, not on the latch // count: a Disarm during a capture publishes what the capture got to without an event // having fired, and that partial has to be framed and held exactly like one that ran // to term. The latch count stays the answer to "how many events fired", which is what // the probes assert on. const std::uint64_t seq = model.event_seq(); // The frame that *frames* the snapshot is the one that consumes its sequence number, // not merely the first frame to see it. Two things can bind something else while a new // snapshot is already published: // // - A publication and the *next* edge land in the same frame: the display follows the // newer of the two and the capture above has framed its own window. // - `display` is read before the lock the producer publishes under, and // `trigger_pending` is only lowered at the end of the producer's packet loop -- long // after `publish_event()`. So a tick can read `Capturing`, block on the mutex while // the snapshot is published and the flag lowered, and get here bound to a capture // that no longer exists. // // Consuming the number in either case dropped the pin permanently: the axis kept the // capture's *requested* [-pre, +post] while the snapshot was shorter, and `event_held` // stayed false, so `clamp_to_data()` was free to drag the frozen view off it. Leaving // it unconsumed costs nothing -- the pin is simply owed until a frame is bound to the // snapshot, and by then it is still the newest one. if (seq != app->seen_event_seq && display == DisplayMode::Held) { app->seen_event_seq = seq; app->event_held = true; // Keep it, at the end of this tick: the record's thumbnail is a picture of the plot, and the plot // is bound to the event later in this same frame. app->record_pending = true; // The window is pinned to exactly what the snapshot captured, read off the // snapshot itself. Not off `event_span`/`pretrigger_pct`: those say what was // *asked for*, in stream seconds, while how much the capture actually covers // depends on the cadence the trigger channel was running at. Pinning to the // request collapsed the waveform into a sliver at x = 0 and left the rest of // the axis -- the whole post side included -- blank. // // The snapshot is edge-relative (the edge at x = 0) and the epoch is back to 0, so // the bind at the top of this frame already put the slots and the axis on the same // coordinates the pin is about to state. double pre = 0.0; double post = 0.0; if (auto snap = model.event()) { pre = snap->pre_s; post = snap->post_s; } if (!(pre + post > 0.0)) { // Nothing was captured (no samples in the ring yet): fall back to the // requested window so the axis is at least readable. const double span = std::max(1.0e-3, model.event_span()); post = model.trigger().post_span_s(span); pre = span - post; } app->rows->pin_event_window(pre, post); app->rows->set_trigger_marker(0.0, true); // the edge sits at x = 0 sync_window_display(*app, pre + post); } // The trigger instant is marked on every row while its event is the thing on screen -- // being captured or held. x is seconds from the event in both: the display epoch is // minus the edge while the live ring is on a pinned axis, and the snapshot's own stamps // are edge-relative, so the edge sits at exactly x = 0 wherever the user has zoomed. // ... and it is the *only* thing that hides it. A file's own event is on screen for as long as the // file is, so the file branch above must survive this one: it used to run first and be undone here, // which is why an opened event had no trigger line. if (app->waveform_name.empty() && !app->capturing && !(trigger_on && model.trigger_time_valid().load(std::memory_order_relaxed))) { app->rows->set_trigger_marker(0.0, false); } { // A bound slot with no published samples while its channel's ring has plenty // means the consumer side has not caught up: this frame draws an empty plot, and // the y auto-fit collapses with it. int bound = 0; int empty = 0; for (int i = 0; i < app->rows->row_count(); ++i) { RowSet::Row& r = app->rows->row(i); for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) { const std::size_t si = static_cast(sl); const int ch = r.channel[si]; if (ch < 0) continue; const ChannelBuffer* b = r.slots[si].bound(); if (b == nullptr || static_cast(ch) >= model.channel_count()) continue; ++bound; if (b->size() == 0 && model.live().values(static_cast(ch)).produced() > 0) { ++empty; } } } if (bound > 0 && empty == bound) ++app->dbg_blank_frames; if (bound > 0 && empty < bound) app->dbg_ever_bound = true; // A rebuild drops every ring's history, so the frames after one draw an empty plot until the // producer has refilled the window -- which is what "the plot flickers as if there were no // data" looks like. `dbg_blank_frames` cannot see it: it asks whether the ring holds samples // the view has not got, and a rebuilt ring holds none either. The generation is the only // thing that says a rebuild happened, so each change is named here with what asked for it. const unsigned gen = model.generation(); if (app->dbg_generation_seen && gen != app->dbg_generation_prev) { ++app->dbg_generation_changes; if (app->dbg_generation_changes <= 40) { std::fprintf(stderr, "[gen] frame %d generation %u -> %u (bound=%d empty=%d)\n", app->dbg_axes_frames, app->dbg_generation_prev, gen, bound, empty); } } app->dbg_generation_prev = gen; app->dbg_generation_seen = true; // 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 // floor that only grows: without it the axes' left edge follows the y tick // labels and the x axis twitches by a few pixels as the scale changes. double gutter = 0.0; for (int i = 0; i < app->rows->row_count(); ++i) { const RowSet::Row& r = app->rows->row(i); if (r.plot) gutter = std::max(gutter, double(r.plot->layout().y_axis_width)); } app->rows->note_y_gutter(gutter); app->rows->apply_gutters(); // The editor's buttons are placed from the rects this frame was laid out with, so they // follow a resize and the gutter settling without being told. if (app->layout_editing) sync_layout_editor(*app); // The one thing that has to be decided *per frame* about the sidebar: whether there is room for it // beside the plot. Collapsed, the split view puts it *over* the plot rather than giving it a share of // a width that is not there -- so a small window shows fewer pixels of plot, never a panel squeezed // to nothing. `kSidebarRoom` is the sidebar's 320 plus the width below which the plot stops being // worth looking at. if (app->side_view != nullptr && app->window != nullptr) { // The split view writes `show-sidebar` itself (at startup and when it collapses), so the wish is // re-asserted here -- but only when they disagree, so a settled frame touches nothing. This is also // why a hide *gesture* does not stick: the panel is where the button says it is. const bool want = !app->model->signals().empty() && app->sidebar_wanted; if (adw_overlay_split_view_get_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(app->side_view)) != want) { adw_overlay_split_view_set_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(app->side_view), want); if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app->sidebar_toggle)) != want) { app->sidebar_syncing = true; gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->sidebar_toggle), want); app->sidebar_syncing = false; } } const int window_w = gtk_widget_get_width(app->window); if (window_w > 0) { adw_overlay_split_view_set_collapsed(ADW_OVERLAY_SPLIT_VIEW(app->side_view), window_w < kSidebarRoom); } } // The render, queued once a frame -- but not unconditionally. In `Held` and `Paused` the rows // are bound to an immutable snapshot on a pinned axis and the producer is feeding the live ring, // not the series on screen, so a frame in which none of the picture's own terms moved is the // same frame again. Every other mode renders: `Live` and `Capturing` roll new samples under the // axis, and `Waiting` moves the live epoch, so in all three the picture differs every frame. // // The gate is two parts, and the second is what makes the first safe to keep coarse: // * a signature of what a frozen view *can* be re-framed by -- a new snapshot, a pan or zoom // (the group's range, which is where the gestures and the pin both write), a row added or // removed -- which renders the moment it changes; // * `kStaticRenderEvery`, a floor that renders anyway every few ticks, so anything not in the // signature is late rather than lost. A gate that can only be opened by a list of state is // a gate that goes wrong the first time the list is one item short. // Interaction needs neither: `PlotArea` queues its own render from the pointer, scroll and drag // handlers, so a crosshair and a cursor drag are immediate whatever this decides. // // The layout editor is excluded outright: its buttons are placed from the rects the last frame // was laid out with (`sync_layout_editor()` above), so it is the one state where a skipped frame // is a stale *widget* and not just a stale picture. if (app->area_widget != nullptr) { const bool frozen = (display == DisplayMode::Held || display == DisplayMode::Paused) && !app->layout_editing; const plot::Range view = app->rows->view_range(); const std::uint64_t seq = model.event_seq(); const int rows = app->rows->row_count(); const bool same = frozen && seq == app->render_event_seq && view.min == app->render_x_min && view.max == app->render_x_max && rows == app->render_rows; // A change opens the gate for `kSettleRenders` frames, not one: the figure's left gutter is a // monotone floor that converges over two layout passes (see the `layout over N frames` // reading), and a layout pass happens per *render* -- so a single frame after a re-frame would // leave the gutter one pass short until the floor below fired, 64 ms later. if (!same) app->render_open = kSettleRenders; if (app->render_open > 0 || app->render_skipped + 1 >= kStaticRenderEvery) { if (app->render_open > 0) --app->render_open; app->render_skipped = 0; gtk_gl_area_queue_render(GTK_GL_AREA(app->area_widget)); } else { ++app->render_skipped; ++app->dbg_render_skips; } app->render_event_seq = seq; app->render_x_min = view.min; app->render_x_max = view.max; app->render_rows = rows; } // What the side column *demands*, not what it got. A GTK box's minimum width is its // children's, a `GtkLabel`'s minimum is its text, and a minimum larger than the screen is // honoured by growing the toplevel -- which hands the plot an allocation wider than the window // and pushes most of the trace off the right-hand edge. `axes.w` sees the consequence a frame // later and cannot name the cause, so the demand is read here, from the panes whose contents // the frame tick rewrites. { const auto min_w = [](GtkWidget* w) { if (w == nullptr || !GTK_IS_WIDGET(w)) return 0; int min = 0; int nat = 0; gtk_widget_measure(w, GTK_ORIENTATION_HORIZONTAL, -1, &min, &nat, nullptr, nullptr); return min; }; const int side = min_w(app->side_box); const int meas = min_w(app->measures_list); const int curs = min_w(app->cursors_body); const int worst = std::max(side, std::max(meas, curs)); if (worst > app->dbg_min_w_worst) { const int was = app->dbg_min_w_worst; app->dbg_min_w_worst = worst; if (was > 0 && worst - was > 32 && app->dbg_min_w_reports < 40) { ++app->dbg_min_w_reports; std::fprintf(stderr, "[width-probe] frame %d min width grew %d -> %d (side=%d measures=%d " "cursors=%d)\n", app->dbg_axes_frames, was, worst, side, meas, curs); } } } if (plot::Plot* p0 = app->rows->row(0).plot; p0 != nullptr && p0->viewport().w > 100.0f) { // steady state only const int ax = static_cast(p0->layout().axes.x); const int gw = static_cast(p0->layout().y_axis_width); app->dbg_axes_x_min = std::min(app->dbg_axes_x_min, ax); app->dbg_axes_x_max = std::max(app->dbg_axes_x_max, ax); app->dbg_gutter_min = std::min(app->dbg_gutter_min, gw); app->dbg_gutter_max = std::max(app->dbg_gutter_max, gw); const int aw = static_cast(p0->layout().axes.w); app->dbg_axes_w_min = std::min(app->dbg_axes_w_min, aw); app->dbg_axes_w_max = std::max(app->dbg_axes_w_max, aw); // A min..max range alone cannot tell the twitch bug (a width that changes every // frame) from one resize that happened once and stayed, which look identical in a // summary and need opposite responses. Count the changes and keep the last one's // frame, and whether the side panel was up for it -- hiding that column is the one // legitimate reason the plot's width moves by itself. if (app->dbg_axes_frames > 0 && aw != app->dbg_axes_w_prev) { ++app->dbg_axes_w_changes; app->dbg_axes_w_change_frame = app->dbg_axes_frames; app->dbg_axes_w_change_side = gtk_widget_get_visible(app->side_box) ? 1 : 0; // Each change, not just the last: a summary says the plot's width moved and cannot say // *what* moved it, and a width that moves by itself is the "the plot flickers" report's // first candidate. Named here beside the widths that can cause it -- the side column and // the two panes whose contents the frame tick rewrites -- so the culprit is read rather // than guessed. Capped, because the twitch bug would otherwise fill the log. if (app->dbg_axes_w_changes <= 40) { int side_w = 0; int meas_w = 0; int curs_w = 0; if (app->side_box != nullptr) side_w = gtk_widget_get_width(app->side_box); if (app->measures_list != nullptr) meas_w = gtk_widget_get_width(app->measures_list); if (app->cursors_body != nullptr) curs_w = gtk_widget_get_width(app->cursors_body); std::fprintf(stderr, "[width-probe] frame %d axes.w %d -> %d viewport.w %d side=%dx(vis=%d) " "measures_w=%d cursors_w=%d\n", app->dbg_axes_frames, app->dbg_axes_w_prev, aw, static_cast(p0->viewport().w), side_w, app->dbg_axes_w_change_side, meas_w, curs_w); // A step of this size is not the gutter settling, so name what demanded it. if (std::abs(aw - app->dbg_axes_w_prev) > 100) { report_wide_widgets(app->window, 900, 1); } } } app->dbg_axes_w_prev = aw; app->dbg_vp_w_min = std::min(app->dbg_vp_w_min, static_cast(p0->viewport().w)); app->dbg_vp_w_max = std::max(app->dbg_vp_w_max, static_cast(p0->viewport().w)); const int nt = static_cast(p0->x_axis(0).ticks.size()); app->dbg_xtick_min = std::min(app->dbg_xtick_min, nt); app->dbg_xtick_max = std::max(app->dbg_xtick_max, nt); if (app->area != nullptr) { const float sc = app->area->device_scale(); app->dbg_scale_min = std::min(app->dbg_scale_min, sc); app->dbg_scale_max = std::max(app->dbg_scale_max, sc); } // The rows are members of one x link group, so they hold one window; a row that // disagrees by more than a pixel of data means the group did not reach it. const plot::Range x0 = p0->x_range(app->rows->row(0).axis_x); const double tol = std::max(1.0e-12, x0.span() / std::max(1.0, double(aw))); for (int i = 1; i < app->rows->row_count(); ++i) { const RowSet::Row& r = app->rows->row(i); if (r.plot == nullptr) continue; const plot::Range xi = r.plot->x_range(r.axis_x); if (std::fabs(xi.min - x0.min) > tol || std::fabs(xi.max - x0.max) > tol) { ++app->dbg_x_desync; break; } } ++app->dbg_axes_frames; } // Once a second: how many samples each active channel has *stored*, and the rate that // implies. Channels are never decimated against each other (`stride=1`), so with // `decim=1` a stored rate far below the declared one is the datasource's own wire rate; // with `decim=N` it is the wire rate over N, the resampling the window asked for. This // line is the receipt for both. static gint64 next_channels = 0; const gint64 now_c = g_get_monotonic_time(); // Per-channel rate computed only on the measuring tick; the detail tooltip on the label // now carries the full status string instead of a per-channel breakdown. if (app->last_stored.size() != model.channel_count()) { // Seed new entries with the current count: a fresh entry must not be counted // as a delta from zero. app->last_stored.assign(model.channel_count(), 0); for (std::size_t c = 0; c < model.channel_count() && c < app->last_stored.size(); ++c) { app->last_stored[c] = model.live().values(c).produced(); } } if (now_c >= next_channels) { const double dt_s = std::min(10.0, double(now_c - app->last_channels_meas) / 1.0e6); next_channels = now_c + G_TIME_SPAN_SECOND; app->last_channels_meas = now_c; // The cache's own question, on the same cadence as the channel dump: a directory read per second, // and the button's availability re-decided only when the answer changed. if (app->event_store != nullptr) { const bool cached = !app->event_store->list().empty(); if (cached != app->events_cached) { app->events_cached = cached; sync_events_availability(*app); } } // Once a second, alongside the rate measurement: is the stream arriving at all? Nothing is // owed in file mode -- there is no socket and no source -- so a file is never starved. if (app->waveform_name.empty()) note_starvation(*app); std::lock_guard lock(model.mutex()); app->stored_rate.assign(model.channel_count(), 0.0); app->declared_rate.assign(model.channel_count(), 0.0); for (std::size_t c = 0; c < model.channel_count(); ++c) { const ChannelInfo& info = model.channels()[c]; const u64 stored = model.live().values(c).produced(); // `produced()` restarts at 0 whenever the buffer is rebuilt (a CONFIG, an // enable/disable, a calibration change), so the delta can run backwards. As // unsigned that wrapped to ~2^64 and the line reported a rate near 1.8e13 MS/s // -- a number with no relation to the stream, which is actively misleading when // this line is the evidence for a timing complaint. A rebuild has no measurable // rate, so report none. const u64 prev = c < app->last_stored.size() ? app->last_stored[static_cast(c)] : stored; const double per_s = stored >= prev && dt_s > 0.0 ? double(stored - prev) / dt_s : 0.0; if (c < app->last_stored.size()) app->last_stored[static_cast(c)] = stored; const double declared = info.dt > 0.0 ? 1.0 / info.dt : 0.0; app->stored_rate[c] = per_s; app->declared_rate[c] = declared; const ChannelBuffer& buf = model.live().channel(c); const plot::Range y = buf.size() > 0 ? buf.y_bounds() : plot::Range{0.0, 0.0}; std::fprintf(stderr, "[udpscope] ch %zu '%s' stored=%llu (%.4g MS/s, declared %.4g S/s) " "elements=%llu stride=%llu decim=%llu published=%llu y=[%.6g, %.6g]\n", c, info.name.c_str(), static_cast(stored), per_s / 1e6, declared, static_cast(info.elements), static_cast(model.partition().stride[c]), static_cast(model.partition().time_stride), static_cast(buf.size()), double(y.min), double(y.max)); } std::fflush(stderr); } // Status line. The model's lists and partition are rebuilt on the producer // thread, so every read of them takes the model mutex -- narrowly: GTK calls and // model setters below must stay outside it (a setter takes the same lock, and a // button toggle emits a handler that calls one). std::size_t signal_count = 0; std::size_t channel_count = 0; double span_s = 0.0; std::uint64_t time_stride = 1; double trigger_level = 0.0; double trigger_value = 0.0; bool have_trigger_value = false; std::string trigger_name; { std::lock_guard lock(model.mutex()); signal_count = model.signals().size(); channel_count = model.channel_count(); span_s = model.partition().span_s; time_stride = model.partition().time_stride; const int chan = model.trigger().channel; trigger_level = model.trigger().condition.level; if (chan >= 0 && chan < static_cast(model.channel_count())) { trigger_name = model.channels()[static_cast(chan)].name; } for (std::size_t c = 0; c < model.channel_count(); ++c) { if (static_cast(c) != chan) continue; const ChannelBuffer& buf = model.live().channel(c); if (buf.size() > 0) { f32 v = 0.0f; buf.raw(buf.size() - 1, buf.size(), &v); trigger_value = double(v); have_trigger_value = true; } break; } } StreamStats& st = model.stats(); const char* mode = "live"; if (!st.have_config.load()) { // Ahead of every other branch for the reason the glance word is: with no CONFIG there is no // signal list, so "live" (or "armed", which cannot happen without one) would name a pipeline // that has not been described yet. mode = "trying to connect: no configuration, and so no signal list, from the stream yet"; } else if (trigger_on) { if (app->capturing) { mode = "trigger: CAPTURING (post-trigger window filling)"; } else if (app->event_held) { mode = "trigger: EVENT HELD (Arm to re-arm, or switch the trigger off to stream)"; } else if (armed && have_trigger_value) { static char waiting[192]; std::snprintf(waiting, sizeof(waiting), "trigger: armed on '%s' (now %.4g, level %.4g), waiting for an event", trigger_name.c_str(), trigger_value, trigger_level); mode = waiting; } else if (armed) { mode = "trigger: armed, waiting for an event"; } else { mode = "trigger: disarmed (view frozen)"; } } else if (app->acq->paused()) { mode = "paused"; } // The badge: lit and blinking while the pipeline is actually running end to end. // Paused and disarmed both stop it, and so does a stream that has not arrived -- a // green light over a dead socket is the one reading that must not happen. The blink is // a 1 Hz square wave off the monotonic clock rather than its own timer, so it cannot // drift away from the frame it is drawn on. // A source that answered CONNECT but has sent no CONFIG is *connecting*, not acquiring: // there is no signal list, so nothing can be enabled and no DATA frame can arrive. It gets // the steady yellow rather than the blinking green, because the green says "the pipeline is // running end to end" and here it has not started. const bool paused = app->acq->paused(); const bool connecting = st.connected.load() && !st.have_config.load(); const bool active = st.connected.load() && !connecting && !paused && (!trigger_on || armed); const bool lit = ((g_get_monotonic_time() / (G_TIME_SPAN_SECOND / 2)) & 1) == 0; const BadgePhase phase = connecting ? BadgePhase::Connecting : !active ? BadgePhase::Idle : (lit ? BadgePhase::Lit : BadgePhase::Dim); const int reason = active ? 0 : !st.connected.load() ? 1 : connecting ? 4 : paused ? 2 : 3; static const char* const kWhy[] = {"acquiring", "waiting for the stream", "paused", "trigger disarmed", "trying to connect: no configuration from the stream yet"}; // Keyed on the reason as well as the phase: three of the reasons share the Idle phase, // so a pause that becomes a disarm has to reach the tooltip. ++app->dbg_badge_frames[static_cast(phase)]; const int badge_key = static_cast(phase) * 8 + reason; if (app->status_badge != nullptr && badge_key != app->badge_phase) { app->badge_phase = badge_key; gtk_label_set_markup(GTK_LABEL(app->status_badge), badge_markup(phase)); gtk_widget_set_tooltip_text(app->status_badge, kWhy[reason]); } int bound = 0; std::size_t samples = 0; for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { const ChannelBuffer* b = app->rows->row(0).slots[static_cast(s)].bound(); if (b != nullptr && b->size() > 0) { ++bound; samples += static_cast(b->size()); } } // The rate the buffer is actually storing at. One figure when every channel agrees and // a range when they do not: a per-channel list would make the field's width depend on // the stream, and the breakdown is on the label's tooltip instead. std::string rate; if (!app->stored_rate.empty()) { double lo = app->stored_rate.front(); double hi = lo; for (const double r : app->stored_rate) { lo = std::min(lo, r); hi = std::max(hi, r); } // Within a percent is the same rate measured over a one-second window, not two rates. rate = hi - lo <= 0.01 * hi ? format_rate(hi) : format_rate(lo) + ".." + format_rate(hi); } // Derive the one-word state for the glance line from the same conditions the mode prose // branches on, so the two cannot disagree. // // `connecting` is first and covers both halves of the handshake -- no socket yet, and a // socket with no CONFIG behind it -- because in neither is there a signal list, and every // other word here claims one. The badge is what distinguishes them (grey against yellow): // the word says what the app is doing, the colour says how far it got. It is no wider than // `event held`, which is what lets it join a set the plot's layout is measured against. const char* state_word = "live"; if (!st.have_config.load()) { state_word = "connecting"; } else if (display == DisplayMode::Capturing) { state_word = "capturing"; } else if (display == DisplayMode::Held) { state_word = "event held"; } else if (trigger_on && !armed) { state_word = "disarmed"; } else if (display == DisplayMode::Waiting) { state_word = "armed"; } else if (paused) { state_word = "paused"; } udpscope::StatusSnapshot snap; snap.state_detail = mode; snap.state_word = state_word; snap.connected = st.connected.load(); snap.have_config = st.have_config.load(); snap.trigger_on = trigger_on; snap.rate_text = rate; snap.stored_rate_hz = app->stored_rate.empty() ? 0.0 : app->stored_rate.front(); snap.declared_rate_hz = app->declared_rate.empty() ? 0.0 : app->declared_rate.front(); snap.window_text = format_window(app->rows->time_window()); snap.packets = st.packets.load(); snap.frames = st.frames.load(); snap.gaps = st.gaps.load(); snap.resequenced = st.resequenced.load(); snap.late = st.late.load(); snap.stale = st.stale.load(); snap.holes = st.holes.load(); snap.hole_slots = st.hole_slots.load(); snap.backfilled = st.backfilled.load(); snap.signals = signal_count; snap.channels = channel_count; snap.plot1_traces = bound; snap.plot1_samples = samples; snap.buffer_mb = double(model.config().buffer_mb); snap.span_s = span_s; snap.time_stride = time_stride; snap.have_event = app->last_latch_us != 0; snap.since_event_s = snap.have_event ? double(g_get_monotonic_time() - app->last_latch_us) / 1.0e6 : 0.0; snap.reconnects = static_cast(st.reconnects.load(std::memory_order_relaxed)); // Paused *and* with a recorded start: the button's handler is what stamps it, so a pause // arriving any other way (there is none today) reports the state without a duration rather // than counting from the epoch. snap.paused = paused && app->pause_started_us != 0; snap.paused_for_s = snap.paused ? double(g_get_monotonic_time() - app->pause_started_us) / 1.0e6 : 0.0; const std::string glance = udpscope::format_glance(snap); app->dbg_glance_last = glance; app->status_detail = udpscope::format_detail(snap); if (app->status_detail.size() > app->status_detail_max) { app->status_detail_max = app->status_detail.size(); } if (glance.size() > app->glance_max_chars) app->glance_max_chars = glance.size(); // A notice is not the glance. It is prefixed onto the same line, which is exactly where a reader // skimming the routine text will not see it -- "there is no clear feedback" was that, with a refusal // ("plot 2 holds four signals already") looking like part of the status. The stock `.warning` class is // what makes a message look like a message, and it goes when the notice does. if (!app->notice.empty() && g_get_monotonic_time() < app->notice_until_us) { gtk_widget_add_css_class(app->status_label, "warning"); const std::string with_notice = app->notice + " | " + glance; gtk_label_set_text(GTK_LABEL(app->status_label), with_notice.c_str()); } else { gtk_widget_remove_css_class(app->status_label, "warning"); app->notice.clear(); gtk_label_set_text(GTK_LABEL(app->status_label), glance.c_str()); } gtk_widget_set_tooltip_text(app->status_label, app->status_detail.c_str()); if (app->status_popover != nullptr && gtk_widget_get_visible(app->status_popover) == TRUE) { update_status_popover(*app, snap); update_capture_row(*app); } if (app->record_pending) { app->record_pending = false; record_event(*app); } if (app->capture_button != nullptr) { // Insensitive until connected: the capture's path is keyed on the source. gtk_widget_set_sensitive(app->capture_button, snap.connected ? TRUE : FALSE); if (app->capture_want_ui && !app->acq->capture_want()) { // The producer refused the open (a full disk, a read-only directory) and cleared // the flag; the button follows the flag rather than lying about the state. Going // inactive runs the button's own handler, which only ever sets the flag. app->capture_want_ui = false; gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->capture_button), FALSE); } } const double tick_ms = double(g_get_monotonic_time() - tick_start_us) / 1000.0; app->dbg_tick_ms_sum += tick_ms; app->dbg_tick_ms_max = std::max(app->dbg_tick_ms_max, tick_ms); ++app->dbg_tick_n; return G_SOURCE_CONTINUE; } // --- window construction ---------------------------------------------------- /// The display-window control: a button reading the current window, and a popover holding /// the number, its unit and a decade stepper either side. /// /// [ 2 s ▾ ] -> [ /10 ] [ 2____ ] [ s ▾ ] [ x10 ] /// /// A ladder dropdown could only offer the spans someone had thought of, and the useful spans /// run from microseconds (a fast datasource's few hundred samples) to minutes; a typed number /// plus a unit covers that without an entry per decade, and the steppers cover the common /// case of "an order of magnitude wider" without typing at all. GtkWidget* build_window_control(App& app) { app.window_label = gtk_label_new(""); gtk_label_set_width_chars(GTK_LABEL(app.window_label), kWindowLabelChars); gtk_label_set_max_width_chars(GTK_LABEL(app.window_label), kWindowLabelChars); gtk_label_set_ellipsize(GTK_LABEL(app.window_label), PANGO_ELLIPSIZE_END); GtkWidget* face = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); gtk_box_append(GTK_BOX(face), app.window_label); gtk_box_append(GTK_BOX(face), gtk_image_new_from_icon_name("pan-down-symbolic")); app.window_button = gtk_menu_button_new(); gtk_menu_button_set_child(GTK_MENU_BUTTON(app.window_button), face); gtk_widget_set_tooltip_text(app.window_button, "the time span on screen, and the trigger's event window"); GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); gtk_widget_set_margin_top(row, 6); gtk_widget_set_margin_bottom(row, 6); gtk_widget_set_margin_start(row, 6); gtk_widget_set_margin_end(row, 6); GtkWidget* slower = gtk_button_new_with_label("\u00f710"); gtk_widget_set_tooltip_text(slower, "a decade narrower"); g_signal_connect(slower, "clicked", G_CALLBACK(on_window_step), &app); gtk_box_append(GTK_BOX(row), slower); app.window_entry = gtk_entry_new(); gtk_entry_set_input_purpose(GTK_ENTRY(app.window_entry), GTK_INPUT_PURPOSE_NUMBER); gtk_editable_set_width_chars(GTK_EDITABLE(app.window_entry), 8); // Enter only. `changed` would re-frame the plot on every keystroke, so typing "100" would // pass through 1 and 10 -- and each of those re-partitions nothing but does move the axis. g_signal_connect(app.window_entry, "activate", G_CALLBACK(on_window_entry_activate), &app); gtk_box_append(GTK_BOX(row), app.window_entry); const char* units[kWindowUnitCount + 1] = {}; for (int i = 0; i < kWindowUnitCount; ++i) units[i] = kWindowUnits[i].name; app.window_unit_drop = gtk_drop_down_new_from_strings(units); g_signal_connect(app.window_unit_drop, "notify::selected", G_CALLBACK(on_window_unit_changed), &app); gtk_box_append(GTK_BOX(row), app.window_unit_drop); GtkWidget* faster = gtk_button_new_with_label("\u00d710"); gtk_widget_set_tooltip_text(faster, "a decade wider"); // Which direction a stepper steps is data on the button rather than a second callback: both // do the same thing to the same number. g_object_set_data(G_OBJECT(faster), "udpscope-window-up", faster); g_signal_connect(faster, "clicked", G_CALLBACK(on_window_step), &app); gtk_box_append(GTK_BOX(row), faster); GtkWidget* popover = gtk_popover_new(); gtk_popover_set_child(GTK_POPOVER(popover), row); gtk_menu_button_set_popover(GTK_MENU_BUTTON(app.window_button), popover); return app.window_button; } /// Any press in the window closes the time-window popover. /// /// `GtkPopover`'s autohide does this by itself for as long as GTK holds the popover's grab -- and the grab /// is the thing that can go missing (a window manager moving it, a nested popover taking it for the unit /// menu, and every headless run, where there is no manager to hand one out at all), after which a click /// anywhere else in the window leaves the bubble up. That is the "sometimes it is awkward to close". /// /// A press that reaches the window's own surface was *not* inside the popover: a popover is its own /// surface, so a click within it never arrives here. That is what makes this the rule stated outright /// rather than a grab away from working -- and it needs no hit test beyond leaving the button alone, /// whose own click is what toggles the bubble shut. void on_window_popover_press(GtkGestureClick*, int, double x, double y, gpointer user) { App* app = static_cast(user); if (app->window_button == nullptr || !gtk_menu_button_get_active(GTK_MENU_BUTTON(app->window_button))) { return; } // The button itself is left to its own click, which toggles the bubble shut -- closing it here would // only have the toggle open it again. Anything else on the window's surface closes it. for (GtkWidget* w = gtk_widget_pick(app->window, x, y, GTK_PICK_DEFAULT); w != nullptr && w != app->window; w = gtk_widget_get_parent(w)) { if (w == app->window_button) return; } gtk_menu_button_popdown(GTK_MENU_BUTTON(app->window_button)); } /// The one place a key turns into an action. Toggles go through /// gtk_toggle_button_set_active(), which emits ::toggled and so runs exactly the handler a /// click would; the non-toggles call their click handlers with a null button, which they /// already ignore. Split out of the key handler so the probe can fire a row directly. /// The pane's switches and the header bar's buttons are one control with two places to click, so a /// change in either re-reads the other. `panel_switch_sync` is the guard: setting a `GtkSwitch` emits /// its own `notify::active`, and without it the program's write would come back as a click. void sync_panel_switches(App& app) { if (app.panel_switch_sync) return; app.panel_switch_sync = true; const auto mirror = [](GtkWidget* panel_switch, GtkWidget* toolbar_toggle) { if (panel_switch == nullptr || toolbar_toggle == nullptr) return; gtk_switch_set_active(GTK_SWITCH(panel_switch), gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(toolbar_toggle)) == TRUE); }; mirror(app.trigger_switch, app.trigger_toggle); mirror(app.cursors_switch, app.cursors_toggle); app.panel_switch_sync = false; } /// A pane's own switch drives the *same action* as the header bar's button, so either place reaches /// the feature down one path, and the sync above is what keeps the two showing the same thing. void on_panel_switch_toggled(GObject* obj, GParamSpec*, gpointer user) { App* app = static_cast(user); if (app->panel_switch_sync) return; const bool on = gtk_switch_get_active(GTK_SWITCH(obj)) != FALSE; const bool is_trigger = app->trigger_switch != nullptr && obj == G_OBJECT(app->trigger_switch); GtkWidget* toggle = is_trigger ? app->trigger_toggle : app->cursors_toggle; if (toggle == nullptr) return; // The action toggles the toolbar button, so it is run only when the two actually disagree -- which // is what makes this a *set* rather than a second toggle of something already in that state. if ((gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(toggle)) != FALSE) != on) { run_app_action(*app, is_trigger ? AppAction::TriggerPanel : AppAction::CursorsPane); } sync_panel_switches(*app); } /// Both panes' modes, applied together: the trigger's body against its summary line, the two [edit] /// buttons against what they would do, and the signal list rebuilt because the lock decides what a row /// carries. Idempotent, so a caller only flips a flag and calls this. void apply_pane_lock(App& app) { if (app.trigger_body != nullptr) gtk_widget_set_visible(app.trigger_body, !app.trigger_locked); if (app.trigger_summary != nullptr) { gtk_widget_set_visible(app.trigger_summary, app.trigger_locked); } const auto edit_button = [](GtkWidget* button, bool locked, const char* what) { if (button == nullptr) return; // Both names must be ones Adwaita *has*: `emblem-ok-symbolic` exists only in breeze, so the "done" // button was blank -- and a blank icon button is invisible in every other reading, which is why // `pane edits:` checks these two by name and verdict. gtk_button_set_icon_name(GTK_BUTTON(button), locked ? "document-edit-symbolic" : "object-select-symbolic"); const std::string tip = std::string(locked ? "configure " : "keep this configuration of ") + what; gtk_widget_set_tooltip_text(button, tip.c_str()); }; edit_button(app.trigger_edit, app.trigger_locked, "the trigger"); edit_button(app.signals_edit, app.signals_locked, "the signals"); sync_trigger_summary(app); // The list, because the lock is what decides whether a row carries a tick, a menu and the `+`. if (app.signal_list != nullptr) rebuild_signal_panels(app); } /// Either pane's [edit] button: one handler, because the two differ only in which flag they flip. void on_pane_edit_clicked(GtkButton* button, gpointer user) { App* app = static_cast(user); if (GTK_WIDGET(button) == app->trigger_edit) { app->trigger_locked = !app->trigger_locked; } else if (GTK_WIDGET(button) == app->signals_edit) { app->signals_locked = !app->signals_locked; } apply_pane_lock(*app); } /// Hides the sidebar for the length of a drag, when it is *overlaid*, and restores it after. /// /// With the split view collapsed the sidebar covers the plot -- that is what collapsed means -- so a drag /// that starts in the signal list has nowhere to land: the pointer is over the sidebar and never over a /// drop target, and the drag is impossible rather than merely awkward. Side by side nothing is done: the /// plot is already visible, and hiding the list the drag started in would be startling. void row_drag_sidebar(App& app, bool dragging) { if (app.side_view == nullptr) return; AdwOverlaySplitView* split = ADW_OVERLAY_SPLIT_VIEW(app.side_view); if (dragging) { if (!adw_overlay_split_view_get_collapsed(split)) return; app.sidebar_hidden_for_drag = adw_overlay_split_view_get_show_sidebar(split) != FALSE; if (app.sidebar_hidden_for_drag) adw_overlay_split_view_set_show_sidebar(split, FALSE); } else if (app.sidebar_hidden_for_drag) { app.sidebar_hidden_for_drag = false; adw_overlay_split_view_set_show_sidebar(split, TRUE); } } void run_app_action(App& app, AppAction action) { const auto toggle = [](GtkWidget* button) { if (button == nullptr) return; const gboolean now = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(button)); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(button), now == TRUE ? FALSE : TRUE); }; switch (action) { case AppAction::PauseToggle: toggle(app.pause_button); break; case AppAction::TriggerArm: on_arm_clicked(nullptr, &app); break; case AppAction::TriggerPanel: toggle(app.trigger_toggle); break; case AppAction::CursorsPane: toggle(app.cursors_toggle); break; case AppAction::SideTabMeasure: select_side_tab(app, "measure"); break; case AppAction::SideTabSignals: select_side_tab(app, "signals"); break; case AppAction::LayoutEditor: toggle(app.layout_toggle); break; case AppAction::SidePanel: toggle(app.sidebar_toggle); break; case AppAction::Export: on_export_clicked(nullptr, &app); break; case AppAction::Help: show_help(GTK_WINDOW(app.window)); break; case AppAction::Shortcuts: show_shortcuts(GTK_WINDOW(app.window)); break; case AppAction::Quit: if (app.window != nullptr) gtk_window_close(GTK_WINDOW(app.window)); break; } } /// Looks a key up in the accel table and runs it. Refuses outright while a GtkEditable has /// focus: every bare-letter accel is also a character somebody may be typing. gboolean on_app_key_pressed(GtkEventControllerKey*, guint keyval, guint, GdkModifierType state, gpointer user) { App* app = static_cast(user); if (app->window == nullptr) return FALSE; GtkWidget* focus = gtk_window_get_focus(GTK_WINDOW(app->window)); if (focus != nullptr && GTK_IS_EDITABLE(focus)) return FALSE; const Accel* accel = find_accel(keyval, static_cast(state)); if (accel == nullptr) return FALSE; run_app_action(*app, accel->action); return TRUE; } /// Re-reads the file a *file view* is showing; true when something was reloaded. /// /// Any structural change -- a math channel added, a signal enabled, a plan change -- rebuilds the buffer /// and drops its history. That is right for a stream, whose producer refills it from the next packet. A /// file has no producer, so the same rebuild leaves *every* channel reading `0 samples` and the view is /// simply empty: "adding a math channel breaks the plot" was this, and it is the same emptiness whichever /// structural change caused it. The file's own path is the only thing that can refill it, so it is loaded /// again -- which is also what computes the derived channels over it, since the load's own pushes run the /// math once the set is in place. bool reload_file_view(App& app) { if (!app.replaying && app.model->config().open_waveform.empty()) return false; // Nothing to restore: a change that really happened leaves the store empty in a file view -- the // rebuild replaces it and there is no producer to refill it -- while one that found nothing to do // leaves it full. Reading the file again for the second kind is the waste this avoids. if (app.model->stored_slots() > 0) return false; const std::string path = !app.waveform_name.empty() ? app.waveform_name : app.model->config().open_waveform; if (path.empty()) return false; WaveformLoadReport report; std::string error; if (!load_waveform_into_model(*app.model, path, &report, &error)) { set_notice(app, "cannot reload " + path + " after the change: " + error, 6); return false; } // Said out loud because it is the one place a file is read twice: a structural change really did // drop the buffer, and the file is the only thing that can put it back. std::fprintf(stderr, "[udpscope] reloaded %s after a structural change: %llu samples back\n", path.c_str(), static_cast(report.samples)); return true; } /// The header bar carries only the controls you reach for *while watching a signal*; /// everything you set up once lives in the overflow menu. Nine text buttons in a /// header bar is a wall of words to read past every time you want the one you need, /// so the four that stay are icons with tooltips. /// /// [ 2 s ▾ ] [plots ▾] ............... [pause] [trigger] [panel] [menu] GtkWidget* build_toolbar(App& app) { // The bar's frame is a `.ui` file (`src/ui/header-bar.ui`): the bar, the slot the window control goes // in, the trigger/pause/events group with its separator, and the five controls at the far end. Its // *order* is the file's too, which is what three comments here used to explain. GtkBuilder* ui = ui_load(app, "/org/udpscope/ui/header-bar.ui"); GtkWidget* bar = GTK_WIDGET(gtk_builder_get_object(ui, "header_bar")); if (bar == nullptr) return gtk_header_bar_new(); app.toolbar = bar; // The control already *is* the window's name: a `window` label in front of it said nothing the // `2 s` beside it did not, and the bar is the busiest row in the window. It goes into the file's slot // rather than straight onto the bar: `gtk_header_bar_pack_start()` appends *after* whatever the file // has already packed, so a control added here would land right of the whole group. if (GtkWidget* slot = GTK_WIDGET(gtk_builder_get_object(ui, "window_control_slot"))) { gtk_box_append(GTK_BOX(slot), build_window_control(app)); } refresh_window_limits(app); // The end group reads, left to right: cursors, measurements, math, menu -- with the // sidebar toggle to their left. `pack_end()` lays children out right to left (the first // packed is the *rightmost*), so they are packed in the reverse of that order, which is // what puts the menu last on screen. The trigger and the pause are *start*-packed above, // beside the window control: their event window is read against the span on screen, so // they belong with the control that sets it. // --- the overflow menu ---------------------------------------------------- // Rows rather than plain buttons: the label at the left, the shortcut at the right and a // check on the rows that toggle something, which is the shape every GNOME menu has and the // reason this popover no longer reads as a stack of dialogs (see menu_row(), and the // `.udpscope-menu` rules in install_css()). It stays hand-built instead of a `GMenu` model // because these act on the `RowSet` directly and two of them are toggles whose state the // rest of the UI reads, so an action model would only add a second copy of that state. // // The order is a GNOME menu's: what you do to the *view*, then the data, then the help // trio. Nothing here needs a separator of its own -- the groups are the separators. GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); gtk_widget_set_margin_top(menu_box, 6); gtk_widget_set_margin_bottom(menu_box, 6); gtk_widget_set_margin_start(menu_box, 6); gtk_widget_set_margin_end(menu_box, 6); GtkWidget* fit = menu_button("Fit to data", "F"); gtk_widget_set_tooltip_text(fit, "auto-scale every axis and frame the buffered data"); g_signal_connect(fit, "clicked", G_CALLBACK(on_fit_clicked), &app); gtk_box_append(GTK_BOX(menu_box), fit); app.layout_toggle = menu_toggle("Layout editor", "L", false); gtk_widget_set_tooltip_text(app.layout_toggle, "add and remove plot rows in place: \u00d7 on a row removes " "it, + between rows inserts one there"); g_signal_connect(app.layout_toggle, "toggled", G_CALLBACK(on_layout_editor_toggled), &app); gtk_box_append(GTK_BOX(menu_box), app.layout_toggle); gtk_box_append(GTK_BOX(menu_box), menu_separator()); GtkWidget* export_btn = menu_button("Export visible range...", "Ctrl+E"); gtk_widget_set_tooltip_text(export_btn, "write every plotted signal over the visible x range, at the " "stored sample rate, to a .npy or .csv file"); g_signal_connect(export_btn, "clicked", G_CALLBACK(on_export_clicked), &app); gtk_box_append(GTK_BOX(menu_box), export_btn); GtkWidget* configs = menu_button("Configurations...", nullptr); gtk_widget_set_tooltip_text(configs, "named sessions of this source: open one, save this arrangement " "under a new name with a note, or delete one"); g_signal_connect(configs, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { open_configurations(*static_cast(user)); }), &app); gtk_box_append(GTK_BOX(menu_box), configs); gtk_box_append(GTK_BOX(menu_box), menu_separator()); GtkWidget* shortcuts_item = menu_button("Keyboard shortcuts", "Ctrl+/"); gtk_widget_set_tooltip_text(shortcuts_item, "every key and mouse gesture, including the plot's own"); g_signal_connect(shortcuts_item, "clicked", G_CALLBACK(on_shortcuts_clicked), &app); gtk_box_append(GTK_BOX(menu_box), shortcuts_item); GtkWidget* help_item = menu_button("Help", "F1"); gtk_widget_set_tooltip_text(help_item, "the paged guide to every feature"); g_signal_connect(help_item, "clicked", G_CALLBACK(on_help_clicked), &app); gtk_box_append(GTK_BOX(menu_box), help_item); GtkWidget* prefs_item = menu_button("Preferences\u2026", nullptr); g_signal_connect(prefs_item, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { open_preferences(*static_cast(user)); }), &app); gtk_box_append(GTK_BOX(menu_box), prefs_item); gtk_box_append(GTK_BOX(menu_box), menu_separator()); GtkWidget* about_item = menu_button("About udpscope", nullptr); gtk_widget_set_tooltip_text(about_item, "version, build timestamp and settings paths"); g_signal_connect(about_item, "clicked", G_CALLBACK(on_about_clicked), &app); gtk_box_append(GTK_BOX(menu_box), about_item); // --- the pane toggles, which live on the bar rather than in the menu ------ // The three pane toggles, and the rest of the bar, are the file's now; their handlers are wired where // the bar's ids are resolved, below. The popover above still shares this function's styling helpers // (`menu_button()`, `menu_toggle()`), which is why it is built here. GtkWidget* popover = gtk_popover_new(); gtk_widget_add_css_class(popover, "udpscope-menu"); gtk_popover_set_child(GTK_POPOVER(popover), menu_box); app.menu_button = GTK_WIDGET(gtk_builder_get_object(ui, "menu_button")); app.sidebar_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "sidebar_toggle")); app.trigger_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_toggle")); app.events_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "events_toggle")); app.pause_button = GTK_WIDGET(gtk_builder_get_object(ui, "pause_button")); app.cursors_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "cursors_toggle")); if (app.menu_button == nullptr || app.sidebar_toggle == nullptr || app.trigger_toggle == nullptr || app.events_toggle == nullptr || app.pause_button == nullptr || app.cursors_toggle == nullptr) { // A missing id is a mistake in the `.ui`: the bar comes up with the controls it got rather than taking // the window with it, and `[udpscope] header:` reads whatever geometry is left. return bar; } gtk_menu_button_set_popover(GTK_MENU_BUTTON(app.menu_button), popover); // What each control *does*, which a file cannot hold. g_signal_connect(app.sidebar_toggle, "toggled", G_CALLBACK(on_sidebar_toggled), &app); g_signal_connect(app.trigger_toggle, "toggled", G_CALLBACK(on_trigger_toggled), &app); // The event history is a *trigger* thing, and one click from the mode that produces it. g_signal_connect(app.events_toggle, "toggled", G_CALLBACK(+[](GtkToggleButton* b, gpointer user) { App* a = static_cast(user); const bool open = gtk_toggle_button_get_active(b); gtk_revealer_set_reveal_child(GTK_REVEALER(a->events_revealer), open); // Through the one function that decides *both*: revealing the child alone leaves an // invisible pane with no width, which is a button that appears to do nothing. sync_events_availability(*a); if (open) refresh_events_pane(*a); }), &app); g_signal_connect(app.pause_button, "toggled", G_CALLBACK(on_pause_toggled), &app); g_signal_connect(app.cursors_toggle, "toggled", G_CALLBACK(on_cursors_toggled), &app); return bar; } void on_activate(GtkApplication* application, gpointer user) { App* app = static_cast(user); install_css(); // The window's skeleton is a `.ui` file: the window, its two panes, the plot column with the status // line under it, and the sidebar's tab switcher and two-page stack. Everything that depends on the // stream -- the events pane, the two plot areas, the sidebar's panels -- is built below and put // into the slots by id. g_clear_object(&app->window_ui); app->window_ui = gtk_builder_new_from_resource("/org/udpscope/ui/main-window.ui"); if (app->window_ui == nullptr) return; app->window = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "main_window")); GtkWidget* side_view = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_view")); app->side_view = side_view; GtkWidget* events_divider = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "events_divider")); app->plot_box = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_box")); app->plot_split = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_split")); app->status_badge = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_badge")); app->status_label = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_label")); app->status_info_button = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_info")); GtkWidget* side = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_column")); GtkWidget* side_stack = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_stack")); GtkWidget* side_switcher = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_switcher")); GtkWidget* measure_page = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "measure_page")); GtkWidget* signals_page = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "signals_page")); // A missing id is the one mistake neither the build nor `GtkBuilder` sees, and every use below would // be a null pointer. if (app->window == nullptr || side_view == nullptr || events_divider == nullptr || app->plot_box == nullptr || app->plot_split == nullptr || app->status_badge == nullptr || app->status_label == nullptr || app->status_info_button == nullptr || side == nullptr || side_stack == nullptr || side_switcher == nullptr || measure_page == nullptr || signals_page == nullptr) { return; } // The window's actions live on the application, so it has to know which one it belongs to. gtk_window_set_application(GTK_WINDOW(app->window), application); // A window closed by hand (a WM close button) must stop the frame tick: it holds // pointers to widgets that are finalized with the window. g_signal_connect(app->window, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer user) { App* a = static_cast(user); // A pending debounced save will never fire now: flush it, so // quitting right after a change keeps the change. if (a->layout_save_source != 0) { g_source_remove(a->layout_save_source); a->layout_save_source = 0; save_layout_now(*a); } a->window = nullptr; }), app); // The header bar is the window's own titlebar, and it is *not* in the `.ui`: every button on it is // wired to an action the application owns, which is code by nature. Its title and the icon the shell // shows are the window's, and those are in the file. gtk_window_set_titlebar(GTK_WINDOW(app->window), build_toolbar(*app)); // The size the window opens at, stated in code as well as in the `.ui`: `default-width` there is the // size it *asks* for, and a window whose content has a larger minimum opens at that minimum instead -- // 756 px here, the plot column's own minimum plus the sidebar's 320. The old code set it explicitly; // this is where it went when the window moved into the file, and it is why the window came up small. gtk_window_set_default_size(GTK_WINDOW(app->window), 1280, 800); // Nothing listens to the split view's own `show-sidebar`: it is an *output*. `AdwOverlaySplitView` // writes it itself -- at startup, and when it collapses -- and reading that back as user input is what // made connecting a source leave the sidebar shut. The button is the only input; `update_side_visibility` // re-asserts the wish on top of the widget (see the tick). // The badge and the text share one row so the badge sits at the line's head. It is a // sibling rather than part of the label's own markup because the label's text is // rewritten every frame and its markup is ellipsized -- a blinking span inside it would // be reparsed sixty times a second and could be the character that gets elided. // The badge's markup is the one value the `.ui` cannot carry (it is a phase, not a string), and its // tooltip says which phase the badge is *not* reporting. gtk_label_set_markup(GTK_LABEL(app->status_badge), badge_markup(BadgePhase::Idle)); gtk_widget_set_tooltip_text(app->status_badge, "waiting for the stream"); // The line is rewritten every frame and its text changes width as the counters // grow. A label's *minimum* width is its text, so an unellipsized one pushes the // paned divider around as the numbers change -- which resizes the plot, which // re-lays out the axes and their ticks. Ellipsizing gives it a minimum of "…" and // makes it fill whatever width it is given instead. // Guard the construction so the counter means something: if status_popover is already // non-null (e.g. run_ui called twice), count the extra build and avoid a leak. if (app->status_popover != nullptr) { ++app->popover_rebuilds; } else { app->status_popover = gtk_popover_new(); GtkWidget* detail_grid = gtk_grid_new(); gtk_grid_set_row_spacing(GTK_GRID(detail_grid), 2); gtk_grid_set_column_spacing(GTK_GRID(detail_grid), 16); gtk_widget_set_margin_start(detail_grid, 10); gtk_widget_set_margin_end(detail_grid, 10); gtk_widget_set_margin_top(detail_grid, 8); gtk_widget_set_margin_bottom(detail_grid, 8); // Row headings at indices 0..4, value lines at 4..12, in the order format_detail's fields // appear, then the capture's. Built once: update_status_popover() only ever calls // gtk_label_set_text(). static const char* const kHeadings[] = {"Stream", "Buffer", "Plot 1", "Reconnects", "Capture"}; const int kHeadingRow[] = {0, 3, 5, 6, 7}; for (int i = 0; i < 5; ++i) { GtkWidget* heading = gtk_label_new(nullptr); char markup[64]; std::snprintf(markup, sizeof(markup), "%s", kHeadings[i]); gtk_label_set_markup(GTK_LABEL(heading), markup); gtk_label_set_xalign(GTK_LABEL(heading), 0.0f); gtk_label_set_yalign(GTK_LABEL(heading), 0.0f); gtk_grid_attach(GTK_GRID(detail_grid), heading, 0, kHeadingRow[i], 1, 1); app->popover_label[i] = heading; } for (int i = 0; i < 8; ++i) { GtkWidget* value = gtk_label_new(""); gtk_label_set_xalign(GTK_LABEL(value), 0.0f); gtk_grid_attach(GTK_GRID(detail_grid), value, 1, i, 1, 1); app->popover_label[4 + i] = value; } // The capture toggle, below the grid: `toggled` sets the producer's atomic and // nothing else. Insensitive until connected, because the capture's path is keyed // on the source and there is no source before the first connect. app->capture_button = gtk_toggle_button_new(); GtkWidget* cap_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); gtk_box_append(GTK_BOX(cap_box), gtk_image_new_from_icon_name("media-record-symbolic")); gtk_box_append(GTK_BOX(cap_box), gtk_label_new("Capture packets")); gtk_button_set_child(GTK_BUTTON(app->capture_button), cap_box); gtk_widget_add_css_class(app->capture_button, "flat"); gtk_widget_set_margin_start(app->capture_button, 10); gtk_widget_set_margin_end(app->capture_button, 10); gtk_widget_set_margin_bottom(app->capture_button, 8); gtk_widget_set_sensitive(app->capture_button, FALSE); g_signal_connect(app->capture_button, "toggled", G_CALLBACK(+[](GtkToggleButton* b, gpointer user) { App* a = static_cast(user); // The whole GTK side of the feature: the flag, and nothing else. // The path is written once inside, before the flag is published. a->capture_want_ui = gtk_toggle_button_get_active(b); a->acq->set_capture_want(gtk_toggle_button_get_active(b)); }), app); update_capture_tooltip(*app); GtkWidget* popover_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); gtk_box_append(GTK_BOX(popover_box), detail_grid); gtk_box_append(GTK_BOX(popover_box), app->capture_button); gtk_popover_set_child(GTK_POPOVER(app->status_popover), popover_box); // Above the button, always. The status row is the last thing in the window, so "below" // is off the window's bottom edge and, on a window that reaches the bottom of the screen, // off the screen: the bubble's top edge and its arrow stayed at the button while the body // was clipped away -- what reads as an arrow disconnected from its body. GTK still slides // it sideways to fit, which is why only the vertical placement has to be stated. gtk_menu_button_set_popover(GTK_MENU_BUTTON(app->status_info_button), app->status_popover); // After `set_popover()`: a `GtkMenuButton` states a position of its own (below, which is // where a menu button in a header bar wants it), and it is the *last* word that counts. gtk_popover_set_position(GTK_POPOVER(app->status_popover), GTK_POS_TOP); gtk_popover_set_has_arrow(GTK_POPOVER(app->status_popover), TRUE); } // The right-hand side is two tabs, not five stacked panels: one tab is on screen at a time, so // no section competes with four others for height. Each page scrolls on its own -- the two are // different heights, and one shared scroller would leave a switch scrolled where the new page // never asked to be. // `side` and `side_stack`/`side_switcher`/`measure_page`/`signals_page` are resolved above with // the rest of the skeleton and checked by the null guard; assign the members here. app->side_box = side; app->side_stack = side_stack; app->side_switcher = side_switcher; // Measure: what the cursors bracket, and the figures derived from it. app->cursors_panel = build_cursors_panel(*app); gtk_box_append(GTK_BOX(measure_page), app->cursors_panel); gtk_box_append(GTK_BOX(measure_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); gtk_box_append(GTK_BOX(measure_page), build_measures_panel(*app)); // Signals: the trigger picks a channel from the list beside it. app->trigger_panel = build_trigger_panel(*app); gtk_box_append(GTK_BOX(signals_page), app->trigger_panel); gtk_box_append(GTK_BOX(signals_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); app->signal_panel = build_signal_panel(*app); gtk_box_append(GTK_BOX(signals_page), app->signal_panel); // The cursors start off, so their readout starts insensitive -- stated here by the one place that // knows the default, and the *body* and not the panel, because the panel's heading carries the // switch that turns it back on. The trigger's body is deliberately *not* disabled: a trigger is // configured before it is switched on, so its settings have to stay editable while it is off. gtk_widget_set_sensitive(app->cursors_body, FALSE); // The trigger's Arm button is the one control in its pane that needs trigger mode running. if (app->arm_button != nullptr) gtk_widget_set_sensitive(app->arm_button, FALSE); gtk_widget_set_sensitive(app->cursors_reset, FALSE); if (app->side_stack != nullptr) { adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(app->side_stack), "signals"); g_signal_connect(app->side_stack, "notify::visible-child-name", G_CALLBACK(on_side_tab_changed), app); } // The left side is the events pane *and* the plot, in one row: the pane is a column of its own // (the item's "collapsable pane on the left"), revealed by trigger mode. // A *paned*: the pane's width is the user's to choose, and the plot takes what is left. // The events pane is the one subtree of the inner divider that is built: whether it exists at all // depends on trigger mode. gtk_paned_set_start_child(GTK_PANED(events_divider), build_events_pane(*app)); // Once, now that both the button and the pane exist: trigger mode is off, so the pane is closed and // its button is not on the bar -- stated by the one function that decides it rather than by whichever // widget happened to be built with which default. sync_events_availability(*app); app->events_divider = events_divider; // Two plots, in a paned so a comparison's divider can move: the primary takes everything while the // second is hidden, and the width is the user's once it is not. // Nothing to state about the sidebar's size. It is the split view's: 320 px from the `.ui`, floored // at 240, and never a number derived from the window -- which is what the removed // `gtk_paned_set_position(paned, 940)` was, and why a window narrower than 940 px gave the sidebar // no width at all. // The split is in the `.ui`; what goes in it is not, because an area is a GL surface with a figure // handed to it. gtk_paned_set_start_child(GTK_PANED(app->plot_split), make_area(*app)); gtk_paned_set_end_child(GTK_PANED(app->plot_split), make_compare_area(*app)); // A waveform opened from a file is already in the model by the time the UI is built -- there is no // drop and no CONFIG to wait for -- so its channels are bound to the rows here: four traces to a // row, more rows when the file has more columns. This is the drop handler's job for a live source, // done once. The samples stay: they are the file's, and the plot draws from the same rings a // streamed run fills. const std::string waveform = app->model->config().open_waveform; if (!waveform.empty()) { app->waveform_name = waveform; const std::vector& loaded = app->model->channels(); if (!loaded.empty()) { // **One column per row**, not four to a row. Four to a row fills the default single row // completely, and then every drop is refused -- "plot 1 already holds four signals" -- which // is a loaded file the user cannot plot anything else on, and cannot rearrange. One per row // leaves three free slots on each, shows a four-signal file as the four stacked traces a // bench scope would, and keeps the remaining columns draggable from the panel. // One column per row, and the row count was already sized for exactly that before the UI // existed (`main.cpp`, the way `--plots` does it): a `set_row_count()` *here* leaves the // panels built for the old count, and the app hangs on the first relayout rather than // drawing. Four to a row would fit one row, fill it, and refuse every later drop. for (std::size_t c = 0; c < loaded.size(); ++c) { const int row = static_cast(c); if (row >= app->rows->row_count()) break; app->rows->assign(row, 0, loaded[c].name, loaded[c].name, signal_color_index(app->model->signals(), loaded[c].signal)); } // The figure is rebuilt with them on the first tick -- the drop handler's own contract. app->relayout_pending = true; app->rows->fit_all(); std::string base = waveform; const std::size_t slash = base.rfind('/'); if (slash != std::string::npos) base = base.substr(slash + 1); set_notice(*app, "opened " + base + ": " + std::to_string(loaded.size()) + " signals, " + std::to_string(app->rows->row_count()) + " rows"); } } rebuild_signal_panels(*app); g_timeout_add(kTickMs, tick, app); // Bubble phase on purpose: a focused GtkEditable or GtkButton wins its own keys, so typing // 'm' into the window entry does not toggle the measurements pane and Space does not // double-fire on a focused button. GtkEventController* keys = gtk_event_controller_key_new(); gtk_event_controller_set_propagation_phase(keys, GTK_PHASE_BUBBLE); g_signal_connect(keys, "key-pressed", G_CALLBACK(on_app_key_pressed), app); gtk_widget_add_controller(app->window, keys); // Capture phase, unlike the keys above: a press belongs to whatever widget is under it (the plot's // gestures among them), and the point here is to see every one of them, not to win none of them. GtkGesture* press = gtk_gesture_click_new(); gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(press), GTK_PHASE_CAPTURE); g_signal_connect(press, "pressed", G_CALLBACK(on_window_popover_press), app); gtk_widget_add_controller(app->window, GTK_EVENT_CONTROLLER(press)); gtk_window_present(GTK_WINDOW(app->window)); // The loading window covered the gap from the chooser to here -- the buffer, the file read and // the whole of the construction above, none of which returns to a main loop. It goes down only // now, *after* the present: destroying it before would leave the screen empty again for the // frame or two the real window takes to map. A no-op when there was no chooser. loading_hide(); // What the window is waiting for, said *before* the first tick. The stream's first CONFIG plans // the buffer on the producer thread, which holds the model's mutex for the whole of it -- at a few // gigabytes that is around a second per gigabyte, and the frame tick cannot read the model while // it runs, so the window does not change. Set here it is on screen already: the present paints a // frame before any timeout fires, which is the difference between waiting and "the dialog // disappeared and nothing happened". if (app->waveform_name.empty()) { const Config& cfg = app->model->config(); char buf[192]; std::snprintf(buf, sizeof(buf), "connecting to %s:%u -- planning the %.0f MB buffer", cfg.server.c_str(), unsigned(cfg.port), cfg.buffer_mb); set_notice(*app, buf, 6); } } /// Headless self-check, step 2: read the panel geometry while the widgets still /// exist, then close the window. Runs after the reveal animations have settled. /// Step 2: record the status line while trigger mode is on, then leave trigger /// mode the way the "Go live" button does, and stop the tick. /// Headless self-check, step 1: toggle the trigger the way the toolbar button /// does, then let the reveal animation settle before measuring. /// Headless self-check: arm the trigger, then disarm it the way the button does, /// so the check can prove no event latched after the disarm. /// Finds a toolbar/panel button by its label, so the self-check drives the *user's* /// path (a toggle's handler, a button's click) rather than poking the model -- which /// the frame tick then legitimately corrects. GtkWidget* find_labelled_button(GtkWidget* w, const char* label) { if (w == nullptr) return nullptr; if (GTK_IS_BUTTON(w)) { const char* l = gtk_button_get_label(GTK_BUTTON(w)); if (l != nullptr && std::strcmp(l, label) == 0) return w; } for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr; c = gtk_widget_get_next_sibling(c)) { if (GtkWidget* hit = find_labelled_button(c, label)) return hit; } return nullptr; } /// Headless self-check: sample the trigger state machine and what the plot is /// actually bound to, so the arm -> capture -> latch -> wait cycle is observable /// headlessly. Two things in this line are assertions in disguise: /// /// * with `trigger=on`, `display=` must never read `live` -- in trigger mode the /// picture changes only on a latch; /// * `snap=[a..b]` and `axis=[a..b]` must agree, and `b` must be positive by /// roughly the post-trigger share of the span. A post side that is empty, or a /// snapshot that does not fill the axis, is the defect this probe exists for. /// Headless self-check: waits for a *held* event, then lets the tick run the zoom sweep (see /// `report_event_zoom`). Polling for `Held` rather than firing on a fixed delay is the same /// lesson the disarm probe learned: a latch happens when the stream says so, and a sweep that /// starts before one has nothing to zoom into. gboolean eventzoom_wait(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; if (a->model->display_mode() != DisplayMode::Held) return G_SOURCE_CONTINUE; // Eight halvings: a 2 s event down to about 8 ms, which crosses the pyramid's own // raw/LOD gate on the way and so exercises both gather paths. Then the same again // through the window control. a->dbg_zoom_steps = kZoomGestureSteps + kZoomWindowSteps; a->dbg_zoom_step_no = 0; return G_SOURCE_REMOVE; } /// Single trigger mode, armed: one event, held for as long as the sweep needs it. /// Headless self-check: defines math channels the way the math pane will, so the *pipeline* can be /// checked without driving the editor -- the same graph reaches the producer either way. Two graphs /// on the first acquired channel: a gain of 2 and a low pass, which the dump then reports beside the /// source's own extremes, so "twice the amplitude" and "attenuated" are read off real samples. /// 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:`. /// Headless self-check for the statistics line: turns the pane-wide toggle on over the /// measurements `measure_probe` added, lets several recomputes land, and reports each /// accumulator. `n` must grow with the passes and never with the samples, and a Reset must /// put every `n` back to zero. /// /// Three phases, because "one observation per recompute" needs two different witnesses. Phase 0 /// is the running count. Phase 1 is the same count after a **pause held for two seconds**: a /// pinned window recomputes once when it settles and then never again, so `n` must advance by /// *at most one* across the pause -- that is what separates accumulating per recompute from /// accumulating per frame, which would add a hundred and twenty observations of the identical /// window and collapse `sd`. Phase 2 is after a Reset. /// 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. /// Headless self-check: arms the trigger the way the toolbar's trigger switch does. /// The capture the renumber probe opened: the edge it opened on, and the latch count /// before it, so step 3 can tell a new latch from the one already on screen. /// Headless self-check, step 3: waits for the capture to latch and reports whether the /// edge it latched on is still the one it opened on. /// /// `t0_held=1` is the reading. With the engine's invalidation keyed on the trigger /// channel's *index* rather than its name, step 2's renumber abandoned the capture and /// set no hold-off, so the next edge inside the post window became t0 and this read 0. /// Headless self-check, step 2: the moment a capture is open, enables a signal that sorts /// *before* the trigger's own -- which is what renumbers the trigger channel under the /// engine. Polls rather than times, so it lands inside the post window whatever the rate. /// Headless self-check, step 1: the reported geometry, driven through the real widgets -- /// 1 s window, 10 % pre share, hold-off from `--holdoff` -- on the *square* alone, so that /// enabling the sine later moves the trigger channel's index. /// Headless self-check: changes the trigger panel's two dropdowns, which is what a user /// does to switch Normal/Single or the edge -- mid-capture, if the probe is timed into /// one. Reaching the dropdowns directly is the point: their `notify::selected` handler /// has a different signature from the spin buttons' `value-changed`, and connecting the /// wrong one crashed on the first field read with no way to tell from the state dump. /// A line here with no SIGSEGV after it is the assertion. /// Headless self-check for the qualified conditions. Selected by /// `UDPSCOPE_PROBE_CONDITION_SHAPE` (0 = pulse `<`, 1 = pulse `>`, 2 = window, 3 = dropout). /// /// The assertion is snapshot-relative rather than an absolute t0. The app's stream clock /// epoch is the first packet it received, not the mock's t = 0; the two clocks differ by /// however many packets the CONNECT handshake cost (typically a whole number of 100-sample / /// 10 ms packets), so comparing trigger_time() against a mock-clock target is meaningless. /// /// Shape 0 (pulse `<`): x = 0 is the glitch's closing edge. Scans backwards for the most /// recent upward crossing; `measured = -stamp` is the glitch width (≈ 0.002 s). Window 0.2 s, /// 10% pre (0.02 s back) — the glitch is 0.002 s wide so 0.02 s comfortably contains it. /// /// Shape 1 (pulse `>`): same scan; `measured` is the ordinary square half-period (≈ 0.200 s). /// Needs a 1.0 s window with 50% pre (0.5 s) because the opening edge of the captured pulse /// is 0.2 s before x = 0, beyond the 0.02 s pre a 0.2 s window would provide. /// /// Shape 2 (window): edge is set to `falling` (out-of-band), so x = 0 is the moment the /// signal left the band. Uses the sine channel because the square has no sample inside /// [-0.5, +0.5] and can never produce a band exit. Reports `at0` (the sample at x = 0, which /// must be outside the band) and `earlier` (the sample immediately before it, which must be /// inside). Window 0.2 s, 10% pre — the crossing is one sample before x = 0. /// /// Shape 3 (dropout): x = 0 is the moment the timeout expired. Scans backwards for the most /// recent crossing in either direction, because the last sine crossing before the quiet period /// may be downward, and an upward-only scan would miss it. `timeout` must exceed the sine's /// 0.1 s half-period; 0.3 s is used so that an ordinary live sine cannot satisfy the /// condition. Needs a 1.0 s window with 50% pre because the crossing is `timeout` ≈ 0.3 s /// before x = 0. /// /// All legs print one `[condition-probe]` line with `nan` for any unmeasurable field. // Returns the channel name required by the given shape: shapes 2 and 3 use the sine (the // square has no sample inside [-0.5, +0.5] and cannot satisfy a window condition, and the // dropout relies on the sine going quiet); shapes 0 and 1 use the square. /// Headless self-check for the buffer search, and for the claim that makes it worth having: /// a scanned hit is an instant the *live trigger* would have fired on. /// /// It arms the same pulse-width condition `condition_probe` arms, waits for a real latch, /// then searches the buffer for that condition and compares the latched t0 with the nearest /// scanned hit. `agree=1` is the assertion; `agree=0` means the two evaluators have drifted /// apart, which is the whole failure mode separating `EventDetector` out was meant to /// prevent. It then steps to a hit and reports the pinned axis against it. /// Reports the marks a *frame* after the jump, because that is the only moment they can be read /// honestly: `search_jump()` publishes a pause view, but the axis it is pinned against and the /// `view_range()` that `place_search_marks()` filters by are both settled by the frame tick, not /// by the publish. Reading `search_marks_placed()` in the same callback as the jump would quote /// the window the scope was on *before* it moved. /// /// This cannot be read from the exit dump instead. The exit probe stops the tick from `Live`, /// where the newest window holds no hit at all, so a dump reading is `placed=0` whatever the /// marks did -- true of a correct implementation and of a broken one alike. The dump's own line /// asserts the thing that does survive going live: that the new cursor pool is unselectable. /// Headless self-check: drives the window control the four ways a user can -- type a number, /// change the unit under it, and step a decade either way -- and prints what each produced. /// Each line reports the typed request, the window the plot took and the trigger's event /// span: all three must agree except where the cap clamps, and the event span must track the /// display window or a capture would be cut to a different length than the axis frames. /// One line of the layout probe: the row count, how many buttons are on the overlay, the /// rows' own rects in logical pixels, where each button was placed, and what every row is /// plotting. The last of those is the point of the whole probe -- an insert or a remove /// has to move the rows' *contents* with them, not renumber which row a signal is on. /// Headless self-check: drives the layout editor the way a user does -- switch it on, /// insert above the first row, remove a row, append one at the bottom, switch it off -- /// with a frame between each step, because the buttons are placed from the rects the last /// frame was laid out with. /// Headless self-check: acquire every signal the CONFIG described, the way the /// signal panel's switches would, and put each resulting channel on a plot, the way /// a drag from the panel onto a row would. Without the second half nothing is bound /// and every downstream number -- trace sizes, painted frames, what the trigger /// displays -- reads zero whatever the pipeline is doing. /// Headless self-check, the half that reads the *outcome* of the Disarm. A disarm that /// landed mid-capture (`was_capturing=1`) must leave an event on screen with the /// capture's own span: `display` reading `nothing` there is the "the event completely /// disappeared" defect, and `seq` must have advanced by one while `events` has not. /// Waits for a capture to actually be open before clicking Disarm. The interesting case /// is the Disarm that lands *mid-capture*, and a fixed delay catches it only by luck -- /// the capture opens as soon as the ring holds a pre window and closes one post window /// later. Polling for `trigger_pending()` makes the probe deterministic. /// 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. /// 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. /// Headless self-check for the export: collect the same table the menu item would and /// write it both ways, skipping only the file chooser. Two numbers are the assertion -- /// `time=` must equal `axis=` (the export is the *visible* range, not the whole ring) /// and `rows=` must be that span times the stream's real rate, so a table cut from the /// LOD pyramid or from a stale window shows up as a count far short of it. /// Reports the cursors: the instruments' values, whether the pane is up, and the pane's /// own text (which is what a user reads, so it is what the probe asserts). /// The two fits the right-click menu offers, driven through the handlers the menu wires /// (a null button: neither handler looks at it). Both must put x back on the whole time window /// and **leave the window itself alone**; `fit x` must leave the y ranges alone too, and /// `fit all` must move them to the data's. /// 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. /// Zooms row 0 in to a quarter of what it shows, the way a gesture does -- on the *member* axis, /// which `Figure::layout()` adopts into the group. A fit is the undo of this, so the probe has to /// perform it or it has nothing to undo. /// Drives the plot's keyboard zoom with Ctrl and with Alt and reports which axis moved. /// The ranges are read **synchronously**, in this callback, which is deliberate and is the /// opposite of what the pan and zoom probes do. The question here is only "does the /// controller's modifier restriction reach the app", so the read has to see the /// controller's own write and nothing else: `hold_pinned_window()` clamps the *x* span /// into the pinned window, so a reading taken a frame later would report that clamp's /// answer rather than the restriction's for the ctrl case. Nor can a same-callback read /// drift past a state boundary, which is the hazard the read-in-the-tick rule exists for. /// Whether a zoom the controller performed is then *adopted* by the figure is a different /// question, and `[pan-probe]`'s `zoom-in asked=/group=/member=` line is what asserts it. /// Pausing first is therefore not required, only tidy — it is how Task 11 runs it: /// UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200 /// How tall a menu row may be. Adwaita's own minimum for a button is 34 px, and a popover of /// seven such rows is what made the overflow read as a stack of dialogs; the `.udpscope-menu` /// rules trim it to a menu row and this is what says they are still applied. /// The label and the shortcut of one row, in the order menu_row() appends them. The optional /// check image comes first and is not a label, so this counts labels rather than positions. void menu_row_labels(GtkWidget* box, GtkWidget** label, GtkWidget** accel) { *label = nullptr; *accel = nullptr; for (GtkWidget* child = box != nullptr ? gtk_widget_get_first_child(box) : nullptr; child != nullptr; child = gtk_widget_get_next_sibling(child)) { if (!GTK_IS_LABEL(child)) continue; if (*label == nullptr) { *label = child; } else { *accel = child; } } } /// The two popovers, measured, with the side panel hidden -- the state the report is about. /// What it asserts, row by row, is what a screenshot can only show an eye: the label at the /// left of its row, the shortcut at the right of it, a row under kMenuRowMaxPx, the rows in /// the intended order, and an info bubble placed above its button with room for its height. /// Checks that a chosen trace colour reaches the **trace** and not merely the resolver. /// /// It reads the colour back out of `plot::Plot::trace()` -- what the renderer will stroke -- and out /// of the signal list's chip markup, because the chip's only job is to name a trace and the two /// resolving a colour by separate routes is the one failure a chip cannot survive. The override is /// `#123456`, deliberately in no palette slot: an override that happened to equal the entry it /// replaced would pass against an implementation that ignored it entirely. /// `UDPSCOPE_PROBE_RESEQ_MS`: one reading of the resequencing path, mid-run. /// /// The counters are in the exit dump too; what is only here is `nan_slots`, `trigger_in_hole` and /// `bands`. The first is the state of the *data* rather than of a counter: a reservation writes /// placeholders and a backfill repairs them, and the two counters agreeing proves only that the /// same number of slots was written and read, not that the right ones were. The second asserts the /// trigger never latched inside a reserved span. The third asserts the band reached the plot, and /// splits it by state: a hole whose late packets have all arrived keeps its band in the fainter /// tint, so `bands_repaired` is how a reading tells a window that is *missing* data from one that /// merely *was*. /// Toggles the capture **through the same handler the button uses**, lets the stream /// run, then reads the file back off disk and prints what it holds. `stored` must equal /// the datagrams the exit dump counts from the toggle's instant, `torn` must be 0, and /// `first_seq` must be 1; with `--capture-packets=N` and a long enough run, `stored` /// exceeds the slot count and `last_seq - first_seq == N - 1` -- the wrap, which shows /// here and nowhere else. /// The probe's readings across its steps: each view is measured a frame after the one before it was /// swapped out, because a popover re-lays out its new child on the next layout pass -- measured in /// the same call, the rows are unallocated (`h=0`), which reads like a style defect and is not one. /// A row of the menu, found by the label it shows: the menu is rebuilt on every view change, so no /// widget handle survives one. GtkWidget* row_menu_find(GtkWidget* container, const char* want) { for (GtkWidget* row = container != nullptr ? gtk_widget_get_first_child(container) : nullptr; row != nullptr; row = gtk_widget_get_next_sibling(row)) { if (!GTK_IS_BUTTON(row)) continue; GtkWidget* label = nullptr; GtkWidget* accel = nullptr; menu_row_labels(gtk_widget_get_first_child(row), &label, &accel); if (label != nullptr && std::string(gtk_label_get_text(GTK_LABEL(label))) == want) return row; } return nullptr; } /// One view's rows, measured. `bad` counts anything outside the compact style: a row taller than /// `kMenuRowMaxPx`, a label not left of its shortcut, or a label past the indent a leading icon /// costs (the Back row's arrow -- every menu indents a Back row). void row_menu_measure(GtkWidget* box, const char* tag, int* rows_out, int* bad_out) { int rows = 0; int bad = 0; std::string labels; for (GtkWidget* row = box != nullptr ? gtk_widget_get_first_child(box) : nullptr; row != nullptr; row = gtk_widget_get_next_sibling(row)) { if (!GTK_IS_BUTTON(row)) continue; GtkWidget* label = nullptr; GtkWidget* accel = nullptr; menu_row_labels(gtk_widget_get_first_child(row), &label, &accel); const int row_w = gtk_widget_get_width(row); const int h = gtk_widget_get_height(row); const graphene_point_t origin = GRAPHENE_POINT_INIT(0.0f, 0.0f); graphene_point_t at = GRAPHENE_POINT_INIT(0.0f, 0.0f); const int label_x = label != nullptr && gtk_widget_compute_point(label, row, &origin, &at) ? static_cast(at.x) : -1; const int accel_right = accel != nullptr && gtk_widget_compute_point(accel, row, &origin, &at) ? static_cast(at.x) + gtk_widget_get_width(accel) : -1; const bool left = label_x >= 0 && label_x <= 60; const bool right = accel == nullptr || (accel_right > 0 && row_w - accel_right <= 12); const bool compact = h > 0 && h <= kMenuRowMaxPx; const bool ok = left && right && compact; if (!ok) ++bad; const char* text = label != nullptr ? gtk_label_get_text(GTK_LABEL(label)) : "?"; labels += (labels.empty() ? "" : ",") + std::string(text); std::fprintf(stderr, "[rowmenu] %s %d '%s' h=%d label_x=%d accel_right=%d/%d ok=%d\n", tag, rows, text, h, label_x, accel_right, row_w, ok ? 1 : 0); ++rows; } std::fprintf(stderr, "[rowmenu] %s rows=%d bad=%d labels=[%s]\n", tag, rows, bad, labels.c_str()); *rows_out = rows; *bad_out = bad; } /// Opens the plot's context menu on row 0 and walks its two views: the main rows, the remove view /// it swaps in, Back to the main view, and into the remove view a *second* time. The last one is the /// case that used to break -- a `GtkMenuButton` collapsed on the second click and left its row dead, /// which is why the submenu is a second view of this menu rather than a popover of its own. /// How many traces a row is *actually drawing*: the figure's answer, not the slots'. int group_visible_traces(App& app, int row) { if (app.rows == nullptr || row < 0 || row >= app.rows->row_count()) return -1; const RowSet::Row& r = app.rows->row(row); if (r.plot == nullptr) return -1; int visible = 0; for (plot::u32 t = 0; t < r.plot->trace_count(); ++t) { if (r.plot->trace(t).visible()) ++visible; } return visible; } void report_events_availability(App& a, const char* what) { std::fprintf(stderr, "[events] %s: button_visible=%d pane=%d pane_w=%d tick=%d recording=%d\n", what, a.events_toggle != nullptr && gtk_widget_get_visible(a.events_toggle) ? 1 : 0, a.events_revealer != nullptr && gtk_revealer_get_reveal_child(GTK_REVEALER(a.events_revealer)) ? 1 : 0, a.events_divider != nullptr ? gtk_paned_get_position(GTK_PANED(a.events_divider)) : -1, signal_tick_visible(a), a.event_recording ? 1 : 0); } /// Walks the whole event-cache loop the way a user does: trigger mode on (the real toggle, which is /// what reveals the pane), a latch recorded automatically, the cache listed, the newest event /// replayed through the loader `--open` uses, and *Live* taken back. What it reports is what each /// step must be able to do -- a cache nothing can replay is a directory of files, and a replay with /// no way back is a one-way door. /// The two group layouts, driven through the buttons a user presses. What they must read is what the /// figure *draws*, and what the second plot *is*. /// Drives the drop handler the way a drag from the signal panel does, and reports what the row holds /// afterwards. This is the path a user's "plot this signal" takes, so it is the one that says /// whether a *loaded file* can be plotted at all: a file-mode run whose rows are sized four signals /// to one row fills its only row, refuses every drop with "plot 1 already holds four signals", and /// reads exactly like a scope that cannot plot anything. /// Every spin button under `w`, in the order they were built. A `GtkSpinButton` starts at its /// range's **minimum**, so an unseeded one is a number nobody chose -- which is exactly how a /// calibration offset opened at -1e12 and how a trigger level did too. void collect_spins(GtkWidget* w, std::vector* out) { if (w == nullptr) return; if (GTK_IS_SPIN_BUTTON(w)) out->push_back(gtk_spin_button_get_value(GTK_SPIN_BUTTON(w))); for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr; c = gtk_widget_get_next_sibling(c)) { collect_spins(c, out); } } /// Opens the two dialogs a user reads *before* touching anything -- Calibrate and the trigger /// panel -- and reports every value in them. Each must be the field's own default; a `-1e12` here /// is an unseeded spin button the dialog would then apply. /// Fires a representative accel from each group and reports the state it changed, then /// checks the GtkEditable refusal. It goes through run_app_action() for the actions and /// through the real handler for the refusal, because the refusal lives in the handler. /// Loads and validates every help page, opens all three windows, closes them, and reports. /// `parsed` must equal `pages` and no page may be empty; `closed ok` with no GTK critical /// is the crash check for three windows built and destroyed in one run. int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisition& acq, RowSet& rows) { (void)argc; // Installs libadwaita's stylesheet, which the shortcut overview's `AdwShortcutsDialog` // needs to be drawn at all. Idempotent, and `choose_source()` may already have run it. adw_init(); // The preferences are read before anything is built: the theme decides what the window looks like, // and the rest is in force from the first frame. A *local* until `App` exists below, which is why // this one read happens here and the assignment after it. const Settings startup_settings = load_settings(); apply_theme(startup_settings.theme); // The app's own icons, before anything asks for one. They are a GResource compiled into the // binary (see CMakeLists), and this is what makes the theme look there: an unregistered path // is not an error, it is a missing-image placeholder. // // It is here rather than in `build_toolbar()` because there are now two consumers -- the // window's own icon and the toolbar's buttons -- and this is the one place that is ahead of // both. `adw_init()` calls `gtk_init()`, which opens the default display, so the line above // is what makes `gdk_display_get_default()` valid here. gtk_icon_theme_add_resource_path(gtk_icon_theme_get_for_display(gdk_display_get_default()), "/org/udpscope/icons"); App app; app.settings = startup_settings; app.model = &model; app.acq = &acq; app.rows = &rows; // Which data source this session talks to: the persisted-layout file's name. // A waveform has **no source**: the address the command line defaults to is not one, and treating // it as one made a file-mode run load -- and then *save* -- the session of `127.0.0.1:8000`, which // both overrode the arrangement the file was opened with and could clobber a real session. With no // port, `save_layout_now()` refuses and the first-CONFIG layout load never runs, which is exactly // a source-less run. app.source_host = cfg.open_waveform.empty() ? cfg.server : std::string(); app.source_port = cfg.open_waveform.empty() ? cfg.port : 0; app.source_multicast = cfg.open_waveform.empty() ? cfg.multicast : std::string(); app.source_interface = cfg.interface_addr; // The event cache is keyed on the source, like the packet capture. A waveform has no source, so a // file-mode run has no cache either -- there are no latches in a file. if (!app.source_host.empty() && app.source_port != 0) { app.event_store = std::make_unique(app.source_host + "-" + std::to_string(app.source_port), app.settings.event_cache_dir); } refresh_events_pane(app); GtkApplication* application = // `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 // the pipeline produced, so a CI run can assert the stream actually arrived and // both panels were laid out. double exit_after_ms = 0.0; if (const char* env = g_getenv("UDPSCOPE_EXIT_AFTER_MS")) exit_after_ms = std::atof(env); if (exit_after_ms > 0.0) { g_timeout_add(static_cast(exit_after_ms), exit_probe_step1, &app); } if (const char* env = g_getenv("UDPSCOPE_ENABLE_ALL_MS")) { g_timeout_add(static_cast(std::atoi(env)), enable_all_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_DISARM_MS")) { g_timeout_add(static_cast(std::atoi(env)), disarm_probe_step1, &app); } if (const char* env = g_getenv("UDPSCOPE_ARM_TRIGGER_MS")) { g_timeout_add(static_cast(std::atoi(env)), arm_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_TRIGRENUM_MS")) { g_timeout_add(static_cast(std::atoi(env)), trigrenum_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_CONDITION_MS")) { g_timeout_add(static_cast(std::atoi(env)), condition_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_STATS_MS")) { g_timeout_add(static_cast(std::atoi(env)), stats_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_SIDE_TAB_MS")) { g_timeout_add(static_cast(std::atoi(env)), side_tab_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_SEARCH_MS")) { g_timeout_add(static_cast(std::atoi(env)), search_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_FIELDS_MS")) { g_timeout_add(static_cast(std::atoi(env)), fields_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_WINDOW_MS")) { g_timeout_add(static_cast(std::atoi(env)), window_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_TIMELABELS_MS")) { g_timeout_add(static_cast(std::atoi(env)), timelabels_probe_step, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURES_MS")) { g_timeout_add(static_cast(std::atoi(env)), measures_probe_step, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_FIT_MS")) { g_timeout_add(static_cast(std::atoi(env)), fit_probe_step, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_CURSORS_MS")) { g_timeout_add(static_cast(std::atoi(env)), cursors_probe_step, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_LAYOUT_MS")) { g_timeout_add(static_cast(std::atoi(env)), layout_probe_step, &app); } if (const char* env = g_getenv("UDPSCOPE_TRIG_TRACE_MS")) { g_timeout_add(static_cast(std::atoi(env)), trig_trace_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_MATH_MS")) { g_timeout_add(static_cast(std::atoi(env)), math_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURE_MS")) { g_timeout_add(static_cast(std::atoi(env)), measure_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_EVENTZOOM_MS")) { g_timeout_add(static_cast(std::atoi(env)), eventzoom_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_PAN_MS")) { g_timeout_add(static_cast(std::atoi(env)), pan_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_PAUSE_MS")) { g_timeout_add(static_cast(std::atoi(env)), pause_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_EXPORT_MS")) { g_timeout_add(static_cast(std::atoi(env)), export_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_ZOOMKEYS_MS")) { g_timeout_add(static_cast(std::atoi(env)), zoomkeys_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_HELP_MS")) { g_timeout_add(static_cast(std::atoi(env)), help_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_CONFIGS_MS")) { g_timeout_add(static_cast(std::atoi(env)), configs_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_EVENTS_MS")) { events_probe_next(app, 0, static_cast(std::atoi(env))); } if (const char* env = g_getenv("UDPSCOPE_PROBE_DROP_MS")) { g_timeout_add(static_cast(std::atoi(env)), drop_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_NOISE_MS")) { const int ms = std::atoi(env); // Times the *setup* stage; `noise_probe` runs 5000 ms after it, so 3000 puts the legs at the same // 8000 ms they were measured at. if (ms > 0) g_timeout_add(static_cast(ms), noise_probe_setup, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_ROWMENU_MS")) { rowmenu_probe_next(app, 0, static_cast(std::atoi(env))); } if (const char* env = g_getenv("UDPSCOPE_PROBE_DEFAULTS_MS")) { g_timeout_add(static_cast(std::atoi(env)), defaults_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_BOXZOOM_MS")) { g_timeout_add(static_cast(std::atoi(env)), boxzoom_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_SIDEBAR_MS")) { g_timeout_add(static_cast(std::atoi(env)), sidebar_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_PREFS_MS")) { g_timeout_add(static_cast(std::atoi(env)), prefs_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_CAPTURE_MS")) { capture_probe_next(app, 0, static_cast(std::atoi(env))); } if (const char* env = g_getenv("UDPSCOPE_PROBE_MENUS_MS")) { menus_probe_next(app, 0, static_cast(std::atoi(env))); } if (const char* env = g_getenv("UDPSCOPE_PROBE_KEYS_MS")) { g_timeout_add(static_cast(std::atoi(env)), keys_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_WINDOWPOP_MS")) { g_timeout_add(static_cast(std::atoi(env)), windowpop_probe_step, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_COLOR_MS")) { g_timeout_add(static_cast(std::atoi(env)), color_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_RESEQ_MS")) { g_timeout_add(static_cast(std::atoi(env)), reseq_probe, &app); } // Our own options were consumed by parse_args(); GTK would reject them, so only // the program name is handed to g_application_run(). const int result = g_application_run(G_APPLICATION(application), 1, const_cast(argv)); g_object_unref(application); if (exit_after_ms > 0.0) { // The derived channels' own reading: how much of the tail is *finite*, and what it holds. // A filter that integrated a reserved hole's NaN used to come out empty here for ever // (`finite=0` at the first drop, with the streamed channels holding values) -- so this is // the assertion that a gap is a gap and not the end of the trace. { std::lock_guard lock(model.mutex()); for (std::size_t i = 0; i < model.channels().size(); ++i) { if (!model.channels()[i].math) continue; auto& ring = model.live().values(i); const std::uint64_t produced = ring.produced(); const std::size_t take = static_cast(std::min(produced, 4000)); if (take == 0) continue; double sum = 0, sum2 = 0, lo = 0, hi = 0; std::size_t finite = 0; for (std::size_t k = 0; k < take; ++k) { const double v = ring[static_cast(produced - take + k)]; if (!std::isfinite(v)) continue; if (finite == 0) lo = hi = v; ++finite; sum += v; sum2 += v * v; lo = std::min(lo, v); hi = std::max(hi, v); } const double mean = finite > 0 ? sum / finite : 0.0; const double sd = finite > 0 ? std::sqrt(std::max(0.0, sum2 / finite - mean * mean)) : 0.0; std::fprintf(stderr, "[udpscope] math '%s': finite=%zu/%zu mean=%.4f sd=%.4f min=%.4f max=%.4f\n", model.channels()[i].name.c_str(), finite, take, mean, sd, lo, hi); } } const Partition& part = model.partition(); std::printf("[udpscope] frames=%llu packets=%llu gaps=%llu channels=%zu span=%.3fs " "stride=%llu" " reseq=%llu late=%llu stale=%llu overflows=%llu holes=%llu hole_slots=%llu" " backfilled=%llu\n", static_cast(model.stats().frames.load()), static_cast(model.stats().packets.load()), static_cast(model.stats().gaps.load()), model.channel_count(), part.span_s, static_cast(part.time_stride), static_cast(model.stats().resequenced.load()), static_cast(model.stats().late.load()), static_cast(model.stats().stale.load()), static_cast(model.stats().overflows.load()), static_cast(model.stats().holes.load()), static_cast(model.stats().hole_slots.load()), static_cast(model.stats().backfilled.load())); for (std::size_t i = 0; i < model.channel_count(); ++i) { std::printf("[udpscope] channel %zu '%s' stored=%llu published=%llu\n", i, model.channels()[i].name.c_str(), static_cast(model.live().values(i).produced()), static_cast(model.live().channel(i).size())); } // `frames_painted` is the area's, read from the snapshot taken before the window was // destroyed (the PlotArea wrapper dies with its widget): one figure, one render per // frame, so it is the same count for every row. std::printf("[udpscope] rows: %d of at most %d, layout editor %s\n", rows.row_count(), RowSet::kMaxRows, app.layout_editing ? "up" : "down"); for (int i = 0; i < rows.row_count() && i < RowSet::kMaxRows; ++i) { if (rows.row(i).plot == nullptr) continue; std::printf("[udpscope] plot %d frames_painted=%d traces=%u\n", i, app.dbg_frames_painted, rows.row(i).plot->trace_count()); } // Where a frame's time went, split at the only two boundaries observable from the // host: the frame function, the plot's draw, and the toolkit's whole paint. `draw` // close to `paint` is the library's cost; `paint` far above `draw` is the toolkit // compositing a framebuffer (which llvmpipe dominates). `path` says whether the // traces went through the LOD pyramid at all -- a raw frame is O(visible samples). std::printf("[udpscope] frame cost: tick %.2f/%.2f ms over %d, draw %.2f/%.2f ms " "(max at frame %d), paint %.2f/%.2f ms over %d (mean/max)\n", app.dbg_tick_n > 0 ? app.dbg_tick_ms_sum / app.dbg_tick_n : 0.0, app.dbg_tick_ms_max, app.dbg_tick_n, app.dbg_paint_n > 0 ? app.dbg_draw_ms_sum / app.dbg_paint_n : 0.0, app.dbg_draw_ms_max, app.dbg_draw_max_frame, app.dbg_paint_n > 0 ? app.dbg_paint_ms_sum / app.dbg_paint_n : 0.0, app.dbg_paint_ms_max, app.dbg_paint_n); std::printf("[udpscope] frame path: raw=%d lod=%d columns=%d graded=%d frames, segments " "%llu/%u (mean/max), visible max %llu samples\n", app.dbg_raw_frames, app.dbg_lod_frames, app.dbg_column_frames, app.dbg_graded_frames, static_cast(app.dbg_paint_n > 0 ? app.dbg_seg_sum / app.dbg_paint_n : 0), app.dbg_seg_max, static_cast(app.dbg_visible_max)); // Layout stability: every one of these must be a single value. A range means // something is resizing or re-measuring the plot, which is what a wiggling axis // looks like from the outside. std::printf("[udpscope] layout over %d frames: axes.x %d..%d, axes.w %d..%d, " "viewport.w %d..%d, y-gutter %d..%d, x-ticks %d..%d, x-desync %d\n", app.dbg_axes_frames, app.dbg_axes_x_min, app.dbg_axes_x_max, app.dbg_axes_w_min, app.dbg_axes_w_max, app.dbg_vp_w_min, app.dbg_vp_w_max, app.dbg_gutter_min, app.dbg_gutter_max, app.dbg_xtick_min, app.dbg_xtick_max, app.dbg_x_desync); std::printf("[udpscope] axes.w changes: %d (last at frame %d, side panel up=%d)\n", app.dbg_axes_w_changes, app.dbg_axes_w_change_frame, app.dbg_axes_w_change_side); std::printf("[udpscope] reset button sensitive: %d\n", app.dbg_reset_sensitive); std::printf("[udpscope] render blank frames: %d of %d painted\n", app.dbg_render_blank_frames, app.dbg_frames_painted); std::printf("[udpscope] traceless paints: %d (%d after the first bind, last at paint %d)\n", app.dbg_notrace_frames, app.dbg_notrace_after_bound, app.dbg_notrace_last); std::printf("[udpscope] worst side/pane min width: %d px\n", app.dbg_min_w_worst); std::printf("[udpscope] trace ink: %d paints checked, %d collapsed, %d staircase, %d short, " "worst cover %.1f%%, worst drawn-vs-quoted %.4f px over %d raw paints\n", app.dbg_ink_checked, app.dbg_ink_collapse, app.dbg_ink_staircase, app.dbg_ink_short, app.dbg_ink_worst_cover * 100.0, app.dbg_ink_worst_offset_px, app.dbg_ink_raw_checked); 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 " "means Disarm reached the engine)\n", static_cast(model.trigger_latches().load()), static_cast(app.dbg_latches_at_disarm)); std::printf("[udpscope] window control: '%s'\n", app.dbg_window_text.c_str()); { // The signals panel's frame, read from the window rather than from the `.ui`: the two labels and the // list the file gives it. A `.ui` that lost one of them reads here -- `labels=1` or `list=0` -- rather // than showing as a panel quietly missing its hint. GtkWidget* panel = app.signal_list != nullptr ? gtk_widget_get_parent(app.signal_list) : nullptr; // Counted over the panel's *subtree*, not its direct children: the heading lives in a heading row // beside the pane's [edit] button, and a count of children would read 1 whatever the file lost. int labels = 0; int lists = 0; const std::function count = [&](GtkWidget* w) { // The list stops the walk: its rows are the code's, and this reading is about the file's frame. if (GTK_IS_LIST_BOX(w)) { ++lists; return; } if (GTK_IS_LABEL(w)) ++labels; for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr; c = gtk_widget_get_next_sibling(c)) { count(c); } }; if (panel != nullptr) count(panel); // The list's shape, beside the frame: `rows` is what the list holds, `signals` the streamed // ones, and the `MATH` divider plus the math channels account for the rest -- so // `rows == signals + 1 + math` is the invariant, and `divider=0` is a list that lost its `+`. std::printf("[udpscope] signals panel: labels=%d list=%d rows=%d signals=%zu math=%zu " "divider=%d drag_sources=%d\n", labels, lists, app.dbg_signal_rows, app.dbg_signal_plain, app.math.size(), app.dbg_signal_rows == static_cast(app.dbg_signal_plain + 1 + app.math.size()) ? 1 : 0, app.dbg_signal_drag_sources); } { // The events pane's frame, read from the window: the four widgets the `.ui` gives it, and the list // being inside the revealer the file builds. A control that lost its id reads 0 here rather than // showing as a pane missing a button, and `nested=0` is a list that is no longer in the pane at all. const auto inside = [](GtkWidget* w, GtkWidget* root) { if (w == nullptr) return 0; for (GtkWidget* p = w; p != nullptr; p = gtk_widget_get_parent(p)) { if (p == root) return 1; } return 0; }; std::printf("[udpscope] events pane: compare=%d accumulate=%d list=%d revealer=%d nested=%d\n", app.events_compare != nullptr ? 1 : 0, app.events_accumulate != nullptr ? 1 : 0, app.events_list != nullptr ? 1 : 0, app.events_revealer != nullptr ? 1 : 0, inside(app.events_list, app.events_revealer)); } std::printf("[udpscope] panels: side=%dx%d (visible=%d) signals=%dx%d row_h=%d " "(page_current=%d) trigger=%dx%d (sensitive=%d) switcher=%d " "cursors=%d window=%.3fs window_cap=%.1fs\n", app.dbg_side_w, app.dbg_side_h, app.dbg_side_column_after_hide, app.dbg_signal_w, app.dbg_signal_h, app.dbg_signal_row_h, app.dbg_signal_page_current, app.dbg_trigger_w, app.dbg_trigger_h, app.dbg_trigger_sensitive, app.dbg_switcher_visible, rows.cursors_visible() ? 1 : 0, rows.time_window(), app.window_limit_s); { GtkIconTheme* theme = gtk_icon_theme_get_for_display(gdk_display_get_default()); std::printf("[udpscope] pane drag: sidebar hidden for the drag=%d restored after it=%d\n", app.dbg_drag_hid_sidebar, app.dbg_drag_restored_sidebar); std::printf("[udpscope] pane edits: locked=%s(%s) config=%s(%s)\n", app.dbg_pane_edit_icon.c_str(), gtk_icon_theme_has_icon(theme, app.dbg_pane_edit_icon.c_str()) ? "ok" : "MISSING", app.dbg_pane_view_icon.c_str(), gtk_icon_theme_has_icon(theme, app.dbg_pane_view_icon.c_str()) ? "ok" : "MISSING"); } std::printf("[udpscope] panes: trigger=%s signals=%s body_visible=%d summary='%s'\n", app.dbg_trigger_locked ? "locked" : "config", app.dbg_signals_locked ? "locked" : "config", app.dbg_trigger_body_visible, app.dbg_trigger_summary.c_str()); std::printf("[udpscope] panes: the same line as the probe found it: '%s'\n", app.dbg_trigger_summary_early.c_str()); std::printf("[udpscope] panel switches: trigger=%d/%d cursors=%d/%d drove_toolbar=%d " "(pane switch / header bar button; `drove_toolbar` is the pane's own switch having " "moved the button)\n", app.dbg_panel_switch_trigger, app.dbg_toolbar_switch_trigger, app.dbg_panel_switch_cursors, app.dbg_toolbar_switch_cursors, app.dbg_panel_drove_toolbar); std::printf("[udpscope] switcher: policy=%s pills=%d labels=%d names=%d icon=%dx%d " "size=%dx%d\n", app.dbg_switcher_policy == 0 ? "narrow" : (app.dbg_switcher_policy == 1 ? "wide" : "?"), app.dbg_switcher_pills, app.dbg_switcher_labels, app.dbg_switcher_names, app.dbg_switcher_icon_w, app.dbg_switcher_icon_h, app.dbg_switcher_w, app.dbg_switcher_h); std::printf("[udpscope] mode with trigger on: %s\n", app.dbg_mode_triggered.c_str()); std::printf("[udpscope] trigger marker x=%.3f, view x_max=%.3f; model trigger_time=%.3f " "valid=%d\n", 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()); std::printf("[udpscope] status bar: glance=[● %s] max_glance_chars=%zu detail_fields=11 " "popover_rebuilds=%d\n", app.dbg_glance_last.c_str(), app.glance_max_chars, app.popover_rebuilds); // The derived channels beside the channel they came from. A math channel defined by the pane // reaches the producer by exactly the path the probe uses, so this is the pipeline's own // assertion: `x2` must read twice its source's extremes, a low pass must read less. { std::vector> wanted; { std::lock_guard lock(app.model->mutex()); // The first *streamed* channel too: it is the one a saturated ring clamps at the front, and // the order check below is the only place that shows it without a screenshot. if (!app.model->channels().empty()) { wanted.emplace_back(app.model->channels().front().name, 0); } for (const MathChannel& m : app.model->math_channels()) { int idx = -1; for (std::size_t c = 0; c < app.model->channels().size(); ++c) { if (app.model->channels()[c].name == m.name) idx = static_cast(c); } wanted.emplace_back(m.name, idx); } } for (const auto& [name, idx] : wanted) { if (idx < 0) { std::printf("[udpscope] math '%s': no channel (disabled?)\n", name.c_str()); continue; } std::vector t; std::vector v; const bool ok = visible_samples(app, static_cast(idx), t, v); double lo = 0.0; double hi = 0.0; for (std::size_t i = 0; ok && i < v.size(); ++i) { lo = i == 0 ? v[i] : std::min(lo, v[i]); hi = i == 0 ? v[i] : std::max(hi, v[i]); } // The stamps of the window the plot would draw: they must be strictly increasing. A // duplicate or a backwards step is what a wrap artifact looks like from the data side -- // the renderer's index window comes from a binary search over these, and a segment drawn // from the end of the ring to its beginning is a window that spans the wrap. // A break can only be at the *front*: the window's oldest slots may be clamped to the oldest // readable stamp (the published window is pinned while the rings slide under it), so a // saturated ring legitimately shows a few non-increasing stamps at the start. A break // anywhere else is a real one, so the *first* break's position is reported. std::size_t order_breaks = 0; long long first_break = -1; for (std::size_t i = 1; ok && i < t.size(); ++i) { if (!(t[i] > t[i - 1])) { ++order_breaks; if (first_break < 0) first_break = static_cast(i); } } // `produced` beside the visible count: a derived channel is filled once per stored time // slot, so its ring must hold exactly as many samples as the time ring the plot reads it // against. The first version pushed twice per slot and this ratio would have said so. u64 produced = 0; u64 slot_count = 0; { std::lock_guard lock(app.model->mutex()); produced = app.model->live().values(static_cast(idx)).produced(); slot_count = app.model->live().time().produced(); } std::printf("[udpscope] math '%s': channel %d, %llu samples vs %llu time slots, " "%zu visible (order breaks %zu, first at %lld), y=[%.4f, %.4f]\n", name.c_str(), idx, static_cast(produced), static_cast(slot_count), ok ? v.size() : 0u, order_breaks, first_break, ok ? lo : 0.0, ok ? hi : 0.0); } // What the derived channels cost the *producer*. This is not a frame cost: the math runs // between two DATA packets on the acquisition thread, so it never appears in `frame cost:` // and shows up only as dropped packets once it exceeds the inter-packet budget. `ns/slot` // is the figure to compare across runs; it is *sampled* (see `ScopeModel::math_slots()`), // because timing every slot costs about as much as a slot and would measure itself. if (app.model->math_timed() > 0) { const double per_slot = static_cast(app.model->math_cost_ns()) / static_cast(app.model->math_timed()); std::printf("[udpscope] math cost: %llu slots, %.1f ns/slot (%llu timed), " "%.2f ms total est\n", static_cast(app.model->math_slots()), per_slot, static_cast(app.model->math_timed()), per_slot * static_cast(app.model->math_slots()) / 1.0e6); } } // What freezing a view costs, counted apart for the two publishes because they stall opposite // threads (see `ScopeModel::publish_event_count()`). This is the figure a second live ring would // buy: freezing a whole `BufferStore` copies nothing and inherits the pyramid the producer // already maintains, so both `worst` figures would be 0 -- at a second budget's worth of memory // and a second per-sample write on the producer. Read `event worst` against `frame cost:`'s // `tick` max (the same stall, seen from the UI) and `pause worst` against `gaps`. { const auto ev = app.model->publish_event_count(); const auto pa = app.model->publish_pause_count(); std::printf("[udpscope] publish cost: %llu events, %.2f/%.2f ms (mean/worst, blocks the UI);" " %llu pauses, %.2f/%.2f ms (mean/worst, blocks the producer)\n", static_cast(ev), ev > 0 ? static_cast(app.model->publish_event_us_total()) / static_cast(ev) / 1000.0 : 0.0, static_cast(app.model->publish_event_us_worst()) / 1000.0, static_cast(pa), pa > 0 ? static_cast(app.model->publish_pause_us_total()) / static_cast(pa) / 1000.0 : 0.0, static_cast(app.model->publish_pause_us_worst()) / 1000.0); } // 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); // Where the panel controls live. Computed once, when the widgets exist -- the dump runs *after* // the window is destroyed, where a widget pointer is dangling and asking GTK about it is a // critical (which is how the first version of this line earned four of them). std::printf("[udpscope] panel controls: %s\n", app.dbg_panel_controls.c_str()); std::printf("[udpscope] toolbar icons: %s\n", app.dbg_toolbar_icons.c_str()); std::printf("[udpscope] page icons: %s\n", app.dbg_page_icons.c_str()); std::printf("[udpscope] app icons: %s\n", app.dbg_app_icons.c_str()); // 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, " "data %.2f ms (%.0f%%), covered=%llu buckets=%llu raw=%llu (%.1f%%), " "%.1f ns/covered sample\n", app.measure_passes, app.measure_cost_us / 1000.0, app.measure_cost_us / 1000.0 / app.measure_passes, app.measure_fetch_us / 1000.0, app.measure_cost_us > 0 ? 100.0 * static_cast(app.measure_fetch_us) / static_cast(app.measure_cost_us) : 0.0, static_cast(app.measure_covered), static_cast(app.measure_bucketed), static_cast(app.measure_raw), app.measure_covered > 0 ? 100.0 * static_cast(app.measure_raw) / static_cast(app.measure_covered) : 0.0, app.measure_covered > 0 ? 1000.0 * static_cast(app.measure_cost_us) / static_cast(app.measure_covered) : 0.0); } } std::printf("[udpscope] badge frames: lit=%d dim=%d idle=%d connecting=%d\n", app.dbg_badge_frames[0], app.dbg_badge_frames[1], app.dbg_badge_frames[2], app.dbg_badge_frames[3]); std::printf("[udpscope] trigger panel sensitive before the probe toggled it: %d; " "signal list items=%d selected=%d; column visible after sidebar toggle off=%d; " "pane switches live while their panes are off=%d; arm sensitive with the trigger off=%d\n", app.dbg_trigger_sensitive_default, app.dbg_trigger_items, app.dbg_trigger_selected, app.dbg_side_column_after_hide, app.dbg_panel_switch_live, app.dbg_arm_sensitive); { // The panel's *frame* read back: the row boxes the `.ui` holds, and that every control the code built // ended up inside the panel rather than in a row that is not there. A control is created in code and // placed by an id, so an id that drifted leaves it built and nowhere -- which looks like a panel // missing a field, and is what `controls_inside` counts. `[defaults]` prints the same panel's spins // in tree order, so the two together read what the file gave and what the code put in it. const auto inside = [](GtkWidget* w, GtkWidget* root) { if (w == nullptr) return 0; for (GtkWidget* p = w; p != nullptr; p = gtk_widget_get_parent(p)) { if (p == root) return 1; } return 0; }; // The *file's* panel (`trigger_body`), not the wrapper this app puts it in: the heading row and the // locked summary are the code's arrangement, and what these counts read is what the `.ui` holds. int boxes = 0; for (GtkWidget* c = gtk_widget_get_first_child(app.trigger_body); c != nullptr; c = gtk_widget_get_next_sibling(c)) { if (GTK_IS_BOX(c)) ++boxes; } // `search_widgets` separated from `search_inside` on purpose: the first says the ids resolved, the // second says they are in the panel. A `.ui` that lost one and a `.ui` that lost its *place* are two // different mistakes, and only one of them shows as a missing button. // The expander's child is **not** in the widget-parent chain: `gtk_widget_get_parent()` on it stays // NULL even after the window is presented, while `gtk_expander_get_child()` returns it (measured with // a throwaway program, all three ways of setting it). So the walk the search widgets are read with has // to start at the expander's child and not at the panel -- walking to the panel reads 0/4 on a panel // that is entirely correct, which is what this line did first. GtkWidget* search_root = app.trigger_body; for (GtkWidget* c = gtk_widget_get_first_child(app.trigger_body); c != nullptr; c = gtk_widget_get_next_sibling(c)) { if (GTK_IS_EXPANDER(c)) { if (GtkWidget* inner = gtk_expander_get_child(GTK_EXPANDER(c))) search_root = inner; break; } } const int search_present = (app.search_run_button != nullptr) + (app.search_cancel_button != nullptr) + (app.search_prev_button != nullptr) + (app.search_next_button != nullptr); std::printf( "[udpscope] trigger panel: boxes=%d controls_inside=%d/15 search_widgets=%d/4 " "search_inside=%d/4\n", boxes, inside(app.trigger_signal_drop, app.trigger_body) + inside(app.trigger_type_drop, app.trigger_body) + inside(app.trigger_edge_drop, app.trigger_body) + inside(app.trigger_cmp_drop, app.trigger_body) + inside(app.trigger_mode_drop, app.trigger_body) + inside(app.trigger_width_spin, app.trigger_body) + inside(app.trigger_width2_spin, app.trigger_body) + inside(app.trigger_level2_spin, app.trigger_body) + inside(app.trigger_timeout_spin, app.trigger_body) + inside(app.trigger_level_spin, app.trigger_body) + inside(app.trigger_pre_spin, app.trigger_body) + inside(app.trigger_holdoff_spin, app.trigger_body) + inside(app.trigger_band_spin, app.trigger_body) + inside(app.trigger_hf_spin, app.trigger_body) + inside(app.events_record_switch, app.trigger_body), search_present, inside(app.search_run_button, search_root) + inside(app.search_cancel_button, search_root) + inside(app.search_prev_button, search_root) + inside(app.search_next_button, search_root)); } { plot::Plot& p = *rows.row(0).plot; const plot::PlotLayout& lay = p.layout(); int visible = 0; for (plot::u32 t = 0; t < p.trace_count(); ++t) { if (p.trace(t).visible()) ++visible; } std::printf("[udpscope] row 0 plot: x=[%.3f,%.3f] y=[%.3f,%.3f] axes=%.0fx%.0f " "visible_traces=%d legend_rows=%zu legend_panel=%.0fx%.0f offset=%.3f " "trigger_marker_x=%.3f\n", p.x_range(0).min, p.x_range(0).max, p.y_range(0).min, p.y_range(0).max, double(lay.axes.w), double(lay.axes.h), visible, lay.legend.rows.size(), double(lay.legend.panel.w), double(lay.legend.panel.h), p.x_offset(0), p.cursor(rows.row(0).trigger_marker).x); for (plot::u32 t = 0; t < p.trace_count(); ++t) { const plot::Trace& tr = p.trace(t); if (tr.series == nullptr) continue; std::printf("[udpscope] trace %u '%s' size=%llu x0=%.3f dx=%.6f bounds=[%.3f,%.3f]\n", t, tr.name.c_str(), static_cast(tr.series->size()), tr.series->x0(), tr.series->dx(), tr.series->x_bounds().min, tr.series->x_bounds().max); } } { static const char* const kModes[] = {"vertical", "horizontal", "both", "mix"}; const int mode = static_cast(rows.cursor_mode()); // What the *plot* holds, which is what the renderer draws from: a marker the app // placed but the plot parked (or hid) would show up here and not on screen. for (int i = 0; i < rows.row_count(); ++i) { const RowSet::Row& r = rows.row(i); if (r.plot == nullptr) continue; const plot::Plot& p = *r.plot; std::printf("[udpscope] cursors in plot %d: vis=%d a=(x=%.6f vis=%d) " "b=(x=%.6f vis=%d) ruler=(y=%.6f vis=%d) ruler_b=(y=%.6f vis=%d) " "stored_rulers=%.6f,%.6f\n", i, rows.cursors_visible() ? 1 : 0, p.cursor(r.cursor_a).x, p.cursor(r.cursor_a).visible, p.cursor(r.cursor_b).x, p.cursor(r.cursor_b).visible, p.cursor(r.ruler).y, p.cursor(r.ruler).visible, p.cursor(r.ruler_b).y, p.cursor(r.ruler_b).visible, r.ruler_y, r.ruler_b_y); // Where the plot would draw them: the row's own axes rect and the device // coordinate the ruler's y maps to (`cursor_screen_coordinate`). const plot::PlotLayout& lay = p.layout(); std::printf("[udpscope] cursors in plot %d: axes=(%.0f,%.0f %.0fx%.0f) " "ruler_screen=%.1f ruler_b_screen=%.1f y_range=[%.4g..%.4g]\n", i, double(lay.axes.x), double(lay.axes.y), double(lay.axes.w), double(lay.axes.h), double(p.cursor_screen_coordinate(r.ruler)), double(p.cursor_screen_coordinate(r.ruler_b)), p.y_range(r.axis_y).min, p.y_range(r.axis_y).max); } std::printf("[udpscope] cursors: on=%d mode=%s t0=%.6f t1=%.6f dt=%.6f", rows.cursors_visible() ? 1 : 0, kModes[mode], rows.cursor_a_x(), rows.cursor_b_x(), rows.cursor_b_x() - rows.cursor_a_x()); for (int r = 0; r < rows.row_count(); ++r) { std::printf(" y%d=[%.6g,%.6g]", r, rows.ruler_y(r), rows.ruler_b_y(r)); } std::printf("\n"); } for (std::size_t i = 0; i < app.measures.size(); ++i) { const Measure& m = app.measures[i]; const MeasureResult& r = i < app.measure_results.size() ? app.measure_results[i] : MeasureResult{}; std::printf("[udpscope] measure %zu: '%s' %s thr=%.1f%% above=%d show=%d -> %s " "%.9g", i, m.signal.c_str(), measure_type_name(m.type), m.options.threshold_pct, m.options.above ? 1 : 0, m.options.show_on_plot ? 1 : 0, r.valid ? "ok" : "invalid", r.valid ? r.primary : 0.0); // What the row *reads*: the formatted string with its unit (so `frequency` showing Hz // is asserted here, not inferred), and the row's chip count (two: the measurement's // colour and the signal's). if (i < app.measure_text.size()) std::printf(" text=[%s]", app.measure_text[i].c_str()); if (r.valid && r.has_secondary) std::printf(" duty=%.6g", r.secondary); if (r.valid && !r.marks.empty()) { // One entry per mark with its kind: the mix is the point (`min` is a ruler *and* a // marker), so a shape that could only say "level" or "time" would hide the case this // asserts. std::printf(" marks="); for (std::size_t k = 0; k < r.marks.size(); ++k) { if (k != 0) std::printf(","); std::printf("%s:%.9g", r.marks[k].level ? "level" : "time", r.marks[k].value); } } std::printf("\n"); } { // The x label form: relative to the window's own start (with the caption that names // that start) when the labels could not carry the tick step absolutely, absolute // otherwise. `x_origin_set` is read back out of the plot, so this is what the axis is // actually formatting with, and `note` is the plot's own caption string. const RowSet::Row& rlt = rows.row(rows.row_count() - 1); std::string note; if (rlt.plot != nullptr) note = rlt.plot->x_note(); // 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; bool distinct = true; if (rlt.plot != nullptr) { int shown = 0; std::vector seen; for (const plot::Tick& t : rlt.plot->x_axis(rlt.axis_x).ticks) { if (!t.major) continue; if (std::find(seen.begin(), seen.end(), t.label) != seen.end()) distinct = false; seen.emplace_back(t.label); if (shown++ < 4) { if (shown != 1) ticks += ","; ticks += t.label; } } } // 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 distinct=%d origin=%.6f note=[%s] " "x_origin_set=%.6f ticks=[%s] first_rel=%.9f\n", rows.time_labels_relative() ? 1 : 0, distinct ? 1 : 0, rows.time_labels_origin(), note.c_str(), origin_set, ticks.c_str(), first_value - origin_set); } { std::lock_guard lock(model.mutex()); // `results_for` is the window the numbers describe, `win` the one on screen now: the // drift between them is the assertion that a measurement is a statistic of the *current* // window -- a timer-decoupled recomputation is what used to leave a marker outside it. // Both on the **stream's** clock, which is the space `measure_window` and the time marks // are stored in: a live axis is a fixed `[-window, 0]`, so comparing display x would read // no drift whatever the data did (see `App::measure_window`). const double s0 = model.stream_at_x0(); const RowSet::Row& r0 = rows.row(0); const plot::Range view = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{}; const plot::Range win{view.min + s0, view.max + s0}; const double span = app.measure_window.span(); int in_window = 0; int marks = 0; for (const RowSet::MeasureMark& mk : app.measure_marks) { if (mk.level) continue; ++marks; if (mk.value >= app.measure_window.min && mk.value <= app.measure_window.max) ++in_window; } // Whether the marks the app placed are *unselectable* -- the property the pointer's hit // test respects. Read back out of the plot, so this is the app's own setting and not // the intent behind it. int selectable = 0; for (int i = 0; i < rows.row_count(); ++i) { const RowSet::Row& r = rows.row(i); if (r.plot == nullptr) continue; for (int m = 0; m < RowSet::kMeasureMarks; ++m) { const std::size_t mi = static_cast(m); if (r.plot->cursor(r.measure_h[mi]).selectable) ++selectable; if (r.plot->cursor(r.measure_v[mi]).selectable) ++selectable; } } std::printf("[udpscope] measures window: win=[%.6f..%.6f] results_for=[%.6f..%.6f] " "drift=%.4f%% marks_in_window=%d/%d marks_selectable=%d\n", win.min, win.max, app.measure_window.min, app.measure_window.max, span > 0.0 ? 100.0 * (std::fabs(win.min - app.measure_window.min) + std::fabs(win.max - app.measure_window.max)) / span : 0.0, in_window, marks, selectable); } { std::lock_guard lock(model.mutex()); std::printf("[udpscope] measures pane: n=%zu marks_shown=%d pane_rows=%zu " "pane_w=%d pane_h=%d switch_on=%d reset_sensitive=%d\n", app.measures.size(), rows.measure_marks_placed(), app.measure_labels.size(), app.dbg_measures_pane_w, app.dbg_measures_pane_h, // The two widgets the panel's *frame* is read by: the code drives `measure_stats_on`, // so the switch and the Reset button are where a `.ui` that lost them -- or lost the // wiring to them -- shows up. `switch_on` disagreeing with `[stats-probe] on=` is that. app.measures_stats_check != nullptr && gtk_check_button_get_active(GTK_CHECK_BUTTON(app.measures_stats_check)) ? 1 : 0, app.measures_stats_reset != nullptr && gtk_widget_get_sensitive(app.measures_stats_reset) ? 1 : 0); } // `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 (bar_w=%d)\n", app.dbg_win_btn_w, app.dbg_bar_w); std::printf("[udpscope] layout: rows=%d window_s=%.6g", rows.row_count(), rows.time_window()); for (int r = 0; r < rows.row_count() && r < RowSet::kMaxRows; ++r) { std::printf(" row%d=[", r); for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { if (s != 0) std::printf(","); std::printf("'%s'", rows.row(r).signal_name[static_cast(s)].c_str()); } std::printf("]"); } std::printf("\n"); for (int r = 0; r < rows.row_count() && r < RowSet::kMaxRows; ++r) { for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { const int ch = rows.channel_of(r, s); if (ch < 0) continue; ChannelBuffer* buf = rows.row(r).slots[static_cast(s)].bound(); std::printf("[udpscope] row %d slot %d -> channel %d '%s' samples=%llu\n", r, s, ch, rows.row(r).name[static_cast(s)].c_str(), static_cast(buf != nullptr ? buf->size() : 0)); } } } return result; } } // namespace ui_impl /// The public entry point `ui.hpp` declares. The front end's own `run_ui` lives with the rest /// of the window construction in `ui_impl`, which owns `App`; this is the one function the rest /// of the program sees. int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisition& acq, RowSet& rows) { return ui_impl::run_ui(argc, argv, cfg, model, acq, rows); } } // namespace udpscope