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