feat: add Vietnamese localization to GUI

Introduces a two-language (EN/VI) i18n system with hot-swap support.
All ~160 user-facing strings are centralised in gui/locales.py; views
retranslate in-place on language switch without restarting the app.

- gui/locales.py: new file — _EN/_VI dicts, t() lookup, set_language(),
  get_language(); assert guard ensures EN/VI key parity
- gui/app.py: switch_language(), _apply_startup_language(),
  _save_language_pref(), _rebuild_nav_labels(); language stored in
  config.json under ui.language; pipeline step headers and run_tool
  status lines translated
- gui/views/settings.py: Language dropdown card (English / Tiếng Việt)
- gui/views/dashboard.py: all strings via t(); static header widgets
  stored and retranslated in refresh()
- gui/views/mods.py: all strings via t(); _STATUS dict built at call
  time so server status labels update on language switch
- gui/views/tools.py: all strings via _translatable registry; tab names
  and segmented-button values kept in English (CTkTabview constraint)
- gui/views/logs.py: title + Copy/Clear buttons stored, retranslated
- gui/wizard.py: all 3 pages fully translated
- docs/huong-dan-su-dung.md: full Vietnamese user guide
- CLAUDE.md: documents localization architecture and constraints
This commit is contained in:
Tran G. (Revernomad) Khoa
2026-04-08 16:58:41 +07:00
parent 4478ec3cab
commit 903cd366e2
10 changed files with 1226 additions and 230 deletions

View File

@@ -15,6 +15,7 @@ from gui._constants import (
SIDEBAR_W, APP_TITLE, PROJECT_ROOT, SELECTION_FILE,
)
from gui._io import _QueueWriter
from gui.locales import t
from gui.wizard import SetupWizard
from gui.views.base import BaseView
@@ -62,6 +63,7 @@ class ArmaModManagerApp(ctk.CTk):
else:
self._load_config()
self._apply_startup_language()
self._build_layout()
self._poll_log()
@@ -106,21 +108,31 @@ class ArmaModManagerApp(ctk.CTk):
"""Thread-safe: enqueue text for the Logs panel."""
self._log_q.put(text)
def switch_language(self, lang: str) -> None:
"""Switch the UI language and refresh all cached views."""
from gui import locales
locales.set_language(lang)
self._save_language_pref(lang)
self._rebuild_nav_labels()
for view in self._view_cache.values():
view.refresh()
if self._active_name:
self.navigate_to(self._active_name)
def run_pipeline(self, selected_names: set[str]) -> None:
"""Start the background pipeline for the given preset filenames."""
if self._pipeline_running:
return
if len(selected_names) < 2:
messagebox.showwarning(
"Not enough presets selected",
"Please select at least 2 preset files to compare.\n\n"
"Use the checkboxes on the Dashboard to choose which presets to use.",
t("app.dlg_presets_title"),
t("app.dlg_presets_body"),
)
return
cfg = self._cfg
if not cfg:
messagebox.showwarning("Setup required", "Please complete Setup first.")
messagebox.showwarning(t("app.dlg_setup_title"), t("app.dlg_setup_body"))
return
self._pipeline_running = True
@@ -137,7 +149,7 @@ class ArmaModManagerApp(ctk.CTk):
from arma_modlist_tools.compare import compare_presets
# Step 1 — Parse selected presets
_hdr("Step 1 / 4", "Parse presets")
_hdr("Step 1 / 4", t("pipeline.step1_name"))
cfg.modlist_json.mkdir(exist_ok=True)
presets = []
for fp in sorted(cfg.modlist_html.glob("*.html")):
@@ -154,7 +166,7 @@ class ArmaModManagerApp(ctk.CTk):
presets.append(preset)
# Step 2 — Compare
_hdr("Step 2 / 4", "Compare presets")
_hdr("Step 2 / 4", t("pipeline.step2_name"))
result = compare_presets(*presets)
cfg.comparison.write_text(
json.dumps(result, indent=2, ensure_ascii=False),
@@ -166,11 +178,11 @@ class ArmaModManagerApp(ctk.CTk):
f"Unique: {total_unique}")
# Step 3 — Fetch
_hdr("Step 3 / 4", "Download mods")
_hdr("Step 3 / 4", t("pipeline.step3_name"))
step_fetch(cfg)
# Step 4 — Link
_hdr("Step 4 / 4", "Link mods")
_hdr("Step 4 / 4", t("pipeline.step4_name"))
groups = (
sorted(p.name for p in cfg.downloads.iterdir() if p.is_dir())
if cfg.downloads.is_dir() else []
@@ -212,11 +224,13 @@ class ArmaModManagerApp(ctk.CTk):
self.post_log(line)
proc.wait()
ok = proc.returncode == 0
self.post_log(
f"\n{'✓ Done' if ok else f'✗ Exited with code {proc.returncode}'}.\n"
done_msg = (
t("app.tool_done") if ok
else t("app.tool_exit_code", code=proc.returncode)
)
self.post_log(f"\n{done_msg}.\n")
except Exception as e:
self.post_log(f"\n✗ Failed to start {script}: {e}\n")
self.post_log(f"\n{t('app.tool_failed', script=script, e=e)}\n")
threading.Thread(target=worker, daemon=True).start()
@@ -241,6 +255,38 @@ class ArmaModManagerApp(ctk.CTk):
def open_wizard(self) -> None:
SetupWizard(self, on_complete=self._after_wizard)
# =========================================================================
# Private — language
# =========================================================================
def _apply_startup_language(self) -> None:
"""Read language preference from config.json and activate it."""
from gui import locales
lang = "en"
cfg_path = PROJECT_ROOT / "config.json"
if cfg_path.exists():
try:
raw = json.loads(cfg_path.read_text(encoding="utf-8"))
lang = raw.get("ui", {}).get("language", "en")
except Exception:
pass
locales.set_language(lang)
def _save_language_pref(self, lang: str) -> None:
"""Persist language preference into the 'ui' key of config.json."""
cfg_path = PROJECT_ROOT / "config.json"
try:
raw = json.loads(cfg_path.read_text(encoding="utf-8"))
raw.setdefault("ui", {})["language"] = lang
cfg_path.write_text(json.dumps(raw, indent=2), encoding="utf-8")
except Exception:
pass # non-fatal — language preference simply resets next run
def _rebuild_nav_labels(self) -> None:
"""Retranslate the sidebar navigation button labels."""
for name, btn in self._nav_btns.items():
btn.configure(text=t(f"nav.{name.lower()}"))
# =========================================================================
# Private — layout
# =========================================================================
@@ -264,7 +310,7 @@ class ArmaModManagerApp(ctk.CTk):
self._nav_btns: dict[str, ctk.CTkButton] = {}
for i, name in enumerate(_VIEW_NAMES, start=1):
b = ctk.CTkButton(
sb, text=name, width=SIDEBAR_W - 24,
sb, text=t(f"nav.{name.lower()}"), width=SIDEBAR_W - 24,
anchor="w", command=lambda n=name: self.navigate_to(n),
fg_color="transparent",
hover_color=("gray80", "gray30"),