Add GUI
This commit is contained in:
409
gui/views/tools.py
Normal file
409
gui/views/tools.py
Normal file
@@ -0,0 +1,409 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from tkinter import messagebox
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from gui._constants import COLOR_WARN, PROJECT_ROOT
|
||||
from gui.views.base import BaseView
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gui.app import ArmaModManagerApp
|
||||
|
||||
_WARN_COLOR = COLOR_WARN
|
||||
|
||||
|
||||
class ToolsView(BaseView):
|
||||
"""Per-tool panels inside a CTkTabview."""
|
||||
|
||||
def build(self) -> None:
|
||||
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._tab_view = ctk.CTkTabview(self)
|
||||
self._tab_view.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12))
|
||||
|
||||
# Per-tab group menu references so refresh() can repopulate them all
|
||||
self._group_menus: list[tuple[ctk.CTkOptionMenu, ctk.StringVar]] = []
|
||||
|
||||
self._build_check_names_tab()
|
||||
self._build_update_mods_tab()
|
||||
self._build_link_mods_tab()
|
||||
self._build_sync_missing_tab()
|
||||
self._build_report_missing_tab()
|
||||
|
||||
# =========================================================================
|
||||
# Public
|
||||
# =========================================================================
|
||||
|
||||
def refresh(self) -> None:
|
||||
groups = self._get_groups()
|
||||
all_groups = ["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")
|
||||
|
||||
# Link Mods group menu (no "All groups")
|
||||
lm_prev = self._lm_group_var.get()
|
||||
lm_vals = groups if groups else ["(no groups found)"]
|
||||
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()
|
||||
self._update_rm_label()
|
||||
|
||||
# =========================================================================
|
||||
# Private — tab builders
|
||||
# =========================================================================
|
||||
|
||||
def _build_check_names_tab(self) -> None:
|
||||
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).")
|
||||
|
||||
# Group
|
||||
gf = _row(tab, row=1, label="Group:")
|
||||
self._cn_group_var = ctk.StringVar(value="All groups")
|
||||
menu = ctk.CTkOptionMenu(gf, variable=self._cn_group_var,
|
||||
values=["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:")
|
||||
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")
|
||||
|
||||
# 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
|
||||
|
||||
# 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")
|
||||
|
||||
def _cn_on_toggle(self) -> None:
|
||||
if self._cn_fix_var.get() or self._cn_fix_ids_var.get():
|
||||
self._cn_warn.grid(row=5, column=0, padx=24, pady=(4, 0), sticky="w")
|
||||
else:
|
||||
self._cn_warn.grid_forget()
|
||||
|
||||
def _cn_run(self) -> None:
|
||||
args = ["check_names.py"]
|
||||
g = self._cn_group_var.get()
|
||||
if g != "All groups":
|
||||
args += ["--group", g]
|
||||
if self._cn_fix_var.get():
|
||||
args.append("--fix")
|
||||
if self._cn_fix_ids_var.get():
|
||||
args.append("--fix-ids")
|
||||
self._launch(args)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _build_update_mods_tab(self) -> None:
|
||||
self._tab_view.add("Update Mods")
|
||||
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.")
|
||||
|
||||
# Group
|
||||
gf = _row(tab, row=1, label="Group:")
|
||||
self._um_group_var = ctk.StringVar(value="All groups")
|
||||
um_menu = ctk.CTkOptionMenu(
|
||||
gf, variable=self._um_group_var, values=["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:")
|
||||
self._um_mod_entry = ctk.CTkEntry(
|
||||
mf, placeholder_text="Optional — e.g. @ace", 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)
|
||||
|
||||
# Force checkbox
|
||||
ff = _row(tab, row=3, label="Options:")
|
||||
self._um_force_var = ctk.BooleanVar(value=False)
|
||||
ctk.CTkCheckBox(
|
||||
ff, text="Force re-download all files (--force)",
|
||||
variable=self._um_force_var,
|
||||
command=self._um_on_toggle,
|
||||
).pack(side="left")
|
||||
|
||||
# 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",
|
||||
)
|
||||
|
||||
ctk.CTkButton(tab, text="Run Update", width=180,
|
||||
command=self._um_run).grid(
|
||||
row=10, column=0, padx=24, pady=(16, 24), sticky="e")
|
||||
|
||||
def _um_on_group_change(self, _: str) -> None:
|
||||
is_specific = self._um_group_var.get() != "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")
|
||||
|
||||
def _um_on_toggle(self) -> None:
|
||||
if self._um_force_var.get():
|
||||
self._um_warn.grid(row=5, column=0, padx=24, pady=(4, 0), sticky="w")
|
||||
else:
|
||||
self._um_warn.grid_forget()
|
||||
|
||||
def _um_run(self) -> None:
|
||||
args = ["update_mods.py"]
|
||||
g = self._um_group_var.get()
|
||||
if g != "All groups":
|
||||
args += ["--group", g]
|
||||
mod = self._um_mod_entry.get().strip()
|
||||
if mod:
|
||||
args += ["--mod", mod]
|
||||
if self._um_force_var.get():
|
||||
args.append("--force")
|
||||
self._launch(args)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _build_link_mods_tab(self) -> None:
|
||||
self._tab_view.add("Link Mods")
|
||||
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).")
|
||||
|
||||
# Command selector
|
||||
cf = _row(tab, row=1, label="Command:")
|
||||
self._lm_cmd_var = ctk.StringVar(value="Status")
|
||||
ctk.CTkSegmentedButton(
|
||||
cf,
|
||||
values=["Status", "Link", "Unlink"],
|
||||
variable=self._lm_cmd_var,
|
||||
command=self._lm_on_change,
|
||||
).pack(side="left")
|
||||
|
||||
# Group (required — no "All groups")
|
||||
gf = _row(tab, row=2, 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,
|
||||
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",
|
||||
)
|
||||
|
||||
# Run button (label changes with command)
|
||||
self._lm_run_btn = ctk.CTkButton(
|
||||
tab, text="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"}
|
||||
self._lm_run_btn.configure(text=labels.get(cmd, cmd))
|
||||
|
||||
if cmd == "Unlink":
|
||||
self._lm_warn.grid(row=5, column=0, padx=24, pady=(4, 0), sticky="w")
|
||||
else:
|
||||
self._lm_warn.grid_forget()
|
||||
|
||||
def _lm_run(self) -> None:
|
||||
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.")
|
||||
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/.",
|
||||
)
|
||||
if not confirmed:
|
||||
return
|
||||
args.append("--yes")
|
||||
|
||||
self._launch(args)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _build_sync_missing_tab(self) -> None:
|
||||
self._tab_view.add("Sync Missing")
|
||||
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.")
|
||||
|
||||
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")
|
||||
|
||||
def _update_sm_label(self) -> None:
|
||||
cfg = self.app.cfg
|
||||
if not cfg:
|
||||
self._sm_info.configure(text="")
|
||||
return
|
||||
report_path = cfg.missing_report if hasattr(cfg, "missing_report") else (
|
||||
cfg.modlist_json / "missing_report.json"
|
||||
if hasattr(cfg, "modlist_json") else None
|
||||
)
|
||||
if report_path and report_path.exists():
|
||||
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.")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
self._sm_info.configure(
|
||||
text="No missing_report.json found — run the pipeline first.")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _build_report_missing_tab(self) -> None:
|
||||
self._tab_view.add("Report Missing")
|
||||
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.")
|
||||
|
||||
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")
|
||||
|
||||
def _update_rm_label(self) -> None:
|
||||
cfg = self.app.cfg
|
||||
if not cfg:
|
||||
self._rm_info.configure(text="")
|
||||
return
|
||||
report_path = cfg.missing_report if hasattr(cfg, "missing_report") else (
|
||||
cfg.modlist_json / "missing_report.json"
|
||||
if hasattr(cfg, "modlist_json") else None
|
||||
)
|
||||
if report_path and report_path.exists():
|
||||
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}")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
self._rm_info.configure(text="No report yet.")
|
||||
|
||||
# =========================================================================
|
||||
# Private — helpers
|
||||
# =========================================================================
|
||||
|
||||
def _get_groups(self) -> list[str]:
|
||||
cfg = self.app.cfg
|
||||
if cfg and cfg.downloads.is_dir():
|
||||
return sorted(p.name for p in cfg.downloads.iterdir() if p.is_dir())
|
||||
# Fallback: read comparison.json
|
||||
if cfg and cfg.comparison.exists():
|
||||
try:
|
||||
comp = json.loads(cfg.comparison.read_text(encoding="utf-8"))
|
||||
return ["shared"] + list(comp.get("unique", {}).keys())
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
def _launch(self, args: list[str]) -> None:
|
||||
self.app.navigate_to("Logs")
|
||||
self.app.run_tool(args)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layout helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _desc(parent, row: int, text: str) -> ctk.CTkLabel:
|
||||
lbl = ctk.CTkLabel(parent, text=text, justify="left",
|
||||
wraplength=700, text_color="gray", anchor="w")
|
||||
lbl.grid(row=row, column=0, padx=24, pady=(16, 8), sticky="ew")
|
||||
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")
|
||||
f = ctk.CTkFrame(parent, fg_color="transparent")
|
||||
f.grid(row=row, column=0, padx=(140, 24), pady=6, sticky="w")
|
||||
return f
|
||||
Reference in New Issue
Block a user