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

+84 -21
View File
@@ -3,7 +3,9 @@
#include <adwaita.h>
#include <functional>
#include <string>
#include <vector>
#include "build_info.hpp"
#include "layout_store.hpp"
@@ -44,27 +46,55 @@ void on_dialog_closed(AdwDialog* dialog, gpointer slot) {
*static_cast<AdwDialog**>(slot) = nullptr;
}
/// The launch is asynchronous and can fail -- no help viewer installed, no handler for the
/// `help:` scheme, or a document no viewer can find -- and it runs in another process, so
/// nothing raises a window of ours. Report the failure here rather than leaving a click that
/// silently did nothing; the detail names the identifier a viewer looks for.
/// What an asynchronous launch needs to survive the call: the window it is attributed to (held,
/// because the call outlives the stack frame that made it) and the caller's reporter, if it asked
/// for one. One small struct rather than two `gpointer`s, because a callback that takes both through
/// one `user_data` still has to get them there.
struct HelpLaunch {
GtkWindow* parent = nullptr;
std::function<void(bool, const std::string&)> done;
};
/// The launch failed. Report it rather than leaving a click that silently did nothing -- and say
/// *which* thing is missing: `gtk_uri_launcher_launch_finish()` reports that the URI could not be
/// handed over, which is the handler's absence, while a document that is not installed is a
/// different failure the launcher cannot tell it from. `help_installed_path()` settles that from the
/// file system, so a missing *document* is not reported as a missing *handler*: with the manual
/// installed the message names the viewer, and without it the message names the document, its
/// expected path and the install command -- and a viewer as well, because that second possibility
/// cannot be ruled out from here.
void on_help_launched(GObject* source, GAsyncResult* result, gpointer user) {
GtkWindow* parent = user != nullptr ? GTK_WINDOW(user) : nullptr;
auto* launch = static_cast<HelpLaunch*>(user);
GError* error = nullptr;
if (!gtk_uri_launcher_launch_finish(GTK_URI_LAUNCHER(source), result, &error)) {
const bool ok = gtk_uri_launcher_launch_finish(GTK_URI_LAUNCHER(source), result, &error);
if (!ok) {
const std::string installed = help_installed_path();
GtkAlertDialog* alert = gtk_alert_dialog_new("Could not open the help manual");
const std::string detail =
std::string(error->message) +
"\n\nThe manual is installed under $XDG_DATA_DIRS/help/C/help:" UDPSCOPE_HELP_NAME
"; a desktop help viewer (for example `yelp`) that handles the `help:` URI is needed "
"to read it.";
std::string detail = error != nullptr ? error->message : std::string("the launch failed");
detail += "\n\n";
if (!installed.empty()) {
detail += "The manual is installed at " + installed +
", so what is missing is a viewer for the `help:` URI it is opened with: install "
"one (for example `yelp`) or make it the desktop's default for that scheme.";
} else {
detail += "The manual does not look installed: nothing named \"help:" UDPSCOPE_HELP_NAME
"\" was found under $XDG_DATA_HOME/help/<lang>/" UDPSCOPE_HELP_NAME "/ or "
"$XDG_DATA_DIRS/help/<lang>/" UDPSCOPE_HELP_NAME "/. Install it with\n\n"
" cmake --install build --prefix ~/.local\n\n"
"which writes ~/.local/share/help/C/" UDPSCOPE_HELP_NAME "/ -- the directory "
"`help:" UDPSCOPE_HELP_NAME "` resolves to. If there is no `help:` viewer either, "
"install one as well (for example `yelp`).";
}
gtk_alert_dialog_set_detail(alert, detail.c_str());
gtk_alert_dialog_show(alert, parent);
gtk_alert_dialog_show(alert, launch->parent);
g_object_unref(alert);
g_error_free(error);
}
// `parent` was referenced for the duration of the async call.
if (parent != nullptr) g_object_unref(parent);
if (launch->done) {
launch->done(ok, ok || error == nullptr ? std::string() : std::string(error->message));
}
if (launch->parent != nullptr) g_object_unref(launch->parent);
delete launch;
if (error != nullptr) g_error_free(error);
}
} // namespace
@@ -108,6 +138,37 @@ const char* about_icon_name() {
return adw_about_dialog_get_application_icon(ADW_ABOUT_DIALOG(g_about));
}
std::string help_installed_path() {
// The freedesktop Help System Specification's own search order: `$XDG_DATA_HOME` first, then every
// `$XDG_DATA_DIRS` entry in order. Yelp resolves `help:<id>` the same way, so this is a reading of
// where the document *would* be found rather than a second, private convention.
std::vector<std::string> roots;
roots.emplace_back(g_get_user_data_dir() != nullptr ? g_get_user_data_dir() : "");
const char* const* dirs = g_get_system_data_dirs();
for (int i = 0; dirs != nullptr && dirs[i] != nullptr; ++i) roots.emplace_back(dirs[i]);
for (const std::string& root : roots) {
if (root.empty()) continue;
const std::string base = root + "/help";
GError* error = nullptr;
GDir* dir = g_dir_open(base.c_str(), 0, &error);
if (dir == nullptr) {
if (error != nullptr) g_error_free(error);
continue;
}
// `<lang>` is a directory of its own (`C`, `en`, `it`, ...), so the document is looked for one
// level below the `help` root rather than straight under it.
while (const char* lang = g_dir_read_name(dir)) {
const std::string candidate = base + "/" + lang + "/" UDPSCOPE_HELP_NAME;
if (g_file_test((candidate + "/index.page").c_str(), G_FILE_TEST_IS_REGULAR)) {
g_dir_close(dir);
return candidate;
}
}
g_dir_close(dir);
}
return std::string();
}
HelpReport help_self_check() {
HelpReport report;
@@ -141,14 +202,16 @@ HelpReport help_self_check() {
return report;
}
void show_help(GtkWindow* parent) {
void show_help(GtkWindow* parent, std::function<void(bool, const std::string&)> done) {
// The freedesktop `help:` URI, handed to whatever the desktop registered for it -- Yelp on
// GNOME. The launch is asynchronous, so the parent is referenced across it and released in
// the callback; `parent` may legitimately be nullptr (the chooser runs before any window
// exists).
// GNOME. The launch is asynchronous, so both the parent and the reporter are carried across it in
// a heap `HelpLaunch` and released in the callback; `parent` may legitimately be nullptr (the
// chooser runs before any window exists).
auto* launch = new HelpLaunch;
launch->parent = parent != nullptr ? GTK_WINDOW(g_object_ref(parent)) : nullptr;
launch->done = std::move(done);
GtkUriLauncher* launcher = gtk_uri_launcher_new("help:" UDPSCOPE_HELP_NAME);
gtk_uri_launcher_launch(launcher, parent, nullptr, on_help_launched,
parent != nullptr ? g_object_ref(parent) : nullptr);
gtk_uri_launcher_launch(launcher, parent, nullptr, on_help_launched, launch);
g_object_unref(launcher);
}