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

@@ -15,6 +15,7 @@ from gui._constants import (
SIDEBAR_W, APP_TITLE, PROJECT_ROOT, SELECTION_FILE,
)
from gui._io import _QueueWriter
from gui.locales import t
from gui.wizard import SetupWizard
from gui.views.base import BaseView
@@ -62,6 +63,7 @@ class ArmaModManagerApp(ctk.CTk):
else:
self._load_config()
self._apply_startup_language()
self._build_layout()
self._poll_log()
@@ -106,21 +108,31 @@ class ArmaModManagerApp(ctk.CTk):
"""Thread-safe: enqueue text for the Logs panel."""
self._log_q.put(text)
def switch_language(self, lang: str) -> None:
"""Switch the UI language and refresh all cached views."""
from gui import locales
locales.set_language(lang)
self._save_language_pref(lang)
self._rebuild_nav_labels()
for view in self._view_cache.values():
view.refresh()
if self._active_name:
self.navigate_to(self._active_name)
def run_pipeline(self, selected_names: set[str]) -> None:
"""Start the background pipeline for the given preset filenames."""
if self._pipeline_running:
return
if len(selected_names) < 2:
messagebox.showwarning(
"Not enough presets selected",
"Please select at least 2 preset files to compare.\n\n"
"Use the checkboxes on the Dashboard to choose which presets to use.",
t("app.dlg_presets_title"),
t("app.dlg_presets_body"),
)
return
cfg = self._cfg
if not cfg:
messagebox.showwarning("Setup required", "Please complete Setup first.")
messagebox.showwarning(t("app.dlg_setup_title"), t("app.dlg_setup_body"))
return
self._pipeline_running = True
@@ -137,7 +149,7 @@ class ArmaModManagerApp(ctk.CTk):
from arma_modlist_tools.compare import compare_presets
# Step 1 — Parse selected presets
_hdr("Step 1 / 4", "Parse presets")
_hdr("Step 1 / 4", t("pipeline.step1_name"))
cfg.modlist_json.mkdir(exist_ok=True)
presets = []
for fp in sorted(cfg.modlist_html.glob("*.html")):
@@ -154,7 +166,7 @@ class ArmaModManagerApp(ctk.CTk):
presets.append(preset)
# Step 2 — Compare
_hdr("Step 2 / 4", "Compare presets")
_hdr("Step 2 / 4", t("pipeline.step2_name"))
result = compare_presets(*presets)
cfg.comparison.write_text(
json.dumps(result, indent=2, ensure_ascii=False),
@@ -166,11 +178,11 @@ class ArmaModManagerApp(ctk.CTk):
f"Unique: {total_unique}")
# Step 3 — Fetch
_hdr("Step 3 / 4", "Download mods")
_hdr("Step 3 / 4", t("pipeline.step3_name"))
step_fetch(cfg)
# Step 4 — Link
_hdr("Step 4 / 4", "Link mods")
_hdr("Step 4 / 4", t("pipeline.step4_name"))
groups = (
sorted(p.name for p in cfg.downloads.iterdir() if p.is_dir())
if cfg.downloads.is_dir() else []
@@ -212,11 +224,13 @@ class ArmaModManagerApp(ctk.CTk):
self.post_log(line)
proc.wait()
ok = proc.returncode == 0
self.post_log(
f"\n{'✓ Done' if ok else f'✗ Exited with code {proc.returncode}'}.\n"
done_msg = (
t("app.tool_done") if ok
else t("app.tool_exit_code", code=proc.returncode)
)
self.post_log(f"\n{done_msg}.\n")
except Exception as e:
self.post_log(f"\n✗ Failed to start {script}: {e}\n")
self.post_log(f"\n{t('app.tool_failed', script=script, e=e)}\n")
threading.Thread(target=worker, daemon=True).start()
@@ -241,6 +255,38 @@ class ArmaModManagerApp(ctk.CTk):
def open_wizard(self) -> None:
SetupWizard(self, on_complete=self._after_wizard)
# =========================================================================
# Private — language
# =========================================================================
def _apply_startup_language(self) -> None:
"""Read language preference from config.json and activate it."""
from gui import locales
lang = "en"
cfg_path = PROJECT_ROOT / "config.json"
if cfg_path.exists():
try:
raw = json.loads(cfg_path.read_text(encoding="utf-8"))
lang = raw.get("ui", {}).get("language", "en")
except Exception:
pass
locales.set_language(lang)
def _save_language_pref(self, lang: str) -> None:
"""Persist language preference into the 'ui' key of config.json."""
cfg_path = PROJECT_ROOT / "config.json"
try:
raw = json.loads(cfg_path.read_text(encoding="utf-8"))
raw.setdefault("ui", {})["language"] = lang
cfg_path.write_text(json.dumps(raw, indent=2), encoding="utf-8")
except Exception:
pass # non-fatal — language preference simply resets next run
def _rebuild_nav_labels(self) -> None:
"""Retranslate the sidebar navigation button labels."""
for name, btn in self._nav_btns.items():
btn.configure(text=t(f"nav.{name.lower()}"))
# =========================================================================
# Private — layout
# =========================================================================
@@ -264,7 +310,7 @@ class ArmaModManagerApp(ctk.CTk):
self._nav_btns: dict[str, ctk.CTkButton] = {}
for i, name in enumerate(_VIEW_NAMES, start=1):
b = ctk.CTkButton(
sb, text=name, width=SIDEBAR_W - 24,
sb, text=t(f"nav.{name.lower()}"), width=SIDEBAR_W - 24,
anchor="w", command=lambda n=name: self.navigate_to(n),
fg_color="transparent",
hover_color=("gray80", "gray30"),

470
gui/locales.py Normal file
View File

@@ -0,0 +1,470 @@
from __future__ import annotations
# ---------------------------------------------------------------------------
# Localization module — English + Vietnamese
#
# Usage:
# from gui.locales import t
# label_text = t("dashboard.title")
# label_text = t("dashboard.sel_count", n_sel=2, n_total=5)
#
# Tab names in ToolsView are NOT translated — they double as CTkTabview
# lookup keys and cannot be renamed after creation.
# Segmented-button values in ToolsView ("Status", "Link", "Unlink") are also
# kept in English because they drive internal logic in _lm_on_change().
# ---------------------------------------------------------------------------
_LANG: str = "en"
_EN: dict[str, str] = {
# ── App / sidebar ────────────────────────────────────────────────────────
"app.title": "Arma Mod Manager",
"nav.dashboard": "Dashboard",
"nav.mods": "Mods",
"nav.tools": "Tools",
"nav.logs": "Logs",
"nav.settings": "Settings",
# ── Pipeline step headers (printed to log) ───────────────────────────────
"pipeline.step1_name": "Parse presets",
"pipeline.step2_name": "Compare presets",
"pipeline.step3_name": "Download mods",
"pipeline.step4_name": "Link mods",
# ── app.py dialogs ────────────────────────────────────────────────────────
"app.dlg_presets_title": "Not enough presets selected",
"app.dlg_presets_body": (
"Please select at least 2 preset files to compare.\n\n"
"Use the checkboxes on the Dashboard to choose which presets to use."
),
"app.dlg_setup_title": "Setup required",
"app.dlg_setup_body": "Please complete Setup first.",
# ── run_tool status lines (go to log) ────────────────────────────────────
"app.tool_done": "✓ Done",
"app.tool_exit_code": "✗ Exited with code {code}",
"app.tool_failed": "✗ Failed to start {script}: {e}",
# ── Dashboard ────────────────────────────────────────────────────────────
"dashboard.title": "Dashboard",
"dashboard.refresh_btn": "⟳ Refresh",
"dashboard.preset_card_title": "Preset Files",
"dashboard.preset_card_desc": "HTML exports from Arma 3 Launcher → Mods → Export to HTML",
"dashboard.sel_count": "{n_sel} of {n_total} selected",
"dashboard.btn_none": "None",
"dashboard.btn_all": "All",
"dashboard.btn_add": "+ Add Preset Files",
"dashboard.no_config": "No config found. Complete Setup first.",
"dashboard.folder_missing": "Folder missing:\n{path}",
"dashboard.no_presets": "No preset files yet.\nUse the button below to add them.",
"dashboard.file_dialog_title": "Select Arma 3 Launcher preset files",
"dashboard.dlg_setup_title": "Setup required",
"dashboard.dlg_setup_body": "Please complete Setup before adding presets.",
"dashboard.pipeline_title": "Pipeline Status",
"dashboard.step_parse": "Parse presets",
"dashboard.step_compare": "Compare presets",
"dashboard.step_download": "Download mods",
"dashboard.step_link": "Link to Arma",
"dashboard.stats": "{total} mods · {shared} shared",
"dashboard.stats_missing": "\n{missing} missing from server",
"dashboard.run_btn": "▶ Run Full Pipeline",
"dashboard.running": "Running…",
# ── Mods ─────────────────────────────────────────────────────────────────
"mods.title": "Mods",
"mods.refresh_btn": "⟳ Refresh",
"mods.check_btn": "☁ Check Updates",
"mods.check_btn_checking": "Checking…",
"mods.search_label": "Search:",
"mods.search_placeholder": "Filter mods in active tab…",
"mods.no_config": "No config found. Complete Setup first.",
"mods.no_data": (
"No mod data yet.\n"
"Go to Dashboard, select your presets, then click Run Full Pipeline."
),
"mods.read_error": "Error reading comparison.json: {e}",
"mods.col_name": "Mod Name",
"mods.col_downloaded": "Downloaded",
"mods.col_linked": "Linked",
"mods.col_server": "Server Status",
"mods.status_ok": "✓ Up to date",
"mods.status_stale": "{n} outdated",
"mods.status_not_downloaded": "",
"mods.status_not_on_server": "Not on server",
"mods.status_error": "✗ Error",
"mods.status_checking": "Checking…",
"mods.update_btn": "Update",
# ── Logs ─────────────────────────────────────────────────────────────────
"logs.title": "Logs",
"logs.copy_btn": "Copy",
"logs.clear_btn": "Clear",
# ── Settings ─────────────────────────────────────────────────────────────
"settings.title": "Settings",
"settings.server_card_title": "Server & Path Configuration",
"settings.server_card_desc": (
"Re-run the setup wizard to change your server URL, "
"credentials, or Arma folder."
),
"settings.wizard_btn": "Open Setup Wizard",
"settings.appearance_title": "Appearance",
"settings.language_title": "Language",
"settings.config_title": "Current Configuration",
# ── Wizard ───────────────────────────────────────────────────────────────
"wizard.title": "Setup — Arma Mod Manager",
"wizard.step1_title": "Step 1 of 3 — Server Connection",
"wizard.step1_desc": "Enter the details for your Caddy mod server.",
"wizard.label_url": "Server URL",
"wizard.label_user": "Username",
"wizard.label_pw": "Password",
"wizard.btn_next": "Next →",
"wizard.btn_test": "Test Connection",
"wizard.testing": "Testing…",
"wizard.connected": "✓ Connected",
"wizard.http_error": "✗ HTTP {code}",
"wizard.conn_error": "{e}",
"wizard.step2_title": "Step 2 of 3 — Arma 3 Server Folder",
"wizard.step2_desc": (
"Point to your Arma 3 Server installation. "
"Links (junctions) will be created here."
),
"wizard.label_arma": "Arma 3 Server folder",
"wizard.btn_browse": "Browse",
"wizard.step2_hint": (
"All other folders (downloads, presets) will be created "
"automatically next to this tool."
),
"wizard.btn_back": "← Back",
"wizard.step3_title": "Step 3 of 3 — Review & Save",
"wizard.step3_desc": "Check your settings, then click Save.",
"wizard.not_set": "(not set)",
"wizard.btn_save": "Save & Open",
"wizard.browse_title": "Select Arma 3 Server folder",
# ── Tools — shared ────────────────────────────────────────────────────────
"tools.title": "Tools",
"tools.label_group": "Group:",
"tools.label_options": "Options:",
"tools.label_command": "Command:",
"tools.all_groups": "All groups",
"tools.no_groups": "(no groups found)",
# ── Tools — Check Names ──────────────────────────────────────────────────
"tools.cn_desc": (
"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)."
),
"tools.cn_fix_chk": "Auto-fix folder name mismatches (--fix)",
"tools.cn_fix_ids_chk": "Auto-fix wrong Steam IDs in meta.cpp (--fix-ids)",
"tools.cn_warn": (
"⚠ --fix renames folders and updates junctions. "
"--fix-ids rewrites meta.cpp files."
),
"tools.cn_btn": "Run Check Names",
# ── Tools — Update Mods ──────────────────────────────────────────────────
"tools.um_desc": (
"Re-download mod files whose size on the server differs from "
"your local copy. Use --force to re-download everything "
"regardless of size."
),
"tools.um_mod_label": "Mod folder:",
"tools.um_mod_placeholder": "Optional — e.g. @ace",
"tools.um_mod_hint": "(only when a specific group is selected)",
"tools.um_force_chk": "Force re-download all files (--force)",
"tools.um_warn": (
"⚠ --force re-downloads every file regardless of size. "
"This may transfer a large amount of data."
),
"tools.um_btn": "Run Update",
# ── Tools — Link Mods ────────────────────────────────────────────────────
"tools.lm_desc": (
"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)."
),
"tools.lm_warn": (
"⚠ Unlink removes junction links from the Arma 3 directory. "
"Mod files in downloads/ are NOT deleted."
),
"tools.lm_show_status": "Show Status",
"tools.lm_create_links": "Create Links",
"tools.lm_remove_links": "Remove Links",
"tools.lm_no_group_title": "No group selected",
"tools.lm_no_group_body": "Please select a group from the dropdown.",
"tools.lm_confirm_title": "Confirm Unlink",
"tools.lm_confirm_body": (
"Remove junction links for group '{group}'?\n\n"
"This removes links from the Arma 3 directory but does NOT delete "
"mod files in downloads/."
),
# ── Tools — Sync Missing ─────────────────────────────────────────────────
"tools.sm_desc": (
"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."
),
"tools.sm_btn": "Run Sync Missing",
"tools.sm_count": "{count} mod(s) currently listed as missing.",
"tools.sm_no_report": "No missing_report.json found — run the pipeline first.",
# ── Tools — Report Missing ───────────────────────────────────────────────
"tools.rm_desc": (
"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."
),
"tools.rm_btn": "Generate Report",
"tools.rm_last": "Last generated: {ts}",
"tools.rm_none": "No report yet.",
}
_VI: dict[str, str] = {
# ── App / sidebar ────────────────────────────────────────────────────────
"app.title": "Arma Mod Manager",
"nav.dashboard": "Tổng quan",
"nav.mods": "Danh sách Mod",
"nav.tools": "Công cụ",
"nav.logs": "Nhật ký",
"nav.settings": "Cài đặt",
# ── Pipeline step headers ────────────────────────────────────────────────
"pipeline.step1_name": "Phân tích preset",
"pipeline.step2_name": "So sánh preset",
"pipeline.step3_name": "Tải mod",
"pipeline.step4_name": "Liên kết mod",
# ── app.py dialogs ────────────────────────────────────────────────────────
"app.dlg_presets_title": "Chưa chọn đủ preset",
"app.dlg_presets_body": (
"Vui lòng chọn ít nhất 2 tệp preset để so sánh.\n\n"
"Sử dụng các ô tick ở Tổng quan để chọn preset."
),
"app.dlg_setup_title": "Cần thiết lập",
"app.dlg_setup_body": "Vui lòng hoàn thành thiết lập trước.",
# ── run_tool status lines ────────────────────────────────────────────────
"app.tool_done": "✓ Hoàn thành",
"app.tool_exit_code": "✗ Thoát với mã lỗi {code}",
"app.tool_failed": "✗ Không thể khởi động {script}: {e}",
# ── Dashboard ────────────────────────────────────────────────────────────
"dashboard.title": "Tổng quan",
"dashboard.refresh_btn": "⟳ Làm mới",
"dashboard.preset_card_title": "Tệp Preset",
"dashboard.preset_card_desc": "Xuất từ Arma 3 Launcher → Mods → Export to HTML",
"dashboard.sel_count": "Đã chọn {n_sel} / {n_total}",
"dashboard.btn_none": "Bỏ chọn",
"dashboard.btn_all": "Tất cả",
"dashboard.btn_add": "+ Thêm tệp Preset",
"dashboard.no_config": "Chưa tìm thấy cấu hình. Vui lòng hoàn thành thiết lập.",
"dashboard.folder_missing": "Thư mục không tồn tại:\n{path}",
"dashboard.no_presets": "Chưa có tệp preset.\nDùng nút bên dưới để thêm.",
"dashboard.file_dialog_title": "Chọn tệp preset Arma 3 Launcher",
"dashboard.dlg_setup_title": "Cần thiết lập",
"dashboard.dlg_setup_body": "Vui lòng hoàn thành thiết lập trước khi thêm preset.",
"dashboard.pipeline_title": "Trạng thái Pipeline",
"dashboard.step_parse": "Phân tích preset",
"dashboard.step_compare": "So sánh preset",
"dashboard.step_download": "Tải mod",
"dashboard.step_link": "Liên kết với Arma",
"dashboard.stats": "{total} mod · {shared} dùng chung",
"dashboard.stats_missing": "\n{missing} mod thiếu trên máy chủ",
"dashboard.run_btn": "▶ Chạy toàn bộ quy trình",
"dashboard.running": "Đang chạy…",
# ── Mods ─────────────────────────────────────────────────────────────────
"mods.title": "Danh sách Mod",
"mods.refresh_btn": "⟳ Làm mới",
"mods.check_btn": "☁ Kiểm tra cập nhật",
"mods.check_btn_checking": "Đang kiểm tra…",
"mods.search_label": "Tìm kiếm:",
"mods.search_placeholder": "Lọc mod trong tab hiện tại…",
"mods.no_config": "Chưa tìm thấy cấu hình. Vui lòng hoàn thành thiết lập.",
"mods.no_data": (
"Chưa có dữ liệu mod.\n"
"Vào Tổng quan, chọn preset rồi nhấn Chạy toàn bộ quy trình."
),
"mods.read_error": "Lỗi đọc comparison.json: {e}",
"mods.col_name": "Tên Mod",
"mods.col_downloaded": "Đã tải",
"mods.col_linked": "Đã liên kết",
"mods.col_server": "Trạng thái máy chủ",
"mods.status_ok": "✓ Đã cập nhật",
"mods.status_stale": "{n} tệp cũ",
"mods.status_not_downloaded": "",
"mods.status_not_on_server": "Không có trên máy chủ",
"mods.status_error": "✗ Lỗi",
"mods.status_checking": "Đang kiểm tra…",
"mods.update_btn": "Cập nhật",
# ── Logs ─────────────────────────────────────────────────────────────────
"logs.title": "Nhật ký",
"logs.copy_btn": "Sao chép",
"logs.clear_btn": "Xóa",
# ── Settings ─────────────────────────────────────────────────────────────
"settings.title": "Cài đặt",
"settings.server_card_title": "Cấu hình máy chủ & đường dẫn",
"settings.server_card_desc": (
"Mở lại trình hướng dẫn thiết lập để thay đổi URL máy chủ, "
"thông tin đăng nhập hoặc thư mục Arma."
),
"settings.wizard_btn": "Mở trình thiết lập",
"settings.appearance_title": "Giao diện",
"settings.language_title": "Ngôn ngữ",
"settings.config_title": "Cấu hình hiện tại",
# ── Wizard ───────────────────────────────────────────────────────────────
"wizard.title": "Thiết lập — Arma Mod Manager",
"wizard.step1_title": "Bước 1 / 3 — Kết nối máy chủ",
"wizard.step1_desc": "Nhập thông tin máy chủ Caddy của bạn.",
"wizard.label_url": "URL máy chủ",
"wizard.label_user": "Tên đăng nhập",
"wizard.label_pw": "Mật khẩu",
"wizard.btn_next": "Tiếp theo →",
"wizard.btn_test": "Kiểm tra kết nối",
"wizard.testing": "Đang kiểm tra…",
"wizard.connected": "✓ Đã kết nối",
"wizard.http_error": "✗ HTTP {code}",
"wizard.conn_error": "{e}",
"wizard.step2_title": "Bước 2 / 3 — Thư mục Arma 3 Server",
"wizard.step2_desc": (
"Trỏ tới thư mục cài đặt Arma 3 Server của bạn. "
"Các liên kết (junction) sẽ được tạo tại đây."
),
"wizard.label_arma": "Thư mục Arma 3 Server",
"wizard.btn_browse": "Duyệt",
"wizard.step2_hint": (
"Các thư mục khác (downloads, presets) sẽ được tạo tự động "
"bên cạnh công cụ này."
),
"wizard.btn_back": "← Quay lại",
"wizard.step3_title": "Bước 3 / 3 — Xem lại & Lưu",
"wizard.step3_desc": "Kiểm tra cài đặt rồi nhấn Lưu.",
"wizard.not_set": "(chưa đặt)",
"wizard.btn_save": "Lưu & Mở",
"wizard.browse_title": "Chọn thư mục Arma 3 Server",
# ── Tools — shared ────────────────────────────────────────────────────────
"tools.title": "Công cụ",
"tools.label_group": "Nhóm:",
"tools.label_options": "Tùy chọn:",
"tools.label_command": "Lệnh:",
"tools.all_groups": "Tất cả nhóm",
"tools.no_groups": "(không tìm thấy nhóm)",
# ── Tools — Check Names ──────────────────────────────────────────────────
"tools.cn_desc": (
"Quét thư mục mod và so sánh với máy chủ. "
"Báo cáo tên không khớp (MISMATCH), thư mục không nhận ra "
"(NOT_ON_SERVER) và Steam ID sai trong meta.cpp (ID_COLLISION)."
),
"tools.cn_fix_chk": "Tự động sửa tên thư mục không khớp (--fix)",
"tools.cn_fix_ids_chk": "Tự động sửa Steam ID sai trong meta.cpp (--fix-ids)",
"tools.cn_warn": (
"⚠ --fix đổi tên thư mục và cập nhật junction. "
"--fix-ids ghi đè tệp meta.cpp."
),
"tools.cn_btn": "Chạy kiểm tra tên",
# ── Tools — Update Mods ──────────────────────────────────────────────────
"tools.um_desc": (
"Tải lại tệp mod có kích thước khác với bản trên máy chủ. "
"Dùng --force để tải lại tất cả bất kể kích thước."
),
"tools.um_mod_label": "Thư mục mod:",
"tools.um_mod_placeholder": "Không bắt buộc — ví dụ @ace",
"tools.um_mod_hint": "(chỉ dùng khi chọn một nhóm cụ thể)",
"tools.um_force_chk": "Buộc tải lại tất cả tệp (--force)",
"tools.um_warn": (
"⚠ --force tải lại mọi tệp bất kể kích thước. "
"Điều này có thể truyền một lượng dữ liệu lớn."
),
"tools.um_btn": "Chạy cập nhật",
# ── Tools — Link Mods ────────────────────────────────────────────────────
"tools.lm_desc": (
"Quản lý liên kết junction/symlink giữa thư mục downloads "
"và thư mục Arma 3.\n"
"Status — xem liên kết hiện có. "
"Link — tạo junction còn thiếu. "
"Unlink — xóa junction (tệp mod KHÔNG bị xóa)."
),
"tools.lm_warn": (
"⚠ Unlink xóa liên kết junction khỏi thư mục Arma 3. "
"Tệp mod trong downloads/ KHÔNG bị xóa."
),
"tools.lm_show_status": "Xem trạng thái",
"tools.lm_create_links": "Tạo liên kết",
"tools.lm_remove_links": "Xóa liên kết",
"tools.lm_no_group_title": "Chưa chọn nhóm",
"tools.lm_no_group_body": "Vui lòng chọn một nhóm từ danh sách.",
"tools.lm_confirm_title": "Xác nhận xóa liên kết",
"tools.lm_confirm_body": (
"Xóa liên kết junction cho nhóm '{group}'?\n\n"
"Thao tác này xóa liên kết khỏi thư mục Arma 3 "
"nhưng KHÔNG xóa tệp mod trong downloads/."
),
# ── Tools — Sync Missing ─────────────────────────────────────────────────
"tools.sm_desc": (
"Thử tải lại các mod bị thiếu trên máy chủ khi chạy pipeline lần trước. "
"Kiểm tra lại máy chủ và tải về nếu mod đã xuất hiện."
),
"tools.sm_btn": "Chạy đồng bộ mod thiếu",
"tools.sm_count": "{count} mod đang được liệt kê là thiếu.",
"tools.sm_no_report": "Chưa có missing_report.json — hãy chạy pipeline trước.",
# ── Tools — Report Missing ───────────────────────────────────────────────
"tools.rm_desc": (
"Kiểm tra mod nào trong comparison.json không có trên máy chủ. "
"Lưu missing_report.json để theo dõi mod cần bổ sung."
),
"tools.rm_btn": "Tạo báo cáo",
"tools.rm_last": "Tạo lần cuối: {ts}",
"tools.rm_none": "Chưa có báo cáo.",
}
# Guard: both dicts must have identical key sets
assert set(_EN.keys()) == set(_VI.keys()), (
"EN/VI key mismatch: "
+ str(set(_EN.keys()) ^ set(_VI.keys()))
)
_STRINGS: dict[str, dict[str, str]] = {"en": _EN, "vi": _VI}
def set_language(lang: str) -> None:
"""Set the active language. Unknown codes fall back to English."""
global _LANG
_LANG = lang if lang in _STRINGS else "en"
def get_language() -> str:
"""Return the currently active language code."""
return _LANG
def t(key: str, **kwargs: object) -> str:
"""Look up *key* in the active language, falling back to English then the key itself.
Dynamic placeholders use str.format_map with keyword arguments::
t("dashboard.sel_count", n_sel=2, n_total=5)
# dict entry: "{n_sel} of {n_total} selected"
"""
text = _STRINGS[_LANG].get(key) or _STRINGS["en"].get(key, key)
if kwargs:
try:
return text.format_map(kwargs)
except (KeyError, IndexError):
return text
return text

View File

@@ -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()

View File

@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING
import customtkinter as ctk
from gui._constants import COLOR_ERROR
from gui.locales import t
from gui.views.base import BaseView
if TYPE_CHECKING:
@@ -27,16 +28,19 @@ class LogsView(BaseView):
# ── Header ────────────────────────────────────────────────────────────
hdr = ctk.CTkFrame(self, fg_color="transparent")
hdr.grid(row=0, column=0, sticky="ew", padx=24, pady=(20, 8))
ctk.CTkLabel(hdr, text="Logs",
font=ctk.CTkFont(size=22, weight="bold")).pack(side="left")
self._title_lbl = ctk.CTkLabel(hdr, text=t("logs.title"),
font=ctk.CTkFont(size=22, weight="bold"))
self._title_lbl.pack(side="left")
btn_row = ctk.CTkFrame(hdr, fg_color="transparent")
btn_row.pack(side="right")
ctk.CTkButton(btn_row, text="Copy", width=72,
command=self._copy).pack(side="left", padx=4)
ctk.CTkButton(btn_row, text="Clear", width=72,
fg_color=COLOR_ERROR, hover_color="#c62828",
command=self._clear).pack(side="left")
self._copy_btn = ctk.CTkButton(btn_row, text=t("logs.copy_btn"), width=72,
command=self._copy)
self._copy_btn.pack(side="left", padx=4)
self._clear_btn = ctk.CTkButton(btn_row, text=t("logs.clear_btn"), width=72,
fg_color=COLOR_ERROR, hover_color="#c62828",
command=self._clear)
self._clear_btn.pack(side="left")
# ── Log textbox (persistent) ──────────────────────────────────────────
self._log_box = ctk.CTkTextbox(
@@ -44,6 +48,12 @@ class LogsView(BaseView):
font=ctk.CTkFont(family="Consolas", size=12))
self._log_box.grid(row=1, column=0, sticky="nsew", padx=24, pady=(0, 12))
def refresh(self) -> None:
# Retranslate header widgets (log content intentionally preserved)
self._title_lbl.configure(text=t("logs.title"))
self._copy_btn.configure(text=t("logs.copy_btn"))
self._clear_btn.configure(text=t("logs.clear_btn"))
def append(self, text: str) -> None:
"""Thread-safe-ish: called from the app's after() poll loop (main thread)."""
try:

View File

@@ -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")

View File

@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING
import customtkinter as ctk
from gui.locales import t
from gui.views.base import BaseView
if TYPE_CHECKING:
@@ -11,15 +12,15 @@ if TYPE_CHECKING:
class SettingsView(BaseView):
"""Appearance switcher, wizard re-opener, and current config display."""
"""Appearance switcher, language selector, wizard re-opener, and current config display."""
def build(self) -> None:
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(1, weight=1)
ctk.CTkLabel(self, text="Settings",
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("settings.title"),
font=ctk.CTkFont(size=22, weight="bold"))
self._title_lbl.grid(row=0, column=0, sticky="w", padx=24, pady=(20, 8))
self._scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
self._scroll.grid(row=1, column=0, sticky="nsew", padx=24, pady=(0, 12))
@@ -27,7 +28,8 @@ class SettingsView(BaseView):
self._build_cards()
def refresh(self) -> None:
# Config info may have changed (e.g. after wizard); rebuild cards.
# Config info and language may have changed; rebuild everything.
self._title_lbl.configure(text=t("settings.title"))
for w in self._scroll.winfo_children():
w.destroy()
self._build_cards()
@@ -36,22 +38,21 @@ class SettingsView(BaseView):
# ── Server & Paths ────────────────────────────────────────────────────
c1 = ctk.CTkFrame(self._scroll)
c1.pack(fill="x", pady=6)
ctk.CTkLabel(c1, text="Server & Path Configuration",
ctk.CTkLabel(c1, text=t("settings.server_card_title"),
font=ctk.CTkFont(size=14, weight="bold")).pack(
anchor="w", padx=16, pady=(14, 3))
ctk.CTkLabel(c1,
text="Re-run the setup wizard to change your server URL, "
"credentials, or Arma folder.",
text=t("settings.server_card_desc"),
text_color="gray", wraplength=600, justify="left").pack(
anchor="w", padx=16, pady=(0, 8))
ctk.CTkButton(c1, text="Open Setup Wizard", width=160,
ctk.CTkButton(c1, text=t("settings.wizard_btn"), width=160,
command=self.app.open_wizard).pack(
anchor="e", padx=16, pady=(0, 14))
# ── Appearance ────────────────────────────────────────────────────────
c2 = ctk.CTkFrame(self._scroll)
c2.pack(fill="x", pady=6)
ctk.CTkLabel(c2, text="Appearance",
ctk.CTkLabel(c2, text=t("settings.appearance_title"),
font=ctk.CTkFont(size=14, weight="bold")).pack(
anchor="w", padx=16, pady=(14, 3))
mode_var = ctk.StringVar(value=ctk.get_appearance_mode())
@@ -60,12 +61,28 @@ class SettingsView(BaseView):
command=ctk.set_appearance_mode,
width=140).pack(anchor="w", padx=16, pady=(0, 14))
# ── Language ──────────────────────────────────────────────────────────
c_lang = ctk.CTkFrame(self._scroll)
c_lang.pack(fill="x", pady=6)
ctk.CTkLabel(c_lang, text=t("settings.language_title"),
font=ctk.CTkFont(size=14, weight="bold")).pack(
anchor="w", padx=16, pady=(14, 3))
from gui.locales import get_language
current_display = "Tiếng Việt" if get_language() == "vi" else "English"
ctk.CTkOptionMenu(
c_lang,
values=["English", "Tiếng Việt"],
variable=ctk.StringVar(value=current_display),
command=lambda v: self.app.switch_language("vi" if v == "Tiếng Việt" else "en"),
width=160,
).pack(anchor="w", padx=16, pady=(0, 14))
# ── Current config info ───────────────────────────────────────────────
cfg = self.app.cfg
if cfg:
c3 = ctk.CTkFrame(self._scroll)
c3.pack(fill="x", pady=6)
ctk.CTkLabel(c3, text="Current Configuration",
ctk.CTkLabel(c3, text=t("settings.config_title"),
font=ctk.CTkFont(size=14, weight="bold")).pack(
anchor="w", padx=16, pady=(14, 3))
info = (

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

View File

@@ -7,6 +7,7 @@ import customtkinter as ctk
from tkinter import filedialog
from gui._constants import COLOR_OK, COLOR_ERROR, PROJECT_ROOT
from gui.locales import t
class SetupWizard(ctk.CTkToplevel):
@@ -18,7 +19,7 @@ class SetupWizard(ctk.CTkToplevel):
on_complete: Callable[[], None],
) -> None:
super().__init__(parent)
self.title("Setup — Arma Mod Manager")
self.title(t("wizard.title"))
self.geometry("500x420")
self.resizable(False, False)
self.grab_set()
@@ -48,20 +49,20 @@ class SetupWizard(ctk.CTkToplevel):
def _page_server(self) -> None:
ctk.CTkLabel(
self._body, text="Step 1 of 3 — Server Connection",
self._body, text=t("wizard.step1_title"),
font=ctk.CTkFont(size=16, weight="bold"),
).pack(anchor="w")
ctk.CTkLabel(
self._body, text="Enter the details for your Caddy mod server.",
self._body, text=t("wizard.step1_desc"),
text_color="gray",
).pack(anchor="w", pady=(4, 18))
for lbl, var, show in [
("Server URL", self._url, ""),
("Username", self._user, ""),
("Password", self._pw, ""),
for lbl_key, var, show in [
("wizard.label_url", self._url, ""),
("wizard.label_user", self._user, ""),
("wizard.label_pw", self._pw, ""),
]:
ctk.CTkLabel(self._body, text=lbl).pack(anchor="w")
ctk.CTkLabel(self._body, text=t(lbl_key)).pack(anchor="w")
ctk.CTkEntry(self._body, textvariable=var, width=440, show=show).pack(
anchor="w", pady=(2, 10))
@@ -69,15 +70,15 @@ class SetupWizard(ctk.CTkToplevel):
foot.pack(fill="x", pady=(8, 0))
self._conn_lbl = ctk.CTkLabel(foot, text="", text_color="gray")
self._conn_lbl.pack(side="left")
ctk.CTkButton(foot, text="Next", width=90,
ctk.CTkButton(foot, text=t("wizard.btn_next"), width=90,
command=lambda: self._show(1)).pack(side="right")
ctk.CTkButton(foot, text="Test Connection", width=140,
ctk.CTkButton(foot, text=t("wizard.btn_test"), width=140,
fg_color="transparent", border_width=1,
text_color=("gray10", "gray90"),
command=self._test).pack(side="right", padx=(0, 8))
def _test(self) -> None:
self._conn_lbl.configure(text="Testing", text_color="gray")
self._conn_lbl.configure(text=t("wizard.testing"), text_color="gray")
self.update()
try:
import requests
@@ -85,53 +86,51 @@ class SetupWizard(ctk.CTkToplevel):
auth=(self._user.get(), self._pw.get()),
timeout=8)
if r.ok:
self._conn_lbl.configure(text="✓ Connected", text_color=COLOR_OK)
self._conn_lbl.configure(text=t("wizard.connected"),
text_color=COLOR_OK)
else:
self._conn_lbl.configure(text=f"✗ HTTP {r.status_code}",
self._conn_lbl.configure(text=t("wizard.http_error", code=r.status_code),
text_color=COLOR_ERROR)
except Exception as e:
self._conn_lbl.configure(text=f"{e}", text_color=COLOR_ERROR)
self._conn_lbl.configure(text=t("wizard.conn_error", e=e),
text_color=COLOR_ERROR)
# ── Page 2: paths ────────────────────────────────────────────────────────
def _page_paths(self) -> None:
ctk.CTkLabel(
self._body, text="Step 2 of 3 — Arma 3 Server Folder",
self._body, text=t("wizard.step2_title"),
font=ctk.CTkFont(size=16, weight="bold"),
).pack(anchor="w")
ctk.CTkLabel(
self._body,
text="Point to your Arma 3 Server installation. "
"Links (junctions) will be created here.",
self._body, text=t("wizard.step2_desc"),
text_color="gray", wraplength=440, justify="left",
).pack(anchor="w", pady=(4, 18))
ctk.CTkLabel(self._body, text="Arma 3 Server folder").pack(anchor="w")
ctk.CTkLabel(self._body, text=t("wizard.label_arma")).pack(anchor="w")
row = ctk.CTkFrame(self._body, fg_color="transparent")
row.pack(fill="x", pady=(2, 8))
ctk.CTkEntry(row, textvariable=self._arma, width=350).pack(side="left")
ctk.CTkButton(row, text="Browse", width=80,
ctk.CTkButton(row, text=t("wizard.btn_browse"), width=80,
command=self._browse_arma).pack(side="left", padx=8)
ctk.CTkLabel(
self._body,
text="All other folders (downloads, presets) will be created "
"automatically next to this tool.",
self._body, text=t("wizard.step2_hint"),
text_color="gray", font=ctk.CTkFont(size=11),
wraplength=440, justify="left",
).pack(anchor="w", pady=(8, 0))
foot = ctk.CTkFrame(self._body, fg_color="transparent")
foot.pack(fill="x", pady=(20, 0))
ctk.CTkButton(foot, text="← Back", width=80,
ctk.CTkButton(foot, text=t("wizard.btn_back"), width=80,
fg_color="transparent", border_width=1,
text_color=("gray10", "gray90"),
command=lambda: self._show(0)).pack(side="left")
ctk.CTkButton(foot, text="Next", width=80,
ctk.CTkButton(foot, text=t("wizard.btn_next"), width=80,
command=lambda: self._show(2)).pack(side="right")
def _browse_arma(self) -> None:
d = filedialog.askdirectory(title="Select Arma 3 Server folder")
d = filedialog.askdirectory(title=t("wizard.browse_title"))
if d:
self._arma.set(d)
@@ -139,18 +138,18 @@ class SetupWizard(ctk.CTkToplevel):
def _page_review(self) -> None:
ctk.CTkLabel(
self._body, text="Step 3 of 3 — Review & Save",
self._body, text=t("wizard.step3_title"),
font=ctk.CTkFont(size=16, weight="bold"),
).pack(anchor="w")
ctk.CTkLabel(
self._body, text="Check your settings, then click Save.",
self._body, text=t("wizard.step3_desc"),
text_color="gray",
).pack(anchor="w", pady=(4, 14))
summary = (
f"Server URL: {self._url.get()}\n"
f"Username: {self._user.get()}\n"
f"Arma folder: {self._arma.get() or '(not set)'}\n"
f"Arma folder: {self._arma.get() or t('wizard.not_set')}\n"
)
box = ctk.CTkTextbox(self._body, height=90,
font=ctk.CTkFont(family="Consolas", size=12))
@@ -160,11 +159,11 @@ class SetupWizard(ctk.CTkToplevel):
foot = ctk.CTkFrame(self._body, fg_color="transparent")
foot.pack(fill="x")
ctk.CTkButton(foot, text="← Back", width=80,
ctk.CTkButton(foot, text=t("wizard.btn_back"), width=80,
fg_color="transparent", border_width=1,
text_color=("gray10", "gray90"),
command=lambda: self._show(1)).pack(side="left")
ctk.CTkButton(foot, text="Save & Open", width=120,
ctk.CTkButton(foot, text=t("wizard.btn_save"), width=120,
command=self._save).pack(side="right")
def _save(self) -> None: