diff --git a/CLAUDE.md b/CLAUDE.md index fa217a8..cf4bb7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 mispacked record. Read it back with `pd.DataFrame(np.load(path))` after changing 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 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 `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. + 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 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 @@ -271,6 +283,11 @@ far the largest file. - **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 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 through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via `find_labelled_button()`. The Arm/Disarm button in the trigger panel still has text. diff --git a/README.md b/README.md index 4fbee96..4200f46 100644 --- a/README.md +++ b/README.md @@ -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 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 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 diff --git a/src/ui.cpp b/src/ui.cpp index 9f00c19..68e7314 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -57,6 +57,12 @@ struct App { 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; GtkWidget* window_drop = nullptr; GtkWidget* trigger_toggle = nullptr; GtkWidget* trigger_revealer = nullptr; @@ -94,7 +100,19 @@ struct App { /// frame, and whether an event is held (which is what "frozen" means). std::uint64_t seen_latches = 0; 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 + /// 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 stored_rate; + std::vector 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); @@ -108,6 +126,10 @@ struct App { int dbg_blank_frames = 0; /// Frames the one area staged: a figure is one `::render` for every row. 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; std::vector last_stored; std::uint64_t dbg_latches_at_disarm = 0; @@ -258,6 +280,61 @@ std::string format_window(double s) { 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 "\u25CF"; + case BadgePhase::Dim: + return "\u25CF"; + case BadgePhase::Idle: + break; + } + return "\u25CF"; +} + +/// `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(seconds) / 60; + std::snprintf(buf, sizeof(buf), "%dm %02ds", m, static_cast(seconds) % 60); + } else { + const int h = static_cast(seconds) / 3600; + std::snprintf(buf, sizeof(buf), "%dh %02dm", h, (static_cast(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 /// picture does not show by itself (a drop that changed nothing, an export that wrote a /// file). `tick()` clears it when it expires. @@ -1272,6 +1349,7 @@ gboolean tick(gpointer user) { if (latches != app->seen_latches) { app->seen_latches = latches; 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 // snapshot itself. Not off `event_span`/`pretrigger_pct`: those say what was // *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. static gint64 next_channels = 0; 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()) { // Seed new entries with the current count: a fresh entry must not be counted // as a delta from zero. @@ -1414,6 +1496,8 @@ gboolean tick(gpointer user) { next_channels = now_c + G_TIME_SPAN_SECOND; app->last_channels_meas = now_c; std::lock_guard 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(); @@ -1430,6 +1514,10 @@ gboolean tick(gpointer user) { stored >= prev && dt_s > 0.0 ? double(stored - prev) / dt_s : 0.0; if (c < app->last_stored.size()) app->last_stored[static_cast(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; + 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 plot::Range y = buf.size() > 0 ? buf.y_bounds() : plot::Range{0.0, 0.0}; std::fprintf(stderr, @@ -1496,6 +1584,30 @@ gboolean tick(gpointer user) { } 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. + 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(phase)]; + const int badge_key = static_cast(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) { @@ -1505,18 +1617,46 @@ gboolean tick(gpointer user) { samples += static_cast(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), - "[%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, " - "time x%llu", + "time x%llu%s", mode, st.connected.load() ? "connected" : "waiting", - st.have_config.load() ? "configured" : "-", + st.have_config.load() ? "configured" : "-", rate.c_str(), static_cast(st.packets.load()), static_cast(st.frames.load()), static_cast(st.gaps.load()), signal_count, channel_count, bound, samples, double(model.config().buffer_mb), span_s, - static_cast(time_stride)); + static_cast(time_stride), since.c_str()); if (!app->notice.empty() && g_get_monotonic_time() < app->notice_until_us) { const std::string with_notice = app->notice + " | " + status; gtk_label_set_text(GTK_LABEL(app->status_label), with_notice.c_str()); @@ -1524,6 +1664,7 @@ gboolean tick(gpointer user) { app->notice.clear(); 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; } @@ -1737,6 +1878,16 @@ void on_activate(GtkApplication* application, gpointer user) { 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 @@ -1746,10 +1897,11 @@ void on_activate(GtkApplication* application, gpointer user) { 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_widget_set_margin_top(app->status_label, 2); - gtk_widget_set_margin_bottom(app->status_label, 2); - gtk_widget_set_margin_start(app->status_label, 6); - gtk_box_append(GTK_BOX(left), app->status_label); + gtk_box_append(GTK_BOX(status_row), app->status_label); + 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 @@ -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_trigger_time_valid); 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; " "signal list items=%d selected=%d; column visible after sidebar " "toggle off=%d\n",