added status line info:

This commit is contained in:
Martino Ferrari committed 2026-10-01 16:26:54 +02:00
1 parent ea560ed272
commit 3d4085898e
3 files changed
+204 -9

No files matched your search

+17
View File
@@ -120,6 +120,13 @@ Two lines of the self-check output are assertions in disguise:
`64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)`, so a size that is not that is a `64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)`, so a size that is not that is a
mispacked record. Read it back with `pd.DataFrame(np.load(path))` after changing mispacked record. Read it back with `pd.DataFrame(np.load(path))` after changing
either writer. either writer.
- `badge frames: lit=L dim=D idle=I` — the activity badge's phase per frame. Over a run
that acquired anything **both `lit` and `dim` must be non-zero**: that is what "it
blinks" means, and a badge stuck lit is indistinguishable from a working one in a
screenshot. One or two `idle` frames at the start are the frames before the socket
connected; a large `idle` is correct only when the run paused or disarmed (a
`UDPSCOPE_PROBE_DISARM_MS` run should show hundreds). The `mode ...` lines carry the
rest of the status line, so `rate` and `last event ... ago` are checked there.
The probes are the regression net — when changing UI or plot-host behaviour, extend The probes are the regression net — when changing UI or plot-host behaviour, extend
the probe/dump in `src/ui.cpp` rather than adding a separate harness. the probe/dump in `src/ui.cpp` rather than adding a separate harness.
@@ -233,6 +240,11 @@ far the largest file.
- **Layout must not depend on content.** The y gutter is reserved at the widest label - **Layout must not depend on content.** The y gutter is reserved at the widest label
`TickFormat::Si` can produce and only ever grows; the status label is ellipsized so `TickFormat::Si` can produce and only ever grows; the status label is ellipsized so
its text cannot drive the paned divider. Both exist to stop per-frame relayout. its text cannot drive the paned divider. Both exist to stop per-frame relayout.
The same rule is why the activity badge is one glyph in three colours rather than
three glyphs, why the `rate` field collapses to one figure or a range instead of a
per-channel list, and why `format_elapsed()` steps to a coarser unit instead of
letting the counter grow: each is a status field whose *width* would otherwise depend
on the stream, and the status row is directly above the plot.
The reservation goes on the **figure** (`Figure::set_min_y_gutter()`, added to the The reservation goes on the **figure** (`Figure::set_min_y_gutter()`, added to the
plotter for this), not on the rows: `Figure::layout()` clears every row's plotter for this), not on the rows: `Figure::layout()` clears every row's
`set_min_y_gutter()` to measure what its own labels need, so a floor written onto the `set_min_y_gutter()` to measure what its own labels need, so a floor written onto the
@@ -271,6 +283,11 @@ far the largest file.
- **The export is collected at click time, not in the file-dialog callback.** The live - **The export is collected at click time, not in the file-dialog callback.** The live
view keeps scrolling while the chooser is up, so a late collection would write a view keeps scrolling while the chooser is up, so a late collection would write a
window the user never saw. The writer is picked by the chosen path's extension. window the user never saw. The writer is picked by the chosen path's extension.
- **The status line's per-second fields are measured once a second, not per frame.**
`App::stored_rate`/`declared_rate` and the label's tooltip are refreshed on the same
tick as the channel dump; the status line only formats what is already there. The
badge's markup is likewise rewritten on a change of phase (twice a second), never on
all sixty frames.
- **The header bar's buttons are icon-only**, so headless probes must reach them - **The header bar's buttons are icon-only**, so headless probes must reach them
through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via
`find_labelled_button()`. The Arm/Disarm button in the trigger panel still has text. `find_labelled_button()`. The Arm/Disarm button in the trigger panel still has text.
+24
View File
@@ -231,6 +231,30 @@ later, but the line marks the edge, so the pre/post split can be read straight o
the plot. It is not one of the user's cursors: it has its own colour, ignores the the plot. It is not one of the user's cursors: it has its own colour, ignores the
cursor show/hide toggle, and is parked (invisible) when no event is held. cursor show/hide toggle, and is parked (invisible) when no event is held.
**The status line.** It starts with an activity badge and then the mode, the
connection, the measured rate, the packet/frame/gap counters, what plot 1 holds and
the buffer's size and span. Three of those are worth calling out:
- **The badge blinks green at 1 Hz while the pipeline is actually running** — the
stream is connected, acquisition is not paused, and in trigger mode the trigger is
armed. Anything else leaves it a steady grey, with the reason on its tooltip
(`paused`, `trigger disarmed`, `waiting for the stream`). *Blinking* is the signal,
not the colour: a steady badge is a stopped pipeline, and that distinction survives
a screenshot. It is one glyph whose colour is all that changes, so blinking it
cannot change its width — a badge that grew and shrank twice a second would move the
label beside it and re-lay out the plot with it.
- **`rate 9.99 kS/s` is the rate the buffer actually stored over the last second**, not
the rate the CONFIG declared. The app does **no** decimation (see `plan_partition()`),
so the two normally agree and a gap between them is the datasource's real wire rate.
One figure when every channel agrees within a percent, `lo..hi` when they do not;
the per-channel breakdown, stored against declared, is on the line's tooltip. A
per-channel list in the line itself would make its width depend on the stream.
- **`last event 12 s ago`** (trigger mode only) ages the picture on screen, in seconds,
then minutes, then hours. A frozen view looks the same whether the last event was a
second or an hour ago, so without it there is no way to tell a working trigger from
a dead one. Measured on the wall clock, not the stream's: it is a question about how
long *you* have been waiting. Before the first latch it reads `no event yet`.
Switching the header bar's **trigger** button off returns to `[live]` and resumes Switching the header bar's **trigger** button off returns to `[live]` and resumes
drawing the stream; the trigger panel carries the same action as a drawing the stream; the trigger panel carries the same action as a
**Go live (trigger off)** button, so the way out is inside the panel that froze **Go live (trigger off)** button, so the way out is inside the panel that froze
+163 -9
View File
@@ -57,6 +57,12 @@ struct App {
plot::Plot placeholder_plot; plot::Plot placeholder_plot;
GtkWidget* status_label = nullptr; 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;
GtkWidget* window_drop = nullptr; GtkWidget* window_drop = nullptr;
GtkWidget* trigger_toggle = nullptr; GtkWidget* trigger_toggle = nullptr;
GtkWidget* trigger_revealer = nullptr; GtkWidget* trigger_revealer = nullptr;
@@ -94,7 +100,19 @@ struct App {
/// frame, and whether an event is held (which is what "frozen" means). /// frame, and whether an event is held (which is what "frozen" means).
std::uint64_t seen_latches = 0; std::uint64_t seen_latches = 0;
bool event_held = false; bool event_held = false;
/// Monotonic time of the latch `seen_latches` counts, so the status line can age 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 latch of the session.
gint64 last_latch_us = 0;
double window_choices_limit = -1.0; ///< the cap the dropdown was last built with double window_choices_limit = -1.0; ///< the cap the dropdown was last built with
/// The rate each channel actually *stored* over the last second, and the declared rate
/// beside it. The app does no decimation, so the stored rate is the datasource's own
/// wire rate rather than anything the viewer chose -- which is why it is worth showing:
/// the declared rate is a claim and this is the receipt. 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. /// Self-check: how much the axes' left edge moves between frames.
int dbg_axes_x_min = 1 << 20; int dbg_axes_x_min = 1 << 20;
int dbg_axes_x_max = -(1 << 20); int dbg_axes_x_max = -(1 << 20);
@@ -108,6 +126,10 @@ struct App {
int dbg_blank_frames = 0; int dbg_blank_frames = 0;
/// Frames the one area staged: a figure is one `::render` for every row. /// Frames the one area staged: a figure is one `::render` for every row.
int dbg_frames_painted = 0; int dbg_frames_painted = 0;
/// Frames the badge spent in each `BadgePhase`. Both `lit` and `dim` have to be non-zero
/// over a run that acquired anything -- that is what "it blinks" means, and a badge
/// stuck lit looks identical to a working one in a screenshot.
int dbg_badge_frames[3] = {0, 0, 0};
int dbg_reset_visible = -1; int dbg_reset_visible = -1;
std::vector<std::uint64_t> last_stored; std::vector<std::uint64_t> last_stored;
std::uint64_t dbg_latches_at_disarm = 0; std::uint64_t dbg_latches_at_disarm = 0;
@@ -258,6 +280,61 @@ std::string format_window(double s) {
return buf; return buf;
} }
/// What the activity badge is saying. `Lit`/`Dim` are the two halves of the blink, so
/// the badge is only *blinking* while the pipeline is actually running; a steady badge is
/// a stopped one, which is the state worth noticing on a glance.
enum class BadgePhase { Lit = 0, Dim = 1, Idle = 2 };
/// The badge is one glyph in one of three colours. It must stay the *same glyph*: the
/// status row sits above the plot, and a badge that changed width as it blinked would
/// move the label beside it and re-lay out the plot twice a second.
const char* badge_markup(BadgePhase phase) {
switch (phase) {
case BadgePhase::Lit:
return "<span foreground=\"#45E045\">\u25CF</span>";
case BadgePhase::Dim:
return "<span foreground=\"#1C5A1C\">\u25CF</span>";
case BadgePhase::Idle:
break;
}
return "<span foreground=\"#5A5A5A\">\u25CF</span>";
}
/// `12 s`, `3m 07s`, `2h 14m` -- one unit coarser as soon as the finer one would need
/// three digits, so the field never grows past six characters and the status line's shape
/// does not depend on how long the wait has been.
std::string format_elapsed(double seconds) {
if (!(seconds >= 0.0)) return "--";
char buf[32];
if (seconds < 60.0) {
std::snprintf(buf, sizeof(buf), "%.0f s", std::floor(seconds));
} else if (seconds < 3600.0) {
const int m = static_cast<int>(seconds) / 60;
std::snprintf(buf, sizeof(buf), "%dm %02ds", m, static_cast<int>(seconds) % 60);
} else {
const int h = static_cast<int>(seconds) / 3600;
std::snprintf(buf, sizeof(buf), "%dh %02dm", h, (static_cast<int>(seconds) / 60) % 60);
}
return buf;
}
/// A sample rate in the unit it reads shortest in. Rates here span six decades (a 1 Hz
/// housekeeping signal next to a 1 MS/s acquisition), so a fixed unit makes one of the
/// two unreadable.
std::string format_rate(double per_s) {
char buf[32];
if (!(per_s > 0.0)) {
return "0 S/s";
} else if (per_s >= 1.0e6) {
std::snprintf(buf, sizeof(buf), "%.3g MS/s", per_s / 1.0e6);
} else if (per_s >= 1.0e3) {
std::snprintf(buf, sizeof(buf), "%.3g kS/s", per_s / 1.0e3);
} else {
std::snprintf(buf, sizeof(buf), "%.3g S/s", per_s);
}
return buf;
}
/// A short-lived line in front of the status text: the outcome of an action that the /// 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 /// picture does not show by itself (a drop that changed nothing, an export that wrote a
/// file). `tick()` clears it when it expires. /// file). `tick()` clears it when it expires.
@@ -1272,6 +1349,7 @@ gboolean tick(gpointer user) {
if (latches != app->seen_latches) { if (latches != app->seen_latches) {
app->seen_latches = latches; app->seen_latches = latches;
app->event_held = true; app->event_held = true;
app->last_latch_us = g_get_monotonic_time();
// The window is pinned to exactly what the snapshot captured, read off the // 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 // snapshot itself. Not off `event_span`/`pretrigger_pct`: those say what was
// *asked for*, in stream seconds, while how much the capture actually covers // *asked for*, in stream seconds, while how much the capture actually covers
@@ -1401,6 +1479,10 @@ gboolean tick(gpointer user) {
// wire rate -- this line is the receipt for that. // wire rate -- this line is the receipt for that.
static gint64 next_channels = 0; static gint64 next_channels = 0;
const gint64 now_c = g_get_monotonic_time(); const gint64 now_c = g_get_monotonic_time();
/// Filled only on a measuring tick, and applied to the status label's tooltip after the
/// model lock is dropped: the status line can only carry the aggregate rate, so the
/// per-channel breakdown goes where there is room for it.
std::string rate_tip;
if (app->last_stored.size() != model.channel_count()) { if (app->last_stored.size() != model.channel_count()) {
// Seed new entries with the current count: a fresh entry must not be counted // Seed new entries with the current count: a fresh entry must not be counted
// as a delta from zero. // as a delta from zero.
@@ -1414,6 +1496,8 @@ gboolean tick(gpointer user) {
next_channels = now_c + G_TIME_SPAN_SECOND; next_channels = now_c + G_TIME_SPAN_SECOND;
app->last_channels_meas = now_c; app->last_channels_meas = now_c;
std::lock_guard<std::mutex> lock(model.mutex()); 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) { for (std::size_t c = 0; c < model.channel_count(); ++c) {
const ChannelInfo& info = model.channels()[c]; const ChannelInfo& info = model.channels()[c];
const u64 stored = model.live().values(c).produced(); const u64 stored = model.live().values(c).produced();
@@ -1430,6 +1514,10 @@ gboolean tick(gpointer user) {
stored >= prev && dt_s > 0.0 ? double(stored - prev) / dt_s : 0.0; 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; 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; const double declared = info.dt > 0.0 ? 1.0 / info.dt : 0.0;
app->stored_rate[c] = per_s;
app->declared_rate[c] = declared;
rate_tip += (rate_tip.empty() ? "" : "\n") + info.name + ": " + format_rate(per_s) +
" stored (declared " + format_rate(declared) + ")";
const ChannelBuffer& buf = model.live().channel(c); const ChannelBuffer& buf = model.live().channel(c);
const plot::Range y = buf.size() > 0 ? buf.y_bounds() : plot::Range{0.0, 0.0}; const plot::Range y = buf.size() > 0 ? buf.y_bounds() : plot::Range{0.0, 0.0};
std::fprintf(stderr, std::fprintf(stderr,
@@ -1496,6 +1584,30 @@ gboolean tick(gpointer user) {
} else if (app->acq->paused()) { } else if (app->acq->paused()) {
mode = "paused"; mode = "paused";
} }
// The badge: lit and blinking while the pipeline is actually running end to end.
// Paused and disarmed both stop it, and so does a stream that has not arrived -- a
// green light over a dead socket is the one reading that must not happen. The blink is
// a 1 Hz square wave off the monotonic clock rather than its own timer, so it cannot
// drift away from the frame it is drawn on.
const bool paused = app->acq->paused();
const bool active = st.connected.load() && !paused && (!trigger_on || armed);
const bool lit = ((g_get_monotonic_time() / (G_TIME_SPAN_SECOND / 2)) & 1) == 0;
const BadgePhase phase =
!active ? BadgePhase::Idle : (lit ? BadgePhase::Lit : BadgePhase::Dim);
const int reason = active ? 0 : !st.connected.load() ? 1 : paused ? 2 : 3;
static const char* const kWhy[] = {"acquiring", "waiting for the stream", "paused",
"trigger disarmed"};
// Keyed on the reason as well as the phase: three of the reasons share the Idle phase,
// so a pause that becomes a disarm has to reach the tooltip.
++app->dbg_badge_frames[static_cast<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; int bound = 0;
std::size_t samples = 0; std::size_t samples = 0;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
@@ -1505,18 +1617,46 @@ gboolean tick(gpointer user) {
samples += static_cast<std::size_t>(b->size()); samples += static_cast<std::size_t>(b->size());
} }
} }
char status[768]; // 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 = "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 = "rate " + (hi - lo <= 0.01 * hi ? format_rate(hi)
: format_rate(lo) + ".." + format_rate(hi));
}
// In trigger mode, how long ago the event on screen was captured. It is what tells a
// frozen picture apart from a stale one: the view looks identical whether the last
// event was a second or an hour ago.
std::string since;
if (trigger_on) {
since = app->last_latch_us != 0
? " | last event " +
format_elapsed(double(g_get_monotonic_time() - app->last_latch_us) / 1.0e6) +
" ago"
: " | no event yet";
}
char status[1024];
std::snprintf(status, sizeof(status), std::snprintf(status, sizeof(status),
"[%s] %s %s | packets %llu frames %llu gaps %llu | %zu signals, %zu " "[%s] %s %s | %s | packets %llu frames %llu gaps %llu | %zu signals, %zu "
"channels | plot 1: %d trace(s), %zu samples | buffer %.1f MB, %.2f s span, " "channels | plot 1: %d trace(s), %zu samples | buffer %.1f MB, %.2f s span, "
"time x%llu", "time x%llu%s",
mode, st.connected.load() ? "connected" : "waiting", mode, st.connected.load() ? "connected" : "waiting",
st.have_config.load() ? "configured" : "-", st.have_config.load() ? "configured" : "-", rate.c_str(),
static_cast<unsigned long long>(st.packets.load()), static_cast<unsigned long long>(st.packets.load()),
static_cast<unsigned long long>(st.frames.load()), static_cast<unsigned long long>(st.frames.load()),
static_cast<unsigned long long>(st.gaps.load()), signal_count, channel_count, bound, static_cast<unsigned long long>(st.gaps.load()), signal_count, channel_count, bound,
samples, double(model.config().buffer_mb), span_s, samples, double(model.config().buffer_mb), span_s,
static_cast<unsigned long long>(time_stride)); static_cast<unsigned long long>(time_stride), since.c_str());
if (!app->notice.empty() && g_get_monotonic_time() < app->notice_until_us) { if (!app->notice.empty() && g_get_monotonic_time() < app->notice_until_us) {
const std::string with_notice = app->notice + " | " + status; const std::string with_notice = app->notice + " | " + status;
gtk_label_set_text(GTK_LABEL(app->status_label), with_notice.c_str()); gtk_label_set_text(GTK_LABEL(app->status_label), with_notice.c_str());
@@ -1524,6 +1664,7 @@ gboolean tick(gpointer user) {
app->notice.clear(); app->notice.clear();
gtk_label_set_text(GTK_LABEL(app->status_label), status); gtk_label_set_text(GTK_LABEL(app->status_label), status);
} }
if (!rate_tip.empty()) gtk_widget_set_tooltip_text(app->status_label, rate_tip.c_str());
return G_SOURCE_CONTINUE; return G_SOURCE_CONTINUE;
} }
@@ -1737,6 +1878,16 @@ void on_activate(GtkApplication* application, gpointer user) {
gtk_box_append(GTK_BOX(left), app->plot_box); gtk_box_append(GTK_BOX(left), app->plot_box);
gtk_widget_set_vexpand(app->plot_box, true); 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..."); app->status_label = gtk_label_new("starting...");
// The line is rewritten every frame and its text changes width as the counters // 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 // grow. A label's *minimum* width is its text, so an unellipsized one pushes the
@@ -1746,10 +1897,11 @@ void on_activate(GtkApplication* application, gpointer user) {
gtk_label_set_ellipsize(GTK_LABEL(app->status_label), PANGO_ELLIPSIZE_END); gtk_label_set_ellipsize(GTK_LABEL(app->status_label), PANGO_ELLIPSIZE_END);
gtk_widget_set_hexpand(app->status_label, true); gtk_widget_set_hexpand(app->status_label, true);
gtk_widget_set_halign(app->status_label, GTK_ALIGN_FILL); gtk_widget_set_halign(app->status_label, GTK_ALIGN_FILL);
gtk_widget_set_margin_top(app->status_label, 2); gtk_box_append(GTK_BOX(status_row), app->status_label);
gtk_widget_set_margin_bottom(app->status_label, 2); gtk_widget_set_margin_top(status_row, 2);
gtk_widget_set_margin_start(app->status_label, 6); gtk_widget_set_margin_bottom(status_row, 2);
gtk_box_append(GTK_BOX(left), app->status_label); 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 // The right-hand side: two independently collapsible panels, each revealed by
// its toolbar toggle. No outer scrolled window -- the signal list scrolls // its toolbar toggle. No outer scrolled window -- the signal list scrolls
@@ -2271,6 +2423,8 @@ int run_ui(int argc, char** argv, ScopeModel& model, Acquisition& acq, RowSet& r
app.dbg_marker_x, app.dbg_marker_max, app.dbg_trigger_time, app.dbg_marker_x, app.dbg_marker_max, app.dbg_trigger_time,
app.dbg_trigger_time_valid); app.dbg_trigger_time_valid);
std::printf("[udpscope] mode after go-live: %s\n", app.dbg_mode_live.c_str()); std::printf("[udpscope] mode after go-live: %s\n", app.dbg_mode_live.c_str());
std::printf("[udpscope] badge frames: lit=%d dim=%d idle=%d\n", app.dbg_badge_frames[0],
app.dbg_badge_frames[1], app.dbg_badge_frames[2]);
std::printf("[udpscope] trigger panel revealed before the probe toggled it: %d; " std::printf("[udpscope] trigger panel revealed before the probe toggled it: %d; "
"signal list items=%d selected=%d; column visible after sidebar " "signal list items=%d selected=%d; column visible after sidebar "
"toggle off=%d\n", "toggle off=%d\n",