feat: paged help window with the four Getting started pages

The pages are Pango markup in a GResource, bundled for the same reason the icons
are: udpscope runs straight out of the build tree with no install step, so a
runtime search path's failure mode would be a silently empty help window.
--c-name=udpscope_help keeps the two generated bundles from colliding.

Every page is validated with pango_parse_markup() at load and falls back to a
one-line stand-in rather than rendering raw markup or nothing. show_help() reuses
help_window.cpp's existing close-request handler, and closes via
gtk_window_close() so that handler runs and the pointer is nulled.

UDPSCOPE_PROBE_HELP_MS loads and validates every page, opens the help and About
windows, closes both and reports; parsed must equal pages.
This commit is contained in:
Martino Ferrari committed 2026-10-04 14:53:20 +02:00
1 parent 856f538681
commit 04b6181926
9 files changed
+373

No files matched your search

+18
View File
@@ -51,6 +51,23 @@ add_custom_command(
COMMENT "Compiling udpscope icon resources"
VERBATIM)
# --- help pages: Pango markup in a GResource ----------------------------------
# A second bundle beside the icons'. --c-name is given explicitly so the two generated
# sources cannot collide on symbol names.
set(UDPSCOPE_HELP_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/help)
file(GLOB UDPSCOPE_HELP_FILES CONFIGURE_DEPENDS ${UDPSCOPE_HELP_DIR}/*.txt)
add_custom_command(
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
COMMAND ${GLIB_COMPILE_RESOURCES}
--target=${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
--sourcedir=${UDPSCOPE_HELP_DIR}
--c-name=udpscope_help
--generate-source
${UDPSCOPE_HELP_DIR}/udpscope-help.gresource.xml
DEPENDS ${UDPSCOPE_HELP_DIR}/udpscope-help.gresource.xml ${UDPSCOPE_HELP_FILES}
COMMENT "Compiling udpscope help resources"
VERBATIM)
# --- build info: version, commit and timestamp compiled into the binary -------
# Written at configure time so add_executable can name the file, and again on every
# build so the commit and the timestamp belong to the binary that is running rather
@@ -72,6 +89,7 @@ set_source_files_properties(${UDPSCOPE_BUILD_INFO} PROPERTIES GENERATED TRUE)
# --- the application ----------------------------------------------------------
add_executable(udpscope
${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
${UDPSCOPE_BUILD_INFO}
src/main.cpp
src/config.cpp
+26
View File
@@ -0,0 +1,26 @@
<b>What udpscope is</b>
udpscope plots a live UDPStreamer source. One window holds up to 8 rows of plots, each
with up to 4 traces; every row shares one time axis, so the whole figure scrolls together.
Acquisition runs on its own thread and writes a ring buffer sized from --buffer-mb; the
drawing thread reads whatever that ring holds when a frame starts.
<b>How to reach it</b>
Start the app with a source (<tt>udpscope --server 127.0.0.1 --port 8000</tt>), or start it
with no <tt>--server</tt> and pick one from the chooser. The signals arrive disabled; turn
one on in the side panel (<tt>s</tt>) and drag it onto a row.
<b>Worth knowing</b>
• Nothing is acquired until a signal is enabled. Enabling is what starts acquisition, so a
freshly opened scope with an idle side panel is working correctly.
• The stream carries its own clock. Every time on screen is a stream timestamp, never the
local wall clock, which is why a stalled source freezes the view instead of scrolling it.
• The window remembers itself. Rows, traces, the time window, calibration, math channels
and measurements are written to a session file per host and port, and reloaded next time.
<b>Example</b>
A 3-signal source at 10 kS/s with a 200 MB buffer holds 1677 s of history. Enable all three,
drop them on row 1, and the status bar reads <tt>● live · 10.0 kS/s · 2 s window</tt>.
+31
View File
@@ -0,0 +1,31 @@
<b>Choosing a source</b>
A source is a host and a port, optionally reached through a multicast group on a named
interface. Pass it on the command line, or let udpscope ask.
<b>How to reach it</b>
Run with <tt>--server</tt> and the app connects straight away. Run <i>without</i>
<tt>--server</tt> and a chooser appears first: the sessions udpscope has stored, newest
first, plus a <b>New host…</b> form for one it has not seen. The chooser runs before any
buffer is allocated and before any socket is opened, so declining it costs nothing.
<b>Worth knowing</b>
• The list is the sessions that <i>named a signal</i>. A session whose streamer was down
stores empty slot names and is filtered out, which is what keeps the list the sources you
actually configured rather than every port you have ever typed.
• A stored row reconnects the way it was reached. The multicast group and the interface are
written inside the session file, because the file's name (<tt>host_port.toml</tt>) cannot
carry them.
• <tt>--multicast</tt> still needs <tt>--server</tt>: the CONFIG message is fetched from the
server, not from the group.
• The <tt>×</tt> beside a row deletes that session file. Nothing else is removed.
<b>Example</b>
<tt>udpscope</tt> with two stored sessions lists
<tt>127.0.0.1:8000 plots=1 signals=3</tt> and
<tt>10.0.0.5:8001 plots=2 signals=4 multicast=239.1.2.3 interface=10.0.0.9</tt>.
Activating the first reports <tt>[sources] picked 127.0.0.1:8000</tt> and continues into
the scope with that session's layout already loaded.
+30
View File
@@ -0,0 +1,30 @@
<b>Signals and calibration</b>
The source's CONFIG message describes its signals: a name, a unit and a type. udpscope turns
each enabled signal into a <i>channel</i> — a ring of values beside the shared time ring —
and a channel is what a trace plots. Calibration is a scale and an offset applied at ingest.
<b>How to reach it</b>
The side panel (<tt>s</tt>) lists every signal with a switch. The <b>Signal list</b> item in
the overflow menu opens the editor where the unit and the calibration live.
<b>Worth knowing</b>
• Signals start disabled, and enabling one is what starts acquiring it. A trigger on a signal
nobody acquires can never fire, so arming a trigger enables its signal for you.
• Calibration is applied <i>at ingest</i>, so changing it re-plans the buffer and drops the
history. So does enabling or disabling a signal, and so does a new CONFIG. An empty plot
for a second after one of those is the ring refilling, not a fault.
• The stream's first signal is usually its <i>timestamp</i>, and that one is never acquired.
It cannot be plotted, measured or used as a math source, and udpscope refuses to put it in
a row rather than binding a trace that would read nothing for ever.
• A signal's colour follows the <i>signal</i>, not the slot, so the same signal on two rows
is one colour and the side panel's chip identifies it unambiguously. A signal on no row
gets a grey chip rather than none, so the panel's rows keep their height.
<b>Example</b>
A source declaring <tt>time</tt>, <tt>sine</tt>, <tt>ramp</tt> and <tt>noise</tt> yields three
plottable signals. Enable all three and the exit dump reads <tt>3 signals, 3 channels</tt>;
<tt>time</tt> is not among them.
+32
View File
@@ -0,0 +1,32 @@
<b>Rows, layout and drag &amp; drop</b>
The figure is a stack of rows, 1 to 8 of them, each holding up to 4 traces. Rows share one
time axis and one left gutter, so their y labels line up and their windows cannot drift apart.
<b>How to reach it</b>
Drag a signal's chip out of the side panel (<tt>s</tt>) and drop it on a row to add a trace.
Press <tt>l</tt>, or pick <b>Layout editor</b> from the overflow menu, to add and remove rows:
a <tt>×</tt> appears at the top left of each row and a <tt>+</tt> on each boundary between
rows, including above the first and below the last. The editor's own button in the top-right
corner leaves it.
<b>Worth knowing</b>
• A drop <i>adds</i> a trace to the row's next free slot; it does not replace one. With four
traces already on a row the drop is refused.
• Inserting a row moves the rows below it <i>with their contents</i>. Inserting above row 1
puts what row 1 plotted onto row 2 and leaves the new row empty — the rows are not
renumbered under you.
• A trace is remembered by the signal's <i>name</i>, not by its channel number, because a new
CONFIG or an enable renumbers the channels and an index would silently re-point the trace.
• Adding or removing a row rebuilds the figure, which is cheap, but it also re-deals every
trace. A frozen trigger event survives the rebuild: the pinned window is restated rather
than re-framed on the live edge.
<b>Example</b>
With three traces on row 1, pressing <tt>l</tt> shows 3 buttons (one <tt>×</tt>, two
<tt>+</tt>). Clicking the <tt>+</tt> above row 1 gives 2 rows and 5 buttons, with the three
traces now on row 2 — the layout probe reports exactly this as
<tt>rows=2 buttons=5 traces=[0:-|1:sine,ramp,noise]</tt>.
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
SPDX-License-Identifier: MIT
The help pages, compiled into the binary for the same reason the icons are: udpscope is
run straight out of the build tree and has no install step, so a search path would have
to be located at runtime and the failure mode of not locating it is a help window full
of missing pages.
-->
<gresources>
<gresource prefix="/org/udpscope/help">
<file>01-overview.txt</file>
<file>02-source.txt</file>
<file>03-signals.txt</file>
<file>04-layout.txt</file>
</gresource>
</gresources>
+157
View File
@@ -2,6 +2,7 @@
#include "help_window.hpp"
#include <cstdio>
#include <string>
#include "build_info.hpp"
#include "layout_store.hpp"
@@ -12,6 +13,7 @@ namespace {
/// Nulled by the `close-request` handler so a second open cannot present a dead widget.
/// One pointer per window, file-scope because there is one application.
GtkWidget* g_about = nullptr;
GtkWidget* g_help = nullptr;
gboolean on_window_close(GtkWindow* window, gpointer slot) {
(void)window;
@@ -19,6 +21,56 @@ gboolean on_window_close(GtkWindow* window, gpointer slot) {
return FALSE; // let the default handler destroy it
}
struct HelpPage {
const char* section; ///< the group heading in the sidebar
const char* title; ///< the row's label and the page's heading
const char* resource; ///< a path under /org/udpscope/help
};
/// Adding a page is one .txt file, one <file> line in the manifest and one row here.
/// The order here is the order the sidebar shows; `section` groups consecutive rows.
constexpr HelpPage kPages[] = {
{"Getting started", "Overview", "/org/udpscope/help/01-overview.txt"},
{"Getting started", "Choosing a source", "/org/udpscope/help/02-source.txt"},
{"Getting started", "Signals and calibration", "/org/udpscope/help/03-signals.txt"},
{"Getting started", "Rows, layout and drag & drop", "/org/udpscope/help/04-layout.txt"},
};
constexpr int kPageCount = static_cast<int>(sizeof(kPages) / sizeof(kPages[0]));
/// The page's markup, or a one-line stand-in. `ok` reports whether the real body loaded
/// *and* parsed: a missing resource and markup Pango rejects both render the stand-in,
/// because a help window that fails to open is worse than one page that says so.
std::string load_page(const HelpPage& page, bool* ok) {
*ok = false;
GError* error = nullptr;
GBytes* bytes = g_resources_lookup_data(page.resource, G_RESOURCE_LOOKUP_FLAGS_NONE,
&error);
if (bytes == nullptr) {
if (error != nullptr) g_error_free(error);
return "<i>This page is missing from the build.</i>";
}
gsize size = 0;
const char* data = static_cast<const char*>(g_bytes_get_data(bytes, &size));
std::string body(data, size);
g_bytes_unref(bytes);
if (!pango_parse_markup(body.c_str(), -1, 0, nullptr, nullptr, nullptr, &error)) {
if (error != nullptr) g_error_free(error);
return "<i>This page could not be displayed: its markup is malformed.</i>";
}
*ok = true;
return body;
}
void on_help_row_selected(GtkListBox*, GtkListBoxRow* row, gpointer body_label) {
if (row == nullptr) return;
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "help-page"));
if (idx < 0 || idx >= kPageCount) return;
bool ok = false;
const std::string body = load_page(kPages[idx], &ok);
gtk_label_set_markup(GTK_LABEL(body_label), body.c_str());
}
} // namespace
void show_about(GtkWindow* parent, const std::string& session_path) {
@@ -50,4 +102,109 @@ void show_about(GtkWindow* parent, const std::string& session_path) {
gtk_window_present(GTK_WINDOW(dialog));
}
HelpReport help_self_check() {
HelpReport report;
report.pages = kPageCount;
for (const HelpPage& page : kPages) {
bool ok = false;
const std::string body = load_page(page, &ok);
if (ok && !body.empty()) ++report.parsed;
if (report.shortest_title.empty() || body.size() < report.shortest_bytes) {
report.shortest_title = page.title;
report.shortest_bytes = body.size();
}
}
return report;
}
void show_help(GtkWindow* parent) {
if (g_help != nullptr) {
gtk_window_present(GTK_WINDOW(g_help));
return;
}
GtkWidget* window = gtk_window_new();
g_help = window;
gtk_window_set_title(GTK_WINDOW(window), "udpscope help");
gtk_window_set_default_size(GTK_WINDOW(window), 860, 640);
gtk_window_set_transient_for(GTK_WINDOW(window), parent);
// One label, width-locked by the scrolled window, holding one page's markup. The
// sidebar swaps the markup rather than rebuilding anything.
GtkWidget* body = gtk_label_new(nullptr);
gtk_label_set_use_markup(GTK_LABEL(body), TRUE);
gtk_label_set_wrap(GTK_LABEL(body), TRUE);
gtk_label_set_xalign(GTK_LABEL(body), 0.0f);
gtk_label_set_yalign(GTK_LABEL(body), 0.0f);
gtk_label_set_selectable(GTK_LABEL(body), TRUE);
gtk_widget_set_margin_start(body, 18);
gtk_widget_set_margin_end(body, 18);
gtk_widget_set_margin_top(body, 14);
gtk_widget_set_margin_bottom(body, 14);
GtkWidget* body_scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(body_scroll), GTK_POLICY_NEVER,
GTK_POLICY_AUTOMATIC);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(body_scroll), body);
gtk_widget_set_hexpand(body_scroll, TRUE);
GtkWidget* list = gtk_list_box_new();
gtk_list_box_set_selection_mode(GTK_LIST_BOX(list), GTK_SELECTION_SINGLE);
const char* current_section = nullptr;
for (int i = 0; i < kPageCount; ++i) {
if (current_section == nullptr ||
std::string(current_section) != kPages[i].section) {
current_section = kPages[i].section;
GtkWidget* heading = gtk_label_new(nullptr);
char markup[128];
std::snprintf(markup, sizeof(markup), "<b><small>%s</small></b>", current_section);
gtk_label_set_markup(GTK_LABEL(heading), markup);
gtk_label_set_xalign(GTK_LABEL(heading), 0.0f);
gtk_widget_set_margin_start(heading, 10);
gtk_widget_set_margin_top(heading, 10);
gtk_widget_set_margin_bottom(heading, 2);
GtkWidget* heading_row = gtk_list_box_row_new();
gtk_list_box_row_set_selectable(GTK_LIST_BOX_ROW(heading_row), FALSE);
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(heading_row), FALSE);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(heading_row), heading);
gtk_list_box_append(GTK_LIST_BOX(list), heading_row);
}
GtkWidget* label = gtk_label_new(kPages[i].title);
gtk_label_set_xalign(GTK_LABEL(label), 0.0f);
gtk_widget_set_margin_start(label, 18);
gtk_widget_set_margin_end(label, 10);
gtk_widget_set_margin_top(label, 4);
gtk_widget_set_margin_bottom(label, 4);
GtkWidget* row = gtk_list_box_row_new();
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), label);
g_object_set_data(G_OBJECT(row), "help-page", GINT_TO_POINTER(i));
gtk_list_box_append(GTK_LIST_BOX(list), row);
}
g_signal_connect(list, "row-selected", G_CALLBACK(on_help_row_selected), body);
GtkWidget* list_scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(list_scroll), GTK_POLICY_NEVER,
GTK_POLICY_AUTOMATIC);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(list_scroll), list);
gtk_widget_set_size_request(list_scroll, 220, -1);
GtkWidget* split = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
gtk_box_append(GTK_BOX(split), list_scroll);
gtk_box_append(GTK_BOX(split), gtk_separator_new(GTK_ORIENTATION_VERTICAL));
gtk_box_append(GTK_BOX(split), body_scroll);
gtk_window_set_child(GTK_WINDOW(window), split);
// Select the first page so the window never opens blank.
GtkListBoxRow* first = gtk_list_box_get_row_at_index(GTK_LIST_BOX(list), 1);
if (first != nullptr) gtk_list_box_select_row(GTK_LIST_BOX(list), first);
g_signal_connect(window, "close-request", G_CALLBACK(on_window_close), &g_help);
gtk_window_present(GTK_WINDOW(window));
}
void close_help_windows() {
if (g_help != nullptr) gtk_window_close(GTK_WINDOW(g_help));
if (g_about != nullptr) gtk_window_close(GTK_WINDOW(g_about));
}
} // namespace udpscope
+21
View File
@@ -3,6 +3,7 @@
#include <gtk/gtk.h>
#include <cstddef>
#include <string>
namespace udpscope {
@@ -11,4 +12,24 @@ namespace udpscope {
/// Modal on `parent`. `session_path` is this run's own session file.
void show_about(GtkWindow* parent, const std::string& session_path);
/// The paged help window. Modal on nothing — it is a reference, so it stays usable while
/// the scope runs. Already open: presented.
void show_help(GtkWindow* parent);
/// What UDPSCOPE_PROBE_HELP_MS reports. `parsed` counts pages whose markup
/// pango_parse_markup() accepted and whose body is non-empty; it must equal `pages`.
struct HelpReport {
int pages = 0;
int parsed = 0;
std::string shortest_title;
std::size_t shortest_bytes = 0;
};
/// Loads and validates every page without building a window. Safe to call headless.
HelpReport help_self_check();
/// Closes whichever of the three windows are open, via gtk_window_close() so the
/// close-request handlers run and the pointers are nulled. Used by the probe.
void close_help_windows();
} // namespace udpscope
+41
View File
@@ -15,6 +15,7 @@
#include <gtk4/gtk_plot_area.hpp>
#include "acquisition.hpp"
#include "build_info.hpp"
#include "config.hpp"
#include "export.hpp"
#include "help_window.hpp"
@@ -4094,6 +4095,15 @@ void on_about_clicked(GtkButton*, gpointer user) {
layout_path(app->model->config().server, app->model->config().port));
}
void on_help_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->menu_button != nullptr) {
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
if (popover != nullptr) gtk_popover_popdown(popover);
}
show_help(GTK_WINDOW(app->window));
}
// --- right-click menu -------------------------------------------------------
void on_y_auto_clicked(GtkButton* button, gpointer user) {
@@ -5555,6 +5565,11 @@ GtkWidget* build_toolbar(App& app) {
gtk_box_append(GTK_BOX(menu_box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
GtkWidget* help_item = gtk_button_new_with_label("Help (F1)");
gtk_widget_set_tooltip_text(help_item, "the paged guide to every feature");
g_signal_connect(help_item, "clicked", G_CALLBACK(on_help_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), help_item);
GtkWidget* about_item = gtk_button_new_with_label("About udpscope");
gtk_widget_set_tooltip_text(about_item, "version, build timestamp and settings paths");
g_signal_connect(about_item, "clicked", G_CALLBACK(on_about_clicked), &app);
@@ -7231,6 +7246,29 @@ gboolean zoomkeys_probe(gpointer user) {
return G_SOURCE_REMOVE;
}
/// Loads and validates every help page, opens all three windows, closes them, and reports.
/// `parsed` must equal `pages` and no page may be empty; `closed ok` with no GTK critical
/// is the crash check for three windows built and destroyed in one run.
gboolean help_probe(gpointer user) {
App* a = static_cast<App*>(user);
const HelpReport report = help_self_check();
const std::string session =
layout_path(a->model->config().server, a->model->config().port);
show_help(GTK_WINDOW(a->window));
show_about(GTK_WINDOW(a->window), session);
close_help_windows();
std::fprintf(stderr,
"[help] pages=%d parsed=%d shortest='%s' %zu B; "
"about version=%s commit=%s built=%s settings=%s; closed ok\n",
report.pages, report.parsed, report.shortest_title.c_str(),
report.shortest_bytes, build_info::kVersion, build_info::kCommit,
build_info::kTimestamp, config_dir().c_str());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisition& acq,
RowSet& rows) {
(void)argc;
@@ -7314,6 +7352,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_ZOOMKEYS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), zoomkeys_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_HELP_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), help_probe, &app);
}
// Our own options were consumed by parse_args(); GTK would reject them, so only
// the program name is handed to g_application_run().