Files
udpscope/src/ui_status.cpp
T
Martino Ferrari bdf0ff7ddc scope: full-screen figures, second windows, gated measurements, per-configuration events
A batch of view and instrument work, with the probes and docs for each.

- **Full screen per figure row**: a toggle on each row's top-right corner, revealed by
  pointer proximity (`kMaximizeRevealPx` around its placed rect, per row, read from an
  overlay-level motion controller so a revealed button under the pointer cannot flicker);
  the plot menu offers the same as *Full screen this figure* / *Restore the layout*. The
  arrangement is saved and restored through `RowState`, so nothing plotted moves. A layout
  of one figure carries no toggle.
- **Second window**: `open_scope_window()` copies a figure (cells, width shares, signals in
  their colours) or opens an empty one, with its own `RowSet` and `PlotArea`; the main
  window's tick binds, acquires and repaints it, mirroring the time window and pin. Entries:
  the plot menu's *Open this plot in a new window* and the overflow menu's *New scope window*.
- **Layout editor from the plot**: the plot menu gains *Edit the layout...* / *Done editing
  the layout*, flipping the app's one toggle.
- **Measurements gate**: `MeasureOptions::gate` restricts a measurement to the held/pause
  snapshot (live falls back to the live ring), persisted with the session and shown in the
  measure dialog.
- **Crosshair axis badges**: a preference (`crosshair_axis_labels`, on by default) and the
  plotter's badges where the lines meet the axes, in the axes' own formats; the plot menu
  and the probe read them.
- **Event cache per configuration**: `EventStore` is keyed `<source>/<configuration>/`,
  `retarget_event_store()` follows every configuration switch, and a pre-configuration cache
  (loose files) is adopted into the configuration's folder on first start.
- **GTK4 lifecycle**: the main window's handlers move from the non-existent `"destroy"`
  signal to `close-request`; scope windows are closed with it and the application quits
  explicitly (the exit run's hang). The exit probe closes with `gtk_window_close()`.
- Probes: `[maximize-probe]` (single-figure rule and the corner reveal), `[scope-probe]`,
  `[split-probe]` cell crosshair leg, `[prefs-probe]` store keying, `[configs]` store key,
  `[rowmenu]` layout-editor entry; docs, help pages, README and AGENT.md updated.

Also fixes a probe-only use-after-free: `toggle_maximize_figure_row` replaces the figure
object, so the area must be re-pointed (the button's path does; the probe's raw call did not).
2026-11-05 17:58:59 +01:00

220 lines
11 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The status bar and the detail popover: the one-line glance with its activity badge, the popover\n// behind the ⓘ, the short-lived notices, and the starvation detector that says a stream stopped.
#include "ui_app.hpp"
#include <gtk/gtk.h>
#include <string>
namespace udpscope {
namespace ui_impl {
void on_popover_closed(GtkPopover* popover, gpointer) {
// Unparent, one idle later. `gtk_widget_set_parent()` took the reference the popover was created
// with (GTK4 widgets come 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, which is why there is none.
//
// The *deferral* is not tidiness either: this runs inside the `closed` emission, which GTK raises
// from `gtk_popover_popdown()`, and destroying a widget in the middle of its own signal leaves the
// popdown path running on freed memory -- a `Gtk-CRITICAL` from `gtk_popover_get_autohide`, on
// every menu entry that dismisses its popover, which is every entry there is. The idle holds the
// reference (`g_object_ref` in, `g_object_unref` out) so the popover outlives its own emission.
g_idle_add_full(
G_PRIORITY_DEFAULT_IDLE,
+[](gpointer p) -> gboolean {
gtk_widget_unparent(GTK_WIDGET(p));
return G_SOURCE_REMOVE;
},
g_object_ref(popover), g_object_unref);
}
/// 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.
/// 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>";
}
/// What the trigger status circle is saying, by state: 0 solid green (an event is on screen,
/// or a capture is filling), 1 and 2 the bright and dim halves of the waiting flash, 3 red
/// (disarmed). Same glyph and the same colour family as the activity badge -- the two sit
/// beside each other on the status row, and two alphabets there would be a puzzle -- and one
/// glyph in every state, for the same width-locked reason.
const char* trigger_circle_markup(int state) {
switch (state) {
case 0:
case 1:
return "<span foreground=\"#45E045\">\u25CF</span>";
case 2:
return "<span foreground=\"#1C5A1C\">\u25CF</span>";
case 3:
return "<span foreground=\"#E04545\">\u25CF</span>";
default:
break;
}
return "<span foreground=\"#5A5A5A\">\u25CF</span>";
}
/// 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) {
app.notice = std::move(what);
app.notice_until_us = g_get_monotonic_time() + seconds * G_TIME_SPAN_SECOND;
}
/// 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; }
/// 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) {
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());
}
/// 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];
if (!app.waveform_name.empty()) {
// Not "waiting, no config": a file is not a source that has not answered yet.
std::snprintf(buf, sizeof(buf), "file: %s", app.waveform_name.c_str());
} else {
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), "%s samples/signal · %s · %.0f s span · time x%llu",
udpscope::format_samples(s.buffer_samples).c_str(),
s.buffer_bytes > 0 ? udpscope::format_bytes(s.buffer_bytes).c_str() : "not planned",
s.span_s, static_cast<unsigned long long>(s.time_stride));
gtk_label_set_text(GTK_LABEL(app.popover_label[7]), buf);
// The reason the figure is not the one asked for, where there is one: a smaller ring than the
// source form promised, with the limit that did it, as the label's tooltip.
gtk_widget_set_tooltip_text(
app.popover_label[7],
s.memory_limited
? ("limited to " + udpscope::format_samples(s.buffer_samples) + " samples by the " +
udpscope::format_bytes(s.memory_limit_bytes) + " memory limit (" +
udpscope::format_samples(s.buffer_requested) + " requested)")
.c_str()
: nullptr);
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);
}
/// 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);
}
}
} // namespace ui_impl
} // namespace udpscope