Files
arma-modlist-tools/gui/views/dashboard.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

314 lines
13 KiB
Python

from __future__ import annotations
import json
import shutil
from pathlib import Path
from tkinter import filedialog, messagebox
from typing import TYPE_CHECKING
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:
from gui.app import ArmaModManagerApp
class DashboardView(BaseView):
"""
Preset file selector + pipeline status + Run button.
Dynamic regions (rebuilt in refresh):
- _preset_scroll — checkbox list of .html files
- _step_icons — ✓/○ status for each pipeline step
- _stats_lbl — mod counts from comparison.json
Static regions (built once):
- _run_btn / _prog — pipeline controls, state changed by set_pipeline_ui()
- _sel_count_lbl — "X of Y selected" label, updated by _on_toggle()
"""
def build(self) -> None:
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(1, weight=1)
# ── Header ────────────────────────────────────────────────────────────
hdr = ctk.CTkFrame(self, fg_color="transparent")
hdr.grid(row=0, column=0, sticky="ew", padx=24, pady=(20, 10))
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")
cards.grid(row=1, column=0, sticky="nsew", padx=24)
cards.columnconfigure(0, weight=3)
cards.columnconfigure(1, weight=2)
self._build_preset_card(cards)
self._build_pipeline_card(cards)
# ── Run button area ───────────────────────────────────────────────────
run_area = ctk.CTkFrame(self, fg_color="transparent")
run_area.grid(row=2, column=0, sticky="ew", padx=24, pady=16)
self._run_btn = ctk.CTkButton(
run_area,
text=t("dashboard.run_btn"),
font=ctk.CTkFont(size=15, weight="bold"),
height=46,
command=self._on_run,
)
self._run_btn.pack(fill="x")
self._prog = ctk.CTkProgressBar(run_area, mode="indeterminate")
# ── Preset card ───────────────────────────────────────────────────────────
def _build_preset_card(self, parent: ctk.CTkFrame) -> None:
pc = ctk.CTkFrame(parent)
pc.grid(row=0, column=0, sticky="nsew", padx=(0, 8), pady=4)
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=t("dashboard.preset_card_desc"),
text_color="gray", font=ctk.CTkFont(size=11)).pack(
anchor="w", padx=14)
self._preset_scroll = ctk.CTkScrollableFrame(pc, height=150)
self._preset_scroll.pack(fill="x", padx=14, pady=(10, 4))
# Selection controls
sel_row = ctk.CTkFrame(pc, fg_color="transparent")
sel_row.pack(fill="x", padx=14, pady=(0, 8))
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=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=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=t("dashboard.btn_add"),
fg_color="transparent", border_width=1,
text_color=("gray10", "gray90"),
command=self._add_presets).pack(pady=(0, 14))
# ── Pipeline card ─────────────────────────────────────────────────────────
def _build_pipeline_card(self, parent: ctk.CTkFrame) -> None:
pipe = ctk.CTkFrame(parent)
pipe.grid(row=0, column=1, sticky="nsew", padx=(8, 0), pady=4)
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] = {}
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)
icon = ctk.CTkLabel(row, text="", width=22,
text_color=COLOR_PENDING,
font=ctk.CTkFont(size=15))
icon.pack(side="left")
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),
wraplength=200, justify="left")
self._stats_lbl.pack(anchor="w", padx=14, pady=(10, 14))
# ── 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()
def _rebuild_preset_list(self) -> None:
for w in self._preset_scroll.winfo_children():
w.destroy()
self._preset_checks: dict[str, ctk.BooleanVar] = {}
cfg = self.app.cfg
if not cfg:
ctk.CTkLabel(self._preset_scroll,
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=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=t("dashboard.no_presets"),
text_color="gray", justify="left").pack(padx=4, pady=8)
return
saved = self.app.load_selection()
for fp in files:
var = ctk.BooleanVar(value=fp.name in saved)
self._preset_checks[fp.name] = var
ctk.CTkCheckBox(
self._preset_scroll,
text=fp.name,
variable=var,
command=self._on_toggle,
).pack(anchor="w", padx=4, pady=3)
self._on_toggle() # initialise count label
def _update_pipeline_status(self) -> None:
cfg = self.app.cfg
if not cfg:
for key in self._step_icons:
self._set_step(key, False)
return
selected_count = sum(1 for v in self._preset_checks.values() if v.get())
downloads_ok = (
cfg.downloads.is_dir()
and any(True for _ in cfg.downloads.rglob("@*"))
)
self._set_step("parse", selected_count >= 2)
self._set_step("compare", cfg.comparison.exists())
self._set_step("download", downloads_ok)
self._set_step("link", cfg.arma_dir.is_dir())
if cfg.comparison.exists():
try:
comp = json.loads(cfg.comparison.read_text(encoding="utf-8"))
total = (comp["shared"]["mod_count"]
+ sum(v["mod_count"] for v in comp["unique"].values()))
shared = comp["shared"]["mod_count"]
missing = 0
if cfg.missing_report.exists():
rep = json.loads(cfg.missing_report.read_text(encoding="utf-8"))
missing = rep.get("missing", 0)
stat = t("dashboard.stats", total=total, shared=shared)
if missing:
stat += t("dashboard.stats_missing", missing=missing)
self._stats_lbl.configure(text=stat)
except Exception:
pass
def _set_step(self, key: str, done: bool) -> None:
icon = self._step_icons.get(key)
if icon:
icon.configure(
text="" if done else "",
text_color=COLOR_OK if done else COLOR_PENDING,
)
# ── Selection helpers ─────────────────────────────────────────────────────
def get_selected_names(self) -> set[str]:
return {name for name, var in self._preset_checks.items() if var.get()}
def _on_toggle(self) -> None:
selected = self.get_selected_names()
self.app.save_selection(selected)
n_sel = len(selected)
n_total = len(self._preset_checks)
color = (COLOR_OK if n_sel >= 2 else
COLOR_WARN if n_sel == 1 else
COLOR_ERROR)
self._sel_count_lbl.configure(
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():
var.set(True)
self._on_toggle()
def _select_none(self) -> None:
for var in self._preset_checks.values():
var.set(False)
self._on_toggle()
# ── Add presets ───────────────────────────────────────────────────────────
def _add_presets(self) -> None:
cfg = self.app.cfg
if not cfg:
messagebox.showwarning(t("dashboard.dlg_setup_title"),
t("dashboard.dlg_setup_body"))
return
files = filedialog.askopenfilenames(
title=t("dashboard.file_dialog_title"),
filetypes=[("HTML Preset", "*.html"), ("All files", "*.*")],
)
if not files:
return
dest = cfg.modlist_html
dest.mkdir(parents=True, exist_ok=True)
current = self.app.load_selection()
for fp in files:
name = Path(fp).name
shutil.copy2(fp, dest / name)
current.add(name)
self.app.save_selection(current)
self.refresh()
# ── Run button ────────────────────────────────────────────────────────────
def _on_run(self) -> None:
self.app.run_pipeline(self.get_selected_names())
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=t("dashboard.running"))
self._prog.pack(fill="x", pady=(6, 0))
self._prog.start()
else:
self._run_btn.configure(state="normal", text=t("dashboard.run_btn"))
self._prog.stop()
self._prog.pack_forget()