This commit is contained in:
revernomad17
2026-04-08 13:36:49 +07:00
parent 595544e94f
commit 5b497cf414
18 changed files with 2121 additions and 6 deletions

361
gui/app.py Normal file
View File

@@ -0,0 +1,361 @@
from __future__ import annotations
import json
import queue
import subprocess
import sys
import threading
from pathlib import Path
from typing import Optional
import customtkinter as ctk
from tkinter import messagebox
from gui._constants import (
SIDEBAR_W, APP_TITLE, PROJECT_ROOT, SELECTION_FILE,
)
from gui._io import _QueueWriter
from gui.wizard import SetupWizard
from gui.views.base import BaseView
# ---------------------------------------------------------------------------
# View name → class mapping (imported lazily to avoid circular imports)
# ---------------------------------------------------------------------------
_VIEW_NAMES = ("Dashboard", "Mods", "Tools", "Logs", "Settings")
def _get_view_class(name: str):
from gui.views import DashboardView, ModsView, ToolsView, LogsView, SettingsView
return {
"Dashboard": DashboardView,
"Mods": ModsView,
"Tools": ToolsView,
"Logs": LogsView,
"Settings": SettingsView,
}[name]
# ---------------------------------------------------------------------------
# Main application
# ---------------------------------------------------------------------------
class ArmaModManagerApp(ctk.CTk):
def __init__(self) -> None:
super().__init__()
self.title(APP_TITLE)
self.geometry("980x640")
self.minsize(820, 560)
self._log_q: queue.Queue[str] = queue.Queue()
self._orig_stdout = sys.stdout
self._orig_stderr = sys.stderr
self._pipeline_running: bool = False
self._cfg = None
self._view_cache: dict[str, BaseView] = {}
self._active_name: str = ""
if not (PROJECT_ROOT / "config.json").exists():
self.after(200, self.open_wizard)
else:
self._load_config()
self._build_layout()
self._poll_log()
# =========================================================================
# Public interface (used by views)
# =========================================================================
@property
def cfg(self):
"""Loaded Config object, or None if config.json is missing/invalid."""
return self._cfg
@property
def pipeline_running(self) -> bool:
return self._pipeline_running
def navigate_to(self, view_name: str) -> None:
"""Switch the content area to the named view, refreshing its data."""
assert view_name in _VIEW_NAMES, f"Unknown view: {view_name}"
# Build view on first visit
if view_name not in self._view_cache:
cls = _get_view_class(view_name)
view = cls(self._content, self)
self._view_cache[view_name] = view
# Hide current
if self._active_name and self._active_name in self._view_cache:
old = self._view_cache[self._active_name]
old.grid_forget()
old.lower() # push canvas-based widgets below everything on Windows
# Show new
view = self._view_cache[view_name]
view.grid(row=0, column=0, sticky="nsew")
view.lift() # ensure it's above any lingering hidden views
view.refresh()
self._active_name = view_name
self._nav_select(view_name)
def post_log(self, text: str) -> None:
"""Thread-safe: enqueue text for the Logs panel."""
self._log_q.put(text)
def run_pipeline(self, selected_names: set[str]) -> None:
"""Start the background pipeline for the given preset filenames."""
if self._pipeline_running:
return
if len(selected_names) < 2:
messagebox.showwarning(
"Not enough presets selected",
"Please select at least 2 preset files to compare.\n\n"
"Use the checkboxes on the Dashboard to choose which presets to use.",
)
return
cfg = self._cfg
if not cfg:
messagebox.showwarning("Setup required", "Please complete Setup first.")
return
self._pipeline_running = True
self._get_dashboard().set_pipeline_ui(running=True)
self.navigate_to("Logs")
def worker() -> None:
# run.py calls fix_console_encoding() at import time, which needs
# the real sys.stdout.buffer. Import it before we redirect stdout.
from run import step_fetch, step_link
self._redirect_output()
try:
from arma_modlist_tools.parser import parse_modlist_html
from arma_modlist_tools.compare import compare_presets
# Step 1 — Parse selected presets
_hdr("Step 1 / 4", "Parse presets")
cfg.modlist_json.mkdir(exist_ok=True)
presets = []
for fp in sorted(cfg.modlist_html.glob("*.html")):
if fp.name not in selected_names:
print(f" SKIP {fp.name}")
continue
preset = parse_modlist_html(fp)
out = cfg.modlist_json / (preset["preset_name"] + ".json")
out.write_text(
json.dumps(preset, indent=2, ensure_ascii=False),
encoding="utf-8",
)
print(f" {fp.name}{out.name} ({preset['mod_count']} mods)")
presets.append(preset)
# Step 2 — Compare
_hdr("Step 2 / 4", "Compare presets")
result = compare_presets(*presets)
cfg.comparison.write_text(
json.dumps(result, indent=2, ensure_ascii=False),
encoding="utf-8",
)
total_unique = sum(v["mod_count"] for v in result["unique"].values())
print(f" Compared: {', '.join(result['compared_presets'])}")
print(f" Shared: {result['shared']['mod_count']} | "
f"Unique: {total_unique}")
# Step 3 — Fetch
_hdr("Step 3 / 4", "Download mods")
step_fetch(cfg)
# Step 4 — Link
_hdr("Step 4 / 4", "Link mods")
groups = (
sorted(p.name for p in cfg.downloads.iterdir() if p.is_dir())
if cfg.downloads.is_dir() else []
)
step_link(cfg, groups)
print("\n✓ Pipeline complete.\n")
except Exception as e:
print(f"\n✗ Error: {e}\n")
import traceback
traceback.print_exc()
finally:
self._restore_output()
self.after(0, self._pipeline_done)
threading.Thread(target=worker, daemon=True).start()
def run_tool(self, script_args: list[str]) -> None:
"""Run a maintenance script via subprocess, streaming output to Logs."""
script = script_args[0]
extra = script_args[1:]
def worker() -> None:
self.post_log(f"\n{''*50}\n {' '.join(script_args)}\n{''*50}\n")
try:
result = subprocess.run(
[sys.executable, str(PROJECT_ROOT / script)] + extra,
capture_output=True, text=True, cwd=str(PROJECT_ROOT),
)
if result.stdout:
self.post_log(result.stdout)
if result.stderr:
self.post_log(result.stderr)
ok = result.returncode == 0
self.post_log(
f"\n{'✓ Done' if ok else f'✗ Exited with code {result.returncode}'}.\n"
)
except Exception as e:
self.post_log(f"\n✗ Failed to start {script}: {e}\n")
threading.Thread(target=worker, daemon=True).start()
def load_selection(self) -> set[str]:
"""Return selected preset filenames, defaulting to all if no file saved."""
if SELECTION_FILE.exists():
try:
data = json.loads(SELECTION_FILE.read_text(encoding="utf-8"))
return set(data.get("selected", []))
except Exception:
pass
if self._cfg and self._cfg.modlist_html.is_dir():
return {f.name for f in self._cfg.modlist_html.glob("*.html")}
return set()
def save_selection(self, selected: set[str]) -> None:
SELECTION_FILE.write_text(
json.dumps({"selected": sorted(selected)}, indent=2),
encoding="utf-8",
)
def open_wizard(self) -> None:
SetupWizard(self, on_complete=self._after_wizard)
# =========================================================================
# Private — layout
# =========================================================================
def _build_layout(self) -> None:
self.grid_columnconfigure(1, weight=1)
self.grid_rowconfigure(0, weight=1)
# Sidebar
sb = ctk.CTkFrame(self, width=SIDEBAR_W, corner_radius=0)
sb.grid(row=0, column=0, sticky="nsew")
sb.grid_propagate(False)
sb.grid_rowconfigure(10, weight=1)
ctk.CTkLabel(
sb, text=APP_TITLE,
font=ctk.CTkFont(size=15, weight="bold"),
wraplength=SIDEBAR_W - 16, justify="center",
).grid(row=0, column=0, padx=12, pady=(22, 14))
self._nav_btns: dict[str, ctk.CTkButton] = {}
for i, name in enumerate(_VIEW_NAMES, start=1):
b = ctk.CTkButton(
sb, text=name, width=SIDEBAR_W - 24,
anchor="w", command=lambda n=name: self.navigate_to(n),
fg_color="transparent",
hover_color=("gray80", "gray30"),
text_color=("gray10", "gray90"),
)
b.grid(row=i, column=0, padx=12, pady=3)
self._nav_btns[name] = b
# Content area
self._content = ctk.CTkFrame(self, fg_color="transparent", corner_radius=0)
self._content.grid(row=0, column=1, sticky="nsew")
self._content.grid_columnconfigure(0, weight=1)
self._content.grid_rowconfigure(0, weight=1)
self.navigate_to("Dashboard")
def _nav_select(self, name: str) -> None:
for lbl, btn in self._nav_btns.items():
btn.configure(
fg_color=("gray75", "gray25") if lbl == name else "transparent"
)
# =========================================================================
# Private — config
# =========================================================================
def _load_config(self) -> None:
try:
from arma_modlist_tools.config import load_config
self._cfg = load_config(PROJECT_ROOT / "config.json")
except Exception as e:
self._cfg = None
self.post_log(f"[config] {e}\n")
def _after_wizard(self) -> None:
self._load_config()
# grid_forget before popping — navigate_to can't hide a view that's
# already been removed from _view_cache, so we do it manually first.
for name in ("Dashboard", "Settings"):
view = self._view_cache.pop(name, None)
if view is not None:
view.grid_forget()
view.lower()
self._active_name = ""
self.navigate_to("Dashboard")
# =========================================================================
# Private — logging
# =========================================================================
def _poll_log(self) -> None:
try:
from gui.views.logs import LogsView
logs_view = self._view_cache.get("Logs")
while True:
text = self._log_q.get_nowait()
if isinstance(logs_view, LogsView):
logs_view.append(text)
except queue.Empty:
pass
self.after(80, self._poll_log)
def _redirect_output(self) -> None:
writer = _QueueWriter(self._log_q)
sys.stdout = writer
sys.stderr = writer
def _restore_output(self) -> None:
sys.stdout = self._orig_stdout
sys.stderr = self._orig_stderr
# =========================================================================
# Private — pipeline lifecycle
# =========================================================================
def _pipeline_done(self) -> None:
self._pipeline_running = False
self._get_dashboard().set_pipeline_ui(running=False)
# Refresh all cached views so data is current without manual refresh
for view in self._view_cache.values():
view.refresh()
def _get_dashboard(self):
from gui.views.dashboard import DashboardView
view = self._view_cache.get("Dashboard")
if not isinstance(view, DashboardView):
# Build it if not yet visited (shouldn't normally happen)
view = DashboardView(self._content, self)
self._view_cache["Dashboard"] = view
return view
# ---------------------------------------------------------------------------
# Module-level helper
# ---------------------------------------------------------------------------
def _hdr(step: str, name: str) -> None:
print(f"\n{'='*50}")
print(f" {step}: {name}")
print(f"{'='*50}\n")