From 7bdbedce0b846f147c61b0e6d534397ffcf0e24d Mon Sep 17 00:00:00 2001 From: Vivek A Nadig <96881767+viveknadig@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:36:32 +0530 Subject: [PATCH] feat(language): follow the active keyboard layout while dictating (#837) * feat(language): follow the active keyboard layout while dictating Adds a "Follow keyboard layout" switch to Settings -> Speech Model -> Advanced. With it on, the active keyboard layout is re-read at the start of every dictation and the matching language is used, so someone who switches layouts to work in two languages gets the right one without opening Settings. #796 already seeded the language once on first run, but from the *configured* primary layout, which never changes as the user switches between them: a "fr,us" setup reports fr forever. detect_active_keyboard_ layout() reads GNOME's input sources instead. It takes mru-sources rather than the current index, because that index goes stale -- observed still reading 0 after a switch to the second layout -- which is why the IBus injection path already trusts the MRU list (#497, #738). An active source that is an IBus engine rather than an xkb layout is a real answer that cannot be mapped, so the language is left to the engine's own auto-detect. Borrowing the configured xkb layout there would pin English for someone typing Japanese through mozc-jp. The mode is a switch over the language picker rather than an entry inside it, since it is a mode and not a language. While on, the picker is disabled and shows what the layout resolves to; turning it off pins that. A language change no longer re-initialises the engine when the loaded model takes the language per utterance, which is the whole whisper family, so following a layout costs a settings read rather than a model reload. VOSK loads one model per language, Parakeet ignores the picker, and an English-only model has no non-English weights, so Settings refuses the mode for all three rather than offering a switch that does nothing. Fixes #821 * fix(language): keep follow mode armed and map IBus engines Swap English-only whisper.cpp weights to the multilingual sibling while passing the layout sentinel into reconfigure, so follow mode stays armed. Map common GNOME IBus engines onto catalogue languages instead of leaving them unmapped. * fix(language): stop IBus hyphen false-maps and gate the sibling swap The last-token hyphen heuristic mapped mozc-us and foo-us to English. Restrict it to *-jp/*-kr and name Mozc's extra engines as Japanese. Only whisper.cpp may consult the whisper.cpp download catalog. * fix(language): keep multilingual weights while following layout The picker shows the active layout while follow is on, often en-us. Deriving whisper.cpp .en from that display made Settings refuse the mode as English-only, then the next auto-apply persisted a concrete language and disarmed follow. Use LANGUAGE_FOLLOWS_LAYOUT for .en vs multilingual derivation instead, including on the load path before the switch is synced. * fix(language): type follow helpers and log sibling-swap failures * fix(test): stub follow-layout model rec so mocked psutil does not blow up follow-mode English-only guard calls get_recommended_whispercpp_model, which reads RAM through psutil. Full CI leaves a MagicMock in sys.modules["psutil"], so ram_gb >= 16 raises TypeError. Patch the settings_dialog alias to return a multilingual tiny rec so the assertion still holds without real RAM. * fix(language): catch specific errors on layout lookup and sibling swap * fix(language): swallow more sibling-swap reconfigure failures ImportError, TypeError, AttributeError, and ChecksumError from the multilingual swap must not abort dictation or disarm follow mode. --------- Co-authored-by: Jatin K Malik --- docs/USER_GUIDE.md | 17 + .../speech_recognition/recognition_manager.py | 162 +++++++- src/vocalinux/ui/config_manager.py | 7 +- src/vocalinux/ui/settings_dialog.py | 175 ++++++++- src/vocalinux/utils/system_language.py | 220 +++++++++++ tests/test_follow_keyboard_layout.py | 353 ++++++++++++++++++ tests/test_system_language.py | 181 +++++++++ 7 files changed, 1103 insertions(+), 12 deletions(-) create mode 100644 tests/test_follow_keyboard_layout.py diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index b553d2a..48d12c7 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -85,6 +85,23 @@ Open **Settings → Speech Model**. The page starts with a simple setup (languag Parakeet runs NVIDIA NeMo ASR models through sherpa-onnx. The default bundle is **v3-european** (25 European languages). **v2-english** is English-only. Parakeet ignores the catalog language picker (language is treated as auto). +### Language + +Pick the language you dictate in from **Language**, or leave it on auto-detect. + +If you work in more than one language and switch keyboard layouts to do it, turn +on **Follow keyboard layout** instead. Vocalinux then reads your active layout at +the start of every dictation and uses the matching language, so switching layout +switches dictation language with it. The Language picker greys out while this is +on and shows what your current layout resolves to; turning it off pins that +language. + +Available for whisper.cpp, Whisper, Faster Whisper, and Remote API. VOSK loads a +separate model per language, so following a layout would mean a model reload on +the hotkey; Parakeet does not use the language picker at all. Active-layout +detection uses GNOME's input-source settings; on other desktops it falls back to +the configured primary layout. + ### Model size (whisper.cpp / Whisper) | Size | Approx. size | Tradeoff | diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index 4fc6a90..dc48a9c 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -13,6 +13,7 @@ import logging import os import queue import re +import subprocess import sys import threading import time @@ -24,6 +25,7 @@ if TYPE_CHECKING: from ..common_types import RecognitionState from ..ui.audio_feedback import play_error_sound, play_start_sound, play_stop_sound +from ..ui.config_manager import _multilingual_sibling from ..utils import faster_whisper_model_info as faster_whisper from ..utils import parakeet_model_info as parakeet from ..utils.host_process import host_env @@ -34,13 +36,19 @@ from ..utils.model_checksums import ( write_verification_stamp, ) from ..utils.paths import models_dir +from ..utils.system_language import LANGUAGE_FOLLOWS_LAYOUT, language_for_active_layout from ..utils.vosk_model_info import SUPPORTED_LANGUAGES, VOSK_MODEL_INFO from ..utils.whisper_model_info import ( migrate_legacy_checkpoint_names, whisper_model_file, whisper_model_url, ) -from ..utils.whispercpp_model_info import WHISPERCPP_MODEL_INFO, get_model_path, is_model_downloaded +from ..utils.whispercpp_model_info import ( + WHISPERCPP_MODEL_INFO, + get_model_path, + is_english_only_model, + is_model_downloaded, +) from ..version import __version__ from .command_processor import CommandProcessor from .silero_vad import SILERO_CHUNK_SIZE, load_silero_vad @@ -101,6 +109,35 @@ def normalize_language_for_engine(engine: str, language: str) -> str: return language +def resolve_language_preference(language: str) -> str: + """Turn a stored language preference into one an engine can consume (#821). + + ``layout`` is not a language: it asks for whatever the active keyboard + layout points at, which is why this resolves fresh on every call rather + than once at startup. A layout that cannot be read, or that maps to nothing + in the catalogue, degrades to ``auto`` -- the same answer the engine would + have reached on its own, and better than refusing to dictate. + """ + if language != LANGUAGE_FOLLOWS_LAYOUT: + return language + + try: + resolved = language_for_active_layout(SUPPORTED_LANGUAGES) + except ( + OSError, + FileNotFoundError, + subprocess.SubprocessError, + ValueError, + TypeError, + KeyError, + AttributeError, + ) as exc: + logger.debug(f"Keyboard layout lookup failed: {exc}") + return "auto" + + return resolved or "auto" + + # ALSA error handler to suppress warnings during PyAudio initialization def _setup_alsa_error_handler(): """Set up an error handler to suppress ALSA warnings.""" @@ -1086,7 +1123,11 @@ class SpeechRecognitionManager: """ self.engine = engine self.model_size = model_size - self.language = normalize_language_for_engine(engine, language) + # The stored preference, which may be the "layout" sentinel. self.language + # is always something an engine can consume, re-resolved from this at the + # start of every dictation while the sentinel is in force (#821). + self.language_preference = language + self.language = normalize_language_for_engine(engine, resolve_language_preference(language)) self.stop_sound_guard_ms = kwargs.get("stop_sound_guard_ms", 200) self.state = RecognitionState.IDLE self.audio_thread = None @@ -1226,6 +1267,14 @@ class SpeechRecognitionManager: else: raise ValueError(f"Unsupported speech recognition engine: {self.engine}") + #: Class-level default so a manager built without ``__init__`` still answers + #: "not following the layout" instead of raising on the dictation path. + language_preference: str = "auto" + + #: Logged once rather than per dictation, so an unsupported pairing does not + #: spam the log on every hotkey press. + _warned_follow_layout_unsupported: bool = False + # Every live field ``reconfigure()`` can write. A failed engine switch # restores this set so dictation matches the persisted config, not the # attempted-but-unsaved Settings values. @@ -1233,6 +1282,7 @@ class SpeechRecognitionManager: "engine", "model_size", "language", + "language_preference", "vad_sensitivity", "silence_timeout", "audio_device_index", @@ -1265,6 +1315,91 @@ class SpeechRecognitionManager: for name, value in previous.items(): setattr(self, name, value) + #: Engines that read the language per utterance instead of baking it into the + #: loaded model, so changing it costs nothing. + _PER_UTTERANCE_LANGUAGE_ENGINES = ("whisper", "whisper_cpp", "faster_whisper", "remote_api") + + def _can_relanguage_without_reload(self) -> bool: + """Whether a language change can skip re-initialising the engine (#821). + + The whisper family passes the language to each transcribe call, so the + loaded model does not care. VOSK loads a different model per language and + Parakeet never consumes a catalog language at all, so both keep the + restart. An English-only whisper model is excluded as well: it cannot + honour a non-English layout, and only a reload can fetch one that can. + """ + if self.engine not in self._PER_UTTERANCE_LANGUAGE_ENGINES: + return False + if self.engine == "remote_api": + # The language rides along in the request body; nothing is loaded here. + return True + return not is_english_only_model(self.model_size) + + def _refresh_language_from_layout(self) -> None: + """Re-point the engine at the active keyboard layout before dictating. + + A no-op unless the user asked for the follow mode. Failures are logged + and swallowed: dictating in the previous language beats not dictating. + """ + if self.language_preference != LANGUAGE_FOLLOWS_LAYOUT: + return + + target = normalize_language_for_engine( + self.engine, resolve_language_preference(LANGUAGE_FOLLOWS_LAYOUT) + ) + if target == self.language: + return + + if self._can_relanguage_without_reload(): + logger.info(f"Keyboard layout changed: dictating in {target}") + self.language = target + self.command_processor.set_language(target) + if self._faster_whisper_engine is not None: + # Read per utterance by the engine's own _normalize_language. + self._faster_whisper_engine.language = target + return + + # English-only whisper.cpp weights cannot honour a non-English layout. + # Swap to the multilingual sibling and keep the follow-mode sentinel; + # handing ``target`` to reconfigure() would store it as the new + # preference and disarm the mode after a single switch. + # is_model_downloaded / _multilingual_sibling are the whisper.cpp + # catalog: do not consult them for whisper / faster_whisper .en ids. + if self.engine == "whisper_cpp": + sibling = _multilingual_sibling(self.model_size) + # No surprise fetch on the hotkey: only swap if the sibling is already downloaded. + if sibling != self.model_size and is_model_downloaded(sibling): + try: + self.reconfigure(model_size=sibling, language=LANGUAGE_FOLLOWS_LAYOUT) + return + except ( + RuntimeError, + ValueError, + FileNotFoundError, + OSError, + ImportError, + TypeError, + AttributeError, + ChecksumError, + ): + logger.error( + "Failed to swap to multilingual sibling %r for layout " + "language %s (engine=%s, model=%s)", + sibling, + target, + self.engine, + self.model_size, + exc_info=True, + ) + + if not self._warned_follow_layout_unsupported: + self._warned_follow_layout_unsupported = True + logger.warning( + f"Cannot follow the keyboard layout to {target}: the loaded " + f"{self.model_size!r} model cannot transcribe it. Pick a " + "multilingual model in Settings to use this mode." + ) + def _init_vosk(self): """Initialize the VOSK speech recognition engine.""" # VOSK doesn't support auto-detect, so fall back to en-us for "auto" @@ -3217,6 +3352,10 @@ class SpeechRecognitionManager: ) return False + # Last thing before listening, so the language matches the layout the + # user is typing in right now rather than the one they had at startup. + self._refresh_language_from_layout() + logger.info("Starting speech recognition") self._update_state(RecognitionState.LISTENING) @@ -3863,10 +4002,18 @@ class SpeechRecognitionManager: # Whisper needs to know the language for transcription # VOSK needs to load a different model for the new language language_changed = False - if language is not None and language != self.language: - self.language = language - language_changed = True - restart_needed = True + if language is not None: + # Settings hands over the preference, which may be the sentinel. Keep + # it so the follow mode survives, and resolve what the engine gets. + self.language_preference = language + resolved = resolve_language_preference(language) + if resolved != self.language: + self.language = resolved + language_changed = True + # Only the engines that bake the language into the loaded model + # need the restart; see _can_relanguage_without_reload. + if not self._can_relanguage_without_reload(): + restart_needed = True # Parakeet never consumes catalog language. Apply after engine/language # updates so switching TO parakeet also clears a leftover code. @@ -3879,6 +4026,9 @@ class SpeechRecognitionManager: # Command aliases follow the stored language (auto after Parakeet). if language_changed: self.command_processor.set_language(self.language) + if not restart_needed and self._faster_whisper_engine is not None: + # No re-init will rebuild it, so update the live engine in place. + self._faster_whisper_engine.language = self.language # Update VOSK specific params if provided if vad_sensitivity is not None: diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index c8da1a4..8c498ed 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -69,7 +69,9 @@ def normalize_sound_effect_tone(tone: Any) -> str: DEFAULT_CONFIG = { "speech_recognition": { # Changed section name "engine": "whisper_cpp", # whisper_cpp is default; vosk/whisper/parakeet/faster_whisper/remote_api are optional - "language": "auto", # Auto-detect language (Whisper/whisper.cpp only) + # "auto" detects per utterance, "layout" follows the active keyboard + # layout (#821), anything else pins one catalog language. + "language": "auto", "model_size": "tiny", # Current model size (for backward compatibility) "vosk_model_size": "small", # Default model for VOSK engine "whisper_model_size": "tiny", # Default model for Whisper engine @@ -189,6 +191,9 @@ def resolve_whispercpp_variant(saved_model: str, pinned_variant: str, language_i specialization. True leftover specializations (turbo, versioned large, quantized multilingual) are still honoured. """ + # The "layout" sentinel is deliberately not in the catalog, so it reads as + # non-English here and derives the multilingual variant -- which is what a + # mode that can land on any language needs (#821). language_is_english = SUPPORTED_LANGUAGES.get(language_id, {}).get("whisper") == "en" pinned = pinned_variant.lower() if isinstance(pinned_variant, str) else "" diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 5127b65..3fd3bf5 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -56,6 +56,10 @@ from ..utils.model_choice import ( size_for_priority, ) from ..utils.paths import models_dir # noqa: E402 +from ..utils.system_language import ( # noqa: E402 + LANGUAGE_FOLLOWS_LAYOUT, + language_for_active_layout, +) from ..utils.update_checker import ( # noqa: E402 DEFAULT_UPDATE_CHANNEL, ReleaseInfo, @@ -277,6 +281,45 @@ def _model_specialization_display_name(model_name: str) -> str: return "Standard multilingual" +#: Engines that can honour the follow-layout mode. VOSK loads a different model +#: per language, so following a layout would stall the hotkey on a model reload; +#: Parakeet never consumes a catalog language at all. Module level, not a class +#: attribute, so tests driving the methods against a Mock still reach it. +FOLLOW_LAYOUT_ENGINES = ("whisper", "whisper_cpp", "faster_whisper", "remote_api") + + +def _is_following_layout(dialog: Any) -> bool: + """Whether the follow-keyboard-layout mode is on for this dialog (#821). + + A module function reading a cached bool, deliberately not a method. Several + tests drive the dialog methods unbound against a ``Mock``, where a method + call on self resolves to a truthy Mock attribute and would invert every + guard below. The ``is True`` keeps a Mock attribute from passing either. + """ + return getattr(dialog, "_follow_layout_active", False) is True + + +def _language_for_whispercpp_variant(dialog: Any) -> Optional[str]: + """Language that drives .en vs multilingual whisper.cpp derivation. + + Follow-the-layout can land on any language, so it must keep multilingual + weights even when the picker is showing the current English layout. Using + that display as the derivation language selects ``.en``, Settings then + refuses the mode as English-only, and the next auto-apply persists a + concrete language and disarms follow (#821). + """ + if _is_following_layout(dialog): + return LANGUAGE_FOLLOWS_LAYOUT + # Load path: the saved mode is follow, but the switch has not been synced + # into ``_follow_layout_active`` yet. ``is True`` so a Mock stays out. + if ( + getattr(dialog, "_follow_layout_saved", False) is True + and getattr(dialog, "_initializing", False) is True + ): + return LANGUAGE_FOLLOWS_LAYOUT + return dialog.language_combo.get_active_id() or dialog.language + + def _language_is_english(language_id: str) -> bool: """Return whether a language ID maps to English for Whisper.""" return SUPPORTED_LANGUAGES.get(language_id, {}).get("whisper") == "en" @@ -640,6 +683,13 @@ MODEL_SPECIALIZATION_TOOLTIP = ( "Choose Standard multilingual unless you specifically need English-only accuracy, " "lower-memory quantized models, Turbo speed, or a legacy large model." ) +FOLLOW_LAYOUT_TOOLTIP = ( + "Re-reads your active keyboard layout at the start of every dictation and " + "picks the matching language. Useful if you switch layouts to work in more " + "than one language. Needs a multilingual model; not available for VOSK, " + "which loads a separate model per language." +) + LANGUAGE_TOOLTIP = ( "Choose the language you dictate in. Search the list. Picking a language " "other than English switches off an English-only model." @@ -2367,6 +2417,11 @@ class SettingsDialog(Gtk.Dialog): # user returns to Whisper/cpp. Do not store engine-coerced fallbacks here. self._last_non_parakeet_language = None self._engine_for_language_memory = None + # Set from the saved config in _load_and_apply_settings; the switch itself + # is the source of truth once the dialog is built (#821). + self._follow_layout_saved = False + # Cached so Mock-driven tests and early calls read a real bool. + self._follow_layout_active = False self._applying_settings = False # Flag to prevent recursive settings application self._advanced_prompt_dirty = False self._about_release_url = "" @@ -3550,6 +3605,22 @@ class SettingsDialog(Gtk.Dialog): self.language_row.set_tooltip_text(LANGUAGE_TOOLTIP) group.add_row(self.language_row) + # Follow-the-layout is a mode, not a language, so it is a switch over the + # picker rather than another entry inside it (#821). On, the picker is + # disabled and shows what the layout currently resolves to; off, whatever + # it shows becomes the pinned language again. + self.follow_layout_switch = Gtk.Switch() + self.follow_layout_switch.set_valign(Gtk.Align.CENTER) + self.follow_layout_switch.set_tooltip_text(FOLLOW_LAYOUT_TOOLTIP) + self.follow_layout_row = PreferenceRow( + title="Follow keyboard layout", + subtitle="Dictate in the language of whichever layout you are typing in", + widget=self.follow_layout_switch, + keywords=("keyboard", "layout", "follow", "bilingual", "language", "switch"), + ) + self.follow_layout_row.set_tooltip_text(FOLLOW_LAYOUT_TOOLTIP) + group.add_row(self.follow_layout_row) + # Lives inside the revealer built above, so the Advanced switch slides it out. self.advanced_box.pack_start(group, False, False, 0) @@ -3645,6 +3716,7 @@ class SettingsDialog(Gtk.Dialog): self.model_combo.connect("changed", self._on_model_changed) self.model_variant_combo.connect("changed", self._on_model_variant_changed) self.language_combo.connect("changed", self._on_language_changed) + self.follow_layout_switch.connect("notify::active", self._on_follow_layout_toggled) def _on_remote_api_settings_changed(self, widget): """Handle remote API URL/Key/endpoint changes.""" @@ -5361,6 +5433,12 @@ class SettingsDialog(Gtk.Dialog): settings = self._get_current_settings() self.current_engine = settings["engine"] self.language = settings["language"] + # The picker holds languages, not modes. Remember the mode separately and + # seed the picker with whatever the layout resolves to right now, so the + # disabled row reads as a live answer instead of going blank (#821). + self._follow_layout_saved = self.language == LANGUAGE_FOLLOWS_LAYOUT + if self._follow_layout_saved: + self.language = language_for_active_layout(SUPPORTED_LANGUAGES) or "auto" if self.current_engine == "parakeet": # Config may already be auto; keep any non-auto leftover as restore seed. self._last_non_parakeet_language = ( @@ -5464,6 +5542,10 @@ class SettingsDialog(Gtk.Dialog): # Populate model and language options for the selected engine self._populate_model_options() self._sync_language_options_for_selected_model(self.language) + self.follow_layout_switch.set_active( + self._follow_layout_saved and self._follow_layout_supported() + ) + self._sync_follow_layout_controls() # Set spin button values self.vad_spin.set_value(self.current_vad) @@ -5546,7 +5628,7 @@ class SettingsDialog(Gtk.Dialog): def _get_recommended_whispercpp_model_for_language(self) -> tuple[str, str]: """Return the recommended whisper.cpp variant for the selected language.""" recommended_model, reason = get_recommended_whispercpp_model() - language_id = self.language_combo.get_active_id() or self.language + language_id = _language_for_whispercpp_variant(self) return _recommended_whispercpp_variant_for_language( recommended_model, reason, @@ -5565,7 +5647,7 @@ class SettingsDialog(Gtk.Dialog): if recommended in WHISPERCPP_MODEL_INFO and is_whispercpp_model_downloaded(recommended): return None - language_id = self.language_combo.get_active_id() or self.language + language_id = _language_for_whispercpp_variant(self) wants_english = _language_is_english(language_id) recommended_mb = WHISPERCPP_MODEL_INFO.get(recommended, {}).get("size_mb", 0) @@ -5593,7 +5675,7 @@ class SettingsDialog(Gtk.Dialog): def _get_default_whispercpp_variant_for_size(self, model_size: str) -> Optional[str]: """Return the default specialization for a user-selected size.""" - language_id = self.language_combo.get_active_id() or self.language + language_id = _language_for_whispercpp_variant(self) return _default_whispercpp_variant_for_size(model_size, language_id) def _on_apply_recommendation(self, _button: Any) -> None: @@ -5701,6 +5783,7 @@ class SettingsDialog(Gtk.Dialog): finally: self._processing_language_change = False + self._sync_follow_layout_controls() self._update_language_warning() self._update_model_picker_tooltips() @@ -5831,7 +5914,7 @@ class SettingsDialog(Gtk.Dialog): specialization: it is re-derived from the language currently in the combo. """ pinned = self.config_manager.get_model_variant_for_engine("whisper_cpp") - language_id = self.language_combo.get_active_id() or self.language + language_id = _language_for_whispercpp_variant(self) return resolve_whispercpp_variant(saved_model_for_engine, pinned, language_id) def _populate_whispercpp_model_options(self, saved_model_for_engine: str): @@ -5886,11 +5969,13 @@ class SettingsDialog(Gtk.Dialog): ) self.model_variant_combo.append(model_name, display_text) - language_id = self.language_combo.get_active_id() or self.language + language_id = _language_for_whispercpp_variant(self) model_to_set = selected_model if selected_model in variants else None if model_to_set: # Bare size ids double as multilingual variants; retarget for language # so English picks .en instead of leaving Standard multilingual stuck. + # Follow mode uses the layout sentinel here so an English *display* + # does not retarget onto .en and then refuse the mode. model_to_set = _whispercpp_variant_for_language(model_to_set, language_id) if model_to_set not in variants: model_to_set = None @@ -6332,6 +6417,14 @@ class SettingsDialog(Gtk.Dialog): "⚠ This model only understands English." ) self.language_warning.show() + elif _is_following_layout(self): + resolved = language_for_active_layout(SUPPORTED_LANGUAGES) + resolved_name = SUPPORTED_LANGUAGES.get(resolved, {}).get("name") if resolved else None + self.language_warning.set_markup( + "Follows your active keyboard layout. " + f"Right now that is {resolved_name or 'auto-detect'}." + ) + self.language_warning.show() elif lang_info.get("warning"): lang_name = lang_info.get("name", "This language") self.language_warning.set_markup( @@ -6342,6 +6435,55 @@ class SettingsDialog(Gtk.Dialog): self.language_warning.set_markup("") self.language_warning.hide() + def _follow_layout_supported(self, engine: Optional[str] = None) -> bool: + """Whether the selected engine can follow the keyboard layout.""" + return (engine or self._get_selected_engine()) in FOLLOW_LAYOUT_ENGINES + + def _sync_follow_layout_controls(self) -> None: + """Enable/disable the switch for the engine, and the picker for the mode.""" + # An English-only model has no non-English weights, so it cannot honour + # a layout that points anywhere else. Refusing the mode here is honest; + # the alternative is a switch that silently does nothing. + supported = self._follow_layout_supported() and not ( + self._is_selected_whispercpp_model_english_only() + ) + self.follow_layout_row.set_sensitive(supported) + + if not supported and self.follow_layout_switch.get_active(): + # An engine or model that cannot run the mode must not keep it on. + self._processing_language_change = True + try: + self.follow_layout_switch.set_active(False) + finally: + self._processing_language_change = False + + self._follow_layout_active = bool(self.follow_layout_switch.get_active()) and supported + active = self._follow_layout_active + # The picker still shows a language while the mode drives it -- the one + # the layout resolves to right now -- so it reads as a live readout + # rather than going blank, and turning the mode off pins what is shown. + self.language_row.set_sensitive(not active) + + def _on_follow_layout_toggled(self, *_args: Any) -> None: + """Apply the follow mode, or pin whatever the picker shows when it goes off.""" + if self._initializing or self._applying_settings or self._processing_language_change: + return + + if self.follow_layout_switch.get_active(): + # Show what it resolves to, so the greyed-out picker stays meaningful. + resolved = language_for_active_layout(SUPPORTED_LANGUAGES) + if resolved: + self._processing_language_change = True + try: + self._set_combo_active_id_or_first(self.language_combo, resolved) + finally: + self._processing_language_change = False + + self._sync_follow_layout_controls() + self._update_language_warning() + self._refresh_simple_readout() + self._auto_apply_settings() + def _on_language_changed(self, widget: Any) -> None: """Handle language selection change. @@ -6421,6 +6563,27 @@ class SettingsDialog(Gtk.Dialog): pin and dropped when Advanced is auto. """ language = self.language_combo.get_active_id() or self.language or "auto" + + if _is_following_layout(self): + # Neither simple question can say "follow the layout", and writing an + # answer here would let the next simple edit silently replace the mode + # with whatever those two controls happen to read. Refresh only the + # part that does not depend on the language and leave them alone. + self._simple_syncing = True + try: + recommended, _ = self._get_recommended_whispercpp_model_for_language() + current = self._get_selected_whispercpp_model() + self.simple_priority_combo.set_active_id( + priority_for_size( + get_whispercpp_model_size(recommended), + get_whispercpp_model_size(current), + ) + ) + finally: + self._simple_syncing = False + self._update_simple_visibility() + return + is_auto = language == "auto" stored_second = self.config_manager.get("speech_recognition", "simple_second_language", "") @@ -7079,6 +7242,8 @@ class SettingsDialog(Gtk.Dialog): model_size = model_id.lower() if model_id else "small" if engine == "parakeet": language = "auto" + elif _is_following_layout(self): + language = LANGUAGE_FOLLOWS_LAYOUT else: language = language_id if language_id else self._default_language_for_engine(engine) diff --git a/src/vocalinux/utils/system_language.py b/src/vocalinux/utils/system_language.py index 2e3b3fe..550ce87 100644 --- a/src/vocalinux/utils/system_language.py +++ b/src/vocalinux/utils/system_language.py @@ -11,6 +11,7 @@ so the interface language is a weak signal for *spoken* language. What someone types in tracks what they say far more closely. """ +import ast import logging import os import re @@ -42,6 +43,26 @@ _LAYOUT_TO_LANGUAGE = { "vn": "vi", } +# Common GNOME IBus engine ids. Looked up case-insensitively. +# Mozc ships mozc-jp by default; mozc-us / mozc-on / mozc-off are documented +# extra engine names for US-layout and composition-mode variants -- still Japanese. +_IBUS_ENGINE_TO_LANGUAGE = { + "mozc-jp": "ja", + "mozc-us": "ja", + "mozc-on": "ja", + "mozc-off": "ja", + "anthy": "ja", + "kkc": "ja", + "skk": "ja", + "libpinyin": "zh", + "pinyin": "zh", + "rime": "zh", + "chewing": "zh", + "hangul": "ko", + "unikey": "vi", + "bamboo": "vi", +} + # Locale territory codes that pick a specific catalogue entry. _LOCALE_TO_LANGUAGE = { "en_in": "en-in", @@ -53,6 +74,12 @@ _LOCALE_TO_LANGUAGE = { _LOCALE_ENV_VARS = ("LC_ALL", "LC_MESSAGES", "LANGUAGE", "LANG") +#: Stored in ``speech_recognition.language`` to mean "whatever the active +#: keyboard layout points at", resolved fresh at the start of every dictation +#: (#821). It is never handed to an engine; ``resolve_language_preference`` +#: turns it into a real catalogue id or ``auto`` first. +LANGUAGE_FOLLOWS_LAYOUT = "layout" + def _normalise(value: str) -> str: return value.strip().lower().replace("-", "_") @@ -200,3 +227,196 @@ def detect_system_language( logger.info("Could not work out a language from the system") return None + + +# GNOME exposes the configured input sources as ``(type, id)`` pairs. The id +# carries an optional variant, so "fr+oss" is the oss variant of the fr layout. +_INPUT_SOURCES_SCHEMA = "org.gnome.desktop.input-sources" + + +def _gsettings_get(key: str) -> Optional[str]: + """Return a raw gsettings value from the input-sources schema, or None.""" + try: + result = subprocess.run( + ["gsettings", "get", _INPUT_SOURCES_SCHEMA, key], + capture_output=True, + text=True, + timeout=2, + env=host_env(), + ) + except (FileNotFoundError, OSError, subprocess.SubprocessError) as exc: + logger.debug(f"Could not query gsettings {key}: {exc}") + return None + + if result.returncode != 0: + logger.debug(f"gsettings {key} exited with {result.returncode}") + return None + + return result.stdout.strip() or None + + +def _parse_sources(value: Optional[str]) -> list: + """Parse a gsettings input-source list, tolerating the ``@a(ss)`` prefix.""" + text = (value or "").strip() + if text.startswith("@a(ss) "): + text = text[len("@a(ss) ") :] + try: + parsed = ast.literal_eval(text) + except (ValueError, SyntaxError): + logger.debug(f"Could not parse an input-source list: {value!r}") + return [] + return list(parsed) if isinstance(parsed, (list, tuple)) else [] + + +def _xkb_layout_from_source(source: object) -> Optional[str]: + """Return the xkb layout id from one ``(type, id)`` input-source pair.""" + if not isinstance(source, (list, tuple)) or len(source) != 2: + return None + source_type, source_id = source + if not isinstance(source_type, str) or not isinstance(source_id, str) or not source_id: + return None + if source_type != "xkb": + # An IBus engine (mozc-jp, pinyin) is not an xkb layout, and its id is + # not something _LAYOUT_TO_LANGUAGE can map. + logger.debug(f"Active input source {source_id!r} is {source_type}, not xkb") + return None + return source_id.split("+", 1)[0].strip() or None + + +def _source_at_index(parsed: list, current: str) -> Optional[object]: + """Return the ``(type, id)`` pair the ``current`` index points at.""" + if not parsed: + return None + + # "uint32 1" is what gsettings prints, so anchor on the trailing integer: + # a bare \d+ search matches the 32 in "uint32" and every lookup reads as + # index 32. + match = re.search(r"(\d+)\s*$", (current or "").strip()) + index = int(match.group(1)) if match else 0 + if index >= len(parsed): + # A stale index outlives a removed source. GNOME falls back to the first + # entry in that case and so do we, rather than reporting nothing. + logger.debug(f"Input source index {index} is out of range, using the first") + index = 0 + + return parsed[index] + + +def _parse_active_source(sources: str, current: str) -> Optional[str]: + """Pick the active xkb layout out of the ``sources`` list and ``current`` index.""" + return _xkb_layout_from_source(_source_at_index(_parse_sources(sources), current)) + + +def _active_input_source() -> Optional[object]: + """Return GNOME's active ``(type, id)`` input source, or None if unreadable. + + None means "no answer available" -- not GNOME, or the keys are missing. An + answer that is an IBus engine rather than an xkb layout is still an answer, + and callers must not treat it as absence. + """ + mru = _parse_sources(_gsettings_get("mru-sources")) + if mru: + # Trust the MRU answer even when it is an unmappable IBus engine; the + # index fallback would only supply a different, wronger layout. + return mru[0] + + sources = _gsettings_get("sources") + if not sources: + return None + return _source_at_index(_parse_sources(sources), _gsettings_get("current") or "") + + +def detect_active_keyboard_layout() -> Optional[str]: + """Return the layout currently being typed with, or None (#821). + + ``detect_keyboard_layout`` answers a different question: it reports the + *configured* primary layout, which never changes as the user switches + between them. Someone who runs "fr,us" to work in two languages gets "fr" + from it forever, so it cannot drive a follow-the-layout mode. + + ``mru-sources`` is read first because GNOME moves the source actually in use + to the front of it. The ``current`` index is not equivalent and goes stale -- + it has been observed still reading 0 after a switch to the second layout -- + which is why the IBus injection path already trusts the MRU list for this + same question (#497, #738). ``current`` is kept only for a session that has + never switched and therefore has an empty MRU list. + + GNOME is the only desktop asked here: under Wayland the compositor owns the + keyboard and exposes nothing standard, and the X11 XKB group is not readable + through the python-xlib we already depend on. Elsewhere this returns None and + ``language_for_active_layout`` falls back to the configured layout. + """ + return _xkb_layout_from_source(_active_input_source()) + + +def _language_for_ibus_source(source: object, supported: set[str] | dict) -> Optional[str]: + """Map a GNOME IBus ``(type, id)`` pair onto a catalogue language.""" + if not isinstance(source, (list, tuple)) or len(source) != 2: + return None + source_type, source_id = source + if not isinstance(source_type, str) or source_type.lower() != "ibus": + return None + if not isinstance(source_id, str) or not source_id: + return None + + engine = source_id.strip().lower() + mapped = _IBUS_ENGINE_TO_LANGUAGE.get(engine) + if mapped and mapped in supported: + return mapped + + if engine.startswith("m17n:"): + lang = engine.split(":", 2)[1] + if lang: + return _language_for_locale(lang, supported) or _language_for_layout(lang, supported) + return None + + # Only *-jp / *-kr: a generic last-token lookup maps mozc-us and foo-us + # onto English via the xkb "us" layout, which is the IBus fallback bug. + if "-" in engine: + suffix = engine.rsplit("-", 1)[-1] + if suffix in ("jp", "kr"): + return _language_for_layout(suffix, supported) + return None + + +def language_for_active_layout(supported: set[str] | dict) -> Optional[str]: + """Return the catalogue language for the active layout, or None (#821). + + Falls back to the configured primary layout where the active one cannot be + read, which is what a single-layout install reports anyway. An active IBus + engine is mapped when we know it; otherwise auto-detect is used rather than + borrowing the configured xkb layout. + """ + active = _active_input_source() + if active is not None: + layout = _xkb_layout_from_source(active) + if layout is not None: + return _language_for_layout_logged(layout, supported, "active keyboard layout") + language = _language_for_ibus_source(active, supported) + if language: + logger.debug(f"Language {language} taken from IBus engine {active!r}") + return language + # An active IBus engine we cannot map is still a real answer. Falling + # back to the configured xkb layout here would pin English for someone + # typing Japanese, so say nothing and let the engine auto-detect. + logger.debug(f"Active input source {active!r} is not an xkb layout; using auto-detect") + return None + + # Nothing readable -- not GNOME. The configured primary layout is the only + # signal left, and is what a single-layout install reports anyway. + layout = detect_keyboard_layout() + if not layout: + return None + return _language_for_layout_logged(layout, supported, "configured keyboard layout") + + +def _language_for_layout_logged( + layout: str, supported: set[str] | dict, source: str +) -> Optional[str]: + """Map a layout onto the catalogue, saying where it came from.""" + language = _language_for_layout(layout, supported) + if not language: + logger.debug(f"No catalogue language for {source} {layout!r}") + return None + logger.debug(f"Language {language} taken from {source} {layout!r}") + return language diff --git a/tests/test_follow_keyboard_layout.py b/tests/test_follow_keyboard_layout.py new file mode 100644 index 0000000..8b25f8c --- /dev/null +++ b/tests/test_follow_keyboard_layout.py @@ -0,0 +1,353 @@ +"""The follow-keyboard-layout mode is a switch over the language picker (#821).""" + +from __future__ import annotations + +import importlib +import subprocess +import sys +from collections.abc import Iterator +from typing import Any +from unittest.mock import MagicMock, Mock, patch + +import pytest + +import vocalinux.ui +from vocalinux.utils.model_checksums import ChecksumError + + +@pytest.fixture(scope="module") +def settings_dialog() -> Iterator[Any]: + """Import settings_dialog with real base classes for its GTK subclasses. + + Same approach as tests/test_simple_model_settings.py; both sys.modules and the + package attribute are restored so later tests patching the module by name do + not reach a second module object (#686). + """ + repository = sys.modules["gi.repository"] + bases = {name: type(name, (), {}) for name in ("Box", "ListBoxRow", "Dialog")} + saved_module = sys.modules.pop("vocalinux.ui.settings_dialog", None) + saved_attribute = getattr(vocalinux.ui, "settings_dialog", None) + try: + with patch.object(repository, "Gtk", MagicMock(**bases)): + module = importlib.import_module("vocalinux.ui.settings_dialog") + yield module + finally: + sys.modules.pop("vocalinux.ui.settings_dialog", None) + if saved_module is not None: + sys.modules["vocalinux.ui.settings_dialog"] = saved_module + if saved_attribute is not None: + vocalinux.ui.settings_dialog = saved_attribute + elif hasattr(vocalinux.ui, "settings_dialog"): + del vocalinux.ui.settings_dialog + + +@pytest.fixture +def dialog_class(settings_dialog: Any) -> type[Any]: + return settings_dialog.SettingsDialog + + +def test_mode_is_off_for_a_bare_mock_dialog(settings_dialog: Any) -> None: + """The guard must not read truthy on a Mock, or it inverts every caller. + + Written as a module function precisely because ``self._method()`` on a Mock + dialog returns a truthy Mock; an earlier version of this guard silently + skipped the whole simple-mode sync for that reason. + """ + assert settings_dialog._is_following_layout(Mock()) is False + + +@pytest.mark.parametrize( + "value,expected", + [(True, True), (False, False), (None, False), ("yes", False), (1, False)], +) +def test_mode_reads_the_cached_bool_strictly( + settings_dialog: Any, value: object, expected: bool +) -> None: + dialog = Mock() + dialog._follow_layout_active = value + assert settings_dialog._is_following_layout(dialog) is expected + + +def test_mode_is_off_when_the_attribute_is_absent(settings_dialog: Any) -> None: + class Bare: + pass + + assert settings_dialog._is_following_layout(Bare()) is False + + +def _controls_stub(switch_on: bool, supported: bool, english_only: bool = False) -> Mock: + dialog = Mock() + dialog.follow_layout_switch.get_active.return_value = switch_on + dialog._follow_layout_supported.return_value = supported + dialog._is_selected_whispercpp_model_english_only.return_value = english_only + dialog._processing_language_change = False + return dialog + + +def test_turning_the_mode_on_disables_the_language_picker(dialog_class: type[Any]) -> None: + """The picker still shows a language, but the mode owns it.""" + dialog = _controls_stub(switch_on=True, supported=True) + + dialog_class._sync_follow_layout_controls(dialog) + + assert dialog._follow_layout_active is True + dialog.follow_layout_row.set_sensitive.assert_called_once_with(True) + dialog.language_row.set_sensitive.assert_called_once_with(False) + + +def test_leaving_the_mode_off_leaves_the_picker_usable(dialog_class: type[Any]) -> None: + dialog = _controls_stub(switch_on=False, supported=True) + + dialog_class._sync_follow_layout_controls(dialog) + + assert dialog._follow_layout_active is False + dialog.language_row.set_sensitive.assert_called_once_with(True) + + +def test_an_engine_that_cannot_follow_turns_the_mode_off(dialog_class: type[Any]) -> None: + """VOSK loads a model per language; Parakeet ignores the picker entirely.""" + dialog = _controls_stub(switch_on=True, supported=False) + + dialog_class._sync_follow_layout_controls(dialog) + + dialog.follow_layout_row.set_sensitive.assert_called_once_with(False) + dialog.follow_layout_switch.set_active.assert_called_once_with(False) + assert dialog._follow_layout_active is False + # The picker is the only way to choose a language on those engines. + dialog.language_row.set_sensitive.assert_called_once_with(True) + + +@pytest.mark.parametrize( + "engine,expected", + [ + ("whisper_cpp", True), + ("whisper", True), + ("faster_whisper", True), + ("remote_api", True), + ("vosk", False), + ("parakeet", False), + ], +) +def test_engine_support(dialog_class: type[Any], engine: str, expected: bool) -> None: + dialog = Mock() + assert dialog_class._follow_layout_supported(dialog, engine) is expected + + +def test_an_english_only_model_cannot_follow_a_layout(dialog_class: type[Any]) -> None: + """small.en has no non-English weights, so the mode would silently do nothing.""" + dialog = _controls_stub(switch_on=True, supported=True, english_only=True) + + dialog_class._sync_follow_layout_controls(dialog) + + dialog.follow_layout_row.set_sensitive.assert_called_once_with(False) + dialog.follow_layout_switch.set_active.assert_called_once_with(False) + assert dialog._follow_layout_active is False + + +# --- The mode must stay armed across dictations ----------------------------- + + +def _manager_stub(model_size: str, language: str = "en-us") -> Any: + from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager + + manager = SpeechRecognitionManager.__new__(SpeechRecognitionManager) + manager.engine = "whisper_cpp" + manager.model_size = model_size + manager.language = language + manager.language_preference = "layout" + manager._faster_whisper_engine = None + manager.command_processor = MagicMock() + manager.reconfigure = MagicMock() + return manager + + +@pytest.mark.parametrize( + "error", + [ + OSError("gsettings gone"), + FileNotFoundError("gsettings"), + subprocess.SubprocessError(), + ValueError("bad parse"), + TypeError("unexpected type"), + KeyError("missing"), + AttributeError("no attr"), + ], +) +def test_layout_lookup_failure_falls_back_to_auto(error: Exception) -> None: + """A layout helper error must not block dictation.""" + import vocalinux.speech_recognition.recognition_manager as rm + + with patch.object(rm, "language_for_active_layout", side_effect=error): + assert rm.resolve_language_preference("layout") == "auto" + + +def test_following_a_layout_keeps_the_mode_armed() -> None: + """A switch must not turn the sentinel into a concrete language. + + reconfigure() stores whatever language it is handed as the new preference, + so calling it here would disarm the mode after a single switch and the + remaining dictations would silently stop following anything. + """ + import vocalinux.speech_recognition.recognition_manager as rm + + manager = _manager_stub("small") + with patch.object(rm, "language_for_active_layout", return_value="fr"): + manager._refresh_language_from_layout() + assert manager.language == "fr" + assert manager.language_preference == "layout" + + # And again, from the other direction. + with patch.object(rm, "language_for_active_layout", return_value="en-us"): + manager._refresh_language_from_layout() + assert manager.language == "en-us" + assert manager.language_preference == "layout" + manager.reconfigure.assert_not_called() + + +def test_an_english_only_model_reloads_the_multilingual_sibling() -> None: + """English-only weights cannot honour a non-English layout; swap the sibling. + + reconfigure() stores whatever language it is handed as the new preference, + so the resolved language must never be passed in -- only the layout sentinel + and the multilingual model id. + """ + import vocalinux.speech_recognition.recognition_manager as rm + + manager = _manager_stub("small.en") + with patch.object(rm, "language_for_active_layout", return_value="fr"): + with patch.object(rm, "is_model_downloaded", return_value=True): + manager._refresh_language_from_layout() + + manager.reconfigure.assert_called_once_with(model_size="small", language="layout") + assert manager.language_preference == "layout" + assert "fr" not in manager.reconfigure.call_args.args + + +def test_an_english_only_model_skips_reload_when_already_english() -> None: + """The loaded English-only model already matches an English layout.""" + import vocalinux.speech_recognition.recognition_manager as rm + + manager = _manager_stub("small.en") + with patch.object(rm, "language_for_active_layout", return_value="en-us"): + manager._refresh_language_from_layout() + + manager.reconfigure.assert_not_called() + assert manager.language == "en-us" + assert manager.language_preference == "layout" + + +@pytest.mark.parametrize( + "error", + [ + ImportError("missing backend"), + TypeError("bad args"), + AttributeError("no attr"), + ChecksumError("digest mismatch"), + ], +) +def test_sibling_swap_reconfigure_failure_does_not_abort_dictation( + error: Exception, +) -> None: + """A sibling-swap reconfigure error must not block dictation or disarm follow.""" + import vocalinux.speech_recognition.recognition_manager as rm + + manager = _manager_stub("small.en") + manager.reconfigure.side_effect = error + with patch.object(rm, "language_for_active_layout", return_value="fr"): + with patch.object(rm, "is_model_downloaded", return_value=True): + manager._refresh_language_from_layout() + + manager.reconfigure.assert_called_once_with(model_size="small", language="layout") + assert manager.language_preference == "layout" + + +def test_an_english_only_model_skips_reload_when_sibling_is_missing() -> None: + """Do not fetch the multilingual sibling on the dictation hotkey.""" + import vocalinux.speech_recognition.recognition_manager as rm + + manager = _manager_stub("small.en") + with patch.object(rm, "language_for_active_layout", return_value="fr"): + with patch.object(rm, "is_model_downloaded", return_value=False): + manager._refresh_language_from_layout() + + manager.reconfigure.assert_not_called() + assert manager.language_preference == "layout" + + +@pytest.mark.parametrize("engine", ["whisper", "faster_whisper"]) +def test_english_only_non_whispercpp_does_not_use_whispercpp_catalog(engine: str) -> None: + """Follow is offered for these engines; the whisper.cpp download check is the wrong catalog.""" + import vocalinux.speech_recognition.recognition_manager as rm + + manager = _manager_stub("small.en") + manager.engine = engine + with patch.object(rm, "language_for_active_layout", return_value="fr"): + with patch.object(rm, "is_model_downloaded", return_value=True) as downloaded: + manager._refresh_language_from_layout() + + downloaded.assert_not_called() + manager.reconfigure.assert_not_called() + assert manager.language_preference == "layout" + + +def _variant_dialog( + language: str = "en-us", + follow_active: bool = False, + follow_saved: bool = False, + initializing: bool = False, +) -> Mock: + dialog = Mock() + dialog.language = language + dialog.language_combo.get_active_id.return_value = language + dialog.config_manager.get_model_variant_for_engine.return_value = "" + dialog._follow_layout_active = follow_active + dialog._follow_layout_saved = follow_saved + dialog._initializing = initializing + return dialog + + +def test_follow_mode_does_not_derive_english_only_from_the_displayed_layout( + settings_dialog: Any, dialog_class: type[Any] +) -> None: + """The picker shows the current layout while follow is on, often English. + + Deriving .en from that display would make Settings refuse the mode as + English-only, and the next auto-apply would persist a concrete language. + """ + dialog = _variant_dialog(follow_active=True) + + assert dialog_class._resolve_saved_whispercpp_variant(dialog, "tiny") == "tiny" + assert dialog_class._get_default_whispercpp_variant_for_size(dialog, "tiny") == "tiny" + with patch.object( + settings_dialog, + "get_recommended_whispercpp_model", + return_value=("tiny", "test"), + ): + recommended, _ = dialog_class._get_recommended_whispercpp_model_for_language(dialog) + assert not settings_dialog.is_english_only_whispercpp_model(recommended) + + +def test_follow_mode_load_path_keeps_multilingual_before_the_switch_is_synced( + settings_dialog: Any, dialog_class: type[Any] +) -> None: + """On load the saved sentinel is follow, but _follow_layout_active is still false.""" + dialog = _variant_dialog(follow_saved=True, initializing=True) + + assert dialog_class._resolve_saved_whispercpp_variant(dialog, "tiny") == "tiny" + assert dialog_class._get_default_whispercpp_variant_for_size(dialog, "tiny") == "tiny" + + +def test_follow_off_still_derives_english_only_from_an_english_picker( + settings_dialog: Any, dialog_class: type[Any] +) -> None: + dialog = _variant_dialog() + + assert dialog_class._resolve_saved_whispercpp_variant(dialog, "tiny") == "tiny.en" + assert dialog_class._get_default_whispercpp_variant_for_size(dialog, "tiny") == "tiny.en" + + +def test_layout_sentinel_does_not_retarget_a_bare_size_onto_english_only( + settings_dialog: Any, +) -> None: + assert settings_dialog._whispercpp_variant_for_language("tiny", "layout") == "tiny" + assert settings_dialog._whispercpp_variant_for_language("tiny.en", "layout") == "tiny" diff --git a/tests/test_system_language.py b/tests/test_system_language.py index a470deb..2736c56 100644 --- a/tests/test_system_language.py +++ b/tests/test_system_language.py @@ -172,3 +172,184 @@ def test_nb_no_locale_maps_to_norwegian(): ) assert detected == "no" + + +# --- Follow the active keyboard layout (#821) ------------------------------- + + +@pytest.mark.parametrize( + "sources,current,expected", + [ + # The bilingual case from the issue: same config, different active index. + ("[('xkb', 'us'), ('xkb', 'fr')]", "uint32 0", "us"), + ("[('xkb', 'us'), ('xkb', 'fr')]", "uint32 1", "fr"), + # A variant is still that layout. + ("[('xkb', 'fr+oss')]", "uint32 0", "fr"), + # "uint32 1" must not read as index 32 via a bare \d+ search. + ("[('xkb', 'us'), ('xkb', 'pl')]", "uint32 1", "pl"), + # A stale index outlives a removed source; GNOME falls back to the first. + ("[('xkb', 'us'), ('xkb', 'fr')]", "uint32 9", "us"), + ("[('xkb', 'us')]", "", "us"), + # An IBus engine is not an xkb layout and maps to nothing here. + ("[('ibus', 'mozc-jp')]", "uint32 0", None), + ("@a(ss) []", "uint32 0", None), + ("", "uint32 0", None), + ], +) +def test_active_source_parsing(sources, current, expected): + assert sl._parse_active_source(sources, current) == expected + + +def test_active_layout_reads_gsettings(): + with patch.object(sl, "_gsettings_get", side_effect=["[('xkb', 'fr')]", "uint32 0"]): + assert sl.detect_active_keyboard_layout() == "fr" + + +def test_active_layout_is_none_without_gsettings(): + with patch.object(sl, "_gsettings_get", return_value=None): + assert sl.detect_active_keyboard_layout() is None + + +def test_active_layout_language_maps_onto_catalogue(): + with patch.object(sl, "_active_input_source", return_value=("xkb", "fr")): + assert sl.language_for_active_layout(SUPPORTED_LANGUAGES) == "fr" + + +def test_active_layout_falls_back_to_configured_layout(): + """Outside GNOME there is no readable active source; the configured one remains.""" + with patch.object(sl, "_active_input_source", return_value=None): + with patch.object(sl, "detect_keyboard_layout", return_value="pl"): + assert sl.language_for_active_layout(SUPPORTED_LANGUAGES) == "pl" + + +def test_active_layout_language_is_none_when_unmappable(): + with patch.object(sl, "_active_input_source", return_value=("xkb", "xyz")): + assert sl.language_for_active_layout(SUPPORTED_LANGUAGES) is None + + +def test_active_layout_language_is_none_without_any_layout(): + with patch.object(sl, "_active_input_source", return_value=None): + with patch.object(sl, "detect_keyboard_layout", return_value=None): + assert sl.language_for_active_layout(SUPPORTED_LANGUAGES) is None + + +def test_an_active_ibus_engine_does_not_borrow_the_configured_layout(): + """Typing Japanese through mozc-jp must not resolve to the us layout's English.""" + with patch.object(sl, "_active_input_source", return_value=("ibus", "mozc-jp")): + with patch.object(sl, "detect_keyboard_layout", return_value="us") as configured: + assert sl.language_for_active_layout(SUPPORTED_LANGUAGES) == "ja" + configured.assert_not_called() + + +def test_an_unmapped_ibus_engine_does_not_borrow_the_configured_layout(): + """An unknown IBus engine is still a real answer; leave it to auto-detect.""" + with patch.object(sl, "_active_input_source", return_value=("ibus", "foo-engine")): + with patch.object(sl, "detect_keyboard_layout", return_value="us") as configured: + assert sl.language_for_active_layout(SUPPORTED_LANGUAGES) is None + configured.assert_not_called() + + +@pytest.mark.parametrize("engine_id", ["foo-us", "foo-gb", "foo-fr", "foo-de"]) +def test_hyphen_suffix_does_not_false_map_layout_tokens(engine_id): + """rsplit last token is not a language code: mozc-us is Japanese, not xkb us.""" + with patch.object(sl, "_active_input_source", return_value=("ibus", engine_id)): + with patch.object(sl, "detect_keyboard_layout", return_value="us") as configured: + assert sl.language_for_active_layout(SUPPORTED_LANGUAGES) is None + configured.assert_not_called() + + +def test_ibus_mozc_us_maps_to_japanese(): + """Mozc's documented US-layout engine is still a Japanese IME.""" + with patch.object(sl, "_active_input_source", return_value=("ibus", "mozc-us")): + with patch.object(sl, "detect_keyboard_layout", return_value="us") as configured: + assert sl.language_for_active_layout(SUPPORTED_LANGUAGES) == "ja" + configured.assert_not_called() + + +def test_ibus_hyphen_jp_and_kr_suffixes_still_map(): + with patch.object(sl, "_active_input_source", return_value=("ibus", "foo-jp")): + assert sl.language_for_active_layout(SUPPORTED_LANGUAGES) == "ja" + with patch.object(sl, "_active_input_source", return_value=("ibus", "foo-kr")): + assert sl.language_for_active_layout(SUPPORTED_LANGUAGES) == "ko" + + +def test_ibus_m17n_hi_itrans_maps_to_hindi(): + with patch.object(sl, "_active_input_source", return_value=("ibus", "m17n:hi:itrans")): + assert sl.language_for_active_layout(SUPPORTED_LANGUAGES) == "hi" + + +def test_ibus_libpinyin_maps_to_chinese(): + with patch.object(sl, "_active_input_source", return_value=("ibus", "libpinyin")): + assert sl.language_for_active_layout(SUPPORTED_LANGUAGES) == "zh" + + +def test_active_source_prefers_mru_and_falls_back_to_the_index(): + values = { + "mru-sources": "[('ibus', 'mozc-jp'), ('xkb', 'us')]", + "sources": "[('xkb', 'us')]", + "current": "uint32 0", + } + with patch.object(sl, "_gsettings_get", side_effect=lambda key: values[key]): + # The MRU answer wins even though it is unmappable. + assert tuple(sl._active_input_source()) == ("ibus", "mozc-jp") + + values["mru-sources"] = "@a(ss) []" + with patch.object(sl, "_gsettings_get", side_effect=lambda key: values[key]): + assert tuple(sl._active_input_source()) == ("xkb", "us") + + +def test_no_active_source_without_gsettings(): + with patch.object(sl, "_gsettings_get", return_value=None): + assert sl._active_input_source() is None + + +def test_active_layout_prefers_mru_over_stale_current_index(): + """GNOME's "current" index goes stale; the MRU list is what tracks a switch. + + Observed live: after switching to the second layout, mru-sources led with + ('xkb', 'fr') while current still read 0, which points at 'us' (#497, #738). + """ + values = { + "mru-sources": "[('xkb', 'fr'), ('xkb', 'us')]", + "sources": "[('xkb', 'us'), ('xkb', 'fr')]", + "current": "uint32 0", + } + with patch.object(sl, "_gsettings_get", side_effect=lambda key: values[key]): + assert sl.detect_active_keyboard_layout() == "fr" + + +def test_active_layout_falls_back_to_index_when_mru_is_empty(): + """A session that has never switched has no MRU list to go on.""" + values = { + "mru-sources": "@a(ss) []", + "sources": "[('xkb', 'us'), ('xkb', 'pl')]", + "current": "uint32 1", + } + with patch.object(sl, "_gsettings_get", side_effect=lambda key: values[key]): + assert sl.detect_active_keyboard_layout() == "pl" + + +def test_active_layout_reports_nothing_for_an_ibus_mru_entry(): + """An IBus engine at the front of the MRU list must not fall through.""" + values = { + "mru-sources": "[('ibus', 'mozc-jp'), ('xkb', 'us')]", + "sources": "[('xkb', 'us')]", + "current": "uint32 0", + } + with patch.object(sl, "_gsettings_get", side_effect=lambda key: values[key]): + assert sl.detect_active_keyboard_layout() is None + + +@pytest.mark.parametrize( + "value,expected", + [ + ("[('xkb', 'us'), ('xkb', 'fr')]", [("xkb", "us"), ("xkb", "fr")]), + ("@a(ss) [('xkb', 'us')]", [("xkb", "us")]), + ("@a(ss) []", []), + ("not a list", []), + ("", []), + (None, []), + ], +) +def test_source_list_parsing(value, expected): + assert sl._parse_sources(value) == expected