Files
udpscope/src/ui.cpp
T
Martino Ferrari 8918494acb feat: a tidy button lays the graph out by depth, so no two boxes overlap
A single forward pass -- the list is already in evaluation order, so every
input's depth is in hand by the time the reader is reached -- then a fit. The
column pitch is `kNodeW + 48` rather than the add-node grid's 160, which left
28 units for a wire's 40-unit tread and made the bezier double back.
2026-10-07 02:44:50 +02:00

9639 lines
467 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <gtk4/gtk_plot_area.hpp>
#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 <adwaita.h>
#include <gtk/gtk.h>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
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<GtkWidget*> 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;
/// When the pause began, on the **monotonic wall clock**, or 0 while running. The elapsed time
/// the status bar shows has to be wall time rather than stream time: the producer never stops
/// while paused (it keeps filling the live ring, which is what makes the resume seamless), so
/// the stream's own clock advances whether the view is frozen or not and would say nothing
/// about how long the user has been looking at a still picture.
gint64 pause_started_us = 0;
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<CursorLine> 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<Measure> measures;
std::vector<MeasureResult> 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<MathChannel> 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<GtkWidget*> 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<std::string> 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`).
///
/// It is kept in **stream** seconds (through `ScopeModel::stream_at_x0()`), not in the display x
/// it was read as, because a live view is newest-relative: the axis there is a fixed
/// `[-window, 0]` whatever arrives, so "the window moved" is invisible in display coordinates
/// and the pane would compute once and then never again -- the readout frozen on the samples
/// that happened to be on screen when the pane opened.
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<double> pct; ///< the level shares asked for, deduplicated
std::vector<MeasureLevel> level; ///< one accumulator per entry of `pct`
bool ok = false;
};
std::unordered_map<int, MeasureWindow> 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<MeasureBucket> 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<plot::f32> 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<MeasureResult> measure_last_valid;
/// The marks the last computation produced: re-placed every frame, which is what makes the
/// measurement cursors read-only.
///
/// A **time** mark's `value` is held in stream seconds, like `measure_window`, and converted to
/// the axis' own x by `place_measure_marks()` on the frame it is placed. That is what keeps a
/// mark glued to the sample it measured while a live view rolls the data leftwards under it --
/// a mark is a *result*, so it travels with its datum, which is the opposite of a cursor (an
/// instrument the user put at a position and that stays there). A level mark's `value` is a y
/// and has nothing to do with either clock.
std::vector<RowSet::MeasureMark> measure_marks;
/// `measure_marks` with the time marks converted to display x, rebuilt in place each frame
/// rather than allocated: this is on the frame tick's path.
std::vector<RowSet::MeasureMark> measure_marks_display;
/// 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;
/// The measurements pane's allocation, for the same reason and more urgently: the dump used
/// to ask the widget itself, which is where the two documented `Gtk-CRITICAL`s came from --
/// and a critical is the lucky outcome. The pointer is freed memory by then, so a run that
/// built and destroyed other windows first (`UDPSCOPE_PROBE_HELP_MS`, three of them) has had
/// that memory reused and the read is a **SIGSEGV** in the middle of the dump.
int dbg_measures_pane_w = -1;
int dbg_measures_pane_h = -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 `<host>_<port>.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;
/// True once the renderer's `Style` has been stated (see `tick()`): the renderer does not
/// exist until the area is realized, so the one-shot has to be a tick's job.
bool style_stated = 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<double> stored_rate;
std::vector<double> 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;
/// Frames on which at least one trace's geometry was reduced onto the pixel grid (one
/// vertical segment per column). It is orthogonal to `raw`/`lod` on purpose -- the
/// reduction is gated on the *density* of the gathered points, not on which level
/// produced them -- and it is the only reading that distinguishes "the segment count
/// collapsed because the reduction ran" from "the window got narrower".
int dbg_column_frames = 0;
/// Frames on which at least one reduced column was drawn **graded by dwell** rather than
/// as one flat band (`Style::intensity_grading`, which this app turns on). A subset of
/// `columns`: grading is a way of drawing the reduction, not a third path, so the two
/// counts being equal is what says the look is reaching every reduced frame.
int dbg_graded_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;
/// The worst distance, in pixels, between where a sample is *drawn* and the x a readout
/// quotes for the same sample. `gather()` stages x as an f32 relative to an origin the
/// renderer names, while `Plot::crosshair_point()` reads `x_at()` in exact f64, so this is
/// the quantisation of that f32 expressed in the unit the user sees it in. Measured on the
/// raw levels only, where a gathered point *is* a sample; above them a point is a bucket's
/// envelope and has no single sample to be off from.
double dbg_ink_worst_offset_px = 0.0;
int dbg_ink_raw_checked = 0; ///< frames the figure above was measured over
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[4] = {0, 0, 0, 0};
int dbg_reset_visible = -1;
std::vector<std::uint64_t> 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<SignalInfo>& 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<std::size_t>(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<bool> 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<App*>(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<int>(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<std::size_t>(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. `Connecting` is the
/// half-way state -- the socket answered but the stream has sent no CONFIG, so there is no
/// signal list and nothing to acquire -- and it is steady yellow rather than green,
/// because a blinking green badge over a source that has sent nothing claims a working
/// pipeline.
enum class BadgePhase { Lit = 0, Dim = 1, Idle = 2, Connecting = 3 };
/// The badge is one glyph in one of four 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 "<span foreground=\"#45E045\">\u25CF</span>";
case BadgePhase::Dim:
return "<span foreground=\"#1C5A1C\">\u25CF</span>";
case BadgePhase::Connecting:
return "<span foreground=\"#E0B020\">\u25CF</span>";
case BadgePhase::Idle:
break;
}
return "<span foreground=\"#5A5A5A\">\u25CF</span>";
}
/// 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<int>(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<plot::u32>(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<std::size_t>(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<plot::f32>& xy = app.measure_ink_scratch;
xy.clear();
// The same origin the renderer names (`x_range.min`), so the reconstruction below is its
// reconstruction: see `plot::data::Series::gather()` for why the caller owns it.
s->gather(level, first, last, axis.min, xy);
const std::size_t pts = xy.size() / 2;
std::size_t kept = 0;
double lo = 0.0;
double hi = 0.0;
// Where the filter's drops fall, and how far back each one steps. The origin is a constant
// for the walk's duration *by construction* now -- the renderer reconstructs
// `gather[i] + x_range.min`, a local of its own, where it used to re-read `series.x0()` on
// every point and a ring-backed `x0()` advances with the producer. So a drop here is a
// genuinely out-of-order gather, and `back` says by how much.
std::size_t drop_first = 0;
std::size_t drop_last = 0;
double worst_back = 0.0;
bool any_drop = false;
{
double next = std::numeric_limits<double>::infinity();
for (std::size_t k = pts; k > 0; --k) {
const double x = double(xy[2 * (k - 1)]) + axis.min;
if (!(x < next)) {
worst_back = std::max(worst_back, x - next);
if (!any_drop) drop_last = k - 1;
any_drop = true;
drop_first = k - 1;
continue;
}
next = x;
if (kept == 0) hi = x;
lo = x;
++kept;
}
}
++app.dbg_ink_checked;
// Drawn against quoted, in pixels. At level 0 a gathered pair *is* sample `first + k`, so
// this is the one place the two numbers the user compares by eye -- the polyline's position
// and the crosshair's readout -- can be differenced. The origin is what decides it: the f32
// resolves 2^-24 of whatever span it is relative to, so a view narrower than that fraction
// of the *retained window* drew a sample visibly away from the x quoted for it, which is
// what "when zooming there is a visible offset between the real data point and the crosshair
// selected one" was.
if (level == 0 && pts > 0) {
const double px_per_x = axes_w / axis.span();
double worst = 0.0;
for (std::size_t k = 0; k < pts; ++k) {
const double drawn = axis.min + double(xy[2 * k]);
worst = std::max(worst, std::abs(drawn - s->x_at(first + k)) * px_per_x);
}
app.dbg_ink_worst_offset_px = std::max(app.dbg_ink_worst_offset_px, worst);
++app.dbg_ink_raw_checked;
}
const double cover = kept >= 2 ? (hi - lo) / axis.span() : 0.0;
app.dbg_ink_worst_cover = std::min(app.dbg_ink_worst_cover, cover);
const bool collapse = kept < 2;
const bool staircase = pts >= 8 && kept * 4 < pts;
// A ring that has not yet reached back to the axis' left edge is *meant* to leave its left
// side empty (see the `clamp_to_data()` invariant), so a short ink there is the buffer
// filling and not a defect: it is only reported once the data spans the window.
const bool short_ink = !collapse && cover < 0.25 && s->x_bounds().min <= axis.min;
if (collapse) ++app.dbg_ink_collapse;
if (staircase) ++app.dbg_ink_staircase;
if (short_ink) ++app.dbg_ink_short;
if ((collapse || staircase || short_ink) && app.dbg_ink_reports < 40) {
++app.dbg_ink_reports;
// The series' own answers beside the reconstruction, because the two disagreeing is itself
// a reading: `rel` is what `gather()` emitted (relative to `axis.min`, the origin it was
// given) while `x0`/`x_at`/`bounds` are the window the series says it holds, so a trace
// whose ink sits a whole ring-span off its axis separates into "the stamps moved under us"
// and "the window is the wrong one".
const plot::Range xb = s->x_bounds();
std::fprintf(stderr,
"[ink] paint %d %s%s%s mode=%s axis=[%.6f..%.6f] n=%llu idx=[%llu..%llu) "
"spp=%.1f level=%u pts=%zu kept=%zu ink=[%.6f..%.6f] cover=%.1f%% "
"x0=%.6f x_at=[%.6f..%.6f] bounds=[%.6f..%.6f] rel=[%.6f..%.6f] dx=%.3g "
"drops=[%zu..%zu] back=%.3g\n",
app.dbg_paint_n, collapse ? "collapse " : "", staircase ? "staircase " : "",
short_ink ? "short " : "", display_mode_name(app.dbg_display), axis.min, axis.max,
(unsigned long long)n, (unsigned long long)first, (unsigned long long)last, spp,
level, pts, kept, lo, hi, cover * 100.0, s->x0(), s->x_at(0), s->x_at(n - 1),
xb.min, xb.max, pts > 0 ? double(xy[0]) : 0.0,
pts > 0 ? double(xy[2 * (pts - 1)]) : 0.0, s->dx(), drop_first, drop_last,
worst_back);
std::fflush(stderr);
}
}
/// Frame-cost probe, end of the plot's own draw. Connected *after* the adapter's
/// `::render`, which is the whole reason `PlotArea::on_render` returns FALSE: the signal
/// accumulates with `g_signal_accumulator_true_handled`, so a TRUE there would stop the
/// emission and this would never run. Reading the stats here rather than in `tick()` is
/// what makes them this frame's: `begin_frame()` resets them.
int on_render_probe(GtkGLArea*, GdkGLContext*, gpointer user) {
App* app = static_cast<App*>(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.column_traces > 0) ++app->dbg_column_frames;
if (s.graded_traces > 0) ++app->dbg_graded_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<std::size_t>(sl)] < 0) continue;
const plot::Range b = r.slots[static_cast<std::size_t>(sl)].x_bounds();
std::fprintf(stderr, " slot%d n=%zu x=[%.6f..%.6f]", sl,
r.slots[static_cast<std::size_t>(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<App*>(user)->dbg_paint_start_us = g_get_monotonic_time();
}
void on_after_paint(GdkFrameClock*, gpointer user) {
App* app = static_cast<App*>(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<App*>(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<App*>(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<int>(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<double>(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<double>(fl.rows[static_cast<std::size_t>(idx)].y) / scale
: (static_cast<double>(fl.rows.back().y) +
static_cast<double>(fl.rows.back().h)) / scale;
const double top = std::clamp(edge - half, 0.0, height - kLayoutButtonPx);
gtk_widget_set_margin_top(b, static_cast<int>(top + 0.5));
} else {
const plot::Rect& r = fl.rows[static_cast<std::size_t>(idx)];
const double top = std::clamp(static_cast<double>(r.y) / scale + kLayoutButtonPad,
0.0, height - kLayoutButtonPx);
gtk_widget_set_margin_top(b, static_cast<int>(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<double>(p->layout().axes.x) / scale
: static_cast<double>(r.x) / scale) +
kLayoutButtonPad;
gtk_widget_set_margin_start(b, static_cast<int>(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<App*>(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<App*>(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<App*>(user);
if (app->updating_panels) return;
const std::size_t index = static_cast<std::size_t>(
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<App*>(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<std::mutex> 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<std::size_t>(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<std::mutex> 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<App*>(user));
}
/// The same publish, for the two dropdowns. It needs its own entry point because
/// `notify::<prop>` 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<App*>(user));
}
void on_trigger_signal_changed(GObject* obj, GParamSpec*, gpointer user) {
App* app = static_cast<App*>(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<std::mutex> lock(app->model->mutex());
if (sel >= app->model->channels().size()) return;
app->model->trigger().channel = static_cast<int>(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<App*>(user);
const std::size_t index = static_cast<std::size_t>(
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<CalibContext*>(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<CalibContext*>(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<DragContext*>(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<SignalInfo>& 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), "<span foreground=\"#%02X%02X%02X\">\u25CF</span>",
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<SignalInfo>& 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<std::size_t>(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<SignalInfo>& 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<App*>(user);
const std::size_t idx = static_cast<std::size_t>(
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<DragContext*>(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<ChannelInfo>& channels = app.model->channels();
app.updating_panels = true;
std::vector<std::string> storage;
storage.reserve(channels.size());
for (const ChannelInfo& c : channels) {
storage.push_back(c.unit.empty() ? c.name : c.name + " [" + c.unit + "]");
}
std::vector<const char*> 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<int>(storage.size())
? 0u
: static_cast<guint>(sel));
if (sel >= 0 && sel < static_cast<int>(channels.size()) && !app.trigger_level_touched) {
const SignalInfo& sig =
app.model->signals()[static_cast<std::size_t>(channels[static_cast<std::size_t>(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<guint>(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<App*>(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<App*>(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<guint>(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<App*>(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<App*>(user);
const bool paused = gtk_toggle_button_get_active(button);
app->acq->set_paused(paused);
app->model->set_paused(paused);
if (paused) {
// Freeze the *view*: snapshot exactly the window on screen, and pin the axes there. The
// acquisition thread keeps writing the live ring behind it, so resuming shows the data that
// arrived while paused instead of a hole.
if (RowSet::Row& r0 = app->rows->row(0); r0.plot != nullptr) {
const plot::Range view = r0.plot->x_range(r0.axis_x);
// The axis is in display x and the ring is on the stream's clock, so the window has to be
// put back on that clock to be cut -- `stream_at_x0()` is the one accessor that is right in
// every mode, including a pause taken while an event is *held*, where the axis is
// edge-relative and the old `- display_epoch()` form searched the ring with event
// coordinates. The same offset is handed to the snapshot as its `x_offset`, so its stamps
// come back rebased onto the x the axis is already showing and the frozen picture does not
// move at the instant of the pause.
const double s0 = app->model->stream_at_x0();
app->model->publish_pause_view(view.min + s0, view.max + s0, s0);
app->pause_started_us = g_get_monotonic_time();
// Pin on what the snapshot *got*: the cut is clamped to the ring, so it can be narrower than
// the view, and pinning the request left the shortfall as an empty left margin.
plot::Range pin = view;
if (std::shared_ptr<const EventSnapshot> snap = app->model->pause_view()) {
if (!snap->stamps.empty()) {
pin = plot::Range{static_cast<double>(snap->stamps.front()),
static_cast<double>(snap->stamps.back())};
}
}
app->rows->pin_pause_window(pin.min, pin.max);
}
} else {
app->model->clear_pause_view();
app->rows->unpin_pause_window();
app->pause_started_us = 0;
}
gtk_button_set_icon_name(GTK_BUTTON(button), paused ? "media-playback-start-symbolic"
: "media-playback-pause-symbolic");
gtk_widget_set_tooltip_text(GTK_WIDGET(button), paused ? "resume acquisition"
: "pause acquisition");
}
void on_trigger_toggled(GtkToggleButton* button, gpointer user) {
App* app = static_cast<App*>(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<std::mutex> 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);
// The epoch is *not* written here: the frame tick owns it and states the live answer -- minus
// the newest acquired sample -- after it has bound and acquired, which is the only moment the
// right number is knowable. This used to set it to 0 ("seconds since the stream started"),
// which is no longer a display coordinate anything uses. Nothing is left stale in the
// meantime: the group follows the live edge again from here, and `Figure::layout()` re-derives
// the range from it on every frame.
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<std::size_t>(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<App*>(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<double>& time,
std::vector<double>& 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<const EventSnapshot> snap = app.model->display_snapshot()) {
if (snap == nullptr || channel >= snap->values.size()) return false;
const std::vector<f64>& st = snap->stamps;
const std::vector<f32>& 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<std::size_t>(hi - lo);
if (count == 0) return false;
const std::size_t off = static_cast<std::size_t>(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<double>(vals[off + i]);
}
return true;
}
std::lock_guard<std::mutex> 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<std::size_t>(n));
value.resize(static_cast<std::size_t>(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<f64> t_scratch(kChunk);
std::vector<f32> v_scratch(kChunk);
for (u64 at = 0; at < n; at += kChunk) {
const u64 take = std::min<u64>(kChunk, n - at);
store.time().copy_from(first + at, take, t_scratch.data());
for (u64 i = 0; i < take; ++i) {
time[static_cast<std::size_t>(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<std::size_t>(at + i)] = static_cast<double>(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<double>` 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<f64>& 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<std::size_t>(lo - st.begin());
count_ = static_cast<std::size_t>(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<std::mutex>(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<std::size_t>(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<u64>(origin_, pyr_->window_base());
const u64 end = std::min<u64>(origin_ + count_, pyr_->window_end());
if (end <= begin) return false;
const u64 stored = pyr_->level_base(0);
first = std::max<u64>(stored, (begin + plot::data::kBucket - 1) / plot::data::kBucket);
last = std::min<u64>(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<std::size_t>(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<double>(mm.min);
out.hi = static_cast<double>(mm.max);
out.sum = pyr_->bucket_sum(0, k);
out.count = static_cast<std::size_t>(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<const EventSnapshot> snap_;
const std::vector<f32>* snap_vals_ = nullptr;
std::size_t off_ = 0;
std::unique_lock<std::mutex> 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<f32> 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<std::mutex> lock(app.model->mutex());
const std::vector<SignalInfo>& signals = app.model->signals();
const std::vector<ChannelInfo>& 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<int>(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<std::size_t>(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<std::mutex> 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<std::mutex> lock(app.model->mutex());
const std::vector<SignalInfo>& 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<int>(index) + 1));
g_signal_connect(edit, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
App* a = static_cast<App*>(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<App*>(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<RowSet::CursorMode>(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<int>(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<std::mutex> 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<std::size_t>(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<std::size_t>(ch) >= app.model->channels().size()) continue;
const std::string& u = app.model->channels()[static_cast<std::size_t>(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<std::mutex> lock(app.model->mutex());
const RowSet::Row& r = app.rows->row(row);
const int ch = r.channel[static_cast<std::size_t>(slot)];
if (ch < 0 || static_cast<std::size_t>(ch) >= app.model->channels().size()) return "";
return app.model->channels()[static_cast<std::size_t>(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<std::size_t>(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<int>(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<std::size_t>(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<int>(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<plot::u32>(line.slot), t0, y1);
const bool b = row.plot != nullptr &&
row.plot->crosshair_value(static_cast<plot::u32>(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<std::string> 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<std::size_t>(
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<MeasureDialog*>(user));
}
void on_measure_dialog_apply(GtkButton*, gpointer user) {
MeasureDialog* d = static_cast<MeasureDialog*>(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<std::size_t>(d->edit) < app.measures.size()) {
app.measures[static_cast<std::size_t>(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<MeasureDialog*>(user);
App& app = *d->app;
if (d->edit >= 0 && static_cast<std::size_t>(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<std::size_t>(edit_index) < app.measures.size()
? &app.measures[static_cast<std::size_t>(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<std::string> 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<std::size_t>(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<const char*> 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<int>(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<guint>(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<const char*> 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<App*>(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<MeasureDialog*>(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<guint>(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));
}
/// Puts `App::measure_marks` on the plots, converting each **time** mark from the stream seconds
/// it is stored in to the x the axis is reading this frame. Called every frame -- that is what
/// makes the marks read-only (a gesture moves one, and this puts it back) and, since a live axis
/// is newest-relative, also what rolls each mark leftwards with the sample it measured.
void place_measure_marks(App& app) {
const double s0 = app.model->stream_at_x0();
app.measure_marks_display = app.measure_marks;
for (RowSet::MeasureMark& mk : app.measure_marks_display) {
if (!mk.level) mk.value -= s0;
}
app.rows->set_measure_marks(app.measure_marks_display);
}
/// 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;
// The window on the **stream's** clock, which is the only space in which "it moved" is a
// question with an answer: a live axis is newest-relative, so `view` itself is a fixed
// `[-window, 0]` that never changes while the data under it does.
const double s0 = app.model->stream_at_x0();
const plot::Range view = r0.plot->x_range(r0.axis_x);
const plot::Range swin{view.min + s0, view.max + s0};
const bool held = app.model->display_mode() == DisplayMode::Held;
const bool moved = app.measure_window_valid && !held &&
std::fabs(swin.min - app.measure_window.min) +
std::fabs(swin.max - app.measure_window.max) >
0.02 * std::max(1.0e-12, swin.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) {
place_measure_marks(app);
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.
place_measure_marks(app);
return;
}
app.measure_window = swin;
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<RowSet::MeasureMark> 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<int> chan(app.measures.size(), -1);
std::vector<int> 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<int>(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<MeasureBucket>& 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<std::size_t>(entry.first));
const std::size_t n = reader.size();
const std::function<double(std::size_t)> 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<std::size_t>(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) {
// The level *and* its hysteresis band, in the engine: how wide the band is is a property of
// how an edge is counted, not of the pane, and two expressions for it is how the walk and
// the bucket fast path end up disagreeing about where a crossing was.
measure_set_level(w.level[k], w.stats, w.pct[k]);
}
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<std::uint64_t>(n);
app.measure_bucketed += static_cast<std::uint64_t>(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<std::size_t>(want_level[i]) < w.level.size()
? &w.level[static_cast<std::size_t>(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;
// A time mark is stored on the **stream's** clock -- the walks report display x, so `s0`
// puts it back -- and `place_measure_marks()` converts it again on every frame it draws.
// That is what keeps the mark on its own sample as a live view rolls underneath it; a level
// mark is a y and is stored as it comes.
marks.push_back({row, spec.level, spec.level ? spec.value : spec.value + s0, color});
}
}
app.measure_marks = std::move(marks);
place_measure_marks(app);
}
/// 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<std::mutex> lock(app.model->mutex());
if (!app.model->channels().empty()) name = app.model->channels().front().name;
}
MathChannel m;
// A name of its own: calling it after its first source would put a second entry with that name in
// the signal panel, where the user cannot tell the derived one from the streamed one.
m.name = "math" + std::to_string(app.math.size() + 1);
m.enabled = true;
MathNode src;
src.op = MathOp::Source;
src.source = name;
src.x = 30.0;
src.y = 40.0;
m.nodes.push_back(src);
m.output = 0;
app.math.push_back(std::move(m));
apply_math_channels(app);
}
// The node editor: a compact graph. Each node is a box; inputs are the squares on its left edge,
// the output the square on its right. Click a box to select it (its parameters appear in the form
// beside the canvas), drag from an output square to an input square to wire them, drag a wired
// input off to detach it, drag a box to move it. The graph is a *list*, walked in order, so a node
// may only read ones above it -- 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;
/// A wire, named by the node that *consumes* it and which of its inputs. The consumer is the
/// identity because that is where a wire is stored: `in_a`/`in_b` on the reader. A producer may
/// feed several inputs and so cannot name one.
struct MathWireRef {
int node = -1;
int which = 0;
};
enum class MathDrag { None, Node, Wire, Pan };
struct MathDialog {
App* app = nullptr;
int edit = -1;
std::vector<MathNode> nodes;
int output = 0;
int selected = 0;
/// What a drag is doing, decided once at `drag-begin` from what is under the press and not
/// revisited: a gesture that changes its mind mid-drag is unusable. `Wire` carries a loose end
/// in graph units, which the draw shows so the user can see what is being connected.
MathDrag drag_mode = MathDrag::None;
int wire_from = -1;
int wire_detach = -1;
int wire_detach_which = 0;
double wire_x = 0.0;
double wire_y = 0.0;
double pan_x0 = 0.0;
double pan_y0 = 0.0;
/// `Wire`: the press point in canvas coordinates. The gesture's `dx`/`dy` is relative to this
/// point, so `press_cx + dx` is always the pointer, even on a detach where `wire_from` is a
/// different node from the one the pointer touched.
double press_cx = 0.0;
double press_cy = 0.0;
/// 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;
/// The wire `Delete` would remove, or `{-1, 0}`.
MathWireRef selected_wire{-1, 0};
/// The view: canvas pixels per graph unit, and where graph (0,0) is drawn. Applied once to the
/// cairo context by `math_canvas_draw()`; every hit test runs the inverse (`math_to_graph()`)
/// rather than comparing a pointer position against a graph one, and sizes its tolerance in
/// *screen* pixels (`kPort / view_scale`) -- a tolerance left in graph units shrinks with the
/// view and makes a port unhittable zoomed out.
double view_scale = 1.0;
double view_x = 0.0;
double view_y = 0.0;
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 validation line and the button it governs. Both are set from `math_dialog_problem()` and
/// from nowhere else, so what the line says and what the button allows cannot disagree.
GtkWidget* problem = nullptr;
GtkWidget* apply = 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<std::string> math_source_names(App& app) {
std::vector<std::string> names;
std::lock_guard<std::mutex> lock(app.model->mutex());
for (const ChannelInfo& c : app.model->channels()) names.push_back(c.name);
return names;
}
void math_dialog_sync(MathDialog* d);
/// Why this graph could not be saved, or "" when it could. Both checks in one place: the graph's own
/// (an output that exists, every input wired to a node that exists) and that a `Source` names a
/// channel the scope actually *acquires* -- which the engine's validator cannot see, since it knows
/// nothing about the acquired list. The two used to be a pair of modal alerts raised after Apply was
/// pressed, which is a refusal rather than guidance: there is nothing in the message a user could
/// not have been told while they were still building the graph.
std::string math_dialog_problem(const MathDialog* d) {
std::string why;
if (!math_channel_valid_nodes(d->nodes, d->output, &why)) return why;
const std::vector<std::string> acquired = math_source_names(*d->app);
for (std::size_t i = 0; i < d->nodes.size(); ++i) {
if (d->nodes[i].op != MathOp::Source) continue;
if (std::find(acquired.begin(), acquired.end(), d->nodes[i].source) != acquired.end()) continue;
return "node " + std::to_string(i + 1) + ": '" + d->nodes[i].source +
"' is not an acquired channel";
}
return std::string();
}
/// The box a node is drawn in, in canvas pixels.
GdkRectangle math_node_rect(const MathNode& n) {
return GdkRectangle{static_cast<int>(n.x), static_cast<int>(n.y), static_cast<int>(kNodeW),
static_cast<int>(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<int>(d->nodes.size()) - 1; i >= 0; --i) {
const GdkRectangle r = math_node_rect(d->nodes[static_cast<std::size_t>(i)]);
if (x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height) return i;
}
return -1;
}
/// A port's identity: `kPortOut` for a node's output, 0 or 1 for one of its inputs.
constexpr int kPortOut = -1;
/// The port under a canvas position: the **node index** it belongs to, with `which` set to
/// `kPortOut` or the input number; `-1` when there is no port there. `tol` is the hit radius in
/// the same units as `x`/`y`.
///
/// It returns the node rather than a sentinel because the output square is drawn *straddling* the
/// box's right edge (centred on `n.x + kNodeW`, half-width `kPort * 0.5`) and hit with a radius of
/// `kPort` -- so a caller that recovered the node with `math_node_at()`, which matches only inside
/// the box, got `-1` for every pixel past the edge. That was a silent disarm of the first click of
/// a wiring gesture, and it is the whole of "edges cannot be added".
///
/// Topmost-first, like `math_node_at()`: when two boxes overlap, the one drawn on top owns the port.
int math_port_at(const MathDialog* d, double x, double y, double tol, int& which) {
which = kPortOut;
for (int i = static_cast<int>(d->nodes.size()) - 1; i >= 0; --i) {
const MathNode& n = d->nodes[static_cast<std::size_t>(i)];
double px = 0.0;
double py = 0.0;
math_output_pos(n, px, py);
if (std::fabs(x - px) <= tol && std::fabs(y - py) <= tol) {
which = kPortOut;
return i;
}
for (int k = 0; k < math_op_arity(n.op); ++k) {
math_input_pos(n, k, px, py);
if (std::fabs(x - px) <= tol && std::fabs(y - py) <= tol) {
which = k;
return i;
}
}
}
return -1;
}
/// The smallest and largest the canvas may be scaled to. Below a quarter the op names are
/// unreadable; above four a box fills the canvas and there is nothing left to navigate.
constexpr double kMathScaleMin = 0.25;
constexpr double kMathScaleMax = 4.0;
constexpr double kMathZoomStep = 1.1;
/// A canvas position in graph coordinates: the inverse of the transform the draw applies.
void math_to_graph(const MathDialog* d, double cx, double cy, double& gx, double& gy) {
gx = (cx - d->view_x) / d->view_scale;
gy = (cy - d->view_y) / d->view_scale;
}
/// Sets the scale, clamped. One helper for all three callers (the wheel, Fit, reset) so the bounds
/// cannot drift apart. Returns what it actually set.
double math_set_scale(MathDialog* d, double want) {
d->view_scale = std::clamp(want, kMathScaleMin, kMathScaleMax);
return d->view_scale;
}
/// Zooms about a canvas position: one step in for `dy < 0`, one out otherwise. The graph point
/// under `(px, py)` is still under it afterwards -- which is what makes the canvas follow the
/// hand rather than slide away from it.
void math_zoom_at(MathDialog* d, double px, double py, double dy) {
double gx = 0.0;
double gy = 0.0;
math_to_graph(d, px, py, gx, gy);
const double s = math_set_scale(d, d->view_scale * (dy < 0.0 ? kMathZoomStep
: 1.0 / kMathZoomStep));
d->view_x = px - gx * s;
d->view_y = py - gy * s;
gtk_widget_queue_draw(d->canvas);
}
void math_view_reset(MathDialog* d) {
math_set_scale(d, 1.0);
d->view_x = 0.0;
d->view_y = 0.0;
if (d->canvas != nullptr) gtk_widget_queue_draw(d->canvas);
}
/// Frames every node in the canvas, centred, at a scale that fits with a little margin.
void math_view_fit(MathDialog* d) {
if (d->nodes.empty()) {
math_view_reset(d);
return;
}
double x0 = d->nodes[0].x;
double y0 = d->nodes[0].y;
double x1 = x0 + kNodeW;
double y1 = y0 + kNodeH;
for (const MathNode& n : d->nodes) {
x0 = std::min(x0, n.x);
y0 = std::min(y0, n.y);
x1 = std::max(x1, n.x + kNodeW);
y1 = std::max(y1, n.y + kNodeH);
}
// The port squares stick out past the box on both sides, and a wire needs a little air.
constexpr double kPad = 16.0;
x0 -= kPad;
y0 -= kPad;
x1 += kPad;
y1 += kPad;
double w_px = d->canvas != nullptr ? gtk_widget_get_width(d->canvas) : 0;
double h_px = d->canvas != nullptr ? gtk_widget_get_height(d->canvas) : 0;
// Before the first allocation the widget has no size; its size request is the honest fallback.
if (w_px <= 0.0) w_px = 420.0;
if (h_px <= 0.0) h_px = 300.0;
const double s = math_set_scale(d, std::min(w_px / (x1 - x0), h_px / (y1 - y0)));
d->view_x = (w_px - (x1 - x0) * s) * 0.5 - x0 * s;
d->view_y = (h_px - (y1 - y0) * s) * 0.5 - y0 * s;
if (d->canvas != nullptr) gtk_widget_queue_draw(d->canvas);
}
/// Lays the graph out left to right by depth: a node sits one column right of the deepest thing it
/// reads, and its row is its position among the nodes at that depth. It needs no recursion and no
/// visited set -- the list is already in evaluation order, so a single forward pass has every
/// input's depth in hand by the time it reaches the node that reads it.
void math_tidy(MathDialog* d) {
const std::size_t n = d->nodes.size();
if (n == 0) return;
// Wider than the add-node grid's 160: a column pitch of `kNodeW + 48` leaves the wire its 40-unit
// tread on each side with room to curve. At 160 there were 28 units for a 40-unit tread, so the
// bezier's control points crossed and the wire doubled back on itself.
constexpr double kColPitch = kNodeW + 48.0;
constexpr double kRowPitch = kNodeH + 22.0;
std::vector<int> depth(n, 0);
std::vector<int> row(n, 0);
std::vector<int> used;
for (std::size_t i = 0; i < n; ++i) {
const MathNode& node = d->nodes[i];
int deepest = -1;
const int ins[2] = {node.in_a, node.in_b};
for (int k = 0; k < math_op_arity(node.op); ++k) {
if (ins[k] >= 0 && ins[k] < static_cast<int>(i)) {
deepest = std::max(deepest, depth[static_cast<std::size_t>(ins[k])]);
}
}
const int dep = deepest + 1;
depth[i] = dep;
if (static_cast<std::size_t>(dep) >= used.size()) used.resize(static_cast<std::size_t>(dep) + 1, 0);
row[i] = used[static_cast<std::size_t>(dep)]++;
}
for (std::size_t i = 0; i < n; ++i) {
d->nodes[i].x = 24.0 + static_cast<double>(depth[i]) * kColPitch;
d->nodes[i].y = 20.0 + static_cast<double>(row[i]) * kRowPitch;
}
// And frame it, since a tidy that leaves the graph off screen has moved the problem rather than
// solved it.
math_view_fit(d);
}
/// The port under a *canvas* position, with the tolerance in screen pixels. Every gesture goes
/// through this rather than converting for itself: `kPort` is a size on screen, so the graph-space
/// tolerance has to shrink as the view grows, and a handler that got that backwards makes a port
/// unhittable at one end of the zoom range and greedy at the other.
int math_port_at_canvas(const MathDialog* d, double cx, double cy, int& which) {
double gx = 0.0;
double gy = 0.0;
math_to_graph(d, cx, cy, gx, gy);
return math_port_at(d, gx, gy, kPort / d->view_scale, which);
}
/// The node body under a canvas position.
int math_node_at_canvas(const MathDialog* d, double cx, double cy) {
double gx = 0.0;
double gy = 0.0;
math_to_graph(d, cx, cy, gx, gy);
return math_node_at(d, gx, gy);
}
/// The wire under a graph position, or `{-1, 0}`. The wire is the cubic the draw lays down, so it
/// is *sampled* rather than solved: 24 segments is well under a pixel of chord error at the scales
/// this canvas allows, and a closed form for "nearest point on a cubic" is a quartic root find for
/// no gain. Topmost-first, matching every other hit test here.
MathWireRef math_wire_at(const MathDialog* d, double gx, double gy, double tol) {
constexpr int kSamples = 24;
for (int i = static_cast<int>(d->nodes.size()) - 1; i >= 0; --i) {
const MathNode& n = d->nodes[static_cast<std::size_t>(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<int>(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<std::size_t>(from)], ax, ay);
math_input_pos(n, k, bx, by);
const double c1x = ax + 40.0;
const double c2x = bx - 40.0;
for (int s = 0; s <= kSamples; ++s) {
const double t = static_cast<double>(s) / kSamples;
const double u = 1.0 - t;
const double px = u * u * u * ax + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * bx;
const double py = u * u * u * ay + 3 * u * u * t * ay + 3 * u * t * t * by + t * t * t * by;
if (std::fabs(gx - px) <= tol && std::fabs(gy - py) <= tol) {
return MathWireRef{i, k};
}
}
}
}
return MathWireRef{-1, 0};
}
/// The pick radius for a wire, in screen pixels. Smaller than a port's, so a wire that passes
/// behind a port never wins the press -- the port is tested first anyway, but a user aiming at a
/// port should not be able to miss it onto a wire either.
constexpr double kWirePick = 6.0;
MathWireRef math_wire_at_canvas(const MathDialog* d, double cx, double cy) {
double gx = 0.0;
double gy = 0.0;
math_to_graph(d, cx, cy, gx, gy);
return math_wire_at(d, gx, gy, kWirePick / d->view_scale);
}
/// Wires `from`'s output into `to`'s input `which`. The evaluator walks the node list in order, so
/// a reader must sit after what it reads -- and rather than refuse a wire drawn right to left (the
/// old editor did not even draw such an input as connectable, which made the order the *user's*
/// problem), the list is re-sorted so that it holds. Returns false and sets `why` for a wire that
/// cannot be ordered at all, i.e. one that would close a cycle; nothing is written in that case.
bool math_connect(MathDialog* d, int from, int to, int which, std::string* why) {
const int n = static_cast<int>(d->nodes.size());
if (from < 0 || from >= n || to < 0 || to >= n) return false;
if (from == to) {
if (why != nullptr) *why = "a node cannot read itself";
return false;
}
// Kahn's algorithm over the *trial* edge set, so a cycle is found before anything is written.
std::vector<int> in_a(static_cast<std::size_t>(n));
std::vector<int> in_b(static_cast<std::size_t>(n));
for (int i = 0; i < n; ++i) {
in_a[static_cast<std::size_t>(i)] = d->nodes[static_cast<std::size_t>(i)].in_a;
in_b[static_cast<std::size_t>(i)] = d->nodes[static_cast<std::size_t>(i)].in_b;
}
(which == 0 ? in_a : in_b)[static_cast<std::size_t>(to)] = from;
std::vector<int> indegree(static_cast<std::size_t>(n), 0);
const auto edges_of = [&](int i, int (&out)[2]) {
const MathNode& node = d->nodes[static_cast<std::size_t>(i)];
const int arity = math_op_arity(node.op);
out[0] = arity >= 1 ? in_a[static_cast<std::size_t>(i)] : -1;
out[1] = arity >= 2 ? in_b[static_cast<std::size_t>(i)] : -1;
};
for (int i = 0; i < n; ++i) {
int e[2] = {-1, -1};
edges_of(i, e);
for (int k = 0; k < 2; ++k) {
if (e[k] >= 0 && e[k] < n && e[k] != i) ++indegree[static_cast<std::size_t>(i)];
}
}
// Ties broken by the current list index, so a graph that is already in order is not permuted:
// the positions are the user's layout as much as the evaluator's order, and shuffling a valid
// graph on every wire would read as the editor rearranging itself.
std::vector<int> order;
order.reserve(static_cast<std::size_t>(n));
std::vector<bool> done(static_cast<std::size_t>(n), false);
for (int step = 0; step < n; ++step) {
int pick = -1;
for (int i = 0; i < n; ++i) {
if (!done[static_cast<std::size_t>(i)] && indegree[static_cast<std::size_t>(i)] == 0) {
pick = i;
break;
}
}
if (pick < 0) {
if (why != nullptr) *why = "that wire would make a loop";
return false;
}
done[static_cast<std::size_t>(pick)] = true;
order.push_back(pick);
for (int i = 0; i < n; ++i) {
if (done[static_cast<std::size_t>(i)]) continue;
int e[2] = {-1, -1};
edges_of(i, e);
for (int k = 0; k < 2; ++k) {
if (e[k] == pick) --indegree[static_cast<std::size_t>(i)];
}
}
}
// `order[p]` is the node that moves to position `p`; `where[old]` is where it ends up. Every
// index the dialog holds is in the old space and has to be remapped in one block -- a missed one
// silently renames an operation or re-points a wire.
std::vector<int> where(static_cast<std::size_t>(n), -1);
for (int p = 0; p < n; ++p) where[static_cast<std::size_t>(order[static_cast<std::size_t>(p)])] = p;
std::vector<MathNode> sorted;
sorted.reserve(static_cast<std::size_t>(n));
for (int p = 0; p < n; ++p) sorted.push_back(d->nodes[static_cast<std::size_t>(order[static_cast<std::size_t>(p)])]);
const auto remap = [&](int i) { return i >= 0 && i < n ? where[static_cast<std::size_t>(i)] : -1; };
for (int p = 0; p < n; ++p) {
MathNode& node = sorted[static_cast<std::size_t>(p)];
const int old = order[static_cast<std::size_t>(p)];
node.in_a = math_op_arity(node.op) >= 1 ? remap(in_a[static_cast<std::size_t>(old)]) : -1;
node.in_b = math_op_arity(node.op) >= 2 ? remap(in_b[static_cast<std::size_t>(old)]) : -1;
}
d->nodes = std::move(sorted);
d->output = remap(d->output);
d->selected = remap(d->selected);
d->drag_node = remap(d->drag_node);
d->wire_from = remap(d->wire_from);
d->wire_detach = remap(d->wire_detach);
d->selected_wire.node = remap(d->selected_wire.node);
if (why != nullptr) why->clear();
return true;
}
void math_canvas_draw(GtkDrawingArea*, cairo_t* cr, int width, int height, gpointer user) {
MathDialog* d = static_cast<MathDialog*>(user);
cairo_set_source_rgb(cr, 0.06, 0.078, 0.094);
cairo_paint(cr);
// One transform for the whole graph. Everything below is in graph units; every hit test runs
// `math_to_graph()`, which is this inverted.
cairo_save(cr);
cairo_translate(cr, d->view_x, d->view_y);
cairo_scale(cr, d->view_scale, d->view_scale);
// 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<int>(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<std::size_t>(from)], ax, ay);
math_input_pos(n, k, bx, by);
const bool picked = d->selected_wire.node == static_cast<int>(i) &&
d->selected_wire.which == k;
cairo_set_source_rgb(cr, picked ? 1.0 : 0.55, picked ? 0.69 : 0.62, picked ? 0.13 : 0.72);
cairo_set_line_width(cr, picked ? 3.0 : 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<int>(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<int>(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);
}
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);
cairo_set_source_rgb(cr, 0.45, 0.52, 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, 0.45, 0.52, 0.62);
cairo_rectangle(cr, ox - kPort * 0.5, oy - kPort * 0.5, kPort, kPort);
cairo_fill(cr);
}
// The wire being dragged, drawn last so it is over the boxes: without it the gesture gives no
// sign of what it is about to connect.
if (d->drag_mode == MathDrag::Wire && d->wire_from >= 0 &&
d->wire_from < static_cast<int>(d->nodes.size())) {
double ax = 0.0;
double ay = 0.0;
math_output_pos(d->nodes[static_cast<std::size_t>(d->wire_from)], ax, ay);
cairo_set_source_rgb(cr, 1.0, 0.69, 0.13);
cairo_set_line_width(cr, 1.5);
cairo_move_to(cr, ax, ay);
cairo_curve_to(cr, ax + 40.0, ay, d->wire_x - 40.0, d->wire_y, d->wire_x, d->wire_y);
cairo_stroke(cr);
}
cairo_restore(cr);
(void)width;
(void)height;
}
void math_canvas_pressed(GtkGestureClick*, int, double x, double y, gpointer user) {
MathDialog* d = static_cast<MathDialog*>(user);
// The key controller is on the canvas, so the canvas has to hold focus for `Delete`, `Escape`
// and `Ctrl+0` to reach it.
gtk_widget_grab_focus(d->canvas);
int which = kPortOut;
const int port = math_port_at_canvas(d, x, y, which);
if (port >= 0) {
d->selected_wire = MathWireRef{-1, 0};
d->selected = port;
math_dialog_sync(d);
return;
}
const MathWireRef wire = math_wire_at_canvas(d, x, y);
if (wire.node >= 0) {
d->selected_wire = wire;
d->selected = wire.node;
math_dialog_sync(d);
gtk_widget_queue_draw(d->canvas);
return;
}
const int node = math_node_at_canvas(d, x, y);
if (node >= 0) {
d->selected_wire = MathWireRef{-1, 0};
d->selected = node;
math_dialog_sync(d);
return;
}
d->selected_wire = MathWireRef{-1, 0};
gtk_widget_queue_draw(d->canvas);
}
void math_canvas_drag_begin(GtkGestureDrag*, double x, double y, gpointer user) {
MathDialog* d = static_cast<MathDialog*>(user);
d->drag_mode = MathDrag::None;
d->drag_node = -1;
d->wire_from = -1;
d->wire_detach = -1;
d->press_cx = x;
d->press_cy = y;
// Precedence: a port, then a box, then the background. A port sticks out past the box edge, so
// asking the box first would make the output square unreachable -- the same geometry that made
// the old two-click protocol disarm itself.
int which = kPortOut;
const int port = math_port_at_canvas(d, x, y, which);
if (port >= 0) {
d->drag_mode = MathDrag::Wire;
double gx = 0.0;
double gy = 0.0;
math_to_graph(d, x, y, gx, gy);
d->wire_x = gx;
d->wire_y = gy;
if (which == kPortOut) {
d->wire_from = port;
} else {
// A wired input is dragged *off*: the input is emptied now, so releasing over nothing is
// itself the detach and needs no second verb. `Escape` puts it back.
MathNode& n = d->nodes[static_cast<std::size_t>(port)];
const int from = which == 0 ? n.in_a : n.in_b;
if (from < 0) {
d->drag_mode = MathDrag::None;
return;
}
d->wire_from = from;
d->wire_detach = port;
d->wire_detach_which = which;
(which == 0 ? n.in_a : n.in_b) = -1;
}
d->selected = d->wire_from;
math_dialog_sync(d);
gtk_widget_queue_draw(d->canvas);
return;
}
const int node = math_node_at_canvas(d, x, y);
if (node >= 0) {
d->drag_mode = MathDrag::Node;
d->drag_node = node;
d->selected = node;
d->grab_x = d->nodes[static_cast<std::size_t>(node)].x;
d->grab_y = d->nodes[static_cast<std::size_t>(node)].y;
math_dialog_sync(d);
return;
}
d->drag_mode = MathDrag::Pan;
d->pan_x0 = d->view_x;
d->pan_y0 = d->view_y;
}
void math_canvas_drag(GtkGestureDrag*, double dx, double dy, gpointer user) {
MathDialog* d = static_cast<MathDialog*>(user);
// The gesture reports the *total* offset from where it began, so every branch adds it to a value
// recorded at `drag-begin` and never to one a previous update wrote. Feeding a delta back into
// the origin is what made a box fly off the canvas.
switch (d->drag_mode) {
case MathDrag::Node: {
if (d->drag_node < 0 || d->drag_node >= static_cast<int>(d->nodes.size())) return;
MathNode& n = d->nodes[static_cast<std::size_t>(d->drag_node)];
// The offset is in canvas pixels and a position is in graph units.
n.x = std::max(0.0, d->grab_x + dx / d->view_scale);
n.y = std::max(0.0, d->grab_y + dy / d->view_scale);
break;
}
case MathDrag::Wire: {
// The loose end, so the user can see what is being connected.
double gx = 0.0;
double gy = 0.0;
math_to_graph(d, d->press_cx + dx, d->press_cy + dy, gx, gy);
d->wire_x = gx;
d->wire_y = gy;
break;
}
case MathDrag::Pan:
d->view_x = d->pan_x0 + dx;
d->view_y = d->pan_y0 + dy;
break;
case MathDrag::None:
return;
}
gtk_widget_queue_draw(d->canvas);
}
void math_canvas_drag_end(GtkGestureDrag*, double dx, double dy, gpointer user) {
MathDialog* d = static_cast<MathDialog*>(user);
if (d->drag_mode != MathDrag::Wire) {
d->drag_mode = MathDrag::None;
d->drag_node = -1;
return;
}
// Where the release landed: the press point plus the gesture's total offset is the pointer in
// every case, including a detach where `wire_from` is a node the press never touched. Using
// `wire_x` instead would be wrong when `drag-end` arrives without a preceding final `drag-update`
// (GTK makes no guarantee); using the output port would be wrong on a detach, where the source
// node may be a column away from the press point.
int which = kPortOut;
const int target = math_port_at_canvas(d, d->press_cx + dx, d->press_cy + dy, which);
if (target >= 0 && which != kPortOut && target != d->wire_from) {
std::string why;
if (math_connect(d, d->wire_from, target, which, &why)) {
// `math_connect()` may have permuted the list, so the target's index is no longer `target`.
math_dialog_sync(d);
} else if (d->wire_detach >= 0) {
// A refused wire must not also lose the one the drag detached to start with.
MathNode& back = d->nodes[static_cast<std::size_t>(d->wire_detach)];
(d->wire_detach_which == 0 ? back.in_a : back.in_b) = d->wire_from;
math_dialog_sync(d);
}
}
// A release over nothing leaves the input the drag emptied empty: that *is* the detach.
d->drag_mode = MathDrag::None;
d->wire_from = -1;
d->wire_detach = -1;
gtk_widget_queue_draw(d->canvas);
}
gboolean math_canvas_scrolled(GtkEventControllerScroll* scroll, double, double dy, gpointer user) {
MathDialog* d = static_cast<MathDialog*>(user);
if (dy == 0.0) return TRUE;
// Resolve the anchor: the pointer position from the current event, or the canvas centre as a
// fallback (a real scroll event always arrives on an allocated widget, so the fallback is only
// reached in degenerate cases).
double px = 0.0;
double py = 0.0;
bool have = false;
if (scroll != nullptr) {
GdkEvent* event = gtk_event_controller_get_current_event(GTK_EVENT_CONTROLLER(scroll));
if (event != nullptr) have = gdk_event_get_position(event, &px, &py);
}
if (!have) {
const double w = d->canvas != nullptr ? static_cast<double>(gtk_widget_get_width(d->canvas)) : 0.0;
const double h = d->canvas != nullptr ? static_cast<double>(gtk_widget_get_height(d->canvas)) : 0.0;
px = w > 0.0 ? w * 0.5 : 0.0;
py = h > 0.0 ? h * 0.5 : 0.0;
}
math_zoom_at(d, px, py, dy);
return TRUE;
}
gboolean math_canvas_key(GtkEventControllerKey*, guint keyval, guint, GdkModifierType state,
gpointer user) {
MathDialog* d = static_cast<MathDialog*>(user);
const bool ctrl = (state & GDK_CONTROL_MASK) != 0;
if (ctrl && (keyval == GDK_KEY_0 || keyval == GDK_KEY_KP_0)) {
math_view_reset(d);
return TRUE;
}
if (keyval == GDK_KEY_f || keyval == GDK_KEY_F) {
math_view_fit(d);
return TRUE;
}
if (keyval == GDK_KEY_Escape) {
// A detach empties the input at `drag-begin`, so the undo is to put it back. Anything else is
// a cancel of a gesture that has changed nothing yet.
if (d->drag_mode == MathDrag::Wire && d->wire_detach >= 0 &&
d->wire_detach < static_cast<int>(d->nodes.size())) {
MathNode& n = d->nodes[static_cast<std::size_t>(d->wire_detach)];
(d->wire_detach_which == 0 ? n.in_a : n.in_b) = d->wire_from;
}
d->drag_mode = MathDrag::None;
d->wire_from = -1;
d->wire_detach = -1;
gtk_widget_queue_draw(d->canvas);
return TRUE;
}
if (keyval == GDK_KEY_Delete || keyval == GDK_KEY_BackSpace) {
if (d->selected_wire.node >= 0 &&
d->selected_wire.node < static_cast<int>(d->nodes.size())) {
MathNode& n = d->nodes[static_cast<std::size_t>(d->selected_wire.node)];
(d->selected_wire.which == 0 ? n.in_a : n.in_b) = -1;
d->selected_wire = MathWireRef{-1, 0};
math_dialog_sync(d);
gtk_widget_queue_draw(d->canvas);
return TRUE;
}
}
return FALSE;
}
/// Adds a node of `op` and selects it. The grid placement is the same one the auto-place uses, so a
/// node added here lands clear of the others -- 160 is narrower than `kNodeW` plus a wire's tread,
/// which is why the tidy layout uses a wider pitch; this is only a first guess at a free cell.
void math_add_node(MathDialog* d, MathOp op) {
// 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<int>(d->nodes.size()) - 1 || d->nodes.empty();
MathNode n;
n.op = op;
const std::size_t k = d->nodes.size();
n.x = 24.0 + 160.0 * static_cast<double>(k % 2u);
n.y = 20.0 + 58.0 * static_cast<double>(k / 2u);
const int arity = math_op_arity(n.op);
if (arity >= 1 && k > 0) n.in_a = static_cast<int>(k) - 1;
if (arity >= 2 && k > 0) n.in_b = static_cast<int>(k) - 1;
if (op == MathOp::Source) {
// Only an *acquired* channel: `signals().front()` is the stream's timestamp signal, which has
// no samples, so a graph sourced from it validates and then reads NaN for ever.
const std::vector<std::string> acquired = math_source_names(*d->app);
if (!acquired.empty()) n.source = acquired.front();
}
d->nodes.push_back(n);
d->selected = static_cast<int>(d->nodes.size()) - 1;
if (was_linear) d->output = d->selected;
math_dialog_sync(d);
}
void on_math_node_added(GtkButton*, gpointer user) {
math_add_node(static_cast<MathDialog*>(user), MathOp::Gain);
}
void on_math_node_deleted(GtkButton*, gpointer user) {
MathDialog* d = static_cast<MathDialog*>(user);
if (d->selected < 0 || d->selected >= static_cast<int>(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<int>(gone, static_cast<int>(d->nodes.size()) - 1);
d->drag_mode = MathDrag::None;
d->wire_from = -1;
d->wire_detach = -1;
math_dialog_sync(d);
}
void on_math_output_set(GtkButton*, gpointer user) {
MathDialog* d = static_cast<MathDialog*>(user);
if (d->selected < 0 || d->selected >= static_cast<int>(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<MathDialog*>(user);
if (d->selected < 0 || d->selected >= static_cast<int>(d->nodes.size())) return;
MathNode& n = d->nodes[static_cast<std::size_t>(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));
const std::vector<std::string> names = math_source_names(*d->app);
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<MathDialog*>(user);
if (d->selected < 0 || d->selected >= static_cast<int>(d->nodes.size())) return;
if (d->op_drop == nullptr || object != G_OBJECT(d->op_drop)) return;
MathNode& n = d->nodes[static_cast<std::size_t>(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<std::string> 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) {
// Before the early return below: an out-of-range selection is exactly the state in which a stale
// validation line is most misleading -- a graph with no node selected can still be broken, and the
// user has no form to read instead.
if (d->problem != nullptr) {
const std::string why = math_dialog_problem(d);
gtk_label_set_text(GTK_LABEL(d->problem), why.c_str());
gtk_widget_set_visible(d->problem, !why.empty());
if (d->apply != nullptr) gtk_widget_set_sensitive(d->apply, why.empty());
}
if (d->selected < 0 || d->selected >= static_cast<int>(d->nodes.size())) {
if (d->canvas != nullptr) gtk_widget_queue_draw(d->canvas);
return;
}
MathNode& n = d->nodes[static_cast<std::size_t>(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) {
// The *acquired* channels, which is what Apply validates against. Listing every signal let a
// user pick a source that could never be made valid -- and put the stream's timestamp signal,
// which has no samples at all, at the top of the list.
const std::vector<std::string> names = math_source_names(*d->app);
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<guint>(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);
}
// Through `math_op_index()`, not a cast: the dropdown is built from `math_op_at()`, whose order
// is the dialog's rather than the enum's, and the cast worked only because they happen to agree.
if (d->op_drop != nullptr) {
gtk_drop_down_set_selected(GTK_DROP_DOWN(d->op_drop),
static_cast<guint>(math_op_index(op)));
}
std::string inputs;
const auto describe = [&](int index) -> std::string {
if (index < 0) return "—";
if (index >= static_cast<int>(d->nodes.size())) return "—";
const MathNode& other = d->nodes[static_cast<std::size_t>(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<MathDialog*>(user);
on_math_param_changed(nullptr, d);
// The validation line has said what is wrong for as long as it has been wrong, and Apply is
// insensitive while it is non-empty -- so this is a guard against a programmatic call (the
// headless probe makes one), not a message to the user.
const std::string why = math_dialog_problem(d);
if (!why.empty()) {
std::fprintf(stderr, "[math] apply refused: %s\n", why.c_str());
return;
}
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 (m.name.empty()) m.name = "math";
if (d->edit >= 0 && d->edit < static_cast<int>(d->app->math.size())) {
m.enabled = d->app->math[static_cast<std::size_t>(d->edit)].enabled;
d->app->math[static_cast<std::size_t>(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<std::mutex> lock(app.model->mutex());
d->nodes = edit >= 0 && edit < static_cast<int>(app.math.size())
? app.math[static_cast<std::size_t>(edit)].nodes
: std::vector<MathNode>{};
}
if (d->nodes.empty()) {
MathNode src;
src.op = MathOp::Source;
const std::vector<std::string> 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<double>(i % 2u);
d->nodes[i].y = 20.0 + 58.0 * static_cast<double>(i / 2u);
}
d->output = edit >= 0 && edit < static_cast<int>(app.math.size())
? app.math[static_cast<std::size_t>(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<int>(d->nodes.size())) {
d->output = static_cast<int>(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<MathDialog*>(p); });
g_signal_connect(window, "close-request", G_CALLBACK(+[](GtkWindow* w, gpointer) {
GtkWidget* dd = GTK_WIDGET(w);
MathDialog* md = static_cast<MathDialog*>(
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<int>(app.math.size())) {
gtk_editable_set_text(GTK_EDITABLE(d->name), app.math[static_cast<std::size_t>(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<int>(app.math.size())) {
gtk_editable_set_text(GTK_EDITABLE(d->unit), app.math[static_cast<std::size_t>(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);
g_signal_connect(drag, "drag-end", G_CALLBACK(+[](GtkGestureDrag* g, double dx, double dy,
gpointer user) {
math_canvas_drag_end(g, dx, dy, user);
}),
d);
gtk_widget_add_controller(d->canvas, GTK_EVENT_CONTROLLER(drag));
// The wheel zooms. A scroll controller is the only way to get one on a drawing area, and
// `VERTICAL` is what a wheel reports.
GtkEventController* zoom = gtk_event_controller_scroll_new(GTK_EVENT_CONTROLLER_SCROLL_VERTICAL);
g_signal_connect(zoom, "scroll", G_CALLBACK(math_canvas_scrolled), d);
gtk_widget_add_controller(d->canvas, zoom);
// A `GtkDrawingArea` is not focusable by default, so a key controller on it never fires. It
// takes focus on a press rather than only on Tab, because every key this editor binds is about
// something the pointer just did.
gtk_widget_set_focusable(d->canvas, TRUE);
GtkEventController* keys = gtk_event_controller_key_new();
g_signal_connect(keys, "key-pressed", G_CALLBACK(math_canvas_key), d);
gtk_widget_add_controller(d->canvas, keys);
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<const char*> 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]));
// Why the graph cannot be saved, in the form column where the user is already reading. Wrapped,
// so a long channel name does not widen the dialog, and hidden when there is nothing to say.
d->problem = gtk_label_new("");
gtk_label_set_wrap(GTK_LABEL(d->problem), TRUE);
gtk_label_set_xalign(GTK_LABEL(d->problem), 0.0f);
gtk_widget_add_css_class(d->problem, "error");
gtk_widget_set_visible(d->problem, FALSE);
gtk_box_append(GTK_BOX(form), d->problem);
// A menu of the twelve ops rather than one button that always makes a gain: building anything
// else was add-then-change-the-dropdown, which is two steps for the common case.
GtkWidget* add = gtk_menu_button_new();
gtk_menu_button_set_label(GTK_MENU_BUTTON(add), "add node");
GtkWidget* add_menu = gtk_popover_new();
GtkWidget* add_list = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
for (int i = 0; i < math_op_count(); ++i) {
const MathOp op = math_op_at(i);
GtkWidget* item = gtk_button_new_with_label(math_op_name(op));
gtk_widget_add_css_class(item, "flat");
gtk_widget_set_halign(gtk_button_get_child(GTK_BUTTON(item)), GTK_ALIGN_START);
// The op travels in the widget's own data, as an `int` through `math_op_index()`'s space: a
// pointer-sized enum smuggled through `gpointer` is the kind of cast that survives a
// reordering of the enum and then means something else.
g_object_set_data(G_OBJECT(item), "udpscope-math-op", GINT_TO_POINTER(i));
g_object_set_data(G_OBJECT(item), "udpscope-math-popover", add_menu);
g_signal_connect(item, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
MathDialog* dd = static_cast<MathDialog*>(user);
const int index =
GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), "udpscope-math-op"));
auto* pop = GTK_POPOVER(
g_object_get_data(G_OBJECT(b), "udpscope-math-popover"));
if (pop != nullptr) gtk_popover_popdown(pop);
math_add_node(dd, math_op_at(index));
}), d);
gtk_box_append(GTK_BOX(add_list), item);
}
gtk_popover_set_child(GTK_POPOVER(add_menu), add_list);
gtk_menu_button_set_popover(GTK_MENU_BUTTON(add), add_menu);
GtkWidget* del = gtk_button_new_with_label("delete node");
GtkWidget* out = gtk_button_new_with_label("set as output");
g_signal_connect(del, "clicked", G_CALLBACK(on_math_node_deleted), d);
g_signal_connect(out, "clicked", G_CALLBACK(on_math_output_set), d);
GtkWidget* fit = gtk_button_new_with_label("fit");
gtk_widget_set_tooltip_text(fit, "Frame the whole graph (f). Ctrl+0 resets the zoom.");
g_signal_connect(fit, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
math_view_fit(static_cast<MathDialog*>(user));
}), d);
GtkWidget* tidy = gtk_button_new_with_label("tidy");
gtk_widget_set_tooltip_text(tidy, "Lay the graph out left to right and frame it.");
g_signal_connect(tidy, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
math_tidy(static_cast<MathDialog*>(user));
}), d);
GtkWidget* buttons = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_box_append(GTK_BOX(buttons), add);
gtk_box_append(GTK_BOX(buttons), del);
gtk_box_append(GTK_BOX(buttons), out);
gtk_box_append(GTK_BOX(buttons), fit);
gtk_box_append(GTK_BOX(buttons), tidy);
gtk_box_append(GTK_BOX(form), buttons);
gtk_box_append(GTK_BOX(body), form);
gtk_box_append(GTK_BOX(outer), body);
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
// Named so the headless probe can click the *button* rather than call its handler: the defect
// this guards was in the connection, which a direct call cannot see.
g_object_set_data(G_OBJECT(window), "udpscope-math-cancel", cancel);
// `gtk_window_close()`, never `gtk_window_destroy()`: only a close emits `close-request`, which
// is what clears `app.math_dialog`. A destroy left that pointer at a dead widget, and every
// later open then took the "already open" branch and presented it -- "I cannot edit a math
// channel again". The same trap the other three windows in this file document.
g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_close), window);
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;
d->apply = apply;
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<App*>(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<App*>(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<App*>(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<int>(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<App*>(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<App*>(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<App*>(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<App*>(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<App*>(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<App*>(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<App*>(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<ExportContext> ctx(static_cast<ExportContext*>(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<App*>(user);
auto ctx = std::make_unique<ExportContext>();
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<App*>(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<App*>(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<App*>(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<App*>(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<FixedRangeContext*>(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<App*>(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<FixedRangeContext*>(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<RemoveTarget*>(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<std::size_t>(
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<App*>(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<std::size_t>(s)] < 0) continue;
const std::string label =
"Remove '" + r.name[static_cast<std::size_t>(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<RemoveTarget*>(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<int>(x), static_cast<int>(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<App*>(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::size_t>(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<ChannelInfo>& 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<int>(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<ChannelInfo>& again = app->model->channels();
for (std::size_t i = 0; i < again.size(); ++i) {
if (again[i].signal == signal) {
channel = static_cast<int>(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<SignalInfo>& 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<App*>(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<SignalInfo> signals;
bool have_signals = false;
{
std::lock_guard<std::mutex> 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 `<svg>` 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<Found*>(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<gssize>(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<const char*>(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<GtkWidget*, const char*> 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<void(GtkWidget*)> 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<std::size_t>(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<plot::f32> xy;
// `axis.min` is the origin the renderer names, so every reconstruction below is its own --
// and at the deep end of this sweep that choice is the whole point: relative to the retained
// window instead, the f32's ULP would be a large fraction of these spans.
s->gather(level, first, last, axis.min, xy);
double ink_lo = 0.0, ink_hi = 0.0;
if (xy.size() >= 2) {
ink_lo = axis.min + double(xy[0]);
ink_hi = axis.min + double(xy[xy.size() - 2]);
}
// The renderer's own increasing-suffix filter, counted rather than applied: it drops every
// point that is not strictly older than the one kept after it, which for a *run* of equal x
// keeps the last of the run. The stamps here are f32, so once the sample spacing falls below
// the f32 step at the *gather origin's* magnitude the gathered x has such runs -- and `kept`
// is how many distinct positions the polyline is left with. A `kept` far below `pts` is a
// staircase, and a `kept` of a handful is a trace with nothing to draw. This is the other
// reading the origin change buys: the step is now 2^-24 of the span on screen, so a sweep
// that used to grind `kept` down as it zoomed keeps every point.
std::size_t kept = 0;
double worst_back = 0.0; // the largest backwards step, in data units: non-monotonic gather
{
double next = std::numeric_limits<double>::infinity();
for (std::size_t k = xy.size() / 2; k > 0; --k) {
const double x = double(xy[2 * (k - 1)]) + axis.min;
if (!(x < next)) {
worst_back = std::max(worst_back, x - next);
continue;
}
next = x;
++kept;
}
}
const double span = axis.span() > 0.0 ? axis.span() : 1.0;
std::fprintf(stderr,
"[zoom-probe] %-8s axis=[%.6f..%.6f] span=%.6g n=%llu idx=[%llu..%llu) spp=%.1f "
"level=%u pts=%zu kept=%zu back=%.3g ink=[%.6f..%.6f] x_at_first=%.6f "
"gap_px=%.1f end_px=%.1f rect=[%g,%g,%gx%g]\n",
what, double(axis.min), double(axis.max), axis.span(), (unsigned long long)n,
(unsigned long long)first, (unsigned long long)last, spp, level, xy.size() / 2, kept,
worst_back, ink_lo, ink_hi, s->x_at(first),
(ink_lo - axis.min) / span * axes_w, (axis.max - ink_hi) / span * axes_w,
double(axes.x), double(axes.y), double(axes.w), double(axes.h));
std::fflush(stderr);
}
// --- 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<unsigned long long>(s.packets),
static_cast<unsigned long long>(s.frames),
static_cast<unsigned long long>(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<unsigned long long>(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<App*>(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);
}
}
// The trace look, stated on the first tick that finds a renderer -- there is none until
// the area is realized, and this is the same "claim it when it exists" shape as the frame
// clock above. `intensity_grading` is off in the library by default (it is a change of
// look, not a correction, and other hosts must not be restyled by an upgrade); an
// oscilloscope is exactly the host it is for, so udpscope turns it on. A one-shot rather
// than a per-frame write because `Style` is plain data and nothing else touches it.
if (!app->style_stated && app->area != nullptr) {
if (plot::Renderer* r = app->area->renderer()) {
// `UDPSCOPE_NO_PHOSPHOR` puts it back to the library's default, which is the only way
// to read `frame cost:` and `frame path:` against the ungraded path -- a frame mean
// cannot attribute its own cost, so the A/B is the measurement.
r->style().intensity_grading = g_getenv("UDPSCOPE_NO_PHOSPHOR") == nullptr;
app->style_stated = true;
}
}
if (app->relayout_pending) {
app->relayout_pending = false;
relayout_areas(*app);
}
// Header geometry, once a second: the window control must be a fixed, small
// widget beside the title whatever the bar's width is (it used to expand through
// the title's centre box, i.e. window_button_w tracked the window width).
if (app->window_button != nullptr) {
static gint64 next_header_log = 0;
const gint64 now = g_get_monotonic_time();
if (now >= next_header_log) {
next_header_log = now + G_TIME_SPAN_SECOND;
app->dbg_win_btn_w = gtk_widget_get_width(app->window_button);
app->dbg_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);
}
}
// Captured every frame so the dump has the last live value: see `dbg_measures_pane_w`.
if (app->measures_list != nullptr) {
app->dbg_measures_pane_w = gtk_widget_get_width(app->measures_list);
app->dbg_measures_pane_h = gtk_widget_get_height(app->measures_list);
}
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<plot::u32>(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<std::mutex> lock(model.mutex());
app->rows->bind(model, display);
app->rows->acquire(model);
// What x = 0 means, written *after* the bind and the acquire on purpose: the live answer
// is minus the newest sample the traces can now report, and asking before they were bound
// would put the newest data a frame's production to the right of the axis -- a third of
// the screen at 1 MS/s over a 50 ms window. The epoch is only read on the consumer side
// (it offsets the live channels' x), so nothing between here and the next frame's bind
// depends on it having been set earlier. It is written under the lock because a rebuild
// can replace the store it is pushed into.
//
// Three cases, one rule -- `display_x = stream_t + epoch`:
// * streaming, the newest stored sample sits at x = 0, so the axis is a fixed
// [-window, 0] and the data flows leftwards through it. A scope's time base is "how
// long ago", and the stream's own clock is an uptime nobody reads off an axis.
// * mid-capture, the coming event's edge sits at x = 0 exactly as the published
// snapshot's will, so the pinned [-pre, +post] means the same thing in both and the
// picture does not jump at the latch.
// * with a *snapshot* on screen (a held event, a paused view) the live channels are
// bound to nothing and the snapshot carries its own `x_offset`, so the epoch is 0 and
// `ScopeModel::stream_at_x0()` is what crosses between the clocks.
double want_epoch = 0.0;
if (display == DisplayMode::Capturing) {
want_epoch = -cap_edge;
} else if (model.display_snapshot() == nullptr) {
// `live_edge()` reports in the epoch currently in force, so subtracting it is minus the
// raw stamp: the fixed point is reached in one frame rather than converged on.
double newest = 0.0;
want_epoch = app->rows->live_edge(newest) ? model.display_epoch() - newest
: model.display_epoch();
}
if (model.display_epoch() != want_epoch) model.set_display_epoch(want_epoch);
}
// The 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<RowSet::Row&>(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<RowSet::Row&>(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<std::mutex> 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<std::size_t>(sl);
const int ch = r.channel[si];
if (ch < 0) continue;
const ChannelBuffer* b = r.slots[si].bound();
if (b == nullptr || static_cast<std::size_t>(ch) >= model.channel_count()) continue;
++bound;
if (b->size() == 0 && model.live().values(static_cast<std::size_t>(ch)).produced() > 0) {
++empty;
}
}
}
if (bound > 0 && empty == bound) ++app->dbg_blank_frames;
if (bound > 0 && empty < bound) app->dbg_ever_bound = true;
// A rebuild drops every ring's history, so the frames after one draw an empty plot until the
// producer has refilled the window -- which is what "the plot flickers as if there were no
// data" looks like. `dbg_blank_frames` cannot see it: it asks whether the ring holds samples
// the view has not got, and a rebuilt ring holds none either. The generation is the only
// thing that says a rebuild happened, so each change is named here with what asked for it.
const unsigned gen = model.generation();
if (app->dbg_generation_seen && gen != app->dbg_generation_prev) {
++app->dbg_generation_changes;
if (app->dbg_generation_changes <= 40) {
std::fprintf(stderr, "[gen] frame %d generation %u -> %u (bound=%d empty=%d)\n",
app->dbg_axes_frames, app->dbg_generation_prev, gen, bound, empty);
}
}
app->dbg_generation_prev = gen;
app->dbg_generation_seen = true;
// And the renderer's blank: the rings hold samples, yet the frame staged nothing.
if (bound > 0 && empty == 0 && app->dbg_seg_last == 0) ++app->dbg_render_blank_frames;
}
// The gutter the rows need, measured from the frame just laid out, imposed as a
// floor that only grows: without it the axes' left edge follows the y tick
// labels and the x axis twitches by a few pixels as the scale changes.
double gutter = 0.0;
for (int i = 0; i < app->rows->row_count(); ++i) {
const RowSet::Row& r = app->rows->row(i);
if (r.plot) gutter = std::max(gutter, double(r.plot->layout().y_axis_width));
}
app->rows->note_y_gutter(gutter);
app->rows->apply_gutters();
// The editor's buttons are placed from the rects this frame was laid out with, so they
// follow a resize and the gutter settling without being told.
if (app->layout_editing) sync_layout_editor(*app);
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<int>(p0->layout().axes.x);
const int gw = static_cast<int>(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<int>(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<int>(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<int>(p0->viewport().w));
app->dbg_vp_w_max = std::max(app->dbg_vp_w_max, static_cast<int>(p0->viewport().w));
const int nt = static_cast<int>(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<std::mutex> 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<std::size_t>(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<std::size_t>(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<unsigned long long>(stored), per_s / 1e6,
declared, static_cast<unsigned long long>(info.elements),
static_cast<unsigned long long>(model.partition().stride[c]),
static_cast<unsigned long long>(model.partition().time_stride),
static_cast<unsigned long long>(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<std::mutex> 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<int>(model.channel_count())) {
trigger_name = model.channels()[static_cast<std::size_t>(chan)].name;
}
for (std::size_t c = 0; c < model.channel_count(); ++c) {
if (static_cast<int>(c) != chan) continue;
const ChannelBuffer& buf = model.live().channel(c);
if (buf.size() > 0) {
f32 v = 0.0f;
buf.raw(buf.size() - 1, buf.size(), &v);
trigger_value = double(v);
have_trigger_value = true;
}
break;
}
}
StreamStats& st = model.stats();
const char* mode = "live";
if (!st.have_config.load()) {
// Ahead of every other branch for the reason the glance word is: with no CONFIG there is no
// signal list, so "live" (or "armed", which cannot happen without one) would name a pipeline
// that has not been described yet.
mode = "trying to connect: no configuration, and so no signal list, from the stream yet";
} else if (trigger_on) {
if (app->capturing) {
mode = "trigger: CAPTURING (post-trigger window filling)";
} else if (app->event_held) {
mode = "trigger: EVENT HELD (Arm to re-arm, or '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.
// A source that answered CONNECT but has sent no CONFIG is *connecting*, not acquiring:
// there is no signal list, so nothing can be enabled and no DATA frame can arrive. It gets
// the steady yellow rather than the blinking green, because the green says "the pipeline is
// running end to end" and here it has not started.
const bool paused = app->acq->paused();
const bool connecting = st.connected.load() && !st.have_config.load();
const bool active =
st.connected.load() && !connecting && !paused && (!trigger_on || armed);
const bool lit = ((g_get_monotonic_time() / (G_TIME_SPAN_SECOND / 2)) & 1) == 0;
const BadgePhase phase = connecting ? BadgePhase::Connecting
: !active ? BadgePhase::Idle
: (lit ? BadgePhase::Lit : BadgePhase::Dim);
const int reason = active ? 0
: !st.connected.load() ? 1
: connecting ? 4
: paused ? 2
: 3;
static const char* const kWhy[] = {"acquiring", "waiting for the stream", "paused",
"trigger disarmed",
"trying to connect: no configuration from the stream yet"};
// Keyed on the reason as well as the phase: three of the reasons share the Idle phase,
// so a pause that becomes a disarm has to reach the tooltip.
++app->dbg_badge_frames[static_cast<int>(phase)];
const int badge_key = static_cast<int>(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<std::size_t>(s)].bound();
if (b != nullptr && b->size() > 0) {
++bound;
samples += static_cast<std::size_t>(b->size());
}
}
// The rate the buffer is actually storing at. One figure when every channel agrees and
// a range when they do not: a per-channel list would make the field's width depend on
// the stream, and the breakdown is on the label's tooltip instead.
std::string rate;
if (!app->stored_rate.empty()) {
double lo = app->stored_rate.front();
double hi = lo;
for (const double r : app->stored_rate) {
lo = std::min(lo, r);
hi = std::max(hi, r);
}
// Within a percent is the same rate measured over a one-second window, not two rates.
rate = hi - lo <= 0.01 * hi ? format_rate(hi) : format_rate(lo) + ".." + format_rate(hi);
}
// Derive the one-word state for the glance line from the same conditions the mode prose
// branches on, so the two cannot disagree.
//
// `connecting` is first and covers both halves of the handshake -- no socket yet, and a
// socket with no CONFIG behind it -- because in neither is there a signal list, and every
// other word here claims one. The badge is what distinguishes them (grey against yellow):
// the word says what the app is doing, the colour says how far it got. It is no wider than
// `event held`, which is what lets it join a set the plot's layout is measured against.
const char* state_word = "live";
if (!st.have_config.load()) {
state_word = "connecting";
} else if (display == DisplayMode::Capturing) {
state_word = "capturing";
} else if (display == DisplayMode::Held) {
state_word = "event held";
} else if (trigger_on && !armed) {
state_word = "disarmed";
} else if (display == DisplayMode::Waiting) {
state_word = "armed";
} else if (paused) {
state_word = "paused";
}
udpscope::StatusSnapshot snap;
snap.state_detail = mode;
snap.state_word = state_word;
snap.connected = st.connected.load();
snap.have_config = st.have_config.load();
snap.trigger_on = trigger_on;
snap.rate_text = rate;
snap.stored_rate_hz = app->stored_rate.empty() ? 0.0 : app->stored_rate.front();
snap.declared_rate_hz = app->declared_rate.empty() ? 0.0 : app->declared_rate.front();
snap.window_text = format_window(app->rows->time_window());
snap.packets = st.packets.load();
snap.frames = st.frames.load();
snap.gaps = st.gaps.load();
snap.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<int>(st.reconnects.load(std::memory_order_relaxed));
// Paused *and* with a recorded start: the button's handler is what stamps it, so a pause
// arriving any other way (there is none today) reports the state without a duration rather
// than counting from the epoch.
snap.paused = paused && app->pause_started_us != 0;
snap.paused_for_s =
snap.paused ? double(g_get_monotonic_time() - app->pause_started_us) / 1.0e6 : 0.0;
const std::string glance = udpscope::format_glance(snap);
app->dbg_glance_last = glance;
app->status_detail = udpscope::format_detail(snap);
if (app->status_detail.size() > app->status_detail_max) {
app->status_detail_max = app->status_detail.size();
}
if (glance.size() > app->glance_max_chars) app->glance_max_chars = glance.size();
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<App*>(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);
// "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;
}
// Two diagnoses, split on `have_config`, because they are not the same situation. A source
// that sent a CONFIG and then went quiet is the unicast fight: the signal list is here, so
// something took the stream away. One that has sent no CONFIG at all never had the stream to
// lose -- there is no signal list, so nothing can be enabled and no DATA frame is owed -- and
// naming that "connected, but no data is arriving" blamed a second reader that does not exist.
// It is still the same stall test: a CONFIG that arrives promptly leaves no notice at all.
const bool have_config = st.have_config.load(std::memory_order_relaxed);
app.notice =
have_config
? "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"
: "trying to connect: the source answered, but has sent no configuration, so there is "
"no signal list yet -- check that the streamer is running and serving this port";
app.notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
if (!app.starve_reported) {
app.starve_reported = true;
std::fprintf(stderr,
have_config
? "[udpscope] starved: connected (config=1), reconnects=%llu, no new frames "
"in the last second -- a unicast source serves one client at a time\n"
: "[udpscope] connecting: connected (config=0), reconnects=%llu, no "
"configuration from the stream -- there is no signal list to acquire\n",
static_cast<unsigned long long>(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<App*>(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<unsigned>(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<App*>(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<App*>(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<App*>(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<App*>(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), "<b>%s</b>", 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<App*>(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<int>(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<int>(g_list_model_get_n_items(model)) : 0;
a->dbg_trigger_selected = static_cast<int>(
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<App*>(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<App*>(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<std::mutex> 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<std::size_t>(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 the way the renderer does it:
// `Series::gather()` emits x relative to an origin **the caller names**, and the
// consumer adds that same origin back before mapping to the screen. So
// `origin + gathered` must equal `x_at()`, and `drawn=` must equal `data=` to the
// f32's precision. The origin named here is `x0()`, deliberately -- the renderer
// passes the visible range's minimum, but this line reads the series' *whole* span,
// which is the widest relative value a gather can be asked to stage and so the worst
// case for the f32.
//
// 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 consumer add the origin
// twice, so the trace lands a whole origin 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<f32> xy;
shown->gather(0u, 0u, 1u, shown->x0(), xy);
const std::size_t head = xy.size();
shown->gather(0u, n - 1, n, shown->x0(), 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>((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<unsigned long long>(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<unsigned long long>(m.trigger_latches().load()),
int(m.trigger_armed().load()),
static_cast<unsigned long long>(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<App*>(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<App*>(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<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
std::string name;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
if (a->model->channels().empty()) return G_SOURCE_REMOVE;
name = a->model->channels().front().name;
}
std::vector<MathChannel> 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<int>(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<MathDialog*>(
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 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<std::size_t>(index)].nodes.size();
const int output_saved =
a->math.empty() ? -1 : a->math[static_cast<std::size_t>(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<MathDialog*>(
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");
}
if (a->math_dialog != nullptr) {
auto* h = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (h != nullptr && !h->nodes.empty()) {
// The output square straddles the box's right edge: it is drawn centred on
// `n.x + kNodeW` with a 5 px half-width and hit with a 10 px radius. A hit test that
// recovers the node by a box test therefore misses every pixel past the edge, which
// is a *silent disarm* of the first click of a wiring gesture.
double ox = 0.0;
double oy = 0.0;
math_output_pos(h->nodes[0], ox, oy);
int w_in = 0;
const int n_in = math_port_at(h, ox - 2.0, oy, kPort, w_in);
int w_out = 0;
const int n_out = math_port_at(h, ox + 4.0, oy, kPort, w_out);
std::fprintf(stderr,
"[math-probe] hit: inside node=%d which=%d, past-edge node=%d which=%d "
"(want 0 %d 0 %d)\n",
n_in, w_in, n_out, w_out, kPortOut, kPortOut);
}
// Cancel must clear the pointer exactly as the close above does: it is the *button* that is
// clicked here, not the window, because the defect was in what the button was connected to.
if (a->math_dialog != nullptr) {
GtkWidget* cancel_button = GTK_WIDGET(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-cancel"));
if (cancel_button != nullptr) {
g_signal_emit_by_name(cancel_button, "clicked");
std::fprintf(stderr, "[math-probe] cancel: dialog=%s (want cleared)\n",
a->math_dialog == nullptr ? "cleared" : "STILL SET");
open_math_dialog(*a, 0);
} else {
std::fprintf(stderr, "[math-probe] cancel: NO BUTTON (the data key is not set)\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<MathDialog*>(
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<std::string> 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)));
}
}
if (a->math_dialog != nullptr) {
auto* v = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (v != nullptr && !v->nodes.empty()) {
// A wheel zoom must keep the graph point under the pointer under the pointer: that is the
// one thing a zoom can get wrong without looking broken, and it reads as the canvas
// sliding away from the hand.
const double px = 180.0;
const double py = 140.0;
double gx0 = 0.0;
double gy0 = 0.0;
math_to_graph(v, px, py, gx0, gy0);
math_zoom_at(v, px, py, -1.0);
math_zoom_at(v, px, py, -1.0);
double gx1 = 0.0;
double gy1 = 0.0;
math_to_graph(v, px, py, gx1, gy1);
std::fprintf(stderr,
"[math-probe] view: zoom scale=%.3f anchor kept dx=%.4f dy=%.4f "
"(want 0.0000 0.0000)\n",
v->view_scale, gx1 - gx0, gy1 - gy0);
// And the hit tests must go through the inverse. At the identity view a test that forgot
// it passes anyway, so the port is probed *after* a zoom and a pan -- which is the only
// arrangement that can fail.
v->view_x += 37.0;
v->view_y -= 19.0;
double ox = 0.0;
double oy = 0.0;
math_output_pos(v->nodes[0], ox, oy);
const double cx = ox * v->view_scale + v->view_x;
const double cy = oy * v->view_scale + v->view_y;
int which = 0;
const int node = math_port_at_canvas(v, cx + 3.0, cy, which);
std::fprintf(stderr,
"[math-probe] view: pan+zoom hit node=%d which=%d (want 0 %d)\n", node,
which, kPortOut);
math_view_fit(v);
int inside = 1;
int w_px = gtk_widget_get_width(v->canvas);
int h_px = gtk_widget_get_height(v->canvas);
if (w_px <= 0) w_px = 420;
if (h_px <= 0) h_px = 300;
for (const MathNode& n : v->nodes) {
const double x0 = n.x * v->view_scale + v->view_x;
const double y0 = n.y * v->view_scale + v->view_y;
const double x1 = (n.x + kNodeW) * v->view_scale + v->view_x;
const double y1 = (n.y + kNodeH) * v->view_scale + v->view_y;
if (x0 < -0.5 || y0 < -0.5 || x1 > w_px + 0.5 || y1 > h_px + 0.5) inside = 0;
}
std::fprintf(stderr, "[math-probe] view: fit scale=%.3f all_inside=%d (want 1)\n",
v->view_scale, inside);
math_view_reset(v);
std::fprintf(stderr,
"[math-probe] view: reset scale=%.3f offset=(%.1f,%.1f) "
"(want 1.000 (0.0,0.0))\n",
v->view_scale, v->view_x, v->view_y);
}
}
if (a->math_dialog != nullptr) {
auto* g = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
// The editor's two graphs are source -> gain, so node 1 has one input and node 0 an output.
if (g != nullptr && g->nodes.size() >= 2) {
// Set a non-identity view (scale=2, x=50, y=20) before the gesture probes so that a
// transform bug -- e.g. passing canvas coords straight to math_port_at() without
// math_to_graph() -- would produce wrong hit results. At the identity view canvas coords equal
// graph coords, making such a bug invisible. The trailing math_view_reset() below tidies up.
math_set_scale(g, 2.0);
g->view_x = 50.0;
g->view_y = 20.0;
const auto canvas_of = [&](double gx, double gy, double& cx, double& cy) {
cx = gx * g->view_scale + g->view_x;
cy = gy * g->view_scale + g->view_y;
};
double ox = 0.0;
double oy = 0.0;
double ix = 0.0;
double iy = 0.0;
math_output_pos(g->nodes[0], ox, oy);
math_input_pos(g->nodes[1], 0, ix, iy);
double ocx = 0.0;
double ocy = 0.0;
double icx = 0.0;
double icy = 0.0;
canvas_of(ox, oy, ocx, ocy);
canvas_of(ix, iy, icx, icy);
// Connect: press the output, release over the input.
g->nodes[1].in_a = -1;
math_canvas_drag_begin(nullptr, ocx, ocy, g);
const int mode_wire = static_cast<int>(g->drag_mode);
math_canvas_drag(nullptr, icx - ocx, icy - ocy, g);
math_canvas_drag_end(nullptr, icx - ocx, icy - ocy, g);
std::fprintf(stderr,
"[math-probe] wire: connect mode=%d in_a=%d (want %d 0)\n", mode_wire,
g->nodes[1].in_a, static_cast<int>(MathDrag::Wire));
// Detach: a drag that starts on a *wired* input empties it at once, so a release over
// nothing is the detach. Dropping it back is what the connect above already covers.
math_canvas_drag_begin(nullptr, icx, icy, g);
const int emptied = g->nodes[1].in_a;
math_canvas_drag(nullptr, 0.0, 90.0, g);
math_canvas_drag_end(nullptr, 0.0, 90.0, g);
std::fprintf(stderr,
"[math-probe] detach: emptied_at_begin=%d in_a=%d (want -1 -1)\n", emptied,
g->nodes[1].in_a);
// And Escape during the same gesture must put it back.
g->nodes[1].in_a = 0;
math_canvas_drag_begin(nullptr, icx, icy, g);
math_canvas_drag(nullptr, 0.0, 90.0, g);
math_canvas_key(nullptr, GDK_KEY_Escape, 0, static_cast<GdkModifierType>(0), g);
std::fprintf(stderr, "[math-probe] detach: escape restores in_a=%d (want 0)\n",
g->nodes[1].in_a);
// Detach and release in place: a wire detached from an input and released at the same canvas
// position must reconnect. With Finding 1 unfixed the release point is computed from the wrong
// origin (the source node's output port rather than the press point), so this reads in_a=-1.
g->nodes[1].in_a = 0;
math_canvas_drag_begin(nullptr, icx, icy, g);
math_canvas_drag(nullptr, 0.0, 0.0, g);
math_canvas_drag_end(nullptr, 0.0, 0.0, g);
std::fprintf(stderr, "[math-probe] detach: release in place reconnects in_a=%d (want 0)\n",
g->nodes[1].in_a);
// A drag that begins on the background pans and moves nothing.
const double was_x = g->view_x;
const double node_x = g->nodes[0].x;
math_canvas_drag_begin(nullptr, 5.0, 5.0, g);
const int mode_pan = static_cast<int>(g->drag_mode);
math_canvas_drag(nullptr, 30.0, 0.0, g);
math_canvas_drag_end(nullptr, 30.0, 0.0, g);
std::fprintf(stderr,
"[math-probe] view: pan mode=%d dview=%.1f dnode=%.1f (want %d 30.0 0.0)\n",
mode_pan, g->view_x - was_x, g->nodes[0].x - node_x,
static_cast<int>(MathDrag::Pan));
math_view_reset(g);
}
}
if (a->math_dialog != nullptr) {
auto* p = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (p != nullptr && p->nodes.size() >= 2) {
math_set_scale(p, 2.0);
p->view_x = 50.0;
p->view_y = 20.0;
p->nodes[1].in_a = 0;
// Halfway along the wire in *graph* units: the bezier's own midpoint, which for a cubic
// with these control points is the average of the two ends plus three eighths of the
// tread -- so sampling the curve is the only honest way to find a point on it.
double ax = 0.0;
double ay = 0.0;
double bx = 0.0;
double by = 0.0;
math_output_pos(p->nodes[0], ax, ay);
math_input_pos(p->nodes[1], 0, bx, by);
const double t = 0.5;
const double u = 1.0 - t;
const double mx = u * u * u * ax + 3 * u * u * t * (ax + 40.0) +
3 * u * t * t * (bx - 40.0) + t * t * t * bx;
const double my = u * u * u * ay + 3 * u * u * t * ay + 3 * u * t * t * by + t * t * t * by;
const MathWireRef hit = math_wire_at_canvas(p, mx * p->view_scale + p->view_x,
my * p->view_scale + p->view_y);
p->selected_wire = hit;
math_canvas_key(nullptr, GDK_KEY_Delete, 0, static_cast<GdkModifierType>(0), p);
std::fprintf(stderr,
"[math-probe] wire: pick node=%d which=%d -> in_a=%d (want 1 0 -1)\n",
hit.node, hit.which, p->nodes[1].in_a);
p->nodes[1].in_a = 0;
p->selected_wire = MathWireRef{-1, 0};
}
}
if (a->math_dialog != nullptr) {
auto* c = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (c != nullptr && c->nodes.size() >= 2) {
// A wire drawn right-to-left is legal: the editor re-sorts so that the reader still sits
// after what it reads. Node 0 is the source and node 1 the gain, so wiring the gain into
// the source is impossible (a source has no inputs) -- a second gain reading the first,
// placed *before* it, is the case.
c->nodes.clear();
MathNode src;
src.op = MathOp::Source;
src.source = name;
// The reader is placed *before* what it reads, which is the case the evaluator cannot run
// and the editor has to repair. Index 1 will be wired to read index 2, and index 2 reads
// the source.
MathNode reader;
reader.op = MathOp::Gain;
MathNode feeder;
feeder.op = MathOp::Gain;
c->nodes = {src, reader, feeder};
c->nodes[2].in_a = 0;
// The output is the reader, so the remap is visible in `output` as well as in the wiring.
c->output = 1;
c->selected = 1;
std::string why;
const bool ok = math_connect(c, 2, 1, 0, &why);
// After the sort node 1 must still be the one that reads 0, and the reader of it must sit
// after it. The op is the same for both, so the *wiring* is the reading.
std::string order;
for (std::size_t i = 0; i < c->nodes.size(); ++i) {
order += (i != 0 ? "," : "");
order += std::to_string(i) + ":" + std::to_string(c->nodes[i].in_a);
}
std::string valid_why;
const bool valid = math_channel_valid_nodes(c->nodes, c->output, &valid_why);
std::fprintf(stderr,
"[math-probe] wire: backwards ok=%d order=[%s] output=%d valid=%d why=[%s] "
"(want 1 ... 1 [])\n",
ok ? 1 : 0, order.c_str(), c->output, valid ? 1 : 0, valid_why.c_str());
std::string self_why;
const bool self_ok = math_connect(c, 1, 1, 0, &self_why);
std::fprintf(stderr, "[math-probe] wire: self refused=%d why=[%s] (want 1)\n",
self_ok ? 0 : 1, self_why.c_str());
// A cycle: the node that already feeds the other is asked to read it back.
const int reader_idx = c->nodes[1].in_a == 0 ? 2 : 1;
const int feeder_idx = reader_idx == 2 ? 1 : 2;
std::string cyc_why;
const bool cyc_ok = math_connect(c, reader_idx, feeder_idx, 0, &cyc_why);
std::fprintf(stderr, "[math-probe] wire: cycle refused=%d why=[%s] (want 1)\n",
cyc_ok ? 0 : 1, cyc_why.c_str());
}
}
if (a->math_dialog != nullptr) {
auto* v2 = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (v2 != nullptr && v2->nodes.size() >= 2) {
// Broken: an unwired input. The line must say so and Apply must be insensitive -- the old
// behaviour was a modal alert *after* the press, which is a refusal rather than guidance.
v2->nodes[1].in_a = -1;
math_dialog_sync(v2);
const std::string bad = math_dialog_problem(v2);
const int bad_sensitive = gtk_widget_get_sensitive(v2->apply) ? 1 : 0;
// Copied before the next sync call overwrites the label's internal string.
const std::string bad_text_copy =
gtk_label_get_text(GTK_LABEL(v2->problem)) != nullptr
? std::string(gtk_label_get_text(GTK_LABEL(v2->problem)))
: std::string("(null)");
v2->nodes[1].in_a = 0;
math_dialog_sync(v2);
const std::string good = math_dialog_problem(v2);
const int good_sensitive = gtk_widget_get_sensitive(v2->apply) ? 1 : 0;
std::fprintf(stderr,
"[math-probe] validate: broken problem=[%s] label=[%s] apply=%d; "
"fixed problem=[%s] apply=%d (want non-empty 0; empty 1)\n",
bad.c_str(), bad_text_copy.c_str(), bad_sensitive,
good.c_str(), good_sensitive);
// The selection out of range is the case `math_dialog_sync()` returns early on, so the
// line must be written above that return or it goes stale exactly when it matters.
const int was_sel = v2->selected;
v2->selected = 999;
v2->nodes[1].in_a = -1;
math_dialog_sync(v2);
const char* stale = gtk_label_get_text(GTK_LABEL(v2->problem));
std::fprintf(stderr, "[math-probe] validate: no-selection label=[%s] (want non-empty)\n",
stale != nullptr ? stale : "(null)");
v2->selected = was_sel;
v2->nodes[1].in_a = 0;
math_dialog_sync(v2);
// And the source dropdown must list the *acquired* channels, which is what Apply checks
// against: listing every signal let a user pick one that could never be made valid.
const std::vector<std::string> acquired = math_source_names(*a);
std::size_t signals_n = 0;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
signals_n = a->model->signals().size();
}
v2->selected = 0;
v2->nodes[0].op = MathOp::Source;
math_dialog_sync(v2);
GListModel* lm = gtk_drop_down_get_model(GTK_DROP_DOWN(v2->source_drop));
const guint listed = lm != nullptr ? g_list_model_get_n_items(lm) : 0u;
std::fprintf(stderr,
"[math-probe] validate: source list=%u acquired=%zu signals=%zu "
"(want list==acquired)\n",
listed, acquired.size(), signals_n);
v2->selected = was_sel;
}
}
if (a->math_dialog != nullptr) {
auto* o = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (o != nullptr) {
const std::size_t before = o->nodes.size();
math_add_node(o, MathOp::BandPass);
const int op_added = o->nodes.empty() ? -1 : math_op_index(o->nodes.back().op);
// The op picker must make the node the user asked for, and the `rows` the form shows must
// follow it: a band pass is the one op that shows the Q row, so it is the case worth the
// check.
const int q_row = gtk_widget_get_visible(o->rows[3]) ? 1 : 0;
std::fprintf(stderr,
"[math-probe] op pick: nodes %zu->%zu op_index=%d q_row=%d "
"(want %zu %d 1)\n",
before, o->nodes.size(), op_added, q_row, before + 1,
math_op_index(MathOp::BandPass));
// And the identity the whole picker rests on: a round trip through both accessors.
int round = 1;
for (int i = 0; i < math_op_count(); ++i) {
if (math_op_index(math_op_at(i)) != i) round = 0;
}
std::fprintf(stderr, "[math-probe] op pick: index round trip=%d (want 1)\n", round);
}
}
if (a->math_dialog != nullptr) {
auto* ty = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (ty != nullptr && ty->nodes.size() >= 2) {
// Pile every node on one spot, so a layout that did nothing would be visible.
for (MathNode& n : ty->nodes) {
n.x = 10.0;
n.y = 10.0;
}
math_tidy(ty);
// No two boxes may overlap -- `math_node_at()` searches topmost-first, so an overlap means
// a click or a drag lands on the wrong node, which reads as the dialog ignoring the pointer.
int overlaps = 0;
std::string xs;
for (std::size_t i = 0; i < ty->nodes.size(); ++i) {
xs += (i != 0 ? "," : "") + std::to_string(static_cast<int>(ty->nodes[i].x));
for (std::size_t j = i + 1; j < ty->nodes.size(); ++j) {
const MathNode& p = ty->nodes[i];
const MathNode& q = ty->nodes[j];
if (p.x < q.x + kNodeW && q.x < p.x + kNodeW && p.y < q.y + kNodeH &&
q.y < p.y + kNodeH) {
++overlaps;
}
}
}
std::fprintf(stderr, "[math-probe] tidy: xs=[%s] overlaps=%d (want 0)\n", xs.c_str(),
overlaps);
}
}
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<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
std::string name;
{
std::lock_guard<std::mutex> 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<App*>(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("--"));
}
// The marks as *placed*, not as stored: a time mark is kept on the stream's clock and converted
// for the axis each frame (see `place_measure_marks()`), and it is the converted value that is
// comparable with the `view=` printed beside it.
line += " marks=" + std::to_string(a->measure_marks_display.size());
for (const RowSet::MeasureMark& m : a->measure_marks_display) {
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<App*>(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<App*>(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<App*>(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<std::mutex> 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<App*>(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<std::mutex> 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<unsigned long long>(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<std::size_t>(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<double>(r.origin.x),
static_cast<double>(r.origin.y), static_cast<double>(r.size.width),
static_cast<double>(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<App*>(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<App*>(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<App*>(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<App*>(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<App*>(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<App*>(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<std::mutex> 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);
}
// The bar's own string, because "how long have I been paused" is a *status* field and the
// elapsed figure is the one part of it that no other line can show: the exit dump's
// `status bar:` is read live and the two `mode ...` lines are the live and triggered states.
std::fprintf(stderr,
"[pause-probe] %-9s paused=%d ring_produced=%zu event_size=%zu "
"frozen_x=[%.6f..%.6f] axis=[%.6f..%.6f] glance=[%s]\n",
what, a.model->paused() ? 1 : 0, ring_produced, event_size, bounds_lo, bounds_hi,
double(axis.min), double(axis.max), a.dbg_glance_last.c_str());
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<App*>(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<App*>(user);
pause_probe_report(*app, "paused");
return G_SOURCE_REMOVE;
}, a);
g_timeout_add(1600, +[](gpointer user) {
App* app = static_cast<App*>(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<App*>(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<App*>(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] "
"abs=[%.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.abs_seconds.front() : 0.0,
table.rows() != 0 ? table.abs_seconds.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<App*>(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<int>(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<App*>(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<std::string> 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<App*>(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<App*>(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<MeasureDialog*>(
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<int>(count);
}
}
int drop_h = -1;
char options[192] = "";
if (a.measure_dialog != nullptr) {
auto* dd = static_cast<MeasureDialog*>(
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<int>(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<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
switch (a->dbg_measure_step) {
case 0: {
std::string signal;
{
std::lock_guard<std::mutex> 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).
// The marks as *placed* throughout: the drag writes a plot cursor and the check below reads
// one back, so both sides have to be in the plot's own coordinates -- and a time mark is
// stored on the stream's clock (see `place_measure_marks()`).
if (!a->measure_marks_display.empty()) {
RowSet::Row& r0 = a->rows->row(0);
if (r0.plot != nullptr) {
const RowSet::MeasureMark& mk = a->measure_marks_display.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;
// Both read *now*: a time mark rolls leftwards with its sample on a live axis, so the value
// placed on the most recent frame is the only one the plot's cursor can be expected to hold
// -- comparing against the value the drag was launched from would read 300 ms of scroll as a
// failure to restore.
if (!a->measure_marks_display.empty()) {
const RowSet::Row& r0 = a->rows->row(0);
const RowSet::MeasureMark& mk = a->measure_marks_display.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_display.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<App*>(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<plot::u32>(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::u32>(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::u32>(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<App*>(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<int>(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<int>(at.x) : -1;
const int accel_right =
accel != nullptr && gtk_widget_compute_point(accel, row, &origin, &at)
? static_cast<int>(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<int>(bounds.origin.y) : -1;
const int button_right =
have_button ? static_cast<int>(bounds.origin.x + bounds.size.width) : -1;
const int pos = bubble != nullptr
? static_cast<int>(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<App*>(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<GdkModifierType>(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<App*>(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<guint>(exit_after_ms), exit_probe_step1, &app);
}
if (const char* env = g_getenv("UDPSCOPE_ENABLE_ALL_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), enable_all_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_DISARM_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), disarm_probe_step1, &app);
}
if (const char* env = g_getenv("UDPSCOPE_ARM_TRIGGER_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), arm_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_FIELDS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), fields_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_WINDOW_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), window_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_TIMELABELS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), timelabels_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURES_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), measures_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_FIT_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), fit_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_CURSORS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), cursors_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_LAYOUT_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), layout_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_TRIG_TRACE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), trig_trace_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_MATH_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), math_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), measure_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_EVENTZOOM_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), eventzoom_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_PAN_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), pan_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_PAUSE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), pause_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_EXPORT_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), export_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_ZOOMKEYS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), zoomkeys_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_HELP_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), help_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_MENUS_MS")) {
menus_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_KEYS_MS")) {
g_timeout_add(static_cast<guint>(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<char**>(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<unsigned long long>(model.stats().frames.load()),
static_cast<unsigned long long>(model.stats().packets.load()),
static_cast<unsigned long long>(model.stats().gaps.load()),
model.channel_count(), part.span_s,
static_cast<unsigned long long>(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<unsigned long long>(model.live().values(i).produced()),
static_cast<unsigned long long>(model.live().channel(i).size()));
}
// `frames_painted` is the area's, read from the snapshot taken before the window was
// destroyed (the PlotArea wrapper dies with its widget): one figure, one render per
// frame, so it is the same count for every row.
std::printf("[udpscope] rows: %d of at most %d, layout editor %s\n", rows.row_count(),
RowSet::kMaxRows, app.layout_editing ? "up" : "down");
for (int i = 0; i < rows.row_count() && i < RowSet::kMaxRows; ++i) {
if (rows.row(i).plot == nullptr) continue;
std::printf("[udpscope] plot %d frames_painted=%d traces=%u\n", i,
app.dbg_frames_painted, rows.row(i).plot->trace_count());
}
// Where a frame's time went, split at the only two boundaries observable from the
// host: the frame function, the plot's draw, and the toolkit's whole paint. `draw`
// close to `paint` is the library's cost; `paint` far above `draw` is the toolkit
// compositing a framebuffer (which llvmpipe dominates). `path` says whether the
// traces went through the LOD pyramid at all -- a raw frame is O(visible samples).
std::printf("[udpscope] frame cost: tick %.2f/%.2f ms over %d, draw %.2f/%.2f ms "
"(max at frame %d), paint %.2f/%.2f ms over %d (mean/max)\n",
app.dbg_tick_n > 0 ? app.dbg_tick_ms_sum / app.dbg_tick_n : 0.0,
app.dbg_tick_ms_max, app.dbg_tick_n,
app.dbg_paint_n > 0 ? app.dbg_draw_ms_sum / app.dbg_paint_n : 0.0,
app.dbg_draw_ms_max, app.dbg_draw_max_frame,
app.dbg_paint_n > 0 ? app.dbg_paint_ms_sum / app.dbg_paint_n : 0.0,
app.dbg_paint_ms_max, app.dbg_paint_n);
std::printf("[udpscope] frame path: raw=%d lod=%d columns=%d graded=%d frames, segments "
"%llu/%u (mean/max), visible max %llu samples\n",
app.dbg_raw_frames, app.dbg_lod_frames, app.dbg_column_frames,
app.dbg_graded_frames,
static_cast<unsigned long long>(app.dbg_paint_n > 0
? app.dbg_seg_sum / app.dbg_paint_n
: 0),
app.dbg_seg_max, static_cast<unsigned long long>(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%%, worst drawn-vs-quoted %.4f px over %d raw paints\n",
app.dbg_ink_checked, app.dbg_ink_collapse, app.dbg_ink_staircase,
app.dbg_ink_short, app.dbg_ink_worst_cover * 100.0, app.dbg_ink_worst_offset_px,
app.dbg_ink_raw_checked);
std::printf("[udpscope] blank view frames: %d of %d\n", app.dbg_blank_frames,
app.dbg_axes_frames);
std::printf("[udpscope] trigger events: %llu total, %llu at the disarm (no event after it "
"means Disarm reached the engine)\n",
static_cast<unsigned long long>(model.trigger_latches().load()),
static_cast<unsigned long long>(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<std::pair<std::string, int>> wanted;
{
std::lock_guard<std::mutex> 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<int>(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<double> t;
std::vector<double> v;
const bool ok = visible_samples(app, static_cast<std::size_t>(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<long long>(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<std::mutex> lock(app.model->mutex());
produced = app.model->live().values(static_cast<std::size_t>(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<unsigned long long>(produced),
static_cast<unsigned long long>(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<double>(app.model->math_cost_ns()) /
static_cast<double>(app.model->math_timed());
std::printf("[udpscope] math cost: %llu slots, %.1f ns/slot (%llu timed), "
"%.2f ms total est\n",
static_cast<unsigned long long>(app.model->math_slots()), per_slot,
static_cast<unsigned long long>(app.model->math_timed()),
per_slot * static_cast<double>(app.model->math_slots()) / 1.0e6);
}
}
// What freezing a view costs, counted apart for the two publishes because they stall opposite
// threads (see `ScopeModel::publish_event_count()`). This is the figure a second live ring would
// buy: freezing a whole `BufferStore` copies nothing and inherits the pyramid the producer
// already maintains, so both `worst` figures would be 0 -- at a second budget's worth of memory
// and a second per-sample write on the producer. Read `event worst` against `frame cost:`'s
// `tick` max (the same stall, seen from the UI) and `pause worst` against `gaps`.
{
const auto ev = app.model->publish_event_count();
const auto pa = app.model->publish_pause_count();
std::printf("[udpscope] publish cost: %llu events, %.2f/%.2f ms (mean/worst, blocks the UI);"
" %llu pauses, %.2f/%.2f ms (mean/worst, blocks the producer)\n",
static_cast<unsigned long long>(ev),
ev > 0 ? static_cast<double>(app.model->publish_event_us_total()) /
static_cast<double>(ev) / 1000.0
: 0.0,
static_cast<double>(app.model->publish_event_us_worst()) / 1000.0,
static_cast<unsigned long long>(pa),
pa > 0 ? static_cast<double>(app.model->publish_pause_us_total()) /
static_cast<double>(pa) / 1000.0
: 0.0,
static_cast<double>(app.model->publish_pause_us_worst()) / 1000.0);
}
// Unconditional, unlike the pane's own lines below: this is about the instruments the app
// *created*, which exist whether or not any measurement was added.
std::printf("[udpscope] readout instruments locked: %d (trigger marker + measurement marks)\n",
app.rows->readouts_locked() ? 1 : 0);
// Where the panel controls live. Computed once, when the widgets exist -- the dump runs *after*
// the window is destroyed, where a widget pointer is dangling and asking GTK about it is a
// critical (which is how the first version of this line earned four of them).
std::printf("[udpscope] panel controls: %s\n", app.dbg_panel_controls.c_str());
std::printf("[udpscope] toolbar icons: %s\n", app.dbg_toolbar_icons.c_str());
std::printf("[udpscope] 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<double>(app.measure_fetch_us) /
static_cast<double>(app.measure_cost_us)
: 0.0,
static_cast<unsigned long long>(app.measure_covered),
static_cast<unsigned long long>(app.measure_bucketed),
static_cast<unsigned long long>(app.measure_raw),
app.measure_covered > 0 ? 100.0 * static_cast<double>(app.measure_raw) /
static_cast<double>(app.measure_covered)
: 0.0,
app.measure_covered > 0
? 1000.0 * static_cast<double>(app.measure_cost_us) /
static_cast<double>(app.measure_covered)
: 0.0);
}
}
std::printf("[udpscope] badge frames: lit=%d dim=%d idle=%d connecting=%d\n",
app.dbg_badge_frames[0], app.dbg_badge_frames[1], app.dbg_badge_frames[2],
app.dbg_badge_frames[3]);
std::printf("[udpscope] trigger panel 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<unsigned long long>(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<int>(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<std::string> 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<std::mutex> lock(model.mutex());
// `results_for` is the window the numbers describe, `win` the one on screen now: the
// drift between them is the assertion that a measurement is a statistic of the *current*
// window -- a timer-decoupled recomputation is what used to leave a marker outside it.
// Both on the **stream's** clock, which is the space `measure_window` and the time marks
// are stored in: a live axis is a fixed `[-window, 0]`, so comparing display x would read
// no drift whatever the data did (see `App::measure_window`).
const double s0 = model.stream_at_x0();
const RowSet::Row& r0 = rows.row(0);
const plot::Range view = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{};
const plot::Range win{view.min + s0, view.max + s0};
const double span = app.measure_window.span();
int in_window = 0;
int marks = 0;
for (const RowSet::MeasureMark& mk : app.measure_marks) {
if (mk.level) continue;
++marks;
if (mk.value >= app.measure_window.min && mk.value <= app.measure_window.max) ++in_window;
}
// Whether the marks the app placed are *unselectable* -- the property the pointer's hit
// test respects. Read back out of the plot, so this is the app's own setting and not
// the intent behind it.
int selectable = 0;
for (int i = 0; i < rows.row_count(); ++i) {
const RowSet::Row& r = rows.row(i);
if (r.plot == nullptr) continue;
for (int m = 0; m < RowSet::kMeasureMarks; ++m) {
const std::size_t mi = static_cast<std::size_t>(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<std::mutex> 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.dbg_measures_pane_w, app.dbg_measures_pane_h);
}
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<std::size_t>(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<std::size_t>(s)].bound();
std::printf("[udpscope] row %d slot %d -> channel %d '%s' samples=%llu\n", r, s, ch,
rows.row(r).name[static_cast<std::size_t>(s)].c_str(),
static_cast<unsigned long long>(buf != nullptr ? buf->size() : 0));
}
}
}
return result;
}
} // namespace udpscope