Add GUI desktop application
- Add gui/ package: CustomTkinter app with dashboard, mods, tools, logs, and settings views; first-run SetupWizard for config.json generation - Add gui.py root entry point (calls gui.run_app()) - Add selection.json for GUI selection state persistence - Add customtkinter to requirements.txt - Fix link_mods.py minor issues surfaced during GUI integration - Add modlist_html/Test_Preset_A.html and Test_Preset_B.html as example inputs - Update README.md: add GUI prerequisites, gui.py script section, gui/ folder structure, customtkinter to prerequisites table - Update CLAUDE.md: add python gui.py to common commands, document GUI package architecture and views Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
24
gui/__init__.py
Normal file
24
gui/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
gui — Arma Mod Manager UI package.
|
||||
|
||||
Entry point:
|
||||
from gui import run_app
|
||||
run_app()
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
# Apply theme before any widget is created
|
||||
ctk.set_appearance_mode("dark")
|
||||
ctk.set_default_color_theme("blue")
|
||||
|
||||
|
||||
def run_app() -> None:
|
||||
"""Create and start the main window."""
|
||||
from gui.app import ArmaModManagerApp
|
||||
app = ArmaModManagerApp()
|
||||
app.mainloop()
|
||||
|
||||
|
||||
__all__ = ["run_app"]
|
||||
28
gui/_constants.py
Normal file
28
gui/_constants.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Window geometry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SIDEBAR_W: int = 190
|
||||
APP_TITLE: str = "Arma Mod Manager"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status colours
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
COLOR_OK: str = "#4CAF50"
|
||||
COLOR_PENDING: str = "#9E9E9E"
|
||||
COLOR_RUNNING: str = "#2196F3"
|
||||
COLOR_ERROR: str = "#F44336"
|
||||
COLOR_WARN: str = "#FF9800"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filesystem
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# gui/ lives one level below the project root
|
||||
PROJECT_ROOT: Path = Path(__file__).parent.parent
|
||||
SELECTION_FILE: Path = PROJECT_ROOT / "selection.json"
|
||||
19
gui/_io.py
Normal file
19
gui/_io.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import queue
|
||||
|
||||
|
||||
class _QueueWriter(io.TextIOBase):
|
||||
"""Redirect sys.stdout / sys.stderr into a Queue for the Logs panel."""
|
||||
|
||||
def __init__(self, q: queue.Queue[str]) -> None:
|
||||
self._q = q
|
||||
|
||||
def write(self, text: str) -> int: # type: ignore[override]
|
||||
if text:
|
||||
self._q.put(text)
|
||||
return len(text)
|
||||
|
||||
def flush(self) -> None:
|
||||
pass
|
||||
361
gui/app.py
Normal file
361
gui/app.py
Normal 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")
|
||||
7
gui/views/__init__.py
Normal file
7
gui/views/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from gui.views.dashboard import DashboardView
|
||||
from gui.views.mods import ModsView
|
||||
from gui.views.tools import ToolsView
|
||||
from gui.views.logs import LogsView
|
||||
from gui.views.settings import SettingsView
|
||||
|
||||
__all__ = ["DashboardView", "ModsView", "ToolsView", "LogsView", "SettingsView"]
|
||||
32
gui/views/base.py
Normal file
32
gui/views/base.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gui.app import ArmaModManagerApp
|
||||
|
||||
|
||||
class BaseView(ctk.CTkFrame):
|
||||
"""
|
||||
Common base for all view panels.
|
||||
|
||||
Each view is a CTkFrame owned by the app's content area. The app creates
|
||||
view instances once and caches them; it calls refresh() on each navigation
|
||||
so views can update their dynamic content without rebuilding the whole frame.
|
||||
|
||||
Subclasses must implement build() and may override refresh().
|
||||
"""
|
||||
|
||||
def __init__(self, parent: ctk.CTkFrame, app: ArmaModManagerApp) -> None:
|
||||
super().__init__(parent, fg_color="transparent")
|
||||
self.app = app
|
||||
self.build()
|
||||
|
||||
def build(self) -> None:
|
||||
"""Construct all child widgets. Called once from __init__."""
|
||||
raise NotImplementedError
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Re-query data and update dynamic widgets. Called on every navigation."""
|
||||
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()
|
||||
64
gui/views/logs.py
Normal file
64
gui/views/logs.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from gui._constants import COLOR_ERROR
|
||||
from gui.views.base import BaseView
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gui.app import ArmaModManagerApp
|
||||
|
||||
|
||||
class LogsView(BaseView):
|
||||
"""
|
||||
Monospace textbox showing captured stdout/stderr from pipeline and tools.
|
||||
|
||||
The app's poll loop appends text by calling append() directly on this view.
|
||||
Log content persists across navigation — the textbox is built once in build()
|
||||
and never recreated.
|
||||
"""
|
||||
|
||||
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, 8))
|
||||
ctk.CTkLabel(hdr, text="Logs",
|
||||
font=ctk.CTkFont(size=22, weight="bold")).pack(side="left")
|
||||
|
||||
btn_row = ctk.CTkFrame(hdr, fg_color="transparent")
|
||||
btn_row.pack(side="right")
|
||||
ctk.CTkButton(btn_row, text="Copy", width=72,
|
||||
command=self._copy).pack(side="left", padx=4)
|
||||
ctk.CTkButton(btn_row, text="Clear", width=72,
|
||||
fg_color=COLOR_ERROR, hover_color="#c62828",
|
||||
command=self._clear).pack(side="left")
|
||||
|
||||
# ── Log textbox (persistent) ──────────────────────────────────────────
|
||||
self._log_box = ctk.CTkTextbox(
|
||||
self, state="disabled",
|
||||
font=ctk.CTkFont(family="Consolas", size=12))
|
||||
self._log_box.grid(row=1, column=0, sticky="nsew", padx=24, pady=(0, 12))
|
||||
|
||||
def append(self, text: str) -> None:
|
||||
"""Thread-safe-ish: called from the app's after() poll loop (main thread)."""
|
||||
try:
|
||||
self._log_box.configure(state="normal")
|
||||
self._log_box.insert("end", text)
|
||||
self._log_box.see("end")
|
||||
self._log_box.configure(state="disabled")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _copy(self) -> None:
|
||||
self.clipboard_clear()
|
||||
self.clipboard_append(self._log_box.get("1.0", "end"))
|
||||
|
||||
def _clear(self) -> None:
|
||||
self._log_box.configure(state="normal")
|
||||
self._log_box.delete("1.0", "end")
|
||||
self._log_box.configure(state="disabled")
|
||||
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()
|
||||
79
gui/views/settings.py
Normal file
79
gui/views/settings.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from gui.views.base import BaseView
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gui.app import ArmaModManagerApp
|
||||
|
||||
|
||||
class SettingsView(BaseView):
|
||||
"""Appearance switcher, wizard re-opener, and current config display."""
|
||||
|
||||
def build(self) -> None:
|
||||
self.grid_columnconfigure(0, weight=1)
|
||||
self.grid_rowconfigure(1, weight=1)
|
||||
|
||||
ctk.CTkLabel(self, text="Settings",
|
||||
font=ctk.CTkFont(size=22, weight="bold")).grid(
|
||||
row=0, column=0, sticky="w", padx=24, pady=(20, 8))
|
||||
|
||||
self._scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
||||
self._scroll.grid(row=1, column=0, sticky="nsew", padx=24, pady=(0, 12))
|
||||
|
||||
self._build_cards()
|
||||
|
||||
def refresh(self) -> None:
|
||||
# Config info may have changed (e.g. after wizard); rebuild cards.
|
||||
for w in self._scroll.winfo_children():
|
||||
w.destroy()
|
||||
self._build_cards()
|
||||
|
||||
def _build_cards(self) -> None:
|
||||
# ── Server & Paths ────────────────────────────────────────────────────
|
||||
c1 = ctk.CTkFrame(self._scroll)
|
||||
c1.pack(fill="x", pady=6)
|
||||
ctk.CTkLabel(c1, text="Server & Path Configuration",
|
||||
font=ctk.CTkFont(size=14, weight="bold")).pack(
|
||||
anchor="w", padx=16, pady=(14, 3))
|
||||
ctk.CTkLabel(c1,
|
||||
text="Re-run the setup wizard to change your server URL, "
|
||||
"credentials, or Arma folder.",
|
||||
text_color="gray", wraplength=600, justify="left").pack(
|
||||
anchor="w", padx=16, pady=(0, 8))
|
||||
ctk.CTkButton(c1, text="Open Setup Wizard", width=160,
|
||||
command=self.app.open_wizard).pack(
|
||||
anchor="e", padx=16, pady=(0, 14))
|
||||
|
||||
# ── Appearance ────────────────────────────────────────────────────────
|
||||
c2 = ctk.CTkFrame(self._scroll)
|
||||
c2.pack(fill="x", pady=6)
|
||||
ctk.CTkLabel(c2, text="Appearance",
|
||||
font=ctk.CTkFont(size=14, weight="bold")).pack(
|
||||
anchor="w", padx=16, pady=(14, 3))
|
||||
mode_var = ctk.StringVar(value=ctk.get_appearance_mode())
|
||||
ctk.CTkOptionMenu(c2, values=["Dark", "Light", "System"],
|
||||
variable=mode_var,
|
||||
command=ctk.set_appearance_mode,
|
||||
width=140).pack(anchor="w", padx=16, pady=(0, 14))
|
||||
|
||||
# ── Current config info ───────────────────────────────────────────────
|
||||
cfg = self.app.cfg
|
||||
if cfg:
|
||||
c3 = ctk.CTkFrame(self._scroll)
|
||||
c3.pack(fill="x", pady=6)
|
||||
ctk.CTkLabel(c3, text="Current Configuration",
|
||||
font=ctk.CTkFont(size=14, weight="bold")).pack(
|
||||
anchor="w", padx=16, pady=(14, 3))
|
||||
info = (
|
||||
f"Server: {cfg.server_url}\n"
|
||||
f"Arma dir: {cfg.arma_dir}\n"
|
||||
f"Downloads: {cfg.downloads}\n"
|
||||
f"Presets: {cfg.modlist_html}\n"
|
||||
)
|
||||
ctk.CTkLabel(c3, text=info, justify="left",
|
||||
font=ctk.CTkFont(family="Consolas", size=11),
|
||||
text_color="gray").pack(anchor="w", padx=16, pady=(0, 14))
|
||||
409
gui/views/tools.py
Normal file
409
gui/views/tools.py
Normal file
@@ -0,0 +1,409 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from tkinter import messagebox
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from gui._constants import COLOR_WARN, PROJECT_ROOT
|
||||
from gui.views.base import BaseView
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gui.app import ArmaModManagerApp
|
||||
|
||||
_WARN_COLOR = COLOR_WARN
|
||||
|
||||
|
||||
class ToolsView(BaseView):
|
||||
"""Per-tool panels inside a CTkTabview."""
|
||||
|
||||
def build(self) -> None:
|
||||
self.grid_columnconfigure(0, weight=1)
|
||||
self.grid_rowconfigure(1, weight=1)
|
||||
|
||||
ctk.CTkLabel(self, text="Tools",
|
||||
font=ctk.CTkFont(size=22, weight="bold")).grid(
|
||||
row=0, column=0, sticky="w", padx=24, pady=(20, 8))
|
||||
|
||||
self._tab_view = ctk.CTkTabview(self)
|
||||
self._tab_view.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12))
|
||||
|
||||
# Per-tab group menu references so refresh() can repopulate them all
|
||||
self._group_menus: list[tuple[ctk.CTkOptionMenu, ctk.StringVar]] = []
|
||||
|
||||
self._build_check_names_tab()
|
||||
self._build_update_mods_tab()
|
||||
self._build_link_mods_tab()
|
||||
self._build_sync_missing_tab()
|
||||
self._build_report_missing_tab()
|
||||
|
||||
# =========================================================================
|
||||
# Public
|
||||
# =========================================================================
|
||||
|
||||
def refresh(self) -> None:
|
||||
groups = self._get_groups()
|
||||
all_groups = ["All groups"] + groups
|
||||
|
||||
# Repopulate generic group menus
|
||||
for menu, var in self._group_menus:
|
||||
prev = var.get()
|
||||
menu.configure(values=all_groups)
|
||||
if prev not in all_groups:
|
||||
var.set("All groups")
|
||||
|
||||
# Link Mods group menu (no "All groups")
|
||||
lm_prev = self._lm_group_var.get()
|
||||
lm_vals = groups if groups else ["(no groups found)"]
|
||||
self._lm_group_menu.configure(values=lm_vals)
|
||||
if lm_prev not in lm_vals:
|
||||
self._lm_group_var.set(lm_vals[0])
|
||||
self._lm_on_change() # re-evaluate button state
|
||||
|
||||
# Info labels
|
||||
self._update_sm_label()
|
||||
self._update_rm_label()
|
||||
|
||||
# =========================================================================
|
||||
# Private — tab builders
|
||||
# =========================================================================
|
||||
|
||||
def _build_check_names_tab(self) -> None:
|
||||
self._tab_view.add("Check Names")
|
||||
tab = self._tab_view.tab("Check Names")
|
||||
tab.grid_columnconfigure(0, weight=1)
|
||||
|
||||
_desc(tab, row=0,
|
||||
text="Scan mod folders and compare against the server. "
|
||||
"Reports naming mismatches (MISMATCH), unrecognised folders "
|
||||
"(NOT_ON_SERVER), and wrong Steam IDs in meta.cpp (ID_COLLISION).")
|
||||
|
||||
# Group
|
||||
gf = _row(tab, row=1, label="Group:")
|
||||
self._cn_group_var = ctk.StringVar(value="All groups")
|
||||
menu = ctk.CTkOptionMenu(gf, variable=self._cn_group_var,
|
||||
values=["All groups"], width=200)
|
||||
menu.pack(side="left")
|
||||
self._group_menus.append((menu, self._cn_group_var))
|
||||
|
||||
# Checkboxes
|
||||
cf = _row(tab, row=2, label="Options:")
|
||||
self._cn_fix_var = ctk.BooleanVar(value=False)
|
||||
self._cn_fix_ids_var = ctk.BooleanVar(value=False)
|
||||
ctk.CTkCheckBox(cf, text="Auto-fix folder name mismatches (--fix)",
|
||||
variable=self._cn_fix_var,
|
||||
command=self._cn_on_toggle).pack(side="left", padx=(0, 16))
|
||||
ctk.CTkCheckBox(cf, text="Auto-fix wrong Steam IDs in meta.cpp (--fix-ids)",
|
||||
variable=self._cn_fix_ids_var,
|
||||
command=self._cn_on_toggle).pack(side="left")
|
||||
|
||||
# Warning (hidden until checkbox ticked)
|
||||
self._cn_warn = ctk.CTkLabel(
|
||||
tab,
|
||||
text="⚠ --fix renames folders and updates junctions. "
|
||||
"--fix-ids rewrites meta.cpp files.",
|
||||
text_color=_WARN_COLOR, anchor="w",
|
||||
)
|
||||
# not gridded yet — shown on demand
|
||||
|
||||
# Run button
|
||||
ctk.CTkButton(tab, text="Run Check Names", width=180,
|
||||
command=self._cn_run).grid(
|
||||
row=10, column=0, padx=24, pady=(16, 24), sticky="e")
|
||||
|
||||
def _cn_on_toggle(self) -> None:
|
||||
if self._cn_fix_var.get() or self._cn_fix_ids_var.get():
|
||||
self._cn_warn.grid(row=5, column=0, padx=24, pady=(4, 0), sticky="w")
|
||||
else:
|
||||
self._cn_warn.grid_forget()
|
||||
|
||||
def _cn_run(self) -> None:
|
||||
args = ["check_names.py"]
|
||||
g = self._cn_group_var.get()
|
||||
if g != "All groups":
|
||||
args += ["--group", g]
|
||||
if self._cn_fix_var.get():
|
||||
args.append("--fix")
|
||||
if self._cn_fix_ids_var.get():
|
||||
args.append("--fix-ids")
|
||||
self._launch(args)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _build_update_mods_tab(self) -> None:
|
||||
self._tab_view.add("Update Mods")
|
||||
tab = self._tab_view.tab("Update Mods")
|
||||
tab.grid_columnconfigure(0, weight=1)
|
||||
|
||||
_desc(tab, row=0,
|
||||
text="Re-download mod files whose size on the server differs from "
|
||||
"your local copy. Use --force to re-download everything "
|
||||
"regardless of size.")
|
||||
|
||||
# Group
|
||||
gf = _row(tab, row=1, label="Group:")
|
||||
self._um_group_var = ctk.StringVar(value="All groups")
|
||||
um_menu = ctk.CTkOptionMenu(
|
||||
gf, variable=self._um_group_var, values=["All groups"], width=200,
|
||||
command=self._um_on_group_change,
|
||||
)
|
||||
um_menu.pack(side="left")
|
||||
self._group_menus.append((um_menu, self._um_group_var))
|
||||
|
||||
# Mod name (enabled only when a specific group is selected)
|
||||
mf = _row(tab, row=2, label="Mod folder:")
|
||||
self._um_mod_entry = ctk.CTkEntry(
|
||||
mf, placeholder_text="Optional — e.g. @ace", width=220,
|
||||
state="disabled",
|
||||
)
|
||||
self._um_mod_entry.pack(side="left")
|
||||
ctk.CTkLabel(mf, text="(only when a specific group is selected)",
|
||||
text_color="gray").pack(side="left", padx=8)
|
||||
|
||||
# Force checkbox
|
||||
ff = _row(tab, row=3, label="Options:")
|
||||
self._um_force_var = ctk.BooleanVar(value=False)
|
||||
ctk.CTkCheckBox(
|
||||
ff, text="Force re-download all files (--force)",
|
||||
variable=self._um_force_var,
|
||||
command=self._um_on_toggle,
|
||||
).pack(side="left")
|
||||
|
||||
# Warning
|
||||
self._um_warn = ctk.CTkLabel(
|
||||
tab,
|
||||
text="⚠ --force re-downloads every file regardless of size. "
|
||||
"This may transfer a large amount of data.",
|
||||
text_color=_WARN_COLOR, anchor="w",
|
||||
)
|
||||
|
||||
ctk.CTkButton(tab, text="Run Update", width=180,
|
||||
command=self._um_run).grid(
|
||||
row=10, column=0, padx=24, pady=(16, 24), sticky="e")
|
||||
|
||||
def _um_on_group_change(self, _: str) -> None:
|
||||
is_specific = self._um_group_var.get() != "All groups"
|
||||
self._um_mod_entry.configure(state="normal" if is_specific else "disabled")
|
||||
if not is_specific:
|
||||
self._um_mod_entry.delete(0, "end")
|
||||
|
||||
def _um_on_toggle(self) -> None:
|
||||
if self._um_force_var.get():
|
||||
self._um_warn.grid(row=5, column=0, padx=24, pady=(4, 0), sticky="w")
|
||||
else:
|
||||
self._um_warn.grid_forget()
|
||||
|
||||
def _um_run(self) -> None:
|
||||
args = ["update_mods.py"]
|
||||
g = self._um_group_var.get()
|
||||
if g != "All groups":
|
||||
args += ["--group", g]
|
||||
mod = self._um_mod_entry.get().strip()
|
||||
if mod:
|
||||
args += ["--mod", mod]
|
||||
if self._um_force_var.get():
|
||||
args.append("--force")
|
||||
self._launch(args)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _build_link_mods_tab(self) -> None:
|
||||
self._tab_view.add("Link Mods")
|
||||
tab = self._tab_view.tab("Link Mods")
|
||||
tab.grid_columnconfigure(0, weight=1)
|
||||
|
||||
_desc(tab, row=0,
|
||||
text="Manage junction/symlink links between your downloads folder "
|
||||
"and the Arma 3 directory.\n"
|
||||
"Status — show what's linked. "
|
||||
"Link — create missing junctions. "
|
||||
"Unlink — remove junctions (mod files are NOT deleted).")
|
||||
|
||||
# Command selector
|
||||
cf = _row(tab, row=1, label="Command:")
|
||||
self._lm_cmd_var = ctk.StringVar(value="Status")
|
||||
ctk.CTkSegmentedButton(
|
||||
cf,
|
||||
values=["Status", "Link", "Unlink"],
|
||||
variable=self._lm_cmd_var,
|
||||
command=self._lm_on_change,
|
||||
).pack(side="left")
|
||||
|
||||
# Group (required — no "All groups")
|
||||
gf = _row(tab, row=2, label="Group:")
|
||||
self._lm_group_var = ctk.StringVar(value="")
|
||||
self._lm_group_menu = ctk.CTkOptionMenu(
|
||||
gf, variable=self._lm_group_var,
|
||||
values=["(no groups found)"], width=200,
|
||||
command=lambda _: self._lm_on_change(),
|
||||
)
|
||||
self._lm_group_menu.pack(side="left")
|
||||
|
||||
# Warning (shown for Unlink)
|
||||
self._lm_warn = ctk.CTkLabel(
|
||||
tab,
|
||||
text="⚠ Unlink removes junction links from the Arma 3 directory. "
|
||||
"Mod files in downloads/ are NOT deleted.",
|
||||
text_color=_WARN_COLOR, anchor="w",
|
||||
)
|
||||
|
||||
# Run button (label changes with command)
|
||||
self._lm_run_btn = ctk.CTkButton(
|
||||
tab, text="Show Status", width=180,
|
||||
command=self._lm_run,
|
||||
)
|
||||
self._lm_run_btn.grid(row=10, column=0, padx=24, pady=(16, 24), sticky="e")
|
||||
|
||||
def _lm_on_change(self, _: str = "") -> None:
|
||||
cmd = self._lm_cmd_var.get()
|
||||
labels = {"Status": "Show Status", "Link": "Create Links", "Unlink": "Remove Links"}
|
||||
self._lm_run_btn.configure(text=labels.get(cmd, cmd))
|
||||
|
||||
if cmd == "Unlink":
|
||||
self._lm_warn.grid(row=5, column=0, padx=24, pady=(4, 0), sticky="w")
|
||||
else:
|
||||
self._lm_warn.grid_forget()
|
||||
|
||||
def _lm_run(self) -> None:
|
||||
cmd = self._lm_cmd_var.get().lower()
|
||||
group = self._lm_group_var.get()
|
||||
|
||||
if not group or group == "(no groups found)":
|
||||
messagebox.showwarning("No group selected",
|
||||
"Please select a group from the dropdown.")
|
||||
return
|
||||
|
||||
args = ["link_mods.py", cmd, "--group", group]
|
||||
|
||||
if cmd == "unlink":
|
||||
confirmed = messagebox.askyesno(
|
||||
"Confirm Unlink",
|
||||
f"Remove junction links for group '{group}'?\n\n"
|
||||
"This removes links from the Arma 3 directory but does NOT delete "
|
||||
"mod files in downloads/.",
|
||||
)
|
||||
if not confirmed:
|
||||
return
|
||||
args.append("--yes")
|
||||
|
||||
self._launch(args)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _build_sync_missing_tab(self) -> None:
|
||||
self._tab_view.add("Sync Missing")
|
||||
tab = self._tab_view.tab("Sync Missing")
|
||||
tab.grid_columnconfigure(0, weight=1)
|
||||
|
||||
_desc(tab, row=0,
|
||||
text="Retry downloading mods that were missing from the server "
|
||||
"when you last ran the pipeline. "
|
||||
"Checks the server again and downloads any that have since appeared.")
|
||||
|
||||
self._sm_info = ctk.CTkLabel(tab, text="", text_color="gray", anchor="w")
|
||||
self._sm_info.grid(row=1, column=0, padx=24, pady=(4, 0), sticky="w")
|
||||
|
||||
ctk.CTkButton(tab, text="Run Sync Missing", width=180,
|
||||
command=lambda: self._launch(["sync_missing.py"])).grid(
|
||||
row=10, column=0, padx=24, pady=(16, 24), sticky="e")
|
||||
|
||||
def _update_sm_label(self) -> None:
|
||||
cfg = self.app.cfg
|
||||
if not cfg:
|
||||
self._sm_info.configure(text="")
|
||||
return
|
||||
report_path = cfg.missing_report if hasattr(cfg, "missing_report") else (
|
||||
cfg.modlist_json / "missing_report.json"
|
||||
if hasattr(cfg, "modlist_json") else None
|
||||
)
|
||||
if report_path and report_path.exists():
|
||||
try:
|
||||
data = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
count = data.get("missing", len(data.get("missing_mods", [])))
|
||||
self._sm_info.configure(
|
||||
text=f"{count} mod(s) currently listed as missing.")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
self._sm_info.configure(
|
||||
text="No missing_report.json found — run the pipeline first.")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _build_report_missing_tab(self) -> None:
|
||||
self._tab_view.add("Report Missing")
|
||||
tab = self._tab_view.tab("Report Missing")
|
||||
tab.grid_columnconfigure(0, weight=1)
|
||||
|
||||
_desc(tab, row=0,
|
||||
text="Check which mods from comparison.json are absent from the "
|
||||
"file server. Saves missing_report.json so you can track what "
|
||||
"still needs to be added to the server.")
|
||||
|
||||
self._rm_info = ctk.CTkLabel(tab, text="", text_color="gray", anchor="w")
|
||||
self._rm_info.grid(row=1, column=0, padx=24, pady=(4, 0), sticky="w")
|
||||
|
||||
ctk.CTkButton(tab, text="Generate Report", width=180,
|
||||
command=lambda: self._launch(["report_missing.py"])).grid(
|
||||
row=10, column=0, padx=24, pady=(16, 24), sticky="e")
|
||||
|
||||
def _update_rm_label(self) -> None:
|
||||
cfg = self.app.cfg
|
||||
if not cfg:
|
||||
self._rm_info.configure(text="")
|
||||
return
|
||||
report_path = cfg.missing_report if hasattr(cfg, "missing_report") else (
|
||||
cfg.modlist_json / "missing_report.json"
|
||||
if hasattr(cfg, "modlist_json") else None
|
||||
)
|
||||
if report_path and report_path.exists():
|
||||
try:
|
||||
data = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
ts = data.get("generated_at", "unknown")
|
||||
self._rm_info.configure(text=f"Last generated: {ts}")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
self._rm_info.configure(text="No report yet.")
|
||||
|
||||
# =========================================================================
|
||||
# Private — helpers
|
||||
# =========================================================================
|
||||
|
||||
def _get_groups(self) -> list[str]:
|
||||
cfg = self.app.cfg
|
||||
if cfg and cfg.downloads.is_dir():
|
||||
return sorted(p.name for p in cfg.downloads.iterdir() if p.is_dir())
|
||||
# Fallback: read comparison.json
|
||||
if cfg and cfg.comparison.exists():
|
||||
try:
|
||||
comp = json.loads(cfg.comparison.read_text(encoding="utf-8"))
|
||||
return ["shared"] + list(comp.get("unique", {}).keys())
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
def _launch(self, args: list[str]) -> None:
|
||||
self.app.navigate_to("Logs")
|
||||
self.app.run_tool(args)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layout helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _desc(parent, row: int, text: str) -> ctk.CTkLabel:
|
||||
lbl = ctk.CTkLabel(parent, text=text, justify="left",
|
||||
wraplength=700, text_color="gray", anchor="w")
|
||||
lbl.grid(row=row, column=0, padx=24, pady=(16, 8), sticky="ew")
|
||||
return lbl
|
||||
|
||||
|
||||
def _row(parent, row: int, label: str) -> ctk.CTkFrame:
|
||||
"""A label + horizontal frame for a settings row."""
|
||||
ctk.CTkLabel(parent, text=label, anchor="w", width=110).grid(
|
||||
row=row, column=0, padx=(24, 0), pady=6, sticky="w")
|
||||
f = ctk.CTkFrame(parent, fg_color="transparent")
|
||||
f.grid(row=row, column=0, padx=(140, 24), pady=6, sticky="w")
|
||||
return f
|
||||
187
gui/wizard.py
Normal file
187
gui/wizard.py
Normal file
@@ -0,0 +1,187 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Callable
|
||||
|
||||
import customtkinter as ctk
|
||||
from tkinter import filedialog
|
||||
|
||||
from gui._constants import COLOR_OK, COLOR_ERROR, PROJECT_ROOT
|
||||
|
||||
|
||||
class SetupWizard(ctk.CTkToplevel):
|
||||
"""Modal first-run wizard that writes config.json."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: ctk.CTk,
|
||||
on_complete: Callable[[], None],
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.title("Setup — Arma Mod Manager")
|
||||
self.geometry("500x420")
|
||||
self.resizable(False, False)
|
||||
self.grab_set()
|
||||
self.lift()
|
||||
self.focus_force()
|
||||
|
||||
self._on_complete = on_complete
|
||||
self._url = ctk.StringVar(value="https://")
|
||||
self._user = ctk.StringVar()
|
||||
self._pw = ctk.StringVar()
|
||||
self._arma = ctk.StringVar()
|
||||
|
||||
self._body = ctk.CTkFrame(self, fg_color="transparent")
|
||||
self._body.pack(fill="both", expand=True, padx=28, pady=24)
|
||||
|
||||
self._show(0)
|
||||
|
||||
def _clear(self) -> None:
|
||||
for w in self._body.winfo_children():
|
||||
w.destroy()
|
||||
|
||||
def _show(self, step: int) -> None:
|
||||
self._clear()
|
||||
[self._page_server, self._page_paths, self._page_review][step]()
|
||||
|
||||
# ── Page 1: server ───────────────────────────────────────────────────────
|
||||
|
||||
def _page_server(self) -> None:
|
||||
ctk.CTkLabel(
|
||||
self._body, text="Step 1 of 3 — Server Connection",
|
||||
font=ctk.CTkFont(size=16, weight="bold"),
|
||||
).pack(anchor="w")
|
||||
ctk.CTkLabel(
|
||||
self._body, text="Enter the details for your Caddy mod server.",
|
||||
text_color="gray",
|
||||
).pack(anchor="w", pady=(4, 18))
|
||||
|
||||
for lbl, var, show in [
|
||||
("Server URL", self._url, ""),
|
||||
("Username", self._user, ""),
|
||||
("Password", self._pw, "•"),
|
||||
]:
|
||||
ctk.CTkLabel(self._body, text=lbl).pack(anchor="w")
|
||||
ctk.CTkEntry(self._body, textvariable=var, width=440, show=show).pack(
|
||||
anchor="w", pady=(2, 10))
|
||||
|
||||
foot = ctk.CTkFrame(self._body, fg_color="transparent")
|
||||
foot.pack(fill="x", pady=(8, 0))
|
||||
self._conn_lbl = ctk.CTkLabel(foot, text="", text_color="gray")
|
||||
self._conn_lbl.pack(side="left")
|
||||
ctk.CTkButton(foot, text="Next →", width=90,
|
||||
command=lambda: self._show(1)).pack(side="right")
|
||||
ctk.CTkButton(foot, text="Test Connection", width=140,
|
||||
fg_color="transparent", border_width=1,
|
||||
text_color=("gray10", "gray90"),
|
||||
command=self._test).pack(side="right", padx=(0, 8))
|
||||
|
||||
def _test(self) -> None:
|
||||
self._conn_lbl.configure(text="Testing…", text_color="gray")
|
||||
self.update()
|
||||
try:
|
||||
import requests
|
||||
r = requests.get(self._url.get(),
|
||||
auth=(self._user.get(), self._pw.get()),
|
||||
timeout=8)
|
||||
if r.ok:
|
||||
self._conn_lbl.configure(text="✓ Connected", text_color=COLOR_OK)
|
||||
else:
|
||||
self._conn_lbl.configure(text=f"✗ HTTP {r.status_code}",
|
||||
text_color=COLOR_ERROR)
|
||||
except Exception as e:
|
||||
self._conn_lbl.configure(text=f"✗ {e}", text_color=COLOR_ERROR)
|
||||
|
||||
# ── Page 2: paths ────────────────────────────────────────────────────────
|
||||
|
||||
def _page_paths(self) -> None:
|
||||
ctk.CTkLabel(
|
||||
self._body, text="Step 2 of 3 — Arma 3 Server Folder",
|
||||
font=ctk.CTkFont(size=16, weight="bold"),
|
||||
).pack(anchor="w")
|
||||
ctk.CTkLabel(
|
||||
self._body,
|
||||
text="Point to your Arma 3 Server installation. "
|
||||
"Links (junctions) will be created here.",
|
||||
text_color="gray", wraplength=440, justify="left",
|
||||
).pack(anchor="w", pady=(4, 18))
|
||||
|
||||
ctk.CTkLabel(self._body, text="Arma 3 Server folder").pack(anchor="w")
|
||||
row = ctk.CTkFrame(self._body, fg_color="transparent")
|
||||
row.pack(fill="x", pady=(2, 8))
|
||||
ctk.CTkEntry(row, textvariable=self._arma, width=350).pack(side="left")
|
||||
ctk.CTkButton(row, text="Browse", width=80,
|
||||
command=self._browse_arma).pack(side="left", padx=8)
|
||||
|
||||
ctk.CTkLabel(
|
||||
self._body,
|
||||
text="All other folders (downloads, presets) will be created "
|
||||
"automatically next to this tool.",
|
||||
text_color="gray", font=ctk.CTkFont(size=11),
|
||||
wraplength=440, justify="left",
|
||||
).pack(anchor="w", pady=(8, 0))
|
||||
|
||||
foot = ctk.CTkFrame(self._body, fg_color="transparent")
|
||||
foot.pack(fill="x", pady=(20, 0))
|
||||
ctk.CTkButton(foot, text="← Back", width=80,
|
||||
fg_color="transparent", border_width=1,
|
||||
text_color=("gray10", "gray90"),
|
||||
command=lambda: self._show(0)).pack(side="left")
|
||||
ctk.CTkButton(foot, text="Next →", width=80,
|
||||
command=lambda: self._show(2)).pack(side="right")
|
||||
|
||||
def _browse_arma(self) -> None:
|
||||
d = filedialog.askdirectory(title="Select Arma 3 Server folder")
|
||||
if d:
|
||||
self._arma.set(d)
|
||||
|
||||
# ── Page 3: review + save ────────────────────────────────────────────────
|
||||
|
||||
def _page_review(self) -> None:
|
||||
ctk.CTkLabel(
|
||||
self._body, text="Step 3 of 3 — Review & Save",
|
||||
font=ctk.CTkFont(size=16, weight="bold"),
|
||||
).pack(anchor="w")
|
||||
ctk.CTkLabel(
|
||||
self._body, text="Check your settings, then click Save.",
|
||||
text_color="gray",
|
||||
).pack(anchor="w", pady=(4, 14))
|
||||
|
||||
summary = (
|
||||
f"Server URL: {self._url.get()}\n"
|
||||
f"Username: {self._user.get()}\n"
|
||||
f"Arma folder: {self._arma.get() or '(not set)'}\n"
|
||||
)
|
||||
box = ctk.CTkTextbox(self._body, height=90,
|
||||
font=ctk.CTkFont(family="Consolas", size=12))
|
||||
box.insert("1.0", summary)
|
||||
box.configure(state="disabled")
|
||||
box.pack(fill="x", pady=(0, 16))
|
||||
|
||||
foot = ctk.CTkFrame(self._body, fg_color="transparent")
|
||||
foot.pack(fill="x")
|
||||
ctk.CTkButton(foot, text="← Back", width=80,
|
||||
fg_color="transparent", border_width=1,
|
||||
text_color=("gray10", "gray90"),
|
||||
command=lambda: self._show(1)).pack(side="left")
|
||||
ctk.CTkButton(foot, text="Save & Open", width=120,
|
||||
command=self._save).pack(side="right")
|
||||
|
||||
def _save(self) -> None:
|
||||
cfg = {
|
||||
"server": {
|
||||
"base_url": self._url.get(),
|
||||
"username": self._user.get(),
|
||||
"password": self._pw.get(),
|
||||
},
|
||||
"paths": {
|
||||
"arma_dir": self._arma.get(),
|
||||
"downloads": "downloads",
|
||||
"modlist_html": "modlist_html",
|
||||
"modlist_json": "modlist_json",
|
||||
},
|
||||
}
|
||||
(PROJECT_ROOT / "config.json").write_text(
|
||||
json.dumps(cfg, indent=2), encoding="utf-8")
|
||||
self.destroy()
|
||||
self._on_complete()
|
||||
Reference in New Issue
Block a user