From dadae8f60aa527700c284ba0786dc5e3e13fc139 Mon Sep 17 00:00:00 2001 From: m4ko Date: Mon, 31 Aug 2026 23:23:37 +0200 Subject: [PATCH] Added ui changes to modals, pre-loader and some fixes for other ui elements and performance improvements all over. --- ptlinux/news.py | 53 +++- ptlinux/tk_chrome.py | 47 ++- ptlinux/ui.py | 712 +++++++++++++++++++++++++++++++------------ 3 files changed, 597 insertions(+), 215 deletions(-) diff --git a/ptlinux/news.py b/ptlinux/news.py index ffcc6f6..cee0e7c 100644 --- a/ptlinux/news.py +++ b/ptlinux/news.py @@ -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 diff --git a/ptlinux/tk_chrome.py b/ptlinux/tk_chrome.py index 3b36230..ff105df 100644 --- a/ptlinux/tk_chrome.py +++ b/ptlinux/tk_chrome.py @@ -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,17 +348,39 @@ 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_bar, - width=22, - height=22, - background="#17100d", - borderwidth=0, - 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")) + 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, + background="#17100d", + borderwidth=0, + highlightthickness=0, + cursor="fleur", + ) 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, diff --git a/ptlinux/ui.py b/ptlinux/ui.py index 63f159d..bffb2eb 100644 --- a/ptlinux/ui.py +++ b/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 = "" 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,13 +2433,163 @@ 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 + def append_patch_note(patch: PatchNote) -> Iterator[None]: + card = tk.Frame( + patches_content, + background="#211916", + highlightbackground="#4a3528", + highlightthickness=1, + padx=16, + pady=13, + ) + card.pack(fill="x", padx=4, pady=(4, 9)) + tk.Label( + card, + text=patch.title, + background="#211916", + foreground="#fff0d2", + font=("Sans", 12, "bold"), + wraplength=PATCH_CARD_WRAP_LENGTH, + justify="left", + anchor="w", + ).pack(anchor="w", fill="x") + metadata = tk.Frame(card, background="#211916") + metadata.pack(anchor="w", fill="x", pady=(7, 0)) + tk.Label( + metadata, + text=f"VERSION {patch.version or '—'}", + background="#30231d", + foreground="#d8aa61", + font=("Sans", 8, "bold"), + padx=6, + pady=2, + ).pack(side="left", padx=(0, 7)) + for tag in patch.tags or ("general",): + tk.Label( + metadata, + text=tag.upper(), + background="#2a201c", + foreground="#d8aa61", + font=("Sans", 7, "bold"), + padx=6, + pady=2, + ).pack(side="left", padx=(0, 7)) + if patch.published_at: + tk.Label( + metadata, + text=patch.published_at, + background="#211916", + foreground="#c9baaa", + font=("Sans", 8), + ).pack(side="right") + tk.Label( + card, + text=patch.summary or "No summary provided.", + background="#211916", + foreground="#ded1c4", + font=("Sans", 10), + wraplength=PATCH_CARD_WRAP_LENGTH, + justify="left", + anchor="w", + ).pack(anchor="w", fill="x", pady=(9, 3)) + if not patch.sections: + tk.Label( + card, + text="No detailed changes were published for this patch.", + background="#211916", + foreground="#ded1c4", + font=("Sans", 9), + ).pack(anchor="w", pady=(8, 0)) + yield + for section in patch.sections: + tk.Label( + card, + text=section.title.upper(), + background="#211916", + 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) + badge_background, badge_foreground = patch_change_colors( + change.type_key + ) + tk.Label( + change_row, + text=patch_change_tag(change.type_key), + background=badge_background, + foreground=badge_foreground, + font=("Sans", 8, "bold"), + width=14, + anchor="center", + padx=5, + pady=2, + ).pack(side="left", anchor="n", padx=(0, 8)) + tk.Label( + change_row, + text=change.text, + background="#211916", + foreground="#ded1c4", + font=("Sans", 10), + wraplength=PATCH_CHANGE_WRAP_LENGTH, + justify="left", + anchor="w", + ).pack(side="left", anchor="w", fill="x", expand=True) + yield + make_news_card_clickable(card, patch.url, border_only=True) + + 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, @@ -2227,115 +2599,37 @@ def run_dashboard(paths: AppPaths) -> None: ), style="Muted.TLabel", ).pack(anchor="w", padx=8, pady=8) - rendered_patch_notes = visible_patches + root.after(PATCH_PRELOAD_INTERVAL_MILLISECONDS, finish_render) return - for patch in visible_patches: - card = tk.Frame( - patches_content, - background="#211916", - highlightbackground="#4a3528", - highlightthickness=1, - padx=16, - pady=13, - ) - card.pack(fill="x", padx=4, pady=(4, 9)) - tk.Label( - card, - text=patch.title, - background="#211916", - foreground="#fff0d2", - font=("Sans", 12, "bold"), - wraplength=PATCH_CARD_WRAP_LENGTH, - justify="left", - anchor="w", - ).pack(anchor="w", fill="x") - metadata = tk.Frame(card, background="#211916") - metadata.pack(anchor="w", fill="x", pady=(7, 0)) - tk.Label( - metadata, - text=f"VERSION {patch.version or '—'}", - background="#30231d", - foreground="#d8aa61", - font=("Sans", 8, "bold"), - padx=6, - pady=2, - ).pack(side="left", padx=(0, 7)) - for tag in patch.tags or ("general",): - tk.Label( - metadata, - text=tag.upper(), - background="#2a201c", - foreground="#d8aa61", - font=("Sans", 7, "bold"), - padx=6, - pady=2, - ).pack(side="left", padx=(0, 7)) - if patch.published_at: - tk.Label( - metadata, - text=patch.published_at, - background="#211916", - foreground="#c9baaa", - font=("Sans", 8), - ).pack(side="right") - tk.Label( - card, - text=patch.summary or "No summary provided.", - background="#211916", - foreground="#ded1c4", - font=("Sans", 10), - wraplength=PATCH_CARD_WRAP_LENGTH, - justify="left", - anchor="w", - ).pack(anchor="w", fill="x", pady=(9, 3)) - if not patch.sections: - tk.Label( - card, - text="No detailed changes were published for this patch.", - background="#211916", - foreground="#ded1c4", - font=("Sans", 9), - ).pack(anchor="w", pady=(8, 0)) - for section in patch.sections: - tk.Label( - card, - text=section.title.upper(), - background="#211916", - foreground="#d8aa61", - font=("Sans", 9, "bold"), - ).pack(anchor="w", pady=(12, 5)) - for change in section.items: - change_row = tk.Frame(card, background="#211916") - change_row.pack(anchor="w", fill="x", pady=3) - badge_background, badge_foreground = patch_change_colors( - change.type_key - ) - tk.Label( - change_row, - text=patch_change_tag(change.type_key), - background=badge_background, - foreground=badge_foreground, - font=("Sans", 8, "bold"), - width=14, - anchor="center", - padx=5, - pady=2, - ).pack(side="left", anchor="n", padx=(0, 8)) - tk.Label( - change_row, - text=change.text, - background="#211916", - foreground="#ded1c4", - font=("Sans", 10), - wraplength=PATCH_CHANGE_WRAP_LENGTH, - justify="left", - anchor="w", - ).pack(side="left", anchor="w", fill="x", expand=True) - 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 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,20 +3204,22 @@ 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 - installation = inspect_game_installation( - paths, - official_snapshot.build if official_snapshot is not None else None, - compatibility_ready=( - runtime_compatibility.compatible - if runtime_compatibility is not None - else None - ), - ) + if installation is None: + installation = inspect_game_installation( + paths, + official_snapshot.build if official_snapshot is not None else None, + compatibility_ready=( + runtime_compatibility.compatible + if runtime_compatibility is not None + else None + ), + ) latest_installation_status = installation additional_client_ready = game_clients.running_count > 0 and installation.installed client_activity_text.set(game_client_activity_text(game_clients.running_count)) @@ -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( - "patches", - "Patch notes 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()