fix: smooth GUI during pipeline downloads and harden wizard connection test
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).
This commit is contained in:
14
gui/_io.py
14
gui/_io.py
@@ -2,6 +2,14 @@ from __future__ import annotations
|
||||
|
||||
import io
|
||||
import queue
|
||||
import re
|
||||
|
||||
# Strip ANSI escape sequences and normalise carriage returns so tqdm output
|
||||
# is readable in the log textbox (which has no terminal emulation).
|
||||
_ANSI_RE = re.compile(
|
||||
r"\x1b\[[0-9;]*[A-Za-z]" # CSI sequences e.g. \x1b[32m
|
||||
r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC sequences, BEL or ST terminator
|
||||
)
|
||||
|
||||
|
||||
class _QueueWriter(io.TextIOBase):
|
||||
@@ -12,7 +20,11 @@ class _QueueWriter(io.TextIOBase):
|
||||
|
||||
def write(self, text: str) -> int: # type: ignore[override]
|
||||
if text:
|
||||
self._q.put(text)
|
||||
cleaned = _ANSI_RE.sub("", text)
|
||||
cleaned = cleaned.replace("\r\n", "\n") # Windows CRLF → LF
|
||||
cleaned = cleaned.replace("\r", "\n") # bare CR → newline
|
||||
if cleaned:
|
||||
self._q.put(cleaned)
|
||||
return len(text)
|
||||
|
||||
def flush(self) -> None:
|
||||
|
||||
49
gui/app.py
49
gui/app.py
@@ -1,12 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from arma_modlist_tools.config import Config
|
||||
from gui.views.dashboard import DashboardView
|
||||
|
||||
import customtkinter as ctk
|
||||
from tkinter import messagebox
|
||||
@@ -27,7 +32,7 @@ from gui.views.base import BaseView
|
||||
_VIEW_NAMES = ("Dashboard", "Mods", "Tools", "Logs", "Settings")
|
||||
|
||||
|
||||
def _get_view_class(name: str):
|
||||
def _get_view_class(name: str) -> type[BaseView]:
|
||||
from gui.views import DashboardView, ModsView, ToolsView, LogsView, SettingsView
|
||||
return {
|
||||
"Dashboard": DashboardView,
|
||||
@@ -72,7 +77,7 @@ class ArmaModManagerApp(ctk.CTk):
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def cfg(self):
|
||||
def cfg(self) -> Optional["Config"]:
|
||||
"""Loaded Config object, or None if config.json is missing/invalid."""
|
||||
return self._cfg
|
||||
|
||||
@@ -203,7 +208,6 @@ class ArmaModManagerApp(ctk.CTk):
|
||||
|
||||
def run_tool(self, script_args: list[str]) -> None:
|
||||
"""Run a maintenance script via subprocess, streaming output to Logs."""
|
||||
import os
|
||||
script = script_args[0]
|
||||
extra = script_args[1:]
|
||||
|
||||
@@ -259,26 +263,28 @@ class ArmaModManagerApp(ctk.CTk):
|
||||
# Private — language
|
||||
# =========================================================================
|
||||
|
||||
@staticmethod
|
||||
def _read_raw_config() -> dict:
|
||||
"""Return config.json as a raw dict, or {} on missing / parse error."""
|
||||
try:
|
||||
return json.loads((PROJECT_ROOT / "config.json").read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _apply_startup_language(self) -> None:
|
||||
"""Read language preference from config.json and activate it."""
|
||||
from gui import locales
|
||||
lang = "en"
|
||||
cfg_path = PROJECT_ROOT / "config.json"
|
||||
if cfg_path.exists():
|
||||
try:
|
||||
raw = json.loads(cfg_path.read_text(encoding="utf-8"))
|
||||
lang = raw.get("ui", {}).get("language", "en")
|
||||
except Exception:
|
||||
pass
|
||||
lang = self._read_raw_config().get("ui", {}).get("language", "en")
|
||||
locales.set_language(lang)
|
||||
|
||||
def _save_language_pref(self, lang: str) -> None:
|
||||
"""Persist language preference into the 'ui' key of config.json."""
|
||||
cfg_path = PROJECT_ROOT / "config.json"
|
||||
try:
|
||||
raw = json.loads(cfg_path.read_text(encoding="utf-8"))
|
||||
raw = self._read_raw_config()
|
||||
raw.setdefault("ui", {})["language"] = lang
|
||||
cfg_path.write_text(json.dumps(raw, indent=2), encoding="utf-8")
|
||||
(PROJECT_ROOT / "config.json").write_text(
|
||||
json.dumps(raw, indent=2), encoding="utf-8"
|
||||
)
|
||||
except Exception:
|
||||
pass # non-fatal — language preference simply resets next run
|
||||
|
||||
@@ -362,15 +368,16 @@ class ArmaModManagerApp(ctk.CTk):
|
||||
# =========================================================================
|
||||
|
||||
def _poll_log(self) -> None:
|
||||
parts: list[str] = []
|
||||
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)
|
||||
parts.append(self._log_q.get_nowait())
|
||||
except queue.Empty:
|
||||
pass
|
||||
if parts:
|
||||
logs_view = self._view_cache.get("Logs")
|
||||
if logs_view is not None and hasattr(logs_view, "append"):
|
||||
logs_view.append("".join(parts)) # type: ignore[attr-defined]
|
||||
self.after(80, self._poll_log)
|
||||
|
||||
def _redirect_output(self) -> None:
|
||||
@@ -393,7 +400,7 @@ class ArmaModManagerApp(ctk.CTk):
|
||||
for view in self._view_cache.values():
|
||||
view.refresh()
|
||||
|
||||
def _get_dashboard(self):
|
||||
def _get_dashboard(self) -> "DashboardView":
|
||||
from gui.views.dashboard import DashboardView
|
||||
view = self._view_cache.get("Dashboard")
|
||||
if not isinstance(view, DashboardView):
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from tkinter import TclError, filedialog
|
||||
from typing import Callable
|
||||
|
||||
import customtkinter as ctk
|
||||
from tkinter import filedialog
|
||||
|
||||
from gui._constants import COLOR_OK, COLOR_ERROR, PROJECT_ROOT
|
||||
from gui.locales import t
|
||||
@@ -72,28 +73,46 @@ class SetupWizard(ctk.CTkToplevel):
|
||||
self._conn_lbl.pack(side="left")
|
||||
ctk.CTkButton(foot, text=t("wizard.btn_next"), width=90,
|
||||
command=lambda: self._show(1)).pack(side="right")
|
||||
ctk.CTkButton(foot, text=t("wizard.btn_test"), width=140,
|
||||
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).pack(side="right", padx=(0, 8))
|
||||
command=self._test)
|
||||
self._test_btn.pack(side="right", padx=(0, 8))
|
||||
|
||||
def _test(self) -> None:
|
||||
self._conn_lbl.configure(text=t("wizard.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=t("wizard.connected"),
|
||||
text_color=COLOR_OK)
|
||||
else:
|
||||
self._conn_lbl.configure(text=t("wizard.http_error", code=r.status_code),
|
||||
text_color=COLOR_ERROR)
|
||||
except Exception as e:
|
||||
self._conn_lbl.configure(text=t("wizard.conn_error", e=e),
|
||||
text_color=COLOR_ERROR)
|
||||
# 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 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user