// 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 #include "acquisition.hpp" #include "config.hpp" #include "export.hpp" #include "model.hpp" #include "plot_host.hpp" #include #include #include #include #include #include #include #include #include #include #include namespace udpscope { namespace { constexpr int kTickMs = 16; struct App { ScopeModel* model = nullptr; Acquisition* acq = nullptr; RowSet* rows = nullptr; GtkWidget* window = nullptr; GtkWidget* plot_box = nullptr; /// The one GL area, drawing the `RowSet`'s figure: every row is a row of that /// figure, so there is one widget, one context and one `::render` whatever the row /// count is. GtkWidget* area_widget = nullptr; plot::gtk4::PlotArea* area = nullptr; /// The GL area wrapped in a `GtkOverlay`, which is what the plot box actually holds. /// The layout editor's buttons are overlay children: each is a small widget placed by /// its margins over the row it acts on, so it intercepts pointer events only where it /// is -- a single transparent child covering the area would swallow every pan, drop and /// hover the editor is not interested in. GtkWidget* area_overlay = nullptr; /// On while the layout editor is up. Nothing else about the plot changes: the editor is /// a set of overlay buttons, not a mode the figure knows about. bool layout_editing = false; GtkWidget* layout_toggle = nullptr; /// The editor's buttons, in the order they were added, so they can be taken off the /// overlay again. Rebuilt when the row count changes, repositioned when the rows move. std::vector layout_buttons; /// The row count and figure geometry the buttons were placed for. Their positions are /// restated only when one of these moves: a `gtk_widget_set_margin_top()` per frame is a /// relayout per frame, and overlay children are measured out of the main child's /// allocation, so the churn would reach the plot. int layout_buttons_rows = 0; /// Which step of the layout probe runs next (`UDPSCOPE_PROBE_LAYOUT_MS`). The steps are /// a frame apart, because the buttons are placed from the rects the last frame was laid /// out with, so the sequence cannot be one function. int dbg_layout_step = 0; /// What `PlotArea::create()` is handed. The area is in figure mode from the start and /// never draws this, but it keeps a reference to the plot it was created with (its /// single-plot controller holds one) -- and every plot of the figure is destroyed by a /// rebuild, so handing it a row would leave that reference dangling. plot::Plot placeholder_plot; GtkWidget* status_label = nullptr; /// The activity badge: one glyph whose *colour* is the whole signal, so that blinking /// it cannot change its width and drive a relayout (see the status label's comment). GtkWidget* status_badge = nullptr; /// What the badge was last set to (`BadgePhase`), so the markup is rewritten on a /// change of phase rather than on every one of the 60 frames a second. int badge_phase = -1; /// The display-window control: a header-bar button whose label is the current window /// and whose popover holds the number, its unit and the two decade steppers. The label /// is width-locked (see `kWindowLabelChars`) because the header bar sits above the plot /// and a label that grew with the text would relayout everything under it. GtkWidget* window_button = nullptr; GtkWidget* window_label = nullptr; GtkWidget* window_entry = nullptr; GtkWidget* window_unit_drop = nullptr; /// The unit `sync_window_display()` last wrote into the dropdown. `GtkDropDown`'s /// `notify::selected` arrives *after* the call that caused it returns, so the /// `updating_panels` flag is already clear by then and cannot tell our own write from the /// user's pick; the entry the sync wrote is rounded for reading, so re-parsing it made the /// rounding the window (a 500 s request came back as 499.98 s). A selection that matches /// what we synced is ours -- GTK only notifies on a change, so the user cannot produce it. int window_sync_unit = -1; GtkWidget* trigger_toggle = nullptr; GtkWidget* trigger_revealer = nullptr; GtkWidget* signal_toggle = nullptr; GtkWidget* signal_revealer = nullptr; GtkWidget* signal_panel = nullptr; GtkWidget* side_box = nullptr; GtkWidget* arm_button = nullptr; GtkWidget* trigger_signal_drop = nullptr; GtkWidget* sidebar_toggle = nullptr; GtkWidget* side_scroll = nullptr; GtkWidget* trigger_edge_drop = nullptr; GtkWidget* trigger_mode_drop = nullptr; GtkWidget* trigger_level_spin = nullptr; GtkWidget* trigger_pre_spin = nullptr; GtkWidget* trigger_holdoff_spin = nullptr; GtkWidget* signal_list = nullptr; GtkWidget* pause_button = nullptr; GtkWidget* menu_button = nullptr; GtkWidget* cursors_toggle = nullptr; GtkWidget* cursors_reset = nullptr; unsigned signal_generation = ~0u; bool updating_panels = false; ///< guards signal handlers during a rebuild bool relayout_pending = false; bool signals_panel_initialised = false; /// Set once the user edits the trigger level, so the panel stops defaulting it /// into the middle of the declared range. bool trigger_level_touched = false; /// A short-lived message shown in the status line (a drop's outcome), so an /// action that changes nothing still says why. std::string notice; gint64 notice_until_us = 0; /// Trigger-mode state for the status line: how many latches were on screen last /// frame, and whether an event is held (which is what "frozen" means). std::uint64_t seen_latches = 0; /// Which snapshot was framed last. Separate from `seen_latches` because a Disarm during /// a capture publishes a snapshot without an event having fired. std::uint64_t seen_event_seq = 0; bool event_held = false; /// Whether a capture is open on screen: the edge has been accepted and the post window /// is filling in view, on the coming event's own clock. It is what keeps /// `clamp_to_data()` off the pinned axis and tells the status line apart from "armed". bool capturing = false; /// The edge of the capture `capturing` refers to, so a second edge accepted before the /// first capture closed re-frames instead of being mistaken for the same one. double capture_edge = 0.0; /// Monotonic time of the last event, and the edge that started the clock. The clock /// starts when the *edge* is accepted, not when the capture closes -- that is the /// instant the user saw -- and the edge is kept so the latch that ends a live-updated /// capture does not restart it. /// Wall time, not stream time: "how long since the last event" is a question about the /// user's wait, and a stream whose clock runs at any other speed would answer it wrong. /// 0 until the first event of the session. gint64 last_latch_us = 0; double timed_edge = 0.0; double window_limit_s = -1.0; ///< the cap the window control was last clamped against /// The rate each channel actually *stored* over the last second, and the declared rate /// beside it. The two differ for two reasons now and both are worth seeing: the declared /// rate is a claim the stream may not keep, and a window too long to hold at the wire /// rate is resampled on ingest (`Partition::time_stride`), so this is also the readout /// of how much resolution the current window costs. Re-measured once a second (the same /// tick as the channel dump), never per frame. std::vector stored_rate; std::vector declared_rate; /// Self-check: how much the axes' left edge moves between frames. int dbg_axes_x_min = 1 << 20; int dbg_axes_x_max = -(1 << 20); int dbg_axes_frames = 0; /// Frames on which some row's x window did not match row 0's. The rows share the /// link group's one window, so this is an assertion: it must read 0. int dbg_x_desync = 0; gint64 last_channels_meas = 0; /// Frames drawn with every bound trace slot empty although its channel has /// samples: the view was swapped and not yet published. int dbg_blank_frames = 0; /// Frames the one area staged: a figure is one `::render` for every row. int dbg_frames_painted = 0; /// Where a frame's time goes, split at the two boundaries that can be observed from /// here: `tick` is this function (bind, acquire, clamp, status), `draw` is everything /// the plot renderer did (measured from the frame clock's `before-paint` to the return /// of `PlotArea`'s own `::render` handler, which is why the adapter returns FALSE from /// it), and `paint` is the whole GTK paint including the toolkit compositing the GL /// area's framebuffer. A complaint about lag names one of the three; a summary that /// does not separate them cannot say which. int dbg_tick_n = 0; double dbg_tick_ms_sum = 0.0; double dbg_tick_ms_max = 0.0; int dbg_paint_n = 0; double dbg_paint_ms_sum = 0.0; double dbg_paint_ms_max = 0.0; double dbg_draw_ms_sum = 0.0; double dbg_draw_ms_max = 0.0; /// Which paint the worst `draw` was. A mean far below the max says the cost is /// concentrated in a few frames but not *which*, and the two answers need opposite /// responses: an early frame is the warm-up (the glyph atlas bake and the first /// vertex-buffer growth land on it), a late one is a frame whose own geometry was /// expensive and is the thing to chase. int dbg_draw_max_frame = -1; gint64 dbg_paint_start_us = 0; GdkFrameClock* dbg_clock = nullptr; /// The renderer's own account of what each frame cost it: whether the traces went /// through the LOD pyramid or were drawn raw, and how much geometry that staged. The /// *path* is the first question to ask of a slow frame -- `raw` frames are O(visible /// samples) by construction. std::uint64_t dbg_seg_sum = 0; std::uint32_t dbg_seg_max = 0; std::uint64_t dbg_visible_max = 0; int dbg_raw_frames = 0; int dbg_lod_frames = 0; /// Frames the badge spent in each `BadgePhase`. Both `lit` and `dim` have to be non-zero /// over a run that acquired anything -- that is what "it blinks" means, and a badge /// stuck lit looks identical to a working one in a screenshot. int dbg_badge_frames[3] = {0, 0, 0}; int dbg_reset_visible = -1; std::vector last_stored; std::uint64_t dbg_latches_at_disarm = 0; std::uint64_t dbg_seq_at_disarm = 0; bool dbg_capturing_at_disarm = false; int dbg_disarm_polls = 0; int dbg_disarm_in_capture = 0; int dbg_swaps = 0; int dbg_axes_w_min = 1 << 20; int dbg_axes_w_max = -(1 << 20); int dbg_axes_w_prev = -1; int dbg_axes_w_changes = 0; int dbg_axes_w_change_frame = -1; int dbg_axes_w_change_side = -1; float dbg_scale_min = 1e9f; float dbg_scale_max = -1e9f; int dbg_vp_w_min = 1 << 20; int dbg_vp_w_max = -(1 << 20); int dbg_xtick_min = 1 << 20; int dbg_xtick_max = -(1 << 20); int dbg_gutter_min = 1 << 20; int dbg_gutter_max = -(1 << 20); int dbg_side_w = 0; int dbg_side_h = 0; int dbg_signal_w = 0; int dbg_signal_h = 0; int dbg_signal_row_h = 0; int dbg_trigger_w = 0; int dbg_trigger_h = 0; int dbg_signal_revealed = 0; int dbg_trigger_revealed = 0; int dbg_side_visible = 0; int dbg_trigger_items = 0; int dbg_trigger_selected = -1; int dbg_side_scroll_after_hide = 1; std::string dbg_mode_triggered; std::string dbg_window_text; double dbg_marker_x = 0.0; double dbg_marker_max = 0.0; double dbg_trigger_time = 0.0; int dbg_trigger_time_valid = -1; std::string dbg_mode_live; int dbg_toggles_visible = 0; int dbg_trigger_revealed_default = 0; }; // --- small helpers ---------------------------------------------------------- /// Installs the one style rule the app needs: a signal row that is as short as its /// contents. /// /// Adwaita gives a button a 34 px minimum height and a list row its own padding, and /// those -- not the widgets in it -- are what set a signal row's height, so no amount /// of trimming margins in C makes the list shorter. A handful of signals then filled /// the whole side panel. Scoped to `.udpscope-compact` so it cannot reach any other /// button in the window. void install_css() { static const char kCss[] = "row.udpscope-compact { min-height: 0; padding: 1px 0; }\n" "row.udpscope-compact button { min-height: 20px; min-width: 20px; padding: 0 3px; }\n" "row.udpscope-compact checkbutton { min-height: 20px; }\n"; GtkCssProvider* p = gtk_css_provider_new(); gtk_css_provider_load_from_string(p, kCss); gtk_style_context_add_provider_for_display(gdk_display_get_default(), GTK_STYLE_PROVIDER(p), GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); g_object_unref(p); } GtkWidget* labeled_box(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_widget_set_hexpand(child, true); gtk_box_append(GTK_BOX(box), child); return box; } GtkWidget* spin(double min, double max, double step, int digits) { GtkWidget* s = gtk_spin_button_new_with_range(min, max, step); gtk_spin_button_set_digits(GTK_SPIN_BUTTON(s), digits); return s; } /// A modal, transient window with a vertical content box; the caller appends its /// controls and then a button row. GTK 4.10 deprecated `GtkDialog` and /// `GtkMessageDialog`, so the dialogs here are built from a plain window. GtkWidget* dialog_window(GtkWidget* parent, const char* title) { GtkWidget* w = gtk_window_new(); gtk_window_set_title(GTK_WINDOW(w), title); if (parent != nullptr) gtk_window_set_transient_for(GTK_WINDOW(w), GTK_WINDOW(parent)); gtk_window_set_modal(GTK_WINDOW(w), TRUE); gtk_window_set_default_size(GTK_WINDOW(w), 340, -1); GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10); gtk_widget_set_margin_top(box, 12); gtk_widget_set_margin_bottom(box, 12); gtk_widget_set_margin_start(box, 12); gtk_widget_set_margin_end(box, 12); gtk_window_set_child(GTK_WINDOW(w), box); return w; } /// Appends `button` to a right-aligned row at the end of the dialog's box. void dialog_add_button(GtkWidget* window, GtkWidget* button) { GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window)); GtkWidget* last = gtk_widget_get_last_child(box); GtkWidget* row = nullptr; if (last != nullptr && g_object_get_data(G_OBJECT(last), "udpscope-dialog-buttons") != nullptr) { row = last; } else { row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); gtk_widget_set_halign(row, GTK_ALIGN_END); g_object_set_data(G_OBJECT(row), "udpscope-dialog-buttons", GINT_TO_POINTER(1)); gtk_box_append(GTK_BOX(box), row); } gtk_box_append(GTK_BOX(row), button); } void on_popover_closed(GtkPopover* popover, gpointer) { // Unparent only. `gtk_widget_set_parent()` took the reference that the popover // was created with (GTK4 widgets are created with a floating reference, sunk by // the first parent), so unparenting drops it to zero and destroys the popover -- // a following g_object_unref() would be a use-after-free. gtk_widget_unparent(GTK_WIDGET(popover)); } /// 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. struct WindowUnit { const char* name; double seconds; }; constexpr WindowUnit kWindowUnits[] = { {"\u00b5s", 1.0e-6}, {"ms", 1.0e-3}, {"s", 1.0}, {"min", 60.0}}; constexpr int kWindowUnitCount = 4; /// 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. constexpr double kWindowMinS = 1.0e-6; /// 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. constexpr int kWindowLabelChars = 9; /// 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; } /// What the activity badge is saying. `Lit`/`Dim` are the two halves of the blink, so /// the badge is only *blinking* while the pipeline is actually running; a steady badge is /// a stopped one, which is the state worth noticing on a glance. enum class BadgePhase { Lit = 0, Dim = 1, Idle = 2 }; /// The badge is one glyph in one of three colours. It must stay the *same glyph*: the /// status row sits above the plot, and a badge that changed width as it blinked would /// move the label beside it and re-lay out the plot twice a second. const char* badge_markup(BadgePhase phase) { switch (phase) { case BadgePhase::Lit: return "\u25CF"; case BadgePhase::Dim: return "\u25CF"; case BadgePhase::Idle: break; } return "\u25CF"; } /// `12 s`, `3m 07s`, `2h 14m` -- one unit coarser as soon as the finer one would need /// three digits, so the field never grows past six characters and the status line's shape /// does not depend on how long the wait has been. std::string format_elapsed(double seconds) { if (!(seconds >= 0.0)) return "--"; char buf[32]; if (seconds < 60.0) { std::snprintf(buf, sizeof(buf), "%.0f s", std::floor(seconds)); } else if (seconds < 3600.0) { const int m = static_cast(seconds) / 60; std::snprintf(buf, sizeof(buf), "%dm %02ds", m, static_cast(seconds) % 60); } else { const int h = static_cast(seconds) / 3600; std::snprintf(buf, sizeof(buf), "%dh %02dm", h, (static_cast(seconds) / 60) % 60); } return buf; } /// A sample rate in the unit it reads shortest in. Rates here span six decades (a 1 Hz /// housekeeping signal next to a 1 MS/s acquisition), so a fixed unit makes one of the /// two unreadable. std::string format_rate(double per_s) { char buf[32]; if (!(per_s > 0.0)) { return "0 S/s"; } else if (per_s >= 1.0e6) { std::snprintf(buf, sizeof(buf), "%.3g MS/s", per_s / 1.0e6); } else if (per_s >= 1.0e3) { std::snprintf(buf, sizeof(buf), "%.3g kS/s", per_s / 1.0e3); } else { std::snprintf(buf, sizeof(buf), "%.3g S/s", per_s); } return buf; } /// A short-lived line in front of the status text: the outcome of an action that the /// picture does not show by itself (a drop that changed nothing, an export that wrote a /// file). `tick()` clears it when it expires. void set_notice(App& app, std::string what, int seconds = 4) { app.notice = std::move(what); app.notice_until_us = g_get_monotonic_time() + seconds * G_TIME_SPAN_SECOND; } // --- plot widgets ----------------------------------------------------------- void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer user); gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, gpointer user); /// 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". constexpr int kRowKeyBias = 1; 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}); } } /// 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. int on_render_probe(GtkGLArea*, GdkGLContext*, gpointer user) { App* app = static_cast(user); if (app->dbg_paint_start_us != 0) { const double ms = double(g_get_monotonic_time() - app->dbg_paint_start_us) / 1000.0; app->dbg_draw_ms_sum += ms; if (ms > app->dbg_draw_ms_max) { app->dbg_draw_ms_max = ms; app->dbg_draw_max_frame = app->dbg_paint_n; } } if (app->area != nullptr) { if (const plot::Renderer* r = app->area->renderer()) { const plot::FrameStats& s = r->stats(); app->dbg_seg_sum += s.staged_segments; app->dbg_seg_max = std::max(app->dbg_seg_max, s.staged_segments); app->dbg_visible_max = std::max(app->dbg_visible_max, s.visible_samples); if (s.raw_traces > 0) ++app->dbg_raw_frames; if (s.lod_traces > 0) ++app->dbg_lod_frames; } } return FALSE; // same reason the adapter does: never stop the emission } 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. void rebuild_signal_panels(App& app); // --- 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. constexpr int kLayoutButtonPx = 24; constexpr int kLayoutButtonPad = 4; /// `g_object_set_data` key telling a remove button from an insert one. The row/boundary /// index is the usual `set_row_key()`. constexpr const char* kLayoutKindKey = "udpscope-layout-insert"; void rebuild_layout_editor(App& app); /// 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); } } app.layout_buttons.clear(); app.layout_buttons_rows = 0; } void on_layout_row_remove(GtkButton* button, gpointer user) { App* app = static_cast(user); const int row = row_key(GTK_WIDGET(button)); if (!app->rows->remove_row(row)) { app->notice = "a figure needs at least one row"; app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND; return; } // `remove_row()` has already built the new figure (a `Figure`'s row count is fixed at // construction), so the area has to be re-pointed here rather than through // `relayout_areas()`, which only acts on a *dirty* row. if (app->area != nullptr) app->area->set_figure(&app->rows->figure()); rebuild_layout_editor(*app); // The signal list's chips name the trace a signal is drawn as, and a removed row took // its traces with it. rebuild_signal_panels(*app); } void on_layout_row_insert(GtkButton* button, gpointer user) { App* app = static_cast(user); const int at = row_key(GTK_WIDGET(button)); if (!app->rows->insert_row(at)) { app->notice = "at most " + std::to_string(RowSet::kMaxRows) + " rows"; app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND; return; } if (app->area != nullptr) app->area->set_figure(&app->rows->figure()); rebuild_layout_editor(*app); rebuild_signal_panels(*app); } /// 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; 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)); } } } /// Builds one `×` per row and one `+` per boundary, or takes them all off again when the /// editor is switched off. 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 = gtk_button_new_from_icon_name("window-close-symbolic"); gtk_widget_set_tooltip_text(b, "remove this row"); gtk_widget_add_css_class(b, "circular"); gtk_widget_add_css_class(b, "osd"); gtk_widget_set_size_request(b, kLayoutButtonPx, kLayoutButtonPx); 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); gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), b); app.layout_buttons.push_back(b); } for (int b_i = 0; b_i <= rows; ++b_i) { GtkWidget* b = gtk_button_new_from_icon_name("list-add-symbolic"); gtk_widget_set_tooltip_text(b, b_i == 0 ? "add a row above" : b_i == rows ? "add a row below" : "add a row here"); gtk_widget_add_css_class(b, "circular"); gtk_widget_add_css_class(b, "osd"); gtk_widget_set_size_request(b, kLayoutButtonPx, kLayoutButtonPx); 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); gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), b); app.layout_buttons.push_back(b); } sync_layout_editor(app); } void on_layout_editor_toggled(GtkToggleButton* button, gpointer user) { App* app = static_cast(user); app->layout_editing = gtk_toggle_button_get_active(button); rebuild_layout_editor(*app); } void on_signal_switch(GObject* obj, GParamSpec*, gpointer user) { App* app = static_cast(user); if (app->updating_panels) return; const std::size_t index = static_cast( GPOINTER_TO_SIZE(g_object_get_data(obj, "udpscope-signal"))); app->model->set_signal_enabled(index, gtk_check_button_get_active(GTK_CHECK_BUTTON(obj))); } void on_arm_clicked(GtkButton* button, gpointer user) { App* app = static_cast(user); (void)button; bool armed = false; int signal = -1; { // The producer reads (and, on a single-shot latch, writes) these fields under // the same lock; an unguarded read-modify-write here can lose its update. std::lock_guard lock(app->model->mutex()); TriggerConfig& tc = app->model->trigger(); tc.armed = !tc.armed; armed = tc.armed; signal = tc.channel; } if (armed) { app->event_held = false; app->capturing = false; app->model->trigger_time_valid().store(false, std::memory_order_relaxed); } // Arming is a statement that this signal should be watched, so its acquisition // is switched on with it -- otherwise the trigger could never see a sample. // Outside the lock: set_signal_enabled() takes it. if (armed && signal >= 0) app->model->set_signal_enabled(static_cast(signal), true); gtk_button_set_label(GTK_BUTTON(app->arm_button), armed ? "Disarm" : "Arm"); } void publish_trigger_fields(App* app) { if (app->updating_panels) return; // Read the widgets first (they may be touched again by a re-entrant handler if a // setter ran while the lock was held), then publish the values under the lock the // producer reads them with. const double level = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_level_spin)); const double pre = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_pre_spin)); const double holdoff = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_holdoff_spin)); const guint edge = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_edge_drop)); const guint mode = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_mode_drop)); { std::lock_guard lock(app->model->mutex()); TriggerConfig& tc = app->model->trigger(); tc.level = level; tc.pretrigger_pct = pre; tc.holdoff_s = holdoff; tc.edge = edge == 0 ? TriggerEdge::Rising : edge == 1 ? TriggerEdge::Falling : TriggerEdge::Both; tc.mode = mode == 0 ? TriggerMode::Normal : TriggerMode::Single; } } void on_trigger_field_changed(GtkWidget*, gpointer user) { publish_trigger_fields(static_cast(user)); } /// The same publish, for the two dropdowns. It needs its own entry point because /// `notify::` passes a `GParamSpec*` ahead of the user data while /// `GtkSpinButton::value-changed` does not: a two-parameter callback on a `notify::` /// signal reads the pspec as its `App*` and crashes on the first field it touches. void on_trigger_drop_changed(GObject*, GParamSpec*, gpointer user) { publish_trigger_fields(static_cast(user)); } void on_trigger_signal_changed(GObject* obj, GParamSpec*, gpointer user) { App* app = static_cast(user); if (app->updating_panels) return; (void)obj; const guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_signal_drop)); if (sel == GTK_INVALID_LIST_POSITION) return; { std::lock_guard lock(app->model->mutex()); if (sel >= app->model->channels().size()) return; app->model->trigger().channel = static_cast(sel); } app->trigger_level_touched = false; // a new signal gets a fresh default level } 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]); } } struct CalibContext { App* app; std::size_t signal; GtkWidget* gain; GtkWidget* offset; GtkWidget* window; }; 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))); 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); GtkWidget* gain = spin(-1.0e12, 1.0e12, 0.001, 6); GtkWidget* offset = spin(-1.0e12, 1.0e12, 0.001, 6); gtk_spin_button_set_value(GTK_SPIN_BUTTON(gain), 1.0); 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"); 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_calibrate_apply), ctx); dialog_add_button(window, cancel); dialog_add_button(window, apply); gtk_window_present(GTK_WINDOW(window)); } struct DragContext { App* app; std::size_t signal; }; 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); } /// The palette entry a signal owns: its position among the *plottable* signals, the /// time base skipped. It is a property of the signal and not of the row/slot a drop /// lands in, so the same signal is drawn in the same colour on every plot it appears on /// -- which is what lets one chip in this list name every one of those traces. /// /// Positional, like the channel list, so a new CONFIG re-colours; that is fine because a /// CONFIG drops the assignments and the history with them. A name hash would survive a /// CONFIG but collide inside one, and two traces of the same colour on one row is the /// failure this exists to prevent. int signal_color_index(const std::vector& signals, std::size_t signal) { int index = 0; for (std::size_t i = 0; i < signals.size() && i < signal; ++i) { if (!ScopeModel::is_time_signal(signals, i)) ++index; } return index; } std::string chip_markup(plot::Color c) { char buf[64]; std::snprintf(buf, sizeof(buf), "\u25CF", unsigned(c.r * 255.0f + 0.5f), unsigned(c.g * 255.0f + 0.5f), unsigned(c.b * 255.0f + 0.5f)); return buf; } /// The chip for a row of the signal list, as Pango markup: the signal's own colour when /// it is on a plot, grey when it is not (whether that is because it is not acquired or /// because it is acquired and not placed). Always a filled disc, never a blank: a blank /// cell is narrower than a disc, so the names beside it used to shift sideways as /// signals were placed, and "no trace" read as "no chip here" rather than as a state. std::string signal_chip_markup(App& app, const std::vector& signals, std::size_t signal) { const std::string& name = signals[signal].name; 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) { if (row.signal_name[static_cast(s)] != name) continue; return chip_markup(RowSet::palette_color(signal_color_index(signals, signal))); } } return chip_markup(RowSet::unplotted_color()); } /// One line per signal: colour chip, checkbox, name, unit, and a menu for the things /// you touch once (information, calibration). A `GtkSwitch` and two always-visible /// buttons made every row about twice this tall, which is what turned a four-signal /// stream into a panel you have to scroll. void rebuild_signal_list(App& app, const std::vector& signals) { app.updating_panels = true; GtkWidget* list = app.signal_list; GtkWidget* child = gtk_widget_get_first_child(list); while (child != nullptr) { GtkWidget* next = gtk_widget_get_next_sibling(child); gtk_list_box_remove(GTK_LIST_BOX(list), child); child = next; } for (std::size_t i = 0; i < signals.size(); ++i) { if (ScopeModel::is_time_signal(signals, i)) { continue; // the time base is not a plottable signal } const SignalInfo& sig = signals[i]; GtkWidget* row = gtk_list_box_row_new(); gtk_widget_add_css_class(row, "udpscope-compact"); GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); gtk_widget_set_margin_start(box, 6); gtk_widget_set_margin_end(box, 2); GtkWidget* chip = gtk_label_new(nullptr); gtk_label_set_markup(GTK_LABEL(chip), signal_chip_markup(app, signals, i).c_str()); gtk_widget_set_tooltip_text(chip, app.model->signal_enabled(i) ? "the colour this signal is plotted in" : "not acquired"); gtk_box_append(GTK_BOX(box), chip); GtkWidget* enable = gtk_check_button_new(); gtk_check_button_set_active(GTK_CHECK_BUTTON(enable), app.model->signal_enabled(i)); gtk_widget_set_tooltip_text(enable, "acquire this signal"); g_object_set_data(G_OBJECT(enable), "udpscope-signal", GSIZE_TO_POINTER(i)); g_signal_connect(enable, "notify::active", G_CALLBACK(on_signal_switch), &app); gtk_box_append(GTK_BOX(box), enable); GtkWidget* name = gtk_label_new(sig.name.c_str()); gtk_widget_set_hexpand(name, true); gtk_widget_set_halign(name, GTK_ALIGN_START); gtk_label_set_ellipsize(GTK_LABEL(name), PANGO_ELLIPSIZE_END); gtk_box_append(GTK_BOX(box), name); if (!sig.unit.empty()) { GtkWidget* unit = gtk_label_new(sig.unit.c_str()); gtk_widget_add_css_class(unit, "dim-label"); gtk_box_append(GTK_BOX(box), unit); } // The overflow: information and calibration are set once per session, so they // cost a click here instead of a button on every row forever. GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); gtk_widget_set_margin_top(menu_box, 4); gtk_widget_set_margin_bottom(menu_box, 4); gtk_widget_set_margin_start(menu_box, 4); gtk_widget_set_margin_end(menu_box, 4); GtkWidget* info = gtk_button_new_with_label("Information"); g_object_set_data(G_OBJECT(info), "udpscope-signal", GSIZE_TO_POINTER(i)); g_signal_connect(info, "clicked", G_CALLBACK(on_info_clicked), &app); gtk_box_append(GTK_BOX(menu_box), info); GtkWidget* cal = gtk_button_new_with_label("Calibrate..."); gtk_widget_set_tooltip_text(cal, "gain and offset, applied at ingest"); g_object_set_data(G_OBJECT(cal), "udpscope-signal", GSIZE_TO_POINTER(i)); g_signal_connect(cal, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { App* a = static_cast(user); const std::size_t idx = static_cast( GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(b), "udpscope-signal"))); open_calibration(*a, GTK_WINDOW(a->window), idx); }), &app); gtk_box_append(GTK_BOX(menu_box), cal); GtkWidget* popover = gtk_popover_new(); gtk_popover_set_child(GTK_POPOVER(popover), menu_box); GtkWidget* more = gtk_menu_button_new(); gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(more), "view-more-symbolic"); gtk_menu_button_set_popover(GTK_MENU_BUTTON(more), popover); gtk_widget_add_css_class(more, "flat"); gtk_widget_set_tooltip_text(more, "information and calibration"); gtk_box_append(GTK_BOX(box), more); gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box); DragContext* ctx = new DragContext{&app, i}; GtkDragSource* source = gtk_drag_source_new(); gtk_drag_source_set_actions(source, GDK_ACTION_COPY); g_signal_connect_data(source, "prepare", G_CALLBACK(on_drag_prepare), ctx, [](gpointer data, GClosure*) { delete static_cast(data); }, G_CONNECT_DEFAULT); gtk_widget_add_controller(row, GTK_EVENT_CONTROLLER(source)); gtk_list_box_append(GTK_LIST_BOX(list), row); } app.updating_panels = false; } /// 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(); 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.level = mid; gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_level_spin), mid); } } app.updating_panels = false; } // --- toolbar handlers ------------------------------------------------------- /// The widest window that is worth offering. `--max-window` only: the buffer no longer /// sets the ceiling, because a window too long to store at the wire rate is decimated on /// ingest to fit rather than refused (see `plan_partition()`). It used to be capped to /// `buffered_span()` as well, which is what made a 600 s view of a 1 MS/s stream /// unreachable. double window_limit(const App& app) { return app.model->config().max_window_s; } /// 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 (app.window_label == nullptr || !(seconds > 0.0)) return; app.updating_panels = true; gtk_label_set_text(GTK_LABEL(app.window_label), format_window(seconds).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; } /// The one place the display window 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. 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.rows->set_time_window(clamped); app.model->set_event_span(clamped); sync_window_display(app, clamped); } /// 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); } /// Re-clamps the window against the current cap. Called once at start-up and again when a /// CONFIG changes how much history one buffer holds -- never on a mere enable or calibration /// change, so those cannot silently re-anchor the view. void refresh_window_limits(App& app) { if (app.window_label == nullptr) return; const double limit = window_limit(app); if (std::fabs(limit - app.window_limit_s) < 1.0e-9) return; app.window_limit_s = limit; apply_time_window(app, app.rows->time_window()); } 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); 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"); } void on_trigger_toggled(GtkToggleButton* button, gpointer user) { App* app = static_cast(user); const bool on = gtk_toggle_button_get_active(button); int signal = -1; app->model->set_trigger_enabled(on); // takes the lock and mirrors the atomic { std::lock_guard lock(app->model->mutex()); TriggerConfig& tc = app->model->trigger(); tc.armed = on; // trigger mode means "wait for an event", so it starts armed signal = tc.channel; } app->event_held = false; app->capturing = false; if (!on) { app->model->trigger_time_valid().store(false, std::memory_order_relaxed); app->model->set_display_epoch(0.0); // back to seconds since the stream started app->rows->set_time_window(app->rows->time_window()); // and follow the live edge } if (on && signal >= 0) { app->model->set_signal_enabled(static_cast(signal), true); } gtk_revealer_set_reveal_child(GTK_REVEALER(app->trigger_revealer), on); gtk_button_set_label(GTK_BUTTON(app->arm_button), on ? "Disarm" : "Arm"); } /// The way out of trigger mode, from inside the panel that froze the view: it is /// the toolbar's trigger switch, so there is exactly one piece of state. void on_go_live(GtkButton*, gpointer user) { App* app = static_cast(user); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->trigger_toggle), FALSE); } void on_cursors_toggled(GtkToggleButton* button, gpointer user) { App* app = static_cast(user); const bool on = gtk_toggle_button_get_active(button); app->rows->set_cursors_visible(on); // The reset button exists for the cursors: with them hidden there is nothing to // reset, and a button that does nothing reads as broken. gtk_widget_set_visible(app->cursors_reset, on); } void on_signals_toggled(GtkToggleButton* button, gpointer user) { App* app = static_cast(user); gtk_revealer_set_reveal_child(GTK_REVEALER(app->signal_revealer), gtk_toggle_button_get_active(button)); } /// Fit: auto-scale every row's y axis and re-frame the x window on the data the /// ring holds (the plot's own `f` semantics), then tell the toolbar what span that /// is. void on_fit_clicked(GtkButton*, gpointer user) { App* app = static_cast(user); app->rows->fit_all(); sync_window_display(*app, app->rows->time_window()); app->notice = "fitted to the buffered data (" + format_window(app->rows->time_window()) + ")"; app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND; } void on_cursors_reset(GtkButton*, gpointer user) { App* app = static_cast(user); // A reset the user cannot see reads as a dead button, so it also switches the // cursors on. if (!gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app->cursors_toggle))) { gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->cursors_toggle), true); } app->rows->reset_cursors(); } // --- 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. struct ExportContext { App* app = nullptr; ExportTable table; }; 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); } // --- 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); } struct FixedRangeContext { App* app; int row; GtkWidget* lo; GtkWidget* hi; GtkWidget* window; }; 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); GtkWidget* hi = spin(-1.0e12, 1.0e12, 0.1, 6); gtk_spin_button_set_value(GTK_SPIN_BUTTON(lo), cur.min); gtk_spin_button_set_value(GTK_SPIN_BUTTON(hi), 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. struct RemoveTarget { App* app; int row; int slot; }; 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; const std::string name = target->app->rows->row(target->row).name[static_cast( target->slot)]; // Freeing the slot is all it takes: the row is rebuilt with a trace whose series // is nothing, so it draws nothing and disappears from the legend. target->app->rows->assign(target->row, target->slot, std::string(), std::string(), 0); target->app->relayout_pending = true; rebuild_signal_panels(*target->app); // the signal's chip goes grey with it target->app->notice = "removed '" + name + "' from plot " + std::to_string(target->row + 1); target->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); } 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); GtkWidget* popover = gtk_popover_new(); GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); gtk_widget_set_margin_top(box, 4); gtk_widget_set_margin_bottom(box, 4); gtk_widget_set_margin_start(box, 4); gtk_widget_set_margin_end(box, 4); char title[64]; std::snprintf(title, sizeof(title), "Plot %d", row + 1); GtkWidget* heading = gtk_label_new(title); gtk_widget_set_halign(heading, GTK_ALIGN_START); gtk_box_append(GTK_BOX(box), heading); GtkWidget* auto_btn = gtk_button_new_with_label("Y axis: auto scale"); set_row_key(auto_btn, row); g_object_set_data(G_OBJECT(auto_btn), "udpscope-popover", popover); g_signal_connect(auto_btn, "clicked", G_CALLBACK(on_y_auto_clicked), app); gtk_box_append(GTK_BOX(box), auto_btn); GtkWidget* fixed_btn = gtk_button_new_with_label("Y axis: fixed range..."); set_row_key(fixed_btn, row); g_object_set_data(G_OBJECT(fixed_btn), "udpscope-popover", popover); g_signal_connect(fixed_btn, "clicked", G_CALLBACK(on_y_fixed_clicked), app); gtk_box_append(GTK_BOX(box), fixed_btn); // One entry per occupied trace slot of this row. const RowSet::Row& r = app->rows->row(row); bool any = false; for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { if (r.channel[static_cast(s)] < 0) continue; const std::string label = "Remove '" + r.name[static_cast(s)] + "' from plot " + std::to_string(row + 1); GtkWidget* btn = gtk_button_new_with_label(label.c_str()); auto* target = new RemoveTarget{app, row, s}; g_object_set_data_full(G_OBJECT(btn), "udpscope-remove", target, [](gpointer p) { delete static_cast(p); }); g_object_set_data(G_OBJECT(btn), "udpscope-popover", popover); g_signal_connect(btn, "clicked", G_CALLBACK(on_remove_clicked), nullptr); gtk_box_append(GTK_BOX(box), btn); any = true; } if (!any) { GtkWidget* empty = gtk_label_new("no signals on this plot"); gtk_widget_add_css_class(empty, "dim-label"); gtk_widget_set_halign(empty, GTK_ALIGN_START); gtk_box_append(GTK_BOX(box), empty); } 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, widget); // 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); 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); } gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, gpointer user) { App* app = static_cast(user); if (!G_VALUE_HOLDS_STRING(value)) return FALSE; // Where the drop landed decides the row: one area covers them all. const int row = row_at(*app, x, y); const char* text = g_value_get_string(value); const std::size_t signal = static_cast(std::atoll(text ? text : "0")); if (signal >= app->model->signals().size()) return FALSE; const auto notice = [&](const std::string& what) { app->notice = what; app->notice_until_us = g_get_monotonic_time() + 4 * G_TIME_SPAN_SECOND; std::fprintf(stderr, "[udpscope] drop: %s\n", what.c_str()); std::fflush(stderr); }; // The dropped signal's channels are the ones the model built for it; take the // first, or the first element if the signal is a vector. const std::vector& channels = app->model->channels(); int channel = -1; std::string name; for (std::size_t i = 0; i < channels.size(); ++i) { if (channels[i].signal == signal) { channel = static_cast(i); name = channels[i].name; break; } } if (channel < 0) { // The signal is not enabled: enable it, then take the channel that appears. app->model->set_signal_enabled(signal, true); const std::vector& again = app->model->channels(); for (std::size_t i = 0; i < again.size(); ++i) { if (again[i].signal == signal) { channel = static_cast(i); name = again[i].name; break; } } } if (channel < 0) { notice("cannot acquire '" + app->model->signals()[signal].name + "' (multi-element signals are not acquired)"); return FALSE; } int slot = -1; for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { if (!app->rows->slot_bound(row, s)) { slot = s; break; } } if (slot < 0) { notice("plot " + std::to_string(row + 1) + " already holds four signals"); return FALSE; } // The slot remembers the *signal* (and element), never the channel index: the // channel list is renumbered whenever a signal is enabled or disabled. const std::vector& signals = app->model->signals(); const SignalInfo& sig = signals[signal]; app->rows->assign(row, slot, sig.name, name, signal_color_index(signals, signal)); app->relayout_pending = true; rebuild_signal_panels(*app); // the row's colour chip now has a trace to name cancel_drag_on(*app, row); notice("plotting '" + name + "' on plot " + std::to_string(row + 1)); return TRUE; } /// 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_scroll == nullptr) return; const bool have_signals = !app.model->signals().empty(); const bool want_column = have_signals && gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.sidebar_toggle)); gtk_widget_set_visible(app.side_scroll, want_column); gtk_widget_set_visible(app.signal_toggle, have_signals); gtk_widget_set_visible(app.trigger_toggle, have_signals); } void on_sidebar_toggled(GtkToggleButton* button, gpointer user) { App* app = static_cast(user); (void)button; update_side_visibility(*app); } // --- rebuild of the derived panels ----------------------------------------- void rebuild_signal_panels(App& app) { // Snapshot the model's lists under the lock: the acquisition thread replaces // them on every CONFIG, and the panel build holds these references across many // GTK calls (a use-after-free of the name strings otherwise). std::vector signals; bool have_signals = false; { std::lock_guard lock(app.model->mutex()); signals = app.model->signals(); have_signals = !signals.empty(); } rebuild_signal_list(app, signals); rebuild_trigger_signals(app); refresh_window_limits(app); // Nothing to configure before a CONFIG arrives: hide the whole column and its // toolbar buttons rather than showing two empty panels. The first CONFIG // reveals the signal panel (the trigger panel stays behind its own button). update_side_visibility(app); if (have_signals && !app.signals_panel_initialised) { app.signals_panel_initialised = true; gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.signal_toggle), TRUE); } if (!have_signals) { app.signals_panel_initialised = false; gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.signal_toggle), FALSE); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.trigger_toggle), FALSE); } app.signal_generation = app.model->generation(); } // --- the frame tick --------------------------------------------------------- gboolean tick(gpointer user) { App* app = static_cast(user); if (app->window == nullptr) return G_SOURCE_REMOVE; 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); } } if (app->relayout_pending) { app->relayout_pending = false; relayout_areas(*app); } if (model.generation() != app->signal_generation) rebuild_signal_panels(*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(); 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()); // Mid-capture the live ring is drawn on the coming event's clock, so its edge sits at // x = 0 exactly as a snapshot's does and the pinned [-pre, +post] axis means the same // thing in both. Every other mode reads stream seconds. The epoch is a consumer-side // offset on the live channels, so it has to be in place before the bind that hands // them to the traces -- and it is written here, under the lock, because a rebuild can // replace the store. const double want_epoch = display == DisplayMode::Capturing ? -cap_edge : 0.0; if (model.display_epoch() != want_epoch) model.set_display_epoch(want_epoch); app->rows->bind(model, display); app->rows->acquire(model); } // 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) app->rows->clamp_to_data(); // Curator: the trigger's arm state can change under us (single mode disarms // itself), so the button and the status line read the published atomics. 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; // 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. if (!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; } // 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_y_gutter(); // 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); if (app->area_widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(app->area_widget)); 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; } 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(); /// Filled only on a measuring tick, and applied to the status label's tooltip after the /// model lock is dropped: the status line can only carry the aggregate rate, so the /// per-channel breakdown goes where there is room for it. std::string rate_tip; 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; 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; rate_tip += (rate_tip.empty() ? "" : "\n") + info.name + ": " + format_rate(per_s) + " stored (declared " + format_rate(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().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 (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 'Go live' for streaming)"; } 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. const bool paused = app->acq->paused(); const bool active = st.connected.load() && !paused && (!trigger_on || armed); const bool lit = ((g_get_monotonic_time() / (G_TIME_SPAN_SECOND / 2)) & 1) == 0; const BadgePhase phase = !active ? BadgePhase::Idle : (lit ? BadgePhase::Lit : BadgePhase::Dim); const int reason = active ? 0 : !st.connected.load() ? 1 : paused ? 2 : 3; static const char* const kWhy[] = {"acquiring", "waiting for the stream", "paused", "trigger disarmed"}; // 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 = "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 = "rate " + (hi - lo <= 0.01 * hi ? format_rate(hi) : format_rate(lo) + ".." + format_rate(hi)); } // In trigger mode, how long ago the event on screen was captured. It is what tells a // frozen picture apart from a stale one: the view looks identical whether the last // event was a second or an hour ago. std::string since; if (trigger_on) { since = app->last_latch_us != 0 ? " | last event " + format_elapsed(double(g_get_monotonic_time() - app->last_latch_us) / 1.0e6) + " ago" : " | no event yet"; } char status[1024]; std::snprintf(status, sizeof(status), "[%s] %s %s | %s | packets %llu frames %llu gaps %llu | %zu signals, %zu " "channels | plot 1: %d trace(s), %zu samples | buffer %.1f MB, %.2f s span, " "time x%llu%s", mode, st.connected.load() ? "connected" : "waiting", st.have_config.load() ? "configured" : "-", rate.c_str(), static_cast(st.packets.load()), static_cast(st.frames.load()), static_cast(st.gaps.load()), signal_count, channel_count, bound, samples, double(model.config().buffer_mb), span_s, static_cast(time_stride), since.c_str()); if (!app->notice.empty() && g_get_monotonic_time() < app->notice_until_us) { const std::string with_notice = app->notice + " | " + status; gtk_label_set_text(GTK_LABEL(app->status_label), with_notice.c_str()); } else { app->notice.clear(); gtk_label_set_text(GTK_LABEL(app->status_label), status); } if (!rate_tip.empty()) gtk_widget_set_tooltip_text(app->status_label, rate_tip.c_str()); 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 ---------------------------------------------------- GtkWidget* build_trigger_panel(App& app) { GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); gtk_widget_set_margin_top(box, 8); gtk_widget_set_margin_bottom(box, 8); gtk_widget_set_margin_start(box, 8); gtk_widget_set_margin_end(box, 8); GtkWidget* heading = gtk_label_new("Trigger"); gtk_widget_set_halign(heading, GTK_ALIGN_START); gtk_box_append(GTK_BOX(box), heading); app.trigger_signal_drop = gtk_drop_down_new(G_LIST_MODEL(gtk_string_list_new(nullptr)), nullptr); g_signal_connect(app.trigger_signal_drop, "notify::selected", G_CALLBACK(on_trigger_signal_changed), &app); gtk_box_append(GTK_BOX(box), labeled_box("signal", app.trigger_signal_drop)); const char* edges[] = {"rising", "falling", "both", nullptr}; app.trigger_edge_drop = gtk_drop_down_new_from_strings(edges); gtk_box_append(GTK_BOX(box), labeled_box("edge", app.trigger_edge_drop)); app.trigger_level_spin = spin(-1.0e12, 1.0e12, 0.01, 4); g_signal_connect(app.trigger_level_spin, "value-changed", G_CALLBACK(+[](GtkWidget*, gpointer u) { static_cast(u)->trigger_level_touched = true; }), &app); gtk_box_append(GTK_BOX(box), labeled_box("level", app.trigger_level_spin)); app.trigger_pre_spin = spin(0.0, 99.0, 1.0, 0); gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_pre_spin), 50.0); gtk_box_append(GTK_BOX(box), labeled_box("pre-trigger %", app.trigger_pre_spin)); app.trigger_holdoff_spin = spin(0.0, 10.0, 0.001, 4); gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_holdoff_spin), app.model->config().trigger_holdoff_s); gtk_box_append(GTK_BOX(box), labeled_box("hold-off s", app.trigger_holdoff_spin)); const char* modes[] = {"normal", "single", nullptr}; app.trigger_mode_drop = gtk_drop_down_new_from_strings(modes); gtk_box_append(GTK_BOX(box), labeled_box("mode", app.trigger_mode_drop)); app.arm_button = gtk_button_new_with_label("Arm"); g_signal_connect(app.arm_button, "clicked", G_CALLBACK(on_arm_clicked), &app); gtk_box_append(GTK_BOX(box), app.arm_button); GtkWidget* live = gtk_button_new_with_label("Go live (trigger off)"); gtk_widget_set_tooltip_text(live, "disable trigger mode and stream continuously"); g_signal_connect(live, "clicked", G_CALLBACK(on_go_live), &app); gtk_box_append(GTK_BOX(box), live); for (GtkWidget* w : {app.trigger_level_spin, app.trigger_pre_spin, app.trigger_holdoff_spin}) { g_signal_connect(w, "value-changed", G_CALLBACK(on_trigger_field_changed), &app); } g_signal_connect(app.trigger_edge_drop, "notify::selected", G_CALLBACK(on_trigger_drop_changed), &app); g_signal_connect(app.trigger_mode_drop, "notify::selected", G_CALLBACK(on_trigger_drop_changed), &app); return box; } GtkWidget* build_signal_panel(App& app) { GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); gtk_widget_set_margin_top(box, 8); gtk_widget_set_margin_bottom(box, 8); gtk_widget_set_margin_start(box, 8); gtk_widget_set_margin_end(box, 8); GtkWidget* heading = gtk_label_new("Signals"); gtk_widget_set_halign(heading, GTK_ALIGN_START); gtk_box_append(GTK_BOX(box), heading); GtkWidget* hint = gtk_label_new("drag a signal onto a plot"); gtk_widget_set_halign(hint, GTK_ALIGN_START); gtk_widget_add_css_class(hint, "dim-label"); gtk_box_append(GTK_BOX(box), hint); app.signal_list = gtk_list_box_new(); gtk_list_box_set_selection_mode(GTK_LIST_BOX(app.signal_list), GTK_SELECTION_NONE); // Width only. A height floor here made the list occupy 120 px whatever it held, // which hid the compact rows' height behind it and wasted the space on an empty // stream; the scroller above it already caps how tall it can grow. gtk_widget_set_size_request(app.signal_list, 260, -1); gtk_box_append(GTK_BOX(box), app.signal_list); return box; } /// 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; } /// 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. /// /// [window ▾] [plots ▾] ............... [pause] [trigger] [panel] [menu] GtkWidget* build_toolbar(App& app) { GtkWidget* bar = gtk_header_bar_new(); gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), labeled_box("window", build_window_control(app))); refresh_window_limits(app); // --- the overflow menu ---------------------------------------------------- // Plain buttons in a popover rather than a GMenu: these act on the `RowSet` // directly and two of them are toggles whose state the rest of the UI reads, so // a GAction model would only add a second copy of that state. GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); 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 = gtk_button_new_with_label("Fit to data"); 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 = gtk_toggle_button_new_with_label("Layout editor"); 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); app.cursors_toggle = gtk_toggle_button_new_with_label("Cursors"); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.cursors_toggle), false); g_signal_connect(app.cursors_toggle, "toggled", G_CALLBACK(on_cursors_toggled), &app); gtk_box_append(GTK_BOX(menu_box), app.cursors_toggle); app.cursors_reset = gtk_button_new_with_label("Reset cursors"); gtk_widget_set_tooltip_text(app.cursors_reset, "re-place the cursors (only shown while they exist)"); g_signal_connect(app.cursors_reset, "clicked", G_CALLBACK(on_cursors_reset), &app); gtk_box_append(GTK_BOX(menu_box), app.cursors_reset); gtk_box_append(GTK_BOX(menu_box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); GtkWidget* export_btn = gtk_button_new_with_label("Export visible range..."); 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); gtk_box_append(GTK_BOX(menu_box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); app.signal_toggle = gtk_toggle_button_new_with_label("Signal list"); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.signal_toggle), true); g_signal_connect(app.signal_toggle, "toggled", G_CALLBACK(on_signals_toggled), &app); gtk_box_append(GTK_BOX(menu_box), app.signal_toggle); GtkWidget* popover = gtk_popover_new(); gtk_popover_set_child(GTK_POPOVER(popover), menu_box); app.menu_button = gtk_menu_button_new(); gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(app.menu_button), "open-menu-symbolic"); gtk_menu_button_set_popover(GTK_MENU_BUTTON(app.menu_button), popover); gtk_widget_set_tooltip_text(app.menu_button, "view options"); gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.menu_button); // --- what stays on the bar (packed end: first call is rightmost) ---------- app.sidebar_toggle = gtk_toggle_button_new(); gtk_button_set_icon_name(GTK_BUTTON(app.sidebar_toggle), "sidebar-show-symbolic"); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.sidebar_toggle), true); gtk_widget_set_tooltip_text(app.sidebar_toggle, "show the side panel"); g_signal_connect(app.sidebar_toggle, "toggled", G_CALLBACK(on_sidebar_toggled), &app); gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.sidebar_toggle); app.trigger_toggle = gtk_toggle_button_new(); gtk_button_set_icon_name(GTK_BUTTON(app.trigger_toggle), "media-record-symbolic"); gtk_widget_set_tooltip_text(app.trigger_toggle, "trigger mode: freeze the view and show one captured event " "at a time"); g_signal_connect(app.trigger_toggle, "toggled", G_CALLBACK(on_trigger_toggled), &app); gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.trigger_toggle); app.pause_button = gtk_toggle_button_new(); gtk_button_set_icon_name(GTK_BUTTON(app.pause_button), "media-playback-pause-symbolic"); gtk_widget_set_tooltip_text(app.pause_button, "pause acquisition"); g_signal_connect(app.pause_button, "toggled", G_CALLBACK(on_pause_toggled), &app); gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.pause_button); return bar; } void on_activate(GtkApplication* application, gpointer user) { App* app = static_cast(user); install_css(); app->window = gtk_application_window_new(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) { static_cast(user)->window = nullptr; }), app); gtk_window_set_title(GTK_WINDOW(app->window), "udpscope"); gtk_window_set_default_size(GTK_WINDOW(app->window), 1280, 800); gtk_window_set_titlebar(GTK_WINDOW(app->window), build_toolbar(*app)); GtkWidget* paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL); app->plot_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); gtk_box_set_homogeneous(GTK_BOX(app->plot_box), true); gtk_widget_set_hexpand(app->plot_box, true); gtk_widget_set_vexpand(app->plot_box, true); GtkWidget* left = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); gtk_box_append(GTK_BOX(left), app->plot_box); gtk_widget_set_vexpand(app->plot_box, true); // 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. GtkWidget* status_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); app->status_badge = gtk_label_new(nullptr); 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"); gtk_box_append(GTK_BOX(status_row), app->status_badge); app->status_label = gtk_label_new("starting..."); // 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. gtk_label_set_ellipsize(GTK_LABEL(app->status_label), PANGO_ELLIPSIZE_END); gtk_widget_set_hexpand(app->status_label, true); gtk_widget_set_halign(app->status_label, GTK_ALIGN_FILL); gtk_box_append(GTK_BOX(status_row), app->status_label); gtk_widget_set_margin_top(status_row, 2); gtk_widget_set_margin_bottom(status_row, 2); gtk_widget_set_margin_start(status_row, 6); gtk_box_append(GTK_BOX(left), status_row); // The right-hand side: two independently collapsible panels, each revealed by // its toolbar toggle. No outer scrolled window -- the signal list scrolls // inside its own panel -- so neither panel can be squeezed to nothing by the // other's size request. GtkWidget* side = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); app->side_box = side; // The two panels share a vertical box, so they collapse *vertically*: a // horizontal slide would animate the width and leave the panel's full height // reserved -- an invisible gap pushing the other panel down the column. app->trigger_revealer = gtk_revealer_new(); gtk_revealer_set_transition_type(GTK_REVEALER(app->trigger_revealer), GTK_REVEALER_TRANSITION_TYPE_SLIDE_DOWN); gtk_revealer_set_child(GTK_REVEALER(app->trigger_revealer), build_trigger_panel(*app)); gtk_box_append(GTK_BOX(side), app->trigger_revealer); gtk_box_append(GTK_BOX(side), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); app->signal_panel = build_signal_panel(*app); app->signal_revealer = gtk_revealer_new(); gtk_revealer_set_transition_type(GTK_REVEALER(app->signal_revealer), GTK_REVEALER_TRANSITION_TYPE_SLIDE_DOWN); gtk_revealer_set_child(GTK_REVEALER(app->signal_revealer), app->signal_panel); gtk_revealer_set_reveal_child(GTK_REVEALER(app->signal_revealer), TRUE); gtk_box_append(GTK_BOX(side), app->signal_revealer); gtk_widget_set_size_request(side, 300, -1); // The column scrolls as a whole, so a long signal list or a revealed trigger // panel can never push either panel out of the window. app->side_scroll = gtk_scrolled_window_new(); gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(app->side_scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(app->side_scroll), side); gtk_widget_set_size_request(app->side_scroll, 320, -1); gtk_paned_set_start_child(GTK_PANED(paned), left); gtk_paned_set_end_child(GTK_PANED(paned), app->side_scroll); gtk_paned_set_resize_end_child(GTK_PANED(paned), FALSE); gtk_paned_set_shrink_end_child(GTK_PANED(paned), FALSE); gtk_window_set_child(GTK_WINDOW(app->window), paned); // After the children exist, or the position is clamped against an empty pane. gtk_paned_set_position(GTK_PANED(paned), 940); gtk_box_append(GTK_BOX(app->plot_box), make_area(*app)); rebuild_signal_panels(*app); g_timeout_add(kTickMs, tick, app); gtk_window_present(GTK_WINDOW(app->window)); } /// 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. gboolean exit_probe_step3(gpointer user) { App* a = static_cast(user); a->dbg_mode_live = gtk_label_get_text(GTK_LABEL(a->status_label)); GtkWidget* window = a->window; a->window = nullptr; // stop the tick before the widgets go a->dbg_side_w = gtk_widget_get_width(a->side_box); a->dbg_side_h = gtk_widget_get_height(a->side_box); a->dbg_signal_w = gtk_widget_get_width(a->signal_revealer); a->dbg_signal_h = gtk_widget_get_height(a->signal_revealer); a->dbg_trigger_w = gtk_widget_get_width(a->trigger_revealer); a->dbg_trigger_h = gtk_widget_get_height(a->trigger_revealer); // The height of one signal row, which is the thing "the channel toggle is too tall" // was about. The revealer's own height cannot stand in for it: it also covers the // heading, the hint and the box margins. if (GtkWidget* first = gtk_widget_get_first_child(a->signal_list)) { a->dbg_signal_row_h = gtk_widget_get_height(first); } if (a->area != nullptr) a->dbg_frames_painted = static_cast(a->area->frames_painted()); a->dbg_reset_visible = a->cursors_reset != nullptr && gtk_widget_get_visible(a->cursors_reset) ? 1 : 0; a->dbg_signal_revealed = gtk_revealer_get_child_revealed(GTK_REVEALER(a->signal_revealer)) ? 1 : 0; a->dbg_trigger_revealed = gtk_revealer_get_child_revealed(GTK_REVEALER(a->trigger_revealer)) ? 1 : 0; a->dbg_side_visible = gtk_widget_get_visible(a->side_box) ? 1 : 0; a->dbg_side_scroll_after_hide = gtk_widget_get_visible(a->side_scroll) ? 1 : 0; if (a->trigger_signal_drop != nullptr) { GListModel* model = gtk_drop_down_get_model(GTK_DROP_DOWN(a->trigger_signal_drop)); a->dbg_trigger_items = model != nullptr ? static_cast(g_list_model_get_n_items(model)) : 0; a->dbg_trigger_selected = static_cast( gtk_drop_down_get_selected(GTK_DROP_DOWN(a->trigger_signal_drop))); } if (a->window_label != nullptr) { // The button's label, not the entry: the label is what the user reads, and the entry can // legitimately hold a half-typed number. a->dbg_window_text = gtk_label_get_text(GTK_LABEL(a->window_label)); } a->dbg_toggles_visible = (gtk_widget_get_visible(a->signal_toggle) && gtk_widget_get_visible(a->trigger_toggle)) ? 1 : 0; if (window != nullptr) gtk_window_destroy(GTK_WINDOW(window)); return G_SOURCE_REMOVE; } /// 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. gboolean exit_probe_step2(gpointer user) { App* a = static_cast(user); a->dbg_mode_triggered = gtk_label_get_text(GTK_LABEL(a->status_label)); if (a->rows->row(0).plot) { a->dbg_marker_x = a->rows->row(0).plot->cursor(a->rows->row(0).trigger_marker).x; a->dbg_marker_max = a->rows->row(0).plot->x_range(0).max; a->dbg_trigger_time = a->model->trigger_time().load(); a->dbg_trigger_time_valid = a->model->trigger_time_valid().load() ? 1 : 0; } if (a->trigger_toggle != nullptr) { gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), FALSE); } // The tick keeps running for these 400 ms, so by step 3 the status line reads // the state the app is actually in after leaving trigger mode. g_timeout_add(400, exit_probe_step3, a); return G_SOURCE_REMOVE; } /// 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. gboolean trig_trace_probe(gpointer user) { App* a = static_cast(user); // Before the first CONFIG the store does not exist yet: live() would hand back a // null BufferStore. if (a->window == nullptr || a->model->channel_count() == 0) return G_SOURCE_CONTINUE; std::lock_guard lock(a->model->mutex()); ScopeModel& m = *a->model; // What the first bound slot shows, read off the slot itself: the binding is the // thing under test, so it must not be inferred from the trigger state. const char* display = "nothing"; const SlotSeries* shown = nullptr; for (int i = 0; i < a->rows->row_count(); ++i) { RowSet::Row& r = a->rows->row(i); bool found = false; for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { const std::size_t si = static_cast(s); if (r.channel[si] < 0) continue; // A live binding in trigger mode is only legitimate as a capture in progress, so // the two are named apart: `live` with `trigger=on` is the defect, `live(capturing)` // is the feature. if (r.slots[si].bound_event() != nullptr) display = "event(frozen)"; else if (r.slots[si].bound() != nullptr) display = a->capturing ? "live(capturing)" : "live"; shown = &r.slots[si]; found = true; break; } if (found) break; } // Where that slot's samples are *drawn*, reconstructed exactly as the renderer // does it: `Series::gather()` emits x relative to `x0()`, and the renderer adds // `x0()` back before mapping to the screen. So `x0() + gathered` must equal // `x_at()`, and `drawn=` must equal `data=` to the f32's precision. // // This is the one defect in this area the picture alone shows and no other probe // line does: a series that emits absolute x instead makes the renderer add `x0()` // twice, so the trace lands a whole `x0()` away from the axis it was measured // against -- off screen entirely once the view is narrower than that shift, while // the crosshair (which reads `x_at()`) goes on reporting correct values. double drawn_lo = 0.0, drawn_hi = 0.0, data_lo = 0.0, data_hi = 0.0; if (shown != nullptr && shown->size() > 0) { const u64 n = shown->size(); std::vector xy; shown->gather(0u, 0u, 1u, xy); const std::size_t head = xy.size(); shown->gather(0u, n - 1, n, xy); if (head >= 2 && xy.size() >= head + 2) { drawn_lo = shown->x0() + double(xy[0]); drawn_hi = shown->x0() + double(xy[head]); data_lo = shown->x_at(0); data_hi = shown->x_at(n - 1); } } double snap_lo = 0.0; double snap_hi = 0.0; std::size_t snap_n = 0; if (auto snap = m.event()) { snap_lo = -snap->pre_s; snap_hi = snap->post_s; snap_n = snap->stamps.size(); } plot::Range axis{0.0, 0.0}; if (a->rows->row_count() > 0 && a->rows->row(0).plot != nullptr) { const RowSet::Row& r0 = a->rows->row(0); axis = r0.plot->x_range(r0.axis_x); } // The renderer's own index window, recomputed exactly as `Renderer::draw_traces` // does it: it maps the visible x range onto sample indices with // `(x - x0()) / dx()` -- a *uniform grid* formula -- and gathers only // `[first, last)`. `ChannelBuffer`'s x is a streamed timestamp, so that formula is // only ever as good as `dx()` describing the real local spacing. `want=` is the // same window found by binary search over the real time ring, which is what // `x_at()`/`neighbours()` -- and therefore the crosshair -- use. // // **`got=` must contain `want=`.** When `dx()` comes out larger than the real // spacing every index is overestimated, `first` lands past the first visible // sample, and the samples between the two are in the buffer, readable by the // crosshair, and never drawn: the view's older part goes blank while its newest // part looks perfectly fine. `dx_ratio` is the cause in one number -- it is 1.0 // when the grid assumption holds, and it inflates whenever a stamp inside the // window is wrong (a sample whose time slot the ring already dropped reads 0). long long got_lo = -1, got_hi = -1, want_lo = -1, want_hi = -1; double dx_nominal = 0.0, dx_real = 0.0, bound_lo = 0.0, bound_hi = 0.0; if (shown != nullptr && shown->size() > 0) { const u64 n = shown->size(); const f64 sx0 = shown->x0(); dx_nominal = shown->dx(); dx_real = n >= 2 ? shown->x_at(n - 1) - shown->x_at(n - 2) : 0.0; bound_lo = shown->x_bounds().min; bound_hi = shown->x_bounds().max; if (dx_nominal > 0.0) { const auto clamp_index = [n](double v) -> long long { if (!(v > 0.0)) return 0; return v >= double(n) ? (long long)n : (long long)v; }; got_lo = clamp_index(std::floor((axis.min - sx0) / dx_nominal) - 1.0); got_hi = clamp_index(std::ceil((axis.max - sx0) / dx_nominal) + 1.0); } u64 i0 = 0, i1 = 0; want_lo = shown->neighbours(axis.min, i0, i1) ? (long long)i0 : (axis.min < shown->x_at(0) ? 0 : (long long)n); want_hi = shown->neighbours(axis.max, i0, i1) ? (long long)i1 + 1 : (axis.max < shown->x_at(0) ? 0 : (long long)n); } std::fprintf(stderr, "[trig-probe/idx] n=%llu bounds=[%.4f..%.4f] dx=%.6g dx_real=%.6g " "dx_ratio=%.3f got=[%lld..%lld) want=[%lld..%lld)\n", static_cast(shown != nullptr ? shown->size() : 0), bound_lo, bound_hi, dx_nominal, dx_real, dx_real > 0.0 ? dx_nominal / dx_real : 0.0, got_lo, got_hi, want_lo, want_hi); std::fprintf(stderr, "[trig-probe] trigger=%s pending=%d display=%s epoch=%.4f latches=%llu armed=%d " "live_pub=%llu snap=[%.4f..%.4f] n=%zu axis=[%.4f..%.4f] " "data=[%.4f..%.4f] drawn=[%.4f..%.4f]\n", m.trigger_enabled() ? "on" : "off", int(m.trigger_pending()), display, m.display_epoch(), static_cast(m.trigger_latches().load()), int(m.trigger_armed().load()), static_cast(m.live().values(0).produced()), snap_lo, snap_hi, snap_n, double(axis.min), double(axis.max), data_lo, data_hi, drawn_lo, drawn_hi); std::fflush(stderr); return G_SOURCE_CONTINUE; } /// Headless self-check: arms the trigger the way the toolbar's trigger switch does. gboolean arm_probe(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; // The toggle itself, not a label search: the header bar's buttons carry icons now, // so there is no text to find them by. if (a->trigger_toggle != nullptr) gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE); std::fprintf(stderr, "[arm-probe] trigger armed\n"); std::fflush(stderr); return G_SOURCE_REMOVE; } /// 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. gboolean fields_probe(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; GtkDropDown* mode = GTK_DROP_DOWN(a->trigger_mode_drop); GtkDropDown* edge = GTK_DROP_DOWN(a->trigger_edge_drop); gtk_drop_down_set_selected(mode, gtk_drop_down_get_selected(mode) == 0 ? 1 : 0); gtk_drop_down_set_selected(edge, (gtk_drop_down_get_selected(edge) + 1) % 3); std::lock_guard lock(a->model->mutex()); std::fprintf(stderr, "[fields-probe] mode=%u edge=%u published mode=%d edge=%d capturing=%d\n", gtk_drop_down_get_selected(mode), gtk_drop_down_get_selected(edge), int(a->model->trigger().mode), int(a->model->trigger().edge), int(a->model->trigger_pending())); std::fflush(stderr); return G_SOURCE_REMOVE; } /// 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. gboolean window_probe(gpointer user) { App* a = static_cast(user); if (a->window == nullptr || a->window_entry == nullptr) return G_SOURCE_REMOVE; GtkEditable* entry = GTK_EDITABLE(a->window_entry); GtkDropDown* unit = GTK_DROP_DOWN(a->window_unit_drop); auto report = [a](const char* what) { double span = 0.0; u64 stride = 0; double stored = 0.0; double buffered = 0.0; double aa = 0.0; { std::lock_guard lock(a->model->mutex()); span = a->model->event_span(); const Partition& part = a->model->partition(); stride = part.time_stride; stored = part.stored_rate(); buffered = part.span_s; aa = part.antialias_hz(); } std::fprintf(stderr, "[window-probe] %-14s label='%s' entry='%s' unit=%u window=%.6fs " "event_span=%.6fs cap=%.3fs decim=%llu stored=%.4gS/s aa=%.4gHz " "buffered=%.4gs\n", what, gtk_label_get_text(GTK_LABEL(a->window_label)), gtk_editable_get_text(GTK_EDITABLE(a->window_entry)), gtk_drop_down_get_selected(GTK_DROP_DOWN(a->window_unit_drop)), a->rows->time_window(), span, a->window_limit_s, static_cast(stride), stored, aa, buffered); std::fflush(stderr); }; report("initial"); // 500 ms typed: the entry's own path, and a span no ladder of round numbers had. gtk_drop_down_set_selected(unit, 1); gtk_editable_set_text(entry, "500"); on_window_entry_activate(GTK_ENTRY(a->window_entry), a); report("type 500ms"); // The unit re-interprets the number rather than converting it, so this is 500 s -- past the // cap, and therefore the clamp's own test. gtk_drop_down_set_selected(unit, 2); report("unit ms->s"); gtk_editable_set_text(entry, "2"); on_window_entry_activate(GTK_ENTRY(a->window_entry), a); report("type 2s"); GtkWidget* up = gtk_button_new_with_label("x10"); g_object_set_data(G_OBJECT(up), "udpscope-window-up", up); on_window_step(GTK_BUTTON(up), a); report("x10"); GtkWidget* down = gtk_button_new_with_label("/10"); on_window_step(GTK_BUTTON(down), a); report("/10"); // Both were created only to carry the direction flag; they were never parented, so they // still hold the floating reference GTK4 created them with. g_object_ref_sink(up); g_object_unref(up); g_object_ref_sink(down); g_object_unref(down); return G_SOURCE_REMOVE; } /// The editor button of one kind at one index, or null. The probe clicks the real /// widgets rather than calling the handlers, so what it drives is what a user reaches. GtkWidget* layout_button(App& a, bool insert, int index) { for (GtkWidget* b : a.layout_buttons) { const bool is_insert = g_object_get_data(G_OBJECT(b), kLayoutKindKey) != nullptr; if (is_insert == insert && row_key(b) == index) return b; } return nullptr; } /// 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. void layout_probe_report(App& a, const char* what) { const int rows = a.rows->row_count(); const double scale = (a.area != nullptr && a.area->device_scale() > 0.0f) ? a.area->device_scale() : 1.0; std::string geom; const plot::FigureLayout& fl = a.rows->figure().layout(); for (std::size_t i = 0; i < fl.rows.size(); ++i) { char buf[48]; std::snprintf(buf, sizeof(buf), "%s%.0f..%.0f", i == 0 ? "" : ",", double(fl.rows[i].y) / scale, (double(fl.rows[i].y) + double(fl.rows[i].h)) / scale); geom += buf; } std::string close_tops; std::string add_tops; for (int i = 0; i < rows; ++i) { char buf[24]; if (GtkWidget* b = layout_button(a, false, i)) { std::snprintf(buf, sizeof(buf), "%s%d", i == 0 ? "" : ",", gtk_widget_get_margin_top(b)); close_tops += buf; } } for (int i = 0; i <= rows; ++i) { char buf[24]; if (GtkWidget* b = layout_button(a, true, i)) { std::snprintf(buf, sizeof(buf), "%s%d", i == 0 ? "" : ",", gtk_widget_get_margin_top(b)); add_tops += buf; } } std::string traces; for (int r = 0; r < rows; ++r) { traces += (r == 0 ? "" : "|"); traces += std::to_string(r) + ":"; const RowSet::Row& row = a.rows->row(r); bool any = false; for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { const std::string& n = row.signal_name[static_cast(s)]; if (n.empty()) continue; if (any) traces += ","; traces += n; any = true; } if (!any) traces += "-"; } std::fprintf(stderr, "[layout-probe] %-12s rows=%d buttons=%zu rows_y=[%s] close=[%s] " "add=[%s] traces=[%s]\n", what, rows, a.layout_buttons.size(), geom.c_str(), close_tops.c_str(), add_tops.c_str(), traces.c_str()); std::fflush(stderr); } /// 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. gboolean layout_probe_step(gpointer user); /// Clicks one editor button. `::clicked` directly and not `gtk_widget_activate()`: a /// `GtkButton` activated that way holds the press for `ACTIVATE_TIMEOUT` (250 ms) and only /// then emits `::clicked`, so every step of the probe read the *previous* step's result -- /// and the button whose timeout was pending is destroyed by the rebuild the step before it /// caused, which dropped the click entirely. void layout_click(GtkWidget* b) { if (b != nullptr) g_signal_emit_by_name(b, "clicked"); } void layout_probe_next(App& a, int step, guint delay_ms) { a.dbg_layout_step = step; g_timeout_add(delay_ms, layout_probe_step, &a); } gboolean layout_probe_step(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; switch (a->dbg_layout_step) { case 0: // Through the menu's toggle, so the probe exercises the handler the user reaches. gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->layout_toggle), true); layout_probe_report(*a, "editor on"); layout_probe_next(*a, 1, 200); break; case 1: layout_probe_report(*a, "placed"); layout_click(layout_button(*a, true, 0)); layout_probe_next(*a, 2, 200); break; case 2: // What row 0 held must now be on row 1: an insert moves rows, it does not renumber. layout_probe_report(*a, "add above 0"); layout_click(layout_button(*a, false, 0)); layout_probe_next(*a, 3, 200); break; case 3: layout_probe_report(*a, "remove 0"); layout_click(layout_button(*a, true, a->rows->row_count())); layout_probe_next(*a, 4, 200); break; case 4: layout_probe_report(*a, "add at end"); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->layout_toggle), false); layout_probe_next(*a, 5, 200); break; default: // Nothing left on the overlay: the editor's buttons exist only while it is up. layout_probe_report(*a, "editor off"); break; } return G_SOURCE_REMOVE; } /// 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. gboolean enable_all_probe(gpointer user) { App* a = static_cast(user); for (std::size_t i = 0; i < a->model->signals().size(); ++i) { a->model->set_signal_enabled(i, true); } int row = 0; int slot = 0; for (std::size_t c = 0; c < a->model->channel_count(); ++c) { if (row >= a->rows->row_count()) break; const ChannelInfo& ch = a->model->channels()[c]; a->rows->assign(row, slot, ch.name, ch.name, signal_color_index(a->model->signals(), ch.signal)); if (++slot >= RowSet::kSlotsPerRow) { slot = 0; ++row; } } a->relayout_pending = true; std::fprintf(stderr, "[enable-all-probe] %zu channel(s) enabled and assigned to plots\n", a->model->channel_count()); std::fflush(stderr); return G_SOURCE_REMOVE; } gboolean disarm_probe_step3(gpointer user); gboolean disarm_probe_step2(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; // The Arm/Disarm button is in the trigger panel, not the title bar: search the // whole window, and say so when it is not there rather than claiming a click. GtkWidget* button = find_labelled_button(a->window, "Disarm"); if (button == nullptr) button = find_labelled_button(a->window, "Arm"); std::fprintf(stderr, "[disarm-probe] button %s\n", button ? "found, clicking" : "NOT FOUND"); if (button != nullptr) g_signal_emit_by_name(button, "clicked"); a->dbg_latches_at_disarm = a->model->trigger_latches().load(); std::fprintf(stderr, "[disarm-probe] %llu events so far, armed now=%d\n", (unsigned long long)a->dbg_latches_at_disarm, int(a->model->trigger().armed)); a->dbg_capturing_at_disarm = a->model->trigger_pending(); a->dbg_seq_at_disarm = a->model->event_seq(); g_timeout_add(600, disarm_probe_step3, a); return G_SOURCE_REMOVE; } /// 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. gboolean disarm_probe_step3(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; const DisplayMode d = a->model->display_mode(); auto snap = a->model->event(); RowSet::Row& r = a->rows->row(0); const plot::Range axis = r.plot != nullptr ? r.plot->x_range(r.axis_x) : plot::Range{0.0, 0.0}; std::fprintf(stderr, "[disarm-probe] was_capturing=%d display=%s seq=%llu->%llu events=%llu->%llu " "snap=[%.4f..%.4f] n=%llu axis=[%.4f..%.4f]\n", int(a->dbg_capturing_at_disarm), d == DisplayMode::Held ? "event(frozen)" : d == DisplayMode::Live ? "live" : d == DisplayMode::Capturing ? "live(capturing)" : "nothing", (unsigned long long)a->dbg_seq_at_disarm, (unsigned long long)a->model->event_seq(), (unsigned long long)a->dbg_latches_at_disarm, (unsigned long long)a->model->trigger_latches().load(), snap ? -snap->pre_s : 0.0, snap ? snap->post_s : 0.0, (unsigned long long)(snap ? snap->stamps.size() : 0), double(axis.min), double(axis.max)); std::fflush(stderr); return G_SOURCE_REMOVE; } /// 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. gboolean disarm_probe_wait(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; ++a->dbg_disarm_polls; if (a->model->trigger_pending()) ++a->dbg_disarm_in_capture; // 300 ms into the capture: post-trigger data worth keeping, well short of the latch. // The 10 s cap is for a stream that never triggers -- the probe still has to report. if (a->dbg_disarm_in_capture >= 6 || a->dbg_disarm_polls > 200) { if (a->dbg_disarm_in_capture == 0) { std::fprintf(stderr, "[disarm-probe] no capture opened in 10 s; disarming anyway\n"); std::fflush(stderr); } disarm_probe_step2(a); return G_SOURCE_REMOVE; } return G_SOURCE_CONTINUE; } gboolean disarm_probe_step1(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; if (a->trigger_toggle != nullptr) gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE); g_timeout_add(50, disarm_probe_wait, a); return G_SOURCE_REMOVE; } /// Headless self-check: pause the acquisition and reset the movement sample, so /// what is measured is a still picture -- the state a wiggle is most visible in. gboolean pause_probe(gpointer user) { App* a = static_cast(user); a->acq->set_paused(true); a->dbg_axes_x_min = 1 << 20; a->dbg_axes_x_max = -(1 << 20); a->dbg_axes_w_min = 1 << 20; a->dbg_axes_w_max = -(1 << 20); a->dbg_axes_w_prev = -1; a->dbg_axes_w_changes = 0; a->dbg_axes_w_change_frame = -1; a->dbg_axes_w_change_side = -1; a->dbg_vp_w_min = 1 << 20; a->dbg_vp_w_max = -(1 << 20); a->dbg_xtick_min = 1 << 20; a->dbg_xtick_max = -(1 << 20); a->dbg_gutter_min = 1 << 20; a->dbg_gutter_max = -(1 << 20); a->dbg_axes_frames = 0; a->dbg_x_desync = 0; return G_SOURCE_REMOVE; } /// 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. gboolean export_probe(gpointer user) { App* a = static_cast(user); const char* env = g_getenv("UDPSCOPE_EXPORT_PATH"); const std::string prefix = env != nullptr ? env : "/tmp/udpscope-export"; const RowSet::Row& r0 = a->rows->row(0); const plot::Range axis = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{0.0, 0.0}; const ExportTable table = collect_export(*a->model, *a->rows); std::fprintf(stderr, "[export-probe] mode=%s axis=[%.6f..%.6f] rows=%zu cols=%zu time=[%.6f..%.6f] " "stream=[%.6f..%.6f]\n", a->model->trigger_enabled() && a->model->event() != nullptr ? "event" : "live", axis.min, axis.max, table.rows(), table.columns.size(), table.rows() != 0 ? table.time.front() : 0.0, table.rows() != 0 ? table.time.back() : 0.0, table.rows() != 0 ? table.stream_time.front() : 0.0, table.rows() != 0 ? table.stream_time.back() : 0.0); for (const ExportTable::Column& col : table.columns) { std::fprintf(stderr, "[export-probe] column '%s' n=%zu\n", col.name.c_str(), col.values.size()); } for (const char* ext : {".npy", ".csv"}) { const std::string path = prefix + ext; std::string error; const bool ok = std::strcmp(ext, ".csv") == 0 ? write_csv(path, table, error) : write_npy(path, table, error); long bytes = -1; if (ok) { if (std::FILE* f = std::fopen(path.c_str(), "rb"); f != nullptr) { std::fseek(f, 0, SEEK_END); bytes = std::ftell(f); std::fclose(f); } } std::fprintf(stderr, "[export-probe] %s %s bytes=%ld%s%s\n", path.c_str(), ok ? "written" : "FAILED", bytes, ok ? "" : " error=", ok ? "" : error.c_str()); } std::fflush(stderr); return G_SOURCE_REMOVE; } gboolean exit_probe_step1(gpointer user) { App* a = static_cast(user); // Record the panel states before the toggles are exercised. a->dbg_trigger_revealed_default = gtk_revealer_get_child_revealed(GTK_REVEALER(a->trigger_revealer)) ? 1 : 0; if (a->trigger_toggle != nullptr) { gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE); } g_timeout_add(1700, exit_probe_step2, a); return G_SOURCE_REMOVE; } } // namespace int run_ui(int argc, char** argv, ScopeModel& model, Acquisition& acq, RowSet& rows) { (void)argc; App app; app.model = &model; app.acq = &acq; app.rows = &rows; GtkApplication* application = gtk_application_new("org.udpscope.app", G_APPLICATION_DEFAULT_FLAGS); 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_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_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_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); } // 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) { const Partition& part = model.partition(); std::printf("[udpscope] frames=%llu packets=%llu gaps=%llu channels=%zu span=%.3fs " "stride=%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)); 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 frames, segments %llu/%u " "(mean/max), visible max %llu samples\n", app.dbg_raw_frames, app.dbg_lod_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 visible: %d\n", app.dbg_reset_visible); 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()); std::printf("[udpscope] panels: side=%dx%d (visible=%d) signals=%dx%d row_h=%d " "(revealed=%d) trigger=%dx%d (revealed=%d) toggles=%d cursors=%d window=%.3fs " "window_cap=%.1fs\n", app.dbg_side_w, app.dbg_side_h, app.dbg_side_visible, app.dbg_signal_w, app.dbg_signal_h, app.dbg_signal_row_h, app.dbg_signal_revealed, app.dbg_trigger_w, app.dbg_trigger_h, app.dbg_trigger_revealed, app.dbg_toggles_visible, rows.row(0).cursors_visible ? 1 : 0, rows.time_window(), app.window_limit_s); 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] badge frames: lit=%d dim=%d idle=%d\n", app.dbg_badge_frames[0], app.dbg_badge_frames[1], app.dbg_badge_frames[2]); std::printf("[udpscope] trigger panel revealed before the probe toggled it: %d; " "signal list items=%d selected=%d; column visible after sidebar " "toggle off=%d\n", app.dbg_trigger_revealed_default, app.dbg_trigger_items, app.dbg_trigger_selected, app.dbg_side_scroll_after_hide); { 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); } } 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 udpscope