From 20e01d231c082d253ee0179afdf9e5057bf1d096 Mon Sep 17 00:00:00 2001 From: Ashija <81858000+AhegaoZKun@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:05:45 +0200 Subject: [PATCH 01/12] Fix README formatting by removing incomplete bullet Removed incomplete bullet point from requirements section. --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 37cf0df..2fd7352 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,6 @@ Adding a new term is simple. Edit the relevant `addon/BabelChat/Data/*.lua` file - **Linux Compositor** – Requires compositor to support layer-shell (GNOME doesn't) - **DeepL Free limit** — 500K chars/month (~10K messages). Paid plans available - **Outgoing messages** — copy → paste in WoW chat (by design, ToS compliance) -- ** ## Tech Stack From 85687c09106868475e63cc2508c03eb9a5ff7d9a Mon Sep 17 00:00:00 2001 From: Ashija <81858000+AhegaoZKun@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:39:50 +0200 Subject: [PATCH 02/12] Fixed an issue where the setup wizard on linux would not update the shown language when the dropdown box was selected to something other than russian. (It used to stay russian despite the selection.) Added a function to find the users locale and use that as the shown language first, fallsback to russian if the locale can't be read or doesn't map the three supported languages. --- app/main_gtk.py | 48 +++++++++++++++++++--- app/setup_wizard_gtk.py | 88 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 124 insertions(+), 12 deletions(-) diff --git a/app/main_gtk.py b/app/main_gtk.py index d8ff38c..585eb53 100644 --- a/app/main_gtk.py +++ b/app/main_gtk.py @@ -61,6 +61,30 @@ def _build_pipeline_config(config: AppConfig) -> PipelineConfig: ) +def _guess_locale_lang() -> str: + """Tries to find the user's locale from the OS. + + Falls back to RU if the locale can't be read + or doesn't map to a supported language. + """ + import locale + + for env_var in ("LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"): + val = os.environ.get(env_var, "") + if val: + code = val.split(".")[0].split("_")[0].upper() + if code in ("RU", "EN", "ES"): + return code + try: + loc = locale.getlocale()[0] or "" + code = loc.split("_")[0].upper() + if code in ("RU", "EN", "ES"): + return code + except Exception: # noqa: BLE001 + pass + return "RU" + + def main() -> int: load_dotenv() logging.basicConfig( @@ -72,20 +96,29 @@ def main() -> int: # Off unless asked for: it records every chat line in full. debug_log.configure(config.debug_capture_trace) - # The Qt entry point has always done this and the GTK one never did, so - # every Linux user got the default interface language regardless of what - # they picked in Settings — and the default is Russian. - tr.set_language(config.ui_language) - # First run: no config file yet, or no translation API configured → # run the setup wizard (its own blocking GTK loop) before normal startup. if not os.path.exists(CONFIG_FILE) or not any_configured(config.providers): + # Give the wizard itself a reasonable display language BEFORE it opens. + # It has no config yet to base this on (config.ui_language is just the + # RU default, not a real signal), so guess from the OS locale instead — + # the wizard's own on-screen text otherwise defaults to Russian for + # every first-run user regardless of their system language. + tr.set_language(_guess_locale_lang()) + from app.setup_wizard_gtk import run_setup_wizard config = run_setup_wizard(config) if config is None: # user closed the wizard without finishing return 0 + # The Qt entry point has always done this and the GTK one never did, so + # every Linux user got the default interface language regardless of what + # they picked — and the default is Russian. Must run AFTER the wizard: + # applying it first used the pre-wizard config and ignored the language + # the user had just chosen on first run. + tr.set_language(config.ui_language) + overlay = ChatOverlayGtk(config) # Reply translator (outgoing): default EN unless own language is EN. @@ -124,6 +157,9 @@ def _open_settings() -> None: def _on_saved(updated: AppConfig) -> None: # Apply live: rebuild pipeline config (channels/langs). pipeline.update_config(_build_pipeline_config(updated)) + # If this save is what FIRST configured a provider, this is the + # signal the startup failsafe was waiting on, start scanning now. + _start_pipeline_if_ready(updated) # Rebuild the reply translator so API key/priority changes take # effect without a restart. new_translator = TranslatorService.from_config(updated) @@ -192,7 +228,7 @@ def _toggle_translation(enabled: bool) -> None: except Exception: # noqa: BLE001 logging.exception("history load failed (continuing without it)") - pipeline.start() + _start_pipeline_if_ready(config) try: return overlay.run() except KeyboardInterrupt: diff --git a/app/setup_wizard_gtk.py b/app/setup_wizard_gtk.py index 68f6dd3..2765c71 100644 --- a/app/setup_wizard_gtk.py +++ b/app/setup_wizard_gtk.py @@ -43,7 +43,7 @@ class _WizardWindow(Gtk.ApplicationWindow): def __init__(self, app: Gtk.Application, config: AppConfig, result: dict) -> None: - super().__init__(application=app, title="BabelChat Setup") + super().__init__(application=app, title=tr("wizard.title")) self._config = config self._result = result # {"config": AppConfig|None} self.set_default_size(520, 480) @@ -51,10 +51,7 @@ def __init__(self, app: Gtk.Application, config: AppConfig, result: dict) -> Non self._stack = Gtk.Stack() self._stack.set_vexpand(True) self._pages: list[Gtk.Widget] = [] - for builder in (self._page_welcome, self._page_api, self._page_wow, self._page_langs, self._page_ready): - page = builder() - self._pages.append(page) - self._stack.add_child(page) + self._build_pages() self._index = 0 # Nav bar @@ -79,6 +76,77 @@ def __init__(self, app: Gtk.Application, config: AppConfig, result: dict) -> Non self.set_child(root) self._sync_nav() + # ── page (re)building ──────────────────────────────────────────────── + def _build_pages(self) -> None: + """(Re)build all pages from scratch so tr() text reflects the current + language. Called on init and again whenever the interface-language + dropdown changes, so the wizard updates live rather than only on the + NEXT run.""" + snapshot = self._snapshot_fields() if self._pages else None + + for page in self._pages: + self._stack.remove(page) + self._pages = [] + + for builder in (self._page_welcome, self._page_api, self._page_wow, + self._page_langs, self._page_ready): + page = builder() + self._pages.append(page) + self._stack.add_child(page) + + if snapshot is not None: + self._restore_fields(snapshot) + self._stack.set_visible_child(self._pages[self._index]) + + def _snapshot_fields(self) -> dict: + """Capture whatever the user has already entered, so switching the + interface language mid-wizard (a rebuild) doesn't lose it.""" + snap: dict = { + "ui_lang": self._dd_code(self._ui_lang), + "priority": self._dd_code(self._priority), + "wow_path": self._wow_entry.get_text(), + "own_lang": self._dd_code(self._own_lang), + "target_lang": self._dd_code(self._target_lang), + "providers": { + pid: {key: entry.get_text() for key, entry in fields.items()} + for pid, fields in self._provider_entries.items() + }, + } + return snap + + def _restore_fields(self, snap: dict) -> None: + self._set_dd_code(self._ui_lang, snap["ui_lang"]) + self._set_dd_code(self._priority, snap["priority"]) + self._wow_entry.set_text(snap["wow_path"]) + self._set_dd_code(self._own_lang, snap["own_lang"]) + self._set_dd_code(self._target_lang, snap["target_lang"]) + for pid, values in snap["providers"].items(): + fields = self._provider_entries.get(pid, {}) + for key, text in values.items(): + entry = fields.get(key) + if entry is not None: + entry.set_text(text) + + @staticmethod + def _set_dd_code(dd: Gtk.DropDown, code: str) -> None: + codes = getattr(dd, "_codes", []) + try: + dd.set_selected(codes.index(code)) + except ValueError: + pass + + def _on_ui_lang_changed(self, dd: Gtk.DropDown, _param: object) -> None: + code = self._dd_code(dd) + if code == tr.get_language(): + return + tr.set_language(code) + self._config.ui_language = code + # Rebuild so every page (including this one) re-renders in the new + # language immediately, instead of only taking effect next launch. + self._build_pages() + self._sync_nav() + self.set_title(tr("wizard.title")) + # ── navigation ──────────────────────────────────────────────────────── def _go(self, delta: int) -> None: self._index = max(0, min(len(self._pages) - 1, self._index + delta)) @@ -88,6 +156,7 @@ def _go(self, delta: int) -> None: def _sync_nav(self) -> None: last = self._index == len(self._pages) - 1 self._back.set_sensitive(self._index > 0) + self._back.set_label(tr("wizard.back")) self._next.set_label(tr("wizard.start") if last else tr("wizard.next")) # Same step names the Qt wizard uses, from the one string that holds # them; the key wants a name as well as the numbers. @@ -146,7 +215,14 @@ def _page_welcome(self) -> Gtk.Widget: ) row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) row.append(self._body(tr("wizard.welcome.ui_lang"))) - self._ui_lang = self._dropdown(_UI_LANGS, self._config.ui_language or "EN") + # Source the selection from tr (what's actually on screen right now), + # not self._config.ui_language directly — before anything is saved, + # config.ui_language is just its raw default and can disagree with + # the language the page text is actually rendered in (e.g. the + # locale-guessed language on first open), showing a dropdown value + # that doesn't match what the user is looking at. + self._ui_lang = self._dropdown(_UI_LANGS, tr.get_language()) + self._ui_lang.connect("notify::selected", self._on_ui_lang_changed) row.append(self._ui_lang) box.append(row) return box From 4b5eebc7acc0a78b5d3ae38b254c779b1916c7f5 Mon Sep 17 00:00:00 2001 From: Ashija <81858000+AhegaoZKun@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:31:10 +0200 Subject: [PATCH 03/12] Fixed an undeclared variable (whoops) Fixed an issue where the interface language would not update in real time to what the user just saved, no longer requires the app to be restarted to see changes. --- app/main_gtk.py | 26 ++++++++++++++++++++++++++ app/overlay_gtk.py | 21 +++++++++++++++++++++ app/settings_gtk.py | 3 +++ 3 files changed, 50 insertions(+) diff --git a/app/main_gtk.py b/app/main_gtk.py index 585eb53..7a3bb7d 100644 --- a/app/main_gtk.py +++ b/app/main_gtk.py @@ -134,6 +134,23 @@ def main() -> int: on_message=overlay.deliver_message, ) + # Start the pipeline only once a translation provider is configured. + # Settings can configure the first provider while the application is + # already running, so this also acts as the startup failsafe used by the + # settings callback below. + pipeline_started = False + + def _start_pipeline_if_ready(updated_config: AppConfig) -> None: + nonlocal pipeline_started + if pipeline_started: + return + if not any_configured(updated_config.providers): + logging.info("pipeline not started: no translation provider configured") + return + + pipeline.start() + pipeline_started = True + def _quit() -> None: try: if tray is not None: @@ -155,6 +172,15 @@ def _open_settings() -> None: return def _on_saved(updated: AppConfig) -> None: + nonlocal config + config = updated + + # The translation helper is process-global. Changing the saved config + # alone is not enough because existing GTK widgets already contain + # strings produced by tr() at construction time. + tr.set_language(updated.ui_language) + overlay.apply_language() + # Apply live: rebuild pipeline config (channels/langs). pipeline.update_config(_build_pipeline_config(updated)) # If this save is what FIRST configured a provider, this is the diff --git a/app/overlay_gtk.py b/app/overlay_gtk.py index a027e71..8947fc1 100644 --- a/app/overlay_gtk.py +++ b/app/overlay_gtk.py @@ -556,6 +556,7 @@ def _win_mapped(w: Gtk.Window) -> None: title = Gtk.Label(label="BabelChat") title.set_xalign(0.0) title.set_hexpand(True) + self._title_label = title # WoW connection status ("WoW: ✔ / … / ✖"), polled via the checker # wired by main — same behavior as the PyQt overlay. self._wow_status = Gtk.Label(label="WoW: ?") @@ -570,10 +571,12 @@ def _win_mapped(w: Gtk.Window) -> None: self._translate_toggle.set_cursor_from_name("pointer") self._translate_toggle.connect("toggled", self._on_translate_toggled) settings_btn = Gtk.Button(label="⚙") + self._settings_button = settings_btn settings_btn.add_css_class("bc-tool") settings_btn.set_cursor_from_name("pointer") settings_btn.connect("clicked", lambda _b: self.on_settings and self.on_settings()) quit_btn = Gtk.Button(label="✕") + self._quit_button = quit_btn quit_btn.add_css_class("bc-close") quit_btn.set_cursor_from_name("pointer") quit_btn.connect("clicked", lambda _b: self.on_quit and self.on_quit()) @@ -871,6 +874,24 @@ def set_translation_active(self, enabled: bool) -> None: if getattr(self, "_translate_toggle", None) is not None: self._translate_toggle.set_active(enabled) + def apply_language(self) -> None: + """Refresh all persistent overlay UI text after a language change.""" + if self._win is None: + return + self._translate_toggle.set_label( + tr("overlay.badge.on") if self._translate_toggle.get_active() else tr("overlay.badge.off") + ) + self._translate_toggle.set_tooltip_text(tr("overlay.translate_toggle")) + self._reply_entry.set_placeholder_text(tr("overlay.reply.placeholder")) + self._reply_lang_dd.set_tooltip_text(tr("overlay.reply.into")) + self._copy_btn.set_label(tr("overlay.reply.copy")) + self._copy_btn.set_tooltip_text(tr("overlay.reply.copy")) + self._update_filter_labels() + + def _update_filter_labels(self) -> None: + for name, btn in getattr(self, "_filter_buttons", {}).items(): + btn.set_label(tr(_FILTER_LABELS[name])) + def apply_appearance(self) -> None: """(Re)build the overlay CSS from current config — opacity, font size. diff --git a/app/settings_gtk.py b/app/settings_gtk.py index 0863c74..d4282f7 100644 --- a/app/settings_gtk.py +++ b/app/settings_gtk.py @@ -441,3 +441,6 @@ def _on_save(self, _btn: Gtk.Button) -> None: if self._on_saved is not None: self._on_saved(c) + # The dialog's labels were created in the old language. Close it after + # save so the next opening is rebuilt with the newly selected language. + self._win.close() From e585b8ef93e42245b6af6664022b5d6c0c2783e0 Mon Sep 17 00:00:00 2001 From: Ashija <81858000+AhegaoZKun@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:44:26 +0200 Subject: [PATCH 04/12] replace try/except pass with contextlib.suppress --- app/setup_wizard_gtk.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/setup_wizard_gtk.py b/app/setup_wizard_gtk.py index 2765c71..b155491 100644 --- a/app/setup_wizard_gtk.py +++ b/app/setup_wizard_gtk.py @@ -13,6 +13,7 @@ from __future__ import annotations +import contextlib import threading # noqa: E402 import gi # noqa: E402 @@ -130,10 +131,8 @@ def _restore_fields(self, snap: dict) -> None: @staticmethod def _set_dd_code(dd: Gtk.DropDown, code: str) -> None: codes = getattr(dd, "_codes", []) - try: + with contextlib.suppress(ValueError): dd.set_selected(codes.index(code)) - except ValueError: - pass def _on_ui_lang_changed(self, dd: Gtk.DropDown, _param: object) -> None: code = self._dd_code(dd) From 09dd4237f707789167e576ece44ba75027c34c06 Mon Sep 17 00:00:00 2001 From: Andrey Yumashev Date: Wed, 26 Aug 2026 14:00:07 +0300 Subject: [PATCH 05/12] Ask the OS what language to open in, but never over a saved answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guessing the interface language from the locale was done inside the block that opens the setup wizard, and that block runs on more than a first run: it also runs whenever no provider is configured, which an expired key or a config written before the provider registry will do. A config file that old still names a language its owner chose. The welcome page seeds its dropdown from whatever is on screen and finishing writes that back, so a user who picked Spanish and clicked through would have found it saved as German. The rule is now one function, startup_ui_language, asked by both entry points: a config file that exists decides, and only a machine that has never run the app is asked what language it speaks. That also gives Windows the first-run guess, which it did not have. The guess itself moved to app/i18n.py, next to UI_LANGUAGES — the list of supported languages it had been carrying a fourth copy of, and where it can be tested at all: it was in main_gtk, which imports gi at module level and therefore cannot be imported on CI. It reads the environment in gettext's order now, LANGUAGE first rather than last, and treats that variable as the colon-separated preference list it is. Smaller things from the same change: the resize grip is relabelled along with the rest of the overlay; three widget references nothing reads are gone; and a provider validation that finishes after a language switch no longer pokes the button the rebuild destroyed. Version 3.5.0, and the CI test floor rises with the 25 tests added. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 14 +++ CHANGELOG_ru.md | 14 +++ README.md | 2 +- README_es.md | 2 +- README_ru.md | 2 +- addon/BabelChat/BabelChat.toc | 2 +- addon/BabelChat/Config.lua | 2 +- app/about_dialog.py | 2 +- app/i18n.py | 67 +++++++++++ app/main.py | 14 ++- app/main_gtk.py | 51 ++------ app/overlay_gtk.py | 7 +- app/setup_wizard_gtk.py | 7 +- pyproject.toml | 2 +- tests/test_i18n_completeness.py | 17 ++- tests/test_ui_language_guess.py | 202 ++++++++++++++++++++++++++++++++ 17 files changed, 352 insertions(+), 57 deletions(-) create mode 100644 tests/test_ui_language_guess.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d20e79..788e696 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,5 +60,5 @@ jobs: # tests into skips, and skips still count here. - name: Guard test count env: - MIN_TESTS: "1015" + MIN_TESTS: "1040" run: python .github/scripts/check_test_count.py report.xml ${{ env.MIN_TESTS }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 423d5ea..73927fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ ship under the same number. --- +## [3.5.0] — 2026-08-26 + +### Added + +- **The interface opens in the language of the machine it is installed on.** BabelChat defaults to Russian, which is right for the audience it was written for and wrong for everyone else at the one moment it matters most: the setup wizard is the first thing a new player reads, and it was in Russian whatever their system was set to. On a first run the OS locale now decides, falling back to Russian when it names a language the interface does not have. Both frontends ask the same function, and it reads the environment in the order gettext does — `LANGUAGE` first, which is the variable a user sets precisely to be obeyed and the one such code usually ignores. + +### Fixed + +- **Changing the interface language on Linux did nothing until the next launch.** The GTK entry point never applied the saved language at all, so it was Russian regardless of what Settings said; and even once applied, the windows already on screen kept the strings they were built with. The overlay relabels itself now, and the setup wizard rebuilds its pages the moment the language dropdown changes rather than at the next launch — carrying across whatever had already been typed into it. +- **The setup wizard reopening no longer overwrites the language you chose.** It reopens whenever no provider is configured, which is not the same as a first run — an expired key does it, and so does a config file written before providers became a registry. Consulting the OS locale there put the machine's opinion over a real preference, and because the welcome page seeds its dropdown from what is on screen and finishing writes that back, clicking straight through would have saved Spanish as German. +- **A GTK setting saved from an old-language window.** The settings window closes on save, as the Windows one always has, so the next opening is built in the language just chosen instead of showing labels in the previous one. + +--- + ## [3.4.0] — 2026-08-23 ### Added diff --git a/CHANGELOG_ru.md b/CHANGELOG_ru.md index 3096286..5f6734b 100644 --- a/CHANGELOG_ru.md +++ b/CHANGELOG_ru.md @@ -8,6 +8,20 @@ --- +## [3.5.0] — 2026-08-26 + +### Добавлено + +- **Интерфейс открывается на языке той машины, куда его поставили.** По умолчанию BabelChat говорит по-русски — это верно для тех, для кого он писался, и неверно для всех остальных ровно в тот момент, когда это важнее всего: мастер настройки новичок читает первым, и он был русским независимо от системных настроек. На первом запуске язык теперь выбирает локаль ОС, а если она называет язык, которого у интерфейса нет, остаётся русский. Оба фронтенда спрашивают одну и ту же функцию, и переменные окружения она читает в том порядке, в каком их читает gettext: `LANGUAGE` первой — той самой, которую выставляют, чтобы её послушались, и которую такой код обычно не замечает. + +### Исправлено + +- **Смена языка интерфейса на Linux не давала ничего до следующего запуска.** GTK-вход сохранённый язык вообще не применял, так что он оставался русским что бы ни было выбрано в настройках; а уже открытые окна и после применения держали те строки, с которыми были построены. Оверлей теперь переподписывает себя сам, а мастер настройки перестраивает страницы в тот момент, когда меняется список языков, а не к следующему запуску — перенося то, что в него уже успели ввести. +- **Повторно открытый мастер больше не затирает выбранный язык.** Он открывается всякий раз, когда не настроен ни один провайдер, а это не то же самое, что первый запуск: так бывает и с протухшим ключом, и с конфигом, написанным до того, как провайдеры стали реестром. Спрашивать там локаль ОС — значит ставить мнение машины выше настоящего выбора, и, поскольку страница приветствия берёт значение списка с экрана, а завершение пишет его обратно, у щёлкнувшего «Далее» испанский сохранился бы как немецкий. +- **Настройка на GTK сохранялась из окна на старом языке.** Окно настроек закрывается после сохранения — так же, как всегда вело себя окно на Windows, — и следующее открытие строится уже на выбранном языке, а не показывает подписи на прежнем. + +--- + ## [3.4.0] — 2026-08-23 ### Добавлено diff --git a/README.md b/README.md index fcb80bd..5210aa1 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,7 @@ WoW, so an addon-only setup has no egress at all. | Cache | SQLite + LRU | | Build | PyInstaller → .exe (Windows) / AppImage, .deb, .rpm (Linux) | | Addon | Lua 5.1, WoW API | -| Tests | 998 tests (pytest) | +| Tests | 1040 tests (pytest) | ## Development diff --git a/README_es.md b/README_es.md index 2e90e34..8151c46 100644 --- a/README_es.md +++ b/README_es.md @@ -280,7 +280,7 @@ alguno. | Caché | SQLite + LRU | | Compilación | PyInstaller → .exe (Windows) / AppImage, .deb, .rpm (Linux) | | Addon | Lua 5.1, WoW API | -| Tests | 998 tests (pytest) | +| Tests | 1040 tests (pytest) | ## Desarrollo diff --git a/README_ru.md b/README_ru.md index ed19ea3..1dabd19 100644 --- a/README_ru.md +++ b/README_ru.md @@ -278,7 +278,7 @@ BabelChat переводит, отправляя текст сообщений | Кэш | SQLite + LRU | | Сборка | PyInstaller → .exe (Windows) / AppImage, .deb, .rpm (Linux) | | Аддон | Lua 5.1, WoW API | -| Тесты | 998 тестов (pytest) | +| Тесты | 1040 тестов (pytest) | ## Разработка diff --git a/addon/BabelChat/BabelChat.toc b/addon/BabelChat/BabelChat.toc index be4e48f..6b9b986 100644 --- a/addon/BabelChat/BabelChat.toc +++ b/addon/BabelChat/BabelChat.toc @@ -5,7 +5,7 @@ ## Notes-esES: |cFF33CCFFTraducción de chat en tiempo real — diccionario integrado + overlay con app acompañante.|r ## Notes-esMX: |cFF33CCFFTraducción de chat en tiempo real — diccionario integrado + overlay con app acompañante.|r ## Author: Andrey Yumashev, Pirson -## Version: 3.4.0 +## Version: 3.5.0 ## X-License: MIT ## X-Website: https://github.com/Yumash/BabelChat diff --git a/addon/BabelChat/Config.lua b/addon/BabelChat/Config.lua index a78d0dc..a5bc7c6 100644 --- a/addon/BabelChat/Config.lua +++ b/addon/BabelChat/Config.lua @@ -68,7 +68,7 @@ function addonTable.CreateConfigUI() local version = panel:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") version:SetPoint("TOP", logo, "BOTTOM", 0, -2) - version:SetText("v" .. (C_AddOns.GetAddOnMetadata(ADDON_NAME, "Version") or "3.4.0")) + version:SetText("v" .. (C_AddOns.GetAddOnMetadata(ADDON_NAME, "Version") or "3.5.0")) -- ════════════════════════════════════ -- SECTION 1: GENERAL diff --git a/app/about_dialog.py b/app/about_dialog.py index 79f2ab9..d654132 100644 --- a/app/about_dialog.py +++ b/app/about_dialog.py @@ -18,7 +18,7 @@ from app.i18n import tr -VERSION = "3.4.0" +VERSION = "3.5.0" ABOUT_STYLESHEET = """ QDialog { diff --git a/app/i18n.py b/app/i18n.py index f709188..4cd8f7b 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -2,6 +2,8 @@ from __future__ import annotations +import locale +import os from typing import ClassVar from app import locales @@ -44,3 +46,68 @@ def __new__(cls, key: str, **kwargs: object) -> str: # type: ignore[misc] if kwargs: text = text.format(**kwargs) return text + + +#: Environment variables that state the user's preferred UI language, in the +#: order gettext resolves them: LANGUAGE wins outright, and only then do the +#: LC_* variables and LANG get a say. Getting this order wrong is easy — LANG +#: is the famous one — and it silently ignores whatever LANGUAGE asked for. +_LOCALE_ENV_VARS = ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG") + + +def _codes_in(value: str) -> list[str]: + """Language codes named by one locale environment variable. + + LANGUAGE holds a colon-separated preference list ("es:ru"); the others hold + a single locale ("es_ES.UTF-8"). Both reduce to the leading language part, + upper-cased, which is what UI_LANGUAGES is keyed by. + """ + codes = [] + for item in value.split(":"): + code = item.split(".")[0].split("@")[0].split("_")[0].strip().upper() + if code: + codes.append(code) + return codes + + +def guess_ui_language(default: str = "RU") -> str: + """The UI language to open with when nothing has been saved yet. + + First run has no preference to honour, and defaulting to Russian for + everyone meant a first-time player anywhere else read the setup wizard in a + language they may not have. The OS locale is the only signal available, so + it decides — falling back to `default` when it is absent, unreadable, or + names a language this build has no UI for. + + Only for a genuine first run: once a config file exists it carries a real + choice, and guessing over that replaces it with the machine's opinion. + """ + for env_var in _LOCALE_ENV_VARS: + for code in _codes_in(os.environ.get(env_var, "")): + if code in UI_LANGUAGES: + return code + + try: + loc = locale.getlocale()[0] or "" + except (ValueError, TypeError): # a malformed locale setting, not our problem + return default + for code in _codes_in(loc): + if code in UI_LANGUAGES: + return code + return default + + +def startup_ui_language(*, config_exists: bool, saved: str) -> str: + """The language to render in before the user has had a chance to say. + + One rule, called by both entry points, because this is exactly the kind of + decision that has drifted between the Qt and GTK frontends every time it + was written twice: a saved choice is honoured, and only a machine that has + never run the app is asked what language it speaks. + + `config_exists` is deliberately about the FILE, not about whether the app + is fully set up. The setup wizard also reopens when a provider stops being + configured, and treating that as a first run would let the OS locale + overwrite the language the user picked the last time round. + """ + return saved if config_exists else guess_ui_language() diff --git a/app/main.py b/app/main.py index b55ae72..b096ce8 100644 --- a/app/main.py +++ b/app/main.py @@ -15,9 +15,9 @@ from app import debug_log from app.about_dialog import AboutDialog -from app.config import AppConfig, enabled_channels, enabled_filter_tabs, resolve_chatlog_path +from app.config import CONFIG_FILE, AppConfig, enabled_channels, enabled_filter_tabs, resolve_chatlog_path from app.hotkeys import GlobalHotkeyManager -from app.i18n import tr +from app.i18n import startup_ui_language, tr from app.overlay import ChatOverlay from app.parser import Channel from app.pipeline import PipelineConfig, TranslationPipeline @@ -349,8 +349,14 @@ def main() -> int: # other players' whispers included, so it is never on by default. debug_log.configure(config.debug_capture_trace) - # Set UI language from config - tr.set_language(config.ui_language) + # Set UI language from config — except on a genuine first run, where the + # config holds no choice yet and its RU default would show the wizard in + # Russian to a player anywhere in the world. The OS locale stands in until + # the wizard saves a real preference. Same rule as the GTK entry point, + # from the same function, so the two cannot drift apart again. + tr.set_language( + startup_ui_language(config_exists=os.path.exists(CONFIG_FILE), saved=config.ui_language) + ) # First run — setup wizard if no API key if not any_configured(config.providers): diff --git a/app/main_gtk.py b/app/main_gtk.py index 7a3bb7d..5ed0d47 100644 --- a/app/main_gtk.py +++ b/app/main_gtk.py @@ -21,7 +21,7 @@ from app import debug_log from app.config import CONFIG_FILE, AppConfig, enabled_channels, resolve_chatlog_path -from app.i18n import tr +from app.i18n import startup_ui_language, tr from app.overlay_gtk import ChatOverlayGtk from app.pipeline import PipelineConfig, TranslationPipeline from app.settings_gtk import SettingsWindowGtk @@ -61,30 +61,6 @@ def _build_pipeline_config(config: AppConfig) -> PipelineConfig: ) -def _guess_locale_lang() -> str: - """Tries to find the user's locale from the OS. - - Falls back to RU if the locale can't be read - or doesn't map to a supported language. - """ - import locale - - for env_var in ("LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"): - val = os.environ.get(env_var, "") - if val: - code = val.split(".")[0].split("_")[0].upper() - if code in ("RU", "EN", "ES"): - return code - try: - loc = locale.getlocale()[0] or "" - code = loc.split("_")[0].upper() - if code in ("RU", "EN", "ES"): - return code - except Exception: # noqa: BLE001 - pass - return "RU" - - def main() -> int: load_dotenv() logging.basicConfig( @@ -96,28 +72,25 @@ def main() -> int: # Off unless asked for: it records every chat line in full. debug_log.configure(config.debug_capture_trace) + # The Qt entry point has always applied the saved language and the GTK one + # never did, so every Linux user got the default — Russian — whatever they + # had picked in Settings. This has to happen before the wizard, because the + # wizard is what a first-time player reads first. + config_exists = os.path.exists(CONFIG_FILE) + tr.set_language(startup_ui_language(config_exists=config_exists, saved=config.ui_language)) + # First run: no config file yet, or no translation API configured → # run the setup wizard (its own blocking GTK loop) before normal startup. - if not os.path.exists(CONFIG_FILE) or not any_configured(config.providers): - # Give the wizard itself a reasonable display language BEFORE it opens. - # It has no config yet to base this on (config.ui_language is just the - # RU default, not a real signal), so guess from the OS locale instead — - # the wizard's own on-screen text otherwise defaults to Russian for - # every first-run user regardless of their system language. - tr.set_language(_guess_locale_lang()) - + if not config_exists or not any_configured(config.providers): from app.setup_wizard_gtk import run_setup_wizard config = run_setup_wizard(config) if config is None: # user closed the wizard without finishing return 0 - # The Qt entry point has always done this and the GTK one never did, so - # every Linux user got the default interface language regardless of what - # they picked — and the default is Russian. Must run AFTER the wizard: - # applying it first used the pre-wizard config and ignored the language - # the user had just chosen on first run. - tr.set_language(config.ui_language) + # The wizard saves a language of its own — adopt it, or the app starts + # in whatever was on screen before the user chose. + tr.set_language(config.ui_language) overlay = ChatOverlayGtk(config) diff --git a/app/overlay_gtk.py b/app/overlay_gtk.py index 8947fc1..9676d5c 100644 --- a/app/overlay_gtk.py +++ b/app/overlay_gtk.py @@ -260,6 +260,7 @@ def __init__(self, config: AppConfig) -> None: self._list: Gtk.Box | None = None self._scroller: Gtk.ScrolledWindow | None = None self._reply_entry: Gtk.Entry | None = None + self._grip: Gtk.DrawingArea | None = None self._reply_status: Gtk.Label | None = None # Callbacks wired by main (so this module stays UI-only). @@ -556,7 +557,6 @@ def _win_mapped(w: Gtk.Window) -> None: title = Gtk.Label(label="BabelChat") title.set_xalign(0.0) title.set_hexpand(True) - self._title_label = title # WoW connection status ("WoW: ✔ / … / ✖"), polled via the checker # wired by main — same behavior as the PyQt overlay. self._wow_status = Gtk.Label(label="WoW: ?") @@ -571,12 +571,10 @@ def _win_mapped(w: Gtk.Window) -> None: self._translate_toggle.set_cursor_from_name("pointer") self._translate_toggle.connect("toggled", self._on_translate_toggled) settings_btn = Gtk.Button(label="⚙") - self._settings_button = settings_btn settings_btn.add_css_class("bc-tool") settings_btn.set_cursor_from_name("pointer") settings_btn.connect("clicked", lambda _b: self.on_settings and self.on_settings()) quit_btn = Gtk.Button(label="✕") - self._quit_button = quit_btn quit_btn.add_css_class("bc-close") quit_btn.set_cursor_from_name("pointer") quit_btn.connect("clicked", lambda _b: self.on_quit and self.on_quit()) @@ -724,6 +722,7 @@ def _drag_end(_g: Gtk.GestureDrag, ox: float, oy: float) -> None: grip.set_content_height(16) grip.add_css_class("bc-grip") grip.set_tooltip_text(tr("overlay.resize_hint")) + self._grip = grip grip.set_cursor_from_name("nwse-resize") grip.set_halign(Gtk.Align.END) grip.set_valign(Gtk.Align.END) @@ -886,6 +885,8 @@ def apply_language(self) -> None: self._reply_lang_dd.set_tooltip_text(tr("overlay.reply.into")) self._copy_btn.set_label(tr("overlay.reply.copy")) self._copy_btn.set_tooltip_text(tr("overlay.reply.copy")) + if self._grip is not None: + self._grip.set_tooltip_text(tr("overlay.resize_hint")) self._update_filter_labels() def _update_filter_labels(self) -> None: diff --git a/app/setup_wizard_gtk.py b/app/setup_wizard_gtk.py index b155491..8c3090a 100644 --- a/app/setup_wizard_gtk.py +++ b/app/setup_wizard_gtk.py @@ -370,7 +370,12 @@ def worker() -> None: GLib.idle_add(done, valid, msg) def done(valid: bool, msg: str) -> bool: - btn.set_sensitive(True) + # Switching the interface language rebuilds every page, so the + # button this validation started from may no longer be in the + # window by the time the worker answers. Its replacement is + # sensitive already; poking the orphan only earns GTK criticals. + if btn.get_parent() is not None: + btn.set_sensitive(True) if valid: extra = f" — {msg}" if msg and msg != "valid" else "" self._api_status.set_markup( diff --git a/pyproject.toml b/pyproject.toml index 29c7f07..d200e3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "babelchat" -version = "3.4.0" +version = "3.5.0" description = "Real-time WoW chat translator with smart overlay" requires-python = ">=3.12" diff --git a/tests/test_i18n_completeness.py b/tests/test_i18n_completeness.py index bb263fb..f1f56b3 100644 --- a/tests/test_i18n_completeness.py +++ b/tests/test_i18n_completeness.py @@ -337,10 +337,23 @@ def test_every_entry_point_applies_the_configured_ui_language(entry_point): `gi` is not installed on Windows or on CI, so `main_gtk` cannot be imported here. The call is asserted in the source instead — a weaker check than running it, and the reason it is written down. + + Read as whole statements rather than as lines: the argument is now long + enough to wrap, and a line scan would report an entry point that does this + correctly as one that never does it at all. """ - text = (APP.parent / entry_point).read_text(encoding="utf-8") + import ast - calls = [line.strip() for line in text.splitlines() if "tr.set_language(" in line] + text = (APP.parent / entry_point).read_text(encoding="utf-8") + tree = ast.parse(text) + + calls = [ + ast.get_source_segment(text, node) + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "set_language" + ] assert calls, f"{entry_point} never applies the configured language" assert any("ui_language" in call for call in calls), calls diff --git a/tests/test_ui_language_guess.py b/tests/test_ui_language_guess.py new file mode 100644 index 0000000..a4ea0db --- /dev/null +++ b/tests/test_ui_language_guess.py @@ -0,0 +1,202 @@ +"""Which language the interface opens in before anything has been saved. + +The app defaults to Russian, which is right for the audience it was written +for and wrong for everyone else on their very first launch: the setup wizard +is the first thing a new player sees, and it was showing them Russian whatever +their machine was set to. The OS locale is the only signal available at that +point, so it decides. + +The dangerous half is knowing when NOT to consult it. The wizard reopens +whenever no provider is configured — an expired key, a config migrated from +before the provider registry — and that is not a first run: a real preference +is sitting in the config file. The wizard seeds its dropdown from the language +on screen and writes it back on finish, so guessing there does not merely +mislabel a window, it overwrites the choice the user made. +""" + +from __future__ import annotations + +import pytest + +from app.i18n import UI_LANGUAGES, guess_ui_language, startup_ui_language + +#: Every variable the guesser reads. Cleared wholesale per test so the machine +#: running the suite cannot answer for the machine being simulated. +LOCALE_VARS = ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG") + + +@pytest.fixture +def env(monkeypatch): + """A machine with no locale opinion, plus a lever to give it one.""" + for var in LOCALE_VARS: + monkeypatch.delenv(var, raising=False) + # getlocale() reads the process's own setting, which pytest inherits from + # whoever ran it. Neutralise it; the tests that care set it themselves. + monkeypatch.setattr("app.i18n.locale.getlocale", lambda *a: (None, None)) + return monkeypatch + + +# ── reading the environment ────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("es_ES.UTF-8", "ES"), + ("en_GB", "EN"), + ("ru_RU.UTF-8", "RU"), + ("es", "ES"), + ("en_US.iso88591", "EN"), + ("ru_RU.UTF-8@cyrillic", "RU"), + ], +) +def test_a_locale_names_its_language(env, value, expected): + env.setenv("LANG", value) + + assert guess_ui_language() == expected + + +def test_language_wins_over_the_lc_variables(env): + """gettext resolves LANGUAGE first and LANG nearly last. Ordering them the + other way round is the easy mistake — LANG is the famous one — and it + silently ignores the variable the user set precisely to be obeyed.""" + env.setenv("LANGUAGE", "es") + env.setenv("LC_ALL", "ru_RU.UTF-8") + env.setenv("LC_MESSAGES", "ru_RU.UTF-8") + env.setenv("LANG", "ru_RU.UTF-8") + + assert guess_ui_language() == "ES" + + +def test_lc_all_wins_over_lang(env): + env.setenv("LC_ALL", "es_ES.UTF-8") + env.setenv("LANG", "en_US.UTF-8") + + assert guess_ui_language() == "ES" + + +def test_language_is_a_preference_list(env): + """LANGUAGE holds colon-separated fallbacks, unlike the others. Reading it + as a single locale would see "de:es" as a language named "de:es".""" + env.setenv("LANGUAGE", "de:es:ru") + + assert guess_ui_language() == "ES" + + +def test_an_unsupported_language_does_not_end_the_search(env): + """A German desktop with LANG=de_DE has no German UI to offer, but its + LC_MESSAGES may still name one this build has. Returning the default at + the first variable that says anything would never look.""" + env.setenv("LANGUAGE", "de") + env.setenv("LANG", "es_ES.UTF-8") + + assert guess_ui_language() == "ES" + + +# ── falling back ───────────────────────────────────────────────────────────── + + +def test_no_locale_at_all_falls_back(env): + assert guess_ui_language() == "RU" + + +def test_a_language_this_build_cannot_show_falls_back(env): + """Translating the UI is not the same as translating chat: the app speaks + twenty languages to WoW and three to its own user.""" + env.setenv("LANG", "ja_JP.UTF-8") + + assert guess_ui_language() == "RU" + + +@pytest.mark.parametrize("value", ["", "C", "POSIX", "C.UTF-8"]) +def test_the_uninformative_locales_say_nothing(env, value): + env.setenv("LANG", value) + + assert guess_ui_language() == "RU" + + +def test_the_process_locale_answers_when_the_environment_is_silent(env): + """Windows sets none of these variables; getlocale() is all there is.""" + env.setattr("app.i18n.locale.getlocale", lambda *a: ("es_ES", "UTF-8")) + + assert guess_ui_language() == "ES" + + +def test_a_malformed_locale_setting_is_not_a_crash(env): + """getlocale() raises on a setting it cannot parse. Failing to guess a + language must not stop the app from starting.""" + + def explode(*_args): + raise ValueError("unknown locale format") + + env.setattr("app.i18n.locale.getlocale", explode) + + assert guess_ui_language() == "RU" + + +def test_the_caller_chooses_the_fallback(env): + assert guess_ui_language(default="EN") == "EN" + + +def test_it_only_ever_returns_a_language_the_ui_has(env): + """The supported set is UI_LANGUAGES, not a list copied beside it — a + fourth translation should not need this function edited to be reachable.""" + for value in ("es_ES", "en_US", "ru_RU", "zz_ZZ", "de_DE", ""): + env.setenv("LANG", value) + + assert guess_ui_language() in UI_LANGUAGES + + +# ── when the guess is allowed to speak at all ──────────────────────────────── + + +def test_a_saved_choice_is_honoured(env): + """The ordinary case: the config file exists, so it decides.""" + env.setenv("LANG", "de_DE.UTF-8") + + assert startup_ui_language(config_exists=True, saved="ES") == "ES" + + +def test_the_guess_does_not_overrule_a_saved_choice(env): + """The wizard reopens whenever no provider is configured, not only on a + first run — an expired key does it, so does a config migrated from before + the provider registry. Treating that as a first run consults the OS locale + over a preference that already exists, and because the welcome page seeds + its dropdown from the language on screen and finish() writes it back, a + user who clicked through would find Spanish saved as German.""" + env.setenv("LANG", "de_DE.UTF-8") + + assert startup_ui_language(config_exists=True, saved="ES") != "RU" + assert startup_ui_language(config_exists=True, saved="ES") == "ES" + + +def test_a_first_run_has_nothing_to_honour(env): + """No config file: `saved` is only the dataclass default, not a choice.""" + env.setenv("LANG", "es_ES.UTF-8") + + assert startup_ui_language(config_exists=False, saved="RU") == "ES" + + +def test_a_first_run_on_a_machine_with_no_locale_keeps_the_default(env): + assert startup_ui_language(config_exists=False, saved="RU") == "RU" + + +def test_both_entry_points_ask_the_same_question(): + """This decision lived in one frontend and not the other for a release, + which is how Linux users ended up unable to change the interface language + at all. Whatever it grows into, both callers get the same answer.""" + import ast + import pathlib + + root = pathlib.Path(__file__).resolve().parent.parent + for entry in ("main.py", "main_gtk.py"): + source = (root / "app" / entry).read_text(encoding="utf-8") + tree = ast.parse(source) + called = { + node.func.id + for node in ast.walk(tree) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + } + + assert "startup_ui_language" in called, f"{entry} decides the startup language by itself" + assert "guess_ui_language" not in called, f"{entry} reaches past the shared rule" From 53a5aa0941d155bb347b9634c8da8276bb855c5f Mon Sep 17 00:00:00 2001 From: Andrey Yumashev Date: Wed, 26 Aug 2026 14:20:56 +0300 Subject: [PATCH 06/12] Ask Windows what language it is in, and config.py what a first run is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects a review of the previous commit found, both in code added there and neither shipped. locale.getlocale() cannot answer this on Windows. It reports the C runtime's name for the locale — measured here, ('Russian_Russia', '1252') — which is not an ISO code, so the guess fell through to the default and every Windows first run opened in Russian, which is the thing the change was written to stop. GetUserDefaultUILanguage through locale.windows_locale answers with ru_RU, and it is also the better question: Windows keeps the language the interface is in separate from the locale dates and numbers are formatted by, and the first of those is what we are matching. The existing test passed only because it mocked the POSIX shape Windows never produces. The other: startup_ui_language was handed os.path.exists(CONFIG_FILE), and AppConfig.load reads config.json.bak as well. With only the backup on disk, load hands back a real saved ui_language that the caller one line later discards as a first run — and on GTK the wizard opens too, seeds its dropdown from the guess and writes it back on finish, so the recovered choice is gone for good. That is exactly what the docstring claims the function prevents. It is config.py's question now: saved_config_exists walks the same candidate list load does, so the two cannot come to look at different files, and it parses rather than stats, which also gets the corrupt-config case the right way round. Co-Authored-By: Claude Opus 5 (1M context) --- app/config.py | 36 +++++++- app/i18n.py | 35 ++++++++ app/main.py | 4 +- app/main_gtk.py | 7 +- tests/test_ui_language_guess.py | 151 +++++++++++++++++++++++++++++++- 5 files changed, 225 insertions(+), 8 deletions(-) diff --git a/app/config.py b/app/config.py index 8354eb4..11d97c5 100644 --- a/app/config.py +++ b/app/config.py @@ -152,8 +152,7 @@ def save(self, path: str = CONFIG_FILE) -> None: @classmethod def load(cls, path: str = CONFIG_FILE) -> AppConfig: """Load config from JSON file, using defaults for missing fields.""" - target = Path(path) - for try_path in [target, target.with_suffix(".json.bak")]: + for try_path in _config_candidates(path): try: data = json.loads(try_path.read_text(encoding="utf-8")) _migrate_provider_keys(data, try_path) @@ -173,6 +172,39 @@ def load(cls, path: str = CONFIG_FILE) -> AppConfig: return cls() +def _config_candidates(path: str = CONFIG_FILE) -> list[Path]: + """Every file `AppConfig.load` will accept a saved config from, in order. + + Declared once so that asking "is there a saved config?" and answering + "here is the saved config" cannot come to look at different files. + """ + target = Path(path) + return [target, target.with_suffix(".json.bak")] + + +def saved_config_exists(path: str = CONFIG_FILE) -> bool: + """True when a config this build can actually read is on disk. + + Deliberately not `os.path.exists(CONFIG_FILE)`, which answers a different + question and gets it wrong in both directions. `load` also reads + `config.json.bak`, so an absent main file does not mean the user has no + saved preferences — and it falls back to defaults on a corrupt one, so a + present main file does not mean any were read. Anything deciding whether + this is a first run has to ask about the same candidates `load` does. + + Parses rather than stats, for the corrupt case, but does not migrate: the + migrations write backups of their own, and running them from a question is + not what a question should do. + """ + for candidate in _config_candidates(path): + try: + json.loads(candidate.read_text(encoding="utf-8")) + except (OSError, ValueError): + continue + return True + return False + + @dataclass(frozen=True, slots=True) class ChannelToggle: """One switch in the settings window, and everything that depends on it. diff --git a/app/i18n.py b/app/i18n.py index 4cd8f7b..64ae399 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -4,6 +4,7 @@ import locale import os +import sys from typing import ClassVar from app import locales @@ -70,6 +71,33 @@ def _codes_in(value: str) -> list[str]: return codes +def _windows_ui_language() -> str: + """The display language Windows itself is set to, as an ISO locale name. + + `locale.getlocale()` cannot answer this on Windows: it reports the C + runtime's name for the locale — "Russian_Russia", "English_United States" — + which is not an ISO code and which nothing here can key on. Measured on a + Russian Windows 11: `getlocale()` gives `('Russian_Russia', '1252')`, so + every guess fell through to the default and the first-run language was + Russian for the whole world, which is the bug this was written to fix. + + Windows also separates the language the interface is in from the locale + dates and numbers are formatted by — a machine can be English with Russian + formats — and it is the first of those we are trying to match. That is + `GetUserDefaultUILanguage`, not the format locale `getdefaultlocale` reads + (and which is deprecated for removal besides). + """ + if sys.platform != "win32": + return "" + try: + import ctypes + + lcid = ctypes.windll.kernel32.GetUserDefaultUILanguage() + except (AttributeError, OSError, ValueError): # not the Windows we expected + return "" + return getattr(locale, "windows_locale", {}).get(lcid, "") + + def guess_ui_language(default: str = "RU") -> str: """The UI language to open with when nothing has been saved yet. @@ -87,6 +115,13 @@ def guess_ui_language(default: str = "RU") -> str: if code in UI_LANGUAGES: return code + # Windows sets none of those variables, and its C-runtime locale name is + # not something `_codes_in` can read. Ask Win32 directly before falling + # back to the POSIX path below. + for code in _codes_in(_windows_ui_language()): + if code in UI_LANGUAGES: + return code + try: loc = locale.getlocale()[0] or "" except (ValueError, TypeError): # a malformed locale setting, not our problem diff --git a/app/main.py b/app/main.py index b096ce8..b3cfb55 100644 --- a/app/main.py +++ b/app/main.py @@ -15,7 +15,7 @@ from app import debug_log from app.about_dialog import AboutDialog -from app.config import CONFIG_FILE, AppConfig, enabled_channels, enabled_filter_tabs, resolve_chatlog_path +from app.config import AppConfig, enabled_channels, enabled_filter_tabs, resolve_chatlog_path, saved_config_exists from app.hotkeys import GlobalHotkeyManager from app.i18n import startup_ui_language, tr from app.overlay import ChatOverlay @@ -355,7 +355,7 @@ def main() -> int: # the wizard saves a real preference. Same rule as the GTK entry point, # from the same function, so the two cannot drift apart again. tr.set_language( - startup_ui_language(config_exists=os.path.exists(CONFIG_FILE), saved=config.ui_language) + startup_ui_language(config_exists=saved_config_exists(), saved=config.ui_language) ) # First run — setup wizard if no API key diff --git a/app/main_gtk.py b/app/main_gtk.py index 5ed0d47..b4a0c4f 100644 --- a/app/main_gtk.py +++ b/app/main_gtk.py @@ -20,7 +20,7 @@ from lingua import Language from app import debug_log -from app.config import CONFIG_FILE, AppConfig, enabled_channels, resolve_chatlog_path +from app.config import AppConfig, enabled_channels, resolve_chatlog_path, saved_config_exists from app.i18n import startup_ui_language, tr from app.overlay_gtk import ChatOverlayGtk from app.pipeline import PipelineConfig, TranslationPipeline @@ -76,7 +76,10 @@ def main() -> int: # never did, so every Linux user got the default — Russian — whatever they # had picked in Settings. This has to happen before the wizard, because the # wizard is what a first-time player reads first. - config_exists = os.path.exists(CONFIG_FILE) + # Whether a saved config exists is config.py's question to answer, not a + # stat of one filename: load() reads config.json.bak too, so the main file + # being gone does not mean the user's language preference is. + config_exists = saved_config_exists() tr.set_language(startup_ui_language(config_exists=config_exists, saved=config.ui_language)) # First run: no config file yet, or no translation API configured → diff --git a/tests/test_ui_language_guess.py b/tests/test_ui_language_guess.py index a4ea0db..98e954e 100644 --- a/tests/test_ui_language_guess.py +++ b/tests/test_ui_language_guess.py @@ -16,9 +16,18 @@ from __future__ import annotations +import json +import pathlib + import pytest -from app.i18n import UI_LANGUAGES, guess_ui_language, startup_ui_language +from app.config import AppConfig, saved_config_exists +from app.i18n import ( + UI_LANGUAGES, + _windows_ui_language, + guess_ui_language, + startup_ui_language, +) #: Every variable the guesser reads. Cleared wholesale per test so the machine #: running the suite cannot answer for the machine being simulated. @@ -33,6 +42,10 @@ def env(monkeypatch): # getlocale() reads the process's own setting, which pytest inherits from # whoever ran it. Neutralise it; the tests that care set it themselves. monkeypatch.setattr("app.i18n.locale.getlocale", lambda *a: (None, None)) + # Same for Windows: CI runs on a Windows runner whose interface is English, + # so leaving this live would have every "no locale anywhere" test answered + # by the runner's own machine. + monkeypatch.setattr("app.i18n._windows_ui_language", lambda: "") return monkeypatch @@ -186,7 +199,6 @@ def test_both_entry_points_ask_the_same_question(): which is how Linux users ended up unable to change the interface language at all. Whatever it grows into, both callers get the same answer.""" import ast - import pathlib root = pathlib.Path(__file__).resolve().parent.parent for entry in ("main.py", "main_gtk.py"): @@ -200,3 +212,138 @@ def test_both_entry_points_ask_the_same_question(): assert "startup_ui_language" in called, f"{entry} decides the startup language by itself" assert "guess_ui_language" not in called, f"{entry} reaches past the shared rule" + assert "saved_config_exists" in called, ( + f"{entry} decides what a first run is without asking config.py" + ) + + # os.path.exists(CONFIG_FILE) is the wrong question and was asked here + # twice: load() also reads config.json.bak, so the main file's absence + # is not proof there is no saved language. + segments = [ast.get_source_segment(source, node) or "" for node in ast.walk(tree)] + stats = [seg for seg in segments if "CONFIG_FILE" in seg and "exists" in seg] + assert stats == [], f"{entry} still stats the config path: {stats}" + + +# ── Windows answers this question differently ──────────────────────────────── + + +def test_windows_says_which_language_its_interface_is_in(env): + """The env vars are a POSIX convention; Windows sets none of them.""" + env.setattr("app.i18n._windows_ui_language", lambda: "en_US") + + assert guess_ui_language() == "EN" + + +def test_the_c_runtime_locale_name_is_not_an_iso_code(env): + """What `getlocale()` returns on Windows, measured: `('Russian_Russia', + '1252')`. It is the C runtime's name for the locale, not a language code, + and reading it as one is why every Windows first run answered RU whatever + the machine was set to — including the English ones this was written for. + A machine whose interface is English must not be talked out of it by the + formats locale next door.""" + env.setattr("app.i18n.locale.getlocale", lambda *a: ("English_United States", "1252")) + env.setattr("app.i18n._windows_ui_language", lambda: "en_US") + + assert guess_ui_language() == "EN" + + +def test_the_windows_lookup_stays_on_windows(monkeypatch): + """It is called unconditionally, so it has to be inert everywhere else — + `ctypes.windll` does not exist on Linux and reaching for it would raise + during startup on the platform this app was ported to.""" + monkeypatch.setattr("app.i18n.sys.platform", "linux") + + assert _windows_ui_language() == "" + + +def test_an_lcid_python_has_no_name_for_says_nothing(monkeypatch): + """`windows_locale` is a fixed table shipped with Python; a language ID + added to Windows after that table was written is simply absent from it, + and must read as "no answer" rather than as a crash.""" + monkeypatch.setattr("app.i18n.sys.platform", "win32") + monkeypatch.setattr("app.i18n.locale.windows_locale", {}, raising=False) + + assert _windows_ui_language() == "" + + +def test_the_environment_still_outranks_windows(env): + """A user who sets LANGUAGE on Windows — through a launcher script, say — + is asking for something specific, and asked first.""" + env.setenv("LANGUAGE", "es") + env.setattr("app.i18n._windows_ui_language", lambda: "en_US") + + assert guess_ui_language() == "ES" + + +# ── what counts as having run this app before ──────────────────────────────── + + +def _write(path, **fields) -> None: + path.write_text(json.dumps(fields), encoding="utf-8") + + +def test_a_config_only_in_the_backup_still_counts(tmp_path): + """`AppConfig.load` reads config.json.bak when the main file is gone — and + hands back the language saved in it. Asking `os.path.exists(CONFIG_FILE)` + instead calls that a first run, so the guess overrides a preference that + was successfully loaded one line earlier; on GTK the wizard opens too, + seeds its dropdown from the guess and writes it back on finish, which + deletes the recovered choice for good.""" + main = tmp_path / "config.json" + _write(main.with_suffix(".json.bak"), ui_language="ES") + + assert not main.exists() + assert saved_config_exists(str(main)) is True + assert AppConfig.load(str(main)).ui_language == "ES" + + +def test_a_corrupt_config_with_no_backup_is_a_first_run(tmp_path): + """The other direction. `load` falls back to defaults here, so the RU it + returns is not a choice anybody made — treating the file's presence as one + shows the wizard in Russian to someone who has never used the app.""" + main = tmp_path / "config.json" + main.write_text("{ this is not json", encoding="utf-8") + + assert saved_config_exists(str(main)) is False + assert AppConfig.load(str(main)).ui_language == "RU" + + +def test_nothing_on_disk_is_a_first_run(tmp_path): + assert saved_config_exists(str(tmp_path / "config.json")) is False + + +def test_the_question_and_the_answer_read_the_same_files(tmp_path): + """These drifted the moment they were written apart: one stats a filename, + the other has a fallback list. They share the list now.""" + from app.config import _config_candidates + + main = tmp_path / "config.json" + candidates = [str(p) for p in _config_candidates(str(main))] + + assert candidates == [str(main), str(main.with_suffix(".json.bak"))] + + for candidate in candidates: + pathlib.Path(candidate).write_text(json.dumps({"ui_language": "ES"}), encoding="utf-8") + assert saved_config_exists(str(main)) is True + pathlib.Path(candidate).unlink() + + +def test_asking_does_not_rewrite_anything(tmp_path): + """`load` runs migrations that write backups of their own. A question that + quietly does that is not a question.""" + main = tmp_path / "config.json" + _write(main, deepl_api_key="secret-from-before-the-registry", ui_language="ES") + before = {p.name for p in tmp_path.iterdir()} + + assert saved_config_exists(str(main)) is True + assert {p.name for p in tmp_path.iterdir()} == before + + +@pytest.mark.parametrize( + ("config_exists", "expected"), + [(True, "ES"), (False, "RU")], +) +def test_the_startup_rule_takes_that_answer(env, config_exists, expected): + """Joining the two halves: a recovered config keeps its language, a machine + with no config at all gets the guess (RU here, the environment is bare).""" + assert startup_ui_language(config_exists=config_exists, saved="ES") == expected From 3a82c82041b20d87b5fc9eca9aaa42fc3a18836c Mon Sep 17 00:00:00 2001 From: Andrey Yumashev Date: Wed, 26 Aug 2026 14:44:49 +0300 Subject: [PATCH 07/12] Ship this as 3.4.1, and stop the dead code shipping with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version was wrong. Nothing here is new: the app has offered three interface languages since long before this branch and opened in one of them regardless, on both platforms. Fixing that is a patch, so the changelog entry loses its Added section along with the number, and the two bullets describing defects that only ever existed inside this branch go with it — a changelog is for what changed for the people running it. Three things a review of the finished branch turned up: tr.set_language validated against a hardcoded ("RU","EN","ES") while the locale guess resolved against UI_LANGUAGES. A fourth translation added to the table would be found by the guess and then silently clamped back to Russian on the way in — this branch's own bug, wearing a new hat. One table now, and a test that walks it. _start_pipeline_if_ready is gone. It could not fire (control only reaches it after the wizard, and _finish always writes the keyless provider, so any_configured is true by then) and it would have been wrong if it could: TranslationPipeline builds its TranslatorService in __init__ and update_config never rebuilds it, so the very case it existed for — a first provider configured while the app runs — would have started a pipeline that cannot translate. pipeline.start() is unconditional again, as on Qt. The settings window now closes only when the language changed. That is the one save that needs the rebuild; closing on every save meant the "Saved" written two lines earlier was never on screen long enough to read. Test floor tightened to the number CI actually collects, rather than left ninety short of it. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 11 +++-------- CHANGELOG_ru.md | 11 +++-------- README.md | 2 +- README_es.md | 2 +- README_ru.md | 2 +- addon/BabelChat/BabelChat.toc | 2 +- addon/BabelChat/Config.lua | 2 +- app/about_dialog.py | 2 +- app/i18n.py | 6 +++++- app/main_gtk.py | 22 +-------------------- app/settings_gtk.py | 12 ++++++++--- pyproject.toml | 2 +- tests/test_ui_language_guess.py | 35 +++++++++++++++++++++++++++++++++ 14 files changed, 64 insertions(+), 49 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 788e696..73994a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,5 +60,5 @@ jobs: # tests into skips, and skips still count here. - name: Guard test count env: - MIN_TESTS: "1040" + MIN_TESTS: "1131" run: python .github/scripts/check_test_count.py report.xml ${{ env.MIN_TESTS }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 73927fe..bbb7fcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,17 +8,12 @@ ship under the same number. --- -## [3.5.0] — 2026-08-26 - -### Added - -- **The interface opens in the language of the machine it is installed on.** BabelChat defaults to Russian, which is right for the audience it was written for and wrong for everyone else at the one moment it matters most: the setup wizard is the first thing a new player reads, and it was in Russian whatever their system was set to. On a first run the OS locale now decides, falling back to Russian when it names a language the interface does not have. Both frontends ask the same function, and it reads the environment in the order gettext does — `LANGUAGE` first, which is the variable a user sets precisely to be obeyed and the one such code usually ignores. +## [3.4.1] — 2026-08-26 ### Fixed -- **Changing the interface language on Linux did nothing until the next launch.** The GTK entry point never applied the saved language at all, so it was Russian regardless of what Settings said; and even once applied, the windows already on screen kept the strings they were built with. The overlay relabels itself now, and the setup wizard rebuilds its pages the moment the language dropdown changes rather than at the next launch — carrying across whatever had already been typed into it. -- **The setup wizard reopening no longer overwrites the language you chose.** It reopens whenever no provider is configured, which is not the same as a first run — an expired key does it, and so does a config file written before providers became a registry. Consulting the OS locale there put the machine's opinion over a real preference, and because the welcome page seeds its dropdown from what is on screen and finishing writes that back, clicking straight through would have saved Spanish as German. -- **A GTK setting saved from an old-language window.** The settings window closes on save, as the Windows one always has, so the next opening is built in the language just chosen instead of showing labels in the previous one. +- **The interface opened in Russian whatever language you had chosen.** On Linux the GTK entry point never applied the saved language at all, so the setting in Settings had no effect on anything; and on a first run, before there is a choice to apply, both frontends fell back to the Russian default — so a new player anywhere in the world read the setup wizard, the first thing they see, in a language they may not have. A saved choice is honoured now, and a first run takes its language from the operating system instead, falling back to Russian when the system names one the interface does not have. Windows is asked which language its *interface* is in, which is a different setting there from the one dates and numbers are formatted by, and the one that actually answers the question. +- **Changing the language did not reach windows already on screen.** A widget keeps the string it was built with, so the change took hold only at the next launch. The overlay relabels itself now; the setup wizard rebuilds its pages the moment the language dropdown changes, carrying across whatever had already been typed into it; and saving a new language closes the settings window, because rebuilding it is what reopening it does — any other save leaves it open, with its confirmation where you can read it. --- diff --git a/CHANGELOG_ru.md b/CHANGELOG_ru.md index 5f6734b..a6fbadd 100644 --- a/CHANGELOG_ru.md +++ b/CHANGELOG_ru.md @@ -8,17 +8,12 @@ --- -## [3.5.0] — 2026-08-26 - -### Добавлено - -- **Интерфейс открывается на языке той машины, куда его поставили.** По умолчанию BabelChat говорит по-русски — это верно для тех, для кого он писался, и неверно для всех остальных ровно в тот момент, когда это важнее всего: мастер настройки новичок читает первым, и он был русским независимо от системных настроек. На первом запуске язык теперь выбирает локаль ОС, а если она называет язык, которого у интерфейса нет, остаётся русский. Оба фронтенда спрашивают одну и ту же функцию, и переменные окружения она читает в том порядке, в каком их читает gettext: `LANGUAGE` первой — той самой, которую выставляют, чтобы её послушались, и которую такой код обычно не замечает. +## [3.4.1] — 2026-08-26 ### Исправлено -- **Смена языка интерфейса на Linux не давала ничего до следующего запуска.** GTK-вход сохранённый язык вообще не применял, так что он оставался русским что бы ни было выбрано в настройках; а уже открытые окна и после применения держали те строки, с которыми были построены. Оверлей теперь переподписывает себя сам, а мастер настройки перестраивает страницы в тот момент, когда меняется список языков, а не к следующему запуску — перенося то, что в него уже успели ввести. -- **Повторно открытый мастер больше не затирает выбранный язык.** Он открывается всякий раз, когда не настроен ни один провайдер, а это не то же самое, что первый запуск: так бывает и с протухшим ключом, и с конфигом, написанным до того, как провайдеры стали реестром. Спрашивать там локаль ОС — значит ставить мнение машины выше настоящего выбора, и, поскольку страница приветствия берёт значение списка с экрана, а завершение пишет его обратно, у щёлкнувшего «Далее» испанский сохранился бы как немецкий. -- **Настройка на GTK сохранялась из окна на старом языке.** Окно настроек закрывается после сохранения — так же, как всегда вело себя окно на Windows, — и следующее открытие строится уже на выбранном языке, а не показывает подписи на прежнем. +- **Интерфейс открывался по-русски, какой язык ни выбери.** На Linux GTK-вход сохранённый язык не применял вовсе, так что переключатель в настройках не влиял ни на что; а на первом запуске, когда применять ещё нечего, оба фронтенда откатывались к русскому по умолчанию — и новичок в любой точке мира читал мастер настройки, первое, что он вообще видит, на языке, которого может не знать. Сохранённый выбор теперь соблюдается, а первый запуск берёт язык у операционной системы и остаётся русским, только если система называет язык, которого у интерфейса нет. У Windows спрашивается язык её *интерфейса* — это там отдельная настройка, не та, по которой форматируются даты и числа, и отвечает на вопрос именно она. +- **Смена языка не доходила до уже открытых окон.** Виджет хранит ту строку, с которой был построен, поэтому смена вступала в силу только со следующего запуска. Оверлей теперь переподписывает себя сам; мастер настройки перестраивает страницы в тот момент, когда меняется список языков, перенося всё, что в него успели ввести; а сохранение нового языка закрывает окно настроек, потому что перестроение — это и есть то, что происходит при следующем открытии; любое другое сохранение оставляет окно открытым, вместе с подтверждением, которое можно прочитать. --- diff --git a/README.md b/README.md index 5210aa1..d508edf 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,7 @@ WoW, so an addon-only setup has no egress at all. | Cache | SQLite + LRU | | Build | PyInstaller → .exe (Windows) / AppImage, .deb, .rpm (Linux) | | Addon | Lua 5.1, WoW API | -| Tests | 1040 tests (pytest) | +| Tests | 1131 tests (pytest) | ## Development diff --git a/README_es.md b/README_es.md index 8151c46..cf48e83 100644 --- a/README_es.md +++ b/README_es.md @@ -280,7 +280,7 @@ alguno. | Caché | SQLite + LRU | | Compilación | PyInstaller → .exe (Windows) / AppImage, .deb, .rpm (Linux) | | Addon | Lua 5.1, WoW API | -| Tests | 1040 tests (pytest) | +| Tests | 1131 tests (pytest) | ## Desarrollo diff --git a/README_ru.md b/README_ru.md index 1dabd19..5dec67c 100644 --- a/README_ru.md +++ b/README_ru.md @@ -278,7 +278,7 @@ BabelChat переводит, отправляя текст сообщений | Кэш | SQLite + LRU | | Сборка | PyInstaller → .exe (Windows) / AppImage, .deb, .rpm (Linux) | | Аддон | Lua 5.1, WoW API | -| Тесты | 1040 тестов (pytest) | +| Тесты | 1131 тестов (pytest) | ## Разработка diff --git a/addon/BabelChat/BabelChat.toc b/addon/BabelChat/BabelChat.toc index 6b9b986..4c2790b 100644 --- a/addon/BabelChat/BabelChat.toc +++ b/addon/BabelChat/BabelChat.toc @@ -5,7 +5,7 @@ ## Notes-esES: |cFF33CCFFTraducción de chat en tiempo real — diccionario integrado + overlay con app acompañante.|r ## Notes-esMX: |cFF33CCFFTraducción de chat en tiempo real — diccionario integrado + overlay con app acompañante.|r ## Author: Andrey Yumashev, Pirson -## Version: 3.5.0 +## Version: 3.4.1 ## X-License: MIT ## X-Website: https://github.com/Yumash/BabelChat diff --git a/addon/BabelChat/Config.lua b/addon/BabelChat/Config.lua index a5bc7c6..f0dfb0c 100644 --- a/addon/BabelChat/Config.lua +++ b/addon/BabelChat/Config.lua @@ -68,7 +68,7 @@ function addonTable.CreateConfigUI() local version = panel:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") version:SetPoint("TOP", logo, "BOTTOM", 0, -2) - version:SetText("v" .. (C_AddOns.GetAddOnMetadata(ADDON_NAME, "Version") or "3.5.0")) + version:SetText("v" .. (C_AddOns.GetAddOnMetadata(ADDON_NAME, "Version") or "3.4.1")) -- ════════════════════════════════════ -- SECTION 1: GENERAL diff --git a/app/about_dialog.py b/app/about_dialog.py index d654132..a73493f 100644 --- a/app/about_dialog.py +++ b/app/about_dialog.py @@ -18,7 +18,7 @@ from app.i18n import tr -VERSION = "3.5.0" +VERSION = "3.4.1" ABOUT_STYLESHEET = """ QDialog { diff --git a/app/i18n.py b/app/i18n.py index 64ae399..b656d0b 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -28,7 +28,11 @@ class tr: @classmethod def set_language(cls, lang: str) -> None: - cls._lang = lang if lang in ("RU", "EN", "ES") else "RU" + # Against UI_LANGUAGES, not a tuple repeating it: a fourth translation + # added to the table but not here would be resolved by the locale guess + # and then silently clamped back to Russian on the way in, which is the + # first-run bug this module exists to prevent, wearing a new hat. + cls._lang = lang if lang in UI_LANGUAGES else "RU" @classmethod def get_language(cls) -> str: diff --git a/app/main_gtk.py b/app/main_gtk.py index b4a0c4f..85e4259 100644 --- a/app/main_gtk.py +++ b/app/main_gtk.py @@ -110,23 +110,6 @@ def main() -> int: on_message=overlay.deliver_message, ) - # Start the pipeline only once a translation provider is configured. - # Settings can configure the first provider while the application is - # already running, so this also acts as the startup failsafe used by the - # settings callback below. - pipeline_started = False - - def _start_pipeline_if_ready(updated_config: AppConfig) -> None: - nonlocal pipeline_started - if pipeline_started: - return - if not any_configured(updated_config.providers): - logging.info("pipeline not started: no translation provider configured") - return - - pipeline.start() - pipeline_started = True - def _quit() -> None: try: if tray is not None: @@ -159,9 +142,6 @@ def _on_saved(updated: AppConfig) -> None: # Apply live: rebuild pipeline config (channels/langs). pipeline.update_config(_build_pipeline_config(updated)) - # If this save is what FIRST configured a provider, this is the - # signal the startup failsafe was waiting on, start scanning now. - _start_pipeline_if_ready(updated) # Rebuild the reply translator so API key/priority changes take # effect without a restart. new_translator = TranslatorService.from_config(updated) @@ -230,7 +210,7 @@ def _toggle_translation(enabled: bool) -> None: except Exception: # noqa: BLE001 logging.exception("history load failed (continuing without it)") - _start_pipeline_if_ready(config) + pipeline.start() try: return overlay.run() except KeyboardInterrupt: diff --git a/app/settings_gtk.py b/app/settings_gtk.py index d4282f7..8a2087f 100644 --- a/app/settings_gtk.py +++ b/app/settings_gtk.py @@ -399,6 +399,7 @@ def _on_save(self, _btn: Gtk.Button) -> None: setattr(c, attr, cb.get_active()) c.own_language = self._dd_value(self._own) c.target_language = self._dd_value(self._target) + language_changed = self._dd_value(self._ui) != tr.get_language() c.ui_language = self._dd_value(self._ui) # Applied immediately, the way the Qt dialog does it: a language you # picked and saved that does not take hold reads as the setting being @@ -441,6 +442,11 @@ def _on_save(self, _btn: Gtk.Button) -> None: if self._on_saved is not None: self._on_saved(c) - # The dialog's labels were created in the old language. Close it after - # save so the next opening is rebuilt with the newly selected language. - self._win.close() + + # Every label in here was built in the old language and a GTK widget + # keeps the string it was built with, so a language change has to close + # the window: the next opening is the rebuild. Any other save leaves it + # open — closing on all of them would mean the "Saved" just written two + # lines up is never on screen long enough to read. + if language_changed: + self._win.close() diff --git a/pyproject.toml b/pyproject.toml index d200e3c..22043ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "babelchat" -version = "3.5.0" +version = "3.4.1" description = "Real-time WoW chat translator with smart overlay" requires-python = ">=3.12" diff --git a/tests/test_ui_language_guess.py b/tests/test_ui_language_guess.py index 98e954e..cfb5018 100644 --- a/tests/test_ui_language_guess.py +++ b/tests/test_ui_language_guess.py @@ -347,3 +347,38 @@ def test_the_startup_rule_takes_that_answer(env, config_exists, expected): """Joining the two halves: a recovered config keeps its language, a machine with no config at all gets the guess (RU here, the environment is bare).""" assert startup_ui_language(config_exists=config_exists, saved="ES") == expected + + +# ── the two halves agree on which languages exist ──────────────────────────── + + +def test_setting_a_language_accepts_every_language_the_guess_can_return(): + """These were two lists: `tr.set_language` validated against a tuple while + the guess resolved against UI_LANGUAGES. A fourth translation added to the + table and not the tuple would be found by the guess and then silently + clamped back to Russian on the way in — the first-run bug again, wearing a + new hat, and silent because set_language reports nothing.""" + from app.i18n import tr + + previous = tr.get_language() + try: + for code in UI_LANGUAGES: + tr.set_language(code) + + assert tr.get_language() == code, f"{code} is offered but not accepted" + finally: + tr.set_language(previous) + + +def test_a_language_that_does_not_exist_is_still_refused(): + """Widening the check must not turn it off: the fallback is what keeps a + stale config or a hand-edited one from blanking the interface.""" + from app.i18n import tr + + previous = tr.get_language() + try: + tr.set_language("ZZ") + + assert tr.get_language() == "RU" + finally: + tr.set_language(previous) From 6883962d5e4951ef78e60383808b1345e0ca3802 Mon Sep 17 00:00:00 2001 From: Andrey Yumashev Date: Wed, 26 Aug 2026 15:10:38 +0300 Subject: [PATCH 08/12] Relabel the Windows overlay too, so the changelog stops overpromising MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live language change was fixed on GTK and described in the changelog without qualification, which left every Windows user reading about a repair they had not been given: open_settings applied opacity and nothing else, so the ON/OFF badge, the Settings button, the opacity label, the reply placeholder, the Copy button and the filter tabs all stayed in the language they were built in until the app was restarted. ChatOverlay.apply_language does for Qt what its GTK counterpart already did. Two of the widgets it needs were locals in the chrome builder and are kept on the overlay now. The badge is relabelled from its own state rather than from the string table alone, so a running translation cannot be shown as stopped. The reply panel's status line is deliberately left out: it is rewritten on every action, so it arrives in the new language by itself, and touching it here would overwrite whatever it is currently saying. Also the last hardcoded copy of the language table, in the GTK wizard. A fourth translation added to UI_LANGUAGES and missed there would leave the dropdown falling back to its first entry and _finish persisting that over the language the locale guess had got right — the same silent clamp removed from tr.set_language in the previous commit, one file over. The tests read the source rather than driving the widgets, which is weaker and is written down as a choice: constructing ChatOverlay under pytest kills the interpreter outright, which is why nothing else in the suite instantiates it. What they do hold is the part that rots — a label added to the chrome and forgotten in the refresh fails the run. The behaviour itself was verified by hand, off-screen, against a live overlay: five chrome labels and thirteen filter tabs followed the switch, and the badge kept its state. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 2 +- README.md | 2 +- README_es.md | 2 +- README_ru.md | 2 +- app/main.py | 3 + app/overlay.py | 20 ++++ app/overlay_chrome.py | 4 + app/overlay_widgets.py | 11 ++ app/setup_wizard_gtk.py | 7 +- tests/test_overlay_language_refresh.py | 144 +++++++++++++++++++++++++ 10 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 tests/test_overlay_language_refresh.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73994a8..88aa446 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,5 +60,5 @@ jobs: # tests into skips, and skips still count here. - name: Guard test count env: - MIN_TESTS: "1131" + MIN_TESTS: "1139" run: python .github/scripts/check_test_count.py report.xml ${{ env.MIN_TESTS }} diff --git a/README.md b/README.md index d508edf..a1a8895 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,7 @@ WoW, so an addon-only setup has no egress at all. | Cache | SQLite + LRU | | Build | PyInstaller → .exe (Windows) / AppImage, .deb, .rpm (Linux) | | Addon | Lua 5.1, WoW API | -| Tests | 1131 tests (pytest) | +| Tests | 1139 tests (pytest) | ## Development diff --git a/README_es.md b/README_es.md index cf48e83..3bd2454 100644 --- a/README_es.md +++ b/README_es.md @@ -280,7 +280,7 @@ alguno. | Caché | SQLite + LRU | | Compilación | PyInstaller → .exe (Windows) / AppImage, .deb, .rpm (Linux) | | Addon | Lua 5.1, WoW API | -| Tests | 1131 tests (pytest) | +| Tests | 1139 tests (pytest) | ## Desarrollo diff --git a/README_ru.md b/README_ru.md index 5dec67c..6a07044 100644 --- a/README_ru.md +++ b/README_ru.md @@ -278,7 +278,7 @@ BabelChat переводит, отправляя текст сообщений | Кэш | SQLite + LRU | | Сборка | PyInstaller → .exe (Windows) / AppImage, .deb, .rpm (Linux) | | Аддон | Lua 5.1, WoW API | -| Тесты | 1131 тестов (pytest) | +| Тесты | 1139 тестов (pytest) | ## Разработка diff --git a/app/main.py b/app/main.py index b3cfb55..d078878 100644 --- a/app/main.py +++ b/app/main.py @@ -401,6 +401,9 @@ def open_settings() -> None: config = dialog.get_config() overlay.update_channel_filters(_enabled_filter_names(config)) overlay.apply_settings(config) + # The dialog has already called tr.set_language; the widgets built + # before it did are still holding the old strings. + overlay.apply_language() # Propagate language/channel settings to the pipeline thread new_pipeline_config = _build_pipeline_config(config) pipeline_thread.update_config(new_pipeline_config) diff --git a/app/overlay.py b/app/overlay.py index e3c5679..fee6262 100644 --- a/app/overlay.py +++ b/app/overlay.py @@ -434,3 +434,23 @@ def apply_settings(self, config: AppConfig) -> None: self._bg_opacity = config.overlay_opacity self._opacity_slider.setValue(config.overlay_opacity) self._on_opacity_changed(config.overlay_opacity) + + def apply_language(self) -> None: + """Relabel everything built when the window was, after a language change. + + A Qt widget keeps the string it was constructed with, so setting the + language alone changes nothing that is already on screen — the setting + appeared to do nothing until the next launch, which reads as broken. + + Only the persistent chrome belongs here. The reply panel's status line + is written afresh on every action, so it arrives in the new language by + itself, and relabelling it here would overwrite whatever it is saying. + """ + self._toggle_btn.setText( + tr("overlay.badge.on") if self._translation_enabled else tr("overlay.badge.off") + ) + self._settings_btn.setText(tr("overlay.settings")) + self._opacity_label.setText(tr("overlay.opacity")) + self._reply_input.setPlaceholderText(tr("overlay.reply.input_hint")) + self._reply_copy_btn.setText(tr("overlay.reply.copy")) + self._filter_bar.apply_language() diff --git a/app/overlay_chrome.py b/app/overlay_chrome.py index 276fe37..453cc51 100644 --- a/app/overlay_chrome.py +++ b/app/overlay_chrome.py @@ -119,12 +119,16 @@ def build(overlay: ChatOverlay) -> None: ) settings_btn = QPushButton(tr("overlay.settings")) + # Kept on the overlay so a language change can reach it: a Qt widget holds + # the string it was built with, and a local goes out of scope here. + overlay._settings_btn = settings_btn settings_btn.setFixedHeight(20) settings_btn.setStyleSheet(_TB_BTN) settings_btn.clicked.connect(overlay.settings_requested.emit) tb_layout.addWidget(settings_btn) opacity_label = QLabel(tr("overlay.opacity")) + overlay._opacity_label = opacity_label opacity_label.setStyleSheet("color: #999; font-size: 10px;") tb_layout.addWidget(opacity_label) diff --git a/app/overlay_widgets.py b/app/overlay_widgets.py index b6666f3..00035e5 100644 --- a/app/overlay_widgets.py +++ b/app/overlay_widgets.py @@ -138,6 +138,17 @@ def __init__(self, parent: QWidget | None = None) -> None: layout.addStretch() + def apply_language(self) -> None: + """Relabel the tabs after the interface language changed. + + From the same shared declaration they were built from, so a tab added + there is relabelled here without this method being touched. + """ + for name, label_key in FILTER_TABS: + button = self._buttons.get(name) + if button is not None: + button.setText(tr(label_key)) + def _on_click(self, name: str) -> None: self._active = name for btn_name, btn in self._buttons.items(): diff --git a/app/setup_wizard_gtk.py b/app/setup_wizard_gtk.py index 8c3090a..16364e4 100644 --- a/app/setup_wizard_gtk.py +++ b/app/setup_wizard_gtk.py @@ -22,7 +22,7 @@ from gi.repository import GLib, Gtk # noqa: E402 from app.config import AppConfig, detect_wow_path # noqa: E402 -from app.i18n import tr # noqa: E402 +from app.i18n import UI_LANGUAGES, tr # noqa: E402 from app.translators import all_providers # noqa: E402 from app.translators import get as provider_get # noqa: E402 @@ -39,7 +39,10 @@ ("KO", "한국어"), ("JA", "日本語"), ] -_UI_LANGS = [("EN", "English"), ("RU", "Русский"), ("ES", "Español")] +#: From the one table, not a fourth copy of it: a translation added there and +#: missed here would leave the dropdown falling back to its first entry, and +#: _finish would persist that over the language the guess had got right. +_UI_LANGS = list(UI_LANGUAGES.items()) class _WizardWindow(Gtk.ApplicationWindow): diff --git a/tests/test_overlay_language_refresh.py b/tests/test_overlay_language_refresh.py new file mode 100644 index 0000000..dd78211 --- /dev/null +++ b/tests/test_overlay_language_refresh.py @@ -0,0 +1,144 @@ +"""A language change has to reach the windows already on screen. + +Both overlays build their chrome once and a widget keeps the string it was +constructed with, so setting the interface language changed nothing already +drawn. The setting appeared to do nothing at all until the next launch, which +is indistinguishable from a broken control — and it was fixed on the GTK side +first, which left the changelog promising Windows users a repair they had not +been given. + +Read from the source rather than by driving the widgets. That is weaker, and +it is written down because it is a choice: constructing `ChatOverlay` under +pytest kills the interpreter outright — no traceback, no failure, the run just +stops — which is why nothing else in this suite instantiates it either, and +`gi` is absent on Windows and on CI so the GTK half could not be built here +regardless. The behaviour was verified by hand instead, off-screen, with the +language switched under a live overlay: all five chrome labels and all +thirteen filter tabs followed, and the ON/OFF badge kept its state. + +What the source can still be held to is the thing that actually rots: a label +added to the chrome and forgotten in the refresh. +""" + +from __future__ import annotations + +import ast +import pathlib +import re + +from app.i18n import UI_LANGUAGES + +ROOT = pathlib.Path(__file__).resolve().parent.parent +APP = ROOT / "app" + +#: `tr("some.key")` anywhere in a source file. +TR_KEY = re.compile(r'tr\(\s*"([^"]+)"') + + +def source_of(module: str) -> str: + return (APP / module).read_text(encoding="utf-8") + + +def method_source(module: str, name: str) -> str: + """The text of one method, or "" when the module does not define it.""" + text = source_of(module) + for node in ast.walk(ast.parse(text)): + if isinstance(node, ast.FunctionDef) and node.name == name: + return ast.get_source_segment(text, node) or "" + return "" + + +# ── both frontends can do it at all ────────────────────────────────────────── + + +def test_both_overlays_can_relabel_themselves(): + """This was fixed on GTK and not on Qt while the changelog described it as + fixed outright. Neither half is allowed to be the only one again.""" + for module in ("overlay.py", "overlay_gtk.py"): + assert method_source(module, "apply_language"), f"{module} cannot relabel itself" + + +def test_both_entry_points_call_it_when_settings_are_saved(): + """A refresh that exists and is never called is the same bug with more + code in it.""" + for entry, overlay in (("main.py", "overlay"), ("main_gtk.py", "overlay")): + text = source_of(entry) + calls = {ast.get_source_segment(text, node) for node in ast.walk(ast.parse(text)) if isinstance(node, ast.Call)} + + assert f"{overlay}.apply_language()" in calls, f"{entry} never refreshes the overlay" + + +# ── and neither leaves a label behind ──────────────────────────────────────── + + +def test_every_label_the_qt_chrome_builds_is_refreshed(): + """The failure mode this guards is not today's code, it is next year's: a + label added to the toolbar and forgotten here shows up as one English + word among Russian ones, which nobody reports as a bug.""" + built = set(TR_KEY.findall(source_of("overlay_chrome.py"))) + refreshed = method_source("overlay.py", "apply_language") + + missing = sorted(key for key in built if key not in refreshed) + + assert built, "no translated labels found — has the chrome moved?" + assert missing == [], f"built by the chrome and never refreshed: {missing}" + + +def test_the_qt_refresh_reaches_the_filter_tabs(): + """They are built from the shared FILTER_TABS declaration, so their keys + are not in the chrome's source to be matched above — the bar relabels + itself and the overlay has to ask it to.""" + assert "self._filter_bar.apply_language()" in method_source("overlay.py", "apply_language") + assert method_source("overlay_widgets.py", "apply_language"), "the filter bar cannot relabel" + + +def test_the_widgets_the_qt_refresh_touches_are_kept_somewhere(): + """Two of them were locals in a builder function, so the refresh could + name them but never reach them — an AttributeError on every settings + save.""" + chrome = source_of("overlay_chrome.py") + refresh = method_source("overlay.py", "apply_language") + + # Widgets only — those the refresh calls a method on. `_translation_enabled` + # is read as a value and lives on the overlay itself, not in the chrome. + attributes = set(re.findall(r"self\.(_\w+)\.", refresh)) + + for attribute in attributes: + assert f"overlay.{attribute} =" in chrome or f"overlay.{attribute}=" in chrome, ( + f"apply_language reads self.{attribute}, which the chrome never assigns" + ) + + +def test_the_badge_is_refreshed_from_its_state_not_from_the_table_alone(): + """It is the one label whose text depends on more than the language. + Writing the "on" string unconditionally would show a stopped translation + as running, or the reverse — the label and the behaviour disagreeing is + worse than the label being stale.""" + refresh = method_source("overlay.py", "apply_language") + + assert "overlay.badge.on" in refresh and "overlay.badge.off" in refresh + assert "_translation_enabled" in refresh, "the badge is relabelled without consulting its state" + + +# ── the wizard offers what the app actually has ────────────────────────────── + + +def test_the_gtk_wizard_offers_every_language_the_app_has(): + """`_UI_LANGS` was a hand-written copy of the table. A translation added to + UI_LANGUAGES and missed there leaves the dropdown falling back to its first + entry, and finishing the wizard persists that over the language the locale + guess had got right — the same silent clamp that was just removed from + `tr.set_language`, one file over.""" + assert "_UI_LANGS = list(UI_LANGUAGES.items())" in source_of("setup_wizard_gtk.py") + + +def test_the_language_table_has_the_shape_that_call_produces(): + """`_dropdown` unpacks (code, label) pairs and indexes its codes list by an + upper-case code. A table shaped any other way fails at wizard startup, on + Linux only, where it would be found by a user rather than by this.""" + pairs = list(UI_LANGUAGES.items()) + + assert len(pairs) >= 2 + for code, label in pairs: + assert code == code.upper(), f"{code!r} is not the upper-case form the lookup uses" + assert label and label != code, f"{code!r} has no name to show in the dropdown" From 514fe2860d8963d9523755b4b3cbc8e1a1b295b5 Mon Sep 17 00:00:00 2001 From: Andrey Yumashev Date: Wed, 26 Aug 2026 15:26:59 +0300 Subject: [PATCH 09/12] Finish the language refresh: the clipboard dialog and the tray menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two windows the previous commit did not reach, found by reviewing it. The clipboard reply dialog is a separate window, created on demand by the hotkey and then kept, so it outlives the setting changed after it — being separate is exactly why it was missed. Its placeholder and Copy button relabel now; the status line and output field are written on every action and arrive in the new language by themselves. The tray menu is worse, because it is the one window a user cannot close and reopen to get the new language: built once at startup and never touched again. Its five items were locals except the two that already needed keeping. The first item is written from state, not from the table alone — it says Hide or Show depending on where the overlay is, and relabelling it unconditionally would offer to hide a window that is already hidden. Verified by hand against live widgets, off-screen, the same way as the overlay: both dialog labels and all five tray items follow the switch, and the Hide/Show item keeps its state. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 2 +- README.md | 2 +- README_es.md | 2 +- README_ru.md | 2 +- app/main.py | 4 ++- app/overlay.py | 4 +++ app/overlay_reply.py | 13 +++++++++ app/tray.py | 36 ++++++++++++++++++------ tests/test_overlay_language_refresh.py | 38 ++++++++++++++++++++++---- 9 files changed, 84 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88aa446..04ef90f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,5 +60,5 @@ jobs: # tests into skips, and skips still count here. - name: Guard test count env: - MIN_TESTS: "1139" + MIN_TESTS: "1141" run: python .github/scripts/check_test_count.py report.xml ${{ env.MIN_TESTS }} diff --git a/README.md b/README.md index a1a8895..22c2b09 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,7 @@ WoW, so an addon-only setup has no egress at all. | Cache | SQLite + LRU | | Build | PyInstaller → .exe (Windows) / AppImage, .deb, .rpm (Linux) | | Addon | Lua 5.1, WoW API | -| Tests | 1139 tests (pytest) | +| Tests | 1141 tests (pytest) | ## Development diff --git a/README_es.md b/README_es.md index 3bd2454..890511e 100644 --- a/README_es.md +++ b/README_es.md @@ -280,7 +280,7 @@ alguno. | Caché | SQLite + LRU | | Compilación | PyInstaller → .exe (Windows) / AppImage, .deb, .rpm (Linux) | | Addon | Lua 5.1, WoW API | -| Tests | 1139 tests (pytest) | +| Tests | 1141 tests (pytest) | ## Desarrollo diff --git a/README_ru.md b/README_ru.md index 6a07044..b84008e 100644 --- a/README_ru.md +++ b/README_ru.md @@ -278,7 +278,7 @@ BabelChat переводит, отправляя текст сообщений | Кэш | SQLite + LRU | | Сборка | PyInstaller → .exe (Windows) / AppImage, .deb, .rpm (Linux) | | Аддон | Lua 5.1, WoW API | -| Тесты | 1139 тестов (pytest) | +| Тесты | 1141 тестов (pytest) | ## Разработка diff --git a/app/main.py b/app/main.py index d078878..4c88d29 100644 --- a/app/main.py +++ b/app/main.py @@ -402,8 +402,10 @@ def open_settings() -> None: overlay.update_channel_filters(_enabled_filter_names(config)) overlay.apply_settings(config) # The dialog has already called tr.set_language; the widgets built - # before it did are still holding the old strings. + # before it did are still holding the old strings. The tray menu is + # the one the user cannot close and reopen to refresh. overlay.apply_language() + tray.apply_language() # Propagate language/channel settings to the pipeline thread new_pipeline_config = _build_pipeline_config(config) pipeline_thread.update_config(new_pipeline_config) diff --git a/app/overlay.py b/app/overlay.py index fee6262..5184e85 100644 --- a/app/overlay.py +++ b/app/overlay.py @@ -454,3 +454,7 @@ def apply_language(self) -> None: self._reply_input.setPlaceholderText(tr("overlay.reply.input_hint")) self._reply_copy_btn.setText(tr("overlay.reply.copy")) self._filter_bar.apply_language() + # The clipboard dialog is created on demand and then kept, so it + # outlives the setting that was changed after it. + if self._reply_dialog is not None: + self._reply_dialog.apply_language() diff --git a/app/overlay_reply.py b/app/overlay_reply.py index 923c6ea..1e9f985 100644 --- a/app/overlay_reply.py +++ b/app/overlay_reply.py @@ -148,6 +148,7 @@ def _setup_ui(self) -> None: ) self._copy_btn.clicked.connect(self._copy_result) result_row.addWidget(self._copy_btn) + panel_layout.addLayout(result_row) self._status = QLabel("") @@ -157,6 +158,18 @@ def _setup_ui(self) -> None: layout.addWidget(panel) + def apply_language(self) -> None: + """Relabel what this window was built with, after a language change. + + It is created lazily — by the clipboard hotkey, or the first time the + reply panel is opened — and then kept, so it outlives the setting that + was changed after it. Only the two labels built here: the status line + and the output field are written on every action and arrive in the new + language by themselves. + """ + self._reply_input.setPlaceholderText(tr("overlay.reply.input_hint")) + self._copy_btn.setText(tr("overlay.reply.copy")) + def set_translator(self, translator: TranslatorService, target_lang: str) -> None: self._translator = translator self._target_lang = target_lang diff --git a/app/tray.py b/app/tray.py index 55a77ce..6ceb5e1 100644 --- a/app/tray.py +++ b/app/tray.py @@ -85,23 +85,41 @@ def __init__(self, parent: QWidget | None = None) -> None: self._menu.addSeparator() - settings_action = QAction(tr("tray.settings")) - settings_action.triggered.connect(self.settings_requested) - self._menu.addAction(settings_action) + # Kept on the icon, not local: a menu built once holds the strings it + # was built with, and the tray is the one window a user cannot close + # and reopen to get the new language. + self._settings_action = QAction(tr("tray.settings")) + self._settings_action.triggered.connect(self.settings_requested) + self._menu.addAction(self._settings_action) - about_action = QAction(tr("tray.about")) - about_action.triggered.connect(self.about_requested) - self._menu.addAction(about_action) + self._about_action = QAction(tr("tray.about")) + self._about_action.triggered.connect(self.about_requested) + self._menu.addAction(self._about_action) self._menu.addSeparator() - quit_action = QAction(tr("tray.quit")) - quit_action.triggered.connect(self.quit_requested) - self._menu.addAction(quit_action) + self._quit_action = QAction(tr("tray.quit")) + self._quit_action.triggered.connect(self.quit_requested) + self._menu.addAction(self._quit_action) self.setContextMenu(self._menu) self.activated.connect(self._on_activated) + def apply_language(self) -> None: + """Relabel the menu after the interface language changed. + + The first item is written from state rather than from the table alone: + it says Hide or Show depending on where the overlay is, and relabelling + it unconditionally would offer to hide a window that is already hidden. + """ + self._show_action.setText( + tr("tray.hide_overlay") if self._overlay_visible else tr("tray.show_overlay") + ) + self._translate_action.setText(tr("tray.toggle_translation")) + self._settings_action.setText(tr("tray.settings")) + self._about_action.setText(tr("tray.about")) + self._quit_action.setText(tr("tray.quit")) + def _toggle_overlay(self) -> None: if self._overlay_visible: self._overlay_visible = False diff --git a/tests/test_overlay_language_refresh.py b/tests/test_overlay_language_refresh.py index dd78211..317b9da 100644 --- a/tests/test_overlay_language_refresh.py +++ b/tests/test_overlay_language_refresh.py @@ -84,6 +84,30 @@ def test_every_label_the_qt_chrome_builds_is_refreshed(): assert missing == [], f"built by the chrome and never refreshed: {missing}" +def test_the_clipboard_dialog_is_refreshed_too(): + """It is a separate window, created on demand by the hotkey and then kept, + so it outlives the setting changed after it — and being separate is exactly + why it was missed.""" + refresh = method_source("overlay.py", "apply_language") + + assert "self._reply_dialog.apply_language()" in refresh + assert method_source("overlay_reply.py", "apply_language"), "the dialog cannot relabel itself" + + +def test_the_tray_menu_is_refreshed_too(): + """The tray is the one window a user cannot close and reopen to get the new + language — it is only ever built once, at startup.""" + text = source_of("main.py") + calls = { + ast.get_source_segment(text, node) + for node in ast.walk(ast.parse(text)) + if isinstance(node, ast.Call) + } + + assert "tray.apply_language()" in calls, "the tray menu keeps the old language" + assert method_source("tray.py", "apply_language"), "the tray cannot relabel itself" + + def test_the_qt_refresh_reaches_the_filter_tabs(): """They are built from the shared FILTER_TABS declaration, so their keys are not in the chrome's source to be matched above — the bar relabels @@ -96,16 +120,20 @@ def test_the_widgets_the_qt_refresh_touches_are_kept_somewhere(): """Two of them were locals in a builder function, so the refresh could name them but never reach them — an AttributeError on every settings save.""" - chrome = source_of("overlay_chrome.py") - refresh = method_source("overlay.py", "apply_language") + # Assigned by the chrome builder as `overlay._x`, or by the overlay itself + # as `self._x` — the lazily created clipboard dialog is the second kind. + assigned = set(re.findall(r"overlay\.(_\w+)\s*=", source_of("overlay_chrome.py"))) + assigned |= set(re.findall(r"self\.(_\w+)\s*=", source_of("overlay.py"))) + refresh = method_source("overlay.py", "apply_language") # Widgets only — those the refresh calls a method on. `_translation_enabled` - # is read as a value and lives on the overlay itself, not in the chrome. + # is read as a value, not sent a message. attributes = set(re.findall(r"self\.(_\w+)\.", refresh)) + assert attributes, "the refresh touches no widgets at all" for attribute in attributes: - assert f"overlay.{attribute} =" in chrome or f"overlay.{attribute}=" in chrome, ( - f"apply_language reads self.{attribute}, which the chrome never assigns" + assert attribute in assigned, ( + f"apply_language calls self.{attribute}, which nothing ever assigns" ) From 9d31c1b18f8485c70e5aef1cf196cff58c2933ef Mon Sep 17 00:00:00 2001 From: Andrey Yumashev Date: Wed, 26 Aug 2026 15:34:37 +0300 Subject: [PATCH 10/12] Move the single-instance guard out of the entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main.py crossed the five-hundred-line limit on the last commit, and the hundred and thirty lines it was carrying for this had nothing to do with the job the rest of the file does. Wiring the application together is one thing; deciding whether another copy of it is already running is another, and the second is the half with the sharp edge, because it terminates a process. Nothing about it changed. Its tests were already a file of their own and already named after it — they point at the module now instead of reaching through main, which as a side effect lets them run where PyQt6 is not installed, since the guard never needed Qt. main.py: 504 lines to 370. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 2 +- README.md | 2 +- README_es.md | 2 +- README_ru.md | 2 +- app/main.py | 136 +---------------------------- app/single_instance.py | 160 ++++++++++++++++++++++++++++++++++ tests/test_single_instance.py | 60 ++++++------- 7 files changed, 194 insertions(+), 170 deletions(-) create mode 100644 app/single_instance.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04ef90f..ce4d678 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,5 +60,5 @@ jobs: # tests into skips, and skips still count here. - name: Guard test count env: - MIN_TESTS: "1141" + MIN_TESTS: "1142" run: python .github/scripts/check_test_count.py report.xml ${{ env.MIN_TESTS }} diff --git a/README.md b/README.md index 22c2b09..88d5601 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,7 @@ WoW, so an addon-only setup has no egress at all. | Cache | SQLite + LRU | | Build | PyInstaller → .exe (Windows) / AppImage, .deb, .rpm (Linux) | | Addon | Lua 5.1, WoW API | -| Tests | 1141 tests (pytest) | +| Tests | 1142 tests (pytest) | ## Development diff --git a/README_es.md b/README_es.md index 890511e..b5d24bb 100644 --- a/README_es.md +++ b/README_es.md @@ -280,7 +280,7 @@ alguno. | Caché | SQLite + LRU | | Compilación | PyInstaller → .exe (Windows) / AppImage, .deb, .rpm (Linux) | | Addon | Lua 5.1, WoW API | -| Tests | 1141 tests (pytest) | +| Tests | 1142 tests (pytest) | ## Desarrollo diff --git a/README_ru.md b/README_ru.md index b84008e..48ee0a3 100644 --- a/README_ru.md +++ b/README_ru.md @@ -278,7 +278,7 @@ BabelChat переводит, отправляя текст сообщений | Кэш | SQLite + LRU | | Сборка | PyInstaller → .exe (Windows) / AppImage, .deb, .rpm (Linux) | | Аддон | Lua 5.1, WoW API | -| Тесты | 1141 тестов (pytest) | +| Тесты | 1142 тестов (pytest) | ## Разработка diff --git a/app/main.py b/app/main.py index 4c88d29..d4da30a 100644 --- a/app/main.py +++ b/app/main.py @@ -22,6 +22,7 @@ from app.parser import Channel from app.pipeline import PipelineConfig, TranslationPipeline from app.settings_dialog import SettingsDialog +from app.single_instance import _ensure_single_instance from app.translator import TranslatorService, any_configured from app.tray import TrayIcon @@ -187,141 +188,6 @@ def _setup_console(visible: bool) -> None: _console_initialized = True -def _get_lock_file() -> str: - if getattr(__import__("sys"), "frozen", False): - lock_dir = os.path.join(os.path.expanduser("~"), ".config", "BabelChat") - os.makedirs(lock_dir, exist_ok=True) - return os.path.join(lock_dir, "babelchat.lock") - return os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "babelchat.lock") - - -_LOCK_FILE = _get_lock_file() - - -def _linux_start_time(stat_line: str) -> str: - """`starttime` out of a line of /proc//stat. - - Separate from the reading so it can be tested where /proc does not exist, - which is where this project is developed. The parsing is not obvious: field - two is the executable name in parentheses, and it may itself contain spaces - and parentheses — `(my prog) (v2)` is a legal name — so splitting the line - on whitespace from the left puts every later field at an offset that - depends on what the process is called. Counting from the last ')' is the - documented way round it. starttime is field 22, and the last ')' ends field - two, so it is index 19 in what follows. - """ - return stat_line.rpartition(")")[2].split()[19] - - -def _start_stamp(pid: int) -> str | None: - """When the process at `pid` started, as the operating system recorded it. - - A PID on its own does not identify a process for longer than that process - lives: Windows hands the numbers back out, and Linux wraps them. The lock - file outlives the copy that wrote it, so by the time it is read the number - in it may belong to something the user very much wants to keep running. - - Paired with the PID, the start time is unique — a process that took over the - number necessarily started later. Returns None when the answer is unknown, - which the caller must treat as "do not touch it". - """ - try: - if sys.platform == "win32": - PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 - kernel32 = ctypes.windll.kernel32 - handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) - if not handle: - return None - try: - created = ctypes.c_ulonglong() - exited = ctypes.c_ulonglong() - kernel_time = ctypes.c_ulonglong() - user_time = ctypes.c_ulonglong() - ok = kernel32.GetProcessTimes( - handle, - ctypes.byref(created), - ctypes.byref(exited), - ctypes.byref(kernel_time), - ctypes.byref(user_time), - ) - return str(created.value) if ok else None - finally: - kernel32.CloseHandle(handle) - - if sys.platform.startswith("linux"): - with open(f"/proc/{pid}/stat", encoding="utf-8") as f: - return _linux_start_time(f.read()) - except (OSError, ValueError, IndexError): - return None - - return None - - -def _terminate(pid: int) -> None: - """Stop the previous copy. Only ever called for a verified match.""" - if sys.platform == "win32": - PROCESS_TERMINATE = 0x0001 - SYNCHRONIZE = 0x00100000 - kernel32 = ctypes.windll.kernel32 - handle = kernel32.OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, False, pid) - if not handle: - logger.info("Old PID %d is already gone", pid) - return - kernel32.TerminateProcess(handle, 0) - kernel32.WaitForSingleObject(handle, 2000) - kernel32.CloseHandle(handle) - else: - import time as _time - - try: - os.kill(pid, signal.SIGTERM) - except (ProcessLookupError, PermissionError): - logger.info("Old PID %d is already gone", pid) - return - _time.sleep(0.5) - logger.info("Stopped the previous instance, PID %d", pid) - - -def _ensure_single_instance() -> None: - """Stop the previous copy of BabelChat, and nothing else. - - The lock file carries the PID and the start stamp of the process that wrote - it. Both must match a live process before anything is terminated; a lock - with only a PID — written by a version before this check existed — matches - nothing, so an upgrade leaves the running copy for the user to close rather - than gambling on the number. - """ - lock_path = os.path.abspath(_LOCK_FILE) - if os.path.exists(lock_path): - try: - with open(lock_path, encoding="utf-8") as f: - recorded = f.read().splitlines() - old_pid = int(recorded[0].strip()) - was = recorded[1].strip() if len(recorded) > 1 else "" - now = _start_stamp(old_pid) - - # One condition, deliberately. An earlier version spelled the three - # ways this can fail as three branches, and each of them turned out - # to be unreachable — the comparison below already rejects a missing - # stamp, an unknown one and a mismatched one. Branches that cannot - # change the outcome cannot be tested either, and they read as if - # they were load-bearing. - if now and now == was: - _terminate(old_pid) - else: - logger.info( - "Leaving PID %d alone: the lock says it started at %s, the live process says %s", - old_pid, - was or "(nothing)", - now or "(nothing there)", - ) - except (OSError, ValueError, IndexError) as e: - logger.warning("Could not read the lock file: %s", e) - - with open(lock_path, "w", encoding="utf-8") as f: - f.write(f"{os.getpid()}\n{_start_stamp(os.getpid()) or ''}\n") - - def main() -> int: load_dotenv() diff --git a/app/single_instance.py b/app/single_instance.py new file mode 100644 index 0000000..6a1e1c6 --- /dev/null +++ b/app/single_instance.py @@ -0,0 +1,160 @@ +"""Only one copy of BabelChat runs at a time, and the old one steps aside. + +Split out of the entry point, which had grown past the line limit and was +carrying two unrelated jobs: wiring the application together, and deciding +whether another copy of it is already running. This is the second one, and it +is the half with the sharp edge — it terminates a process — so it is easier to +find and to read on its own. + +The sharp edge, in one paragraph: the lock file used to hold a bare PID, and +startup opened that PID with PROCESS_TERMINATE and killed it. Windows hands +PIDs back out, so the number written yesterday may belong to the user's editor +today. The file records the start stamp beside the PID now; a process that +took over the number necessarily started later, so the pair identifies the +copy we meant and nothing else. +""" + +from __future__ import annotations + +import ctypes +import logging +import os +import signal +import sys + +logger = logging.getLogger(__name__) + + +def _get_lock_file() -> str: + if getattr(__import__("sys"), "frozen", False): + lock_dir = os.path.join(os.path.expanduser("~"), ".config", "BabelChat") + os.makedirs(lock_dir, exist_ok=True) + return os.path.join(lock_dir, "babelchat.lock") + return os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "babelchat.lock") + + +_LOCK_FILE = _get_lock_file() + + +def _linux_start_time(stat_line: str) -> str: + """`starttime` out of a line of /proc//stat. + + Separate from the reading so it can be tested where /proc does not exist, + which is where this project is developed. The parsing is not obvious: field + two is the executable name in parentheses, and it may itself contain spaces + and parentheses — `(my prog) (v2)` is a legal name — so splitting the line + on whitespace from the left puts every later field at an offset that + depends on what the process is called. Counting from the last ')' is the + documented way round it. starttime is field 22, and the last ')' ends field + two, so it is index 19 in what follows. + """ + return stat_line.rpartition(")")[2].split()[19] + + +def _start_stamp(pid: int) -> str | None: + """When the process at `pid` started, as the operating system recorded it. + + A PID on its own does not identify a process for longer than that process + lives: Windows hands the numbers back out, and Linux wraps them. The lock + file outlives the copy that wrote it, so by the time it is read the number + in it may belong to something the user very much wants to keep running. + + Paired with the PID, the start time is unique — a process that took over the + number necessarily started later. Returns None when the answer is unknown, + which the caller must treat as "do not touch it". + """ + try: + if sys.platform == "win32": + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + kernel32 = ctypes.windll.kernel32 + handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not handle: + return None + try: + created = ctypes.c_ulonglong() + exited = ctypes.c_ulonglong() + kernel_time = ctypes.c_ulonglong() + user_time = ctypes.c_ulonglong() + ok = kernel32.GetProcessTimes( + handle, + ctypes.byref(created), + ctypes.byref(exited), + ctypes.byref(kernel_time), + ctypes.byref(user_time), + ) + return str(created.value) if ok else None + finally: + kernel32.CloseHandle(handle) + + if sys.platform.startswith("linux"): + with open(f"/proc/{pid}/stat", encoding="utf-8") as f: + return _linux_start_time(f.read()) + except (OSError, ValueError, IndexError): + return None + + return None + + +def _terminate(pid: int) -> None: + """Stop the previous copy. Only ever called for a verified match.""" + if sys.platform == "win32": + PROCESS_TERMINATE = 0x0001 + SYNCHRONIZE = 0x00100000 + kernel32 = ctypes.windll.kernel32 + handle = kernel32.OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, False, pid) + if not handle: + logger.info("Old PID %d is already gone", pid) + return + kernel32.TerminateProcess(handle, 0) + kernel32.WaitForSingleObject(handle, 2000) + kernel32.CloseHandle(handle) + else: + import time as _time + + try: + os.kill(pid, signal.SIGTERM) + except (ProcessLookupError, PermissionError): + logger.info("Old PID %d is already gone", pid) + return + _time.sleep(0.5) + logger.info("Stopped the previous instance, PID %d", pid) + + +def _ensure_single_instance() -> None: + """Stop the previous copy of BabelChat, and nothing else. + + The lock file carries the PID and the start stamp of the process that wrote + it. Both must match a live process before anything is terminated; a lock + with only a PID — written by a version before this check existed — matches + nothing, so an upgrade leaves the running copy for the user to close rather + than gambling on the number. + """ + lock_path = os.path.abspath(_LOCK_FILE) + if os.path.exists(lock_path): + try: + with open(lock_path, encoding="utf-8") as f: + recorded = f.read().splitlines() + old_pid = int(recorded[0].strip()) + was = recorded[1].strip() if len(recorded) > 1 else "" + now = _start_stamp(old_pid) + + # One condition, deliberately. An earlier version spelled the three + # ways this can fail as three branches, and each of them turned out + # to be unreachable — the comparison below already rejects a missing + # stamp, an unknown one and a mismatched one. Branches that cannot + # change the outcome cannot be tested either, and they read as if + # they were load-bearing. + if now and now == was: + _terminate(old_pid) + else: + logger.info( + "Leaving PID %d alone: the lock says it started at %s, the live process says %s", + old_pid, + was or "(nothing)", + now or "(nothing there)", + ) + except (OSError, ValueError, IndexError) as e: + logger.warning("Could not read the lock file: %s", e) + + with open(lock_path, "w", encoding="utf-8") as f: + f.write(f"{os.getpid()}\n{_start_stamp(os.getpid()) or ''}\n") diff --git a/tests/test_single_instance.py b/tests/test_single_instance.py index 239ac11..dbd21da 100644 --- a/tests/test_single_instance.py +++ b/tests/test_single_instance.py @@ -19,19 +19,17 @@ import pytest -pytest.importorskip("PyQt6", reason="main imports the Qt frontend") - -from app import main as app_main # noqa: E402 +from app import single_instance @pytest.fixture def lock(tmp_path, monkeypatch): """Point the module at a lock file of our own and count the kills.""" path = tmp_path / "babelchat.lock" - monkeypatch.setattr(app_main, "_LOCK_FILE", str(path)) + monkeypatch.setattr(single_instance, "_LOCK_FILE", str(path)) killed: list[int] = [] - monkeypatch.setattr(app_main, "_terminate", lambda pid: killed.append(pid)) + monkeypatch.setattr(single_instance, "_terminate", lambda pid: killed.append(pid)) return SimpleNamespace(path=path, killed=killed) @@ -39,9 +37,9 @@ def lock(tmp_path, monkeypatch): def test_a_reused_pid_is_not_killed(lock, monkeypatch): """The whole point. Same number, different process — leave it alone.""" lock.path.write_text("4242\nstarted-yesterday\n", encoding="utf-8") - monkeypatch.setattr(app_main, "_start_stamp", lambda pid: "started-today") + monkeypatch.setattr(single_instance, "_start_stamp", lambda pid: "started-today") - app_main._ensure_single_instance() + single_instance._ensure_single_instance() assert lock.killed == [], "terminated a process that only shares the number" @@ -49,9 +47,9 @@ def test_a_reused_pid_is_not_killed(lock, monkeypatch): def test_the_previous_copy_is_killed(lock, monkeypatch): """And the feature still works: same number, same process.""" lock.path.write_text("4242\nstarted-yesterday\n", encoding="utf-8") - monkeypatch.setattr(app_main, "_start_stamp", lambda pid: "started-yesterday") + monkeypatch.setattr(single_instance, "_start_stamp", lambda pid: "started-yesterday") - app_main._ensure_single_instance() + single_instance._ensure_single_instance() assert lock.killed == [4242] @@ -61,9 +59,9 @@ def test_an_unknown_stamp_kills_nothing(lock, monkeypatch): query failed for some other reason. Both are 'I do not know', and killing on 'I do not know' is what this test exists to prevent.""" lock.path.write_text("4242\nstarted-yesterday\n", encoding="utf-8") - monkeypatch.setattr(app_main, "_start_stamp", lambda pid: None) + monkeypatch.setattr(single_instance, "_start_stamp", lambda pid: None) - app_main._ensure_single_instance() + single_instance._ensure_single_instance() assert lock.killed == [] @@ -73,9 +71,9 @@ def test_a_lock_from_an_older_version_kills_nothing(lock, monkeypatch): There is nothing to compare against, so the old copy is left running and the user closes it by hand. Nobody's unrelated process dies for the upgrade.""" lock.path.write_text("4242", encoding="utf-8") - monkeypatch.setattr(app_main, "_start_stamp", lambda pid: "started-today") + monkeypatch.setattr(single_instance, "_start_stamp", lambda pid: "started-today") - app_main._ensure_single_instance() + single_instance._ensure_single_instance() assert lock.killed == [] @@ -86,42 +84,42 @@ def test_an_empty_stamp_never_matches_an_old_lock(lock, monkeypatch): bare PID again — the exact bug, reintroduced by a plausible edit somewhere else entirely.""" lock.path.write_text("4242\n\n", encoding="utf-8") - monkeypatch.setattr(app_main, "_start_stamp", lambda pid: "") + monkeypatch.setattr(single_instance, "_start_stamp", lambda pid: "") - app_main._ensure_single_instance() + single_instance._ensure_single_instance() assert lock.killed == [] def test_a_damaged_lock_does_not_stop_the_app(lock, monkeypatch): lock.path.write_text("not a pid at all", encoding="utf-8") - monkeypatch.setattr(app_main, "_start_stamp", lambda pid: "started-today") + monkeypatch.setattr(single_instance, "_start_stamp", lambda pid: "started-today") - app_main._ensure_single_instance() + single_instance._ensure_single_instance() assert lock.killed == [] - assert lock.path.read_text(encoding="utf-8").splitlines()[0] == str(app_main.os.getpid()) + assert lock.path.read_text(encoding="utf-8").splitlines()[0] == str(single_instance.os.getpid()) def test_the_lock_records_this_process(lock, monkeypatch): - monkeypatch.setattr(app_main, "_start_stamp", lambda pid: f"stamp-of-{pid}") + monkeypatch.setattr(single_instance, "_start_stamp", lambda pid: f"stamp-of-{pid}") - app_main._ensure_single_instance() + single_instance._ensure_single_instance() pid, stamp = lock.path.read_text(encoding="utf-8").splitlines()[:2] - assert pid == str(app_main.os.getpid()) - assert stamp == f"stamp-of-{app_main.os.getpid()}" + assert pid == str(single_instance.os.getpid()) + assert stamp == f"stamp-of-{single_instance.os.getpid()}" def test_the_lock_is_written_even_with_no_stamp_available(lock, monkeypatch): """A platform we cannot query still gets single-instance behaviour on the next run — it just never kills anything. Writing no lock at all would leave stale files around and change nothing for the better.""" - monkeypatch.setattr(app_main, "_start_stamp", lambda pid: None) + monkeypatch.setattr(single_instance, "_start_stamp", lambda pid: None) - app_main._ensure_single_instance() + single_instance._ensure_single_instance() - assert lock.path.read_text(encoding="utf-8").splitlines()[0] == str(app_main.os.getpid()) + assert lock.path.read_text(encoding="utf-8").splitlines()[0] == str(single_instance.os.getpid()) # ── the stamp itself, against the real operating system ────────────────────── @@ -131,7 +129,7 @@ def test_this_process_has_a_stamp(): """A test double is only worth something if the real thing behaves the same way. Windows and Linux are both covered by the implementation; anywhere else the function is allowed to say it does not know.""" - stamp = app_main._start_stamp(app_main.os.getpid()) + stamp = single_instance._start_stamp(single_instance.os.getpid()) if sys.platform in ("win32", "linux"): assert stamp, f"no start stamp for our own process on {sys.platform}" @@ -141,7 +139,7 @@ def test_this_process_has_a_stamp(): def test_a_pid_that_cannot_exist_has_no_stamp(): """PIDs are bounded; this one is past the end on both platforms.""" - assert app_main._start_stamp(0x7FFFFFFF) is None + assert single_instance._start_stamp(0x7FFFFFFF) is None # ── /proc parsing, testable where there is no /proc ────────────────────────── @@ -156,7 +154,7 @@ def test_a_pid_that_cannot_exist_has_no_stamp(): def test_the_start_time_is_read_from_the_documented_field(): - assert app_main._linux_start_time(STAT) == "8654321" + assert single_instance._linux_start_time(STAT) == "8654321" def test_a_command_name_full_of_spaces_and_brackets_does_not_shift_the_field(): @@ -166,13 +164,13 @@ def test_a_command_name_full_of_spaces_and_brackets_does_not_shift_the_field(): also how a process could choose a name that makes it look like ours.""" hostile = STAT.replace("(BabelChat)", "(evil ) prog (x) )") - assert app_main._linux_start_time(hostile) == "8654321" + assert single_instance._linux_start_time(hostile) == "8654321" def test_the_stamp_is_stable_across_calls(): """It is compared between two runs of the program, so a stamp that changed between calls would make every previous copy look like a stranger and the single-instance behaviour would quietly stop working.""" - pid = app_main.os.getpid() + pid = single_instance.os.getpid() - assert app_main._start_stamp(pid) == app_main._start_stamp(pid) + assert single_instance._start_stamp(pid) == single_instance._start_stamp(pid) From 26cfc302ffee70fa0e8f4f894e987e1d2961fbfa Mon Sep 17 00:00:00 2001 From: Andrey Yumashev Date: Wed, 26 Aug 2026 15:58:36 +0300 Subject: [PATCH 11/12] Translate the Linux tray, and stop the Qt wizard forgetting what you typed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two asymmetries a final review found, both of the same kind as the rest of this release: one frontend was fixed and the other was described as fixed. Changing the language in the Qt wizard restarts it, and the new one builds its fields from the config — so anything not written to the config first is simply gone. An API key pasted on page two, the WoW path browsed for on page three: both discarded, silently, by the one control on page one. The GTK wizard got a snapshot and restore in this branch and the changelog describes the carry-across without naming a platform. The restart writes the two fields first now, the same two calls _finish makes. The GTK tray menu was five English literals. The keys have existed as long as the Qt tray has been using them; the menu simply never went through tr(), so a Russian player got a Russian overlay above an English tray whatever they picked. It is translated now and relabelled on save, because the tray is built once at startup and there is no reopening it to get the new language. Its first item is written from state rather than the table alone, so it cannot offer to hide a window that is already hidden. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 2 +- README.md | 2 +- README_es.md | 2 +- README_ru.md | 2 +- app/main_gtk.py | 27 ++++++++++---- app/setup_wizard.py | 7 ++++ tests/test_overlay_language_refresh.py | 50 ++++++++++++++++++++++++++ 7 files changed, 82 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce4d678..1ea49f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,5 +60,5 @@ jobs: # tests into skips, and skips still count here. - name: Guard test count env: - MIN_TESTS: "1142" + MIN_TESTS: "1146" run: python .github/scripts/check_test_count.py report.xml ${{ env.MIN_TESTS }} diff --git a/README.md b/README.md index 88d5601..ec60da9 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,7 @@ WoW, so an addon-only setup has no egress at all. | Cache | SQLite + LRU | | Build | PyInstaller → .exe (Windows) / AppImage, .deb, .rpm (Linux) | | Addon | Lua 5.1, WoW API | -| Tests | 1142 tests (pytest) | +| Tests | 1146 tests (pytest) | ## Development diff --git a/README_es.md b/README_es.md index b5d24bb..1e9c4c9 100644 --- a/README_es.md +++ b/README_es.md @@ -280,7 +280,7 @@ alguno. | Caché | SQLite + LRU | | Compilación | PyInstaller → .exe (Windows) / AppImage, .deb, .rpm (Linux) | | Addon | Lua 5.1, WoW API | -| Tests | 1142 tests (pytest) | +| Tests | 1146 tests (pytest) | ## Desarrollo diff --git a/README_ru.md b/README_ru.md index 48ee0a3..798b21e 100644 --- a/README_ru.md +++ b/README_ru.md @@ -278,7 +278,7 @@ BabelChat переводит, отправляя текст сообщений | Кэш | SQLite + LRU | | Сборка | PyInstaller → .exe (Windows) / AppImage, .deb, .rpm (Linux) | | Аддон | Lua 5.1, WoW API | -| Тесты | 1142 тестов (pytest) | +| Тесты | 1146 тестов (pytest) | ## Разработка diff --git a/app/main_gtk.py b/app/main_gtk.py index 85e4259..327d55a 100644 --- a/app/main_gtk.py +++ b/app/main_gtk.py @@ -139,6 +139,13 @@ def _on_saved(updated: AppConfig) -> None: # strings produced by tr() at construction time. tr.set_language(updated.ui_language) overlay.apply_language() + if tray is not None: + # The tray is built once at startup and there is no reopening + # it, so nothing else will ever bring it into the new language. + tray.update_item("overlay", label=_overlay_item_label()) + tray.update_item("tr", label=tr("tray.toggle_translation")) + tray.update_item("settings", label=tr("tray.settings")) + tray.update_item("quit", label=tr("tray.quit")) # Apply live: rebuild pipeline config (channels/langs). pipeline.update_config(_build_pipeline_config(updated)) @@ -170,10 +177,18 @@ def _icon_path() -> str | None: path = os.path.join(base, "assets", "icon.png") return path if os.path.exists(path) else None + #: Whether the overlay is on screen, which decides what the first tray item + #: offers to do. Kept here so relabelling the menu after a language change + #: does not have to guess, and cannot offer to hide a hidden window. + overlay_visible = [True] + + def _overlay_item_label() -> str: + return tr("tray.hide_overlay") if overlay_visible[0] else tr("tray.show_overlay") + def _tray_toggle_overlay() -> None: - visible = overlay.toggle_visible() + overlay_visible[0] = overlay.toggle_visible() if tray is not None: - tray.update_item("overlay", label="Hide overlay" if visible else "Show overlay") + tray.update_item("overlay", label=_overlay_item_label()) def _tray_toggle_tr() -> None: overlay.set_translation_active(not pipeline.translation_enabled) @@ -191,12 +206,12 @@ def _toggle_translation(enabled: bool) -> None: on_activate=_tray_toggle_overlay, on_secondary_activate=_tray_toggle_tr, items=[ - MenuItem("overlay", "Hide overlay", _tray_toggle_overlay), - MenuItem("tr", "Translation", _tray_toggle_tr, checkable=True, + MenuItem("overlay", _overlay_item_label(), _tray_toggle_overlay), + MenuItem("tr", tr("tray.toggle_translation"), _tray_toggle_tr, checkable=True, checked=bool(config.translation_enabled_default)), - MenuItem("settings", "Settings…", _open_settings), + MenuItem("settings", tr("tray.settings"), _open_settings), MenuItem(), # separator - MenuItem("quit", "Quit", _quit), + MenuItem("quit", tr("tray.quit"), _quit), ], ) except Exception: # noqa: BLE001 — tray is optional; never block startup diff --git a/app/setup_wizard.py b/app/setup_wizard.py index 17e6d85..757b1bd 100644 --- a/app/setup_wizard.py +++ b/app/setup_wizard.py @@ -152,6 +152,13 @@ def _on_ui_lang_changed(self) -> None: if lang and lang != tr.get_language(): tr.set_language(lang) self._config.ui_language = lang + # Everything typed so far goes into the config first. Restarting is + # how this wizard changes language, and the new one builds its + # fields from the config — so anything not written here is simply + # gone: an API key pasted on page two, the WoW path browsed for on + # page three. Same two calls _finish makes, for the same reason. + self._provider_group.apply_to(self._config) + self._config.wow_path = self._wow_path_input.text().strip() # Signal main to restart wizard with new language self._restart_requested = True self.done(2) # Custom result code: restart diff --git a/tests/test_overlay_language_refresh.py b/tests/test_overlay_language_refresh.py index 317b9da..51dd185 100644 --- a/tests/test_overlay_language_refresh.py +++ b/tests/test_overlay_language_refresh.py @@ -170,3 +170,53 @@ def test_the_language_table_has_the_shape_that_call_produces(): for code, label in pairs: assert code == code.upper(), f"{code!r} is not the upper-case form the lookup uses" assert label and label != code, f"{code!r} has no name to show in the dropdown" + + +# ── the tray on Linux, and what the wizard remembers ───────────────────────── + + +def test_the_gtk_tray_menu_is_translated_at_all(): + """It was five English literals. The keys existed and the Qt tray had been + using them since it was written; the GTK menu simply never went through + `tr`, so a Russian user got a Russian overlay above an English tray.""" + text = source_of("main_gtk.py") + items = re.findall(r"MenuItem\(\s*\"[^\"]+\"\s*,\s*([^,)]+)", text) + + assert items, "no menu items found — has the tray moved?" + for label in items: + assert "tr(" in label or label.strip().startswith("_"), f"hardcoded tray label: {label.strip()}" + + +def test_the_gtk_tray_is_refreshed_when_settings_are_saved(): + """Same reason as the Qt one: the tray is built once at startup and there + is no reopening it, so nothing else will ever bring it into the language + the user just picked.""" + text = source_of("main_gtk.py") + saved = method_source("main_gtk.py", "_on_saved") + + assert saved, "the settings callback has moved" + assert "tray.update_item" in saved, "the tray keeps the old language" + assert 'tr("tray.settings")' in text, "the menu is refreshed with something other than the table" + + +def test_the_gtk_tray_overlay_item_is_written_from_state(): + """It says Hide or Show depending on where the overlay is. Relabelling it + from the table alone would offer to hide a window that is already hidden — + the same trap as the translation badge.""" + label = method_source("main_gtk.py", "_overlay_item_label") + + assert label, "the label is not derived anywhere" + assert "tray.hide_overlay" in label and "tray.show_overlay" in label + + +def test_the_qt_wizard_keeps_what_was_typed_across_a_language_change(): + """Changing the language restarts this wizard, and the new one builds its + fields from the config — so anything not written to the config first is + gone: an API key pasted on page two, the WoW path browsed for on page + three. The GTK wizard snapshots and restores; the changelog describes the + carry-across without naming a platform.""" + handler = method_source("setup_wizard.py", "_on_ui_lang_changed") + + assert handler, "the language handler has moved" + assert "apply_to" in handler, "the entered credentials are dropped on restart" + assert "wow_path" in handler, "the entered WoW path is dropped on restart" From f0387b9927454dd664b84bd1c216c6310db4634a Mon Sep 17 00:00:00 2001 From: Andrey Yumashev Date: Wed, 26 Aug 2026 16:18:27 +0300 Subject: [PATCH 12/12] Say in the changelog what the release actually contains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entry was written when the live language change was fixed on GTK only, and three more repairs landed after it: the Qt overlay and the clipboard window relabel themselves, the tray menu is translated and refreshed on both platforms — on Linux it had never gone through tr() at all — and neither setup wizard drops what was typed into it any more. Five bullets instead of two, and none of them describes as general something done on one platform. That was the failure this whole release kept repeating; it would be a poor place to repeat it one last time. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 7 +++++-- CHANGELOG_ru.md | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbb7fcc..1866b20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,11 @@ ship under the same number. ### Fixed -- **The interface opened in Russian whatever language you had chosen.** On Linux the GTK entry point never applied the saved language at all, so the setting in Settings had no effect on anything; and on a first run, before there is a choice to apply, both frontends fell back to the Russian default — so a new player anywhere in the world read the setup wizard, the first thing they see, in a language they may not have. A saved choice is honoured now, and a first run takes its language from the operating system instead, falling back to Russian when the system names one the interface does not have. Windows is asked which language its *interface* is in, which is a different setting there from the one dates and numbers are formatted by, and the one that actually answers the question. -- **Changing the language did not reach windows already on screen.** A widget keeps the string it was built with, so the change took hold only at the next launch. The overlay relabels itself now; the setup wizard rebuilds its pages the moment the language dropdown changes, carrying across whatever had already been typed into it; and saving a new language closes the settings window, because rebuilding it is what reopening it does — any other save leaves it open, with its confirmation where you can read it. +- **The interface opened in Russian whatever language you had chosen.** On Linux the GTK entry point never applied the saved language at all, so the control in Settings had no effect on anything; and on a first run, before there is a choice to apply, both frontends fell back to the Russian default — so a new player anywhere in the world read the setup wizard, the first thing they see, in a language they may not have. A saved choice is honoured now, and a first run takes its language from the operating system instead, falling back to Russian when the system names one the interface does not have. Windows is asked which language its *interface* is in, which is a different setting there from the one dates and numbers are formatted by, and the one that actually answers the question. +- **Changing the language left every open window in the old one.** A widget keeps the string it was built with, so the new language reached nothing already on screen and took hold only at the next launch — which reads as a control that does nothing. The overlay relabels itself now, on both platforms, and on Windows so does the separate window the clipboard hotkey opens — created on demand and then kept, so it outlived the setting that was changed after it. +- **The tray menu was the worst of those, because you cannot close and reopen it.** It is built once at startup, and on Linux it was never translated at all: five English items above a Russian overlay, whatever you had picked. It is translated now and follows a language change on both platforms — and its first entry is written from where the overlay actually is, so it no longer offers to hide a window that is already hidden. +- **The setup wizard forgot what you had typed when you changed its language.** Showing it in a new language means building its pages again, and neither wizard kept what was already in the fields — an API key pasted on the second page, the WoW folder browsed for on the third, both silently gone because of the dropdown on the first. They carry across now. +- **Saving a new language leaves the settings window rebuilt rather than stale.** Reopening it is the rebuild, so that one save closes it; any other save leaves it where it is, with its confirmation where you can read it. --- diff --git a/CHANGELOG_ru.md b/CHANGELOG_ru.md index a6fbadd..bb8f07e 100644 --- a/CHANGELOG_ru.md +++ b/CHANGELOG_ru.md @@ -13,7 +13,10 @@ ### Исправлено - **Интерфейс открывался по-русски, какой язык ни выбери.** На Linux GTK-вход сохранённый язык не применял вовсе, так что переключатель в настройках не влиял ни на что; а на первом запуске, когда применять ещё нечего, оба фронтенда откатывались к русскому по умолчанию — и новичок в любой точке мира читал мастер настройки, первое, что он вообще видит, на языке, которого может не знать. Сохранённый выбор теперь соблюдается, а первый запуск берёт язык у операционной системы и остаётся русским, только если система называет язык, которого у интерфейса нет. У Windows спрашивается язык её *интерфейса* — это там отдельная настройка, не та, по которой форматируются даты и числа, и отвечает на вопрос именно она. -- **Смена языка не доходила до уже открытых окон.** Виджет хранит ту строку, с которой был построен, поэтому смена вступала в силу только со следующего запуска. Оверлей теперь переподписывает себя сам; мастер настройки перестраивает страницы в тот момент, когда меняется список языков, перенося всё, что в него успели ввести; а сохранение нового языка закрывает окно настроек, потому что перестроение — это и есть то, что происходит при следующем открытии; любое другое сохранение оставляет окно открытым, вместе с подтверждением, которое можно прочитать. +- **Смена языка оставляла все открытые окна на прежнем.** Виджет хранит ту строку, с которой был построен, поэтому новый язык не доходил ни до чего, что уже на экране, и вступал в силу только со следующего запуска — а это выглядит как переключатель, который ничего не делает. Оверлей теперь переподписывает себя сам на обеих платформах, а на Windows — и отдельное окно, которое открывает горячая клавиша перевода из буфера обмена: оно создаётся по требованию и потом остаётся жить, переживая настройку, изменённую после него. +- **Хуже всех было меню в трее, потому что его нельзя закрыть и открыть заново.** Оно строится один раз при запуске, а на Linux не переводилось вообще: пять английских пунктов над русским оверлеем, что ни выбери. Теперь переведено и следует за сменой языка на обеих платформах, а первый пункт пишется по тому, где оверлей на самом деле, — и больше не предлагает скрыть уже скрытое окно. +- **Мастер настройки забывал введённое, когда в нём меняли язык.** Показать мастер на новом языке — значит построить его страницы заново, и ни один из двух не сохранял того, что уже было в полях: ключ, вставленный на второй странице, папка WoW, выбранная на третьей, — всё пропадало молча из-за списка на первой. Теперь переносится. +- **Сохранение нового языка оставляет окно настроек перестроенным, а не устаревшим.** Перестроение — это и есть то, что происходит при следующем открытии, поэтому именно такое сохранение окно закрывает; любое другое оставляет его на месте, вместе с подтверждением, которое можно прочитать. ---