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.locales import t 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) 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)) # 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() self._build_sync_missing_tab() self._build_report_missing_tab() # ========================================================================= # Public # ========================================================================= 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 = [t("tools.all_groups")] + groups # Repopulate generic group menus for menu, var in self._group_menus: prev = var.get() menu.configure(values=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 [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]) # Info labels self._update_sm_label() self._update_rm_label() # ========================================================================= # Private — tab builders # ========================================================================= 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_lbl = _desc(tab, row=0, text=t("tools.cn_desc")) self._translatable.append((desc_lbl, "tools.cn_desc")) # Group 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=[t("tools.all_groups")], width=200) menu.pack(side="left") self._group_menus.append((menu, self._cn_group_var)) # Checkboxes 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) 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=t("tools.cn_warn"), text_color=_WARN_COLOR, anchor="w") self._translatable.append((self._cn_warn, "tools.cn_warn")) # Run button 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(): 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 != t("tools.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_lbl = _desc(tab, row=0, text=t("tools.um_desc")) self._translatable.append((desc_lbl, "tools.um_desc")) # Group 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=[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, 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=t("tools.um_mod_placeholder"), width=220, state="disabled", ) self._um_mod_entry.pack(side="left") 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, 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) um_force_chk = ctk.CTkCheckBox( ff, text=t("tools.um_force_chk"), variable=self._um_force_var, command=self._um_on_toggle, ) um_force_chk.pack(side="left") self._translatable.append((um_force_chk, "tools.um_force_chk")) # Warning 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")) 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() != 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") 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 != t("tools.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_lbl = _desc(tab, row=0, text=t("tools.lm_desc")) self._translatable.append((desc_lbl, "tools.lm_desc")) # 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, values=["Status", "Link", "Unlink"], variable=self._lm_cmd_var, command=self._lm_on_change, ).pack(side="left") # Group (required — no "All groups") 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=[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=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=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() # 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": 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 == 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( t("tools.lm_confirm_title"), t("tools.lm_confirm_body", group=group), ) 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_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") 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 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=t("tools.sm_count", count=count)) return except Exception: pass self._sm_info.configure(text=t("tools.sm_no_report")) # ------------------------------------------------------------------------- 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_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") 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 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=t("tools.rm_last", ts=ts)) return except Exception: pass self._rm_info.configure(text=t("tools.rm_none")) # ========================================================================= # 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) -> 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, lbl