feat: keyboard and mouse shortcut overview window

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Sonnet 4.6 committed 2026-10-04 15:15:47 +02:00
1 parent 3e0d0de325
commit 018b98eddf
6 files changed
+248 -3

No files matched your search

+1
View File
@@ -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
)
+86
View File
@@ -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), "<tt>%s</tt>", 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), "<b>%s</b>", 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
+5
View File
@@ -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();
+80
View File
@@ -0,0 +1,80 @@
// SPDX-License-Identifier: MIT
#include "shortcuts.hpp"
#include <gdk/gdk.h>
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<int>(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<int>(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
+52
View File
@@ -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
+24 -3
View File
@@ -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<App*>(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;
}