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