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:
1 parent
856f538681
commit
04b6181926
9 files changed
+373
No files matched your search
@@ -51,6 +51,23 @@ add_custom_command(
|
|||||||
COMMENT "Compiling udpscope icon resources"
|
COMMENT "Compiling udpscope icon resources"
|
||||||
VERBATIM)
|
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 -------
|
# --- 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
|
# 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
|
# 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 ----------------------------------------------------------
|
# --- the application ----------------------------------------------------------
|
||||||
add_executable(udpscope
|
add_executable(udpscope
|
||||||
${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
|
${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
|
||||||
|
${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
|
||||||
${UDPSCOPE_BUILD_INFO}
|
${UDPSCOPE_BUILD_INFO}
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
src/config.cpp
|
src/config.cpp
|
||||||
|
|||||||
@@ -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>.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<b>Rows, layout and drag & 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>.
|
||||||
@@ -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>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
#include "help_window.hpp"
|
#include "help_window.hpp"
|
||||||
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
#include "build_info.hpp"
|
#include "build_info.hpp"
|
||||||
#include "layout_store.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.
|
/// 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.
|
/// One pointer per window, file-scope because there is one application.
|
||||||
GtkWidget* g_about = nullptr;
|
GtkWidget* g_about = nullptr;
|
||||||
|
GtkWidget* g_help = nullptr;
|
||||||
|
|
||||||
gboolean on_window_close(GtkWindow* window, gpointer slot) {
|
gboolean on_window_close(GtkWindow* window, gpointer slot) {
|
||||||
(void)window;
|
(void)window;
|
||||||
@@ -19,6 +21,56 @@ gboolean on_window_close(GtkWindow* window, gpointer slot) {
|
|||||||
return FALSE; // let the default handler destroy it
|
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
|
} // namespace
|
||||||
|
|
||||||
void show_about(GtkWindow* parent, const std::string& session_path) {
|
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));
|
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
|
} // namespace udpscope
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include <gtk/gtk.h>
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
namespace udpscope {
|
namespace udpscope {
|
||||||
@@ -11,4 +12,24 @@ namespace udpscope {
|
|||||||
/// Modal on `parent`. `session_path` is this run's own session file.
|
/// Modal on `parent`. `session_path` is this run's own session file.
|
||||||
void show_about(GtkWindow* parent, const std::string& session_path);
|
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
|
} // namespace udpscope
|
||||||
+41
@@ -15,6 +15,7 @@
|
|||||||
#include <gtk4/gtk_plot_area.hpp>
|
#include <gtk4/gtk_plot_area.hpp>
|
||||||
|
|
||||||
#include "acquisition.hpp"
|
#include "acquisition.hpp"
|
||||||
|
#include "build_info.hpp"
|
||||||
#include "config.hpp"
|
#include "config.hpp"
|
||||||
#include "export.hpp"
|
#include "export.hpp"
|
||||||
#include "help_window.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));
|
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 -------------------------------------------------------
|
// --- right-click menu -------------------------------------------------------
|
||||||
|
|
||||||
void on_y_auto_clicked(GtkButton* button, gpointer user) {
|
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));
|
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");
|
GtkWidget* about_item = gtk_button_new_with_label("About udpscope");
|
||||||
gtk_widget_set_tooltip_text(about_item, "version, build timestamp and settings paths");
|
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);
|
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;
|
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,
|
int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisition& acq,
|
||||||
RowSet& rows) {
|
RowSet& rows) {
|
||||||
(void)argc;
|
(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")) {
|
if (const char* env = g_getenv("UDPSCOPE_PROBE_ZOOMKEYS_MS")) {
|
||||||
g_timeout_add(static_cast<guint>(std::atoi(env)), zoomkeys_probe, &app);
|
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
|
// Our own options were consumed by parse_args(); GTK would reject them, so only
|
||||||
// the program name is handed to g_application_run().
|
// the program name is handed to g_application_run().
|
||||||
|
|||||||
Reference in new issue
Block a user