// 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 the `close-request` handler so a second open cannot present a dead widget. /// One pointer per window, file-scope because there is one application. GtkWidget* g_about = nullptr; GtkWidget* g_help = nullptr; /// The shortcut overview is an `AdwDialog`, not a window, so it has its own pointer and /// its own clearing signal (`::closed` rather than `::close-request`). 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 } void on_shortcuts_closed(AdwDialog* dialog, gpointer user) { (void)dialog; (void)user; g_shortcuts = 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 }; /// 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. constexpr HelpPage kPages[] = { {"Getting started", "Overview", "/org/udpscope/help/01-overview.txt"}, {"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"}, {"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"}, {"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()); } } // namespace void show_about(GtkWindow* parent, const std::string& session_path) { if (g_about != nullptr) { gtk_window_present(GTK_WINDOW(g_about)); return; } // GtkAboutDialog is not deprecated in GTK 4 (unlike GtkShortcutsWindow), so the // stock dialog is used here and the overview is hand-rolled. GtkWidget* dialog = gtk_about_dialog_new(); g_about = dialog; GtkAboutDialog* about = GTK_ABOUT_DIALOG(dialog); gtk_about_dialog_set_program_name(about, "udpscope"); gtk_about_dialog_set_version(about, build_info::kVersion); gtk_about_dialog_set_comments(about, "An oscilloscope for UDPStreamer sources."); gtk_about_dialog_set_license_type(about, GTK_LICENSE_MIT_X11); char details[1024]; std::snprintf(details, sizeof(details), "Commit: %s\nBuilt: %s\nSettings: %s\nSession file: %s", build_info::kCommit, build_info::kTimestamp, config_dir().c_str(), session_path.c_str()); gtk_about_dialog_set_system_information(about, details); gtk_window_set_transient_for(GTK_WINDOW(dialog), parent); gtk_window_set_modal(GTK_WINDOW(dialog), TRUE); g_signal_connect(dialog, "close-request", G_CALLBACK(on_window_close), &g_about); gtk_window_present(GTK_WINDOW(dialog)); } 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); gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(body_scroll), body); 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_shortcuts_closed), nullptr); 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) gtk_window_close(GTK_WINDOW(g_about)); if (g_shortcuts != nullptr) adw_dialog_close(g_shortcuts); } } // namespace udpscope