// 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 "build_info.hpp" #include "config.hpp" #include "export.hpp" #include "help_window.hpp" #include "layout_store.hpp" #include "shortcuts.hpp" #include "status_text.hpp" #include "measure.hpp" #include "model.hpp" #include "plot_host.hpp" #include #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 editor's way out: one `Done` button in the plot area's top-right corner while /// the editor is up. Not part of `layout_buttons`, which is one button per row and per /// boundary -- the count the layout probe asserts. GtkWidget* layout_done_button = nullptr; /// 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; /// Which step of the cursors probe runs next (`UDPSCOPE_PROBE_CURSORS_MS`). int dbg_cursors_step = 0; /// Which step of the fit probe runs next (`UDPSCOPE_PROBE_FIT_MS`). int dbg_fit_step = 0; /// Which step of the x-label-form probe runs next (`UDPSCOPE_PROBE_TIMELABELS_MS`). int dbg_timelabels_step = 0; /// The cursors probe's own bookkeeping: the stored time before a gestureless move, so the /// report can assert the move was ignored. double dbg_gestureless_before = 0.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; /// Where the panel controls ended up, decided once at build time: the dump runs after the window /// is destroyed and cannot ask GTK anything about a widget. std::string dbg_panel_controls; /// The icon each toolbar button carries, and whether the theme resolved it. Recorded at the /// same moment and for the same reason, but it guards a different thing: four of the icons are /// drawn for this app and shipped inside the binary, and a resource path that never got /// registered is *silent* -- GTK substitutes a missing-image placeholder, the button still /// works, and every other line of the dump still passes. std::string dbg_toolbar_icons; std::string dbg_app_icons; /// The header bar, so the dump can report the order the controls are in: a header bar is a /// *titlebar*, not a child of the window, so walking down from the window does not reach it. GtkWidget* toolbar = nullptr; GtkWidget* cursors_reset = nullptr; /// The cursors pane: revealed with the toggle, and listing what the instruments /// read -- T0/T1/dT always, per row an Y1/Y2/dY where rulers are up, and in `mix` mode /// the value each cursor crosses each trace at. GtkWidget* cursors_revealer = nullptr; GtkWidget* cursors_mode_drop = nullptr; GtkWidget* cursors_body = nullptr; /// One line per row (rulers) and one per bound trace (`mix`), in the order the body /// was built with. The texts are rewritten each frame; the widgets are rebuilt only /// when the rows or the mode change. /// One row of the pane's table: a `who` cell and the three value cells, so the numbers /// line up as columns the way a table does. struct CursorLine { GtkWidget* label = nullptr; ///< the `who` cell (a chip's markup, or text) GtkWidget* a = nullptr; GtkWidget* b = nullptr; GtkWidget* d = nullptr; int row = -1; int slot = -1; }; std::vector cursors_lines; /// The measurements the user set up, and what they last computed. The list is the state; /// the results are recomputed on a throttle (the numbers are window statistics, and the /// window scrolls every frame -- 60 recomputations a second of every sample is not a /// price the frame tick should pay). std::vector measures; std::vector measure_results; GtkWidget* measures_revealer = nullptr; GtkWidget* measures_list = nullptr; GtkWidget* measures_toggle = nullptr; // --- math channels -------------------------------------------------------- /// The app's copy of the math channels, and the source of truth for the pane. Every change goes /// through `apply_math_channels()`, which writes this and hands it to the model -- the model's own /// copy is what the producer evaluates. std::vector math; GtkWidget* math_toggle = nullptr; GtkWidget* math_revealer = nullptr; GtkWidget* math_list = nullptr; GtkWidget* math_dialog = nullptr; /// One value label per measure, in `measures` order; rebuilt with the list. std::vector measure_labels; /// The text each label was last set to. The dump runs from the destroy handler, where the /// labels are gone, so it prints these instead of reading freed widgets. std::vector measure_text; /// The window the current results were computed over, and whether a capture was held then: /// a recomputation is owed when either moves (see `update_measures`). plot::Range measure_window{}; bool measure_window_valid = false; bool measure_was_held = false; /// What the two walks over one channel's visible window produced, shared by every measurement /// on that signal: the window's statistics, and one accumulator per *distinct level* any of /// them asked for. Several measurements on one signal must cost two walks, not two each -- and /// the window itself is never stored, which is what the chunked walks bought (see /// `MeasureChunk`). struct MeasureWindow { MeasureStats stats; std::vector pct; ///< the level shares asked for, deduplicated std::vector level; ///< one accumulator per entry of `pct` bool ok = false; }; std::unordered_map measure_samples; /// The window's LOD buckets, reused across recomputes: a 1.7 M-sample window is 26 k of them, /// and both passes walk the same list. std::vector measure_bucket_scratch; /// The ink probe's gather buffer, reused for the same reason: it is refilled on every /// painted frame and its size is O(axis pixels). std::vector measure_ink_scratch; /// When the pane was last recomputed, for the refresh cadence above. gint64 measure_stamp_us = 0; bool measure_stamp_valid = false; /// What the pane cost: the recomputes it ran and the wall time they took. A readout that is /// recomputed per frame over a live window is exactly the kind of thing that is invisible in a /// frame mean, so it is measured where it happens and reported in the dump. gint64 measure_cost_us = 0; int measure_passes = 0; /// How much of the window the pane actually *read*: the samples covered, against the LOD /// buckets the two walks answered from and the samples they still had to open. That ratio is /// the whole question for this pane -- a bucket is 64 samples not read, and the raw tally is /// the window's partial edges plus the buckets that straddle a level, which on a clean /// waveform is one per crossing. A `raw` near `covered` means the pyramid bought nothing /// (a noisy signal, or a window shorter than a bucket) and the cost is back to O(window). gint64 measure_fetch_us = 0; std::uint64_t measure_covered = 0; std::uint64_t measure_bucketed = 0; std::uint64_t measure_raw = 0; /// The last pass' own duration, which the cadence adapts to. gint64 measure_last_pass_us = 0; /// The last *valid* result per measurement, which the marks fall back to: a level is a property /// of the signal and survives the window it was measured in, so the rulers do not blink off when /// a pass lands on a window with no samples (a rebuild, a capture boundary, a mode change). std::vector measure_last_valid; /// The marks the last computation produced: re-placed every frame, which is what makes the /// measurement cursors read-only. std::vector measure_marks; /// What the dump prints for the header and the panels. Captured while the window is alive: /// the dump runs from the destroy handler, where every widget call is a GTK-CRITICAL and /// reads nothing. int dbg_win_btn_w = -1; int dbg_win_label_w = -1; int dbg_bar_w = -1; bool measures_revealed = false; /// The measurement dialog while one is open (null otherwise). A probe needs a handle on it: /// it is a window of its own, so the main window's widget tree does not reach its buttons. GtkWidget* measure_dialog = nullptr; int dbg_measure_step = 0; int cursors_pane_mode = -1; int cursors_pane_rows = -1; unsigned cursors_pane_generation = ~0u; /// Which slots were *bound* when the pane was last built, one bit per row/slot /// (`kMaxRows * kSlotsPerRow` is 32). `mix` draws a line per bound trace, and a slot's /// channel is re-resolved by `bind()` every frame -- so the pane has to follow the bind /// and not just the model's generation, or a trace that appears without a structural /// change gets no line until something else rebuilds the table. std::uint32_t cursors_pane_bound = ~0u; /// The persisted-layout file this data source maps to (--server/--port), a /// debounced save handle, and whether the file has been applied once the /// CONFIG arrived. std::string source_host; std::uint16_t source_port = 0; /// How that source was reached. Unused at runtime -- `Acquisition` has its own copy -- but /// written into the session file, because `_.toml` can say neither, and the startup /// chooser needs both to reconnect to a multicast source from a stored row. std::string source_multicast; std::string source_interface; guint layout_save_source = 0; bool layout_loaded = false; 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; /// Starvation detector: the delivered-frame count and the client's reconnect count a /// second ago. A source that serves one client at a time shows up as a reconnect plus /// no frames for the second between two samples (see `note_starvation`). std::uint64_t starve_frames = 0; bool starve_have_prev = false; /// Consecutive one-second samples with `frames` unchanged while connected and running. int starve_still = 0; /// Set while the starvation notice is up, so the stderr line prints on the edges. bool starve_reported = false; /// 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; /// The *renderer's* blank: a frame that staged no trace geometry while every bound trace held /// samples. `dbg_blank_frames` cannot see this one -- it asks the rings, and a ring that holds /// data while the renderer draws nothing is exactly the "the data disappears for a frame" glitch. int dbg_render_blank_frames = 0; /// The last frame's staged segment count, so the tick can judge the frame the renderer drew. unsigned long long dbg_seg_last = 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; /// Painted frames on which `draw_traces` drew **no trace at all**, and the last one's index. /// This is the observable for "the plot flickers as if there were no data": `staged_segments` /// cannot see it, because the grid and the instruments stage geometry of their own, so a frame /// that drew every chrome line and not one sample still reports tens of thousands of segments. /// A count that is only the frames before the first bind is startup; one that keeps growing /// afterwards is the glitch. int dbg_notrace_frames = 0; int dbg_notrace_last = -1; /// The widest *minimum* width the side column or either pane has ever demanded. The side /// column is a fixed 320 px by design, so a figure far above that is a label whose text drove /// its own minimum -- the "layout must not depend on content" rule broken, and the only known /// mechanism by which the plot's allocation can exceed the window. int dbg_min_w_worst = 0; int dbg_min_w_reports = 0; /// Buffer rebuilds the tick has seen. Each one drops the history behind every trace. unsigned dbg_generation_prev = 0; bool dbg_generation_seen = false; int dbg_generation_changes = 0; /// Set by the tick once a bind has put samples on a trace, so the counter above can tell the /// frames before any data from the ones after. bool dbg_ever_bound = false; int dbg_notrace_after_bound = 0; /// The frame's one `DisplayMode`, recorded by the tick so the paint probe can name the /// state a frame was drawn in without taking a second read of it (the producer can latch /// between any two, which is why the mode is decided once per frame). DisplayMode dbg_display = DisplayMode::Live; /// The **ink** account, recomputed per painted frame the renderer's own way (see /// `check_frame_ink`). `staged_segments` and `lod_traces` cannot see a waveform that /// vanished for a frame -- the grid and the instruments stage geometry of their own, and a /// trace whose gathered points all collapse onto one x is still "drawn" -- so these count /// the polyline the filter actually leaves and how much of the axis it spans. int dbg_ink_checked = 0; int dbg_ink_collapse = 0; ///< fewer than two distinct positions left: nothing to draw int dbg_ink_staircase = 0; ///< the filter dropped over three quarters of the points int dbg_ink_short = 0; ///< the ink reached under a quarter of the axis' width double dbg_ink_worst_cover = 1.0; int dbg_ink_reports = 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; /// What the pan probe asked for, so the frame after it can be compared against it: a capture /// during the probe is not a value either side can recompute, and a capturing lambda cannot be /// a `GSourceFunc`. double dbg_pan_asked_lo = 0.0; double dbg_pan_asked_hi = 0.0; bool dbg_pan_asked = false; /// Which gesture the probe is on: 1 = the pan was asked and its verdict is due, 2 = the /// zoom-out was asked and its verdict is due. int dbg_gesture_stage = 0; /// A verdict the *tick* owes, one frame after a gesture was asked: 1 pan, 2 zoom-out, 3 /// zoom-in. Reported from the frame loop rather than a `g_timeout`, because a 300 ms timer /// slips past the state it was meant to observe as soon as the frames are heavy -- which is /// exactly the load this probe runs under. int dbg_gesture_verdict = 0; /// The event-zoom probe's remaining steps, counted down by the *tick* for the same reason /// the gesture verdicts are: each step's reading has to be taken after the frame's pin and /// clamp have run, or it reports the ask rather than the view. int dbg_zoom_steps = 0; int dbg_zoom_step_no = 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; /// The detail string the popover and the exit dump read. The dump used to read the label, /// which no longer carries these fields. std::string status_detail; std::size_t status_detail_max = 0; /// The widest glance line this run produced. The bar sits above the plot, so this is the /// "layout must not depend on content" reading for the status row. std::size_t glance_max_chars = 0; /// The last glance line, for the exit dump's `status bar:` line — the dump runs after the /// window is destroyed, so it cannot ask the label. std::string dbg_glance_last; /// Which step of the menu probe runs next (`UDPSCOPE_PROBE_MENUS_MS`). int dbg_menus_step = 0; GtkWidget* status_info_button = nullptr; ///< the ⓘ GtkWidget* status_popover = nullptr; /// The popover's eleven labels: four row headings and seven value lines. Built once; the /// tick sets their text. A rebuild here would be a per-frame relayout of a visible widget. GtkWidget* popover_label[11] = {}; int popover_rebuilds = 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" // A popover menu row: Adwaita gives a button a 34 px minimum height and 8 px of padding, // which is what made the overflow read as a stack of dialogs rather than a menu. The // shortcut label is a plain .dim-label at the row's right, so all that is left to trim // is the row itself. "popover.udpscope-menu button { min-height: 0; padding: 3px 8px; }\n" "popover.udpscope-menu button > box { min-height: 0; }\n" "popover.udpscope-menu button label { min-height: 0; }\n" "popover.udpscope-menu image { min-width: 12px; min-height: 0; }\n" "popover.udpscope-menu separator { margin: 4px 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); } /// Keeps a menu row's check in step with its toggle. The check keeps its allocation when it is /// off (opacity, not visibility), so a checked and an unchecked row's labels line up. void on_menu_row_toggled(GtkToggleButton* button, gpointer) { GtkWidget* check = GTK_WIDGET(g_object_get_data(G_OBJECT(button), "udpscope-check")); if (check != nullptr) { gtk_widget_set_opacity(check, gtk_toggle_button_get_active(button) ? 1.0 : 0.0); } } /// One row of a popover menu, laid out the way a GNOME menu lays one out: the label at the /// left, the shortcut at the right when the action has one, and -- for a row that toggles /// something -- a check in its own column. Returned as a box for the caller to hand to /// `gtk_button_set_child()`; `.udpscope-menu` in install_css() is what trims the height. GtkWidget* menu_row(const char* label, const char* accel, bool with_check, GtkWidget** out_check) { GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10); if (with_check) { GtkWidget* check = gtk_image_new_from_icon_name("object-select-symbolic"); gtk_widget_set_opacity(check, 0.0); gtk_box_append(GTK_BOX(box), check); if (out_check != nullptr) *out_check = check; } GtkWidget* text = gtk_label_new(label); gtk_label_set_xalign(GTK_LABEL(text), 0.0f); gtk_widget_set_hexpand(text, true); gtk_box_append(GTK_BOX(box), text); if (accel != nullptr) { GtkWidget* key = gtk_label_new(accel); gtk_label_set_xalign(GTK_LABEL(key), 1.0f); gtk_widget_add_css_class(key, "dim-label"); gtk_box_append(GTK_BOX(box), key); } return box; } /// A plain menu row. `accel` is what the app binds the action to (see shortcuts.cpp) -- it is /// shown, never bound here: the key handling stays in the one controller that refuses a key /// while a text field has focus. GtkWidget* menu_button(const char* label, const char* accel) { GtkWidget* button = gtk_button_new(); // The check column is reserved even though this row cannot be checked: a menu whose labels // start at two different x -- one for the rows that can be checked and one for the rest -- // is what "the text is not left-aligned" looks like. gtk_button_set_child(GTK_BUTTON(button), menu_row(label, accel, true, nullptr)); gtk_widget_add_css_class(button, "flat"); gtk_widget_set_hexpand(button, true); gtk_widget_set_halign(button, GTK_ALIGN_FILL); return button; } /// A menu row that toggles something, with the check menu_row() reserves the column for. The /// initial state is set *before* the handler is connected: a programmatic `set_active()` emits /// `toggled`, the handler acts on widgets the caller has not built yet (the signal list's /// revealer is created later in run_ui()), and a row that is created on must not reach it. GtkWidget* menu_toggle(const char* label, const char* accel, bool active) { GtkWidget* button = gtk_toggle_button_new(); GtkWidget* check = nullptr; gtk_button_set_child(GTK_BUTTON(button), menu_row(label, accel, true, &check)); gtk_widget_add_css_class(button, "flat"); gtk_widget_set_hexpand(button, true); gtk_widget_set_halign(button, GTK_ALIGN_FILL); g_object_set_data(G_OBJECT(button), "udpscope-check", check); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(button), active); on_menu_row_toggled(GTK_TOGGLE_BUTTON(button), nullptr); g_signal_connect(button, "toggled", G_CALLBACK(on_menu_row_toggled), nullptr); return button; } GtkWidget* menu_separator() { return gtk_separator_new(GTK_ORIENTATION_HORIZONTAL); } 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; } /// Same, without expanding the child. Header-bar children expand through the /// title's centre box (a start-packed expanding widget is measured into it), so /// the window control there stretched all the way to the title; panels want the /// fill, the bar does not. // --- persisted layout ------------------------------------------------------- void apply_time_window(App& app, double seconds); void note_starvation(App& app); int signal_color_index(const std::vector& signals, std::size_t signal); void rebuild_signal_panels(App& app); /// Reads the session layout back out of the model and rows: the display window, /// each row's slot signal names, and each signal's calibration. LayoutState collect_layout(App& app) { LayoutState st; st.window_s = app.rows->time_window(); ScopeModel& model = *app.model; for (int i = 0; i < app.rows->row_count(); ++i) { LayoutState::Row row; const RowSet::Row& r = app.rows->row(i); for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { row.signals.push_back(r.signal_name[static_cast(s)]); } st.rows.push_back(std::move(row)); } for (std::size_t si = 0; si < model.signals().size(); ++si) { for (std::size_t c = 0; c < model.channels().size(); ++c) { const ChannelInfo& ch = model.channels()[c]; if (ch.signal != si) continue; st.calibration.push_back({model.signals()[si].name, ch.gain, ch.offset}); break; } } // The *enable* state, not just what is plotted: a signal can be enabled only as a math channel's // source, and a session that came back with it off would come back with a NaN trace. "Enabled" is // read off the channels the model built -- it has no per-signal accessor and does not need one, // since an enabled signal is exactly one that produced a channel. std::vector on(model.signals().size(), false); for (const ChannelInfo& ch : model.channels()) { if (ch.math) continue; if (ch.signal < on.size()) on[ch.signal] = true; } for (std::size_t si = 0; si < model.signals().size(); ++si) { if (model.signals()[si].math || !on[si]) continue; st.enabled.push_back(model.signals()[si].name); } // The app's own lists are the pane's source of truth for both of these. st.math = app.math; st.measures = app.measures; // The connection is the session's too: the file's name carries only host and port. st.multicast = app.source_multicast; st.interface_addr = app.source_interface; return st; } /// Writes the layout file (a debounced schedule lands here once). Errors go to /// stderr; success is silent -- a save per click must not narrate. void save_layout_now(App& app) { if (app.source_port == 0) return; // Nothing is known about this source yet -- no CONFIG ever arrived -- so every slot name is // empty and this would overwrite a configured session with `signals = ["", ...]`. The startup // chooser filters such a file out of its list, so a source whose streamer was down during one // run would silently vanish from the only place it can be picked. Keeping the old file loses // nothing: this run configured nothing. if (app.model->signals().empty()) return; if (!save_layout(layout_path(app.source_host, app.source_port), collect_layout(app))) { std::fprintf(stderr, "[udpscope] failed to write the layout file\n"); std::fflush(stderr); } } /// One-shot debounce: a burst of changes (a drop, a row insert, a calibration /// commit in the same second) writes once, 400 ms after the last one. gboolean layout_save_tick(gpointer user) { App* app = static_cast(user); app->layout_save_source = 0; save_layout_now(*app); return G_SOURCE_REMOVE; } // Defined below; `apply_layout()` uses them, and it is above both. void apply_math_channels(App& app); void rebuild_measures_pane(App& app); void rebuild_math_pane(App& app); void schedule_layout_save(App& app) { if (app.source_port == 0) return; if (app.layout_save_source != 0) return; app.layout_save_source = g_timeout_add(400, layout_save_tick, &app); } /// Applies a loaded layout: rows, slot assignments, calibration, window. Names /// that no longer exist in the stream are skipped rather than freeing their slot /// (a stream that renamed a signal must not blank the user's layout). void apply_layout(App& app, const LayoutState& st) { ScopeModel& model = *app.model; const auto& signals = model.signals(); // The session's own state first: enabling is what starts acquisition, and a math channel's // *name* is only a signal once its graph is installed -- a row that holds one would otherwise be // skipped as a name the stream no longer has. for (const std::string& name : st.enabled) { for (std::size_t si = 0; si < signals.size(); ++si) { if (signals[si].name != name || signals[si].math) continue; model.set_signal_enabled(si, true); break; } } app.math = st.math; apply_math_channels(app); app.measures = st.measures; rebuild_measures_pane(app); int rows = static_cast(st.rows.size()); if (rows > RowSet::kMaxRows) rows = RowSet::kMaxRows; if (rows > 0) app.rows->set_row_count(rows); for (int i = 0; i < rows; ++i) { int slot = 0; for (const std::string& name : st.rows[static_cast(i)].signals) { if (slot >= RowSet::kSlotsPerRow) break; if (name.empty()) continue; for (std::size_t si = 0; si < signals.size(); ++si) { if (signals[si].name != name) continue; // A signal the model will never build a channel for must not take a slot. The // stream's time signal is skipped by `build_channels_locked()` however often it is // enabled, so such a slot holds a name for ever and data never -- and because // `collect_layout()` writes the names straight back, one that got in stayed in and // was re-saved every session. On the plot it drew nothing; in the cursors pane's // `mix` mode it drew a line that could only read `--`. if (ScopeModel::is_time_signal(signals, si)) break; // Enabling is what starts acquisition (a loaded plot nobody acquires would // come back blank), and it is what a drop does for the signal it places. app.rows->assign(i, slot, name, name, signal_color_index(signals, si)); model.set_signal_enabled(si, true); ++slot; break; } } } for (const auto& cal : st.calibration) { for (std::size_t si = 0; si < signals.size(); ++si) { if (signals[si].name != cal.name) continue; model.set_calibration(si, cal.gain, cal.offset); break; } } if (st.window_s > 0.0) apply_time_window(app, st.window_s); // set_row_count() rebuilt the figure (its row count is fixed at construction): // re-point the area, as every other row-count change must, and refresh the // signal panel's chips so they name the traces the file brought back. if (app.area != nullptr) app.area->set_figure(&app.rows->figure()); app.relayout_pending = true; rebuild_signal_panels(app); } GtkWidget* labeled_box_fixed(const char* label, GtkWidget* child) { GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget* l = gtk_label_new(label); gtk_widget_set_halign(l, GTK_ALIGN_START); gtk_box_append(GTK_BOX(box), l); gtk_box_append(GTK_BOX(box), child); return box; } 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"; } /// A measurement: at most two decimals, scaled by an SI prefix so the number reads in /// `1..999` (0.01052 V is `10.52 mV`), and the unit appended when the stream declares one. /// /// Two decimals is the cursors' resolution claim: the markers land on a sample and the /// sample is an `f32` of a value the CONFIG may scale arbitrarily, so more digits would be /// noise dressed as precision. Trailing zeros go, so `1.50 V` reads `1.5 V` -- "at most /// two decimals", not exactly two. /// /// The prefix is applied **whether or not a unit is declared**, and that is the point: two /// decimals of an unprefixed small value is `0.00`, i.e. the reading is simply gone. A signal /// whose CONFIG declares no unit is ordinary (and a *math* channel never has one), so the pane /// showed `0.00` for every value under 5 mV of an entire class of channels. A bare prefix is /// written tight against the number -- `1.34m`, not `1.34 m` -- because the space is there to /// separate the number from its unit, and with no unit to separate, a spaced `m` reads as metres. std::string format_measure(double v, const std::string& unit) { if (!std::isfinite(v)) return "--"; static const struct { double scale; const char* prefix; } kPrefixes[] = { {1.0e12, "T"}, {1.0e9, "G"}, {1.0e6, "M"}, {1.0e3, "k"}, {1.0, ""}, {1.0e-3, "m"}, {1.0e-6, "\u00b5"}, {1.0e-9, "n"}, {1.0e-12, "p"}, }; const double mag = std::fabs(v); const char* prefix = ""; double scale = 1.0; for (const auto& p : kPrefixes) { if (mag >= p.scale) { prefix = p.prefix; scale = p.scale; break; } } char buf[64]; if (mag > 0.0 && mag < 1.0e-12) { // Below the ladder, where scaling cannot help and `%.2f` would print `0.00` again. Three // significant digits in whatever form fits, so the reading survives as a reading. std::snprintf(buf, sizeof(buf), "%.3g", v); std::string out = buf; if (!unit.empty()) out += " " + unit; return out; } std::snprintf(buf, sizeof(buf), "%.2f", v / scale); std::string num = buf; if (num.find('.') != std::string::npos) { // at most two decimals, not exactly two while (num.size() > 1 && num.back() == '0') num.pop_back(); if (num.back() == '.') num.pop_back(); } if (num == "-0") num = "0"; std::string out = num; if (!unit.empty()) out += " "; // the space separates the number from its unit, nothing else out += prefix; out += unit; return out; } /// 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}); } } /// Every widget under `root` whose *minimum* width exceeds `threshold`, as a type name and a /// depth. A minimum width is what GTK must honour, so the chain printed here is the chain that /// forced the toplevel wider than the screen -- and the deepest line in it is the widget whose /// own content did it, which no summary of the plot's allocation can name. void report_wide_widgets(GtkWidget* root, int threshold, int depth) { if (root == nullptr || !GTK_IS_WIDGET(root)) return; int min = 0; int nat = 0; gtk_widget_measure(root, GTK_ORIENTATION_HORIZONTAL, -1, &min, &nat, nullptr, nullptr); if (min <= threshold) return; std::fprintf(stderr, "[width-probe] %*s%s min=%d nat=%d\n", depth * 2, "", G_OBJECT_TYPE_NAME(root), min, nat); for (GtkWidget* c = gtk_widget_get_first_child(root); c != nullptr; c = gtk_widget_get_next_sibling(c)) { report_wide_widgets(c, threshold, depth + 1); } } const char* display_mode_name(DisplayMode m) { switch (m) { case DisplayMode::Live: return "live"; case DisplayMode::Capturing: return "capturing"; case DisplayMode::Held: return "held"; case DisplayMode::Waiting: return "waiting"; case DisplayMode::Paused: return "paused"; } return "?"; } /// What a painted frame's first bound trace actually had to *draw*, recomputed through the /// same three calls `Renderer::draw_traces` makes: the index window from `neighbours()`, the /// LOD level from the 64-samples-per-pixel gate, then `gather()`. It then **counts** the /// renderer's increasing-suffix filter rather than applying it, and measures the kept /// polyline's extent against the axis in pixels. /// /// This exists because every other counter here measures *geometry*, and the reported defect /// is missing *ink*: `staged_segments` includes the grid and the instruments, so a frame that /// drew all the chrome and not one sample still reports tens of thousands, and `lod_traces` /// counts a trace whose gathered points the filter then collapses onto a single x as drawn. /// Three distinguishable outcomes, each a different bug: `collapse` (under two distinct /// positions left -- an invisible trace on a frame the renderer thinks it drew), `staircase` /// (most points dropped, i.e. the stamps came back in runs of equal f32) and `short` (the ink /// reaches a fraction of the axis -- geometry that is there but off to one side). void check_frame_ink(App& app) { if (app.rows == nullptr || app.rows->row_count() == 0) return; const RowSet::Row& r = app.rows->row(0); if (r.plot == nullptr) return; const plot::data::Series* s = nullptr; for (int i = 0; i < RowSet::kSlotsPerRow; ++i) { const std::size_t si = static_cast(i); if (r.channel[si] < 0) continue; s = &r.slots[si]; break; } if (s == nullptr || s->size() == 0) return; const plot::Range axis = r.plot->x_range(r.axis_x); const double axes_w = double(r.plot->layout().axes.w); if (!(axes_w > 0.0) || !(axis.span() > 0.0)) return; const plot::u64 n = s->size(); plot::u64 i0 = 0; plot::u64 i1 = 0; plot::u64 first = 0; plot::u64 last = n; if (s->neighbours(axis.min, i0, i1)) first = i0 > 0 ? i0 - 1 : 0; if (s->neighbours(axis.max, i0, i1)) last = std::min(n, i1 + 1); if (last <= first) return; const double spp = double(last - first) / axes_w; const plot::u32 level = spp >= 64.0 ? s->pyramid().level_for(n, spp) : 0u; std::vector& xy = app.measure_ink_scratch; xy.clear(); s->gather(level, first, last, xy); const std::size_t pts = xy.size() / 2; std::size_t kept = 0; double lo = 0.0; double hi = 0.0; // Where the filter's drops fall, and how far back each one steps. The renderer reconstructs // every point's absolute x as `gather[i] + series.x0()` **inside** this loop, so an `x0()` // that is not constant for the loop's duration shifts the comparison under itself -- and on a // ring-backed series `x0()` is `time_of(window_base)`, which *clamps to the oldest readable // slot* and therefore advances with the producer. The two readings that separate that from a // genuinely out-of-order gather are the drops' *positions* (spread through the points, or // bunched at the front where the clamp lives) and `back` (one sample's spacing, or a packet's // worth). So the loop is run twice: once the renderer's way, and once with `x0()` read a // single time. std::size_t drop_first = 0; std::size_t drop_last = 0; double worst_back = 0.0; bool any_drop = false; { double next = std::numeric_limits::infinity(); for (std::size_t k = pts; k > 0; --k) { const double x = double(xy[2 * (k - 1)]) + s->x0(); if (!(x < next)) { worst_back = std::max(worst_back, x - next); if (!any_drop) drop_last = k - 1; any_drop = true; drop_first = k - 1; continue; } next = x; if (kept == 0) hi = x; lo = x; ++kept; } } std::size_t kept_fixed = 0; { const double base = s->x0(); // one read, held for the whole walk double next = std::numeric_limits::infinity(); for (std::size_t k = pts; k > 0; --k) { const double x = double(xy[2 * (k - 1)]) + base; if (!(x < next)) continue; next = x; ++kept_fixed; } } ++app.dbg_ink_checked; const double cover = kept >= 2 ? (hi - lo) / axis.span() : 0.0; app.dbg_ink_worst_cover = std::min(app.dbg_ink_worst_cover, cover); const bool collapse = kept < 2; const bool staircase = pts >= 8 && kept * 4 < pts; // A ring that has not yet reached back to the axis' left edge is *meant* to leave its left // side empty (see the `clamp_to_data()` invariant), so a short ink there is the buffer // filling and not a defect: it is only reported once the data spans the window. const bool short_ink = !collapse && cover < 0.25 && s->x_bounds().min <= axis.min; if (collapse) ++app.dbg_ink_collapse; if (staircase) ++app.dbg_ink_staircase; if (short_ink) ++app.dbg_ink_short; if ((collapse || staircase || short_ink) && app.dbg_ink_reports < 40) { ++app.dbg_ink_reports; // The series' own answers beside the reconstruction, because the two disagreeing is itself // a reading: `rel` is what `gather()` emitted (relative to the `x0()` *it* read) and `x0` // is the `x0()` read here one call later, so a trace whose ink sits a whole ring-span off // its axis separates into "the stamps moved under us" and "the window is the wrong one". const plot::Range xb = s->x_bounds(); std::fprintf(stderr, "[ink] paint %d %s%s%s mode=%s axis=[%.6f..%.6f] n=%llu idx=[%llu..%llu) " "spp=%.1f level=%u pts=%zu kept=%zu ink=[%.6f..%.6f] cover=%.1f%% " "x0=%.6f x_at=[%.6f..%.6f] bounds=[%.6f..%.6f] rel=[%.6f..%.6f] dx=%.3g " "kept_fixed_x0=%zu drops=[%zu..%zu] back=%.3g\n", app.dbg_paint_n, collapse ? "collapse " : "", staircase ? "staircase " : "", short_ink ? "short " : "", display_mode_name(app.dbg_display), axis.min, axis.max, (unsigned long long)n, (unsigned long long)first, (unsigned long long)last, spp, level, pts, kept, lo, hi, cover * 100.0, s->x0(), s->x_at(0), s->x_at(n - 1), xb.min, xb.max, pts > 0 ? double(xy[0]) : 0.0, pts > 0 ? double(xy[2 * (pts - 1)]) : 0.0, s->dx(), kept_fixed, drop_first, drop_last, worst_back); std::fflush(stderr); } } /// Frame-cost probe, end of the plot's own draw. Connected *after* the adapter's /// `::render`, which is the whole reason `PlotArea::on_render` returns FALSE: the signal /// accumulates with `g_signal_accumulator_true_handled`, so a TRUE there would stop the /// emission and this would never run. Reading the stats here rather than in `tick()` is /// what makes them this frame's: `begin_frame()` resets them. 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); app->dbg_seg_last = s.staged_segments; if (s.raw_traces > 0) ++app->dbg_raw_frames; if (s.lod_traces > 0) ++app->dbg_lod_frames; if (s.raw_traces > 0 || s.lod_traces > 0) check_frame_ink(*app); if (s.raw_traces == 0 && s.lod_traces == 0) { ++app->dbg_notrace_frames; app->dbg_notrace_last = app->dbg_paint_n; if (app->dbg_ever_bound) { ++app->dbg_notrace_after_bound; // What the frame was asked to draw, beside what there was to draw it from: an axis // that left the data and a trace that had nothing bound are the same picture and // different bugs, so both are named here rather than inferred from the count. if (app->dbg_notrace_after_bound <= 40) { const RowSet::Row& r = app->rows->row(0); const plot::Range ax = r.plot != nullptr ? r.plot->x_range(r.axis_x) : plot::Range{}; std::fprintf(stderr, "[notrace] paint %d mode=%s axis=[%.6f..%.6f] segments=%u", app->dbg_paint_n, display_mode_name(app->dbg_display), ax.min, ax.max, s.staged_segments); for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) { if (r.channel[static_cast(sl)] < 0) continue; const plot::Range b = r.slots[static_cast(sl)].x_bounds(); std::fprintf(stderr, " slot%d n=%zu x=[%.6f..%.6f]", sl, r.slots[static_cast(sl)].size(), b.min, b.max); } std::fprintf(stderr, "\n"); } } } } } 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); void on_layout_editor_done(GtkButton*, gpointer); /// Takes the editor's buttons off the overlay. `gtk_overlay_remove_overlay()` drops the /// overlay's reference, which is the only one, so the widgets go with it. /// /// This runs from a *button's own* `::clicked` handler (the click rebuilds the set), which /// is safe because the emission holds its own reference on the instance: the button is /// finalized after the handler returns, not under it. void clear_layout_editor(App& app) { if (app.area_overlay != nullptr) { for (GtkWidget* b : app.layout_buttons) { gtk_overlay_remove_overlay(GTK_OVERLAY(app.area_overlay), b); } if (app.layout_done_button != nullptr) { gtk_overlay_remove_overlay(GTK_OVERLAY(app.area_overlay), app.layout_done_button); } } app.layout_buttons.clear(); app.layout_done_button = nullptr; app.layout_buttons_rows = 0; } 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); schedule_layout_save(*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); schedule_layout_save(*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); } // The editor's way out, in the area's top-right corner: the toolbar's toggle is in the // overflow menu, which is a long way from the plot you are editing. Aligned by margins // like every other overlay child, so it never touches the figure's layout. GtkWidget* done = gtk_button_new_with_label("Done"); gtk_widget_set_tooltip_text(done, "leave the layout editor"); gtk_widget_add_css_class(done, "osd"); gtk_widget_set_size_request(done, -1, kLayoutButtonPx); gtk_widget_set_halign(done, GTK_ALIGN_END); gtk_widget_set_valign(done, GTK_ALIGN_START); gtk_widget_set_margin_top(done, kLayoutButtonPad); gtk_widget_set_margin_end(done, kLayoutButtonPad); g_signal_connect(done, "clicked", G_CALLBACK(on_layout_editor_done), &app); gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), done); app.layout_done_button = done; sync_layout_editor(app); } /// The `Done` button: the toolbar toggle is the one piece of state that says whether the /// editor is up, so this turns *that* off and the existing `toggled` handler does the rest /// (including taking this button down). void on_layout_editor_done(GtkButton*, gpointer user) { App* app = static_cast(user); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->layout_toggle), false); } 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))); // The enable state is part of the session (a signal can be on only as a math channel's source). schedule_layout_save(*app); } 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))); schedule_layout_save(*ctx->app); gtk_window_destroy(GTK_WINDOW(ctx->window)); } void open_calibration(App& app, GtkWindow* parent, std::size_t signal) { const SignalInfo& sig = app.model->signals()[signal]; GtkWidget* window = dialog_window(GTK_WIDGET(parent), "Calibrate"); GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window)); GtkWidget* title = gtk_label_new(sig.name.c_str()); gtk_widget_set_halign(title, GTK_ALIGN_START); gtk_box_append(GTK_BOX(box), title); 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"); // `gtk_window_close()`, not `gtk_window_destroy()`: the destroy path does **not** emit // `close-request`, so the handler that clears `app.math_dialog` never ran and every later open // was a `present` on a dead widget -- a critical, and nothing on screen. Closing emits the signal, // the handler clears the pointer, and the default handler then destroys the window. g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_close), window); GtkWidget* apply = gtk_button_new_with_label("Apply"); g_object_set_data_full(G_OBJECT(apply), "udpscope-ctx", ctx, [](gpointer p) { delete static_cast(p); }); g_signal_connect(apply, "clicked", G_CALLBACK(on_calibrate_apply), ctx); dialog_add_button(window, cancel); dialog_add_button(window, apply); gtk_window_present(GTK_WINDOW(window)); } 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, 0); 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 = menu_button("Information", nullptr); 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 = menu_button("Calibrate...", nullptr); 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_widget_add_css_class(popover, "udpscope-menu"); 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 time scale changes. Clamps to what the buffer can hold, moves the plot and /// the trigger's event span together -- its pre-trigger share is a share of *this* window -- and /// then restates the result in the control, so a clamped request reads back as the window that /// was actually taken rather than the one that was typed. /// /// **Only the window control comes here.** The time scale is more than the axis: it is also the /// trigger's event span, the buffer partition (so a change of decimation drops the history) and /// the session, so a *plot interaction* may not state one. A zoom and a pan never touch it by /// construction -- they move the row's axis, and `clamp_to_data()` caps the span at `window_s_` -- /// and the two fits do not either: `RowSet::fit_x()` undoes a zoom by restoring the window that /// is already set. Both fits used to frame the data instead and move `window_s_` alone, which /// left the event span on the *old* window until the next decade step or unit change applied the /// fitted value for real: one action, two changes of time scale. void apply_time_window(App& app, double seconds) { if (!(seconds > 0.0)) return; const double clamped = std::min(std::max(seconds, kWindowMinS), window_limit(app)); // The buffer has to cover the window, so this may re-plan the partition and drop the // history -- a change of decimation factor cannot keep samples stored at the old rate. // It is announced rather than silent, because losing the trace is otherwise unexplained. // The frame tick rebinds every slot from the model, so the new rings are picked up // without being told -- the same path an enable or a CONFIG already takes. if (app.model->set_target_span(clamped)) { app.notice = "window " + format_window(clamped) + ": resampled to " + format_rate(app.model->partition().stored_rate()) + ", history cleared"; app.notice_until_us = g_get_monotonic_time() + 4 * G_TIME_SPAN_SECOND; } app.model->set_event_span(clamped); // The partition before the axis, so the axis is framed over the rings that will exist. app.rows->set_time_window(clamped); sync_window_display(app, clamped); schedule_layout_save(app); } /// Enter in the entry. A number that will not parse restores the current window instead of /// reporting an error: the entry is also the readout, so leaving junk in it would leave the /// control disagreeing with the plot. void on_window_entry_activate(GtkEntry* entry, gpointer user) { App* app = static_cast(user); if (app->updating_panels) return; const char* text = gtk_editable_get_text(GTK_EDITABLE(entry)); char* end = nullptr; const double value = std::strtod(text, &end); if (end == text || !(value > 0.0)) { sync_window_display(*app, app->rows->time_window()); return; } const guint unit = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->window_unit_drop)); apply_time_window(*app, value * kWindowUnits[unit < kWindowUnitCount ? unit : 2].seconds); } /// Changing the unit *re-interprets* the number already typed rather than converting it: "2" /// switched from ms to s means 2 s. Converting instead would make the unit selector a /// no-op, since the window it names would not change. void on_window_unit_changed(GObject* obj, GParamSpec*, gpointer user) { App* app = static_cast(user); if (app->updating_panels) return; // Our own sync, arriving late (see `window_sync_unit`). if (gtk_drop_down_get_selected(GTK_DROP_DOWN(obj)) == static_cast(app->window_sync_unit)) { return; } on_window_entry_activate(GTK_ENTRY(app->window_entry), user); } /// The decade steppers. A factor of ten per click, from the window actually in force rather /// than from the entry, so a half-typed number cannot be stepped. void on_window_step(GtkButton* button, gpointer user) { App* app = static_cast(user); const double factor = g_object_get_data(G_OBJECT(button), "udpscope-window-up") != nullptr ? 10.0 : 0.1; apply_time_window(*app, app->rows->time_window() * factor); } /// 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); app->model->set_paused(paused); if (paused) { // Freeze the *view*: snapshot exactly the window on screen, and pin the axes there. The // acquisition thread keeps writing the live ring behind it, so resuming shows the data that // arrived while paused instead of a hole. if (RowSet::Row& r0 = app->rows->row(0); r0.plot != nullptr) { const plot::Range view = r0.plot->x_range(r0.axis_x); const double epoch = app->model->display_epoch(); app->model->publish_pause_view(view.min - epoch, view.max - epoch); // Pin on what the snapshot *got*: the cut is clamped to the ring, so it can be narrower than // the view, and pinning the request left the shortfall as an empty left margin. plot::Range pin = view; if (std::shared_ptr snap = app->model->pause_view()) { if (!snap->stamps.empty()) { pin = plot::Range{static_cast(snap->stamps.front()), static_cast(snap->stamps.back())}; } } app->rows->pin_pause_window(pin.min, pin.max); } } else { app->model->clear_pause_view(); app->rows->unpin_pause_window(); } 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); } // --- measurements ----------------------------------------------------------- /// A measurement's own colour, so the pane's badge and the marks it puts on the plot are /// recognisably the same measurement. Picked from the trace palette's neighbours rather than /// it: a measurement's mark must not look like one of the signals. plot::u32 measure_color(std::size_t index) { static const plot::u32 kPalette[] = {0x9BE8A0u, 0xE8A0C8u, 0xE8D79Bu, 0x9BD7E8u, 0xC8A0E8u, 0xA0E8D7u, 0xE8B9A0u, 0xA0A8E8u}; return kPalette[index % (sizeof(kPalette) / sizeof(kPalette[0]))]; } /// The visible samples of one channel as parallel time/value arrays: the same cut the export /// makes -- the held event's snapshot while one is on screen, else the live ring over the /// shared window -- at the stored rate, so a measurement describes exactly the data the plot /// is showing. Call from the GTK thread; the live branch takes the model mutex. bool visible_samples(App& app, std::size_t channel, std::vector& time, std::vector& value) { time.clear(); value.clear(); const RowSet::Row& r0 = app.rows->row(0); if (r0.plot == nullptr) return false; const plot::Range view = r0.plot->x_range(r0.axis_x); if (std::shared_ptr snap = app.model->display_snapshot()) { if (snap == nullptr || channel >= snap->values.size()) return false; const std::vector& st = snap->stamps; const std::vector& vals = snap->values[channel]; if (vals.size() != st.size()) return false; const auto lo = std::lower_bound(st.begin(), st.end(), view.min); const auto hi = std::upper_bound(st.begin(), st.end(), view.max); const std::size_t count = static_cast(hi - lo); if (count == 0) return false; const std::size_t off = static_cast(lo - st.begin()); time.resize(count); value.resize(count); for (std::size_t i = 0; i < count; ++i) { time[i] = st[off + i]; value[i] = static_cast(vals[off + i]); } return true; } std::lock_guard lock(app.model->mutex()); if (channel >= app.model->channel_count()) return false; BufferStore& store = app.model->live(); const f64 epoch = app.model->display_epoch(); u64 first = 0; u64 last = 0; app.model->live_window_locked(view.min - epoch, view.max - epoch, first, last); const u64 n = last - first; if (n == 0) return false; time.resize(static_cast(n)); value.resize(static_cast(n)); // In bounded chunks rather than through two whole-window temporaries: the value ring holds // f32 and the engine reads f64, so that widening is unavoidable, but the *copies* are not -- // a full window is millions of samples, and the temporaries cost a full write and read of it // on top of the result. `kChunk` keeps both scratches in cache. Two of them, because the // clock ring is f64 and the values f32 (see `BufferStore::time()`). constexpr u64 kChunk = 8192; std::vector t_scratch(kChunk); std::vector v_scratch(kChunk); for (u64 at = 0; at < n; at += kChunk) { const u64 take = std::min(kChunk, n - at); store.time().copy_from(first + at, take, t_scratch.data()); for (u64 i = 0; i < take; ++i) { time[static_cast(at + i)] = epoch + t_scratch[i]; } store.values(channel).copy_from(first + at, take, v_scratch.data()); for (u64 i = 0; i < take; ++i) { value[static_cast(at + i)] = static_cast(v_scratch[i]); } } return true; } /// One channel's visible window, readable in chunks and never copied out whole -- what the /// measurements walk instead of `visible_samples()`. /// /// The old path widened the window into two `std::vector` before anything was computed, /// which at a 1.7 M-sample window was 27 MB written and then read again per pass: 60 % of the /// pane's cost, 12 ns a sample, 20 ms a recompute. Here the *values* are handed out as `f32` /// straight from the snapshot (a pointer, no copy) or in a cache-sized scratch from the ring, and /// a **stamp** is fetched one at a time -- the walks need a time only at the window's extremes and /// at the level crossings, which number in the handful. /// /// For a live window it holds `ScopeModel::mutex()` for its whole life, as the materialization did /// but for a fraction of the time. Keep one of these alive no longer than the walks. class MeasureWindowReader { public: MeasureWindowReader(App& app, std::size_t channel) { const RowSet::Row& r0 = app.rows->row(0); if (r0.plot == nullptr) return; const plot::Range view = r0.plot->x_range(r0.axis_x); if ((snap_ = app.model->display_snapshot()) != nullptr) { if (channel >= snap_->values.size()) return; const std::vector& st = snap_->stamps; if (snap_->values[channel].size() != st.size()) return; const auto lo = std::lower_bound(st.begin(), st.end(), view.min); const auto hi = std::upper_bound(st.begin(), st.end(), view.max); if (hi <= lo) return; off_ = static_cast(lo - st.begin()); count_ = static_cast(hi - lo); snap_vals_ = &snap_->values[channel]; // The snapshot's pyramid is built from sample 0 and nothing is ever dropped from it, so // its absolute index space *is* the snapshot's. if (channel < snap_->pyramids.size()) pyr_ = &snap_->pyramids[channel]; origin_ = off_; return; } lock_ = std::unique_lock(app.model->mutex()); if (channel >= app.model->channel_count()) return; epoch_ = app.model->display_epoch(); u64 first = 0; u64 last = 0; app.model->live_window_locked(view.min - epoch_, view.max - epoch_, first, last); if (last <= first) return; store_ = &app.model->live(); channel_ = channel; first_ = first; count_ = static_cast(last - first); // The channel's own pyramid, in the value ring's absolute index space -- the same space // `live_window_locked()` answers in, since a channel's stride is 1. It is current as of this // frame's `acquire_all()`; the samples produced since are past `window_end()` and are read // raw, which bounds that tail at one frame of production. pyr_ = &store_->channel(channel).pyramid(); origin_ = first; } std::size_t size() const noexcept { return count_; } /// The whole level-0 LOD buckets lying inside this window, as absolute bucket indices /// `[first, last)`. False when the window has none -- a window shorter than a bucket, or one /// the pyramid does not reach. bool bucket_range(u64& first, u64& last) const { if (pyr_ == nullptr || count_ == 0) return false; const u64 begin = std::max(origin_, pyr_->window_base()); const u64 end = std::min(origin_ + count_, pyr_->window_end()); if (end <= begin) return false; const u64 stored = pyr_->level_base(0); first = std::max(stored, (begin + plot::data::kBucket - 1) / plot::data::kBucket); last = std::min(stored + pyr_->level_size(0), end / plot::data::kBucket); return last > first; } /// Window offset of absolute bucket `k`'s first sample. std::size_t bucket_offset(u64 k) const noexcept { return static_cast(k * plot::data::kBucket - origin_); } MeasureBucket bucket(u64 k) const { const plot::data::MinMax mm = pyr_->bucket(0, k); MeasureBucket out; out.lo = static_cast(mm.min); out.hi = static_cast(mm.max); out.sum = pyr_->bucket_sum(0, k); out.count = static_cast(pyr_->bucket_count(0, k)); out.offset = bucket_offset(k); out.span = plot::data::kBucket; return out; } /// `n` values from window offset `at`. The pointer is valid until the next call. const f32* values(std::size_t at, std::size_t n) { if (snap_vals_ != nullptr) return snap_vals_->data() + off_ + at; if (scratch_.size() < n) scratch_.resize(n); store_->values(channel_).copy_from(first_ + at, n, scratch_.data()); return scratch_.data(); } /// Window sample `i`'s stream time. One sample at a time on purpose: see the class comment. double time_of(std::size_t i) const { if (snap_vals_ != nullptr) return snap_->stamps[off_ + i]; f64 stamp = 0.0; store_->time().copy_from(first_ + i, 1, &stamp); return epoch_ + stamp; } private: std::shared_ptr snap_; const std::vector* snap_vals_ = nullptr; std::size_t off_ = 0; std::unique_lock lock_; BufferStore* store_ = nullptr; std::size_t channel_ = 0; u64 first_ = 0; std::size_t count_ = 0; f64 epoch_ = 0.0; const plot::data::MinMaxPyramid* pyr_ = nullptr; /// Absolute sample index of window offset 0, so a bucket's absolute span becomes an offset. u64 origin_ = 0; std::vector scratch_; }; /// The channel a measurement's signal resolved to, or -1 while the stream does not carry it. int measure_channel(App& app, const std::string& signal) { std::lock_guard lock(app.model->mutex()); const std::vector& signals = app.model->signals(); const std::vector& channels = app.model->channels(); for (std::size_t s = 0; s < signals.size(); ++s) { if (signals[s].name != signal) continue; for (std::size_t c = 0; c < channels.size(); ++c) { if (channels[c].signal == s) return static_cast(c); } return -1; // the signal exists but is not acquired } return -1; } /// Which row plots a signal (the first slot that does), or 0: a measurement's marks belong on /// the plot showing the signal, and a signal nothing plots still has to go somewhere. int measure_row(App& app, const std::string& signal) { 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)] == signal) return r; } } return 0; } /// The unit of a signal's channel, for the pane's readout. std::string measure_unit(App& app, const std::string& signal) { std::lock_guard lock(app.model->mutex()); for (const ChannelInfo& c : app.model->channels()) { if (c.name == signal) return c.unit; } return ""; } /// How often the measurements pane is recomputed while the window is still moving: a readout, /// where the honest currency is the window's own settling, not the frame rate. Ten a second is /// past the point a number can be read, and it makes the cost independent of the framerate. constexpr gint64 kMeasureRefreshUs = 100 * 1000; /// A pass that costs more than this is a *hitch*, not a refresh: the pane waits four times as /// long next time. Bounding the interval alone does not bound what a frame pays -- a window of /// thirteen million samples costs 13 ms, which is most of a frame -- so the cadence follows the /// *work*, and what stays bounded is the duty cycle at any window size. constexpr gint64 kMeasureHeavyUs = 5 * 1000; void rebuild_measures_pane(App& app); void update_measures(App& app, bool force); void open_measure_dialog(App& app, int edit_index); /// One pane row: the measurement's badge, its type, its value and an `edit` button. The value /// label is the one widget `update_measures()` rewrites; everything else is rebuilt with the /// list. GtkWidget* measures_row(App& app, std::size_t index, const Measure& m) { GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget* chip = gtk_label_new(""); gtk_label_set_markup(GTK_LABEL(chip), chip_markup(plot::Color::hex(measure_color(index))).c_str()); gtk_widget_set_tooltip_text(chip, "this measurement's colour, and the colour of its marks"); gtk_box_append(GTK_BOX(box), chip); // The signal's own chip beside it, in the palette colour every other view names that signal // with, so the row says *what* is being measured as well as by which measurement. GtkWidget* sig_chip = gtk_label_new(""); { std::lock_guard lock(app.model->mutex()); const std::vector& signals = app.model->signals(); int color_index = -1; for (std::size_t i = 0; i < signals.size(); ++i) { if (signals[i].name == m.signal) { color_index = signal_color_index(signals, i); break; } } gtk_label_set_markup(GTK_LABEL(sig_chip), chip_markup(color_index >= 0 ? RowSet::palette_color(color_index) : RowSet::unplotted_color()) .c_str()); } gtk_widget_set_tooltip_text(sig_chip, m.signal.c_str()); gtk_box_append(GTK_BOX(box), sig_chip); GtkWidget* kind = gtk_label_new(measure_type_name(m.type)); gtk_widget_set_halign(kind, GTK_ALIGN_START); gtk_widget_set_hexpand(kind, true); gtk_label_set_ellipsize(GTK_LABEL(kind), PANGO_ELLIPSIZE_END); char tip[160]; std::snprintf(tip, sizeof(tip), "%s\n%s", m.signal.c_str(), measure_uses_threshold(m.type) ? (m.options.above ? "the above-threshold side" : "the below-threshold side") : ""); gtk_widget_set_tooltip_text(kind, tip); gtk_box_append(GTK_BOX(box), kind); GtkWidget* value = gtk_label_new("--"); gtk_widget_set_halign(value, GTK_ALIGN_END); gtk_label_set_selectable(GTK_LABEL(value), true); gtk_box_append(GTK_BOX(box), value); GtkWidget* edit = gtk_button_new_with_label("edit"); gtk_widget_add_css_class(edit, "flat"); g_object_set_data(G_OBJECT(edit), "udpscope-measure-index", GINT_TO_POINTER(static_cast(index) + 1)); g_signal_connect(edit, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { App* a = static_cast(user); const int idx = GPOINTER_TO_INT( g_object_get_data(G_OBJECT(b), "udpscope-measure-index")) - 1; open_measure_dialog(*a, idx); }), &app); gtk_box_append(GTK_BOX(box), edit); app.measure_labels.push_back(value); return box; } void rebuild_measures_pane(App& app) { if (app.measures_list == nullptr) return; GtkWidget* child = gtk_widget_get_first_child(app.measures_list); while (child != nullptr) { GtkWidget* next = gtk_widget_get_next_sibling(child); gtk_box_remove(GTK_BOX(app.measures_list), child); child = next; } app.measure_labels.clear(); app.measure_text.clear(); if (app.measures.empty()) { GtkWidget* empty = gtk_label_new("no measurements -- [+] to add one"); gtk_widget_add_css_class(empty, "dim-label"); gtk_widget_set_halign(empty, GTK_ALIGN_START); gtk_box_append(GTK_BOX(app.measures_list), empty); } for (std::size_t i = 0; i < app.measures.size(); ++i) { gtk_box_append(GTK_BOX(app.measures_list), measures_row(app, i, app.measures[i])); app.measure_text.emplace_back(); } update_measures(app, true); } // --- the cursors pane ------------------------------------------------------- /// The cursor modes, in the order the dropdown lists them; the index is `CursorMode`. constexpr const char* kCursorModeNames[] = {"vertical", "horizontal", "both", "mix"}; void rebuild_cursors_pane(App& app); void on_cursor_mode_changed(GObject* obj, GParamSpec*, gpointer user) { App* app = static_cast(user); if (app->updating_panels) return; const guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(obj)); if (sel > 3) return; app->rows->set_cursor_mode(static_cast(sel)); rebuild_cursors_pane(*app); } /// Appends one row to the pane's table: a `who` cell and the three value cells, in one /// `GtkGrid` so the four columns size to their widest cell and the numbers read as a table /// (`| | A | B | delta |`) rather than as a sentence per line. App::CursorLine cursors_line(App& app, const char* who, plot::Color chip, bool has_chip) { GtkGrid* grid = GTK_GRID(app.cursors_body); const int at = static_cast(app.cursors_lines.size()) + 1; // row 0 is the header App::CursorLine line; line.label = gtk_label_new(who); gtk_widget_set_halign(line.label, GTK_ALIGN_START); gtk_label_set_ellipsize(GTK_LABEL(line.label), PANGO_ELLIPSIZE_END); if (has_chip) { // The chip **and** the name: a chip alone says a trace has a colour and leaves the user // to match it against the plot, which with several traces is a guess. Escaped because // this is markup and a signal name is the stream's text, not ours. char* esc = g_markup_escape_text(who, -1); gtk_label_set_markup(GTK_LABEL(line.label), (chip_markup(chip) + " " + esc).c_str()); g_free(esc); gtk_widget_set_tooltip_text(line.label, who); } gtk_grid_attach(grid, line.label, 0, at, 1, 1); GtkWidget* cells[3] = {nullptr, nullptr, nullptr}; for (int c = 0; c < 3; ++c) { GtkWidget* v = gtk_label_new(""); gtk_widget_set_halign(v, GTK_ALIGN_END); gtk_label_set_selectable(GTK_LABEL(v), true); gtk_widget_set_margin_start(v, 10); gtk_grid_attach(grid, v, c + 1, at, 1, 1); cells[c] = v; } line.a = cells[0]; line.b = cells[1]; line.d = cells[2]; return line; } /// The table's header: the columns have no name but the two cursors and their difference. GtkWidget* cursors_header(App& app) { GtkGrid* grid = GTK_GRID(app.cursors_body); GtkWidget* cells[4] = {gtk_label_new(""), gtk_label_new("A"), gtk_label_new("B"), gtk_label_new("delta")}; for (int c = 0; c < 4; ++c) { gtk_widget_set_halign(cells[c], c == 0 ? GTK_ALIGN_START : GTK_ALIGN_END); gtk_widget_add_css_class(cells[c], "dim-label"); if (c > 0) gtk_widget_set_margin_start(cells[c], 10); gtk_grid_attach(grid, cells[c], c, 0, 1, 1); } return cells[1]; } /// The unit a row's y rulers are in: the one its bound channels agree on, or none when they /// disagree -- a ruler sits on the row's y axis, and two signals of different units on one /// axis make "the" unit a guess. /// /// The channel list is rebuilt by the producer, so it is read under the model mutex, like /// every other read of it from this thread. std::string row_unit(App& app, int row) { std::lock_guard lock(app.model->mutex()); std::string unit; bool first = true; for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { const std::size_t si = static_cast(s); const RowSet::Row& r = app.rows->row(row); if (r.signal_name[si].empty()) continue; const int ch = r.channel[si]; if (ch < 0 || static_cast(ch) >= app.model->channels().size()) continue; const std::string& u = app.model->channels()[static_cast(ch)].unit; if (first) { unit = u; first = false; } else if (unit != u) { return ""; } } return unit; } /// The unit of one bound trace, or "" for a slot that plots nothing. std::string slot_unit(App& app, int row, int slot) { std::lock_guard lock(app.model->mutex()); const RowSet::Row& r = app.rows->row(row); const int ch = r.channel[static_cast(slot)]; if (ch < 0 || static_cast(ch) >= app.model->channels().size()) return ""; return app.model->channels()[static_cast(ch)].unit; } /// One bit per row/slot for the slots that are *bound* -- a name **and** a channel the /// model actually built. A name alone is not a trace: the stream's time signal is never /// acquired, so a slot naming it has a name for ever and data never, and `mix` would draw /// it a line that can only read `--`. std::uint32_t bound_slot_mask(App& app) { std::uint32_t mask = 0; for (int r = 0; r < app.rows->row_count(); ++r) { const RowSet::Row& row = app.rows->row(r); for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { const std::size_t si = static_cast(s); if (row.signal_name[si].empty() || row.channel[si] < 0) continue; mask |= 1u << (r * RowSet::kSlotsPerRow + s); } } return mask; } /// The pane's body follows what the mode reads and what is plotted: a line per row for /// the rulers, a line per bound trace for `mix`. Called when the mode changes, when the /// rows change and when the stream is re-described -- the texts are then rewritten every /// frame by `update_cursors_pane()`. void rebuild_cursors_pane(App& app) { if (app.cursors_body == nullptr) return; GtkWidget* child = gtk_widget_get_first_child(app.cursors_body); while (child != nullptr) { GtkWidget* next = gtk_widget_get_next_sibling(child); gtk_grid_remove(GTK_GRID(app.cursors_body), child); child = next; } app.cursors_lines.clear(); cursors_header(app); app.cursors_pane_mode = static_cast(app.rows->cursor_mode()); app.cursors_pane_rows = app.rows->row_count(); app.cursors_pane_generation = app.model->generation(); app.cursors_pane_bound = bound_slot_mask(app); const RowSet::CursorMode mode = app.rows->cursor_mode(); const bool rulers = mode == RowSet::CursorMode::Horizontal || mode == RowSet::CursorMode::Both; const bool crossings = mode == RowSet::CursorMode::Mix; // The times are a row of the same table: `| t | T0 | T1 | dT |`. `slot = -2` marks it, // `-1` a ruler row, `>= 0` a trace. Only the modes that *have* the time cursors get it: // in `horizontal` the pair is parked, and a row of times nothing on the plot can move // would be a readout of nothing. if (mode != RowSet::CursorMode::Horizontal) { app.cursors_lines.push_back(cursors_line(app, "t", plot::Color::hex(0), false)); app.cursors_lines.back().row = -1; app.cursors_lines.back().slot = -2; } if (rulers || crossings) { for (int r = 0; r < app.rows->row_count(); ++r) { if (!rulers) break; char who[64]; std::snprintf(who, sizeof(who), "row %d", r); app.cursors_lines.push_back(cursors_line(app, who, plot::Color::hex(0), false)); app.cursors_lines.back().row = r; app.cursors_lines.back().slot = -1; } if (crossings) { for (int r = 0; r < app.rows->row_count(); ++r) { const RowSet::Row& row = app.rows->row(r); for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { const std::size_t si = static_cast(s); // A *bound* trace, not merely a named slot: a slot can name a signal the model // will never build a channel for (the stream's time signal is never acquired), // and a line for one of those is a row of `--` beside a colour chip -- which is // what "the pane shows a lot of empty signals that do not exist" was. if (row.signal_name[si].empty() || row.channel[si] < 0) continue; app.cursors_lines.push_back(cursors_line( app, row.name[si].c_str(), RowSet::palette_color(row.color_index[si]), true)); app.cursors_lines.back().row = r; app.cursors_lines.back().slot = s; } } } } } /// `%.6g s` is exact enough for a cursor readout and short enough for the panel. void update_cursors_pane(App& app) { if (app.cursors_body == nullptr || !app.rows->cursors_visible()) return; if (app.cursors_pane_mode != static_cast(app.rows->cursor_mode()) || app.cursors_pane_rows != app.rows->row_count() || app.cursors_pane_generation != app.model->generation() || app.cursors_pane_bound != bound_slot_mask(app)) { rebuild_cursors_pane(app); } const double t0 = app.rows->cursor_a_x(); const double t1 = app.rows->cursor_b_x(); const auto put = [](GtkWidget* w, const std::string& text) { if (w != nullptr) gtk_label_set_text(GTK_LABEL(w), text.c_str()); }; const RowSet::CursorMode mode = app.rows->cursor_mode(); const bool rulers = mode == RowSet::CursorMode::Horizontal || mode == RowSet::CursorMode::Both; const bool crossings = mode == RowSet::CursorMode::Mix; for (const App::CursorLine& line : app.cursors_lines) { if (line.a == nullptr) continue; // One measurement per cell, each with the unit its own axis declares: the pane is a // readout beside a plot, where the values are the point. std::string va; std::string vb; std::string vd; if (line.slot == -2) { va = format_measure(t0, "s"); vb = format_measure(t1, "s"); vd = format_measure(t1 - t0, "s"); } else if (line.slot == -1 && rulers) { const std::string unit = row_unit(app, line.row); const double y1 = app.rows->ruler_y(line.row); const double y2 = app.rows->ruler_b_y(line.row); va = format_measure(y1, unit); vb = format_measure(y2, unit); vd = format_measure(y2 - y1, unit); } else if (crossings && line.slot >= 0) { // Where each cursor crosses this trace: the value the trace has at that time, // read the way a crosshair reads it. // The same call the crossing markers use, on the same trace: the pane and the // marks on the plot cannot disagree about what the value is. const RowSet::Row& row = app.rows->row(line.row); double y1 = 0.0; double y2 = 0.0; const bool a = row.plot != nullptr && row.plot->crosshair_value(static_cast(line.slot), t0, y1); const bool b = row.plot != nullptr && row.plot->crosshair_value(static_cast(line.slot), t1, y2); if (a && b) { const std::string unit = slot_unit(app, line.row, line.slot); va = format_measure(y1, unit); vb = format_measure(y2, unit); vd = format_measure(y2 - y1, unit); } else { va = vb = vd = a || b ? "partial" : "--"; } } else { va = vb = vd = "--"; } put(line.a, va); put(line.b, vb); put(line.d, vd); } } /// `labeled_box()` with the label handed back, for a row whose text depends on what is being /// edited (the dialog's option rows). GtkWidget* labelled_row(const char* label, GtkWidget* child, GtkWidget** label_out) { GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget* l = gtk_label_new(label); gtk_widget_set_halign(l, GTK_ALIGN_START); gtk_box_append(GTK_BOX(box), l); gtk_box_append(GTK_BOX(box), child); *label_out = l; return box; } /// The add/edit dialog. `edit_index < 0` adds; otherwise it edits that measurement in place /// (and offers Delete, which is the only way one is removed -- there is no undo here, so the /// destructive button sits with the dialog that shows what it would destroy). struct MeasureDialog { App* app = nullptr; int edit = -1; GtkWidget* signal_drop = nullptr; GtkWidget* type_drop = nullptr; GtkWidget* threshold = nullptr; GtkWidget* above = nullptr; GtkWidget* show = nullptr; GtkWidget* window = nullptr; /// The option rows' boxes and labels, so the dialog can *retitle* them per type: the same /// switch means "the pulse is above the level" for a pulse and nothing at all for a period, /// and the difference has to be in words, not just in visibility. GtkWidget* threshold_row = nullptr; GtkWidget* threshold_label = nullptr; GtkWidget* side_row = nullptr; GtkWidget* side_label = nullptr; /// The names the signal dropdown was built from, in order. `apply` resolves an index /// through *this*, not through the model's signal list: the dropdown lists the plotted /// traces, so index `i` here is the i-th trace -- indexing the CONFIG with it selected /// whatever signal happened to sit at that position there (the stream's timestamp, for the /// first entry, which is what picking the first trace used to store). std::vector signals; }; /// Shows the fields a type actually uses: the threshold and the side only where the engine /// reads them, and the plot option only for a type that can be drawn. A field that does /// nothing is worse than an absent one -- it invites the user to set it. void measure_dialog_sync(MeasureDialog* d) { const int sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(d->type_drop)); const MeasureType type = measure_type_at(sel); const bool threshold = measure_uses_threshold(type); const bool side = measure_uses_side(type); gtk_widget_set_visible(d->threshold_row, threshold); gtk_widget_set_visible(d->side_row, side); gtk_widget_set_visible(d->show, measure_can_show_on_plot(type)); // The words follow the type: one row, several meanings. const std::string signal = d->signals.empty() ? std::string("the signal") : d->signals[std::min( gtk_drop_down_get_selected(GTK_DROP_DOWN(d->signal_drop)), d->signals.size() - 1)]; switch (type) { case MeasureType::Average: gtk_label_set_text(GTK_LABEL(d->threshold_label), ""); gtk_label_set_text(GTK_LABEL(d->side_label), ""); gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (ruler at the mean)"); break; case MeasureType::Min: case MeasureType::Max: gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (ruler at the value, marker where it is)"); break; case MeasureType::PeakToPeak: case MeasureType::Amplitude: gtk_label_set_text(GTK_LABEL(d->threshold_label), ""); gtk_label_set_text(GTK_LABEL(d->side_label), ""); gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (rulers at both extremes)"); break; case MeasureType::Top: case MeasureType::Bottom: gtk_label_set_text(GTK_LABEL(d->threshold_label), "split % of the range"); gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (ruler at the level)"); break; case MeasureType::PulseDuration: gtk_label_set_text(GTK_LABEL(d->threshold_label), "split % of the range"); gtk_label_set_text(GTK_LABEL(d->side_label), "pulse above the level"); gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (markers at the pulse's edges)"); break; case MeasureType::Period: case MeasureType::Frequency: // No split to choose: the engine counts rising crossings of the mid level (see // `compute_measure`), and the row that would offer one is not shown at all. gtk_label_set_text(GTK_LABEL(d->threshold_label), ""); gtk_label_set_text(GTK_LABEL(d->side_label), ""); gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (markers at two edges of a cycle)"); break; } gtk_widget_set_tooltip_text( d->show, ("draw what the measurement found on the row plotting " + signal + " -- it follows the " "window and cannot be dragged").c_str()); } void on_measure_type_changed(GObject* obj, GParamSpec*, gpointer user) { (void)obj; measure_dialog_sync(static_cast(user)); } void on_measure_dialog_apply(GtkButton*, gpointer user) { MeasureDialog* d = static_cast(user); App& app = *d->app; const guint sig = gtk_drop_down_get_selected(GTK_DROP_DOWN(d->signal_drop)); if (sig >= d->signals.size()) { gtk_window_destroy(GTK_WINDOW(d->window)); return; } Measure m; m.signal = d->signals[sig]; m.type = measure_type_at(gtk_drop_down_get_selected(GTK_DROP_DOWN(d->type_drop))); m.options.threshold_pct = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->threshold)); m.options.above = gtk_switch_get_active(GTK_SWITCH(d->above)); m.options.show_on_plot = gtk_check_button_get_active(GTK_CHECK_BUTTON(d->show)); if (d->edit >= 0 && static_cast(d->edit) < app.measures.size()) { app.measures[static_cast(d->edit)] = std::move(m); } else { app.measures.push_back(std::move(m)); } rebuild_measures_pane(app); gtk_window_destroy(GTK_WINDOW(d->window)); } void on_measure_dialog_delete(GtkButton*, gpointer user) { MeasureDialog* d = static_cast(user); App& app = *d->app; if (d->edit >= 0 && static_cast(d->edit) < app.measures.size()) { app.measures.erase(app.measures.begin() + d->edit); rebuild_measures_pane(app); } gtk_window_destroy(GTK_WINDOW(d->window)); } /// The dialog. Two dropdowns and the type's own options, laid out like the calibration one: /// a small window with a `labeled_box` per field and the buttons in the header bar. void open_measure_dialog(App& app, int edit_index) { const Measure* editing = edit_index >= 0 && static_cast(edit_index) < app.measures.size() ? &app.measures[static_cast(edit_index)] : nullptr; GtkWidget* window = dialog_window(GTK_WIDGET(app.window), editing != nullptr ? "Edit measurement" : "Add measurement"); GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window)); // The list is the signals the *plots* are showing, in signal-list order: a measurement // reads the window a plot is displaying, and its marks are drawn on the row that plots the // signal, so the candidates are the traces on screen. That is also what keeps a channel // nobody plots out of the list -- including a stream's timestamp signal, which is // acquired but is not something to measure. The editing measurement's own signal is added // back when it is not plotted (otherwise Save would silently re-point it at whatever the // dropdown happened to show). std::vector sig_names; int selected_signal = 0; for (int r = 0; r < app.rows->row_count(); ++r) { const RowSet::Row& row = app.rows->row(r); for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) { const std::size_t si = static_cast(sl); const std::string& n = row.name[si]; if (n.empty()) continue; if (std::find(sig_names.begin(), sig_names.end(), n) != sig_names.end()) continue; sig_names.push_back(n); } } if (editing != nullptr) { if (std::find(sig_names.begin(), sig_names.end(), editing->signal) == sig_names.end()) { sig_names.push_back(editing->signal); } } // The `const char*` array is built from the *finished* strings: taking `c_str()` as the // vector grew left every pointer pointing into memory the next push reallocated. std::vector sig_cstrs; sig_cstrs.reserve(sig_names.size() + 1); for (const std::string& n : sig_names) sig_cstrs.push_back(n.c_str()); sig_cstrs.push_back(nullptr); if (editing != nullptr) { const auto it = std::find(sig_names.begin(), sig_names.end(), editing->signal); if (it != sig_names.end()) { selected_signal = static_cast(it - sig_names.begin()); } } GtkWidget* signal_drop = gtk_drop_down_new_from_strings(sig_cstrs.data()); gtk_drop_down_set_selected(GTK_DROP_DOWN(signal_drop), static_cast(selected_signal)); // No scrolled window around it: a `GtkDropDown` scrolls its own popover list, and wrapping // one asked the *combo box itself* to grow to 160 px. gtk_box_append(GTK_BOX(box), labeled_box("signal", signal_drop)); std::vector type_names; for (int i = 0; i < measure_type_count(); ++i) type_names.push_back(measure_type_name(measure_type_at(i))); type_names.push_back(nullptr); GtkWidget* type_drop = gtk_drop_down_new_from_strings(type_names.data()); gtk_box_append(GTK_BOX(box), labeled_box("measure", type_drop)); GtkWidget* threshold = spin(0.0, 100.0, 1.0, 1); gtk_widget_set_tooltip_text(threshold, "where the signal is split, as a share of the visible range"); GtkWidget* threshold_label = nullptr; GtkWidget* threshold_row = labelled_row("threshold %", threshold, &threshold_label); gtk_box_append(GTK_BOX(box), threshold_row); GtkWidget* above = gtk_switch_new(); gtk_widget_set_halign(above, GTK_ALIGN_START); gtk_widget_set_tooltip_text(above, "which half of the waveform is the pulse"); GtkWidget* side_label = nullptr; GtkWidget* side_row = labelled_row("pulse above level", above, &side_label); gtk_box_append(GTK_BOX(box), side_row); GtkWidget* show = gtk_check_button_new_with_label("show on plot"); gtk_widget_set_tooltip_text(show, "draw the result on the plot: a ruler at the level, a marker " "at the time it happened, the pulse's edges as markers"); gtk_box_append(GTK_BOX(box), show); auto* d = new MeasureDialog{&app, edit_index, signal_drop, type_drop, threshold, above, show, window, threshold_row, threshold_label, side_row, side_label, sig_names}; app.measure_dialog = window; g_signal_connect(window, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer user) { static_cast(user)->measure_dialog = nullptr; }), &app); // One owner, on the window: the buttons only *refer* to the context. Letting two of them // own it (Apply and Delete) freed it twice, which is the abort an edit or a delete used to // produce -- an add has one button, which is why only those two paths crashed. g_object_set_data_full(G_OBJECT(window), "udpscope-measure-ctx", d, [](gpointer p) { delete static_cast(p); }); if (editing != nullptr) { // The index by looking `editing->type` up in the same function that fills the dropdown: // a hand-written chain of ternaries is a second copy of the order, and the copy is what // decides which type the user gets. for (int i = 0; i < measure_type_count(); ++i) { if (measure_type_at(i) == editing->type) { gtk_drop_down_set_selected(GTK_DROP_DOWN(type_drop), static_cast(i)); break; } } gtk_spin_button_set_value(GTK_SPIN_BUTTON(threshold), editing->options.threshold_pct); gtk_switch_set_active(GTK_SWITCH(above), editing->options.above); gtk_check_button_set_active(GTK_CHECK_BUTTON(show), editing->options.show_on_plot); } else { gtk_spin_button_set_value(GTK_SPIN_BUTTON(threshold), 50.0); gtk_switch_set_active(GTK_SWITCH(above), true); } g_signal_connect(type_drop, "notify::selected", G_CALLBACK(on_measure_type_changed), d); measure_dialog_sync(d); GtkWidget* cancel = gtk_button_new_with_label("Cancel"); g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window); dialog_add_button(window, cancel); if (editing != nullptr) { GtkWidget* del = gtk_button_new_with_label("Delete"); gtk_widget_add_css_class(del, "destructive-action"); g_signal_connect(del, "clicked", G_CALLBACK(on_measure_dialog_delete), d); dialog_add_button(window, del); } GtkWidget* apply = gtk_button_new_with_label(editing != nullptr ? "Save" : "Add"); gtk_widget_add_css_class(apply, "suggested-action"); g_signal_connect(apply, "clicked", G_CALLBACK(on_measure_dialog_apply), d); dialog_add_button(window, apply); gtk_window_present(GTK_WINDOW(window)); } /// Recomputes every measurement over the visible window and writes the results into the pane, /// collecting the marks the ones asking to be shown need. /// /// A measurement is a statistic of the window, so it is recomputed when the *window* moves -- /// not on a timer. A timer decouples the two: with the window scrolling under a 5 Hz cadence /// the stored marks describe a range the plot has already left, which is how a min/max marker /// ended up outside the viewport, and how a dragged marker survived until the next tick. The /// threshold is a small fraction of the span, so the marks stay within a few pixels of where /// the current window would put them, and the cost is bounded by the window's own motion /// rather than by the frame rate. void update_measures(App& app, bool force) { if (app.measure_labels.size() != app.measures.size()) return; // mid-rebuild const RowSet::Row& r0 = app.rows->row(0); if (r0.plot == nullptr) return; const plot::Range view = r0.plot->x_range(r0.axis_x); const bool held = app.model->display_mode() == DisplayMode::Held; const bool moved = app.measure_window_valid && !held && std::fabs(view.min - app.measure_window.min) + std::fabs(view.max - app.measure_window.max) > 0.02 * std::max(1.0e-12, view.span()); // A measurement is a statistic of the visible window, and *a live view moves that window every // frame* -- so "recompute when the window moves" is "recompute every frame", at O(visible // samples) per measurement. That is what stalled the frame tick (0.4 ms -> 13.7 ms, a third of // the run's frames lost) once the window held a few million samples. The pane is a *readout*, // so it is refreshed on the terms the status line's own fields already use: at most every // `kMeasureRefreshUs`, plus immediately whenever the window stops moving or the mode changes, // which is when the number has to be exact. Between those a tenth of a second of lag is // invisible on a statistic that is itself a window average, and the marks below are re-placed // from the last result every frame either way. const gint64 now_us = g_get_monotonic_time(); const gint64 interval = app.measure_last_pass_us > kMeasureHeavyUs ? 4 * kMeasureRefreshUs : kMeasureRefreshUs; const bool due = !app.measure_stamp_valid || now_us - app.measure_stamp_us >= interval; // Two reasons to skip, and they are different questions. `measure_window` is the window the // *last recompute described*, so no change at all means the pane already says the right thing: // that is the settled case, and it must recompute once when it settles and then never again -- // getting this backwards is what had a *pinned* window recomputing every single frame (457 // passes in one run) instead of once. The other is a window still scrolling: at most one pass // per `kMeasureRefreshUs`, because the number is a window average and a tenth of a second of // lag on it is not readable. const bool changed = !app.measure_window_valid || held != app.measure_was_held || moved; if (!force && !changed) { app.rows->set_measure_marks(app.measure_marks); return; } if (!force && moved && held == app.measure_was_held && !due) { // Nothing to recompute -- but the marks are still re-placed below, which is what makes // them read-only: a gesture moves one on the plot, and this is what puts it back. app.rows->set_measure_marks(app.measure_marks); return; } app.measure_window = view; app.measure_window_valid = true; app.measure_was_held = held; app.measure_stamp_us = now_us; app.measure_stamp_valid = true; const gint64 pass_start_us = g_get_monotonic_time(); app.measure_samples.clear(); app.measure_results.assign(app.measures.size(), MeasureResult{}); app.measure_last_valid.resize(app.measures.size()); app.measure_text.assign(app.measures.size(), std::string("--")); app.measure_marks.clear(); std::vector marks; struct PassTimer { App& app; gint64 start; ~PassTimer() { const gint64 took = g_get_monotonic_time() - start; app.measure_cost_us += took; app.measure_last_pass_us = took; ++app.measure_passes; } } pass_timer{app, pass_start_us}; // Who needs what, before any data is touched: the channel each measurement resolved to, and // *which* level accumulator answers it. Several measurements on one signal share both walks, and // several sharing a threshold share one accumulator -- so a pane full of measurements on one // trace costs the same two walks as a single one. The common case is exactly one level: every // crossing type reads the mid level and `Top`/`Bottom` default to it. std::vector chan(app.measures.size(), -1); std::vector want_level(app.measures.size(), -1); for (std::size_t i = 0; i < app.measures.size(); ++i) { const Measure& m = app.measures[i]; const int channel = measure_channel(app, m.signal); chan[i] = channel; if (channel < 0) continue; // unresolvable this pass: a rebuild has emptied the channel list App::MeasureWindow& w = app.measure_samples[channel]; double pct = 0.0; if (!measure_level_pct(m.type, m.options, pct)) continue; std::size_t k = 0; while (k < w.pct.size() && w.pct[k] != pct) ++k; if (k == w.pct.size()) w.pct.push_back(pct); want_level[i] = static_cast(k); } // The data work: two walks per channel, whatever the pane holds. The first fixes the window's // extremes (and so every level, which is a share of them); the second is skipped entirely when // no measurement on this channel reads a level. // // Both go through the **LOD pyramid** rather than over the samples. The producer already folds // every sample into it as it arrives, which is what "evaluate only on the new samples" means // here: a 64-sample bucket carries its own extremes and its exact sum, so pass one reads 1/64 // of the window, and pass two opens only the buckets that *straddle* its level -- a handful per // crossing. The raw path is still here, and still exact, for the window's partial edge buckets, // for the samples produced since this frame's `acquire_all()`, and for those straddling // buckets; a window shorter than a bucket, or a signal noisy enough to straddle everywhere, // degrades back to the O(window) cost and nothing else changes. constexpr std::size_t kChunk = 8192; std::vector& buckets = app.measure_bucket_scratch; for (auto& entry : app.measure_samples) { App::MeasureWindow& w = entry.second; const gint64 fetch_start_us = g_get_monotonic_time(); MeasureWindowReader reader(app, static_cast(entry.first)); const std::size_t n = reader.size(); const std::function time_of = [&reader](std::size_t i) { return reader.time_of(i); }; // The window's whole buckets, fetched once and walked by both passes. u64 kb = 0; u64 ke = 0; buckets.clear(); if (reader.bucket_range(kb, ke)) { buckets.reserve(static_cast(ke - kb)); for (u64 k = kb; k < ke; ++k) buckets.push_back(reader.bucket(k)); } const std::size_t bulk_from = buckets.empty() ? n : buckets.front().offset; const std::size_t bulk_to = buckets.empty() ? n : buckets.back().offset + buckets.back().span; std::size_t raw_read = 0; // In window order, always: the first sample to reach an extreme is the one the mark names, so // a later chunk that merely ties it must not move it. const auto raw = [&](std::size_t from, std::size_t to, const auto& fn) { for (std::size_t at = from; at < to; at += kChunk) { const std::size_t take = std::min(kChunk, to - at); fn(MeasureChunk{reader.values(at, take), take, at}); raw_read += take; } }; const auto scan = [&](const MeasureChunk& c) { measure_scan(c, w.stats); }; raw(0, bulk_from, scan); if (!buckets.empty()) measure_scan_buckets(buckets.data(), buckets.size(), w.stats); raw(bulk_to, n, scan); // A bucket named the 64 samples that hold an extreme, not the sample. One scan of that bucket // pins it -- two buckets at most, whatever the window's size. if (w.stats.valid) { const auto pin = [&](std::size_t& index, std::size_t& search, bool want_hi) { if (search == 0) return; MeasureStats one; measure_scan(MeasureChunk{reader.values(index, search), search, index}, one); if (one.valid) index = want_hi ? one.hi_i : one.lo_i; raw_read += search; search = 0; }; pin(w.stats.lo_i, w.stats.lo_search, false); pin(w.stats.hi_i, w.stats.hi_search, true); // The two stamps the whole scan needed, read now rather than at every chunk that moved an // extreme: a stamp is a ring read, and an extreme moves many times over a long window. w.stats.at_lo = reader.time_of(w.stats.lo_i); w.stats.at_hi = reader.time_of(w.stats.hi_i); } w.ok = w.stats.valid; if (w.ok && !w.pct.empty()) { w.level.assign(w.pct.size(), MeasureLevel{}); for (std::size_t k = 0; k < w.pct.size(); ++k) { w.level[k].level = w.stats.lo + w.pct[k] * (w.stats.hi - w.stats.lo); } const auto accumulate = [&](const MeasureChunk& c) { measure_accumulate(c, time_of, w.level.data(), w.level.size()); }; raw(0, bulk_from, accumulate); for (const MeasureBucket& b : buckets) { const f32* values = nullptr; for (MeasureLevel& level : w.level) { if (measure_accumulate_bucket(b, time_of, level)) continue; // Fetched once for however many levels this bucket straddles, and only then: the usual // case is one level, and the usual bucket answers without being opened at all. if (values == nullptr) { values = reader.values(b.offset, b.span); raw_read += b.span; } measure_accumulate(MeasureChunk{values, b.span, b.offset}, time_of, &level, 1); } } raw(bulk_to, n, accumulate); } app.measure_fetch_us += g_get_monotonic_time() - fetch_start_us; app.measure_covered += static_cast(n); app.measure_bucketed += static_cast(buckets.size()); app.measure_raw += raw_read; } for (std::size_t i = 0; i < app.measures.size(); ++i) { const Measure& m = app.measures[i]; GtkWidget* label = app.measure_labels[i]; const auto it = chan[i] < 0 ? app.measure_samples.end() : app.measure_samples.find(chan[i]); if (it == app.measure_samples.end() || !it->second.ok) { // Its signal is momentarily unresolvable, or the window holds nothing -- so there is nothing // to measure *this* pass. The marks still fall back (below); `continue`ing out of the marks // was the other half of the rulers blinking off. app.measure_text[i] = "--"; if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--"); continue; } const App::MeasureWindow& w = it->second; const MeasureLevel* level = want_level[i] >= 0 && static_cast(want_level[i]) < w.level.size() ? &w.level[static_cast(want_level[i])] : nullptr; const MeasureResult r = compute_measure(w.stats, level, m.type, m.options); app.measure_results[i] = r; if (r.valid) app.measure_last_valid[i] = r; if (label != nullptr) { std::string text = "--"; if (r.valid) { // Time-valued types carry seconds and a frequency carries hertz whatever the // signal's own unit is; everything else carries that unit. const bool is_time = m.type == MeasureType::PulseDuration || m.type == MeasureType::Period; const std::string unit = is_time ? "s" : m.type == MeasureType::Frequency ? "Hz" : measure_unit(app, m.signal); text = format_measure(r.primary, unit); if (r.has_secondary) { // Pulse duration also reports the duty, which has no unit. text += " " + format_measure(100.0 * r.secondary, "%"); } } app.measure_text[i] = text; gtk_label_set_text(GTK_LABEL(label), text.c_str()); } } // The marks last, in their own pass over the measurements, so that no `continue` above can skip // them. That is exactly how the rulers came to blink off -- first at a mode change, then at a // rebuild where the signal is momentarily unresolvable -- while the plot put them back a few // frames later, which reads as the lines flickering. // // A pass with no answer falls back to the last valid one, and the split is by *kind*: a // **level** is a property of the signal (a mean, a top, an amplitude) and outlives the window it // was measured in, so its ruler stays; a **time** mark is a position *inside* the window that // result describes, so it may not outlive it and is dropped. for (std::size_t i = 0; i < app.measures.size() && i < app.measure_results.size(); ++i) { const Measure& m = app.measures[i]; if (!m.options.show_on_plot) continue; const MeasureResult& r = app.measure_results[i]; const MeasureResult* mk = &r; if (!r.valid && i < app.measure_last_valid.size() && app.measure_last_valid[i].valid) { mk = &app.measure_last_valid[i]; } const int row = measure_row(app, m.signal); const plot::u32 color = measure_color(i); for (const MeasureMarkSpec& spec : mk->marks) { if (!spec.level && !r.valid) continue; marks.push_back({row, spec.level, spec.value, color}); } } app.measure_marks = std::move(marks); app.rows->set_measure_marks(app.measure_marks); } /// The measurements panel: a header with the `[+]`, and one row per measurement. // ----------------------------------------------------------------- math channels // // A math channel is a *channel*: the model synthesises a signal for it, so the panel's switch, the // plot binding, the measurements and the export all treat it like a streamed one, and the pane only // has to say which graphs exist. The editor is the compact node graph: nodes as boxes on a canvas, // click a box to select it and edit its parameters in the form beside the canvas, click an output // port and then an input port to wire them, drag a box to move it. void rebuild_math_pane(App& app); void open_math_dialog(App& app, int edit); /// Hands the app's list to the model. A structural change -- the derived channels are rebuilt and /// the producer starts or stops evaluating each one -- so it is the single writer for both copies. void apply_math_channels(App& app) { // `set_math_channels()` takes the model's lock itself -- holding it here is a self-deadlock on a // non-recursive mutex, and it is exactly how the first version of the probe hung the app. app.model->set_math_channels(app.math); rebuild_math_pane(app); schedule_layout_save(app); } /// The panel's `+`: a new channel with one source node, so the editor opens on something valid and /// the user has a box to connect. void add_math_channel(App& app) { // From the *acquired* channels, never `signals().front()`: the first signal of a stream is its // timestamp, which is not acquired, so a node sourced from it reads NaN for ever -- a graph that // validates and then shows nothing. (The dialog's own default was fixed for this; the pane's `+` // was not.) std::string name; { std::lock_guard lock(app.model->mutex()); if (!app.model->channels().empty()) name = app.model->channels().front().name; } MathChannel m; // A name of its own: calling it after its first source would put a second entry with that name in // the signal panel, where the user cannot tell the derived one from the streamed one. m.name = "math" + std::to_string(app.math.size() + 1); m.enabled = true; MathNode src; src.op = MathOp::Source; src.source = name; src.x = 30.0; src.y = 40.0; m.nodes.push_back(src); m.output = 0; app.math.push_back(std::move(m)); apply_math_channels(app); } // The node editor: a compact graph. Each node is a box; inputs are the squares on its left edge, // the output the square on its right. Click a box to select it (its parameters appear in the form // beside the canvas), click an output square and then an input square to wire them, drag a box to // move it. The graph is a *list*, walked in order, so a node may only read ones above it -- which // is why an input square offers only the nodes before it and a cycle cannot be drawn at all. constexpr double kNodeW = 132.0; constexpr double kNodeH = 46.0; constexpr double kPort = 10.0; struct MathDialog { App* app = nullptr; int edit = -1; std::vector nodes; int output = 0; int selected = 0; /// The node whose output square was clicked and is waiting for an input to connect to. int pending_out = -1; /// Where the box being dragged *was* when the drag began, and which box it is. The gesture /// reports the total offset from its own start point, so adding it to a value that the previous /// update already moved is a runaway: the box accelerates away from the pointer and leaves the /// canvas. `-1` means the drag did not begin on a box. double grab_x = 0.0; double grab_y = 0.0; int drag_node = -1; GtkWidget* window = nullptr; GtkWidget* canvas = nullptr; GtkWidget* name = nullptr; GtkWidget* unit = nullptr; GtkWidget* op_drop = nullptr; GtkWidget* source_drop = nullptr; GtkWidget* gain = nullptr; GtkWidget* offset = nullptr; GtkWidget* fc = nullptr; GtkWidget* q = nullptr; GtkWidget* lo = nullptr; GtkWidget* hi = nullptr; GtkWidget* rows[6] = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}; GtkWidget* inputs = nullptr; }; /// The channels a math channel may read: the ones the model actually acquires. Offering the whole /// signal list would let a graph name a signal nobody is acquiring -- which reads as no data for /// ever -- and would put the stream's timestamp signal in the list, which has no samples at all. std::vector math_source_names(App& app) { std::vector names; std::lock_guard lock(app.model->mutex()); for (const ChannelInfo& c : app.model->channels()) names.push_back(c.name); return names; } void math_dialog_sync(MathDialog* d); /// The box a node is drawn in, in canvas pixels. GdkRectangle math_node_rect(const MathNode& n) { return GdkRectangle{static_cast(n.x), static_cast(n.y), static_cast(kNodeW), static_cast(kNodeH)}; } /// Where an input square sits: on the left edge, split when there are two. void math_input_pos(const MathNode& n, int which, double& x, double& y) { const int arity = math_op_arity(n.op); x = n.x; y = n.y + kNodeH * (arity == 2 ? (which == 0 ? 1.0 : 2.0) / 3.0 : 0.5); } void math_output_pos(const MathNode& n, double& x, double& y) { x = n.x + kNodeW; y = n.y + kNodeH * 0.5; } int math_node_at(const MathDialog* d, double x, double y) { for (int i = static_cast(d->nodes.size()) - 1; i >= 0; --i) { const GdkRectangle r = math_node_rect(d->nodes[static_cast(i)]); if (x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height) return i; } return -1; } /// The port under the pointer: `-1` none, `-2` the selected node's output, otherwise the index of /// the node whose *input* `which` was hit. int math_port_at(const MathDialog* d, double x, double y, int& which) { which = 0; for (std::size_t i = 0; i < d->nodes.size(); ++i) { const MathNode& n = d->nodes[i]; double px = 0.0; double py = 0.0; math_output_pos(n, px, py); if (std::fabs(x - px) <= kPort && std::fabs(y - py) <= kPort) return -2; for (int k = 0; k < math_op_arity(n.op); ++k) { math_input_pos(n, k, px, py); if (std::fabs(x - px) <= kPort && std::fabs(y - py) <= kPort) { which = k; return static_cast(i); } } } return -1; } void math_canvas_draw(GtkDrawingArea*, cairo_t* cr, int width, int height, gpointer user) { MathDialog* d = static_cast(user); cairo_set_source_rgb(cr, 0.06, 0.078, 0.094); cairo_paint(cr); // The links first, so a box is never crossed by its own wires: from the source box's output // square to the destination's input square, with a horizontal tread in between. for (std::size_t i = 0; i < d->nodes.size(); ++i) { const MathNode& n = d->nodes[i]; const int ins[2] = {n.in_a, n.in_b}; for (int k = 0; k < math_op_arity(n.op); ++k) { const int from = ins[k]; if (from < 0 || from >= static_cast(i)) continue; double ax = 0.0; double ay = 0.0; double bx = 0.0; double by = 0.0; math_output_pos(d->nodes[static_cast(from)], ax, ay); math_input_pos(n, k, bx, by); cairo_set_source_rgb(cr, 0.55, 0.62, 0.72); cairo_set_line_width(cr, 1.5); cairo_move_to(cr, ax, ay); cairo_curve_to(cr, ax + 40.0, ay, bx - 40.0, by, bx, by); cairo_stroke(cr); } } for (std::size_t i = 0; i < d->nodes.size(); ++i) { const MathNode& n = d->nodes[i]; const GdkRectangle r = math_node_rect(n); const bool sel = static_cast(i) == d->selected; cairo_set_source_rgb(cr, sel ? 0.24 : 0.13, sel ? 0.29 : 0.16, sel ? 0.36 : 0.20); cairo_rectangle(cr, r.x, r.y, r.width, r.height); cairo_fill_preserve(cr); cairo_set_source_rgb(cr, sel ? 0.94 : 0.55, sel ? 0.96 : 0.60, sel ? 1.0 : 0.68); cairo_set_line_width(cr, sel ? 2.0 : 1.0); cairo_stroke(cr); cairo_set_source_rgb(cr, 0.90, 0.92, 0.96); cairo_select_font_face(cr, "sans", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_NORMAL); cairo_set_font_size(cr, 12.0); cairo_move_to(cr, r.x + 8.0, r.y + 18.0); cairo_show_text(cr, math_op_name(n.op)); if (n.op == MathOp::Source) { cairo_set_font_size(cr, 10.0); cairo_set_source_rgb(cr, 0.70, 0.76, 0.84); cairo_move_to(cr, r.x + 8.0, r.y + 34.0); cairo_show_text(cr, n.source.c_str()); } if (static_cast(i) == d->output) { // The output node carries the channel's value, so it is marked rather than merely selected. cairo_set_source_rgb(cr, 1.0, 0.69, 0.13); cairo_arc(cr, r.x + r.width - 12.0, r.y + 12.0, 4.0, 0.0, 2.0 * 3.14159265358979); cairo_fill(cr); } // Ports: inputs on the left, the output on the right. for (int k = 0; k < math_op_arity(n.op); ++k) { double px = 0.0; double py = 0.0; math_input_pos(n, k, px, py); const bool armed = d->pending_out >= 0 && static_cast(i) > d->pending_out; cairo_set_source_rgb(cr, armed ? 1.0 : 0.45, armed ? 0.69 : 0.52, armed ? 0.13 : 0.62); cairo_rectangle(cr, px - kPort * 0.5, py - kPort * 0.5, kPort, kPort); cairo_fill(cr); } double ox = 0.0; double oy = 0.0; math_output_pos(n, ox, oy); cairo_set_source_rgb(cr, d->pending_out == static_cast(i) ? 1.0 : 0.45, d->pending_out == static_cast(i) ? 0.69 : 0.52, d->pending_out == static_cast(i) ? 0.13 : 0.62); cairo_rectangle(cr, ox - kPort * 0.5, oy - kPort * 0.5, kPort, kPort); cairo_fill(cr); } (void)width; (void)height; } void math_canvas_pressed(GtkGestureClick*, int, double x, double y, gpointer user) { MathDialog* d = static_cast(user); int which = 0; const int port = math_port_at(d, x, y, which); if (port == -2) { // An output square: arm it. Clicking it again disarms, so a mis-click needs no escape. const int node = math_node_at(d, x, y); d->pending_out = (node >= 0 && d->pending_out == node) ? -1 : node; gtk_widget_queue_draw(d->canvas); return; } if (port >= 0) { if (d->pending_out >= 0 && d->pending_out < port) { MathNode& n = d->nodes[static_cast(port)]; (which == 0 ? n.in_a : n.in_b) = d->pending_out; d->pending_out = -1; d->selected = port; math_dialog_sync(d); return; } d->selected = port; math_dialog_sync(d); return; } const int node = math_node_at(d, x, y); if (node >= 0) { d->selected = node; d->pending_out = -1; math_dialog_sync(d); return; } d->pending_out = -1; gtk_widget_queue_draw(d->canvas); } void math_canvas_drag_begin(GtkGestureDrag*, double x, double y, gpointer user) { MathDialog* d = static_cast(user); // Only a drag that *began on a box* moves one. Dragging the background used to move whatever was // selected, which reads as the window fighting the pointer. const int node = math_node_at(d, x, y); d->drag_node = node; if (node < 0) return; d->selected = node; d->grab_x = d->nodes[static_cast(node)].x; d->grab_y = d->nodes[static_cast(node)].y; math_dialog_sync(d); } void math_canvas_drag(GtkGestureDrag*, double dx, double dy, gpointer user) { MathDialog* d = static_cast(user); if (d->drag_node < 0 || d->drag_node >= static_cast(d->nodes.size())) return; MathNode& n = d->nodes[static_cast(d->drag_node)]; // The *total* offset from where the box was when the drag began -- from `grab_x`/`grab_y`, which // no update writes. Feeding each update's delta back into the origin is what made the box fly off. n.x = std::max(0.0, d->grab_x + dx); n.y = std::max(0.0, d->grab_y + dy); gtk_widget_queue_draw(d->canvas); } void on_math_node_added(GtkButton*, gpointer user) { MathDialog* d = static_cast(user); // A node added to a graph that was *linear* -- its output was the last node -- becomes the output, // which is what "add a gain and it applies" means. A graph whose output is an earlier node is a // deliberate choice and is left alone. const bool was_linear = d->output == static_cast(d->nodes.size()) - 1 || d->nodes.empty(); MathNode n; n.op = MathOp::Gain; const std::size_t k = d->nodes.size(); // The same grid as the auto-place, so a node added here lands clear of the others. n.x = 24.0 + 160.0 * static_cast(k % 2u); n.y = 20.0 + 58.0 * static_cast(k / 2u); const int arity = math_op_arity(n.op); if (arity >= 1 && k > 0) n.in_a = static_cast(k) - 1; if (arity >= 2 && k > 0) n.in_b = static_cast(k) - 1; d->nodes.push_back(n); d->selected = static_cast(d->nodes.size()) - 1; if (was_linear) d->output = d->selected; math_dialog_sync(d); } void on_math_node_deleted(GtkButton*, gpointer user) { MathDialog* d = static_cast(user); if (d->selected < 0 || d->selected >= static_cast(d->nodes.size())) return; const int gone = d->selected; d->nodes.erase(d->nodes.begin() + gone); // Re-point every reference: a node after the removed one shifts down, and one reading it is left // unconnected rather than quietly reading its neighbour. for (MathNode& n : d->nodes) { const int ins[2] = {n.in_a, n.in_b}; int* slots[2] = {&n.in_a, &n.in_b}; for (int k = 0; k < 2; ++k) { if (ins[k] == gone) { *slots[k] = -1; } else if (ins[k] > gone) { *slots[k] = ins[k] - 1; } } } if (d->output == gone) d->output = -1; else if (d->output > gone) d->output -= 1; d->selected = std::min(gone, static_cast(d->nodes.size()) - 1); d->pending_out = -1; math_dialog_sync(d); } void on_math_output_set(GtkButton*, gpointer user) { MathDialog* d = static_cast(user); if (d->selected < 0 || d->selected >= static_cast(d->nodes.size())) return; d->output = d->selected; math_dialog_sync(d); } /// Reads every parameter widget into the selected node. One writer for the whole form, so a change /// cannot leave two copies of a value disagreeing -- and the widgets are created *once* and merely /// shown, hidden and set here, which is what keeps a rebuild from invalidating a live handle. void on_math_param_changed(GtkWidget*, gpointer user) { MathDialog* d = static_cast(user); if (d->selected < 0 || d->selected >= static_cast(d->nodes.size())) return; MathNode& n = d->nodes[static_cast(d->selected)]; n.gain = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->gain)); n.offset = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->offset)); n.fc = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->fc)); n.q = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->q)); n.lo = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->lo)); n.hi = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->hi)); if (d->source_drop != nullptr) { const guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(d->source_drop)); std::vector names; { std::lock_guard lock(d->app->model->mutex()); for (const SignalInfo& sig : d->app->model->signals()) names.push_back(sig.name); } if (sel < names.size()) n.source = names[sel]; } gtk_widget_queue_draw(d->canvas); } /// The op changed: the new op may take a different number of inputs, so the ones it cannot use are /// cleared rather than left pointing at a node the graph would then evaluate from anyway. void on_math_op_changed(GObject* object, GParamSpec*, gpointer user) { MathDialog* d = static_cast(user); if (d->selected < 0 || d->selected >= static_cast(d->nodes.size())) return; if (d->op_drop == nullptr || object != G_OBJECT(d->op_drop)) return; MathNode& n = d->nodes[static_cast(d->selected)]; const MathOp op = math_op_at(gtk_drop_down_get_selected(GTK_DROP_DOWN(d->op_drop))); if (op != n.op) { n.op = op; if (math_op_arity(op) < 2) n.in_b = -1; if (math_op_arity(op) < 1) { n.in_a = -1; n.in_b = -1; } // A node may only read the ones above it, so a new op keeps only the inputs that still qualify. if (n.in_a >= d->selected) n.in_a = -1; if (n.in_b >= d->selected) n.in_b = -1; if (op == MathOp::Source && n.source.empty()) { const std::vector names = math_source_names(*d->app); if (!names.empty()) n.source = names.front(); } } math_dialog_sync(d); } /// The form and the canvas from the selected node: which rows an op uses, what they hold, and which /// nodes its inputs are wired to. void math_dialog_sync(MathDialog* d) { if (d->selected < 0 || d->selected >= static_cast(d->nodes.size())) return; MathNode& n = d->nodes[static_cast(d->selected)]; const MathOp op = n.op; const bool gain = math_op_uses_gain(op); const bool filter = math_op_uses_filter(op); const bool clamp = math_op_uses_clamp(op); const bool source = op == MathOp::Source; gtk_widget_set_visible(d->rows[0], source); // the source channel gtk_widget_set_visible(d->rows[1], gain); gtk_widget_set_visible(d->rows[2], filter); gtk_widget_set_visible(d->rows[3], filter && op == MathOp::BandPass); gtk_widget_set_visible(d->rows[4], clamp); gtk_widget_set_visible(d->rows[5], true); // the inputs, always: they say what is wired gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->gain), n.gain); gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->offset), n.offset); gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->fc), n.fc); gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->q), n.q); gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->lo), n.lo); gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->hi), n.hi); if (d->source_drop != nullptr) { std::vector names; { std::lock_guard lock(d->app->model->mutex()); for (const SignalInfo& sig : d->app->model->signals()) names.push_back(sig.name); } GtkStringList* list = gtk_string_list_new(nullptr); guint pick = 0; bool found = false; for (std::size_t i = 0; i < names.size(); ++i) { gtk_string_list_append(list, names[i].c_str()); if (names[i] == n.source) { pick = static_cast(i); found = true; } } // A source that is not in the list is a graph naming a channel nobody acquires -- it would read // NaN for ever while the dropdown showed the first entry as if it were selected. The node is // moved to what the form is actually showing, so what is on screen is what the graph says. if (!found && !names.empty()) n.source = names.front(); gtk_drop_down_set_model(GTK_DROP_DOWN(d->source_drop), G_LIST_MODEL(list)); gtk_drop_down_set_selected(GTK_DROP_DOWN(d->source_drop), pick); g_object_unref(list); } // The op dropdown follows the selection without firing its own handler back into the node. if (d->op_drop != nullptr) gtk_drop_down_set_selected(GTK_DROP_DOWN(d->op_drop), static_cast(op)); std::string inputs; const auto describe = [&](int index) -> std::string { if (index < 0) return "—"; if (index >= static_cast(d->nodes.size())) return "—"; const MathNode& other = d->nodes[static_cast(index)]; std::string text = std::to_string(index + 1) + ": " + math_op_name(other.op); if (other.op == MathOp::Source) text += " (" + other.source + ")"; return text; }; const int arity = math_op_arity(op); inputs = arity == 0 ? "this node reads a channel" : arity == 1 ? ("input: " + describe(n.in_a)) : ("inputs: " + describe(n.in_a) + " " + describe(n.in_b)); gtk_label_set_text(GTK_LABEL(d->inputs), inputs.c_str()); gtk_widget_set_visible(d->inputs, true); gtk_widget_queue_draw(d->canvas); } void on_math_apply(GtkButton*, gpointer user) { MathDialog* d = static_cast(user); on_math_param_changed(nullptr, d); std::string why; MathChannel m; m.name = gtk_editable_get_text(GTK_EDITABLE(d->name)); m.unit = gtk_editable_get_text(GTK_EDITABLE(d->unit)); m.nodes = d->nodes; m.output = d->output; if (!math_channel_valid(m, &why)) { // The dialog stays up: an invalid graph is a thing to fix, and closing on it would throw the // work away with no message. GtkAlertDialog* alert = gtk_alert_dialog_new("%s", why.c_str()); gtk_alert_dialog_show(alert, GTK_WINDOW(d->window)); g_object_unref(alert); return; } // `math_channel_valid()` is about the *graph* (an output, every input wired to a node above it); // that a `Source` names a channel the scope actually acquires is the dialog's business, since only // it can see the acquired list. A graph naming a disabled signal is one that would read NaN for // ever, and it must not be saved. { const std::vector acquired = math_source_names(*d->app); for (std::size_t i = 0; i < m.nodes.size(); ++i) { if (m.nodes[i].op != MathOp::Source) continue; if (std::find(acquired.begin(), acquired.end(), m.nodes[i].source) != acquired.end()) continue; const std::string text = "node " + std::to_string(i + 1) + ": '" + m.nodes[i].source + "' is not an acquired channel"; GtkAlertDialog* alert = gtk_alert_dialog_new("%s", text.c_str()); gtk_alert_dialog_show(alert, GTK_WINDOW(d->window)); g_object_unref(alert); return; } } if (m.name.empty()) m.name = "math"; if (d->edit >= 0 && d->edit < static_cast(d->app->math.size())) { m.enabled = d->app->math[static_cast(d->edit)].enabled; d->app->math[static_cast(d->edit)] = m; } else { m.enabled = true; d->app->math.push_back(m); } apply_math_channels(*d->app); // Last statement: `close` emits `close-request`, whose handler clears `app.math_dialog`, and the // default then destroys the window -- which frees `d` through the data's destroy notify. gtk_window_close(GTK_WINDOW(d->window)); } GtkWidget* math_row(const char* label, GtkWidget* widget, GtkWidget** row_out) { GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget* text = gtk_label_new(label); gtk_widget_set_size_request(text, 74, -1); gtk_widget_set_halign(text, GTK_ALIGN_START); gtk_box_append(GTK_BOX(row), text); gtk_widget_set_hexpand(widget, true); gtk_box_append(GTK_BOX(row), widget); if (row_out != nullptr) *row_out = row; return row; } void open_math_dialog(App& app, int edit) { if (app.math_dialog != nullptr) { gtk_window_present(GTK_WINDOW(app.math_dialog)); return; } MathDialog* d = new MathDialog(); d->app = &app; d->edit = edit; { std::lock_guard lock(app.model->mutex()); d->nodes = edit >= 0 && edit < static_cast(app.math.size()) ? app.math[static_cast(edit)].nodes : std::vector{}; } if (d->nodes.empty()) { MathNode src; src.op = MathOp::Source; const std::vector names = math_source_names(app); if (!names.empty()) src.source = names.front(); d->nodes.push_back(src); } // A node with no position yet -- a graph built by a probe, or one whose box was never moved -- // would otherwise sit in the canvas's top-left corner, stacked under whatever is drawn after it. // Laid out in a column, which is also the order the graph is walked in. // Two columns, spaced by more than a box is wide. The first version staggered them by 24 px for a // 132 px box, so every node sat on top of the one before it and the click always found the last -- // which reads as the dialog ignoring the pointer. for (std::size_t i = 0; i < d->nodes.size(); ++i) { if (d->nodes[i].x != 0.0 || d->nodes[i].y != 0.0) continue; d->nodes[i].x = 24.0 + 160.0 * static_cast(i % 2u); d->nodes[i].y = 20.0 + 58.0 * static_cast(i / 2u); } d->output = edit >= 0 && edit < static_cast(app.math.size()) ? app.math[static_cast(edit)].output : 0; // **The last node, not the first.** A new graph is built source -> operation, and the *output* // defaulted to node 0 -- the source -- so every operation was computed and then thrown away: a // gain of 2 and a low pass both produced a channel equal to its input. The output is drawn as the // orange dot and settable explicitly, so a graph whose output is genuinely an earlier node keeps // it; this is only the default for one that has never been given a choice. if (d->output < 0 || d->output >= static_cast(d->nodes.size())) { d->output = static_cast(d->nodes.size()) - 1; } d->selected = d->output; GtkWidget* window = gtk_window_new(); d->window = window; gtk_window_set_title(GTK_WINDOW(window), edit >= 0 ? "Edit math channel" : "New math channel"); gtk_window_set_default_size(GTK_WINDOW(window), 720, 420); gtk_window_set_modal(GTK_WINDOW(window), true); g_object_set_data_full(G_OBJECT(window), "udpscope-math-dialog", d, +[](gpointer p) { delete static_cast(p); }); g_signal_connect(window, "close-request", G_CALLBACK(+[](GtkWindow* w, gpointer) { GtkWidget* dd = GTK_WIDGET(w); MathDialog* md = static_cast( g_object_get_data(G_OBJECT(dd), "udpscope-math-dialog")); if (md != nullptr && md->app != nullptr) md->app->math_dialog = nullptr; return false; }), nullptr); GtkWidget* outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); gtk_widget_set_margin_top(outer, 10); gtk_widget_set_margin_bottom(outer, 10); gtk_widget_set_margin_start(outer, 10); gtk_widget_set_margin_end(outer, 10); gtk_window_set_child(GTK_WINDOW(window), outer); // The channel's own identity: the name is also how another graph names it as a source. GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); d->name = gtk_entry_new(); gtk_entry_set_placeholder_text(GTK_ENTRY(d->name), "name"); if (edit >= 0 && edit < static_cast(app.math.size())) { gtk_editable_set_text(GTK_EDITABLE(d->name), app.math[static_cast(edit)].name.c_str()); } d->unit = gtk_entry_new(); gtk_entry_set_placeholder_text(GTK_ENTRY(d->unit), "unit (optional)"); if (edit >= 0 && edit < static_cast(app.math.size())) { gtk_editable_set_text(GTK_EDITABLE(d->unit), app.math[static_cast(edit)].unit.c_str()); } gtk_widget_set_hexpand(d->name, true); gtk_box_append(GTK_BOX(head), d->name); gtk_box_append(GTK_BOX(head), d->unit); gtk_box_append(GTK_BOX(outer), head); GtkWidget* body = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10); d->canvas = gtk_drawing_area_new(); gtk_widget_set_size_request(d->canvas, 420, 300); gtk_widget_set_hexpand(d->canvas, true); gtk_widget_set_vexpand(d->canvas, true); gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(d->canvas), math_canvas_draw, d, nullptr); GtkGesture* click = gtk_gesture_click_new(); g_signal_connect(click, "pressed", G_CALLBACK(+[](GtkGestureClick* g, int, double x, double y, gpointer user) { math_canvas_pressed(g, 0, x, y, user); }), d); gtk_widget_add_controller(d->canvas, GTK_EVENT_CONTROLLER(click)); GtkGesture* drag = gtk_gesture_drag_new(); g_signal_connect(drag, "drag-begin", G_CALLBACK(+[](GtkGestureDrag* g, double x, double y, gpointer user) { math_canvas_drag_begin(g, x, y, user); }), d); g_signal_connect(drag, "drag-update", G_CALLBACK(+[](GtkGestureDrag* g, double dx, double dy, gpointer user) { math_canvas_drag(g, dx, dy, user); }), d); gtk_widget_add_controller(d->canvas, GTK_EVENT_CONTROLLER(drag)); gtk_box_append(GTK_BOX(body), d->canvas); // The form: every row exists from the start and is shown or hidden per op, so no rebuild can // invalidate a callback's handle. GtkWidget* form = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); gtk_widget_set_size_request(form, 250, -1); GtkWidget* op_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget* op_text = gtk_label_new("operation"); gtk_widget_set_size_request(op_text, 74, -1); gtk_widget_set_halign(op_text, GTK_ALIGN_START); gtk_box_append(GTK_BOX(op_row), op_text); std::vector op_names; for (int i = 0; i < math_op_count(); ++i) op_names.push_back(math_op_name(math_op_at(i))); op_names.push_back(nullptr); d->op_drop = gtk_drop_down_new_from_strings(op_names.data()); gtk_widget_set_hexpand(d->op_drop, true); g_signal_connect(d->op_drop, "notify::selected", G_CALLBACK(on_math_op_changed), d); gtk_box_append(GTK_BOX(op_row), d->op_drop); gtk_box_append(GTK_BOX(form), op_row); d->source_drop = gtk_drop_down_new_from_strings(nullptr); g_signal_connect(d->source_drop, "notify::selected", G_CALLBACK(on_math_param_changed), d); gtk_box_append(GTK_BOX(form), math_row("channel", d->source_drop, &d->rows[0])); d->gain = gtk_spin_button_new_with_range(-1e6, 1e6, 0.1); d->offset = gtk_spin_button_new_with_range(-1e6, 1e6, 0.1); // Digits, not just a range: a `GtkSpinButton` holds zero decimals by default, so a 12.5 Hz cutoff // was stored as 12 and a gain of 0.25 as 0 -- the values looked wrong because they *were*. gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->gain), 4); gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->offset), 4); // An explicit starting value, because `gtk_spin_button_new_with_range()` leaves the value at the // range's *minimum* when zero is not one of the bounds it can pick: the offset began at -1e6, and // `on_math_param_changed()` reads the widgets -- so a gain of 2 saved as `x*2 - 2000000`. Every // field is seeded from its own identity value; `math_dialog_sync()` then sets the node's. gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->gain), 1.0); gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->offset), 0.0); g_signal_connect(d->gain, "value-changed", G_CALLBACK(on_math_param_changed), d); g_signal_connect(d->offset, "value-changed", G_CALLBACK(on_math_param_changed), d); { // One box for both: `gain + offset` is a single op and the two fields are only meaningful // together, so they show and hide as one. The offset used to be a row of its own that nothing // ever hid, which is what made it look like a property of every node instead of of this one op. GtkWidget* gain_rows = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); gtk_box_append(GTK_BOX(gain_rows), math_row("gain", d->gain, nullptr)); gtk_box_append(GTK_BOX(gain_rows), math_row("offset", d->offset, nullptr)); d->rows[1] = gain_rows; gtk_box_append(GTK_BOX(form), gain_rows); } d->fc = gtk_spin_button_new_with_range(0.001, 1e7, 0.5); d->q = gtk_spin_button_new_with_range(0.01, 100.0, 0.05); gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->fc), 3); gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->q), 3); gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->fc), 100.0); gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->q), 0.707); g_signal_connect(d->fc, "value-changed", G_CALLBACK(on_math_param_changed), d); g_signal_connect(d->q, "value-changed", G_CALLBACK(on_math_param_changed), d); { GtkWidget* filter_rows = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); gtk_box_append(GTK_BOX(filter_rows), math_row("corner Hz", d->fc, nullptr)); gtk_box_append(GTK_BOX(filter_rows), math_row("Q", d->q, &d->rows[3])); gtk_box_append(GTK_BOX(form), filter_rows); d->rows[2] = filter_rows; // The Q row is only meaningful for the band pass, which is the only op that reads it. gtk_widget_set_visible(d->rows[3], false); } d->lo = gtk_spin_button_new_with_range(-1e6, 1e6, 0.1); d->hi = gtk_spin_button_new_with_range(-1e6, 1e6, 0.1); gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->lo), 4); gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->hi), 4); gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->lo), -1.0); gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->hi), 1.0); g_signal_connect(d->lo, "value-changed", G_CALLBACK(on_math_param_changed), d); g_signal_connect(d->hi, "value-changed", G_CALLBACK(on_math_param_changed), d); { GtkWidget* clamp_rows = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); gtk_box_append(GTK_BOX(clamp_rows), math_row("min", d->lo, nullptr)); gtk_box_append(GTK_BOX(clamp_rows), math_row("max", d->hi, nullptr)); d->rows[4] = clamp_rows; gtk_box_append(GTK_BOX(form), clamp_rows); } d->inputs = gtk_label_new(""); gtk_label_set_wrap(GTK_LABEL(d->inputs), true); gtk_widget_add_css_class(d->inputs, "dim-label"); gtk_widget_set_halign(d->inputs, GTK_ALIGN_START); gtk_box_append(GTK_BOX(form), math_row("wired", d->inputs, &d->rows[5])); GtkWidget* add = gtk_button_new_with_label("add node"); GtkWidget* del = gtk_button_new_with_label("delete node"); GtkWidget* out = gtk_button_new_with_label("set as output"); g_signal_connect(add, "clicked", G_CALLBACK(on_math_node_added), d); g_signal_connect(del, "clicked", G_CALLBACK(on_math_node_deleted), d); g_signal_connect(out, "clicked", G_CALLBACK(on_math_output_set), d); GtkWidget* buttons = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); gtk_box_append(GTK_BOX(buttons), add); gtk_box_append(GTK_BOX(buttons), del); gtk_box_append(GTK_BOX(buttons), out); gtk_box_append(GTK_BOX(form), buttons); gtk_box_append(GTK_BOX(body), form); gtk_box_append(GTK_BOX(outer), body); GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); 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(edit >= 0 ? "Save" : "Add"); gtk_widget_add_css_class(apply, "suggested-action"); g_signal_connect(apply, "clicked", G_CALLBACK(on_math_apply), d); gtk_widget_set_halign(actions, GTK_ALIGN_END); gtk_box_append(GTK_BOX(actions), cancel); gtk_box_append(GTK_BOX(actions), apply); gtk_box_append(GTK_BOX(outer), actions); app.math_dialog = window; math_dialog_sync(d); gtk_window_present(GTK_WINDOW(window)); } GtkWidget* build_math_panel(App& app) { GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); gtk_widget_set_margin_top(box, 6); gtk_widget_set_margin_bottom(box, 6); gtk_widget_set_margin_start(box, 6); gtk_widget_set_margin_end(box, 6); GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); GtkWidget* title = gtk_label_new("Math channels"); gtk_widget_set_halign(title, GTK_ALIGN_START); gtk_widget_set_hexpand(title, true); gtk_box_append(GTK_BOX(head), title); GtkWidget* add = gtk_button_new_from_icon_name("list-add-symbolic"); gtk_widget_add_css_class(add, "flat"); gtk_widget_set_tooltip_text(add, "add a math channel"); g_signal_connect(add, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { add_math_channel(*static_cast(user)); }), &app); gtk_box_append(GTK_BOX(head), add); gtk_box_append(GTK_BOX(box), head); app.math_list = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); gtk_box_append(GTK_BOX(box), app.math_list); return box; } void rebuild_math_pane(App& app) { if (app.math_list == nullptr) return; while (GtkWidget* child = gtk_widget_get_first_child(app.math_list)) { gtk_box_remove(GTK_BOX(app.math_list), child); } if (app.math.empty()) { GtkWidget* none = gtk_label_new("— none —"); gtk_widget_add_css_class(none, "dim-label"); gtk_widget_set_halign(none, GTK_ALIGN_START); gtk_box_append(GTK_BOX(app.math_list), none); return; } for (std::size_t i = 0; i < app.math.size(); ++i) { const MathChannel& m = app.math[i]; GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget* on = gtk_check_button_new(); gtk_check_button_set_active(GTK_CHECK_BUTTON(on), m.enabled); gtk_widget_set_tooltip_text(on, "enable this math channel: off means it is not acquired, not\n" "plotted and not measured, exactly like a signal's switch"); const std::size_t index = i; g_signal_connect(on, "toggled", G_CALLBACK(+[](GtkToggleButton* b, gpointer user) { App* a = static_cast(user); const std::size_t k = GPOINTER_TO_SIZE( g_object_get_data(G_OBJECT(b), "udpscope-math-index")); if (k >= a->math.size()) return; a->math[k].enabled = gtk_toggle_button_get_active(b); apply_math_channels(*a); }), &app); g_object_set_data(G_OBJECT(on), "udpscope-math-index", GSIZE_TO_POINTER(index)); gtk_box_append(GTK_BOX(row), on); std::string text = m.name; if (!m.unit.empty()) text += " [" + m.unit + "]"; GtkWidget* label = gtk_label_new(text.c_str()); gtk_label_set_ellipsize(GTK_LABEL(label), PANGO_ELLIPSIZE_MIDDLE); gtk_widget_set_hexpand(label, true); gtk_widget_set_halign(label, GTK_ALIGN_START); gtk_box_append(GTK_BOX(row), label); GtkWidget* edit = gtk_button_new_from_icon_name("document-edit-symbolic"); gtk_widget_add_css_class(edit, "flat"); gtk_widget_set_tooltip_text(edit, "edit this math channel's graph"); g_object_set_data(G_OBJECT(edit), "udpscope-math-index", GSIZE_TO_POINTER(index)); g_signal_connect(edit, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { App* a = static_cast(user); const std::size_t k = GPOINTER_TO_SIZE( g_object_get_data(G_OBJECT(b), "udpscope-math-index")); open_math_dialog(*a, static_cast(k)); }), &app); gtk_box_append(GTK_BOX(row), edit); GtkWidget* del = gtk_button_new_from_icon_name("window-close-symbolic"); gtk_widget_add_css_class(del, "flat"); gtk_widget_set_tooltip_text(del, "delete this math channel"); g_object_set_data(G_OBJECT(del), "udpscope-math-index", GSIZE_TO_POINTER(index)); g_signal_connect(del, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { App* a = static_cast(user); const std::size_t k = GPOINTER_TO_SIZE( g_object_get_data(G_OBJECT(b), "udpscope-math-index")); if (k < a->math.size()) a->math.erase(a->math.begin() + k); apply_math_channels(*a); }), &app); gtk_box_append(GTK_BOX(row), del); gtk_box_append(GTK_BOX(app.math_list), row); } } GtkWidget* build_measures_panel(App& app) { GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); gtk_widget_set_margin_top(box, 6); gtk_widget_set_margin_bottom(box, 6); gtk_widget_set_margin_start(box, 6); gtk_widget_set_margin_end(box, 6); GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); GtkWidget* title = gtk_label_new("Measurements"); gtk_widget_set_halign(title, GTK_ALIGN_START); gtk_widget_set_hexpand(title, true); gtk_box_append(GTK_BOX(head), title); GtkWidget* add = gtk_button_new_from_icon_name("list-add-symbolic"); gtk_widget_add_css_class(add, "flat"); gtk_widget_set_tooltip_text(add, "add a measurement"); g_signal_connect(add, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { open_measure_dialog(*static_cast(user), -1); }), &app); gtk_box_append(GTK_BOX(head), add); gtk_box_append(GTK_BOX(box), head); app.measures_list = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); gtk_box_append(GTK_BOX(box), app.measures_list); return box; } /// The panel's frame: the title row (the mode) and the readouts. The time line is not /// part of the body, because it is there in every mode. void on_cursors_reset(GtkButton*, gpointer user); // defined below, used by the pane GtkWidget* build_cursors_panel(App& app) { GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); gtk_widget_set_margin_top(box, 6); gtk_widget_set_margin_bottom(box, 6); gtk_widget_set_margin_start(box, 6); gtk_widget_set_margin_end(box, 6); GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); GtkWidget* title = gtk_label_new("Cursors"); gtk_widget_set_halign(title, GTK_ALIGN_START); gtk_widget_set_hexpand(title, true); gtk_box_append(GTK_BOX(head), title); const char* names[5] = {kCursorModeNames[0], kCursorModeNames[1], kCursorModeNames[2], kCursorModeNames[3], nullptr}; app.cursors_mode_drop = gtk_drop_down_new_from_strings(names); gtk_widget_set_tooltip_text(app.cursors_mode_drop, "vertical: two times, shared by every row\n" "horizontal: two rulers per row\n" "both: the two together\n" "mix: the two times, read off each trace where they cross"); g_signal_connect(app.cursors_mode_drop, "notify::selected", G_CALLBACK(on_cursor_mode_changed), &app); gtk_box_append(GTK_BOX(head), app.cursors_mode_drop); // Reset lives here, beside the mode it resets, rather than in the overflow menu: it acts on this // pane's instruments and is only visible while they are on, both of which are this pane's facts. app.cursors_reset = gtk_button_new_from_icon_name("edit-clear-symbolic"); gtk_widget_add_css_class(app.cursors_reset, "flat"); gtk_widget_set_tooltip_text(app.cursors_reset, "re-place the cursors at the window's edges"); g_signal_connect(app.cursors_reset, "clicked", G_CALLBACK(on_cursors_reset), &app); gtk_box_append(GTK_BOX(head), app.cursors_reset); gtk_box_append(GTK_BOX(box), head); app.cursors_body = gtk_grid_new(); gtk_grid_set_row_spacing(GTK_GRID(app.cursors_body), 2); gtk_grid_set_column_spacing(GTK_GRID(app.cursors_body), 4); gtk_box_append(GTK_BOX(box), app.cursors_body); return box; } 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); // The pane is the cursors' readout: with them off there is nothing to read. if (app->cursors_revealer != nullptr) { gtk_revealer_set_reveal_child(GTK_REVEALER(app->cursors_revealer), on); } if (on) { app->rows->reset_cursors(); rebuild_cursors_pane(*app); update_cursors_pane(*app); } } 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 to the data it holds, and put x back on the whole time /// window. /// /// No `apply_time_window()` here, deliberately: a fit is a plot interaction and may not restate /// the time scale (see that function). The y axes are fitted to the data; x is only un-zoomed. void on_fit_clicked(GtkButton*, gpointer user) { App* app = static_cast(user); app->rows->fit_all(); app->notice = "y fitted to the data, x back to the " + format_window(app->rows->time_window()) + " window"; app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND; } /// Reset the x zoom only: the y axes keep the scales the user set. The counterpart of the other /// fit, for the common case where a zoom should be undone but the levels were placed by hand. void on_fit_x_clicked(GtkButton*, gpointer user) { App* app = static_cast(user); app->rows->fit_x(); app->notice = "x back to the " + format_window(app->rows->time_window()) + " window"; app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND; } 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); } void on_about_clicked(GtkButton*, gpointer user) { App* app = static_cast(user); if (app->menu_button != nullptr) { GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button)); if (popover != nullptr) gtk_popover_popdown(popover); } show_about(GTK_WINDOW(app->window), layout_path(app->model->config().server, app->model->config().port)); } void on_help_clicked(GtkButton*, gpointer user) { App* app = static_cast(user); if (app->menu_button != nullptr) { GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button)); if (popover != nullptr) gtk_popover_popdown(popover); } show_help(GTK_WINDOW(app->window)); } void on_shortcuts_clicked(GtkButton*, gpointer user) { App* app = static_cast(user); if (app->menu_button != nullptr) { GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button)); if (popover != nullptr) gtk_popover_popdown(popover); } show_shortcuts(GTK_WINDOW(app->window)); } // --- right-click menu ------------------------------------------------------- void on_y_auto_clicked(GtkButton* button, gpointer user) { App* app = static_cast(user); const int row = row_key(GTK_WIDGET(button)); app->rows->set_y_auto(row, true); GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(button), "udpscope-popover")); if (popover != nullptr) gtk_popover_popdown(popover); } 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); // Fitting is a per-plot gesture in the menu as it is on the screen: the two do the // same thing whatever row they were opened on (the x window is shared and the y fit // walks them all), which is what "fit" means here. GtkWidget* fitx_btn = gtk_button_new_with_label("Fit X axis"); g_object_set_data(G_OBJECT(fitx_btn), "udpscope-popover", popover); g_signal_connect(fitx_btn, "clicked", G_CALLBACK(on_fit_x_clicked), app); gtk_box_append(GTK_BOX(box), fitx_btn); GtkWidget* fitall_btn = gtk_button_new_with_label("Fit all"); g_object_set_data(G_OBJECT(fitall_btn), "udpscope-popover", popover); g_signal_connect(fitall_btn, "clicked", G_CALLBACK(on_fit_clicked), app); gtk_box_append(GTK_BOX(box), fitall_btn); gtk_box_append(GTK_BOX(box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); // 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)); schedule_layout_save(*app); 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 width and height the root `` element of `data` declares, or 0x0 when it has none. /// GMarkup rather than a string scan because the attributes may be in any order and on any /// line; it is *not* a validity check -- GMarkup is a deliberately lenient subset parser and /// accepts a double hyphen inside a comment, which is exactly the file librsvg refuses. That /// check is `xml-stripblanks` in the GResource manifest, at build time. void svg_declared_size(const char* data, gsize size, int* w, int* h) { struct Found { int w = 0; int h = 0; }; Found found; GMarkupParser parser{}; parser.start_element = [](GMarkupParseContext*, const char* element, const char** names, const char** values, gpointer user, GError**) { if (std::strcmp(element, "svg") != 0) return; Found* f = static_cast(user); for (int i = 0; names[i] != nullptr; ++i) { if (std::strcmp(names[i], "width") == 0) f->w = std::atoi(values[i]); if (std::strcmp(names[i], "height") == 0) f->h = std::atoi(values[i]); } }; GMarkupParseContext* ctx = g_markup_parse_context_new(&parser, GMarkupParseFlags(0), &found, nullptr); if (g_markup_parse_context_parse(ctx, data, static_cast(size), nullptr)) { g_markup_parse_context_end_parse(ctx, nullptr); } g_markup_parse_context_free(ctx); *w = found.w; *h = found.h; } /// One entry of the `app icons:` line: the icon is *named* (the theme resolved it, so the /// resource path was registered) and *present* (the bytes the name resolved to are really in /// the binary), at the size its own root element declares. /// /// It deliberately does not claim the file renders. `gtk_icon_theme_lookup_icon()` never /// returns NULL, its paintable's intrinsic size is the size that was asked for, and a valid /// icon, a malformed one and a name that does not exist at all all snapshot to the same /// texture node -- GTK substitutes `image-missing` and there is nothing left to compare. The /// malformed case is a build error instead; see the GResource manifest. std::string icon_report(GtkIconTheme* theme, const char* name, const char* resource) { const bool named = gtk_icon_theme_has_icon(theme, name) != FALSE; GBytes* bytes = g_resources_lookup_data(resource, G_RESOURCE_LOOKUP_FLAGS_NONE, nullptr); const bool present = bytes != nullptr; int w = 0; int h = 0; if (present) { gsize size = 0; const char* data = static_cast(g_bytes_get_data(bytes, &size)); svg_declared_size(data, size, &w, &h); g_bytes_unref(bytes); } std::string out = std::string(name) + "("; out += (named && present) ? "ok " : "MISSING "; out += std::to_string(w) + "x" + std::to_string(h) + ")"; return out; } /// Where the panel controls live, as their parents say: the three pane toggles are toolbar buttons /// and Reset is inside the cursors pane. Recorded on the first tick -- the widgets exist and the /// window is alive, neither of which is true when the dump runs -- so the dump can print a fact /// instead of asking GTK about a destroyed widget. void note_panel_controls(App& app) { if (!app.dbg_panel_controls.empty()) return; const auto in_bar = [](GtkWidget* w) { for (GtkWidget* p = w != nullptr ? gtk_widget_get_parent(w) : nullptr; p != nullptr; p = gtk_widget_get_parent(p)) { if (GTK_IS_HEADER_BAR(p)) return 1; } return 0; }; const auto in_cursors_pane = [&app](GtkWidget* w) { for (GtkWidget* p = w != nullptr ? gtk_widget_get_parent(w) : nullptr; p != nullptr; p = gtk_widget_get_parent(p)) { if (p == app.cursors_revealer) return 1; } return 0; }; // `HeaderBar::pack_end()` parents into an internal box, so the bar is a level up, not the parent. char text[256]; std::snprintf(text, sizeof(text), "cursors=%d measurements=%d math=%d in the toolbar, reset=%d in the cursors pane", in_bar(app.cursors_toggle), in_bar(app.measures_toggle), in_bar(app.math_toggle), in_cursors_pane(app.cursors_reset)); // ... and the order the bar actually shows, which is what "the menu should be last" is about. A // widget tree walked in child order *is* the layout order, so this is the reading order of the // controls the user sees -- the only way to check an arrangement without a screenshot. const std::pair named[] = { {app.trigger_toggle, "trigger"}, {app.pause_button, "pause"}, {app.cursors_toggle, "cursors"}, {app.measures_toggle, "measures"}, {app.math_toggle, "math"}, {app.sidebar_toggle, "sidebar"}, {app.menu_button, "menu"}, }; // The widget tree's child order, which *is* the visual order left to right: a box lays its children // out in the order it holds them. Since `pack_end()` puts the *first* packed furthest right, this // reads as the reverse of the pack order -- which is the whole reason the line exists, because the // pack order alone is not what the user sees. std::string order; const std::function walk = [&](GtkWidget* w) { for (const auto& [widget, name] : named) { if (w == widget) order += std::string(order.empty() ? "" : " ") + name; } for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr; c = gtk_widget_get_next_sibling(c)) { walk(c); } }; if (app.toolbar != nullptr) walk(app.toolbar); app.dbg_panel_controls = std::string(text) + "; toolbar, left to right: " + order; // ... and the icon each of them carries, asked of the theme rather than of our own // string. Four of these are drawn for this app and live in a GResource inside the // binary, which the theme only looks in because `run_ui()` adds the resource path // before building anything -- and a path that was never added fails *silently*: the // button keeps working, the layout is unchanged, and GTK draws a missing-image // placeholder no probe can see. The two app icons below come out of the same // registration, which is why a MISSING on either line is one defect. GtkIconTheme* theme = gtk_icon_theme_get_for_display(gdk_display_get_default()); for (const auto& [widget, name] : named) { const char* icon = nullptr; if (GTK_IS_BUTTON(widget)) { icon = gtk_button_get_icon_name(GTK_BUTTON(widget)); } else if (GTK_IS_MENU_BUTTON(widget)) { icon = gtk_menu_button_get_icon_name(GTK_MENU_BUTTON(widget)); } if (icon == nullptr) continue; app.dbg_toolbar_icons += std::string(app.dbg_toolbar_icons.empty() ? "" : " ") + name + "=" + icon + (gtk_icon_theme_has_icon(theme, icon) ? "(ok)" : "(MISSING)"); } // The application icon, the same two ways and for the same reason: a resource path that was // never registered is not an error, it is a missing-image placeholder. This one is read on // the first tick like the line above, because the dump runs after the window is destroyed. app.dbg_app_icons = icon_report(theme, "org.udpscope.app", "/org/udpscope/icons/scalable/apps/org.udpscope.app.svg") + " " + icon_report(theme, "org.udpscope.app-symbolic", "/org/udpscope/icons/scalable/apps/org.udpscope.app-symbolic.svg"); } /// The event-zoom sweep's two halves: a gesture zoom, then the same walk through the window /// control. The second is the tail of the countdown. constexpr int kZoomGestureSteps = 8; constexpr int kZoomWindowSteps = 8; /// Headless self-check: what `Renderer::draw_traces` would gather for row 0's first bound /// trace at the axis the frame settled on, recomputed through the same calls it makes. /// /// The report this exists for is "zooming into a held event clips the trace further and /// further from the left, while the crosshair still reads the right values there". The /// crosshair reads `x_at()`, so a symptom that only the *ink* has localises to the index /// window and the gather -- and a blank margin that grows as a fraction of the screen while /// the view narrows is the signature of a roughly constant shift in *data* space. So the /// line prints both sides of the comparison in data units and in pixels: /// `axis=` is what the frame drew, `ink=` is the first and last gathered x reconstructed the /// renderer's way (`x0() + gather`), and `gap_px=` is how many pixels of the left margin no /// geometry reaches. `gap_px` must stay at a pixel or two at every zoom. void report_event_zoom(App& a, const char* what) { if (a.rows->row_count() == 0 || a.rows->row(0).plot == nullptr) return; const RowSet::Row& r0 = a.rows->row(0); const plot::data::Series* s = nullptr; for (int i = 0; i < RowSet::kSlotsPerRow; ++i) { const std::size_t si = static_cast(i); if (r0.channel[si] < 0) continue; s = &r0.slots[si]; break; } const plot::Range axis = r0.plot->x_range(r0.axis_x); const plot::Rect axes = r0.plot->layout().axes; const double axes_w = double(axes.w); if (s == nullptr || s->size() == 0 || !(axes_w > 0.0)) { std::fprintf(stderr, "[zoom-probe] %-8s axis=[%.6f..%.6f] no trace\n", what, double(axis.min), double(axis.max)); std::fflush(stderr); return; } const plot::u64 n = s->size(); plot::u64 i0 = 0, i1 = 0; plot::u64 first = 0; plot::u64 last = n; if (s->neighbours(axis.min, i0, i1)) first = i0 > 0 ? i0 - 1 : 0; if (s->neighbours(axis.max, i0, i1)) last = std::min(n, i1 + 1); if (last <= first) { std::fprintf(stderr, "[zoom-probe] %-8s axis=[%.6f..%.6f] empty window [%llu..%llu)\n", what, double(axis.min), double(axis.max), (unsigned long long)first, (unsigned long long)last); std::fflush(stderr); return; } // `dense_threshold_spp` is the library's own default (64): above it the renderer takes a // pyramid level, below it raw samples, and which side of the gate a step lands on is part // of what this line reports. const double spp = double(last - first) / axes_w; const plot::u32 level = spp >= 64.0 ? s->pyramid().level_for(n, spp) : 0u; std::vector xy; s->gather(level, first, last, xy); double ink_lo = 0.0, ink_hi = 0.0; if (xy.size() >= 2) { ink_lo = s->x0() + double(xy[0]); ink_hi = s->x0() + double(xy[xy.size() - 2]); } // The renderer's own increasing-suffix filter, counted rather than applied: it drops every // point that is not strictly older than the one kept after it, which for a *run* of equal x // keeps the last of the run. The stamps here are f32, so once the sample spacing falls below // the f32 step at the stream's magnitude the gathered x has such runs -- and `kept` is how // many distinct positions the polyline is left with. A `kept` far below `pts` is a staircase, // and a `kept` of a handful is a trace with nothing to draw. std::size_t kept = 0; double worst_back = 0.0; // the largest backwards step, in data units: non-monotonic gather { double next = std::numeric_limits::infinity(); for (std::size_t k = xy.size() / 2; k > 0; --k) { const double x = double(xy[2 * (k - 1)]) + s->x0(); if (!(x < next)) { worst_back = std::max(worst_back, x - next); continue; } next = x; ++kept; } } const double span = axis.span() > 0.0 ? axis.span() : 1.0; std::fprintf(stderr, "[zoom-probe] %-8s axis=[%.6f..%.6f] span=%.6g n=%llu idx=[%llu..%llu) spp=%.1f " "level=%u pts=%zu kept=%zu back=%.3g ink=[%.6f..%.6f] x_at_first=%.6f " "gap_px=%.1f end_px=%.1f rect=[%g,%g,%gx%g]\n", what, double(axis.min), double(axis.max), axis.span(), (unsigned long long)n, (unsigned long long)first, (unsigned long long)last, spp, level, xy.size() / 2, kept, worst_back, ink_lo, ink_hi, s->x_at(first), (ink_lo - axis.min) / span * axes_w, (axis.max - ink_hi) / span * axes_w, double(axes.x), double(axes.y), double(axes.w), double(axes.h)); std::fflush(stderr); } // --- status popover updater ------------------------------------------------- /// Sets the popover's seven value labels. Never creates a widget: `popover_rebuilds` exists to /// assert that, because the popover is visible when this runs and a rebuild would relayout it. void update_status_popover(App& app, const udpscope::StatusSnapshot& s) { if (app.popover_label[4] == nullptr) return; char buf[256]; std::snprintf(buf, sizeof(buf), "%s, %s", s.connected ? "connected" : "waiting", s.have_config ? "configured" : "no config"); gtk_label_set_text(GTK_LABEL(app.popover_label[4]), buf); std::snprintf(buf, sizeof(buf), "packets %llu · frames %llu · gaps %llu", static_cast(s.packets), static_cast(s.frames), static_cast(s.gaps)); gtk_label_set_text(GTK_LABEL(app.popover_label[5]), buf); std::snprintf(buf, sizeof(buf), "%zu signals · %zu channels", s.signals, s.channels); gtk_label_set_text(GTK_LABEL(app.popover_label[6]), buf); std::snprintf(buf, sizeof(buf), "%.1f MB · %.0f s span · time x%llu", s.buffer_mb, s.span_s, static_cast(s.time_stride)); gtk_label_set_text(GTK_LABEL(app.popover_label[7]), buf); const std::string stored = udpscope::format_rate(s.stored_rate_hz); const std::string wire = udpscope::format_rate(s.declared_rate_hz); std::snprintf(buf, sizeof(buf), "stored %s (wire %s)", stored.c_str(), wire.c_str()); gtk_label_set_text(GTK_LABEL(app.popover_label[8]), buf); std::snprintf(buf, sizeof(buf), "%d traces · %zu samples", s.plot1_traces, s.plot1_samples); gtk_label_set_text(GTK_LABEL(app.popover_label[9]), buf); std::snprintf(buf, sizeof(buf), "%d", s.reconnects); gtk_label_set_text(GTK_LABEL(app.popover_label[10]), buf); } // --- the frame tick --------------------------------------------------------- gboolean tick(gpointer user) { App* app = static_cast(user); if (app->window == nullptr) return G_SOURCE_REMOVE; note_panel_controls(*app); ScopeModel& model = *app->model; const gint64 tick_start_us = g_get_monotonic_time(); // The paint probe's clock, claimed on the first tick that finds one: a widget has no // frame clock until it is realized, and the area is realized on its first draw. if (app->dbg_clock == nullptr && app->area_widget != nullptr) { if (GdkFrameClock* fc = gtk_widget_get_frame_clock(app->area_widget)) { app->dbg_clock = fc; g_signal_connect(fc, "before-paint", G_CALLBACK(on_before_paint), app); g_signal_connect(fc, "after-paint", G_CALLBACK(on_after_paint), app); } } if (app->relayout_pending) { app->relayout_pending = false; relayout_areas(*app); } // Header geometry, once a second: the window control must be a fixed, small // widget beside the title whatever the bar's width is (it used to expand through // the title's centre box, i.e. window_button_w tracked the window width). if (app->window_button != nullptr) { static gint64 next_header_log = 0; const gint64 now = g_get_monotonic_time(); if (now >= next_header_log) { next_header_log = now + G_TIME_SPAN_SECOND; app->dbg_win_btn_w = gtk_widget_get_width(app->window_button); app->dbg_win_label_w = gtk_widget_get_width(app->window_label); app->dbg_bar_w = gtk_widget_get_width(app->window); std::fprintf(stderr, "[udpscope] header: window_button_w=%d label_w=%d bar_w=%d\n", app->dbg_win_btn_w, app->dbg_win_label_w, app->dbg_bar_w); std::fflush(stderr); } } if (model.generation() != app->signal_generation) rebuild_signal_panels(*app); // A gesture moves the *plot's* cursor; adopting it here is what makes the two // vertical markers one time across every row, and what the pane then reads. app->rows->update_time_labels(); // Which row's controller holds a cursor: the only row whose plots are worth reading back // (`cursor(id).x` is the free pointer position while a drag runs and the snapped value // otherwise, so reading outside a gesture reads our own echo). int cursor_drag_row = -1; if (app->area != nullptr) { for (int i = 0; i < app->rows->row_count(); ++i) { plot::input::Controller* c = app->area->row_controller(static_cast(i)); if (c != nullptr && c->grabbed_cursor() != plot::kInvalid) { cursor_drag_row = i; break; } } } app->rows->sync_cursors(cursor_drag_row); update_cursors_pane(*app); update_measures(*app, false); // The first CONFIG names the signals, which is what the persisted layout is // written against: apply it once, here, and never again (a later CONFIG only // renames or renumbers; the user's layout survives those untouched). if (!app->layout_loaded && !model.signals().empty()) { app->layout_loaded = true; LayoutState st; if (load_layout(layout_path(app->source_host, app->source_port), st)) { apply_layout(*app, st); std::fprintf(stderr, "[udpscope] layout loaded from %s\n", layout_path(app->source_host, app->source_port).c_str()); std::fflush(stderr); } } // What the display shows, decided once for the whole frame: the producer can open or // close a capture between any two reads of the model, and the bind, the epoch, the pin // and the status line all have to describe the same picture. const DisplayMode display = model.display_mode(); app->dbg_display = display; // the paint probe names the state rather than re-reading it const double cap_edge = model.capture_edge(); const bool new_capture = display == DisplayMode::Capturing && (!app->capturing || cap_edge != app->capture_edge); app->capturing = display == DisplayMode::Capturing; if (new_capture) app->capture_edge = cap_edge; { // Serialise against the acquisition thread's CONFIG handling and against the // UI's own structural changes: the bind takes shared handles into the live // store, and acquire() reads it. std::lock_guard lock(model.mutex()); // 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); } // The gesture probe's verdicts, one frame after the ask and after this frame's clamp: a // `g_timeout` was the first version and it drifted past the paused/resumed boundary, so the // "zoom in" verdict read the *live* window and called a working fix broken. if (app->dbg_gesture_verdict != 0 && app->rows->row_count() > 0) { const int was_verdict = app->dbg_gesture_verdict; { const RowSet::Row& r0 = app->rows->row(0); const plot::Range after = app->rows->view_range(); if (app->dbg_gesture_verdict == 1) { const bool moved = std::abs(after.min - app->dbg_pan_asked_lo) < 1e-9 && std::abs(after.max - app->dbg_pan_asked_hi) < 1e-9; std::fprintf(stderr, "[pan-probe] after=[%.6f..%.6f] held=%d asked=[%.6f..%.6f]\n", after.min, after.max, moved ? 0 : 1, app->dbg_pan_asked_lo, app->dbg_pan_asked_hi); // The next gesture: a zoom-out ten times the current span, anchored where a wheel zoom // about the right edge would put it. const double span = after.span(); if (span > 0.0) { const plot::Range zoomed{after.max - 10.0 * span, after.max}; app->dbg_pan_asked_lo = zoomed.min; app->dbg_pan_asked_hi = zoomed.max; const_cast(r0).plot->set_x_range(r0.axis_x, zoomed); // Judged on the next frame's tick: the member's range is only pushed by the layout. app->dbg_gesture_verdict = 2; } } else if (app->dbg_gesture_verdict == 2) { const double window = app->rows->time_window(); std::fprintf(stderr, "[pan-probe] zoom asked=[%.6f..%.6f] after=[%.6f..%.6f] span=%.6f " "window=%.6f limited=%d\n", app->dbg_pan_asked_lo, app->dbg_pan_asked_hi, after.min, after.max, after.span(), window, after.span() <= window * (1.0 + 1e-9) ? 1 : 0); // The last gesture: a window a quarter of what is on screen, about its centre. A pinned // view must allow it -- "not outside the event" is not "not readable". const double span = after.span(); if (span > 0.0 && r0.plot != nullptr) { const double centre = 0.5 * (after.min + after.max); const plot::Range zoomed{centre - 0.125 * span, centre + 0.125 * span}; app->dbg_pan_asked_lo = zoomed.min; app->dbg_pan_asked_hi = zoomed.max; const_cast(r0).plot->set_x_range(r0.axis_x, zoomed); app->dbg_gesture_verdict = 3; } } else if (app->dbg_gesture_verdict == 3) { // The zoom-in ask was a quarter of the span: allowed means the view actually narrowed. const double asked = app->dbg_pan_asked_hi - app->dbg_pan_asked_lo; const plot::Range member = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{0.0, 0.0}; std::fprintf(stderr, "[pan-probe] zoom-in asked=[%.6f..%.6f] group=[%.6f..%.6f] member=[%.6f..%.6f]" " span=%.6f allowed=%d\n", app->dbg_pan_asked_lo, app->dbg_pan_asked_hi, after.min, after.max, member.min, member.max, after.span(), after.span() <= 2.0 * asked ? 1 : 0); } std::fflush(stderr); // Cleared only if this frame did not hand the probe the *next* gesture: a report that asks // for the next one must leave it armed for the following frame. app->dbg_gesture_verdict = app->dbg_gesture_verdict == was_verdict ? 0 : app->dbg_gesture_verdict; app->dbg_gesture_stage = 0; } } // Nothing to do about the rows' x windows: they are one window, the x link group's. // A gesture changes the row it landed on, `Figure::layout()` adopts that change into // the group and pushes it to the others, so the "which row acted" search this replaced // (and the per-row pan/zoom propagation behind it) has no subject any more. // While an event is on screen -- held, or still being captured -- the window is pinned // to [-pre, +post]; clamp_to_data would fight that pin whenever the bound series still // reports the acquisition buffer's raw coordinates, dragging the view along the ring. // Clamping is a live-view concern only, and a capture is live data on a pinned axis, // which is exactly the case it must not touch. if (app->event_held && !app->capturing) { // A held event is navigable *within its data*. `pin_event_window()` framed the axis from the // snapshot's recorded `pre_s`/`post_s` -- the *request* -- and a capture shorter than it (a // ring that had not reached back a full pre-trigger window, or a trimmed one) left the // shortfall as an empty margin, exactly as the pause did. The pin is the snapshot's own stamps // here; the framing's split is what the marker at x = 0 already says. double lo = 0.0; double hi = 0.0; if (app->rows->event_data_window(lo, hi)) app->rows->set_data_pin(lo, hi); app->rows->hold_pinned_window(); } else if (display == DisplayMode::Paused || app->capturing) { // Pinned: the event (or the frozen pause), not a navigation surface. `clamp_to_data()` is // the live-mode half of the same rule -- a live view must stay on the data -- and the two // are complements, which is why this is one branch rather than two conditions. app->rows->hold_pinned_window(); } else { app->rows->clamp_to_data(); } // The event-zoom probe's step, taken here because the pin and the clamp above are what // decide the axis the renderer is about to read: a `g_timeout` would report the ask. if (app->dbg_zoom_steps > 0 && app->rows->row_count() > 0 && app->rows->row(0).plot != nullptr) { char what[24]; // Two sweeps, because there are two ways to zoom the time base and they take different // paths: the second half drives the *window control* (`apply_time_window`, which unpins // and re-arms the live follow before the pin is restated), the first half a gesture. const bool via_window = app->dbg_zoom_steps <= kZoomWindowSteps; std::snprintf(what, sizeof what, "%s/x%d", via_window ? "win" : "ges", 1 << app->dbg_zoom_step_no); report_event_zoom(*app, what); --app->dbg_zoom_steps; ++app->dbg_zoom_step_no; const RowSet::Row& r0 = app->rows->row(0); if (app->dbg_zoom_steps == kZoomWindowSteps) { // Hand the window sweep a full event again, so it starts where the gesture sweep did. app->dbg_zoom_step_no = 0; double lo = 0.0, hi = 0.0; if (app->rows->event_data_window(lo, hi)) apply_time_window(*app, hi - lo); } else if (via_window) { apply_time_window(*app, std::max(1.0e-4, app->rows->time_window() * 0.5)); } else { // Halve the window about its **centre**, so the left edge walks *into* the data. An // anchor that keeps the left edge where it is keeps it on the series' own first sample, // where no index window can be short and the margin cannot be empty -- which is how the // first version of this probe reported `gap_px=0.0` at every zoom on a view that was // visibly clipped. const plot::Range now = r0.plot->x_range(r0.axis_x); const double c = 0.5 * (now.min + now.max); r0.plot->set_x_range(r0.axis_x, plot::Range{c - 0.25 * now.span(), c + 0.25 * now.span()}); } } // Curator: the trigger's arm state can change under us (single mode disarms // itself), so the button and the status line read the published atomics. const bool trigger_on = model.trigger_enabled(); if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app->trigger_toggle)) != trigger_on) { gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->trigger_toggle), trigger_on); } const bool armed = model.trigger_armed().load(std::memory_order_relaxed); gtk_button_set_label(GTK_BUTTON(app->arm_button), armed ? "Disarm" : "Arm"); if (new_capture) { // The edge has been accepted, which means the pre window was already full, so the // whole event window can be framed now and the post side filled in view. // // The split is the *requested* one -- the same `post_span_s()` the producer will cut // with -- because there is no snapshot to read it off yet, and the marker has to sit // where that cut will put it rather than where a different rounding would. double span = 0.0; double post = 0.0; { std::lock_guard lock(model.mutex()); span = std::max(1.0e-3, model.event_span()); post = model.trigger().post_span_s(span); } app->event_held = false; app->rows->pin_event_window(span - post, post); app->rows->set_trigger_marker(0.0, true); sync_window_display(*app, span); // The user saw the event at its edge, so that is when the counter starts. app->timed_edge = cap_edge; app->last_latch_us = g_get_monotonic_time(); } const std::uint64_t latches = model.trigger_latches().load(std::memory_order_relaxed); if (latches != app->seen_latches) { app->seen_latches = latches; const double edge = model.trigger_time().load(std::memory_order_relaxed); // A capture the UI watched fill already started this clock at its edge; only one that // opened and closed inside a single frame -- never reported pending, so never // displayed as a capture -- starts it here. if (edge != app->timed_edge) { app->timed_edge = edge; app->last_latch_us = g_get_monotonic_time(); } } // Framing the held event is keyed on *which snapshot was published*, not on the latch // count: a Disarm during a capture publishes what the capture got to without an event // having fired, and that partial has to be framed and held exactly like one that ran // to term. The latch count stays the answer to "how many events fired", which is what // the probes assert on. const std::uint64_t seq = model.event_seq(); // The frame that *frames* the snapshot is the one that consumes its sequence number, // not merely the first frame to see it. Two things can bind something else while a new // snapshot is already published: // // - A publication and the *next* edge land in the same frame: the display follows the // newer of the two and the capture above has framed its own window. // - `display` is read before the lock the producer publishes under, and // `trigger_pending` is only lowered at the end of the producer's packet loop -- long // after `publish_event()`. So a tick can read `Capturing`, block on the mutex while // the snapshot is published and the flag lowered, and get here bound to a capture // that no longer exists. // // Consuming the number in either case dropped the pin permanently: the axis kept the // capture's *requested* [-pre, +post] while the snapshot was shorter, and `event_held` // stayed false, so `clamp_to_data()` was free to drag the frozen view off it. Leaving // it unconsumed costs nothing -- the pin is simply owed until a frame is bound to the // snapshot, and by then it is still the newest one. if (seq != app->seen_event_seq && display == DisplayMode::Held) { app->seen_event_seq = seq; app->event_held = true; // 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; if (bound > 0 && empty < bound) app->dbg_ever_bound = true; // A rebuild drops every ring's history, so the frames after one draw an empty plot until the // producer has refilled the window -- which is what "the plot flickers as if there were no // data" looks like. `dbg_blank_frames` cannot see it: it asks whether the ring holds samples // the view has not got, and a rebuilt ring holds none either. The generation is the only // thing that says a rebuild happened, so each change is named here with what asked for it. const unsigned gen = model.generation(); if (app->dbg_generation_seen && gen != app->dbg_generation_prev) { ++app->dbg_generation_changes; if (app->dbg_generation_changes <= 40) { std::fprintf(stderr, "[gen] frame %d generation %u -> %u (bound=%d empty=%d)\n", app->dbg_axes_frames, app->dbg_generation_prev, gen, bound, empty); } } app->dbg_generation_prev = gen; app->dbg_generation_seen = true; // And the renderer's blank: the rings hold samples, yet the frame staged nothing. if (bound > 0 && empty == 0 && app->dbg_seg_last == 0) ++app->dbg_render_blank_frames; } // The gutter the rows need, measured from the frame just laid out, imposed as a // floor that only grows: without it the axes' left edge follows the y tick // labels and the x axis twitches by a few pixels as the scale changes. double gutter = 0.0; for (int i = 0; i < app->rows->row_count(); ++i) { const RowSet::Row& r = app->rows->row(i); if (r.plot) gutter = std::max(gutter, double(r.plot->layout().y_axis_width)); } app->rows->note_y_gutter(gutter); app->rows->apply_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)); // What the side column *demands*, not what it got. A GTK box's minimum width is its // children's, a `GtkLabel`'s minimum is its text, and a minimum larger than the screen is // honoured by growing the toplevel -- which hands the plot an allocation wider than the window // and pushes most of the trace off the right-hand edge. `axes.w` sees the consequence a frame // later and cannot name the cause, so the demand is read here, from the panes whose contents // the frame tick rewrites. { const auto min_w = [](GtkWidget* w) { if (w == nullptr || !GTK_IS_WIDGET(w)) return 0; int min = 0; int nat = 0; gtk_widget_measure(w, GTK_ORIENTATION_HORIZONTAL, -1, &min, &nat, nullptr, nullptr); return min; }; const int side = min_w(app->side_box); const int meas = min_w(app->measures_list); const int curs = min_w(app->cursors_body); const int worst = std::max(side, std::max(meas, curs)); if (worst > app->dbg_min_w_worst) { const int was = app->dbg_min_w_worst; app->dbg_min_w_worst = worst; if (was > 0 && worst - was > 32 && app->dbg_min_w_reports < 40) { ++app->dbg_min_w_reports; std::fprintf(stderr, "[width-probe] frame %d min width grew %d -> %d (side=%d measures=%d " "cursors=%d)\n", app->dbg_axes_frames, was, worst, side, meas, curs); } } } if (plot::Plot* p0 = app->rows->row(0).plot; p0 != nullptr && p0->viewport().w > 100.0f) { // steady state only const int ax = static_cast(p0->layout().axes.x); const int gw = static_cast(p0->layout().y_axis_width); app->dbg_axes_x_min = std::min(app->dbg_axes_x_min, ax); app->dbg_axes_x_max = std::max(app->dbg_axes_x_max, ax); app->dbg_gutter_min = std::min(app->dbg_gutter_min, gw); app->dbg_gutter_max = std::max(app->dbg_gutter_max, gw); const int aw = static_cast(p0->layout().axes.w); app->dbg_axes_w_min = std::min(app->dbg_axes_w_min, aw); app->dbg_axes_w_max = std::max(app->dbg_axes_w_max, aw); // A min..max range alone cannot tell the twitch bug (a width that changes every // frame) from one resize that happened once and stayed, which look identical in a // summary and need opposite responses. Count the changes and keep the last one's // frame, and whether the side panel was up for it -- hiding that column is the one // legitimate reason the plot's width moves by itself. if (app->dbg_axes_frames > 0 && aw != app->dbg_axes_w_prev) { ++app->dbg_axes_w_changes; app->dbg_axes_w_change_frame = app->dbg_axes_frames; app->dbg_axes_w_change_side = gtk_widget_get_visible(app->side_box) ? 1 : 0; // Each change, not just the last: a summary says the plot's width moved and cannot say // *what* moved it, and a width that moves by itself is the "the plot flickers" report's // first candidate. Named here beside the widths that can cause it -- the side column and // the two panes whose contents the frame tick rewrites -- so the culprit is read rather // than guessed. Capped, because the twitch bug would otherwise fill the log. if (app->dbg_axes_w_changes <= 40) { int side_w = 0; int meas_w = 0; int curs_w = 0; if (app->side_box != nullptr) side_w = gtk_widget_get_width(app->side_box); if (app->measures_list != nullptr) meas_w = gtk_widget_get_width(app->measures_list); if (app->cursors_body != nullptr) curs_w = gtk_widget_get_width(app->cursors_body); std::fprintf(stderr, "[width-probe] frame %d axes.w %d -> %d viewport.w %d side=%dx(vis=%d) " "measures_w=%d cursors_w=%d\n", app->dbg_axes_frames, app->dbg_axes_w_prev, aw, static_cast(p0->viewport().w), side_w, app->dbg_axes_w_change_side, meas_w, curs_w); // A step of this size is not the gutter settling, so name what demanded it. if (std::abs(aw - app->dbg_axes_w_prev) > 100) { report_wide_widgets(app->window, 900, 1); } } } app->dbg_axes_w_prev = aw; app->dbg_vp_w_min = std::min(app->dbg_vp_w_min, static_cast(p0->viewport().w)); app->dbg_vp_w_max = std::max(app->dbg_vp_w_max, static_cast(p0->viewport().w)); const int nt = static_cast(p0->x_axis(0).ticks.size()); app->dbg_xtick_min = std::min(app->dbg_xtick_min, nt); app->dbg_xtick_max = std::max(app->dbg_xtick_max, nt); if (app->area != nullptr) { const float sc = app->area->device_scale(); app->dbg_scale_min = std::min(app->dbg_scale_min, sc); app->dbg_scale_max = std::max(app->dbg_scale_max, sc); } // The rows are members of one x link group, so they hold one window; a row that // disagrees by more than a pixel of data means the group did not reach it. const plot::Range x0 = p0->x_range(app->rows->row(0).axis_x); const double tol = std::max(1.0e-12, x0.span() / std::max(1.0, double(aw))); for (int i = 1; i < app->rows->row_count(); ++i) { const RowSet::Row& r = app->rows->row(i); if (r.plot == nullptr) continue; const plot::Range xi = r.plot->x_range(r.axis_x); if (std::fabs(xi.min - x0.min) > tol || std::fabs(xi.max - x0.max) > tol) { ++app->dbg_x_desync; break; } } ++app->dbg_axes_frames; } // Once a second: how many samples each active channel has *stored*, and the rate that // implies. Channels are never decimated against each other (`stride=1`), so with // `decim=1` a stored rate far below the declared one is the datasource's own wire rate; // with `decim=N` it is the wire rate over N, the resampling the window asked for. This // line is the receipt for both. static gint64 next_channels = 0; const gint64 now_c = g_get_monotonic_time(); // Per-channel rate computed only on the measuring tick; the detail tooltip on the label // now carries the full status string instead of a per-channel breakdown. if (app->last_stored.size() != model.channel_count()) { // Seed new entries with the current count: a fresh entry must not be counted // as a delta from zero. app->last_stored.assign(model.channel_count(), 0); for (std::size_t c = 0; c < model.channel_count() && c < app->last_stored.size(); ++c) { app->last_stored[c] = model.live().values(c).produced(); } } if (now_c >= next_channels) { const double dt_s = std::min(10.0, double(now_c - app->last_channels_meas) / 1.0e6); next_channels = now_c + G_TIME_SPAN_SECOND; app->last_channels_meas = now_c; // Once a second, alongside the rate measurement: is the stream arriving at all? note_starvation(*app); std::lock_guard lock(model.mutex()); app->stored_rate.assign(model.channel_count(), 0.0); app->declared_rate.assign(model.channel_count(), 0.0); for (std::size_t c = 0; c < model.channel_count(); ++c) { const ChannelInfo& info = model.channels()[c]; const u64 stored = model.live().values(c).produced(); // `produced()` restarts at 0 whenever the buffer is rebuilt (a CONFIG, an // enable/disable, a calibration change), so the delta can run backwards. As // unsigned that wrapped to ~2^64 and the line reported a rate near 1.8e13 MS/s // -- a number with no relation to the stream, which is actively misleading when // this line is the evidence for a timing complaint. A rebuild has no measurable // rate, so report none. const u64 prev = c < app->last_stored.size() ? app->last_stored[static_cast(c)] : stored; const double per_s = stored >= prev && dt_s > 0.0 ? double(stored - prev) / dt_s : 0.0; if (c < app->last_stored.size()) app->last_stored[static_cast(c)] = stored; const double declared = info.dt > 0.0 ? 1.0 / info.dt : 0.0; app->stored_rate[c] = per_s; app->declared_rate[c] = declared; const ChannelBuffer& buf = model.live().channel(c); const plot::Range y = buf.size() > 0 ? buf.y_bounds() : plot::Range{0.0, 0.0}; std::fprintf(stderr, "[udpscope] ch %zu '%s' stored=%llu (%.4g MS/s, declared %.4g S/s) " "elements=%llu stride=%llu decim=%llu published=%llu y=[%.6g, %.6g]\n", c, info.name.c_str(), static_cast(stored), per_s / 1e6, declared, static_cast(info.elements), static_cast(model.partition().stride[c]), static_cast(model.partition().time_stride), static_cast(buf.size()), double(y.min), double(y.max)); } std::fflush(stderr); } // Status line. The model's lists and partition are rebuilt on the producer // thread, so every read of them takes the model mutex -- narrowly: GTK calls and // model setters below must stay outside it (a setter takes the same lock, and a // button toggle emits a handler that calls one). std::size_t signal_count = 0; std::size_t channel_count = 0; double span_s = 0.0; std::uint64_t time_stride = 1; double trigger_level = 0.0; double trigger_value = 0.0; bool have_trigger_value = false; std::string trigger_name; { std::lock_guard lock(model.mutex()); signal_count = model.signals().size(); channel_count = model.channel_count(); span_s = model.partition().span_s; time_stride = model.partition().time_stride; const int chan = model.trigger().channel; trigger_level = model.trigger().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; if (!app->stored_rate.empty()) { double lo = app->stored_rate.front(); double hi = lo; for (const double r : app->stored_rate) { lo = std::min(lo, r); hi = std::max(hi, r); } // Within a percent is the same rate measured over a one-second window, not two rates. rate = hi - lo <= 0.01 * hi ? format_rate(hi) : format_rate(lo) + ".." + format_rate(hi); } // Derive the one-word state for the glance line from the same conditions the mode prose // branches on, so the two cannot disagree. const char* state_word = "live"; if (display == DisplayMode::Capturing) { state_word = "capturing"; } else if (display == DisplayMode::Held) { state_word = "event held"; } else if (trigger_on && !armed) { state_word = "disarmed"; } else if (display == DisplayMode::Waiting) { state_word = "armed"; } else if (paused) { state_word = "paused"; } udpscope::StatusSnapshot snap; snap.state_detail = mode; snap.state_word = state_word; snap.connected = st.connected.load(); snap.have_config = st.have_config.load(); snap.trigger_on = trigger_on; snap.rate_text = rate; snap.stored_rate_hz = app->stored_rate.empty() ? 0.0 : app->stored_rate.front(); snap.declared_rate_hz = app->declared_rate.empty() ? 0.0 : app->declared_rate.front(); snap.window_text = format_window(app->rows->time_window()); snap.packets = st.packets.load(); snap.frames = st.frames.load(); snap.gaps = st.gaps.load(); snap.signals = signal_count; snap.channels = channel_count; snap.plot1_traces = bound; snap.plot1_samples = samples; snap.buffer_mb = double(model.config().buffer_mb); snap.span_s = span_s; snap.time_stride = time_stride; snap.have_event = app->last_latch_us != 0; snap.since_event_s = snap.have_event ? double(g_get_monotonic_time() - app->last_latch_us) / 1.0e6 : 0.0; snap.reconnects = static_cast(st.reconnects.load(std::memory_order_relaxed)); const std::string glance = udpscope::format_glance(snap); app->dbg_glance_last = glance; app->status_detail = udpscope::format_detail(snap); if (app->status_detail.size() > app->status_detail_max) { app->status_detail_max = app->status_detail.size(); } if (glance.size() > app->glance_max_chars) app->glance_max_chars = glance.size(); if (!app->notice.empty() && g_get_monotonic_time() < app->notice_until_us) { const std::string with_notice = app->notice + " | " + glance; 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), glance.c_str()); } gtk_widget_set_tooltip_text(app->status_label, app->status_detail.c_str()); if (app->status_popover != nullptr && gtk_widget_get_visible(app->status_popover) == TRUE) { update_status_popover(*app, snap); } 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, 3600.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; } /// Recognises the "the source is serving someone else" state and says so. /// /// A UDPStreamer over unicast keeps one session: a second client's CONNECT displaces the /// first, the first sees `silence_timeout_s` of no data and reconnects, and the two take /// turns. Nothing in the client can give both of them the stream -- the wire has one /// destination -- so the useful thing is to name the situation: a reconnect with no frames /// delivered in the same second is that fight, or a server that keeps dropping us, and /// neither is visible in an empty plot. /// /// Called once a second from the frame tick. void note_starvation(App& app) { StreamStats& st = app.model->stats(); const std::uint64_t frames = st.frames.load(std::memory_order_relaxed); const std::uint64_t reconnects = st.reconnects.load(std::memory_order_relaxed); const bool connected = st.connected.load(std::memory_order_relaxed); // No `have_config` here: the losing client of a single-client source need never see a // CONFIG at all (the server answered the first one), and "connected, kicked, nothing // arriving" is the state worth naming either way. // "Connected, but nothing is arriving" is a *rate* fact, not a reconnect one: a source that // answers CONNECT and then goes quiet -- the unicast case where another client took the session -- // never reconnects while it is silent, so requiring a reconnect made the notice invisible in // exactly the state it describes. What it takes is `frames` not moving across three consecutive // one-second samples: one sample would call a source slower than the sample interval starved, and // three still clears the moment anything arrives. const bool idle = app.starve_have_prev && frames == app.starve_frames; app.starve_still = (connected && !app.acq->paused() && idle) ? app.starve_still + 1 : 0; app.starve_frames = frames; app.starve_have_prev = true; if (app.starve_still < 3) { app.starve_reported = false; // **A `return`, not just a branch.** The notice and its line used to sit outside this test, so // they were re-armed every frame and the status bar carried "no data is arriving" while the // stream was arriving perfectly well. return; } app.notice = "connected, but no data is arriving: another reader may hold the source (a unicast " "source serves one client at a time) -- use --multicast to watch from several UIs"; app.notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND; if (!app.starve_reported) { app.starve_reported = true; std::fprintf(stderr, "[udpscope] starved: connected (config=%d), reconnects=%llu, no new frames " "in the last second -- a unicast source serves one client at a time\n", st.have_config.load(std::memory_order_relaxed) ? 1 : 0, static_cast(reconnects)); std::fflush(stderr); } } /// 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 one place a key turns into an action. Toggles go through /// gtk_toggle_button_set_active(), which emits ::toggled and so runs exactly the handler a /// click would; the non-toggles call their click handlers with a null button, which they /// already ignore. Split out of the key handler so the probe can fire a row directly. void run_app_action(App& app, AppAction action) { const auto toggle = [](GtkWidget* button) { if (button == nullptr) return; const gboolean now = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(button)); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(button), now == TRUE ? FALSE : TRUE); }; switch (action) { case AppAction::PauseToggle: toggle(app.pause_button); break; case AppAction::TriggerArm: on_arm_clicked(nullptr, &app); break; case AppAction::TriggerPanel: toggle(app.trigger_toggle); break; case AppAction::CursorsPane: toggle(app.cursors_toggle); break; case AppAction::MeasuresPane: toggle(app.measures_toggle); break; case AppAction::MathPane: toggle(app.math_toggle); break; case AppAction::LayoutEditor: toggle(app.layout_toggle); break; case AppAction::SidePanel: toggle(app.sidebar_toggle); break; case AppAction::Export: on_export_clicked(nullptr, &app); break; case AppAction::Help: show_help(GTK_WINDOW(app.window)); break; case AppAction::Shortcuts: show_shortcuts(GTK_WINDOW(app.window)); break; case AppAction::Quit: if (app.window != nullptr) gtk_window_close(GTK_WINDOW(app.window)); break; } } /// Looks a key up in the accel table and runs it. Refuses outright while a GtkEditable has /// focus: every bare-letter accel is also a character somebody may be typing. gboolean on_app_key_pressed(GtkEventControllerKey*, guint keyval, guint, GdkModifierType state, gpointer user) { App* app = static_cast(user); if (app->window == nullptr) return FALSE; GtkWidget* focus = gtk_window_get_focus(GTK_WINDOW(app->window)); if (focus != nullptr && GTK_IS_EDITABLE(focus)) return FALSE; const Accel* accel = find_accel(keyval, static_cast(state)); if (accel == nullptr) return FALSE; run_app_action(*app, accel->action); return TRUE; } /// 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(); app.toolbar = bar; gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), labeled_box_fixed("window", build_window_control(app))); refresh_window_limits(app); // The end group reads, left to right: cursors, measurements, math, menu -- with the // sidebar toggle to their left. `pack_end()` lays children out right to left (the first // packed is the *rightmost*), so they are packed in the reverse of that order, which is // what puts the menu last on screen. The trigger and the pause are *start*-packed above, // beside the window control: their event window is read against the span on screen, so // they belong with the control that sets it. // --- the overflow menu ---------------------------------------------------- // Rows rather than plain buttons: the label at the left, the shortcut at the right and a // check on the rows that toggle something, which is the shape every GNOME menu has and the // reason this popover no longer reads as a stack of dialogs (see menu_row(), and the // `.udpscope-menu` rules in install_css()). It stays hand-built instead of a `GMenu` model // because these act on the `RowSet` directly and two of them are toggles whose state the // rest of the UI reads, so an action model would only add a second copy of that state. // // The order is a GNOME menu's: what you do to the *view*, then the data, then the help // trio. Nothing here needs a separator of its own -- the groups are the separators. GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); gtk_widget_set_margin_top(menu_box, 6); gtk_widget_set_margin_bottom(menu_box, 6); gtk_widget_set_margin_start(menu_box, 6); gtk_widget_set_margin_end(menu_box, 6); GtkWidget* fit = menu_button("Fit to data", "F"); gtk_widget_set_tooltip_text(fit, "auto-scale every axis and frame the buffered data"); g_signal_connect(fit, "clicked", G_CALLBACK(on_fit_clicked), &app); gtk_box_append(GTK_BOX(menu_box), fit); app.layout_toggle = menu_toggle("Layout editor", "L", false); gtk_widget_set_tooltip_text(app.layout_toggle, "add and remove plot rows in place: \u00d7 on a row removes " "it, + between rows inserts one there"); g_signal_connect(app.layout_toggle, "toggled", G_CALLBACK(on_layout_editor_toggled), &app); gtk_box_append(GTK_BOX(menu_box), app.layout_toggle); app.signal_toggle = menu_toggle("Signal list", nullptr, true); gtk_widget_set_tooltip_text(app.signal_toggle, "show or hide the signal list"); g_signal_connect(app.signal_toggle, "toggled", G_CALLBACK(on_signals_toggled), &app); gtk_box_append(GTK_BOX(menu_box), app.signal_toggle); gtk_box_append(GTK_BOX(menu_box), menu_separator()); GtkWidget* export_btn = menu_button("Export visible range...", "Ctrl+E"); gtk_widget_set_tooltip_text(export_btn, "write every plotted signal over the visible x range, at the " "stored sample rate, to a .npy or .csv file"); g_signal_connect(export_btn, "clicked", G_CALLBACK(on_export_clicked), &app); gtk_box_append(GTK_BOX(menu_box), export_btn); gtk_box_append(GTK_BOX(menu_box), menu_separator()); GtkWidget* shortcuts_item = menu_button("Keyboard shortcuts", "Ctrl+/"); gtk_widget_set_tooltip_text(shortcuts_item, "every key and mouse gesture, including the plot's own"); g_signal_connect(shortcuts_item, "clicked", G_CALLBACK(on_shortcuts_clicked), &app); gtk_box_append(GTK_BOX(menu_box), shortcuts_item); GtkWidget* help_item = menu_button("Help", "F1"); gtk_widget_set_tooltip_text(help_item, "the paged guide to every feature"); g_signal_connect(help_item, "clicked", G_CALLBACK(on_help_clicked), &app); gtk_box_append(GTK_BOX(menu_box), help_item); GtkWidget* about_item = menu_button("About udpscope", nullptr); gtk_widget_set_tooltip_text(about_item, "version, build timestamp and settings paths"); g_signal_connect(about_item, "clicked", G_CALLBACK(on_about_clicked), &app); gtk_box_append(GTK_BOX(menu_box), about_item); // --- the pane toggles, which live on the bar rather than in the menu ------ // Created here because they share the popover's styling helpers; packed onto the header bar // below, where the pack order (not this order) decides where they sit. app.measures_toggle = gtk_toggle_button_new(); gtk_button_set_icon_name(GTK_BUTTON(app.measures_toggle), "udpscope-measure-symbolic"); gtk_widget_set_tooltip_text(app.measures_toggle, "compute a value over the visible window of a signal, and " "optionally mark it on the plot"); g_signal_connect(app.measures_toggle, "toggled", G_CALLBACK(+[](GtkToggleButton* b, gpointer user) { App* a = static_cast(user); a->measures_revealed = gtk_toggle_button_get_active(b); if (a->measures_revealer != nullptr) { gtk_revealer_set_reveal_child(GTK_REVEALER(a->measures_revealer), a->measures_revealed); } }), &app); app.math_toggle = gtk_toggle_button_new(); gtk_button_set_icon_name(GTK_BUTTON(app.math_toggle), "udpscope-math-symbolic"); gtk_widget_set_tooltip_text(app.math_toggle, "channels derived from the others by a graph of operations: gain, " "absolute value, filters, sums and the rest. An enabled one is " "acquired, plotted and measured exactly like a signal"); g_signal_connect(app.math_toggle, "toggled", G_CALLBACK(+[](GtkToggleButton* b, gpointer user) { App* a = static_cast(user); if (a->math_revealer != nullptr) { gtk_revealer_set_reveal_child(GTK_REVEALER(a->math_revealer), gtk_toggle_button_get_active(b)); } }), &app); app.cursors_toggle = gtk_toggle_button_new(); gtk_button_set_icon_name(GTK_BUTTON(app.cursors_toggle), "udpscope-cursors-symbolic"); gtk_widget_set_tooltip_text(app.cursors_toggle, "the cursor instruments: two times, two levels, or the two read " "off each trace where they cross"); 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); GtkWidget* popover = gtk_popover_new(); gtk_widget_add_css_class(popover, "udpscope-menu"); gtk_popover_set_child(GTK_POPOVER(popover), menu_box); app.menu_button = gtk_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"); // `pack_end()` lays its children out right to left -- the first packed is the *rightmost* -- so // the menu is packed first to sit last on screen, and the pane toggles before it in reverse of the // order they should read in: cursors, measurements, math, menu. // --- 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); app.trigger_toggle = gtk_toggle_button_new(); gtk_button_set_icon_name(GTK_BUTTON(app.trigger_toggle), "udpscope-trigger-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); // Packed with the window control instead of at the far end: the trigger and the pause are read // against the *span* on screen (a window change moves the trigger's event window with it), so they // belong next to the control that sets it. gtk_header_bar_pack_start(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_start(GTK_HEADER_BAR(bar), app.pause_button); // Packed here, after every widget above has been created: the *pack* order is what decides the // layout, not the creation order, and packing before the widget exists passes NULL to the header // bar (five `gtk_box_prepend: assertion` criticals, and the controls simply absent). // The **first** packed is the rightmost, and the dump settled it: its tree order is the visual // order left to right, and it reads the *reverse* of this list. (Both of this comment's earlier // versions were wrong in turn; the arrangement they produced is what got reported.) Packed in this // order the row reads, left to right: signal list, cursors, measurements, math, sidebar, menu. gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.menu_button); gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.sidebar_toggle); gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.math_toggle); gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.measures_toggle); gtk_header_bar_pack_end(GTK_HEADER_BAR(bar), app.cursors_toggle); 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) { App* a = static_cast(user); // A pending debounced save will never fire now: flush it, so // quitting right after a change keeps the change. if (a->layout_save_source != 0) { g_source_remove(a->layout_save_source); a->layout_save_source = 0; save_layout_now(*a); } a->window = nullptr; }), app); gtk_window_set_title(GTK_WINDOW(app->window), "udpscope"); // The shell takes a window's icon from this name, and GTK takes the WM class from the // application ID -- which is the same string, which is what associates this window with the // desktop entry. gtk_window_set_icon_name(GTK_WINDOW(app->window), "org.udpscope.app"); 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); // Guard the construction so the counter means something: if status_popover is already // non-null (e.g. run_ui called twice), count the extra build and avoid a leak. if (app->status_popover != nullptr) { ++app->popover_rebuilds; } else { app->status_popover = gtk_popover_new(); GtkWidget* detail_grid = gtk_grid_new(); gtk_grid_set_row_spacing(GTK_GRID(detail_grid), 2); gtk_grid_set_column_spacing(GTK_GRID(detail_grid), 16); gtk_widget_set_margin_start(detail_grid, 10); gtk_widget_set_margin_end(detail_grid, 10); gtk_widget_set_margin_top(detail_grid, 8); gtk_widget_set_margin_bottom(detail_grid, 8); // Row headings at indices 0..3, value lines at 4..10, in the order format_detail's fields // appear. Built once: update_status_popover() only ever calls gtk_label_set_text(). static const char* const kHeadings[] = {"Stream", "Buffer", "Plot 1", "Reconnects"}; const int kHeadingRow[] = {0, 3, 5, 6}; for (int i = 0; i < 4; ++i) { GtkWidget* heading = gtk_label_new(nullptr); char markup[64]; std::snprintf(markup, sizeof(markup), "%s", kHeadings[i]); gtk_label_set_markup(GTK_LABEL(heading), markup); gtk_label_set_xalign(GTK_LABEL(heading), 0.0f); gtk_label_set_yalign(GTK_LABEL(heading), 0.0f); gtk_grid_attach(GTK_GRID(detail_grid), heading, 0, kHeadingRow[i], 1, 1); app->popover_label[i] = heading; } for (int i = 0; i < 7; ++i) { GtkWidget* value = gtk_label_new(""); gtk_label_set_xalign(GTK_LABEL(value), 0.0f); gtk_grid_attach(GTK_GRID(detail_grid), value, 1, i, 1, 1); app->popover_label[4 + i] = value; } gtk_popover_set_child(GTK_POPOVER(app->status_popover), detail_grid); app->status_info_button = gtk_menu_button_new(); gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(app->status_info_button), "help-about-symbolic"); // Above the button, always. The status row is the last thing in the window, so "below" // is off the window's bottom edge and, on a window that reaches the bottom of the screen, // off the screen: the bubble's top edge and its arrow stayed at the button while the body // was clipped away -- what reads as an arrow disconnected from its body. GTK still slides // it sideways to fit, which is why only the vertical placement has to be stated. gtk_menu_button_set_popover(GTK_MENU_BUTTON(app->status_info_button), app->status_popover); // After `set_popover()`: a `GtkMenuButton` states a position of its own (below, which is // where a menu button in a header bar wants it), and it is the *last* word that counts. gtk_popover_set_position(GTK_POPOVER(app->status_popover), GTK_POS_TOP); gtk_popover_set_has_arrow(GTK_POPOVER(app->status_popover), TRUE); gtk_widget_set_tooltip_text(app->status_info_button, "Stream and buffer detail"); gtk_widget_set_has_tooltip(app->status_info_button, TRUE); gtk_box_append(GTK_BOX(status_row), app->status_info_button); } 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)); // The cursors readout sits with the other panels and is revealed by its toggle: the // instruments are on the plot, but what they read has to be somewhere a value can be // selected and copied from. app->cursors_revealer = gtk_revealer_new(); gtk_revealer_set_transition_type(GTK_REVEALER(app->cursors_revealer), GTK_REVEALER_TRANSITION_TYPE_SLIDE_DOWN); gtk_revealer_set_child(GTK_REVEALER(app->cursors_revealer), build_cursors_panel(*app)); gtk_box_append(GTK_BOX(side), app->cursors_revealer); gtk_box_append(GTK_BOX(side), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); app->measures_revealer = gtk_revealer_new(); gtk_revealer_set_transition_type(GTK_REVEALER(app->measures_revealer), GTK_REVEALER_TRANSITION_TYPE_SLIDE_DOWN); gtk_revealer_set_child(GTK_REVEALER(app->measures_revealer), build_measures_panel(*app)); gtk_box_append(GTK_BOX(side), app->measures_revealer); gtk_box_append(GTK_BOX(side), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); app->math_revealer = gtk_revealer_new(); gtk_revealer_set_transition_type(GTK_REVEALER(app->math_revealer), GTK_REVEALER_TRANSITION_TYPE_SLIDE_DOWN); gtk_revealer_set_child(GTK_REVEALER(app->math_revealer), build_math_panel(*app)); gtk_box_append(GTK_BOX(side), app->math_revealer); gtk_box_append(GTK_BOX(side), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); rebuild_math_pane(*app); 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); // Bubble phase on purpose: a focused GtkEditable or GtkButton wins its own keys, so typing // 'm' into the window entry does not toggle the measurements pane and Space does not // double-fire on a focused button. GtkEventController* keys = gtk_event_controller_key_new(); gtk_event_controller_set_propagation_phase(keys, GTK_PHASE_BUBBLE); g_signal_connect(keys, "key-pressed", G_CALLBACK(on_app_key_pressed), app); gtk_widget_add_controller(app->window, keys); 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 = a->status_detail; 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 = a->status_detail; 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; u64 i0 = 0, i1 = 0; (void)sx0; if (dx_nominal > 0.0) { // What the renderer draws, through the series' own x: it used to walk the // `(x - x0()) / dx()` grid, and on a ring-backed series that landed the window inside // the visible range and dropped the two segments that draw into the left and right // margins. `neighbours()`, one sample of slack either side, is the whole of it now -- // the same call the crosshair reads through. The *guard* against the grid walk coming // back is the library's own pixel test // (`a_sparse_window_between_samples_still_inks_both_margins`), because a probe that // recomputes the renderer's formula cannot see one that no longer matches it; what this // line still says is the rate: `dx_ratio` is how far the nominal grid sits from the real // stamps, which is what made the walk wrong in the first place. // A window edge outside the series keeps the renderer's own default on that side: 0 on // the left (draw from the oldest sample) and n on the right (draw to the newest), which // is why a view reaching past the data leaves the margin beyond it genuinely empty. got_lo = shown->neighbours(axis.min, i0, i1) ? (i0 > 0 ? (long long)i0 - 1 : 0) : 0; got_hi = shown->neighbours(axis.max, i0, i1) ? std::min((long long)i1 + 1, (long long)n) : (long long)n; } 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: waits for a *held* event, then lets the tick run the zoom sweep (see /// `report_event_zoom`). Polling for `Held` rather than firing on a fixed delay is the same /// lesson the disarm probe learned: a latch happens when the stream says so, and a sweep that /// starts before one has nothing to zoom into. gboolean eventzoom_wait(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; if (a->model->display_mode() != DisplayMode::Held) return G_SOURCE_CONTINUE; // Eight halvings: a 2 s event down to about 8 ms, which crosses the pyramid's own // raw/LOD gate on the way and so exercises both gather paths. Then the same again // through the window control. a->dbg_zoom_steps = kZoomGestureSteps + kZoomWindowSteps; a->dbg_zoom_step_no = 0; return G_SOURCE_REMOVE; } /// Single trigger mode, armed: one event, held for as long as the sweep needs it. gboolean eventzoom_probe(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; if (a->trigger_mode_drop != nullptr) gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_mode_drop), 1); if (a->trigger_toggle != nullptr) gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE); g_timeout_add(50, eventzoom_wait, a); return G_SOURCE_REMOVE; } /// Headless self-check: defines math channels the way the math pane will, so the *pipeline* can be /// checked without driving the editor -- the same graph reaches the producer either way. Two graphs /// on the first acquired channel: a gain of 2 and a low pass, which the dump then reports beside the /// source's own extremes, so "twice the amplitude" and "attenuated" are read off real samples. gboolean math_probe(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; std::string name; { std::lock_guard lock(a->model->mutex()); if (a->model->channels().empty()) return G_SOURCE_REMOVE; name = a->model->channels().front().name; } std::vector channels; MathChannel gain; gain.name = "math x2"; gain.enabled = true; gain.nodes = {MathNode{MathOp::Source, name}, MathNode{MathOp::Gain}}; gain.nodes[1].in_a = 0; gain.nodes[1].gain = 2.0; gain.output = 1; channels.push_back(gain); MathChannel lp; lp.name = "math lp"; lp.enabled = true; lp.nodes = {MathNode{MathOp::Source, name}, MathNode{MathOp::LowPass}}; lp.nodes[1].in_a = 0; lp.nodes[1].fc = 20.0; lp.output = 1; channels.push_back(lp); // Through the app's list, which is the pane's source of truth: a probe that wrote the model // directly left the two disagreeing, and the next pane edit then silently dropped its channels. a->math = std::move(channels); apply_math_channels(*a); std::fprintf(stderr, "[math-probe] 2 math channels on '%s'\n", name.c_str()); // And open the editor on the first one: as much a construction crash test as a state check, // since a modal window is presented and its canvas, form and gestures are all built here. // The pane's own `+`, which is the path the user takes: it must source an *acquired* channel. // From `signals().front()` it sourced the stream's timestamp signal -- no samples, so the graph // validated and then read NaN for ever, which is exactly the reported "[ch] -> [gain] is all nan". add_math_channel(*a); if (!a->math.empty()) { const std::string src = a->math.back().nodes.empty() ? std::string("(none)") : a->math.back().nodes[0].source; std::fprintf(stderr, "[math-probe] pane add: '%s' sources '%s'\n", a->math.back().name.c_str(), src.c_str()); } open_math_dialog(*a, 0); std::fprintf(stderr, "[math-probe] editor opened\n"); // The user's own flow, through the app's handlers: the pane's `+` makes a bare source graph, the // editor adds a gain, the gain is set, Apply saves. The *output* must be the node that was just // added -- it defaulted to node 0, the source, so every operation was computed and discarded. { const int index = static_cast(a->math.size()) - 1; // Closed first: `open_math_dialog()` on a dialog that is already up just presents it, so this // block used to edit the *previous* channel and its numbers described the wrong graph. if (a->math_dialog != nullptr) gtk_window_close(GTK_WINDOW(a->math_dialog)); open_math_dialog(*a, index); auto* ed = static_cast( g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog")); if (ed != nullptr) { on_math_node_added(nullptr, ed); const int output_after_add = ed->output; // Through the *widgets*, which is what the user touches -- and the read-back below is what // `on_math_param_changed()` will take them from. if (ed->gain != nullptr) gtk_spin_button_set_value(GTK_SPIN_BUTTON(ed->gain), 2.0); if (ed->offset != nullptr) gtk_spin_button_set_value(GTK_SPIN_BUTTON(ed->offset), 0.0); on_math_param_changed(nullptr, ed); const std::size_t built = ed->nodes.size(); const int output_built = ed->output; const double gain_read = ed->nodes.empty() ? 0.0 : ed->nodes.back().gain; const double offset_read = ed->nodes.empty() ? 0.0 : ed->nodes.back().offset; on_math_apply(nullptr, ed); // frees the dialog: nothing below may touch `ed` const std::size_t saved = a->math.empty() ? 0u : a->math[static_cast(index)].nodes.size(); const int output_saved = a->math.empty() ? -1 : a->math[static_cast(index)].output; std::fprintf(stderr, "[math-probe] dialog build: nodes=%zu, output after add=%d (want %zu); " "reading back gain=%.2f offset=%.2f; saved nodes=%zu output=%d\n", built, output_after_add, built - 1, gain_read, offset_read, saved, output_saved); } } // Closed and reopened: `gtk_window_destroy()` does not emit `close-request`, so a dialog closed // that way left `app.math_dialog` pointing at a dead widget and every later open was a `present` // on it -- "I cannot edit a math channel again". A close must clear the pointer, and the reopen // must build a *new* dialog with its own data. if (a->math_dialog != nullptr) gtk_window_close(GTK_WINDOW(a->math_dialog)); std::fprintf(stderr, "[math-probe] after close: dialog=%s\n", a->math_dialog == nullptr ? "cleared" : "STILL SET (the pointer did not clear)"); open_math_dialog(*a, 0); if (a->math_dialog != nullptr) { auto* again = static_cast( g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog")); std::fprintf(stderr, "[math-probe] reopened: dialog=%s, nodes=%s\n", again != nullptr ? "ok" : "NO DATA", again != nullptr ? std::to_string(again->nodes.size()).c_str() : "-"); } else { std::fprintf(stderr, "[math-probe] reopened: FAILED (no dialog)\n"); } // The editor's two reported failures, driven directly. A drag reports the *total* offset from // where the gesture began, so replaying a real sequence of them must land the box at start+last, // not at the sum of the deltas; and a numeric field must hold a fraction. if (a->math_dialog != nullptr) { auto* d = static_cast( g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog")); if (d != nullptr && !d->nodes.empty()) { const double x0 = d->nodes[0].x; const double y0 = d->nodes[0].y; math_canvas_drag_begin(nullptr, x0 + kNodeW * 0.5, y0 + kNodeH * 0.5, d); std::fprintf(stderr, "[math-probe] drag-begin at (%.0f,%.0f) over node %d, selected %d, nodes %zu\n", x0 + kNodeW * 0.5, y0 + kNodeH * 0.5, d->drag_node, d->selected, d->nodes.size()); math_canvas_drag(nullptr, 20.0, 10.0, d); math_canvas_drag(nullptr, 40.0, 20.0, d); math_canvas_drag(nullptr, 60.0, 30.0, d); std::fprintf(stderr, "[math-probe] drag: (%.0f,%.0f) -> (%.0f,%.0f), want (%.0f,%.0f)\n", x0, y0, d->nodes[0].x, d->nodes[0].y, x0 + 60.0, y0 + 30.0); } if (d != nullptr && !d->nodes.empty()) { // Only *acquired* channels may be a source: a disabled signal would read as no data for ever, // and the stream's timestamp signal has no samples at all. const std::vector acquired = math_source_names(*a); std::size_t listed = 0; std::string first; if (d->source_drop != nullptr) { GListModel* model = gtk_drop_down_get_model(GTK_DROP_DOWN(d->source_drop)); listed = model != nullptr ? g_list_model_get_n_items(model) : 0; if (listed > 0) { GObject* item = G_OBJECT(g_list_model_get_item(model, 0)); first = gtk_string_object_get_string(GTK_STRING_OBJECT(item)); g_object_unref(item); } } std::fprintf(stderr, "[math-probe] sources: %zu acquired, dropdown lists %zu, first='%s'\n", acquired.size(), listed, first.c_str()); // The gain+offset group belongs to one op and must be hidden for the others. d->selected = 0; const MathOp was = d->nodes[0].op; d->nodes[0].op = MathOp::LowPass; math_dialog_sync(d); const int for_filter = gtk_widget_get_visible(d->rows[1]) ? 1 : 0; d->nodes[0].op = MathOp::Gain; math_dialog_sync(d); const int for_gain = gtk_widget_get_visible(d->rows[1]) ? 1 : 0; d->nodes[0].op = was; math_dialog_sync(d); std::fprintf(stderr, "[math-probe] rows: gain+offset visible for a filter=%d, for gain=%d " "(want 0, 1)\n", for_filter, for_gain); } if (d != nullptr) { gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->fc), 12.5); gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->gain), 0.25); std::fprintf(stderr, "[math-probe] values: fc=%.4f gain=%.4f, want 12.5000 0.2500\n", gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->fc)), gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->gain))); } } std::fflush(stderr); return G_SOURCE_REMOVE; } /// Headless self-check: adds one measurement of each statistical type, which is what a user does /// from the pane's `+`. /// /// It exists because the frame cost with measurements on is a thing to *measure*, not to reason /// about: a measurement is a statistic of the visible window, and a live view moves that window /// every frame. The values land in the exit dump (`measurements:`), next to `frame cost:`. gboolean measure_trace(gpointer user); gboolean measure_probe(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; std::string name; { std::lock_guard lock(a->model->mutex()); // A *channel*, not `signals().front()`: the first signal of a stream is its timestamp, which // is not acquired and has no samples to measure. That mistake made the probe add four // measurements that all read `--` -- and, because a measurement with no channel bails before // collecting anything, it also made the first baseline look free. if (a->model->channels().empty()) return G_SOURCE_REMOVE; name = a->model->channels().front().name; } for (const MeasureType t : {MeasureType::Average, MeasureType::Top, MeasureType::PeakToPeak, MeasureType::Amplitude}) { MeasureOptions options; // The marks are what a wrong window shows up in, so the probe asks for them. options.show_on_plot = measure_can_show_on_plot(t); a->measures.push_back({name, t, options}); } rebuild_measures_pane(*a); update_measures(*a, true); std::fprintf(stderr, "[measure-probe] %zu measurements on '%s'\n", a->measures.size(), name.c_str()); std::fflush(stderr); g_timeout_add(250, measure_trace, a); return G_SOURCE_REMOVE; } /// Repeating trace of the pane's numbers beside the *current* window and the marks actually /// placed: the only way a stale result is visible as a number rather than as a stutter. gboolean measure_trace(gpointer user) { App* a = static_cast(user); if (a->rows->row_count() <= 0) return G_SOURCE_REMOVE; RowSet::Row& r0 = a->rows->row(0); if (r0.plot == nullptr) return G_SOURCE_REMOVE; const plot::Range view = r0.plot->x_range(r0.axis_x); std::string line = "[measure-trace] view=[" + std::to_string(view.min) + ".." + std::to_string(view.max) + "]"; for (std::size_t i = 0; i < a->measures.size() && i < a->measure_results.size(); ++i) { const MeasureResult& r = a->measure_results[i]; line += " " + std::string(measure_type_name(a->measures[i].type)) + "=" + (r.valid ? std::to_string(r.primary) : std::string("--")); } line += " marks=" + std::to_string(a->measure_marks.size()); for (const RowSet::MeasureMark& m : a->measure_marks) { line += " " + std::string(m.level ? "level" : "time") + ":" + std::to_string(m.value); } std::fprintf(stderr, "%s\n", line.c_str()); std::fflush(stderr); return G_SOURCE_CONTINUE; } /// Headless self-check: a gesture must not move a pinned window. /// /// It writes the far-away range the way a gesture does -- on the *member* axis, which /// `Figure::layout()` adopts into the group -- and then reports the axis the frame after it /// produced: `held=1` when the pin survived, and the panned range when it did not. gboolean pan_probe(gpointer user) { App* a = static_cast(user); if (a->window == nullptr || a->rows->row_count() <= 0) return G_SOURCE_REMOVE; RowSet::Row& r0 = a->rows->row(0); if (r0.plot == nullptr) return G_SOURCE_REMOVE; const plot::Range before = r0.plot->x_range(r0.axis_x); const double span = before.span(); if (!(span > 0.0)) return G_SOURCE_REMOVE; // Where the data is not: ten windows to the left, the same order of magnitude a drag moves. const plot::Range panned{before.min - 10.0 * span, before.max - 10.0 * span}; r0.plot->set_x_range(r0.axis_x, panned); std::fprintf(stderr, "[pan-probe] asked=[%.6f..%.6f] was=[%.6f..%.6f]\n", panned.min, panned.max, before.min, before.max); std::fflush(stderr); a->dbg_pan_asked_lo = panned.min; a->dbg_pan_asked_hi = panned.max; a->dbg_gesture_verdict = 1; return G_SOURCE_REMOVE; } /// 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 += "-"; } // Where the Done button ended up, in the overlay's own coordinates: the assertion is // that it hugs the area's top-right corner, so a layout change that pushed it off the // corner shows here as numbers rather than as a screenshot someone has to look at. std::string done_box = "-"; if (a.layout_done_button != nullptr && a.area_overlay != nullptr) { graphene_rect_t r{}; if (gtk_widget_compute_bounds(a.layout_done_button, a.area_overlay, &r)) { char buf[64]; std::snprintf(buf, sizeof(buf), "%.0f,%.0f,%.0fx%.0f", static_cast(r.origin.x), static_cast(r.origin.y), static_cast(r.size.width), static_cast(r.size.height)); done_box = buf; } } std::fprintf(stderr, "[layout-probe] %-12s rows=%d buttons=%zu done=%d done_box=[%s] " "rows_y=[%s] close=[%s] add=[%s] traces=[%s]\n", what, rows, a.layout_buttons.size(), a.layout_done_button != nullptr ? 1 : 0, done_box.c_str(), 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"); // Leave through the editor's own Done button, which is the way out a user has in // front of them; the toolbar toggle is the same state and stays exercised by the // `on` step above. layout_probe_report(*a, "before done"); layout_click(a->layout_done_button); 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; } /// What the pause work is *for*: the acquisition keeps filling the ring while the picture is /// frozen on the view the pause snapshotted, and resuming shows the data that arrived meanwhile /// instead of a hole. Reported from the probe below. void pause_probe_report(App& a, const char* what) { double bounds_lo = 0.0; double bounds_hi = 0.0; std::size_t event_size = 0; std::size_t ring_produced = 0; { std::lock_guard lock(a.model->mutex()); if (!a.model->channels().empty()) ring_produced = a.model->live().values(0).produced(); } if (const EventSeries* ev = a.rows->row(0).slots[0].bound_event()) { event_size = ev->size(); const plot::Range b = ev->x_bounds(); bounds_lo = b.min; bounds_hi = b.max; } // The axis beside the data, because those two disagreeing is the whole failure: the frozen view // is the *snapshot*, and a pin taken from the requested window instead of from the snapshot left // the difference as an empty margin. `frozen_x` must equal `axis` while paused. 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); } std::fprintf(stderr, "[pause-probe] %-9s paused=%d ring_produced=%zu event_size=%zu " "frozen_x=[%.6f..%.6f] axis=[%.6f..%.6f]\n", what, a.model->paused() ? 1 : 0, ring_produced, event_size, bounds_lo, bounds_hi, double(axis.min), double(axis.max)); std::fflush(stderr); } /// 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); // Through the button, so the *view* snapshot and the axis pin run: pausing is a display // state, not only an acquisition one. pause_probe_report(*a, "live"); if (a->pause_button != nullptr) { gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->pause_button), TRUE); } else { a->acq->set_paused(true); a->model->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); // Reported again a second later (the probe is re-armed below) and once after the resume: the // ring count must have grown while the frozen window did not move. static bool reported_paused = false; if (!reported_paused) { reported_paused = true; g_timeout_add(1200, +[](gpointer user) { App* app = static_cast(user); pause_probe_report(*app, "paused"); return G_SOURCE_REMOVE; }, a); g_timeout_add(1600, +[](gpointer user) { App* app = static_cast(user); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->pause_button), FALSE); return G_SOURCE_REMOVE; }, a); g_timeout_add(2200, +[](gpointer user) { App* app = static_cast(user); pause_probe_report(*app, "resumed"); return G_SOURCE_REMOVE; }, a); } 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 /// Reports the cursors: the instruments' values, whether the pane is up, and the pane's /// own text (which is what a user reads, so it is what the probe asserts). void cursors_probe_report(App& a, const char* what) { static const char* const kModes[] = {"vertical", "horizontal", "both", "mix"}; const int mode = static_cast(a.rows->cursor_mode()); // The table as a user reads it: the time row first, then one row per line, each cell's // own text -- which is the assertion (the app's formatting, not the probe's). std::string t_text; std::string lines; int n = 0; // The row's own name too, not just its three cells: a `mix` line names the trace it reads, // and a line that should not exist at all is only recognisable by *which* trace it claims. const auto cells = [](const App::CursorLine& line) { std::string who = line.label != nullptr ? gtk_label_get_text(GTK_LABEL(line.label)) : ""; return who + "=" + gtk_label_get_text(GTK_LABEL(line.a)) + " " + gtk_label_get_text(GTK_LABEL(line.b)) + " " + gtk_label_get_text(GTK_LABEL(line.d)); }; for (const App::CursorLine& line : a.cursors_lines) { if (line.a == nullptr) continue; if (line.slot == -2) { t_text = cells(line); continue; } if (n != 0) lines += " | "; lines += cells(line); ++n; } // The `mix` crossing markers: how many are on screen and where the first is. They are // what the mode promises on the plot itself, so they are what the probe asserts. double mx = 0.0; double my = 0.0; const bool have_mark = a.rows->crossing_at(0, 0, false, mx, my); std::fprintf(stderr, "[cursors-probe] %-14s mode=%-10s on=%d pane=%d t0=%.6f t1=%.6f dt=%.6f " "T=[%s] lines=%d marks=%d [%s]\n", what, kModes[mode], a.rows->cursors_visible() ? 1 : 0, a.cursors_revealer != nullptr && gtk_revealer_get_reveal_child(GTK_REVEALER(a.cursors_revealer)) ? 1 : 0, a.rows->cursor_a_x(), a.rows->cursor_b_x(), a.rows->cursor_b_x() - a.rows->cursor_a_x(), t_text.c_str(), n, a.rows->crossings_visible(), lines.c_str()); if (have_mark) { std::fprintf(stderr, "[cursors-probe] %-14s first mark at x=%.6f y=%.6g\n", what, mx, my); } std::fflush(stderr); } gboolean cursors_probe_step(gpointer user); /// The two fits the right-click menu offers, driven through the handlers the menu wires /// (a null button: neither handler looks at it). Both must put x back on the whole time window /// and **leave the window itself alone**; `fit x` must leave the y ranges alone too, and /// `fit all` must move them to the data's. void fit_probe_report(App& a, const char* what) { const RowSet::Row& r0 = a.rows->row(0); const plot::Range x = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{}; const plot::Range y = r0.plot != nullptr ? r0.plot->y_range(r0.axis_y) : plot::Range{}; // `window` and `event_span` are the assertion that a fit did *not* restate the time scale: // both are written only by `apply_time_window()`, so they must hold the typed value on every // line, with `span` the reading that moves -- down on the zoom, back up on the fit. std::fprintf(stderr, "[fit-probe] %-10s pinned=%d window=%.6f event_span=%.6f x=[%.6f..%.6f] span=%.6f " "y=[%.6f..%.6f]\n", what, a.rows->window_pinned() ? 1 : 0, a.rows->time_window(), a.model->event_span(), x.min, x.max, x.span(), y.min, y.max); std::fflush(stderr); } /// The x label form across a zoom, driven through the *app's* window path (`apply_time_window`: /// under the figure's x link group the group owns the window, so a row-level `set_time_window` /// is ignored). Absolute at 2 s and at 500 ms, relative below a second with the caption naming /// the anchor and the ticks written against it. gboolean timelabels_probe_step(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; const char* what = "at 2 s"; switch (a->dbg_timelabels_step) { case 0: apply_time_window(*a, 2.0); break; case 1: what = "at 500 ms"; apply_time_window(*a, 0.5); break; case 2: what = "at 50 ms"; apply_time_window(*a, 0.05); break; default: // Ends *in* a sub-second window: the dump runs after the last paint, so it is the // race-free place to read whether the zero tick really sits on the left edge. what = "final 20 ms"; apply_time_window(*a, 0.02); break; } ++a->dbg_timelabels_step; if (a->dbg_timelabels_step <= 4) { g_timeout_add(400, timelabels_probe_step, a); } // Report the *previous* step's state: the labels are laid out on the frame after the window // changes, so reading them in the same call would report the old form. const RowSet::Row& r0 = a->rows->row(0); const RowSet::Row& last = a->rows->row(a->rows->row_count() - 1); std::string ticks; std::string note; // Whether *every* major label on the axis is a different string. That, and not a span // threshold, is what the relative form exists to buy: two adjacent ticks reading `12.3s` are // not labels. It is therefore the one assertion that holds whatever the stream's uptime is -- // which decides where the switch actually falls, since the label resolution is a function of // the axis' magnitude. bool distinct = true; if (last.plot != nullptr) { int n = 0; std::vector seen; for (const plot::Tick& t : last.plot->x_axis(last.axis_x).ticks) { if (!t.major) continue; if (std::find(seen.begin(), seen.end(), t.label) != seen.end()) distinct = false; seen.emplace_back(t.label); if (n++ < 4) { if (n != 1) ticks += ","; ticks += t.label; } } note = last.plot->x_note(); } double first_value = 0.0; plot::Range shown{}; if (last.plot != nullptr) { for (const plot::Tick& t : last.plot->x_axis(last.axis_x).ticks) { if (t.major) { first_value = t.value; break; } } shown = last.plot->x_range(last.axis_x); } std::fprintf(stderr, "[timelabels-probe] %-12s relative=%d distinct=%d window=%.6g origin=%.6f " "note=[%s] ticks=[%s] first_value=%.6f first_rel=%.6f range=[%.6f..%.6f]\n", what, a->rows->time_labels_relative() ? 1 : 0, distinct ? 1 : 0, a->rows->time_window(), a->rows->time_labels_origin(), note.c_str(), ticks.c_str(), first_value, first_value - (last.plot != nullptr ? last.plot->x_axis_origin(last.axis_x) : 0.0), shown.min, shown.max); std::fflush(stderr); (void)r0; return G_SOURCE_REMOVE; } /// Zooms row 0 in to a quarter of what it shows, the way a gesture does -- on the *member* axis, /// which `Figure::layout()` adopts into the group. A fit is the undo of this, so the probe has to /// perform it or it has nothing to undo. void fit_probe_zoom_in(App& a) { RowSet::Row& r0 = a.rows->row(0); if (r0.plot == nullptr) return; const plot::Range x = r0.plot->x_range(r0.axis_x); const double mid = x.min + 0.5 * x.span(); const double half = 0.125 * x.span(); r0.plot->set_x_range(r0.axis_x, {mid - half, mid + half}); } gboolean fit_probe_step(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; switch (a->dbg_fit_step) { case 0: // Move the y scale off the data's range first, so "fit x leaves y alone" is a statement // with something to be wrong about -- and set the window through the control's own path, // since that is the only thing allowed to, and its value is what both fits must preserve. a->rows->set_y_range(0, -100.0, 100.0); // Not while pinned: `apply_time_window()` unpins, and the pinned branch of the fit is the // other half of what this probe is for. Timed inside a pause (`UDPSCOPE_PROBE_PAUSE_MS`) // it takes the window the pause froze and reports `pinned=1`. if (!a->rows->window_pinned()) apply_time_window(*a, 2.0); fit_probe_report(*a, "start"); g_timeout_add(400, fit_probe_step, a); a->dbg_fit_step = 1; break; case 1: fit_probe_zoom_in(*a); g_timeout_add(400, fit_probe_step, a); a->dbg_fit_step = 2; break; case 2: // A quarter of the window: what the fits have to undo, and the reading that proves the // `span=` on the fit lines is the fit's work rather than a window nothing ever left. fit_probe_report(*a, "zoomed"); // The handlers themselves, not `RowSet::fit_x()` alone: the window the fit must *not* // move is the app's, and the notice and the control are the handler's half. on_fit_x_clicked(nullptr, a); g_timeout_add(400, fit_probe_step, a); a->dbg_fit_step = 3; break; case 3: fit_probe_report(*a, "fit x"); fit_probe_zoom_in(*a); on_fit_clicked(nullptr, a); g_timeout_add(400, fit_probe_step, a); a->dbg_fit_step = 4; break; default: fit_probe_report(*a, "fit all"); break; } return G_SOURCE_REMOVE; } void cursors_probe_next(App& a, int step, guint delay_ms) { a.dbg_cursors_step = step; g_timeout_add(delay_ms, cursors_probe_step, &a); } gboolean cursors_probe_step(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; switch (a->dbg_cursors_step) { case 0: // Through the toggle the toolbar offers, which is what also reveals the pane. gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->cursors_toggle), true); cursors_probe_report(*a, "on"); cursors_probe_next(*a, 1, 300); break; case 1: a->rows->set_cursor_mode(RowSet::CursorMode::Horizontal); cursors_probe_next(*a, 2, 300); break; case 2: cursors_probe_report(*a, "horizontal"); a->rows->set_cursor_mode(RowSet::CursorMode::Both); cursors_probe_next(*a, 3, 300); break; case 3: cursors_probe_report(*a, "both"); a->rows->set_cursor_mode(RowSet::CursorMode::Mix); cursors_probe_next(*a, 4, 300); break; case 4: cursors_probe_report(*a, "mix"); cursors_probe_next(*a, 5, 300); break; case 5: { // A plot-level move with *no gesture behind it*: the sync reads a row only while its // controller holds a cursor, so this must be ignored. That is the invariant the // "drag it and it springs back" defect broke -- the tick adopted a row's own echo, and a // row that had not been re-written yet adopted the stale shared value straight back. RowSet::Row& r0 = a->rows->row(0); a->dbg_gestureless_before = a->rows->cursor_a_x(); if (r0.plot != nullptr) { const plot::Range xr = r0.plot->x_range(r0.axis_x); r0.plot->set_cursor_x(r0.cursor_a, xr.min + 0.20 * xr.span(), plot::CursorPlacement::Free); } cursors_probe_next(*a, 6, 300); break; } case 6: { const double after = a->rows->cursor_a_x(); std::fprintf(stderr, "[cursors-probe] %-14s plot move with no gesture: stored %.6f -> %.6f " "ignored=%d\n", "gestureless", a->dbg_gestureless_before, after, std::fabs(after - a->dbg_gestureless_before) < 1e-12 ? 1 : 0); std::fflush(stderr); cursors_probe_report(*a, "after drag"); // (The adoption half -- a gesture that *does* move a cursor -- is the library's own // controller test plus the app's rule that the tick passes the row whose controller holds // a cursor; a programmatic press here cannot reach the adapter's event pump, so this probe // asserts the invariant that is reachable from here.) cursors_probe_next(*a, 7, 300); break; } case 7: { cursors_probe_report(*a, "both"); // A ruler is dragged through its own plot, and the pane's Y cells have to follow it. // Nothing shares a ruler, so this is the read-back path alone. RowSet::Row& r0 = a->rows->row(0); if (r0.plot != nullptr) { const plot::Range yr = r0.plot->y_range(r0.axis_y); r0.plot->set_cursor_y(r0.ruler, yr.min + 0.80 * yr.span(), plot::CursorPlacement::Free); } cursors_probe_next(*a, 8, 300); break; } default: cursors_probe_report(*a, "after ruler drag"); break; } return G_SOURCE_REMOVE; } /// Adds one measurement of every type on the longest-cycling scalar signal the stream /// describes, then reports each result -- through the pane's own list and the tick's own /// recomputation, so what it prints is what a user would read. /// Clicks a labelled button inside the measurement dialog, which is a window of its own. void measure_dialog_click(App& a, const char* label) { if (a.measure_dialog == nullptr) { std::fprintf(stderr, "[measures-probe] no dialog to click '%s' in\n", label); // (the zoom-in is judged on the next frame, after the layout pushes it) } GtkWidget* b = find_labelled_button(a.measure_dialog, label); if (b == nullptr) { std::fprintf(stderr, "[measures-probe] no '%s' button in the dialog\n", label); // (the zoom-in is judged on the next frame, after the layout pushes it) } g_signal_emit_by_name(b, "clicked"); } /// What the dialog's signal dropdown holds: the probe's assertion that the list is the /// *acquired* signals (plus the edited measurement's own, when the stream no longer acquires /// it), and that every entry is a name rather than the mangled pointer it used to be. void measure_dialog_report(App& a, const char* what) { std::string items; int n = 0; if (a.measure_dialog != nullptr) { // The dialog keeps its fields in the context it stores on its own window, which is how a // probe reaches a dropdown inside a window that is not the main one. auto* d = static_cast( g_object_get_data(G_OBJECT(a.measure_dialog), "udpscope-measure-ctx")); if (d != nullptr && d->signal_drop != nullptr) { GListModel* model = gtk_drop_down_get_model(GTK_DROP_DOWN(d->signal_drop)); const guint count = model != nullptr ? g_list_model_get_n_items(model) : 0; for (guint i = 0; i < count; ++i) { GObject* obj = G_OBJECT(g_list_model_get_item(model, i)); const char* text = GTK_IS_STRING_OBJECT(obj) ? gtk_string_object_get_string(GTK_STRING_OBJECT(obj)) : "?"; if (i != 0) items += ", "; items += text; g_object_unref(obj); } n = static_cast(count); } } int drop_h = -1; char options[192] = ""; if (a.measure_dialog != nullptr) { auto* dd = static_cast( g_object_get_data(G_OBJECT(a.measure_dialog), "udpscope-measure-ctx")); if (dd != nullptr && dd->signal_drop != nullptr) { drop_h = gtk_widget_get_height(dd->signal_drop); } if (dd != nullptr) { // What the dialog is offering for the type on screen: the option rows' visibility and // the words on them, which is the per-type behaviour a user sees. std::snprintf(options, sizeof(options), "type=%d thr=%d side=%d show=%d '%s'", static_cast(gtk_drop_down_get_selected(GTK_DROP_DOWN(dd->type_drop))), dd->threshold_row != nullptr && gtk_widget_get_visible(dd->threshold_row) ? 1 : 0, dd->side_row != nullptr && gtk_widget_get_visible(dd->side_row) ? 1 : 0, dd->show != nullptr && gtk_widget_get_visible(dd->show) ? 1 : 0, dd->show != nullptr && gtk_check_button_get_child(GTK_CHECK_BUTTON(dd->show)) != nullptr ? gtk_label_get_text(GTK_LABEL(gtk_check_button_get_child( GTK_CHECK_BUTTON(dd->show)))) : ""); } } std::fprintf(stderr, "[measures-probe] %-14s measures=%zu dialog=%d signals=%d drop_h=%d [%s] %s\n", what, a.measures.size(), a.measure_dialog != nullptr ? 1 : 0, n, drop_h, items.c_str(), options); std::fflush(stderr); } gboolean measures_probe_step(gpointer user); void measures_probe_next(App& a, int step, guint delay_ms) { a.dbg_measure_step = step; g_timeout_add(delay_ms, measures_probe_step, &a); } gboolean measures_probe_step(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; switch (a->dbg_measure_step) { case 0: { std::string signal; { std::lock_guard lock(a->model->mutex()); if (!a->model->channels().empty()) signal = a->model->channels().front().name; } if (signal.empty()) { std::fprintf(stderr, "[measures-probe] no acquired signal to measure\n"); return G_SOURCE_REMOVE; } for (int i = 0; i < measure_type_count(); ++i) { Measure m; m.signal = signal; m.type = measure_type_at(i); m.options.show_on_plot = measure_can_show_on_plot(m.type); a->measures.push_back(std::move(m)); } gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->measures_toggle), true); rebuild_measures_pane(*a); std::fprintf(stderr, "[measures-probe] added %zu measurements on '%s'\n", a->measures.size(), signal.c_str()); measures_probe_next(*a, 1, 400); break; } case 1: open_measure_dialog(*a, 0); // the edit path: this and the delete are what aborted measures_probe_next(*a, 2, 400); break; case 2: // Reported a frame after opening, so the dropdown has an allocation: its height is // what says the combo is a combo and not a 160 px box. measure_dialog_report(*a, "edit opened"); measures_probe_next(*a, 3, 200); break; case 3: measure_dialog_click(*a, "Save"); measures_probe_next(*a, 4, 400); break; case 4: measure_dialog_report(*a, "after save"); open_measure_dialog(*a, 0); measures_probe_next(*a, 5, 200); break; case 5: measure_dialog_click(*a, "Delete"); measures_probe_next(*a, 6, 400); break; case 6: // A gesture on a measurement's own cursor, placed through the plot exactly as the // controller would: the next frame has to put it back, which is what "read-only" means // here (the library has no flag the hit test would respect). if (!a->measure_marks.empty()) { RowSet::Row& r0 = a->rows->row(0); if (r0.plot != nullptr) { const RowSet::MeasureMark& mk = a->measure_marks.front(); if (mk.level) { r0.plot->set_cursor_y(r0.measure_h[0], mk.value + 3.0, plot::CursorPlacement::Free); } else { r0.plot->set_cursor_x(r0.measure_v[0], mk.value + 3.0, plot::CursorPlacement::Free); } } } measures_probe_next(*a, 7, 300); break; default: { double restored = -1.0; double want = 0.0; if (!a->measure_marks.empty()) { const RowSet::Row& r0 = a->rows->row(0); const RowSet::MeasureMark& mk = a->measure_marks.front(); want = mk.value; if (r0.plot != nullptr) { restored = mk.level ? r0.plot->cursor(r0.measure_h[0]).y : r0.plot->cursor(r0.measure_v[0]).x; } } std::fprintf(stderr, "[measures-probe] %-14s marks=%zu first=%.6f plot_now=%.6f restored=%d\n", "after drag", a->measure_marks.size(), want, restored, std::fabs(restored - want) < 1.0e-9 ? 1 : 0); std::fflush(stderr); measure_dialog_report(*a, "final"); break; } } return G_SOURCE_REMOVE; } /// Drives the plot's keyboard zoom with Ctrl and with Alt and reports which axis moved. /// The ranges are read **synchronously**, in this callback, which is deliberate and is the /// opposite of what the pan and zoom probes do. The question here is only "does the /// controller's modifier restriction reach the app", so the read has to see the /// controller's own write and nothing else: `hold_pinned_window()` clamps the *x* span /// into the pinned window, so a reading taken a frame later would report that clamp's /// answer rather than the restriction's for the ctrl case. Nor can a same-callback read /// drift past a state boundary, which is the hazard the read-in-the-tick rule exists for. /// Whether a zoom the controller performed is then *adopted* by the figure is a different /// question, and `[pan-probe]`'s `zoom-in asked=/group=/member=` line is what asserts it. /// Pausing first is therefore not required, only tidy — it is how Task 11 runs it: /// UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200 gboolean zoomkeys_probe(gpointer user) { App* a = static_cast(user); if (a->rows->row_count() == 0 || a->rows->row(0).plot == nullptr) return G_SOURCE_REMOVE; plot::Plot& p = *a->rows->row(0).plot; const plot::u32 ax = a->rows->row(0).axis_x; const plot::u32 ay = a->rows->row(0).axis_y; plot::input::Controller* c = a->area != nullptr ? a->area->row_controller(static_cast(0)) : nullptr; if (c == nullptr) { std::fprintf(stderr, "[zoomkeys] no controller for row 0\n"); std::fflush(stderr); return G_SOURCE_REMOVE; } const plot::Range x0 = p.x_range(ax); const plot::Range y0 = p.y_range(ay); c->on_key({plot::input::kKeyZoomIn, static_cast(plot::input::Mod::Ctrl), true}); const plot::Range x1 = p.x_range(ax); const plot::Range y1 = p.y_range(ay); const int y_held = std::fabs(y1.span() - y0.span()) < 1e-9 ? 1 : 0; c->on_key({plot::input::kKeyZoomIn, static_cast(plot::input::Mod::Alt), true}); const plot::Range x2 = p.x_range(ax); const plot::Range y2 = p.y_range(ay); const int x_held = std::fabs(x2.span() - x1.span()) < 1e-9 ? 1 : 0; std::fprintf(stderr, "[zoomkeys] ctrl+plus x=[%.4f..%.4f]->[%.4f..%.4f] y_held=%d | " "alt+plus y=[%.4f..%.4f]->[%.4f..%.4f] x_held=%d\n", x0.min, x0.max, x1.min, x1.max, y_held, y1.min, y1.max, y2.min, y2.max, x_held); std::fflush(stderr); return G_SOURCE_REMOVE; } /// How tall a menu row may be. Adwaita's own minimum for a button is 34 px, and a popover of /// seven such rows is what made the overflow read as a stack of dialogs; the `.udpscope-menu` /// rules trim it to a menu row and this is what says they are still applied. constexpr int kMenuRowMaxPx = 30; gboolean menus_probe_step(gpointer user); void menus_probe_next(App& a, int step, guint delay_ms) { a.dbg_menus_step = step; g_timeout_add(delay_ms, menus_probe_step, &a); } /// The label and the shortcut of one row, in the order menu_row() appends them. The optional /// check image comes first and is not a label, so this counts labels rather than positions. void menu_row_labels(GtkWidget* box, GtkWidget** label, GtkWidget** accel) { *label = nullptr; *accel = nullptr; for (GtkWidget* child = box != nullptr ? gtk_widget_get_first_child(box) : nullptr; child != nullptr; child = gtk_widget_get_next_sibling(child)) { if (!GTK_IS_LABEL(child)) continue; if (*label == nullptr) { *label = child; } else { *accel = child; } } } /// The two popovers, measured, with the side panel hidden -- the state the report is about. /// What it asserts, row by row, is what a screenshot can only show an eye: the label at the /// left of its row, the shortcut at the right of it, a row under kMenuRowMaxPx, the rows in /// the intended order, and an info bubble placed above its button with room for its height. gboolean menus_probe_step(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; if (a->dbg_menus_step == 0) { // Hiding the panel is the case that was reported: the info button then sits at the // window's right edge, and a bubble placed below it has nowhere to go. run_app_action(*a, AppAction::SidePanel); // Autohide off *for the probe only*: a popover dismisses itself when it loses the grab, and // under a bare `xvfb-run` there is no window manager to hand it one, so the rows would be // measured before they were ever mapped. The app's own popovers keep autohide on. GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->menu_button)); if (popover != nullptr) gtk_popover_set_autohide(popover, FALSE); gtk_menu_button_popup(GTK_MENU_BUTTON(a->menu_button)); menus_probe_next(*a, 1, 300); return G_SOURCE_REMOVE; } if (a->dbg_menus_step == 1) { GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->menu_button)); GtkWidget* box = popover != nullptr ? gtk_popover_get_child(popover) : nullptr; static const char* const kExpected[] = {"Fit to data", "Layout editor", "Signal list", "Export visible range...", "Keyboard shortcuts", "Help", "About udpscope"}; const int expected = static_cast(sizeof(kExpected) / sizeof(kExpected[0])); int rows = 0; int bad = 0; int first_label_x = 0; for (GtkWidget* row = box != nullptr ? gtk_widget_get_first_child(box) : nullptr; row != nullptr; row = gtk_widget_get_next_sibling(row)) { if (!GTK_IS_BUTTON(row)) continue; GtkWidget* label = nullptr; GtkWidget* accel = nullptr; menu_row_labels(gtk_widget_get_first_child(row), &label, &accel); const int row_w = gtk_widget_get_width(row); const int row_h = gtk_widget_get_height(row); const graphene_point_t origin = GRAPHENE_POINT_INIT(0.0f, 0.0f); graphene_point_t at = GRAPHENE_POINT_INIT(0.0f, 0.0f); const int label_x = label != nullptr && gtk_widget_compute_point(label, row, &origin, &at) ? static_cast(at.x) : -1; const int accel_right = accel != nullptr && gtk_widget_compute_point(accel, row, &origin, &at) ? static_cast(at.x) + gtk_widget_get_width(accel) : -1; const char* text = label != nullptr ? gtk_label_get_text(GTK_LABEL(label)) : ""; const char* key = accel != nullptr ? gtk_label_get_text(GTK_LABEL(accel)) : ""; const bool named = rows < expected && std::strcmp(text, kExpected[rows]) == 0; // Every label starts at the same x -- that is what "left-aligned" means for a menu whose // rows differ in whether they carry a check. if (rows == 0) first_label_x = label_x; const bool left = label_x >= 0 && label_x <= 40 && label_x == first_label_x; const bool right = accel == nullptr || (accel_right > 0 && row_w - accel_right <= 12); const bool compact = row_h > 0 && row_h <= kMenuRowMaxPx; const bool ok = named && left && right && compact; if (!ok) ++bad; std::fprintf(stderr, "[menu] %d '%s' accel='%s' h=%d label_x=%d accel_right=%d/%d order=%d " "ok=%d\n", rows, text, key, row_h, label_x, accel_right, row_w, named ? 1 : 0, ok ? 1 : 0); ++rows; } std::fprintf(stderr, "[menu] rows=%d expected=%d order=%d rows_bad=%d popover_visible=%d " "mapped=%d button_mapped=%d\n", rows, expected, rows == expected ? 1 : 0, bad, popover != nullptr ? gtk_widget_get_visible(GTK_WIDGET(popover)) : -1, popover != nullptr ? gtk_widget_get_mapped(GTK_WIDGET(popover)) : -1, a->menu_button != nullptr ? gtk_widget_get_mapped(a->menu_button) : -1); if (popover != nullptr) gtk_popover_set_autohide(popover, TRUE); gtk_menu_button_popdown(GTK_MENU_BUTTON(a->menu_button)); if (a->status_info_button != nullptr) { GtkPopover* info = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->status_info_button)); if (info != nullptr) gtk_popover_set_autohide(info, FALSE); gtk_menu_button_popup(GTK_MENU_BUTTON(a->status_info_button)); } menus_probe_next(*a, 2, 300); return G_SOURCE_REMOVE; } // The info bubble: its own size against the room above the button it points at. GTK slides a // popover sideways to fit, so only the vertical placement has to hold -- and it is the one // that was wrong: below the button is off the window's bottom edge. GtkWidget* bubble = a->status_popover; const int bubble_w = bubble != nullptr ? gtk_widget_get_width(bubble) : -1; const int bubble_h = bubble != nullptr ? gtk_widget_get_height(bubble) : -1; graphene_rect_t bounds = GRAPHENE_RECT_INIT(0, 0, 0, 0); const bool have_button = a->status_info_button != nullptr && gtk_widget_compute_bounds(a->status_info_button, a->window, &bounds); const int button_top = have_button ? static_cast(bounds.origin.y) : -1; const int button_right = have_button ? static_cast(bounds.origin.x + bounds.size.width) : -1; const int pos = bubble != nullptr ? static_cast(gtk_popover_get_position(GTK_POPOVER(bubble))) : -1; const int fits = (pos == GTK_POS_TOP && bubble_h > 0 && bubble_w > 0 && button_top >= bubble_h + 14) ? 1 : 0; std::fprintf(stderr, "[menu] status pos=%s bubble=%dx%d button_top=%d button_right=%d " "fits=%d\n", pos == GTK_POS_TOP ? "top" : "other", bubble_w, bubble_h, button_top, button_right, fits); if (a->status_info_button != nullptr) { GtkPopover* info = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->status_info_button)); if (info != nullptr) gtk_popover_set_autohide(info, TRUE); gtk_menu_button_popdown(GTK_MENU_BUTTON(a->status_info_button)); } std::fflush(stderr); return G_SOURCE_REMOVE; } /// Fires a representative accel from each group and reports the state it changed, then /// checks the GtkEditable refusal. It goes through run_app_action() for the actions and /// through the real handler for the refusal, because the refusal lives in the handler. gboolean keys_probe(gpointer user) { App* a = static_cast(user); std::string line = "[keys]"; const auto before_after = [&](const char* name, GtkWidget* button, AppAction action) { const int before = button != nullptr ? (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(button)) ? 1 : 0) : -1; run_app_action(*a, action); const int after = button != nullptr ? (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(button)) ? 1 : 0) : -1; char buf[96]; std::snprintf(buf, sizeof(buf), " %s: %d->%d |", name, before, after); line += buf; }; // Read the lock-protected field, not the atomic mirror: on_arm_clicked() writes // trigger().armed directly, while trigger_armed() (the atomic) is updated by the // acquisition thread on the next packet -- reading it immediately would race. const bool armed_before = a->model->trigger().armed; run_app_action(*a, AppAction::TriggerArm); char armed[64]; std::snprintf(armed, sizeof(armed), " a: armed %d->%d |", armed_before ? 1 : 0, a->model->trigger().armed ? 1 : 0); line += armed; const bool paused_before = a->acq->paused(); run_app_action(*a, AppAction::PauseToggle); char paused[64]; std::snprintf(paused, sizeof(paused), " space: paused %d->%d |", paused_before ? 1 : 0, a->acq->paused() ? 1 : 0); line += paused; run_app_action(*a, AppAction::PauseToggle); // leave the run live for the other probes before_after("t: trigger panel", a->trigger_toggle, AppAction::TriggerPanel); before_after("c: cursors", a->cursors_toggle, AppAction::CursorsPane); before_after("m: measures", a->measures_toggle, AppAction::MeasuresPane); before_after("g: math", a->math_toggle, AppAction::MathPane); before_after("l: layout", a->layout_toggle, AppAction::LayoutEditor); before_after("s: side panel", a->sidebar_toggle, AppAction::SidePanel); // An accel must be refused while text is being typed. Focus the window entry, send 'm' // through the real handler, and require the measurements toggle not to move. int refused = -1; if (a->window_entry != nullptr) { gtk_menu_button_popup(GTK_MENU_BUTTON(a->window_button)); gtk_widget_grab_focus(a->window_entry); const int before = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->measures_toggle)) ? 1 : 0; const gboolean handled = on_app_key_pressed(nullptr, GDK_KEY_m, 0, static_cast(0), a); const int after = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->measures_toggle)) ? 1 : 0; refused = (handled == FALSE && before == after) ? 1 : 0; gtk_menu_button_popdown(GTK_MENU_BUTTON(a->window_button)); } char tail[64]; std::snprintf(tail, sizeof(tail), " editable focused: 'm' refused=%d\n", refused); line += tail; std::fprintf(stderr, "%s", line.c_str()); std::fflush(stderr); return G_SOURCE_REMOVE; } /// Loads and validates every help page, opens all three windows, closes them, and reports. /// `parsed` must equal `pages` and no page may be empty; `closed ok` with no GTK critical /// is the crash check for three windows built and destroyed in one run. gboolean help_probe(gpointer user) { App* a = static_cast(user); const HelpReport report = help_self_check(); const std::string session = layout_path(a->model->config().server, a->model->config().port); show_help(GTK_WINDOW(a->window)); show_about(GTK_WINDOW(a->window), session); show_shortcuts(GTK_WINDOW(a->window)); // Read while it is open: the icon is the point of the dialog carrying one, and // `close_help_windows()` is right below. const std::string about_icon = about_icon_name(); close_help_windows(); int accel_count = 0; int gesture_count = 0; (void)app_accels(&accel_count); (void)plot_gestures(&gesture_count); std::fprintf(stderr, "[help] pages=%d parsed=%d shortest='%s' %zu B; shortcuts: app=%d plot=%d; " "about version=%s commit=%s built=%s settings=%s icon=%s; closed ok\n", report.pages, report.parsed, report.shortest_title.c_str(), report.shortest_bytes, accel_count, gesture_count, build_info::kVersion, build_info::kCommit, build_info::kTimestamp, config_dir().c_str(), about_icon.c_str()); std::fflush(stderr); return G_SOURCE_REMOVE; } int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisition& acq, RowSet& rows) { (void)argc; // Installs libadwaita's stylesheet, which the shortcut overview's `AdwShortcutsDialog` // needs to be drawn at all. Idempotent, and `choose_source()` may already have run it. adw_init(); // The app's own icons, before anything asks for one. They are a GResource compiled into the // binary (see CMakeLists), and this is what makes the theme look there: an unregistered path // is not an error, it is a missing-image placeholder. // // It is here rather than in `build_toolbar()` because there are now two consumers -- the // window's own icon and the toolbar's buttons -- and this is the one place that is ahead of // both. `adw_init()` calls `gtk_init()`, which opens the default display, so the line above // is what makes `gdk_display_get_default()` valid here. gtk_icon_theme_add_resource_path(gtk_icon_theme_get_for_display(gdk_display_get_default()), "/org/udpscope/icons"); App app; app.model = &model; app.acq = &acq; app.rows = &rows; // Which data source this session talks to: the persisted-layout file's name. app.source_host = cfg.server; app.source_port = cfg.port; app.source_multicast = cfg.multicast; app.source_interface = cfg.interface_addr; GtkApplication* application = // `NON_UNIQUE`: a scope is the kind of program a user runs twice side by side -- one // instance per source, or two viewers of the same one -- and without it GApplication's // D-Bus single-instance rule turns every later launch into an activation on the first // (the new process exits at once, so only the first instance ever lives). gtk_application_new("org.udpscope.app", G_APPLICATION_NON_UNIQUE); g_signal_connect(application, "activate", G_CALLBACK(on_activate), &app); // Headless self-check: exercise the toolbar's toggles, then exit and print what // the pipeline produced, so a CI run can assert the stream actually arrived and // both panels were laid out. double exit_after_ms = 0.0; if (const char* env = g_getenv("UDPSCOPE_EXIT_AFTER_MS")) exit_after_ms = std::atof(env); if (exit_after_ms > 0.0) { g_timeout_add(static_cast(exit_after_ms), exit_probe_step1, &app); } if (const char* env = g_getenv("UDPSCOPE_ENABLE_ALL_MS")) { g_timeout_add(static_cast(std::atoi(env)), enable_all_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_DISARM_MS")) { g_timeout_add(static_cast(std::atoi(env)), disarm_probe_step1, &app); } if (const char* env = g_getenv("UDPSCOPE_ARM_TRIGGER_MS")) { g_timeout_add(static_cast(std::atoi(env)), arm_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_FIELDS_MS")) { g_timeout_add(static_cast(std::atoi(env)), fields_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_WINDOW_MS")) { g_timeout_add(static_cast(std::atoi(env)), window_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_TIMELABELS_MS")) { g_timeout_add(static_cast(std::atoi(env)), timelabels_probe_step, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURES_MS")) { g_timeout_add(static_cast(std::atoi(env)), measures_probe_step, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_FIT_MS")) { g_timeout_add(static_cast(std::atoi(env)), fit_probe_step, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_CURSORS_MS")) { g_timeout_add(static_cast(std::atoi(env)), cursors_probe_step, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_LAYOUT_MS")) { g_timeout_add(static_cast(std::atoi(env)), layout_probe_step, &app); } if (const char* env = g_getenv("UDPSCOPE_TRIG_TRACE_MS")) { g_timeout_add(static_cast(std::atoi(env)), trig_trace_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_MATH_MS")) { g_timeout_add(static_cast(std::atoi(env)), math_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURE_MS")) { g_timeout_add(static_cast(std::atoi(env)), measure_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_EVENTZOOM_MS")) { g_timeout_add(static_cast(std::atoi(env)), eventzoom_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_PAN_MS")) { g_timeout_add(static_cast(std::atoi(env)), pan_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_PAUSE_MS")) { g_timeout_add(static_cast(std::atoi(env)), pause_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_EXPORT_MS")) { g_timeout_add(static_cast(std::atoi(env)), export_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_ZOOMKEYS_MS")) { g_timeout_add(static_cast(std::atoi(env)), zoomkeys_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_HELP_MS")) { g_timeout_add(static_cast(std::atoi(env)), help_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_MENUS_MS")) { menus_probe_next(app, 0, static_cast(std::atoi(env))); } if (const char* env = g_getenv("UDPSCOPE_PROBE_KEYS_MS")) { g_timeout_add(static_cast(std::atoi(env)), keys_probe, &app); } // 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] render blank frames: %d of %d painted\n", app.dbg_render_blank_frames, app.dbg_frames_painted); std::printf("[udpscope] traceless paints: %d (%d after the first bind, last at paint %d)\n", app.dbg_notrace_frames, app.dbg_notrace_after_bound, app.dbg_notrace_last); std::printf("[udpscope] worst side/pane min width: %d px\n", app.dbg_min_w_worst); std::printf("[udpscope] trace ink: %d paints checked, %d collapsed, %d staircase, %d short, " "worst cover %.1f%%\n", app.dbg_ink_checked, app.dbg_ink_collapse, app.dbg_ink_staircase, app.dbg_ink_short, app.dbg_ink_worst_cover * 100.0); 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.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] status bar: glance=[● %s] max_glance_chars=%zu detail_fields=11 " "popover_rebuilds=%d\n", app.dbg_glance_last.c_str(), app.glance_max_chars, app.popover_rebuilds); // The derived channels beside the channel they came from. A math channel defined by the pane // reaches the producer by exactly the path the probe uses, so this is the pipeline's own // assertion: `x2` must read twice its source's extremes, a low pass must read less. { std::vector> wanted; { std::lock_guard lock(app.model->mutex()); // The first *streamed* channel too: it is the one a saturated ring clamps at the front, and // the order check below is the only place that shows it without a screenshot. if (!app.model->channels().empty()) { wanted.emplace_back(app.model->channels().front().name, 0); } for (const MathChannel& m : app.model->math_channels()) { int idx = -1; for (std::size_t c = 0; c < app.model->channels().size(); ++c) { if (app.model->channels()[c].name == m.name) idx = static_cast(c); } wanted.emplace_back(m.name, idx); } } for (const auto& [name, idx] : wanted) { if (idx < 0) { std::printf("[udpscope] math '%s': no channel (disabled?)\n", name.c_str()); continue; } std::vector t; std::vector v; const bool ok = visible_samples(app, static_cast(idx), t, v); double lo = 0.0; double hi = 0.0; for (std::size_t i = 0; ok && i < v.size(); ++i) { lo = i == 0 ? v[i] : std::min(lo, v[i]); hi = i == 0 ? v[i] : std::max(hi, v[i]); } // The stamps of the window the plot would draw: they must be strictly increasing. A // duplicate or a backwards step is what a wrap artifact looks like from the data side -- // the renderer's index window comes from a binary search over these, and a segment drawn // from the end of the ring to its beginning is a window that spans the wrap. // A break can only be at the *front*: the window's oldest slots may be clamped to the oldest // readable stamp (the published window is pinned while the rings slide under it), so a // saturated ring legitimately shows a few non-increasing stamps at the start. A break // anywhere else is a real one, so the *first* break's position is reported. std::size_t order_breaks = 0; long long first_break = -1; for (std::size_t i = 1; ok && i < t.size(); ++i) { if (!(t[i] > t[i - 1])) { ++order_breaks; if (first_break < 0) first_break = static_cast(i); } } // `produced` beside the visible count: a derived channel is filled once per stored time // slot, so its ring must hold exactly as many samples as the time ring the plot reads it // against. The first version pushed twice per slot and this ratio would have said so. u64 produced = 0; u64 slot_count = 0; { std::lock_guard lock(app.model->mutex()); produced = app.model->live().values(static_cast(idx)).produced(); slot_count = app.model->live().time().produced(); } std::printf("[udpscope] math '%s': channel %d, %llu samples vs %llu time slots, " "%zu visible (order breaks %zu, first at %lld), y=[%.4f, %.4f]\n", name.c_str(), idx, static_cast(produced), static_cast(slot_count), ok ? v.size() : 0u, order_breaks, first_break, ok ? lo : 0.0, ok ? hi : 0.0); } // What the derived channels cost the *producer*. This is not a frame cost: the math runs // between two DATA packets on the acquisition thread, so it never appears in `frame cost:` // and shows up only as dropped packets once it exceeds the inter-packet budget. `ns/slot` // is the figure to compare across runs; it is *sampled* (see `ScopeModel::math_slots()`), // because timing every slot costs about as much as a slot and would measure itself. if (app.model->math_timed() > 0) { const double per_slot = static_cast(app.model->math_cost_ns()) / static_cast(app.model->math_timed()); std::printf("[udpscope] math cost: %llu slots, %.1f ns/slot (%llu timed), " "%.2f ms total est\n", static_cast(app.model->math_slots()), per_slot, static_cast(app.model->math_timed()), per_slot * static_cast(app.model->math_slots()) / 1.0e6); } } // Unconditional, unlike the pane's own lines below: this is about the instruments the app // *created*, which exist whether or not any measurement was added. std::printf("[udpscope] readout instruments locked: %d (trigger marker + measurement marks)\n", app.rows->readouts_locked() ? 1 : 0); // Where the panel controls live. Computed once, when the widgets exist -- the dump runs *after* // the window is destroyed, where a widget pointer is dangling and asking GTK about it is a // critical (which is how the first version of this line earned four of them). std::printf("[udpscope] panel controls: %s\n", app.dbg_panel_controls.c_str()); std::printf("[udpscope] toolbar icons: %s\n", app.dbg_toolbar_icons.c_str()); std::printf("[udpscope] app icons: %s\n", app.dbg_app_icons.c_str()); // The measurements pane, with the numbers the engine computed: a probe adds measurements and // this is where their values are read, so a wrong threshold or a window that moved under a // stale result is visible in the run rather than only on screen. if (!app.measures.empty()) { std::printf("[udpscope] measurements: %zu", app.measures.size()); for (std::size_t i = 0; i < app.measures.size() && i < app.measure_results.size(); ++i) { const MeasureResult& r = app.measure_results[i]; std::printf(" %s=%s", measure_type_name(app.measures[i].type), r.valid ? std::to_string(r.primary).c_str() : "--"); if (r.has_secondary) std::printf("(%s)", std::to_string(r.secondary).c_str()); } std::printf("\n"); std::printf("[udpscope] clamp fires: %d (a clamp that fires every frame is a re-frame loop)\n", app.rows->clamp_fires()); if (app.measure_passes > 0) { std::printf("[udpscope] measure cost: %d recomputes, %.2f ms total, %.2f ms each, " "data %.2f ms (%.0f%%), covered=%llu buckets=%llu raw=%llu (%.1f%%), " "%.1f ns/covered sample\n", app.measure_passes, app.measure_cost_us / 1000.0, app.measure_cost_us / 1000.0 / app.measure_passes, app.measure_fetch_us / 1000.0, app.measure_cost_us > 0 ? 100.0 * static_cast(app.measure_fetch_us) / static_cast(app.measure_cost_us) : 0.0, static_cast(app.measure_covered), static_cast(app.measure_bucketed), static_cast(app.measure_raw), app.measure_covered > 0 ? 100.0 * static_cast(app.measure_raw) / static_cast(app.measure_covered) : 0.0, app.measure_covered > 0 ? 1000.0 * static_cast(app.measure_cost_us) / static_cast(app.measure_covered) : 0.0); } } std::printf("[udpscope] badge frames: lit=%d dim=%d idle=%d\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); } } { static const char* const kModes[] = {"vertical", "horizontal", "both", "mix"}; const int mode = static_cast(rows.cursor_mode()); // What the *plot* holds, which is what the renderer draws from: a marker the app // placed but the plot parked (or hid) would show up here and not on screen. for (int i = 0; i < rows.row_count(); ++i) { const RowSet::Row& r = rows.row(i); if (r.plot == nullptr) continue; const plot::Plot& p = *r.plot; std::printf("[udpscope] cursors in plot %d: vis=%d a=(x=%.6f vis=%d) " "b=(x=%.6f vis=%d) ruler=(y=%.6f vis=%d) ruler_b=(y=%.6f vis=%d) " "stored_rulers=%.6f,%.6f\n", i, rows.cursors_visible() ? 1 : 0, p.cursor(r.cursor_a).x, p.cursor(r.cursor_a).visible, p.cursor(r.cursor_b).x, p.cursor(r.cursor_b).visible, p.cursor(r.ruler).y, p.cursor(r.ruler).visible, p.cursor(r.ruler_b).y, p.cursor(r.ruler_b).visible, r.ruler_y, r.ruler_b_y); // Where the plot would draw them: the row's own axes rect and the device // coordinate the ruler's y maps to (`cursor_screen_coordinate`). const plot::PlotLayout& lay = p.layout(); std::printf("[udpscope] cursors in plot %d: axes=(%.0f,%.0f %.0fx%.0f) " "ruler_screen=%.1f ruler_b_screen=%.1f y_range=[%.4g..%.4g]\n", i, double(lay.axes.x), double(lay.axes.y), double(lay.axes.w), double(lay.axes.h), double(p.cursor_screen_coordinate(r.ruler)), double(p.cursor_screen_coordinate(r.ruler_b)), p.y_range(r.axis_y).min, p.y_range(r.axis_y).max); } std::printf("[udpscope] cursors: on=%d mode=%s t0=%.6f t1=%.6f dt=%.6f", rows.cursors_visible() ? 1 : 0, kModes[mode], rows.cursor_a_x(), rows.cursor_b_x(), rows.cursor_b_x() - rows.cursor_a_x()); for (int r = 0; r < rows.row_count(); ++r) { std::printf(" y%d=[%.6g,%.6g]", r, rows.ruler_y(r), rows.ruler_b_y(r)); } std::printf("\n"); } for (std::size_t i = 0; i < app.measures.size(); ++i) { const Measure& m = app.measures[i]; const MeasureResult& r = i < app.measure_results.size() ? app.measure_results[i] : MeasureResult{}; std::printf("[udpscope] measure %zu: '%s' %s thr=%.1f%% above=%d show=%d -> %s " "%.9g", i, m.signal.c_str(), measure_type_name(m.type), m.options.threshold_pct, m.options.above ? 1 : 0, m.options.show_on_plot ? 1 : 0, r.valid ? "ok" : "invalid", r.valid ? r.primary : 0.0); // What the row *reads*: the formatted string with its unit (so `frequency` showing Hz // is asserted here, not inferred), and the row's chip count (two: the measurement's // colour and the signal's). if (i < app.measure_text.size()) std::printf(" text=[%s]", app.measure_text[i].c_str()); if (r.valid && r.has_secondary) std::printf(" duty=%.6g", r.secondary); if (r.valid && !r.marks.empty()) { // One entry per mark with its kind: the mix is the point (`min` is a ruler *and* a // marker), so a shape that could only say "level" or "time" would hide the case this // asserts. std::printf(" marks="); for (std::size_t k = 0; k < r.marks.size(); ++k) { if (k != 0) std::printf(","); std::printf("%s:%.9g", r.marks[k].level ? "level" : "time", r.marks[k].value); } } std::printf("\n"); } { // The x label form: relative to the window's own start (with the caption that names // that start) when the labels could not carry the tick step absolutely, absolute // otherwise. `x_origin_set` is read back out of the plot, so this is what the axis is // actually formatting with, and `note` is the plot's own caption string. const RowSet::Row& rlt = rows.row(rows.row_count() - 1); std::string note; if (rlt.plot != nullptr) note = rlt.plot->x_note(); // The labels themselves, from the axis the reader looks at: the assertion is what the // ticks *say*, not only which mode is on. std::string ticks; bool distinct = true; if (rlt.plot != nullptr) { int shown = 0; std::vector seen; for (const plot::Tick& t : rlt.plot->x_axis(rlt.axis_x).ticks) { if (!t.major) continue; if (std::find(seen.begin(), seen.end(), t.label) != seen.end()) distinct = false; seen.emplace_back(t.label); if (shown++ < 4) { if (shown != 1) ticks += ","; ticks += t.label; } } } // The labels against the anchor they were built with: the dump runs after the last // paint, so unlike the probe's timer there is no race here -- the first major must sit // on the origin (`first_rel` the fraction of a step the window has scrolled since). double first_value = 0.0; double origin_set = 0.0; if (rlt.plot != nullptr) { origin_set = rlt.plot->x_axis_origin(rlt.axis_x); for (const plot::Tick& t : rlt.plot->x_axis(rlt.axis_x).ticks) { if (t.major) { first_value = t.value; break; } } } std::printf("[udpscope] time labels: relative=%d distinct=%d origin=%.6f note=[%s] " "x_origin_set=%.6f ticks=[%s] first_rel=%.9f\n", rows.time_labels_relative() ? 1 : 0, distinct ? 1 : 0, rows.time_labels_origin(), note.c_str(), origin_set, ticks.c_str(), first_value - origin_set); } { std::lock_guard lock(model.mutex()); // `results_for` is the window the numbers describe, `win` the one on screen now: the // drift between them is the assertion that a measurement is a statistic of the *current* // window -- a timer-decoupled recomputation is what used to leave a marker outside it. const RowSet::Row& r0 = rows.row(0); const plot::Range win = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{}; const double span = app.measure_window.span(); int in_window = 0; int marks = 0; for (const RowSet::MeasureMark& mk : app.measure_marks) { if (mk.level) continue; ++marks; if (mk.value >= app.measure_window.min && mk.value <= app.measure_window.max) ++in_window; } // Whether the marks the app placed are *unselectable* -- the property the pointer's hit // test respects. Read back out of the plot, so this is the app's own setting and not // the intent behind it. int selectable = 0; for (int i = 0; i < rows.row_count(); ++i) { const RowSet::Row& r = rows.row(i); if (r.plot == nullptr) continue; for (int m = 0; m < RowSet::kMeasureMarks; ++m) { const std::size_t mi = static_cast(m); if (r.plot->cursor(r.measure_h[mi]).selectable) ++selectable; if (r.plot->cursor(r.measure_v[mi]).selectable) ++selectable; } } std::printf("[udpscope] measures window: win=[%.6f..%.6f] results_for=[%.6f..%.6f] " "drift=%.4f%% marks_in_window=%d/%d marks_selectable=%d\n", win.min, win.max, app.measure_window.min, app.measure_window.max, span > 0.0 ? 100.0 * (std::fabs(win.min - app.measure_window.min) + std::fabs(win.max - app.measure_window.max)) / span : 0.0, in_window, marks, selectable); } { std::lock_guard lock(model.mutex()); std::printf("[udpscope] measures pane: n=%zu revealed=%d marks_shown=%d pane_rows=%zu " "pane_w=%d pane_h=%d\n", app.measures.size(), app.measures_revealed ? 1 : 0, rows.measure_marks_placed(), app.measure_labels.size(), app.measures_list != nullptr ? gtk_widget_get_width(app.measures_list) : -1, app.measures_list != nullptr ? gtk_widget_get_height(app.measures_list) : -1); } std::printf("[udpscope] header: window_button_w=%d window_label_w=%d (bar_w=%d)\n", app.dbg_win_btn_w, app.dbg_win_label_w, app.dbg_bar_w); std::printf("[udpscope] layout: rows=%d window_s=%.6g", rows.row_count(), rows.time_window()); for (int r = 0; r < rows.row_count() && r < RowSet::kMaxRows; ++r) { std::printf(" row%d=[", r); for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { if (s != 0) std::printf(","); std::printf("'%s'", rows.row(r).signal_name[static_cast(s)].c_str()); } std::printf("]"); } std::printf("\n"); for (int r = 0; r < rows.row_count() && r < RowSet::kMaxRows; ++r) { for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { const int ch = rows.channel_of(r, s); if (ch < 0) continue; ChannelBuffer* buf = rows.row(r).slots[static_cast(s)].bound(); std::printf("[udpscope] row %d slot %d -> channel %d '%s' samples=%llu\n", r, s, ch, rows.row(r).name[static_cast(s)].c_str(), static_cast(buf != nullptr ? buf->size() : 0)); } } } return result; } } // namespace udpscope