// SPDX-License-Identifier: MIT #include "help_window.hpp" #include #include #include #include #include "build_info.hpp" #include "layout_store.hpp" // Both come from CMakeLists: the document identifier is derived from the desktop file's // basename (its desktop-file id), and the directory is where the Mallard set lives in the // source tree, which is what the self-check scans. Yelp resolves `help:` to // `/help///` on its own (freedesktop Help System Specification), // so the identifier and the installed directory have to name the same set -- which is why // neither is written out again here. static_assert(sizeof(UDPSCOPE_HELP_NAME) > 1, "UDPSCOPE_HELP_NAME must come from the build"); static_assert(sizeof(UDPSCOPE_HELP_DIR) > 1, "UDPSCOPE_HELP_DIR must come from the build"); namespace udpscope { namespace { /// Nulled by each dialog's own close handler, so a second open cannot present a dead widget. /// One pointer per dialog, file-scope because there is one application. The help manual is no /// longer one of these: it opens in a separate viewer process through the `help:` URI, and a /// process with no window of ours has nothing to null. AdwDialog* g_about = nullptr; /// The overview is built from a `.ui` file, so its builder is held too: the window belongs to /// the builder, and a *destroyed* `GtkWindow` cannot be presented again -- which is why the /// builder is replaced on the next opening rather than reused. GtkWidget* g_shortcuts = nullptr; GtkBuilder* g_shortcuts_ui = nullptr; gboolean on_window_close(GtkWindow* window, gpointer slot) { (void)window; *static_cast(slot) = nullptr; return FALSE; // let the default handler destroy it } /// Shared by both `AdwDialog`s: the pointer to clear is the `user_data`, so there is one /// handler rather than one per dialog. void on_dialog_closed(AdwDialog* dialog, gpointer slot) { (void)dialog; *static_cast(slot) = nullptr; } /// 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 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) { auto* launch = static_cast(user); GError* error = nullptr; 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"); 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 { // The system-wide install is named *first* on purpose: a set under the user's data // directory is one a sandboxed viewer (Yelp) cannot read its figures from -- the pages // open without their images -- so the no-root route carries that caveat rather than // leading. The message is kept no longer than the one it replaces; do the same on edit. detail += "The manual does not look installed: nothing named \"help:" UDPSCOPE_HELP_NAME "\" was found where `help:` resolves. Install it system-wide:\n\n" " cmake -DCMAKE_INSTALL_PREFIX=/usr .. && sudo cmake --build build --target install-help\n\n" "That writes where the viewer looks. The no-root `install-help-user` writes under " "your data directory instead: a sandboxed viewer shows those pages without their " "figures. A `help:` viewer (for example `yelp`) is needed as well."; } gtk_alert_dialog_set_detail(alert, detail.c_str()); gtk_alert_dialog_show(alert, launch->parent); g_object_unref(alert); } 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 void show_about(GtkWindow* parent, const std::string& session_path) { if (g_about != nullptr) { adw_dialog_present(g_about, GTK_WIDGET(parent)); return; } // `AdwAboutDialog` rather than `GtkAboutDialog`: it shows the application icon as the page's // own header, which is the whole reason the app has one, and `adw_about_window_new()` is // deprecated in favour of it. It is an `AdwDialog`, so it is modal on what it is presented // on and needs no transient-for or modal call of its own. AdwDialog* dialog = adw_about_dialog_new(); g_about = dialog; AdwAboutDialog* about = ADW_ABOUT_DIALOG(dialog); adw_about_dialog_set_application_name(about, "udpscope"); adw_about_dialog_set_application_icon(about, "org.udpscope.app"); adw_about_dialog_set_developer_name(about, "Martino Ferrari"); adw_about_dialog_set_version(about, build_info::kVersion); adw_about_dialog_set_comments(about, "An oscilloscope for UDPStreamer sources."); adw_about_dialog_set_license_type(about, GTK_LICENSE_MIT_X11); adw_about_dialog_set_website(about, "https://git.ferrarinas.it/martino/udpscope"); // `debug_info` rather than `system_information`: it renders as a Troubleshooting page with a // copy button, where the old field was inert text a user had to retype. A `std::string` // rather than a fixed buffer, because a long settings path used to truncate it silently. const std::string debug = "Commit: " + std::string(build_info::kCommit) + "\nBuilt: " + build_info::kTimestamp + "\nSettings: " + config_dir() + "\nSession file: " + session_path; adw_about_dialog_set_debug_info(about, debug.c_str()); g_signal_connect(dialog, "closed", G_CALLBACK(on_dialog_closed), &g_about); adw_dialog_present(dialog, GTK_WIDGET(parent)); } const char* about_icon_name() { if (g_about == nullptr) return ""; 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:` the same way, so this is a reading of // where the document *would* be found rather than a second, private convention. std::vector 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; } // `` 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; // The manual is a set of files on disk now, so the reading is a file-system one: a missing // directory leaves `topics=0 index=false`, which is a manual that is not where the build // says it is -- and Yelp would show nothing for `help:`. GError* error = nullptr; GDir* dir = g_dir_open(UDPSCOPE_HELP_DIR, 0, &error); if (dir == nullptr) { if (error != nullptr) g_error_free(error); return report; } while (const char* name = g_dir_read_name(dir)) { if (!g_str_has_suffix(name, ".page")) continue; if (g_strcmp0(name, "index.page") == 0) report.index = true; else ++report.topics; } g_dir_close(dir); // The figures the pages reference, counted for the same reason: an image that went missing // is a broken figure in the viewer rather than a failed build. const std::string figures = std::string(UDPSCOPE_HELP_DIR) + "/figures"; if (GDir* figdir = g_dir_open(figures.c_str(), 0, &error)) { while (const char* name = g_dir_read_name(figdir)) { if (g_str_has_suffix(name, ".png") || g_str_has_suffix(name, ".jpg")) ++report.figures; } g_dir_close(figdir); } else if (error != nullptr) { g_error_free(error); } return report; } void show_help(GtkWindow* parent, std::function done) { // The freedesktop `help:` URI, handed to whatever the desktop registered for it -- Yelp on // 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, launch); g_object_unref(launcher); } void show_shortcuts(GtkWindow* parent) { if (g_shortcuts != nullptr) { gtk_window_present(GTK_WINDOW(g_shortcuts)); return; } // A `GtkShortcutsWindow` from `src/ui/shortcuts.ui` -- gedit's arrangement, and GTK's own: one // section per page, with the sidebar and the search box drawn by the window itself. A UI file // rather than widget construction because of the mouse rows: an accelerator cannot spell "Ctrl // and drag", and libadwaita's `AdwShortcutsItem` -- the modern replacement -- has no icon // property, so its cell reads "No Shortcut". `GtkShortcutsShortcut`'s one generic gesture type // draws the `icon` the row names. (The widget is deprecated since 4.20, but this uses none of // its functions: the file is loaded as data and presented as a `GtkWindow`.) // // A builder per opening, not one reused: the window belongs to the builder and `close-request` // destroys the window, so the resource is parsed again rather than an already-dead window // fetched. g_clear_object(&g_shortcuts_ui); g_shortcuts_ui = gtk_builder_new_from_resource("/org/udpscope/ui/shortcuts.ui"); if (g_shortcuts_ui == nullptr) return; GObject* overlay = gtk_builder_get_object(g_shortcuts_ui, "help_overlay"); if (overlay == nullptr) return; g_shortcuts = GTK_WIDGET(overlay); gtk_window_set_transient_for(GTK_WINDOW(g_shortcuts), parent); g_signal_connect(g_shortcuts, "close-request", G_CALLBACK(on_window_close), &g_shortcuts); gtk_window_present(GTK_WINDOW(g_shortcuts)); } void close_help_windows() { if (g_about != nullptr) adw_dialog_close(g_about); if (g_shortcuts != nullptr) gtk_window_close(GTK_WINDOW(g_shortcuts)); } } // namespace udpscope