Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,59 @@ 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`, `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.

- Copy the English catalog into `locales/<code>.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 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. 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 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
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.
The `halo/whatsnew.py` window translates its controls, known section headings and
date using the same catalogs. Keep externally published release-note prose in its
original language. Update notifications retain technical kinds and parameters so
messages held while gaming use the current language when delivered.
- 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. French regional variants select `fr`, Japanese
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` (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.
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,31 @@ and the project follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added
- 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, 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
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
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.
Also covers the 1.15 update installer, manual update checks, retained-device preference,
NZXT and Steam families, and the What's new window's controls, headings and dates.
Published release-note prose stays in its original language. HID++ 1.0 coarse levels,
the Xbox Bluetooth fallback and Steam's pending state retain language-independent
presentation metadata alongside unchanged technical text.

### Changed
- **A tidier repository and a shorter README**: the app's modules moved into the `halo/`
package (`halo_battery.pyw` stays the entry point), the .bat helpers into `scripts/`
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ One icon per device. No vendor software.
- **Stays out of the way.** Alerts wait until your full-screen game closes, and devices are polled less often while you play.
- **Native look.** A Windows 11 style menu, sharp at any display scale, light or dark.
- **Autoupdates.** A new release installs from the tray menu in one click.
- **Your language.** English, Français, 繁體中文, 简体中文 or 日本語, selected in Preferences.

<p align="center"><img src="docs/charging.gif" alt="The charging animation: the green arc slowly breathes" width="300"></p>

Expand Down
9 changes: 9 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ 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 日本語. On first startup without
a saved language, the Windows UI language selects a supported translation, otherwise English.
The selection is saved with the other settings (next to the app in portable mode).
A manual choice updates menus and tooltips immediately and always takes precedence over Windows.
An invalid saved value falls back to English without redetection. Diagnostics and status-file text
remain in English.
- **Poll interval** (15 s to 5 min) and **Low battery alert** (off, 5–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`.
Expand All @@ -43,6 +49,9 @@ Right-click a device icon to open its menu. It looks like a Windows 11 menu (acr

The app checks for a new release once a day (or at once with **Preferences → Check for updates now**) and adds **Update to vX.Y.Z** to the menu when there is one. One click downloads it, checks it, closes the app, replaces the program files and starts the new version, which then shows what is new in a small window. Settings, the battery history and `portable.txt` are kept. From version 1.14.0 or older, or when the app sits in a folder it cannot write to (e.g. Program Files), update by hand: tray menu → **Exit**, then replace the folder; **Start with Windows** follows the new copy. If you used the old single-file `HaloBattery.exe`, delete it.

The **What's new** window uses the selected language for its controls, section headings and date.
The published release-note prose and the full notes on GitHub remain in their original language.

## SmartScreen and antivirus warnings

Windows SmartScreen may warn about an unrecognized app on first launch, because the file is not code-signed: **More info → Run anyway**. Some antivirus tools flag unsigned Python apps by mistake (usually a generic `!ml` detection). The release is built by GitHub Actions straight from this repository, with public build logs; if you would rather not trust it, run it from source (see below).
Expand Down
52 changes: 33 additions & 19 deletions halo/whatsnew.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from typing import Callable, List, Optional, Tuple

from . import flyout
import i18n

log = logging.getLogger("halo_battery")

Expand Down Expand Up @@ -282,20 +283,30 @@ def visible_blocks(blocks: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
return blocks


def nice_date(iso: str) -> str:
def localized_blocks(notes_md: str, *, language: str = "en") -> List[Tuple[str, str]]:
# Release prose is external published content, kept in its original language.
headings = {name: "whatsnew." + name.lower()
for name in ("Added", "Fixed", "Changed", "Removed", "Deprecated", "Security")}
return [(kind, i18n.translate(headings[text], language=language)
if kind == "heading" and text in headings else text)
for kind, text in visible_blocks(parse_notes(notes_md))]


def nice_date(iso: str, *, language: str = "en") -> str:
"""'2026-10-05' -> '5 October 2026'; anything else -> ''."""
try:
d = datetime.date.fromisoformat((iso or "")[:10])
except ValueError:
return ""
return f"{d.day} {d.strftime('%B')} {d.year}"
month = i18n.translate("date.month." + str(d.month), language=language)
return i18n.translate("whatsnew.date", language=language, day=d.day, month=month, year=d.year)


def subtitle(from_version: str, date: str) -> str:
def subtitle(from_version: str, date: str, *, language: str = "en") -> str:
parts = []
if from_version:
parts.append(f"Updated from {from_version}")
when = nice_date(date)
parts.append(i18n.translate("whatsnew.from", language=language, version=from_version))
when = nice_date(date, language=language)
if when:
parts.append(when)
return " · ".join(parts)
Expand Down Expand Up @@ -464,28 +475,32 @@ def thumb_geometry(track: int, view: int, full: int, scroll: int, min_h: int) ->

def show(host: "flyout.FlyoutHost", version: str, notes_md: str, url: str,
from_version: str = "", date: str = "",
open_url: Optional[Callable[[str], None]] = None) -> threading.Event:
open_url: Optional[Callable[[str], None]] = None, *, language: str = "en") -> threading.Event:
"""Show the window (from any thread). -> an event that is set once it is closed,
or at once when it cannot be shown."""
closed = threading.Event()
if not host.call(lambda: _create(host, version, notes_md, url, from_version, date,
open_url, closed)):
open_url, closed, language)):
log.info("what's new: no window (tkinter is not available)")
closed.set()
return closed


def _create(host, version, notes_md, url, from_version, date, open_url, closed) -> None:
def _create(host, version, notes_md, url, from_version, date, open_url, closed, language="en") -> None:
try:
WhatsNewWindow(host, version, notes_md, url, from_version, date, open_url, closed)
WhatsNewWindow(host, version, notes_md, url, from_version, date, open_url, closed, language=language)
except Exception as e:
log.exception("what's new: %s", e)
closed.set()


class WhatsNewWindow:
def tr(self, key):
return i18n.translate(key, language=self.language)

def __init__(self, host, version: str, notes_md: str, url: str, from_version: str,
date: str, open_url, closed: threading.Event):
date: str, open_url, closed: threading.Event, *, language: str = "en"):
self.language = language
self.host, self.url, self.open_url, self.closed = host, url, open_url, closed
tk, root, w32 = host._tk, host._root, host._w
self.tk, self.root, self.w32 = tk, root, w32
Expand Down Expand Up @@ -525,10 +540,9 @@ def font(names, size, bold=False):
icon_fam = next((f for f in flyout.ICON_FONTS if f in fams), None)
self.f_icon = tkf.Font(root, family=icon_fam, size=-px(CLOSE_PX)) if icon_fam else None
self.version, self.from_version, self.date = version, from_version, date
self.blocks = visible_blocks(parse_notes(notes_md))
self.blocks = localized_blocks(notes_md, language=language)
if not self.blocks:
self.blocks = [("para", "The release notes could not be loaded. "
"They are on the release page.")]
self.blocks = [("para", self.tr("whatsnew.notes_unavailable"))]
self.hover: Optional[str] = None # "close" / "link" / "button"
self.pressed: Optional[str] = None # the part the mouse went down on
self.thumb_drag: Optional[Tuple[int, int]] = None # (y at the press, scroll then)
Expand Down Expand Up @@ -590,10 +604,10 @@ def _build(self, work) -> None:
label_h = self.f_label.metrics("linespace")
eyebrow_h = self.f_eyebrow.metrics("linespace")
title_h = self.f_title.metrics("linespace")
sub = subtitle(self.from_version, self.date)
sub = subtitle(self.from_version, self.date, language=self.language)
header_h = eyebrow_h + title_h + (label_h if sub else 0)
y = top
cv.create_text(x_text, y, text="What's new", font=self.f_eyebrow, fill=c["link"], anchor="nw",
cv.create_text(x_text, y, text=self.tr("whatsnew.title"), font=self.f_eyebrow, fill=c["link"], anchor="nw",
tags="accent")
y += eyebrow_h
cv.create_text(x_text, y - px(2), text=f"Halo Battery {self.version}", font=self.f_title,
Expand All @@ -618,11 +632,11 @@ def _build(self, work) -> None:
cv.create_window(px(PAD), y, window=self.body, anchor="nw")
y += self.body_h + px(FOOTER_GAP)
# footer: the link on the left, the button on the right
bw = self.f_button.measure("Got it") + 2 * px(BUTTON_PAD_X)
bw = self.f_button.measure(self.tr("whatsnew.got_it")) + 2 * px(BUTTON_PAD_X)
bh = px(BUTTON_H)
self.button_box = (self.w - px(PAD) - bw, y, self.w - px(PAD), y + bh)
self._make_buttons()
link_text = "Full release notes"
link_text = self.tr("whatsnew.full_notes")
lw = self.f_link.measure(link_text)
gw = self.f_icon.measure(LINK_GLYPH) + px(6) if self.f_icon else 0
mid = y + bh // 2
Expand Down Expand Up @@ -731,9 +745,9 @@ def _draw_chrome(self) -> None:
fill), True)
shift = px(1) if down else 0
tx, ty = (bx0 + bx1) // 2, (by0 + by1) // 2 + shift
cv.create_text(tx, ty + px(1), text="Got it", font=self.f_button,
cv.create_text(tx, ty + px(1), text=self.tr("whatsnew.got_it"), font=self.f_button,
fill=c["emboss_down"] if down else c["emboss"], anchor="center", tags="chrome")
cv.create_text(tx, ty, text="Got it", font=self.f_button,
cv.create_text(tx, ty, text=self.tr("whatsnew.got_it"), font=self.f_button,
fill=c["on_accent_down"] if down else c["on_accent"],
anchor="center", tags="chrome")
self._thumb = self._track = None # redrawn below
Expand Down
Loading