diff --git a/CMakeLists.txt b/CMakeLists.txt index 9594037..2b43668 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -112,6 +112,7 @@ add_executable(udpscope src/source_dialog.cpp src/measure.cpp src/math_channel.cpp + src/status_text.cpp src/shortcuts.cpp src/help_window.cpp src/ui.cpp diff --git a/src/status_text.cpp b/src/status_text.cpp new file mode 100644 index 0000000..bf178e7 --- /dev/null +++ b/src/status_text.cpp @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT +#include "status_text.hpp" + +#include +#include + +namespace udpscope { + +/// `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; +} + +std::string format_glance(const StatusSnapshot& s) { + std::string out = s.state_word; + out += " · "; + out += s.rate_text.empty() ? "no data" : s.rate_text; + out += " · "; + out += s.window_text; + out += " window"; + if (s.trigger_on) { + out += " · "; + if (s.have_event) { + out += "event " + format_elapsed(s.since_event_s) + " ago"; + } else { + out += "no event yet"; + } + } + return out; +} + +std::string format_detail(const StatusSnapshot& s) { + char buf[1024]; + std::snprintf(buf, sizeof(buf), + "[%s] %s %s | rate %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", + s.state_detail.c_str(), s.connected ? "connected" : "waiting", + s.have_config ? "configured" : "-", + s.rate_text.empty() ? "--" : s.rate_text.c_str(), + static_cast(s.packets), + static_cast(s.frames), + static_cast(s.gaps), s.signals, s.channels, + s.plot1_traces, s.plot1_samples, s.buffer_mb, s.span_s, + static_cast(s.time_stride)); + std::string out = buf; + if (s.trigger_on) { + out += s.have_event ? " | last event " + format_elapsed(s.since_event_s) + " ago" + : " | no event yet"; + } + if (s.reconnects > 0) { + char note[48]; + std::snprintf(note, sizeof(note), " | %d reconnects", s.reconnects); + out += note; + } + return out; +} + +} // namespace udpscope diff --git a/src/status_text.hpp b/src/status_text.hpp new file mode 100644 index 0000000..7e9d698 --- /dev/null +++ b/src/status_text.hpp @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +#pragma once + +#include +#include +#include + +namespace udpscope { + +/// Everything either tier of the status bar shows, gathered once per frame by the tick so the +/// two cannot disagree. No GTK here on purpose: this is the one part of the status bar that +/// can be reasoned about as text. +struct StatusSnapshot { + /// One word from the fixed set: "live", "paused", "armed", "capturing", "event held", + /// "disarmed". It is a word from a *fixed* set because the bar sits directly above the plot + /// and a field whose width tracked the stream would relayout it every frame. + const char* state_word = "live"; + /// The long prose the old single-line bar carried, kept for the detail string and the dump. + std::string state_detail; + + bool connected = false; + bool have_config = false; + bool trigger_on = false; + + /// Already collapsed to one figure or a range by the caller, without the "rate " prefix. + std::string rate_text; + double stored_rate_hz = 0.0; + double declared_rate_hz = 0.0; + + /// Pre-formatted by ui.cpp's format_window(), which the window control and the Fit notices + /// also use, so it stays there rather than moving here. + std::string window_text; + + std::uint64_t packets = 0; + std::uint64_t frames = 0; + std::uint64_t gaps = 0; + std::size_t signals = 0; + std::size_t channels = 0; + int plot1_traces = 0; + std::size_t plot1_samples = 0; + double buffer_mb = 0.0; + double span_s = 0.0; + std::uint64_t time_stride = 1; + bool have_event = false; + double since_event_s = 0.0; + int reconnects = 0; +}; + +/// The bar: "live · 10.0 kS/s · 2 s window", plus " · event 4.2 s ago" in trigger mode and +/// nothing else ever. The badge glyph is a separate widget and is not part of this string. +std::string format_glance(const StatusSnapshot& s); + +/// The popover's prose and the exit dump's line, field for field as the old single-line bar +/// wrote it. CLAUDE.md asserts those fields are read out of the dump, so the field names and +/// their order must not change without updating it. +std::string format_detail(const StatusSnapshot& s); + +/// A sample rate as "10.0 kS/s". Moved here from ui.cpp, which still calls it. +std::string format_rate(double hz); + +/// An elapsed time, stepping to a coarser unit rather than growing digits, so its width +/// cannot track the stream. Moved here from ui.cpp. +std::string format_elapsed(double seconds); + +} // namespace udpscope diff --git a/src/ui.cpp b/src/ui.cpp index 73a9286..f879e9a 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -21,6 +21,7 @@ #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" @@ -483,6 +484,24 @@ struct App { 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; + + 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 ---------------------------------------------------------- @@ -810,23 +829,6 @@ const char* badge_markup(BadgePhase phase) { 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 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. @@ -879,22 +881,6 @@ std::string format_measure(double v, const std::string& unit) { return out; } -/// 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 @@ -4565,6 +4551,43 @@ void report_event_zoom(App& a, const char* what) { 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(s.packets), + static_cast(s.frames), + static_cast(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(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) { @@ -5063,10 +5086,8 @@ gboolean tick(gpointer user) { // line is the receipt for both. 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; + // 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. @@ -5102,8 +5123,6 @@ gboolean tick(gpointer user) { 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, @@ -5210,7 +5229,7 @@ gboolean tick(gpointer user) { // 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 --"; + std::string rate; if (!app->stored_rate.empty()) { double lo = app->stored_rate.front(); double hi = lo; @@ -5219,51 +5238,70 @@ gboolean tick(gpointer user) { 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)); + 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"; + // Derive the one-word state for the glance line from the same conditions the mode prose + // branches on, so the two cannot disagree. + const char* state_word = "live"; + 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"; } - // Only when it has happened: a stream that is fine carries no counter, so the line - // stays as short as it was, and a client being kicked shows the number it was kicked. - char reconnects_note[48]; - reconnects_note[0] = '\0'; - if (st.reconnects.load(std::memory_order_relaxed) > 0) { - std::snprintf(reconnects_note, sizeof(reconnects_note), " | %llu reconnects", - static_cast(st.reconnects.load(std::memory_order_relaxed))); + 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(st.reconnects.load(std::memory_order_relaxed)); + + 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(); - char status[1024]; - std::snprintf(status, sizeof(status), - "[%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%s%s", - mode, st.connected.load() ? "connected" : "waiting", - 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), since.c_str(), reconnects_note); 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 + " | " + 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), status); + 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); } - if (!rate_tip.empty()) gtk_widget_set_tooltip_text(app->status_label, rate_tip.c_str()); 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); @@ -5760,6 +5798,53 @@ void on_activate(GtkApplication* application, gpointer user) { 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), "%s", 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"); + gtk_menu_button_set_popover(GTK_MENU_BUTTON(app->status_info_button), + app->status_popover); + 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); @@ -5851,7 +5936,7 @@ void on_activate(GtkApplication* application, gpointer user) { /// exist, then close the window. Runs after the reveal animations have settled. gboolean exit_probe_step3(gpointer user) { App* a = static_cast(user); - a->dbg_mode_live = gtk_label_get_text(GTK_LABEL(a->status_label)); + 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); @@ -5896,7 +5981,7 @@ gboolean exit_probe_step3(gpointer user) { /// mode the way the "Go live" button does, and stop the tick. gboolean exit_probe_step2(gpointer user) { App* a = static_cast(user); - a->dbg_mode_triggered = gtk_label_get_text(GTK_LABEL(a->status_label)); + 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; @@ -7621,6 +7706,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit 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.