From cfe93f03977ae8fc171679f3ad0b1f7d08a3af37 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Wed, 12 Aug 2026 23:32:08 -0700 Subject: [PATCH] feat(settings): searchable language combobox (#672) * feat(settings): make language selector a searchable combobox Turn the Speech Model language picker into a ComboBoxText with an entry and GtkEntryCompletion so typing filters the 34+ language list instead of scrolling the whole alphabet. Fixes #652 * fix(settings): use ComboBoxText text/id column order for language search Gtk.ComboBoxText stores display text in column 0 and the id in column 1. Point EntryCompletion at the text column so typing "span" lists Spanish. --- src/vocalinux/ui/settings_dialog.py | 110 ++++++++++++++++++++++++++-- tests/test_settings_dialog.py | 71 +++++++++++++++++- 2 files changed, 175 insertions(+), 6 deletions(-) diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index f10b4a8..e2bbb30 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -254,8 +254,8 @@ MODEL_SPECIALIZATION_TOOLTIP = ( "lower-memory quantized models, Turbo speed, or a legacy large model." ) LANGUAGE_TOOLTIP = ( - "Choose the language you dictate in. English-only model specializations limit this " - "list to English." + "Choose the language you dictate in. Type to search the list. English-only model " + "specializations limit this list to English." ) @@ -545,6 +545,7 @@ levelbar block.empty { /* Combo boxes and spin buttons */ combobox button, +combobox entry, spinbutton { min-height: 32px; border-radius: 6px; @@ -678,6 +679,70 @@ def _prevent_scroll_on_hover(widget: Gtk.Widget): widget.set_can_focus(True) +def _combo_text_matches_query(query: str, item_id: str, item_text: str) -> bool: + """Return whether a combo row matches a case-insensitive search query.""" + needle = (query or "").strip().lower() + if not needle: + return True + return needle in (item_text or "").lower() or needle in (item_id or "").lower() + + +def _resolve_combo_text_query(query: str, rows: list) -> Optional[str]: + """Return a unique matching combo id, preferring an exact name or id.""" + needle = (query or "").strip() + if not needle: + return None + needle_l = needle.lower() + matches = [] + for item_id, item_text in rows: + item_id = item_id or "" + item_text = item_text or "" + if item_text.lower() == needle_l or item_id.lower() == needle_l: + return item_id + if _combo_text_matches_query(needle, item_id, item_text): + matches.append(item_id) + if len(matches) == 1: + return matches[0] + return None + + +def _combo_text_rows(combo: Gtk.ComboBoxText) -> list: + """Return ``(id, display text)`` pairs from a ComboBoxText model. + + Gtk.ComboBoxText stores display text in column 0 and the id in column 1. + """ + model = combo.get_model() + if not model: + return [] + return [(row[1], row[0]) for row in model] + + +def _combo_completion_match(completion, key, tree_iter, *_args) -> bool: + """GtkEntryCompletion match function for ComboBoxText text/id columns.""" + model = completion.get_model() + if model is None: + return False + row = model[tree_iter] + return _combo_text_matches_query(key, row[1], row[0]) + + +def _attach_language_combo_search(combo: Gtk.ComboBoxText) -> None: + """Filter language choices as the user types in a ComboBoxText entry.""" + entry = combo.get_child() + if entry is None: + return + entry.set_placeholder_text("Search languages…") + completion = Gtk.EntryCompletion() + completion.set_model(combo.get_model()) + # ComboBoxText store: column 0 is display text, column 1 is id. + completion.set_text_column(0) + completion.set_inline_completion(False) + completion.set_popup_completion(True) + completion.set_minimum_key_length(1) + completion.set_match_func(_combo_completion_match) + entry.set_completion(completion) + + def _get_whisper_cache_dir() -> str: """Get the Whisper model cache directory.""" return os.path.join(MODELS_DIR, "whisper") @@ -2141,14 +2206,19 @@ class SettingsDialog(Gtk.Dialog): self.model_variant_row.set_tooltip_text(MODEL_SPECIALIZATION_TOOLTIP) group.add_row(self.model_variant_row) - # Language selection - self.language_combo = Gtk.ComboBoxText() + # Language selection (searchable: type to filter the 30+ language list) + self.language_combo = Gtk.ComboBoxText.new_with_entry() self.language_combo.set_size_request(_CONTROL_WIDTH, -1) self.language_combo.set_tooltip_text(LANGUAGE_TOOLTIP) _prevent_scroll_on_hover(self.language_combo) + _attach_language_combo_search(self.language_combo) + language_entry = self.language_combo.get_child() + if language_entry is not None: + language_entry.connect("activate", self._on_language_entry_activate) + language_entry.connect("focus-out-event", self._on_language_entry_focus_out) self.language_row = PreferenceRow( title="Language", - subtitle="Primary language for recognition", + subtitle="Type to search, or pick from the list", widget=self.language_combo, ) self.language_row.set_tooltip_text(LANGUAGE_TOOLTIP) @@ -4198,6 +4268,36 @@ class SettingsDialog(Gtk.Dialog): finally: self._processing_language_change = False + def _on_language_entry_activate(self, entry): + """Commit a unique typed match when Enter is pressed in the language entry.""" + self._commit_or_restore_language_entry() + + def _on_language_entry_focus_out(self, entry, event): + """Commit or restore the language after the entry loses focus.""" + # Defer so a completion click can set the active id before we restore. + GLib.idle_add(self._commit_or_restore_language_entry) + return False + + def _commit_or_restore_language_entry(self) -> bool: + """Resolve typed language text, or restore the last valid selection.""" + if self._processing_language_change or self._initializing: + return False + if self.language_combo.get_active_id(): + return False + entry = self.language_combo.get_child() + typed = entry.get_text() if entry is not None else "" + match_id = _resolve_combo_text_query(typed, _combo_text_rows(self.language_combo)) + if match_id: + self.language_combo.set_active_id(match_id) + return False + # Restoring the same language should not re-apply settings. + self._processing_language_change = True + try: + self._set_combo_active_id_or_first(self.language_combo, self.language) + finally: + self._processing_language_change = False + return False + def _update_engine_specific_ui(self): """Show/hide UI elements driven by the active engine.""" engine_text = self.engine_combo.get_active_text() diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 749480b..80601e4 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -262,6 +262,12 @@ class TestSettingsDialogCSS(unittest.TestCase): self.assertNotIn("border-left:", SETTINGS_CSS) self.assertNotIn("border-left-color:", SETTINGS_CSS) + def test_settings_css_styles_combobox_entry(self): + """Searchable combos need the inner entry to match button height.""" + from vocalinux.ui.settings_dialog import SETTINGS_CSS + + self.assertIn("combobox entry", SETTINGS_CSS) + class TestSettingsDialogClasses(unittest.TestCase): """Test cases for SettingsDialog helper classes.""" @@ -371,7 +377,10 @@ class TestSettingsDialogInstantApply(unittest.TestCase): with open(source_path, "r") as f: source_code = f.read() - self.assertNotIn("focus-out-event", source_code) + # The initial prompt must not auto-apply on focus-out; that fights typing. + # Language combo search may restore a valid selection on focus-out. + advanced_body = source_code.split("def _build_advanced_section")[1].split("\n def ")[0] + self.assertNotIn("focus-out-event", advanced_body) self.assertNotIn( 'advanced_initial_prompt_buffer.connect("changed", self._on_advanced_param_changed)', source_code, @@ -644,6 +653,7 @@ class TestSettingsDialogHelperFunctions(unittest.TestCase): self.assertIn("largest model", MODEL_SIZE_TOOLTIP) self.assertIn("Standard multilingual", MODEL_SPECIALIZATION_TOOLTIP) self.assertIn("English-only", LANGUAGE_TOOLTIP) + self.assertIn("Type to search", LANGUAGE_TOOLTIP) self.assertIn("only in English", _model_specialization_tooltip("medium.en")) self.assertIn("lower-memory systems", _model_specialization_tooltip("medium-q5_0")) self.assertIn("Turbo", _model_specialization_tooltip("large-v3-turbo")) @@ -672,6 +682,8 @@ class TestSettingsDialogHelperFunctions(unittest.TestCase): source_code, ) self.assertIn("self.language_row.set_tooltip_text(LANGUAGE_TOOLTIP)", source_code) + self.assertIn("Gtk.ComboBoxText.new_with_entry()", source_code) + self.assertIn("_attach_language_combo_search", source_code) def test_whispercpp_recommendation_uses_language_for_specialization(self): """Test that English language nudges recommendations to .en variants.""" @@ -701,6 +713,63 @@ class TestSettingsDialogHelperFunctions(unittest.TestCase): self.assertEqual(_default_whispercpp_variant_for_size("medium", "auto"), "medium") +class TestLanguageComboSearch(unittest.TestCase): + """Searchable language ComboBox matching and commit helpers.""" + + def setUp(self): + if "vocalinux.ui.settings_dialog" in sys.modules: + del sys.modules["vocalinux.ui.settings_dialog"] + + def test_combo_text_matches_query_name_id_and_case(self): + from vocalinux.ui.settings_dialog import _combo_text_matches_query + + self.assertTrue(_combo_text_matches_query("span", "es", "Spanish")) + self.assertTrue(_combo_text_matches_query("SPAN", "es", "Spanish")) + self.assertTrue(_combo_text_matches_query("es", "es", "Spanish")) + self.assertTrue(_combo_text_matches_query("", "es", "Spanish")) + self.assertFalse(_combo_text_matches_query("span", "fr", "French")) + + def test_resolve_combo_text_query_unique_exact_and_ambiguous(self): + from vocalinux.ui.settings_dialog import _resolve_combo_text_query + from vocalinux.utils.vosk_model_info import SUPPORTED_LANGUAGES + + rows = [(code, info["name"]) for code, info in SUPPORTED_LANGUAGES.items()] + self.assertEqual(_resolve_combo_text_query("span", rows), "es") + self.assertEqual(_resolve_combo_text_query("English (US)", rows), "en-us") + self.assertEqual(_resolve_combo_text_query("auto", rows), "auto") + self.assertEqual(_resolve_combo_text_query("ja", rows), "ja") + self.assertIsNone(_resolve_combo_text_query("en", rows)) + self.assertIsNone(_resolve_combo_text_query("zzzz", rows)) + self.assertIsNone(_resolve_combo_text_query("", rows)) + + def test_resolve_prefers_exact_name_over_substring(self): + from vocalinux.ui.settings_dialog import _resolve_combo_text_query + + rows = [("xx", "Spanish"), ("es", "Not Spanish")] + self.assertEqual(_resolve_combo_text_query("Spanish", rows), "xx") + + def test_language_combo_is_searchable_in_source(self): + import os + + source_path = os.path.join( + os.path.dirname(__file__), + "..", + "src", + "vocalinux", + "ui", + "settings_dialog.py", + ) + with open(source_path, "r") as f: + source_code = f.read() + + self.assertIn("Gtk.ComboBoxText.new_with_entry()", source_code) + self.assertIn("Gtk.EntryCompletion()", source_code) + self.assertIn("completion.set_text_column(0)", source_code) + self.assertIn("Search languages…", source_code) + self.assertIn("def _commit_or_restore_language_entry", source_code) + self.assertIn("Type to search, or pick from the list", source_code) + + class TestSettingsSearch(unittest.TestCase): """Test cases for the settings search feature."""