Add GUI
This commit is contained in:
289
gui/views/dashboard.py
Normal file
289
gui/views/dashboard.py
Normal file
@@ -0,0 +1,289 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user