- Use padx=(8,4) consistently for the name column in both header and rows, removing the leading-space text hack in row labels - Add a 16px spacer at the right end of the header to compensate for CTkScrollableFrame's internal scrollbar width - Centre-align Downloaded and Linked columns (header + tick/cross labels) - Unify update button width to 80px to match header col width
391 lines
16 KiB
Python
391 lines
16 KiB
Python
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, _parse_meta_cpp
|
|
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, steam_id: str | None = None) -> 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``)
|
|
4. Steam ID match via ``meta.cpp`` ``publishedid`` field (folder name differs from modlist name)
|
|
"""
|
|
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
|
|
# Fallback: match by steam_id via meta.cpp when folder name diverges from modlist name
|
|
if steam_id:
|
|
for p in group_dir.iterdir():
|
|
if not p.is_dir():
|
|
continue
|
|
meta = p / "meta.cpp"
|
|
try:
|
|
sid = _parse_meta_cpp(meta.read_text(encoding="utf-8", errors="replace"))
|
|
if sid == steam_id:
|
|
return p
|
|
except OSError:
|
|
pass
|
|
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, anc) in enumerate([
|
|
(0, "mods.col_name", "w"),
|
|
(80, "mods.col_downloaded", "center"),
|
|
(80, "mods.col_linked", "center"),
|
|
(160, "mods.col_server", "w"),
|
|
(80, "", "center"),
|
|
]):
|
|
ctk.CTkLabel(col_hdr, text=t(lbl_key) if lbl_key else "",
|
|
font=ctk.CTkFont(weight="bold"),
|
|
anchor=anc, width=w or 1).grid(
|
|
row=0, column=col,
|
|
padx=(8 if col == 0 else 4, 4), pady=5,
|
|
sticky="ew" if col == 0 else "")
|
|
# Spacer compensates for CTkScrollableFrame's internal scrollbar width
|
|
# so the header columns line up with the data rows below.
|
|
ctk.CTkLabel(col_hdr, text="", width=16).grid(row=0, column=5, padx=0)
|
|
|
|
# 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"], mod.get("steam_id"))
|
|
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=mod["name"], anchor="w")
|
|
name_lbl.grid(row=0, column=0, sticky="ew", padx=(8, 4), pady=3)
|
|
|
|
# Downloaded
|
|
ctk.CTkLabel(
|
|
row,
|
|
text="✓" if downloaded else "✗",
|
|
text_color=COLOR_OK if downloaded else COLOR_ERROR,
|
|
width=80, anchor="center",
|
|
).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="center",
|
|
).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=80,
|
|
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()
|