From 018b98eddf456450a47bbf27cbc65c506963d73f Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sun, 4 Oct 2026 15:15:47 +0200 Subject: [PATCH] feat: keyboard and mouse shortcut overview window Co-Authored-By: Claude Sonnet 4.6 --- CMakeLists.txt | 1 + src/help_window.cpp | 86 +++++++++++++++++++++++++++++++++++++++++++++ src/help_window.hpp | 5 +++ src/shortcuts.cpp | 80 +++++++++++++++++++++++++++++++++++++++++ src/shortcuts.hpp | 52 +++++++++++++++++++++++++++ src/ui.cpp | 27 ++++++++++++-- 6 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 src/shortcuts.cpp create mode 100644 src/shortcuts.hpp 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; }