from __future__ import annotations import json import threading from pathlib import Path from typing import TYPE_CHECKING, Optional 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: from gui.app import ArmaModManagerApp def _find_folder(group_dir: Path, mod_name: str) -> Optional[Path]: """Return the local mod folder path, or None if not downloaded. Matches in priority order: 1. Exact folder name ``@{mod_name}`` 2. Case-insensitive name (handles ``@CBA_A3`` vs ``CBA_A3``) 3. Normalized name — strips non-alphanumeric (handles ``@cba_a3`` vs ``CBA A3``) """ if not group_dir.is_dir(): return None candidate = group_dir / f"@{mod_name}" if candidate.is_dir(): return candidate target_lower = mod_name.lower() target_norm = _normalize_name(mod_name) for p in group_dir.iterdir(): if not p.is_dir(): continue if p.name.lstrip("@").lower() == target_lower: return p if _normalize_name(p.name) == target_norm: return p return None class ModsView(BaseView): """Tabbed mod browser — one tab per comparison group with server status checking.""" def build(self) -> None: self.grid_columnconfigure(0, weight=1) self.grid_rowconfigure(2, weight=1) # ── State ───────────────────────────────────────────────────────────── self._search_var = ctk.StringVar() self._checking = False self._check_btn: Optional[ctk.CTkButton] = None self._tab_view: Optional[ctk.CTkTabview] = None # key = "{group}/{folder_name_or_mod_name}" # value = dict(status_label, update_btn, group, folder_path, mod_dict, name_label) self._mod_rows: dict[str, dict] = {} # ── Header ──────────────────────────────────────────────────────────── hdr = ctk.CTkFrame(self, fg_color="transparent") hdr.grid(row=0, column=0, sticky="ew", padx=24, pady=(20, 6)) hdr.columnconfigure(1, weight=1) 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") 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=t("mods.check_btn"), width=140, command=self._check_updates, ) self._check_btn.pack(side="left") # ── Search ──────────────────────────────────────────────────────────── bar = ctk.CTkFrame(self, fg_color="transparent") bar.grid(row=1, column=0, sticky="ew", padx=24, pady=(0, 8)) 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=t("mods.search_placeholder"), width=220).pack(side="left") self._search_var.trace_add("write", lambda *_: self._apply_search()) # ── Tab area placeholder ─────────────────────────────────────────────── self._tab_area = ctk.CTkFrame(self, fg_color="transparent") self._tab_area.grid(row=2, column=0, sticky="nsew", padx=16, pady=(0, 12)) self._tab_area.grid_columnconfigure(0, weight=1) self._tab_area.grid_rowconfigure(0, weight=1) self._msg_label: Optional[ctk.CTkLabel] = None # ========================================================================= # Public # ========================================================================= 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 if self._tab_view is not None: self._tab_view.destroy() self._tab_view = None if self._msg_label is not None: self._msg_label.destroy() self._msg_label = None cfg = self.app.cfg if not cfg: self._show_msg(t("mods.no_config")) return if not cfg.comparison.exists(): 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(t("mods.read_error", e=e), error=True) return # Build ordered group list: shared first, then unique groups groups: list[tuple[str, dict]] = [("shared", comp["shared"])] for preset, data in comp["unique"].items(): groups.append((preset, data)) # Precompute link maps per group (one get_link_status call per group) link_maps: dict[str, dict[str, bool]] = {} try: from arma_modlist_tools.linker import get_link_status for group, _ in groups: gdir = cfg.downloads / group if gdir.is_dir(): link_maps[group] = { e["name"].lower(): e["is_linked"] for e in get_link_status(gdir, cfg.arma_dir) } except Exception: pass # Build CTkTabview tv = ctk.CTkTabview(self._tab_area) tv.grid(row=0, column=0, sticky="nsew") self._tab_view = tv for group, data in groups: mods = data.get("mods", []) count = len(mods) tab_label = f"{group} ({count})" tv.add(tab_label) tab_frame = tv.tab(tab_label) tab_frame.grid_columnconfigure(0, weight=1) tab_frame.grid_rowconfigure(1, weight=1) # Column header col_hdr = ctk.CTkFrame(tab_frame, 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_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=t(lbl_key) if lbl_key else "", font=ctk.CTkFont(weight="bold"), anchor="w", width=w or 1).grid( row=0, column=col, padx=(10 if col == 0 else 4, 4), pady=5, sticky="ew" if col == 0 else "") # Scrollable rows scroll = ctk.CTkScrollableFrame(tab_frame) scroll.grid(row=1, column=0, sticky="nsew", padx=4, pady=(0, 4)) self._build_group_rows(scroll, group, mods, cfg, link_maps.get(group, {})) if groups: tv.set(f"{groups[0][0]} ({len(groups[0][1].get('mods', []))})") # ========================================================================= # Private — layout helpers # ========================================================================= def _show_msg(self, text: str, error: bool = False) -> None: self._msg_label = ctk.CTkLabel( self._tab_area, text=text, justify="left", text_color="#F44336" if error else "gray", ) self._msg_label.grid(row=0, column=0, padx=24, pady=24, sticky="nw") def _build_group_rows( self, parent: ctk.CTkScrollableFrame, group: str, mods: list[dict], cfg, link_map: dict[str, bool], ) -> None: for i, mod in enumerate(sorted(mods, key=lambda m: m["name"].lower())): folder_path = _find_folder(cfg.downloads / group, mod["name"]) downloaded = folder_path is not None linked = (link_map.get(folder_path.name.lower(), False) if folder_path else False) bg = ("gray90", "gray17") if i % 2 == 0 else ("gray86", "gray14") row = ctk.CTkFrame(parent, fg_color=bg, corner_radius=4) row.pack(fill="x", pady=1) row.columnconfigure(0, weight=1) # Mod name name_lbl = ctk.CTkLabel(row, text=f" {mod['name']}", anchor="w") name_lbl.grid(row=0, column=0, sticky="ew", padx=4, pady=3) # Downloaded ctk.CTkLabel( row, text=" ✓" if downloaded else " ✗", text_color=COLOR_OK if downloaded else COLOR_ERROR, width=80, anchor="w", ).grid(row=0, column=1, padx=4) # Linked ctk.CTkLabel( row, text=" ✓" if linked else (" —" if not downloaded else " ✗"), text_color=COLOR_OK if linked else "gray", width=80, anchor="w", ).grid(row=0, column=2, padx=4) # Server status status_lbl = ctk.CTkLabel( row, text="—", text_color="gray", width=160, anchor="w", ) status_lbl.grid(row=0, column=3, padx=4) # Update button (hidden until stale detected) folder_name = folder_path.name if folder_path else None update_btn = ctk.CTkButton( 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", ) update_btn.grid(row=0, column=4, padx=(4, 8), pady=2) update_btn.grid_remove() # hidden until check finds stale files # Register in row map key = f"{group}/{folder_name or mod['name']}" self._mod_rows[key] = { "status_label": status_lbl, "update_btn": update_btn, "name_label": name_lbl, "row_frame": row, "group": group, "folder_path": folder_path, "mod_dict": mod, "mod_name": mod["name"], } # ========================================================================= # Private — server update check # ========================================================================= def _check_updates(self) -> None: if self._checking: return cfg = self.app.cfg if not cfg: return if not self._mod_rows: return self._checking = True 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=t("mods.status_checking"), text_color=COLOR_RUNNING) else: row["status_label"].configure(text="—", text_color="gray") # Snapshot rows for thread (avoid race with refresh) rows_snapshot = dict(self._mod_rows) def worker() -> None: from arma_modlist_tools.fetcher import ( build_server_index, list_mod_updates, make_session, find_mod_folder, ) results: dict[str, tuple[str, int]] = {} try: idx = build_server_index(cfg.server_url, cfg.server_auth) session = make_session(cfg.server_auth) for key, row in rows_snapshot.items(): if not row["folder_path"]: results[key] = ("not_downloaded", 0) continue try: folder_url = find_mod_folder(row["mod_dict"], idx) if not folder_url: results[key] = ("not_on_server", 0) continue stale = list_mod_updates( folder_url, row["folder_path"], session) results[key] = ("stale" if stale else "ok", len(stale)) except Exception: results[key] = ("error", 0) except Exception: for key in rows_snapshot: results[key] = ("error", 0) self.after(0, lambda: self._apply_check_results(results)) 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": (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")) # 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=t("mods.check_btn"), state="normal") def _update_mod(self, group: str, folder_name: str) -> None: self.app.navigate_to("Logs") self.app.run_tool(["update_mods.py", "--group", group, "--mod", folder_name]) # ========================================================================= # Private — search filter # ========================================================================= def _apply_search(self) -> None: search = self._search_var.get().lower() for row in self._mod_rows.values(): frame = row["row_frame"] if not search or search in row["mod_name"].lower(): frame.pack(fill="x", pady=1) else: frame.pack_forget()