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

@@ -9,6 +9,7 @@ import customtkinter as ctk
from arma_modlist_tools.fetcher import _normalize_name
from gui._constants import COLOR_OK, COLOR_ERROR, COLOR_WARN, COLOR_RUNNING
from gui.locales import t
from gui.views.base import BaseView
if TYPE_CHECKING:
@@ -61,18 +62,19 @@ class ModsView(BaseView):
hdr.grid(row=0, column=0, sticky="ew", padx=24, pady=(20, 6))
hdr.columnconfigure(1, weight=1)
ctk.CTkLabel(hdr, text="Mods",
font=ctk.CTkFont(size=22, weight="bold")).grid(
row=0, column=0, sticky="w")
self._title_lbl = ctk.CTkLabel(hdr, text=t("mods.title"),
font=ctk.CTkFont(size=22, weight="bold"))
self._title_lbl.grid(row=0, column=0, sticky="w")
btn_frame = ctk.CTkFrame(hdr, fg_color="transparent")
btn_frame.grid(row=0, column=2, sticky="e")
ctk.CTkButton(btn_frame, text="⟳ Refresh", width=100,
command=self.refresh).pack(side="left", padx=(0, 6))
self._refresh_btn = ctk.CTkButton(btn_frame, text=t("mods.refresh_btn"),
width=100, command=self.refresh)
self._refresh_btn.pack(side="left", padx=(0, 6))
self._check_btn = ctk.CTkButton(
btn_frame, text="☁ Check Updates", width=130,
btn_frame, text=t("mods.check_btn"), width=140,
command=self._check_updates,
)
self._check_btn.pack(side="left")
@@ -80,9 +82,10 @@ class ModsView(BaseView):
# ── Search ────────────────────────────────────────────────────────────
bar = ctk.CTkFrame(self, fg_color="transparent")
bar.grid(row=1, column=0, sticky="ew", padx=24, pady=(0, 8))
ctk.CTkLabel(bar, text="Search:").pack(side="left", padx=(0, 6))
self._search_lbl = ctk.CTkLabel(bar, text=t("mods.search_label"))
self._search_lbl.pack(side="left", padx=(0, 6))
ctk.CTkEntry(bar, textvariable=self._search_var,
placeholder_text="Filter mods in active tab…",
placeholder_text=t("mods.search_placeholder"),
width=220).pack(side="left")
self._search_var.trace_add("write", lambda *_: self._apply_search())
@@ -99,6 +102,13 @@ class ModsView(BaseView):
# =========================================================================
def refresh(self) -> None:
# Retranslate static header widgets
self._title_lbl.configure(text=t("mods.title"))
self._refresh_btn.configure(text=t("mods.refresh_btn"))
if not self._checking:
self._check_btn.configure(text=t("mods.check_btn"))
self._search_lbl.configure(text=t("mods.search_label"))
self._mod_rows.clear()
# Destroy previous tab_view / message
@@ -111,19 +121,16 @@ class ModsView(BaseView):
cfg = self.app.cfg
if not cfg:
self._show_msg("No config found. Complete Setup first.")
self._show_msg(t("mods.no_config"))
return
if not cfg.comparison.exists():
self._show_msg(
"No mod data yet.\n"
"Go to Dashboard, select your presets, then click Run Full Pipeline."
)
self._show_msg(t("mods.no_data"))
return
try:
comp = json.loads(cfg.comparison.read_text(encoding="utf-8"))
except Exception as e:
self._show_msg(f"Error reading comparison.json: {e}", error=True)
self._show_msg(t("mods.read_error", e=e), error=True)
return
# Build ordered group list: shared first, then unique groups
@@ -164,14 +171,14 @@ class ModsView(BaseView):
fg_color=("gray82", "gray22"), corner_radius=6)
col_hdr.grid(row=0, column=0, sticky="ew", padx=4, pady=(6, 2))
col_hdr.columnconfigure(0, weight=1)
for col, (w, lbl) in enumerate([
(0, "Mod Name"),
(80, "Downloaded"),
(80, "Linked"),
(160, "Server Status"),
for col, (w, lbl_key) in enumerate([
(0, "mods.col_name"),
(80, "mods.col_downloaded"),
(80, "mods.col_linked"),
(160, "mods.col_server"),
(80, ""),
]):
ctk.CTkLabel(col_hdr, text=lbl,
ctk.CTkLabel(col_hdr, text=t(lbl_key) if lbl_key else "",
font=ctk.CTkFont(weight="bold"),
anchor="w", width=w or 1).grid(
row=0, column=col,
@@ -247,7 +254,7 @@ class ModsView(BaseView):
# Update button (hidden until stale detected)
folder_name = folder_path.name if folder_path else None
update_btn = ctk.CTkButton(
row, text="Update", width=70,
row, text=t("mods.update_btn"), width=70,
command=(lambda g=group, fn=folder_name:
self._update_mod(g, fn)) if folder_name else None,
state="normal" if folder_name else "disabled",
@@ -282,13 +289,13 @@ class ModsView(BaseView):
return
self._checking = True
self._check_btn.configure(text="Checking", state="disabled")
self._check_btn.configure(text=t("mods.check_btn_checking"), state="disabled")
# Reset downloaded rows to "Checking…"
for row in self._mod_rows.values():
if row["folder_path"]:
row["status_label"].configure(
text="Checking", text_color=COLOR_RUNNING)
text=t("mods.status_checking"), text_color=COLOR_RUNNING)
else:
row["status_label"].configure(text="", text_color="gray")
@@ -325,27 +332,29 @@ class ModsView(BaseView):
threading.Thread(target=worker, daemon=True).start()
def _apply_check_results(self, results: dict[str, tuple[str, int]]) -> None:
# Build status map from current translations
_STATUS: dict[str, tuple[str, str]] = {
"ok": ("✓ Up to date", COLOR_OK),
"stale": ("{n} outdated", COLOR_WARN),
"not_downloaded": ("", "gray"),
"not_on_server": ("Not on server", "gray"),
"error": ("✗ Error", COLOR_ERROR),
"ok": (t("mods.status_ok"), COLOR_OK),
"stale": (t("mods.status_stale"), COLOR_WARN),
"not_downloaded": (t("mods.status_not_downloaded"), "gray"),
"not_on_server": (t("mods.status_not_on_server"), "gray"),
"error": (t("mods.status_error"), COLOR_ERROR),
}
for key, (status, n) in results.items():
row = self._mod_rows.get(key)
if not row:
continue
tmpl, color = _STATUS.get(status, ("", "gray"))
row["status_label"].configure(
text=tmpl.replace("{n}", str(n)), text_color=color)
# For "stale", the template contains {n} placeholder
text = tmpl.format_map({"n": n}) if "{n}" in tmpl else tmpl
row["status_label"].configure(text=text, text_color=color)
if status == "stale" and row["folder_path"]:
row["update_btn"].grid()
else:
row["update_btn"].grid_remove()
self._checking = False
self._check_btn.configure(text="☁ Check Updates", state="normal")
self._check_btn.configure(text=t("mods.check_btn"), state="normal")
def _update_mod(self, group: str, folder_name: str) -> None:
self.app.navigate_to("Logs")