help: default the contextual layer off, and carry the help into the chooser

The contextual help was on by default, so a first run met a window with a "?"
beside every surface it had not asked to have explained; `contextual_help` now
defaults to false and the layer is asked for from the Help menu, from the
chooser's new help menu, or with Ctrl+H.

The source chooser runs before `App` exists, which makes it the window a user
who does not know how to start is looking at, so it now carries the same three
help affordances the window's Help view does: Documentation (the manual is
another process and needs nothing of the app), Enable contextual help (a row
whose check *is* the setting, written to the file the app reads a moment
later), and Guided tour. The tour is drawn on the app's own window, so the
chooser can only record the wish: it sets `Config::tour_pending` -- the value
`main()` owns and hands to both `choose_source()` and `run_ui()` -- and
`on_activate()` starts it once the window is up and laid out, through its own
callback so the readings can tell a request from the first-run auto-start.
The rows are the window's own `menu_button()`/`menu_toggle()`; `install_css()`
therefore runs from `choose_source()` (it is idempotent now) so the popover
gets the `.udpscope-menu` rules before it is shown.

Opening the manual could also fail with a message that blamed the handler
whatever was missing. `help_installed_path()` now resolves `help:<id>` the way
the specification does and the failure alert says which of the two it is: with
the set installed it names the viewer, without it it names the identifier, the
expected installed path and the install command. `show_help()` gained an
optional reporter, which `UDPSCOPE_PROBE_HELP_MS` uses for a new reading --
`[help] launch attempted= done= ok= error='' installed=` -- since a successful
launch leaves no window of ours to observe.

Tested: clean --clean-first build, three warnings as documented; test-ui 13/13
and test-help pass; no Gtk-CRITICAL in the help, helpui and chooser runs.
This commit is contained in:
Martino Ferrari committed 2026-10-14 17:22:03 +02:00
1 parent e140642ed0
commit d8a79b96ba
15 files changed
+461 -66

No files matched your search

+132 -6
View File
@@ -5,8 +5,13 @@
#include "waveform.hpp"
#include "help_window.hpp"
#include "layout_store.hpp"
#include "settings.hpp"
// The app's shared chrome -- `ui_impl::menu_button()`/`menu_toggle()` and `install_css()` -- so the
// chooser's help menu is the *same* menu the window's Help view is, not a second one that merely
// looks like it. Heavy header, but a row shape copied here would be a row shape that drifts.
#include "ui_app.hpp"
#include "ui_prefs.hpp"
#include <adwaita.h>
@@ -127,6 +132,15 @@ struct Chooser {
/// The preferences, for the button below. Same reasoning as `form` above: a member of the chooser
/// rather than a heap struct, so nothing can outlive the stack frame that owns it.
ui_impl::PrefsForm prefs;
/// The help menu: the `.ui`'s own `GtkMenuButton` and the three rows built onto it. The rows are
/// code for the same reason every other row here is -- each carries a handler, and the toggle
/// carries the value the settings file holds -- while the button that opens them is the file's, so
/// a `.ui` that lost it is caught by the null check below rather than by an empty popover.
GtkWidget* help_button = nullptr;
GtkWidget* help_docs = nullptr;
GtkWidget* help_tour = nullptr;
GtkWidget* help_toggle = nullptr;
GtkWidget* help_popover = nullptr;
};
@@ -596,14 +610,90 @@ void open_chooser_preferences(Chooser& c) {
});
}
/// The chooser's help menu: the same three affordances the window's Help view carries, because a
/// user who has not started yet is exactly the one who does not know how to start. The rows are
/// `ui_impl::menu_button()`/`menu_toggle()` -- the app's own menu-row shape, so the two menus read
/// as one program -- and the popover carries the same `.udpscope-menu` class, which is what trims
/// the rows; `install_css()` therefore runs before this, in `choose_source()`.
///
/// No row shows an accelerator. The window's Help rows show "F1"/"Ctrl+T" because the window handles
/// those keys; this dialog binds none of them, and a shortcut printed beside an action that does not
/// answer to it is the "documented and does nothing" failure the shortcut tables are checked for.
void build_chooser_help(Chooser& c) {
c.help_popover = gtk_popover_new();
gtk_widget_add_css_class(c.help_popover, "udpscope-menu");
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_set_margin_top(box, 6);
gtk_widget_set_margin_bottom(box, 6);
gtk_widget_set_margin_start(box, 6);
gtk_widget_set_margin_end(box, 6);
c.help_docs = ui_impl::menu_button("Documentation", nullptr);
gtk_widget_set_tooltip_text(c.help_docs,
"the udpscope manual, opened in the desktop help viewer");
g_signal_connect(c.help_docs, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
Chooser& ch = *static_cast<Chooser*>(user);
gtk_popover_popdown(GTK_POPOVER(ch.help_popover));
// No window of the app exists yet: the manual is a separate process reached
// through the `help:` URI, so this needs no more of the app than the chooser
// itself. The parent may be null, and this dialog is the right attribution.
show_help(GTK_WINDOW(ch.window));
}),
&c);
gtk_box_append(GTK_BOX(box), c.help_docs);
c.help_tour = ui_impl::menu_button("Guided tour", nullptr);
gtk_widget_set_tooltip_text(c.help_tour,
"walk over the plot, the side panel, the trigger, the signals and "
"the status line, once the window is up");
g_signal_connect(c.help_tour, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
Chooser& ch = *static_cast<Chooser*>(user);
gtk_popover_popdown(GTK_POPOVER(ch.help_popover));
// The tour lives on the window the app builds, which does not exist yet, so the
// request rides on the `Config` this call was handed and the app starts it once
// the window is up -- see `Config::tour_pending`.
ch.cfg->tour_pending = true;
std::fprintf(stderr, "[sources] chooser help: tour requested\n");
std::fflush(stderr);
}),
&c);
gtk_box_append(GTK_BOX(box), c.help_tour);
// The check *is* the setting, exactly as in the window's menu: the row is stated before the handler
// is connected, so a row that opens checked does not write the file while the dialog is still being
// built. The chooser cannot re-sync anything (there is no "?" layer here), so the handler only has
// to leave the setting where the app will read it.
c.help_toggle = ui_impl::menu_toggle("Enable contextual help", nullptr,
load_settings().contextual_help);
gtk_widget_set_tooltip_text(c.help_toggle,
"a small \u201c?\u201d beside each surface of the main window, saying "
"what that surface is for");
g_signal_connect(c.help_toggle, "toggled", G_CALLBACK(+[](GtkToggleButton* button, gpointer) {
Settings s = load_settings();
s.contextual_help = gtk_toggle_button_get_active(button) != FALSE;
// Written in full, the way the window's toggle writes it: the keys this row does
// not own are carried through from the file rather than dropped.
if (!save_settings(s)) {
std::fprintf(stderr, "[sources] chooser help: could not write %s\n",
settings_path().c_str());
std::fflush(stderr);
}
}),
&c);
gtk_box_append(GTK_BOX(box), c.help_toggle);
gtk_popover_set_child(GTK_POPOVER(c.help_popover), box);
gtk_menu_button_set_popover(GTK_MENU_BUTTON(c.help_button), c.help_popover);
}
/// `UDPSCOPE_PROBE_PICK_MS`: activate the first row, the way a click does.
/// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row. The crash surface of a
/// dialog built, filled and destroyed is as much the point as the values, the same way
/// `UDPSCOPE_PROBE_FIELDS_MS` is in `ui.cpp`.
/// The chooser's frame, read back from the `.ui` it was built from: the two pages by name and title, the
/// switcher wired to the stack, and the four buttons the code resolved. `[help] ui=` does the same against
/// its tables -- the frame is what the file holds, so the frame is what is read, while the rows a user sees
/// are read by the probes that drive them.
/// switcher wired to the stack, the four buttons the code resolved, and the help menu's three rows.
/// `[help] ui=` does the same against its tables -- the frame is what the file holds, so the frame is
/// what is read, while the rows a user sees are read by the probes that drive them.
void report_chooser_ui(const Chooser& c) {
std::fprintf(stderr, "[sources] chooser ui:");
for (GtkWidget* w = gtk_widget_get_first_child(c.page_stack); w != nullptr;
@@ -617,14 +707,21 @@ void report_chooser_ui(const Chooser& c) {
adw_view_stack_page_get_icon_name(page) != nullptr ? "yes" : "NO");
}
std::fprintf(stderr,
" visible='%s' switcher_wired=%d buttons=[prefs=%d new_host=%d open=%d cancel=%d]\n",
" visible='%s' switcher_wired=%d buttons=[prefs=%d new_host=%d open=%d cancel=%d] "
"help=[button=%d docs=%d tour=%d toggle=%d on=%d]\n",
adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(c.page_stack)),
adw_view_switcher_bar_get_stack(ADW_VIEW_SWITCHER_BAR(c.switcher)) ==
ADW_VIEW_STACK(c.page_stack)
? 1
: 0,
c.prefs_button != nullptr ? 1 : 0, c.new_host != nullptr ? 1 : 0,
c.waves_open != nullptr ? 1 : 0, c.cancel_button != nullptr ? 1 : 0);
c.waves_open != nullptr ? 1 : 0, c.cancel_button != nullptr ? 1 : 0,
c.help_button != nullptr ? 1 : 0, c.help_docs != nullptr ? 1 : 0,
c.help_tour != nullptr ? 1 : 0, c.help_toggle != nullptr ? 1 : 0,
c.help_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(c.help_toggle))
? 1
: 0);
std::fflush(stderr);
}
@@ -638,6 +735,26 @@ gboolean pick_probe(gpointer user) {
// What the `.ui` contributed, before anything is driven: the pages, the switcher and the buttons.
report_chooser_ui(c);
// `UDPSCOPE_PROBE_CHOOSER_TOUR=1` and `UDPSCOPE_PROBE_CHOOSER_HELP=1` drive the help menu's two
// stateful rows the way a click does, and then fall through to the pick below -- the point of the
// tour entry is that it crosses *into the run this chooser starts*, so a probe that stopped here
// would assert nothing. The tour is read back out of the `Config` the run will be handed, which is
// the carrier; the toggle is read back out of the settings file it writes, since the app is built
// from that file and not from the dialog.
if (g_getenv("UDPSCOPE_PROBE_CHOOSER_TOUR") != nullptr && c.help_tour != nullptr) {
g_signal_emit_by_name(c.help_tour, "clicked");
std::fprintf(stderr, "[sources] chooser help: tour_pending=%d\n", c.cfg->tour_pending ? 1 : 0);
std::fflush(stderr);
}
if (g_getenv("UDPSCOPE_PROBE_CHOOSER_HELP") != nullptr && c.help_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(c.help_toggle), TRUE);
const Settings back = load_settings();
std::fprintf(stderr, "[sources] chooser help: toggle=%d setting=%d file=%s\n",
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(c.help_toggle)) ? 1 : 0,
back.contextual_help ? 1 : 0, settings_path().c_str());
std::fflush(stderr);
}
// `UDPSCOPE_PROBE_CHOOSER_PREFS=1`: the preferences opened from *here* rather than from the app's
// menu -- the entry point that exists because this dialog runs before `App`. It is driven through
// the same fill-and-apply the app's probe uses, and reports what the run about to start will get,
@@ -906,15 +1023,24 @@ bool choose_source(Config& cfg) {
c.new_host = GTK_WIDGET(gtk_builder_get_object(c.ui, "new_host_button"));
c.waves_open = GTK_WIDGET(gtk_builder_get_object(c.ui, "waves_open"));
c.cancel_button = GTK_WIDGET(gtk_builder_get_object(c.ui, "cancel_button"));
c.help_button = GTK_WIDGET(gtk_builder_get_object(c.ui, "help_menu_button"));
if (c.window == nullptr || c.page_stack == nullptr || c.switcher == nullptr || c.list == nullptr ||
c.wave_list == nullptr || c.prefs_button == nullptr || c.new_host == nullptr ||
c.waves_open == nullptr || c.cancel_button == nullptr) {
c.waves_open == nullptr || c.cancel_button == nullptr || c.help_button == nullptr) {
std::fprintf(stderr, "[sources] chooser ui: source-chooser.ui is missing an id this code reads\n");
std::fflush(stderr);
return false;
}
adw_view_switcher_bar_set_stack(ADW_VIEW_SWITCHER_BAR(c.switcher), ADW_VIEW_STACK(c.page_stack));
// The help menu's rows are the window's own `menu_button()`/`menu_toggle()`, so its popover wants
// the same stylesheet the overflow menu is drawn with (`.udpscope-menu`, which trims a row to a
// menu row rather than Adwaita's 34 px button). The app installs it in `on_activate()`, which is
// *after* this dialog, so it has to be installed here first; `install_css()` is idempotent, so
// the later call is the no-op it reads as.
ui_impl::install_css();
build_chooser_help(c);
g_signal_connect(c.list, "row-activated", G_CALLBACK(on_row_activated), &c);
g_signal_connect(c.wave_list, "row-activated", G_CALLBACK(on_wave_activated), &c);
g_signal_connect(c.waves_open, "clicked", G_CALLBACK(on_wave_open_clicked), &c);