Skip to content

feat: Rychlé přepínání jazyka v tray a snapshot relace - #37

Merged
DavidHruby1 merged 1 commit into
mainfrom
feat/tray-language-switching
Sep 30, 2026
Merged

DavidHruby1 merged 1 commit into
mainfrom
feat/tray-language-switching

Conversation

@DavidHruby1

Copy link
Copy Markdown
Owner

MR Overview

Overview

Oblast Co se změnilo Dopad
Konfigurace stt_language_favorites se ukládá jako validované JSON pole vedle dosavadního aktivního stt_language. Starší INI a .env aktivní jazyky zůstávají dostupné; poškozený seznam nesmaže aktivní hodnotu.
Settings Aktivní jazyk a oblíbené kódy lze vybrat, přidat, editovat a odebrat. Apply/OK uloží obě hodnoty, Cancel zahodí neuložený draft.
Tray Radio submenu Language nabízí Auto, Czech, English, oblíbené a starý aktivní kód. Přepnutí se uloží okamžitě; chyba zápisu vrátí viditelnou i živou volbu.
Relace main.py kopíruje konfiguraci před začátkem nahrávání. STT, fallback, rewrite i post-type key nedostanou pozdější změny z tray.
Souběh Settings Konfigurační tray ovladače jsou během dialogu vypnuté. Apply nepřepíše mezitím změněnou hodnotu; Enabled a Exit fungují dál.

Kontext a pozadí

P0 z plánu požaduje rychlé přepínání jazyka bez profilů. Dříve byl aktivní jazyk jen volný text v Settings, seznam oblíbených neexistoval a worker dostával živou konfiguraci až při zastavení nahrávání. stt.transcribe(audio_wav, config) a rewrite(text, config) si ponechávají signatury i výběr providerů. Neznámé starší aktivní kódy zůstávají viditelné i tehdy, když by neprošly novým pravidlem pro oblíbené položky.

Stavové hodnoty a důležité proměnné

Hodnota / proměnná Umístění Význam
stt_language AppConfig Jediná aktivní hodnota; "" znamená Auto.
stt_language_favorites AppConfig / INI Seřazený seznam dalších voleb, JSON pole; nevybírá aktivní jazyk podle pořadí.
_session_config _TrayApp Kopie konfigurace vlastněná aktuální relací od startu po terminální výsledek nebo zahození.
_settings_dlg _TrayApp Určuje, kdy jsou konfigurační tray akce vypnuté.

Detail změn

1. normalize_language_code: kanonický kód

src/config.py · normalize_language_code

code = value.strip().lower()
if len(code) > _MAX_LANGUAGE_CODE_LENGTH or not _LANGUAGE_CODE.fullmatch(code):
    raise ValueError("Use a language code such as cs, en, or pt-br (up to 32 characters)")
return code

Jak to funguje: Řetězec se ořízne, převede na malá písmena a ověří proti omezené syntaxi. Co je změna: Nové favority lze zadat normalizovaně místo původního volného textu bez validace. Proč je to tak: Jedna pravidelná reprezentace předchází duplicitám; neověřuje schopnosti konkrétního STT providera.

2. normalize_language_favorites: pořadí a limity

src/config.py · normalize_language_favorites

if not isinstance(codes, list) or len(codes) > _MAX_LANGUAGE_FAVORITES:
    raise ValueError("Choose no more than 32 favorite languages")
result: list[str] = []
for value in codes:
    code = normalize_language_code(value)
    if code not in result:
        result.append(code)

Jak to funguje: Pole má maximálně 32 vstupů a kódy se normalizují a deduplikují při zachování prvního výskytu. Co je změna: Přibyla doménová validace nové uložené kolekce. Proč je to tak: QSettings variantní seznam neurčuje bezpečný přenos ani požadované pořadí a tvar.

3. language_choices: nabídka i pro staré instalace

src/config.py · language_choices

codes = [code for code, _ in LANGUAGE_OPTIONS]
for code in [*favorites, active]:
    if code and code not in codes:
        codes.append(code)
return [(code, _LANGUAGE_LABELS.get(code, code)) for code in codes]

Jak to funguje: Auto, cs a en jsou vždy první, za nimi unikátní oblíbené a případný dosavadní aktivní kód. Co je změna: Settings a tray mají stejný zdroj nabídky. Proč je to tak: Staré INI nebo .env jazyky nesmějí po migraci zmizet a neznámý jazyk se zobrazuje svým kódem.

4. load_config: dekódování bez ztráty aktivní volby

src/config.py · load_config

if key == "stt_language_favorites":
    try:
        cfg.stt_language_favorites = normalize_language_favorites(json.loads(val))
    except (TypeError, ValueError):
        log.warning("Invalid favorite language settings; using an empty list")
    continue

Jak to funguje: Chybějící klíč ponechá []; nesprávný JSON, typ nebo položka zahodí jen kolekci. Platný aktivní kód se na konci loaderu normalizuje, neplatný starší hint zůstane viditelný beze změny. Co je změna: Scalar-only loader nyní dekóduje jedno JSON pole. Proč je to tak: Poškozené oblíbené položky nesmějí zahodit existující aktivní jazyk ani způsobit tichý výběr jiného.

if cfg.stt_language:
    try:
        cfg.stt_language = normalize_language_code(cfg.stt_language)
    except ValueError:
        pass  # Keep a legacy value visible until the user explicitly changes it.

5. save_config: atomický zápis JSON seznamu

src/config.py · save_config

if f.name == "stt_language_favorites":
    pending.setValue(
        f.name, json.dumps(normalize_language_favorites(cfg.stt_language_favorites))
    )
else:
    pending.setValue(f.name, getattr(cfg, f.name))

Jak to funguje: Seznam se validuje a serializuje v existujícím dočasném INI před os.replace; aktivní hodnota se zapisuje v témž nahrazení. Co je změna: Nové pole nepoužívá implicitní Qt převod Python listu. Proč je to tak: Formát na disku musí být stabilní a jedna konfigurace musí udržet aktivní volbu a seznam pohromadě.

6. validate_config: kontrola oblíbených položek

src/config.py · validate_config

try:
    normalize_language_favorites(cfg.stt_language_favorites)
except ValueError as e:
    issues.append(ConfigValidationIssue(f"Favorite STT languages are invalid: {e}", 1))

Jak to funguje: Chyba nové kolekce míří na STT tab, ostatní provider kontroly zůstaly. Co je změna: Validace Settings nyní zahrnuje oblíbené kódy, ale neblokuje starý aktivní hint s odlišnou syntaxí. Proč je to tak: Zabrání zapsání neplatného seznamu bez rozbití upgradu uživatelů.

7. import_from_env: normalizace platného backfillu

src/config.py · import_from_env

if field_name == "stt_language":
    try:
        val = normalize_language_code(val)
    except ValueError:
        pass  # Keep a legacy hint visible rather than silently dropping it.
setattr(cfg, field_name, val)

Jak to funguje: Backfill se provede jen pro prázdné pole; platné STT_LANGUAGE přejde na kanonický kód, historický neplatný hint zůstane. Co je změna: Jazyk z .env sdílí normalizaci s INI. Proč je to tak: Zobrazená aktivní volba se má shodovat s vestavěnými kódy, aniž se smaže staré nastavení.

8. _TrayApp.__init__: vlastník snapshotu

src/main.py · _TrayApp.__init__

self._worker: _WorkerThread | None = None
self._session_config: AppConfig | None = None
self._settings_dlg: SettingsDialog | None = None

Jak to funguje: Qt vlastník drží právě jednu aktuální kopii nebo None. Co je změna: Předtím byl jen živý self._config předaný workeru. Proč je to tak: Jedna relace potřebuje neměnné vstupy od startu nahrávání do výsledku.

9. _add_choice_submenu: dialogový zámek voleb

src/main.py · _add_choice_submenu

submenu = QMenu(title, self._menu)
submenu.setEnabled(self._settings_dlg is None)
self._menu.addMenu(submenu)

Jak to funguje: Nové i po Apply přestavěné choice submenu se deaktivuje při otevřeném Settings. Co je změna: Původní tray nabídky zůstávaly přístupné během nested exec(). Proč je to tak: Jejich uložené změny by mohl otevřený dialog následně přepsat svým draftem.

10. _rebuild_menu: jazyk a rewrite při otevřeném Settings

src/main.py · _rebuild_menu

self._add_choice_submenu(
    "Language",
    language_choices(c.stt_language, c.stt_language_favorites),
    c.stt_language,
    self._set_language,
)

Jak to funguje: Menu vykreslí radio volbu podle aktivního kódu; AI Rewrite checkbox se navíc deaktivuje při otevřeném Settings, zatímco Enabled/Exit zůstanou. Co je změna: Přibylo Language submenu a uzamčení konfiguračních prvků. Proč je to tak: Rychlá volba nemá vyžadovat Settings ani závodit s jeho Apply.

rewrite_checkbox = self._add_persistent_checkbox(
    "AI Rewrite", c.llm_enabled, self._toggle_rewrite
)
rewrite_checkbox.setEnabled(self._settings_dlg is None)

11. _start_recording: kopie před audiosession

src/main.py · _start_recording

self._session_config = config = copy.deepcopy(self._config)
try:
    device_id = resolve_device(config.audio_device_id, config.audio_device_name)
    self._recorder = AudioRecorder(device_id=device_id)
    self._recorder.rms_threshold = config.rms_threshold

Jak to funguje: Kopie vzniká ještě před otevřením zařízení, při start failure se vyčistí. Co je změna: Dříve se konfigurace předala workeru až z živého self._config po nahrávání. Proč je to tak: Změna jazyka či rewrite během nahrávání nesmí změnit rozběhnutý diktát.

12. _finalize_recording: jediná kopie pro workera

src/main.py · _finalize_recording

if not audio_wav:
    self._session_config = None
    self._apply_state(TrayState.IDLE)
    return

assert self._session_config is not None
self._cancel_event.clear()
self._worker = _WorkerThread(audio_wav, self._session_config, self._cancel_event, self)

Jak to funguje: Nevyužitý/selhaný capture uvolní kopii; validní WAV jde workeru s konfigurací ze startu. Co je změna: Worker už nepřebírá současný živý stav. Proč je to tak: Fallback a rewrite musí dostat stejný jazyk i při pozdější tray změně.

13. _cancel_recording: discard kopie

src/main.py · _cancel_recording

self._recording_timer.stop()
self._recording = False
self._session_config = None
try:
    self._recorder.stop()

Jak to funguje: Disable/discard zastaví časovač a mikrofon a zruší konfigurační kopii bez workera. Co je změna: Nový vlastněný snapshot se při cancel uvolňuje. Proč je to tak: Příští diktát nesmí omylem zdědit konfiguraci zahozeného capture.

14. _on_worker_succeeded: post-key z aktuální relace

src/main.py · _on_worker_succeeded

config = self._session_config
self._session_config = None
if (
    not self._cancel_event.is_set()
    and self._enabled
    and not self._exiting
    and self._settings_dlg is None
):

Jak to funguje: Terminální úspěch odebere kopii; stávající cancel/disable/exit/settings guard zůstává a při injekci použije config.post_type_key. Co je změna: Post-key se dřív četl z živého self._config. Proč je to tak: Změna nastavení v tray během HTTP práce se nesmí projevit až při výstupu jiné relace.

assert config is not None
try:
    post_key = config.post_type_key
    type_text(result.text, post_key if post_key != "none" else None)

15. _on_worker_failed: uvolnění po neúspěchu

src/main.py · _on_worker_failed

def _on_worker_failed(self, error: Exception) -> None:
    self._session_config = None
    if isinstance(error, ScreamerError):

Jak to funguje: Před stávajícím reportováním chyby se odstraní snapshot. Co je změna: Přibyla úklidová větev nové relace. Proč je to tak: Neúspěšný worker nesmí držet konfiguraci pro další nahrávku.

16. _on_worker_cancelled: uvolnění po zrušení

src/main.py · _on_worker_cancelled

def _on_worker_cancelled(self) -> None:
    self._session_config = None
    self._apply_state(TrayState.IDLE)

Jak to funguje: Terminální cancelled signál vyčistí snapshot a vrátí tray do idle. Co je změna: Dříve neexistoval snapshot k uvolnění. Proč je to tak: Zrušený úkol nesmí ovlivnit následující relaci.

17. _set_language: save nebo návrat volby

src/main.py · _set_language

previous = self._config.stt_language
self._config.stt_language = code
try:
    save_config(self._config)
except Exception:
    self._config.stt_language = previous
    self._rebuild_menu()  # A radio click already changed the visible choice.
    self._on_error(AppError.KEY_STORAGE_FAILED)
    return

Jak to funguje: Tray aktualizuje aktivní kód a hned ukládá; při selhání vrátí live config i radio a ukáže chybu. Po úspěchu menu přestaví jen když to volající chce. Co je změna: Nový okamžitý jazykový setter. Proč je to tak: Uživateli se nesmí ukazovat neuložená volba jako trvalá.

18. _open_settings: uzamčení před nested loop

src/main.py · _open_settings

self._settings_dlg = dlg
# exec() runs a nested event loop; tray edits must not race the dialog draft.
self._rebuild_menu()

Jak to funguje: Po nastavení vlastníka dialogu se menu přestaví do disabled stavu ještě před exec; dosavadní finally reload po Apply/Cancel obnoví menu. Co je změna: Konfigurační tray akce nejsou během dialogu přístupné. Proč je to tak: Nested Qt loop umožňoval souběh draftu s rychlými tray změnami.

19. _exit: uvolnění snapshotu při shutdown

src/main.py · _exit

self._exiting = True
self._recording_timer.stop()
self._recording = False
self._session_config = None

Jak to funguje: Exit zahodí main-owned kopii a zachová existující čekání na pracovní vlákno. Co je změna: Nová relace má explicitní životnost i při odchodu. Proč je to tak: Snapshot nesmí zůstat vlastněný po konci aplikace.

20. _build_stt_tab: editor místo volného textu

src/settings_dialog.py · SettingsDialog._build_stt_tab

self._stt_lang = QComboBox()
form.addRow("Active language:", self._stt_lang)

self._stt_favorites = QListWidget()
self._stt_favorites.setMaximumHeight(120)
self._stt_favorites.itemSelectionChanged.connect(self._on_favorite_selected)
form.addRow("Favorite languages:", self._stt_favorites)

Jak to funguje: STT tab nabízí výběr aktivního kódu, seřazený seznam a vstup s Add/Edit/Remove tlačítky; provider/fallback formuláře zůstaly. Co je změna: Volný QLineEdit pro jazyk nahradil spravovaný seznam. Proč je to tak: Oblíbené hodnoty musí jít editovat odděleně od aktivního jazyka.

21. _refresh_language_choices: synchronizace návrhu

src/settings_dialog.py · SettingsDialog._refresh_language_choices

favorites = [
    self._stt_favorites.item(i).data(Qt.ItemDataRole.UserRole)
    for i in range(self._stt_favorites.count())
]
self._stt_lang.clear()
for code, label in language_choices(active, favorites):
    self._stt_lang.addItem(label, code)

Jak to funguje: Po úpravě seznamu znovu postaví výběr a nastaví požadovaný aktivní kód. Co je změna: Stav UI už není jediný textový vstup. Proč je to tak: Auto, vestavěné kódy a starý aktivní jazyk musejí zůstat viditelné i po úpravě favoritu.

22. _on_favorite_selected: načtení položky k editaci

src/settings_dialog.py · SettingsDialog._on_favorite_selected

item = self._stt_favorites.currentItem()
if item is not None:
    self._stt_favorite_input.setText(item.data(Qt.ItemDataRole.UserRole))

Jak to funguje: Výběr řádku naplní editační pole přes uložený kód, ne přes zobrazovací label. Co je změna: Nová interakce favoritu. Proč je to tak: Známý jazyk má čitelný název, ale pro editaci musí zůstat přesný provider kód.

23. _favorite_code: validace uživatelského vstupu

src/settings_dialog.py · SettingsDialog._favorite_code

try:
    return normalize_language_code(self._stt_favorite_input.text())
except ValueError as e:
    QMessageBox.warning(self, "Invalid Language", str(e))
    return None

Jak to funguje: Při chybné syntaxi zůstane draft a uživatel uvidí vysvětlení. Co je změna: Add/Edit mají jednotnou hranici validace. Proč je to tak: Neplatný hint nemá vstoupit do perzistentních favoritů.

24. _on_favorite_add: přidání bez duplicity

src/settings_dialog.py · SettingsDialog._on_favorite_add

if code in existing:
    QMessageBox.warning(self, "Invalid Language", "That language is already a favorite.")
    return
try:
    normalize_language_favorites([*existing, code])
except ValueError as e:
    QMessageBox.warning(self, "Invalid Language", str(e))
    return

Jak to funguje: Před přidáním se ověří duplicitní kód i limit, potom se uloží UserRole a obnoví výběr. Co je změna: Nová Add akce zůstává v pracovní kopii Settings do Apply/OK. Proč je to tak: Při chybě nemá dojít k tichému přeskládání nebo uložení neplatného seznamu.

label = dict(LANGUAGE_OPTIONS).get(code, code)
item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, code)
self._stt_favorites.addItem(item)
self._refresh_language_choices()

25. _on_favorite_edit: přejmenování zachovává aktivní kód

src/settings_dialog.py · SettingsDialog._on_favorite_edit

active = self._stt_lang.currentData()
item.setData(Qt.ItemDataRole.UserRole, code)
item.setText(dict(LANGUAGE_OPTIONS).get(code, code))
self._refresh_language_choices(code if active == old else active)

Jak to funguje: Edit odmítne kolizi s jiným favoritem; pokud byl editovaný kód aktivní, vybere se jeho nová hodnota. Co je změna: Nová edit akce mění draft bez zásahu do živého stavu. Proč je to tak: Přejmenování nesmí nechat neviditelný aktivní kód ani přeskočit na jiný jazyk.

if code != old and any(
    self._stt_favorites.item(i).data(Qt.ItemDataRole.UserRole) == code
    for i in range(self._stt_favorites.count())
):
    QMessageBox.warning(self, "Invalid Language", "That language is already a favorite.")
    return

26. _on_favorite_remove: bezpečný výchozí jazyk

src/settings_dialog.py · SettingsDialog._on_favorite_remove

code = self._stt_favorites.takeItem(row).data(Qt.ItemDataRole.UserRole)
self._stt_favorite_input.clear()
if active == code and code not in dict(LANGUAGE_OPTIONS):
    active = ""
self._refresh_language_choices(active)

Jak to funguje: Smazání aktivního doplňkového jazyka zvolí Auto, odstranění cs/en z favoritů zachová vestavěnou aktivní volbu. Co je změna: Nová Remove akce řízeně řeší ztrátu aktivní položky. Proč je to tak: Zmizelá hodnota nesmí dál figurovat jako volba favoritu.

27. _populate: načtení jazykového draftu

src/settings_dialog.py · SettingsDialog._populate

self._stt_favorites.clear()
for code in cfg.stt_language_favorites:
    item = QListWidgetItem(dict(LANGUAGE_OPTIONS).get(code, code))
    item.setData(Qt.ItemDataRole.UserRole, code)
    self._stt_favorites.addItem(item)
self._stt_favorite_input.clear()
self._refresh_language_choices(cfg.stt_language)

Jak to funguje: Reset/import/otevření vyplní draft z konfigurace; starý neznámý aktivní kód zůstane v combo. Co je změna: Původní jediný setText je nahrazen populací obou hodnot. Proč je to tak: UI musí odrážet uložené pořadí a aktivní nastavení bez automatického přidání do favoritů.

28. _collect: společné uložení aktivního kódu a seznamu

src/settings_dialog.py · SettingsDialog._collect

cfg.stt_language = self._stt_lang.currentData()
cfg.stt_language_favorites = [
    self._stt_favorites.item(i).data(Qt.ItemDataRole.UserRole)
    for i in range(self._stt_favorites.count())
]

Jak to funguje: Apply/OK sbírá datové kódy z combo a listu do kopie AppConfig; existující dialog následně validuje a atomicky ukládá. Co je změna: Namísto jediného volného textu ukládá dvojici hodnot. Proč je to tak: Jedno Apply musí mít konzistentní aktivní výběr i favority.

29. make_tray_app: fixture snapshotu

tests/test_tray_menu.py · make_tray_app

tray_app._settings_dlg = None
tray_app._session_config = None
tray_app._hotkey_restart_pending = False

Jak to funguje: Ručně vytvořený QObject fixture má stejný počáteční stav relace jako skutečná aplikace. Co je změna: Přibylo pole potřebné pro sloty worker výsledků. Proč je to tak: Regresní testy nepoužívají plný tray constructor.

30. test_five_minute_limit_processes_recorded_audio_once: worker argument

tests/test_tray_menu.py · TrayMenuTests.test_five_minute_limit_processes_recorded_audio_once

worker.assert_called_once_with(
    b"recorded audio", tray._session_config, tray._cancel_event, tray
)
self.assertIsNot(tray._session_config, tray._config)

Jak to funguje: Stávající limit test stále ověřuje jediný worker, nově s kopií. Co je změna: Očekávání argumentu již nepředpokládá živou konfiguraci. Proč je to tak: Limitní automatický stop musí dodržet stejné snapshot pravidlo jako ruční stop.

31. test_recording_start_failure_returns_to_idle: bez osiřelé relace

tests/test_tray_menu.py · TrayMenuTests.test_recording_start_failure_returns_to_idle

self.assertFalse(tray._recording)
self.assertIsNone(tray._session_config)
tray._apply_state.assert_called_once_with(TrayState.IDLE)

Jak to funguje: Simulovaný device error neponechá snapshot. Co je změna: Původní idle assertion nyní kontroluje i životnost nové kopie. Proč je to tak: Neúspěšný začátek nesmí přenést stav do příští relace.

32. test_session_freezes_language_rewrite_and_post_key_at_recording_start: dva momenty mutace

tests/test_tray_menu.py · TrayMenuTests.test_session_freezes_language_rewrite_and_post_key_at_recording_start

tray._config.stt_language = "en"
tray._config.llm_enabled = False
tray._config.post_type_key = "tab"
tray._finalize_recording()

Jak to funguje: Test mění live config po startu a znovu před výsledkem; worker dostane cs/aktivní rewrite a type_text enter. Co je změna: Nový regresní test hranice relace. Proč je to tak: Volba během capture nebo processing má platit až pro příští diktát.

33. test_worker_requests_use_frozen_language_for_fallback_and_rewrite: transport

tests/test_tray_menu.py · TrayMenuTests.test_worker_requests_use_frozen_language_for_fallback_and_rewrite

self.assertEqual([call[1]["data"]["language"] for call in requests[:2]], ["cs", "cs"])
self.assertEqual(
    requests[2][1]["json"]["messages"][0]["content"],
    "Clean dictation.\nThe speech language is cs.",
)

Jak to funguje: Simulovaný primární HTTP výpadek vynutí fallback a další worker s Auto ověří nepřítomnost language i rewrite hintu. Co je změna: Test skutečné STT/rewrite request skladby proti HTTP boundary. Proč je to tak: Pouhá kontrola AppConfig by neprokázala konzistenci primary/fallback a LLM.

self.assertNotIn("language", requests[0][1]["data"])
self.assertNotIn("language", requests[1][1]["data"])
self.assertEqual(requests[2][1]["json"]["messages"][0]["content"], "Clean dictation.")

34. fake_post: deterministický transport v request testu

tests/test_tray_menu.py · TrayMenuTests.test_worker_requests_use_frozen_language_for_fallback_and_rewrite.fake_post

if "primary.test" in url:
    raise httpx.ConnectError("offline")
if "audio/transcriptions" in url:
    return httpx.Response(200, json={"text": "raw text"})

Jak to funguje: Jedna vnější HTTP hranice vrací explicitní STT/LLM odpovědi a uloží payloady. Co je změna: Nový lokální test double, ne změna produkčního fallbacku. Proč je to tak: Test má být deterministický bez provider credentials.

35. test_settings_keeps_tray_config_controls_disabled_through_apply: nested loop

tests/test_tray_menu.py · TrayMenuTests.test_settings_keeps_tray_config_controls_disabled_through_apply

self.assertEqual(seen[:2], [(False, False, False, False, False, True, True)] * 2)
self.assertEqual(seen[2], (True, True, True, True, True, True, True))

Jak to funguje: Měří menu před Apply, po přestavbě během dialogu a po jeho zavření. Co je změna: Nové pokrytí disabled tray akcí. Proč je to tak: Přestavba menu po Apply nesmí znovu povolit jiný konfigurační zápis.

36. inspect_menu: skutečné stavy QWidgetAction

tests/test_tray_menu.py · TrayMenuTests.test_settings_keeps_tray_config_controls_disabled_through_apply.inspect_menu

actions = {}
for action in tray._menu.actions():
    widget = action.defaultWidget() if hasattr(action, "defaultWidget") else None
    actions[widget.text() if widget is not None else action.text()] = action

Jak to funguje: Pomocný callback přečte názvy QAction i widgetových checkboxů. Co je změna: Nová testovací observace skutečného Qt menu. Proč je to tak: Checkbox AI Rewrite není obyčejná menu QAction a musí se kontrolovat zvlášť.

37. during_dialog: Apply uvnitř exec

tests/test_tray_menu.py · TrayMenuTests.test_settings_keeps_tray_config_controls_disabled_through_apply.during_dialog

inspect_menu()
tray._sync_settings_from_disk()  # Settings Apply rebuilds the menu while exec runs.
inspect_menu()
return 0

Jak to funguje: Mock exec() vyvolá reload během otevřeného dialogu. Co je změna: Nový kontrolovaný nested-loop scénář. Proč je to tak: Obyčejný test po zavření dialogu by mezistav nezachytil.

38. test_language_submenu_shows_builtins_favorites_and_old_active_code: viditelná volba

tests/test_tray_menu.py · TrayMenuTests.test_language_submenu_shows_builtins_favorites_and_old_active_code

self.assertEqual(
    [radio.text() for radio in radios], ["Auto", "Czech", "English", "de", "zz"]
)
self.assertEqual([radio.isChecked() for radio in radios], [False] * 4 + [True])

Jak to funguje: Reálné radio widgets obsahují vestavěné, jeden extra a starý aktivní kód. Co je změna: Nový menu test místo kontroly konfiguračního seznamu samotného. Proč je to tak: Uživateli nesmí zmizet starý aktivní jazyk z rychlé nabídky.

39. test_language_tray_change_persists_and_next_session_uses_new_value: dvě relace

tests/test_tray_menu.py · TrayMenuTests.test_language_tray_change_persists_and_next_session_uses_new_value

select("Czech")
tray._start_recording()
select("English")
tray._finalize_recording()
self.assertEqual(worker.call_args.args[1].stt_language, "cs")

Jak to funguje: Kliknutí Auto se uloží, následně volba Czech spustí relaci, změna na English ovlivní až druhou. Co je změna: Nový test propojení tray widgetu, save a snapshotu. Proč je to tak: Požadavek není jen persistovat, ale také nezměnit aktuální diktát.

40. select: Qt radio interakce v testu relací

tests/test_tray_menu.py · TrayMenuTests.test_language_tray_change_persists_and_next_session_uses_new_value.select

menu = next(a.menu() for a in tray._menu.actions() if a.text() == "Language")
radio = next(
    a.defaultWidget() for a in menu.actions() if a.defaultWidget().text() == label
)
radio.setChecked(True)

Jak to funguje: Helper používá skutečný submenu widget místo přímého volání setteru. Co je změna: Nová testovací cesta uživatelského radio výběru. Proč je to tak: Právě toggled callback musí přenést jazyk do uložené konfigurace.

41. test_language_save_failure_restores_live_and_visible_selection: rollback

tests/test_tray_menu.py · TrayMenuTests.test_language_save_failure_restores_live_and_visible_selection

self.assertEqual(tray._config.stt_language, "cs")
self.assertEqual(
    [a.defaultWidget().text() for a in restored.actions() if a.defaultWidget().isChecked()],
    ["Czech"],
)
tray._on_error.assert_called_once_with(AppError.KEY_STORAGE_FAILED)

Jak to funguje: Simulovaná chyba save_config po kliknutí English ověří původní živý jazyk, radio i notifikaci. Co je změna: Nový chybový regresní test. Proč je to tak: Neuložená volba nesmí zůstat vizuálně označená.

42. test_successful_result_types_on_main_thread: očekávání session post-key

tests/test_tray_menu.py · TrayMenuTests.test_successful_result_types_on_main_thread

tray_app._config = AppConfig(post_type_key="enter")
tray_app._session_config = AppConfig(post_type_key="enter")
tray_app._enabled = True

Jak to funguje: Stávající test výsledného type_text nastaví platnou relaci. Co je změna: Příprava testu odpovídá novému zdroji post-key. Proč je to tak: Injekce po dokončení workera už nečte live config.

43. test_normalizes_order_and_rejects_invalid_entries: codec hranice

tests/test_config.py · LanguageFavoritesTests.test_normalizes_order_and_rejects_invalid_entries

self.assertEqual(
    normalize_language_favorites([" CS ", "pt-BR", "cs", "DE"]),
    ["cs", "pt-br", "de"],
)

Jak to funguje: Pokrývá stabilní normalizaci i samostatné chyby prázdného/chybného/příliš dlouhého kódu a nadlimitního seznamu. Co je změna: Nová doménová sada. Proč je to tak: Neplatné oblíbené hodnoty nesmějí vstoupit do menu ani INI.

for value in ("", "c!", "a" * 33, "en--us"):
    with self.subTest(value=value), self.assertRaises(ValueError):
        normalize_language_code(value)
with self.assertRaises(ValueError):
    normalize_language_favorites(["de"] * 33)

44. test_old_ini_and_malformed_list_preserve_active_language: migrace

tests/test_config.py · LanguageFavoritesTests.test_old_ini_and_malformed_list_preserve_active_language

settings.setValue("stt_language", "zh_CN")
settings.sync()
self.assertEqual(load_config().stt_language, "zh_CN")

Jak to funguje: Starý platný pt-BR se normalizuje, starší nestandardní zh_CN zůstane a tři poškozené JSON varianty vynulují pouze favorites. Co je změna: Nový migrační test nad skutečným INI. Proč je to tak: Upgrade nesmí vymazat dříve funkční jazyk.

45. test_favorites_and_active_language_round_trip_together: INI roundtrip

tests/test_config.py · LanguageFavoritesTests.test_favorites_and_active_language_round_trip_together

save_config(AppConfig(stt_language="cs", stt_language_favorites=[" DE ", "cs", "de"]))
restored = load_config()
self.assertEqual(restored.stt_language_favorites, ["de", "cs"])

Jak to funguje: Uloží active+favorites, načte kanonické pořadí a dalším zápisem vybere Auto bez ztráty favoritů. Co je změna: Nový persistence test. Proč je to tak: Obě hodnoty musí přežít restart v témže atomickém INI.

46. test_choices_include_old_or_env_language_without_adding_favorite: .env

tests/test_config.py · LanguageFavoritesTests.test_choices_include_old_or_env_language_without_adding_favorite

env_path.write_text("STT_LANGUAGE=PT-BR\n", encoding="utf-8")
with patch("src.config._env_path", return_value=str(env_path)):
    cfg = import_from_env(AppConfig())

Jak to funguje: Skutečný .env backfill je viditelný v language_choices a seznam zůstane prázdný. Co je změna: Nový důkaz kompatibility zdroje .env. Proč je to tak: Aktivní kód nesmí vyžadovat automatické přidání do favoritů.

47. test_settings_preserves_legacy_active_code_but_rejects_invalid_favorites: odlišná pravidla

tests/test_config.py · LanguageFavoritesTests.test_settings_preserves_legacy_active_code_but_rejects_invalid_favorites

issues = validate_config(cfg)
self.assertEqual([issue.tab_index for issue in issues], [1])
self.assertIn("Favorite STT languages", issues[0].message)

Jak to funguje: zh_CN zůstává jako legacy aktivní hodnota, zatímco -- ve favorites blokuje Settings. Co je změna: Nový test migračního pravidla. Proč je to tak: Nová syntaxe nemá retroaktivně rozbít nesouvisející Apply.

48. test_apply_saves_active_and_favorites_and_cancel_discards_later_edits: draft

tests/test_settings_dialog.py · LanguageSettingsTests.test_apply_saves_active_and_favorites_and_cancel_discards_later_edits

dlg._on_apply()
self.assertEqual(load_config().stt_language, "de")
self.assertEqual(load_config().stt_language_favorites, ["de"])

Jak to funguje: Po Apply přidá další neuložený favorit a Cancel ověří, že disk i původní konfigurace zůstaly u de. Co je změna: Nový Qt+disk test. Proč je to tak: Editace Settings nesmí mutovat live stav před Apply/OK.

dlg.reject()
self.assertEqual(load_config().stt_language, "de")
self.assertEqual(load_config().stt_language_favorites, ["de"])
self.assertEqual(cfg.stt_language_favorites, [])

49. test_ok_saves_active_language_and_favorites: potvrzení dialogu

tests/test_settings_dialog.py · LanguageSettingsTests.test_ok_saves_active_language_and_favorites

dlg.accept()
self.assertEqual(dlg.result(), QDialog.DialogCode.Accepted)
self.assertEqual(load_config().stt_language, "pt-br")

Jak to funguje: OK normalizuje nově přidaný pt-BR a perzistuje jej jako aktivní i oblíbený. Co je změna: Nové ověření druhé save cesty. Proč je to tak: Apply a OK musí mít stejnou persistence záruku.

50. test_remove_active_additional_favorite_selects_auto_but_builtin_stays: odstranění

tests/test_settings_dialog.py · LanguageSettingsTests.test_remove_active_additional_favorite_selects_auto_but_builtin_stays

dlg._on_favorite_remove()
self.assertEqual(dlg._stt_lang.currentData(), "")
self.assertEqual(dlg._stt_lang.findData("de"), -1)

Jak to funguje: Odebrání de přepne Auto, odebrání oblíbeného cs ponechá jeho vestavěnou nabídku a výběr. Co je změna: Nový Qt test obou větví. Proč je to tak: Vestavěné jazyky nejsou závislé na oblíbeném seznamu.

51. test_edit_preserves_selection_and_invalid_or_duplicate_input_is_rejected: úprava

tests/test_settings_dialog.py · LanguageSettingsTests.test_edit_preserves_selection_and_invalid_or_duplicate_input_is_rejected

dlg._stt_favorite_input.setText("pt-BR")
dlg._on_favorite_edit()
self.assertEqual(dlg._stt_lang.currentData(), "pt-br")

Jak to funguje: Edit aktivního favoritu zachová aktivní výběr pod novým kódem; neplatný ani kolizní edit se nepoužije. Co je změna: Nový Qt test hranice editoru. Proč je to tak: Neplatná úprava nesmí poškodit draft ani aktivní volbu.

52. test_unknown_saved_active_code_is_visible_without_becoming_a_favorite: legacy UI

tests/test_settings_dialog.py · LanguageSettingsTests.test_unknown_saved_active_code_is_visible_without_becoming_a_favorite

self.assertEqual(dlg._stt_lang.currentText(), "zh_CN")
self.assertEqual(dlg._stt_favorites.count(), 1)
dlg._collect()

Jak to funguje: Dialog vykreslí a zachová starý aktivní hint mimo oblíbený seznam. Co je změna: Nový migrační UI test. Proč je to tak: Přechod z volného pole na combo nesmí starou hodnotu smazat.

53. LANGUAGE_OPTIONS a AppConfig.stt_language_favorites: datový kontrakt

src/config.py · konstanty · AppConfig

LANGUAGE_OPTIONS: list[tuple[str, str]] = [("", "Auto"), ("cs", "Czech"), ("en", "English")]

Jak to funguje: Vestavěné volby zůstávají nezávislé na novém field(default_factory=list). Co je změna: Přibyla malá mapa štítků, regex a limity 32/32, nikoli provider katalog. Proč je to tak: Auto/cs/en musí být dosažitelné i na starém INI bez seznamu.

stt_language: str = ""
stt_language_favorites: list[str] = field(default_factory=list)
stt_custom_headers: str = ""

54. Dokumentace uživatelského toku a otevřeného gate

README.md · docs/IMPLEMENTATION.md · docs/IMPLEMENTATION-PLAN.md · 01-language.md

README vysvětluje UI; současný API/INI kontrakt je v IMPLEMENTATION.md. Plán označuje kód P0 jako implementovaný a manuální Windows ověření jako otevřené, nepředstírá plnou akceptaci. Tyto dokumentační změny nemají funkční runtime efekt.

Ověření

Provedené ověření

Před commitem 10538c7 na Linuxu s izolovaným Python prostředím a lokální knihovnou PortAudio (bez systémového sudo):

LD_LIBRARY_PATH=/tmp/opencode/portaudio/usr/lib/x86_64-linux-gnu QT_QPA_PLATFORM=offscreen /tmp/opencode/screamer-venv/bin/python -c 'import ctypes.util, runpy, sys; original = ctypes.util.find_library; ctypes.util.find_library = lambda name: "libportaudio.so.2" if name == "portaudio" else original(name); sys.argv = ["unittest", "discover", "-s", "tests", "-v"]; runpy.run_module("unittest", run_name="__main__")'
/tmp/opencode/screamer-venv/bin/python -m compileall src/ tests/ .github/scripts/
/tmp/opencode/screamer-venv/bin/python -c "import src; print('OK')"
/tmp/opencode/screamer-venv/bin/ruff check src/ tests/ .github/scripts/
/tmp/opencode/screamer-venv/bin/ruff format --check src/ tests/ .github/scripts/
git diff --check

Výsledek:

  • 175 testů OK, 4 Windows-only skip; compileall, import, Ruff a diff check prošly. V této publikační fázi se nic znovu netestovalo.
  • Windows balík, reálný tray, změna jazyka během dlouhé relace a reálný provider nebyly ručně ověřeny.

Coverage

Ověřeno zejména:

  • Staré INI, JSON poruchy, .env, normalizace, disk roundtrip a legacy aktivní hint.
  • Qt Apply/OK/Cancel, přidání/úprava/odebrání, tray radio a návrat při chybě uložení.
  • Frozen primary/fallback STT, rewrite hint, Auto a post-key prostřednictvím offscreen/HTTP testů.

Nasazení

Bez migrace schématu: stt_language zůstává scalar, nové stt_language_favorites je volitelné JSON pole ve stejném atomickém INI. Po merge má existující pipeline po úspěšném CI+CodeQL sestavit Windows ZIP a checksum; ruční Windows smoke test je stále otevřený gate podle plánu.

Kompatibilita / dependencies

PR feat/tray-language-switching → main; žádná závislost na dalším PR. Dosavadní signatury transcribe a rewrite zůstávají; starý neznámý aktivní jazyk lze stále zvolit. Požadovaný approving review a úspěšné CI nelze tímto PR obejít.

Data-flow diagram

Settings draft -> AppConfig (active + JSON favorites) -> atomic INI
tray Language -> save_config -> live AppConfig -> next recording snapshot
recording snapshot -> audio -> worker STT primary/fallback -> rewrite -> post-key

Jak číst tok: Aktivní kód se uchovává v live configu; snapshot vzniká před nahráváním a worker nedostává pozdější tray změny.

Reviewer focus

Při review má smysl zaměřit se hlavně na:

  • QSettings JSON roundtrip/migraci na Windows, rollback radio menu při selhání save a velikost Settings dialogu na běžné ploše.
  • Izolaci config snapshotu při dlouhé relaci a chování menu během nested Settings event loopu.

Out of scope

Tento MR záměrně neřeší:

  • Body P1-P3 roadmapy, zvláště Notepad insertion, recovery a historii. Neprohlašuje podporu všech provider jazyků ani inteligentní code switching.
  • Ruční test zabaleného Windows programu; jeho absence je otevřeně zaznamenána v implementačním plánu.

@DavidHruby1
DavidHruby1 merged commit 9f78dd0 into main Sep 30, 2026
8 checks passed
@DavidHruby1
DavidHruby1 deleted the feat/tray-language-switching branch September 30, 2026 13:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant