Added ui changes to modals, pre-loader and some fixes for other ui elements and performance improvements all over.

This commit is contained in:
2026-08-31 23:23:37 +02:00
parent 99982b12d9
commit dadae8f60a
3 changed files with 597 additions and 215 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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()