The hand-rolled grid existed to avoid GTK's deprecated GtkShortcutsWindow; libadwaita 1.8's AdwShortcutsDialog is the supported replacement, so use the platform widget and get its search, sectioning and shortcut labels for free. The table strings become GTK accelerator syntax, since the shortcut label parses them. The six mouse rows cannot: no accelerator syntax names a wheel or a drag, so they carry an empty accelerator and prose in the new subtitle field. adw_init() is called in run_ui() and in choose_source() (replacing its gtk_init()), which means the whole app is drawn with libadwaita's stylesheet. Verified settled: viewport.w 870..870, axes.w changes 2, x-desync 0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
292 lines
12 KiB
C++
292 lines
12 KiB
C++
// SPDX-License-Identifier: MIT
|
|
#include "help_window.hpp"
|
|
|
|
#include <adwaita.h>
|
|
|
|
#include <cstdio>
|
|
#include <string>
|
|
|
|
#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<GtkWidget**>(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 <file> 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<int>(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 "<i>This page is missing from the build.</i>";
|
|
}
|
|
gsize size = 0;
|
|
const char* data = static_cast<const char*>(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 "<i>This page could not be displayed: its markup is malformed.</i>";
|
|
}
|
|
*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), "<b><small>%s</small></b>", 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
|