From 8cd62f76346c5654298e372698739ddf94ed6074 Mon Sep 17 00:00:00 2001 From: Tiago FERREIRA DOMINGUES Date: Thu, 1 Oct 2026 16:03:08 +0200 Subject: [PATCH 1/8] Add French localization with Windows language detection --- CHANGELOG.md | 9 + CONTRIBUTING.md | 26 +++ README.md | 7 +- halo_battery.pyw | 281 +++++++++++++++++++--------- i18n.py | 79 ++++++++ locales/__init__.py | 1 + locales/en.py | 110 +++++++++++ locales/fr.py | 112 +++++++++++ providers/asus.py | 4 +- providers/audeze.py | 4 +- providers/base.py | 6 +- providers/corsair.py | 3 +- providers/eightbitdo.py | 2 +- providers/logitech.py | 6 +- providers/nintendo.py | 8 +- providers/playstation.py | 7 +- providers/steelseries.py | 4 +- providers/xinput.py | 13 +- tests/test_asus.py | 2 + tests/test_audeze_stuck.py | 2 + tests/test_corsair.py | 1 + tests/test_eightbitdo.py | 1 + tests/test_hide_rename.py | 6 +- tests/test_i18n.py | 358 ++++++++++++++++++++++++++++++++++++ tests/test_logitech.py | 14 ++ tests/test_nintendo.py | 3 + tests/test_notifications.py | 3 +- tests/test_playstation.py | 1 + tests/test_steelseries.py | 1 + tests/test_xinput.py | 1 + tests/test_xinput_wired.py | 3 + 31 files changed, 968 insertions(+), 110 deletions(-) create mode 100644 i18n.py create mode 100644 locales/__init__.py create mode 100644 locales/en.py create mode 100644 locales/fr.py create mode 100644 tests/test_i18n.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 90b5be28..16552ed2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ and the project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added +- French localization for tray menus, preferences, tooltips, device states, rename + dialogs and notifications (#162). **Language / Langue** in Preferences switches + between English and Français immediately and saves the selection. On the first + run without a saved language, a French Windows UI selects French; other languages + use English. Saved choices take precedence, and English remains the fallback. + Diagnostics, protocol output and status-file text stay in English. Translation + catalogs are bundled without additional runtime dependencies. + ## [1.13.0] - 2026-09-29 A new Windows 11 style tray menu and a batch of tray features: turn device types off, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 88b6df81..5e08fcdd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,3 +83,29 @@ Bluetooth devices: Halo Battery shows the level that Windows itself reports. If - which other open pull requests change the same files. Look at the open issues and pull requests first, so that two people do not do the same work. + +## 4. Add or update a translation + +Application messages live in `locales/en.py` (the reference and fallback) and +`locales/fr.py`. They are ordinary Python modules, bundled by PyInstaller without +additional dependencies or a translation compilation step. + +- Copy the English catalog for a new language, translate the complete messages, + and register an explicit import, catalog and native language name in `i18n.py`. + Extend the plural rule and Windows UI language detection there for that language. +- Keep message keys, named format parameters (`{name}`, `{level}`, etc.), and the + `one` / `other` plural forms consistent with English. Do not translate brands, + hardware names, user-provided names, device keys, protocols or paths. +- Pass the language explicitly when rendering UI text. A missing translation falls + back to English. Providers supply `ui_message` / `ui_params` for dynamic states; + keep `approx`, protocol output, logs, diagnostics and status-file text in English. +- Test both languages, fallback, format parameters, plural forms, configuration + persistence and notification behavior with `python -m unittest discover -s tests`. + Review long labels, accents and device names in both classic and Fluent menus. + +The app detects the Windows **UI language**, not the regional format or keyboard, +only when no language is saved. All French regional variants select `fr`; other +languages or a failed detection select `en`. Preferences > Language / Langue +overrides that initial selection immediately and persists `language` in +`%APPDATA%\HaloBattery\config.json`. A stored invalid value falls back to English +without triggering detection again. `--probe` never initializes the language. diff --git a/README.md b/README.md index dea69b31..f790adbc 100644 --- a/README.md +++ b/README.md @@ -113,13 +113,18 @@ Right-click a device icon to open its menu. It looks like a Windows 11 menu (acr - **Hide this device**: remove its icon, for example for a controller that always reports 100%. - **Refresh now** - **Preferences**: + - **Language / Langue**: English or Français, applied immediately to menus and tooltips. + On the first run without a saved language (including an upgrade from an older version), + the app selects French when the current user's Windows UI language is French, and + English otherwise. The selection is saved; a manual choice always takes precedence, + even if the Windows language changes later. Diagnostics and the status file stay in English. - **Poll interval** (15 s to 5 min) and **Low battery alert** (off, 10–30%): change them with the − and + buttons or the mouse wheel, the menu stays open - **Alert when fully charged** (a notification once per charge, on by default) - **Estimated time left**: "about 5 h of use left" in the tooltip, from how fast the device has drained since its last charge. Only time the device is awake and on battery counts, and there is no estimate until it has been used for 30 minutes and dropped 3%. The history is kept in `%APPDATA%\HaloBattery\history.json`. - **Quiet while gaming** (on by default): while a game or other app is full screen (the same signal Windows uses to hold its own notifications), alerts are held and shown once it closes - one per device, and a low battery alert is dropped if the device was put on the charger meanwhile. Devices are polled only every 5 minutes then, since every poll talks to them; plugging something in still updates at once. - **Windows Bluetooth devices**, **Device pictogram**, **Percentage in the icon** (the level as a number in the ring instead of the pictogram, amber or red when low), **Charging animation** - **PlayStation full mode (Bluetooth)** (off by default): always read the battery of a PS4 / PS5 controller over Bluetooth. Some games stop seeing the controller in that mode until it is turned off and on - - **Status file for other apps** (off by default): writes `%APPDATA%\HaloBattery\status.json` after every poll, for Rainmeter, a Stream Deck plugin or a script. Each device has `name`, `level`, `charging`, `online`, `kind`, `seconds_left` and the tooltip `text`; `running` turns false when the app exits, and `updated_unix` says how fresh it is. Turning it off deletes the file. + - **Status file for other apps** (off by default): writes `%APPDATA%\HaloBattery\status.json` after every poll, for Rainmeter, a Stream Deck plugin or a script. Each device has `name`, `level`, `charging`, `online`, `kind`, `seconds_left` and the English tooltip `text`; `running` turns false when the app exits, and `updated_unix` says how fresh it is. Turning it off deletes the file. - **Device types**: turn off a brand or device family; its devices are then not opened at all - **Icon colour**: Automatic (the Windows theme, or MyDockFinder's menu bar while it is running), White or Black - **Start with Windows** (per-user registry key, no admin rights needed) diff --git a/halo_battery.pyw b/halo_battery.pyw index 4f7a423d..606689b4 100644 --- a/halo_battery.pyw +++ b/halo_battery.pyw @@ -87,6 +87,7 @@ from pystray import Menu, MenuItem as Item # noqa: E402 import flyout # noqa: E402 import history # noqa: E402 +import i18n # noqa: E402 import icons # noqa: E402 import updates # noqa: E402 import winevents # noqa: E402 @@ -104,6 +105,7 @@ HEADSET_WORDS = ("blackshark", "kraken", "barracuda", "nari", "thresher", "heads "headphone", "earbud", "buds", "hammerhead", "airpods") DEFAULTS = { + "language": "en", # initial Windows UI detection happens only in App(), never in --probe "interval": 60, # seconds between polls "low": 20, # low battery notification threshold, % "full_alert": True, # notification when a charging device reaches 100 % @@ -190,6 +192,8 @@ _config_lock = threading.Lock() def _valid_setting(key: str, value) -> bool: """A value from the settings file has the type of its default (and a sane range).""" default = DEFAULTS[key] + if key == "language": + return isinstance(value, str) and value in i18n.LANGUAGES if isinstance(default, bool): return isinstance(value, bool) if isinstance(default, int): @@ -200,17 +204,21 @@ def _valid_setting(key: str, value) -> bool: return isinstance(value, type(default)) -def load_config() -> dict: +def load_config(*, initialize_language: bool = False) -> dict: """The defaults, overridden by the settings file. A damaged file (not JSON, or not a JSON object) is kept as config.json.bad and the defaults are used; a single wrong value (a string for the poll interval, an interval of 0) falls - back to its default instead of breaking the app.""" + back to its default instead of breaking the app. Only application startup + opts into detecting and persisting a missing language; ordinary reads and + technical tools have no language initialization side effects.""" cfg = dict(DEFAULTS) try: with open(CONFIG_PATH, encoding="utf-8") as f: data = json.load(f) + except FileNotFoundError: + data = {} # no settings yet except OSError: - return cfg # no settings yet + return cfg except ValueError as e: data = e if not isinstance(data, dict): @@ -226,6 +234,9 @@ def load_config() -> dict: log.warning("config: ignoring %s=%r, using %r", key, value, DEFAULTS[key]) continue cfg[key] = value + if initialize_language and "language" not in data: + cfg["language"] = i18n.detect_language() + save_config(cfg) # remember it before the first tray icon return cfg @@ -297,18 +308,17 @@ def running_from_temp() -> bool: # The texts of the app's notifications. -def low_battery_text(name: str, level: Optional[int], approx: bool) -> str: - left = "battery is low" if approx else f"{level}% left" - return f"{name}: {left}. Time to charge." +def low_battery_text(name: str, level: Optional[int], approx: bool, *, language: str = "en") -> str: + key = "notification.low_approx" if approx else "notification.low" + return i18n.translate(key, language=language, name=name, level=level) -def fully_charged_text(name: str) -> str: - return f"{name} is fully charged." +def fully_charged_text(name: str, *, language: str = "en") -> str: + return i18n.translate("notification.full", language=language, name=name) -def update_text(latest: str) -> str: - return (f"Version {latest} is available. Right-click a battery icon " - f"and choose \"Download v{latest}…\".") +def update_text(latest: str, *, language: str = "en") -> str: + return i18n.translate("notification.update", language=language, version=latest) # Windows titles a notification with the app that sent it. Without an id of its own the @@ -360,9 +370,7 @@ def set_app_id() -> None: log.warning("app id: %s", e) -TEMP_AUTOSTART_TEXT = ("Halo Battery is running from a temporary folder (straight from the ZIP). " - "Extract the ZIP to a folder of its own, run HaloBattery.exe from there, " - "then turn on Start with Windows.") +TEMP_AUTOSTART_TEXT = i18n.translate("notification.temp_autostart") def autostart_enabled() -> bool: @@ -707,50 +715,91 @@ def drop_bluetooth_duplicates(results: List[DeviceStatus], return kept -def describe(st: DeviceStatus, name: Optional[str] = None, left: str = "") -> str: +def describe(st: DeviceStatus, name: Optional[str] = None, left: str = "", *, language: str = "en") -> str: """The tooltip text. `name` replaces the device's own name (set with "Rename..."), `left` is the estimated time left ("about 5 h of use left"), shown only while the device is awake and on battery.""" - return f"{name or st.name}: {device_state(st, left)}" + return i18n.translate("device.description", language=language, + name=name or st.name, state=device_state(st, left, language=language)) -def device_state(st: DeviceStatus, left: str = "") -> str: +def device_state(st: DeviceStatus, left: str = "", *, language: str = "en") -> str: """The part of describe() after the name: "85%, charging", "no link ...".""" if st.approx: state = st.approx # XInput: coarse levels or "not reported yet", never a fake "NN%" + if language != "en" and st.ui_message: + params = dict(st.ui_params) + if "grade" in params: + params["grade"] = i18n.translate("level." + params["grade"], language=language) + state = i18n.translate(st.ui_message, language=language, **params) + if params.get("charging"): + state = i18n.translate("state.charging", language=language, state=state) + if params.get("last_known"): + state = i18n.translate("state.last_known", language=language, state=state) elif st.level is None: - state = "no link (off or asleep)" + state = i18n.translate("state.no_link", language=language) else: - state = f"{st.level}%" + state = i18n.translate("state.level", language=language, level=st.level) if st.charging: - state += ", charging" + state = i18n.translate("state.charging", language=language, state=state) if not st.online: - state += " (last known value, device asleep)" + state = i18n.translate("state.asleep", language=language, state=state) elif left and not st.charging: - state += f", {left}" + state = i18n.translate("state.time_left", language=language, state=state, left=left) return state # ------------------------------------------------------------- hide / rename -def ask_name(current: str) -> Optional[str]: +def ask_name(current: str, *, language: str = "en") -> Optional[str]: """Show a Windows input box for a new device name. -> the new name, or None when the user cancels or leaves it empty. - The box comes from PowerShell (Microsoft.VisualBasic InputBox), which is on every + The box comes from PowerShell (Windows Forms), which is on every Windows. The current name goes to PowerShell in an environment variable, not in the command line, so quotes or other characters in a name do no harm.""" if sys.platform != "win32": return None - script = ("[Console]::OutputEncoding = [Text.Encoding]::UTF8; " - "Add-Type -AssemblyName Microsoft.VisualBasic; " - "[Microsoft.VisualBasic.Interaction]::InputBox(" - "'New name for this device:', 'Halo Battery - Rename', $env:HALO_BATTERY_NAME)") + # Text stays out of the script, including translations containing apostrophes. + # AutoSize measures the prompt and buttons at the Windows DPI/font size. + script = """ +[Console]::OutputEncoding = [Text.Encoding]::UTF8 +Add-Type -AssemblyName System.Windows.Forms +$form = New-Object System.Windows.Forms.Form +$form.Text = $env:HALO_BATTERY_TITLE +$form.StartPosition = 'CenterScreen' +$form.FormBorderStyle = 'FixedDialog' +$form.MinimizeBox = $false; $form.MaximizeBox = $false +$form.AutoSize = $true; $form.AutoSizeMode = 'GrowAndShrink' +$layout = New-Object System.Windows.Forms.TableLayoutPanel +$layout.AutoSize = $true; $layout.Dock = 'Fill'; $layout.Padding = 12 +$label = New-Object System.Windows.Forms.Label +$label.Text = $env:HALO_BATTERY_PROMPT; $label.AutoSize = $true +$input = New-Object System.Windows.Forms.TextBox +$input.Text = $env:HALO_BATTERY_NAME; $input.MaxLength = 60; $input.Width = 340 +$buttons = New-Object System.Windows.Forms.FlowLayoutPanel +$buttons.AutoSize = $true; $buttons.FlowDirection = 'RightToLeft' +$ok = New-Object System.Windows.Forms.Button +$ok.Text = $env:HALO_BATTERY_OK; $ok.AutoSize = $true; $ok.DialogResult = 'OK' +$cancel = New-Object System.Windows.Forms.Button +$cancel.Text = $env:HALO_BATTERY_CANCEL; $cancel.AutoSize = $true; $cancel.DialogResult = 'Cancel' +$buttons.Controls.Add($cancel); $buttons.Controls.Add($ok) +$layout.Controls.Add($label, 0, 0); $layout.Controls.Add($input, 0, 1) +$layout.Controls.Add($buttons, 0, 2); $form.Controls.Add($layout) +$form.AcceptButton = $ok; $form.CancelButton = $cancel +$form.Add_Shown({ $input.Focus(); $input.SelectAll() }) +if ($form.ShowDialog() -eq 'OK') { [Console]::WriteLine($input.Text) } +$form.Dispose() +""" try: res = subprocess.run( - ["powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + ["powershell", "-NoProfile", "-STA", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], capture_output=True, timeout=600, creationflags=0x08000000, # CREATE_NO_WINDOW - env=dict(os.environ, HALO_BATTERY_NAME=current)) + env=dict(os.environ, HALO_BATTERY_NAME=current, + HALO_BATTERY_TITLE=i18n.translate("rename.title", language=language), + HALO_BATTERY_PROMPT=i18n.translate("rename.prompt", language=language), + HALO_BATTERY_OK=i18n.translate("rename.ok", language=language), + HALO_BATTERY_CANCEL=i18n.translate("rename.cancel", language=language))) except (OSError, subprocess.SubprocessError) as e: log.warning("rename: %s", e) return None @@ -810,10 +859,12 @@ class DeviceIcon: else: self.frames = None self.icon.icon = art - title = describe(st, self.app.display_name(st), self.app.time_left_text(st)) + title = describe(st, self.app.display_name(st), self.app.time_left_text(st), + language=self.app.cfg.get("language", "en")) # the tray tooltip is limited to 127 characters - if self.icon.title != title[:127]: - self.icon.title = title[:127] + title = i18n.tooltip(title) + if self.icon.title != title: + self.icon.title = title # pystray builds the Windows menu once and keeps its texts. The menu header # shows the same text as the tooltip, so rebuild the menu when it changes; # otherwise the header keeps "No devices found" from before the first reading @@ -866,7 +917,7 @@ class DeviceIcon: class App: def __init__(self): - self.cfg = load_config() + self.cfg = load_config(initialize_language=True) self.light_taskbar = False self.theme_evt = threading.Event() # "re-check the icon colour now" self.win_events: Optional[winevents.WindowEventWatcher] = None @@ -875,7 +926,7 @@ class App: self.key_provider: Dict[str, str] = {} # device key -> provider name self.history = history.History(HISTORY_PATH) self.history.load() - self.held: Dict[tuple, tuple] = {} # (key, title) -> (text, title), held while quiet + self.held: Dict[tuple, tuple] = {} # (device key, kind) -> English text/title + parameters self.was_quiet = False self.bt = BluetoothProvider() self.icons: Dict[str, DeviceIcon] = {} @@ -901,17 +952,39 @@ class App: flyout.FlyoutHost() if sys.platform == "win32" and self.cfg.get("fluent_menu", True) else None) # ---------------- menu + def tr(self, key: str, **params) -> str: + return i18n.translate(key, language=self.cfg.get("language", "en"), **params) + + def set_language(self, language: str) -> None: + """Refresh presentation under the same lock as polls, without polling hardware.""" + if language not in i18n.LANGUAGES: + return + with self.lock: + self.cfg["language"] = language + save_config(self.cfg) + for ic in list(self.icons.values()): + ic.icon.menu = self.build_menu(ic) + if ic.status is not None: + ic.icon.title = i18n.tooltip(describe(ic.status, self.display_name(ic.status), + self.time_left_text(ic.status), language=language)) + if self.placeholder is not None: + self.placeholder.menu = self.build_menu(None) + self.placeholder.title = i18n.tooltip(self.tr("tooltip.no_devices", app=APP_TITLE)) + self.refresh_menus() + def build_menu(self, owner: Optional[DeviceIcon]) -> Menu: + tr = self.tr # the flyout shows the state on the line below the name (flyout.HeaderItem) def header_title(): if owner and owner.status: return self.display_name(owner.status) or owner.status.name hidden = len(self._settings_map("hidden")) - return f"No devices shown ({hidden} hidden)" if hidden else "No devices found" + return tr("menu.none_shown", count=hidden) if hidden else tr("menu.no_devices") def header_detail(): if owner and owner.status: - return device_state(owner.status, self.time_left_text(owner.status)) + return device_state(owner.status, self.time_left_text(owner.status), + language=self.cfg.get("language", "en")) return "" def set_interval(sec): @@ -954,16 +1027,19 @@ class App: def toggle_autostart(icon, item): try: if not set_autostart(not autostart_enabled()): - icon.notify(TEMP_AUTOSTART_TEXT, "Start with Windows") + icon.notify(tr("notification.temp_autostart"), tr("preferences.autostart")) except OSError as e: log.warning("autostart: %s", e) intervals = [(15, "15 s"), (30, "30 s"), (60, "1 min"), (120, "2 min"), (300, "5 min")] - themes = [("auto", "Automatic"), ("white", "White"), ("black", "Black")] - lows = [(0, "Off"), (10, "10%"), (15, "15%"), (20, "20%"), (25, "25%"), (30, "30%")] + themes = [(m, tr("choice." + m)) for m in ("auto", "white", "black")] + lows = [(0, tr("choice.off"))] + [(p, tr("state.level", level=p)) for p in (10, 15, 20, 25, 30)] + + def pick_language(language): + return lambda icon, item: self.set_language(language) def update_text(_item): - return f"Download v{self.update[0]}…" if self.update else "Download update…" + return tr("menu.download", version=self.update[0]) if self.update else tr("menu.download_update") def renamed(_item): return bool(owner and owner.status and owner.status.key in self._settings_map("names")) @@ -989,7 +1065,7 @@ class App: def default_low_text(_item): low = self.cfg["low"] - return f"Default ({low}%)" if low else "Default (off)" + return tr("menu.default_low", level=low) if low else tr("menu.default_off") def provider_on(name): return lambda _item: name not in self.disabled_providers() @@ -998,64 +1074,70 @@ class App: return lambda icon, item: self.toggle_provider(name) def provider_items(): - for name, label in sorted(PROVIDER_LABELS.items(), key=lambda kv: kv[1].lower()): + labels = [(name, tr("provider." + name)) for name in PROVIDER_LABELS] + for name, label in sorted(labels, key=lambda kv: kv[1].lower()): yield Item(label, flip_provider(name), checked=provider_on(name)) def hidden_items(): # built each time the menu opens, so it always shows the current list hidden = self._settings_map("hidden") for key, name in sorted(hidden.items(), key=lambda kv: str(kv[1]).lower()): - yield Item(f"Show {name}", show_again(key)) + yield Item(tr("menu.show", name=name), show_again(key)) # items for the device of this icon only (the "no devices" icon has none) device_items = [ # the flyout has the pencil next to the name instead - flyout.classic_only(Item("Rename…", lambda i, it: self.rename(owner))), - Item("Reset name", lambda i, it: self.reset_name(owner), visible=renamed), - Item("Icon", Menu(*[Item(label, pick(value), checked=picked(value), radio=True) - for value, label in PICTOGRAM_CHOICES])), - Item("Low battery alert at", Menu( + flyout.classic_only(Item(tr("menu.rename"), lambda i, it: self.rename(owner))), + Item(tr("menu.reset_name"), lambda i, it: self.reset_name(owner), visible=renamed), + Item(tr("menu.icon"), Menu(*[Item(tr("icon." + value) if value else tr("choice.auto"), + pick(value), checked=picked(value), radio=True) + for value, _label in PICTOGRAM_CHOICES])), + Item(tr("menu.device_low"), Menu( Item(default_low_text, pick_device_low(None), checked=device_low_picked(None), radio=True), *[Item(t, pick_device_low(p), checked=device_low_picked(p), radio=True) for p, t in lows])), - Item("Hide this device", lambda i, it: self.hide(owner)), + Item(tr("menu.hide"), lambda i, it: self.hide(owner)), ] if owner is not None else [] # all settings in one submenu, so the main menu keeps only the things used often preferences = Menu( + Item(tr("preferences.language"), Menu(*[ + Item(label, pick_language(language), radio=True, + checked=lambda it, language=language: self.cfg.get("language", "en") == language) + for language, label in i18n.LANGUAGES.items()])), # - / + in the menu; the classic menu shows them as a list to pick from - flyout.CounterItem("Poll interval", intervals, lambda: self.cfg["interval"], set_interval), - flyout.CounterItem("Low battery alert", lows, lambda: self.cfg["low"], set_low), - Item("Alert when fully charged", toggle("full_alert"), + flyout.CounterItem(tr("preferences.interval"), intervals, lambda: self.cfg["interval"], set_interval), + flyout.CounterItem(tr("preferences.low"), lows, lambda: self.cfg["low"], set_low), + Item(tr("preferences.full"), toggle("full_alert"), checked=lambda it: self.cfg.get("full_alert", True)), - Item("Estimated time left", toggle("time_left"), + Item(tr("preferences.time_left"), toggle("time_left"), checked=lambda it: self.cfg.get("time_left", True)), - Item("Quiet while gaming", toggle("quiet_fullscreen"), + Item(tr("preferences.quiet"), toggle("quiet_fullscreen"), checked=lambda it: self.cfg.get("quiet_fullscreen", True)), Menu.SEPARATOR, - Item("Windows Bluetooth devices", toggle("bluetooth"), + Item(tr("preferences.bluetooth"), toggle("bluetooth"), checked=lambda it: self.cfg["bluetooth"]), # off: a PS4 / PS5 controller over Bluetooth shows its level only while Steam or a # game has it in the full mode; on: the app switches it, which some games do not # survive until the controller is turned off and on (#96) - Item("PlayStation full mode (Bluetooth)", toggle("playstation_full_mode"), + Item(tr("preferences.playstation"), toggle("playstation_full_mode"), checked=lambda it: self.cfg.get("playstation_full_mode", False)), - Item("Device types", Menu(provider_items)), - Item("Device pictogram", toggle("badges"), + Item(tr("preferences.providers"), Menu(provider_items)), + Item(tr("preferences.badges"), toggle("badges"), checked=lambda it: self.cfg["badges"]), - Item("Percentage in the icon", toggle("percent_in_icon"), + Item(tr("preferences.percent"), toggle("percent_in_icon"), checked=lambda it: self.cfg.get("percent_in_icon", False)), - Item("Charging animation", toggle("animation"), + Item(tr("preferences.animation"), toggle("animation"), checked=lambda it: self.cfg["animation"]), - Item("Icon colour", Menu(*[ + Item(tr("preferences.theme"), Menu(*[ Item(t, set_theme(m), checked=lambda it, m=m: self.cfg.get("icon_theme", "auto") == m, radio=True) for m, t in themes])), Menu.SEPARATOR, - Item("Status file for other apps", toggle("status_file"), + Item(tr("preferences.status_file"), toggle("status_file"), checked=lambda it: self.cfg.get("status_file", False)), - Item("Start with Windows", toggle_autostart, + Item(tr("preferences.autostart"), toggle_autostart, checked=lambda it: autostart_enabled()), - Item("Check for updates", toggle("update_check"), + Item(tr("preferences.updates"), toggle("update_check"), checked=lambda it: self.cfg.get("update_check", True)), ) @@ -1066,13 +1148,13 @@ class App: visible=lambda it: self.update is not None), *device_items, Menu.SEPARATOR, - Item("Refresh now", lambda i, it: self.wake.set(), default=True), - Item("Preferences", preferences), - Item("Hidden devices", Menu(hidden_items), + Item(tr("menu.refresh"), lambda i, it: self.wake.set(), default=True), + Item(tr("menu.preferences"), preferences), + Item(tr("menu.hidden"), Menu(hidden_items), visible=lambda it: bool(self._settings_map("hidden"))), Menu.SEPARATOR, - Item("Diagnostics…", lambda i, it: self.request_diag()), - Item(f"Exit (v{VERSION})", lambda i, it: self.quit()), + Item(tr("menu.diagnostics"), lambda i, it: self.request_diag()), + Item(tr("menu.exit", version=VERSION), lambda i, it: self.quit()), ) # ---------------- hide / rename @@ -1126,7 +1208,7 @@ class App: st = owner.status if st is None: return - new = ask_name(self.display_name(st)) + new = ask_name(self.display_name(st), language=self.cfg.get("language", "en")) if new is None or new == self.display_name(st): return with self.lock: @@ -1242,12 +1324,13 @@ class App: self.wake.set() # ---------------- time left - def time_left_text(self, st: DeviceStatus) -> str: + def time_left_text(self, st: DeviceStatus, *, language: Optional[str] = None) -> str: """"about 5 h of use left", or "" when turned off or not known yet.""" if not self.cfg.get("time_left", True) or st.approx: return "" seconds = self.history.seconds_left(st.key, st.level) - return history.format_left(seconds) if seconds is not None else "" + return i18n.format_left(seconds, language=language if language is not None else + self.cfg.get("language", "en")) if seconds is not None else "" # ---------------- icon colour def compute_light(self) -> bool: @@ -1527,7 +1610,7 @@ class App: ready = threading.Event() self.placeholder = tray_icon(IDLE_KEY, f"{APP_NAME}_idle", icons.render(None, False, False, light_taskbar=self.light_taskbar), - f"{APP_TITLE}: no devices found", + i18n.tooltip(self.tr("tooltip.no_devices", app=APP_TITLE)), self.build_menu(None)) self.placeholder._hb_flyout = getattr(self, "flyout", None) self.placeholder_ready = ready @@ -1554,7 +1637,8 @@ class App: try: self.notify(ic.icon, st.key, low_battery_text(self.display_name(st), st.level, bool(st.approx)), - "Low battery") + "Low battery", kind="low", + params={"name": self.display_name(st), "level": st.level, "approx": bool(st.approx)}) except Exception as e: log.warning("notify: %s", e) @@ -1572,7 +1656,7 @@ class App: if st.level >= 100 and prev == "charging" and self.cfg.get("full_alert", True): try: self.notify(ic.icon, st.key, fully_charged_text(self.display_name(st)), - "Fully charged") + "Fully charged", kind="full", params={"name": self.display_name(st)}) except Exception as e: log.warning("notify: %s", e) if st.level >= 100: @@ -1692,12 +1776,12 @@ class App: except Exception: pass - def notify_any(self, text: str, title: str, key: str = "") -> None: + def notify_any(self, text: str, title: str, key: str = "", *, kind: str = "", params=None) -> None: """A tray notification from whichever icon is there.""" icon = next((ic.icon for ic in list(self.icons.values())), None) or self.placeholder if icon is not None: try: - self.notify(icon, key, text, title) + self.notify(icon, key, text, title, kind=kind, params=params) except Exception as e: log.warning("notify: %s", e) @@ -1723,7 +1807,7 @@ class App: "approx": st.approx or None, "low_alert_at": self.low_for(st), "seconds_left": None if secs is None else int(secs), - "text": describe(st, self.display_name(st), self.time_left_text(st)), + "text": describe(st, self.display_name(st), self.time_left_text(st, language="en")), }) now = time.time() return {"app": APP_TITLE, "version": VERSION, "running": running, @@ -1739,27 +1823,41 @@ class App: """True while "Quiet while gaming" is on and a full-screen app is in front.""" return bool(self.cfg.get("quiet_fullscreen", True)) and fullscreen_app_running() - def notify(self, icon, key: str, text: str, title: str) -> None: + def notify(self, icon, key: str, text: str, title: str, *, kind: str = "", params=None) -> None: """Show a notification now, or hold it until the full-screen app is gone. Only the newest one per device and kind is kept, so a long game ends with one - "Low battery" per device rather than a pile of them.""" - if self.quiet(): - self.held[(key, title)] = (text, title) - log.info("held while full screen: %s", text) - return - icon.notify(text, title) + "Low battery" per device rather than a pile of them. English text/title + remain available for logs; kind/params are rendered in the selected + language only on delivery, including after a game.""" + with self.lock: + if self.quiet(): + self.held[(key, kind or title)] = (text, title, kind, dict(params or {})) + log.info("held while full screen: %s", text) + return + language = self.cfg.get("language", "en") + if kind == "low": + text = low_battery_text(**params, language=language) + title = self.tr("notification.low_title") + elif kind == "full": + text = fully_charged_text(**params, language=language) + title = self.tr("notification.full_title") + elif kind == "update": + text = update_text(params["version"], language=language) + title = self.tr("notification.update_title", app=APP_TITLE) + icon.notify(text, title) def flush_held(self) -> None: """The full-screen app is gone: show what was held, except a low battery alert for a device that has been put on the charger (or topped up) since.""" - held, self.held = self.held, {} - for (key, title), (text, _) in held.items(): + with self.lock: + held, self.held = self.held, {} + for (key, notice_kind), (text, title, kind, params) in held.items(): ic = self.icons.get(key) st = ic.status if ic is not None else None - if title == "Low battery" and st is not None and ( + if notice_kind in ("low", "Low battery") and st is not None and ( st.charging or (st.level is not None and st.level > self.low_for(st))): continue - self.notify_any(text, title, key) + self.notify_any(text, title, key, kind=kind, params=params) def open_update(self) -> None: url = self.update[1] if self.update else updates.RELEASES_URL @@ -1800,7 +1898,8 @@ class App: self.refresh_menus() if self.cfg.get("update_notified") != latest: self.cfg["update_notified"] = latest - self.notify_any(update_text(latest), f"{APP_TITLE} update") + self.notify_any(update_text(latest), f"{APP_TITLE} update", kind="update", + params={"version": latest}) else: self.update = None save_config(self.cfg) diff --git a/i18n.py b/i18n.py new file mode 100644 index 00000000..177a21da --- /dev/null +++ b/i18n.py @@ -0,0 +1,79 @@ +"""Small, explicit-language renderer. Technical output always uses English. + +Catalogs are imported statically so source and PyInstaller builds use the same +translations without resource paths, gettext tooling or a mutable global locale. +""" +from __future__ import annotations + +import sys + +from locales import en, fr + +LANGUAGES = {"en": "English", "fr": "Français"} +CATALOGS = {"en": en.MESSAGES, "fr": fr.MESSAGES} + + +def detect_language() -> str: + """The current user's Windows UI language, not their regional format/keyboard. + + GetUserDefaultUILanguage returns a LANGID. Its primary-language bits cover + all French variants (France, Canada, Belgium, Switzerland, ...). + https://learn.microsoft.com/windows/win32/api/winnls/nf-winnls-getuserdefaultuilanguage + """ + if sys.platform != "win32": + return "en" + try: + import ctypes + get_language = ctypes.windll.kernel32.GetUserDefaultUILanguage + get_language.argtypes = [] + get_language.restype = ctypes.c_ushort + langid = get_language() + french = isinstance(langid, int) and 0 < langid <= 0xFFFF and (langid & 0x3FF) == 0x0C + return "fr" if french else "en" + except (AttributeError, OSError, TypeError, ValueError): + return "en" + + +def translate(key: str, *, language: str = "en", count=None, **params) -> str: + """Render a catalog message; an unsupported language/missing entry uses English. + + An unknown reference key or missing argument is a programming error. Keeping + these visible makes catalog mistakes testable instead of hiding them in the UI. + """ + catalog = CATALOGS.get(language, en.MESSAGES) + if key not in catalog: + catalog = en.MESSAGES + message = catalog.get(key) + if message is None: + raise KeyError(key) + if isinstance(message, dict): + if count is None: + raise ValueError(f"{key} requires count") + # English: 1; French: 0 and 1. Counts here are nonnegative integers. + singular = count in (0, 1) if catalog is fr.MESSAGES else count == 1 + form = "one" if singular else "other" + if form in message: + message = message[form] + else: + message = en.MESSAGES[key]["one" if count == 1 else "other"] + params = dict(params, count=count) + return message.format(**params) + + +def tooltip(text: str) -> str: + """Win32's 128-WCHAR tooltip buffer includes the terminating NUL. + + A supplementary Unicode character (e.g. emoji in a device name) takes two + UTF-16 code units. Do not split its surrogate pair at the buffer boundary. + """ + return text.encode("utf-16-le")[:254].decode("utf-16-le", "ignore") + + +def format_left(seconds: float, *, language: str = "en") -> str: + """Format an estimate; the history calculation itself is language-independent.""" + hours = seconds / 3600.0 + if hours < 1: + return translate("duration.less_hour", language=language) + if hours < 48: + return translate("duration.hours", language=language, count=round(hours)) + return translate("duration.days", language=language, count=round(hours / 24)) diff --git a/locales/__init__.py b/locales/__init__.py new file mode 100644 index 00000000..7a2c1c75 --- /dev/null +++ b/locales/__init__.py @@ -0,0 +1 @@ +"""Bundled translation catalogs (ordinary Python imports for PyInstaller).""" diff --git a/locales/en.py b/locales/en.py new file mode 100644 index 00000000..67a9f9ab --- /dev/null +++ b/locales/en.py @@ -0,0 +1,110 @@ +"""English is the reference catalog and fallback for every language.""" + +MESSAGES = { + "device.description": "{name}: {state}", + "menu.rename": "Rename…", + "menu.reset_name": "Reset name", + "menu.icon": "Icon", + "menu.device_low": "Low battery alert at", + "menu.default_low": "Default ({level}%)", + "menu.default_off": "Default (off)", + "menu.hide": "Hide this device", + "menu.show": "Show {name}", + "menu.refresh": "Refresh now", + "menu.preferences": "Preferences", + "menu.hidden": "Hidden devices", + "menu.diagnostics": "Diagnostics…", + "menu.exit": "Exit (v{version})", + "menu.download": "Download v{version}…", + "menu.download_update": "Download update…", + "menu.no_devices": "No devices found", + "menu.none_shown": {"one": "No devices shown ({count} hidden)", + "other": "No devices shown ({count} hidden)"}, + "tooltip.no_devices": "{app}: no devices found", + "preferences.language": "Language / Langue", + "preferences.interval": "Poll interval", + "preferences.low": "Low battery alert", + "preferences.full": "Alert when fully charged", + "preferences.time_left": "Estimated time left", + "preferences.quiet": "Quiet while gaming", + "preferences.bluetooth": "Windows Bluetooth devices", + "preferences.playstation": "PlayStation full mode (Bluetooth)", + "preferences.providers": "Device types", + "preferences.badges": "Device pictogram", + "preferences.percent": "Percentage in the icon", + "preferences.animation": "Charging animation", + "preferences.theme": "Icon colour", + "preferences.status_file": "Status file for other apps", + "preferences.autostart": "Start with Windows", + "preferences.updates": "Check for updates", + "choice.auto": "Automatic", + "choice.white": "White", + "choice.black": "Black", + "choice.off": "Off", + "icon.mouse": "Mouse", + "icon.keyboard": "Keyboard", + "icon.headset": "Headset", + "icon.gamepad": "Controller", + "icon.bluetooth": "Bluetooth", + "provider.8bitdo": "8BitDo controllers", + "provider.am_infinity": "AM Infinity 8K (Angry Miao)", + "provider.astro": "Astro A50", + "provider.asus": "ASUS ROG / TUF mice", + "provider.audeze": "Audeze Maxwell", + "provider.barracuda": "Razer Barracuda Pro", + "provider.corsair": "Corsair headsets", + "provider.gwolves": "G-Wolves mice", + "provider.hyperx": "HyperX Cloud II Wireless", + "provider.hyperx_alpha2": "HyperX Cloud Alpha 2", + "provider.hyperx_cloud3": "HyperX Cloud III Wireless", + "provider.jbl": "JBL Quantum", + "provider.keychron": "Keychron", + "provider.lamzu": "LAMZU mice", + "provider.lofree": "Lofree keyboards", + "provider.logitech": "Logitech", + "provider.mchose": "MCHOSE mice", + "provider.nintendo": "Nintendo Switch controllers", + "provider.playstation": "PlayStation controllers", + "provider.pulsar": "Pulsar / ATK VXE mice", + "provider.razer": "Razer mice and headsets", + "provider.steelseries": "SteelSeries", + "provider.wlmouse": "WLmouse", + "provider.xinput": "Xbox-compatible controllers", + "state.no_link": "no link (off or asleep)", + "state.level": "{level}%", + "state.charging": "{state}, charging", + "state.asleep": "{state} (last known value, device asleep)", + "state.last_known": "{state} (last known value)", + "state.time_left": "{state}, {left}", + "state.approx": "about {level}%", + "state.approx_grade": "about {level}% ({grade})", + "state.pending": "connected, battery level not reported yet", + "state.unreported": "connected, battery level not reported", + "state.unreadable": "connected, battery not readable (another app may hold it)", + "state.audeze_stuck": "no answer from the headset - unplug the dongle and plug it back in", + "state.playstation_basic": "level shown over Bluetooth only while Steam or a game uses it", + "state.8bitdo_basic": "level shown only while Steam or a game uses the controller", + "state.bluetooth_hint": 'connected over Bluetooth; turn on "Windows Bluetooth devices" to see its battery', + "state.cable": "on cable, charging", + "level.empty": "empty", + "level.critical": "critical", + "level.low": "low", + "level.medium": "medium", + "level.good": "good", + "level.full": "full", + "duration.less_hour": "less than 1 h of use left", + "duration.hours": {"one": "about {count} h of use left", "other": "about {count} h of use left"}, + "duration.days": {"one": "about {count} day of use left", "other": "about {count} days of use left"}, + "notification.low_title": "Low battery", + "notification.low": "{name}: {level}% left. Time to charge.", + "notification.low_approx": "{name}: battery is low. Time to charge.", + "notification.full_title": "Fully charged", + "notification.full": "{name} is fully charged.", + "notification.update_title": "{app} update", + "notification.update": 'Version {version} is available. Right-click a battery icon and choose "Download v{version}…".', + "notification.temp_autostart": "Halo Battery is running from a temporary folder (straight from the ZIP). Extract the ZIP to a folder of its own, run HaloBattery.exe from there, then turn on Start with Windows.", + "rename.prompt": "New name for this device:", + "rename.title": "Halo Battery - Rename", + "rename.ok": "OK", + "rename.cancel": "Cancel", +} diff --git a/locales/fr.py b/locales/fr.py new file mode 100644 index 00000000..2047d8c9 --- /dev/null +++ b/locales/fr.py @@ -0,0 +1,112 @@ +"""French application text; brands and device names are kept unchanged.""" + +MESSAGES = { + "device.description": "{name} : {state}", + "menu.rename": "Renommer…", + "menu.reset_name": "Rétablir le nom", + "menu.icon": "Icône", + "menu.device_low": "Seuil d’alerte de batterie faible", + "menu.default_low": "Par défaut ({level} %)", + "menu.default_off": "Par défaut (désactivée)", + "menu.hide": "Masquer ce périphérique", + "menu.show": "Afficher {name}", + "menu.refresh": "Actualiser maintenant", + "menu.preferences": "Préférences", + "menu.hidden": "Périphériques masqués", + "menu.diagnostics": "Diagnostic…", + "menu.exit": "Quitter (v{version})", + "menu.download": "Télécharger la v{version}…", + "menu.download_update": "Télécharger la mise à jour…", + "menu.no_devices": "Aucun périphérique détecté", + "menu.none_shown": {"one": "Aucun périphérique affiché ({count} masqué)", + "other": "Aucun périphérique affiché ({count} masqués)"}, + "tooltip.no_devices": "{app} : aucun périphérique détecté", + "preferences.language": "Language / Langue", + "preferences.interval": "Intervalle d’actualisation", + "preferences.low": "Alerte de batterie faible", + "preferences.full": "Alerte lorsque la batterie est pleine", + "preferences.time_left": "Autonomie restante estimée", + "preferences.quiet": "Mode silencieux pendant les jeux", + "preferences.bluetooth": "Périphériques Bluetooth de Windows", + "preferences.playstation": "Mode complet PlayStation (Bluetooth)", + "preferences.providers": "Types de périphériques", + "preferences.badges": "Pictogramme du périphérique", + "preferences.percent": "Pourcentage dans l’icône", + "preferences.animation": "Animation de charge", + "preferences.theme": "Couleur des icônes", + "preferences.status_file": "Fichier d’état pour les autres applications", + "preferences.autostart": "Démarrer avec Windows", + "preferences.updates": "Rechercher les mises à jour", + "choice.auto": "Automatique", + "choice.white": "Blanc", + "choice.black": "Noir", + "choice.off": "Désactivée", + "icon.mouse": "Souris", + "icon.keyboard": "Clavier", + "icon.headset": "Casque", + "icon.gamepad": "Manette", + "icon.bluetooth": "Bluetooth", + "provider.8bitdo": "Manettes 8BitDo", + "provider.am_infinity": "AM Infinity 8K (Angry Miao)", + "provider.astro": "Astro A50", + "provider.asus": "Souris ASUS ROG / TUF", + "provider.audeze": "Audeze Maxwell", + "provider.barracuda": "Razer Barracuda Pro", + "provider.corsair": "Casques Corsair", + "provider.gwolves": "Souris G-Wolves", + "provider.hyperx": "HyperX Cloud II Wireless", + "provider.hyperx_alpha2": "HyperX Cloud Alpha 2", + "provider.hyperx_cloud3": "HyperX Cloud III Wireless", + "provider.jbl": "JBL Quantum", + "provider.keychron": "Keychron", + "provider.lamzu": "Souris LAMZU", + "provider.lofree": "Claviers Lofree", + "provider.logitech": "Logitech", + "provider.mchose": "Souris MCHOSE", + "provider.nintendo": "Manettes Nintendo Switch", + "provider.playstation": "Manettes PlayStation", + "provider.pulsar": "Souris Pulsar / ATK VXE", + "provider.razer": "Souris et casques Razer", + "provider.steelseries": "SteelSeries", + "provider.wlmouse": "WLmouse", + "provider.xinput": "Manettes compatibles Xbox", + "state.no_link": "aucune liaison (éteint ou en veille)", + "state.level": "{level} %", + "state.charging": "{state}, en charge", + "state.asleep": "{state} (dernière valeur connue, périphérique en veille)", + "state.last_known": "{state} (dernière valeur connue)", + "state.time_left": "{state}, {left}", + "state.approx": "environ {level} %", + "state.approx_grade": "environ {level} % ({grade})", + "state.pending": "connecté, niveau de batterie pas encore communiqué", + "state.unreported": "connecté, niveau de batterie non communiqué", + "state.unreadable": "connecté, batterie illisible (une autre application peut bloquer l’accès)", + "state.audeze_stuck": "aucune réponse du casque — débranchez puis rebranchez le dongle", + "state.playstation_basic": "niveau disponible en Bluetooth uniquement lorsque Steam ou un jeu utilise la manette", + "state.8bitdo_basic": "niveau disponible uniquement lorsque Steam ou un jeu utilise la manette", + "state.bluetooth_hint": 'connecté en Bluetooth ; activez « Périphériques Bluetooth de Windows » pour voir le niveau de batterie', + "state.cable": "branché par câble, en charge", + "level.empty": "vide", + "level.critical": "critique", + "level.low": "faible", + "level.medium": "moyenne", + "level.good": "bonne", + "level.full": "pleine", + "duration.less_hour": "moins d’une heure d’utilisation restante", + "duration.hours": {"one": "environ {count} heure d’utilisation restante", + "other": "environ {count} heures d’utilisation restantes"}, + "duration.days": {"one": "environ {count} jour d’utilisation restant", + "other": "environ {count} jours d’utilisation restants"}, + "notification.low_title": "Batterie faible", + "notification.low": "{name} : {level} % restants. Pensez à recharger.", + "notification.low_approx": "{name} : batterie faible. Pensez à recharger.", + "notification.full_title": "Batterie pleine", + "notification.full": "La batterie de {name} est pleine.", + "notification.update_title": "Mise à jour de {app}", + "notification.update": 'La version {version} est disponible. Faites un clic droit sur une icône de batterie et choisissez « Télécharger la v{version}… ».', + "notification.temp_autostart": "Halo Battery est exécuté depuis un dossier temporaire (directement depuis le ZIP). Extrayez d’abord l’intégralité du ZIP dans un dossier permanent, puis activez Démarrer avec Windows.", + "rename.prompt": "Nouveau nom du périphérique :", + "rename.title": "Halo Battery - Renommer", + "rename.ok": "OK", + "rename.cancel": "Annuler", +} diff --git a/providers/asus.py b/providers/asus.py index f8d5a8c0..0fe21567 100644 --- a/providers/asus.py +++ b/providers/asus.py @@ -217,7 +217,9 @@ def poll(self) -> List[DeviceStatus]: out: List[DeviceStatus] = [] for name, (level, charging, text) in found.items(): key = "asus:" + name.lower().replace(" ", "-") - out.append(DeviceStatus(key, name, level, charging, True, "asus", text, kind="mouse")) + out.append(DeviceStatus(key, name, level, charging, True, "asus", text, kind="mouse", + ui_message="state.approx" if text else "", + ui_params={"level": level, "charging": charging} if text else {})) return out def diagnostics(self) -> List[str]: diff --git a/providers/audeze.py b/providers/audeze.py index f7894746..862ac9ec 100644 --- a/providers/audeze.py +++ b/providers/audeze.py @@ -466,7 +466,7 @@ def poll(self) -> List[DeviceStatus]: if n >= STUCK_POLLS and stuck is None: self._diag.append(f" stuck for {n} polls in a row: showing \"{STUCK_TEXT}\"") stuck = DeviceStatus(f"audeze:{serial}", HEADSET_NAME, None, False, True, - "audeze", STUCK_TEXT, kind="headset") + "audeze", STUCK_TEXT, kind="headset", ui_message="state.audeze_stuck") if level is None: # A switched-off headset is a routine state, not a fault. Log the # outage once per headset and stay quiet until it answers again: @@ -489,7 +489,7 @@ def poll(self) -> List[DeviceStatus]: f"not measured yet, re-checking in a few seconds") self.pending = True out.append(DeviceStatus(f"audeze:{serial}", HEADSET_NAME, None, False, True, - "audeze", WAKING_TEXT, kind="headset")) + "audeze", WAKING_TEXT, kind="headset", ui_message="state.pending")) break if len(order) > 1: self._diag.append(f" {name} answered, the other endpoint is not read") diff --git a/providers/base.py b/providers/base.py index fdc38216..a2922a00 100644 --- a/providers/base.py +++ b/providers/base.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import List, Optional log = logging.getLogger("halo_battery") @@ -21,6 +21,10 @@ class DeviceStatus: kind: str = "" # headset / mouse / keyboard / gamepad when known # (picks the pictogram); "" = guess from the source via: str = "" # "bluetooth": a controller connected over Bluetooth + # Presentation only: keep approx in English for diagnostics and status.json. + # Appended fields preserve existing positional constructors and protocol tests. + ui_message: str = "" + ui_params: dict = field(default_factory=dict) class Provider: diff --git a/providers/corsair.py b/providers/corsair.py index 7d66fac9..2b32de47 100644 --- a/providers/corsair.py +++ b/providers/corsair.py @@ -264,7 +264,8 @@ def poll(self) -> List[DeviceStatus]: continue level, label = parsed out.append(DeviceStatus(f"corsair:{pid:04x}", name, level, False, True, - "corsair", approx=label, kind="mouse")) + "corsair", approx=label, kind="mouse", ui_message="state.approx", + ui_params={"level": level})) break return out diff --git a/providers/eightbitdo.py b/providers/eightbitdo.py index 7e63c552..81f98107 100644 --- a/providers/eightbitdo.py +++ b/providers/eightbitdo.py @@ -186,7 +186,7 @@ def poll(self) -> List[DeviceStatus]: via = "bluetooth" if bluetooth else "" if res is None: out.append(DeviceStatus(key, name, None, False, True, "8bitdo", NOT_SWITCHED, - kind="gamepad", via=via)) + kind="gamepad", via=via, ui_message="state.8bitdo_basic")) continue level, charging = res self._diag.append(f" -> {level}%{' charging' if charging else ''}") diff --git a/providers/logitech.py b/providers/logitech.py index b72b62fb..4919747a 100644 --- a/providers/logitech.py +++ b/providers/logitech.py @@ -331,7 +331,8 @@ def _read(self, ch: _Channel, pid: int, idx: int, self._last.pop(old, None) self._slot_key[slot] = key return DeviceStatus(key, name, level, chg, True, "logitech", approx=approx, - kind=kind) + kind=kind, ui_message="state.approx_grade" if approx else "", + ui_params={"level": level, "grade": APPROX_LEVELS[r[1]][1]} if approx else {}) self._diag.append(f" idx={idx} '{name}': no battery reading") return None @@ -403,7 +404,8 @@ def poll(self) -> List[DeviceStatus]: continue if now - t < ASLEEP_KEEP and groups: out.append(DeviceStatus(key, st.name, st.level, st.charging, False, "logitech", - approx=st.approx, kind=st.kind)) + approx=st.approx, kind=st.kind, + ui_message=st.ui_message, ui_params=dict(st.ui_params))) else: del self._last[key] return out diff --git a/providers/nintendo.py b/providers/nintendo.py index 37ea969a..f59a41f9 100644 --- a/providers/nintendo.py +++ b/providers/nintendo.py @@ -203,7 +203,9 @@ def poll(self) -> List[DeviceStatus]: self._last[key] = (res, now) level, charging, text = res out.append(DeviceStatus(key, name, level, charging, True, "nintendo", text, - kind="gamepad", via="bluetooth")) + kind="gamepad", via="bluetooth", ui_message="state.approx_grade", + ui_params={"level": level, "grade": LEVEL_NAMES[level * 8 // 100], + "charging": charging})) continue if not self._failing.get(key): self._failing[key] = True @@ -213,7 +215,9 @@ def poll(self) -> List[DeviceStatus]: level, charging, text = last[0] out.append(DeviceStatus(key, name, level, False, False, "nintendo", f"{text.split(',')[0]} (last known value)", - kind="gamepad", via="bluetooth")) + kind="gamepad", via="bluetooth", ui_message="state.approx_grade", + ui_params={"level": level, "grade": LEVEL_NAMES[level * 8 // 100], + "last_known": True})) return out def diagnostics(self) -> List[str]: diff --git a/providers/playstation.py b/providers/playstation.py index 5543d97e..8bc91326 100644 --- a/providers/playstation.py +++ b/providers/playstation.py @@ -302,7 +302,7 @@ def poll(self) -> List[DeviceStatus]: self._pending_since.pop(key, None) out.append(DeviceStatus(key, dev["name"], None, False, True, "playstation", "level shown over Bluetooth only while Steam or a game " - "uses it")) + "uses it", ui_message="state.playstation_basic")) continue if res is None: # Present but no battery read: just connected, or another app (DS4Windows, @@ -317,6 +317,7 @@ def poll(self) -> List[DeviceStatus]: if now - since < PENDING_WINDOW: self.pending = True approx = "connected, battery level not reported yet" + ui_message = "state.pending" else: del self._pending_since[key] self._diag.append(f" {dev['name']}: no battery report for " @@ -325,7 +326,9 @@ def poll(self) -> List[DeviceStatus]: log.info("[PlayStation] %s: no battery report for %.0f s, not re-checking " "every 3 s any more", dev["name"], PENDING_WINDOW) approx = "connected, battery not readable (another app may hold it)" - out.append(DeviceStatus(key, dev["name"], None, False, True, "playstation", approx)) + ui_message = "state.unreadable" + out.append(DeviceStatus(key, dev["name"], None, False, True, "playstation", approx, + ui_message=ui_message)) continue level, charging = res self._pending_since.pop(key, None) diff --git a/providers/steelseries.py b/providers/steelseries.py index 96392912..950d59e6 100644 --- a/providers/steelseries.py +++ b/providers/steelseries.py @@ -569,7 +569,9 @@ def _poll_classic(self, infos: List[dict]) -> List[DeviceStatus]: if online and level is not None: approx = f"about {level}%" if pid in COARSE_MODELS else "" out.append(DeviceStatus(f"steelseries:{pid:04x}", name, level, chg, True, - "steelseries", approx=approx, kind="headset")) + "steelseries", approx=approx, kind="headset", + ui_message="state.approx" if approx else "", + ui_params={"level": level} if approx else {})) else: self._diag.append(" the headset is off or out of range") break diff --git a/providers/xinput.py b/providers/xinput.py index eb5c6333..473d02b7 100644 --- a/providers/xinput.py +++ b/providers/xinput.py @@ -315,6 +315,7 @@ def poll(self) -> List[DeviceStatus]: connected = [] vias = {} + presentation = {} # slot -> message key and parameters, independent of English text for n, (slot, res) in enumerate(slots): rep = reports[n] if n < len(reports) and reports[n].level is not None else None if rep is None and base_vid is not None and base_vid in bt_only: @@ -339,6 +340,7 @@ def poll(self) -> List[DeviceStatus]: connected.append((slot, name, None, False, "connected over Bluetooth; turn on \"Windows Bluetooth devices\" " "to see its battery")) + presentation[slot] = ("state.bluetooth_hint", {}) continue if rep is not None: self._waiting.pop(slot, None) @@ -356,10 +358,16 @@ def poll(self) -> List[DeviceStatus]: self._diag.append(f"[XInput] slot {slot}: type wired, but Windows.Gaming.Input " "says discharging: not shown as on cable") connected.append((slot, name, None, False, "connected, battery level not reported")) + presentation[slot] = ("state.unreported", {}) continue if not charging: self._last[slot] = level connected.append((slot, name, level, charging, approx)) + if charging: + presentation[slot] = ("state.cable", {}) + else: + grade = next(word for pct, word in LEVELS.values() if pct == level) + presentation[slot] = ("state.approx_grade", {"level": level, "grade": grade}) continue # connected, but neither API reports the battery (yet): show the icon # without an arc and re-check often for a while @@ -369,13 +377,16 @@ def poll(self) -> List[DeviceStatus]: if now - since < PENDING_WINDOW: self.pending = True connected.append((slot, name, None, False, "connected, battery level not reported yet")) + presentation[slot] = ("state.pending", {}) out: List[DeviceStatus] = [] for n, (slot, name, level, charging, approx) in enumerate(connected): if len(connected) > 1: name = f"{name} {n + 1}" + ui_message, ui_params = presentation.get(slot, ("", {})) out.append(DeviceStatus(f"xinput:{slot}", name, level, charging, True, "xinput", approx, - kind="gamepad", via=vias.get(slot, ""))) + kind="gamepad", via=vias.get(slot, ""), + ui_message=ui_message, ui_params=ui_params)) return out def diagnostics(self) -> List[str]: diff --git a/tests/test_asus.py b/tests/test_asus.py index a12fab99..0fcf493c 100644 --- a/tests/test_asus.py +++ b/tests/test_asus.py @@ -191,6 +191,8 @@ def test_older_model_shows_an_approximate_level(self): res = self.poll(e, {b"if0-ff01": FakeMouse(level=2)}) self.assertEqual([(r.name, r.level, r.approx) for r in res], [("ROG Keris Wireless", 50, "about 50%")]) + self.assertEqual((res[0].ui_message, res[0].ui_params), + ("state.approx", {"level": 50, "charging": False})) def test_unknown_asus_devices_are_not_opened(self): e = [dict(issue_81_entries()[0], product_id=0x1ACE)] # OMNI receiver: not included diff --git a/tests/test_audeze_stuck.py b/tests/test_audeze_stuck.py index b2285148..a9ccc0ed 100644 --- a/tests/test_audeze_stuck.py +++ b/tests/test_audeze_stuck.py @@ -87,6 +87,7 @@ def test_second_stuck_poll_shows_the_hint(self): st = res[0] self.assertEqual((st.key, st.name, st.level, st.online, st.approx), ("audeze:0000000000000000", "Audeze Maxwell", None, True, audeze.STUCK_TEXT)) + self.assertEqual(st.ui_message, "state.audeze_stuck") def test_replug_brings_the_level_back_under_the_same_key(self): self.p.poll() @@ -128,6 +129,7 @@ def setUp(self): def test_zero_right_after_power_on_is_not_a_reading(self): res = self.p.poll() self.assertEqual([(s.level, s.approx) for s in res], [(None, audeze.WAKING_TEXT)]) + self.assertEqual(res[0].ui_message, "state.pending") self.assertTrue(self.p.pending) # the app re-checks in 3 s, not 60 self.now += 3 self.dongle.level = 80 diff --git a/tests/test_corsair.py b/tests/test_corsair.py index 1a725c25..6f892dce 100644 --- a/tests/test_corsair.py +++ b/tests/test_corsair.py @@ -138,6 +138,7 @@ def test_the_dongle_is_shown_as_a_gauge(self): self.assertEqual(len(out), 1) st = out[0] self.assertEqual((st.key, st.level, st.approx, st.kind), ("corsair:1b7f", 50, "about 50%", "mouse")) + self.assertEqual((st.ui_message, st.ui_params), ("state.approx", {"level": 50})) self.assertFalse(st.charging) self.assertTrue(st.online) self.assertEqual(bus.opened, [b"dongle-ff420001"]) diff --git a/tests/test_eightbitdo.py b/tests/test_eightbitdo.py index 7540ce58..9bb1113f 100644 --- a/tests/test_eightbitdo.py +++ b/tests/test_eightbitdo.py @@ -150,6 +150,7 @@ def test_idle_ordinary_mode_shows_no_level(self): out = self.poll([entry(BT_PATH)], {BT_PATH: FakePad([])}) self.assertIsNone(out[0].level) self.assertIn("Steam", out[0].approx) + self.assertEqual(out[0].ui_message, "state.8bitdo_basic") self.assertLessEqual(self.clock.now - start, E.BUDGET + 0.01) self.assertIn("ordinary mode", "\n".join(self.provider.diagnostics())) diff --git a/tests/test_hide_rename.py b/tests/test_hide_rename.py index 39581ae9..f3b9d35a 100644 --- a/tests/test_hide_rename.py +++ b/tests/test_hide_rename.py @@ -163,7 +163,7 @@ def test_damaged_settings_value_is_ignored(self): class RenameTests(HideRenameTestCase): def rename_to(self, app, ic, answer): - with mock.patch.object(hb, "ask_name", lambda current: answer): + with mock.patch.object(hb, "ask_name", lambda current, **kwargs: answer): app._rename(ic) def test_rename_changes_tooltip_and_alert(self): @@ -239,7 +239,7 @@ def test_clicking_hide_and_rename_in_the_device_menu(self): app.apply([dev()]) ic = app.icons["logitech:C15E09CD"] items = {i.text: i for i in app.build_menu(ic).items if i.visible} - with mock.patch.object(hb, "ask_name", lambda current: "Work mouse"): + with mock.patch.object(hb, "ask_name", lambda current, **kwargs: "Work mouse"): items["Rename…"](FakeTrayIcon()) self.assertEqual(app.cfg["names"], {"logitech:C15E09CD": "Work mouse"}) items["Hide this device"](FakeTrayIcon()) @@ -272,7 +272,7 @@ def test_preferences_holds_the_settings(self): menu = app.build_menu(None) prefs = next(i for i in menu.items if i.text == "Preferences").submenu texts = [i.text for i in prefs.items if i is not hb.Menu.SEPARATOR] - self.assertEqual(texts, ["Poll interval", "Low battery alert", "Alert when fully charged", + self.assertEqual(texts, ["Language / Langue", "Poll interval", "Low battery alert", "Alert when fully charged", "Estimated time left", "Quiet while gaming", "Windows Bluetooth devices", "PlayStation full mode (Bluetooth)", "Device types", diff --git a/tests/test_i18n.py b/tests/test_i18n.py new file mode 100644 index 00000000..9c3b17ca --- /dev/null +++ b/tests/test_i18n.py @@ -0,0 +1,358 @@ +"""Localization and Windows UI detection without hardware or real desktop state.""" +import ctypes +import json +import os +from string import Formatter +import tempfile +import types +import unittest +from unittest import mock + +import i18n +from locales import en, fr +import test_hide_rename +from test_hide_rename import FakeIcon, dev, hb, make_app +from test_flyout import FakeStyle + + +def find(menu, text): + return next(item for item in menu.items if item.text == text) + + +class CatalogTests(unittest.TestCase): + def test_complete_keys_parameters_and_plural_forms(self): + self.assertEqual(set(en.MESSAGES), set(fr.MESSAGES)) + for key, reference in en.MESSAGES.items(): + translated = fr.MESSAGES[key] + with self.subTest(key=key): + self.assertIsInstance(translated, type(reference)) + if isinstance(reference, dict): + self.assertEqual(set(reference), {"one", "other"}) + self.assertEqual(set(reference), set(translated)) + pairs = [(reference[form], translated[form]) for form in reference] + else: + pairs = [(reference, translated)] + for english, french in pairs: + self.assertTrue(english.strip()) + self.assertTrue(french.strip()) + self.assertEqual(english, english.strip()) + self.assertEqual(french, french.strip()) + fields = lambda text: {field for _, field, _, _ in Formatter().parse(text) if field} + self.assertEqual(fields(english), fields(french)) + + def test_language_and_missing_translation_fallback(self): + self.assertEqual(i18n.translate("menu.preferences", language="de"), "Preferences") + with mock.patch.dict(fr.MESSAGES, {}, clear=True): + self.assertEqual(i18n.translate("menu.exit", language="fr", version="1.2.3"), "Exit (v1.2.3)") + # Fallback uses the English plural rule even for a French request. + self.assertEqual(i18n.translate("duration.days", language="fr", count=0), "about 0 days of use left") + with mock.patch.dict(fr.MESSAGES, {"duration.days": {}}): + self.assertEqual(i18n.translate("duration.days", language="fr", count=0), "about 0 days of use left") + + def test_unknown_key_and_missing_parameters_are_programming_errors(self): + with self.assertRaises(KeyError): + i18n.translate("missing") + with self.assertRaises(KeyError): + i18n.translate("menu.exit") + with self.assertRaises(ValueError): + i18n.translate("duration.days") + + def test_plural_rules_and_estimate_boundaries(self): + self.assertIn("1 masqué)", i18n.translate("menu.none_shown", language="fr", count=1)) + self.assertIn("2 masqués)", i18n.translate("menu.none_shown", language="fr", count=2)) + self.assertIn("0 masqué)", i18n.translate("menu.none_shown", language="fr", count=0)) + self.assertEqual(i18n.format_left(3599, language="fr"), "moins d’une heure d’utilisation restante") + self.assertEqual(i18n.format_left(3600, language="fr"), "environ 1 heure d’utilisation restante") + self.assertEqual(i18n.format_left(7200, language="fr"), "environ 2 heures d’utilisation restantes") + self.assertEqual(i18n.format_left(48 * 3600, language="fr"), "environ 2 jours d’utilisation restants") + for hours in (0, 0.5, 1, 1.5, 5.4, 47.9, 48, 72, 200): + self.assertEqual(i18n.format_left(hours * 3600), hb.history.format_left(hours * 3600)) + + def test_unicode_tooltip_buffer(self): + self.assertEqual(i18n.tooltip("Écouteurs 🎧"), "Écouteurs 🎧") + self.assertEqual(i18n.tooltip("é" * 200), "é" * 127) + self.assertEqual(i18n.tooltip("x" * 126 + "🎧"), "x" * 126) + self.assertEqual(i18n.tooltip("🎧" * 100), "🎧" * 63) + + +class DetectionTests(unittest.TestCase): + def detect(self, result=None, error=None): + api = mock.Mock(return_value=result, side_effect=error) + win = types.SimpleNamespace(kernel32=types.SimpleNamespace(GetUserDefaultUILanguage=api)) + with mock.patch.object(i18n.sys, "platform", "win32"), mock.patch.object(ctypes, "windll", win, create=True): + detected = i18n.detect_language() + self.assertEqual(api.argtypes, []) + self.assertIs(api.restype, ctypes.c_ushort) + return detected + + def test_all_regional_french_variants(self): + for langid in (0x040C, 0x080C, 0x0C0C, 0x100C, 0x140C, 0x180C): + with self.subTest(langid=langid): + self.assertEqual(self.detect(langid), "fr") + + def test_other_languages_invalid_results_and_api_failure(self): + for langid in (0x0409, 0x0407, 0, -1, 0x1000C, None, "fr"): + with self.subTest(langid=langid): + self.assertEqual(self.detect(langid), "en") + self.assertEqual(self.detect(error=OSError("unavailable")), "en") + with mock.patch.object(i18n.sys, "platform", "win32"), \ + mock.patch.object(ctypes, "windll", types.SimpleNamespace(), create=True): + self.assertEqual(i18n.detect_language(), "en") + + def test_off_windows(self): + with mock.patch.object(i18n.sys, "platform", "linux"): + self.assertEqual(i18n.detect_language(), "en") + + +class LanguageConfigTests(unittest.TestCase): + def setUp(self): + folder = tempfile.TemporaryDirectory(prefix="halo_language_") + self.addCleanup(folder.cleanup) + self.path = os.path.join(folder.name, "config.json") + patch = mock.patch.object(hb, "CONFIG_PATH", self.path) + patch.start() + self.addCleanup(patch.stop) + + def write(self, data): + with open(self.path, "w", encoding="utf-8") as stream: + json.dump(data, stream, ensure_ascii=False) + + def test_ordinary_load_does_not_detect_or_write(self): + with mock.patch.object(i18n, "detect_language") as detect: + self.assertEqual(hb.load_config()["language"], "en") + detect.assert_not_called() + self.assertFalse(os.path.exists(self.path)) + + def test_first_start_detects_and_persists_then_never_detects_again(self): + with mock.patch.object(i18n, "detect_language", return_value="fr") as detect: + self.assertEqual(hb.load_config(initialize_language=True)["language"], "fr") + detect.assert_called_once_with() + with mock.patch.object(i18n, "detect_language", return_value="en") as detect: + self.assertEqual(hb.load_config(initialize_language=True)["language"], "fr") + detect.assert_not_called() + with open(self.path, encoding="utf-8") as stream: + self.assertEqual(json.load(stream)["language"], "fr") + + def test_old_settings_preserve_names_options_and_unknown_keys(self): + self.write({"interval": 30, "names": {"key": 'Écouteurs 🎧 "bureau"'}, "future": True}) + with mock.patch.object(i18n, "detect_language", return_value="fr"): + cfg = hb.load_config(initialize_language=True) + self.assertEqual(cfg["interval"], 30) + self.assertEqual(cfg["names"], {"key": 'Écouteurs 🎧 "bureau"'}) + self.assertTrue(cfg["future"]) + self.assertEqual(hb.load_config(), cfg) + + def test_saved_languages_and_invalid_values_never_trigger_detection(self): + for value in ("en", "fr", "de", "FR", None, False, 1, {}, []): + with self.subTest(value=value): + self.write({"language": value, "interval": 30}) + with mock.patch.object(i18n, "detect_language") as detect: + cfg = hb.load_config(initialize_language=True) + detect.assert_not_called() + self.assertEqual(cfg["language"], value if value in ("en", "fr") else "en") + self.assertEqual(cfg["interval"], 30) + + def test_damaged_config_keeps_existing_recovery_and_english_default(self): + with open(self.path, "w") as stream: + stream.write("{broken") + with mock.patch.object(i18n, "detect_language") as detect: + self.assertEqual(hb.load_config(initialize_language=True), hb.DEFAULTS) + detect.assert_not_called() + self.assertTrue(os.path.exists(self.path + ".bad")) + + def test_manual_selection_persists_and_overrides_next_windows_detection(self): + app = make_app() + app.set_language("fr") + with mock.patch.object(i18n, "detect_language", return_value="en") as detect: + self.assertEqual(hb.load_config(initialize_language=True)["language"], "fr") + detect.assert_not_called() + + +class PresentationTests(unittest.TestCase): + def setUp(self): + self.app = make_app({"language": "fr", "quiet_fullscreen": False}) + + def test_exact_states_and_unmodified_names(self): + st = dev(name='G502 "Bureau" 🎧', level=85) + self.assertEqual(hb.describe(st), 'G502 "Bureau" 🎧: 85%') + self.assertEqual(hb.describe(st, language="fr"), 'G502 "Bureau" 🎧 : 85 %') + st.charging = True + self.assertEqual(hb.device_state(st, language="fr"), "85 %, en charge") + st.charging, st.online = False, False + self.assertIn("dernière valeur connue", hb.device_state(st, language="fr")) + st.level = None + self.assertEqual(hb.device_state(st, language="fr"), "aucune liaison (éteint ou en veille)") + + def test_real_device_icon_uses_selected_language_during_a_poll(self): + icon = test_hide_rename.MenuRefreshTests().make_icon() + icon.app.cfg["language"] = "fr" + st = dev(name="G502 🎧", level=85) + icon.update(st) + self.assertEqual(icon.icon.title, "G502 🎧 : 85 %") + self.assertEqual(icon.icon.rebuilt, 1) + + def test_structured_coarse_states_do_not_parse_english_text(self): + st = dev(level=50) + st.approx = "about 50% (good)" + st.ui_message, st.ui_params = "state.approx_grade", {"level": 50, "grade": "good"} + self.assertEqual(hb.device_state(st), st.approx) + self.assertEqual(hb.device_state(st, language="fr"), "environ 50 % (bonne)") + st.ui_params["charging"] = True + self.assertIn("en charge", hb.device_state(st, language="fr")) + st.ui_params = {"level": 50, "grade": "good", "last_known": True} + self.assertIn("dernière valeur connue", hb.device_state(st, language="fr")) + st.ui_message = "state.audeze_stuck" + st.ui_params = {} + self.assertIn("débranchez", hb.device_state(st, language="fr")) + + def test_every_provider_and_pictogram_is_translated(self): + prefs = find(self.app.build_menu(None), "Préférences").submenu + labels = [item.text for item in find(prefs, "Types de périphériques").submenu] + self.assertEqual(set(labels), {i18n.translate("provider." + key, language="fr") for key in hb.PROVIDER_LABELS}) + owner = types.SimpleNamespace(status=dev()) + labels = [item.text for item in find(self.app.build_menu(owner), "Icône").submenu] + self.assertEqual(labels, ["Automatique", "Souris", "Clavier", "Casque", "Manette", "Bluetooth"]) + + def test_immediate_switch_all_icons_placeholder_and_both_directions(self): + app = self.app + app.history.seconds_left = lambda key, level: 7200 + menus = [] + for index in range(2): + icon = types.SimpleNamespace(update_menu=lambda: menus.append("refresh")) + app.icons[str(index)] = types.SimpleNamespace(status=dev(key=str(index)), icon=icon) + app.placeholder = types.SimpleNamespace(update_menu=lambda: menus.append("idle")) + with mock.patch.object(hb, "save_config") as save: + app.set_language("fr") + self.assertIn("heures d’utilisation restantes", app.icons["0"].icon.title) + self.assertIn("aucun périphérique", app.placeholder.title) + self.assertEqual(len(menus), 3) + self.assertFalse(app.wake.is_set()) + save.assert_called_once_with(app.cfg) + app.set_language("en") + self.assertIn("h of use left", app.icons["1"].icon.title) + self.assertEqual(find(app.icons["0"].icon.menu, "Preferences").text, "Preferences") + + def test_language_menu_selects_and_marks_saved_language(self): + prefs = find(self.app.build_menu(None), "Préférences").submenu + languages = find(prefs, "Language / Langue").submenu + self.assertEqual([item.text for item in languages], ["English", "Français"]) + self.assertTrue(find(languages, "Français").checked) + with mock.patch.object(hb, "save_config"): + find(languages, "English")(None) + self.assertEqual(self.app.cfg["language"], "en") + self.assertTrue(find(languages, "English").checked) + + def test_status_and_diagnostics_remain_english(self): + st = dev(level=85) + self.app.history.seconds_left = lambda key, level: 7200 + french = self.app.status_data([st])["devices"] + self.app.cfg["language"] = "en" + self.assertEqual(french, self.app.status_data([st])["devices"]) + self.assertEqual(french[0]["text"], "G502 LIGHTSPEED: 85%, about 2 h of use left") + st.approx, st.ui_message, st.ui_params = "about 50%", "state.approx", {"level": 50} + self.app.cfg["language"] = "fr" + self.assertEqual(self.app.status_data([st])["devices"][0]["approx"], "about 50%") + self.assertEqual(hb.describe(st), "G502 LIGHTSPEED: about 50%") + + def test_full_diagnostic_report_is_language_independent(self): + app = self.app + app.cfg["bluetooth"] = False + app.providers, app.win_events = [], None + with tempfile.TemporaryDirectory() as folder, \ + mock.patch.object(hb, "DIAG_PATH", os.path.join(folder, "diagnostics.txt")), \ + mock.patch.object(hb, "LOG_PATH", os.path.join(folder, "empty.log")), \ + mock.patch.object(hb, "dump_hid", return_value=["VID=046d PID=c539"]), \ + mock.patch.object(hb.icons, "theme_report", return_value=["Windows theme: dark"]), \ + mock.patch.object(hb, "fullscreen_app_running", return_value=False), \ + mock.patch.object(hb.time, "strftime", return_value="2026-10-01 12:00:00"), \ + mock.patch.object(hb.os, "startfile", create=True): + french = app.write_diag([dev(level=85)]) + app.cfg["language"] = "en" + self.assertEqual(french, app.write_diag([dev(level=85)])) + self.assertIn("=== Poll result ===\nG502 LIGHTSPEED: 85%", french) + + def test_french_header_and_long_names_wrap_at_dpi_scales(self): + owner = types.SimpleNamespace(status=dev(name="Écouteurs 🎧 " * 20)) + for scale in (1, 1.25, 1.5, 2): + rows = hb.flyout.build_rows(self.app.build_menu(owner)) + measure = lambda text: round(7 * len(text) * scale) + width, height = hb.flyout.layout(rows, FakeStyle(scale), measure, measure) + self.assertGreater(len(rows[0].lines), 1) + self.assertGreater(height, 0) + for text, _ in rows[0].lines: + self.assertLessEqual(measure(text), hb.flyout.header_text_width(width, FakeStyle(scale))) + + +class NotificationTests(unittest.TestCase): + def setUp(self): + self.app = make_app({"language": "fr", "quiet_fullscreen": False}) + self.notes = [] + self.icon = types.SimpleNamespace(notify=lambda text, title: self.notes.append((text, title))) + + def test_immediate_texts_and_titles(self): + self.app.notify(self.icon, "key", "", "", kind="low", params={"name": "G502", "level": 15, "approx": False}) + self.app.notify(self.icon, "key", "", "", kind="full", params={"name": "G502"}) + self.app.notify(self.icon, "", "", "", kind="update", params={"version": "2.0.0"}) + self.assertEqual(self.notes[0], ("G502 : 15 % restants. Pensez à recharger.", "Batterie faible")) + self.assertEqual(self.notes[1], ("La batterie de G502 est pleine.", "Batterie pleine")) + self.assertIn("Télécharger la v2.0.0", self.notes[2][0]) + self.assertEqual(self.notes[2][1], "Mise à jour de Halo Battery") + + def test_held_notification_uses_current_language_and_stable_kind(self): + app = self.app + app.placeholder = self.icon + with mock.patch.object(app, "quiet", return_value=True): + app.notify(self.icon, "key", "English low", "Low battery", kind="low", + params={"name": "G502", "level": 15, "approx": False}) + app.cfg["language"] = "en" + app.notify(self.icon, "key", "English low", "Low battery", kind="low", + params={"name": "G502", "level": 12, "approx": False}) + self.assertEqual(len(app.held), 1) + app.cfg["language"] = "fr" + app.flush_held() + self.assertEqual(self.notes, [("G502 : 12 % restants. Pensez à recharger.", "Batterie faible")]) + + def test_held_low_alert_is_dropped_after_charge_even_if_language_changed(self): + app = self.app + with mock.patch.object(app, "quiet", return_value=True): + app.notify(self.icon, "key", "", "", kind="low", params={"name": "G502", "level": 15, "approx": False}) + st = dev(key="key", level=100) + st.charging = True + app.icons["key"] = types.SimpleNamespace(status=st, icon=self.icon) + app.cfg["language"] = "en" + app.flush_held() + self.assertEqual(self.notes, []) + + def test_held_full_and_update_notifications_are_translated_on_delivery(self): + app = self.app + app.placeholder = self.icon + app.cfg["language"] = "en" + with mock.patch.object(app, "quiet", return_value=True): + app.notify(self.icon, "key", "", "", kind="full", params={"name": "G502"}) + app.notify(self.icon, "", "", "", kind="update", params={"version": "2.0.0"}) + app.cfg["language"] = "fr" + app.flush_held() + self.assertEqual(len(self.notes), 2) + self.assertEqual(self.notes[0][1], "Batterie pleine") + self.assertEqual(self.notes[1][1], "Mise à jour de Halo Battery") + + def test_real_alert_behavior_is_preserved_in_french(self): + with mock.patch.object(hb, "DeviceIcon", FakeIcon): + self.app.apply([dev(level=10)]) + self.app.apply([dev(level=9)]) + self.assertEqual(self.app.notes, ["G502 LIGHTSPEED : 10 % restants. Pensez à recharger."]) + + def test_rename_passes_localized_text_safely_through_environment(self): + with mock.patch.object(hb.sys, "platform", "win32"), \ + mock.patch.object(hb.subprocess, "run", return_value=types.SimpleNamespace(stdout="Écouteurs 🎧".encode())) as run: + self.assertEqual(hb.ask_name('G502 "bureau"', language="fr"), "Écouteurs 🎧") + env = run.call_args.kwargs["env"] + self.assertEqual(env["HALO_BATTERY_CANCEL"], "Annuler") + self.assertEqual(env["HALO_BATTERY_PROMPT"], "Nouveau nom du périphérique :") + command = " ".join(run.call_args.args[0]) + self.assertNotIn("bureau", command) + self.assertNotIn("Annuler", command) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_logitech.py b/tests/test_logitech.py index f56bdbed..207b8411 100644 --- a/tests/test_logitech.py +++ b/tests/test_logitech.py @@ -239,6 +239,20 @@ def test_unified_battery_without_percentage(self): self.use(receiver(), {b"long": ({1: dev}, b"short")}) [st] = L.LogitechProvider().poll() self.assertEqual((st.level, st.approx), (50, "about 50% (good)")) + self.assertEqual((st.ui_message, st.ui_params), + ("state.approx_grade", {"level": 50, "grade": "good"})) + + def test_localization_metadata_survives_sleep_cache(self): + dev = FakeHidpp({L.F_UNIFIED: (5, {1: [0, 4, 0, 0]})}) + self.use(receiver(), {b"long": ({1: dev}, b"short")}) + provider = L.LogitechProvider() + [awake] = provider.poll() + dev.silent = True + [asleep] = provider.poll() + self.assertFalse(asleep.online) + self.assertEqual(asleep.approx, awake.approx) + self.assertEqual((asleep.ui_message, asleep.ui_params), (awake.ui_message, awake.ui_params)) + self.assertIsNot(asleep.ui_params, awake.ui_params) def win_path(instance, col): diff --git a/tests/test_nintendo.py b/tests/test_nintendo.py index 57d8e6ef..91fd2935 100644 --- a/tests/test_nintendo.py +++ b/tests/test_nintendo.py @@ -173,6 +173,8 @@ def test_simple_mode_issue_63(self): self.assertEqual((st.name, st.level, st.charging, st.online), ("Nintendo Switch Pro Controller", 75, False, True)) self.assertEqual(st.approx, "about 75% (medium)") + self.assertEqual((st.ui_message, st.ui_params), + ("state.approx_grade", {"level": 75, "grade": "medium", "charging": False})) self.assertEqual((st.key, st.kind, st.source), ("switch:2009:98b6e9123456", "gamepad", "nintendo")) self.assertEqual(len(pad.writes), 1) self.assertEqual(pad.writes[0][10], N.SUBCOMMAND_DEVICE_INFO) @@ -202,6 +204,7 @@ def test_silent_after_a_reading_keeps_the_last_value_greyed_for_a_while(self): res = self.poll(e, {BT_PATH: pad}) self.assertEqual([(r.level, r.charging, r.online) for r in res], [(75, False, False)]) self.assertEqual(res[0].approx, "about 75% (medium) (last known value)") + self.assertEqual(res[0].ui_params, {"level": 75, "grade": "medium", "last_known": True}) self.clock.now += N.ASLEEP_KEEP self.assertEqual(self.poll(e, {BT_PATH: pad}), []) diff --git a/tests/test_notifications.py b/tests/test_notifications.py index c1b2cf1e..5641e963 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -25,7 +25,8 @@ def test_texts_are_the_ones_users_see(self): def test_real_low_battery_alert_uses_the_shared_text(self): app = make_app({"low": 20}) - with mock.patch.object(hb, "DeviceIcon", __import__("test_hide_rename").FakeIcon): + with mock.patch.object(hb, "DeviceIcon", __import__("test_hide_rename").FakeIcon), \ + mock.patch.object(hb, "fullscreen_app_running", return_value=False): app.apply([dev(level=10)]) self.assertEqual(app.notes, [hb.low_battery_text("G502 LIGHTSPEED", 10, False)]) diff --git a/tests/test_playstation.py b/tests/test_playstation.py index 301dcccf..528f76ff 100644 --- a/tests/test_playstation.py +++ b/tests/test_playstation.py @@ -146,6 +146,7 @@ def test_bluetooth_basic_mode_is_not_switched_by_default(self): self.assertIsNone(out[0].level) self.assertTrue(out[0].approx) self.assertIn("Steam", out[0].approx) + self.assertEqual(out[0].ui_message, "state.playstation_basic") def test_bluetooth_basic_mode_does_not_start_the_fast_recheck(self): # the basic mode is the normal state now, not a controller that is still starting diff --git a/tests/test_steelseries.py b/tests/test_steelseries.py index 8fad809f..968c7323 100644 --- a/tests/test_steelseries.py +++ b/tests/test_steelseries.py @@ -235,6 +235,7 @@ def test_every_step_of_the_nine_step_scale(self): st, _ = self.poll_nova_pro(nova_pro_reply(code, 0x08)) self.assertEqual([(x.level, x.approx) for x in st], [(pct, "about %d%%" % pct)], "code %d" % code) + self.assertEqual((st[0].ui_message, st[0].ui_params), ("state.approx", {"level": pct})) def test_the_state_byte_is_the_gate(self): for state in (0x00, 0x03, 0x04, 0x80, 0xFF): diff --git a/tests/test_xinput.py b/tests/test_xinput.py index 6fac0a20..b0900eb5 100644 --- a/tests/test_xinput.py +++ b/tests/test_xinput.py @@ -94,6 +94,7 @@ def test_issue_108_no_ten_percent(self): self.assertIsNone(st.level) # not the 10% of remain=100 / full=1000 self.assertEqual((st.via, st.name), ("bluetooth", "Xbox controller")) self.assertIn("Windows Bluetooth devices", st.approx) + self.assertEqual(st.ui_message, "state.bluetooth_hint") self.assertIn("[XInput] slot 0: connected over Bluetooth", p.diagnostics()) def test_every_bluetooth_id(self): diff --git a/tests/test_xinput_wired.py b/tests/test_xinput_wired.py index 17e1a97c..53691175 100644 --- a/tests/test_xinput_wired.py +++ b/tests/test_xinput_wired.py @@ -121,6 +121,7 @@ def test_wired_without_wgi_report_unchanged(self): self.assertTrue(st.charging) self.assertEqual(st.level, 100) self.assertEqual(st.approx, "on cable, charging") + self.assertEqual(st.ui_message, "state.cable") def test_wired_when_wgi_unavailable_unchanged(self): _p, res = self.poll({0: (X.TYPE_WIRED, 0)}, None, hid_devices=[XBOX_USB_HID]) @@ -156,6 +157,8 @@ def test_wireless_xinput_level_unchanged(self): [wgi_report(0x2DC8, 0x3106, 1000, 1000, "Discharging")]) self.assertFalse(res[0].charging) self.assertEqual(res[0].level, 55) + self.assertEqual((res[0].ui_message, res[0].ui_params), + ("state.approx_grade", {"level": 55, "grade": "medium"})) if __name__ == "__main__": From d86003e5d3873bce7ba97019152646d7cddd5d5e Mon Sep 17 00:00:00 2001 From: Tiago FERREIRA DOMINGUES Date: Thu, 1 Oct 2026 17:10:02 +0200 Subject: [PATCH 2/8] Use Clair and Sombre for French icon colour options --- locales/fr.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/locales/fr.py b/locales/fr.py index 2047d8c9..310bbcd5 100644 --- a/locales/fr.py +++ b/locales/fr.py @@ -38,8 +38,8 @@ "preferences.autostart": "Démarrer avec Windows", "preferences.updates": "Rechercher les mises à jour", "choice.auto": "Automatique", - "choice.white": "Blanc", - "choice.black": "Noir", + "choice.white": "Clair", + "choice.black": "Sombre", "choice.off": "Désactivée", "icon.mouse": "Souris", "icon.keyboard": "Clavier", From 8077b09cd7b410a31fa31fded74ff78a4b7e7d7f Mon Sep 17 00:00:00 2001 From: Tiago FERREIRA DOMINGUES Date: Thu, 1 Oct 2026 17:31:06 +0200 Subject: [PATCH 3/8] Centralize language metadata and generalize localization checks --- CHANGELOG.md | 3 ++ CONTRIBUTING.md | 29 ++++++++--- halo_battery.pyw | 4 +- i18n.py | 72 +++++++++++++++++++------ tests/test_i18n.py | 127 +++++++++++++++++++++++++++++++++++++-------- 5 files changed, 187 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16552ed2..8013cd92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ and the project follows [Semantic Versioning](https://semver.org/). use English. Saved choices take precedence, and English remains the fallback. Diagnostics, protocol output and status-file text stay in English. Translation catalogs are bundled without additional runtime dependencies. + A single language registry supplies native names, catalogs, Windows detection + and plural rules; catalog checks cover every registered language, including + languages with additional plural forms. ## [1.13.0] - 2026-09-29 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5e08fcdd..d81732c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,18 +90,35 @@ Application messages live in `locales/en.py` (the reference and fallback) and `locales/fr.py`. They are ordinary Python modules, bundled by PyInstaller without additional dependencies or a translation compilation step. -- Copy the English catalog for a new language, translate the complete messages, - and register an explicit import, catalog and native language name in `i18n.py`. - Extend the plural rule and Windows UI language detection there for that language. +- Copy the English catalog into `locales/.py` and translate the complete + messages. Import that module explicitly in `i18n.py` so PyInstaller bundles it. + Add one `Language` entry to `i18n.LANGUAGES` with the native name, catalog, + plural rule, plural forms and Windows primary language IDs. This single registry + drives rendering, initial detection, the preferences menu and configuration + validation; no provider or UI changes are needed to register a language. +- The default `one_other` rule selects `one` for integer 1, otherwise `other`. + Supply a `plural_rule(count)` when the language differs, returning a category + from its declared `plural_forms`. Additional categories such as `few` or `many` + are supported. Counts in this application are nonnegative integers. + `windows_primary_ids` matches every regional variant; leave it empty for a + language available only by manual selection. English remains the default and + fallback regardless of registry order. - Keep message keys, named format parameters (`{name}`, `{level}`, etc.), and the - `one` / `other` plural forms consistent with English. Do not translate brands, - hardware names, user-provided names, device keys, protocols or paths. + parameters of every plural form consistent with English. Every plural message + must contain all categories declared in that language's `plural_forms`, including + `other`. A missing message or selected plural form falls back to English using + the English plural rule. Do not translate brands, hardware names, user-provided + names, device keys, protocols or paths. - Pass the language explicitly when rendering UI text. A missing translation falls back to English. Providers supply `ui_message` / `ui_params` for dynamic states; keep `approx`, protocol output, logs, diagnostics and status-file text in English. -- Test both languages, fallback, format parameters, plural forms, configuration +- Tests automatically check every registered catalog's keys, parameters and plural + forms, along with unambiguous Windows IDs. Add tests for the new plural rule's + boundaries. Test fallback, configuration persistence and notification behavior with `python -m unittest discover -s tests`. Review long labels, accents and device names in both classic and Fluent menus. + A right-to-left language also requires a separate review of text direction and + UI layout; registering its catalog alone does not enable right-to-left layout. The app detects the Windows **UI language**, not the regional format or keyboard, only when no language is saved. All French regional variants select `fr`; other diff --git a/halo_battery.pyw b/halo_battery.pyw index 606689b4..7c0ccb95 100644 --- a/halo_battery.pyw +++ b/halo_battery.pyw @@ -1102,9 +1102,9 @@ class App: # all settings in one submenu, so the main menu keeps only the things used often preferences = Menu( Item(tr("preferences.language"), Menu(*[ - Item(label, pick_language(language), radio=True, + Item(details.name, pick_language(language), radio=True, checked=lambda it, language=language: self.cfg.get("language", "en") == language) - for language, label in i18n.LANGUAGES.items()])), + for language, details in i18n.LANGUAGES.items()])), # - / + in the menu; the classic menu shows them as a list to pick from flyout.CounterItem(tr("preferences.interval"), intervals, lambda: self.cfg["interval"], set_interval), flyout.CounterItem(tr("preferences.low"), lows, lambda: self.cfg["low"], set_low), diff --git a/i18n.py b/i18n.py index 177a21da..c18f000e 100644 --- a/i18n.py +++ b/i18n.py @@ -6,56 +6,94 @@ from __future__ import annotations import sys +from collections.abc import Callable, Mapping +from dataclasses import dataclass from locales import en, fr -LANGUAGES = {"en": "English", "fr": "Français"} -CATALOGS = {"en": en.MESSAGES, "fr": fr.MESSAGES} +DEFAULT_LANGUAGE = "en" + + +def one_other(count: int) -> str: + """Plural rule for languages with singular 1 and all other integer counts.""" + return "one" if count == 1 else "other" + + +def _french_plural(count: int) -> str: + return "one" if count in (0, 1) else "other" + + +@dataclass(frozen=True) +class Language: + """All metadata needed to add a language; counts are nonnegative integers. + + Rules may return additional categories (e.g. few/many). Declare all of them + in plural_forms and supply each form in the catalog's plural messages. + Windows primary IDs match every regional variant; an empty tuple opts out + of automatic detection while keeping manual selection available. + """ + + name: str + catalog: Mapping[str, str | Mapping[str, str]] + plural_rule: Callable[[int], str] = one_other + windows_primary_ids: tuple[int, ...] = () + plural_forms: tuple[str, ...] = ("one", "other") + + +# Static imports above and this single registry also drive menus and validation. +LANGUAGES = { + "en": Language("English", en.MESSAGES, windows_primary_ids=(0x09,)), + "fr": Language("Français", fr.MESSAGES, plural_rule=_french_plural, + windows_primary_ids=(0x0C,)), +} def detect_language() -> str: """The current user's Windows UI language, not their regional format/keyboard. GetUserDefaultUILanguage returns a LANGID. Its primary-language bits cover - all French variants (France, Canada, Belgium, Switzerland, ...). + all regional variants of a registered language. https://learn.microsoft.com/windows/win32/api/winnls/nf-winnls-getuserdefaultuilanguage """ if sys.platform != "win32": - return "en" + return DEFAULT_LANGUAGE try: import ctypes get_language = ctypes.windll.kernel32.GetUserDefaultUILanguage get_language.argtypes = [] get_language.restype = ctypes.c_ushort langid = get_language() - french = isinstance(langid, int) and 0 < langid <= 0xFFFF and (langid & 0x3FF) == 0x0C - return "fr" if french else "en" + if isinstance(langid, int) and not isinstance(langid, bool) and 0 < langid <= 0xFFFF: + primary_id = langid & 0x3FF + for code, language in LANGUAGES.items(): + if primary_id in language.windows_primary_ids: + return code except (AttributeError, OSError, TypeError, ValueError): - return "en" + pass + return DEFAULT_LANGUAGE -def translate(key: str, *, language: str = "en", count=None, **params) -> str: +def translate(key: str, *, language: str = DEFAULT_LANGUAGE, count=None, **params) -> str: """Render a catalog message; an unsupported language/missing entry uses English. An unknown reference key or missing argument is a programming error. Keeping these visible makes catalog mistakes testable instead of hiding them in the UI. """ - catalog = CATALOGS.get(language, en.MESSAGES) - if key not in catalog: - catalog = en.MESSAGES - message = catalog.get(key) + fallback = LANGUAGES[DEFAULT_LANGUAGE] + selected = LANGUAGES.get(language, fallback) + if key not in selected.catalog: + selected = fallback + message = selected.catalog.get(key) if message is None: raise KeyError(key) - if isinstance(message, dict): + if isinstance(message, Mapping): if count is None: raise ValueError(f"{key} requires count") - # English: 1; French: 0 and 1. Counts here are nonnegative integers. - singular = count in (0, 1) if catalog is fr.MESSAGES else count == 1 - form = "one" if singular else "other" + form = selected.plural_rule(count) if form in message: message = message[form] else: - message = en.MESSAGES[key]["one" if count == 1 else "other"] + message = fallback.catalog[key][fallback.plural_rule(count)] params = dict(params, count=count) return message.format(**params) diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 9c3b17ca..565cfb25 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -1,5 +1,6 @@ """Localization and Windows UI detection without hardware or real desktop state.""" import ctypes +from collections.abc import Mapping import json import os from string import Formatter @@ -19,29 +20,80 @@ def find(menu, text): return next(item for item in menu.items if item.text == text) +def extra_language(): + """A third language with three plural forms, registered only inside tests.""" + catalog = { + key: dict(value, few=value["other"]) if isinstance(value, dict) else value + for key, value in en.MESSAGES.items() + } + catalog["menu.preferences"] = "Test preferences" + catalog["duration.hours"] = {"one": "{count} test-hour", "few": "{count} few-hours", + "other": "{count} test-hours"} + return i18n.Language( + "Test language", catalog, + plural_rule=lambda count: "one" if count == 1 else "few" if 2 <= count <= 4 else "other", + windows_primary_ids=(0x3FE,), plural_forms=("one", "few", "other")) + + class CatalogTests(unittest.TestCase): def test_complete_keys_parameters_and_plural_forms(self): - self.assertEqual(set(en.MESSAGES), set(fr.MESSAGES)) - for key, reference in en.MESSAGES.items(): - translated = fr.MESSAGES[key] - with self.subTest(key=key): - self.assertIsInstance(translated, type(reference)) - if isinstance(reference, dict): - self.assertEqual(set(reference), {"one", "other"}) - self.assertEqual(set(reference), set(translated)) - pairs = [(reference[form], translated[form]) for form in reference] - else: - pairs = [(reference, translated)] - for english, french in pairs: - self.assertTrue(english.strip()) - self.assertTrue(french.strip()) - self.assertEqual(english, english.strip()) - self.assertEqual(french, french.strip()) - fields = lambda text: {field for _, field, _, _ in Formatter().parse(text) if field} - self.assertEqual(fields(english), fields(french)) + self.check_catalogs() + + def check_catalogs(self): + fields = lambda text: {field for _, field, _, _ in Formatter().parse(text) if field} + for code, language in i18n.LANGUAGES.items(): + with self.subTest(language=code): + self.assertTrue(language.name.strip()) + self.assertEqual(set(en.MESSAGES), set(language.catalog)) + for key, reference in en.MESSAGES.items(): + translated = language.catalog[key] + with self.subTest(language=code, key=key): + if isinstance(reference, Mapping): + self.assertIsInstance(translated, Mapping) + self.assertEqual(set(translated), set(language.plural_forms)) + expected = fields(reference["other"]) + self.assertTrue(all(fields(text) == expected for text in reference.values())) + messages = translated.values() + else: + self.assertIsInstance(translated, str) + expected = fields(reference) + messages = [translated] + for text in messages: + self.assertIsInstance(text, str) + self.assertTrue(text.strip()) + self.assertEqual(text, text.strip()) + self.assertEqual(fields(text), expected) + + def test_registered_languages_have_unambiguous_detection_and_plural_rules(self): + primary_ids = [] + for code, language in i18n.LANGUAGES.items(): + with self.subTest(language=code): + self.assertIn("other", language.plural_forms) + self.assertEqual(len(language.plural_forms), len(set(language.plural_forms))) + for count in range(201): + self.assertIn(language.plural_rule(count), language.plural_forms) + for primary_id in language.windows_primary_ids: + self.assertIsInstance(primary_id, int) + self.assertGreater(primary_id, 0) + self.assertLessEqual(primary_id, 0x3FF) + primary_ids.append(primary_id) + self.assertEqual(len(primary_ids), len(set(primary_ids))) + + def test_third_language_additional_plural_form_and_english_fallback(self): + language = extra_language() + with mock.patch.dict(i18n.LANGUAGES, {"test": language}): + self.check_catalogs() + for count, expected in ((1, "1 test-hour"), (3, "3 few-hours"), (5, "5 test-hours")): + self.assertEqual(i18n.translate("duration.hours", language="test", count=count), expected) + del language.catalog["duration.hours"]["few"] + self.assertEqual(i18n.translate("duration.hours", language="test", count=3), + "about 3 h of use left") + del language.catalog["duration.days"] + self.assertEqual(i18n.translate("duration.days", language="test", count=0), + "about 0 days of use left") def test_language_and_missing_translation_fallback(self): - self.assertEqual(i18n.translate("menu.preferences", language="de"), "Preferences") + self.assertEqual(i18n.translate("menu.preferences", language="not-registered"), "Preferences") with mock.patch.dict(fr.MESSAGES, {}, clear=True): self.assertEqual(i18n.translate("menu.exit", language="fr", version="1.2.3"), "Exit (v1.2.3)") # Fallback uses the English plural rule even for a French request. @@ -91,7 +143,7 @@ def test_all_regional_french_variants(self): self.assertEqual(self.detect(langid), "fr") def test_other_languages_invalid_results_and_api_failure(self): - for langid in (0x0409, 0x0407, 0, -1, 0x1000C, None, "fr"): + for langid in (0, -1, 0x1000C, None, "fr", True, 1.0): with self.subTest(langid=langid): self.assertEqual(self.detect(langid), "en") self.assertEqual(self.detect(error=OSError("unavailable")), "en") @@ -99,6 +151,19 @@ def test_other_languages_invalid_results_and_api_failure(self): mock.patch.object(ctypes, "windll", types.SimpleNamespace(), create=True): self.assertEqual(i18n.detect_language(), "en") + def test_every_registered_windows_language_and_regional_variant(self): + for code, language in i18n.LANGUAGES.items(): + for primary_id in language.windows_primary_ids: + for region in (1, 2, 3, 4, 63): + with self.subTest(language=code, primary_id=primary_id, region=region): + self.assertEqual(self.detect((region << 10) | primary_id), code) + self.assertEqual(self.detect(0x0400), "en") # unregistered primary ID 0 + + def test_third_language_detection_uses_registry(self): + with mock.patch.dict(i18n.LANGUAGES, {"test": extra_language()}): + self.assertEqual(self.detect((1 << 10) | 0x3FE), "test") + self.assertEqual(self.detect((4 << 10) | 0x3FE), "test") + def test_off_windows(self): with mock.patch.object(i18n.sys, "platform", "linux"): self.assertEqual(i18n.detect_language(), "en") @@ -143,13 +208,14 @@ def test_old_settings_preserve_names_options_and_unknown_keys(self): self.assertEqual(hb.load_config(), cfg) def test_saved_languages_and_invalid_values_never_trigger_detection(self): - for value in ("en", "fr", "de", "FR", None, False, 1, {}, []): + for value in (*i18n.LANGUAGES, "not-registered", "FR", None, False, 1, {}, []): with self.subTest(value=value): self.write({"language": value, "interval": 30}) with mock.patch.object(i18n, "detect_language") as detect: cfg = hb.load_config(initialize_language=True) detect.assert_not_called() - self.assertEqual(cfg["language"], value if value in ("en", "fr") else "en") + valid = isinstance(value, str) and value in i18n.LANGUAGES + self.assertEqual(cfg["language"], value if valid else "en") self.assertEqual(cfg["interval"], 30) def test_damaged_config_keeps_existing_recovery_and_english_default(self): @@ -167,6 +233,20 @@ def test_manual_selection_persists_and_overrides_next_windows_detection(self): self.assertEqual(hb.load_config(initialize_language=True)["language"], "fr") detect.assert_not_called() + def test_third_language_is_selectable_persisted_and_kept_on_restart(self): + with mock.patch.dict(i18n.LANGUAGES, {"test": extra_language()}): + app = make_app() + prefs = find(app.build_menu(None), "Preferences").submenu + menu = find(prefs, "Language / Langue").submenu + find(menu, "Test language")(None) + self.assertEqual(app.cfg["language"], "test") + with mock.patch.object(i18n, "detect_language") as detect: + self.assertEqual(hb.load_config(initialize_language=True)["language"], "test") + detect.assert_not_called() + prefs = find(app.build_menu(None), "Test preferences").submenu + menu = find(prefs, "Language / Langue").submenu + self.assertTrue(find(menu, "Test language").checked) + class PresentationTests(unittest.TestCase): def setUp(self): @@ -235,7 +315,8 @@ def test_immediate_switch_all_icons_placeholder_and_both_directions(self): def test_language_menu_selects_and_marks_saved_language(self): prefs = find(self.app.build_menu(None), "Préférences").submenu languages = find(prefs, "Language / Langue").submenu - self.assertEqual([item.text for item in languages], ["English", "Français"]) + self.assertEqual([item.text for item in languages], + [language.name for language in i18n.LANGUAGES.values()]) self.assertTrue(find(languages, "Français").checked) with mock.patch.object(hb, "save_config"): find(languages, "English")(None) From 7c9e4097f9bc7e250655ad00308ea8e54b00b9e3 Mon Sep 17 00:00:00 2001 From: miguotw Date: Sat, 3 Oct 2026 03:36:22 +0800 Subject: [PATCH 4/8] Add Traditional Chinese and Japanese localization --- CHANGELOG.md | 10 +++-- CONTRIBUTING.md | 18 +++++--- README.md | 6 +-- i18n.py | 21 +++++++-- locales/ja.py | 115 +++++++++++++++++++++++++++++++++++++++++++++++ locales/zh_TW.py | 115 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 269 insertions(+), 16 deletions(-) create mode 100644 locales/ja.py create mode 100644 locales/zh_TW.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8013cd92..78024873 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,15 @@ and the project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Added +- Traditional Chinese and Japanese UI catalogs using the localization registry + from #188. Both are selectable in Preferences and detected from the Windows + UI language; Traditional Chinese uses exact LANGIDs to avoid selecting it on + Simplified Chinese systems. Both languages use the `other` plural form. - French localization for tray menus, preferences, tooltips, device states, rename dialogs and notifications (#162). **Language / Langue** in Preferences switches - between English and Français immediately and saves the selection. On the first - run without a saved language, a French Windows UI selects French; other languages - use English. Saved choices take precedence, and English remains the fallback. + between registered languages immediately and saves the selection. On the first + run without a saved language, the Windows UI language selects a supported + translation; unsupported languages use English. Saved choices take precedence, and English remains the fallback. Diagnostics, protocol output and status-file text stay in English. Translation catalogs are bundled without additional runtime dependencies. A single language registry supplies native names, catalogs, Windows detection diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d81732c2..0f0e69e1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,13 +87,14 @@ Look at the open issues and pull requests first, so that two people do not do th ## 4. Add or update a translation Application messages live in `locales/en.py` (the reference and fallback) and -`locales/fr.py`. They are ordinary Python modules, bundled by PyInstaller without -additional dependencies or a translation compilation step. +`locales/fr.py`, `locales/zh_TW.py` and `locales/ja.py`. They are ordinary Python +modules, bundled by PyInstaller without additional dependencies or a translation +compilation step. - Copy the English catalog into `locales/.py` and translate the complete messages. Import that module explicitly in `i18n.py` so PyInstaller bundles it. Add one `Language` entry to `i18n.LANGUAGES` with the native name, catalog, - plural rule, plural forms and Windows primary language IDs. This single registry + plural rule, plural forms and Windows language IDs. This single registry drives rendering, initial detection, the preferences menu and configuration validation; no provider or UI changes are needed to register a language. - The default `one_other` rule selects `one` for integer 1, otherwise `other`. @@ -101,8 +102,12 @@ additional dependencies or a translation compilation step. from its declared `plural_forms`. Additional categories such as `few` or `many` are supported. Counts in this application are nonnegative integers. `windows_primary_ids` matches every regional variant; leave it empty for a - language available only by manual selection. English remains the default and - fallback regardless of registry order. + language available only by manual selection. Use `windows_language_ids` for + exact LANGIDs when only specific regional variants should match. Traditional + Chinese matches Taiwan, Hong Kong, Macao and Traditional Chinese neutral IDs; + Simplified Chinese remains unsupported and falls back to English. Chinese and + Japanese declare only `other`, using the `other_only` plural rule. English remains + the default and fallback regardless of registry order. - Keep message keys, named format parameters (`{name}`, `{level}`, etc.), and the parameters of every plural form consistent with English. Every plural message must contain all categories declared in that language's `plural_forms`, including @@ -121,7 +126,8 @@ additional dependencies or a translation compilation step. UI layout; registering its catalog alone does not enable right-to-left layout. The app detects the Windows **UI language**, not the regional format or keyboard, -only when no language is saved. All French regional variants select `fr`; other +only when no language is saved. French regional variants select `fr`, Japanese +selects `ja`, and supported Traditional Chinese LANGIDs select `zh-TW`. Unsupported languages or a failed detection select `en`. Preferences > Language / Langue overrides that initial selection immediately and persists `language` in `%APPDATA%\HaloBattery\config.json`. A stored invalid value falls back to English diff --git a/README.md b/README.md index f790adbc..984f2b42 100644 --- a/README.md +++ b/README.md @@ -113,10 +113,10 @@ Right-click a device icon to open its menu. It looks like a Windows 11 menu (acr - **Hide this device**: remove its icon, for example for a controller that always reports 100%. - **Refresh now** - **Preferences**: - - **Language / Langue**: English or Français, applied immediately to menus and tooltips. + - **Language / Langue**: English, Français, 繁體中文 or 日本語, applied immediately to menus and tooltips. On the first run without a saved language (including an upgrade from an older version), - the app selects French when the current user's Windows UI language is French, and - English otherwise. The selection is saved; a manual choice always takes precedence, + the app selects French, Traditional Chinese or Japanese when supported by the + current user's Windows UI language, and English otherwise. The selection is saved; a manual choice always takes precedence, even if the Windows language changes later. Diagnostics and the status file stay in English. - **Poll interval** (15 s to 5 min) and **Low battery alert** (off, 10–30%): change them with the − and + buttons or the mouse wheel, the menu stays open - **Alert when fully charged** (a notification once per charge, on by default) diff --git a/i18n.py b/i18n.py index c18f000e..9d7fc98f 100644 --- a/i18n.py +++ b/i18n.py @@ -9,7 +9,7 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass -from locales import en, fr +from locales import en, fr, ja, zh_TW DEFAULT_LANGUAGE = "en" @@ -19,6 +19,11 @@ def one_other(count: int) -> str: return "one" if count == 1 else "other" +def other_only(count: int) -> str: + """Plural rule for languages without singular/plural grammatical changes.""" + return "other" + + def _french_plural(count: int) -> str: return "one" if count in (0, 1) else "other" @@ -30,7 +35,8 @@ class Language: Rules may return additional categories (e.g. few/many). Declare all of them in plural_forms and supply each form in the catalog's plural messages. Windows primary IDs match every regional variant; an empty tuple opts out - of automatic detection while keeping manual selection available. + of primary-ID detection. Exact windows_language_ids can select specific + regional variants while keeping manual selection available. """ name: str @@ -38,6 +44,7 @@ class Language: plural_rule: Callable[[int], str] = one_other windows_primary_ids: tuple[int, ...] = () plural_forms: tuple[str, ...] = ("one", "other") + windows_language_ids: tuple[int, ...] = () # Static imports above and this single registry also drive menus and validation. @@ -45,6 +52,11 @@ class Language: "en": Language("English", en.MESSAGES, windows_primary_ids=(0x09,)), "fr": Language("Français", fr.MESSAGES, plural_rule=_french_plural, windows_primary_ids=(0x0C,)), + "zh-TW": Language("繁體中文", zh_TW.MESSAGES, plural_rule=other_only, + plural_forms=("other",), + windows_language_ids=(0x0404, 0x0C04, 0x1404, 0x7C04)), + "ja": Language("日本語", ja.MESSAGES, plural_rule=other_only, + windows_primary_ids=(0x11,), plural_forms=("other",)), } @@ -52,7 +64,8 @@ def detect_language() -> str: """The current user's Windows UI language, not their regional format/keyboard. GetUserDefaultUILanguage returns a LANGID. Its primary-language bits cover - all regional variants of a registered language. + all regional variants of a registered language. Exact LANGIDs allow scripts + such as Traditional Chinese to be selected without matching Simplified Chinese. https://learn.microsoft.com/windows/win32/api/winnls/nf-winnls-getuserdefaultuilanguage """ if sys.platform != "win32": @@ -66,7 +79,7 @@ def detect_language() -> str: if isinstance(langid, int) and not isinstance(langid, bool) and 0 < langid <= 0xFFFF: primary_id = langid & 0x3FF for code, language in LANGUAGES.items(): - if primary_id in language.windows_primary_ids: + if langid in language.windows_language_ids or primary_id in language.windows_primary_ids: return code except (AttributeError, OSError, TypeError, ValueError): pass diff --git a/locales/ja.py b/locales/ja.py new file mode 100644 index 00000000..e6d8c0f9 --- /dev/null +++ b/locales/ja.py @@ -0,0 +1,115 @@ +"""Japanese UI messages; keys and parameters follow locales/en.py.""" + +MESSAGES = { + "device.description": "{name}: {state}", + "menu.rename": "名前を変更…", + "menu.reset_name": "名前をリセット", + "menu.icon": "アイコン", + "menu.device_low": "低残量通知のしきい値", + "menu.default_low": "既定({level}%)", + "menu.default_off": "既定(オフ)", + "menu.hide": "このデバイスを非表示", + "menu.show": "{name} を表示", + "menu.refresh": "今すぐ取得", + "menu.preferences": "設定", + "menu.hidden": "非表示のデバイス", + "menu.diagnostics": "診断レポート…", + "menu.exit": "終了(v{version})", + "menu.download": "v{version} をダウンロード…", + "menu.download_update": "更新をダウンロード…", + "menu.no_devices": "デバイスが見つかりません", + "menu.none_shown": { + "other": "表示中のデバイスなし({count} 台を非表示)" + }, + "tooltip.no_devices": "{app}: デバイスが見つかりません", + "preferences.language": "言語", + "preferences.interval": "取得間隔", + "preferences.low": "低残量通知", + "preferences.full": "充電完了時に通知", + "preferences.time_left": "推定残り使用時間", + "preferences.quiet": "ゲーム中は通知を抑制", + "preferences.bluetooth": "Windows の Bluetooth デバイス", + "preferences.playstation": "PlayStation フルモード(Bluetooth)", + "preferences.providers": "デバイスの種類", + "preferences.badges": "デバイスの絵柄", + "preferences.percent": "アイコンに残量を表示", + "preferences.animation": "充電アニメーション", + "preferences.theme": "アイコンの色", + "preferences.status_file": "他のアプリ用の状態ファイル", + "preferences.autostart": "Windows 起動時に実行", + "preferences.updates": "更新を確認", + "choice.auto": "自動", + "choice.white": "白", + "choice.black": "黒", + "choice.off": "オフ", + "icon.mouse": "マウス", + "icon.keyboard": "キーボード", + "icon.headset": "ヘッドセット", + "icon.gamepad": "コントローラー", + "icon.bluetooth": "Bluetooth", + "provider.8bitdo": "8BitDo コントローラー", + "provider.am_infinity": "AM Infinity 8K (Angry Miao)", + "provider.astro": "Astro A50", + "provider.asus": "ASUS ROG / TUF マウス", + "provider.audeze": "Audeze Maxwell", + "provider.barracuda": "Razer Barracuda Pro", + "provider.corsair": "Corsair ヘッドセット", + "provider.gwolves": "G-Wolves マウス", + "provider.hyperx": "HyperX Cloud II Wireless", + "provider.hyperx_alpha2": "HyperX Cloud Alpha 2", + "provider.hyperx_cloud3": "HyperX Cloud III Wireless", + "provider.jbl": "JBL Quantum", + "provider.keychron": "Keychron", + "provider.lamzu": "LAMZU マウス", + "provider.lofree": "Lofree キーボード", + "provider.logitech": "Logitech", + "provider.mchose": "MCHOSE マウス", + "provider.nintendo": "Nintendo Switch コントローラー", + "provider.playstation": "PlayStation コントローラー", + "provider.pulsar": "Pulsar / ATK VXE マウス", + "provider.razer": "Razer マウスとヘッドセット", + "provider.steelseries": "SteelSeries", + "provider.wlmouse": "WLmouse", + "provider.xinput": "Xbox 互換コントローラー", + "state.no_link": "未接続(電源オフまたはスリープ)", + "state.level": "{level}%", + "state.charging": "{state}、充電中", + "state.asleep": "{state}(前回の残量、デバイスはスリープ中)", + "state.last_known": "{state}(前回の残量)", + "state.time_left": "{state}、{left}", + "state.approx": "約 {level}%", + "state.approx_grade": "約 {level}%({grade})", + "state.pending": "接続済み、バッテリー残量は未報告", + "state.unreported": "接続済み、バッテリー残量は未報告", + "state.unreadable": "接続済み、残量を取得できません(他のアプリが使用中の可能性があります)", + "state.audeze_stuck": "ヘッドセットから応答がありません。レシーバーを抜き差ししてください", + "state.playstation_basic": "Bluetooth 接続では Steam またはゲームの使用中のみ残量を表示します", + "state.8bitdo_basic": "Steam またはゲームがコントローラーを使用中のみ残量を表示します", + "state.bluetooth_hint": "Bluetooth 接続中。「Windows の Bluetooth デバイス」を有効にすると残量を表示できます", + "state.cable": "充電ケーブル接続中、充電中", + "level.empty": "空", + "level.critical": "残量わずか", + "level.low": "低下", + "level.medium": "中程度", + "level.good": "十分", + "level.full": "満充電", + "duration.less_hour": "残り使用時間は 1 時間未満", + "duration.hours": { + "other": "あと約 {count} 時間使用できます" + }, + "duration.days": { + "other": "あと約 {count} 日使用できます" + }, + "notification.low_title": "バッテリー残量低下", + "notification.low": "{name}: バッテリー残量 {level}%。充電してください。", + "notification.low_approx": "{name}: バッテリー残量が低下しています。充電してください。", + "notification.full_title": "充電完了", + "notification.full": "{name} の充電が完了しました。", + "notification.update_title": "{app} の更新", + "notification.update": "新しいバージョン {version} が利用できます。バッテリーアイコンを右クリックして「v{version} をダウンロード…」を選択してください。", + "notification.temp_autostart": "Halo Battery は一時フォルダーから実行されています(ZIP から直接起動)。ZIP を専用フォルダーに展開し、そこから HaloBattery.exe を実行してから「Windows 起動時に実行」を有効にしてください。", + "rename.prompt": "このデバイスの新しい名前を入力してください:", + "rename.title": "Halo Battery - 名前を変更", + "rename.ok": "OK", + "rename.cancel": "キャンセル" +} diff --git a/locales/zh_TW.py b/locales/zh_TW.py new file mode 100644 index 00000000..af6c4a05 --- /dev/null +++ b/locales/zh_TW.py @@ -0,0 +1,115 @@ +"""Traditional Chinese UI messages; keys and parameters follow locales/en.py.""" + +MESSAGES = { + "device.description": "{name}: {state}", + "menu.rename": "重新命名…", + "menu.reset_name": "重設名稱", + "menu.icon": "圖示", + "menu.device_low": "低電量提醒門檻", + "menu.default_low": "預設({level}%)", + "menu.default_off": "預設(關閉)", + "menu.hide": "隱藏此裝置", + "menu.show": "顯示 {name}", + "menu.refresh": "立即更新", + "menu.preferences": "偏好設定", + "menu.hidden": "隱藏的裝置", + "menu.diagnostics": "診斷報告…", + "menu.exit": "結束(v{version})", + "menu.download": "下載 v{version}…", + "menu.download_update": "下載更新…", + "menu.no_devices": "找不到裝置", + "menu.none_shown": { + "other": "沒有顯示的裝置(已隱藏 {count} 個)" + }, + "tooltip.no_devices": "{app}: 找不到裝置", + "preferences.language": "語言", + "preferences.interval": "更新間隔", + "preferences.low": "低電量提醒", + "preferences.full": "充飽電時通知", + "preferences.time_left": "預估剩餘使用時間", + "preferences.quiet": "遊戲時保持安靜", + "preferences.bluetooth": "Windows 藍牙裝置", + "preferences.playstation": "PlayStation 完整模式(藍牙)", + "preferences.providers": "裝置類型", + "preferences.badges": "裝置圖案", + "preferences.percent": "在圖示中顯示百分比", + "preferences.animation": "充電動畫", + "preferences.theme": "圖示顏色", + "preferences.status_file": "供其他應用程式使用的狀態檔", + "preferences.autostart": "隨 Windows 啟動", + "preferences.updates": "檢查更新", + "choice.auto": "自動", + "choice.white": "白色", + "choice.black": "黑色", + "choice.off": "關閉", + "icon.mouse": "滑鼠", + "icon.keyboard": "鍵盤", + "icon.headset": "耳機", + "icon.gamepad": "控制器", + "icon.bluetooth": "藍牙", + "provider.8bitdo": "8BitDo 控制器", + "provider.am_infinity": "AM Infinity 8K (Angry Miao)", + "provider.astro": "Astro A50", + "provider.asus": "ASUS ROG / TUF 滑鼠", + "provider.audeze": "Audeze Maxwell", + "provider.barracuda": "Razer Barracuda Pro", + "provider.corsair": "Corsair 耳機", + "provider.gwolves": "G-Wolves 滑鼠", + "provider.hyperx": "HyperX Cloud II Wireless", + "provider.hyperx_alpha2": "HyperX Cloud Alpha 2", + "provider.hyperx_cloud3": "HyperX Cloud III Wireless", + "provider.jbl": "JBL Quantum", + "provider.keychron": "Keychron", + "provider.lamzu": "LAMZU 滑鼠", + "provider.lofree": "Lofree 鍵盤", + "provider.logitech": "Logitech", + "provider.mchose": "MCHOSE 滑鼠", + "provider.nintendo": "Nintendo Switch 控制器", + "provider.playstation": "PlayStation 控制器", + "provider.pulsar": "Pulsar / ATK VXE 滑鼠", + "provider.razer": "Razer 滑鼠與耳機", + "provider.steelseries": "SteelSeries", + "provider.wlmouse": "WLmouse", + "provider.xinput": "Xbox 相容控制器", + "state.no_link": "未連線(已關機或休眠)", + "state.level": "{level}%", + "state.charging": "{state},充電中", + "state.asleep": "{state}(上次記錄的電量,裝置休眠中)", + "state.last_known": "{state}(上次記錄的電量)", + "state.time_left": "{state},{left}", + "state.approx": "約 {level}%", + "state.approx_grade": "約 {level}%({grade})", + "state.pending": "已連線,尚未回報電量", + "state.unreported": "已連線,尚未回報電量", + "state.unreadable": "已連線,但無法讀取電量(其他應用程式可能占用裝置)", + "state.audeze_stuck": "耳機未回應,請拔下接收器後重新插入", + "state.playstation_basic": "透過藍牙連線時,只有 Steam 或遊戲使用控制器時才會顯示電量", + "state.8bitdo_basic": "只有 Steam 或遊戲使用控制器時才會顯示電量", + "state.bluetooth_hint": "已透過藍牙連線;請啟用「Windows 藍牙裝置」以查看電量", + "state.cable": "已接上充電線,充電中", + "level.empty": "已耗盡", + "level.critical": "極低", + "level.low": "偏低", + "level.medium": "中等", + "level.good": "充足", + "level.full": "滿電", + "duration.less_hour": "剩餘使用時間不到 1 小時", + "duration.hours": { + "other": "約可再使用 {count} 小時" + }, + "duration.days": { + "other": "約可再使用 {count} 天" + }, + "notification.low_title": "電量不足", + "notification.low": "{name}: 剩餘電量 {level}%。請充電。", + "notification.low_approx": "{name}: 電量不足。請充電。", + "notification.full_title": "已充飽電", + "notification.full": "{name} 已充飽電。", + "notification.update_title": "{app} 更新", + "notification.update": "已有新版本 {version}。請在電量圖示上按右鍵,選擇「下載 v{version}…」。", + "notification.temp_autostart": "Halo Battery 正從暫存資料夾執行(直接從 ZIP 開啟)。請先將 ZIP 解壓縮至獨立資料夾,再從該資料夾執行 HaloBattery.exe,接著啟用「隨 Windows 啟動」。", + "rename.prompt": "請輸入此裝置的新名稱:", + "rename.title": "Halo Battery - 重新命名", + "rename.ok": "確定", + "rename.cancel": "取消" +} From 941e4732635c2c28abebbe7b8ef0e11920fd9a1a Mon Sep 17 00:00:00 2001 From: miguotw Date: Sat, 3 Oct 2026 03:36:22 +0800 Subject: [PATCH 5/8] Test Asian language detection, menus and plural rules --- tests/test_i18n.py | 53 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 565cfb25..64c77b08 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -435,5 +435,58 @@ def test_rename_passes_localized_text_safely_through_environment(self): self.assertNotIn("Annuler", command) +class AsianLanguageTests(unittest.TestCase): + def test_single_plural_form_for_all_counts(self): + for language in ("zh-TW", "ja"): + for count in (0, 1, 2, 5, 100): + with self.subTest(language=language, count=count): + self.assertEqual(i18n.LANGUAGES[language].plural_rule(count), "other") + self.assertIn(str(count), i18n.translate("duration.hours", language=language, count=count)) + + def test_localized_menus_and_independent_languages(self): + for language, preferences, refresh in (("zh-TW", "偏好設定", "立即更新"), + ("ja", "設定", "今すぐ取得")): + with self.subTest(language=language): + app = make_app({"language": language}) + menu = app.build_menu(None) + find(menu, refresh) + submenu = find(menu, preferences).submenu + options = find(submenu, app.tr("preferences.language")).submenu.items + self.assertEqual([item.text for item in options if item.checked], + [i18n.LANGUAGES[language].name]) + self.assertEqual(app.tr("rename.cancel"), "取消" if language == "zh-TW" else "キャンセル") + self.assertEqual(i18n.translate("menu.preferences"), "Preferences") + + def test_localized_notifications_and_states(self): + for language in ("zh-TW", "ja"): + with self.subTest(language=language): + self.assertIn("Mouse", i18n.translate("notification.low", language=language, name="Mouse", level=15)) + self.assertIn("15", i18n.translate("notification.low", language=language, name="Mouse", level=15)) + self.assertEqual(i18n.translate("state.charging", language=language, state="50%"), + "50%,充電中" if language == "zh-TW" else "50%、充電中") + self.assertEqual(i18n.translate("provider.8bitdo", language=language), + "8BitDo 控制器" if language == "zh-TW" else "8BitDo コントローラー") + + def test_exact_traditional_chinese_and_japanese_detection(self): + detector = DetectionTests() + for langid in (0x0404, 0x0C04, 0x1404, 0x7C04): + with self.subTest(langid=langid): + self.assertEqual(detector.detect(langid), "zh-TW") + for langid in (0x0804, 0x1004, 0x0004): + with self.subTest(langid=langid): + self.assertEqual(detector.detect(langid), "en") + self.assertEqual(detector.detect(0x0411), "ja") + + def test_exact_windows_ids_are_unambiguous(self): + owners = {} + for code, language in i18n.LANGUAGES.items(): + for langid in language.windows_language_ids: + self.assertNotIn(langid, owners) + owners[langid] = code + for other_code, other in i18n.LANGUAGES.items(): + if code != other_code: + self.assertNotIn(langid & 0x3FF, other.windows_primary_ids) + + if __name__ == "__main__": unittest.main() From 272eac3595a1e1ffcea1d532df811bc61e23ee60 Mon Sep 17 00:00:00 2001 From: miguotw Date: Sat, 3 Oct 2026 03:42:01 +0800 Subject: [PATCH 6/8] Add Simplified Chinese localization and Windows detection --- CHANGELOG.md | 7 +-- CONTRIBUTING.md | 8 ++-- README.md | 4 +- i18n.py | 5 +- locales/zh_CN.py | 115 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_i18n.py | 17 +++---- 6 files changed, 139 insertions(+), 17 deletions(-) create mode 100644 locales/zh_CN.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 78024873..3a77cc6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,11 @@ and the project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Added -- Traditional Chinese and Japanese UI catalogs using the localization registry - from #188. Both are selectable in Preferences and detected from the Windows +- Traditional Chinese, Simplified Chinese and Japanese UI catalogs using the localization registry + from #188. All three are selectable in Preferences and detected from the Windows UI language; Traditional Chinese uses exact LANGIDs to avoid selecting it on - Simplified Chinese systems. Both languages use the `other` plural form. + Simplified Chinese systems, which select their own catalog. All three languages + use the `other` plural form. - French localization for tray menus, preferences, tooltips, device states, rename dialogs and notifications (#162). **Language / Langue** in Preferences switches between registered languages immediately and saves the selection. On the first diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f0e69e1..613acac6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,7 +87,7 @@ Look at the open issues and pull requests first, so that two people do not do th ## 4. Add or update a translation Application messages live in `locales/en.py` (the reference and fallback) and -`locales/fr.py`, `locales/zh_TW.py` and `locales/ja.py`. They are ordinary Python +`locales/fr.py`, `locales/zh_TW.py`, `locales/zh_CN.py` and `locales/ja.py`. They are Python modules, bundled by PyInstaller without additional dependencies or a translation compilation step. @@ -105,7 +105,8 @@ compilation step. language available only by manual selection. Use `windows_language_ids` for exact LANGIDs when only specific regional variants should match. Traditional Chinese matches Taiwan, Hong Kong, Macao and Traditional Chinese neutral IDs; - Simplified Chinese remains unsupported and falls back to English. Chinese and + Simplified Chinese matches China, Singapore and Simplified Chinese neutral IDs. + Chinese and Japanese declare only `other`, using the `other_only` plural rule. English remains the default and fallback regardless of registry order. - Keep message keys, named format parameters (`{name}`, `{level}`, etc.), and the @@ -127,7 +128,8 @@ compilation step. The app detects the Windows **UI language**, not the regional format or keyboard, only when no language is saved. French regional variants select `fr`, Japanese -selects `ja`, and supported Traditional Chinese LANGIDs select `zh-TW`. Unsupported +selects `ja`, Traditional Chinese LANGIDs select `zh-TW`, and Simplified Chinese +LANGIDs select `zh-CN`. Unsupported languages or a failed detection select `en`. Preferences > Language / Langue overrides that initial selection immediately and persists `language` in `%APPDATA%\HaloBattery\config.json`. A stored invalid value falls back to English diff --git a/README.md b/README.md index 984f2b42..8a47c1a6 100644 --- a/README.md +++ b/README.md @@ -113,9 +113,9 @@ Right-click a device icon to open its menu. It looks like a Windows 11 menu (acr - **Hide this device**: remove its icon, for example for a controller that always reports 100%. - **Refresh now** - **Preferences**: - - **Language / Langue**: English, Français, 繁體中文 or 日本語, applied immediately to menus and tooltips. + - **Language / Langue**: English, Français, 繁體中文, 简体中文 or 日本語, applied immediately to menus and tooltips. On the first run without a saved language (including an upgrade from an older version), - the app selects French, Traditional Chinese or Japanese when supported by the + the app selects French, Traditional Chinese, Simplified Chinese or Japanese when supported by the current user's Windows UI language, and English otherwise. The selection is saved; a manual choice always takes precedence, even if the Windows language changes later. Diagnostics and the status file stay in English. - **Poll interval** (15 s to 5 min) and **Low battery alert** (off, 10–30%): change them with the − and + buttons or the mouse wheel, the menu stays open diff --git a/i18n.py b/i18n.py index 9d7fc98f..8e248732 100644 --- a/i18n.py +++ b/i18n.py @@ -9,7 +9,7 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass -from locales import en, fr, ja, zh_TW +from locales import en, fr, ja, zh_CN, zh_TW DEFAULT_LANGUAGE = "en" @@ -55,6 +55,9 @@ class Language: "zh-TW": Language("繁體中文", zh_TW.MESSAGES, plural_rule=other_only, plural_forms=("other",), windows_language_ids=(0x0404, 0x0C04, 0x1404, 0x7C04)), + "zh-CN": Language("简体中文", zh_CN.MESSAGES, plural_rule=other_only, + plural_forms=("other",), + windows_language_ids=(0x0804, 0x1004, 0x0004, 0x7804)), "ja": Language("日本語", ja.MESSAGES, plural_rule=other_only, windows_primary_ids=(0x11,), plural_forms=("other",)), } diff --git a/locales/zh_CN.py b/locales/zh_CN.py new file mode 100644 index 00000000..0d764cce --- /dev/null +++ b/locales/zh_CN.py @@ -0,0 +1,115 @@ +"""Simplified Chinese UI messages; keys and parameters follow locales/en.py.""" + +MESSAGES = { + "device.description": "{name}: {state}", + "menu.rename": "重命名…", + "menu.reset_name": "重置名称", + "menu.icon": "图标", + "menu.device_low": "低电量提醒阈值", + "menu.default_low": "默认({level}%)", + "menu.default_off": "默认(关闭)", + "menu.hide": "隐藏此设备", + "menu.show": "显示 {name}", + "menu.refresh": "立即刷新", + "menu.preferences": "首选项", + "menu.hidden": "隐藏的设备", + "menu.diagnostics": "诊断报告…", + "menu.exit": "退出(v{version})", + "menu.download": "下载 v{version}…", + "menu.download_update": "下载更新…", + "menu.no_devices": "未找到设备", + "menu.none_shown": { + "other": "没有显示的设备(已隐藏 {count} 个)" + }, + "tooltip.no_devices": "{app}: 未找到设备", + "preferences.language": "语言", + "preferences.interval": "刷新间隔", + "preferences.low": "低电量提醒", + "preferences.full": "充满电时通知", + "preferences.time_left": "预计剩余使用时间", + "preferences.quiet": "游戏时保持安静", + "preferences.bluetooth": "Windows 蓝牙设备", + "preferences.playstation": "PlayStation 完整模式(蓝牙)", + "preferences.providers": "设备类型", + "preferences.badges": "设备图案", + "preferences.percent": "在图标中显示百分比", + "preferences.animation": "充电动画", + "preferences.theme": "图标颜色", + "preferences.status_file": "供其他应用程序使用的状态文件", + "preferences.autostart": "随 Windows 启动", + "preferences.updates": "检查更新", + "choice.auto": "自动", + "choice.white": "白色", + "choice.black": "黑色", + "choice.off": "关闭", + "icon.mouse": "鼠标", + "icon.keyboard": "键盘", + "icon.headset": "耳机", + "icon.gamepad": "控制器", + "icon.bluetooth": "蓝牙", + "provider.8bitdo": "8BitDo 控制器", + "provider.am_infinity": "AM Infinity 8K (Angry Miao)", + "provider.astro": "Astro A50", + "provider.asus": "ASUS ROG / TUF 鼠标", + "provider.audeze": "Audeze Maxwell", + "provider.barracuda": "Razer Barracuda Pro", + "provider.corsair": "Corsair 耳机", + "provider.gwolves": "G-Wolves 鼠标", + "provider.hyperx": "HyperX Cloud II Wireless", + "provider.hyperx_alpha2": "HyperX Cloud Alpha 2", + "provider.hyperx_cloud3": "HyperX Cloud III Wireless", + "provider.jbl": "JBL Quantum", + "provider.keychron": "Keychron", + "provider.lamzu": "LAMZU 鼠标", + "provider.lofree": "Lofree 键盘", + "provider.logitech": "Logitech", + "provider.mchose": "MCHOSE 鼠标", + "provider.nintendo": "Nintendo Switch 控制器", + "provider.playstation": "PlayStation 控制器", + "provider.pulsar": "Pulsar / ATK VXE 鼠标", + "provider.razer": "Razer 鼠标和耳机", + "provider.steelseries": "SteelSeries", + "provider.wlmouse": "WLmouse", + "provider.xinput": "Xbox 兼容控制器", + "state.no_link": "未连接(已关机或休眠)", + "state.level": "{level}%", + "state.charging": "{state},充电中", + "state.asleep": "{state}(上次记录的电量,设备休眠中)", + "state.last_known": "{state}(上次记录的电量)", + "state.time_left": "{state},{left}", + "state.approx": "约 {level}%", + "state.approx_grade": "约 {level}%({grade})", + "state.pending": "已连接,尚未报告电量", + "state.unreported": "已连接,尚未报告电量", + "state.unreadable": "已连接,但无法读取电量(其他应用程序可能占用设备)", + "state.audeze_stuck": "耳机未响应,请拔下接收器后重新插入", + "state.playstation_basic": "通过蓝牙连接时,仅在 Steam 或游戏使用控制器时显示电量", + "state.8bitdo_basic": "仅在 Steam 或游戏使用控制器时显示电量", + "state.bluetooth_hint": "已通过蓝牙连接;请启用“Windows 蓝牙设备”以查看电量", + "state.cable": "已接上充电线,充电中", + "level.empty": "已耗尽", + "level.critical": "极低", + "level.low": "偏低", + "level.medium": "中等", + "level.good": "充足", + "level.full": "满电", + "duration.less_hour": "剩余使用时间不足 1 小时", + "duration.hours": { + "other": "约可再使用 {count} 小时" + }, + "duration.days": { + "other": "约可再使用 {count} 天" + }, + "notification.low_title": "电量不足", + "notification.low": "{name}: 剩余电量 {level}%。请充电。", + "notification.low_approx": "{name}: 电量不足。请充电。", + "notification.full_title": "已充满电", + "notification.full": "{name} 已充满电。", + "notification.update_title": "{app} 更新", + "notification.update": "已有新版本 {version}。请右键点击电量图标,选择“下载 v{version}…”。", + "notification.temp_autostart": "Halo Battery 正从临时文件夹运行(直接从 ZIP 打开)。请先将 ZIP 解压到独立文件夹,再从该文件夹运行 HaloBattery.exe,然后启用“随 Windows 启动”。", + "rename.prompt": "请输入此设备的新名称:", + "rename.title": "Halo Battery - 重命名", + "rename.ok": "确定", + "rename.cancel": "取消" +} diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 64c77b08..ea316995 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -437,7 +437,7 @@ def test_rename_passes_localized_text_safely_through_environment(self): class AsianLanguageTests(unittest.TestCase): def test_single_plural_form_for_all_counts(self): - for language in ("zh-TW", "ja"): + for language in ("zh-TW", "zh-CN", "ja"): for count in (0, 1, 2, 5, 100): with self.subTest(language=language, count=count): self.assertEqual(i18n.LANGUAGES[language].plural_rule(count), "other") @@ -445,6 +445,7 @@ def test_single_plural_form_for_all_counts(self): def test_localized_menus_and_independent_languages(self): for language, preferences, refresh in (("zh-TW", "偏好設定", "立即更新"), + ("zh-CN", "首选项", "立即刷新"), ("ja", "設定", "今すぐ取得")): with self.subTest(language=language): app = make_app({"language": language}) @@ -454,27 +455,27 @@ def test_localized_menus_and_independent_languages(self): options = find(submenu, app.tr("preferences.language")).submenu.items self.assertEqual([item.text for item in options if item.checked], [i18n.LANGUAGES[language].name]) - self.assertEqual(app.tr("rename.cancel"), "取消" if language == "zh-TW" else "キャンセル") + self.assertEqual(app.tr("rename.cancel"), "キャンセル" if language == "ja" else "取消") self.assertEqual(i18n.translate("menu.preferences"), "Preferences") def test_localized_notifications_and_states(self): - for language in ("zh-TW", "ja"): + for language in ("zh-TW", "zh-CN", "ja"): with self.subTest(language=language): self.assertIn("Mouse", i18n.translate("notification.low", language=language, name="Mouse", level=15)) self.assertIn("15", i18n.translate("notification.low", language=language, name="Mouse", level=15)) self.assertEqual(i18n.translate("state.charging", language=language, state="50%"), - "50%,充電中" if language == "zh-TW" else "50%、充電中") + {"zh-TW": "50%,充電中", "zh-CN": "50%,充电中", "ja": "50%、充電中"}[language]) self.assertEqual(i18n.translate("provider.8bitdo", language=language), - "8BitDo 控制器" if language == "zh-TW" else "8BitDo コントローラー") + "8BitDo コントローラー" if language == "ja" else "8BitDo 控制器") - def test_exact_traditional_chinese_and_japanese_detection(self): + def test_exact_chinese_and_japanese_detection(self): detector = DetectionTests() for langid in (0x0404, 0x0C04, 0x1404, 0x7C04): with self.subTest(langid=langid): self.assertEqual(detector.detect(langid), "zh-TW") - for langid in (0x0804, 0x1004, 0x0004): + for langid in (0x0804, 0x1004, 0x0004, 0x7804): with self.subTest(langid=langid): - self.assertEqual(detector.detect(langid), "en") + self.assertEqual(detector.detect(langid), "zh-CN") self.assertEqual(detector.detect(0x0411), "ja") def test_exact_windows_ids_are_unambiguous(self): From b8d20ac9488b54ef0fa1bbd3569f20a55d43caf0 Mon Sep 17 00:00:00 2001 From: Tiago FERREIRA DOMINGUES Date: Wed, 7 Oct 2026 14:42:35 +0200 Subject: [PATCH 7/8] Localize new preferences and device families from 1.14.0 --- CHANGELOG.md | 3 +++ CONTRIBUTING.md | 5 +++-- README.md | 1 + halo_battery.pyw | 2 +- locales/en.py | 4 ++++ locales/fr.py | 4 ++++ locales/ja.py | 4 ++++ locales/zh_CN.py | 4 ++++ locales/zh_TW.py | 4 ++++ tests/test_bluetooth_poll.py | 2 ++ tests/test_i18n.py | 28 +++++++++++++++++++++++++--- 11 files changed, 55 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc7dd42d..d9799f5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ and the project follows [Semantic Versioning](https://semver.org/). A single language registry supplies native names, catalogs, Windows detection and plural rules; catalog checks cover every registered language, including languages with additional plural forms. + Includes the 1.14.0 low-battery sound preference and the HyperX Cloud III S Wireless, + Logitech G PRO X 2 LIGHTSPEED and SteelSeries Arctis Nova Elite family labels in + every registered catalog; hardware names remain unchanged. ## [1.14.0] - 2026-10-05 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 54a6c30b..a93c1c2b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -132,5 +132,6 @@ selects `ja`, Traditional Chinese LANGIDs select `zh-TW`, and Simplified Chinese LANGIDs select `zh-CN`. Unsupported languages or a failed detection select `en`. Preferences > Language / Langue overrides that initial selection immediately and persists `language` in -`%APPDATA%\HaloBattery\config.json`. A stored invalid value falls back to English -without triggering detection again. `--probe` never initializes the language. +`%APPDATA%\HaloBattery\config.json` (or the app's folder in portable mode). +A stored invalid value falls back to English without triggering detection again. +`--probe` never initializes the language. diff --git a/README.md b/README.md index 050deb51..76c92ccd 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ Right-click a device icon to open its menu. It looks like a Windows 11 menu (acr the app selects French, Traditional Chinese, Simplified Chinese or Japanese when supported by the current user's Windows UI language, and English otherwise. The selection is saved; a manual choice always takes precedence, even if the Windows language changes later. Diagnostics and the status file stay in English. + In portable mode, the language preference is saved with the other settings next to the app. - **Poll interval** (15 s to 5 min) and **Low battery alert** (off, 10–30%): change them with the − and + buttons or the mouse wheel, the menu stays open - **Alert when fully charged** (a notification once per charge, on by default) - **Estimated time left**: "about 5 h of use left" in the tooltip, from how fast the device has drained since its last charge. Only time the device is awake and on battery counts, and there is no estimate until it has been used for 30 minutes and dropped 3%. The history is kept in `%APPDATA%\HaloBattery\history.json`. diff --git a/halo_battery.pyw b/halo_battery.pyw index fb827a56..ae8b048c 100644 --- a/halo_battery.pyw +++ b/halo_battery.pyw @@ -1217,7 +1217,7 @@ class App: Item(tr("preferences.quiet"), toggle("quiet_fullscreen"), checked=lambda it: self.cfg.get("quiet_fullscreen", True)), # for full-screen games, where the notification is not seen (#66) - Item("Sound with the low battery alert", toggle("low_sound"), + Item(tr("preferences.low_sound"), toggle("low_sound"), checked=lambda it: self.cfg.get("low_sound", False)), Menu.SEPARATOR, Item(tr("preferences.bluetooth"), toggle("bluetooth"), diff --git a/locales/en.py b/locales/en.py index 67a9f9ab..303b53e2 100644 --- a/locales/en.py +++ b/locales/en.py @@ -27,6 +27,7 @@ "preferences.full": "Alert when fully charged", "preferences.time_left": "Estimated time left", "preferences.quiet": "Quiet while gaming", + "preferences.low_sound": "Sound with the low battery alert", "preferences.bluetooth": "Windows Bluetooth devices", "preferences.playstation": "PlayStation full mode (Bluetooth)", "preferences.providers": "Device types", @@ -57,17 +58,20 @@ "provider.hyperx": "HyperX Cloud II Wireless", "provider.hyperx_alpha2": "HyperX Cloud Alpha 2", "provider.hyperx_cloud3": "HyperX Cloud III Wireless", + "provider.hyperx_cloud3s": "HyperX Cloud III S Wireless", "provider.jbl": "JBL Quantum", "provider.keychron": "Keychron", "provider.lamzu": "LAMZU mice", "provider.lofree": "Lofree keyboards", "provider.logitech": "Logitech", + "provider.logitech_centurion": "Logitech G PRO X 2 LIGHTSPEED", "provider.mchose": "MCHOSE mice", "provider.nintendo": "Nintendo Switch controllers", "provider.playstation": "PlayStation controllers", "provider.pulsar": "Pulsar / ATK VXE mice", "provider.razer": "Razer mice and headsets", "provider.steelseries": "SteelSeries", + "provider.steelseries_elite": "SteelSeries Arctis Nova Elite", "provider.wlmouse": "WLmouse", "provider.xinput": "Xbox-compatible controllers", "state.no_link": "no link (off or asleep)", diff --git a/locales/fr.py b/locales/fr.py index 310bbcd5..5f0bdeb4 100644 --- a/locales/fr.py +++ b/locales/fr.py @@ -27,6 +27,7 @@ "preferences.full": "Alerte lorsque la batterie est pleine", "preferences.time_left": "Autonomie restante estimée", "preferences.quiet": "Mode silencieux pendant les jeux", + "preferences.low_sound": "Son avec l’alerte de batterie faible", "preferences.bluetooth": "Périphériques Bluetooth de Windows", "preferences.playstation": "Mode complet PlayStation (Bluetooth)", "preferences.providers": "Types de périphériques", @@ -57,17 +58,20 @@ "provider.hyperx": "HyperX Cloud II Wireless", "provider.hyperx_alpha2": "HyperX Cloud Alpha 2", "provider.hyperx_cloud3": "HyperX Cloud III Wireless", + "provider.hyperx_cloud3s": "HyperX Cloud III S Wireless", "provider.jbl": "JBL Quantum", "provider.keychron": "Keychron", "provider.lamzu": "Souris LAMZU", "provider.lofree": "Claviers Lofree", "provider.logitech": "Logitech", + "provider.logitech_centurion": "Logitech G PRO X 2 LIGHTSPEED", "provider.mchose": "Souris MCHOSE", "provider.nintendo": "Manettes Nintendo Switch", "provider.playstation": "Manettes PlayStation", "provider.pulsar": "Souris Pulsar / ATK VXE", "provider.razer": "Souris et casques Razer", "provider.steelseries": "SteelSeries", + "provider.steelseries_elite": "SteelSeries Arctis Nova Elite", "provider.wlmouse": "WLmouse", "provider.xinput": "Manettes compatibles Xbox", "state.no_link": "aucune liaison (éteint ou en veille)", diff --git a/locales/ja.py b/locales/ja.py index e6d8c0f9..986b7aa0 100644 --- a/locales/ja.py +++ b/locales/ja.py @@ -28,6 +28,7 @@ "preferences.full": "充電完了時に通知", "preferences.time_left": "推定残り使用時間", "preferences.quiet": "ゲーム中は通知を抑制", + "preferences.low_sound": "低残量通知時に音を鳴らす", "preferences.bluetooth": "Windows の Bluetooth デバイス", "preferences.playstation": "PlayStation フルモード(Bluetooth)", "preferences.providers": "デバイスの種類", @@ -58,17 +59,20 @@ "provider.hyperx": "HyperX Cloud II Wireless", "provider.hyperx_alpha2": "HyperX Cloud Alpha 2", "provider.hyperx_cloud3": "HyperX Cloud III Wireless", + "provider.hyperx_cloud3s": "HyperX Cloud III S Wireless", "provider.jbl": "JBL Quantum", "provider.keychron": "Keychron", "provider.lamzu": "LAMZU マウス", "provider.lofree": "Lofree キーボード", "provider.logitech": "Logitech", + "provider.logitech_centurion": "Logitech G PRO X 2 LIGHTSPEED", "provider.mchose": "MCHOSE マウス", "provider.nintendo": "Nintendo Switch コントローラー", "provider.playstation": "PlayStation コントローラー", "provider.pulsar": "Pulsar / ATK VXE マウス", "provider.razer": "Razer マウスとヘッドセット", "provider.steelseries": "SteelSeries", + "provider.steelseries_elite": "SteelSeries Arctis Nova Elite", "provider.wlmouse": "WLmouse", "provider.xinput": "Xbox 互換コントローラー", "state.no_link": "未接続(電源オフまたはスリープ)", diff --git a/locales/zh_CN.py b/locales/zh_CN.py index 0d764cce..3901c5c4 100644 --- a/locales/zh_CN.py +++ b/locales/zh_CN.py @@ -28,6 +28,7 @@ "preferences.full": "充满电时通知", "preferences.time_left": "预计剩余使用时间", "preferences.quiet": "游戏时保持安静", + "preferences.low_sound": "低电量提醒时播放声音", "preferences.bluetooth": "Windows 蓝牙设备", "preferences.playstation": "PlayStation 完整模式(蓝牙)", "preferences.providers": "设备类型", @@ -58,17 +59,20 @@ "provider.hyperx": "HyperX Cloud II Wireless", "provider.hyperx_alpha2": "HyperX Cloud Alpha 2", "provider.hyperx_cloud3": "HyperX Cloud III Wireless", + "provider.hyperx_cloud3s": "HyperX Cloud III S Wireless", "provider.jbl": "JBL Quantum", "provider.keychron": "Keychron", "provider.lamzu": "LAMZU 鼠标", "provider.lofree": "Lofree 键盘", "provider.logitech": "Logitech", + "provider.logitech_centurion": "Logitech G PRO X 2 LIGHTSPEED", "provider.mchose": "MCHOSE 鼠标", "provider.nintendo": "Nintendo Switch 控制器", "provider.playstation": "PlayStation 控制器", "provider.pulsar": "Pulsar / ATK VXE 鼠标", "provider.razer": "Razer 鼠标和耳机", "provider.steelseries": "SteelSeries", + "provider.steelseries_elite": "SteelSeries Arctis Nova Elite", "provider.wlmouse": "WLmouse", "provider.xinput": "Xbox 兼容控制器", "state.no_link": "未连接(已关机或休眠)", diff --git a/locales/zh_TW.py b/locales/zh_TW.py index af6c4a05..4be624bc 100644 --- a/locales/zh_TW.py +++ b/locales/zh_TW.py @@ -28,6 +28,7 @@ "preferences.full": "充飽電時通知", "preferences.time_left": "預估剩餘使用時間", "preferences.quiet": "遊戲時保持安靜", + "preferences.low_sound": "低電量提醒時播放音效", "preferences.bluetooth": "Windows 藍牙裝置", "preferences.playstation": "PlayStation 完整模式(藍牙)", "preferences.providers": "裝置類型", @@ -58,17 +59,20 @@ "provider.hyperx": "HyperX Cloud II Wireless", "provider.hyperx_alpha2": "HyperX Cloud Alpha 2", "provider.hyperx_cloud3": "HyperX Cloud III Wireless", + "provider.hyperx_cloud3s": "HyperX Cloud III S Wireless", "provider.jbl": "JBL Quantum", "provider.keychron": "Keychron", "provider.lamzu": "LAMZU 滑鼠", "provider.lofree": "Lofree 鍵盤", "provider.logitech": "Logitech", + "provider.logitech_centurion": "Logitech G PRO X 2 LIGHTSPEED", "provider.mchose": "MCHOSE 滑鼠", "provider.nintendo": "Nintendo Switch 控制器", "provider.playstation": "PlayStation 控制器", "provider.pulsar": "Pulsar / ATK VXE 滑鼠", "provider.razer": "Razer 滑鼠與耳機", "provider.steelseries": "SteelSeries", + "provider.steelseries_elite": "SteelSeries Arctis Nova Elite", "provider.wlmouse": "WLmouse", "provider.xinput": "Xbox 相容控制器", "state.no_link": "未連線(已關機或休眠)", diff --git a/tests/test_bluetooth_poll.py b/tests/test_bluetooth_poll.py index ad9a3b7d..7ae7471e 100644 --- a/tests/test_bluetooth_poll.py +++ b/tests/test_bluetooth_poll.py @@ -87,6 +87,8 @@ def setUp(self): mock.patch.object(hb, "DeviceIcon", FakeIcon), mock.patch.object(hb.pystray, "Icon", FakeTrayIcon), mock.patch.object(hb, "save_config", lambda cfg: None), + # The real desktop's full-screen state must not extend the test interval. + mock.patch.object(hb, "fullscreen_app_running", return_value=False), ] for p in patches: p.start() diff --git a/tests/test_i18n.py b/tests/test_i18n.py index ea316995..fd45f704 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -247,6 +247,20 @@ def test_third_language_is_selectable_persisted_and_kept_on_restart(self): menu = find(prefs, "Language / Langue").submenu self.assertTrue(find(menu, "Test language").checked) + def test_localized_low_sound_option_persists_without_changing_language(self): + for code in i18n.LANGUAGES: + with self.subTest(language=code): + app = make_app({"language": code}) + prefs = find(app.build_menu(None), app.tr("menu.preferences")).submenu + option = find(prefs, app.tr("preferences.low_sound")) + self.assertFalse(option.checked) + option(None) + self.assertTrue(option.checked) + self.assertEqual(app.cfg["language"], code) + cfg = hb.load_config() + self.assertTrue(cfg["low_sound"]) + self.assertEqual(cfg["language"], code) + class PresentationTests(unittest.TestCase): def setUp(self): @@ -286,9 +300,17 @@ def test_structured_coarse_states_do_not_parse_english_text(self): self.assertIn("débranchez", hb.device_state(st, language="fr")) def test_every_provider_and_pictogram_is_translated(self): - prefs = find(self.app.build_menu(None), "Préférences").submenu - labels = [item.text for item in find(prefs, "Types de périphériques").submenu] - self.assertEqual(set(labels), {i18n.translate("provider." + key, language="fr") for key in hb.PROVIDER_LABELS}) + for code in i18n.LANGUAGES: + with self.subTest(language=code): + app = make_app({"language": code}) + prefs = find(app.build_menu(None), app.tr("menu.preferences")).submenu + labels = [item.text for item in find(prefs, app.tr("preferences.providers")).submenu] + self.assertEqual(set(labels), {i18n.translate("provider." + key, language=code) + for key in hb.PROVIDER_LABELS}) + # New providers must also have reference keys, not just translations. + self.assertTrue(all("provider." + key in en.MESSAGES for key in hb.PROVIDER_LABELS)) + if code == "en": + self.assertEqual(set(labels), set(hb.PROVIDER_LABELS.values())) owner = types.SimpleNamespace(status=dev()) labels = [item.text for item in find(self.app.build_menu(owner), "Icône").submenu] self.assertEqual(labels, ["Automatique", "Souris", "Clavier", "Casque", "Manette", "Bluetooth"]) From 2912701103e190bc417deae53c368e193075ae19 Mon Sep 17 00:00:00 2001 From: Tiago FERREIRA DOMINGUES Date: Thu, 22 Oct 2026 13:59:21 +0200 Subject: [PATCH 8/8] Address localization review: empty estimates, rename focus and config recovery --- .github/CONTRIBUTING.md | 5 +++ CHANGELOG.md | 7 ++++ halo_battery.pyw | 14 +++---- i18n.py | 2 + locales/en.py | 1 + locales/fr.py | 1 + locales/ja.py | 1 + locales/zh_CN.py | 1 + locales/zh_TW.py | 1 + tests/test_i18n.py | 61 +++++++++++++++++++++++++++-- tests/test_rename_dialog.py | 76 +++++++++++++++++++++++++++++++++++++ 11 files changed, 159 insertions(+), 11 deletions(-) create mode 100644 tests/test_rename_dialog.py diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index d5c28725..b4128ce0 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -127,6 +127,8 @@ compilation step. boundaries. Test fallback, configuration persistence and notification behavior with `python -m unittest discover -s tests`. Review long labels, accents and device names in both classic and Fluent menus. + On Windows, set HALO_TEST_WINDOWS_DIALOG=1 to include the real Windows Forms + focus/selection regression tests; their invisible dialogs close automatically. A right-to-left language also requires a separate review of text direction and UI layout; registering its catalog alone does not enable right-to-left layout. @@ -138,4 +140,7 @@ languages or a failed detection select `en`. Preferences > Language / Langue overrides that initial selection immediately and persists `language` in `%APPDATA%\HaloBattery\config.json` (or the app's folder in portable mode). A stored invalid value falls back to English without triggering detection again. +After a damaged config is preserved as config.json.bad, normal startup initializes +and saves the language; ordinary reads do not. If the backup fails, the damaged +file is left untouched and English defaults are used for that session. `--probe` never initializes the language. diff --git a/CHANGELOG.md b/CHANGELOG.md index 322ffeaf..d7b294e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,13 @@ and the project follows [Semantic Versioning](https://semver.org/). features and settings is in `docs/features.md` and the credits in `docs/credits.md`. ### Fixed +- Localization review: zero and negative estimates say "no use left" in every + language, matching #223's intended behavior; positive estimates are unchanged. +- The localized rename dialog focuses and selects the current name using a normal + PowerShell TextBox variable instead of the automatic variable $input. +- After a damaged config is preserved as config.json.bad, normal startup detects + and persists the initial language. Ordinary reads and --probe do not initialize + it; a failed backup leaves the damaged file untouched. - **Start-up with portable mode no longer hangs in a folder it cannot write to**: the check whether the app folder is writable used `tempfile`, which on Windows retries forever when the folder's permissions deny creating files (Program Files). It is now one diff --git a/halo_battery.pyw b/halo_battery.pyw index 005eab68..7b31c292 100644 --- a/halo_battery.pyw +++ b/halo_battery.pyw @@ -282,8 +282,8 @@ def load_config(*, initialize_language: bool = False) -> dict: try: os.replace(CONFIG_PATH, CONFIG_PATH + ".bad") except OSError: - pass - return cfg + return cfg # never overwrite an unpreserved damaged file + data = {} # startup initializes language after recovery for key, value in data.items(): if key in DEFAULTS and not _valid_setting(key, value): log.warning("config: ignoring %s=%r, using %r", key, value, DEFAULTS[key]) @@ -920,8 +920,8 @@ $layout = New-Object System.Windows.Forms.TableLayoutPanel $layout.AutoSize = $true; $layout.Dock = 'Fill'; $layout.Padding = 12 $label = New-Object System.Windows.Forms.Label $label.Text = $env:HALO_BATTERY_PROMPT; $label.AutoSize = $true -$input = New-Object System.Windows.Forms.TextBox -$input.Text = $env:HALO_BATTERY_NAME; $input.MaxLength = 60; $input.Width = 340 +$tb = New-Object System.Windows.Forms.TextBox +$tb.Text = $env:HALO_BATTERY_NAME; $tb.MaxLength = 60; $tb.Width = 340 $buttons = New-Object System.Windows.Forms.FlowLayoutPanel $buttons.AutoSize = $true; $buttons.FlowDirection = 'RightToLeft' $ok = New-Object System.Windows.Forms.Button @@ -929,11 +929,11 @@ $ok.Text = $env:HALO_BATTERY_OK; $ok.AutoSize = $true; $ok.DialogResult = 'OK' $cancel = New-Object System.Windows.Forms.Button $cancel.Text = $env:HALO_BATTERY_CANCEL; $cancel.AutoSize = $true; $cancel.DialogResult = 'Cancel' $buttons.Controls.Add($cancel); $buttons.Controls.Add($ok) -$layout.Controls.Add($label, 0, 0); $layout.Controls.Add($input, 0, 1) +$layout.Controls.Add($label, 0, 0); $layout.Controls.Add($tb, 0, 1) $layout.Controls.Add($buttons, 0, 2); $form.Controls.Add($layout) $form.AcceptButton = $ok; $form.CancelButton = $cancel -$form.Add_Shown({ $input.Focus(); $input.SelectAll() }) -if ($form.ShowDialog() -eq 'OK') { [Console]::WriteLine($input.Text) } +$form.Add_Shown({ $tb.Focus(); $tb.SelectAll() }) +if ($form.ShowDialog() -eq 'OK') { [Console]::WriteLine($tb.Text) } $form.Dispose() """ try: diff --git a/i18n.py b/i18n.py index 8e248732..2250d064 100644 --- a/i18n.py +++ b/i18n.py @@ -125,6 +125,8 @@ def tooltip(text: str) -> str: def format_left(seconds: float, *, language: str = "en") -> str: """Format an estimate; the history calculation itself is language-independent.""" + if seconds <= 0: + return translate("duration.none", language=language) hours = seconds / 3600.0 if hours < 1: return translate("duration.less_hour", language=language) diff --git a/locales/en.py b/locales/en.py index 89f07f83..fde87fb6 100644 --- a/locales/en.py +++ b/locales/en.py @@ -96,6 +96,7 @@ "level.medium": "medium", "level.good": "good", "level.full": "full", + "duration.none": "no use left", "duration.less_hour": "less than 1 h of use left", "duration.hours": {"one": "about {count} h of use left", "other": "about {count} h of use left"}, "duration.days": {"one": "about {count} day of use left", "other": "about {count} days of use left"}, diff --git a/locales/fr.py b/locales/fr.py index f40c4886..dacd86ec 100644 --- a/locales/fr.py +++ b/locales/fr.py @@ -96,6 +96,7 @@ "level.medium": "moyenne", "level.good": "bonne", "level.full": "pleine", + "duration.none": "aucune autonomie restante", "duration.less_hour": "moins d’une heure d’utilisation restante", "duration.hours": {"one": "environ {count} heure d’utilisation restante", "other": "environ {count} heures d’utilisation restantes"}, diff --git a/locales/ja.py b/locales/ja.py index 477a9f52..8ce76afa 100644 --- a/locales/ja.py +++ b/locales/ja.py @@ -97,6 +97,7 @@ "level.medium": "中程度", "level.good": "十分", "level.full": "満充電", + "duration.none": "残り使用時間はありません", "duration.less_hour": "残り使用時間は 1 時間未満", "duration.hours": { "other": "あと約 {count} 時間使用できます" diff --git a/locales/zh_CN.py b/locales/zh_CN.py index a086541e..fc2e296f 100644 --- a/locales/zh_CN.py +++ b/locales/zh_CN.py @@ -97,6 +97,7 @@ "level.medium": "中等", "level.good": "充足", "level.full": "满电", + "duration.none": "无剩余使用时间", "duration.less_hour": "剩余使用时间不足 1 小时", "duration.hours": { "other": "约可再使用 {count} 小时" diff --git a/locales/zh_TW.py b/locales/zh_TW.py index ad646074..7ee90a59 100644 --- a/locales/zh_TW.py +++ b/locales/zh_TW.py @@ -97,6 +97,7 @@ "level.medium": "中等", "level.good": "充足", "level.full": "滿電", + "duration.none": "無剩餘使用時間", "duration.less_hour": "剩餘使用時間不到 1 小時", "duration.hours": { "other": "約可再使用 {count} 小時" diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 0d8c3e3a..a88212f7 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -117,9 +117,31 @@ def test_plural_rules_and_estimate_boundaries(self): self.assertEqual(i18n.format_left(3600, language="fr"), "environ 1 heure d’utilisation restante") self.assertEqual(i18n.format_left(7200, language="fr"), "environ 2 heures d’utilisation restantes") self.assertEqual(i18n.format_left(48 * 3600, language="fr"), "environ 2 jours d’utilisation restants") - for hours in (0, 0.5, 1, 1.5, 5.4, 47.9, 48, 72, 200): + for hours in (0.5, 1, 1.5, 5.4, 47.9, 48, 72, 200): self.assertEqual(i18n.format_left(hours * 3600), hb.history.format_left(hours * 3600)) + def test_nonpositive_estimates_and_positive_boundary(self): + expected = {"en": "no use left", "fr": "aucune autonomie restante", + "zh-TW": "無剩餘使用時間", "zh-CN": "无剩余使用时间", + "ja": "残り使用時間はありません"} + for language, text in expected.items(): + for seconds in (-3600, -1, -0.01, 0): + with self.subTest(language=language, seconds=seconds): + self.assertEqual(i18n.format_left(seconds, language=language), text) + self.assertEqual(i18n.format_left(0.01, language=language), + i18n.translate("duration.less_hour", language=language)) + with mock.patch.dict(fr.MESSAGES, {}, clear=True): + self.assertEqual(i18n.format_left(0, language="fr"), "no use left") + self.assertEqual(i18n.format_left(-1, language="not-registered"), "no use left") + + def test_empty_estimate_reaches_tooltip_and_english_status(self): + app = make_app({"language": "fr"}) + app.history.seconds_left = lambda key, level: 0.0 + st = dev(level=0) + self.assertEqual(app.time_left_text(st), "aucune autonomie restante") + self.assertEqual(app.status_data([st])["devices"][0]["text"], + "G502 LIGHTSPEED: 0%, no use left") + def test_unicode_tooltip_buffer(self): self.assertEqual(i18n.tooltip("Écouteurs 🎧"), "Écouteurs 🎧") self.assertEqual(i18n.tooltip("é" * 200), "é" * 127) @@ -218,14 +240,45 @@ def test_saved_languages_and_invalid_values_never_trigger_detection(self): self.assertEqual(cfg["language"], value if valid else "en") self.assertEqual(cfg["interval"], 30) - def test_damaged_config_keeps_existing_recovery_and_english_default(self): + def test_damaged_config_initializes_language_after_preserving_original(self): + for damaged in ("{broken", "[]", "null", '"text"', "42"): + with self.subTest(damaged=damaged): + with open(self.path, "w", encoding="utf-8") as stream: + stream.write(damaged) + with mock.patch.object(i18n, "detect_language", return_value="fr") as detect: + cfg = hb.load_config(initialize_language=True) + detect.assert_called_once_with() + self.assertEqual(cfg, dict(hb.DEFAULTS, language="fr")) + with open(self.path + ".bad", encoding="utf-8") as stream: + self.assertEqual(stream.read(), damaged) + with open(self.path, encoding="utf-8") as stream: + self.assertEqual(json.load(stream)["language"], "fr") + with mock.patch.object(i18n, "detect_language") as detect: + self.assertEqual(hb.load_config(initialize_language=True), cfg) + detect.assert_not_called() + + def test_ordinary_damaged_config_recovery_never_initializes_language(self): with open(self.path, "w") as stream: stream.write("{broken") - with mock.patch.object(i18n, "detect_language") as detect: - self.assertEqual(hb.load_config(initialize_language=True), hb.DEFAULTS) + with mock.patch.object(i18n, "detect_language") as detect, mock.patch.object(hb, "save_config") as save: + self.assertEqual(hb.load_config(), hb.DEFAULTS) detect.assert_not_called() + save.assert_not_called() + self.assertFalse(os.path.exists(self.path)) self.assertTrue(os.path.exists(self.path + ".bad")) + def test_failed_damaged_backup_is_not_overwritten(self): + with open(self.path, "w") as stream: + stream.write("{broken") + with mock.patch.object(hb.os, "replace", side_effect=OSError("backup refused")), \ + mock.patch.object(i18n, "detect_language") as detect, \ + mock.patch.object(hb, "save_config") as save: + self.assertEqual(hb.load_config(initialize_language=True), hb.DEFAULTS) + detect.assert_not_called() + save.assert_not_called() + with open(self.path) as stream: + self.assertEqual(stream.read(), "{broken") + def test_manual_selection_persists_and_overrides_next_windows_detection(self): app = make_app() app.set_language("fr") diff --git a/tests/test_rename_dialog.py b/tests/test_rename_dialog.py new file mode 100644 index 00000000..d1ef568e --- /dev/null +++ b/tests/test_rename_dialog.py @@ -0,0 +1,76 @@ +"""Opt-in Windows Forms integration checks: no hardware and no manual interaction. + +Run on Windows with HALO_TEST_WINDOWS_DIALOG=1. The real Shown callbacks execute +in an invisible, automatically closed dialog; no tray or application startup. +""" +import json +import os +import subprocess +import sys +import types +import unittest +from unittest import mock + +from test_hide_rename import hb + + +@unittest.skipUnless(sys.platform == "win32" and os.environ.get("HALO_TEST_WINDOWS_DIALOG") == "1", + "set HALO_TEST_WINDOWS_DIALOG=1 on an interactive Windows desktop") +class RenameDialogTests(unittest.TestCase): + def run_dialog(self, language, *, automatic_variable=False): + real_run = subprocess.run + with mock.patch.object(hb.subprocess, "run", return_value=types.SimpleNamespace(stdout=b"")) as capture: + hb.ask_name('Écouteurs 🎧 "Bureau"', language=language) + args = list(capture.call_args.args[0]) + kwargs = dict(capture.call_args.kwargs, timeout=20) + script = args[-1] + if automatic_variable: + script = script.replace("$tb", "$input") # reproduce the reviewed regression + start = script.index("if ($form.ShowDialog()") + script = script[:start] + r""" +$script:failed = $false +$form.Opacity = 0 +$form.ShowInTaskbar = $false +$form.Add_Shown({ + try { + $box = @($layout.Controls | Where-Object { $_ -is [System.Windows.Forms.TextBox] })[0] + if (-not $box.Focused) { throw 'TextBox did not receive focus' } + if ($box.SelectionStart -ne 0 -or $box.SelectionLength -ne $box.Text.Length) { + throw 'The current name was not fully selected' + } + $details = @{ name=$box.Text; title=$form.Text; prompt=$label.Text + ok=$ok.Text; cancel=$cancel.Text } + [Console]::WriteLine(($details | ConvertTo-Json -Compress)) + } catch { + $script:failed = $true + [Console]::Error.WriteLine($_) + } finally { + $form.DialogResult = 'Cancel' + $form.Close() + } +}) +[void]$form.ShowDialog() +$form.Dispose() +if ($script:failed -or $Error.Count) { exit 1 } +""" + args[-1] = script + return real_run(args, **kwargs) + + def test_real_shown_callback_focuses_and_selects_name_in_each_language(self): + for code in ("en", "fr", "zh-TW", "zh-CN", "ja"): + with self.subTest(language=code): + result = self.run_dialog(code) + self.assertEqual(result.returncode, 0, result.stderr.decode("utf-8", "replace")) + details = json.loads(result.stdout.decode("utf-8-sig")) + self.assertEqual(details["name"], 'Écouteurs 🎧 "Bureau"') + for field, key in (("title", "rename.title"), ("prompt", "rename.prompt"), + ("ok", "rename.ok"), ("cancel", "rename.cancel")): + self.assertEqual(details[field], hb.i18n.translate(key, language=code)) + + def test_automatic_input_variable_reproduces_the_regression(self): + result = self.run_dialog("fr", automatic_variable=True) + self.assertNotEqual(result.returncode, 0, "the regression control unexpectedly passed") + + +if __name__ == "__main__": + unittest.main()