Files
arma-modlist-tools/gui/views/mods.py
Tran G. (Revernomad) Khoa 903cd366e2 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
2026-04-08 16:58:41 +07:00

375 lines
15 KiB
Python

from __future__ import annotations
import json
import threading
from pathlib import Path
from typing import TYPE_CHECKING, Optional
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:
from gui.app import ArmaModManagerApp
def _find_folder(group_dir: Path, mod_name: str) -> Optional[Path]:
"""Return the local mod folder path, or None if not downloaded.
Matches in priority order:
1. Exact folder name ``@{mod_name}``
2. Case-insensitive name (handles ``@CBA_A3`` vs ``CBA_A3``)
3. Normalized name — strips non-alphanumeric (handles ``@cba_a3`` vs ``CBA A3``)
"""
if not group_dir.is_dir():
return None
candidate = group_dir / f"@{mod_name}"
if candidate.is_dir():
return candidate
target_lower = mod_name.lower()
target_norm = _normalize_name(mod_name)
for p in group_dir.iterdir():
if not p.is_dir():
continue
if p.name.lstrip("@").lower() == target_lower:
return p
if _normalize_name(p.name) == target_norm:
return p
return None
class ModsView(BaseView):
"""Tabbed mod browser — one tab per comparison group with server status checking."""
def build(self) -> None:
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(2, weight=1)
# ── State ─────────────────────────────────────────────────────────────
self._search_var = ctk.StringVar()
self._checking = False
self._check_btn: Optional[ctk.CTkButton] = None
self._tab_view: Optional[ctk.CTkTabview] = None
# key = "{group}/{folder_name_or_mod_name}"
# value = dict(status_label, update_btn, group, folder_path, mod_dict, name_label)
self._mod_rows: dict[str, dict] = {}
# ── Header ────────────────────────────────────────────────────────────
hdr = ctk.CTkFrame(self, fg_color="transparent")
hdr.grid(row=0, column=0, sticky="ew", padx=24, pady=(20, 6))
hdr.columnconfigure(1, weight=1)
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")
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=t("mods.check_btn"), width=140,
command=self._check_updates,
)
self._check_btn.pack(side="left")
# ── Search ────────────────────────────────────────────────────────────
bar = ctk.CTkFrame(self, fg_color="transparent")
bar.grid(row=1, column=0, sticky="ew", padx=24, pady=(0, 8))
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=t("mods.search_placeholder"),
width=220).pack(side="left")
self._search_var.trace_add("write", lambda *_: self._apply_search())
# ── Tab area placeholder ───────────────────────────────────────────────
self._tab_area = ctk.CTkFrame(self, fg_color="transparent")
self._tab_area.grid(row=2, column=0, sticky="nsew", padx=16, pady=(0, 12))
self._tab_area.grid_columnconfigure(0, weight=1)
self._tab_area.grid_rowconfigure(0, weight=1)
self._msg_label: Optional[ctk.CTkLabel] = None
# =========================================================================
# Public
# =========================================================================
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
if self._tab_view is not None:
self._tab_view.destroy()
self._tab_view = None
if self._msg_label is not None:
self._msg_label.destroy()
self._msg_label = None
cfg = self.app.cfg
if not cfg:
self._show_msg(t("mods.no_config"))
return
if not cfg.comparison.exists():
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(t("mods.read_error", e=e), error=True)
return
# Build ordered group list: shared first, then unique groups
groups: list[tuple[str, dict]] = [("shared", comp["shared"])]
for preset, data in comp["unique"].items():
groups.append((preset, data))
# Precompute link maps per group (one get_link_status call per group)
link_maps: dict[str, dict[str, bool]] = {}
try:
from arma_modlist_tools.linker import get_link_status
for group, _ in groups:
gdir = cfg.downloads / group
if gdir.is_dir():
link_maps[group] = {
e["name"].lower(): e["is_linked"]
for e in get_link_status(gdir, cfg.arma_dir)
}
except Exception:
pass
# Build CTkTabview
tv = ctk.CTkTabview(self._tab_area)
tv.grid(row=0, column=0, sticky="nsew")
self._tab_view = tv
for group, data in groups:
mods = data.get("mods", [])
count = len(mods)
tab_label = f"{group} ({count})"
tv.add(tab_label)
tab_frame = tv.tab(tab_label)
tab_frame.grid_columnconfigure(0, weight=1)
tab_frame.grid_rowconfigure(1, weight=1)
# Column header
col_hdr = ctk.CTkFrame(tab_frame,
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_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=t(lbl_key) if lbl_key else "",
font=ctk.CTkFont(weight="bold"),
anchor="w", width=w or 1).grid(
row=0, column=col,
padx=(10 if col == 0 else 4, 4), pady=5,
sticky="ew" if col == 0 else "")
# Scrollable rows
scroll = ctk.CTkScrollableFrame(tab_frame)
scroll.grid(row=1, column=0, sticky="nsew", padx=4, pady=(0, 4))
self._build_group_rows(scroll, group, mods,
cfg, link_maps.get(group, {}))
if groups:
tv.set(f"{groups[0][0]} ({len(groups[0][1].get('mods', []))})")
# =========================================================================
# Private — layout helpers
# =========================================================================
def _show_msg(self, text: str, error: bool = False) -> None:
self._msg_label = ctk.CTkLabel(
self._tab_area, text=text, justify="left",
text_color="#F44336" if error else "gray",
)
self._msg_label.grid(row=0, column=0, padx=24, pady=24, sticky="nw")
def _build_group_rows(
self,
parent: ctk.CTkScrollableFrame,
group: str,
mods: list[dict],
cfg,
link_map: dict[str, bool],
) -> None:
for i, mod in enumerate(sorted(mods, key=lambda m: m["name"].lower())):
folder_path = _find_folder(cfg.downloads / group, mod["name"])
downloaded = folder_path is not None
linked = (link_map.get(folder_path.name.lower(), False)
if folder_path else False)
bg = ("gray90", "gray17") if i % 2 == 0 else ("gray86", "gray14")
row = ctk.CTkFrame(parent, fg_color=bg, corner_radius=4)
row.pack(fill="x", pady=1)
row.columnconfigure(0, weight=1)
# Mod name
name_lbl = ctk.CTkLabel(row, text=f" {mod['name']}", anchor="w")
name_lbl.grid(row=0, column=0, sticky="ew", padx=4, pady=3)
# Downloaded
ctk.CTkLabel(
row,
text="" if downloaded else "",
text_color=COLOR_OK if downloaded else COLOR_ERROR,
width=80, anchor="w",
).grid(row=0, column=1, padx=4)
# Linked
ctk.CTkLabel(
row,
text="" if linked else ("" if not downloaded else ""),
text_color=COLOR_OK if linked else "gray",
width=80, anchor="w",
).grid(row=0, column=2, padx=4)
# Server status
status_lbl = ctk.CTkLabel(
row, text="", text_color="gray", width=160, anchor="w",
)
status_lbl.grid(row=0, column=3, padx=4)
# Update button (hidden until stale detected)
folder_name = folder_path.name if folder_path else None
update_btn = ctk.CTkButton(
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",
)
update_btn.grid(row=0, column=4, padx=(4, 8), pady=2)
update_btn.grid_remove() # hidden until check finds stale files
# Register in row map
key = f"{group}/{folder_name or mod['name']}"
self._mod_rows[key] = {
"status_label": status_lbl,
"update_btn": update_btn,
"name_label": name_lbl,
"row_frame": row,
"group": group,
"folder_path": folder_path,
"mod_dict": mod,
"mod_name": mod["name"],
}
# =========================================================================
# Private — server update check
# =========================================================================
def _check_updates(self) -> None:
if self._checking:
return
cfg = self.app.cfg
if not cfg:
return
if not self._mod_rows:
return
self._checking = True
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=t("mods.status_checking"), text_color=COLOR_RUNNING)
else:
row["status_label"].configure(text="", text_color="gray")
# Snapshot rows for thread (avoid race with refresh)
rows_snapshot = dict(self._mod_rows)
def worker() -> None:
from arma_modlist_tools.fetcher import (
build_server_index, list_mod_updates, make_session, find_mod_folder,
)
results: dict[str, tuple[str, int]] = {}
try:
idx = build_server_index(cfg.server_url, cfg.server_auth)
session = make_session(cfg.server_auth)
for key, row in rows_snapshot.items():
if not row["folder_path"]:
results[key] = ("not_downloaded", 0)
continue
try:
folder_url = find_mod_folder(row["mod_dict"], idx)
if not folder_url:
results[key] = ("not_on_server", 0)
continue
stale = list_mod_updates(
folder_url, row["folder_path"], session)
results[key] = ("stale" if stale else "ok", len(stale))
except Exception:
results[key] = ("error", 0)
except Exception:
for key in rows_snapshot:
results[key] = ("error", 0)
self.after(0, lambda: self._apply_check_results(results))
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": (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"))
# 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=t("mods.check_btn"), state="normal")
def _update_mod(self, group: str, folder_name: str) -> None:
self.app.navigate_to("Logs")
self.app.run_tool(["update_mods.py", "--group", group, "--mod", folder_name])
# =========================================================================
# Private — search filter
# =========================================================================
def _apply_search(self) -> None:
search = self._search_var.get().lower()
for row in self._mod_rows.values():
frame = row["row_frame"]
if not search or search in row["mod_name"].lower():
frame.pack(fill="x", pady=1)
else:
frame.pack_forget()