// SPDX-License-Identifier: MIT #include "help_window.hpp" #include #include #include #include "build_info.hpp" #include "layout_store.hpp" #include "shortcuts.hpp" namespace udpscope { namespace { /// Nulled by each window's own close handler, so a second open cannot present a dead widget. /// One pointer per window, file-scope because there is one application. /// /// The help window and the shortcut overview are `GtkWindow`s. The About dialog is an /// `AdwDialog`, which is **not** a `GtkWindow`: it has no `::close-request`, so it clears on /// `::closed` and closes with `adw_dialog_close()`. GtkWidget* g_help = nullptr; /// The help window is a `.ui` file too, so its builder, the label one page's markup goes into and the /// column its screenshots go into are all kept beside the window's own pointer. All four are cleared by /// the window's own close handler: the page switch reads them, and it cannot run after the window is /// gone. GtkBuilder* g_help_ui = nullptr; GtkWidget* g_help_body = nullptr; GtkWidget* g_help_shots = nullptr; AdwDialog* g_about = nullptr; /// The overview is the one window here 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; /// The help window's own close handler, rather than the shared one: this window also owns the label a /// page is written into and the column its screenshots are built in, and a page switch that ran after /// the window was gone would read both. gboolean on_help_close(GtkWindow* window, gpointer) { (void)window; g_help = nullptr; g_help_body = nullptr; g_help_shots = nullptr; return FALSE; // let the default handler destroy it } 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; } 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 /// The screenshots shown above the text, as resource paths and a null terminator. A page /// with none is the common case and the reason this is a list rather than one field: the /// overview carries two, the window and a held capture. const char* const* images = nullptr; }; /// 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. /// How tall a page's screenshots are drawn. They are scaled to fit this *height* and centre /// themselves in it, so a 1500x950 capture is readable without a horizontal scrollbar. constexpr int kHelpShotHeight = 260; /// The screenshots each page carries, in the order it shows them. constexpr const char* kOverviewShots[] = {"/org/udpscope/help/img/main-window.png", "/org/udpscope/help/img/trigger-capture.png", nullptr}; constexpr const char* kTriggerShots[] = {"/org/udpscope/help/img/trigger-capture.png", nullptr}; constexpr const char* kMathShots[] = {"/org/udpscope/help/img/math-editor.png", nullptr}; constexpr HelpPage kPages[] = { {"Getting started", "Overview", "/org/udpscope/help/01-overview.txt", kOverviewShots}, {"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"}, {"Viewing", "Time window", "/org/udpscope/help/05-window.txt"}, {"Viewing", "Navigating the plot", "/org/udpscope/help/06-navigate.txt"}, {"Viewing", "Pause and play", "/org/udpscope/help/07-pause.txt"}, {"Viewing", "Trigger", "/org/udpscope/help/08-trigger.txt", kTriggerShots}, {"Analysis", "Cursors", "/org/udpscope/help/09-cursors.txt"}, {"Analysis", "Measurements", "/org/udpscope/help/10-measurements.txt"}, {"Analysis", "Math channels", "/org/udpscope/help/11-math.txt", kMathShots}, {"Output and state", "Saving and export", "/org/udpscope/help/12-export.txt"}, {"Output and state", "Sessions and settings", "/org/udpscope/help/13-sessions.txt"}, {"Output and state", "Status bar and diagnostics", "/org/udpscope/help/14-status.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) { if (row == nullptr || g_help_body == 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(g_help_body), body.c_str()); // The screenshots above it, if this page has any. The column comes from the `.ui` by id rather than // being found as the label's previous sibling: refilling it is a page *switch*, not a frame, so // rebuilding it here is cheap and is also the only way to be sure the pictures are this page's and // not the last one's. GtkWidget* shots = g_help_shots; if (shots != nullptr && GTK_IS_BOX(shots)) { while (GtkWidget* old = gtk_widget_get_first_child(shots)) { gtk_box_remove(GTK_BOX(shots), old); } for (const char* const* img = kPages[idx].images; img != nullptr && *img != nullptr; ++img) { GtkWidget* shot = gtk_picture_new_for_resource(*img); gtk_picture_set_can_shrink(GTK_PICTURE(shot), TRUE); // A height, and `CONTAIN` (the default) to fit the image inside it: a picture that may // shrink asks for no height at all in a box, so without this it is allocated nothing and // the page shows its text with a gap where the screenshots should be. gtk_widget_set_size_request(shot, -1, kHelpShotHeight); gtk_box_append(GTK_BOX(shots), shot); } gtk_widget_set_visible(shots, gtk_widget_get_first_child(shots) != nullptr); } } } // 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)); } 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; } // A builder per opening, as with every other `.ui` here: the window belongs to its object list and // `close-request` destroys the window, so the resource is parsed again rather than a dead window // fetched. This is where the previous one goes, and it is safe because that window has been closed // for as long as it takes someone to ask for help again. g_clear_object(&g_help_ui); g_help_ui = gtk_builder_new_from_resource("/org/udpscope/ui/help.ui"); if (g_help_ui == nullptr) return; GObject* window = gtk_builder_get_object(g_help_ui, "help_window"); GObject* list = gtk_builder_get_object(g_help_ui, "page_list"); GObject* body = gtk_builder_get_object(g_help_ui, "body"); GObject* shots = gtk_builder_get_object(g_help_ui, "shot_box"); // A missing id is the one mistake the build and `GtkBuilder` both miss; every reader below would be a // null widget on the first page switch. if (window == nullptr || list == nullptr || body == nullptr || shots == nullptr) return; g_help = GTK_WIDGET(window); g_help_body = GTK_WIDGET(body); g_help_shots = GTK_WIDGET(shots); gtk_window_set_transient_for(GTK_WINDOW(g_help), parent); // The rows are the pages, which is data and not template: a heading row per section, then one row per // page carrying its own index. A template cannot know the fourteen pages, and a sectioned list is not // what a GtkListView with one item factory can draw. 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), nullptr); // 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(g_help, "close-request", G_CALLBACK(on_help_close), &g_help); gtk_window_present(GTK_WINDOW(g_help)); } 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 for this widget -- // has no icon property at all, so its cell reads "No Shortcut". `GtkShortcutsShortcut`'s one generic // gesture type draws the `icon` the row names, which is what puts a mouse glyph where the keycaps // would be. (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`, so the build stays at its four warnings.) // // 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_help != nullptr) gtk_window_close(GTK_WINDOW(g_help)); if (g_about != nullptr) adw_dialog_close(g_about); if (g_shortcuts != nullptr) gtk_window_close(GTK_WINDOW(g_shortcuts)); } } // namespace udpscope