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:
@@ -11,6 +11,7 @@ import customtkinter as ctk
|
||||
from gui._constants import (
|
||||
COLOR_OK, COLOR_PENDING, COLOR_ERROR, COLOR_WARN,
|
||||
)
|
||||
from gui.locales import t
|
||||
from gui.views.base import BaseView
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -38,10 +39,12 @@ class DashboardView(BaseView):
|
||||
# ── Header ────────────────────────────────────────────────────────────
|
||||
hdr = ctk.CTkFrame(self, fg_color="transparent")
|
||||
hdr.grid(row=0, column=0, sticky="ew", padx=24, pady=(20, 10))
|
||||
ctk.CTkLabel(hdr, text="Dashboard",
|
||||
font=ctk.CTkFont(size=22, weight="bold")).pack(side="left")
|
||||
ctk.CTkButton(hdr, text="⟳ Refresh", width=100,
|
||||
command=self.refresh).pack(side="right")
|
||||
self._title_lbl = ctk.CTkLabel(hdr, text=t("dashboard.title"),
|
||||
font=ctk.CTkFont(size=22, weight="bold"))
|
||||
self._title_lbl.pack(side="left")
|
||||
self._refresh_btn = ctk.CTkButton(hdr, text=t("dashboard.refresh_btn"),
|
||||
width=100, command=self.refresh)
|
||||
self._refresh_btn.pack(side="right")
|
||||
|
||||
# ── Cards ─────────────────────────────────────────────────────────────
|
||||
cards = ctk.CTkFrame(self, fg_color="transparent")
|
||||
@@ -58,7 +61,7 @@ class DashboardView(BaseView):
|
||||
|
||||
self._run_btn = ctk.CTkButton(
|
||||
run_area,
|
||||
text="▶ Run Full Pipeline",
|
||||
text=t("dashboard.run_btn"),
|
||||
font=ctk.CTkFont(size=15, weight="bold"),
|
||||
height=46,
|
||||
command=self._on_run,
|
||||
@@ -72,11 +75,11 @@ class DashboardView(BaseView):
|
||||
pc = ctk.CTkFrame(parent)
|
||||
pc.grid(row=0, column=0, sticky="nsew", padx=(0, 8), pady=4)
|
||||
|
||||
ctk.CTkLabel(pc, text="Preset Files",
|
||||
ctk.CTkLabel(pc, text=t("dashboard.preset_card_title"),
|
||||
font=ctk.CTkFont(size=14, weight="bold")).pack(
|
||||
anchor="w", padx=14, pady=(14, 2))
|
||||
ctk.CTkLabel(pc,
|
||||
text="HTML exports from Arma 3 Launcher → Mods → Export to HTML",
|
||||
text=t("dashboard.preset_card_desc"),
|
||||
text_color="gray", font=ctk.CTkFont(size=11)).pack(
|
||||
anchor="w", padx=14)
|
||||
|
||||
@@ -89,16 +92,16 @@ class DashboardView(BaseView):
|
||||
self._sel_count_lbl = ctk.CTkLabel(sel_row, text="", text_color="gray",
|
||||
font=ctk.CTkFont(size=11))
|
||||
self._sel_count_lbl.pack(side="left")
|
||||
ctk.CTkButton(sel_row, text="None", width=54,
|
||||
ctk.CTkButton(sel_row, text=t("dashboard.btn_none"), width=54,
|
||||
fg_color="transparent", border_width=1,
|
||||
text_color=("gray10", "gray90"), font=ctk.CTkFont(size=11),
|
||||
command=self._select_none).pack(side="right")
|
||||
ctk.CTkButton(sel_row, text="All", width=54,
|
||||
ctk.CTkButton(sel_row, text=t("dashboard.btn_all"), width=54,
|
||||
fg_color="transparent", border_width=1,
|
||||
text_color=("gray10", "gray90"), font=ctk.CTkFont(size=11),
|
||||
command=self._select_all).pack(side="right", padx=(0, 6))
|
||||
|
||||
ctk.CTkButton(pc, text="+ Add Preset Files",
|
||||
ctk.CTkButton(pc, text=t("dashboard.btn_add"),
|
||||
fg_color="transparent", border_width=1,
|
||||
text_color=("gray10", "gray90"),
|
||||
command=self._add_presets).pack(pady=(0, 14))
|
||||
@@ -109,16 +112,17 @@ class DashboardView(BaseView):
|
||||
pipe = ctk.CTkFrame(parent)
|
||||
pipe.grid(row=0, column=1, sticky="nsew", padx=(8, 0), pady=4)
|
||||
|
||||
ctk.CTkLabel(pipe, text="Pipeline Status",
|
||||
ctk.CTkLabel(pipe, text=t("dashboard.pipeline_title"),
|
||||
font=ctk.CTkFont(size=14, weight="bold")).pack(
|
||||
anchor="w", padx=14, pady=(14, 8))
|
||||
|
||||
self._step_icons: dict[str, ctk.CTkLabel] = {}
|
||||
for key, label in [
|
||||
("parse", "Parse presets"),
|
||||
("compare", "Compare presets"),
|
||||
("download", "Download mods"),
|
||||
("link", "Link to Arma"),
|
||||
self._step_labels: dict[str, ctk.CTkLabel] = {}
|
||||
for key, lbl_key in [
|
||||
("parse", "dashboard.step_parse"),
|
||||
("compare", "dashboard.step_compare"),
|
||||
("download", "dashboard.step_download"),
|
||||
("link", "dashboard.step_link"),
|
||||
]:
|
||||
row = ctk.CTkFrame(pipe, fg_color="transparent")
|
||||
row.pack(fill="x", padx=14, pady=3)
|
||||
@@ -126,8 +130,10 @@ class DashboardView(BaseView):
|
||||
text_color=COLOR_PENDING,
|
||||
font=ctk.CTkFont(size=15))
|
||||
icon.pack(side="left")
|
||||
ctk.CTkLabel(row, text=label, anchor="w").pack(side="left", padx=6)
|
||||
self._step_icons[key] = icon
|
||||
lbl = ctk.CTkLabel(row, text=t(lbl_key), anchor="w")
|
||||
lbl.pack(side="left", padx=6)
|
||||
self._step_icons[key] = icon
|
||||
self._step_labels[key] = lbl
|
||||
|
||||
self._stats_lbl = ctk.CTkLabel(pipe, text="", text_color="gray",
|
||||
font=ctk.CTkFont(size=11),
|
||||
@@ -137,6 +143,22 @@ class DashboardView(BaseView):
|
||||
# ── refresh ───────────────────────────────────────────────────────────────
|
||||
|
||||
def refresh(self) -> None:
|
||||
# Retranslate static widgets that were built once
|
||||
self._title_lbl.configure(text=t("dashboard.title"))
|
||||
self._refresh_btn.configure(text=t("dashboard.refresh_btn"))
|
||||
# Only update run_btn text when not currently running
|
||||
if self._run_btn.cget("state") != "disabled":
|
||||
self._run_btn.configure(text=t("dashboard.run_btn"))
|
||||
# Retranslate step labels
|
||||
for key, lbl_key in [
|
||||
("parse", "dashboard.step_parse"),
|
||||
("compare", "dashboard.step_compare"),
|
||||
("download", "dashboard.step_download"),
|
||||
("link", "dashboard.step_link"),
|
||||
]:
|
||||
if key in self._step_labels:
|
||||
self._step_labels[key].configure(text=t(lbl_key))
|
||||
|
||||
self._rebuild_preset_list()
|
||||
self._update_pipeline_status()
|
||||
|
||||
@@ -148,21 +170,21 @@ class DashboardView(BaseView):
|
||||
cfg = self.app.cfg
|
||||
if not cfg:
|
||||
ctk.CTkLabel(self._preset_scroll,
|
||||
text="No config found. Complete Setup first.",
|
||||
text=t("dashboard.no_config"),
|
||||
text_color=COLOR_WARN).pack(anchor="w")
|
||||
return
|
||||
|
||||
html_dir = cfg.modlist_html
|
||||
if not html_dir.is_dir():
|
||||
ctk.CTkLabel(self._preset_scroll,
|
||||
text=f"Folder missing:\n{html_dir}",
|
||||
text=t("dashboard.folder_missing", path=html_dir),
|
||||
text_color=COLOR_WARN, justify="left").pack(padx=4, pady=8)
|
||||
return
|
||||
|
||||
files = sorted(html_dir.glob("*.html"))
|
||||
if not files:
|
||||
ctk.CTkLabel(self._preset_scroll,
|
||||
text="No preset files yet.\nUse the button below to add them.",
|
||||
text=t("dashboard.no_presets"),
|
||||
text_color="gray", justify="left").pack(padx=4, pady=8)
|
||||
return
|
||||
|
||||
@@ -207,9 +229,9 @@ class DashboardView(BaseView):
|
||||
if cfg.missing_report.exists():
|
||||
rep = json.loads(cfg.missing_report.read_text(encoding="utf-8"))
|
||||
missing = rep.get("missing", 0)
|
||||
stat = f"{total} mods · {shared} shared"
|
||||
stat = t("dashboard.stats", total=total, shared=shared)
|
||||
if missing:
|
||||
stat += f"\n{missing} missing from server"
|
||||
stat += t("dashboard.stats_missing", missing=missing)
|
||||
self._stats_lbl.configure(text=stat)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -236,7 +258,9 @@ class DashboardView(BaseView):
|
||||
COLOR_WARN if n_sel == 1 else
|
||||
COLOR_ERROR)
|
||||
self._sel_count_lbl.configure(
|
||||
text=f"{n_sel} of {n_total} selected", text_color=color)
|
||||
text=t("dashboard.sel_count", n_sel=n_sel, n_total=n_total),
|
||||
text_color=color,
|
||||
)
|
||||
|
||||
def _select_all(self) -> None:
|
||||
for var in self._preset_checks.values():
|
||||
@@ -253,11 +277,11 @@ class DashboardView(BaseView):
|
||||
def _add_presets(self) -> None:
|
||||
cfg = self.app.cfg
|
||||
if not cfg:
|
||||
messagebox.showwarning("Setup required",
|
||||
"Please complete Setup before adding presets.")
|
||||
messagebox.showwarning(t("dashboard.dlg_setup_title"),
|
||||
t("dashboard.dlg_setup_body"))
|
||||
return
|
||||
files = filedialog.askopenfilenames(
|
||||
title="Select Arma 3 Launcher preset files",
|
||||
title=t("dashboard.file_dialog_title"),
|
||||
filetypes=[("HTML Preset", "*.html"), ("All files", "*.*")],
|
||||
)
|
||||
if not files:
|
||||
@@ -280,10 +304,10 @@ class DashboardView(BaseView):
|
||||
def set_pipeline_ui(self, running: bool) -> None:
|
||||
"""Called by the app to reflect pipeline start/end in the UI."""
|
||||
if running:
|
||||
self._run_btn.configure(state="disabled", text="Running…")
|
||||
self._run_btn.configure(state="disabled", text=t("dashboard.running"))
|
||||
self._prog.pack(fill="x", pady=(6, 0))
|
||||
self._prog.start()
|
||||
else:
|
||||
self._run_btn.configure(state="normal", text="▶ Run Full Pipeline")
|
||||
self._run_btn.configure(state="normal", text=t("dashboard.run_btn"))
|
||||
self._prog.stop()
|
||||
self._prog.pack_forget()
|
||||
|
||||
Reference in New Issue
Block a user