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.locales import t 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)) self._title_lbl = ctk.CTkLabel(hdr, text=t("dashboard.title"), font=ctk.CTkFont(size=22, weight="bold")) self._title_lbl.pack(side="left") self._refresh_btn = ctk.CTkButton(hdr, text=t("dashboard.refresh_btn"), width=100, command=self.refresh) self._refresh_btn.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=t("dashboard.run_btn"), 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=t("dashboard.preset_card_title"), font=ctk.CTkFont(size=14, weight="bold")).pack( anchor="w", padx=14, pady=(14, 2)) ctk.CTkLabel(pc, text=t("dashboard.preset_card_desc"), 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=t("dashboard.btn_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=t("dashboard.btn_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=t("dashboard.btn_add"), 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=t("dashboard.pipeline_title"), font=ctk.CTkFont(size=14, weight="bold")).pack( anchor="w", padx=14, pady=(14, 8)) self._step_icons: dict[str, ctk.CTkLabel] = {} self._step_labels: dict[str, ctk.CTkLabel] = {} for key, lbl_key in [ ("parse", "dashboard.step_parse"), ("compare", "dashboard.step_compare"), ("download", "dashboard.step_download"), ("link", "dashboard.step_link"), ]: 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") lbl = ctk.CTkLabel(row, text=t(lbl_key), anchor="w") lbl.pack(side="left", padx=6) self._step_icons[key] = icon self._step_labels[key] = lbl 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: # Retranslate static widgets that were built once self._title_lbl.configure(text=t("dashboard.title")) self._refresh_btn.configure(text=t("dashboard.refresh_btn")) # Only update run_btn text when not currently running if self._run_btn.cget("state") != "disabled": self._run_btn.configure(text=t("dashboard.run_btn")) # Retranslate step labels for key, lbl_key in [ ("parse", "dashboard.step_parse"), ("compare", "dashboard.step_compare"), ("download", "dashboard.step_download"), ("link", "dashboard.step_link"), ]: if key in self._step_labels: self._step_labels[key].configure(text=t(lbl_key)) 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=t("dashboard.no_config"), 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=t("dashboard.folder_missing", path=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=t("dashboard.no_presets"), 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 = t("dashboard.stats", total=total, shared=shared) if missing: stat += t("dashboard.stats_missing", missing=missing) 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=t("dashboard.sel_count", n_sel=n_sel, n_total=n_total), 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(t("dashboard.dlg_setup_title"), t("dashboard.dlg_setup_body")) return files = filedialog.askopenfilenames( title=t("dashboard.file_dialog_title"), 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=t("dashboard.running")) self._prog.pack(fill="x", pady=(6, 0)) self._prog.start() else: self._run_btn.configure(state="normal", text=t("dashboard.run_btn")) self._prog.stop() self._prog.pack_forget()