from __future__ import annotations import json import shutil from pathlib import Path from tkinter import filedialog, messagebox from typing import TYPE_CHECKING import customtkinter as ctk from gui._constants import ( COLOR_OK, COLOR_PENDING, COLOR_ERROR, COLOR_WARN, ) from gui.views.base import BaseView if TYPE_CHECKING: from gui.app import ArmaModManagerApp class DashboardView(BaseView): """ Preset file selector + pipeline status + Run button. Dynamic regions (rebuilt in refresh): - _preset_scroll — checkbox list of .html files - _step_icons — ✓/○ status for each pipeline step - _stats_lbl — mod counts from comparison.json Static regions (built once): - _run_btn / _prog — pipeline controls, state changed by set_pipeline_ui() - _sel_count_lbl — "X of Y selected" label, updated by _on_toggle() """ def build(self) -> None: self.grid_columnconfigure(0, weight=1) self.grid_rowconfigure(1, weight=1) # ── 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") # ── Cards ───────────────────────────────────────────────────────────── cards = ctk.CTkFrame(self, fg_color="transparent") cards.grid(row=1, column=0, sticky="nsew", padx=24) cards.columnconfigure(0, weight=3) cards.columnconfigure(1, weight=2) self._build_preset_card(cards) self._build_pipeline_card(cards) # ── Run button area ─────────────────────────────────────────────────── run_area = ctk.CTkFrame(self, fg_color="transparent") run_area.grid(row=2, column=0, sticky="ew", padx=24, pady=16) self._run_btn = ctk.CTkButton( run_area, text="▶ Run Full Pipeline", font=ctk.CTkFont(size=15, weight="bold"), height=46, command=self._on_run, ) self._run_btn.pack(fill="x") self._prog = ctk.CTkProgressBar(run_area, mode="indeterminate") # ── Preset card ─────────────────────────────────────────────────────────── def _build_preset_card(self, parent: ctk.CTkFrame) -> None: pc = ctk.CTkFrame(parent) pc.grid(row=0, column=0, sticky="nsew", padx=(0, 8), pady=4) ctk.CTkLabel(pc, text="Preset Files", 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_color="gray", font=ctk.CTkFont(size=11)).pack( anchor="w", padx=14) self._preset_scroll = ctk.CTkScrollableFrame(pc, height=150) self._preset_scroll.pack(fill="x", padx=14, pady=(10, 4)) # Selection controls sel_row = ctk.CTkFrame(pc, fg_color="transparent") sel_row.pack(fill="x", padx=14, pady=(0, 8)) 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, 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, 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", fg_color="transparent", border_width=1, text_color=("gray10", "gray90"), command=self._add_presets).pack(pady=(0, 14)) # ── Pipeline card ───────────────────────────────────────────────────────── def _build_pipeline_card(self, parent: ctk.CTkFrame) -> None: pipe = ctk.CTkFrame(parent) pipe.grid(row=0, column=1, sticky="nsew", padx=(8, 0), pady=4) ctk.CTkLabel(pipe, text="Pipeline Status", 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"), ]: row = ctk.CTkFrame(pipe, fg_color="transparent") row.pack(fill="x", padx=14, pady=3) icon = ctk.CTkLabel(row, text="○", width=22, 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 self._stats_lbl = ctk.CTkLabel(pipe, text="", text_color="gray", font=ctk.CTkFont(size=11), wraplength=200, justify="left") self._stats_lbl.pack(anchor="w", padx=14, pady=(10, 14)) # ── refresh ─────────────────────────────────────────────────────────────── def refresh(self) -> None: self._rebuild_preset_list() self._update_pipeline_status() def _rebuild_preset_list(self) -> None: for w in self._preset_scroll.winfo_children(): w.destroy() self._preset_checks: dict[str, ctk.BooleanVar] = {} cfg = self.app.cfg if not cfg: ctk.CTkLabel(self._preset_scroll, text="No config found. Complete Setup first.", 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_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_color="gray", justify="left").pack(padx=4, pady=8) return saved = self.app.load_selection() for fp in files: var = ctk.BooleanVar(value=fp.name in saved) self._preset_checks[fp.name] = var ctk.CTkCheckBox( self._preset_scroll, text=fp.name, variable=var, command=self._on_toggle, ).pack(anchor="w", padx=4, pady=3) self._on_toggle() # initialise count label def _update_pipeline_status(self) -> None: cfg = self.app.cfg if not cfg: for key in self._step_icons: self._set_step(key, False) return selected_count = sum(1 for v in self._preset_checks.values() if v.get()) downloads_ok = ( cfg.downloads.is_dir() and any(True for _ in cfg.downloads.rglob("@*")) ) self._set_step("parse", selected_count >= 2) self._set_step("compare", cfg.comparison.exists()) self._set_step("download", downloads_ok) self._set_step("link", cfg.arma_dir.is_dir()) if cfg.comparison.exists(): try: comp = json.loads(cfg.comparison.read_text(encoding="utf-8")) total = (comp["shared"]["mod_count"] + sum(v["mod_count"] for v in comp["unique"].values())) shared = comp["shared"]["mod_count"] missing = 0 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" if missing: stat += f"\n{missing} missing from server" self._stats_lbl.configure(text=stat) except Exception: pass def _set_step(self, key: str, done: bool) -> None: icon = self._step_icons.get(key) if icon: icon.configure( text="✓" if done else "○", text_color=COLOR_OK if done else COLOR_PENDING, ) # ── Selection helpers ───────────────────────────────────────────────────── def get_selected_names(self) -> set[str]: return {name for name, var in self._preset_checks.items() if var.get()} def _on_toggle(self) -> None: selected = self.get_selected_names() self.app.save_selection(selected) n_sel = len(selected) n_total = len(self._preset_checks) color = (COLOR_OK if n_sel >= 2 else 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) def _select_all(self) -> None: for var in self._preset_checks.values(): var.set(True) self._on_toggle() def _select_none(self) -> None: for var in self._preset_checks.values(): var.set(False) self._on_toggle() # ── Add presets ─────────────────────────────────────────────────────────── def _add_presets(self) -> None: cfg = self.app.cfg if not cfg: messagebox.showwarning("Setup required", "Please complete Setup before adding presets.") return files = filedialog.askopenfilenames( title="Select Arma 3 Launcher preset files", filetypes=[("HTML Preset", "*.html"), ("All files", "*.*")], ) if not files: return dest = cfg.modlist_html dest.mkdir(parents=True, exist_ok=True) current = self.app.load_selection() for fp in files: name = Path(fp).name shutil.copy2(fp, dest / name) current.add(name) self.app.save_selection(current) self.refresh() # ── Run button ──────────────────────────────────────────────────────────── def _on_run(self) -> None: self.app.run_pipeline(self.get_selected_names()) 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._prog.pack(fill="x", pady=(6, 0)) self._prog.start() else: self._run_btn.configure(state="normal", text="▶ Run Full Pipeline") self._prog.stop() self._prog.pack_forget()