Added tabs for news / patches, integrated new patches api from website.
This commit is contained in:
@@ -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."""
|
||||
|
||||
349
ptlinux/ui.py
349
ptlinux/ui.py
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user