GUI log batching (_poll_log now drains queue into a single CTkTextbox.insert
call per 80 ms tick instead of N calls, each with see("end") scroll).
_QueueWriter strips ANSI/CSI escape codes and bare \r before enqueuing so
tqdm progress output is legible in the log textbox. OSC sequences terminated
by both BEL (\x07) and ST (\x1b\) are handled.
Wizard "Test Connection" moved off the main thread: requests.get runs in a
daemon thread; result posted back via after(0, ...). Widget refs captured
before thread launch to prevent stale updates if user navigates away. Bare
except narrowed to TclError (destroyed-widget guard only).
Code quality: import os moved to module level in app.py; _read_raw_config()
helper extracted to deduplicate dual raw config.json reads; return type
annotations added to _get_view_class, _get_dashboard, and cfg property.
Tests: 11 new unit tests for _QueueWriter (RED -> GREEN on OSC-ST fix).
206 lines
7.9 KiB
Python
206 lines
7.9 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
from tkinter import TclError, filedialog
|
|
from typing import Callable
|
|
|
|
import customtkinter as ctk
|
|
|
|
from gui._constants import COLOR_OK, COLOR_ERROR, PROJECT_ROOT
|
|
from gui.locales import t
|
|
|
|
|
|
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(t("wizard.title"))
|
|
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=t("wizard.step1_title"),
|
|
font=ctk.CTkFont(size=16, weight="bold"),
|
|
).pack(anchor="w")
|
|
ctk.CTkLabel(
|
|
self._body, text=t("wizard.step1_desc"),
|
|
text_color="gray",
|
|
).pack(anchor="w", pady=(4, 18))
|
|
|
|
for lbl_key, var, show in [
|
|
("wizard.label_url", self._url, ""),
|
|
("wizard.label_user", self._user, ""),
|
|
("wizard.label_pw", self._pw, "•"),
|
|
]:
|
|
ctk.CTkLabel(self._body, text=t(lbl_key)).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=t("wizard.btn_next"), width=90,
|
|
command=lambda: self._show(1)).pack(side="right")
|
|
self._test_btn = ctk.CTkButton(foot, text=t("wizard.btn_test"), width=140,
|
|
fg_color="transparent", border_width=1,
|
|
text_color=("gray10", "gray90"),
|
|
command=self._test)
|
|
self._test_btn.pack(side="right", padx=(0, 8))
|
|
|
|
def _test(self) -> None:
|
|
# Capture widget refs now — _clear() replaces them if the user
|
|
# navigates away and back while the request is in-flight.
|
|
lbl = self._conn_lbl
|
|
btn = self._test_btn
|
|
lbl.configure(text=t("wizard.testing"), text_color="gray")
|
|
btn.configure(state="disabled")
|
|
|
|
url = self._url.get()
|
|
auth = (self._user.get(), self._pw.get())
|
|
|
|
def worker() -> None:
|
|
try:
|
|
import requests
|
|
r = requests.get(url, auth=auth, timeout=8)
|
|
if r.ok:
|
|
result = (t("wizard.connected"), COLOR_OK)
|
|
else:
|
|
result = (t("wizard.http_error", code=r.status_code), COLOR_ERROR)
|
|
except Exception as e:
|
|
result = (t("wizard.conn_error", e=e), COLOR_ERROR)
|
|
self.after(0, lambda: _apply_test_result(lbl, btn, *result))
|
|
|
|
threading.Thread(target=worker, daemon=True).start()
|
|
|
|
|
|
def _apply_test_result(lbl: ctk.CTkLabel, btn: ctk.CTkButton,
|
|
text: str, color: str) -> None:
|
|
"""Update connection test result widgets. Silently ignores destroyed widgets."""
|
|
try:
|
|
lbl.configure(text=text, text_color=color)
|
|
btn.configure(state="normal")
|
|
except TclError:
|
|
pass # wizard was closed before the HTTP response arrived
|
|
|
|
# ── Page 2: paths ────────────────────────────────────────────────────────
|
|
|
|
def _page_paths(self) -> None:
|
|
ctk.CTkLabel(
|
|
self._body, text=t("wizard.step2_title"),
|
|
font=ctk.CTkFont(size=16, weight="bold"),
|
|
).pack(anchor="w")
|
|
ctk.CTkLabel(
|
|
self._body, text=t("wizard.step2_desc"),
|
|
text_color="gray", wraplength=440, justify="left",
|
|
).pack(anchor="w", pady=(4, 18))
|
|
|
|
ctk.CTkLabel(self._body, text=t("wizard.label_arma")).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=t("wizard.btn_browse"), width=80,
|
|
command=self._browse_arma).pack(side="left", padx=8)
|
|
|
|
ctk.CTkLabel(
|
|
self._body, text=t("wizard.step2_hint"),
|
|
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=t("wizard.btn_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=t("wizard.btn_next"), width=80,
|
|
command=lambda: self._show(2)).pack(side="right")
|
|
|
|
def _browse_arma(self) -> None:
|
|
d = filedialog.askdirectory(title=t("wizard.browse_title"))
|
|
if d:
|
|
self._arma.set(d)
|
|
|
|
# ── Page 3: review + save ────────────────────────────────────────────────
|
|
|
|
def _page_review(self) -> None:
|
|
ctk.CTkLabel(
|
|
self._body, text=t("wizard.step3_title"),
|
|
font=ctk.CTkFont(size=16, weight="bold"),
|
|
).pack(anchor="w")
|
|
ctk.CTkLabel(
|
|
self._body, text=t("wizard.step3_desc"),
|
|
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 t('wizard.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=t("wizard.btn_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=t("wizard.btn_save"), 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()
|