// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later #include "native_internal.h" static GtkBuilder *builder; static int action; static int voice_changed; // Borrowed from the builder for the duration of one Apply/OK request. static GtkWidget *busy_focus; void prefs_set_action(int value) { action = value; } GtkWidget *prefs_widget(const char *id) { return GTK_WIDGET(gtk_builder_get_object(builder, id)); } static void name_accessible_tree(AtkObject *object, const char *name) { const char *current = atk_object_get_name(object); if (!current || !current[0]) atk_object_set_name(object, name); int children = atk_object_get_n_accessible_children(object); for (int i = 0; i < children; i++) { AtkObject *child = atk_object_ref_accessible_child(object, i); if (!child) continue; name_accessible_tree(child, name); g_object_unref(child); } } static void name_widget(const char *id, const char *name) { name_accessible_tree(gtk_widget_get_accessible(prefs_widget(id)), name); } static void describe_combo_tree(AtkObject *object, const char *name, const char *value) { AtkRole role = atk_object_get_role(object); if (role == ATK_ROLE_COMBO_BOX || role == ATK_ROLE_PUSH_BUTTON || role == ATK_ROLE_TOGGLE_BUTTON) { atk_object_set_name(object, name); atk_object_set_description(object, value); } int children = atk_object_get_n_accessible_children(object); for (int i = 0; i < children; i++) { AtkObject *child = atk_object_ref_accessible_child(object, i); if (!child) continue; describe_combo_tree(child, name, value); g_object_unref(child); } } static void combo_changed(GtkComboBox *combo, gpointer data) { gchar *value = gtk_combo_box_text_get_active_text(GTK_COMBO_BOX_TEXT(combo)); describe_combo_tree(gtk_widget_get_accessible(GTK_WIDGET(combo)), (const char *)data, value ? value : ""); g_free(value); } static void free_signal_data(gpointer data, GClosure *closure) { (void)closure; g_free(data); } static void set_focus_chain(const char *container_id, const char *ids[], unsigned int count) { GList *chain = NULL; for (unsigned int i = 0; i < count; i++) chain = g_list_append(chain, prefs_widget(ids[i])); G_GNUC_BEGIN_IGNORE_DEPRECATIONS gtk_container_set_focus_chain(GTK_CONTAINER(prefs_widget(container_id)), chain); G_GNUC_END_IGNORE_DEPRECATIONS g_list_free(chain); } static void clicked(GtkButton *button, gpointer data) { action = GPOINTER_TO_INT(data); if (action == 1 || action == 2) busy_focus = GTK_WIDGET(button); } static gboolean closing(GtkWidget *window, GdkEvent *event, gpointer data) { action = 3; return TRUE; } static gboolean key(GtkWidget *window, GdkEventKey *event, gpointer data) { if (event->keyval == GDK_KEY_Escape) { action = 3; return TRUE; } return FALSE; } static void defaults(GtkToggleButton *button, gpointer spin) { gtk_widget_set_sensitive(GTK_WIDGET(spin), !gtk_toggle_button_get_active(button)); } static void voice_value_changed(GtkWidget *control, gpointer data) { (void)control; (void)data; voice_changed = 1; } static gboolean voice_scale_key_press(GtkWidget *control, GdkEventKey *event, gpointer data) { (void)data; if (event->state & gtk_accelerator_get_default_mod_mask()) return FALSE; double step; switch (event->keyval) { case GDK_KEY_Up: case GDK_KEY_KP_Up: step = 1.0; break; case GDK_KEY_Down: case GDK_KEY_KP_Down: step = -1.0; break; case GDK_KEY_Right: case GDK_KEY_KP_Right: step = 0.1; break; case GDK_KEY_Left: case GDK_KEY_KP_Left: step = -0.1; break; default: return FALSE; } // GtkRange clamps at the adjustment's bounds and emits value-changed, // preserving accessible value updates and the existing preview path. GtkRange *range = GTK_RANGE(control); gtk_range_set_value(range, gtk_range_get_value(range) + step); return TRUE; } static void echo_sensitivity(GtkToggleButton *button, gpointer data) { (void)button; (void)data; int enabled = prefs_check_get("keyEchoCheckButton"); const char *categories[] = {"enableAlphabeticKeysCheckButton", "enableNumericKeysCheckButton", "enablePunctuationKeysCheckButton", "enableSpaceCheckButton", "enableModifierKeysCheckButton", "enableFunctionKeysCheckButton", "enableActionKeysCheckButton", "enableNavigationKeysCheckButton", "enableDiacriticalKeysCheckButton"}; for (unsigned i = 0; i < G_N_ELEMENTS(categories); i++) gtk_widget_set_sensitive(prefs_widget(categories[i]), enabled); int custom = prefs_check_get("useCustomEchoVoiceCheckButton"); const char *voice[] = {"useCustomEchoSpeechServerCheckButton", "echoSpeechFamilies", "echoRateScale", "echoPitchScale", "echoVolumeScale"}; for (unsigned i = 0; i < G_N_ELEMENTS(voice); i++) gtk_widget_set_sensitive(prefs_widget(voice[i]), custom); gtk_widget_set_sensitive(prefs_widget("echoSpeechServers"), custom && prefs_check_get("useCustomEchoSpeechServerCheckButton")); gtk_widget_set_sensitive(prefs_widget("useCustomEchoForKeyCheckButton"), custom && enabled); gtk_widget_set_sensitive(prefs_widget("useCustomEchoForCharacterCheckButton"), custom && prefs_check_get("enableEchoByCharacterCheckButton")); gtk_widget_set_sensitive(prefs_widget("useCustomEchoForWordCheckButton"), custom && prefs_check_get("enableEchoByWordCheckButton")); gtk_widget_set_sensitive(prefs_widget("useCustomEchoForSentenceCheckButton"), custom && prefs_check_get("enableEchoBySentenceCheckButton")); } static void sound_sensitivity(GtkToggleButton *button, gpointer data) { (void)button; (void)data; int enabled = prefs_check_get("enableSoundCheckButton"); const char *controls[] = {"soundVolumeScale", "soundSinkCombo", "soundThemeCombo", "roleSoundPresentationCombo", "playSoundForRoleCheckButton", "playSoundForStateCheckButton", "playSoundForPositionInSetCheckButton", "spatializeObjectSoundsCheckButton"}; for (unsigned i = 0; i < G_N_ELEMENTS(controls); i++) gtk_widget_set_sensitive(prefs_widget(controls[i]), enabled); } static void ocr_sensitivity(GtkToggleButton *button, gpointer data) { (void)button; (void)data; gtk_widget_set_sensitive(prefs_widget("ocrBlackWhiteValueSpinButton"), prefs_check_get("ocrBlackWhiteCheckButton")); gtk_widget_set_sensitive(prefs_widget("ocrRefreshIntervalSpinButton"), prefs_check_get("ocrAutoRefreshCheckButton")); } int prefs_init(const char *xml) { if (!gtk_init_check(NULL, NULL)) return 0; g_set_application_name("Cthulhu Preferences"); builder = gtk_builder_new(); GError *error = NULL; if (!gtk_builder_add_from_string(builder, xml, -1, &error)) { if (error) g_error_free(error); g_object_unref(builder); builder = NULL; return 0; } prefs_init_reading_settings(); prefs_init_key_bindings(); prefs_init_modifiers(); prefs_init_pronunciation(); prefs_init_text_attributes(); prefs_init_tentacles(); AtkObject *reading_accessible = gtk_widget_get_accessible(prefs_widget("readingSettingsTreeview")); atk_object_set_name(reading_accessible, "Reading settings"); atk_object_set_description(reading_accessible, "Use arrow keys to move between settings and Space to toggle the selected setting."); g_signal_connect(prefs_widget("apply"), "clicked", G_CALLBACK(clicked), GINT_TO_POINTER(1)); g_signal_connect(prefs_widget("save"), "clicked", G_CALLBACK(clicked), GINT_TO_POINTER(2)); g_signal_connect(prefs_widget("cancel"), "clicked", G_CALLBACK(clicked), GINT_TO_POINTER(3)); g_signal_connect(prefs_widget("window"), "delete-event", G_CALLBACK(closing), NULL); g_signal_connect(prefs_widget("window"), "key-press-event", G_CALLBACK(key), NULL); const char *ids[] = {"rate", "pitch", "volume"}; for (int i = 0; i < 3; i++) { gchar *id = g_strconcat(ids[i], "_default", NULL); g_signal_connect(prefs_widget(id), "toggled", G_CALLBACK(defaults), prefs_widget(ids[i])); g_signal_connect(prefs_widget(id), "toggled", G_CALLBACK(voice_value_changed), NULL); g_signal_connect(prefs_widget(ids[i]), "value-changed", G_CALLBACK(voice_value_changed), NULL); g_free(id); } const char *voiceScales[] = {"rate", "pitch", "volume", "echoRateScale", "echoPitchScale", "echoVolumeScale"}; for (unsigned i = 0; i < G_N_ELEMENTS(voiceScales); i++) { GtkWidget *control = prefs_widget(voiceScales[i]); g_signal_connect(control, "key-press-event", G_CALLBACK(voice_scale_key_press), NULL); atk_object_set_description(gtk_widget_get_accessible(control), "Up and Down change by 1; Left and Right change by 0.1."); } const char *voiceCombos[] = { "speechSystems", "speechServers", "speechLanguages", "speechFamilies", "voiceTypesCombo", "hardwareBaudCombo", "echoSpeechServers", "echoSpeechFamilies", }; for (unsigned i = 0; i < G_N_ELEMENTS(voiceCombos); i++) { g_signal_connect(prefs_widget(voiceCombos[i]), "changed", G_CALLBACK(voice_value_changed), NULL); } g_signal_connect(prefs_widget("hardwareDeviceEntry"), "changed", G_CALLBACK(voice_value_changed), NULL); GtkEntryCompletion *completion = gtk_entry_completion_new(); GtkListStore *devices = gtk_list_store_new(1, G_TYPE_STRING); gtk_entry_completion_set_model(completion, GTK_TREE_MODEL(devices)); gtk_entry_completion_set_text_column(completion, 0); gtk_entry_completion_set_inline_completion(completion, TRUE); gtk_entry_set_completion(GTK_ENTRY(prefs_widget("hardwareDeviceEntry")), completion); g_object_unref(completion); g_object_unref(devices); const char *labelled[] = {"rate", "pitch", "volume", "capitalization"}; for (int i = 0; i < 4; i++) { gchar *id = g_strconcat(labelled[i], "_label", NULL); name_widget(labelled[i], gtk_label_get_text(GTK_LABEL(prefs_widget(id)))); g_free(id); } const struct { const char *id; const char *name; } named[] = { {"timeFormatCombo", "Time format"}, {"dateFormatCombo", "Date format"}, {"progressBarOutputCombo", "Progress bar output"}, {"sayAllStyle", "Say All by"}, {"soundVolumeScale", "Sound volume"}, {"soundSinkCombo", "Audio backend"}, {"soundThemeCombo", "Sound theme"}, {"roleSoundPresentationCombo", "Element presentation"}, {"speechSystems", "Speech system"}, {"speechServers", "Speech synthesizer"}, {"speechLanguages", "Language"}, {"speechFamilies", "Person"}, {"speechHistoryLimitSpinButton", "Speech history entries"}, {"voiceTypesCombo", "Voice type"}, {"capitalization", "Capitalization style"}, {"hardwareDeviceEntry", "Serial device"}, {"hardwareBaudCombo", "Serial baud rate"}, {"contractionTableCombo", "Contraction table"}, {"brailleFlashTimeSpinButton", "Flash message duration"}, {"echoSpeechServers", "Key echo speech synthesizer"}, {"echoSpeechFamilies", "Key echo person"}, {"echoRateScale", "Key echo rate"}, {"echoPitchScale", "Key echo pitch"}, {"echoVolumeScale", "Key echo volume"}, {"keyBindingEntry", "Shortcuts"}, {"ocrScaleSpinButton", "OCR scale factor"}, {"ocrBlackWhiteValueSpinButton", "Black and white threshold"}, {"ocrLanguageEntry", "OCR recognition languages"}, {"ocrLayoutCombo", "OCR text layout"}, {"ocrRefreshIntervalSpinButton", "OCR refresh interval in milliseconds"}, }; for (unsigned int i = 0; i < G_N_ELEMENTS(named); i++) { name_widget(named[i].id, named[i].name); GtkWidget *control = prefs_widget(named[i].id); if (GTK_IS_COMBO_BOX(control) && gtk_combo_box_get_active(GTK_COMBO_BOX(control)) < 0) { gtk_combo_box_set_active(GTK_COMBO_BOX(control), 0); } if (GTK_IS_COMBO_BOX_TEXT(control)) { g_signal_connect_data(control, "changed", G_CALLBACK(combo_changed), g_strdup(named[i].name), free_signal_data, 0); } } const char *keyboard_focus[] = { "generalDesktopButton", "generalLaptopButton", }; set_focus_chain("keyboard_layout_controls", keyboard_focus, G_N_ELEMENTS(keyboard_focus)); const char *echo_toggles[] = {"keyEchoCheckButton", "useCustomEchoVoiceCheckButton", "useCustomEchoSpeechServerCheckButton", "enableEchoByCharacterCheckButton", "enableEchoByWordCheckButton", "enableEchoBySentenceCheckButton"}; for (unsigned i = 0; i < G_N_ELEMENTS(echo_toggles); i++) g_signal_connect(prefs_widget(echo_toggles[i]), "toggled", G_CALLBACK(echo_sensitivity), NULL); echo_sensitivity(NULL, NULL); g_signal_connect(prefs_widget("enableSoundCheckButton"), "toggled", G_CALLBACK(sound_sensitivity), NULL); sound_sensitivity(NULL, NULL); g_signal_connect(prefs_widget("ocrBlackWhiteCheckButton"), "toggled", G_CALLBACK(ocr_sensitivity), NULL); g_signal_connect(prefs_widget("ocrAutoRefreshCheckButton"), "toggled", G_CALLBACK(ocr_sensitivity), NULL); ocr_sensitivity(NULL, NULL); gtk_widget_show_all(prefs_widget("window")); for (unsigned int i = 0; i < G_N_ELEMENTS(named); i++) { GtkWidget *control = prefs_widget(named[i].id); if (GTK_IS_COMBO_BOX_TEXT(control)) { combo_changed(GTK_COMBO_BOX(control), (gpointer)named[i].name); } } gtk_widget_grab_focus(prefs_widget("notebook")); return 1; } void prefs_set_application(const char *name) { if (!name || !name[0]) return; // Hiding a notebook child leaves its tab in the AT-SPI tree. Remove the // page entirely so application preferences expose no global controls. GtkNotebook *notebook = GTK_NOTEBOOK(prefs_widget("notebook")); gtk_notebook_remove_page(notebook, gtk_notebook_page_num(notebook, prefs_widget("tentacles_page"))); gchar *title = g_strdup_printf("Cthulhu Preferences for %s", name); gtk_window_set_title(GTK_WINDOW(prefs_widget("window")), title); g_free(title); } void prefs_iterate(void) { for (int i = 0; i < 64 && g_main_context_pending(NULL); i++) g_main_context_iteration(NULL, FALSE); } int prefs_action(void) { int result = action; action = 0; return result; } int prefs_voice_changed(void) { int result = voice_changed; voice_changed = 0; return result; } void prefs_busy(int busy, int can_save) { gtk_widget_set_sensitive(prefs_widget("notebook"), !busy); gtk_widget_set_sensitive(prefs_widget("apply"), !busy); gtk_widget_set_sensitive(prefs_widget("save"), !busy && can_save); if (!busy && busy_focus) { if (gtk_widget_get_sensitive(busy_focus)) gtk_widget_grab_focus(busy_focus); busy_focus = NULL; } } void prefs_present(void) { gtk_window_present(GTK_WINDOW(prefs_widget("window"))); } void prefs_close(void) { busy_focus = NULL; gtk_widget_destroy(prefs_widget("window")); g_object_unref(builder); builder = NULL; } void prefs_status(const char *message, int focus) { gtk_entry_set_text(GTK_ENTRY(prefs_widget("status")), message); atk_object_set_name(gtk_widget_get_accessible(prefs_widget("status")), message); if (focus) gtk_widget_grab_focus(prefs_widget("status")); }