src/ui/source-form.ui is the form a source is added or edited with: an AdwDialog of AdwPreferencesGroup rows (AdwEntryRow, AdwSpinRow, AdwSwitchRow) with its action in the header bar, where the label says which mode the form is in -- Connect for a new host, Save for an existing configuration. The grid of labels and bare entries it replaces had its hints in placeholders, which vanish as soon as there is a value; they are the groups' descriptions now. src/ui/help.ui is the help window's chrome: an AdwWindow with a header bar and an AdwNavigationSplitView whose sidebar is the page list. The rows stay in C++ because the fourteen pages are data, not template. Four UI files now, 653 lines of template that used to be widget construction. Two libadwaita traps cost a run each and are in docs/claude/invariants-gtk-and-chrome.md: an AdwDialog has no content property (its child is a plain <child>, unlike an AdwToolbarView), and AdwEntryRow has no subtitle (it is an AdwPreferencesRow, not an AdwActionRow). The unused form_field() helper went with them. Every probe reading is unchanged -- [sources] form:, [sources] edit form:, [help] -- which is what refactoring a shape rather than a behaviour should look like.
316 lines
15 KiB
C++
316 lines
15 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 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<GtkWidget**>(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<AdwDialog**>(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 <file> 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<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) {
|
|
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), "<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), 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
|