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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user