Added tabs for news / patches, integrated new patches api from website.

This commit is contained in:
2026-08-30 22:47:55 +02:00
parent bc87f10661
commit 094c881798
2 changed files with 341 additions and 14 deletions

View File

@@ -166,6 +166,12 @@ class AppPaths:
def news_cache(self) -> Path:
return self.cache / "news.json"
@property
def patches_cache(self) -> Path:
"""Cached official patch changelog shown beside the news feed."""
return self.cache / "patches.json"
@property
def broadcast_cache(self) -> Path:
"""Cached latest announcement from the broadcast service."""

View File

@@ -17,6 +17,13 @@ import webbrowser
from typing import Callable, Mapping
from . import __version__
from .changelog import (
MAX_PATCHES as MAX_PATCH_NOTES,
PatchNote,
fetch_patch_notes,
patch_change_tag,
read_cached_patch_notes,
)
from .depot import (
DEFAULT_DEPOT_START_OPTION,
DepotClient,
@@ -69,6 +76,8 @@ WINDOW_SIZE = "1000x750"
NEWS_CARD_HEIGHT = 242
NEWS_IMAGE_WIDTH = 292
NEWS_IMAGE_HEIGHT = 118
PATCH_CARD_WRAP_LENGTH = 900
PATCH_CHANGE_WRAP_LENGTH = 790
LAUNCH_STAGE_TOTAL = 4
EVENT_BATCH_SIZE = 48
POLL_INTERVAL_MILLISECONDS = 33
@@ -93,6 +102,7 @@ DASHBOARD_STARTUP_COMPONENTS = frozenset(
"logo",
"news",
"news_artwork",
"patches",
"server_status",
"version",
}
@@ -235,6 +245,20 @@ def server_status_presentation(status: RealmServerStatus) -> ServerStatusPresent
)
def patch_change_colors(type_key: str) -> tuple[str, str]:
"""Return a restrained card-background palette for one patch change tag."""
return {
"fixed": ("#321c1a", "#e88879"),
"hotfix": ("#352018", "#e6a06a"),
"new-feature": ("#192a20", "#77c795"),
"improvement": ("#19252c", "#82b8d2"),
"security": ("#302719", "#ddbb69"),
"server": ("#282035", "#b8a0d8"),
"game": ("#242b1b", "#a8ca75"),
}.get(type_key.casefold(), ("#2a201c", "#d8aa61"))
def launcher_update_presentation(
check: LauncherUpdateCheck,
) -> LauncherUpdatePresentation:
@@ -650,7 +674,7 @@ def run_dashboard(paths: AppPaths) -> None:
bordercolor="#4a3528",
lightcolor="#4a3528",
darkcolor="#4a3528",
font=("Sans", 10, "bold"),
font=("Sans", 9, "bold"),
padding=(10, 7),
relief="flat",
)
@@ -684,7 +708,7 @@ def run_dashboard(paths: AppPaths) -> None:
root.option_add("*realm_selector*Listbox.foreground", "#fff0d2")
root.option_add("*realm_selector*Listbox.selectBackground", "#2a201c")
root.option_add("*realm_selector*Listbox.selectForeground", "#f0d7a2")
root.option_add("*realm_selector*Listbox.font", "Sans 10 bold")
root.option_add("*realm_selector*Listbox.font", "Sans 9 bold")
root.option_add("*realm_selector*Listbox.justify", "center")
root.option_add("*realm_selector*Listbox.selectBorderWidth", 4)
root.option_add("*realm_selector*Listbox.borderWidth", 1)
@@ -732,6 +756,46 @@ def run_dashboard(paths: AppPaths) -> None:
foreground="#c9baaa",
font=("Sans", 8),
)
style.configure(
"NewsTab.TButton",
background="#191311",
foreground="#9f8d7c",
font=("Sans", 8, "bold"),
padding=(10, 4),
bordercolor="#32251f",
borderwidth=1,
relief="flat",
)
style.map(
"NewsTab.TButton",
background=[("active", "#2a201c")],
foreground=[("active", "#f0d7a2")],
bordercolor=[("active", "#4a3528")],
)
style.configure(
"NewsTabActive.TButton",
background="#2a201c",
foreground="#f0d7a2",
font=("Sans", 8, "bold"),
padding=(10, 4),
bordercolor="#6a4d38",
borderwidth=1,
relief="flat",
)
style.map(
"NewsTabActive.TButton",
background=[("active", "#32251f")],
foreground=[("active", "#fff0d2")],
)
style.configure(
"News.Vertical.TScrollbar",
background="#4a3528",
troughcolor="#191311",
arrowcolor="#d8aa61",
bordercolor="#191311",
lightcolor="#4a3528",
darkcolor="#32251f",
)
style.configure(
"Play.TButton",
background="#d98927",
@@ -1134,11 +1198,11 @@ def run_dashboard(paths: AppPaths) -> None:
values=(DEFAULT_DEPOT_START_OPTION.name,),
state="readonly",
style="Realm.TCombobox",
font=("Sans", 10, "bold"),
font=("Sans", 9, "bold"),
justify="center",
width=20,
)
realm_selector.pack(fill="x", pady=(5, 0))
realm_selector.pack(fill="x", pady=(6, 5))
activity_copy = ttk.Frame(activity_panel, style="Surface.TFrame")
activity_copy.pack(side="left", fill="both", expand=True)
status = tk.StringVar(value="Ready")
@@ -1200,21 +1264,58 @@ def run_dashboard(paths: AppPaths) -> None:
news_heading = ttk.Frame(news_panel, style="Surface.TFrame")
news_heading.pack(fill="x")
ttk.Label(news_heading, text="LATEST NEWS", style="Section.TLabel").pack(side="left")
news_tabs = ttk.Frame(news_heading, style="Surface.TFrame")
news_tabs.pack(side="left", padx=(14, 0))
news_tab_button = ttk.Button(
news_tabs,
text="NEWS",
style="NewsTabActive.TButton",
)
news_tab_button.pack(side="left")
patches_tab_button = ttk.Button(
news_tabs,
text="PATCHES",
style="NewsTab.TButton",
)
patches_tab_button.pack(side="left", padx=(5, 0))
ttk.Label(
news_heading,
text="OFFICIAL FEED",
style="Muted.TLabel",
).pack(side="right")
news_viewport = ttk.Frame(news_panel, style="Surface.TFrame")
news_viewport.pack(fill="both", expand=True, pady=(6, 0))
news_viewport.grid_columnconfigure(0, weight=1)
news_viewport.grid_rowconfigure(0, weight=1)
news_canvas = tk.Canvas(
news_panel,
news_viewport,
background="#191311",
highlightthickness=0,
borderwidth=0,
height=252,
)
news_canvas.pack(fill="both", expand=True, pady=(6, 0))
news_content = ttk.Frame(news_canvas, style="Surface.TFrame")
news_window = news_canvas.create_window((0, 0), window=news_content, anchor="nw")
news_canvas.grid(row=0, column=0, sticky="nsew")
news_scrollbar = ttk.Scrollbar(
news_viewport,
orient="vertical",
style="News.Vertical.TScrollbar",
command=news_canvas.yview,
)
news_scrollbar.grid(row=0, column=1, sticky="ns", padx=(5, 0))
news_scrollbar.grid_remove()
news_canvas.configure(yscrollcommand=news_scrollbar.set)
news_content_host = ttk.Frame(news_canvas, style="Surface.TFrame")
news_content_host.grid_columnconfigure(0, weight=1)
news_content = ttk.Frame(news_content_host, style="Surface.TFrame")
patches_content = ttk.Frame(news_content_host, style="Surface.TFrame")
news_content.grid(row=0, column=0, sticky="nsew")
patches_content.grid(row=0, column=0, sticky="nsew")
patches_content.grid_remove()
news_window = news_canvas.create_window(
(0, 0),
window=news_content_host,
anchor="nw",
)
broadcast_panel = ttk.Frame(frame, style="Surface.TFrame", padding=(10, 8))
broadcast_panel.pack(fill="x", pady=(0, 8), before=news_panel)
@@ -1269,6 +1370,9 @@ def run_dashboard(paths: AppPaths) -> None:
news_image_targets: dict[int, tk.Canvas] = {}
news_image_references: dict[int, tk.PhotoImage] = {}
startup_news_images_pending: set[int] = set()
active_news_tab = "news"
rendered_news_articles: tuple[NewsArticle, ...] | None = None
rendered_patch_notes: tuple[PatchNote, ...] | None = None
logo_image_references: list[tk.PhotoImage] = []
preloader: tk.Toplevel | None = None
preloader_status: tk.StringVar | None = None
@@ -1632,12 +1736,37 @@ def run_dashboard(paths: AppPaths) -> None:
def open_article(url: str) -> None:
webbrowser.open(url)
def make_news_card_clickable(card: tk.Frame, url: str) -> None:
def scroll_news_view(event: object) -> str | None:
if active_news_tab != "patches":
return None
delta = getattr(event, "delta", 0)
button = getattr(event, "num", None)
if isinstance(delta, int) and delta:
direction = -1 if delta > 0 else 1
elif button == 4:
direction = -1
elif button == 5:
direction = 1
else:
return None
news_canvas.yview_scroll(direction * 3, "units")
return "break"
def make_news_card_clickable(
card: tk.Frame,
url: str,
*,
border_only: bool = False,
) -> None:
def enter(_event: object) -> None:
card.configure(background="#2a1e17", highlightbackground="#e4a34c", highlightthickness=1)
card.configure(highlightbackground="#e4a34c", highlightthickness=1)
if not border_only:
card.configure(background="#2a1e17")
def leave(_event: object) -> None:
card.configure(background="#211916", highlightbackground="#4a3528", highlightthickness=1)
card.configure(highlightbackground="#4a3528", highlightthickness=1)
if not border_only:
card.configure(background="#211916")
def open_card(_event: object) -> None:
open_article(url)
@@ -1648,6 +1777,9 @@ def run_dashboard(paths: AppPaths) -> None:
bind("<Enter>", enter)
bind("<Leave>", leave)
bind("<Button-1>", open_card)
bind("<MouseWheel>", scroll_news_view, "+")
bind("<Button-4>", scroll_news_view, "+")
bind("<Button-5>", scroll_news_view, "+")
configure(cursor="hand2")
for child in getattr(widget, "winfo_children")():
bind_widget(child)
@@ -1695,12 +1827,18 @@ def run_dashboard(paths: AppPaths) -> None:
).pack(anchor="w", fill="x", pady=(3, 0))
make_news_card_clickable(card, broadcast.url)
def show_news(articles: tuple[NewsArticle, ...]) -> None:
def render_news(articles: tuple[NewsArticle, ...]) -> None:
nonlocal rendered_news_articles
visible_articles = articles[:MAX_NEWS_CARDS]
if rendered_news_articles == visible_articles:
return
news_image_targets.clear()
news_image_references.clear()
for child in news_content.winfo_children():
child.destroy()
if not articles:
for column in range(MAX_NEWS_CARDS):
news_content.grid_columnconfigure(column, weight=0, uniform="")
if not visible_articles:
ttk.Label(
news_content,
text="Official news is temporarily unavailable. Your cached game can still play.",
@@ -1711,8 +1849,8 @@ def run_dashboard(paths: AppPaths) -> None:
"news_artwork",
"News layout ready",
)
rendered_news_articles = visible_articles
return
visible_articles = articles[:MAX_NEWS_CARDS]
for column in range(len(visible_articles)):
news_content.grid_columnconfigure(column, weight=1, uniform="news-card")
news_content.grid_rowconfigure(0, weight=1)
@@ -1775,6 +1913,163 @@ def run_dashboard(paths: AppPaths) -> None:
).pack(anchor="w", fill="x")
make_news_card_clickable(card, article.url)
load_news_images(visible_articles)
rendered_news_articles = visible_articles
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:
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 switch_news_tab(tab: str) -> None:
nonlocal active_news_tab
if tab not in {"news", "patches"} or tab == active_news_tab:
return
active_news_tab = tab
news_tab_button.configure(
style="NewsTabActive.TButton" if tab == "news" else "NewsTab.TButton"
)
patches_tab_button.configure(
style=(
"NewsTabActive.TButton" if tab == "patches" else "NewsTab.TButton"
)
)
news_canvas.yview_moveto(0.0)
if tab == "patches":
news_content.grid_remove()
patches_content.grid()
news_scrollbar.grid()
else:
patches_content.grid_remove()
news_content.grid()
news_scrollbar.grid_remove()
root.after_idle(update_news_viewport)
news_tab_button.configure(command=lambda: switch_news_tab("news"))
patches_tab_button.configure(command=lambda: switch_news_tab("patches"))
def load_news_images(articles: tuple[NewsArticle, ...]) -> None:
image_articles = tuple(
@@ -2144,6 +2439,9 @@ def run_dashboard(paths: AppPaths) -> None:
def news_worker() -> None:
events.put(("news", fetch_news(paths.news_cache)))
def patches_worker() -> None:
events.put(("patches", fetch_patch_notes(paths.patches_cache)))
def server_status_worker() -> None:
try:
statuses = fetch_server_status()
@@ -2171,6 +2469,7 @@ def run_dashboard(paths: AppPaths) -> None:
threading.Thread(target=broadcast_worker, name="ptlinux-broadcast", daemon=True).start()
threading.Thread(target=news_worker, name="ptlinux-news", daemon=True).start()
threading.Thread(target=patches_worker, name="ptlinux-patches", daemon=True).start()
threading.Thread(
target=server_status_worker,
name="ptlinux-server-status",
@@ -2355,6 +2654,16 @@ def run_dashboard(paths: AppPaths) -> None:
"news",
"News cards ready",
)
elif (
kind == "patches"
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",
)
elif kind == "news_image":
complete_news_image(value)
elif kind == "logo_image":
@@ -2909,13 +3218,25 @@ def run_dashboard(paths: AppPaths) -> None:
if isinstance(width, int) and width > 0:
news_canvas.itemconfigure(news_window, width=width)
news_content_host.bind("<Configure>", update_news_viewport)
news_content.bind("<Configure>", update_news_viewport)
patches_content.bind("<Configure>", update_news_viewport)
news_canvas.bind("<Configure>", resize_news_content)
for scroll_widget in (
news_canvas,
news_content_host,
news_content,
patches_content,
):
scroll_widget.bind("<MouseWheel>", scroll_news_view, add="+")
scroll_widget.bind("<Button-4>", scroll_news_view, add="+")
scroll_widget.bind("<Button-5>", scroll_news_view, add="+")
startup_overlay.lift()
startup_status.set("Restoring dashboard content…")
startup_overlay.update_idletasks()
show_broadcast(read_cached_broadcast(paths.broadcast_cache))
show_news(read_cached_news(paths.news_cache))
show_patch_notes(read_cached_patch_notes(paths.patches_cache))
cached_launcher_update = read_cached_launcher_update_check(
paths.launcher_update_check_cache
)