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:
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user