// 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 #include 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 "\u25CF"; case BadgePhase::Dim: return "\u25CF"; case BadgePhase::Connecting: return "\u25CF"; case BadgePhase::Idle: break; } return "\u25CF"; } /// 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(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); } /// 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(reconnects)); std::fflush(stderr); } } } // namespace ui_impl } // namespace udpscope