diff --git a/CMakeLists.txt b/CMakeLists.txt
index 773fd7e..c6d7974 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -103,6 +103,7 @@ add_executable(udpscope
src/source_dialog.cpp
src/measure.cpp
src/math_channel.cpp
+ src/shortcuts.cpp
src/help_window.cpp
src/ui.cpp
)
diff --git a/src/help_window.cpp b/src/help_window.cpp
index 954ec92..1982f5f 100644
--- a/src/help_window.cpp
+++ b/src/help_window.cpp
@@ -6,6 +6,7 @@
#include "build_info.hpp"
#include "layout_store.hpp"
+#include "shortcuts.hpp"
namespace udpscope {
namespace {
@@ -14,6 +15,7 @@ namespace {
/// One pointer per window, file-scope because there is one application.
GtkWidget* g_about = nullptr;
GtkWidget* g_help = nullptr;
+GtkWidget* g_shortcuts = nullptr;
gboolean on_window_close(GtkWindow* window, gpointer slot) {
(void)window;
@@ -212,9 +214,93 @@ void show_help(GtkWindow* parent) {
gtk_window_present(GTK_WINDOW(window));
}
+namespace {
+
+/// One `accel description` pair appended to `grid` at `row`. The accel column is
+/// monospaced and right-aligned so the descriptions line up down the window.
+void append_shortcut_row(GtkWidget* grid, int row, const char* accel_text,
+ const char* description) {
+ GtkWidget* keys = gtk_label_new(nullptr);
+ char markup[256];
+ std::snprintf(markup, sizeof(markup), "%s", accel_text);
+ gtk_label_set_markup(GTK_LABEL(keys), markup);
+ gtk_label_set_xalign(GTK_LABEL(keys), 1.0f);
+ gtk_widget_set_margin_end(keys, 14);
+ gtk_grid_attach(GTK_GRID(grid), keys, 0, row, 1, 1);
+
+ GtkWidget* what = gtk_label_new(description);
+ gtk_label_set_xalign(GTK_LABEL(what), 0.0f);
+ gtk_grid_attach(GTK_GRID(grid), what, 1, row, 1, 1);
+}
+
+/// A section heading spanning both columns.
+void append_shortcut_heading(GtkWidget* grid, int row, const char* section) {
+ GtkWidget* heading = gtk_label_new(nullptr);
+ char markup[128];
+ std::snprintf(markup, sizeof(markup), "%s", section);
+ gtk_label_set_markup(GTK_LABEL(heading), markup);
+ gtk_label_set_xalign(GTK_LABEL(heading), 0.0f);
+ gtk_widget_set_margin_top(heading, row == 0 ? 0 : 14);
+ gtk_widget_set_margin_bottom(heading, 4);
+ gtk_grid_attach(GTK_GRID(grid), heading, 0, row, 2, 1);
+}
+
+} // namespace
+
+void show_shortcuts(GtkWindow* parent) {
+ if (g_shortcuts != nullptr) {
+ gtk_window_present(GTK_WINDOW(g_shortcuts));
+ return;
+ }
+
+ GtkWidget* window = gtk_window_new();
+ g_shortcuts = window;
+ gtk_window_set_title(GTK_WINDOW(window), "Keyboard and mouse shortcuts");
+ gtk_window_set_default_size(GTK_WINDOW(window), 560, 680);
+ gtk_window_set_transient_for(GTK_WINDOW(window), parent);
+
+ GtkWidget* grid = gtk_grid_new();
+ gtk_grid_set_row_spacing(GTK_GRID(grid), 2);
+ gtk_widget_set_margin_start(grid, 20);
+ gtk_widget_set_margin_end(grid, 20);
+ gtk_widget_set_margin_top(grid, 16);
+ gtk_widget_set_margin_bottom(grid, 16);
+
+ int row = 0;
+ const char* section = nullptr;
+ int accel_count = 0;
+ const Accel* accels = app_accels(&accel_count);
+ for (int i = 0; i < accel_count; ++i) {
+ if (section == nullptr || std::string(section) != accels[i].section) {
+ section = accels[i].section;
+ append_shortcut_heading(grid, row++, section);
+ }
+ append_shortcut_row(grid, row++, accels[i].accel_text, accels[i].description);
+ }
+ int gesture_count = 0;
+ const Gesture* gestures = plot_gestures(&gesture_count);
+ for (int i = 0; i < gesture_count; ++i) {
+ if (section == nullptr || std::string(section) != gestures[i].section) {
+ section = gestures[i].section;
+ append_shortcut_heading(grid, row++, section);
+ }
+ append_shortcut_row(grid, row++, gestures[i].accel_text, gestures[i].description);
+ }
+
+ GtkWidget* scroll = gtk_scrolled_window_new();
+ gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_NEVER,
+ GTK_POLICY_AUTOMATIC);
+ gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), grid);
+ gtk_window_set_child(GTK_WINDOW(window), scroll);
+
+ g_signal_connect(window, "close-request", G_CALLBACK(on_window_close), &g_shortcuts);
+ gtk_window_present(GTK_WINDOW(window));
+}
+
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) gtk_window_close(GTK_WINDOW(g_shortcuts));
}
} // namespace udpscope
diff --git a/src/help_window.hpp b/src/help_window.hpp
index e9c452a..ccaff88 100644
--- a/src/help_window.hpp
+++ b/src/help_window.hpp
@@ -28,6 +28,11 @@ struct HelpReport {
/// Loads and validates every page without building a window. Safe to call headless.
HelpReport help_self_check();
+/// The shortcut overview: the application accelerators and the plot's own interactions,
+/// in the sections the tables name. Hand-rolled because GtkShortcutsWindow is deprecated
+/// as of GTK 4.18 and this project builds warning-free.
+void show_shortcuts(GtkWindow* parent);
+
/// Closes whichever of the three windows are open, via gtk_window_close() so the
/// close-request handlers run and the pointers are nulled. Used by the probe.
void close_help_windows();
diff --git a/src/shortcuts.cpp b/src/shortcuts.cpp
new file mode 100644
index 0000000..a0c54d2
--- /dev/null
+++ b/src/shortcuts.cpp
@@ -0,0 +1,80 @@
+// SPDX-License-Identifier: MIT
+#include "shortcuts.hpp"
+
+#include
+
+namespace udpscope {
+namespace {
+
+/// Bare letters are toggles, Ctrl is for files and windows. Nothing here shadows a key the
+/// plot's own controller handles ('+', '-', 'f', the arrows, Escape).
+constexpr Accel kAccels[] = {
+ {GDK_KEY_space, 0, AppAction::PauseToggle, "Space", "Pause or resume the display",
+ "Acquisition"},
+ {GDK_KEY_a, 0, AppAction::TriggerArm, "A", "Arm or disarm the trigger", "Acquisition"},
+ {GDK_KEY_t, 0, AppAction::TriggerPanel, "T", "Show or hide the trigger panel",
+ "Acquisition"},
+ {GDK_KEY_c, 0, AppAction::CursorsPane, "C", "Show or hide the cursors pane",
+ "Panes and editors"},
+ {GDK_KEY_m, 0, AppAction::MeasuresPane, "M", "Show or hide the measurements pane",
+ "Panes and editors"},
+ {GDK_KEY_g, 0, AppAction::MathPane, "G", "Show or hide the math pane",
+ "Panes and editors"},
+ {GDK_KEY_l, 0, AppAction::LayoutEditor, "L", "Show or hide the layout editor",
+ "Panes and editors"},
+ {GDK_KEY_s, 0, AppAction::SidePanel, "S", "Show or hide the side panel",
+ "Panes and editors"},
+ {GDK_KEY_e, GDK_CONTROL_MASK, AppAction::Export, "Ctrl+E",
+ "Export the visible range", "Files and windows"},
+ {GDK_KEY_F1, 0, AppAction::Help, "F1", "Open the help window", "Files and windows"},
+ {GDK_KEY_slash, GDK_CONTROL_MASK, AppAction::Shortcuts, "Ctrl+/",
+ "Open this shortcut overview", "Files and windows"},
+ {GDK_KEY_q, GDK_CONTROL_MASK, AppAction::Quit, "Ctrl+Q", "Quit udpscope",
+ "Files and windows"},
+};
+constexpr int kAccelCount = static_cast(sizeof(kAccels) / sizeof(kAccels[0]));
+
+/// The plot's own interactions, documentation only. The keyboard's modifiers are not the
+/// wheel's: '+' is shift+'=' on common layouts, so Shift could not mean anything there.
+constexpr Gesture kGestures[] = {
+ {"+ / -", "Zoom in or out, both axes", "Plot keyboard"},
+ {"Ctrl + / Ctrl -", "Zoom the time axis only", "Plot keyboard"},
+ {"Alt + / Alt -", "Zoom the value axis only", "Plot keyboard"},
+ {"Left / Right", "Pan along time", "Plot keyboard"},
+ {"Up / Down", "Pan along values", "Plot keyboard"},
+ {"F", "Fit the view to the data", "Plot keyboard"},
+ {"Escape", "Cancel the gesture in progress", "Plot keyboard"},
+ {"Wheel", "Zoom about the pointer, both axes", "Plot mouse"},
+ {"Shift+wheel", "Zoom the time axis only", "Plot mouse"},
+ {"Ctrl+wheel", "Zoom the value axis only", "Plot mouse"},
+ {"Drag", "Pan", "Plot mouse"},
+ {"Ctrl+drag", "Box zoom on time", "Plot mouse"},
+ {"Alt+drag", "Box zoom on values", "Plot mouse"},
+};
+constexpr int kGestureCount = static_cast(sizeof(kGestures) / sizeof(kGestures[0]));
+
+/// The only modifiers a table row may require. Lock, Num-Lock and the pointer buttons all
+/// arrive in the same mask and must be masked out or no row ever matches.
+constexpr unsigned kModMask = GDK_CONTROL_MASK | GDK_SHIFT_MASK | GDK_ALT_MASK;
+
+} // namespace
+
+const Accel* app_accels(int* count) {
+ if (count != nullptr) *count = kAccelCount;
+ return kAccels;
+}
+
+const Gesture* plot_gestures(int* count) {
+ if (count != nullptr) *count = kGestureCount;
+ return kGestures;
+}
+
+const Accel* find_accel(unsigned keyval, unsigned mods) {
+ const unsigned wanted = mods & kModMask;
+ for (const Accel& accel : kAccels) {
+ if (accel.keyval == keyval && accel.mods == wanted) return &accel;
+ }
+ return nullptr;
+}
+
+} // namespace udpscope
diff --git a/src/shortcuts.hpp b/src/shortcuts.hpp
new file mode 100644
index 0000000..407d948
--- /dev/null
+++ b/src/shortcuts.hpp
@@ -0,0 +1,52 @@
+// SPDX-License-Identifier: MIT
+#pragma once
+
+namespace udpscope {
+
+/// Everything a key can ask the application to do. The plot's own actions (zoom, pan, fit)
+/// are not here: they belong to the plotter's input controller, which handles its own keys.
+enum class AppAction {
+ PauseToggle,
+ TriggerArm,
+ TriggerPanel,
+ CursorsPane,
+ MeasuresPane,
+ MathPane,
+ LayoutEditor,
+ SidePanel,
+ Export,
+ Help,
+ Shortcuts,
+ Quit,
+};
+
+/// One application accelerator: what to press, what it does, and where the overview lists it.
+struct Accel {
+ unsigned keyval; ///< GDK keyval
+ unsigned mods; ///< GdkModifierType mask, 0 for a bare key
+ AppAction action;
+ const char* accel_text; ///< what the overview prints
+ const char* description;
+ const char* section;
+};
+
+/// One documented plot interaction. These have no AppAction: the plotter's own controller
+/// implements them, and this table exists so the overview can list them.
+struct Gesture {
+ const char* accel_text;
+ const char* description;
+ const char* section;
+};
+
+/// The application accelerator table. Writes the row count to `count`.
+const Accel* app_accels(int* count);
+
+/// The plot keyboard and mouse table. Writes the row count to `count`.
+const Gesture* plot_gestures(int* count);
+
+/// The accelerator for `keyval` with `mods`, or nullptr. `mods` is masked to
+/// Ctrl/Shift/Alt before comparing, because Lock, Num-Lock and the pointer buttons
+/// arrive in the same mask and would otherwise never match a table row.
+const Accel* find_accel(unsigned keyval, unsigned mods);
+
+} // namespace udpscope
diff --git a/src/ui.cpp b/src/ui.cpp
index 323d8b4..baa045a 100644
--- a/src/ui.cpp
+++ b/src/ui.cpp
@@ -20,6 +20,7 @@
#include "export.hpp"
#include "help_window.hpp"
#include "layout_store.hpp"
+#include "shortcuts.hpp"
#include "measure.hpp"
#include "model.hpp"
#include "plot_host.hpp"
@@ -4104,6 +4105,15 @@ void on_help_clicked(GtkButton*, gpointer user) {
show_help(GTK_WINDOW(app->window));
}
+void on_shortcuts_clicked(GtkButton*, gpointer user) {
+ App* app = static_cast(user);
+ if (app->menu_button != nullptr) {
+ GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
+ if (popover != nullptr) gtk_popover_popdown(popover);
+ }
+ show_shortcuts(GTK_WINDOW(app->window));
+}
+
// --- right-click menu -------------------------------------------------------
void on_y_auto_clicked(GtkButton* button, gpointer user) {
@@ -5570,6 +5580,12 @@ GtkWidget* build_toolbar(App& app) {
g_signal_connect(help_item, "clicked", G_CALLBACK(on_help_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), help_item);
+ GtkWidget* shortcuts_item = gtk_button_new_with_label("Keyboard shortcuts (Ctrl+/)");
+ gtk_widget_set_tooltip_text(shortcuts_item,
+ "every key and mouse gesture, including the plot's own");
+ g_signal_connect(shortcuts_item, "clicked", G_CALLBACK(on_shortcuts_clicked), &app);
+ gtk_box_append(GTK_BOX(menu_box), shortcuts_item);
+
GtkWidget* about_item = gtk_button_new_with_label("About udpscope");
gtk_widget_set_tooltip_text(about_item, "version, build timestamp and settings paths");
g_signal_connect(about_item, "clicked", G_CALLBACK(on_about_clicked), &app);
@@ -7257,14 +7273,19 @@ gboolean help_probe(gpointer user) {
show_help(GTK_WINDOW(a->window));
show_about(GTK_WINDOW(a->window), session);
+ show_shortcuts(GTK_WINDOW(a->window));
close_help_windows();
+ int accel_count = 0;
+ int gesture_count = 0;
+ (void)app_accels(&accel_count);
+ (void)plot_gestures(&gesture_count);
std::fprintf(stderr,
- "[help] pages=%d parsed=%d shortest='%s' %zu B; "
+ "[help] pages=%d parsed=%d shortest='%s' %zu B; shortcuts: app=%d plot=%d; "
"about version=%s commit=%s built=%s settings=%s; closed ok\n",
report.pages, report.parsed, report.shortest_title.c_str(),
- report.shortest_bytes, build_info::kVersion, build_info::kCommit,
- build_info::kTimestamp, config_dir().c_str());
+ report.shortest_bytes, accel_count, gesture_count, build_info::kVersion,
+ build_info::kCommit, build_info::kTimestamp, config_dir().c_str());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}