Add GUI
This commit is contained in:
353
gui/views/mods.py
Normal file
353
gui/views/mods.py
Normal file
@@ -0,0 +1,353 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from gui._constants import COLOR_OK, COLOR_ERROR, COLOR_WARN, COLOR_RUNNING
|
||||
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."""
|
||||
if not group_dir.is_dir():
|
||||
return None
|
||||
candidate = group_dir / f"@{mod_name}"
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
target = mod_name.lower()
|
||||
for p in group_dir.iterdir():
|
||||
if p.is_dir() and p.name.lstrip("@").lower() == target:
|
||||
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)
|
||||
|
||||
ctk.CTkLabel(hdr, text="Mods",
|
||||
font=ctk.CTkFont(size=22, weight="bold")).grid(
|
||||
row=0, column=0, sticky="w")
|
||||
|
||||
btn_frame = ctk.CTkFrame(hdr, fg_color="transparent")
|
||||
btn_frame.grid(row=0, column=2, sticky="e")
|
||||
|
||||
ctk.CTkButton(btn_frame, text="⟳ Refresh", width=100,
|
||||
command=self.refresh).pack(side="left", padx=(0, 6))
|
||||
|
||||
self._check_btn = ctk.CTkButton(
|
||||
btn_frame, text="☁ Check Updates", width=130,
|
||||
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))
|
||||
ctk.CTkLabel(bar, text="Search:").pack(side="left", padx=(0, 6))
|
||||
ctk.CTkEntry(bar, textvariable=self._search_var,
|
||||
placeholder_text="Filter mods in active tab…",
|
||||
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:
|
||||
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("No config found. Complete Setup first.")
|
||||
return
|
||||
if not cfg.comparison.exists():
|
||||
self._show_msg(
|
||||
"No mod data yet.\n"
|
||||
"Go to Dashboard, select your presets, then click Run Full Pipeline."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
comp = json.loads(cfg.comparison.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
self._show_msg(f"Error reading comparison.json: {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) in enumerate([
|
||||
(0, "Mod Name"),
|
||||
(80, "Downloaded"),
|
||||
(80, "Linked"),
|
||||
(160, "Server Status"),
|
||||
(80, ""),
|
||||
]):
|
||||
ctk.CTkLabel(col_hdr, text=lbl,
|
||||
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="Update", 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="Checking…", state="disabled")
|
||||
|
||||
# Reset downloaded rows to "Checking…"
|
||||
for row in self._mod_rows.values():
|
||||
if row["folder_path"]:
|
||||
row["status_label"].configure(
|
||||
text="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:
|
||||
_STATUS: dict[str, tuple[str, str]] = {
|
||||
"ok": ("✓ Up to date", COLOR_OK),
|
||||
"stale": ("⚠ {n} outdated", COLOR_WARN),
|
||||
"not_downloaded": ("—", "gray"),
|
||||
"not_on_server": ("Not on server", "gray"),
|
||||
"error": ("✗ 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"))
|
||||
row["status_label"].configure(
|
||||
text=tmpl.replace("{n}", str(n)), 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="☁ Check Updates", 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()
|
||||
Reference in New Issue
Block a user