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

24
gui/__init__.py Normal file
View 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
View 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
View 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
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")

7
gui/views/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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()