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