diff --git a/gui.py b/gui.py new file mode 100644 index 0000000..15233f0 --- /dev/null +++ b/gui.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +""" +gui.py — Arma Mod Manager launcher. +The implementation lives in the gui/ package. +""" +from gui import run_app + +if __name__ == "__main__": + run_app() diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 0000000..8ea15c2 --- /dev/null +++ b/gui/__init__.py @@ -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"] diff --git a/gui/_constants.py b/gui/_constants.py new file mode 100644 index 0000000..7d9e5ce --- /dev/null +++ b/gui/_constants.py @@ -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" diff --git a/gui/_io.py b/gui/_io.py new file mode 100644 index 0000000..09a791e --- /dev/null +++ b/gui/_io.py @@ -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 diff --git a/gui/app.py b/gui/app.py new file mode 100644 index 0000000..711a8a8 --- /dev/null +++ b/gui/app.py @@ -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") diff --git a/gui/views/__init__.py b/gui/views/__init__.py new file mode 100644 index 0000000..ecba625 --- /dev/null +++ b/gui/views/__init__.py @@ -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"] diff --git a/gui/views/base.py b/gui/views/base.py new file mode 100644 index 0000000..9741d57 --- /dev/null +++ b/gui/views/base.py @@ -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.""" diff --git a/gui/views/dashboard.py b/gui/views/dashboard.py new file mode 100644 index 0000000..a94c260 --- /dev/null +++ b/gui/views/dashboard.py @@ -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() diff --git a/gui/views/logs.py b/gui/views/logs.py new file mode 100644 index 0000000..c886243 --- /dev/null +++ b/gui/views/logs.py @@ -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") diff --git a/gui/views/mods.py b/gui/views/mods.py new file mode 100644 index 0000000..f15c0f8 --- /dev/null +++ b/gui/views/mods.py @@ -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() diff --git a/gui/views/settings.py b/gui/views/settings.py new file mode 100644 index 0000000..15bd1cd --- /dev/null +++ b/gui/views/settings.py @@ -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)) diff --git a/gui/views/tools.py b/gui/views/tools.py new file mode 100644 index 0000000..77e7cf8 --- /dev/null +++ b/gui/views/tools.py @@ -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 diff --git a/gui/wizard.py b/gui/wizard.py new file mode 100644 index 0000000..9d20453 --- /dev/null +++ b/gui/wizard.py @@ -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() diff --git a/link_mods.py b/link_mods.py index 887ef5f..3ff5f28 100644 --- a/link_mods.py +++ b/link_mods.py @@ -99,7 +99,7 @@ def cmd_link(cfg, group: str) -> None: print() -def cmd_unlink(cfg, group: str) -> None: +def cmd_unlink(cfg, group: str, yes: bool = False) -> None: group_dir = cfg.downloads / group if not group_dir.is_dir(): print(f"ERROR: group folder not found: {group_dir}") @@ -118,10 +118,13 @@ def cmd_unlink(cfg, group: str) -> None: print(f" Group: {group} ({linked_count} linked mod(s))") print() - choice = input(" Continue? [y/N]: ").strip().lower() - if choice not in ("y", "yes"): - print(" Aborted.\n") - sys.exit(0) + if yes: + print(" (--yes flag set — skipping confirmation)") + else: + choice = input(" Continue? [y/N]: ").strip().lower() + if choice not in ("y", "yes"): + print(" Aborted.\n") + sys.exit(0) print() @@ -158,6 +161,8 @@ def main() -> None: ) parser.add_argument("command", choices=["status", "link", "unlink"]) parser.add_argument("--group", "-g", metavar="GROUP") + parser.add_argument("--yes", "-y", action="store_true", + help="Skip confirmation prompt (for non-interactive use)") args = parser.parse_args() if not args.group: @@ -176,7 +181,7 @@ def main() -> None: elif args.command == "link": cmd_link(cfg, args.group) elif args.command == "unlink": - cmd_unlink(cfg, args.group) + cmd_unlink(cfg, args.group, yes=args.yes) if __name__ == "__main__": diff --git a/modlist_html/Test_Preset_A.html b/modlist_html/Test_Preset_A.html new file mode 100644 index 0000000..549c38e --- /dev/null +++ b/modlist_html/Test_Preset_A.html @@ -0,0 +1,121 @@ + + + +
+ + ++ To import this preset, drag this file onto the Launcher window. Or click the MODS tab, then PRESET in the top right, then IMPORT at the bottom, and finally select this file. +
+| CBA_A3 | ++ Steam + | ++ https://steamcommunity.com/sharedfiles/filedetails/?id=450814997 + | +
| Zeus Enhanced | ++ Steam + | ++ https://steamcommunity.com/sharedfiles/filedetails/?id=1779063631 + | +
| Enhanced Movement | ++ Steam + | ++ https://steamcommunity.com/sharedfiles/filedetails/?id=333310405 + | +
+ To import this preset, drag this file onto the Launcher window. Or click the MODS tab, then PRESET in the top right, then IMPORT at the bottom, and finally select this file. +
+| CBA_A3 | ++ Steam + | ++ https://steamcommunity.com/sharedfiles/filedetails/?id=450814997 + | +
| Zeus Enhanced | ++ Steam + | ++ https://steamcommunity.com/sharedfiles/filedetails/?id=1779063631 + | +
| DUI - Squad Radar | ++ Steam + | ++ https://steamcommunity.com/sharedfiles/filedetails/?id=1638341685 + | +