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:
70
gui/app.py
70
gui/app.py
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user