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().