working on todo.md features

This commit is contained in:
Martino Ferrari committed 2026-10-10 22:52:12 +02:00
1 parent 210d124814
commit 8f54841c2c
110 files changed
+15932 -6576

No files matched your search

+187
View File
@@ -0,0 +1,187 @@
// 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>";
}
/// 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), "%.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);
}
/// 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