publish_pause_view() filled stamps, values and y and left pyramids empty, so level_for() descended to raw samples and a frozen pause was re-gathered from every sample it held on every frame. It cost draw time (12.34 -> 18.18 ms mean) and, because stage_trace_density() normalises each column against its own peak, it also changed how the trace looked -- which is how it was noticed. Both publishes are now timed and reported as `publish cost:`, counted apart because they stall opposite threads: the event publish runs on the producer under mutex() and blocks the UI, the pause publish runs on the UI thread and blocks the producer. That is the number a second live ring has to be judged against. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
8669 lines
423 KiB
C++
8669 lines
423 KiB
C++
// 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), click an output square and then an input square to wire them, drag a box to
|
||
// move it. The graph is a *list*, walked in order, so a node may only read ones above it -- which
|
||
// is why an input square offers only the nodes before it and a cycle cannot be drawn at all.
|
||
constexpr double kNodeW = 132.0;
|
||
constexpr double kNodeH = 46.0;
|
||
constexpr double kPort = 10.0;
|
||
|
||
struct MathDialog {
|
||
App* app = nullptr;
|
||
int edit = -1;
|
||
std::vector<MathNode> nodes;
|
||
int output = 0;
|
||
int selected = 0;
|
||
/// The node whose output square was clicked and is waiting for an input to connect to.
|
||
int pending_out = -1;
|
||
/// Where the box being dragged *was* when the drag began, and which box it is. The gesture
|
||
/// reports the total offset from its own start point, so adding it to a value that the previous
|
||
/// update already moved is a runaway: the box accelerates away from the pointer and leaves the
|
||
/// canvas. `-1` means the drag did not begin on a box.
|
||
double grab_x = 0.0;
|
||
double grab_y = 0.0;
|
||
int drag_node = -1;
|
||
GtkWidget* window = nullptr;
|
||
GtkWidget* canvas = nullptr;
|
||
GtkWidget* name = nullptr;
|
||
GtkWidget* unit = nullptr;
|
||
GtkWidget* op_drop = nullptr;
|
||
GtkWidget* source_drop = nullptr;
|
||
GtkWidget* gain = nullptr;
|
||
GtkWidget* offset = nullptr;
|
||
GtkWidget* fc = nullptr;
|
||
GtkWidget* q = nullptr;
|
||
GtkWidget* lo = nullptr;
|
||
GtkWidget* hi = nullptr;
|
||
GtkWidget* rows[6] = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr};
|
||
GtkWidget* inputs = nullptr;
|
||
};
|
||
|
||
/// The channels a math channel may read: the ones the model actually acquires. Offering the whole
|
||
/// signal list would let a graph name a signal nobody is acquiring -- which reads as no data for
|
||
/// ever -- and would put the stream's timestamp signal in the list, which has no samples at all.
|
||
std::vector<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);
|
||
|
||
/// 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;
|
||
}
|
||
|
||
/// The port under the pointer: `-1` none, `-2` the selected node's output, otherwise the index of
|
||
/// the node whose *input* `which` was hit.
|
||
int math_port_at(const MathDialog* d, double x, double y, int& which) {
|
||
which = 0;
|
||
for (std::size_t i = 0; i < d->nodes.size(); ++i) {
|
||
const MathNode& n = d->nodes[i];
|
||
double px = 0.0;
|
||
double py = 0.0;
|
||
math_output_pos(n, px, py);
|
||
if (std::fabs(x - px) <= kPort && std::fabs(y - py) <= kPort) return -2;
|
||
for (int k = 0; k < math_op_arity(n.op); ++k) {
|
||
math_input_pos(n, k, px, py);
|
||
if (std::fabs(x - px) <= kPort && std::fabs(y - py) <= kPort) {
|
||
which = k;
|
||
return static_cast<int>(i);
|
||
}
|
||
}
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
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);
|
||
// 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);
|
||
cairo_set_source_rgb(cr, 0.55, 0.62, 0.72);
|
||
cairo_set_line_width(cr, 1.5);
|
||
cairo_move_to(cr, ax, ay);
|
||
cairo_curve_to(cr, ax + 40.0, ay, bx - 40.0, by, bx, by);
|
||
cairo_stroke(cr);
|
||
}
|
||
}
|
||
for (std::size_t i = 0; i < d->nodes.size(); ++i) {
|
||
const MathNode& n = d->nodes[i];
|
||
const GdkRectangle r = math_node_rect(n);
|
||
const bool sel = static_cast<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);
|
||
}
|
||
// Ports: inputs on the left, the output on the right.
|
||
for (int k = 0; k < math_op_arity(n.op); ++k) {
|
||
double px = 0.0;
|
||
double py = 0.0;
|
||
math_input_pos(n, k, px, py);
|
||
const bool armed = d->pending_out >= 0 && static_cast<int>(i) > d->pending_out;
|
||
cairo_set_source_rgb(cr, armed ? 1.0 : 0.45, armed ? 0.69 : 0.52, armed ? 0.13 : 0.62);
|
||
cairo_rectangle(cr, px - kPort * 0.5, py - kPort * 0.5, kPort, kPort);
|
||
cairo_fill(cr);
|
||
}
|
||
double ox = 0.0;
|
||
double oy = 0.0;
|
||
math_output_pos(n, ox, oy);
|
||
cairo_set_source_rgb(cr, d->pending_out == static_cast<int>(i) ? 1.0 : 0.45,
|
||
d->pending_out == static_cast<int>(i) ? 0.69 : 0.52,
|
||
d->pending_out == static_cast<int>(i) ? 0.13 : 0.62);
|
||
cairo_rectangle(cr, ox - kPort * 0.5, oy - kPort * 0.5, kPort, kPort);
|
||
cairo_fill(cr);
|
||
}
|
||
(void)width;
|
||
(void)height;
|
||
}
|
||
|
||
void math_canvas_pressed(GtkGestureClick*, int, double x, double y, gpointer user) {
|
||
MathDialog* d = static_cast<MathDialog*>(user);
|
||
int which = 0;
|
||
const int port = math_port_at(d, x, y, which);
|
||
if (port == -2) {
|
||
// An output square: arm it. Clicking it again disarms, so a mis-click needs no escape.
|
||
const int node = math_node_at(d, x, y);
|
||
d->pending_out = (node >= 0 && d->pending_out == node) ? -1 : node;
|
||
gtk_widget_queue_draw(d->canvas);
|
||
return;
|
||
}
|
||
if (port >= 0) {
|
||
if (d->pending_out >= 0 && d->pending_out < port) {
|
||
MathNode& n = d->nodes[static_cast<std::size_t>(port)];
|
||
(which == 0 ? n.in_a : n.in_b) = d->pending_out;
|
||
d->pending_out = -1;
|
||
d->selected = port;
|
||
math_dialog_sync(d);
|
||
return;
|
||
}
|
||
d->selected = port;
|
||
math_dialog_sync(d);
|
||
return;
|
||
}
|
||
const int node = math_node_at(d, x, y);
|
||
if (node >= 0) {
|
||
d->selected = node;
|
||
d->pending_out = -1;
|
||
math_dialog_sync(d);
|
||
return;
|
||
}
|
||
d->pending_out = -1;
|
||
gtk_widget_queue_draw(d->canvas);
|
||
}
|
||
|
||
void math_canvas_drag_begin(GtkGestureDrag*, double x, double y, gpointer user) {
|
||
MathDialog* d = static_cast<MathDialog*>(user);
|
||
// Only a drag that *began on a box* moves one. Dragging the background used to move whatever was
|
||
// selected, which reads as the window fighting the pointer.
|
||
const int node = math_node_at(d, x, y);
|
||
d->drag_node = node;
|
||
if (node < 0) return;
|
||
d->selected = node;
|
||
d->grab_x = d->nodes[static_cast<std::size_t>(node)].x;
|
||
d->grab_y = d->nodes[static_cast<std::size_t>(node)].y;
|
||
math_dialog_sync(d);
|
||
}
|
||
|
||
void math_canvas_drag(GtkGestureDrag*, double dx, double dy, gpointer user) {
|
||
MathDialog* d = static_cast<MathDialog*>(user);
|
||
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 *total* offset from where the box was when the drag began -- from `grab_x`/`grab_y`, which
|
||
// no update writes. Feeding each update's delta back into the origin is what made the box fly off.
|
||
n.x = std::max(0.0, d->grab_x + dx);
|
||
n.y = std::max(0.0, d->grab_y + dy);
|
||
gtk_widget_queue_draw(d->canvas);
|
||
}
|
||
|
||
void on_math_node_added(GtkButton*, gpointer user) {
|
||
MathDialog* d = static_cast<MathDialog*>(user);
|
||
// A node added to a graph that was *linear* -- its output was the last node -- becomes the output,
|
||
// which is what "add a gain and it applies" means. A graph whose output is an earlier node is a
|
||
// deliberate choice and is left alone.
|
||
const bool was_linear =
|
||
d->output == static_cast<int>(d->nodes.size()) - 1 || d->nodes.empty();
|
||
MathNode n;
|
||
n.op = MathOp::Gain;
|
||
const std::size_t k = d->nodes.size();
|
||
// The same grid as the auto-place, so a node added here lands clear of the others.
|
||
n.x = 24.0 + 160.0 * static_cast<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;
|
||
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_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->pending_out = -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));
|
||
std::vector<std::string> names;
|
||
{
|
||
std::lock_guard<std::mutex> lock(d->app->model->mutex());
|
||
for (const SignalInfo& sig : d->app->model->signals()) names.push_back(sig.name);
|
||
}
|
||
if (sel < names.size()) n.source = names[sel];
|
||
}
|
||
gtk_widget_queue_draw(d->canvas);
|
||
}
|
||
|
||
/// The op changed: the new op may take a different number of inputs, so the ones it cannot use are
|
||
/// cleared rather than left pointing at a node the graph would then evaluate from anyway.
|
||
void on_math_op_changed(GObject* object, GParamSpec*, gpointer user) {
|
||
MathDialog* d = static_cast<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) {
|
||
if (d->selected < 0 || d->selected >= static_cast<int>(d->nodes.size())) 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) {
|
||
std::vector<std::string> names;
|
||
{
|
||
std::lock_guard<std::mutex> lock(d->app->model->mutex());
|
||
for (const SignalInfo& sig : d->app->model->signals()) names.push_back(sig.name);
|
||
}
|
||
GtkStringList* list = gtk_string_list_new(nullptr);
|
||
guint pick = 0;
|
||
bool found = false;
|
||
for (std::size_t i = 0; i < names.size(); ++i) {
|
||
gtk_string_list_append(list, names[i].c_str());
|
||
if (names[i] == n.source) {
|
||
pick = static_cast<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);
|
||
}
|
||
// The op dropdown follows the selection without firing its own handler back into the node.
|
||
if (d->op_drop != nullptr) gtk_drop_down_set_selected(GTK_DROP_DOWN(d->op_drop),
|
||
static_cast<guint>(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);
|
||
std::string why;
|
||
MathChannel m;
|
||
m.name = gtk_editable_get_text(GTK_EDITABLE(d->name));
|
||
m.unit = gtk_editable_get_text(GTK_EDITABLE(d->unit));
|
||
m.nodes = d->nodes;
|
||
m.output = d->output;
|
||
if (!math_channel_valid(m, &why)) {
|
||
// The dialog stays up: an invalid graph is a thing to fix, and closing on it would throw the
|
||
// work away with no message.
|
||
GtkAlertDialog* alert = gtk_alert_dialog_new("%s", why.c_str());
|
||
gtk_alert_dialog_show(alert, GTK_WINDOW(d->window));
|
||
g_object_unref(alert);
|
||
return;
|
||
}
|
||
// `math_channel_valid()` is about the *graph* (an output, every input wired to a node above it);
|
||
// that a `Source` names a channel the scope actually acquires is the dialog's business, since only
|
||
// it can see the acquired list. A graph naming a disabled signal is one that would read NaN for
|
||
// ever, and it must not be saved.
|
||
{
|
||
const std::vector<std::string> acquired = math_source_names(*d->app);
|
||
for (std::size_t i = 0; i < m.nodes.size(); ++i) {
|
||
if (m.nodes[i].op != MathOp::Source) continue;
|
||
if (std::find(acquired.begin(), acquired.end(), m.nodes[i].source) != acquired.end()) continue;
|
||
const std::string text = "node " + std::to_string(i + 1) + ": '" + m.nodes[i].source +
|
||
"' is not an acquired channel";
|
||
GtkAlertDialog* alert = gtk_alert_dialog_new("%s", text.c_str());
|
||
gtk_alert_dialog_show(alert, GTK_WINDOW(d->window));
|
||
g_object_unref(alert);
|
||
return;
|
||
}
|
||
}
|
||
if (m.name.empty()) m.name = "math";
|
||
if (d->edit >= 0 && d->edit < static_cast<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);
|
||
gtk_widget_add_controller(d->canvas, GTK_EVENT_CONTROLLER(drag));
|
||
gtk_box_append(GTK_BOX(body), d->canvas);
|
||
|
||
// The form: every row exists from the start and is shown or hidden per op, so no rebuild can
|
||
// invalidate a callback's handle.
|
||
GtkWidget* form = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
|
||
gtk_widget_set_size_request(form, 250, -1);
|
||
|
||
GtkWidget* op_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||
GtkWidget* op_text = gtk_label_new("operation");
|
||
gtk_widget_set_size_request(op_text, 74, -1);
|
||
gtk_widget_set_halign(op_text, GTK_ALIGN_START);
|
||
gtk_box_append(GTK_BOX(op_row), op_text);
|
||
std::vector<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]));
|
||
|
||
GtkWidget* add = gtk_button_new_with_label("add node");
|
||
GtkWidget* del = gtk_button_new_with_label("delete node");
|
||
GtkWidget* out = gtk_button_new_with_label("set as output");
|
||
g_signal_connect(add, "clicked", G_CALLBACK(on_math_node_added), d);
|
||
g_signal_connect(del, "clicked", G_CALLBACK(on_math_node_deleted), d);
|
||
g_signal_connect(out, "clicked", G_CALLBACK(on_math_output_set), d);
|
||
GtkWidget* buttons = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||
gtk_box_append(GTK_BOX(buttons), add);
|
||
gtk_box_append(GTK_BOX(buttons), del);
|
||
gtk_box_append(GTK_BOX(buttons), out);
|
||
gtk_box_append(GTK_BOX(form), buttons);
|
||
gtk_box_append(GTK_BOX(body), form);
|
||
gtk_box_append(GTK_BOX(outer), body);
|
||
|
||
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
|
||
g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window);
|
||
GtkWidget* apply = gtk_button_new_with_label(edit >= 0 ? "Save" : "Add");
|
||
gtk_widget_add_css_class(apply, "suggested-action");
|
||
g_signal_connect(apply, "clicked", G_CALLBACK(on_math_apply), d);
|
||
gtk_widget_set_halign(actions, GTK_ALIGN_END);
|
||
gtk_box_append(GTK_BOX(actions), cancel);
|
||
gtk_box_append(GTK_BOX(actions), apply);
|
||
gtk_box_append(GTK_BOX(outer), actions);
|
||
|
||
app.math_dialog = window;
|
||
math_dialog_sync(d);
|
||
gtk_window_present(GTK_WINDOW(window));
|
||
}
|
||
|
||
GtkWidget* build_math_panel(App& app) {
|
||
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
|
||
gtk_widget_set_margin_top(box, 6);
|
||
gtk_widget_set_margin_bottom(box, 6);
|
||
gtk_widget_set_margin_start(box, 6);
|
||
gtk_widget_set_margin_end(box, 6);
|
||
|
||
GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
|
||
GtkWidget* title = gtk_label_new("Math channels");
|
||
gtk_widget_set_halign(title, GTK_ALIGN_START);
|
||
gtk_widget_set_hexpand(title, true);
|
||
gtk_box_append(GTK_BOX(head), title);
|
||
GtkWidget* add = gtk_button_new_from_icon_name("list-add-symbolic");
|
||
gtk_widget_add_css_class(add, "flat");
|
||
gtk_widget_set_tooltip_text(add, "add a math channel");
|
||
g_signal_connect(add, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||
add_math_channel(*static_cast<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 int output_built = ed->output;
|
||
const double gain_read = ed->nodes.empty() ? 0.0 : ed->nodes.back().gain;
|
||
const double offset_read = ed->nodes.empty() ? 0.0 : ed->nodes.back().offset;
|
||
on_math_apply(nullptr, ed); // frees the dialog: nothing below may touch `ed`
|
||
const std::size_t saved = a->math.empty()
|
||
? 0u
|
||
: a->math[static_cast<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");
|
||
}
|
||
// 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)));
|
||
}
|
||
}
|
||
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
|