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.
This commit is contained in:
Jatin K Malik
2026-08-12 23:32:08 -07:00
committed by GitHub
parent 73ae0def31
commit cfe93f0397
2 changed files with 175 additions and 6 deletions
+105 -5
View File
@@ -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()
+70 -1
View File
@@ -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."""