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

@@ -7,6 +7,7 @@ import customtkinter as ctk
from tkinter import filedialog
from gui._constants import COLOR_OK, COLOR_ERROR, PROJECT_ROOT
from gui.locales import t
class SetupWizard(ctk.CTkToplevel):
@@ -18,7 +19,7 @@ class SetupWizard(ctk.CTkToplevel):
on_complete: Callable[[], None],
) -> None:
super().__init__(parent)
self.title("Setup — Arma Mod Manager")
self.title(t("wizard.title"))
self.geometry("500x420")
self.resizable(False, False)
self.grab_set()
@@ -48,20 +49,20 @@ class SetupWizard(ctk.CTkToplevel):
def _page_server(self) -> None:
ctk.CTkLabel(
self._body, text="Step 1 of 3 — Server Connection",
self._body, text=t("wizard.step1_title"),
font=ctk.CTkFont(size=16, weight="bold"),
).pack(anchor="w")
ctk.CTkLabel(
self._body, text="Enter the details for your Caddy mod server.",
self._body, text=t("wizard.step1_desc"),
text_color="gray",
).pack(anchor="w", pady=(4, 18))
for lbl, var, show in [
("Server URL", self._url, ""),
("Username", self._user, ""),
("Password", self._pw, ""),
for lbl_key, var, show in [
("wizard.label_url", self._url, ""),
("wizard.label_user", self._user, ""),
("wizard.label_pw", self._pw, ""),
]:
ctk.CTkLabel(self._body, text=lbl).pack(anchor="w")
ctk.CTkLabel(self._body, text=t(lbl_key)).pack(anchor="w")
ctk.CTkEntry(self._body, textvariable=var, width=440, show=show).pack(
anchor="w", pady=(2, 10))
@@ -69,15 +70,15 @@ class SetupWizard(ctk.CTkToplevel):
foot.pack(fill="x", pady=(8, 0))
self._conn_lbl = ctk.CTkLabel(foot, text="", text_color="gray")
self._conn_lbl.pack(side="left")
ctk.CTkButton(foot, text="Next", width=90,
ctk.CTkButton(foot, text=t("wizard.btn_next"), width=90,
command=lambda: self._show(1)).pack(side="right")
ctk.CTkButton(foot, text="Test Connection", width=140,
ctk.CTkButton(foot, text=t("wizard.btn_test"), width=140,
fg_color="transparent", border_width=1,
text_color=("gray10", "gray90"),
command=self._test).pack(side="right", padx=(0, 8))
def _test(self) -> None:
self._conn_lbl.configure(text="Testing", text_color="gray")
self._conn_lbl.configure(text=t("wizard.testing"), text_color="gray")
self.update()
try:
import requests
@@ -85,53 +86,51 @@ class SetupWizard(ctk.CTkToplevel):
auth=(self._user.get(), self._pw.get()),
timeout=8)
if r.ok:
self._conn_lbl.configure(text="✓ Connected", text_color=COLOR_OK)
self._conn_lbl.configure(text=t("wizard.connected"),
text_color=COLOR_OK)
else:
self._conn_lbl.configure(text=f"✗ HTTP {r.status_code}",
self._conn_lbl.configure(text=t("wizard.http_error", code=r.status_code),
text_color=COLOR_ERROR)
except Exception as e:
self._conn_lbl.configure(text=f"{e}", text_color=COLOR_ERROR)
self._conn_lbl.configure(text=t("wizard.conn_error", e=e),
text_color=COLOR_ERROR)
# ── Page 2: paths ────────────────────────────────────────────────────────
def _page_paths(self) -> None:
ctk.CTkLabel(
self._body, text="Step 2 of 3 — Arma 3 Server Folder",
self._body, text=t("wizard.step2_title"),
font=ctk.CTkFont(size=16, weight="bold"),
).pack(anchor="w")
ctk.CTkLabel(
self._body,
text="Point to your Arma 3 Server installation. "
"Links (junctions) will be created here.",
self._body, text=t("wizard.step2_desc"),
text_color="gray", wraplength=440, justify="left",
).pack(anchor="w", pady=(4, 18))
ctk.CTkLabel(self._body, text="Arma 3 Server folder").pack(anchor="w")
ctk.CTkLabel(self._body, text=t("wizard.label_arma")).pack(anchor="w")
row = ctk.CTkFrame(self._body, fg_color="transparent")
row.pack(fill="x", pady=(2, 8))
ctk.CTkEntry(row, textvariable=self._arma, width=350).pack(side="left")
ctk.CTkButton(row, text="Browse", width=80,
ctk.CTkButton(row, text=t("wizard.btn_browse"), width=80,
command=self._browse_arma).pack(side="left", padx=8)
ctk.CTkLabel(
self._body,
text="All other folders (downloads, presets) will be created "
"automatically next to this tool.",
self._body, text=t("wizard.step2_hint"),
text_color="gray", font=ctk.CTkFont(size=11),
wraplength=440, justify="left",
).pack(anchor="w", pady=(8, 0))
foot = ctk.CTkFrame(self._body, fg_color="transparent")
foot.pack(fill="x", pady=(20, 0))
ctk.CTkButton(foot, text="← Back", width=80,
ctk.CTkButton(foot, text=t("wizard.btn_back"), width=80,
fg_color="transparent", border_width=1,
text_color=("gray10", "gray90"),
command=lambda: self._show(0)).pack(side="left")
ctk.CTkButton(foot, text="Next", width=80,
ctk.CTkButton(foot, text=t("wizard.btn_next"), width=80,
command=lambda: self._show(2)).pack(side="right")
def _browse_arma(self) -> None:
d = filedialog.askdirectory(title="Select Arma 3 Server folder")
d = filedialog.askdirectory(title=t("wizard.browse_title"))
if d:
self._arma.set(d)
@@ -139,18 +138,18 @@ class SetupWizard(ctk.CTkToplevel):
def _page_review(self) -> None:
ctk.CTkLabel(
self._body, text="Step 3 of 3 — Review & Save",
self._body, text=t("wizard.step3_title"),
font=ctk.CTkFont(size=16, weight="bold"),
).pack(anchor="w")
ctk.CTkLabel(
self._body, text="Check your settings, then click Save.",
self._body, text=t("wizard.step3_desc"),
text_color="gray",
).pack(anchor="w", pady=(4, 14))
summary = (
f"Server URL: {self._url.get()}\n"
f"Username: {self._user.get()}\n"
f"Arma folder: {self._arma.get() or '(not set)'}\n"
f"Arma folder: {self._arma.get() or t('wizard.not_set')}\n"
)
box = ctk.CTkTextbox(self._body, height=90,
font=ctk.CTkFont(family="Consolas", size=12))
@@ -160,11 +159,11 @@ class SetupWizard(ctk.CTkToplevel):
foot = ctk.CTkFrame(self._body, fg_color="transparent")
foot.pack(fill="x")
ctk.CTkButton(foot, text="← Back", width=80,
ctk.CTkButton(foot, text=t("wizard.btn_back"), width=80,
fg_color="transparent", border_width=1,
text_color=("gray10", "gray90"),
command=lambda: self._show(1)).pack(side="left")
ctk.CTkButton(foot, text="Save & Open", width=120,
ctk.CTkButton(foot, text=t("wizard.btn_save"), width=120,
command=self._save).pack(side="right")
def _save(self) -> None: