Compare commits
2 Commits
5b497cf414
...
85fdfebd74
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85fdfebd74 | ||
|
|
57895a04d3 |
23
CLAUDE.md
23
CLAUDE.md
@@ -20,6 +20,9 @@ python run.py --skip-fetch --skip-link
|
|||||||
# Diagnose mod folder name / steam_id issues
|
# Diagnose mod folder name / steam_id issues
|
||||||
python check_names.py
|
python check_names.py
|
||||||
python check_names.py --fix --fix-ids
|
python check_names.py --fix --fix-ids
|
||||||
|
|
||||||
|
# Launch the GUI
|
||||||
|
python gui.py
|
||||||
```
|
```
|
||||||
|
|
||||||
There is no build step, linter config, or package install beyond `pip install -r requirements.txt`.
|
There is no build step, linter config, or package install beyond `pip install -r requirements.txt`.
|
||||||
@@ -80,6 +83,26 @@ Pass 2 builds `ok_disk_names` — the set of disk names that already match the s
|
|||||||
|
|
||||||
`--fix-ids` corrects `meta.cpp` using steam IDs from `comparison.json` (sourced from Steam Workshop URLs in the HTML presets) as the authoritative source.
|
`--fix-ids` corrects `meta.cpp` using steam IDs from `comparison.json` (sourced from Steam Workshop URLs in the HTML presets) as the authoritative source.
|
||||||
|
|
||||||
|
### GUI package
|
||||||
|
|
||||||
|
`gui/` is a CustomTkinter desktop application wrapping the CLI toolchain. Entry point is `gui.py` at the project root, which calls `gui.run_app()`.
|
||||||
|
|
||||||
|
**Key files:**
|
||||||
|
- `gui/__init__.py` — sets dark theme + blue color scheme; exports `run_app()`
|
||||||
|
- `gui/app.py` — `ArmaModManagerApp` main window; manages view routing, config loading, thread-safe log queue, and background pipeline execution
|
||||||
|
- `gui/wizard.py` — `SetupWizard` dialog shown on first launch when no `config.json` exists
|
||||||
|
- `gui/_constants.py` — window dimensions, status color constants, file paths
|
||||||
|
- `gui/_io.py` — `_QueueWriter` redirects stdout/stderr to a thread-safe queue so pipeline output streams into the Logs view
|
||||||
|
|
||||||
|
**Views** (`gui/views/`): each inherits `BaseView`; `build()` runs once on creation, `refresh()` runs on each navigation:
|
||||||
|
- `dashboard.py` — overview, status, quick stats
|
||||||
|
- `mods.py` — browse and manage downloaded mods by group
|
||||||
|
- `tools.py` — link/unlink, rename folders, sync missing mods, check server
|
||||||
|
- `logs.py` — real-time log viewer fed from the stdout/stderr queue
|
||||||
|
- `settings.py` — in-app editor for `config.json` (server URL, paths, credentials)
|
||||||
|
|
||||||
|
**`selection.json`** — GUI selection state file, tracked in git. Persists which mods/groups are selected between GUI sessions. Written by the GUI; safe to delete (GUI recreates it on next save).
|
||||||
|
|
||||||
## Python Version Compatibility
|
## Python Version Compatibility
|
||||||
|
|
||||||
Minimum is Python **3.9**. All files that use `X | Y` union type annotations **must** have `from __future__ import annotations` as the first import. Without it, the `|` syntax raises `TypeError` at runtime on Python < 3.10. Every module in `arma_modlist_tools/` already has it; any new CLI script you add must include it too.
|
Minimum is Python **3.9**. All files that use `X | Y` union type annotations **must** have `from __future__ import annotations` as the first import. Without it, the `|` syntax raises `TypeError` at runtime on Python < 3.10. Every module in `arma_modlist_tools/` already has it; any new CLI script you add must include it too.
|
||||||
|
|||||||
41
README.md
41
README.md
@@ -21,6 +21,7 @@ Python toolchain for managing Arma 3 mod presets: parse launcher exports, compar
|
|||||||
- [update_mods.py](#update_modspy)
|
- [update_mods.py](#update_modspy)
|
||||||
- [check_names.py](#check_namespy)
|
- [check_names.py](#check_namespy)
|
||||||
- [run.py](#runpy)
|
- [run.py](#runpy)
|
||||||
|
- [gui.py](#guipy)
|
||||||
6. [Migrating Existing Mods](#migrating-existing-mods)
|
6. [Migrating Existing Mods](#migrating-existing-mods)
|
||||||
7. [Folder Structure](#folder-structure)
|
7. [Folder Structure](#folder-structure)
|
||||||
8. [Moving to a New Device](#moving-to-a-new-device)
|
8. [Moving to a New Device](#moving-to-a-new-device)
|
||||||
@@ -35,6 +36,7 @@ Python toolchain for managing Arma 3 mod presets: parse launcher exports, compar
|
|||||||
| Python | >= 3.9 | `python --version` |
|
| Python | >= 3.9 | `python --version` |
|
||||||
| requests | any | `pip install requests` |
|
| requests | any | `pip install requests` |
|
||||||
| tqdm | any | `pip install tqdm` |
|
| tqdm | any | `pip install tqdm` |
|
||||||
|
| customtkinter | any | `pip install customtkinter` (GUI only) |
|
||||||
| Windows or Linux | — | Windows uses junctions, Linux uses symlinks |
|
| Windows or Linux | — | Windows uses junctions, Linux uses symlinks |
|
||||||
|
|
||||||
Run the dep checker to confirm everything is ready:
|
Run the dep checker to confirm everything is ready:
|
||||||
@@ -472,6 +474,30 @@ Orchestrator that chains all four pipeline steps. Described in [Quick Start](#qu
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### gui.py
|
||||||
|
|
||||||
|
Launch the graphical interface for the toolchain.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python gui.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Opens a CustomTkinter desktop window with a sidebar navigation and the following views:
|
||||||
|
|
||||||
|
| View | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| Dashboard | Overview: status, quick stats, recent activity |
|
||||||
|
| Mods | Browse and manage downloaded mods by group |
|
||||||
|
| Tools | Link/unlink, rename, sync missing, check server |
|
||||||
|
| Logs | Real-time log output from pipeline operations |
|
||||||
|
| Settings | Edit `config.json` (server URL, paths, credentials) |
|
||||||
|
|
||||||
|
On first launch (no `config.json`), a setup wizard walks you through creating one.
|
||||||
|
|
||||||
|
**Requires:** `customtkinter` (`pip install customtkinter`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Migrating Existing Mods
|
## Migrating Existing Mods
|
||||||
|
|
||||||
If the Arma 3 Server already has mods installed and you want to bring them under this toolchain without re-downloading:
|
If the Arma 3 Server already has mods installed and you want to bring them under this toolchain without re-downloading:
|
||||||
@@ -520,6 +546,19 @@ arma-modlist-tools/
|
|||||||
| |- config.py # config.json loader
|
| |- config.py # config.json loader
|
||||||
| |- compat.py # OS detection + encoding fix
|
| |- compat.py # OS detection + encoding fix
|
||||||
|
|
|
|
||||||
|
|- gui/ # GUI package (CustomTkinter desktop app)
|
||||||
|
| |- __init__.py # Theme setup + run_app() entry point
|
||||||
|
| |- app.py # Main window, view management, pipeline runner
|
||||||
|
| |- wizard.py # First-run config setup wizard
|
||||||
|
| |- _constants.py # Window size, color, path constants
|
||||||
|
| |- _io.py # stdout/stderr → thread-safe queue for live logs
|
||||||
|
| |- views/
|
||||||
|
| |- dashboard.py # Overview view
|
||||||
|
| |- mods.py # Mod browser view
|
||||||
|
| |- tools.py # Tool actions view
|
||||||
|
| |- logs.py # Real-time log view
|
||||||
|
| |- settings.py # Config editor view
|
||||||
|
|
|
||||||
|- modlist_html/ # INPUT: put your .html preset exports here
|
|- modlist_html/ # INPUT: put your .html preset exports here
|
||||||
| |- MyPreset_A.html
|
| |- MyPreset_A.html
|
||||||
| |- MyPreset_B.html
|
| |- MyPreset_B.html
|
||||||
@@ -540,7 +579,9 @@ arma-modlist-tools/
|
|||||||
|- config.json # YOUR config (gitignored — contains credentials)
|
|- config.json # YOUR config (gitignored — contains credentials)
|
||||||
|- config.template.json # Template to copy from
|
|- config.template.json # Template to copy from
|
||||||
|- requirements.txt
|
|- requirements.txt
|
||||||
|
|- selection.json # GUI selection state (persisted between sessions)
|
||||||
|
|
|
|
||||||
|
|- gui.py # GUI entry point
|
||||||
|- run.py # Orchestrator (parse + compare + fetch + link)
|
|- run.py # Orchestrator (parse + compare + fetch + link)
|
||||||
|- parse_modlist.py # Step 1 standalone
|
|- parse_modlist.py # Step 1 standalone
|
||||||
|- compare_modlists.py # Step 2 standalone
|
|- compare_modlists.py # Step 2 standalone
|
||||||
|
|||||||
9
gui.py
Normal file
9
gui.py
Normal file
@@ -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()
|
||||||
24
gui/__init__.py
Normal file
24
gui/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""
|
||||||
|
gui — Arma Mod Manager UI package.
|
||||||
|
|
||||||
|
Entry point:
|
||||||
|
from gui import run_app
|
||||||
|
run_app()
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
# Apply theme before any widget is created
|
||||||
|
ctk.set_appearance_mode("dark")
|
||||||
|
ctk.set_default_color_theme("blue")
|
||||||
|
|
||||||
|
|
||||||
|
def run_app() -> None:
|
||||||
|
"""Create and start the main window."""
|
||||||
|
from gui.app import ArmaModManagerApp
|
||||||
|
app = ArmaModManagerApp()
|
||||||
|
app.mainloop()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["run_app"]
|
||||||
28
gui/_constants.py
Normal file
28
gui/_constants.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Window geometry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SIDEBAR_W: int = 190
|
||||||
|
APP_TITLE: str = "Arma Mod Manager"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status colours
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
COLOR_OK: str = "#4CAF50"
|
||||||
|
COLOR_PENDING: str = "#9E9E9E"
|
||||||
|
COLOR_RUNNING: str = "#2196F3"
|
||||||
|
COLOR_ERROR: str = "#F44336"
|
||||||
|
COLOR_WARN: str = "#FF9800"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Filesystem
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# gui/ lives one level below the project root
|
||||||
|
PROJECT_ROOT: Path = Path(__file__).parent.parent
|
||||||
|
SELECTION_FILE: Path = PROJECT_ROOT / "selection.json"
|
||||||
19
gui/_io.py
Normal file
19
gui/_io.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import queue
|
||||||
|
|
||||||
|
|
||||||
|
class _QueueWriter(io.TextIOBase):
|
||||||
|
"""Redirect sys.stdout / sys.stderr into a Queue for the Logs panel."""
|
||||||
|
|
||||||
|
def __init__(self, q: queue.Queue[str]) -> None:
|
||||||
|
self._q = q
|
||||||
|
|
||||||
|
def write(self, text: str) -> int: # type: ignore[override]
|
||||||
|
if text:
|
||||||
|
self._q.put(text)
|
||||||
|
return len(text)
|
||||||
|
|
||||||
|
def flush(self) -> None:
|
||||||
|
pass
|
||||||
361
gui/app.py
Normal file
361
gui/app.py
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import queue
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
from tkinter import messagebox
|
||||||
|
|
||||||
|
from gui._constants import (
|
||||||
|
SIDEBAR_W, APP_TITLE, PROJECT_ROOT, SELECTION_FILE,
|
||||||
|
)
|
||||||
|
from gui._io import _QueueWriter
|
||||||
|
from gui.wizard import SetupWizard
|
||||||
|
from gui.views.base import BaseView
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# View name → class mapping (imported lazily to avoid circular imports)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_VIEW_NAMES = ("Dashboard", "Mods", "Tools", "Logs", "Settings")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_view_class(name: str):
|
||||||
|
from gui.views import DashboardView, ModsView, ToolsView, LogsView, SettingsView
|
||||||
|
return {
|
||||||
|
"Dashboard": DashboardView,
|
||||||
|
"Mods": ModsView,
|
||||||
|
"Tools": ToolsView,
|
||||||
|
"Logs": LogsView,
|
||||||
|
"Settings": SettingsView,
|
||||||
|
}[name]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main application
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ArmaModManagerApp(ctk.CTk):
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.title(APP_TITLE)
|
||||||
|
self.geometry("980x640")
|
||||||
|
self.minsize(820, 560)
|
||||||
|
|
||||||
|
self._log_q: queue.Queue[str] = queue.Queue()
|
||||||
|
self._orig_stdout = sys.stdout
|
||||||
|
self._orig_stderr = sys.stderr
|
||||||
|
self._pipeline_running: bool = False
|
||||||
|
self._cfg = None
|
||||||
|
self._view_cache: dict[str, BaseView] = {}
|
||||||
|
self._active_name: str = ""
|
||||||
|
|
||||||
|
if not (PROJECT_ROOT / "config.json").exists():
|
||||||
|
self.after(200, self.open_wizard)
|
||||||
|
else:
|
||||||
|
self._load_config()
|
||||||
|
|
||||||
|
self._build_layout()
|
||||||
|
self._poll_log()
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Public interface (used by views)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cfg(self):
|
||||||
|
"""Loaded Config object, or None if config.json is missing/invalid."""
|
||||||
|
return self._cfg
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pipeline_running(self) -> bool:
|
||||||
|
return self._pipeline_running
|
||||||
|
|
||||||
|
def navigate_to(self, view_name: str) -> None:
|
||||||
|
"""Switch the content area to the named view, refreshing its data."""
|
||||||
|
assert view_name in _VIEW_NAMES, f"Unknown view: {view_name}"
|
||||||
|
|
||||||
|
# Build view on first visit
|
||||||
|
if view_name not in self._view_cache:
|
||||||
|
cls = _get_view_class(view_name)
|
||||||
|
view = cls(self._content, self)
|
||||||
|
self._view_cache[view_name] = view
|
||||||
|
|
||||||
|
# Hide current
|
||||||
|
if self._active_name and self._active_name in self._view_cache:
|
||||||
|
old = self._view_cache[self._active_name]
|
||||||
|
old.grid_forget()
|
||||||
|
old.lower() # push canvas-based widgets below everything on Windows
|
||||||
|
|
||||||
|
# Show new
|
||||||
|
view = self._view_cache[view_name]
|
||||||
|
view.grid(row=0, column=0, sticky="nsew")
|
||||||
|
view.lift() # ensure it's above any lingering hidden views
|
||||||
|
view.refresh()
|
||||||
|
self._active_name = view_name
|
||||||
|
self._nav_select(view_name)
|
||||||
|
|
||||||
|
def post_log(self, text: str) -> None:
|
||||||
|
"""Thread-safe: enqueue text for the Logs panel."""
|
||||||
|
self._log_q.put(text)
|
||||||
|
|
||||||
|
def run_pipeline(self, selected_names: set[str]) -> None:
|
||||||
|
"""Start the background pipeline for the given preset filenames."""
|
||||||
|
if self._pipeline_running:
|
||||||
|
return
|
||||||
|
if len(selected_names) < 2:
|
||||||
|
messagebox.showwarning(
|
||||||
|
"Not enough presets selected",
|
||||||
|
"Please select at least 2 preset files to compare.\n\n"
|
||||||
|
"Use the checkboxes on the Dashboard to choose which presets to use.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
cfg = self._cfg
|
||||||
|
if not cfg:
|
||||||
|
messagebox.showwarning("Setup required", "Please complete Setup first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._pipeline_running = True
|
||||||
|
self._get_dashboard().set_pipeline_ui(running=True)
|
||||||
|
self.navigate_to("Logs")
|
||||||
|
|
||||||
|
def worker() -> None:
|
||||||
|
# run.py calls fix_console_encoding() at import time, which needs
|
||||||
|
# the real sys.stdout.buffer. Import it before we redirect stdout.
|
||||||
|
from run import step_fetch, step_link
|
||||||
|
self._redirect_output()
|
||||||
|
try:
|
||||||
|
from arma_modlist_tools.parser import parse_modlist_html
|
||||||
|
from arma_modlist_tools.compare import compare_presets
|
||||||
|
|
||||||
|
# Step 1 — Parse selected presets
|
||||||
|
_hdr("Step 1 / 4", "Parse presets")
|
||||||
|
cfg.modlist_json.mkdir(exist_ok=True)
|
||||||
|
presets = []
|
||||||
|
for fp in sorted(cfg.modlist_html.glob("*.html")):
|
||||||
|
if fp.name not in selected_names:
|
||||||
|
print(f" SKIP {fp.name}")
|
||||||
|
continue
|
||||||
|
preset = parse_modlist_html(fp)
|
||||||
|
out = cfg.modlist_json / (preset["preset_name"] + ".json")
|
||||||
|
out.write_text(
|
||||||
|
json.dumps(preset, indent=2, ensure_ascii=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
print(f" {fp.name} → {out.name} ({preset['mod_count']} mods)")
|
||||||
|
presets.append(preset)
|
||||||
|
|
||||||
|
# Step 2 — Compare
|
||||||
|
_hdr("Step 2 / 4", "Compare presets")
|
||||||
|
result = compare_presets(*presets)
|
||||||
|
cfg.comparison.write_text(
|
||||||
|
json.dumps(result, indent=2, ensure_ascii=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
total_unique = sum(v["mod_count"] for v in result["unique"].values())
|
||||||
|
print(f" Compared: {', '.join(result['compared_presets'])}")
|
||||||
|
print(f" Shared: {result['shared']['mod_count']} | "
|
||||||
|
f"Unique: {total_unique}")
|
||||||
|
|
||||||
|
# Step 3 — Fetch
|
||||||
|
_hdr("Step 3 / 4", "Download mods")
|
||||||
|
step_fetch(cfg)
|
||||||
|
|
||||||
|
# Step 4 — Link
|
||||||
|
_hdr("Step 4 / 4", "Link mods")
|
||||||
|
groups = (
|
||||||
|
sorted(p.name for p in cfg.downloads.iterdir() if p.is_dir())
|
||||||
|
if cfg.downloads.is_dir() else []
|
||||||
|
)
|
||||||
|
step_link(cfg, groups)
|
||||||
|
|
||||||
|
print("\n✓ Pipeline complete.\n")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n✗ Error: {e}\n")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
self._restore_output()
|
||||||
|
self.after(0, self._pipeline_done)
|
||||||
|
|
||||||
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
def run_tool(self, script_args: list[str]) -> None:
|
||||||
|
"""Run a maintenance script via subprocess, streaming output to Logs."""
|
||||||
|
script = script_args[0]
|
||||||
|
extra = script_args[1:]
|
||||||
|
|
||||||
|
def worker() -> None:
|
||||||
|
self.post_log(f"\n{'─'*50}\n {' '.join(script_args)}\n{'─'*50}\n")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(PROJECT_ROOT / script)] + extra,
|
||||||
|
capture_output=True, text=True, cwd=str(PROJECT_ROOT),
|
||||||
|
)
|
||||||
|
if result.stdout:
|
||||||
|
self.post_log(result.stdout)
|
||||||
|
if result.stderr:
|
||||||
|
self.post_log(result.stderr)
|
||||||
|
ok = result.returncode == 0
|
||||||
|
self.post_log(
|
||||||
|
f"\n{'✓ Done' if ok else f'✗ Exited with code {result.returncode}'}.\n"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.post_log(f"\n✗ Failed to start {script}: {e}\n")
|
||||||
|
|
||||||
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
def load_selection(self) -> set[str]:
|
||||||
|
"""Return selected preset filenames, defaulting to all if no file saved."""
|
||||||
|
if SELECTION_FILE.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(SELECTION_FILE.read_text(encoding="utf-8"))
|
||||||
|
return set(data.get("selected", []))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if self._cfg and self._cfg.modlist_html.is_dir():
|
||||||
|
return {f.name for f in self._cfg.modlist_html.glob("*.html")}
|
||||||
|
return set()
|
||||||
|
|
||||||
|
def save_selection(self, selected: set[str]) -> None:
|
||||||
|
SELECTION_FILE.write_text(
|
||||||
|
json.dumps({"selected": sorted(selected)}, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def open_wizard(self) -> None:
|
||||||
|
SetupWizard(self, on_complete=self._after_wizard)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Private — layout
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def _build_layout(self) -> None:
|
||||||
|
self.grid_columnconfigure(1, weight=1)
|
||||||
|
self.grid_rowconfigure(0, weight=1)
|
||||||
|
|
||||||
|
# Sidebar
|
||||||
|
sb = ctk.CTkFrame(self, width=SIDEBAR_W, corner_radius=0)
|
||||||
|
sb.grid(row=0, column=0, sticky="nsew")
|
||||||
|
sb.grid_propagate(False)
|
||||||
|
sb.grid_rowconfigure(10, weight=1)
|
||||||
|
|
||||||
|
ctk.CTkLabel(
|
||||||
|
sb, text=APP_TITLE,
|
||||||
|
font=ctk.CTkFont(size=15, weight="bold"),
|
||||||
|
wraplength=SIDEBAR_W - 16, justify="center",
|
||||||
|
).grid(row=0, column=0, padx=12, pady=(22, 14))
|
||||||
|
|
||||||
|
self._nav_btns: dict[str, ctk.CTkButton] = {}
|
||||||
|
for i, name in enumerate(_VIEW_NAMES, start=1):
|
||||||
|
b = ctk.CTkButton(
|
||||||
|
sb, text=name, width=SIDEBAR_W - 24,
|
||||||
|
anchor="w", command=lambda n=name: self.navigate_to(n),
|
||||||
|
fg_color="transparent",
|
||||||
|
hover_color=("gray80", "gray30"),
|
||||||
|
text_color=("gray10", "gray90"),
|
||||||
|
)
|
||||||
|
b.grid(row=i, column=0, padx=12, pady=3)
|
||||||
|
self._nav_btns[name] = b
|
||||||
|
|
||||||
|
# Content area
|
||||||
|
self._content = ctk.CTkFrame(self, fg_color="transparent", corner_radius=0)
|
||||||
|
self._content.grid(row=0, column=1, sticky="nsew")
|
||||||
|
self._content.grid_columnconfigure(0, weight=1)
|
||||||
|
self._content.grid_rowconfigure(0, weight=1)
|
||||||
|
|
||||||
|
self.navigate_to("Dashboard")
|
||||||
|
|
||||||
|
def _nav_select(self, name: str) -> None:
|
||||||
|
for lbl, btn in self._nav_btns.items():
|
||||||
|
btn.configure(
|
||||||
|
fg_color=("gray75", "gray25") if lbl == name else "transparent"
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Private — config
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def _load_config(self) -> None:
|
||||||
|
try:
|
||||||
|
from arma_modlist_tools.config import load_config
|
||||||
|
self._cfg = load_config(PROJECT_ROOT / "config.json")
|
||||||
|
except Exception as e:
|
||||||
|
self._cfg = None
|
||||||
|
self.post_log(f"[config] {e}\n")
|
||||||
|
|
||||||
|
def _after_wizard(self) -> None:
|
||||||
|
self._load_config()
|
||||||
|
# grid_forget before popping — navigate_to can't hide a view that's
|
||||||
|
# already been removed from _view_cache, so we do it manually first.
|
||||||
|
for name in ("Dashboard", "Settings"):
|
||||||
|
view = self._view_cache.pop(name, None)
|
||||||
|
if view is not None:
|
||||||
|
view.grid_forget()
|
||||||
|
view.lower()
|
||||||
|
self._active_name = ""
|
||||||
|
self.navigate_to("Dashboard")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Private — logging
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def _poll_log(self) -> None:
|
||||||
|
try:
|
||||||
|
from gui.views.logs import LogsView
|
||||||
|
logs_view = self._view_cache.get("Logs")
|
||||||
|
while True:
|
||||||
|
text = self._log_q.get_nowait()
|
||||||
|
if isinstance(logs_view, LogsView):
|
||||||
|
logs_view.append(text)
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
self.after(80, self._poll_log)
|
||||||
|
|
||||||
|
def _redirect_output(self) -> None:
|
||||||
|
writer = _QueueWriter(self._log_q)
|
||||||
|
sys.stdout = writer
|
||||||
|
sys.stderr = writer
|
||||||
|
|
||||||
|
def _restore_output(self) -> None:
|
||||||
|
sys.stdout = self._orig_stdout
|
||||||
|
sys.stderr = self._orig_stderr
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Private — pipeline lifecycle
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def _pipeline_done(self) -> None:
|
||||||
|
self._pipeline_running = False
|
||||||
|
self._get_dashboard().set_pipeline_ui(running=False)
|
||||||
|
# Refresh all cached views so data is current without manual refresh
|
||||||
|
for view in self._view_cache.values():
|
||||||
|
view.refresh()
|
||||||
|
|
||||||
|
def _get_dashboard(self):
|
||||||
|
from gui.views.dashboard import DashboardView
|
||||||
|
view = self._view_cache.get("Dashboard")
|
||||||
|
if not isinstance(view, DashboardView):
|
||||||
|
# Build it if not yet visited (shouldn't normally happen)
|
||||||
|
view = DashboardView(self._content, self)
|
||||||
|
self._view_cache["Dashboard"] = view
|
||||||
|
return view
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module-level helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _hdr(step: str, name: str) -> None:
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print(f" {step}: {name}")
|
||||||
|
print(f"{'='*50}\n")
|
||||||
7
gui/views/__init__.py
Normal file
7
gui/views/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from gui.views.dashboard import DashboardView
|
||||||
|
from gui.views.mods import ModsView
|
||||||
|
from gui.views.tools import ToolsView
|
||||||
|
from gui.views.logs import LogsView
|
||||||
|
from gui.views.settings import SettingsView
|
||||||
|
|
||||||
|
__all__ = ["DashboardView", "ModsView", "ToolsView", "LogsView", "SettingsView"]
|
||||||
32
gui/views/base.py
Normal file
32
gui/views/base.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from gui.app import ArmaModManagerApp
|
||||||
|
|
||||||
|
|
||||||
|
class BaseView(ctk.CTkFrame):
|
||||||
|
"""
|
||||||
|
Common base for all view panels.
|
||||||
|
|
||||||
|
Each view is a CTkFrame owned by the app's content area. The app creates
|
||||||
|
view instances once and caches them; it calls refresh() on each navigation
|
||||||
|
so views can update their dynamic content without rebuilding the whole frame.
|
||||||
|
|
||||||
|
Subclasses must implement build() and may override refresh().
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent: ctk.CTkFrame, app: ArmaModManagerApp) -> None:
|
||||||
|
super().__init__(parent, fg_color="transparent")
|
||||||
|
self.app = app
|
||||||
|
self.build()
|
||||||
|
|
||||||
|
def build(self) -> None:
|
||||||
|
"""Construct all child widgets. Called once from __init__."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def refresh(self) -> None:
|
||||||
|
"""Re-query data and update dynamic widgets. Called on every navigation."""
|
||||||
289
gui/views/dashboard.py
Normal file
289
gui/views/dashboard.py
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from tkinter import filedialog, messagebox
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
from gui._constants import (
|
||||||
|
COLOR_OK, COLOR_PENDING, COLOR_ERROR, COLOR_WARN,
|
||||||
|
)
|
||||||
|
from gui.views.base import BaseView
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from gui.app import ArmaModManagerApp
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardView(BaseView):
|
||||||
|
"""
|
||||||
|
Preset file selector + pipeline status + Run button.
|
||||||
|
|
||||||
|
Dynamic regions (rebuilt in refresh):
|
||||||
|
- _preset_scroll — checkbox list of .html files
|
||||||
|
- _step_icons — ✓/○ status for each pipeline step
|
||||||
|
- _stats_lbl — mod counts from comparison.json
|
||||||
|
|
||||||
|
Static regions (built once):
|
||||||
|
- _run_btn / _prog — pipeline controls, state changed by set_pipeline_ui()
|
||||||
|
- _sel_count_lbl — "X of Y selected" label, updated by _on_toggle()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def build(self) -> None:
|
||||||
|
self.grid_columnconfigure(0, weight=1)
|
||||||
|
self.grid_rowconfigure(1, weight=1)
|
||||||
|
|
||||||
|
# ── Header ────────────────────────────────────────────────────────────
|
||||||
|
hdr = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
hdr.grid(row=0, column=0, sticky="ew", padx=24, pady=(20, 10))
|
||||||
|
ctk.CTkLabel(hdr, text="Dashboard",
|
||||||
|
font=ctk.CTkFont(size=22, weight="bold")).pack(side="left")
|
||||||
|
ctk.CTkButton(hdr, text="⟳ Refresh", width=100,
|
||||||
|
command=self.refresh).pack(side="right")
|
||||||
|
|
||||||
|
# ── Cards ─────────────────────────────────────────────────────────────
|
||||||
|
cards = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
cards.grid(row=1, column=0, sticky="nsew", padx=24)
|
||||||
|
cards.columnconfigure(0, weight=3)
|
||||||
|
cards.columnconfigure(1, weight=2)
|
||||||
|
|
||||||
|
self._build_preset_card(cards)
|
||||||
|
self._build_pipeline_card(cards)
|
||||||
|
|
||||||
|
# ── Run button area ───────────────────────────────────────────────────
|
||||||
|
run_area = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
run_area.grid(row=2, column=0, sticky="ew", padx=24, pady=16)
|
||||||
|
|
||||||
|
self._run_btn = ctk.CTkButton(
|
||||||
|
run_area,
|
||||||
|
text="▶ Run Full Pipeline",
|
||||||
|
font=ctk.CTkFont(size=15, weight="bold"),
|
||||||
|
height=46,
|
||||||
|
command=self._on_run,
|
||||||
|
)
|
||||||
|
self._run_btn.pack(fill="x")
|
||||||
|
self._prog = ctk.CTkProgressBar(run_area, mode="indeterminate")
|
||||||
|
|
||||||
|
# ── Preset card ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_preset_card(self, parent: ctk.CTkFrame) -> None:
|
||||||
|
pc = ctk.CTkFrame(parent)
|
||||||
|
pc.grid(row=0, column=0, sticky="nsew", padx=(0, 8), pady=4)
|
||||||
|
|
||||||
|
ctk.CTkLabel(pc, text="Preset Files",
|
||||||
|
font=ctk.CTkFont(size=14, weight="bold")).pack(
|
||||||
|
anchor="w", padx=14, pady=(14, 2))
|
||||||
|
ctk.CTkLabel(pc,
|
||||||
|
text="HTML exports from Arma 3 Launcher → Mods → Export to HTML",
|
||||||
|
text_color="gray", font=ctk.CTkFont(size=11)).pack(
|
||||||
|
anchor="w", padx=14)
|
||||||
|
|
||||||
|
self._preset_scroll = ctk.CTkScrollableFrame(pc, height=150)
|
||||||
|
self._preset_scroll.pack(fill="x", padx=14, pady=(10, 4))
|
||||||
|
|
||||||
|
# Selection controls
|
||||||
|
sel_row = ctk.CTkFrame(pc, fg_color="transparent")
|
||||||
|
sel_row.pack(fill="x", padx=14, pady=(0, 8))
|
||||||
|
self._sel_count_lbl = ctk.CTkLabel(sel_row, text="", text_color="gray",
|
||||||
|
font=ctk.CTkFont(size=11))
|
||||||
|
self._sel_count_lbl.pack(side="left")
|
||||||
|
ctk.CTkButton(sel_row, text="None", width=54,
|
||||||
|
fg_color="transparent", border_width=1,
|
||||||
|
text_color=("gray10", "gray90"), font=ctk.CTkFont(size=11),
|
||||||
|
command=self._select_none).pack(side="right")
|
||||||
|
ctk.CTkButton(sel_row, text="All", width=54,
|
||||||
|
fg_color="transparent", border_width=1,
|
||||||
|
text_color=("gray10", "gray90"), font=ctk.CTkFont(size=11),
|
||||||
|
command=self._select_all).pack(side="right", padx=(0, 6))
|
||||||
|
|
||||||
|
ctk.CTkButton(pc, text="+ Add Preset Files",
|
||||||
|
fg_color="transparent", border_width=1,
|
||||||
|
text_color=("gray10", "gray90"),
|
||||||
|
command=self._add_presets).pack(pady=(0, 14))
|
||||||
|
|
||||||
|
# ── Pipeline card ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_pipeline_card(self, parent: ctk.CTkFrame) -> None:
|
||||||
|
pipe = ctk.CTkFrame(parent)
|
||||||
|
pipe.grid(row=0, column=1, sticky="nsew", padx=(8, 0), pady=4)
|
||||||
|
|
||||||
|
ctk.CTkLabel(pipe, text="Pipeline Status",
|
||||||
|
font=ctk.CTkFont(size=14, weight="bold")).pack(
|
||||||
|
anchor="w", padx=14, pady=(14, 8))
|
||||||
|
|
||||||
|
self._step_icons: dict[str, ctk.CTkLabel] = {}
|
||||||
|
for key, label in [
|
||||||
|
("parse", "Parse presets"),
|
||||||
|
("compare", "Compare presets"),
|
||||||
|
("download", "Download mods"),
|
||||||
|
("link", "Link to Arma"),
|
||||||
|
]:
|
||||||
|
row = ctk.CTkFrame(pipe, fg_color="transparent")
|
||||||
|
row.pack(fill="x", padx=14, pady=3)
|
||||||
|
icon = ctk.CTkLabel(row, text="○", width=22,
|
||||||
|
text_color=COLOR_PENDING,
|
||||||
|
font=ctk.CTkFont(size=15))
|
||||||
|
icon.pack(side="left")
|
||||||
|
ctk.CTkLabel(row, text=label, anchor="w").pack(side="left", padx=6)
|
||||||
|
self._step_icons[key] = icon
|
||||||
|
|
||||||
|
self._stats_lbl = ctk.CTkLabel(pipe, text="", text_color="gray",
|
||||||
|
font=ctk.CTkFont(size=11),
|
||||||
|
wraplength=200, justify="left")
|
||||||
|
self._stats_lbl.pack(anchor="w", padx=14, pady=(10, 14))
|
||||||
|
|
||||||
|
# ── refresh ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def refresh(self) -> None:
|
||||||
|
self._rebuild_preset_list()
|
||||||
|
self._update_pipeline_status()
|
||||||
|
|
||||||
|
def _rebuild_preset_list(self) -> None:
|
||||||
|
for w in self._preset_scroll.winfo_children():
|
||||||
|
w.destroy()
|
||||||
|
self._preset_checks: dict[str, ctk.BooleanVar] = {}
|
||||||
|
|
||||||
|
cfg = self.app.cfg
|
||||||
|
if not cfg:
|
||||||
|
ctk.CTkLabel(self._preset_scroll,
|
||||||
|
text="No config found. Complete Setup first.",
|
||||||
|
text_color=COLOR_WARN).pack(anchor="w")
|
||||||
|
return
|
||||||
|
|
||||||
|
html_dir = cfg.modlist_html
|
||||||
|
if not html_dir.is_dir():
|
||||||
|
ctk.CTkLabel(self._preset_scroll,
|
||||||
|
text=f"Folder missing:\n{html_dir}",
|
||||||
|
text_color=COLOR_WARN, justify="left").pack(padx=4, pady=8)
|
||||||
|
return
|
||||||
|
|
||||||
|
files = sorted(html_dir.glob("*.html"))
|
||||||
|
if not files:
|
||||||
|
ctk.CTkLabel(self._preset_scroll,
|
||||||
|
text="No preset files yet.\nUse the button below to add them.",
|
||||||
|
text_color="gray", justify="left").pack(padx=4, pady=8)
|
||||||
|
return
|
||||||
|
|
||||||
|
saved = self.app.load_selection()
|
||||||
|
for fp in files:
|
||||||
|
var = ctk.BooleanVar(value=fp.name in saved)
|
||||||
|
self._preset_checks[fp.name] = var
|
||||||
|
ctk.CTkCheckBox(
|
||||||
|
self._preset_scroll,
|
||||||
|
text=fp.name,
|
||||||
|
variable=var,
|
||||||
|
command=self._on_toggle,
|
||||||
|
).pack(anchor="w", padx=4, pady=3)
|
||||||
|
|
||||||
|
self._on_toggle() # initialise count label
|
||||||
|
|
||||||
|
def _update_pipeline_status(self) -> None:
|
||||||
|
cfg = self.app.cfg
|
||||||
|
if not cfg:
|
||||||
|
for key in self._step_icons:
|
||||||
|
self._set_step(key, False)
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_count = sum(1 for v in self._preset_checks.values() if v.get())
|
||||||
|
downloads_ok = (
|
||||||
|
cfg.downloads.is_dir()
|
||||||
|
and any(True for _ in cfg.downloads.rglob("@*"))
|
||||||
|
)
|
||||||
|
|
||||||
|
self._set_step("parse", selected_count >= 2)
|
||||||
|
self._set_step("compare", cfg.comparison.exists())
|
||||||
|
self._set_step("download", downloads_ok)
|
||||||
|
self._set_step("link", cfg.arma_dir.is_dir())
|
||||||
|
|
||||||
|
if cfg.comparison.exists():
|
||||||
|
try:
|
||||||
|
comp = json.loads(cfg.comparison.read_text(encoding="utf-8"))
|
||||||
|
total = (comp["shared"]["mod_count"]
|
||||||
|
+ sum(v["mod_count"] for v in comp["unique"].values()))
|
||||||
|
shared = comp["shared"]["mod_count"]
|
||||||
|
missing = 0
|
||||||
|
if cfg.missing_report.exists():
|
||||||
|
rep = json.loads(cfg.missing_report.read_text(encoding="utf-8"))
|
||||||
|
missing = rep.get("missing", 0)
|
||||||
|
stat = f"{total} mods · {shared} shared"
|
||||||
|
if missing:
|
||||||
|
stat += f"\n{missing} missing from server"
|
||||||
|
self._stats_lbl.configure(text=stat)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _set_step(self, key: str, done: bool) -> None:
|
||||||
|
icon = self._step_icons.get(key)
|
||||||
|
if icon:
|
||||||
|
icon.configure(
|
||||||
|
text="✓" if done else "○",
|
||||||
|
text_color=COLOR_OK if done else COLOR_PENDING,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Selection helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_selected_names(self) -> set[str]:
|
||||||
|
return {name for name, var in self._preset_checks.items() if var.get()}
|
||||||
|
|
||||||
|
def _on_toggle(self) -> None:
|
||||||
|
selected = self.get_selected_names()
|
||||||
|
self.app.save_selection(selected)
|
||||||
|
n_sel = len(selected)
|
||||||
|
n_total = len(self._preset_checks)
|
||||||
|
color = (COLOR_OK if n_sel >= 2 else
|
||||||
|
COLOR_WARN if n_sel == 1 else
|
||||||
|
COLOR_ERROR)
|
||||||
|
self._sel_count_lbl.configure(
|
||||||
|
text=f"{n_sel} of {n_total} selected", text_color=color)
|
||||||
|
|
||||||
|
def _select_all(self) -> None:
|
||||||
|
for var in self._preset_checks.values():
|
||||||
|
var.set(True)
|
||||||
|
self._on_toggle()
|
||||||
|
|
||||||
|
def _select_none(self) -> None:
|
||||||
|
for var in self._preset_checks.values():
|
||||||
|
var.set(False)
|
||||||
|
self._on_toggle()
|
||||||
|
|
||||||
|
# ── Add presets ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _add_presets(self) -> None:
|
||||||
|
cfg = self.app.cfg
|
||||||
|
if not cfg:
|
||||||
|
messagebox.showwarning("Setup required",
|
||||||
|
"Please complete Setup before adding presets.")
|
||||||
|
return
|
||||||
|
files = filedialog.askopenfilenames(
|
||||||
|
title="Select Arma 3 Launcher preset files",
|
||||||
|
filetypes=[("HTML Preset", "*.html"), ("All files", "*.*")],
|
||||||
|
)
|
||||||
|
if not files:
|
||||||
|
return
|
||||||
|
dest = cfg.modlist_html
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
current = self.app.load_selection()
|
||||||
|
for fp in files:
|
||||||
|
name = Path(fp).name
|
||||||
|
shutil.copy2(fp, dest / name)
|
||||||
|
current.add(name)
|
||||||
|
self.app.save_selection(current)
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
# ── Run button ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_run(self) -> None:
|
||||||
|
self.app.run_pipeline(self.get_selected_names())
|
||||||
|
|
||||||
|
def set_pipeline_ui(self, running: bool) -> None:
|
||||||
|
"""Called by the app to reflect pipeline start/end in the UI."""
|
||||||
|
if running:
|
||||||
|
self._run_btn.configure(state="disabled", text="Running…")
|
||||||
|
self._prog.pack(fill="x", pady=(6, 0))
|
||||||
|
self._prog.start()
|
||||||
|
else:
|
||||||
|
self._run_btn.configure(state="normal", text="▶ Run Full Pipeline")
|
||||||
|
self._prog.stop()
|
||||||
|
self._prog.pack_forget()
|
||||||
64
gui/views/logs.py
Normal file
64
gui/views/logs.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
from gui._constants import COLOR_ERROR
|
||||||
|
from gui.views.base import BaseView
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from gui.app import ArmaModManagerApp
|
||||||
|
|
||||||
|
|
||||||
|
class LogsView(BaseView):
|
||||||
|
"""
|
||||||
|
Monospace textbox showing captured stdout/stderr from pipeline and tools.
|
||||||
|
|
||||||
|
The app's poll loop appends text by calling append() directly on this view.
|
||||||
|
Log content persists across navigation — the textbox is built once in build()
|
||||||
|
and never recreated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def build(self) -> None:
|
||||||
|
self.grid_columnconfigure(0, weight=1)
|
||||||
|
self.grid_rowconfigure(1, weight=1)
|
||||||
|
|
||||||
|
# ── Header ────────────────────────────────────────────────────────────
|
||||||
|
hdr = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
hdr.grid(row=0, column=0, sticky="ew", padx=24, pady=(20, 8))
|
||||||
|
ctk.CTkLabel(hdr, text="Logs",
|
||||||
|
font=ctk.CTkFont(size=22, weight="bold")).pack(side="left")
|
||||||
|
|
||||||
|
btn_row = ctk.CTkFrame(hdr, fg_color="transparent")
|
||||||
|
btn_row.pack(side="right")
|
||||||
|
ctk.CTkButton(btn_row, text="Copy", width=72,
|
||||||
|
command=self._copy).pack(side="left", padx=4)
|
||||||
|
ctk.CTkButton(btn_row, text="Clear", width=72,
|
||||||
|
fg_color=COLOR_ERROR, hover_color="#c62828",
|
||||||
|
command=self._clear).pack(side="left")
|
||||||
|
|
||||||
|
# ── Log textbox (persistent) ──────────────────────────────────────────
|
||||||
|
self._log_box = ctk.CTkTextbox(
|
||||||
|
self, state="disabled",
|
||||||
|
font=ctk.CTkFont(family="Consolas", size=12))
|
||||||
|
self._log_box.grid(row=1, column=0, sticky="nsew", padx=24, pady=(0, 12))
|
||||||
|
|
||||||
|
def append(self, text: str) -> None:
|
||||||
|
"""Thread-safe-ish: called from the app's after() poll loop (main thread)."""
|
||||||
|
try:
|
||||||
|
self._log_box.configure(state="normal")
|
||||||
|
self._log_box.insert("end", text)
|
||||||
|
self._log_box.see("end")
|
||||||
|
self._log_box.configure(state="disabled")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _copy(self) -> None:
|
||||||
|
self.clipboard_clear()
|
||||||
|
self.clipboard_append(self._log_box.get("1.0", "end"))
|
||||||
|
|
||||||
|
def _clear(self) -> None:
|
||||||
|
self._log_box.configure(state="normal")
|
||||||
|
self._log_box.delete("1.0", "end")
|
||||||
|
self._log_box.configure(state="disabled")
|
||||||
353
gui/views/mods.py
Normal file
353
gui/views/mods.py
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
from gui._constants import COLOR_OK, COLOR_ERROR, COLOR_WARN, COLOR_RUNNING
|
||||||
|
from gui.views.base import BaseView
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from gui.app import ArmaModManagerApp
|
||||||
|
|
||||||
|
|
||||||
|
def _find_folder(group_dir: Path, mod_name: str) -> Optional[Path]:
|
||||||
|
"""Return the local mod folder path, or None if not downloaded."""
|
||||||
|
if not group_dir.is_dir():
|
||||||
|
return None
|
||||||
|
candidate = group_dir / f"@{mod_name}"
|
||||||
|
if candidate.is_dir():
|
||||||
|
return candidate
|
||||||
|
target = mod_name.lower()
|
||||||
|
for p in group_dir.iterdir():
|
||||||
|
if p.is_dir() and p.name.lstrip("@").lower() == target:
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ModsView(BaseView):
|
||||||
|
"""Tabbed mod browser — one tab per comparison group with server status checking."""
|
||||||
|
|
||||||
|
def build(self) -> None:
|
||||||
|
self.grid_columnconfigure(0, weight=1)
|
||||||
|
self.grid_rowconfigure(2, weight=1)
|
||||||
|
|
||||||
|
# ── State ─────────────────────────────────────────────────────────────
|
||||||
|
self._search_var = ctk.StringVar()
|
||||||
|
self._checking = False
|
||||||
|
self._check_btn: Optional[ctk.CTkButton] = None
|
||||||
|
self._tab_view: Optional[ctk.CTkTabview] = None
|
||||||
|
# key = "{group}/{folder_name_or_mod_name}"
|
||||||
|
# value = dict(status_label, update_btn, group, folder_path, mod_dict, name_label)
|
||||||
|
self._mod_rows: dict[str, dict] = {}
|
||||||
|
|
||||||
|
# ── Header ────────────────────────────────────────────────────────────
|
||||||
|
hdr = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
hdr.grid(row=0, column=0, sticky="ew", padx=24, pady=(20, 6))
|
||||||
|
hdr.columnconfigure(1, weight=1)
|
||||||
|
|
||||||
|
ctk.CTkLabel(hdr, text="Mods",
|
||||||
|
font=ctk.CTkFont(size=22, weight="bold")).grid(
|
||||||
|
row=0, column=0, sticky="w")
|
||||||
|
|
||||||
|
btn_frame = ctk.CTkFrame(hdr, fg_color="transparent")
|
||||||
|
btn_frame.grid(row=0, column=2, sticky="e")
|
||||||
|
|
||||||
|
ctk.CTkButton(btn_frame, text="⟳ Refresh", width=100,
|
||||||
|
command=self.refresh).pack(side="left", padx=(0, 6))
|
||||||
|
|
||||||
|
self._check_btn = ctk.CTkButton(
|
||||||
|
btn_frame, text="☁ Check Updates", width=130,
|
||||||
|
command=self._check_updates,
|
||||||
|
)
|
||||||
|
self._check_btn.pack(side="left")
|
||||||
|
|
||||||
|
# ── Search ────────────────────────────────────────────────────────────
|
||||||
|
bar = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
bar.grid(row=1, column=0, sticky="ew", padx=24, pady=(0, 8))
|
||||||
|
ctk.CTkLabel(bar, text="Search:").pack(side="left", padx=(0, 6))
|
||||||
|
ctk.CTkEntry(bar, textvariable=self._search_var,
|
||||||
|
placeholder_text="Filter mods in active tab…",
|
||||||
|
width=220).pack(side="left")
|
||||||
|
self._search_var.trace_add("write", lambda *_: self._apply_search())
|
||||||
|
|
||||||
|
# ── Tab area placeholder ───────────────────────────────────────────────
|
||||||
|
self._tab_area = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
self._tab_area.grid(row=2, column=0, sticky="nsew", padx=16, pady=(0, 12))
|
||||||
|
self._tab_area.grid_columnconfigure(0, weight=1)
|
||||||
|
self._tab_area.grid_rowconfigure(0, weight=1)
|
||||||
|
|
||||||
|
self._msg_label: Optional[ctk.CTkLabel] = None
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Public
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def refresh(self) -> None:
|
||||||
|
self._mod_rows.clear()
|
||||||
|
|
||||||
|
# Destroy previous tab_view / message
|
||||||
|
if self._tab_view is not None:
|
||||||
|
self._tab_view.destroy()
|
||||||
|
self._tab_view = None
|
||||||
|
if self._msg_label is not None:
|
||||||
|
self._msg_label.destroy()
|
||||||
|
self._msg_label = None
|
||||||
|
|
||||||
|
cfg = self.app.cfg
|
||||||
|
if not cfg:
|
||||||
|
self._show_msg("No config found. Complete Setup first.")
|
||||||
|
return
|
||||||
|
if not cfg.comparison.exists():
|
||||||
|
self._show_msg(
|
||||||
|
"No mod data yet.\n"
|
||||||
|
"Go to Dashboard, select your presets, then click Run Full Pipeline."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
comp = json.loads(cfg.comparison.read_text(encoding="utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
self._show_msg(f"Error reading comparison.json: {e}", error=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build ordered group list: shared first, then unique groups
|
||||||
|
groups: list[tuple[str, dict]] = [("shared", comp["shared"])]
|
||||||
|
for preset, data in comp["unique"].items():
|
||||||
|
groups.append((preset, data))
|
||||||
|
|
||||||
|
# Precompute link maps per group (one get_link_status call per group)
|
||||||
|
link_maps: dict[str, dict[str, bool]] = {}
|
||||||
|
try:
|
||||||
|
from arma_modlist_tools.linker import get_link_status
|
||||||
|
for group, _ in groups:
|
||||||
|
gdir = cfg.downloads / group
|
||||||
|
if gdir.is_dir():
|
||||||
|
link_maps[group] = {
|
||||||
|
e["name"].lower(): e["is_linked"]
|
||||||
|
for e in get_link_status(gdir, cfg.arma_dir)
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Build CTkTabview
|
||||||
|
tv = ctk.CTkTabview(self._tab_area)
|
||||||
|
tv.grid(row=0, column=0, sticky="nsew")
|
||||||
|
self._tab_view = tv
|
||||||
|
|
||||||
|
for group, data in groups:
|
||||||
|
mods = data.get("mods", [])
|
||||||
|
count = len(mods)
|
||||||
|
tab_label = f"{group} ({count})"
|
||||||
|
tv.add(tab_label)
|
||||||
|
tab_frame = tv.tab(tab_label)
|
||||||
|
tab_frame.grid_columnconfigure(0, weight=1)
|
||||||
|
tab_frame.grid_rowconfigure(1, weight=1)
|
||||||
|
|
||||||
|
# Column header
|
||||||
|
col_hdr = ctk.CTkFrame(tab_frame,
|
||||||
|
fg_color=("gray82", "gray22"), corner_radius=6)
|
||||||
|
col_hdr.grid(row=0, column=0, sticky="ew", padx=4, pady=(6, 2))
|
||||||
|
col_hdr.columnconfigure(0, weight=1)
|
||||||
|
for col, (w, lbl) in enumerate([
|
||||||
|
(0, "Mod Name"),
|
||||||
|
(80, "Downloaded"),
|
||||||
|
(80, "Linked"),
|
||||||
|
(160, "Server Status"),
|
||||||
|
(80, ""),
|
||||||
|
]):
|
||||||
|
ctk.CTkLabel(col_hdr, text=lbl,
|
||||||
|
font=ctk.CTkFont(weight="bold"),
|
||||||
|
anchor="w", width=w or 1).grid(
|
||||||
|
row=0, column=col,
|
||||||
|
padx=(10 if col == 0 else 4, 4), pady=5,
|
||||||
|
sticky="ew" if col == 0 else "")
|
||||||
|
|
||||||
|
# Scrollable rows
|
||||||
|
scroll = ctk.CTkScrollableFrame(tab_frame)
|
||||||
|
scroll.grid(row=1, column=0, sticky="nsew", padx=4, pady=(0, 4))
|
||||||
|
|
||||||
|
self._build_group_rows(scroll, group, mods,
|
||||||
|
cfg, link_maps.get(group, {}))
|
||||||
|
|
||||||
|
if groups:
|
||||||
|
tv.set(f"{groups[0][0]} ({len(groups[0][1].get('mods', []))})")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Private — layout helpers
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def _show_msg(self, text: str, error: bool = False) -> None:
|
||||||
|
self._msg_label = ctk.CTkLabel(
|
||||||
|
self._tab_area, text=text, justify="left",
|
||||||
|
text_color="#F44336" if error else "gray",
|
||||||
|
)
|
||||||
|
self._msg_label.grid(row=0, column=0, padx=24, pady=24, sticky="nw")
|
||||||
|
|
||||||
|
def _build_group_rows(
|
||||||
|
self,
|
||||||
|
parent: ctk.CTkScrollableFrame,
|
||||||
|
group: str,
|
||||||
|
mods: list[dict],
|
||||||
|
cfg,
|
||||||
|
link_map: dict[str, bool],
|
||||||
|
) -> None:
|
||||||
|
for i, mod in enumerate(sorted(mods, key=lambda m: m["name"].lower())):
|
||||||
|
folder_path = _find_folder(cfg.downloads / group, mod["name"])
|
||||||
|
downloaded = folder_path is not None
|
||||||
|
linked = (link_map.get(folder_path.name.lower(), False)
|
||||||
|
if folder_path else False)
|
||||||
|
|
||||||
|
bg = ("gray90", "gray17") if i % 2 == 0 else ("gray86", "gray14")
|
||||||
|
row = ctk.CTkFrame(parent, fg_color=bg, corner_radius=4)
|
||||||
|
row.pack(fill="x", pady=1)
|
||||||
|
row.columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
# Mod name
|
||||||
|
name_lbl = ctk.CTkLabel(row, text=f" {mod['name']}", anchor="w")
|
||||||
|
name_lbl.grid(row=0, column=0, sticky="ew", padx=4, pady=3)
|
||||||
|
|
||||||
|
# Downloaded
|
||||||
|
ctk.CTkLabel(
|
||||||
|
row,
|
||||||
|
text=" ✓" if downloaded else " ✗",
|
||||||
|
text_color=COLOR_OK if downloaded else COLOR_ERROR,
|
||||||
|
width=80, anchor="w",
|
||||||
|
).grid(row=0, column=1, padx=4)
|
||||||
|
|
||||||
|
# Linked
|
||||||
|
ctk.CTkLabel(
|
||||||
|
row,
|
||||||
|
text=" ✓" if linked else (" —" if not downloaded else " ✗"),
|
||||||
|
text_color=COLOR_OK if linked else "gray",
|
||||||
|
width=80, anchor="w",
|
||||||
|
).grid(row=0, column=2, padx=4)
|
||||||
|
|
||||||
|
# Server status
|
||||||
|
status_lbl = ctk.CTkLabel(
|
||||||
|
row, text="—", text_color="gray", width=160, anchor="w",
|
||||||
|
)
|
||||||
|
status_lbl.grid(row=0, column=3, padx=4)
|
||||||
|
|
||||||
|
# Update button (hidden until stale detected)
|
||||||
|
folder_name = folder_path.name if folder_path else None
|
||||||
|
update_btn = ctk.CTkButton(
|
||||||
|
row, text="Update", width=70,
|
||||||
|
command=(lambda g=group, fn=folder_name:
|
||||||
|
self._update_mod(g, fn)) if folder_name else None,
|
||||||
|
state="normal" if folder_name else "disabled",
|
||||||
|
)
|
||||||
|
update_btn.grid(row=0, column=4, padx=(4, 8), pady=2)
|
||||||
|
update_btn.grid_remove() # hidden until check finds stale files
|
||||||
|
|
||||||
|
# Register in row map
|
||||||
|
key = f"{group}/{folder_name or mod['name']}"
|
||||||
|
self._mod_rows[key] = {
|
||||||
|
"status_label": status_lbl,
|
||||||
|
"update_btn": update_btn,
|
||||||
|
"name_label": name_lbl,
|
||||||
|
"row_frame": row,
|
||||||
|
"group": group,
|
||||||
|
"folder_path": folder_path,
|
||||||
|
"mod_dict": mod,
|
||||||
|
"mod_name": mod["name"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Private — server update check
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def _check_updates(self) -> None:
|
||||||
|
if self._checking:
|
||||||
|
return
|
||||||
|
cfg = self.app.cfg
|
||||||
|
if not cfg:
|
||||||
|
return
|
||||||
|
if not self._mod_rows:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._checking = True
|
||||||
|
self._check_btn.configure(text="Checking…", state="disabled")
|
||||||
|
|
||||||
|
# Reset downloaded rows to "Checking…"
|
||||||
|
for row in self._mod_rows.values():
|
||||||
|
if row["folder_path"]:
|
||||||
|
row["status_label"].configure(
|
||||||
|
text="Checking…", text_color=COLOR_RUNNING)
|
||||||
|
else:
|
||||||
|
row["status_label"].configure(text="—", text_color="gray")
|
||||||
|
|
||||||
|
# Snapshot rows for thread (avoid race with refresh)
|
||||||
|
rows_snapshot = dict(self._mod_rows)
|
||||||
|
|
||||||
|
def worker() -> None:
|
||||||
|
from arma_modlist_tools.fetcher import (
|
||||||
|
build_server_index, list_mod_updates, make_session, find_mod_folder,
|
||||||
|
)
|
||||||
|
results: dict[str, tuple[str, int]] = {}
|
||||||
|
try:
|
||||||
|
idx = build_server_index(cfg.server_url, cfg.server_auth)
|
||||||
|
session = make_session(cfg.server_auth)
|
||||||
|
for key, row in rows_snapshot.items():
|
||||||
|
if not row["folder_path"]:
|
||||||
|
results[key] = ("not_downloaded", 0)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
folder_url = find_mod_folder(row["mod_dict"], idx)
|
||||||
|
if not folder_url:
|
||||||
|
results[key] = ("not_on_server", 0)
|
||||||
|
continue
|
||||||
|
stale = list_mod_updates(
|
||||||
|
folder_url, row["folder_path"], session)
|
||||||
|
results[key] = ("stale" if stale else "ok", len(stale))
|
||||||
|
except Exception:
|
||||||
|
results[key] = ("error", 0)
|
||||||
|
except Exception:
|
||||||
|
for key in rows_snapshot:
|
||||||
|
results[key] = ("error", 0)
|
||||||
|
self.after(0, lambda: self._apply_check_results(results))
|
||||||
|
|
||||||
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
def _apply_check_results(self, results: dict[str, tuple[str, int]]) -> None:
|
||||||
|
_STATUS: dict[str, tuple[str, str]] = {
|
||||||
|
"ok": ("✓ Up to date", COLOR_OK),
|
||||||
|
"stale": ("⚠ {n} outdated", COLOR_WARN),
|
||||||
|
"not_downloaded": ("—", "gray"),
|
||||||
|
"not_on_server": ("Not on server", "gray"),
|
||||||
|
"error": ("✗ Error", COLOR_ERROR),
|
||||||
|
}
|
||||||
|
for key, (status, n) in results.items():
|
||||||
|
row = self._mod_rows.get(key)
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
tmpl, color = _STATUS.get(status, ("—", "gray"))
|
||||||
|
row["status_label"].configure(
|
||||||
|
text=tmpl.replace("{n}", str(n)), text_color=color)
|
||||||
|
if status == "stale" and row["folder_path"]:
|
||||||
|
row["update_btn"].grid()
|
||||||
|
else:
|
||||||
|
row["update_btn"].grid_remove()
|
||||||
|
|
||||||
|
self._checking = False
|
||||||
|
self._check_btn.configure(text="☁ Check Updates", state="normal")
|
||||||
|
|
||||||
|
def _update_mod(self, group: str, folder_name: str) -> None:
|
||||||
|
self.app.navigate_to("Logs")
|
||||||
|
self.app.run_tool(["update_mods.py", "--group", group, "--mod", folder_name])
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Private — search filter
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def _apply_search(self) -> None:
|
||||||
|
search = self._search_var.get().lower()
|
||||||
|
for row in self._mod_rows.values():
|
||||||
|
frame = row["row_frame"]
|
||||||
|
if not search or search in row["mod_name"].lower():
|
||||||
|
frame.pack(fill="x", pady=1)
|
||||||
|
else:
|
||||||
|
frame.pack_forget()
|
||||||
79
gui/views/settings.py
Normal file
79
gui/views/settings.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
from gui.views.base import BaseView
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from gui.app import ArmaModManagerApp
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsView(BaseView):
|
||||||
|
"""Appearance switcher, wizard re-opener, and current config display."""
|
||||||
|
|
||||||
|
def build(self) -> None:
|
||||||
|
self.grid_columnconfigure(0, weight=1)
|
||||||
|
self.grid_rowconfigure(1, weight=1)
|
||||||
|
|
||||||
|
ctk.CTkLabel(self, text="Settings",
|
||||||
|
font=ctk.CTkFont(size=22, weight="bold")).grid(
|
||||||
|
row=0, column=0, sticky="w", padx=24, pady=(20, 8))
|
||||||
|
|
||||||
|
self._scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
||||||
|
self._scroll.grid(row=1, column=0, sticky="nsew", padx=24, pady=(0, 12))
|
||||||
|
|
||||||
|
self._build_cards()
|
||||||
|
|
||||||
|
def refresh(self) -> None:
|
||||||
|
# Config info may have changed (e.g. after wizard); rebuild cards.
|
||||||
|
for w in self._scroll.winfo_children():
|
||||||
|
w.destroy()
|
||||||
|
self._build_cards()
|
||||||
|
|
||||||
|
def _build_cards(self) -> None:
|
||||||
|
# ── Server & Paths ────────────────────────────────────────────────────
|
||||||
|
c1 = ctk.CTkFrame(self._scroll)
|
||||||
|
c1.pack(fill="x", pady=6)
|
||||||
|
ctk.CTkLabel(c1, text="Server & Path Configuration",
|
||||||
|
font=ctk.CTkFont(size=14, weight="bold")).pack(
|
||||||
|
anchor="w", padx=16, pady=(14, 3))
|
||||||
|
ctk.CTkLabel(c1,
|
||||||
|
text="Re-run the setup wizard to change your server URL, "
|
||||||
|
"credentials, or Arma folder.",
|
||||||
|
text_color="gray", wraplength=600, justify="left").pack(
|
||||||
|
anchor="w", padx=16, pady=(0, 8))
|
||||||
|
ctk.CTkButton(c1, text="Open Setup Wizard", width=160,
|
||||||
|
command=self.app.open_wizard).pack(
|
||||||
|
anchor="e", padx=16, pady=(0, 14))
|
||||||
|
|
||||||
|
# ── Appearance ────────────────────────────────────────────────────────
|
||||||
|
c2 = ctk.CTkFrame(self._scroll)
|
||||||
|
c2.pack(fill="x", pady=6)
|
||||||
|
ctk.CTkLabel(c2, text="Appearance",
|
||||||
|
font=ctk.CTkFont(size=14, weight="bold")).pack(
|
||||||
|
anchor="w", padx=16, pady=(14, 3))
|
||||||
|
mode_var = ctk.StringVar(value=ctk.get_appearance_mode())
|
||||||
|
ctk.CTkOptionMenu(c2, values=["Dark", "Light", "System"],
|
||||||
|
variable=mode_var,
|
||||||
|
command=ctk.set_appearance_mode,
|
||||||
|
width=140).pack(anchor="w", padx=16, pady=(0, 14))
|
||||||
|
|
||||||
|
# ── Current config info ───────────────────────────────────────────────
|
||||||
|
cfg = self.app.cfg
|
||||||
|
if cfg:
|
||||||
|
c3 = ctk.CTkFrame(self._scroll)
|
||||||
|
c3.pack(fill="x", pady=6)
|
||||||
|
ctk.CTkLabel(c3, text="Current Configuration",
|
||||||
|
font=ctk.CTkFont(size=14, weight="bold")).pack(
|
||||||
|
anchor="w", padx=16, pady=(14, 3))
|
||||||
|
info = (
|
||||||
|
f"Server: {cfg.server_url}\n"
|
||||||
|
f"Arma dir: {cfg.arma_dir}\n"
|
||||||
|
f"Downloads: {cfg.downloads}\n"
|
||||||
|
f"Presets: {cfg.modlist_html}\n"
|
||||||
|
)
|
||||||
|
ctk.CTkLabel(c3, text=info, justify="left",
|
||||||
|
font=ctk.CTkFont(family="Consolas", size=11),
|
||||||
|
text_color="gray").pack(anchor="w", padx=16, pady=(0, 14))
|
||||||
409
gui/views/tools.py
Normal file
409
gui/views/tools.py
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from tkinter import messagebox
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
from gui._constants import COLOR_WARN, PROJECT_ROOT
|
||||||
|
from gui.views.base import BaseView
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from gui.app import ArmaModManagerApp
|
||||||
|
|
||||||
|
_WARN_COLOR = COLOR_WARN
|
||||||
|
|
||||||
|
|
||||||
|
class ToolsView(BaseView):
|
||||||
|
"""Per-tool panels inside a CTkTabview."""
|
||||||
|
|
||||||
|
def build(self) -> None:
|
||||||
|
self.grid_columnconfigure(0, weight=1)
|
||||||
|
self.grid_rowconfigure(1, weight=1)
|
||||||
|
|
||||||
|
ctk.CTkLabel(self, text="Tools",
|
||||||
|
font=ctk.CTkFont(size=22, weight="bold")).grid(
|
||||||
|
row=0, column=0, sticky="w", padx=24, pady=(20, 8))
|
||||||
|
|
||||||
|
self._tab_view = ctk.CTkTabview(self)
|
||||||
|
self._tab_view.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12))
|
||||||
|
|
||||||
|
# Per-tab group menu references so refresh() can repopulate them all
|
||||||
|
self._group_menus: list[tuple[ctk.CTkOptionMenu, ctk.StringVar]] = []
|
||||||
|
|
||||||
|
self._build_check_names_tab()
|
||||||
|
self._build_update_mods_tab()
|
||||||
|
self._build_link_mods_tab()
|
||||||
|
self._build_sync_missing_tab()
|
||||||
|
self._build_report_missing_tab()
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Public
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def refresh(self) -> None:
|
||||||
|
groups = self._get_groups()
|
||||||
|
all_groups = ["All groups"] + groups
|
||||||
|
|
||||||
|
# Repopulate generic group menus
|
||||||
|
for menu, var in self._group_menus:
|
||||||
|
prev = var.get()
|
||||||
|
menu.configure(values=all_groups)
|
||||||
|
if prev not in all_groups:
|
||||||
|
var.set("All groups")
|
||||||
|
|
||||||
|
# Link Mods group menu (no "All groups")
|
||||||
|
lm_prev = self._lm_group_var.get()
|
||||||
|
lm_vals = groups if groups else ["(no groups found)"]
|
||||||
|
self._lm_group_menu.configure(values=lm_vals)
|
||||||
|
if lm_prev not in lm_vals:
|
||||||
|
self._lm_group_var.set(lm_vals[0])
|
||||||
|
self._lm_on_change() # re-evaluate button state
|
||||||
|
|
||||||
|
# Info labels
|
||||||
|
self._update_sm_label()
|
||||||
|
self._update_rm_label()
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Private — tab builders
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def _build_check_names_tab(self) -> None:
|
||||||
|
self._tab_view.add("Check Names")
|
||||||
|
tab = self._tab_view.tab("Check Names")
|
||||||
|
tab.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
_desc(tab, row=0,
|
||||||
|
text="Scan mod folders and compare against the server. "
|
||||||
|
"Reports naming mismatches (MISMATCH), unrecognised folders "
|
||||||
|
"(NOT_ON_SERVER), and wrong Steam IDs in meta.cpp (ID_COLLISION).")
|
||||||
|
|
||||||
|
# Group
|
||||||
|
gf = _row(tab, row=1, label="Group:")
|
||||||
|
self._cn_group_var = ctk.StringVar(value="All groups")
|
||||||
|
menu = ctk.CTkOptionMenu(gf, variable=self._cn_group_var,
|
||||||
|
values=["All groups"], width=200)
|
||||||
|
menu.pack(side="left")
|
||||||
|
self._group_menus.append((menu, self._cn_group_var))
|
||||||
|
|
||||||
|
# Checkboxes
|
||||||
|
cf = _row(tab, row=2, label="Options:")
|
||||||
|
self._cn_fix_var = ctk.BooleanVar(value=False)
|
||||||
|
self._cn_fix_ids_var = ctk.BooleanVar(value=False)
|
||||||
|
ctk.CTkCheckBox(cf, text="Auto-fix folder name mismatches (--fix)",
|
||||||
|
variable=self._cn_fix_var,
|
||||||
|
command=self._cn_on_toggle).pack(side="left", padx=(0, 16))
|
||||||
|
ctk.CTkCheckBox(cf, text="Auto-fix wrong Steam IDs in meta.cpp (--fix-ids)",
|
||||||
|
variable=self._cn_fix_ids_var,
|
||||||
|
command=self._cn_on_toggle).pack(side="left")
|
||||||
|
|
||||||
|
# Warning (hidden until checkbox ticked)
|
||||||
|
self._cn_warn = ctk.CTkLabel(
|
||||||
|
tab,
|
||||||
|
text="⚠ --fix renames folders and updates junctions. "
|
||||||
|
"--fix-ids rewrites meta.cpp files.",
|
||||||
|
text_color=_WARN_COLOR, anchor="w",
|
||||||
|
)
|
||||||
|
# not gridded yet — shown on demand
|
||||||
|
|
||||||
|
# Run button
|
||||||
|
ctk.CTkButton(tab, text="Run Check Names", width=180,
|
||||||
|
command=self._cn_run).grid(
|
||||||
|
row=10, column=0, padx=24, pady=(16, 24), sticky="e")
|
||||||
|
|
||||||
|
def _cn_on_toggle(self) -> None:
|
||||||
|
if self._cn_fix_var.get() or self._cn_fix_ids_var.get():
|
||||||
|
self._cn_warn.grid(row=5, column=0, padx=24, pady=(4, 0), sticky="w")
|
||||||
|
else:
|
||||||
|
self._cn_warn.grid_forget()
|
||||||
|
|
||||||
|
def _cn_run(self) -> None:
|
||||||
|
args = ["check_names.py"]
|
||||||
|
g = self._cn_group_var.get()
|
||||||
|
if g != "All groups":
|
||||||
|
args += ["--group", g]
|
||||||
|
if self._cn_fix_var.get():
|
||||||
|
args.append("--fix")
|
||||||
|
if self._cn_fix_ids_var.get():
|
||||||
|
args.append("--fix-ids")
|
||||||
|
self._launch(args)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_update_mods_tab(self) -> None:
|
||||||
|
self._tab_view.add("Update Mods")
|
||||||
|
tab = self._tab_view.tab("Update Mods")
|
||||||
|
tab.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
_desc(tab, row=0,
|
||||||
|
text="Re-download mod files whose size on the server differs from "
|
||||||
|
"your local copy. Use --force to re-download everything "
|
||||||
|
"regardless of size.")
|
||||||
|
|
||||||
|
# Group
|
||||||
|
gf = _row(tab, row=1, label="Group:")
|
||||||
|
self._um_group_var = ctk.StringVar(value="All groups")
|
||||||
|
um_menu = ctk.CTkOptionMenu(
|
||||||
|
gf, variable=self._um_group_var, values=["All groups"], width=200,
|
||||||
|
command=self._um_on_group_change,
|
||||||
|
)
|
||||||
|
um_menu.pack(side="left")
|
||||||
|
self._group_menus.append((um_menu, self._um_group_var))
|
||||||
|
|
||||||
|
# Mod name (enabled only when a specific group is selected)
|
||||||
|
mf = _row(tab, row=2, label="Mod folder:")
|
||||||
|
self._um_mod_entry = ctk.CTkEntry(
|
||||||
|
mf, placeholder_text="Optional — e.g. @ace", width=220,
|
||||||
|
state="disabled",
|
||||||
|
)
|
||||||
|
self._um_mod_entry.pack(side="left")
|
||||||
|
ctk.CTkLabel(mf, text="(only when a specific group is selected)",
|
||||||
|
text_color="gray").pack(side="left", padx=8)
|
||||||
|
|
||||||
|
# Force checkbox
|
||||||
|
ff = _row(tab, row=3, label="Options:")
|
||||||
|
self._um_force_var = ctk.BooleanVar(value=False)
|
||||||
|
ctk.CTkCheckBox(
|
||||||
|
ff, text="Force re-download all files (--force)",
|
||||||
|
variable=self._um_force_var,
|
||||||
|
command=self._um_on_toggle,
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
# Warning
|
||||||
|
self._um_warn = ctk.CTkLabel(
|
||||||
|
tab,
|
||||||
|
text="⚠ --force re-downloads every file regardless of size. "
|
||||||
|
"This may transfer a large amount of data.",
|
||||||
|
text_color=_WARN_COLOR, anchor="w",
|
||||||
|
)
|
||||||
|
|
||||||
|
ctk.CTkButton(tab, text="Run Update", width=180,
|
||||||
|
command=self._um_run).grid(
|
||||||
|
row=10, column=0, padx=24, pady=(16, 24), sticky="e")
|
||||||
|
|
||||||
|
def _um_on_group_change(self, _: str) -> None:
|
||||||
|
is_specific = self._um_group_var.get() != "All groups"
|
||||||
|
self._um_mod_entry.configure(state="normal" if is_specific else "disabled")
|
||||||
|
if not is_specific:
|
||||||
|
self._um_mod_entry.delete(0, "end")
|
||||||
|
|
||||||
|
def _um_on_toggle(self) -> None:
|
||||||
|
if self._um_force_var.get():
|
||||||
|
self._um_warn.grid(row=5, column=0, padx=24, pady=(4, 0), sticky="w")
|
||||||
|
else:
|
||||||
|
self._um_warn.grid_forget()
|
||||||
|
|
||||||
|
def _um_run(self) -> None:
|
||||||
|
args = ["update_mods.py"]
|
||||||
|
g = self._um_group_var.get()
|
||||||
|
if g != "All groups":
|
||||||
|
args += ["--group", g]
|
||||||
|
mod = self._um_mod_entry.get().strip()
|
||||||
|
if mod:
|
||||||
|
args += ["--mod", mod]
|
||||||
|
if self._um_force_var.get():
|
||||||
|
args.append("--force")
|
||||||
|
self._launch(args)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_link_mods_tab(self) -> None:
|
||||||
|
self._tab_view.add("Link Mods")
|
||||||
|
tab = self._tab_view.tab("Link Mods")
|
||||||
|
tab.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
_desc(tab, row=0,
|
||||||
|
text="Manage junction/symlink links between your downloads folder "
|
||||||
|
"and the Arma 3 directory.\n"
|
||||||
|
"Status — show what's linked. "
|
||||||
|
"Link — create missing junctions. "
|
||||||
|
"Unlink — remove junctions (mod files are NOT deleted).")
|
||||||
|
|
||||||
|
# Command selector
|
||||||
|
cf = _row(tab, row=1, label="Command:")
|
||||||
|
self._lm_cmd_var = ctk.StringVar(value="Status")
|
||||||
|
ctk.CTkSegmentedButton(
|
||||||
|
cf,
|
||||||
|
values=["Status", "Link", "Unlink"],
|
||||||
|
variable=self._lm_cmd_var,
|
||||||
|
command=self._lm_on_change,
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
# Group (required — no "All groups")
|
||||||
|
gf = _row(tab, row=2, label="Group:")
|
||||||
|
self._lm_group_var = ctk.StringVar(value="")
|
||||||
|
self._lm_group_menu = ctk.CTkOptionMenu(
|
||||||
|
gf, variable=self._lm_group_var,
|
||||||
|
values=["(no groups found)"], width=200,
|
||||||
|
command=lambda _: self._lm_on_change(),
|
||||||
|
)
|
||||||
|
self._lm_group_menu.pack(side="left")
|
||||||
|
|
||||||
|
# Warning (shown for Unlink)
|
||||||
|
self._lm_warn = ctk.CTkLabel(
|
||||||
|
tab,
|
||||||
|
text="⚠ Unlink removes junction links from the Arma 3 directory. "
|
||||||
|
"Mod files in downloads/ are NOT deleted.",
|
||||||
|
text_color=_WARN_COLOR, anchor="w",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run button (label changes with command)
|
||||||
|
self._lm_run_btn = ctk.CTkButton(
|
||||||
|
tab, text="Show Status", width=180,
|
||||||
|
command=self._lm_run,
|
||||||
|
)
|
||||||
|
self._lm_run_btn.grid(row=10, column=0, padx=24, pady=(16, 24), sticky="e")
|
||||||
|
|
||||||
|
def _lm_on_change(self, _: str = "") -> None:
|
||||||
|
cmd = self._lm_cmd_var.get()
|
||||||
|
labels = {"Status": "Show Status", "Link": "Create Links", "Unlink": "Remove Links"}
|
||||||
|
self._lm_run_btn.configure(text=labels.get(cmd, cmd))
|
||||||
|
|
||||||
|
if cmd == "Unlink":
|
||||||
|
self._lm_warn.grid(row=5, column=0, padx=24, pady=(4, 0), sticky="w")
|
||||||
|
else:
|
||||||
|
self._lm_warn.grid_forget()
|
||||||
|
|
||||||
|
def _lm_run(self) -> None:
|
||||||
|
cmd = self._lm_cmd_var.get().lower()
|
||||||
|
group = self._lm_group_var.get()
|
||||||
|
|
||||||
|
if not group or group == "(no groups found)":
|
||||||
|
messagebox.showwarning("No group selected",
|
||||||
|
"Please select a group from the dropdown.")
|
||||||
|
return
|
||||||
|
|
||||||
|
args = ["link_mods.py", cmd, "--group", group]
|
||||||
|
|
||||||
|
if cmd == "unlink":
|
||||||
|
confirmed = messagebox.askyesno(
|
||||||
|
"Confirm Unlink",
|
||||||
|
f"Remove junction links for group '{group}'?\n\n"
|
||||||
|
"This removes links from the Arma 3 directory but does NOT delete "
|
||||||
|
"mod files in downloads/.",
|
||||||
|
)
|
||||||
|
if not confirmed:
|
||||||
|
return
|
||||||
|
args.append("--yes")
|
||||||
|
|
||||||
|
self._launch(args)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_sync_missing_tab(self) -> None:
|
||||||
|
self._tab_view.add("Sync Missing")
|
||||||
|
tab = self._tab_view.tab("Sync Missing")
|
||||||
|
tab.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
_desc(tab, row=0,
|
||||||
|
text="Retry downloading mods that were missing from the server "
|
||||||
|
"when you last ran the pipeline. "
|
||||||
|
"Checks the server again and downloads any that have since appeared.")
|
||||||
|
|
||||||
|
self._sm_info = ctk.CTkLabel(tab, text="", text_color="gray", anchor="w")
|
||||||
|
self._sm_info.grid(row=1, column=0, padx=24, pady=(4, 0), sticky="w")
|
||||||
|
|
||||||
|
ctk.CTkButton(tab, text="Run Sync Missing", width=180,
|
||||||
|
command=lambda: self._launch(["sync_missing.py"])).grid(
|
||||||
|
row=10, column=0, padx=24, pady=(16, 24), sticky="e")
|
||||||
|
|
||||||
|
def _update_sm_label(self) -> None:
|
||||||
|
cfg = self.app.cfg
|
||||||
|
if not cfg:
|
||||||
|
self._sm_info.configure(text="")
|
||||||
|
return
|
||||||
|
report_path = cfg.missing_report if hasattr(cfg, "missing_report") else (
|
||||||
|
cfg.modlist_json / "missing_report.json"
|
||||||
|
if hasattr(cfg, "modlist_json") else None
|
||||||
|
)
|
||||||
|
if report_path and report_path.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(report_path.read_text(encoding="utf-8"))
|
||||||
|
count = data.get("missing", len(data.get("missing_mods", [])))
|
||||||
|
self._sm_info.configure(
|
||||||
|
text=f"{count} mod(s) currently listed as missing.")
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._sm_info.configure(
|
||||||
|
text="No missing_report.json found — run the pipeline first.")
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_report_missing_tab(self) -> None:
|
||||||
|
self._tab_view.add("Report Missing")
|
||||||
|
tab = self._tab_view.tab("Report Missing")
|
||||||
|
tab.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
_desc(tab, row=0,
|
||||||
|
text="Check which mods from comparison.json are absent from the "
|
||||||
|
"file server. Saves missing_report.json so you can track what "
|
||||||
|
"still needs to be added to the server.")
|
||||||
|
|
||||||
|
self._rm_info = ctk.CTkLabel(tab, text="", text_color="gray", anchor="w")
|
||||||
|
self._rm_info.grid(row=1, column=0, padx=24, pady=(4, 0), sticky="w")
|
||||||
|
|
||||||
|
ctk.CTkButton(tab, text="Generate Report", width=180,
|
||||||
|
command=lambda: self._launch(["report_missing.py"])).grid(
|
||||||
|
row=10, column=0, padx=24, pady=(16, 24), sticky="e")
|
||||||
|
|
||||||
|
def _update_rm_label(self) -> None:
|
||||||
|
cfg = self.app.cfg
|
||||||
|
if not cfg:
|
||||||
|
self._rm_info.configure(text="")
|
||||||
|
return
|
||||||
|
report_path = cfg.missing_report if hasattr(cfg, "missing_report") else (
|
||||||
|
cfg.modlist_json / "missing_report.json"
|
||||||
|
if hasattr(cfg, "modlist_json") else None
|
||||||
|
)
|
||||||
|
if report_path and report_path.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(report_path.read_text(encoding="utf-8"))
|
||||||
|
ts = data.get("generated_at", "unknown")
|
||||||
|
self._rm_info.configure(text=f"Last generated: {ts}")
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._rm_info.configure(text="No report yet.")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Private — helpers
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def _get_groups(self) -> list[str]:
|
||||||
|
cfg = self.app.cfg
|
||||||
|
if cfg and cfg.downloads.is_dir():
|
||||||
|
return sorted(p.name for p in cfg.downloads.iterdir() if p.is_dir())
|
||||||
|
# Fallback: read comparison.json
|
||||||
|
if cfg and cfg.comparison.exists():
|
||||||
|
try:
|
||||||
|
comp = json.loads(cfg.comparison.read_text(encoding="utf-8"))
|
||||||
|
return ["shared"] + list(comp.get("unique", {}).keys())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _launch(self, args: list[str]) -> None:
|
||||||
|
self.app.navigate_to("Logs")
|
||||||
|
self.app.run_tool(args)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Layout helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _desc(parent, row: int, text: str) -> ctk.CTkLabel:
|
||||||
|
lbl = ctk.CTkLabel(parent, text=text, justify="left",
|
||||||
|
wraplength=700, text_color="gray", anchor="w")
|
||||||
|
lbl.grid(row=row, column=0, padx=24, pady=(16, 8), sticky="ew")
|
||||||
|
return lbl
|
||||||
|
|
||||||
|
|
||||||
|
def _row(parent, row: int, label: str) -> ctk.CTkFrame:
|
||||||
|
"""A label + horizontal frame for a settings row."""
|
||||||
|
ctk.CTkLabel(parent, text=label, anchor="w", width=110).grid(
|
||||||
|
row=row, column=0, padx=(24, 0), pady=6, sticky="w")
|
||||||
|
f = ctk.CTkFrame(parent, fg_color="transparent")
|
||||||
|
f.grid(row=row, column=0, padx=(140, 24), pady=6, sticky="w")
|
||||||
|
return f
|
||||||
187
gui/wizard.py
Normal file
187
gui/wizard.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
from tkinter import filedialog
|
||||||
|
|
||||||
|
from gui._constants import COLOR_OK, COLOR_ERROR, PROJECT_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
class SetupWizard(ctk.CTkToplevel):
|
||||||
|
"""Modal first-run wizard that writes config.json."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent: ctk.CTk,
|
||||||
|
on_complete: Callable[[], None],
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.title("Setup — Arma Mod Manager")
|
||||||
|
self.geometry("500x420")
|
||||||
|
self.resizable(False, False)
|
||||||
|
self.grab_set()
|
||||||
|
self.lift()
|
||||||
|
self.focus_force()
|
||||||
|
|
||||||
|
self._on_complete = on_complete
|
||||||
|
self._url = ctk.StringVar(value="https://")
|
||||||
|
self._user = ctk.StringVar()
|
||||||
|
self._pw = ctk.StringVar()
|
||||||
|
self._arma = ctk.StringVar()
|
||||||
|
|
||||||
|
self._body = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
self._body.pack(fill="both", expand=True, padx=28, pady=24)
|
||||||
|
|
||||||
|
self._show(0)
|
||||||
|
|
||||||
|
def _clear(self) -> None:
|
||||||
|
for w in self._body.winfo_children():
|
||||||
|
w.destroy()
|
||||||
|
|
||||||
|
def _show(self, step: int) -> None:
|
||||||
|
self._clear()
|
||||||
|
[self._page_server, self._page_paths, self._page_review][step]()
|
||||||
|
|
||||||
|
# ── Page 1: server ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _page_server(self) -> None:
|
||||||
|
ctk.CTkLabel(
|
||||||
|
self._body, text="Step 1 of 3 — Server Connection",
|
||||||
|
font=ctk.CTkFont(size=16, weight="bold"),
|
||||||
|
).pack(anchor="w")
|
||||||
|
ctk.CTkLabel(
|
||||||
|
self._body, text="Enter the details for your Caddy mod server.",
|
||||||
|
text_color="gray",
|
||||||
|
).pack(anchor="w", pady=(4, 18))
|
||||||
|
|
||||||
|
for lbl, var, show in [
|
||||||
|
("Server URL", self._url, ""),
|
||||||
|
("Username", self._user, ""),
|
||||||
|
("Password", self._pw, "•"),
|
||||||
|
]:
|
||||||
|
ctk.CTkLabel(self._body, text=lbl).pack(anchor="w")
|
||||||
|
ctk.CTkEntry(self._body, textvariable=var, width=440, show=show).pack(
|
||||||
|
anchor="w", pady=(2, 10))
|
||||||
|
|
||||||
|
foot = ctk.CTkFrame(self._body, fg_color="transparent")
|
||||||
|
foot.pack(fill="x", pady=(8, 0))
|
||||||
|
self._conn_lbl = ctk.CTkLabel(foot, text="", text_color="gray")
|
||||||
|
self._conn_lbl.pack(side="left")
|
||||||
|
ctk.CTkButton(foot, text="Next →", width=90,
|
||||||
|
command=lambda: self._show(1)).pack(side="right")
|
||||||
|
ctk.CTkButton(foot, text="Test Connection", width=140,
|
||||||
|
fg_color="transparent", border_width=1,
|
||||||
|
text_color=("gray10", "gray90"),
|
||||||
|
command=self._test).pack(side="right", padx=(0, 8))
|
||||||
|
|
||||||
|
def _test(self) -> None:
|
||||||
|
self._conn_lbl.configure(text="Testing…", text_color="gray")
|
||||||
|
self.update()
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
r = requests.get(self._url.get(),
|
||||||
|
auth=(self._user.get(), self._pw.get()),
|
||||||
|
timeout=8)
|
||||||
|
if r.ok:
|
||||||
|
self._conn_lbl.configure(text="✓ Connected", text_color=COLOR_OK)
|
||||||
|
else:
|
||||||
|
self._conn_lbl.configure(text=f"✗ HTTP {r.status_code}",
|
||||||
|
text_color=COLOR_ERROR)
|
||||||
|
except Exception as e:
|
||||||
|
self._conn_lbl.configure(text=f"✗ {e}", text_color=COLOR_ERROR)
|
||||||
|
|
||||||
|
# ── Page 2: paths ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _page_paths(self) -> None:
|
||||||
|
ctk.CTkLabel(
|
||||||
|
self._body, text="Step 2 of 3 — Arma 3 Server Folder",
|
||||||
|
font=ctk.CTkFont(size=16, weight="bold"),
|
||||||
|
).pack(anchor="w")
|
||||||
|
ctk.CTkLabel(
|
||||||
|
self._body,
|
||||||
|
text="Point to your Arma 3 Server installation. "
|
||||||
|
"Links (junctions) will be created here.",
|
||||||
|
text_color="gray", wraplength=440, justify="left",
|
||||||
|
).pack(anchor="w", pady=(4, 18))
|
||||||
|
|
||||||
|
ctk.CTkLabel(self._body, text="Arma 3 Server folder").pack(anchor="w")
|
||||||
|
row = ctk.CTkFrame(self._body, fg_color="transparent")
|
||||||
|
row.pack(fill="x", pady=(2, 8))
|
||||||
|
ctk.CTkEntry(row, textvariable=self._arma, width=350).pack(side="left")
|
||||||
|
ctk.CTkButton(row, text="Browse", width=80,
|
||||||
|
command=self._browse_arma).pack(side="left", padx=8)
|
||||||
|
|
||||||
|
ctk.CTkLabel(
|
||||||
|
self._body,
|
||||||
|
text="All other folders (downloads, presets) will be created "
|
||||||
|
"automatically next to this tool.",
|
||||||
|
text_color="gray", font=ctk.CTkFont(size=11),
|
||||||
|
wraplength=440, justify="left",
|
||||||
|
).pack(anchor="w", pady=(8, 0))
|
||||||
|
|
||||||
|
foot = ctk.CTkFrame(self._body, fg_color="transparent")
|
||||||
|
foot.pack(fill="x", pady=(20, 0))
|
||||||
|
ctk.CTkButton(foot, text="← Back", width=80,
|
||||||
|
fg_color="transparent", border_width=1,
|
||||||
|
text_color=("gray10", "gray90"),
|
||||||
|
command=lambda: self._show(0)).pack(side="left")
|
||||||
|
ctk.CTkButton(foot, text="Next →", width=80,
|
||||||
|
command=lambda: self._show(2)).pack(side="right")
|
||||||
|
|
||||||
|
def _browse_arma(self) -> None:
|
||||||
|
d = filedialog.askdirectory(title="Select Arma 3 Server folder")
|
||||||
|
if d:
|
||||||
|
self._arma.set(d)
|
||||||
|
|
||||||
|
# ── Page 3: review + save ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _page_review(self) -> None:
|
||||||
|
ctk.CTkLabel(
|
||||||
|
self._body, text="Step 3 of 3 — Review & Save",
|
||||||
|
font=ctk.CTkFont(size=16, weight="bold"),
|
||||||
|
).pack(anchor="w")
|
||||||
|
ctk.CTkLabel(
|
||||||
|
self._body, text="Check your settings, then click Save.",
|
||||||
|
text_color="gray",
|
||||||
|
).pack(anchor="w", pady=(4, 14))
|
||||||
|
|
||||||
|
summary = (
|
||||||
|
f"Server URL: {self._url.get()}\n"
|
||||||
|
f"Username: {self._user.get()}\n"
|
||||||
|
f"Arma folder: {self._arma.get() or '(not set)'}\n"
|
||||||
|
)
|
||||||
|
box = ctk.CTkTextbox(self._body, height=90,
|
||||||
|
font=ctk.CTkFont(family="Consolas", size=12))
|
||||||
|
box.insert("1.0", summary)
|
||||||
|
box.configure(state="disabled")
|
||||||
|
box.pack(fill="x", pady=(0, 16))
|
||||||
|
|
||||||
|
foot = ctk.CTkFrame(self._body, fg_color="transparent")
|
||||||
|
foot.pack(fill="x")
|
||||||
|
ctk.CTkButton(foot, text="← Back", width=80,
|
||||||
|
fg_color="transparent", border_width=1,
|
||||||
|
text_color=("gray10", "gray90"),
|
||||||
|
command=lambda: self._show(1)).pack(side="left")
|
||||||
|
ctk.CTkButton(foot, text="Save & Open", width=120,
|
||||||
|
command=self._save).pack(side="right")
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
cfg = {
|
||||||
|
"server": {
|
||||||
|
"base_url": self._url.get(),
|
||||||
|
"username": self._user.get(),
|
||||||
|
"password": self._pw.get(),
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"arma_dir": self._arma.get(),
|
||||||
|
"downloads": "downloads",
|
||||||
|
"modlist_html": "modlist_html",
|
||||||
|
"modlist_json": "modlist_json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
(PROJECT_ROOT / "config.json").write_text(
|
||||||
|
json.dumps(cfg, indent=2), encoding="utf-8")
|
||||||
|
self.destroy()
|
||||||
|
self._on_complete()
|
||||||
@@ -99,7 +99,7 @@ def cmd_link(cfg, group: str) -> None:
|
|||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
||||||
def cmd_unlink(cfg, group: str) -> None:
|
def cmd_unlink(cfg, group: str, yes: bool = False) -> None:
|
||||||
group_dir = cfg.downloads / group
|
group_dir = cfg.downloads / group
|
||||||
if not group_dir.is_dir():
|
if not group_dir.is_dir():
|
||||||
print(f"ERROR: group folder not found: {group_dir}")
|
print(f"ERROR: group folder not found: {group_dir}")
|
||||||
@@ -118,6 +118,9 @@ def cmd_unlink(cfg, group: str) -> None:
|
|||||||
print(f" Group: {group} ({linked_count} linked mod(s))")
|
print(f" Group: {group} ({linked_count} linked mod(s))")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
if yes:
|
||||||
|
print(" (--yes flag set — skipping confirmation)")
|
||||||
|
else:
|
||||||
choice = input(" Continue? [y/N]: ").strip().lower()
|
choice = input(" Continue? [y/N]: ").strip().lower()
|
||||||
if choice not in ("y", "yes"):
|
if choice not in ("y", "yes"):
|
||||||
print(" Aborted.\n")
|
print(" Aborted.\n")
|
||||||
@@ -158,6 +161,8 @@ def main() -> None:
|
|||||||
)
|
)
|
||||||
parser.add_argument("command", choices=["status", "link", "unlink"])
|
parser.add_argument("command", choices=["status", "link", "unlink"])
|
||||||
parser.add_argument("--group", "-g", metavar="GROUP")
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if not args.group:
|
if not args.group:
|
||||||
@@ -176,7 +181,7 @@ def main() -> None:
|
|||||||
elif args.command == "link":
|
elif args.command == "link":
|
||||||
cmd_link(cfg, args.group)
|
cmd_link(cfg, args.group)
|
||||||
elif args.command == "unlink":
|
elif args.command == "unlink":
|
||||||
cmd_unlink(cfg, args.group)
|
cmd_unlink(cfg, args.group, yes=args.yes)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
121
modlist_html/Test_Preset_A.html
Normal file
121
modlist_html/Test_Preset_A.html
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<html>
|
||||||
|
<!--Created by Arma 3 Launcher: https://arma3.com-->
|
||||||
|
<head>
|
||||||
|
<meta name="arma:Type" content="list" />
|
||||||
|
<meta name="generator" content="Arma 3 Launcher - https://arma3.com" />
|
||||||
|
<title>Arma 3</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet" type="text/css" />
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: #fff;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
body, th, td {
|
||||||
|
font: 95%/1.3 Roboto, Segoe UI, Tahoma, Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 3px 30px 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
padding: 20px 20px 0 20px;
|
||||||
|
color: white;
|
||||||
|
font-weight: 200;
|
||||||
|
font-family: segoe ui;
|
||||||
|
font-size: 3em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
em {
|
||||||
|
font-variant: italic;
|
||||||
|
color:silver;
|
||||||
|
}
|
||||||
|
|
||||||
|
.before-list {
|
||||||
|
padding: 5px 20px 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-list {
|
||||||
|
background: #222222;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlc-list {
|
||||||
|
background: #222222;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 20px;
|
||||||
|
color:gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whups {
|
||||||
|
color:gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #D18F21;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color:#F1AF41;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.from-steam {
|
||||||
|
color: #449EBD;
|
||||||
|
}
|
||||||
|
.from-local {
|
||||||
|
color: gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Arma 3 Mods</h1>
|
||||||
|
<p class="before-list">
|
||||||
|
<em>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.</em>
|
||||||
|
</p>
|
||||||
|
<div class="mod-list">
|
||||||
|
<table>
|
||||||
|
<tr data-type="ModContainer">
|
||||||
|
<td data-type="DisplayName">CBA_A3</td>
|
||||||
|
<td>
|
||||||
|
<span class="from-steam">Steam</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=450814997" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=450814997</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr data-type="ModContainer">
|
||||||
|
<td data-type="DisplayName">Zeus Enhanced</td>
|
||||||
|
<td>
|
||||||
|
<span class="from-steam">Steam</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1779063631" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1779063631</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr data-type="ModContainer">
|
||||||
|
<td data-type="DisplayName">Enhanced Movement</td>
|
||||||
|
<td>
|
||||||
|
<span class="from-steam">Steam</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=333310405" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=333310405</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<span>Created by Arma 3 Launcher (https://arma3.com)</span>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
121
modlist_html/Test_Preset_B.html
Normal file
121
modlist_html/Test_Preset_B.html
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<html>
|
||||||
|
<!--Created by Arma 3 Launcher: https://arma3.com-->
|
||||||
|
<head>
|
||||||
|
<meta name="arma:Type" content="list" />
|
||||||
|
<meta name="generator" content="Arma 3 Launcher - https://arma3.com" />
|
||||||
|
<title>Arma 3</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet" type="text/css" />
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: #fff;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
body, th, td {
|
||||||
|
font: 95%/1.3 Roboto, Segoe UI, Tahoma, Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 3px 30px 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
padding: 20px 20px 0 20px;
|
||||||
|
color: white;
|
||||||
|
font-weight: 200;
|
||||||
|
font-family: segoe ui;
|
||||||
|
font-size: 3em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
em {
|
||||||
|
font-variant: italic;
|
||||||
|
color:silver;
|
||||||
|
}
|
||||||
|
|
||||||
|
.before-list {
|
||||||
|
padding: 5px 20px 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-list {
|
||||||
|
background: #222222;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlc-list {
|
||||||
|
background: #222222;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 20px;
|
||||||
|
color:gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whups {
|
||||||
|
color:gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #D18F21;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color:#F1AF41;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.from-steam {
|
||||||
|
color: #449EBD;
|
||||||
|
}
|
||||||
|
.from-local {
|
||||||
|
color: gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Arma 3 Mods</h1>
|
||||||
|
<p class="before-list">
|
||||||
|
<em>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.</em>
|
||||||
|
</p>
|
||||||
|
<div class="mod-list">
|
||||||
|
<table>
|
||||||
|
<tr data-type="ModContainer">
|
||||||
|
<td data-type="DisplayName">CBA_A3</td>
|
||||||
|
<td>
|
||||||
|
<span class="from-steam">Steam</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=450814997" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=450814997</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr data-type="ModContainer">
|
||||||
|
<td data-type="DisplayName">Zeus Enhanced</td>
|
||||||
|
<td>
|
||||||
|
<span class="from-steam">Steam</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1779063631" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1779063631</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr data-type="ModContainer">
|
||||||
|
<td data-type="DisplayName">DUI - Squad Radar</td>
|
||||||
|
<td>
|
||||||
|
<span class="from-steam">Steam</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1638341685" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1638341685</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<span>Created by Arma 3 Launcher (https://arma3.com)</span>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
requests
|
requests
|
||||||
tqdm
|
tqdm
|
||||||
|
customtkinter
|
||||||
|
|||||||
6
selection.json
Normal file
6
selection.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"selected": [
|
||||||
|
"Test_Preset_A.html",
|
||||||
|
"Test_Preset_B.html"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -33,12 +33,19 @@ def group(name: str) -> None:
|
|||||||
print(f"{'-'*60}")
|
print(f"{'-'*60}")
|
||||||
|
|
||||||
|
|
||||||
|
class _SkipTest(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def test(name: str, fn) -> None:
|
def test(name: str, fn) -> None:
|
||||||
global _passed, _failed
|
global _passed, _failed, _skipped
|
||||||
try:
|
try:
|
||||||
fn()
|
fn()
|
||||||
print(f" [PASS] {name}")
|
print(f" [PASS] {name}")
|
||||||
_passed += 1
|
_passed += 1
|
||||||
|
except _SkipTest as exc:
|
||||||
|
print(f" [SKIP] {name} ({exc})")
|
||||||
|
_skipped += 1
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f" [FAIL] {name}")
|
print(f" [FAIL] {name}")
|
||||||
for line in traceback.format_exc().splitlines():
|
for line in traceback.format_exc().splitlines():
|
||||||
@@ -1323,7 +1330,7 @@ def _test_comparison_json_consistent_with_html():
|
|||||||
html_dir = Path(__file__).parent / "modlist_html"
|
html_dir = Path(__file__).parent / "modlist_html"
|
||||||
json_file = Path(__file__).parent / "modlist_json" / "comparison.json"
|
json_file = Path(__file__).parent / "modlist_json" / "comparison.json"
|
||||||
if not json_file.exists():
|
if not json_file.exists():
|
||||||
raise AssertionError(f"comparison.json not found: {json_file}")
|
raise _SkipTest("comparison.json not found (run pipeline first)")
|
||||||
|
|
||||||
presets = parse_modlist_dir(html_dir)
|
presets = parse_modlist_dir(html_dir)
|
||||||
fresh = compare_presets(*presets)
|
fresh = compare_presets(*presets)
|
||||||
|
|||||||
Reference in New Issue
Block a user