// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later #include "native_internal.h" #include #include // The preferences window owns the accepted draft. Each recorder owns a fresh // candidate and held modifier set; cancelling or destroying it discards both. typedef struct { GtkWidget *dialog; GtkWidget *entry; GPtrArray *keys; GHashTable *modifiers; } KeyRecorder; static GPtrArray *interruption_keys(void) { return g_object_get_data(G_OBJECT(prefs_widget("window")), "interruption-keys"); } static char *key_label(const char *name) { const struct { const char *name; const char *label; } labels[] = { {"Control_L", "Left Control"}, {"Control_R", "Right Control"}, {"Shift_L", "Left Shift"}, {"Shift_R", "Right Shift"}, {"Alt_L", "Left Alt"}, {"Alt_R", "Right Alt"}, {"Super_L", "Left Super"}, {"Super_R", "Right Super"}, {"Return", "Enter"}, {"KP_Enter", "Numpad Enter"}, {"space", "Space"}, }; for (unsigned i = 0; i < G_N_ELEMENTS(labels); i++) if (strcmp(name, labels[i].name) == 0) return g_strdup(labels[i].label); guint keyval = xkb_keysym_from_name(name, XKB_KEYSYM_NO_FLAGS); return keyval == XKB_KEY_NoSymbol ? g_strdup(name) : gtk_accelerator_get_label(keyval, 0); } static void show_keys(GtkWidget *entry, GPtrArray *keys) { GString *text = g_string_new(NULL); for (unsigned i = 0; i < keys->len; i++) { char *label = key_label(g_ptr_array_index(keys, i)); if (i) g_string_append(text, ", "); g_string_append(text, label); g_free(label); } gtk_entry_set_text(GTK_ENTRY(entry), text->len ? text->str : "No keys defined"); g_string_free(text, TRUE); } void prefs_interruption_clear(void) { g_ptr_array_set_size(interruption_keys(), 0); show_keys(prefs_widget("interruptionKeysEntry"), interruption_keys()); } void prefs_interruption_append(const char *name) { g_ptr_array_add(interruption_keys(), g_strdup(name)); show_keys(prefs_widget("interruptionKeysEntry"), interruption_keys()); } int prefs_interruption_count(void) { return interruption_keys()->len; } const char *prefs_interruption_key(int index) { return g_ptr_array_index(interruption_keys(), index); } static void interruption_sensitivity(GtkToggleButton *button, gpointer data) { (void)button; (void)data; gboolean custom = prefs_check_get("interruptCustomKeysRadio"); gtk_widget_set_sensitive(prefs_widget("recordInterruptionKeysButton"), custom); gtk_widget_set_sensitive(prefs_widget("interruptionKeysEntry"), custom); } static void record_key(KeyRecorder *recorder, guint keyval) { // GTK and X11 use different aliases for Page Up and multimedia keys. // Persist XKB names, matching both input observers rather than GTK names. char name[128]; int length = xkb_keysym_get_name(gdk_keyval_to_lower(keyval), name, sizeof(name)); if (length <= 0 || (unsigned)length >= sizeof(name)) return; for (unsigned i = 0; i < recorder->keys->len; i++) if (strcmp(name, g_ptr_array_index(recorder->keys, i)) == 0) return; if (recorder->keys->len == 64) return; g_ptr_array_add(recorder->keys, g_strdup(name)); show_keys(recorder->entry, recorder->keys); gtk_dialog_set_response_sensitive(GTK_DIALOG(recorder->dialog), GTK_RESPONSE_OK, TRUE); } static guint recording_keyval(GdkEventKey *event) { if (!GDK_IS_X11_DISPLAY(gdk_display_get_default())) return event->keyval; // Match the base names published by the X11 observer, including keypad // keys with Num Lock enabled. This records individual keys, not chords. GdkKeymapKey key = {event->hardware_keycode, 0, 0}; guint keyval = gdk_keymap_lookup_key(gdk_keymap_get_for_display(gdk_display_get_default()), &key); return keyval ? keyval : event->keyval; } static gboolean recording_key(GtkWidget *dialog, GdkEventKey *event, gpointer data) { KeyRecorder *recorder = data; guint state = event->state & gtk_accelerator_get_default_mod_mask(); gboolean finish = state == GDK_CONTROL_MASK && (event->keyval == GDK_KEY_Return || event->keyval == GDK_KEY_KP_Enter); gboolean cancel = state == GDK_CONTROL_MASK && event->keyval == GDK_KEY_Escape; if (event->type == GDK_KEY_PRESS && (finish || cancel)) { g_hash_table_remove_all(recorder->modifiers); if (cancel || recorder->keys->len) gtk_dialog_response(GTK_DIALOG(dialog), cancel ? GTK_RESPONSE_CANCEL : GTK_RESPONSE_OK); else gtk_entry_set_text(GTK_ENTRY(recorder->entry), "Record at least one key, then press Control+Enter."); return TRUE; } guint keyval = recording_keyval(event); if (event->is_modifier) { // Record modifiers on release so the Control used to finish or cancel // never becomes an interruption key just because it ended recording. if (event->type == GDK_KEY_PRESS) g_hash_table_add(recorder->modifiers, GUINT_TO_POINTER(keyval)); else if (g_hash_table_remove(recorder->modifiers, GUINT_TO_POINTER(keyval))) record_key(recorder, keyval); } else if (event->type == GDK_KEY_PRESS) { record_key(recorder, keyval); } // Consume Tab, Enter, Escape and Space before GTK focus/default actions. return TRUE; } static gboolean recording_focus_out(GtkWidget *dialog, GdkEventFocus *event, gpointer data) { (void)dialog; (void)event; KeyRecorder *recorder = data; g_hash_table_remove_all(recorder->modifiers); return FALSE; } static void free_recorder(gpointer data) { KeyRecorder *recorder = data; g_ptr_array_unref(recorder->keys); g_hash_table_unref(recorder->modifiers); g_free(recorder); } static void recording_response(GtkDialog *dialog, int response, gpointer data) { KeyRecorder *recorder = data; if (response == GTK_RESPONSE_OK && recorder->keys->len) { prefs_interruption_clear(); for (unsigned i = 0; i < recorder->keys->len; i++) prefs_interruption_append(g_ptr_array_index(recorder->keys, i)); } gtk_widget_destroy(GTK_WIDGET(dialog)); gtk_window_present(GTK_WINDOW(prefs_widget("window"))); gtk_widget_grab_focus(prefs_widget("recordInterruptionKeysButton")); } static void start_recording(GtkButton *button, gpointer data) { (void)button; (void)data; const char *instructions = "Press each key that should interrupt speech. " "Tab, Enter, Space, and Escape can be recorded. " "Press and release a modifier by itself to record it. " "Control+Enter finishes; Control+Escape cancels. " "Recording replaces the previous keys. Up to 64 keys can be recorded."; KeyRecorder *recorder = g_new0(KeyRecorder, 1); recorder->keys = g_ptr_array_new_with_free_func(g_free); recorder->modifiers = g_hash_table_new(g_direct_hash, g_direct_equal); recorder->dialog = gtk_dialog_new_with_buttons("Record interruption keys", GTK_WINDOW(prefs_widget("window")), GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT, "Cancel recording", GTK_RESPONSE_CANCEL, "Finish recording", GTK_RESPONSE_OK, NULL); g_object_set_data_full(G_OBJECT(recorder->dialog), "key-recorder", recorder, free_recorder); GtkWidget *content = gtk_dialog_get_content_area(GTK_DIALOG(recorder->dialog)); gtk_container_set_border_width(GTK_CONTAINER(content), 12); GtkWidget *label = gtk_label_new(instructions); gtk_label_set_line_wrap(GTK_LABEL(label), TRUE); gtk_label_set_max_width_chars(GTK_LABEL(label), 65); gtk_box_pack_start(GTK_BOX(content), label, FALSE, FALSE, 8); recorder->entry = gtk_entry_new(); gtk_editable_set_editable(GTK_EDITABLE(recorder->entry), FALSE); atk_object_set_name(gtk_widget_get_accessible(recorder->entry), instructions); atk_object_set_description(gtk_widget_get_accessible(recorder->dialog), instructions); gtk_box_pack_start(GTK_BOX(content), recorder->entry, FALSE, FALSE, 8); show_keys(recorder->entry, recorder->keys); gtk_dialog_set_response_sensitive(GTK_DIALOG(recorder->dialog), GTK_RESPONSE_OK, FALSE); g_signal_connect(recorder->dialog, "key-press-event", G_CALLBACK(recording_key), recorder); g_signal_connect(recorder->dialog, "key-release-event", G_CALLBACK(recording_key), recorder); g_signal_connect(recorder->dialog, "focus-out-event", G_CALLBACK(recording_focus_out), recorder); g_signal_connect(recorder->dialog, "response", G_CALLBACK(recording_response), recorder); gtk_widget_show_all(recorder->dialog); gtk_widget_grab_focus(recorder->entry); } void prefs_init_interruption(void) { g_object_set_data_full(G_OBJECT(prefs_widget("window")), "interruption-keys", g_ptr_array_new_with_free_func(g_free), (GDestroyNotify)g_ptr_array_unref); g_signal_connect(prefs_widget("interruptCustomKeysRadio"), "toggled", G_CALLBACK(interruption_sensitivity), NULL); g_signal_connect(prefs_widget("recordInterruptionKeysButton"), "clicked", G_CALLBACK(start_recording), NULL); atk_object_set_description(gtk_widget_get_accessible(prefs_widget("recordInterruptionKeysButton")), "Record individual keys. Control+Enter finishes; Control+Escape cancels."); atk_object_set_name(gtk_widget_get_accessible(prefs_widget("interruptionKeysEntry")), "Defined interruption keys"); interruption_sensitivity(NULL, NULL); }