Added ui changes to modals, pre-loader and some fixes for other ui elements and performance improvements all over.
This commit is contained in:
@@ -20,6 +20,8 @@ OFFICIAL_LOGO_URL = "https://pristontale.eu/public/img/pristontale/logo/logo_sma
|
||||
BROADCAST_ENDPOINT = "http://5.135.35.188:90/broadcasts"
|
||||
MAX_NEWS_BYTES = 2 * 1024 * 1024
|
||||
MAX_NEWS_IMAGE_BYTES = 12 * 1024 * 1024
|
||||
MAX_BROADCAST_AUTHOR_CHARACTERS = 100
|
||||
MAX_BROADCAST_CONTENT_CHARACTERS = 2000
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
@@ -241,26 +243,41 @@ def _parse_latest_broadcast(payload: bytes) -> Broadcast:
|
||||
value = json.loads(payload.decode("utf-8-sig"))
|
||||
if not isinstance(value, list) or not value or not isinstance(value[0], dict):
|
||||
raise NewsError("broadcast response does not contain a latest broadcast")
|
||||
latest = value[0]
|
||||
author = latest.get("author")
|
||||
content = latest.get("content")
|
||||
return _broadcast_from_mapping(value[0])
|
||||
|
||||
|
||||
def _broadcast_from_mapping(latest: dict[object, object]) -> Broadcast:
|
||||
author = _bounded_text(latest.get("author"), MAX_BROADCAST_AUTHOR_CHARACTERS)
|
||||
content = _bounded_text(latest.get("content"), MAX_BROADCAST_CONTENT_CHARACTERS)
|
||||
url = _broadcast_url(latest.get("url"))
|
||||
if (
|
||||
not isinstance(author, str)
|
||||
or not author.strip()
|
||||
or not isinstance(content, str)
|
||||
or not content.strip()
|
||||
author is None
|
||||
or content is None
|
||||
or url is None
|
||||
):
|
||||
raise NewsError("latest broadcast is incomplete")
|
||||
published_at = (
|
||||
_format_timestamp(latest.get("timestamp"))
|
||||
if "timestamp" in latest
|
||||
else _bounded_text(latest.get("published_at"), 64)
|
||||
)
|
||||
return Broadcast(
|
||||
author=author.strip(),
|
||||
content=content.strip(),
|
||||
published_at=_format_timestamp(latest.get("timestamp")),
|
||||
author=author,
|
||||
content=content,
|
||||
published_at=published_at,
|
||||
url=url,
|
||||
)
|
||||
|
||||
|
||||
def _bounded_text(value: object, maximum_characters: int) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
text = value.strip()
|
||||
if not text or len(text) > maximum_characters:
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
def _format_timestamp(value: object) -> str | None:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
@@ -288,10 +305,16 @@ def _image_url(value: object) -> str | None:
|
||||
def _broadcast_url(value: object) -> str | None:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
parsed = urlparse(value)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
url = value.strip()
|
||||
parsed = urlparse(url)
|
||||
if (
|
||||
parsed.scheme.casefold() != "https"
|
||||
or not parsed.netloc
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
):
|
||||
return None
|
||||
return value.strip()
|
||||
return url
|
||||
|
||||
|
||||
def _read_cache(path: Path) -> tuple[NewsArticle, ...]:
|
||||
@@ -320,8 +343,8 @@ def _write_cache(path: Path, articles: tuple[NewsArticle, ...]) -> None:
|
||||
def _read_broadcast_cache(path: Path) -> Broadcast | None:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
return Broadcast(**value) if isinstance(value, dict) else None
|
||||
except (OSError, UnicodeError, TypeError, json.JSONDecodeError):
|
||||
return _broadcast_from_mapping(value) if isinstance(value, dict) else None
|
||||
except (OSError, UnicodeError, NewsError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -304,6 +304,7 @@ def create_window_chrome(
|
||||
*,
|
||||
title: str,
|
||||
subtitle: str,
|
||||
window_icon: Any | None = None,
|
||||
show_title_bar: bool = True,
|
||||
show_close: bool = True,
|
||||
show_minimize: bool = False,
|
||||
@@ -347,7 +348,31 @@ def create_window_chrome(
|
||||
title_bar.pack_propagate(False)
|
||||
tk_accent = tk.Frame(title_bar, background="#d98927", width=3)
|
||||
tk_accent.pack(side="left", fill="y")
|
||||
mark = tk.Canvas(
|
||||
title_icon: Any | None = None
|
||||
if window_icon is not None:
|
||||
try:
|
||||
factor = icon_subsample_factor(
|
||||
window_icon.width(),
|
||||
window_icon.height(),
|
||||
20,
|
||||
)
|
||||
title_icon = window_icon.subsample(factor, factor)
|
||||
except (tk.TclError, ValueError):
|
||||
title_icon = None
|
||||
if title_icon is not None:
|
||||
mark = tk.Label(
|
||||
title_bar,
|
||||
image=title_icon,
|
||||
width=22,
|
||||
height=22,
|
||||
background="#17100d",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
cursor="fleur",
|
||||
)
|
||||
mark.image = title_icon
|
||||
else:
|
||||
mark = tk.Frame(
|
||||
title_bar,
|
||||
width=22,
|
||||
height=22,
|
||||
@@ -356,8 +381,6 @@ def create_window_chrome(
|
||||
highlightthickness=0,
|
||||
cursor="fleur",
|
||||
)
|
||||
mark.create_oval(3, 3, 19, 19, outline="#d98927", width=1)
|
||||
mark.create_text(11, 11, text="PT", fill="#f5d18b", font=("Georgia", 6, "bold"))
|
||||
mark.pack(side="left", padx=(9, 7))
|
||||
identity = tk.Frame(title_bar, background="#17100d", cursor="fleur")
|
||||
identity.pack(side="left", fill="y")
|
||||
@@ -440,6 +463,7 @@ def create_dashboard_chrome(
|
||||
*,
|
||||
title: str,
|
||||
subtitle: str,
|
||||
window_icon: Any | None = None,
|
||||
) -> DashboardChrome:
|
||||
"""Install the draggable frame and full controls used by the dashboard."""
|
||||
|
||||
@@ -447,6 +471,7 @@ def create_dashboard_chrome(
|
||||
root,
|
||||
title=title,
|
||||
subtitle=subtitle,
|
||||
window_icon=window_icon,
|
||||
show_close=True,
|
||||
show_minimize=True,
|
||||
allow_close=True,
|
||||
|
||||
514
ptlinux/ui.py
514
ptlinux/ui.py
@@ -9,12 +9,13 @@ import json
|
||||
import math
|
||||
import os
|
||||
import queue
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from typing import TYPE_CHECKING, Callable, Mapping
|
||||
from typing import TYPE_CHECKING, Callable, Iterator, Mapping
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tkinter import Frame, PhotoImage, Toplevel
|
||||
@@ -109,6 +110,10 @@ DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS = 10_000
|
||||
DASHBOARD_TRANSITION_STEP_MILLISECONDS = 16
|
||||
DASHBOARD_TRANSITION_STEPS = 9
|
||||
DASHBOARD_TRANSITION_MINIMUM_OPACITY = 0.18
|
||||
STARTUP_PROGRESS_ANIMATION_INTERVAL_MILLISECONDS = 16
|
||||
STARTUP_PROGRESS_ANIMATION_EASING = 0.28
|
||||
STARTUP_PROGRESS_ANIMATION_MINIMUM_STEP = 0.08
|
||||
PATCH_PRELOAD_INTERVAL_MILLISECONDS = 1
|
||||
PRELOADER_STATUS_CHARACTERS = 64
|
||||
DASHBOARD_DEBUG_KEY = "<Control-Shift-F12>"
|
||||
SERVER_STATUS_STALE_SECONDS = 5 * 60
|
||||
@@ -176,6 +181,60 @@ class DashboardError(RuntimeError):
|
||||
"""Raised when the native dashboard cannot be displayed."""
|
||||
|
||||
|
||||
class GameLaunchSupervisorRegistry:
|
||||
"""Bridge Tk cancellation requests to game launch worker processes safely."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._processes: dict[int, subprocess.Popen[str]] = {}
|
||||
self._cancel_requested: set[int] = set()
|
||||
|
||||
def attach(self, client_id: int, process: subprocess.Popen[str]) -> bool:
|
||||
"""Register a supervisor and report whether cancellation raced its start."""
|
||||
|
||||
with self._lock:
|
||||
self._processes[client_id] = process
|
||||
return client_id in self._cancel_requested
|
||||
|
||||
def request_cancel(self, client_id: int) -> subprocess.Popen[str] | None:
|
||||
"""Record cancellation and return an already-started supervisor, if any."""
|
||||
|
||||
with self._lock:
|
||||
self._cancel_requested.add(client_id)
|
||||
return self._processes.get(client_id)
|
||||
|
||||
def detach(self, client_id: int, process: subprocess.Popen[str]) -> None:
|
||||
with self._lock:
|
||||
if self._processes.get(client_id) is process:
|
||||
self._processes.pop(client_id, None)
|
||||
|
||||
def is_cancel_requested(self, client_id: int) -> bool:
|
||||
with self._lock:
|
||||
return client_id in self._cancel_requested
|
||||
|
||||
def finish(self, client_id: int) -> bool:
|
||||
"""Forget one launch and return whether the user cancelled it."""
|
||||
|
||||
with self._lock:
|
||||
self._processes.pop(client_id, None)
|
||||
if client_id not in self._cancel_requested:
|
||||
return False
|
||||
self._cancel_requested.remove(client_id)
|
||||
return True
|
||||
|
||||
|
||||
def interrupt_game_supervisor(process: subprocess.Popen[str]) -> bool:
|
||||
"""Ask the play CLI to stop its complete Proton/game process group."""
|
||||
|
||||
if process.poll() is not None:
|
||||
return False
|
||||
try:
|
||||
process.send_signal(signal.SIGINT)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class GameClientTracker:
|
||||
"""Track dashboard-launched clients without retaining process objects in Tk."""
|
||||
|
||||
@@ -527,6 +586,39 @@ def preloader_status_text(
|
||||
return f"Step {step} of {total} · {value}"
|
||||
|
||||
|
||||
def preloader_logo_subsample_factor(
|
||||
width: int,
|
||||
height: int,
|
||||
*,
|
||||
max_dimension: int = 88,
|
||||
) -> int:
|
||||
"""Return an integer Tk subsample factor for a compact preloader logo."""
|
||||
|
||||
if width <= 0 or height <= 0 or max_dimension <= 0:
|
||||
raise ValueError("preloader logo dimensions must be positive")
|
||||
return max(
|
||||
1,
|
||||
math.ceil(width / max_dimension),
|
||||
math.ceil(height / max_dimension),
|
||||
)
|
||||
|
||||
|
||||
def startup_progress_animation_value(current: float, target: float) -> float:
|
||||
"""Ease one determinate startup-progress frame without overshooting."""
|
||||
|
||||
distance = abs(target - current)
|
||||
if distance == 0:
|
||||
return target
|
||||
step = max(
|
||||
STARTUP_PROGRESS_ANIMATION_MINIMUM_STEP,
|
||||
distance * STARTUP_PROGRESS_ANIMATION_EASING,
|
||||
)
|
||||
if step >= distance:
|
||||
return target
|
||||
direction = 1.0 if target > current else -1.0
|
||||
return current + (direction * step)
|
||||
|
||||
|
||||
def freeze_preloader_geometry(frame: Frame) -> tuple[int, int]:
|
||||
"""Lock a measured preloader frame so later status text cannot reflow it."""
|
||||
|
||||
@@ -701,6 +793,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
root,
|
||||
title="PRISTONTALE EU",
|
||||
subtitle="NATIVE LINUX LAUNCHER",
|
||||
window_icon=window_icon,
|
||||
)
|
||||
dashboard_content = dashboard_chrome.content
|
||||
try:
|
||||
@@ -712,6 +805,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
title="Launcher settings",
|
||||
message=f"The launcher could not load its settings:\n\n{startup_error}",
|
||||
kind="error",
|
||||
window_icon=window_icon,
|
||||
)
|
||||
root.destroy()
|
||||
return
|
||||
@@ -734,25 +828,6 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
foreground="#a8998a",
|
||||
font=("Sans", 8),
|
||||
)
|
||||
style.configure("Splash.TFrame", background="#191311")
|
||||
style.configure(
|
||||
"SplashBrand.TLabel",
|
||||
background="#191311",
|
||||
foreground="#f5d18b",
|
||||
font=("Georgia", 22, "bold"),
|
||||
)
|
||||
style.configure(
|
||||
"SplashTagline.TLabel",
|
||||
background="#191311",
|
||||
foreground="#d8aa61",
|
||||
font=("Sans", 8, "bold"),
|
||||
)
|
||||
style.configure(
|
||||
"SplashStatus.TLabel",
|
||||
background="#191311",
|
||||
foreground="#c9baaa",
|
||||
font=("Sans", 9),
|
||||
)
|
||||
style.configure(
|
||||
"Section.TLabel",
|
||||
background="#191311",
|
||||
@@ -1014,6 +1089,29 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
lightcolor="#4a3528",
|
||||
darkcolor="#32251f",
|
||||
)
|
||||
style.map(
|
||||
"News.Vertical.TScrollbar",
|
||||
background=[
|
||||
("pressed", "#765034"),
|
||||
("active", "#62452f"),
|
||||
("!disabled", "#4a3528"),
|
||||
],
|
||||
arrowcolor=[
|
||||
("pressed", "#fff0c2"),
|
||||
("active", "#f0c77f"),
|
||||
("!disabled", "#d8aa61"),
|
||||
],
|
||||
lightcolor=[
|
||||
("pressed", "#765034"),
|
||||
("active", "#62452f"),
|
||||
("!disabled", "#4a3528"),
|
||||
],
|
||||
darkcolor=[
|
||||
("pressed", "#4b3021"),
|
||||
("active", "#3d2a21"),
|
||||
("!disabled", "#32251f"),
|
||||
],
|
||||
)
|
||||
style.configure(
|
||||
"Play.TButton",
|
||||
background="#d98927",
|
||||
@@ -1114,6 +1212,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
|
||||
startup_started_at = time.monotonic()
|
||||
startup_status = tk.StringVar(value="Loading dashboard contents…")
|
||||
startup_image_references: list[tk.PhotoImage] = []
|
||||
startup_overlay = tk.Frame(
|
||||
dashboard_content,
|
||||
background="#0c0908",
|
||||
@@ -1123,62 +1222,117 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
startup_overlay.place(x=0, y=0, relwidth=1, relheight=1)
|
||||
startup_border = tk.Frame(
|
||||
startup_overlay,
|
||||
background="#d98927",
|
||||
background="#6a482d",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
)
|
||||
startup_border.place(relx=0.5, rely=0.5, anchor="center")
|
||||
startup_shell = ttk.Frame(
|
||||
startup_border,
|
||||
style="Splash.TFrame",
|
||||
padding=(26, 22),
|
||||
style="Root.TFrame",
|
||||
padding=(34, 26),
|
||||
)
|
||||
startup_shell.pack(fill="both", expand=True, padx=1, pady=1)
|
||||
startup_logo_source = window_icon
|
||||
if startup_logo_source is not None:
|
||||
startup_logo_factor = preloader_logo_subsample_factor(
|
||||
startup_logo_source.width(),
|
||||
startup_logo_source.height(),
|
||||
)
|
||||
loaded_startup_logo = startup_logo_source.subsample(
|
||||
startup_logo_factor,
|
||||
startup_logo_factor,
|
||||
)
|
||||
startup_image_references.append(loaded_startup_logo)
|
||||
tk.Label(
|
||||
startup_shell,
|
||||
image=loaded_startup_logo,
|
||||
background="#0c0908",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
).pack(anchor="center", pady=(0, 16))
|
||||
ttk.Label(
|
||||
startup_shell,
|
||||
text="PRISTONTALE EU",
|
||||
style="SplashBrand.TLabel",
|
||||
).pack(anchor="w")
|
||||
ttk.Label(
|
||||
startup_shell,
|
||||
text="NATIVE LINUX LAUNCHER",
|
||||
style="SplashTagline.TLabel",
|
||||
).pack(anchor="w", pady=(2, 17))
|
||||
text="Preparing the launcher…",
|
||||
style="PreloaderStatus.TLabel",
|
||||
anchor="center",
|
||||
).pack(fill="x")
|
||||
startup_status_label = ttk.Label(
|
||||
startup_shell,
|
||||
textvariable=startup_status,
|
||||
style="SplashStatus.TLabel",
|
||||
style="PreloaderMuted.TLabel",
|
||||
width=PRELOADER_STATUS_CHARACTERS,
|
||||
anchor="w",
|
||||
anchor="center",
|
||||
)
|
||||
startup_status_label.pack(fill="x", pady=(0, 8))
|
||||
startup_status_label.pack(fill="x", pady=(5, 11))
|
||||
startup_progress_value = tk.DoubleVar(value=0.0)
|
||||
startup_progress = ttk.Progressbar(
|
||||
startup_shell,
|
||||
style="Depot.Horizontal.TProgressbar",
|
||||
mode="indeterminate",
|
||||
orient="horizontal",
|
||||
mode="determinate",
|
||||
maximum=len(DASHBOARD_STARTUP_COMPONENTS),
|
||||
variable=startup_progress_value,
|
||||
length=420,
|
||||
takefocus=False,
|
||||
)
|
||||
startup_progress.pack(fill="x")
|
||||
freeze_preloader_geometry(startup_border)
|
||||
startup_progress.start(12)
|
||||
startup_ready_components: set[str] = set()
|
||||
startup_transition_started = False
|
||||
startup_ready_after: str | None = None
|
||||
startup_timeout_after: str | None = None
|
||||
startup_progress_after: str | None = None
|
||||
startup_progress_target = 0.0
|
||||
root.deiconify()
|
||||
root.lift()
|
||||
startup_overlay.lift()
|
||||
root.update()
|
||||
|
||||
def cancel_startup_progress_animation() -> None:
|
||||
nonlocal startup_progress_after
|
||||
if startup_progress_after is None:
|
||||
return
|
||||
try:
|
||||
root.after_cancel(startup_progress_after)
|
||||
except tk.TclError:
|
||||
pass
|
||||
startup_progress_after = None
|
||||
|
||||
def animate_startup_progress() -> None:
|
||||
nonlocal startup_progress_after
|
||||
startup_progress_after = None
|
||||
if startup_transition_started or not startup_overlay.winfo_exists():
|
||||
return
|
||||
current = float(startup_progress_value.get())
|
||||
value = startup_progress_animation_value(current, startup_progress_target)
|
||||
startup_progress_value.set(value)
|
||||
if value != startup_progress_target:
|
||||
startup_progress_after = root.after(
|
||||
STARTUP_PROGRESS_ANIMATION_INTERVAL_MILLISECONDS,
|
||||
animate_startup_progress,
|
||||
)
|
||||
|
||||
def set_startup_progress_target(value: float, *, immediate: bool = False) -> None:
|
||||
nonlocal startup_progress_after, startup_progress_target
|
||||
startup_progress_target = min(
|
||||
max(0.0, value),
|
||||
float(len(DASHBOARD_STARTUP_COMPONENTS)),
|
||||
)
|
||||
if immediate:
|
||||
cancel_startup_progress_animation()
|
||||
startup_progress_value.set(startup_progress_target)
|
||||
return
|
||||
if startup_progress_after is None:
|
||||
startup_progress_after = root.after(
|
||||
STARTUP_PROGRESS_ANIMATION_INTERVAL_MILLISECONDS,
|
||||
animate_startup_progress,
|
||||
)
|
||||
|
||||
def update_startup_progress(message: str) -> None:
|
||||
if startup_transition_started:
|
||||
return
|
||||
startup_progress.stop()
|
||||
startup_progress.configure(
|
||||
mode="determinate",
|
||||
maximum=len(DASHBOARD_STARTUP_COMPONENTS),
|
||||
value=len(startup_ready_components),
|
||||
)
|
||||
set_startup_progress_target(float(len(startup_ready_components)))
|
||||
startup_status.set(
|
||||
f"{message} · {len(startup_ready_components)}/"
|
||||
f"{len(DASHBOARD_STARTUP_COMPONENTS)}"
|
||||
@@ -1239,6 +1393,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
def swap_loader_for_dashboard(*, fade_supported: bool) -> None:
|
||||
if startup_overlay.winfo_exists():
|
||||
startup_overlay.destroy()
|
||||
startup_image_references.clear()
|
||||
root.update_idletasks()
|
||||
if fade_supported:
|
||||
fade_dashboard_in()
|
||||
@@ -1273,11 +1428,9 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
version.set(
|
||||
f"Launcher {__version__} · game build check continuing in background"
|
||||
)
|
||||
startup_progress.stop()
|
||||
startup_progress.configure(
|
||||
mode="determinate",
|
||||
maximum=len(DASHBOARD_STARTUP_COMPONENTS),
|
||||
value=len(DASHBOARD_STARTUP_COMPONENTS),
|
||||
set_startup_progress_target(
|
||||
float(len(DASHBOARD_STARTUP_COMPONENTS)),
|
||||
immediate=True,
|
||||
)
|
||||
startup_status.set(
|
||||
"Opening dashboard with available content…"
|
||||
@@ -1326,14 +1479,23 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
highlightthickness=0,
|
||||
borderwidth=0,
|
||||
)
|
||||
logo.create_oval(4, 4, 38, 38, outline="#d98927", width=2)
|
||||
logo.create_text(
|
||||
21,
|
||||
21,
|
||||
text="PT",
|
||||
fill="#f5d18b",
|
||||
font=("Georgia", 9, "bold"),
|
||||
dashboard_header_icon: PhotoImage | None = None
|
||||
if window_icon is not None:
|
||||
try:
|
||||
header_icon_factor = preloader_logo_subsample_factor(
|
||||
window_icon.width(),
|
||||
window_icon.height(),
|
||||
max_dimension=40,
|
||||
)
|
||||
dashboard_header_icon = window_icon.subsample(
|
||||
header_icon_factor,
|
||||
header_icon_factor,
|
||||
)
|
||||
except tk.TclError:
|
||||
dashboard_header_icon = None
|
||||
if dashboard_header_icon is not None:
|
||||
logo.create_image(21, 21, image=dashboard_header_icon)
|
||||
logo.image = dashboard_header_icon
|
||||
logo.pack(side="left", padx=(0, 9))
|
||||
brand = ttk.Frame(header, style="Root.TFrame")
|
||||
brand.pack(side="left", fill="x", expand=True)
|
||||
@@ -1598,6 +1760,9 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
active_news_tab = "news"
|
||||
rendered_news_articles: tuple[NewsArticle, ...] | None = None
|
||||
rendered_patch_notes: tuple[PatchNote, ...] | None = None
|
||||
patch_render_generation = 0
|
||||
patch_render_target: tuple[PatchNote, ...] | None = None
|
||||
patch_render_callbacks: list[Callable[[], None]] = []
|
||||
logo_image_references: list[tk.PhotoImage] = []
|
||||
preloader: tk.Toplevel | None = None
|
||||
preloader_status: tk.StringVar | None = None
|
||||
@@ -1607,6 +1772,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
dashboard_debug_window: tk.Toplevel | None = None
|
||||
dashboard_debug_output: tk.Text | None = None
|
||||
game_clients = GameClientTracker()
|
||||
game_launch_supervisors = GameLaunchSupervisorRegistry()
|
||||
client_runtime_debug: dict[int, RuntimeDebugInfo] = {}
|
||||
client_launch_settings: dict[int, Settings] = {}
|
||||
active_game_launch_id: int | None = None
|
||||
@@ -1774,6 +1940,25 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
progress_text.set(activity_text())
|
||||
set_preloader_status(operation_message, operation_stage, operation_stage_total)
|
||||
|
||||
def cancel_active_game_launch() -> None:
|
||||
client_id = active_game_launch_id
|
||||
if client_id is None:
|
||||
close_game_preloader()
|
||||
return
|
||||
process = game_launch_supervisors.request_cancel(client_id)
|
||||
interrupted = process is not None and interrupt_game_supervisor(process)
|
||||
if interrupted:
|
||||
append_log(f"Cancelling game client {client_id}; stopping its process group.")
|
||||
elif process is None:
|
||||
append_log(f"Cancelling game client {client_id} before its supervisor starts.")
|
||||
else:
|
||||
append_log(f"Game client {client_id} was already stopping.")
|
||||
close_game_preloader()
|
||||
root.deiconify()
|
||||
root.lift()
|
||||
status.set(f"Cancelling game client {client_id}…")
|
||||
progress_text.set("Stopping the initiated game and compatibility processes…")
|
||||
|
||||
def show_game_preloader() -> None:
|
||||
nonlocal preloader, preloader_status, preloader_progress
|
||||
if preloader is not None and preloader.winfo_exists():
|
||||
@@ -1784,7 +1969,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
window.title("Launching PristonTale EU")
|
||||
window.configure(background="#0c0908")
|
||||
window.resizable(False, False)
|
||||
window.protocol("WM_DELETE_WINDOW", lambda: None)
|
||||
window.protocol("WM_DELETE_WINDOW", cancel_active_game_launch)
|
||||
window.transient(root)
|
||||
apply_window_icon(window)
|
||||
loader_chrome = create_window_chrome(
|
||||
@@ -1794,8 +1979,26 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
show_title_bar=False,
|
||||
show_close=False,
|
||||
show_minimize=False,
|
||||
allow_close=False,
|
||||
allow_close=True,
|
||||
)
|
||||
loader_chrome.set_close_command(cancel_active_game_launch)
|
||||
cancel_button = tk.Button(
|
||||
loader_chrome.content,
|
||||
text="×",
|
||||
command=cancel_active_game_launch,
|
||||
background="#0c0908",
|
||||
foreground="#bda998",
|
||||
activebackground="#a63f2f",
|
||||
activeforeground="#ffffff",
|
||||
font=("Sans", 12),
|
||||
width=3,
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
relief="flat",
|
||||
cursor="hand2",
|
||||
takefocus=True,
|
||||
)
|
||||
cancel_button.place(relx=1.0, x=-9, y=9, anchor="ne")
|
||||
status_variable = tk.StringVar(value="Starting launcher process…")
|
||||
preloader_status = status_variable
|
||||
content = ttk.Frame(
|
||||
@@ -1804,14 +2007,17 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
padding=(34, 26),
|
||||
)
|
||||
content.pack(fill="both", expand=True)
|
||||
cancel_button.lift()
|
||||
logo_source = window_icon
|
||||
if logo_source is not None:
|
||||
factor = max(
|
||||
1,
|
||||
math.ceil(logo_source.width() / 88),
|
||||
math.ceil(logo_source.height() / 88),
|
||||
modal_logo_factor = preloader_logo_subsample_factor(
|
||||
logo_source.width(),
|
||||
logo_source.height(),
|
||||
)
|
||||
modal_logo = logo_source.subsample(
|
||||
modal_logo_factor,
|
||||
modal_logo_factor,
|
||||
)
|
||||
modal_logo = logo_source.subsample(factor, factor)
|
||||
preloader_image_references.clear()
|
||||
preloader_image_references.append(modal_logo)
|
||||
tk.Label(
|
||||
@@ -1936,6 +2142,13 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
else:
|
||||
events.put(("output", f"[Client {game_client_id}] {line}"))
|
||||
|
||||
if (
|
||||
game_client_id is not None
|
||||
and game_launch_supervisors.is_cancel_requested(game_client_id)
|
||||
):
|
||||
publish_output("Launch cancelled before the game supervisor started.\n")
|
||||
events.put(("game_done", (game_client_id, 130)))
|
||||
return
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
@@ -1954,9 +2167,16 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
events.put(("game_done", (game_client_id, 1)))
|
||||
return
|
||||
if game_client_id is not None:
|
||||
cancel_on_start = game_launch_supervisors.attach(
|
||||
game_client_id,
|
||||
process,
|
||||
)
|
||||
events.put(
|
||||
("game_supervisor_started", (game_client_id, process.pid))
|
||||
)
|
||||
if cancel_on_start:
|
||||
publish_output("Stopping the game supervisor after launch cancellation.\n")
|
||||
interrupt_game_supervisor(process)
|
||||
if process.stdout is not None:
|
||||
for line in process.stdout:
|
||||
if line.startswith("STATUS "):
|
||||
@@ -2009,6 +2229,8 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
):
|
||||
events.put(("game_window_ready", game_client_id))
|
||||
returncode = process.wait()
|
||||
if game_client_id is not None:
|
||||
game_launch_supervisors.detach(game_client_id, process)
|
||||
if game_client_id is None:
|
||||
events.put(
|
||||
("launcher_update_done" if restart_on_success else "done", returncode)
|
||||
@@ -2211,25 +2433,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
def show_news(articles: tuple[NewsArticle, ...]) -> None:
|
||||
render_news(articles)
|
||||
|
||||
def render_patch_notes(patches: tuple[PatchNote, ...]) -> None:
|
||||
nonlocal rendered_patch_notes
|
||||
visible_patches = patches[:MAX_PATCH_NOTES]
|
||||
if rendered_patch_notes == visible_patches:
|
||||
return
|
||||
for child in patches_content.winfo_children():
|
||||
child.destroy()
|
||||
if not visible_patches:
|
||||
ttk.Label(
|
||||
patches_content,
|
||||
text=(
|
||||
"Official patch notes are temporarily unavailable. "
|
||||
"Your cached game can still play."
|
||||
),
|
||||
style="Muted.TLabel",
|
||||
).pack(anchor="w", padx=8, pady=8)
|
||||
rendered_patch_notes = visible_patches
|
||||
return
|
||||
for patch in visible_patches:
|
||||
def append_patch_note(patch: PatchNote) -> Iterator[None]:
|
||||
card = tk.Frame(
|
||||
patches_content,
|
||||
background="#211916",
|
||||
@@ -2296,6 +2500,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
foreground="#ded1c4",
|
||||
font=("Sans", 9),
|
||||
).pack(anchor="w", pady=(8, 0))
|
||||
yield
|
||||
for section in patch.sections:
|
||||
tk.Label(
|
||||
card,
|
||||
@@ -2304,6 +2509,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
foreground="#d8aa61",
|
||||
font=("Sans", 9, "bold"),
|
||||
).pack(anchor="w", pady=(12, 5))
|
||||
yield
|
||||
for change in section.items:
|
||||
change_row = tk.Frame(card, background="#211916")
|
||||
change_row.pack(anchor="w", fill="x", pady=3)
|
||||
@@ -2331,11 +2537,99 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
justify="left",
|
||||
anchor="w",
|
||||
).pack(side="left", anchor="w", fill="x", expand=True)
|
||||
yield
|
||||
make_news_card_clickable(card, patch.url, border_only=True)
|
||||
rendered_patch_notes = visible_patches
|
||||
|
||||
def show_patch_notes(patches: tuple[PatchNote, ...]) -> None:
|
||||
render_patch_notes(patches)
|
||||
def render_patch_notes(
|
||||
patches: tuple[PatchNote, ...],
|
||||
*,
|
||||
on_complete: Callable[[], None] | None = None,
|
||||
) -> None:
|
||||
"""Preload patch cards incrementally without blocking Tk's event loop."""
|
||||
|
||||
nonlocal patch_render_generation, patch_render_target, rendered_patch_notes
|
||||
visible_patches = patches[:MAX_PATCH_NOTES]
|
||||
if rendered_patch_notes == visible_patches:
|
||||
if on_complete is not None:
|
||||
on_complete()
|
||||
return
|
||||
if patch_render_target == visible_patches:
|
||||
if on_complete is not None:
|
||||
patch_render_callbacks.append(on_complete)
|
||||
return
|
||||
|
||||
patch_render_generation += 1
|
||||
generation = patch_render_generation
|
||||
patch_render_target = visible_patches
|
||||
if on_complete is not None:
|
||||
patch_render_callbacks.append(on_complete)
|
||||
rendered_patch_notes = None
|
||||
for child in patches_content.winfo_children():
|
||||
child.destroy()
|
||||
patches_content.grid()
|
||||
if active_news_tab == "patches":
|
||||
patches_content.tkraise()
|
||||
else:
|
||||
news_content.tkraise()
|
||||
|
||||
def finish_render() -> None:
|
||||
nonlocal patch_render_target, rendered_patch_notes
|
||||
if generation != patch_render_generation:
|
||||
return
|
||||
rendered_patch_notes = visible_patches
|
||||
patch_render_target = None
|
||||
if active_news_tab == "patches":
|
||||
patches_content.grid()
|
||||
patches_content.tkraise()
|
||||
else:
|
||||
patches_content.grid_remove()
|
||||
news_content.tkraise()
|
||||
news_canvas.configure(scrollregion=news_canvas.bbox("all"))
|
||||
callbacks = tuple(patch_render_callbacks)
|
||||
patch_render_callbacks.clear()
|
||||
for callback in callbacks:
|
||||
callback()
|
||||
|
||||
if not visible_patches:
|
||||
ttk.Label(
|
||||
patches_content,
|
||||
text=(
|
||||
"Official patch notes are temporarily unavailable. "
|
||||
"Your cached game can still play."
|
||||
),
|
||||
style="Muted.TLabel",
|
||||
).pack(anchor="w", padx=8, pady=8)
|
||||
root.after(PATCH_PRELOAD_INTERVAL_MILLISECONDS, finish_render)
|
||||
return
|
||||
|
||||
def patch_render_steps() -> Iterator[None]:
|
||||
for patch in visible_patches:
|
||||
yield from append_patch_note(patch)
|
||||
|
||||
render_steps = patch_render_steps()
|
||||
|
||||
def append_next() -> None:
|
||||
if generation != patch_render_generation:
|
||||
return
|
||||
try:
|
||||
next(render_steps)
|
||||
except StopIteration:
|
||||
finish_render()
|
||||
return
|
||||
patches_content.update_idletasks()
|
||||
root.after(
|
||||
PATCH_PRELOAD_INTERVAL_MILLISECONDS,
|
||||
append_next,
|
||||
)
|
||||
|
||||
root.after(PATCH_PRELOAD_INTERVAL_MILLISECONDS, append_next)
|
||||
|
||||
def show_patch_notes(
|
||||
patches: tuple[PatchNote, ...],
|
||||
*,
|
||||
on_complete: Callable[[], None] | None = None,
|
||||
) -> None:
|
||||
render_patch_notes(patches, on_complete=on_complete)
|
||||
|
||||
def switch_news_tab(tab: str) -> None:
|
||||
nonlocal active_news_tab
|
||||
@@ -2910,11 +3204,13 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
def refresh_primary_action(
|
||||
snapshot: DepotSnapshot | None = None,
|
||||
*,
|
||||
installation: GameInstallationStatus | None = None,
|
||||
update_activity: bool = True,
|
||||
) -> GameInstallationStatus:
|
||||
nonlocal official_snapshot, latest_installation_status
|
||||
if snapshot is not None:
|
||||
official_snapshot = snapshot
|
||||
if installation is None:
|
||||
installation = inspect_game_installation(
|
||||
paths,
|
||||
official_snapshot.build if official_snapshot is not None else None,
|
||||
@@ -2979,8 +3275,15 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
compatibility_check_generation += 1
|
||||
check_generation = compatibility_check_generation
|
||||
runtime_compatibility = None
|
||||
local_status = inspect_game_installation(paths)
|
||||
refresh_primary_action(update_activity=update_activity)
|
||||
local_status = inspect_game_installation(
|
||||
paths,
|
||||
official_snapshot.build if official_snapshot is not None else None,
|
||||
compatibility_ready=None,
|
||||
)
|
||||
refresh_primary_action(
|
||||
installation=local_status,
|
||||
update_activity=update_activity,
|
||||
)
|
||||
if not local_status.installed or game_import_requires_verification(paths):
|
||||
mark_startup_component_ready(
|
||||
"compatibility",
|
||||
@@ -3091,6 +3394,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
*,
|
||||
ready: bool,
|
||||
returncode: int | None = None,
|
||||
cancelled: bool = False,
|
||||
) -> None:
|
||||
nonlocal active_game_launch_id
|
||||
nonlocal operation_active, game_window_active
|
||||
@@ -3113,6 +3417,10 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
if ready:
|
||||
status.set("Ready for another account.")
|
||||
progress_text.set("Click Play to open another game window.")
|
||||
elif cancelled:
|
||||
root.lift()
|
||||
status.set("Game launch cancelled.")
|
||||
progress_text.set("The initiated game and compatibility processes were stopped.")
|
||||
else:
|
||||
root.lift()
|
||||
status.set(
|
||||
@@ -3181,6 +3489,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
f"restart: {restart_error}"
|
||||
),
|
||||
kind="error",
|
||||
window_icon=window_icon,
|
||||
)
|
||||
else:
|
||||
root.destroy()
|
||||
@@ -3206,13 +3515,18 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
and isinstance(value[0], int)
|
||||
and isinstance(value[1], Mapping)
|
||||
and value[0] == active_game_launch_id
|
||||
and not game_launch_supervisors.is_cancel_requested(value[0])
|
||||
):
|
||||
step = _as_int(value[1].get("step"))
|
||||
total = _as_int(value[1].get("total"))
|
||||
message = value[1].get("message")
|
||||
if step > 0 and total > 0 and isinstance(message, str) and message:
|
||||
set_operation_stage(step, total, message)
|
||||
elif kind == "game_started" and isinstance(value, int):
|
||||
elif (
|
||||
kind == "game_started"
|
||||
and isinstance(value, int)
|
||||
and not game_launch_supervisors.is_cancel_requested(value)
|
||||
):
|
||||
launch_settings = client_launch_settings.get(value)
|
||||
if launch_settings is not None:
|
||||
client_runtime_debug[value] = inspect_runtime_debug(
|
||||
@@ -3221,7 +3535,11 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
)
|
||||
progress_bar.stop()
|
||||
status.set(f"Game client {value} started; preparing its window…")
|
||||
elif kind == "game_window_ready" and isinstance(value, int):
|
||||
elif (
|
||||
kind == "game_window_ready"
|
||||
and isinstance(value, int)
|
||||
and not game_launch_supervisors.is_cancel_requested(value)
|
||||
):
|
||||
if game_clients.mark_running(value):
|
||||
game_window_active = True
|
||||
release_game_launch_controls(value, ready=True)
|
||||
@@ -3233,6 +3551,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
and isinstance(value[1], int)
|
||||
):
|
||||
client_id, returncode = value
|
||||
cancelled = game_launch_supervisors.finish(client_id)
|
||||
phase = game_clients.finish(client_id)
|
||||
client_runtime_debug.pop(client_id, None)
|
||||
client_launch_settings.pop(client_id, None)
|
||||
@@ -3243,6 +3562,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
client_id,
|
||||
ready=False,
|
||||
returncode=returncode,
|
||||
cancelled=cancelled,
|
||||
)
|
||||
elif phase == "running" and not operation_active:
|
||||
set_busy(False)
|
||||
@@ -3290,10 +3610,12 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
and isinstance(value, tuple)
|
||||
and all(isinstance(item, PatchNote) for item in value)
|
||||
):
|
||||
show_patch_notes(value)
|
||||
mark_startup_component_ready(
|
||||
show_patch_notes(
|
||||
value,
|
||||
on_complete=lambda: mark_startup_component_ready(
|
||||
"patches",
|
||||
"Patch notes ready",
|
||||
),
|
||||
)
|
||||
elif kind == "news_image":
|
||||
complete_news_image(value)
|
||||
@@ -3377,6 +3699,11 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
)
|
||||
except queue.Empty:
|
||||
pass
|
||||
except Exception as event_error:
|
||||
append_log(
|
||||
"Dashboard event processing recovered from an error: "
|
||||
f"{event_error}"
|
||||
)
|
||||
if operation_active:
|
||||
latest_progress = progress_updates.take()
|
||||
if latest_progress is not None:
|
||||
@@ -3421,6 +3748,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
title="Launcher is up to date",
|
||||
message=check.reason,
|
||||
kind="success",
|
||||
window_icon=window_icon,
|
||||
)
|
||||
else:
|
||||
show_themed_dialog(
|
||||
@@ -3431,6 +3759,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
f"{required_reason}\n\n{check.reason}"
|
||||
),
|
||||
kind="warning",
|
||||
window_icon=window_icon,
|
||||
)
|
||||
return
|
||||
if not check.supported:
|
||||
@@ -3439,6 +3768,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
title="Manual launcher update required",
|
||||
message=check.reason,
|
||||
kind="warning",
|
||||
window_icon=window_icon,
|
||||
)
|
||||
return
|
||||
if not show_themed_dialog(
|
||||
@@ -3450,6 +3780,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
kind="question",
|
||||
confirm=True,
|
||||
accept_text="INSTALL & RESTART",
|
||||
window_icon=window_icon,
|
||||
):
|
||||
return
|
||||
run_operation(
|
||||
@@ -3477,6 +3808,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
f"{cache_error}"
|
||||
),
|
||||
kind="error",
|
||||
window_icon=window_icon,
|
||||
)
|
||||
return
|
||||
launch_settings: Settings | None = None
|
||||
@@ -3561,6 +3893,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
title="Settings",
|
||||
message=f"The launcher could not load its settings:\n\n{error}",
|
||||
kind="error",
|
||||
window_icon=window_icon,
|
||||
)
|
||||
return
|
||||
dialog = tk.Toplevel(root)
|
||||
@@ -3576,6 +3909,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
dialog,
|
||||
title="PRISTONTALE EU",
|
||||
subtitle="LAUNCHER SETTINGS",
|
||||
window_icon=window_icon,
|
||||
show_close=True,
|
||||
show_minimize=False,
|
||||
allow_close=True,
|
||||
@@ -3732,6 +4066,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
title="Settings",
|
||||
message=str(error),
|
||||
kind="error",
|
||||
window_icon=window_icon,
|
||||
)
|
||||
return
|
||||
append_log(
|
||||
@@ -3944,7 +4279,6 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
)
|
||||
if cached_launcher_update is not None:
|
||||
show_launcher_update_check(cached_launcher_update, log_reason=False)
|
||||
refresh_primary_action()
|
||||
refresh_runtime_compatibility()
|
||||
poll_events()
|
||||
load_official_logo()
|
||||
|
||||
Reference in New Issue
Block a user