// 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 is a `GtkWindow`. The other two are `AdwDialog`s, which are **not** /// `GtkWindow`s: they have no `::close-request`, so they clear on `::closed` and close with /// `adw_dialog_close()`. GtkWidget* g_help = nullptr; AdwDialog* g_about = nullptr; AdwDialog* g_shortcuts = 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; } 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 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()); // The screenshots above it, if this page has any. The column is the label's previous sibling // inside the page's box, which is what lets this handler stay a matter of the label alone -- // and 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* label = GTK_WIDGET(body_label); GtkWidget* parent = gtk_widget_get_parent(label); GtkWidget* shots = parent != nullptr ? gtk_widget_get_first_child(parent) : nullptr; 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; } 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); // The page's own column: its screenshots above its text. Built once and *refilled* by the page // switch, which is why no picture is made here -- one `GtkPicture` per image of the page. GtkWidget* shot_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); gtk_widget_set_margin_start(shot_box, 18); gtk_widget_set_margin_end(shot_box, 18); gtk_widget_set_margin_top(shot_box, 14); gtk_widget_set_visible(shot_box, false); GtkWidget* page_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); gtk_box_append(GTK_BOX(page_box), shot_box); gtk_box_append(GTK_BOX(page_box), body); gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(body_scroll), page_box); 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), "%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), 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)); } namespace { /// Appends one table row to `dialog`, opening a new section whenever the row's `section` /// string differs from the last one. `*current` is the section being filled, `*title` the /// name it carries; both are owned by the caller so the two tables share one run. /// /// `accel` is handed to `AdwShortcutsItem` as an accelerator when it is non-empty and /// `subtitle` is used when it is not, which is how a wheel or a drag gets a row: neither /// GTK's nor libadwaita's accelerator syntax can name one, and asking them to parse /// "Wheel" costs a parse warning and draws nothing. void append_shortcut(AdwShortcutsDialog* dialog, AdwShortcutsSection** current, const char** title, const char* section, const char* accel, const char* subtitle, const char* description) { if (*current == nullptr || std::string(*title) != section) { *current = adw_shortcuts_section_new(section); *title = section; adw_shortcuts_dialog_add(dialog, *current); } AdwShortcutsItem* item = adw_shortcuts_item_new(description, accel); if (subtitle != nullptr && *subtitle != '\0') { adw_shortcuts_item_set_subtitle(item, subtitle); } adw_shortcuts_section_add(*current, item); } } // namespace void show_shortcuts(GtkWindow* parent) { if (g_shortcuts != nullptr) { adw_dialog_present(g_shortcuts, GTK_WIDGET(parent)); return; } AdwDialog* dialog = adw_shortcuts_dialog_new(); g_shortcuts = dialog; adw_dialog_set_title(dialog, "Keyboard and mouse shortcuts"); AdwShortcutsSection* section = nullptr; const char* section_title = nullptr; int accel_count = 0; const Accel* accels = app_accels(&accel_count); for (int i = 0; i < accel_count; ++i) { append_shortcut(ADW_SHORTCUTS_DIALOG(dialog), §ion, §ion_title, accels[i].section, accels[i].accel, "", accels[i].description); } int gesture_count = 0; const Gesture* gestures = plot_gestures(&gesture_count); for (int i = 0; i < gesture_count; ++i) { append_shortcut(ADW_SHORTCUTS_DIALOG(dialog), §ion, §ion_title, gestures[i].section, gestures[i].accel, gestures[i].subtitle, gestures[i].description); } g_signal_connect(dialog, "closed", G_CALLBACK(on_dialog_closed), &g_shortcuts); adw_dialog_present(dialog, GTK_WIDGET(parent)); } 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) adw_dialog_close(g_shortcuts); } } // namespace udpscope