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 @@ from typing import TYPE_CHECKING, Optional
import customtkinter as ctk
from gui._constants import COLOR_WARN, PROJECT_ROOT
from gui.locales import t
from gui.views.base import BaseView
if TYPE_CHECKING:
@@ -22,9 +23,9 @@ class ToolsView(BaseView):
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(1, weight=1)
ctk.CTkLabel(self, text="Tools",
font=ctk.CTkFont(size=22, weight="bold")).grid(
row=0, column=0, sticky="w", padx=24, pady=(20, 8))
self._title_lbl = ctk.CTkLabel(self, text=t("tools.title"),
font=ctk.CTkFont(size=22, weight="bold"))
self._title_lbl.grid(row=0, column=0, sticky="w", padx=24, pady=(20, 8))
self._tab_view = ctk.CTkTabview(self)
self._tab_view.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12))
@@ -32,6 +33,9 @@ class ToolsView(BaseView):
# Per-tab group menu references so refresh() can repopulate them all
self._group_menus: list[tuple[ctk.CTkOptionMenu, ctk.StringVar]] = []
# Tab-internal translatable labels (for hot-swap on refresh)
self._translatable: list[tuple[ctk.CTkLabel | ctk.CTkButton | ctk.CTkCheckBox, str]] = []
self._build_check_names_tab()
self._build_update_mods_tab()
self._build_link_mods_tab()
@@ -43,23 +47,32 @@ class ToolsView(BaseView):
# =========================================================================
def refresh(self) -> None:
self._title_lbl.configure(text=t("tools.title"))
# Retranslate registered widgets
for widget, key in self._translatable:
widget.configure(text=t(key))
# Refresh link-mods button label (depends on current command selection)
self._lm_on_change()
groups = self._get_groups()
all_groups = ["All groups"] + groups
all_groups = [t("tools.all_groups")] + groups
# Repopulate generic group menus
for menu, var in self._group_menus:
prev = var.get()
menu.configure(values=all_groups)
if prev not in all_groups:
var.set("All groups")
# Keep selection if still valid, else reset to "All groups"
if prev not in all_groups and prev not in groups:
var.set(t("tools.all_groups"))
# Link Mods group menu (no "All groups")
lm_prev = self._lm_group_var.get()
lm_vals = groups if groups else ["(no groups found)"]
lm_vals = groups if groups else [t("tools.no_groups")]
self._lm_group_menu.configure(values=lm_vals)
if lm_prev not in lm_vals:
self._lm_group_var.set(lm_vals[0])
self._lm_on_change() # re-evaluate button state
# Info labels
self._update_sm_label()
@@ -70,47 +83,49 @@ class ToolsView(BaseView):
# =========================================================================
def _build_check_names_tab(self) -> None:
# Tab name kept in English — used as CTkTabview lookup key
self._tab_view.add("Check Names")
tab = self._tab_view.tab("Check Names")
tab.grid_columnconfigure(0, weight=1)
_desc(tab, row=0,
text="Scan mod folders and compare against the server. "
"Reports naming mismatches (MISMATCH), unrecognised folders "
"(NOT_ON_SERVER), and wrong Steam IDs in meta.cpp (ID_COLLISION).")
desc_lbl = _desc(tab, row=0, text=t("tools.cn_desc"))
self._translatable.append((desc_lbl, "tools.cn_desc"))
# Group
gf = _row(tab, row=1, label="Group:")
self._cn_group_var = ctk.StringVar(value="All groups")
gf, group_lbl = _row(tab, row=1, label=t("tools.label_group"))
self._translatable.append((group_lbl, "tools.label_group"))
self._cn_group_var = ctk.StringVar(value=t("tools.all_groups"))
menu = ctk.CTkOptionMenu(gf, variable=self._cn_group_var,
values=["All groups"], width=200)
values=[t("tools.all_groups")], width=200)
menu.pack(side="left")
self._group_menus.append((menu, self._cn_group_var))
# Checkboxes
cf = _row(tab, row=2, label="Options:")
cf, opts_lbl = _row(tab, row=2, label=t("tools.label_options"))
self._translatable.append((opts_lbl, "tools.label_options"))
self._cn_fix_var = ctk.BooleanVar(value=False)
self._cn_fix_ids_var = ctk.BooleanVar(value=False)
ctk.CTkCheckBox(cf, text="Auto-fix folder name mismatches (--fix)",
variable=self._cn_fix_var,
command=self._cn_on_toggle).pack(side="left", padx=(0, 16))
ctk.CTkCheckBox(cf, text="Auto-fix wrong Steam IDs in meta.cpp (--fix-ids)",
variable=self._cn_fix_ids_var,
command=self._cn_on_toggle).pack(side="left")
cn_fix_chk = ctk.CTkCheckBox(cf, text=t("tools.cn_fix_chk"),
variable=self._cn_fix_var,
command=self._cn_on_toggle)
cn_fix_chk.pack(side="left", padx=(0, 16))
self._translatable.append((cn_fix_chk, "tools.cn_fix_chk"))
cn_ids_chk = ctk.CTkCheckBox(cf, text=t("tools.cn_fix_ids_chk"),
variable=self._cn_fix_ids_var,
command=self._cn_on_toggle)
cn_ids_chk.pack(side="left")
self._translatable.append((cn_ids_chk, "tools.cn_fix_ids_chk"))
# Warning (hidden until checkbox ticked)
self._cn_warn = ctk.CTkLabel(
tab,
text="⚠ --fix renames folders and updates junctions. "
"--fix-ids rewrites meta.cpp files.",
text_color=_WARN_COLOR, anchor="w",
)
# not gridded yet — shown on demand
self._cn_warn = ctk.CTkLabel(tab, text=t("tools.cn_warn"),
text_color=_WARN_COLOR, anchor="w")
self._translatable.append((self._cn_warn, "tools.cn_warn"))
# Run button
ctk.CTkButton(tab, text="Run Check Names", width=180,
command=self._cn_run).grid(
row=10, column=0, padx=24, pady=(16, 24), sticky="e")
cn_btn = ctk.CTkButton(tab, text=t("tools.cn_btn"), width=180,
command=self._cn_run)
cn_btn.grid(row=10, column=0, padx=24, pady=(16, 24), sticky="e")
self._translatable.append((cn_btn, "tools.cn_btn"))
def _cn_on_toggle(self) -> None:
if self._cn_fix_var.get() or self._cn_fix_ids_var.get():
@@ -121,7 +136,7 @@ class ToolsView(BaseView):
def _cn_run(self) -> None:
args = ["check_names.py"]
g = self._cn_group_var.get()
if g != "All groups":
if g != t("tools.all_groups"):
args += ["--group", g]
if self._cn_fix_var.get():
args.append("--fix")
@@ -136,54 +151,56 @@ class ToolsView(BaseView):
tab = self._tab_view.tab("Update Mods")
tab.grid_columnconfigure(0, weight=1)
_desc(tab, row=0,
text="Re-download mod files whose size on the server differs from "
"your local copy. Use --force to re-download everything "
"regardless of size.")
desc_lbl = _desc(tab, row=0, text=t("tools.um_desc"))
self._translatable.append((desc_lbl, "tools.um_desc"))
# Group
gf = _row(tab, row=1, label="Group:")
self._um_group_var = ctk.StringVar(value="All groups")
gf, group_lbl = _row(tab, row=1, label=t("tools.label_group"))
self._translatable.append((group_lbl, "tools.label_group"))
self._um_group_var = ctk.StringVar(value=t("tools.all_groups"))
um_menu = ctk.CTkOptionMenu(
gf, variable=self._um_group_var, values=["All groups"], width=200,
gf, variable=self._um_group_var, values=[t("tools.all_groups")], width=200,
command=self._um_on_group_change,
)
um_menu.pack(side="left")
self._group_menus.append((um_menu, self._um_group_var))
# Mod name (enabled only when a specific group is selected)
mf = _row(tab, row=2, label="Mod folder:")
mf, mod_lbl = _row(tab, row=2, label=t("tools.um_mod_label"))
self._translatable.append((mod_lbl, "tools.um_mod_label"))
self._um_mod_entry = ctk.CTkEntry(
mf, placeholder_text="Optional — e.g. @ace", width=220,
mf, placeholder_text=t("tools.um_mod_placeholder"), width=220,
state="disabled",
)
self._um_mod_entry.pack(side="left")
ctk.CTkLabel(mf, text="(only when a specific group is selected)",
text_color="gray").pack(side="left", padx=8)
um_hint = ctk.CTkLabel(mf, text=t("tools.um_mod_hint"), text_color="gray")
um_hint.pack(side="left", padx=8)
self._translatable.append((um_hint, "tools.um_mod_hint"))
# Force checkbox
ff = _row(tab, row=3, label="Options:")
ff, opts_lbl = _row(tab, row=3, label=t("tools.label_options"))
self._translatable.append((opts_lbl, "tools.label_options"))
self._um_force_var = ctk.BooleanVar(value=False)
ctk.CTkCheckBox(
ff, text="Force re-download all files (--force)",
um_force_chk = ctk.CTkCheckBox(
ff, text=t("tools.um_force_chk"),
variable=self._um_force_var,
command=self._um_on_toggle,
).pack(side="left")
)
um_force_chk.pack(side="left")
self._translatable.append((um_force_chk, "tools.um_force_chk"))
# Warning
self._um_warn = ctk.CTkLabel(
tab,
text="⚠ --force re-downloads every file regardless of size. "
"This may transfer a large amount of data.",
text_color=_WARN_COLOR, anchor="w",
)
self._um_warn = ctk.CTkLabel(tab, text=t("tools.um_warn"),
text_color=_WARN_COLOR, anchor="w")
self._translatable.append((self._um_warn, "tools.um_warn"))
ctk.CTkButton(tab, text="Run Update", width=180,
command=self._um_run).grid(
row=10, column=0, padx=24, pady=(16, 24), sticky="e")
um_btn = ctk.CTkButton(tab, text=t("tools.um_btn"), width=180,
command=self._um_run)
um_btn.grid(row=10, column=0, padx=24, pady=(16, 24), sticky="e")
self._translatable.append((um_btn, "tools.um_btn"))
def _um_on_group_change(self, _: str) -> None:
is_specific = self._um_group_var.get() != "All groups"
is_specific = self._um_group_var.get() != t("tools.all_groups")
self._um_mod_entry.configure(state="normal" if is_specific else "disabled")
if not is_specific:
self._um_mod_entry.delete(0, "end")
@@ -197,7 +214,7 @@ class ToolsView(BaseView):
def _um_run(self) -> None:
args = ["update_mods.py"]
g = self._um_group_var.get()
if g != "All groups":
if g != t("tools.all_groups"):
args += ["--group", g]
mod = self._um_mod_entry.get().strip()
if mod:
@@ -213,15 +230,12 @@ class ToolsView(BaseView):
tab = self._tab_view.tab("Link Mods")
tab.grid_columnconfigure(0, weight=1)
_desc(tab, row=0,
text="Manage junction/symlink links between your downloads folder "
"and the Arma 3 directory.\n"
"Status — show what's linked. "
"Link — create missing junctions. "
"Unlink — remove junctions (mod files are NOT deleted).")
desc_lbl = _desc(tab, row=0, text=t("tools.lm_desc"))
self._translatable.append((desc_lbl, "tools.lm_desc"))
# Command selector
cf = _row(tab, row=1, label="Command:")
# Command selector — values kept in English (drive internal logic)
cf, cmd_lbl = _row(tab, row=1, label=t("tools.label_command"))
self._translatable.append((cmd_lbl, "tools.label_command"))
self._lm_cmd_var = ctk.StringVar(value="Status")
ctk.CTkSegmentedButton(
cf,
@@ -231,33 +245,36 @@ class ToolsView(BaseView):
).pack(side="left")
# Group (required — no "All groups")
gf = _row(tab, row=2, label="Group:")
gf, group_lbl = _row(tab, row=2, label=t("tools.label_group"))
self._translatable.append((group_lbl, "tools.label_group"))
self._lm_group_var = ctk.StringVar(value="")
self._lm_group_menu = ctk.CTkOptionMenu(
gf, variable=self._lm_group_var,
values=["(no groups found)"], width=200,
values=[t("tools.no_groups")], width=200,
command=lambda _: self._lm_on_change(),
)
self._lm_group_menu.pack(side="left")
# Warning (shown for Unlink)
self._lm_warn = ctk.CTkLabel(
tab,
text="⚠ Unlink removes junction links from the Arma 3 directory. "
"Mod files in downloads/ are NOT deleted.",
text_color=_WARN_COLOR, anchor="w",
)
self._lm_warn = ctk.CTkLabel(tab, text=t("tools.lm_warn"),
text_color=_WARN_COLOR, anchor="w")
self._translatable.append((self._lm_warn, "tools.lm_warn"))
# Run button (label changes with command)
self._lm_run_btn = ctk.CTkButton(
tab, text="Show Status", width=180,
tab, text=t("tools.lm_show_status"), width=180,
command=self._lm_run,
)
self._lm_run_btn.grid(row=10, column=0, padx=24, pady=(16, 24), sticky="e")
def _lm_on_change(self, _: str = "") -> None:
cmd = self._lm_cmd_var.get()
labels = {"Status": "Show Status", "Link": "Create Links", "Unlink": "Remove Links"}
# Keys are English segmented-button values; values are translated labels
labels = {
"Status": t("tools.lm_show_status"),
"Link": t("tools.lm_create_links"),
"Unlink": t("tools.lm_remove_links"),
}
self._lm_run_btn.configure(text=labels.get(cmd, cmd))
if cmd == "Unlink":
@@ -269,19 +286,17 @@ class ToolsView(BaseView):
cmd = self._lm_cmd_var.get().lower()
group = self._lm_group_var.get()
if not group or group == "(no groups found)":
messagebox.showwarning("No group selected",
"Please select a group from the dropdown.")
if not group or group == t("tools.no_groups"):
messagebox.showwarning(t("tools.lm_no_group_title"),
t("tools.lm_no_group_body"))
return
args = ["link_mods.py", cmd, "--group", group]
if cmd == "unlink":
confirmed = messagebox.askyesno(
"Confirm Unlink",
f"Remove junction links for group '{group}'?\n\n"
"This removes links from the Arma 3 directory but does NOT delete "
"mod files in downloads/.",
t("tools.lm_confirm_title"),
t("tools.lm_confirm_body", group=group),
)
if not confirmed:
return
@@ -296,17 +311,16 @@ class ToolsView(BaseView):
tab = self._tab_view.tab("Sync Missing")
tab.grid_columnconfigure(0, weight=1)
_desc(tab, row=0,
text="Retry downloading mods that were missing from the server "
"when you last ran the pipeline. "
"Checks the server again and downloads any that have since appeared.")
desc_lbl = _desc(tab, row=0, text=t("tools.sm_desc"))
self._translatable.append((desc_lbl, "tools.sm_desc"))
self._sm_info = ctk.CTkLabel(tab, text="", text_color="gray", anchor="w")
self._sm_info.grid(row=1, column=0, padx=24, pady=(4, 0), sticky="w")
ctk.CTkButton(tab, text="Run Sync Missing", width=180,
command=lambda: self._launch(["sync_missing.py"])).grid(
row=10, column=0, padx=24, pady=(16, 24), sticky="e")
sm_btn = ctk.CTkButton(tab, text=t("tools.sm_btn"), width=180,
command=lambda: self._launch(["sync_missing.py"]))
sm_btn.grid(row=10, column=0, padx=24, pady=(16, 24), sticky="e")
self._translatable.append((sm_btn, "tools.sm_btn"))
def _update_sm_label(self) -> None:
cfg = self.app.cfg
@@ -321,13 +335,11 @@ class ToolsView(BaseView):
try:
data = json.loads(report_path.read_text(encoding="utf-8"))
count = data.get("missing", len(data.get("missing_mods", [])))
self._sm_info.configure(
text=f"{count} mod(s) currently listed as missing.")
self._sm_info.configure(text=t("tools.sm_count", count=count))
return
except Exception:
pass
self._sm_info.configure(
text="No missing_report.json found — run the pipeline first.")
self._sm_info.configure(text=t("tools.sm_no_report"))
# -------------------------------------------------------------------------
@@ -336,17 +348,16 @@ class ToolsView(BaseView):
tab = self._tab_view.tab("Report Missing")
tab.grid_columnconfigure(0, weight=1)
_desc(tab, row=0,
text="Check which mods from comparison.json are absent from the "
"file server. Saves missing_report.json so you can track what "
"still needs to be added to the server.")
desc_lbl = _desc(tab, row=0, text=t("tools.rm_desc"))
self._translatable.append((desc_lbl, "tools.rm_desc"))
self._rm_info = ctk.CTkLabel(tab, text="", text_color="gray", anchor="w")
self._rm_info.grid(row=1, column=0, padx=24, pady=(4, 0), sticky="w")
ctk.CTkButton(tab, text="Generate Report", width=180,
command=lambda: self._launch(["report_missing.py"])).grid(
row=10, column=0, padx=24, pady=(16, 24), sticky="e")
rm_btn = ctk.CTkButton(tab, text=t("tools.rm_btn"), width=180,
command=lambda: self._launch(["report_missing.py"]))
rm_btn.grid(row=10, column=0, padx=24, pady=(16, 24), sticky="e")
self._translatable.append((rm_btn, "tools.rm_btn"))
def _update_rm_label(self) -> None:
cfg = self.app.cfg
@@ -361,11 +372,11 @@ class ToolsView(BaseView):
try:
data = json.loads(report_path.read_text(encoding="utf-8"))
ts = data.get("generated_at", "unknown")
self._rm_info.configure(text=f"Last generated: {ts}")
self._rm_info.configure(text=t("tools.rm_last", ts=ts))
return
except Exception:
pass
self._rm_info.configure(text="No report yet.")
self._rm_info.configure(text=t("tools.rm_none"))
# =========================================================================
# Private — helpers
@@ -400,10 +411,14 @@ def _desc(parent, row: int, text: str) -> ctk.CTkLabel:
return lbl
def _row(parent, row: int, label: str) -> ctk.CTkFrame:
"""A label + horizontal frame for a settings row."""
ctk.CTkLabel(parent, text=label, anchor="w", width=110).grid(
row=row, column=0, padx=(24, 0), pady=6, sticky="w")
def _row(parent, row: int, label: str) -> tuple[ctk.CTkFrame, ctk.CTkLabel]:
"""A label + horizontal frame for a settings row.
Returns (content_frame, label_widget) so callers can register the label
for later retranslation.
"""
lbl = ctk.CTkLabel(parent, text=label, anchor="w", width=110)
lbl.grid(row=row, column=0, padx=(24, 0), pady=6, sticky="w")
f = ctk.CTkFrame(parent, fg_color="transparent")
f.grid(row=row, column=0, padx=(140, 24), pady=6, sticky="w")
return f
return f, lbl