From f9490061a1386137dd1ca49d41b8b2bc8e730049 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sun, 4 Oct 2026 15:39:46 +0200 Subject: [PATCH] feat: keyboard accelerators for panes, trigger, export and the help windows Co-Authored-By: Claude Sonnet 4.6 --- src/ui.cpp | 146 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/src/ui.cpp b/src/ui.cpp index eabfe86..73a9286 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -5469,6 +5469,71 @@ GtkWidget* build_window_control(App& app) { return app.window_button; } +/// The one place a key turns into an action. Toggles go through +/// gtk_toggle_button_set_active(), which emits ::toggled and so runs exactly the handler a +/// click would; the non-toggles call their click handlers with a null button, which they +/// already ignore. Split out of the key handler so the probe can fire a row directly. +void run_app_action(App& app, AppAction action) { + const auto toggle = [](GtkWidget* button) { + if (button == nullptr) return; + const gboolean now = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(button)); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(button), now == TRUE ? FALSE : TRUE); + }; + switch (action) { + case AppAction::PauseToggle: + toggle(app.pause_button); + break; + case AppAction::TriggerArm: + on_arm_clicked(nullptr, &app); + break; + case AppAction::TriggerPanel: + toggle(app.trigger_toggle); + break; + case AppAction::CursorsPane: + toggle(app.cursors_toggle); + break; + case AppAction::MeasuresPane: + toggle(app.measures_toggle); + break; + case AppAction::MathPane: + toggle(app.math_toggle); + break; + case AppAction::LayoutEditor: + toggle(app.layout_toggle); + break; + case AppAction::SidePanel: + toggle(app.sidebar_toggle); + break; + case AppAction::Export: + on_export_clicked(nullptr, &app); + break; + case AppAction::Help: + show_help(GTK_WINDOW(app.window)); + break; + case AppAction::Shortcuts: + show_shortcuts(GTK_WINDOW(app.window)); + break; + case AppAction::Quit: + if (app.window != nullptr) gtk_window_close(GTK_WINDOW(app.window)); + break; + } +} + +/// Looks a key up in the accel table and runs it. Refuses outright while a GtkEditable has +/// focus: every bare-letter accel is also a character somebody may be typing. +gboolean on_app_key_pressed(GtkEventControllerKey*, guint keyval, guint, GdkModifierType state, + gpointer user) { + App* app = static_cast(user); + if (app->window == nullptr) return FALSE; + GtkWidget* focus = gtk_window_get_focus(GTK_WINDOW(app->window)); + if (focus != nullptr && GTK_IS_EDITABLE(focus)) return FALSE; + + const Accel* accel = find_accel(keyval, static_cast(state)); + if (accel == nullptr) return FALSE; + run_app_action(*app, accel->action); + return TRUE; +} + /// The header bar carries only the controls you reach for *while watching a signal*; /// everything you set up once lives in the overflow menu. Nine text buttons in a /// header bar is a wall of words to read past every time you want the one you need, @@ -5770,6 +5835,15 @@ void on_activate(GtkApplication* application, gpointer user) { gtk_box_append(GTK_BOX(app->plot_box), make_area(*app)); rebuild_signal_panels(*app); g_timeout_add(kTickMs, tick, app); + + // Bubble phase on purpose: a focused GtkEditable or GtkButton wins its own keys, so typing + // 'm' into the window entry does not toggle the measurements pane and Space does not + // double-fire on a focused button. + GtkEventController* keys = gtk_event_controller_key_new(); + gtk_event_controller_set_propagation_phase(keys, GTK_PHASE_BUBBLE); + g_signal_connect(keys, "key-pressed", G_CALLBACK(on_app_key_pressed), app); + gtk_widget_add_controller(app->window, keys); + gtk_window_present(GTK_WINDOW(app->window)); } @@ -7263,6 +7337,75 @@ gboolean zoomkeys_probe(gpointer user) { return G_SOURCE_REMOVE; } +/// Fires a representative accel from each group and reports the state it changed, then +/// checks the GtkEditable refusal. It goes through run_app_action() for the actions and +/// through the real handler for the refusal, because the refusal lives in the handler. +gboolean keys_probe(gpointer user) { + App* a = static_cast(user); + std::string line = "[keys]"; + + const auto before_after = [&](const char* name, GtkWidget* button, AppAction action) { + const int before = button != nullptr + ? (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(button)) ? 1 : 0) + : -1; + run_app_action(*a, action); + const int after = button != nullptr + ? (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(button)) ? 1 : 0) + : -1; + char buf[96]; + std::snprintf(buf, sizeof(buf), " %s: %d->%d |", name, before, after); + line += buf; + }; + + // Read the lock-protected field, not the atomic mirror: on_arm_clicked() writes + // trigger().armed directly, while trigger_armed() (the atomic) is updated by the + // acquisition thread on the next packet -- reading it immediately would race. + const bool armed_before = a->model->trigger().armed; + run_app_action(*a, AppAction::TriggerArm); + char armed[64]; + std::snprintf(armed, sizeof(armed), " a: armed %d->%d |", armed_before ? 1 : 0, + a->model->trigger().armed ? 1 : 0); + line += armed; + + const bool paused_before = a->acq->paused(); + run_app_action(*a, AppAction::PauseToggle); + char paused[64]; + std::snprintf(paused, sizeof(paused), " space: paused %d->%d |", paused_before ? 1 : 0, + a->acq->paused() ? 1 : 0); + line += paused; + run_app_action(*a, AppAction::PauseToggle); // leave the run live for the other probes + + before_after("t: trigger panel", a->trigger_toggle, AppAction::TriggerPanel); + before_after("c: cursors", a->cursors_toggle, AppAction::CursorsPane); + before_after("m: measures", a->measures_toggle, AppAction::MeasuresPane); + before_after("g: math", a->math_toggle, AppAction::MathPane); + before_after("l: layout", a->layout_toggle, AppAction::LayoutEditor); + before_after("s: side panel", a->sidebar_toggle, AppAction::SidePanel); + + // An accel must be refused while text is being typed. Focus the window entry, send 'm' + // through the real handler, and require the measurements toggle not to move. + int refused = -1; + if (a->window_entry != nullptr) { + gtk_menu_button_popup(GTK_MENU_BUTTON(a->window_button)); + gtk_widget_grab_focus(a->window_entry); + const int before = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->measures_toggle)) + ? 1 : 0; + const gboolean handled = on_app_key_pressed(nullptr, GDK_KEY_m, 0, + static_cast(0), a); + const int after = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->measures_toggle)) + ? 1 : 0; + refused = (handled == FALSE && before == after) ? 1 : 0; + gtk_menu_button_popdown(GTK_MENU_BUTTON(a->window_button)); + } + char tail[64]; + std::snprintf(tail, sizeof(tail), " editable focused: 'm' refused=%d\n", refused); + line += tail; + + std::fprintf(stderr, "%s", line.c_str()); + std::fflush(stderr); + return G_SOURCE_REMOVE; +} + /// Loads and validates every help page, opens all three windows, closes them, and reports. /// `parsed` must equal `pages` and no page may be empty; `closed ok` with no GTK critical /// is the crash check for three windows built and destroyed in one run. @@ -7380,6 +7523,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit if (const char* env = g_getenv("UDPSCOPE_PROBE_HELP_MS")) { g_timeout_add(static_cast(std::atoi(env)), help_probe, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_KEYS_MS")) { + g_timeout_add(static_cast(std::atoi(env)), keys_probe, &app); + } // Our own options were consumed by parse_args(); GTK would reject them, so only // the program name is handed to g_application_run().