diff --git a/CMakeLists.txt b/CMakeLists.txt
index 9fdc858..773fd7e 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -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
diff --git a/src/help/01-overview.txt b/src/help/01-overview.txt
new file mode 100644
index 0000000..ba3924c
--- /dev/null
+++ b/src/help/01-overview.txt
@@ -0,0 +1,26 @@
+What udpscope is
+
+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.
+
+How to reach it
+
+Start the app with a source (udpscope --server 127.0.0.1 --port 8000), or start it
+with no --server and pick one from the chooser. The signals arrive disabled; turn
+one on in the side panel (s) and drag it onto a row.
+
+Worth knowing
+
+• 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.
+
+Example
+
+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 ● live · 10.0 kS/s · 2 s window.
diff --git a/src/help/02-source.txt b/src/help/02-source.txt
new file mode 100644
index 0000000..1a622c1
--- /dev/null
+++ b/src/help/02-source.txt
@@ -0,0 +1,31 @@
+Choosing a source
+
+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.
+
+How to reach it
+
+Run with --server and the app connects straight away. Run without
+--server and a chooser appears first: the sessions udpscope has stored, newest
+first, plus a New host… 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.
+
+Worth knowing
+
+• The list is the sessions that named a signal. 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 (host_port.toml) cannot
+ carry them.
+• --multicast still needs --server: the CONFIG message is fetched from the
+ server, not from the group.
+• The × beside a row deletes that session file. Nothing else is removed.
+
+Example
+
+udpscope with two stored sessions lists
+127.0.0.1:8000 plots=1 signals=3 and
+10.0.0.5:8001 plots=2 signals=4 multicast=239.1.2.3 interface=10.0.0.9.
+Activating the first reports [sources] picked 127.0.0.1:8000 and continues into
+the scope with that session's layout already loaded.
diff --git a/src/help/03-signals.txt b/src/help/03-signals.txt
new file mode 100644
index 0000000..01a4ca7
--- /dev/null
+++ b/src/help/03-signals.txt
@@ -0,0 +1,30 @@
+Signals and calibration
+
+The source's CONFIG message describes its signals: a name, a unit and a type. udpscope turns
+each enabled signal into a channel — 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.
+
+How to reach it
+
+The side panel (s) lists every signal with a switch. The Signal list item in
+the overflow menu opens the editor where the unit and the calibration live.
+
+Worth knowing
+
+• 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 at ingest, 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 timestamp, 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 signal, 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.
+
+Example
+
+A source declaring time, sine, ramp and noise yields three
+plottable signals. Enable all three and the exit dump reads 3 signals, 3 channels;
+time is not among them.
diff --git a/src/help/04-layout.txt b/src/help/04-layout.txt
new file mode 100644
index 0000000..cd11efb
--- /dev/null
+++ b/src/help/04-layout.txt
@@ -0,0 +1,32 @@
+Rows, layout and drag & drop
+
+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.
+
+How to reach it
+
+Drag a signal's chip out of the side panel (s) and drop it on a row to add a trace.
+Press l, or pick Layout editor from the overflow menu, to add and remove rows:
+a × appears at the top left of each row and a + 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.
+
+Worth knowing
+
+• A drop adds 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 with their contents. 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 name, 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.
+
+Example
+
+With three traces on row 1, pressing l shows 3 buttons (one ×, two
++). Clicking the + above row 1 gives 2 rows and 5 buttons, with the three
+traces now on row 2 — the layout probe reports exactly this as
+rows=2 buttons=5 traces=[0:-|1:sine,ramp,noise].
diff --git a/src/help/udpscope-help.gresource.xml b/src/help/udpscope-help.gresource.xml
new file mode 100644
index 0000000..e48cef4
--- /dev/null
+++ b/src/help/udpscope-help.gresource.xml
@@ -0,0 +1,17 @@
+
+
+
+
+ 01-overview.txt
+ 02-source.txt
+ 03-signals.txt
+ 04-layout.txt
+
+
diff --git a/src/help_window.cpp b/src/help_window.cpp
index 0e03c91..70bb9e1 100644
--- a/src/help_window.cpp
+++ b/src/help_window.cpp
@@ -2,6 +2,7 @@
#include "help_window.hpp"
#include
+#include
#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 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(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 "This page is missing from the build.";
+ }
+ gsize size = 0;
+ const char* data = static_cast(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 "This page could not be displayed: its markup is malformed.";
+ }
+ *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), "%s", 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
diff --git a/src/help_window.hpp b/src/help_window.hpp
index 055a8e2..e9c452a 100644
--- a/src/help_window.hpp
+++ b/src/help_window.hpp
@@ -3,6 +3,7 @@
#include
+#include
#include
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
diff --git a/src/ui.cpp b/src/ui.cpp
index 9932eea..323d8b4 100644
--- a/src/ui.cpp
+++ b/src/ui.cpp
@@ -15,6 +15,7 @@
#include
#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(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(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(std::atoi(env)), zoomkeys_probe, &app);
}
+ if (const char* env = g_getenv("UDPSCOPE_PROBE_HELP_MS")) {
+ g_timeout_add(static_cast(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().